robrain 2.3.7 → 2.3.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/commands/install.d.ts.map +1 -1
- package/dist/commands/install.js +17 -2
- package/dist/commands/install.js.map +1 -1
- package/dist/commands/up.d.ts +14 -0
- package/dist/commands/up.d.ts.map +1 -1
- package/dist/commands/up.js +60 -2
- package/dist/commands/up.js.map +1 -1
- package/dist/commands/up.test.js +21 -3
- package/dist/commands/up.test.js.map +1 -1
- package/dist/index.js +1 -1
- package/dist/lib/codex-hooks.d.ts +26 -0
- package/dist/lib/codex-hooks.d.ts.map +1 -0
- package/dist/lib/codex-hooks.js +65 -0
- package/dist/lib/codex-hooks.js.map +1 -0
- package/dist/lib/codex-hooks.test.d.ts +2 -0
- package/dist/lib/codex-hooks.test.d.ts.map +1 -0
- package/dist/lib/codex-hooks.test.js +64 -0
- package/dist/lib/codex-hooks.test.js.map +1 -0
- package/dist/lib/editor.codex.test.js +20 -0
- package/dist/lib/editor.codex.test.js.map +1 -1
- package/dist/lib/editor.d.ts +2 -0
- package/dist/lib/editor.d.ts.map +1 -1
- package/dist/lib/editor.js +28 -0
- package/dist/lib/editor.js.map +1 -1
- package/dist/lib/mcp-bundle.test.js +2 -2
- package/dist/lib/release-guard.d.ts +22 -0
- package/dist/lib/release-guard.d.ts.map +1 -0
- package/dist/lib/release-guard.js +46 -0
- package/dist/lib/release-guard.js.map +1 -0
- package/dist/lib/release-guard.test.d.ts +2 -0
- package/dist/lib/release-guard.test.d.ts.map +1 -0
- package/dist/lib/release-guard.test.js +60 -0
- package/dist/lib/release-guard.test.js.map +1 -0
- package/package.json +10 -11
- package/scripts/vendor-codex-hooks.mjs +31 -0
- package/scripts/verify-publish-tarball.mjs +9 -0
- package/scripts/verify-release-artifacts.mjs +77 -0
- package/vendor/codex-hooks/lib.mjs +276 -0
- package/vendor/codex-hooks/session-start.mjs +29 -0
- package/vendor/codex-hooks/stop.mjs +78 -0
- package/vendor/codex-hooks/user-prompt-submit.mjs +95 -0
- package/vendor/sensing-mcp/dist/index.js +26 -1
- package/vendor/sensing-mcp/package.json +1 -1
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// plugins/claude-code/hooks/lib.mjs
|
|
2
|
+
// ─────────────────────────────────────────────────────────────
|
|
3
|
+
// Shared plumbing for RoBrain Claude Code plugin hooks.
|
|
4
|
+
//
|
|
5
|
+
// Design constraints:
|
|
6
|
+
// - Zero dependencies, Node >= 18 (built-in fetch). Hooks must start fast.
|
|
7
|
+
// - Fail-open ALWAYS: a dead Perception, missing config, or timeout must
|
|
8
|
+
// never block or break the user's Claude Code session. Hooks print nothing
|
|
9
|
+
// and exit 0 on any failure.
|
|
10
|
+
// - Thin client: raw turns go to Perception POST /signals with
|
|
11
|
+
// needs_classification=true — classification/extraction/dedup happen
|
|
12
|
+
// server-side, so this plugin (and future Codex/Cursor ports) stays dumb.
|
|
13
|
+
// ─────────────────────────────────────────────────────────────
|
|
14
|
+
|
|
15
|
+
import { createHash } from 'node:crypto'
|
|
16
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from 'node:fs'
|
|
17
|
+
import { homedir } from 'node:os'
|
|
18
|
+
import { join } from 'node:path'
|
|
19
|
+
|
|
20
|
+
/** Read all of stdin (the hook input JSON). */
|
|
21
|
+
export async function readStdin() {
|
|
22
|
+
const chunks = []
|
|
23
|
+
for await (const chunk of process.stdin) chunks.push(chunk)
|
|
24
|
+
return Buffer.concat(chunks).toString('utf8')
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function parseHookInput(raw) {
|
|
28
|
+
try {
|
|
29
|
+
return JSON.parse(raw)
|
|
30
|
+
} catch {
|
|
31
|
+
return {}
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Perception connection: env wins, then ~/.robrain/config.json (written by
|
|
37
|
+
* `robrain up` / `robrain install`), then localhost default.
|
|
38
|
+
*/
|
|
39
|
+
export function loadPerception() {
|
|
40
|
+
let url = process.env.PERCEPTION_URL?.trim() || ''
|
|
41
|
+
let key = process.env.PERCEPTION_API_KEY?.trim() || ''
|
|
42
|
+
if (!url || !key) {
|
|
43
|
+
try {
|
|
44
|
+
const cfg = JSON.parse(readFileSync(join(homedir(), '.robrain', 'config.json'), 'utf8'))
|
|
45
|
+
if (!url && typeof cfg.perceptionUrl === 'string') url = cfg.perceptionUrl.trim()
|
|
46
|
+
if (!key && typeof cfg.perceptionKey === 'string') key = cfg.perceptionKey.trim()
|
|
47
|
+
} catch {
|
|
48
|
+
// no config yet — fall through to defaults
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { url: url || 'http://localhost:3001', key }
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Same derivation as the CLI (packages/cli/src/lib/project.ts): prefer the
|
|
56
|
+
* project_id init-project wrote into CLAUDE.md / AGENTS.md / the Cursor rule,
|
|
57
|
+
* else a stable hash of the working directory.
|
|
58
|
+
*/
|
|
59
|
+
export function resolveProjectId(cwd) {
|
|
60
|
+
for (const rel of ['CLAUDE.md', 'AGENTS.md', join('.cursor', 'rules', 'robrain.mdc')]) {
|
|
61
|
+
const p = join(cwd, rel)
|
|
62
|
+
try {
|
|
63
|
+
if (!existsSync(p)) continue
|
|
64
|
+
const match = readFileSync(p, 'utf8').match(/project_id="([^"]+)"/)
|
|
65
|
+
if (match?.[1]) return match[1].trim()
|
|
66
|
+
} catch {
|
|
67
|
+
// unreadable file — try the next candidate
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return createHash('sha256').update(cwd).digest('hex').slice(0, 12)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/** fetch with a hard timeout; returns null instead of throwing. */
|
|
74
|
+
export async function perceptionFetch(path, { url, key }, init = {}, timeoutMs = 2500) {
|
|
75
|
+
try {
|
|
76
|
+
const res = await fetch(`${url}${path}`, {
|
|
77
|
+
...init,
|
|
78
|
+
headers: {
|
|
79
|
+
'Content-Type': 'application/json',
|
|
80
|
+
...(key ? { Authorization: `Bearer ${key}` } : {}),
|
|
81
|
+
...(init.headers ?? {}),
|
|
82
|
+
},
|
|
83
|
+
signal: AbortSignal.timeout(timeoutMs),
|
|
84
|
+
})
|
|
85
|
+
if (!res.ok) return null
|
|
86
|
+
return await res.json()
|
|
87
|
+
} catch {
|
|
88
|
+
return null
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/**
|
|
93
|
+
* Extract the trailing turn from a Claude Code transcript (JSONL): the last
|
|
94
|
+
* user text message and every assistant text block after it, plus file paths
|
|
95
|
+
* touched by Edit/Write/NotebookEdit tool calls in that span.
|
|
96
|
+
*/
|
|
97
|
+
export function extractLastTurn(transcriptPath) {
|
|
98
|
+
let lines
|
|
99
|
+
try {
|
|
100
|
+
lines = readFileSync(transcriptPath, 'utf8').split('\n').filter(Boolean)
|
|
101
|
+
} catch {
|
|
102
|
+
return null
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
let lastUserIdx = -1
|
|
106
|
+
const entries = []
|
|
107
|
+
for (const line of lines) {
|
|
108
|
+
let e
|
|
109
|
+
try {
|
|
110
|
+
e = JSON.parse(line)
|
|
111
|
+
} catch {
|
|
112
|
+
continue
|
|
113
|
+
}
|
|
114
|
+
entries.push(e)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
118
|
+
const e = entries[i]
|
|
119
|
+
if (e.type === 'user' && !e.isMeta && typeof textOf(e.message?.content) === 'string' && textOf(e.message.content).trim()) {
|
|
120
|
+
// Skip tool_result-only user entries (tool outputs come back as user role)
|
|
121
|
+
if (hasOnlyToolResults(e.message?.content)) continue
|
|
122
|
+
lastUserIdx = i
|
|
123
|
+
break
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
if (lastUserIdx === -1) return null
|
|
127
|
+
|
|
128
|
+
const userMessage = textOf(entries[lastUserIdx].message?.content) ?? ''
|
|
129
|
+
const replyParts = []
|
|
130
|
+
const filesTouched = new Set()
|
|
131
|
+
|
|
132
|
+
for (let i = lastUserIdx + 1; i < entries.length; i++) {
|
|
133
|
+
const e = entries[i]
|
|
134
|
+
if (e.type !== 'assistant') continue
|
|
135
|
+
const content = e.message?.content
|
|
136
|
+
if (!Array.isArray(content)) continue
|
|
137
|
+
for (const block of content) {
|
|
138
|
+
if (block.type === 'text' && block.text?.trim()) replyParts.push(block.text)
|
|
139
|
+
if (block.type === 'tool_use') {
|
|
140
|
+
const fp = block.input?.file_path ?? block.input?.notebook_path
|
|
141
|
+
if (typeof fp === 'string' && fp) filesTouched.add(fp)
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
if (!userMessage.trim() || replyParts.length === 0) return null
|
|
147
|
+
|
|
148
|
+
// Sequence: number of user-authored messages up to and including this one —
|
|
149
|
+
// stable across re-reads of the same transcript.
|
|
150
|
+
let sequence = 0
|
|
151
|
+
for (let i = 0; i <= lastUserIdx; i++) {
|
|
152
|
+
const e = entries[i]
|
|
153
|
+
if (e.type === 'user' && !e.isMeta && !hasOnlyToolResults(e.message?.content) && textOf(e.message?.content)?.trim()) sequence++
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return {
|
|
157
|
+
userMessage,
|
|
158
|
+
claudeReply: replyParts.join('\n\n'),
|
|
159
|
+
filesTouched: [...filesTouched].slice(0, 50),
|
|
160
|
+
sequence: Math.max(1, sequence),
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function textOf(content) {
|
|
165
|
+
if (typeof content === 'string') return content
|
|
166
|
+
if (Array.isArray(content)) {
|
|
167
|
+
return content.filter(b => b.type === 'text').map(b => b.text ?? '').join('\n')
|
|
168
|
+
}
|
|
169
|
+
return null
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function hasOnlyToolResults(content) {
|
|
173
|
+
return Array.isArray(content) && content.length > 0 && content.every(b => b.type === 'tool_result')
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
// ── Session-scoped warning state ──────────────────────────────
|
|
177
|
+
// The same veto re-injected on every on-topic prompt is context spend for
|
|
178
|
+
// zero new information — Control must never cost more context than it saves.
|
|
179
|
+
|
|
180
|
+
const STATE_DIR = join(homedir(), '.robrain', 'state')
|
|
181
|
+
|
|
182
|
+
function warnedFile(sessionId) {
|
|
183
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
|
|
184
|
+
return join(STATE_DIR, `cc-warned-${safe}.json`)
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Decision ids already warned about in this session. */
|
|
188
|
+
export function loadWarnedIds(sessionId) {
|
|
189
|
+
if (!sessionId) return new Set()
|
|
190
|
+
try {
|
|
191
|
+
const ids = JSON.parse(readFileSync(warnedFile(sessionId), 'utf8'))
|
|
192
|
+
return new Set(Array.isArray(ids) ? ids : [])
|
|
193
|
+
} catch {
|
|
194
|
+
return new Set()
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
export function recordWarnedIds(sessionId, ids) {
|
|
199
|
+
if (!sessionId || ids.length === 0) return
|
|
200
|
+
try {
|
|
201
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
202
|
+
const merged = [...new Set([...loadWarnedIds(sessionId), ...ids])].slice(-200)
|
|
203
|
+
writeFileSync(warnedFile(sessionId), JSON.stringify(merged))
|
|
204
|
+
cleanupStateDir()
|
|
205
|
+
} catch {
|
|
206
|
+
// fail-open — a broken state file must never block the session
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/** Best-effort hygiene: drop state files older than 7 days. */
|
|
211
|
+
function cleanupStateDir() {
|
|
212
|
+
try {
|
|
213
|
+
const cutoff = Date.now() - 7 * 24 * 60 * 60 * 1000
|
|
214
|
+
for (const f of readdirSync(STATE_DIR)) {
|
|
215
|
+
const p = join(STATE_DIR, f)
|
|
216
|
+
try {
|
|
217
|
+
if (statSync(p).mtimeMs < cutoff) unlinkSync(p)
|
|
218
|
+
} catch {
|
|
219
|
+
// race or permissions — skip
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
} catch {
|
|
223
|
+
// dir unreadable — skip
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// ── Per-session prompt stash ──────────────────────────────────
|
|
228
|
+
// Written by the UserPromptSubmit hook, read by stop hooks that cannot rely
|
|
229
|
+
// on a parseable transcript (Codex ships last_assistant_message on Stop stdin
|
|
230
|
+
// but its transcript format is not a contract we own). Pairing the stashed
|
|
231
|
+
// prompt with that message gives deterministic capture with zero transcript
|
|
232
|
+
// assumptions.
|
|
233
|
+
|
|
234
|
+
function stashFile(sessionId) {
|
|
235
|
+
const safe = sessionId.replace(/[^A-Za-z0-9._-]/g, '_').slice(0, 120)
|
|
236
|
+
return join(STATE_DIR, `stash-${safe}.json`)
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
export function stashPrompt(sessionId, prompt) {
|
|
240
|
+
if (!sessionId || !prompt) return
|
|
241
|
+
try {
|
|
242
|
+
mkdirSync(STATE_DIR, { recursive: true })
|
|
243
|
+
const prev = loadPromptStash(sessionId)
|
|
244
|
+
writeFileSync(stashFile(sessionId), JSON.stringify({
|
|
245
|
+
prompt: prompt.slice(0, 60_000),
|
|
246
|
+
sequence: (prev?.sequence ?? 0) + 1,
|
|
247
|
+
}))
|
|
248
|
+
} catch {
|
|
249
|
+
// fail-open
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export function loadPromptStash(sessionId) {
|
|
254
|
+
if (!sessionId) return null
|
|
255
|
+
try {
|
|
256
|
+
const s = JSON.parse(readFileSync(stashFile(sessionId), 'utf8'))
|
|
257
|
+
return typeof s?.prompt === 'string' ? s : null
|
|
258
|
+
} catch {
|
|
259
|
+
return null
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Emit hook JSON output (context injection) and exit 0. */
|
|
264
|
+
export function emitContext(hookEventName, additionalContext) {
|
|
265
|
+
if (additionalContext) {
|
|
266
|
+
process.stdout.write(JSON.stringify({
|
|
267
|
+
hookSpecificOutput: { hookEventName, additionalContext },
|
|
268
|
+
}))
|
|
269
|
+
}
|
|
270
|
+
process.exit(0)
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** Silent success — hooks must never fail the session. */
|
|
274
|
+
export function exitSilently() {
|
|
275
|
+
process.exit(0)
|
|
276
|
+
}
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// SessionStart hook — inject the always-on project summary (top decisions with
|
|
3
|
+
// their rejected alternatives) at the start of every Claude Code session.
|
|
4
|
+
// Deterministic replacement for the model remembering to call sensing_start_session.
|
|
5
|
+
|
|
6
|
+
import {
|
|
7
|
+
readStdin, parseHookInput, loadPerception, resolveProjectId,
|
|
8
|
+
perceptionFetch, emitContext, exitSilently,
|
|
9
|
+
} from './lib.mjs'
|
|
10
|
+
|
|
11
|
+
const input = parseHookInput(await readStdin())
|
|
12
|
+
const cwd = input.cwd ?? process.cwd()
|
|
13
|
+
const perception = loadPerception()
|
|
14
|
+
const projectId = resolveProjectId(cwd)
|
|
15
|
+
|
|
16
|
+
const summary = await perceptionFetch(`/projects/${projectId}/summary`, perception, {}, 3000)
|
|
17
|
+
const text = summary?.summary ?? summary?.always_on_summary
|
|
18
|
+
if (!text || typeof text !== 'string' || !text.trim()) exitSilently()
|
|
19
|
+
|
|
20
|
+
emitContext(
|
|
21
|
+
'SessionStart',
|
|
22
|
+
[
|
|
23
|
+
'## RoBrain project memory (always-on summary)',
|
|
24
|
+
'Prior decisions for this project, including rejected alternatives. Respect the rejections —',
|
|
25
|
+
'if a task invites a previously rejected approach, surface the rejection instead of re-proposing it.',
|
|
26
|
+
'',
|
|
27
|
+
text.trim(),
|
|
28
|
+
].join('\n'),
|
|
29
|
+
)
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// Codex Stop hook — deterministic turn capture for Codex CLI / IDE.
|
|
3
|
+
//
|
|
4
|
+
// Codex's Stop stdin carries `last_assistant_message` directly, and the
|
|
5
|
+
// UserPromptSubmit hook stashed the user prompt — so capture needs ZERO
|
|
6
|
+
// assumptions about Codex's transcript format (which is not a contract we
|
|
7
|
+
// own). The transcript, when it happens to parse as Claude-style JSONL, only
|
|
8
|
+
// enriches the signal with files touched.
|
|
9
|
+
//
|
|
10
|
+
// Ships the turn to Perception POST /signals with needs_classification=true;
|
|
11
|
+
// extraction, the confidence gate, and near-duplicate dedup run server-side —
|
|
12
|
+
// including dedup against turns captured via the Sensing MCP protocol, so
|
|
13
|
+
// running both double-captures nothing.
|
|
14
|
+
|
|
15
|
+
import {
|
|
16
|
+
readStdin, parseHookInput, loadPerception, resolveProjectId,
|
|
17
|
+
perceptionFetch, extractLastTurn, loadPromptStash, exitSilently,
|
|
18
|
+
} from './lib.mjs'
|
|
19
|
+
|
|
20
|
+
const input = parseHookInput(await readStdin())
|
|
21
|
+
|
|
22
|
+
// stop_hook_active guards re-entry when a stop hook itself continues the turn.
|
|
23
|
+
if (input.stop_hook_active) exitSilently()
|
|
24
|
+
|
|
25
|
+
const sessionId = typeof input.session_id === 'string' ? input.session_id : ''
|
|
26
|
+
const cwd = input.cwd ?? process.cwd()
|
|
27
|
+
if (!sessionId) exitSilently()
|
|
28
|
+
|
|
29
|
+
// Reply: stdin first; transcript parse as fallback.
|
|
30
|
+
let reply = typeof input.last_assistant_message === 'string' ? input.last_assistant_message : ''
|
|
31
|
+
|
|
32
|
+
// Prompt: the UserPromptSubmit stash is authoritative; transcript is fallback.
|
|
33
|
+
const stash = loadPromptStash(sessionId)
|
|
34
|
+
let userMessage = stash?.prompt ?? ''
|
|
35
|
+
let sequence = stash?.sequence ?? 1
|
|
36
|
+
let filesTouched = []
|
|
37
|
+
|
|
38
|
+
if ((!userMessage || !reply) && input.transcript_path) {
|
|
39
|
+
const turn = extractLastTurn(input.transcript_path)
|
|
40
|
+
if (turn) {
|
|
41
|
+
if (!userMessage) { userMessage = turn.userMessage; sequence = turn.sequence }
|
|
42
|
+
if (!reply) reply = turn.claudeReply
|
|
43
|
+
filesTouched = turn.filesTouched
|
|
44
|
+
}
|
|
45
|
+
} else if (input.transcript_path) {
|
|
46
|
+
// Both present — transcript only enriches with files touched, best-effort.
|
|
47
|
+
const turn = extractLastTurn(input.transcript_path)
|
|
48
|
+
if (turn) filesTouched = turn.filesTouched
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
if (!userMessage.trim() || !reply.trim()) exitSilently()
|
|
52
|
+
|
|
53
|
+
const perception = loadPerception()
|
|
54
|
+
const projectId = resolveProjectId(cwd)
|
|
55
|
+
|
|
56
|
+
await perceptionFetch('/signals', perception, {
|
|
57
|
+
method: 'POST',
|
|
58
|
+
headers: { 'X-Project-Id': projectId },
|
|
59
|
+
body: JSON.stringify({
|
|
60
|
+
signal: {
|
|
61
|
+
turn: {
|
|
62
|
+
session_id: `codex-${sessionId}`.slice(0, 200),
|
|
63
|
+
sequence,
|
|
64
|
+
user_message: userMessage.slice(0, 60_000),
|
|
65
|
+
claude_reply: reply.slice(0, 60_000),
|
|
66
|
+
files_touched: filesTouched,
|
|
67
|
+
timestamp: new Date().toISOString(),
|
|
68
|
+
},
|
|
69
|
+
decision_type: 'unclassified',
|
|
70
|
+
confidence: 0,
|
|
71
|
+
files_affected: filesTouched,
|
|
72
|
+
scope: 'team',
|
|
73
|
+
needs_classification: true,
|
|
74
|
+
},
|
|
75
|
+
}),
|
|
76
|
+
}, 8000)
|
|
77
|
+
|
|
78
|
+
exitSilently()
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// UserPromptSubmit hook — pre-task veto injection, two tiers:
|
|
3
|
+
// 1. Deterministic: POST /veto-scan — does the prompt literally name an
|
|
4
|
+
// option an active decision rejected? Zero embeddings, zero LLM, fires
|
|
5
|
+
// for ANY prompt length. An exact mention warns at confidence 1.0.
|
|
6
|
+
// 2. Semantic: GET /decisions?query= for prompts long enough to embed.
|
|
7
|
+
// Gated on SIMILARITY, not planning_score — planning_score blends in
|
|
8
|
+
// recency decay, and a rejection must not fade from warnings just
|
|
9
|
+
// because it is old.
|
|
10
|
+
// Warnings already shown this session are not repeated (state file) —
|
|
11
|
+
// injected context must never cost more than it saves.
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
readStdin, parseHookInput, loadPerception, resolveProjectId,
|
|
15
|
+
perceptionFetch, emitContext, exitSilently, loadWarnedIds, recordWarnedIds,
|
|
16
|
+
stashPrompt,
|
|
17
|
+
} from './lib.mjs'
|
|
18
|
+
|
|
19
|
+
const input = parseHookInput(await readStdin())
|
|
20
|
+
const prompt = (input.prompt ?? '').trim()
|
|
21
|
+
|
|
22
|
+
// Skip: slash commands and empty prompts. The deterministic tier is cheap
|
|
23
|
+
// enough to run even on short prompts ("use Pinecone now" must warn).
|
|
24
|
+
if (!prompt || prompt.startsWith('/')) exitSilently()
|
|
25
|
+
|
|
26
|
+
const cwd = input.cwd ?? process.cwd()
|
|
27
|
+
const sessionId = typeof input.session_id === 'string' ? input.session_id : ''
|
|
28
|
+
|
|
29
|
+
// Stash the prompt for stop-hook capture on clients whose transcript format
|
|
30
|
+
// we do not parse (Codex pairs this with last_assistant_message).
|
|
31
|
+
stashPrompt(sessionId, prompt)
|
|
32
|
+
const perception = loadPerception()
|
|
33
|
+
const projectId = resolveProjectId(cwd)
|
|
34
|
+
|
|
35
|
+
// Tier 1 — deterministic rejected-option scan.
|
|
36
|
+
const scan = await perceptionFetch('/veto-scan', perception, {
|
|
37
|
+
method: 'POST',
|
|
38
|
+
body: JSON.stringify({ project_id: projectId, text: prompt.slice(0, 2000) }),
|
|
39
|
+
}, 1500)
|
|
40
|
+
const exact = (Array.isArray(scan?.matches) ? scan.matches : [])
|
|
41
|
+
.map(m => ({ ...m, exact: true }))
|
|
42
|
+
|
|
43
|
+
// Tier 2 — semantic search only for prompts worth an embedding round-trip
|
|
44
|
+
// (greetings, "yes", … skip it).
|
|
45
|
+
const MIN_SIMILARITY = 0.45
|
|
46
|
+
let semantic = []
|
|
47
|
+
if (prompt.length >= 24) {
|
|
48
|
+
const params = new URLSearchParams({
|
|
49
|
+
project_id: projectId,
|
|
50
|
+
query: prompt.slice(0, 2000),
|
|
51
|
+
limit: '8',
|
|
52
|
+
})
|
|
53
|
+
const data = await perceptionFetch(`/decisions?${params}`, perception, {}, 2500)
|
|
54
|
+
const decisions = Array.isArray(data) ? data : data?.decisions
|
|
55
|
+
if (Array.isArray(decisions)) {
|
|
56
|
+
semantic = decisions
|
|
57
|
+
.filter(d => Array.isArray(d.rejected) && d.rejected.length > 0)
|
|
58
|
+
.filter(d => typeof d.similarity !== 'number' || d.similarity >= MIN_SIMILARITY)
|
|
59
|
+
.sort((a, b) => (b.similarity ?? 0) - (a.similarity ?? 0))
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Merge: exact matches first (exempt from the similarity gate), then
|
|
64
|
+
// semantic; skip anything already warned in this session.
|
|
65
|
+
const warned = loadWarnedIds(sessionId)
|
|
66
|
+
const seen = new Set()
|
|
67
|
+
const vetoes = []
|
|
68
|
+
for (const d of [...exact, ...semantic]) {
|
|
69
|
+
if (!d.id || seen.has(d.id) || warned.has(d.id)) continue
|
|
70
|
+
seen.add(d.id)
|
|
71
|
+
vetoes.push(d)
|
|
72
|
+
if (vetoes.length === 3) break
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
if (vetoes.length === 0) exitSilently()
|
|
76
|
+
|
|
77
|
+
const lines = vetoes.map(d => {
|
|
78
|
+
const rej = d.rejected
|
|
79
|
+
.slice(0, 2)
|
|
80
|
+
.map(r => `**${r.option}** (${r.reason})`)
|
|
81
|
+
.join('; ')
|
|
82
|
+
const tag = d.exact ? ' *(exact match on rejected option)*' : ''
|
|
83
|
+
return `- ${d.decision}${tag}\n Rejected: ${rej}`
|
|
84
|
+
})
|
|
85
|
+
|
|
86
|
+
recordWarnedIds(sessionId, vetoes.map(d => d.id))
|
|
87
|
+
|
|
88
|
+
emitContext(
|
|
89
|
+
'UserPromptSubmit',
|
|
90
|
+
[
|
|
91
|
+
'⚠ RoBrain — this task touches decisions with previously REJECTED approaches:',
|
|
92
|
+
...lines,
|
|
93
|
+
'Do not re-propose a rejected option without flagging the prior rejection and why circumstances changed.',
|
|
94
|
+
].join('\n'),
|
|
95
|
+
)
|
|
@@ -6911,6 +6911,30 @@ var require_redact = __commonJS({
|
|
|
6911
6911
|
}
|
|
6912
6912
|
});
|
|
6913
6913
|
|
|
6914
|
+
// ../shared/dist/veto-match.js
|
|
6915
|
+
var require_veto_match = __commonJS({
|
|
6916
|
+
"../shared/dist/veto-match.js"(exports) {
|
|
6917
|
+
"use strict";
|
|
6918
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6919
|
+
exports.proposalMatchesRejectedOption = proposalMatchesRejectedOption;
|
|
6920
|
+
function proposalMatchesRejectedOption(proposalText, rejected) {
|
|
6921
|
+
if (!rejected?.length)
|
|
6922
|
+
return null;
|
|
6923
|
+
for (const r of rejected) {
|
|
6924
|
+
const option = r.option.trim();
|
|
6925
|
+
if (option.length < 3)
|
|
6926
|
+
continue;
|
|
6927
|
+
const escaped = option.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
6928
|
+
const lead = /^[A-Za-z0-9_]/.test(option) ? "\\b" : "";
|
|
6929
|
+
const trail = /[A-Za-z0-9_]$/.test(option) ? "\\b" : "";
|
|
6930
|
+
if (new RegExp(`${lead}${escaped}${trail}`, "i").test(proposalText))
|
|
6931
|
+
return r;
|
|
6932
|
+
}
|
|
6933
|
+
return null;
|
|
6934
|
+
}
|
|
6935
|
+
}
|
|
6936
|
+
});
|
|
6937
|
+
|
|
6914
6938
|
// ../shared/dist/extract-decision.js
|
|
6915
6939
|
var require_extract_decision = __commonJS({
|
|
6916
6940
|
"../shared/dist/extract-decision.js"(exports) {
|
|
@@ -7045,6 +7069,7 @@ var require_dist = __commonJS({
|
|
|
7045
7069
|
__exportStar(require_llm(), exports);
|
|
7046
7070
|
__exportStar(require_embeddings(), exports);
|
|
7047
7071
|
__exportStar(require_redact(), exports);
|
|
7072
|
+
__exportStar(require_veto_match(), exports);
|
|
7048
7073
|
__exportStar(require_extract_decision(), exports);
|
|
7049
7074
|
__exportStar(require_db(), exports);
|
|
7050
7075
|
}
|
|
@@ -23037,7 +23062,7 @@ function resolveSessionId(raw) {
|
|
|
23037
23062
|
function buildServer() {
|
|
23038
23063
|
const server = new McpServer({
|
|
23039
23064
|
name: "sensing-mcp",
|
|
23040
|
-
version: "2.3.
|
|
23065
|
+
version: "2.3.9"
|
|
23041
23066
|
});
|
|
23042
23067
|
server.tool(
|
|
23043
23068
|
"sensing_start_session",
|