@theronap/agnoclast-mcp 0.9.96

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.
Files changed (44) hide show
  1. package/README.md +47 -0
  2. package/bin/cortex-mcp.mjs +223 -0
  3. package/lib/capture.mjs +470 -0
  4. package/lib/code_graph_cli.mjs +59 -0
  5. package/lib/context_log.mjs +92 -0
  6. package/lib/diagnose.mjs +360 -0
  7. package/lib/docs_scan.mjs +171 -0
  8. package/lib/doctor.mjs +117 -0
  9. package/lib/edge_extract.mjs +156 -0
  10. package/lib/editors/_fsutil.mjs +31 -0
  11. package/lib/editors/antigravity.mjs +130 -0
  12. package/lib/editors/claude.mjs +202 -0
  13. package/lib/editors/codex.mjs +111 -0
  14. package/lib/editors/cursor.mjs +77 -0
  15. package/lib/editors/index.mjs +42 -0
  16. package/lib/extract_typed.mjs +68 -0
  17. package/lib/graphify_sync.mjs +134 -0
  18. package/lib/grep_cli.mjs +82 -0
  19. package/lib/hydrate.mjs +181 -0
  20. package/lib/imessage_send.mjs +88 -0
  21. package/lib/ingest_folder.mjs +170 -0
  22. package/lib/install.mjs +163 -0
  23. package/lib/login.mjs +148 -0
  24. package/lib/managed.mjs +49 -0
  25. package/lib/migrate_key.mjs +139 -0
  26. package/lib/presence.mjs +226 -0
  27. package/lib/publish_targets.mjs +51 -0
  28. package/lib/red_link_triage.mjs +37 -0
  29. package/lib/redact.mjs +40 -0
  30. package/lib/rename_notice.mjs +31 -0
  31. package/lib/resolve.mjs +153 -0
  32. package/lib/server.mjs +2986 -0
  33. package/lib/session_key.mjs +37 -0
  34. package/lib/setup.mjs +215 -0
  35. package/lib/skills.mjs +374 -0
  36. package/lib/statusline.mjs +67 -0
  37. package/lib/uninstall.mjs +237 -0
  38. package/lib/use_brain.mjs +82 -0
  39. package/lib/with_token.mjs +66 -0
  40. package/package.json +36 -0
  41. package/skills/author-docs/SKILL.md +74 -0
  42. package/skills/context/SKILL.md +25 -0
  43. package/skills/log/SKILL.md +114 -0
  44. package/skills/walkthrough/SKILL.md +189 -0
@@ -0,0 +1,77 @@
1
+ // Editor adapter: Cursor. P0-b task 4 (+ review fixes).
2
+ // ~/.cursor/mcp.json uses the same mcpServers.cortex shape as Claude (minus the `type` field the real
3
+ // Cursor config omits), so wiring Cursor is a pure JSON merge (mergeCursorMcp).
4
+ import { homedir } from 'node:os'
5
+ import { existsSync, readFileSync, writeFileSync, mkdirSync } from 'node:fs'
6
+ import { join } from 'node:path'
7
+ import { backupFile } from './_fsutil.mjs'
8
+
9
+ /** Merge the Agnoclast MCP server into a Cursor mcp.json object. Pure + idempotent: sets only the
10
+ * `cortex` entry, preserves every other server. `spec` is the package@dist-tag STRING — the same
11
+ * convention claude/codex/setup.mjs use, so one install driver can pass a single spec to every adapter. */
12
+ export function mergeCursorMcp(existing, spec, token) {
13
+ const cfg = existing && typeof existing === 'object' ? { ...existing } : {}
14
+ cfg.mcpServers = cfg.mcpServers && typeof cfg.mcpServers === 'object' ? { ...cfg.mcpServers } : {}
15
+ cfg.mcpServers.cortex = { command: 'npx', args: ['-y', spec], env: { CORTEX_TOKEN: token } }
16
+ return cfg
17
+ }
18
+
19
+ /** @type {import('./index.mjs').EditorAdapter} */
20
+ export default {
21
+ id: 'cursor',
22
+ displayName: 'Cursor',
23
+
24
+ detect({ home = homedir(), exists = existsSync } = {}) {
25
+ return exists(join(home, '.cursor'))
26
+ },
27
+
28
+ // Today Cursor is capture-only: the hook buffers turns and POSTs to /api/ingest, but does not
29
+ // inject sibling-session context at prompt time. promptTimeInjection stays false until the
30
+ // Pillar-1 Cursor inject hook is built (a named gap in cross-app-session-visibility-spec.md).
31
+ capabilities: {
32
+ promptTimeInjection: false,
33
+ sessionStart: false,
34
+ captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
35
+ skillDir: null, // Cursor rules/commands dir — revisit in Pillar 2
36
+ docTrigger: 'hook',
37
+ },
38
+
39
+ // Non-fatal by contract: a Cursor failure (parse OR IO) must never abort the other editors' wiring.
40
+ async wire({ home = homedir(), token, spec, log = () => {} } = {}) {
41
+ const wrote = [], skipped = [], warnings = []
42
+ if (!token) { warnings.push('no token — skipped Cursor'); return { wrote, skipped, warnings } }
43
+
44
+ const cursorDir = join(home, '.cursor')
45
+ const mcpPath = join(cursorDir, 'mcp.json')
46
+ try {
47
+ let existing = {}
48
+ if (existsSync(mcpPath)) {
49
+ try {
50
+ existing = JSON.parse(readFileSync(mcpPath, 'utf8'))
51
+ } catch {
52
+ // Data-loss guard: a malformed mcp.json may still hold the user's other MCP servers we can't
53
+ // safely parse. Do NOT overwrite it with a cortex-only config — skip with a warning.
54
+ warnings.push(`${mcpPath} is not valid JSON — left untouched (fix it, then re-run) to avoid dropping your other MCP servers`)
55
+ return { wrote, skipped, warnings }
56
+ }
57
+ }
58
+ const bak = backupFile(mcpPath)
59
+ mkdirSync(cursorDir, { recursive: true })
60
+ writeFileSync(mcpPath, JSON.stringify(mergeCursorMcp(existing, spec, token), null, 2))
61
+ wrote.push(mcpPath)
62
+ log(` ✓ MCP server → ${mcpPath}${bak ? ' (backup saved)' : ''}`)
63
+ } catch (e) {
64
+ warnings.push(`Cursor MCP wiring skipped: ${e.message}`) // IO error → non-fatal, siblings unaffected
65
+ }
66
+
67
+ // Capture hook: detect, don't fabricate. The cortex-cursor.mjs hook isn't bundled in this package
68
+ // yet (it was hand-installed), so warn rather than write a guessed file. Bundling it is follow-up.
69
+ const hookPath = join(cursorDir, 'hooks', 'cortex-cursor.mjs')
70
+ if (!existsSync(hookPath)) {
71
+ warnings.push('Cursor capture hook (~/.cursor/hooks/cortex-cursor.mjs) not found — Cursor sessions will not be captured until it is installed')
72
+ } else {
73
+ skipped.push(`${hookPath} (already present)`)
74
+ }
75
+ return { wrote, skipped, warnings }
76
+ },
77
+ }
@@ -0,0 +1,42 @@
1
+ // Editor adapter registry (P0-b task 1). One place that knows every editor Agnoclast can wire.
2
+ // Adding an editor = add a module here; the install driver + capability manifest fall out of it.
3
+ //
4
+ // EditorAdapter shape (see the four modules):
5
+ // id stable string id (used in the manifest + `--editor <id>`)
6
+ // displayName human label
7
+ // detect(env) → boolean; env = { home?, exists? } is injectable for tests
8
+ // capabilities { promptTimeInjection, sessionStart, captureHook, skillDir, docTrigger }
9
+ // wire(ctx) → performs the install for this editor (stubbed in task 1)
10
+ //
11
+ /** @typedef {Object} EditorAdapter */
12
+
13
+ import claude from './claude.mjs'
14
+ import codex from './codex.mjs'
15
+ import cursor from './cursor.mjs'
16
+ import antigravity from './antigravity.mjs'
17
+
18
+ export const ADAPTERS = [claude, codex, cursor, antigravity]
19
+
20
+ /**
21
+ * Resolve which editors an install targets.
22
+ * 'auto' → every adapter whose detect() is true on this machine (the default)
23
+ * 'all' → every adapter, regardless of detection
24
+ * 'id'|'a,b' → the named adapter(s); throws on an unknown id (fail loud, don't silently skip)
25
+ *
26
+ * @param {string} [flag='auto']
27
+ * @param {{ adapters?: EditorAdapter[], env?: { home?: string, exists?: (p: string) => boolean } }} [opts]
28
+ * @returns {EditorAdapter[]}
29
+ */
30
+ export function resolveEditors(flag = 'auto', { adapters = ADAPTERS, env } = {}) {
31
+ if (flag === 'all') return [...adapters]
32
+ if (flag === 'auto') return adapters.filter((a) => a.detect(env))
33
+
34
+ const ids = String(flag).split(',').map((s) => s.trim()).filter(Boolean)
35
+ const known = new Set(adapters.map((a) => a.id))
36
+ const unknown = ids.filter((id) => !known.has(id))
37
+ if (unknown.length) {
38
+ throw new Error(`Unknown editor(s): ${unknown.join(', ')}. Known: ${adapters.map((a) => a.id).join(', ')}`)
39
+ }
40
+ // Preserve registry order, dedupe.
41
+ return adapters.filter((a) => ids.includes(a.id))
42
+ }
@@ -0,0 +1,68 @@
1
+ import { spawnSync } from 'child_process'
2
+ import { edgeSafeEnv } from './edge_extract.mjs'
3
+
4
+ // Typed extraction (synthesis slice 2) — registry-driven. Given a transcript + the node_types registry,
5
+ // the LLM emits TYPED NOTES (one per real thing that happened / was mentioned) conforming to the type
6
+ // definitions, instead of the old loose {summary, people, namedEntities} blob. Runs locally on the
7
+ // subscription (edgeSafeEnv strips ANTHROPIC_*). PARALLEL to edge_extract.mjs — does NOT replace it yet;
8
+ // built to be tested in isolation, then wired into capture once proven (slice 2b).
9
+ //
10
+ // Per the ontology (cortex-node-ontology-spec): programmatic fields are filled by the caller from source
11
+ // metadata BEFORE this runs; here the LLM fills only what the text supports, self-flags llm_filled fields,
12
+ // and uses "unknown" for required fields it can't determine (→ red-link downstream). It may also COIN a
13
+ // new type (open registry) when nothing fits.
14
+
15
+ function typeCard(t) {
16
+ const fields = (arr) => (Array.isArray(arr) ? arr : []).map((f) => `${f.field}${f.note ? ` (${f.note})` : ''}`).join(', ')
17
+ return `• ${t.name} [${t.layer}]${t.applies_to?.length ? ` applies_to:${t.applies_to.join('/')}` : ''}\n` +
18
+ ` required: ${fields(t.required_fields) || '—'}\n` +
19
+ ` optional: ${fields(t.optional_fields) || '—'}\n` +
20
+ ` how: ${t.extraction_instructions}`
21
+ }
22
+
23
+ export function extractTyped(transcript, registry, opts = {}) {
24
+ const text = (transcript ?? '').trim()
25
+ if (!text || !Array.isArray(registry) || !registry.length) return null
26
+ const catalog = registry.map(typeCard).join('\n')
27
+ const prompt =
28
+ 'You are building an organization knowledge graph from a work session. Using ONLY the node/note types ' +
29
+ 'in the CATALOG below, emit the typed notes this session supports — one per real thing that happened ' +
30
+ '(actions, decisions, communications) or entity referenced (people, projects, etc.).\n\n' +
31
+ 'RULES:\n' +
32
+ '- Fill a field ONLY if the text supports it. If a REQUIRED field is unknowable, set it to "unknown".\n' +
33
+ '- List the fields YOU inferred (vs. ones obviously given) in "llm_filled".\n' +
34
+ '- Reference other things by name (e.g. actors:["Jane Doe"]) — do not invent ids.\n' +
35
+ '- NEVER invent numbers (amounts, values, metrics) — "unknown" if not explicitly stated.\n' +
36
+ '- Be conservative: only emit a note for something genuinely present. Empty array is fine.\n' +
37
+ '- If something important fits NO type, you may coin one: add it to "new_types" with ' +
38
+ '{name, layer, required_fields:[{field}], why}.\n\n' +
39
+ 'Return ONLY minified JSON: {"notes":[{"type","fields":{...},"llm_filled":[...]}],"new_types":[...]}.\n\n' +
40
+ '--- CATALOG ---\n' + catalog + '\n\n--- SESSION ---\n' + text.slice(0, 12000) + '\n--- END ---'
41
+ try {
42
+ const r = spawnSync(
43
+ 'claude',
44
+ ['--print', '--model', opts.model ?? process.env.CORTEX_SUMMARY_MODEL ?? 'claude-haiku-4-5', prompt],
45
+ { env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }), encoding: 'utf8', timeout: 120_000, maxBuffer: 8 * 1024 * 1024 },
46
+ )
47
+ if (r.status !== 0 || !r.stdout) return null
48
+ return parseTyped(r.stdout)
49
+ } catch {
50
+ return null
51
+ }
52
+ }
53
+
54
+ export function parseTyped(out) {
55
+ if (!out) return null
56
+ const start = out.indexOf('{')
57
+ const end = out.lastIndexOf('}')
58
+ if (start < 0 || end <= start) return null
59
+ try {
60
+ const o = JSON.parse(out.slice(start, end + 1))
61
+ return {
62
+ notes: Array.isArray(o.notes) ? o.notes : [],
63
+ new_types: Array.isArray(o.new_types) ? o.new_types : [],
64
+ }
65
+ } catch {
66
+ return null
67
+ }
68
+ }
@@ -0,0 +1,134 @@
1
+ import { spawnSync } from 'child_process'
2
+ import { existsSync, readFileSync } from 'fs'
3
+ import { join } from 'path'
4
+ import { fetchCortex, classify, resolveBase, resolveEnvToken } from './diagnose.mjs'
5
+
6
+ // `cortex-mcp graphify-sync [path]` — local producer: incrementally rebuild a repo's structural
7
+ // code graph (graphify — tree-sitter AST, no LLM) and log an evidence-tier timeline event via
8
+ // POST /api/timeline/graphify. LLM-free, idempotent (server dedupes on repo+commit), additive —
9
+ // never touches the wiki graph (cortex-wiki-primary-spec: extracted/structural data is evidence,
10
+ // never auto-promoted into an authored page).
11
+ //
12
+ // Meant to run from a periodic cron/launchd job per repo you want graphed — NOT a git post-commit
13
+ // hook. A hook fires synchronously inside `git commit`/`git push` and can race another session's
14
+ // git operations in a shared checkout (see reference-cortex-shared-checkout-hazards); a timer-based
15
+ // job just reads whatever the tree looks like at that moment, no hook into git operations at all.
16
+
17
+ function run(cmd, args, cwd) {
18
+ const r = spawnSync(cmd, args, { cwd, encoding: 'utf8', timeout: 120_000 })
19
+ return r.status === 0 ? (r.stdout || '').trim() : null
20
+ }
21
+
22
+ function repoFullNameFromRemote(cwd) {
23
+ const url = run('git', ['remote', 'get-url', 'origin'], cwd)
24
+ if (!url) return null
25
+ const m = /github\.com[:/]([^/]+)\/([^/.]+?)(?:\.git)?$/i.exec(url)
26
+ return m ? `${m[1]}/${m[2]}` : null
27
+ }
28
+
29
+ // `--brain <name-or-org-id>` — which brain an UNROUTED repo's graph event lands in.
30
+ //
31
+ // Deliberately a flag and nothing cleverer. /api/timeline/graphify routes deterministically by repo
32
+ // (resolveSourceRoute) and only asks when the repo has no route; its comment is blunt about why it
33
+ // must not be guessed: this writes a `records` row unique on (org_id, dedupe_key), so "a repo whose
34
+ // graph updates land in two brains becomes two record sets that never reconcile, and re-pointing
35
+ // later converges on nothing (ADR-0020). It is the one write class where a wrong answer is genuinely
36
+ // unrecoverable." So: no sweep (unlike `resolve`, which is a maintenance pass over everything) and
37
+ // no auto-pick.
38
+ //
39
+ // It does NOT create a source route as a side effect, though that would stop the question recurring:
40
+ // routes are append-only precisely because "re-pointing a live source splits its history
41
+ // irreparably", and a near-irreversible write should not fall out of a CLI flag. This command runs
42
+ // from a per-repo cron/launchd job, so the flag lives in that job's definition — answered once,
43
+ // where it is visible. (Route creation currently has NO client on any surface; that is a separate
44
+ // gap, not this command's to paper over.)
45
+ // Split argv into { cwd, brain }. Pure + exported so the ordering trap below is unit-testable
46
+ // without a git repo, a graphify binary or a network.
47
+ //
48
+ // THE TRAP: argv[0] doubles as the optional repo path, and `--brain`'s VALUE has no leading '-'.
49
+ // Parsed naively, `graphify-sync --brain Personal` reads "Personal" as the path and syncs whatever
50
+ // happens to be there. So the flag and its value are stripped BEFORE the positional check.
51
+ export function parseGraphifyArgs(argv = [], fallbackCwd = process.cwd()) {
52
+ const bIdx = argv.indexOf('--brain')
53
+ const brain = bIdx === -1 ? null : argv[bIdx + 1]
54
+ if (bIdx !== -1 && (!brain || brain.startsWith('-'))) {
55
+ return { error: 'Usage: graphify-sync [path] [--brain <name-or-org-id>]' }
56
+ }
57
+ const rest = bIdx === -1 ? argv : argv.filter((_, i) => i !== bIdx && i !== bIdx + 1)
58
+ return { cwd: rest[0] && !rest[0].startsWith('-') ? rest[0] : fallbackCwd, brain }
59
+ }
60
+
61
+ export async function runGraphifySync(argv = []) {
62
+ const parsed = parseGraphifyArgs(argv)
63
+ if (parsed.error) { process.stderr.write(parsed.error + '\n'); return 1 }
64
+ const { cwd, brain } = parsed
65
+ const TOKEN = resolveEnvToken().token
66
+ const BASE = resolveBase(process.env.CORTEX_URL)
67
+ if (!TOKEN) {
68
+ process.stderr.write('cortex-mcp graphify-sync: CORTEX_TOKEN is required.\n')
69
+ return 1
70
+ }
71
+ const graphFile = join(cwd, 'graphify-out', 'graph.json')
72
+ if (!existsSync(graphFile)) {
73
+ process.stderr.write(`cortex-mcp graphify-sync: no graph at ${graphFile} — run the graphify skill (\`/graphify .\`) in this repo first.\n`)
74
+ return 1
75
+ }
76
+ if (!run('graphify', ['--version'], cwd)) {
77
+ process.stderr.write('cortex-mcp graphify-sync: graphify CLI not on PATH (`uv tool install graphifyy`).\n')
78
+ return 1
79
+ }
80
+
81
+ const update = spawnSync('graphify', ['update', '.'], { cwd, encoding: 'utf8', timeout: 300_000, maxBuffer: 8 * 1024 * 1024 })
82
+ if (update.status !== 0) {
83
+ process.stderr.write(`cortex-mcp graphify-sync: \`graphify update\` failed:\n${(update.stderr || update.stdout || '').trim()}\n`)
84
+ return 1
85
+ }
86
+ if (update.stdout) process.stdout.write(update.stdout.trim() + '\n')
87
+
88
+ let graph
89
+ try {
90
+ graph = JSON.parse(readFileSync(graphFile, 'utf8'))
91
+ } catch (e) {
92
+ process.stderr.write(`cortex-mcp graphify-sync: could not read ${graphFile}: ${e.message}\n`)
93
+ return 1
94
+ }
95
+ const nodes = Array.isArray(graph.nodes) ? graph.nodes : []
96
+ const nodeCount = nodes.length
97
+ const edgeCount = Array.isArray(graph.links) ? graph.links.length : 0
98
+ const communityCount = new Set(nodes.map((n) => n.community).filter((c) => c !== undefined)).size
99
+ const commitSha = typeof graph.built_at_commit === 'string' ? graph.built_at_commit : null
100
+
101
+ const repo = repoFullNameFromRemote(cwd)
102
+ if (!commitSha || !repo) {
103
+ process.stdout.write('cortex-mcp graphify-sync: graph updated locally; skipping timeline log (no git commit / no github.com origin found).\n')
104
+ return 0
105
+ }
106
+
107
+ const res = await fetchCortex(`${BASE}/api/timeline/graphify`, {
108
+ method: 'POST',
109
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
110
+ body: JSON.stringify({ repo, commitSha, nodeCount, edgeCount, communityCount, ...(brain ? { brain } : {}) }),
111
+ })
112
+ if (!res.ok) {
113
+ const body = await res.text()
114
+ const d = classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id'))
115
+ process.stderr.write(d.message + '\n')
116
+ // classify names the brains but speaks in MCP terms ("re-run this tool with `brain`"). Say the
117
+ // actual flag, and where to put it — this runs unattended from cron, so the person reading this
118
+ // is looking at a log after the fact, not a prompt.
119
+ if (res.status === 409 && !brain) {
120
+ process.stderr.write(
121
+ `\n${repo} has no routing decision yet, so it cannot be filed without one.\n` +
122
+ `Re-run with: cortex-mcp graphify-sync ${cwd === process.cwd() ? '' : `${cwd} `}--brain "<name-or-org-id>"\n` +
123
+ `and add that flag to this repo's cron/launchd job so it stops asking.\n`,
124
+ )
125
+ }
126
+ return 1
127
+ }
128
+ const payload = await res.json()
129
+ process.stdout.write(
130
+ `Logged to Agnoclast timeline: ${repo}@${commitSha.slice(0, 7)} (${nodeCount} nodes, ${edgeCount} edges, ${communityCount} communities)` +
131
+ `${payload.inserted ? '' : ' (already logged)'}\n`,
132
+ )
133
+ return 0
134
+ }
@@ -0,0 +1,82 @@
1
+ import { fetchCortex, classify, resolveBase, resolveEnvToken } from './diagnose.mjs'
2
+
3
+ // `cortex grep` CLI + shared formatting for the MCP grep tool (Lane B / T6). Thin client of
4
+ // GET /api/grep — the server runs the RLS-INVOKER RPC AS the viewer, so no DB credentials live here.
5
+ // Output is ASCII-only (outbound-message convention).
6
+
7
+ // Pure: parse argv after `grep` → { query, mode, max }.
8
+ // cortex grep <terms...> [--substring | --literal] [--max N]
9
+ // Default mode is 'fts' (ranked keyword — multi-word queries work); pass --substring/--literal for
10
+ // an exact literal match (identifiers, [[links]], code). (#220)
11
+ export function parseGrepArgs(argv = []) {
12
+ const out = { query: '', mode: 'fts', max: undefined }
13
+ const terms = []
14
+ for (let i = 0; i < argv.length; i++) {
15
+ const a = argv[i]
16
+ if (a === '--mode') {
17
+ out.mode = argv[++i] === 'substring' ? 'substring' : 'fts'
18
+ } else if (a === '--fts') {
19
+ out.mode = 'fts'
20
+ } else if (a === '--substring' || a === '--literal') {
21
+ out.mode = 'substring'
22
+ } else if (a === '--max') {
23
+ const n = Number(argv[++i])
24
+ if (Number.isFinite(n)) out.max = Math.trunc(n)
25
+ } else {
26
+ terms.push(a)
27
+ }
28
+ }
29
+ out.query = terms.join(' ').trim()
30
+ return out
31
+ }
32
+
33
+ // Pure: render a /api/grep payload to readable ASCII text.
34
+ export function formatGrepHits(payload, query) {
35
+ const hits = (payload && payload.hits) || []
36
+ if (!hits.length) {
37
+ // Reactive red-link hint — ONLY when the query looks like a page NAME (short, no code/operators), so
38
+ // code and typo searches don't get nagged. read_page carries the full triage (node / new / alias).
39
+ const nameish = /^[\w .'-]{2,40}$/.test(query) && query.split(/\s+/).length <= 5
40
+ return nameish
41
+ ? `No matches for "${query}". If you expected a page here, it may be an unauthored red-link — \`read_page "${query}"\` to triage it (author it, or alias it to an existing page).`
42
+ : `No matches for "${query}".`
43
+ }
44
+ const lines = [`${hits.length} match${hits.length === 1 ? '' : 'es'} for "${query}":`, '']
45
+ for (const h of hits) {
46
+ const head = h.heading ? ` > ${h.heading}` : ''
47
+ lines.push(`- ${h.title}${head} [${h.tier}]`)
48
+ if (h.snippet) lines.push(` ${String(h.snippet).replace(/\s+/g, ' ').trim()}`)
49
+ if (h.links && h.links.length) lines.push(` -> ${h.links.map((l) => `[[${l}]]`).join(' ')}`)
50
+ }
51
+ return lines.join('\n')
52
+ }
53
+
54
+ // Effectful: run the CLI subcommand. Returns an exit code.
55
+ export async function runGrep(rest = []) {
56
+ const TOKEN = resolveEnvToken().token
57
+ const BASE = resolveBase(process.env.CORTEX_URL)
58
+ if (!TOKEN) {
59
+ process.stderr.write('cortex grep: CORTEX_TOKEN is required (get yours from the Agnoclast console).\n')
60
+ return 1
61
+ }
62
+ const { query, mode, max } = parseGrepArgs(rest)
63
+ if (!query) {
64
+ process.stderr.write('usage: cortex grep <query> [--substring|--literal] [--max N]\n')
65
+ return 1
66
+ }
67
+ const qs = new URLSearchParams({ q: query, mode })
68
+ if (max) qs.set('max', String(max))
69
+ const res = await fetchCortex(`${BASE}/api/grep?${qs.toString()}`, {
70
+ headers: { Authorization: `Bearer ${TOKEN}` },
71
+ })
72
+ if (!res.ok) {
73
+ const body = await res.text()
74
+ process.stderr.write(
75
+ classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message + '\n',
76
+ )
77
+ return 1
78
+ }
79
+ const payload = await res.json()
80
+ process.stdout.write(formatGrepHits(payload, query) + '\n')
81
+ return 0
82
+ }
@@ -0,0 +1,181 @@
1
+ import { readFileSync, existsSync, mkdirSync, writeFileSync, readdirSync, statSync, unlinkSync } from 'fs'
2
+ import { homedir } from 'os'
3
+ import { join } from 'path'
4
+ import { fetchCortex, classify, resolveBase, resolveTokenSource } from './diagnose.mjs'
5
+ import { redactSecrets } from './redact.mjs'
6
+ import { formatReceipt, renderUncounted, subjectOf } from './presence.mjs'
7
+ import { writePresence } from './statusline.mjs'
8
+
9
+ // UserPromptSubmit hook (① discovery): on the FIRST substantive turn of a session, hydrate the model
10
+ // with query-centered Agnoclast context BEFORE it answers — then never again this session. It closes the
11
+ // gap that read-triggered authoring can't: an agent that never READS the wiki (works from code + memory)
12
+ // never triggers a currency update, and re-derives already-authored truth. cortex-context is a SKILL the
13
+ // model may forget to invoke; this makes the first hydration non-discretionary (the same move that made
14
+ // authoring pre-authorized). Mid-session topic-shift refresh stays the cortex-context skill's job — a
15
+ // hook can't cheaply judge a semantic topic change.
16
+ //
17
+ // It MUST be synchronous: the value is being in-context before the reply, so it cannot be detached like
18
+ // capture. That cost is bounded — once per session, an 8s timeout, and FAIL-OPEN: any miss injects
19
+ // nothing and never holds up the user's first message. Always exits 0; hydration must never break a turn.
20
+
21
+ const HYDRATE_TIMEOUT_MS = 8_000
22
+ const MAX_ATTEMPTS = 2 // give up after N failed tries so a dead endpoint isn't re-hit every turn
23
+ const MIN_SUBSTANTIVE_CHARS = 15
24
+ // Bare greetings/affirmations are not a real opening query — skip WITHOUT marking done, so the first
25
+ // substantive prompt still hydrates. Anchored to the whole string so "go" skips but "go build X" does not.
26
+ const TRIVIAL_RE = /^(hi|hey|hello|yo|sup|thanks|thank you|ty|ok|okay|k|kk|yes|yep|yup|yeah|no|nope|nah|sure|go|go ahead|do it|continue|next|please)\b[\s!.?]*$/i
27
+ const STATE_RETENTION_DAYS = 7
28
+
29
+ function hydrationDir() {
30
+ return join(homedir(), '.cortex', 'hydration')
31
+ }
32
+ function stateFile(sessionId) {
33
+ return join(hydrationDir(), `${sessionId}.json`)
34
+ }
35
+
36
+ function readState(sessionId) {
37
+ try {
38
+ return JSON.parse(readFileSync(stateFile(sessionId), 'utf8'))
39
+ } catch {
40
+ return {}
41
+ }
42
+ }
43
+
44
+ function writeState(sessionId, state) {
45
+ try {
46
+ const dir = hydrationDir()
47
+ if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
48
+ writeFileSync(stateFile(sessionId), JSON.stringify(state))
49
+ } catch {
50
+ /* best-effort — a state write failure must never break the turn */
51
+ }
52
+ }
53
+
54
+ // Drop state files older than the retention window so per-session markers can't grow unbounded.
55
+ // Best-effort, silent (mirrors the snapshot/beacon prune discipline).
56
+ function pruneState() {
57
+ try {
58
+ const dir = hydrationDir()
59
+ if (!existsSync(dir)) return
60
+ const cutoff = Date.now() - STATE_RETENTION_DAYS * 24 * 3600 * 1000
61
+ for (const f of readdirSync(dir)) {
62
+ try {
63
+ if (statSync(join(dir, f)).mtimeMs < cutoff) unlinkSync(join(dir, f))
64
+ } catch {
65
+ /* ignore individual failures */
66
+ }
67
+ }
68
+ } catch {
69
+ /* ignore — never break the turn */
70
+ }
71
+ }
72
+
73
+ // A real opening question, not chit-chat. Cheap heuristic (length + a trivial-phrase guard); the point is
74
+ // only to avoid burning the once-per-session hydration on "hi". Anything borderline hydrates.
75
+ export function isSubstantive(prompt) {
76
+ const p = (prompt || '').trim()
77
+ if (p.length < MIN_SUBSTANTIVE_CHARS) return false
78
+ if (TRIVIAL_RE.test(p)) return false
79
+ return true
80
+ }
81
+
82
+ // Pure gate decision, extracted so it is unit-testable without stdin/network. Returns one of:
83
+ // 'skip-no-session' | 'skip-done' | 'skip-attempts' | 'skip-trivial' | 'go'
84
+ export function decideHydrate({ hasSessionId, done, attempts = 0, prompt, maxAttempts = MAX_ATTEMPTS }) {
85
+ if (!hasSessionId) return 'skip-no-session' // can't gate without an id → do nothing, never fire every turn
86
+ if (done) return 'skip-done' // the gate: hydrate exactly once per session
87
+ if (attempts >= maxAttempts) return 'skip-attempts' // stop retrying a dead endpoint
88
+ if (!isSubstantive(prompt)) return 'skip-trivial' // wait for a real query; do NOT mark done
89
+ return 'go'
90
+ }
91
+
92
+ // UserPromptSubmit sends a JSON payload on stdin: { session_id, prompt, cwd, ... }.
93
+ function readHook() {
94
+ try {
95
+ return JSON.parse(readFileSync(0, 'utf8'))
96
+ } catch {
97
+ return {}
98
+ }
99
+ }
100
+
101
+ export async function runHydrate() {
102
+ pruneState()
103
+
104
+ const hook = readHook()
105
+ const sessionId = hook.session_id || process.env.CLAUDE_SESSION_ID || ''
106
+ const prompt = hook.prompt ?? hook.user_prompt ?? ''
107
+ const state = readState(sessionId)
108
+
109
+ const action = decideHydrate({
110
+ hasSessionId: Boolean(sessionId),
111
+ done: Boolean(state.done),
112
+ attempts: state.attempts ?? 0,
113
+ prompt,
114
+ })
115
+ if (action !== 'go') return 0
116
+
117
+ const token = resolveTokenSource().token
118
+ if (!token) return 0 // not wired → silent no-op (don't mark done; a later session may be wired)
119
+ const base = resolveBase(process.env.CORTEX_URL)
120
+
121
+ const bumpAttempt = () => writeState(sessionId, { ...state, attempts: (state.attempts ?? 0) + 1 })
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
+
127
+ let res
128
+ try {
129
+ res = await fetchCortex(
130
+ `${base}/api/session-context`,
131
+ {
132
+ method: 'POST',
133
+ headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
134
+ // Redact credential-shaped strings before the prompt leaves the machine (a pasted key must never
135
+ // be transmitted, same discipline as capture).
136
+ body: JSON.stringify({ question: redactSecrets(prompt) }),
137
+ timeoutMs: HYDRATE_TIMEOUT_MS,
138
+ },
139
+ { retries: 1 },
140
+ )
141
+ } catch (e) {
142
+ process.stderr.write(`cortex: hydrate skipped — ${e?.message ?? String(e)}\n`)
143
+ bumpAttempt()
144
+ return 0 // fail-open: never hold the user's first message hostage to hydration
145
+ }
146
+
147
+ if (!res.ok) {
148
+ const body = await res.text().catch(() => '')
149
+ process.stderr.write(
150
+ `cortex: hydrate skipped — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}\n`,
151
+ )
152
+ bumpAttempt()
153
+ return 0
154
+ }
155
+
156
+ const { context, meta } = await res.json().catch(() => ({}))
157
+ if (context && typeof context === 'string' && context.trim()) {
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')
169
+ process.stdout.write(context.trimEnd() + '\n')
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
+ })
177
+ } else {
178
+ bumpAttempt() // empty body — treat as a miss, allow one bounded retry next turn
179
+ }
180
+ return 0
181
+ }