@theronap/cortex-mcp 0.9.54 → 0.9.56
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 +9 -0
- package/lib/hydrate.mjs +159 -0
- package/lib/server.mjs +23 -8
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -52,6 +52,7 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
52
52
|
` docs-scan detect new/changed local docs pending Cortex authoring (used by /cortex-author-docs)\n` +
|
|
53
53
|
` graphify-sync [path] rebuild the local code graph (graphify) + log an evidence-tier timeline event\n` +
|
|
54
54
|
` snapshot-context save the exact startup context Cortex served to a local snapshot\n` +
|
|
55
|
+
` hydrate UserPromptSubmit hook — inject query-centered context on the first substantive turn\n` +
|
|
55
56
|
` capture Stop-hook capturer (invoked by Claude Code)\n` +
|
|
56
57
|
` ingest-folder <path> ingest a local markdown folder as your authored records\n` +
|
|
57
58
|
` (no args) run the MCP server (used by your Claude config)\n\n` +
|
|
@@ -141,6 +142,14 @@ if (cmd === 'setup') {
|
|
|
141
142
|
process.exitCode = await runSnapshotContext()
|
|
142
143
|
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
143
144
|
await closeFetch()
|
|
145
|
+
} else if (cmd === 'hydrate') {
|
|
146
|
+
// UserPromptSubmit hook (① discovery): hydrate the model with query-centered Cortex context on the
|
|
147
|
+
// FIRST substantive turn, before it answers — then never again this session (topic-shift refresh stays
|
|
148
|
+
// the cortex-context skill's job). Synchronous by necessity, but once-per-session + 8s + fail-open.
|
|
149
|
+
const { runHydrate } = await import('../lib/hydrate.mjs')
|
|
150
|
+
process.exitCode = await runHydrate()
|
|
151
|
+
const { closeFetch } = await import('../lib/diagnose.mjs')
|
|
152
|
+
await closeFetch()
|
|
144
153
|
} else if (cmd === 'skills') {
|
|
145
154
|
// Install / repair the managed Cortex skills — bundled + org-published (`skills push` publishes).
|
|
146
155
|
// Org sync is network-fail-soft so the SessionStart hook stays safe offline.
|
package/lib/hydrate.mjs
ADDED
|
@@ -0,0 +1,159 @@
|
|
|
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, readWiredToken } from './diagnose.mjs'
|
|
5
|
+
import { redactSecrets } from './redact.mjs'
|
|
6
|
+
|
|
7
|
+
// UserPromptSubmit hook (① discovery): on the FIRST substantive turn of a session, hydrate the model
|
|
8
|
+
// with query-centered Cortex context BEFORE it answers — then never again this session. It closes the
|
|
9
|
+
// gap that read-triggered authoring can't: an agent that never READS the wiki (works from code + memory)
|
|
10
|
+
// never triggers a currency update, and re-derives already-authored truth. cortex-context is a SKILL the
|
|
11
|
+
// model may forget to invoke; this makes the first hydration non-discretionary (the same move that made
|
|
12
|
+
// authoring pre-authorized). Mid-session topic-shift refresh stays the cortex-context skill's job — a
|
|
13
|
+
// hook can't cheaply judge a semantic topic change.
|
|
14
|
+
//
|
|
15
|
+
// It MUST be synchronous: the value is being in-context before the reply, so it cannot be detached like
|
|
16
|
+
// capture. That cost is bounded — once per session, an 8s timeout, and FAIL-OPEN: any miss injects
|
|
17
|
+
// nothing and never holds up the user's first message. Always exits 0; hydration must never break a turn.
|
|
18
|
+
|
|
19
|
+
const HYDRATE_TIMEOUT_MS = 8_000
|
|
20
|
+
const MAX_ATTEMPTS = 2 // give up after N failed tries so a dead endpoint isn't re-hit every turn
|
|
21
|
+
const MIN_SUBSTANTIVE_CHARS = 15
|
|
22
|
+
// Bare greetings/affirmations are not a real opening query — skip WITHOUT marking done, so the first
|
|
23
|
+
// substantive prompt still hydrates. Anchored to the whole string so "go" skips but "go build X" does not.
|
|
24
|
+
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
|
|
25
|
+
const STATE_RETENTION_DAYS = 7
|
|
26
|
+
|
|
27
|
+
function hydrationDir() {
|
|
28
|
+
return join(homedir(), '.cortex', 'hydration')
|
|
29
|
+
}
|
|
30
|
+
function stateFile(sessionId) {
|
|
31
|
+
return join(hydrationDir(), `${sessionId}.json`)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function readState(sessionId) {
|
|
35
|
+
try {
|
|
36
|
+
return JSON.parse(readFileSync(stateFile(sessionId), 'utf8'))
|
|
37
|
+
} catch {
|
|
38
|
+
return {}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function writeState(sessionId, state) {
|
|
43
|
+
try {
|
|
44
|
+
const dir = hydrationDir()
|
|
45
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true })
|
|
46
|
+
writeFileSync(stateFile(sessionId), JSON.stringify(state))
|
|
47
|
+
} catch {
|
|
48
|
+
/* best-effort — a state write failure must never break the turn */
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// Drop state files older than the retention window so per-session markers can't grow unbounded.
|
|
53
|
+
// Best-effort, silent (mirrors the snapshot/beacon prune discipline).
|
|
54
|
+
function pruneState() {
|
|
55
|
+
try {
|
|
56
|
+
const dir = hydrationDir()
|
|
57
|
+
if (!existsSync(dir)) return
|
|
58
|
+
const cutoff = Date.now() - STATE_RETENTION_DAYS * 24 * 3600 * 1000
|
|
59
|
+
for (const f of readdirSync(dir)) {
|
|
60
|
+
try {
|
|
61
|
+
if (statSync(join(dir, f)).mtimeMs < cutoff) unlinkSync(join(dir, f))
|
|
62
|
+
} catch {
|
|
63
|
+
/* ignore individual failures */
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
} catch {
|
|
67
|
+
/* ignore — never break the turn */
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// A real opening question, not chit-chat. Cheap heuristic (length + a trivial-phrase guard); the point is
|
|
72
|
+
// only to avoid burning the once-per-session hydration on "hi". Anything borderline hydrates.
|
|
73
|
+
export function isSubstantive(prompt) {
|
|
74
|
+
const p = (prompt || '').trim()
|
|
75
|
+
if (p.length < MIN_SUBSTANTIVE_CHARS) return false
|
|
76
|
+
if (TRIVIAL_RE.test(p)) return false
|
|
77
|
+
return true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Pure gate decision, extracted so it is unit-testable without stdin/network. Returns one of:
|
|
81
|
+
// 'skip-no-session' | 'skip-done' | 'skip-attempts' | 'skip-trivial' | 'go'
|
|
82
|
+
export function decideHydrate({ hasSessionId, done, attempts = 0, prompt, maxAttempts = MAX_ATTEMPTS }) {
|
|
83
|
+
if (!hasSessionId) return 'skip-no-session' // can't gate without an id → do nothing, never fire every turn
|
|
84
|
+
if (done) return 'skip-done' // the gate: hydrate exactly once per session
|
|
85
|
+
if (attempts >= maxAttempts) return 'skip-attempts' // stop retrying a dead endpoint
|
|
86
|
+
if (!isSubstantive(prompt)) return 'skip-trivial' // wait for a real query; do NOT mark done
|
|
87
|
+
return 'go'
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// UserPromptSubmit sends a JSON payload on stdin: { session_id, prompt, cwd, ... }.
|
|
91
|
+
function readHook() {
|
|
92
|
+
try {
|
|
93
|
+
return JSON.parse(readFileSync(0, 'utf8'))
|
|
94
|
+
} catch {
|
|
95
|
+
return {}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export async function runHydrate() {
|
|
100
|
+
pruneState()
|
|
101
|
+
|
|
102
|
+
const hook = readHook()
|
|
103
|
+
const sessionId = hook.session_id || process.env.CLAUDE_SESSION_ID || ''
|
|
104
|
+
const prompt = hook.prompt ?? hook.user_prompt ?? ''
|
|
105
|
+
const state = readState(sessionId)
|
|
106
|
+
|
|
107
|
+
const action = decideHydrate({
|
|
108
|
+
hasSessionId: Boolean(sessionId),
|
|
109
|
+
done: Boolean(state.done),
|
|
110
|
+
attempts: state.attempts ?? 0,
|
|
111
|
+
prompt,
|
|
112
|
+
})
|
|
113
|
+
if (action !== 'go') return 0
|
|
114
|
+
|
|
115
|
+
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
116
|
+
if (!token) return 0 // not wired → silent no-op (don't mark done; a later session may be wired)
|
|
117
|
+
const base = resolveBase(process.env.CORTEX_URL)
|
|
118
|
+
|
|
119
|
+
const bumpAttempt = () => writeState(sessionId, { ...state, attempts: (state.attempts ?? 0) + 1 })
|
|
120
|
+
|
|
121
|
+
let res
|
|
122
|
+
try {
|
|
123
|
+
res = await fetchCortex(
|
|
124
|
+
`${base}/api/session-context`,
|
|
125
|
+
{
|
|
126
|
+
method: 'POST',
|
|
127
|
+
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
128
|
+
// Redact credential-shaped strings before the prompt leaves the machine (a pasted key must never
|
|
129
|
+
// be transmitted, same discipline as capture).
|
|
130
|
+
body: JSON.stringify({ question: redactSecrets(prompt) }),
|
|
131
|
+
timeoutMs: HYDRATE_TIMEOUT_MS,
|
|
132
|
+
},
|
|
133
|
+
{ retries: 1 },
|
|
134
|
+
)
|
|
135
|
+
} catch (e) {
|
|
136
|
+
process.stderr.write(`cortex: hydrate skipped — ${e?.message ?? String(e)}\n`)
|
|
137
|
+
bumpAttempt()
|
|
138
|
+
return 0 // fail-open: never hold the user's first message hostage to hydration
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
if (!res.ok) {
|
|
142
|
+
const body = await res.text().catch(() => '')
|
|
143
|
+
process.stderr.write(
|
|
144
|
+
`cortex: hydrate skipped — ${classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message}\n`,
|
|
145
|
+
)
|
|
146
|
+
bumpAttempt()
|
|
147
|
+
return 0
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const { context } = await res.json().catch(() => ({}))
|
|
151
|
+
if (context && typeof context === 'string' && context.trim()) {
|
|
152
|
+
// stdout from a UserPromptSubmit hook is injected into THIS turn's context, before the model answers.
|
|
153
|
+
process.stdout.write(context.trimEnd() + '\n')
|
|
154
|
+
writeState(sessionId, { done: true, at: new Date().toISOString() })
|
|
155
|
+
} else {
|
|
156
|
+
bumpAttempt() // empty body — treat as a miss, allow one bounded retry next turn
|
|
157
|
+
}
|
|
158
|
+
return 0
|
|
159
|
+
}
|
package/lib/server.mjs
CHANGED
|
@@ -499,7 +499,15 @@ export async function runServer(version) {
|
|
|
499
499
|
const day = (d) => (d ? String(d).slice(0, 10) : '')
|
|
500
500
|
const renderMatch = (m, tagBrain) => {
|
|
501
501
|
const blocks = m.tiers.map((t) => {
|
|
502
|
-
const secs = (t.sections ?? []).map((s) =>
|
|
502
|
+
const secs = (t.sections ?? []).map((s) => {
|
|
503
|
+
// Undefined is an older-server response during a rolling deploy: do
|
|
504
|
+
// not invent a currency verdict until this server has computed one.
|
|
505
|
+
const currency = s.hasExplicitDate === false
|
|
506
|
+
? '\n⚠ **Undated section — verify before relying on its claims.**\n'
|
|
507
|
+
: ''
|
|
508
|
+
const asOf = s.asOf ? ` · as of ${day(s.asOf)}` : ''
|
|
509
|
+
return `### ${s.heading}${asOf}${currency}${s.body}`
|
|
510
|
+
}).join('\n\n')
|
|
503
511
|
// ADR-0018: a null version isn't "nothing to show" — it means this variant predates content-
|
|
504
512
|
// hash tracking (a 2026-06-29 import scar) and CANNOT be re-authored via base_version until an
|
|
505
513
|
// admin backfills it. Silently omitting the line here is exactly what sent callers into an
|
|
@@ -859,7 +867,7 @@ export async function runServer(version) {
|
|
|
859
867
|
'list_records',
|
|
860
868
|
{
|
|
861
869
|
title: 'List activity records (filtered)',
|
|
862
|
-
description: 'List recent activity records you can see, filtered by type, project, and recency. RLS-scoped. Use to enumerate "what happened on project X this week" or "recent meetings". Returns
|
|
870
|
+
description: 'List recent activity records you can see, filtered by type, project, and recency. RLS-scoped. Use to enumerate "what happened on project X this week" or "recent meetings". Returns each record\'s FULL summary + id — never a truncated preview, so what you get back is the whole record you are permitted to read (use an id with request_file to ask its owner for the original file).',
|
|
863
871
|
inputSchema: {
|
|
864
872
|
type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
|
|
865
873
|
project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
|
|
@@ -949,9 +957,9 @@ export async function runServer(version) {
|
|
|
949
957
|
'set_record_privacy',
|
|
950
958
|
{
|
|
951
959
|
title: 'Set a record\'s privacy tier',
|
|
952
|
-
description: 'Reclassify the access tier of one of YOUR OWN records: "accessible" (anyone in the org), "scoped" (you + your management chain), or "confidential" (you + people you explicitly grant access). Get record_id from a `
|
|
960
|
+
description: 'Reclassify the access tier of one of YOUR OWN records: "accessible" (anyone in the org), "scoped" (you + your management chain), or "confidential" (you + people you explicitly grant access). Get record_id from a `list_records` result or the "id:" on a `my_context` activity line. Only affects records you own — others return an error.',
|
|
953
961
|
inputSchema: {
|
|
954
|
-
record_id: z.string().describe('the record id (uuid),
|
|
962
|
+
record_id: z.string().describe('the record id (uuid), e.g. the "id:" on a list_records line'),
|
|
955
963
|
privacy: z.enum(['accessible', 'scoped', 'confidential']).describe('the new access tier'),
|
|
956
964
|
},
|
|
957
965
|
},
|
|
@@ -1255,7 +1263,7 @@ export async function runServer(version) {
|
|
|
1255
1263
|
'request_file',
|
|
1256
1264
|
{
|
|
1257
1265
|
title: 'Request the full original of a record',
|
|
1258
|
-
description: 'You have a record\'s SUMMARY but want the full original file (it lives on the owner\'s machine). This asks the owner to share it; once they approve, fetch it with get_file. Get record_id from a
|
|
1266
|
+
description: 'You have a record\'s SUMMARY but want the full original file (it lives on the owner\'s machine). This asks the owner to share it; once they approve, fetch it with get_file. Get record_id from a `list_records` result or the "id:" on a `my_context` activity line.',
|
|
1259
1267
|
inputSchema: { record_id: z.string().describe('the record id (uuid)') },
|
|
1260
1268
|
},
|
|
1261
1269
|
async ({ record_id }) => {
|
|
@@ -1375,7 +1383,7 @@ export async function runServer(version) {
|
|
|
1375
1383
|
{
|
|
1376
1384
|
title: 'Authoring context (call before author)',
|
|
1377
1385
|
description:
|
|
1378
|
-
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (
|
|
1386
|
+
'Fetch the scaffolding to author a Cortex wiki node: the canonical NAMESPACE (current node names — link to these with the EXACT name inside [[ ]]), any deliberately RETIRED page names and their successors, and the node-type CONNECTION RULES. ALWAYS call this BEFORE `author` so the page links to current knowledge rather than minting synonyms or reviving a retired page. Reference a node in the namespace as [[Name]]; if you reference something real that is NOT in the namespace, still write [[Name]] — that is a red-link marking a node worth creating. If the page IS about a specific code repo or chat channel, also stamp it once — [[repo:owner/name]] or [[channel:name]] (lowercase, no #) — identifier join keys, not page links.',
|
|
1379
1387
|
inputSchema: {
|
|
1380
1388
|
kind: z.enum(['project', 'person', 'org', 'user']).optional().describe('the node type you are about to author (default project)'),
|
|
1381
1389
|
},
|
|
@@ -1392,12 +1400,17 @@ export async function runServer(version) {
|
|
|
1392
1400
|
const d = classify(res.status, res.headers.get('content-type'), await res.text(), res.headers.get('x-vercel-id'))
|
|
1393
1401
|
return { content: [{ type: 'text', text: `Could not fetch authoring context: ${d.message}` }] }
|
|
1394
1402
|
}
|
|
1395
|
-
const { connectionRules, namespace } = await res.json()
|
|
1403
|
+
const { connectionRules, namespace, retiredLinks } = await res.json()
|
|
1396
1404
|
const ns = Array.isArray(namespace) ? namespace : []
|
|
1397
1405
|
const nsList = ns.map((n) => `[[${n}]]`).join(', ')
|
|
1406
|
+
const retired = Array.isArray(retiredLinks) ? retiredLinks : []
|
|
1407
|
+
const retiredList = retired.length
|
|
1408
|
+
? `\n\nRETIRED PAGES — do not treat these as current or author over them: ${retired.map((r) => `[[${r.name}]] (${r.validity}${r.supersededBy ? ` → [[${r.supersededBy}]]` : ''})`).join(', ')}`
|
|
1409
|
+
: ''
|
|
1398
1410
|
const text =
|
|
1399
1411
|
`Authoring a "${k}" node. Connection rules (kinds of links to look for):\n${connectionRules}\n\n` +
|
|
1400
1412
|
`NAMESPACE — ${ns.length} existing nodes; link with the EXACT name inside [[ ]]:\n${nsList}\n\n` +
|
|
1413
|
+
retiredList +
|
|
1401
1414
|
`Now author the page (summary + sections) with inline [[links]] woven into the prose. Link, do not restate. ` +
|
|
1402
1415
|
`For something real that is not in this namespace, still write [[Name]] (a red-link). Then call \`author\`.`
|
|
1403
1416
|
return { content: [{ type: 'text', text }] }
|
|
@@ -1444,11 +1457,13 @@ export async function runServer(version) {
|
|
|
1444
1457
|
}
|
|
1445
1458
|
const out = await res.json()
|
|
1446
1459
|
const blue = out?.links?.blue ?? 0
|
|
1460
|
+
const retired = out?.links?.retired ?? 0
|
|
1447
1461
|
const red = out?.links?.red ?? 0
|
|
1448
1462
|
const redList = Array.isArray(out?.redLinks) && out.redLinks.length ? `\nRed-links (wanted nodes): ${out.redLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1463
|
+
const retiredList = Array.isArray(out?.retiredLinks) && out.retiredLinks.length ? `\nRetired links (not current or wanted): ${out.retiredLinks.map((r) => `[[${r}]]`).join(', ')}` : ''
|
|
1449
1464
|
const stamps = Array.isArray(out?.identifiers) && out.identifiers.length ? `\nIdentifier stamps (join keys): ${out.identifiers.map((i) => `[[${i}]]`).join(', ')}` : ''
|
|
1450
1465
|
const verb = out?.created ? 'Created + authored' : 'Authored'
|
|
1451
|
-
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${red} red.${redList}${stamps}`
|
|
1466
|
+
const note = out?.built ? `${verb} "${name}" (${out.built} tier${out.built === 1 ? '' : 's'}). Links: ${blue} resolved, ${retired} retired, ${red} red.${retiredList}${redList}${stamps}`
|
|
1452
1467
|
: `No change to "${name}"${out?.skipped?.length ? ` (${out.skipped.join(', ')})` : ''}.`
|
|
1453
1468
|
return { content: [{ type: 'text', text: note }] }
|
|
1454
1469
|
},
|