@theronap/agnoclast-mcp 0.9.181 → 0.9.183
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/lib/arrivals_notice.mjs +29 -23
- package/lib/server.mjs +42 -17
- package/lib/working_files.mjs +106 -1
- package/package.json +1 -1
package/lib/arrivals_notice.mjs
CHANGED
|
@@ -56,6 +56,10 @@ import { fetchCortex, resolveBase, resolveTokenSource } from './diagnose.mjs'
|
|
|
56
56
|
* to — but this is a hook that prints unprompted, and a notice that dumps content trains its reader
|
|
57
57
|
* to skip it. Exported for the tests.
|
|
58
58
|
*/
|
|
59
|
+
// Enough to hold a session's own recent output in full; small enough that the notice is READ rather
|
|
60
|
+
// than spilled to a file, which is what happened at 49 KB.
|
|
61
|
+
const MAX_YOURS = 25
|
|
62
|
+
|
|
59
63
|
export function formatNotice(signals) {
|
|
60
64
|
const lines = []
|
|
61
65
|
const offers = signals?.settling?.offers ?? []
|
|
@@ -66,32 +70,34 @@ export function formatNotice(signals) {
|
|
|
66
70
|
lines.push(`⚡ ${delta} new arrival${delta === 1 ? '' : 's'} in the pile since this session was last told.`)
|
|
67
71
|
}
|
|
68
72
|
if (offers.length) {
|
|
69
|
-
|
|
70
|
-
// ⚠ EVERY OFFER, GROUPED BY CASCADE STAGE — changed 2026-09-25 from the first SIX.
|
|
73
|
+
// ⭐ LEAD WITH WHAT IS PROVABLY THIS SESSION'S, AND ONLY LIST THAT (2026-09-25).
|
|
71
74
|
//
|
|
72
|
-
//
|
|
73
|
-
//
|
|
74
|
-
//
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
const
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
// The title is what makes a row judgeable; an id names a unit without saying what it is.
|
|
87
|
-
for (const o of group) {
|
|
88
|
-
const name = o.title ? ` ${o.title}` : ''
|
|
89
|
-
lines.push(` ${o.intakeItemId}${name} · ${o.source} · ${o.hoursAgo}h`)
|
|
75
|
+
// Measured on the session that built this, as the one receiving it: 6 bare ids — filed nothing; 205
|
|
76
|
+
// titled rows — filed nothing; 350 rows at 49 KB — the harness spilled it to a file and showed a 2 KB
|
|
77
|
+
// preview, so it was not even read. Filed immediately: the one record it had looked at and could see
|
|
78
|
+
// was its own. What moves an agent is certainty, a short list, and the reason stated. So the rows
|
|
79
|
+
// shown are the ones whose files THIS SESSION CHANGED (`worked`), each with the file that proves it,
|
|
80
|
+
// framed as the step before the answer; every other tier is one line of counts.
|
|
81
|
+
const yours = offers.filter((o) => o.via_reason === 'matched' && (o.worked ?? []).length)
|
|
82
|
+
const rest = offers.filter((o) => !yours.includes(o))
|
|
83
|
+
if (yours.length) {
|
|
84
|
+
const n = yours.length
|
|
85
|
+
lines.push(`⚡ ${n} arrival${n === 1 ? '' : 's'} came from work THIS SESSION did — you changed the files ${n === 1 ? 'it touches' : 'they touch'}. File ${n === 1 ? 'it' : 'them'} before anything else:`)
|
|
86
|
+
for (const o of yours.slice(0, MAX_YOURS)) {
|
|
87
|
+
const files = o.worked.slice(0, 2).map((f) => f.split('/').pop()).join(', ')
|
|
88
|
+
lines.push(` ${o.intakeItemId} ${o.title ?? '(unreadable)'} · ${o.source} · ${o.hoursAgo}h — you changed ${files}`)
|
|
90
89
|
}
|
|
90
|
+
if (n > MAX_YOURS) lines.push(` … and ${n - MAX_YOURS} more that are yours`)
|
|
91
|
+
lines.push(' `intake_look <id>` shows where one would go · `intake_materialize` files it · `not_mine` if one is not yours.')
|
|
91
92
|
}
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
const counts = { matched: 0, orphan: 0, declined: 0 }
|
|
94
|
+
for (const o of rest) counts[o.via_reason] = (counts[o.via_reason] ?? 0) + 1
|
|
95
|
+
const parts = []
|
|
96
|
+
if (counts.matched) parts.push(`${counts.matched} loosely matching your work`)
|
|
97
|
+
if (counts.orphan) parts.push(`${counts.orphan} matching no session`)
|
|
98
|
+
if (counts.declined) parts.push(`${counts.declined} declined by other sessions`)
|
|
99
|
+
if (total > offers.length) parts.push(`${total - offers.length} beyond the scan window`)
|
|
100
|
+
if (parts.length) lines.push(`⚡ Also settling: ${parts.join(' · ')} — not listed here; \`intake_changes\` shows them.`)
|
|
95
101
|
}
|
|
96
102
|
return lines.join('\n')
|
|
97
103
|
}
|
package/lib/server.mjs
CHANGED
|
@@ -89,6 +89,27 @@ export function intakeMaterializePath(mode) {
|
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
/** ADR-0062 §5.2. Which page governs a materialized record, and who chose it — or '' for an older server. */
|
|
92
|
+
// ADR-0051's refusal, rendered as INSTRUCTIONS — shared by route_record and intake_materialize so the two
|
|
93
|
+
// verbs cannot drift apart (the server shares OTHER_HOMES_DETAIL for the same reason). The agent is one
|
|
94
|
+
// call from succeeding, and the whole point of the gate is the question it asks; a bare
|
|
95
|
+
// "other_homes_unchecked" reads as a malfunction and gets retried verbatim.
|
|
96
|
+
function otherHomesRefusalText(opener) {
|
|
97
|
+
return [
|
|
98
|
+
opener,
|
|
99
|
+
'',
|
|
100
|
+
'What OTHER pages does this record belong on? A record is usually about more than the page an',
|
|
101
|
+
'identifier matched: the project it concerns, the person it is about, the decision it bears on.',
|
|
102
|
+
'',
|
|
103
|
+
' know some already → add them to documentIds and call again',
|
|
104
|
+
' none come to mind → go look first. grep or read for pages this could belong to;',
|
|
105
|
+
' you know the namespace better than any matcher does',
|
|
106
|
+
' looked, found none → call again with searchedForOtherHomes: true',
|
|
107
|
+
'',
|
|
108
|
+
'Parking is still a complete answer, and a wrong attachment is still worse than none. The gate',
|
|
109
|
+
'asks that you looked, not that you found something.',
|
|
110
|
+
].join('\n')
|
|
111
|
+
}
|
|
112
|
+
|
|
92
113
|
export function renderGoverningLine(governing) {
|
|
93
114
|
if (!governing || typeof governing !== 'object') return ''
|
|
94
115
|
const name = governing.title || 'a page'
|
|
@@ -1117,7 +1138,8 @@ function renderNudge(payload) {
|
|
|
1117
1138
|
description:
|
|
1118
1139
|
'Atomically publish a claimed intake item into one brain — the disclosure moment (ADR-0062). Deterministic identifier homes in that brain are always attached; documentIds may add further pages. The record is born at the strictest of its source baseline, what the producer asked for, and every attached page; a source floor (iMessage, claude-code) pins it. ' +
|
|
1119
1140
|
'Name the governing page with governingPage (one of documentIds, never a person page) — otherwise the ranking picks it. ' +
|
|
1120
|
-
'Pass mode "preview" first when your choice differs from the claim\'s proposal: it returns the pages, the governing page and the tier this exact call would produce, and writes nothing. Never writes private intake into search/history before this call.'
|
|
1141
|
+
'Pass mode "preview" first when your choice differs from the claim\'s proposal: it returns the pages, the governing page and the tier this exact call would produce, and writes nothing. Never writes private intake into search/history before this call. ' +
|
|
1142
|
+
'⚠ ADR-0051: a unit does not finish until you looked for its OTHER homes. A call whose documentIds add nothing beyond the deterministic homes it gets anyway is REFUSED once — name the pages this unit is about (the design it implements, the project, the person), or go look, or say you looked with searchedForOtherHomes.',
|
|
1121
1143
|
inputSchema: {
|
|
1122
1144
|
intakeItemId: z.string().describe('intake item uuid'),
|
|
1123
1145
|
orgId: z.string().describe('destination brain org uuid'),
|
|
@@ -1136,6 +1158,12 @@ function renderNudge(payload) {
|
|
|
1136
1158
|
mode: z.enum(['preview', 'apply']).optional().describe(
|
|
1137
1159
|
'"preview": report the pages, governing page and tier this call would produce, and write nothing. Default "apply".',
|
|
1138
1160
|
),
|
|
1161
|
+
searchedForOtherHomes: z.boolean().optional().describe(
|
|
1162
|
+
'ADR-0051. "I looked for other homes and there are genuinely none." Only consulted when documentIds add no page ' +
|
|
1163
|
+
'beyond the deterministic homes. ⚠ DO NOT SEND THIS ON THE FIRST CALL AS A MATTER OF COURSE — it is the answer AFTER ' +
|
|
1164
|
+
'looking, not a way past the question. Measured 2026-09-25: 30 records filed through this verb in one afternoon with no ' +
|
|
1165
|
+
'judged page on any of them.',
|
|
1166
|
+
),
|
|
1139
1167
|
},
|
|
1140
1168
|
},
|
|
1141
1169
|
async (args) => {
|
|
@@ -1158,10 +1186,20 @@ function renderNudge(payload) {
|
|
|
1158
1186
|
},
|
|
1159
1187
|
attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
|
|
1160
1188
|
...(args.governingPage ? { governingDocumentId: args.governingPage } : {}),
|
|
1189
|
+
...(args.searchedForOtherHomes ? { searchedForOtherHomes: true } : {}),
|
|
1161
1190
|
}),
|
|
1162
1191
|
})
|
|
1163
1192
|
if (!res.ok) {
|
|
1164
1193
|
const body = await res.text()
|
|
1194
|
+
if (res.status === 409) {
|
|
1195
|
+
let err = null
|
|
1196
|
+
try { err = JSON.parse(body)?.error } catch { /* not JSON: fall through to the raw text */ }
|
|
1197
|
+
if (err === 'other_homes_unchecked') {
|
|
1198
|
+
return toolError(otherHomesRefusalText(
|
|
1199
|
+
'Not filed yet — every page named is one this unit gets anyway, so this would publish it without adding anything of your judgment.',
|
|
1200
|
+
))
|
|
1201
|
+
}
|
|
1202
|
+
}
|
|
1165
1203
|
if (preview && res.status === 404) {
|
|
1166
1204
|
return toolError('This server cannot preview a materialization yet — nothing was written. Materialize without mode, or wait for the server to update.')
|
|
1167
1205
|
}
|
|
@@ -4641,22 +4679,9 @@ function renderNudge(payload) {
|
|
|
4641
4679
|
// agent is one call from succeeding and the whole point of the gate is the question it asks. A
|
|
4642
4680
|
// bare "other_homes_unchecked" would read as a malfunction and get retried verbatim.
|
|
4643
4681
|
if (!res.ok && out?.error === 'other_homes_unchecked') {
|
|
4644
|
-
return toolError(
|
|
4645
|
-
|
|
4646
|
-
|
|
4647
|
-
: 'Not routed yet — every page you named is already attached, so this call would finish the record without adding anything.',
|
|
4648
|
-
'',
|
|
4649
|
-
'What OTHER pages does this record belong on? A record is usually about more than the page an',
|
|
4650
|
-
'identifier matched: the project it concerns, the person it is about, the decision it bears on.',
|
|
4651
|
-
'',
|
|
4652
|
-
' know some already → add them to documentIds and call again',
|
|
4653
|
-
' none come to mind → go look first. grep or read for pages this could belong to;',
|
|
4654
|
-
' you know the namespace better than any matcher does',
|
|
4655
|
-
' looked, found none → call again with searchedForOtherHomes: true',
|
|
4656
|
-
'',
|
|
4657
|
-
'Parking is still a complete answer, and a wrong attachment is still worse than none. The gate',
|
|
4658
|
-
'asks that you looked, not that you found something.',
|
|
4659
|
-
].join('\n'))
|
|
4682
|
+
return toolError(otherHomesRefusalText(park
|
|
4683
|
+
? 'Not parked yet — before a record is filed as having no home, look for its other homes.'
|
|
4684
|
+
: 'Not routed yet — every page you named is already attached, so this call would finish the record without adding anything.'))
|
|
4660
4685
|
}
|
|
4661
4686
|
if (!res.ok) return toolError(`Could not route record: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
4662
4687
|
if (park) return { content: [{ type: 'text', text: 'Parked. It stays visible and unrouted rather than badly attached.' }] }
|
package/lib/working_files.mjs
CHANGED
|
@@ -13,6 +13,13 @@
|
|
|
13
13
|
// appended since the last heartbeat; the first read starts from the tail, since recent work is what
|
|
14
14
|
// "working on" means.
|
|
15
15
|
//
|
|
16
|
+
// ⚠ CHANGED FILES ONLY — reading one is not working on it (2026-09-25, same day as the above). The
|
|
17
|
+
// first cut recorded every file the session READ as well, and a session investigating a bug reads
|
|
18
|
+
// dozens. Those holds then surface OTHER sessions' work as "yours, you worked on this file", which is
|
|
19
|
+
// certainty the signal does not have — and a nudge that claims certainty it lacks is how an agent
|
|
20
|
+
// learns to stop reading them. The existing rule for pages already draws this line: a deliberate
|
|
21
|
+
// open counts, a grep hit does not. For files, the floor is a change.
|
|
22
|
+
//
|
|
16
23
|
// ⚠ ATTENTION DATA, NOT A CLEARANCE STAMP. capture.mjs uses the same paths to stamp REPOS onto records
|
|
17
24
|
// behind a MIN_FILES_FOR_STAMP security threshold, because a stamp grants other people read access.
|
|
18
25
|
// Nothing here grants anything: these identifiers only make this person's OWN sessions candidates for
|
|
@@ -47,6 +54,100 @@ export function pathsInLine(line) {
|
|
|
47
54
|
return out
|
|
48
55
|
}
|
|
49
56
|
|
|
57
|
+
const EDIT_TOOLS = new Set(['Edit', 'Write', 'MultiEdit', 'NotebookEdit'])
|
|
58
|
+
const ABS_PATH = /\/(?:Users|home|opt|srv|var|private|tmp)\/[^\s"'`;|&)<>]+/g
|
|
59
|
+
// A command that writes through a script: every absolute path it names is a candidate target.
|
|
60
|
+
// Over-inclusive for a script that reads one file and writes another, which is the safe side here —
|
|
61
|
+
// the alternative is missing the edit, and the edit is the whole signal.
|
|
62
|
+
const SCRIPTED_WRITE = /open\([^)]*['"][wa]b?\+?['"]|writeFileSync|writeFile\(|write_text\(|Bun\.write\(|sed\s+(?:-\S+\s+)*-i\b|\bperl\s+-\S*i/
|
|
63
|
+
const REDIRECT_TARGET = /(?:^|[^<0-9&>])>>?\s*(['"]?)(\/[^\s'"`;|&)<>]+)\1/g
|
|
64
|
+
const TEE_TARGET = /\btee\s+(?:-a\s+)?(['"]?)(\/[^\s'"`;|&)<>]+)\1/g
|
|
65
|
+
const COPY_MOVE = /\b(?:cp|mv|install)\b[^;|&\n]*/g
|
|
66
|
+
|
|
67
|
+
// `W=/private/tmp/wt; cat > "$W/web/app.ts"` names its target only through the variable, and that is
|
|
68
|
+
// how a great deal of real editing is written. Resolve simple absolute assignments in the same command.
|
|
69
|
+
function expandVarsFrom(shell, text) {
|
|
70
|
+
const vars = {}
|
|
71
|
+
for (const m of shell.matchAll(/(?:^|[\s;&(])([A-Za-z_][A-Za-z0-9_]*)=(['"]?)(\/[^\s'"`;|&)<>]*)\2/g)) vars[m[1]] = m[3]
|
|
72
|
+
return text.replace(/\$\{?([A-Za-z_][A-Za-z0-9_]*)\}?/g, (all, name) => (name in vars ? vars[name] : all))
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// Split a Bash command into the shell text and its heredoc bodies, remembering which command line fed
|
|
76
|
+
// each body. The distinction is the difference between content and code: the body of
|
|
77
|
+
// `cat > f <<EOF` is the file being written, and any path-looking text in it (a code comment giving an
|
|
78
|
+
// example command, say) is not a write; the body of `python3 - <<PY` IS the script doing the writing.
|
|
79
|
+
// Measured: scanning bodies indiscriminately reported `web/app.ts` — an example inside a comment.
|
|
80
|
+
const INTERPRETER = /\b(?:python3?|node|bun|bash|sh|zsh|ruby|perl)\b/
|
|
81
|
+
const SHELL_INTERPRETER = /\b(?:bash|sh|zsh)\b/
|
|
82
|
+
function splitHeredocs(cmd) {
|
|
83
|
+
const lines = cmd.split('\n')
|
|
84
|
+
const shell = []
|
|
85
|
+
const bodies = []
|
|
86
|
+
for (let i = 0; i < lines.length; i++) {
|
|
87
|
+
const line = lines[i]
|
|
88
|
+
shell.push(line)
|
|
89
|
+
const m = line.match(/<<-?\s*(['"]?)([A-Za-z_][A-Za-z0-9_]*)\1/)
|
|
90
|
+
if (!m) continue
|
|
91
|
+
const end = lines.findIndex((l, j) => j > i && l.trim() === m[2])
|
|
92
|
+
const stop = end < 0 ? lines.length : end
|
|
93
|
+
bodies.push({ owner: line, body: lines.slice(i + 1, stop).join('\n'), quoted: Boolean(m[1]) })
|
|
94
|
+
if (end >= 0) shell.push(lines[end])
|
|
95
|
+
i = stop
|
|
96
|
+
}
|
|
97
|
+
return { shell: shell.join('\n'), bodies }
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function writesIn(text, out) {
|
|
101
|
+
if (SCRIPTED_WRITE.test(text)) out.push(...(text.match(ABS_PATH) ?? []))
|
|
102
|
+
for (const m of text.matchAll(REDIRECT_TARGET)) out.push(m[2])
|
|
103
|
+
for (const m of text.matchAll(TEE_TARGET)) out.push(m[2])
|
|
104
|
+
for (const seg of text.match(COPY_MOVE) ?? []) {
|
|
105
|
+
const paths = seg.match(ABS_PATH) ?? []
|
|
106
|
+
if (paths.length >= 2) out.push(paths[paths.length - 1])
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Paths one transcript line CHANGED. Exported for the tests.
|
|
112
|
+
*
|
|
113
|
+
* The edit tools name their target exactly. Bash is heuristic by necessity: a redirect or `tee`
|
|
114
|
+
* target, every path in a `sed -i` or a scripted write, and the destination of `cp`/`mv`/`install`.
|
|
115
|
+
* A plain `cat`, `grep` or `sed -n` of a path is a read and is not counted.
|
|
116
|
+
*/
|
|
117
|
+
export function editedPathsInLine(line) {
|
|
118
|
+
const out = []
|
|
119
|
+
let d
|
|
120
|
+
try { d = JSON.parse(line) } catch { return out }
|
|
121
|
+
for (const b of d?.message?.content ?? []) {
|
|
122
|
+
if (b?.type !== 'tool_use') continue
|
|
123
|
+
const inp = b.input ?? {}
|
|
124
|
+
if (EDIT_TOOLS.has(b.name)) {
|
|
125
|
+
for (const k of ['file_path', 'notebook_path']) {
|
|
126
|
+
if (typeof inp[k] === 'string' && inp[k].startsWith('/')) out.push(inp[k])
|
|
127
|
+
}
|
|
128
|
+
continue
|
|
129
|
+
}
|
|
130
|
+
if (typeof inp.command !== 'string') continue
|
|
131
|
+
const { shell, bodies } = splitHeredocs(inp.command)
|
|
132
|
+
// Variables are resolved from the SHELL text only: an assignment inside a file's content is not
|
|
133
|
+
// an assignment the shell ever made.
|
|
134
|
+
const expand = (t) => expandVarsFrom(shell, t)
|
|
135
|
+
writesIn(expand(shell), out)
|
|
136
|
+
for (const { owner, body, quoted } of bodies) {
|
|
137
|
+
// Shell semantics, exactly: a QUOTED delimiter (<<'PY') hands the body over literally, so a `$W`
|
|
138
|
+
// inside it is text, not a path. Expanding it anyway turned an example in a code comment into a
|
|
139
|
+
// "changed" file that does not exist.
|
|
140
|
+
const text = quoted ? body : expand(body)
|
|
141
|
+
if (SHELL_INTERPRETER.test(owner)) writesIn(text, out) // a shell script: full rules
|
|
142
|
+
else if (INTERPRETER.test(owner)) { // python/node/…: its writes
|
|
143
|
+
if (SCRIPTED_WRITE.test(text)) out.push(...(text.match(ABS_PATH) ?? [])) // shell syntax is data
|
|
144
|
+
}
|
|
145
|
+
// else: content being written by cat/tee — its target is already on the owner line
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
return [...new Set(out)]
|
|
149
|
+
}
|
|
150
|
+
|
|
50
151
|
/**
|
|
51
152
|
* Find this session's transcript by its id alone. Scanning for `<id>.jsonl` avoids depending on how
|
|
52
153
|
* Claude Code slugs the launch directory into a folder name — the id is unique, the slug rule is not
|
|
@@ -114,11 +215,15 @@ export function createWorkedFilesTracker(transcriptPath, opts = {}) {
|
|
|
114
215
|
const lines = nl >= 0 ? text.slice(0, nl).split('\n') : []
|
|
115
216
|
|
|
116
217
|
const touched = []
|
|
117
|
-
for (const line of lines) if (line) touched.push(...
|
|
218
|
+
for (const line of lines) if (line) touched.push(...editedPathsInLine(line))
|
|
118
219
|
|
|
119
220
|
let probes = 0
|
|
120
221
|
for (const p of touched) {
|
|
121
222
|
if (p.includes('/.git/') || p.endsWith('/')) continue
|
|
223
|
+
// A directory is never an edit, and one without a trailing slash would pass the server's shape
|
|
224
|
+
// check and re-introduce exactly the breadth file-level holds exist to remove. Measured: `cd` into
|
|
225
|
+
// `web` and `packages/cortex-mcp` in a writing command reported both as changed "files".
|
|
226
|
+
try { if (statSync(p).isDirectory()) continue } catch { /* gone or never existed: judged below */ }
|
|
122
227
|
const dir = dirname(p)
|
|
123
228
|
if (!repoOfDir.has(dir)) {
|
|
124
229
|
if (probes >= MAX_DIR_PROBES_PER_POLL) continue
|
package/package.json
CHANGED