@theronap/cortex-mcp 0.9.69 → 0.9.71
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/bin/cortex-mcp.mjs +6 -0
- package/lib/editors/claude.mjs +28 -4
- package/lib/hydrate.mjs +23 -1
- package/lib/presence.mjs +226 -0
- package/lib/server.mjs +77 -16
- package/lib/setup.mjs +1 -1
- package/lib/statusline.mjs +67 -0
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -55,6 +55,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
55
55
|
` graphify-sync [path] rebuild the local code graph (graphify) + log an evidence-tier timeline event\n` +
|
|
56
56
|
` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
|
|
57
57
|
` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
|
|
58
|
+
` statusline ambient presence line for the Claude Code statusline (local read only)\n` +
|
|
58
59
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
59
60
|
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
60
61
|
` (no args) run the MCP server (used by your Claude config)\n\n` +
|
|
@@ -159,6 +160,11 @@ if (cmd === 'login') {
|
|
|
159
160
|
process.exitCode = await runHydrate()
|
|
160
161
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
161
162
|
await closeFetch()
|
|
163
|
+
} else if (cmd === 'statusline') {
|
|
164
|
+
// Ambient presence: read one local breadcrumb and print a line. No network, no auth — it redraws
|
|
165
|
+
// constantly, so anything more expensive than a file read does not belong here.
|
|
166
|
+
const { runStatusline } = await import('../lib/statusline.mjs')
|
|
167
|
+
process.exitCode = runStatusline()
|
|
162
168
|
} else if (cmd === 'skills') {
|
|
163
169
|
// Install / repair the managed Cortex skills — bundled + org-published (`skills push` publishes).
|
|
164
170
|
// Org sync is network-fail-soft so the SessionStart hook stays safe offline.
|
package/lib/editors/claude.mjs
CHANGED
|
@@ -22,7 +22,9 @@ export function mergeClaudeMcp(existing, spec, token) {
|
|
|
22
22
|
* CAS-protected + snapshotted (page_revisions → page_history/rollback_page).
|
|
23
23
|
* Deliberately EXCLUDED — these keep prompting: send_imessage (external side effect),
|
|
24
24
|
* set_page_privacy / set_record_privacy / grant_page_access (visibility widening — the
|
|
25
|
-
* unowned-project accessible-default sharp edge, 2026-07-02),
|
|
25
|
+
* unowned-project accessible-default sharp edge, 2026-07-02), replace_variant (destroys a page body
|
|
26
|
+
* and deletes a variant row — the one write here that is not merely CAS-protected but genuinely
|
|
27
|
+
* lossy at the row level, so the prompt IS the guard D1 argued for), rollback_page, decide_page_merge /
|
|
26
28
|
* decide_file_request / request_file / get_file, create_brain / set_active_brain, alias_page,
|
|
27
29
|
* set_writing_style. */
|
|
28
30
|
export const CORTEX_ALLOWED_TOOLS = [
|
|
@@ -38,8 +40,9 @@ export const CORTEX_ALLOWED_TOOLS = [
|
|
|
38
40
|
|
|
39
41
|
/** Merge Cortex's Claude Code hooks into a ~/.claude/settings.json object. Pure + idempotent: drops
|
|
40
42
|
* any prior cortex entry (old token/path/version) from each hook array before appending the current
|
|
41
|
-
* one — capture (Stop), status + skills-repair + snapshot-context (SessionStart),
|
|
42
|
-
* (PreCompact). Commands carry NO inline token (each subcommand
|
|
43
|
+
* one — capture (Stop), status + skills-repair + snapshot-context (SessionStart), hydrate
|
|
44
|
+
* (UserPromptSubmit), precompact (PreCompact). Commands carry NO inline token (each subcommand
|
|
45
|
+
* self-resolves it). Mirrors setup.mjs
|
|
43
46
|
* step 2 exactly; foreign hooks are never touched. Also merges the CORTEX_ALLOWED_TOOLS permission
|
|
44
47
|
* allowlist — additive-only: a user's own allow entries (even extra mcp__cortex__* ones) are never
|
|
45
48
|
* removed, and `deny` is never touched (a user deny always beats our allow). */
|
|
@@ -81,6 +84,27 @@ export function mergeClaudeSettings(existing, spec) {
|
|
|
81
84
|
}
|
|
82
85
|
sgrp.hooks.push({ type: 'command', command: snapshotCmd })
|
|
83
86
|
|
|
87
|
+
// UserPromptSubmit — hydrate (① discovery). Makes the FIRST context load of a session
|
|
88
|
+
// non-discretionary: without it, hydration is a skill the agent may simply forget, and an agent
|
|
89
|
+
// that never reads the wiki never triggers a currency update — it re-derives design that is
|
|
90
|
+
// already authored and already shipped. This is also the surface that prints the presence receipt,
|
|
91
|
+
// so wiring it is what makes a read visible to the person in the chair.
|
|
92
|
+
//
|
|
93
|
+
// The filter is deliberately loose (`cortex-mcp.*hydrate`) so it also reclaims a hand-wired LOCAL
|
|
94
|
+
// pointer of the form `[ -f <worktree>/bin/cortex-mcp.mjs ] && node … hydrate || true`. Those were
|
|
95
|
+
// the recommended way to trial the hook before release, and one on this project's own machine went
|
|
96
|
+
// dead for five days when its worktree was deleted — the `-f` guard fails open, so the hook silently
|
|
97
|
+
// became a no-op with the config still looking wired. Re-running setup should heal that, not skip it.
|
|
98
|
+
s.hooks.UserPromptSubmit = Array.isArray(s.hooks.UserPromptSubmit) ? s.hooks.UserPromptSubmit : []
|
|
99
|
+
const hydrateCmd = `npx -y ${spec} hydrate`
|
|
100
|
+
for (const hg of s.hooks.UserPromptSubmit) {
|
|
101
|
+
if (Array.isArray(hg.hooks)) hg.hooks = hg.hooks.filter((h) => !/cortex-mcp.*hydrate/.test(h.command ?? ''))
|
|
102
|
+
}
|
|
103
|
+
let hgrp = s.hooks.UserPromptSubmit.find((g) => (g.matcher ?? '') === '')
|
|
104
|
+
if (!hgrp) { hgrp = { matcher: '', hooks: [] }; s.hooks.UserPromptSubmit.push(hgrp) }
|
|
105
|
+
hgrp.hooks = hgrp.hooks ?? []
|
|
106
|
+
hgrp.hooks.push({ type: 'command', command: hydrateCmd })
|
|
107
|
+
|
|
84
108
|
// PreCompact — "author now" reminder.
|
|
85
109
|
s.hooks.PreCompact = Array.isArray(s.hooks.PreCompact) ? s.hooks.PreCompact : []
|
|
86
110
|
const precompactCmd = `npx -y ${spec} precompact`
|
|
@@ -148,7 +172,7 @@ export default {
|
|
|
148
172
|
ensureDir(settingsJson)
|
|
149
173
|
writeJson(settingsJson, mergeClaudeSettings(s, spec))
|
|
150
174
|
wrote.push(settingsJson)
|
|
151
|
-
log(` ✓ Capture
|
|
175
|
+
log(` ✓ Capture + hydrate hooks + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
152
176
|
} catch (e) {
|
|
153
177
|
warnings.push(`${settingsJson} wiring skipped: ${e.message}`)
|
|
154
178
|
}
|
package/lib/hydrate.mjs
CHANGED
|
@@ -3,6 +3,8 @@ import { homedir } from 'os'
|
|
|
3
3
|
import { join } from 'path'
|
|
4
4
|
import { fetchCortex, classify, resolveBase, readWiredToken } from './diagnose.mjs'
|
|
5
5
|
import { redactSecrets } from './redact.mjs'
|
|
6
|
+
import { formatReceipt, renderUncounted, subjectOf } from './presence.mjs'
|
|
7
|
+
import { writePresence } from './statusline.mjs'
|
|
6
8
|
|
|
7
9
|
// UserPromptSubmit hook (① discovery): on the FIRST substantive turn of a session, hydrate the model
|
|
8
10
|
// with query-centered Cortex context BEFORE it answers — then never again this session. It closes the
|
|
@@ -118,6 +120,10 @@ export async function runHydrate() {
|
|
|
118
120
|
|
|
119
121
|
const bumpAttempt = () => writeState(sessionId, { ...state, attempts: (state.attempts ?? 0) + 1 })
|
|
120
122
|
|
|
123
|
+
// Measured across the request only. Latency is the cheapest possible proof that a real round-trip
|
|
124
|
+
// happened rather than a cache or a stub — worth showing for that reason alone.
|
|
125
|
+
const startedAt = Date.now()
|
|
126
|
+
|
|
121
127
|
let res
|
|
122
128
|
try {
|
|
123
129
|
res = await fetchCortex(
|
|
@@ -147,11 +153,27 @@ export async function runHydrate() {
|
|
|
147
153
|
return 0
|
|
148
154
|
}
|
|
149
155
|
|
|
150
|
-
const { context } = await res.json().catch(() => ({}))
|
|
156
|
+
const { context, meta } = await res.json().catch(() => ({}))
|
|
151
157
|
if (context && typeof context === 'string' && context.trim()) {
|
|
152
158
|
// stdout from a UserPromptSubmit hook is injected into THIS turn's context, before the model answers.
|
|
159
|
+
// The receipt goes FIRST and deliberately reaches both readers: the human sees that something was
|
|
160
|
+
// served, and the model sees — in the miss case — an explicit instruction not to quietly answer
|
|
161
|
+
// from somewhere else. That is the failure this cue was built for.
|
|
162
|
+
// A server that predates `meta` still served real context — fall back to a countless presence line
|
|
163
|
+
// rather than to formatReceipt, whose empty-pages branch would report a gap that was never measured.
|
|
164
|
+
const receipt =
|
|
165
|
+
meta && typeof meta === 'object' && !Array.isArray(meta)
|
|
166
|
+
? formatReceipt({ subject: subjectOf(prompt), ...meta, ms: Date.now() - startedAt })
|
|
167
|
+
: renderUncounted()
|
|
168
|
+
if (receipt) process.stdout.write(receipt + '\n\n')
|
|
153
169
|
process.stdout.write(context.trimEnd() + '\n')
|
|
154
170
|
writeState(sessionId, { done: true, at: new Date().toISOString() })
|
|
171
|
+
// Leave the breadcrumb the statusline reads. Only ever what this machine just observed, so the
|
|
172
|
+
// ambient line can never assert something the org did not actually serve.
|
|
173
|
+
writePresence({
|
|
174
|
+
pages: Array.isArray(meta?.pages) ? meta.pages : [],
|
|
175
|
+
kind: meta && typeof meta === 'object' && !Array.isArray(meta) ? 'hit' : 'uncounted',
|
|
176
|
+
})
|
|
155
177
|
} else {
|
|
156
178
|
bumpAttempt() // empty body — treat as a miss, allow one bounded retry next turn
|
|
157
179
|
}
|
package/lib/presence.mjs
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
// The presence cue: the one place Agnoclast tells a human it is there.
|
|
2
|
+
//
|
|
3
|
+
// Every cue this product shipped before now reported the WRITE path — "connected", "logged
|
|
4
|
+
// context", "you owe the org a page". Nothing ever announced a READ, which is the only moment
|
|
5
|
+
// the thing is actually earning its keep. So the value was invisible by construction, and the
|
|
6
|
+
// first outside reviewer's blocker was, verbatim, "I don't fully understand how it's going to
|
|
7
|
+
// help yet."
|
|
8
|
+
//
|
|
9
|
+
// TWO RULES GOVERN THIS FILE.
|
|
10
|
+
//
|
|
11
|
+
// (1) THE MISS IS LOUDER THAN THE HIT. A cue that only lights up on success manufactures
|
|
12
|
+
// confidence, which is this codebase's documented recurring failure. The gate-6 miss on
|
|
13
|
+
// 2026-07-29 was exactly that shape: the agent hit the edge of the wiki, silently read the
|
|
14
|
+
// repo instead, and the answer still came back sounding authoritative. `renderMiss` exists
|
|
15
|
+
// to make that impossible to do quietly — and because hook stdout is injected into the
|
|
16
|
+
// model's context, it addresses the AGENT as well as the human. It is a guardrail wearing a
|
|
17
|
+
// receipt's clothes.
|
|
18
|
+
//
|
|
19
|
+
// (2) NEVER INVENT A NUMBER. Every field here is either supplied by the server or omitted.
|
|
20
|
+
// There is no inference, no "probably", no derived-looking count that came from parsing
|
|
21
|
+
// prose. A receipt that guesses is worse than no receipt, because it is a confident-looking
|
|
22
|
+
// claim about the one system whose whole job is not making those.
|
|
23
|
+
//
|
|
24
|
+
// Pure and side-effect free on purpose: no network, no fs, no clock. Callers pass everything in,
|
|
25
|
+
// which is what makes the three states unit-testable without a live org.
|
|
26
|
+
|
|
27
|
+
// Deliberately plain text, no ANSI. Hook stdout is both rendered to the user AND injected into
|
|
28
|
+
// model context; escape codes are noise in the second channel and can render literally in the
|
|
29
|
+
// first depending on the surface. Glyph weight carries the state instead of color.
|
|
30
|
+
const MARK_HIT = '◆'
|
|
31
|
+
const MARK_MISS = '◇'
|
|
32
|
+
const MARK_STALE = '◈'
|
|
33
|
+
|
|
34
|
+
// The blob. Claude's is a pulsing eight-spoke asterisk, so these are the same glyph at rising and
|
|
35
|
+
// falling stroke weight — read in sequence it breathes rather than spins. A spinner says "busy";
|
|
36
|
+
// a breath says "here", which is the thing we are actually trying to convey.
|
|
37
|
+
export const BLOB_FRAMES = ['✳', '✷', '✸', '✹', '✺', '✹', '✸', '✷']
|
|
38
|
+
|
|
39
|
+
export function blobFrame(tick) {
|
|
40
|
+
return BLOB_FRAMES[((tick % BLOB_FRAMES.length) + BLOB_FRAMES.length) % BLOB_FRAMES.length]
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const WIDTH = 92
|
|
44
|
+
|
|
45
|
+
// Join names to fit one line, and say how many were dropped rather than trailing off. "+3 more" is
|
|
46
|
+
// a fact; a bare ellipsis is a shrug.
|
|
47
|
+
export function joinFit(names, budget) {
|
|
48
|
+
const list = (names ?? []).filter((n) => typeof n === 'string' && n.trim()).map((n) => n.trim())
|
|
49
|
+
if (list.length === 0) return ''
|
|
50
|
+
const out = []
|
|
51
|
+
let used = 0
|
|
52
|
+
for (const name of list) {
|
|
53
|
+
const cost = used === 0 ? name.length : name.length + 3
|
|
54
|
+
const remaining = list.length - out.length - 1
|
|
55
|
+
// Reserve room for the "+N more" tail so the fit never depends on it being empty.
|
|
56
|
+
const tail = remaining > 0 ? ` +${remaining} more`.length : 0
|
|
57
|
+
if (used + cost + tail > budget && out.length > 0) break
|
|
58
|
+
out.push(name)
|
|
59
|
+
used += cost
|
|
60
|
+
}
|
|
61
|
+
const dropped = list.length - out.length
|
|
62
|
+
return dropped > 0 ? `${out.join(' · ')} +${dropped} more` : out.join(' · ')
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function line(text) {
|
|
66
|
+
return ` ${text}`
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// Shorten a question to a nameable subject for the miss line. Kept local to the client — the server
|
|
70
|
+
// is never asked to echo the query back, so this adds no new data flow and nothing is stored. That
|
|
71
|
+
// matters: the raw query is customer content, and the standing position is that insight has to be
|
|
72
|
+
// derivable without reading it.
|
|
73
|
+
export function subjectOf(prompt, max = 48) {
|
|
74
|
+
const p = (prompt ?? '').trim().replace(/\s+/g, ' ')
|
|
75
|
+
if (!p) return ''
|
|
76
|
+
if (p.length <= max) return p
|
|
77
|
+
const cut = p.slice(0, max)
|
|
78
|
+
const lastSpace = cut.lastIndexOf(' ')
|
|
79
|
+
return (lastSpace > max * 0.6 ? cut.slice(0, lastSpace) : cut).trimEnd() + '…'
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// A read that landed. `unasked` is the Jarvis beat — something the query did not name that the
|
|
83
|
+
// graph surfaced anyway. It is the single most persuasive line this product can print, so it is
|
|
84
|
+
// also the one most worth refusing to fake: absent means absent.
|
|
85
|
+
export function renderHit({ pages = [], ms, unasked } = {}) {
|
|
86
|
+
const count = pages.length
|
|
87
|
+
const head = [`${MARK_HIT} agnoclast · read ${count} page${count === 1 ? '' : 's'}`]
|
|
88
|
+
if (Number.isFinite(ms)) head.push(`${Math.round(ms)}ms`)
|
|
89
|
+
const out = [head.join(' · ')]
|
|
90
|
+
const names = joinFit(pages, WIDTH - 2)
|
|
91
|
+
if (names) out.push(line(names))
|
|
92
|
+
if (unasked) out.push(line(`↳ unasked: ${unasked}`))
|
|
93
|
+
return out.join('\n')
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// A read that found nothing. This is the load-bearing state.
|
|
97
|
+
//
|
|
98
|
+
// `subject` is what was looked for, `unabsorbed` is how many records have landed on the node since
|
|
99
|
+
// its page was last edited — high-and-recent means someone is actively working on a thing the wiki
|
|
100
|
+
// has not caught up to, which is a far more useful thing to tell a human than "no results".
|
|
101
|
+
//
|
|
102
|
+
// The closing directive is aimed at the model. It is the counter to the documented failure where
|
|
103
|
+
// the agent reached for the repo and reported nothing.
|
|
104
|
+
//
|
|
105
|
+
// ⚠ IT SAYS "SERVED", NOT "AUTHORED", AND THE DISTINCTION IS THE WHOLE POINT. An earlier revision
|
|
106
|
+
// read "nothing authored on X" — an assertion about the CORPUS that this client cannot possibly
|
|
107
|
+
// verify, and which is provably false today for two independent reasons:
|
|
108
|
+
// (1) #407 — `brainPages` is fetched inside the block pinned to the anchor brain
|
|
109
|
+
// (`runAsCloud(authId, fn, viewer.org_id)`), so a caller active in N brains only ever sees
|
|
110
|
+
// pages from one. Measured 2026-08-05: that is 5 brains for the operator, 2 each for two
|
|
111
|
+
// other people. Retrieval itself DOES span brains (`runAcrossBrains`), so the epicenter can
|
|
112
|
+
// be correct while the page lookup silently finds nothing.
|
|
113
|
+
// (2) `CORTEX_BRAIN_DIGEST` — when that flag is not 'on', `brainPages` is `[]` unconditionally.
|
|
114
|
+
// Either way `pages: []` means "none reached me", never "none exist". A receipt built on the rule
|
|
115
|
+
// NEVER INVENT A NUMBER had, in its single most load-bearing line, been inventing a fact.
|
|
116
|
+
export function renderMiss({ subject, unabsorbed } = {}) {
|
|
117
|
+
const out = [`${MARK_MISS} agnoclast · read 0 pages`]
|
|
118
|
+
const what = subject ? `nothing served for "${subject}"` : 'nothing served for this'
|
|
119
|
+
const tail =
|
|
120
|
+
Number.isFinite(unabsorbed) && unabsorbed > 0
|
|
121
|
+
? ` — ${unabsorbed} unabsorbed record${unabsorbed === 1 ? '' : 's'} on this node`
|
|
122
|
+
: ''
|
|
123
|
+
out.push(line(what + tail))
|
|
124
|
+
out.push(line('a gap in what was SERVED, not proof none exists — say so rather than substituting another source'))
|
|
125
|
+
return out.join('\n')
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// A page changed. Surfacing `why` and `rev` is what makes the write feel accountable instead of
|
|
129
|
+
// mysterious: the reader can see the reason and knows it is reversible without being told.
|
|
130
|
+
export function renderWrite({ section, page, why, rev } = {}) {
|
|
131
|
+
const target = section ? `wrote §${section} → ${page ?? 'page'}` : `wrote ${page ?? 'page'}`
|
|
132
|
+
const out = [`${MARK_HIT} agnoclast · ${target}`]
|
|
133
|
+
const detail = []
|
|
134
|
+
if (why) detail.push(`why: ${why}`)
|
|
135
|
+
if (rev != null) detail.push(`rev ${rev}`)
|
|
136
|
+
detail.push('reversible')
|
|
137
|
+
out.push(line(detail.join(' · ')))
|
|
138
|
+
return out.join('\n')
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// A read that landed on a page nothing has refreshed in a while. Distinct from a miss: the answer
|
|
142
|
+
// is real, but the reader should weigh it knowing new facts have arrived since. Time alone does not
|
|
143
|
+
// make a page wrong — unabsorbed facts do, which is why the count is the trigger, not the age.
|
|
144
|
+
export function renderStale({ pages = [], unabsorbed, ms } = {}) {
|
|
145
|
+
const count = pages.length
|
|
146
|
+
const head = [`${MARK_STALE} agnoclast · read ${count} page${count === 1 ? '' : 's'} · stale`]
|
|
147
|
+
if (Number.isFinite(ms)) head.push(`${Math.round(ms)}ms`)
|
|
148
|
+
const out = [head.join(' · ')]
|
|
149
|
+
const names = joinFit(pages, WIDTH - 2)
|
|
150
|
+
if (names) out.push(line(names))
|
|
151
|
+
if (Number.isFinite(unabsorbed) && unabsorbed > 0) {
|
|
152
|
+
out.push(line(`${unabsorbed} record${unabsorbed === 1 ? '' : 's'} landed since the last edit — treat as possibly behind`))
|
|
153
|
+
}
|
|
154
|
+
return out.join('\n')
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// A server too old to report counts. Context WAS served, so claiming a miss here would be a false
|
|
158
|
+
// negative — every bit as dishonest as a false positive, and far easier to ship by accident because
|
|
159
|
+
// `{...undefined}` silently produces an empty object that looks like a measured zero. "The server
|
|
160
|
+
// said zero pages" and "the server did not say" are different facts and must never collapse into one
|
|
161
|
+
// line. So: presence without arithmetic.
|
|
162
|
+
export function renderUncounted() {
|
|
163
|
+
return `${MARK_HIT} agnoclast · context loaded`
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// ── Ambient state ────────────────────────────────────────────────────────────────────────────────
|
|
167
|
+
// The receipt is the moment; the blob is the presence. The statusline cannot call the org on every
|
|
168
|
+
// render (it redraws constantly, and this product is bought or rejected on per-operation cost), so
|
|
169
|
+
// hydration leaves a small breadcrumb behind and the statusline only ever reads the file.
|
|
170
|
+
//
|
|
171
|
+
// Deliberately NOT a cache of org data: it holds what THIS machine did and when, which is the one
|
|
172
|
+
// thing it can state without a round-trip and without going stale behind the reader's back.
|
|
173
|
+
|
|
174
|
+
const PRESENCE_FILE = 'presence.json'
|
|
175
|
+
|
|
176
|
+
export function presencePath(home) {
|
|
177
|
+
return joinPath(home, '.cortex', PRESENCE_FILE)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function joinPath(...parts) {
|
|
181
|
+
return parts.join('/').replace(/\/+/g, '/')
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// Pure: decide what the statusline should say, given the breadcrumb and how old it is. Extracted from
|
|
185
|
+
// all I/O so the decay boundaries are testable without touching a clock or a disk.
|
|
186
|
+
export function describePresence(state, nowMs, idleAfterMs = 45 * 60 * 1000) {
|
|
187
|
+
if (!state || typeof state !== 'object' || Array.isArray(state)) return { status: 'unwired', text: 'agnoclast · not wired' }
|
|
188
|
+
const at = Date.parse(state.at ?? '')
|
|
189
|
+
if (!Number.isFinite(at)) return { status: 'unwired', text: 'agnoclast · not wired' }
|
|
190
|
+
|
|
191
|
+
const ageMs = nowMs - at
|
|
192
|
+
if (ageMs > idleAfterMs) return { status: 'idle', text: 'agnoclast · idle' }
|
|
193
|
+
|
|
194
|
+
// A server too old to report counts served real context with an empty `pages`. Reading that as a
|
|
195
|
+
// miss would republish, in the ambient line, exactly the false negative renderUncounted exists to
|
|
196
|
+
// prevent — caught here by a live run against prod, which is the only place the two differ.
|
|
197
|
+
if (state.kind === 'uncounted') return { status: 'live', text: 'agnoclast · context loaded' }
|
|
198
|
+
|
|
199
|
+
const pages = Array.isArray(state.pages) ? state.pages.length : 0
|
|
200
|
+
if (state.kind === 'miss' || pages === 0) return { status: 'miss', text: 'agnoclast · no pages served' }
|
|
201
|
+
return { status: 'live', text: `agnoclast · ${pages} page${pages === 1 ? '' : 's'}` }
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// The rendered statusline. `tick` advances the blob so it breathes as the conversation moves — the
|
|
205
|
+
// motion is tied to real activity rather than to a timer, which is both cheaper and more honest: it
|
|
206
|
+
// stops moving precisely when nothing is happening.
|
|
207
|
+
export function renderStatusline(state, nowMs, tick = 0) {
|
|
208
|
+
const { status, text } = describePresence(state, nowMs)
|
|
209
|
+
const glyph = status === 'live' ? blobFrame(tick) : status === 'miss' ? MARK_MISS : status === 'idle' ? '·' : '○'
|
|
210
|
+
return `${glyph} ${text}`
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Single entry point. Chooses the state from server-supplied meta ONLY — an absent field routes to
|
|
214
|
+
// the honest branch rather than an optimistic one. Returns '' when there is nothing truthful to
|
|
215
|
+
// say, because a receipt with nothing behind it is the exact failure this file exists to prevent.
|
|
216
|
+
export function formatReceipt(meta) {
|
|
217
|
+
// Arrays are objects and truthy, so a malformed payload would otherwise fall through and render a
|
|
218
|
+
// MISS — reporting a gap that was never measured. Junk in must produce silence, not a finding.
|
|
219
|
+
if (!meta || typeof meta !== 'object' || Array.isArray(meta)) return ''
|
|
220
|
+
if (meta.kind === 'write') return renderWrite(meta)
|
|
221
|
+
|
|
222
|
+
const pages = Array.isArray(meta.pages) ? meta.pages.filter(Boolean) : []
|
|
223
|
+
if (pages.length === 0) return renderMiss(meta)
|
|
224
|
+
if (Number.isFinite(meta.unabsorbed) && meta.unabsorbed > 0) return renderStale({ ...meta, pages })
|
|
225
|
+
return renderHit({ ...meta, pages })
|
|
226
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -934,8 +934,8 @@ export async function runServer(version) {
|
|
|
934
934
|
server.registerTool(
|
|
935
935
|
'my_brains',
|
|
936
936
|
{
|
|
937
|
-
title: 'List your brains
|
|
938
|
-
description: 'List the brains (orgs/workspaces) you belong to. Reads span ALL of them, and
|
|
937
|
+
title: 'List your brains and what each one holds',
|
|
938
|
+
description: 'List the brains (orgs/workspaces) you belong to, with what each one CONTAINS — page count and sample titles. Reads span ALL of them, and an edit to an EXISTING page routes to the brain holding that page, so you do NOT need to check anything before editing. There is no active brain to set (ADR-0022 deleted the write pointer). Use this when creating a page that exists in NO brain yet: pick by relevance from the contents shown here and pass it as `brain`, because a caller with more than one brain must name one.',
|
|
939
939
|
inputSchema: {},
|
|
940
940
|
},
|
|
941
941
|
async () => {
|
|
@@ -949,27 +949,36 @@ export async function runServer(version) {
|
|
|
949
949
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
950
950
|
return toolError(`Could not list brains: ${d.message}`)
|
|
951
951
|
}
|
|
952
|
-
const { brains
|
|
952
|
+
const { brains } = await res.json()
|
|
953
953
|
if (!brains?.length) return { content: [{ type: 'text', text: 'You have no brains.' }] }
|
|
954
954
|
// Show CONTENTS, not just the name (ADR-0020 §7 corollary): a brain named TTO that holds 4
|
|
955
955
|
// pages while the real TTO inventory sits in Personal reads as correct and is exactly
|
|
956
956
|
// backwards. The page count and a couple of titles make that visible at the moment of choosing.
|
|
957
|
+
//
|
|
958
|
+
// ⚠ ADR-0022 CLEANUP. This handler used to destructure activeIsExplicit / activeSource /
|
|
959
|
+
// sessionOrgId / accountOrgId and render a ▶ marker from b.isActive. /api/brains stopped
|
|
960
|
+
// returning ALL of those when the write pointer was deleted, and the client kept reading them.
|
|
961
|
+
// The failure was SILENT and confidently wrong rather than an error: `activeIsExplicit` arrived
|
|
962
|
+
// `undefined`, `!undefined` is `true`, so the branch meaning "you have one brain" printed
|
|
963
|
+
// unconditionally — telling a six-brain account it had one. The ▶ legend likewise advertised a
|
|
964
|
+
// marker that could no longer appear, because b.isActive was undefined for every row.
|
|
965
|
+
//
|
|
966
|
+
// The lesson worth keeping: a client reading a field the server no longer sends does not fail,
|
|
967
|
+
// it narrates. Every branch here asserted a FACT ("you have one brain") that was only ever an
|
|
968
|
+
// inference from a DIFFERENT condition (no explicit pointer set). Deleting the pointer decoupled
|
|
969
|
+
// the two and left the assertion running. Prefer deleting a stale branch to correcting it — a
|
|
970
|
+
// corrected branch still reads state that no longer exists.
|
|
957
971
|
const lines = brains.map((b) => {
|
|
958
972
|
const inv = b.pageCount === 0
|
|
959
973
|
? 'EMPTY'
|
|
960
974
|
: `${b.pageCount} page${b.pageCount === 1 ? '' : 's'}${b.sampleTitles?.length ? `: ${b.sampleTitles.slice(0, 2).join(', ')}` : ''}`
|
|
961
|
-
return
|
|
975
|
+
return ` ${b.name} (${b.role}${b.status !== 'active' ? `, ${b.status}` : ''}) — ${inv} [${b.orgId}]`
|
|
962
976
|
})
|
|
963
|
-
//
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
? `\n(this SESSION's override — your other sessions are unaffected${accountOrgId && accountOrgId !== sessionOrgId ? `; clearing it falls back to ${accountOrgId}` : ''}.)`
|
|
969
|
-
: activeSource === 'account'
|
|
970
|
-
? '\n(account-wide pointer — shared by every session that has not set its own.)'
|
|
971
|
-
: ''
|
|
972
|
-
return { content: [{ type: 'text', text: `Your brains (▶ = default for NEW pages only — edits to existing pages route themselves):\n${lines.join('\n')}${note}` }] }
|
|
977
|
+
// Say how writes actually route now, since that is the question this list gets opened to answer.
|
|
978
|
+
const header = brains.length === 1
|
|
979
|
+
? 'Your brain:'
|
|
980
|
+
: 'Your brains (reads span all of them; an edit routes to the brain holding the page; creating a NEW page takes an explicit `brain`):'
|
|
981
|
+
return { content: [{ type: 'text', text: `${header}\n${lines.join('\n')}` }] }
|
|
973
982
|
},
|
|
974
983
|
)
|
|
975
984
|
|
|
@@ -1046,14 +1055,19 @@ export async function runServer(version) {
|
|
|
1046
1055
|
}
|
|
1047
1056
|
target = byId ?? byName[0]
|
|
1048
1057
|
} else {
|
|
1049
|
-
|
|
1058
|
+
// ADR-0022: there is no active brain to fall back to — /api/brains stopped sending isActive
|
|
1059
|
+
// when the write pointer was deleted, so the old `brains.find((b) => b.isActive)` here could
|
|
1060
|
+
// only ever return undefined. Behaviour was already correct (undefined falls through to the
|
|
1061
|
+
// error below, which is the right contract), but it was correct by accident, reading a field
|
|
1062
|
+
// that no longer exists. Made explicit: sole membership resolves itself, anything else asks.
|
|
1063
|
+
target = brains.length === 1 ? brains[0] : undefined
|
|
1050
1064
|
}
|
|
1051
1065
|
if (!target) {
|
|
1052
1066
|
const names = brains.map((b) => `${b.name} [${b.orgId}]`).join(', ')
|
|
1053
1067
|
return toolError(
|
|
1054
1068
|
wanted
|
|
1055
1069
|
? `No brain called "${brain}". You belong to: ${names}.`
|
|
1056
|
-
: `Could not tell which brain you mean — you belong to ${brains.length} and
|
|
1070
|
+
: `Could not tell which brain you mean — you belong to ${brains.length} and there is no default. Pass one of: ${names}.`,
|
|
1057
1071
|
)
|
|
1058
1072
|
}
|
|
1059
1073
|
|
|
@@ -1455,6 +1469,53 @@ export async function runServer(version) {
|
|
|
1455
1469
|
},
|
|
1456
1470
|
)
|
|
1457
1471
|
|
|
1472
|
+
server.registerTool(
|
|
1473
|
+
'replace_variant',
|
|
1474
|
+
{
|
|
1475
|
+
title: 'Move one tier variant\'s body into another, collapsing the node to one page',
|
|
1476
|
+
description: 'DESTRUCTIVE, and the only sanctioned way to fix a FORKED page. When one node exists at two tiers with different bodies, this moves the SOURCE variant\'s body into the TARGET variant\'s slot and DELETES the source, leaving the node with a single page. The source\'s body WINS — this is not `absorb`, where the target survives; in a fork repair the target is usually the damaged page, so mirroring absorb would keep the damage and delete the good copy. Sections are copied as ROWS, never re-derived from text: re-deriving a body from context is exactly what destroyed 258 sections on 2026-07-18 while sincerely reporting "copied verbatim". BEFORE CALLING: read_page the TARGET and pass its version as target_version — it proves you know which body is about to be overwritten, and a stale or guessed value is rejected rather than silently accepted. If the target tier has NO page, do not use this: the slot is free, so set_page_privacy moves the page there cheaply. Both prior bodies are retained in page history and the operation is reversible via page_history/rollback_page. Owner or editor on BOTH variants; an OWNERLESS target may be overwritten only by an org admin.',
|
|
1477
|
+
inputSchema: {
|
|
1478
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1479
|
+
name: z.string().optional().describe('the exact page name (or pass `ref` instead — one of the two is required)'),
|
|
1480
|
+
ref: z.string().optional().describe('the page\'s stable id, printed as `ref:` by read_page. PREFER THIS over name when you have it: a ref is unique across brains, so it addresses exactly one page and never needs a brain to disambiguate it.'),
|
|
1481
|
+
source_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the variant whose BODY WINS and survives. This variant\'s row is then deleted.'),
|
|
1482
|
+
target_tier: z.enum(['accessible', 'scoped', 'confidential']).describe('the OCCUPIED slot the body lands in. This variant\'s current body is DESTROYED (snapshotted to page history first). The surviving page sits at this tier.'),
|
|
1483
|
+
target_version: z.string().describe('REQUIRED — the TARGET page\'s version, from read_page. Proves you know what is being overwritten. Do not retry a rejection blindly; re-read the target and confirm you are replacing what you think you are.'),
|
|
1484
|
+
brain: z.string().optional().describe('only when the same page name exists in more than one of your brains'),
|
|
1485
|
+
},
|
|
1486
|
+
},
|
|
1487
|
+
async ({ kind, name, ref, source_tier, target_tier, target_version, brain }) => {
|
|
1488
|
+
let res
|
|
1489
|
+
try {
|
|
1490
|
+
res = await fetchCortex(`${BASE}/api/brain/replace-variant`, {
|
|
1491
|
+
method: 'POST',
|
|
1492
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1493
|
+
body: JSON.stringify({
|
|
1494
|
+
kind, ...(name ? { name } : {}), ...(ref ? { ref } : {}),
|
|
1495
|
+
source_tier, target_tier, target_version, ...(brain ? { brain } : {}),
|
|
1496
|
+
}),
|
|
1497
|
+
})
|
|
1498
|
+
} catch (e) {
|
|
1499
|
+
return toolError(`Could not replace variant: ${e.message}`)
|
|
1500
|
+
}
|
|
1501
|
+
const out = await res.json().catch(() => null)
|
|
1502
|
+
if (!res.ok) {
|
|
1503
|
+
// The engine's rejections carry the remedy in their text (free slot → use set_page_privacy;
|
|
1504
|
+
// hash mismatch → re-read, do not retry blindly). Surface it verbatim rather than paraphrasing.
|
|
1505
|
+
if (out?.error) return toolError(`Could not replace variant: ${out.error}`)
|
|
1506
|
+
const d = classify(res.status, res.headers.get('content-type'), '', res.headers.get('x-vercel-id'))
|
|
1507
|
+
return toolError(`Could not replace variant: ${d.message}`)
|
|
1508
|
+
}
|
|
1509
|
+
if (!out) return { content: [{ type: 'text', text: 'Replace reported success, but the server returned no body — re-read the page before assuming it landed.' }] }
|
|
1510
|
+
return {
|
|
1511
|
+
content: [{
|
|
1512
|
+
type: 'text',
|
|
1513
|
+
text: `Done — "${out.moved.title}": the ${out.moved.from} body now occupies the ${out.moved.to} page (${out.sections} section${out.sections === 1 ? '' : 's'}), and the ${out.moved.from} variant was removed. The node now has ONE variant. ${out.note}`,
|
|
1514
|
+
}],
|
|
1515
|
+
}
|
|
1516
|
+
},
|
|
1517
|
+
)
|
|
1518
|
+
|
|
1458
1519
|
server.registerTool(
|
|
1459
1520
|
'grant_page_access',
|
|
1460
1521
|
{
|
package/lib/setup.mjs
CHANGED
|
@@ -134,7 +134,7 @@ export async function runSetup(argv, version) {
|
|
|
134
134
|
|
|
135
135
|
ensureDir(settingsJson)
|
|
136
136
|
writeFileSync(settingsJson, JSON.stringify(s, null, 2))
|
|
137
|
-
log(` ✓ Capture
|
|
137
|
+
log(` ✓ Capture + hydrate hooks + status line → ${settingsJson}${bak ? ' (backup saved)' : ''}`)
|
|
138
138
|
} catch (e) {
|
|
139
139
|
process.stderr.write(` ✗ failed to update ${settingsJson}: ${e.message}\n`)
|
|
140
140
|
process.exit(1)
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { readFileSync, existsSync, mkdirSync, writeFileSync } from 'fs'
|
|
2
|
+
import { homedir } from 'os'
|
|
3
|
+
import { join } from 'path'
|
|
4
|
+
import { renderStatusline } from './presence.mjs'
|
|
5
|
+
|
|
6
|
+
// `cortex-mcp statusline` — the ambient half of the presence cue.
|
|
7
|
+
//
|
|
8
|
+
// Claude Code runs a statusline command on each redraw and prints its stdout under the prompt, so
|
|
9
|
+
// this is the only place Agnoclast can sit permanently in view. It must therefore be the cheapest
|
|
10
|
+
// thing in the product: no network, no auth, no org call. It reads one small local file that
|
|
11
|
+
// hydration wrote and renders a line.
|
|
12
|
+
//
|
|
13
|
+
// It also has to be honest when nothing is happening. An indicator that looks alive while the system
|
|
14
|
+
// is disconnected is worse than no indicator — that is precisely how the hook on this machine sat
|
|
15
|
+
// dead for five days without anyone noticing. Hence the decay to `idle`, and `not wired` when there
|
|
16
|
+
// is no breadcrumb at all.
|
|
17
|
+
|
|
18
|
+
const STATE_DIR = () => join(homedir(), '.cortex')
|
|
19
|
+
const STATE_FILE = () => join(STATE_DIR(), 'presence.json')
|
|
20
|
+
const TICK_FILE = () => join(STATE_DIR(), 'presence-tick')
|
|
21
|
+
|
|
22
|
+
function readState() {
|
|
23
|
+
try {
|
|
24
|
+
return JSON.parse(readFileSync(STATE_FILE(), 'utf8'))
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
// The blob advances one frame per redraw. Persisting the counter is what makes it BREATHE across
|
|
31
|
+
// renders rather than freeze on a single glyph — without it every render would draw frame 0.
|
|
32
|
+
function nextTick() {
|
|
33
|
+
try {
|
|
34
|
+
const prev = Number.parseInt(readFileSync(TICK_FILE(), 'utf8'), 10)
|
|
35
|
+
const tick = (Number.isFinite(prev) ? prev + 1 : 0) % 1_000_000
|
|
36
|
+
writeFileSync(TICK_FILE(), String(tick))
|
|
37
|
+
return tick
|
|
38
|
+
} catch {
|
|
39
|
+
try {
|
|
40
|
+
if (!existsSync(STATE_DIR())) mkdirSync(STATE_DIR(), { recursive: true })
|
|
41
|
+
writeFileSync(TICK_FILE(), '0')
|
|
42
|
+
} catch {
|
|
43
|
+
/* ignore — the statusline must never fail loudly */
|
|
44
|
+
}
|
|
45
|
+
return 0
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// Record what hydration served, for the statusline to read. Best-effort by design: a failure here
|
|
50
|
+
// must never affect the turn it was observing.
|
|
51
|
+
export function writePresence({ pages = [], kind = 'hit', at = new Date().toISOString() } = {}) {
|
|
52
|
+
try {
|
|
53
|
+
if (!existsSync(STATE_DIR())) mkdirSync(STATE_DIR(), { recursive: true })
|
|
54
|
+
writeFileSync(STATE_FILE(), JSON.stringify({ pages, kind, at }))
|
|
55
|
+
} catch {
|
|
56
|
+
/* best-effort */
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function runStatusline() {
|
|
61
|
+
try {
|
|
62
|
+
process.stdout.write(renderStatusline(readState(), Date.now(), nextTick()) + '\n')
|
|
63
|
+
} catch {
|
|
64
|
+
// Print nothing rather than an error: a broken statusline should vanish, not shout.
|
|
65
|
+
}
|
|
66
|
+
return 0
|
|
67
|
+
}
|