@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.
- package/README.md +47 -0
- package/bin/cortex-mcp.mjs +223 -0
- package/lib/capture.mjs +470 -0
- package/lib/code_graph_cli.mjs +59 -0
- package/lib/context_log.mjs +92 -0
- package/lib/diagnose.mjs +360 -0
- package/lib/docs_scan.mjs +171 -0
- package/lib/doctor.mjs +117 -0
- package/lib/edge_extract.mjs +156 -0
- package/lib/editors/_fsutil.mjs +31 -0
- package/lib/editors/antigravity.mjs +130 -0
- package/lib/editors/claude.mjs +202 -0
- package/lib/editors/codex.mjs +111 -0
- package/lib/editors/cursor.mjs +77 -0
- package/lib/editors/index.mjs +42 -0
- package/lib/extract_typed.mjs +68 -0
- package/lib/graphify_sync.mjs +134 -0
- package/lib/grep_cli.mjs +82 -0
- package/lib/hydrate.mjs +181 -0
- package/lib/imessage_send.mjs +88 -0
- package/lib/ingest_folder.mjs +170 -0
- package/lib/install.mjs +163 -0
- package/lib/login.mjs +148 -0
- package/lib/managed.mjs +49 -0
- package/lib/migrate_key.mjs +139 -0
- package/lib/presence.mjs +226 -0
- package/lib/publish_targets.mjs +51 -0
- package/lib/red_link_triage.mjs +37 -0
- package/lib/redact.mjs +40 -0
- package/lib/rename_notice.mjs +31 -0
- package/lib/resolve.mjs +153 -0
- package/lib/server.mjs +2986 -0
- package/lib/session_key.mjs +37 -0
- package/lib/setup.mjs +215 -0
- package/lib/skills.mjs +374 -0
- package/lib/statusline.mjs +67 -0
- package/lib/uninstall.mjs +237 -0
- package/lib/use_brain.mjs +82 -0
- package/lib/with_token.mjs +66 -0
- package/package.json +36 -0
- package/skills/author-docs/SKILL.md +74 -0
- package/skills/context/SKILL.md +25 -0
- package/skills/log/SKILL.md +114 -0
- 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
|
+
}
|
package/lib/grep_cli.mjs
ADDED
|
@@ -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
|
+
}
|
package/lib/hydrate.mjs
ADDED
|
@@ -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
|
+
}
|