@theronap/cortex-mcp 0.9.159 → 0.9.161

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.
@@ -52,7 +52,7 @@ const rest = process.argv.slice(3)
52
52
  //
53
53
  // ⚠ NOT guarded: `capture` (has its own, with a diagnostic), and anything a human might run by hand
54
54
  // while this var happens to be set. Only the ambient context commands are listed.
55
- const CONTEXT_HOOK_COMMANDS = new Set(['snapshot-context', 'status', 'hydrate', 'skills'])
55
+ const CONTEXT_HOOK_COMMANDS = new Set(['snapshot-context', 'status', 'hydrate', 'skills', 'arrivals-notice'])
56
56
  if (process.env.CORTEX_SUMMARIZING && CONTEXT_HOOK_COMMANDS.has(cmd)) {
57
57
  process.stderr.write(`cortex: ${cmd} skipped — summarizer subprocess (CORTEX_SUMMARIZING)\n`)
58
58
  process.exit(0)
@@ -85,6 +85,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
85
85
  ` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
86
86
  ` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
87
87
  ` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
88
+ ` arrivals-notice PostToolUse hook: print incoming-record offers so the PERSON sees them too\n` +
88
89
  ` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
89
90
  ` migrate-key [--dry-run] move the MCP config key to its new name, allow rules first\n` +
90
91
  ` with-token -- <cmd> run <cmd> with your token in its environment (for cron/launchd wrappers)\n` +
@@ -192,6 +193,13 @@ if (cmd === 'login') {
192
193
  process.exitCode = await runSnapshotContext()
193
194
  const { closeFetch } = await import('../lib/diagnose.mjs')
194
195
  await closeFetch()
196
+ } else if (cmd === 'arrivals-notice') {
197
+ // PostToolUse hook: surface the arrivals offer to the PERSON. The ⚡ block is appended to TOOL
198
+ // RESULTS, which only the agent reads — and an audit on 2026-09-21 found the agent acting on it
199
+ // once in a day. Pure text extraction from the payload it is handed: no network, no token, always
200
+ // exit 0. It reports; it never blocks a tool call.
201
+ const { runArrivalsNotice } = await import('../lib/arrivals_notice.mjs')
202
+ process.exitCode = await runArrivalsNotice()
195
203
  } else if (cmd === 'hydrate') {
196
204
  // UserPromptSubmit hook (① discovery): hydrate the model with query-centered Agnoclast context on the
197
205
  // FIRST substantive turn, before it answers — then never again this session (topic-shift refresh stays
@@ -0,0 +1,138 @@
1
+ // PostToolUse hook — make the arrivals offer visible to the PERSON, not just the agent.
2
+ //
3
+ // 🔴 WHY THIS EXISTS, and it is not a nicety. The `⚡ ARRIVED WHILE YOU WERE WORKING` block and the
4
+ // per-turn delta line are appended to TOOL RESULTS, which only the agent reads. Measured on
5
+ // 2026-09-21 against a single session's own record: the block fired repeatedly through the day and
6
+ // the agent acted on it ONCE, and only because the user asked it to prove the mechanism existed. An
7
+ // audit of the four records it named found one that plainly belonged to that session's own work and
8
+ // had simply been let pass.
9
+ //
10
+ // The failure is structural rather than a lapse: the offer arrives MID-TASK, inside a result the
11
+ // agent requested for an unrelated reason, and nothing forces a decision. A signal that only the
12
+ // agent can see, and that the agent reliably scrolls past, is worse than no signal — it LOOKS like
13
+ // coverage. So this does not ask the agent to be more diligent; it removes the agent from the
14
+ // delivery path.
15
+ //
16
+ // ⚠ IT IS NOT A GATE. Hook stdout is surfaced and exit is always 0: this reports, it never blocks a
17
+ // tool call, and it never fails one. A notifier that can break the thing it observes gets disabled,
18
+ // and then it observes nothing.
19
+ //
20
+ // ⚠ NO NETWORK, NO TOKEN, NO DECRYPT. Everything printed is already in the payload the hook was
21
+ // handed. That keeps it fast enough to run after every tool call and means it cannot leak anything
22
+ // the agent was not already shown.
23
+ import { readFileSync, writeFileSync, mkdirSync } from 'node:fs'
24
+ import { homedir } from 'node:os'
25
+ import { join } from 'node:path'
26
+
27
+ const MATCHED = '⚡ ARRIVED WHILE YOU WERE WORKING'
28
+ const DELTA_RE = /⚡ (\d+) new arrivals? in your pile/
29
+ // The listing lines look like: - Title (email · 5.1h ago · id <uuid>) — …
30
+ const ITEM_RE = /^\s*-\s+(.*?)\s+\((\S+)\s+·\s+([^·]+?)\s+·\s+id\s+([0-9a-f-]{36})\)/gmu
31
+
32
+ function readStdin() {
33
+ try { return readFileSync(0, 'utf8') } catch { return '' }
34
+ }
35
+
36
+ /** Every string in the payload, so this does not depend on which key holds the tool's text. */
37
+ function* strings(v, depth = 0) {
38
+ if (depth > 8 || v == null) return
39
+ if (typeof v === 'string') { yield v; return }
40
+ if (Array.isArray(v)) { for (const x of v) yield* strings(x, depth + 1); return }
41
+ if (typeof v === 'object') { for (const x of Object.values(v)) yield* strings(x, depth + 1) }
42
+ }
43
+
44
+ /**
45
+ * Pull the arrivals offer out of a PostToolUse payload.
46
+ *
47
+ * Exported for tests: the parsing is the part that can silently rot when the server changes the
48
+ * block's wording, and a hook that stops matching fails SILENTLY — it just prints nothing forever,
49
+ * which is indistinguishable from "nothing arrived".
50
+ */
51
+ export function extractArrivals(payloadText) {
52
+ let blob = payloadText
53
+ try {
54
+ const parsed = JSON.parse(payloadText)
55
+ blob = [...strings(parsed)].join('\n')
56
+ } catch {
57
+ // Not JSON — scan the raw text. Fail open toward reporting rather than staying silent.
58
+ }
59
+ if (!blob) return null
60
+
61
+ const deltaMatch = blob.match(DELTA_RE)
62
+ const delta = deltaMatch ? Number(deltaMatch[1]) : 0
63
+
64
+ const items = []
65
+ if (blob.includes(MATCHED)) {
66
+ // Only the section AFTER the marker, so an unrelated bulleted list earlier in a page read is
67
+ // not mistaken for arrivals.
68
+ const section = blob.slice(blob.indexOf(MATCHED))
69
+ for (const m of section.matchAll(ITEM_RE)) {
70
+ items.push({ title: m[1].trim(), source: m[2], age: m[3].trim(), id: m[4] })
71
+ }
72
+ }
73
+ if (!delta && items.length === 0) return null
74
+ return { delta, items }
75
+ }
76
+
77
+ /** Ids already announced, so the same arrival is not re-reported on every tool call until claimed. */
78
+ function seenPath(sessionKey) {
79
+ const dir = join(homedir(), '.cortex', 'arrivals-announced')
80
+ try { mkdirSync(dir, { recursive: true }) } catch { /* best effort */ }
81
+ return join(dir, `${(sessionKey || 'nosession').replace(/[^a-zA-Z0-9_-]/g, '_')}.json`)
82
+ }
83
+
84
+ function loadSeen(p) {
85
+ try { return new Set(JSON.parse(readFileSync(p, 'utf8'))) } catch { return new Set() }
86
+ }
87
+
88
+ function saveSeen(p, set) {
89
+ // Bounded: a long session must not grow this file without limit.
90
+ try { writeFileSync(p, JSON.stringify([...set].slice(-500))) } catch { /* best effort */ }
91
+ }
92
+
93
+ export function formatNotice(arrivals) {
94
+ const lines = []
95
+ if (arrivals.items.length) {
96
+ lines.push(`⚡ ${arrivals.items.length} record${arrivals.items.length === 1 ? '' : 's'} offered to this session:`)
97
+ for (const it of arrivals.items.slice(0, 6)) {
98
+ lines.push(` · ${it.title.slice(0, 92)} (${it.source} · ${it.age})`)
99
+ lines.push(` ${it.id}`)
100
+ }
101
+ if (arrivals.items.length > 6) lines.push(` … and ${arrivals.items.length - 6} more`)
102
+ }
103
+ if (arrivals.delta) {
104
+ lines.push(`⚡ ${arrivals.delta} new arrival${arrivals.delta === 1 ? '' : 's'} in the pile since this session was last told.`)
105
+ }
106
+ // Said plainly, because the whole point is that the person can tell whether anything happened.
107
+ if (arrivals.items.length) lines.push(` (Claim + route them, or say they are not yours — they stay unclaimed until someone decides.)`)
108
+ return lines.join('\n')
109
+ }
110
+
111
+ export async function runArrivalsNotice() {
112
+ try {
113
+ const raw = readStdin()
114
+ if (!raw) return 0
115
+
116
+ let sessionKey = ''
117
+ try { sessionKey = String(JSON.parse(raw)?.session_id ?? '') } catch { /* fine */ }
118
+
119
+ const arrivals = extractArrivals(raw)
120
+ if (!arrivals) return 0
121
+
122
+ const p = seenPath(sessionKey)
123
+ const seen = loadSeen(p)
124
+ const fresh = arrivals.items.filter((it) => !seen.has(it.id))
125
+
126
+ // Nothing new to say. The delta line is server-side watermarked and already fires once per
127
+ // change, so it is reported whenever present.
128
+ if (fresh.length === 0 && !arrivals.delta) return 0
129
+
130
+ process.stdout.write(formatNotice({ delta: arrivals.delta, items: fresh }) + '\n')
131
+ for (const it of fresh) seen.add(it.id)
132
+ saveSeen(p, seen)
133
+ return 0
134
+ } catch {
135
+ // ⚠ ALWAYS 0. This observes; it must never be the reason a tool call failed.
136
+ return 0
137
+ }
138
+ }
@@ -98,6 +98,31 @@ export function mergeClaudeSettings(existing, spec) {
98
98
  }
99
99
  sgrp.hooks.push({ type: 'command', command: snapshotCmd })
100
100
 
101
+ // PostToolUse — arrivals-notice.
102
+ //
103
+ // 🔴 THE ARRIVALS OFFER WAS INVISIBLE TO THE PERSON. `⚡ ARRIVED WHILE YOU WERE WORKING` and the
104
+ // per-turn delta are appended to TOOL RESULTS, which only the agent reads. Measured 2026-09-21
105
+ // against one session's own record: the block fired repeatedly through the day, the agent acted on
106
+ // it once, and an audit of the records it named found one belonging to that session's own work
107
+ // that had simply been let pass. A signal only the agent sees, and reliably scrolls past, LOOKS
108
+ // like coverage while providing none.
109
+ //
110
+ // ⚠ SO THIS IS WIRED AS SOFTWARE, NOT LEFT TO INSTRUCTION. Asking the agent to relay arrivals is
111
+ // asking it to be reliable at exactly the thing it was just measured failing at; a hook takes the
112
+ // agent out of the delivery path entirely.
113
+ //
114
+ // ⚠ Matcher is '' (every tool). The block only appears on three surfaces, so the filtering is
115
+ // server-side and this stays a cheap no-op elsewhere: it reads its stdin, finds no marker, exits 0.
116
+ s.hooks.PostToolUse = Array.isArray(s.hooks.PostToolUse) ? s.hooks.PostToolUse : []
117
+ const arrivalsCmd = markCommand(`npx -y ${spec} arrivals-notice`)
118
+ for (const pg of s.hooks.PostToolUse) {
119
+ if (Array.isArray(pg.hooks)) pg.hooks = pg.hooks.filter((h) => !isManagedSubcommand(h.command, 'arrivals-notice'))
120
+ }
121
+ let pgrp = s.hooks.PostToolUse.find((g) => (g.matcher ?? '') === '')
122
+ if (!pgrp) { pgrp = { matcher: '', hooks: [] }; s.hooks.PostToolUse.push(pgrp) }
123
+ pgrp.hooks = pgrp.hooks ?? []
124
+ pgrp.hooks.push({ type: 'command', command: arrivalsCmd })
125
+
101
126
  // UserPromptSubmit — hydrate (① discovery). Makes the FIRST context load of a session
102
127
  // non-discretionary: without it, hydration is a skill the agent may simply forget, and an agent
103
128
  // that never reads the wiki never triggers a currency update — it re-derives design that is
package/lib/server.mjs CHANGED
@@ -2151,6 +2151,51 @@ function renderNudge(payload) {
2151
2151
  },
2152
2152
  )
2153
2153
 
2154
+ server.registerTool(
2155
+ 'staged_look',
2156
+ {
2157
+ title: 'Read a staged arrival before deciding where it goes',
2158
+ description: 'Read ONE staged arrival\'s envelope — sender, subject, recipients, thread, the generated summary, and the identifiers holding it — without claiming or changing anything. Use it before `place_staged_record` whenever the subject alone does not tell you WHAT KIND of thing this is. ⚠ THE SUBJECT LIES MORE OFTEN THAN YOU WOULD THINK: `staged_records` lists subjects only, and a message reading `Re: [owner/repo] fix(triage)…` is as likely to be a bot\'s deployment-status comment as a human discussing the PR — measured 2026-09-21, `vercel[bot]` accounts for 26 of the pending arrivals whose subject names that repo. The `from` header settles it in one look. ⚠ THIS IS THE ENVELOPE, NOT THE LETTER. A staged arrival stores headers and a generated summary; the message BODY is not persisted, so `bodyStored` is always false. Do not describe an arrival as something you READ — you saw who sent it and what it was titled. Looking is free, changes nothing, and needs no release.',
2159
+ inputSchema: {
2160
+ stagedId: z.string().describe('staged arrival id, from `staged_records` or the session-start arrivals block'),
2161
+ },
2162
+ },
2163
+ async ({ stagedId }) => {
2164
+ let res
2165
+ try {
2166
+ res = await fetchCortex(`${BASE}/api/staged/look?stagedId=${encodeURIComponent(stagedId)}`, {
2167
+ headers: { Authorization: `Bearer ${TOKEN}` },
2168
+ })
2169
+ } catch (e) {
2170
+ return toolError(`Could not read that staged arrival: ${e.message}`)
2171
+ }
2172
+ const out = await res.json().catch(() => null)
2173
+ if (res.status === 404) {
2174
+ return toolError(`No staged arrival ${stagedId} of yours. Staged rows are per-person and in no brain, so this means it is not yours or does not exist — the two are deliberately indistinguishable.`)
2175
+ }
2176
+ if (!res.ok) return toolError(`Could not read staged arrival ${stagedId}: ${out?.error ?? res.status}`)
2177
+
2178
+ const l = out?.look ?? {}
2179
+ const line = (label, v) => (v ? ` ${label.padEnd(9)} ${v}\n` : '')
2180
+ const held = Array.isArray(l.blockingIdentifiers) && l.blockingIdentifiers.length
2181
+ ? `\n⚠ HELD ON UNCLAIMED IDENTIFIERS — no page claims these, so it cannot finish until they are dispositioned:\n${l.blockingIdentifiers.map((i) => ` · ${i}`).join('\n')}\n`
2182
+ : ''
2183
+ return {
2184
+ content: [{
2185
+ type: 'text',
2186
+ text:
2187
+ `${l.title ?? '(untitled)'}\n` +
2188
+ ` ${l.sourceType} · arrived ${l.receivedAt ?? '?'} · occurred ${l.occurredAt ?? '?'}\n\n` +
2189
+ line('from', l.from) + line('subject', l.subject) + line('to', l.to) +
2190
+ line('account', l.account) + line('thread', l.threadId) +
2191
+ (l.summary ? `\n summary ${l.summary}\n` : '') +
2192
+ held +
2193
+ `\n⚠ This is the ENVELOPE. The message body was never stored (bodyStored: ${l.bodyStored === true}), so you have seen who sent it and what it was called — not what it says. Judge accordingly, and do not report having read it.`,
2194
+ }],
2195
+ }
2196
+ },
2197
+ )
2198
+
2154
2199
  server.registerTool(
2155
2200
  'discard_staged',
2156
2201
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.159",
3
+ "version": "0.9.161",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {