@theronap/cortex-mcp 0.9.55 → 0.9.57

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -52,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/capture.mjs CHANGED
@@ -1,5 +1,5 @@
1
1
  import { readFileSync } from 'fs'
2
- import { spawn } from 'child_process'
2
+ import { spawn, execFileSync } from 'child_process'
3
3
  import { homedir } from 'os'
4
4
  import { resolve, dirname, join } from 'path'
5
5
  import { fileURLToPath } from 'url'
@@ -37,6 +37,45 @@ export function projectFrom(cwd) {
37
37
 
38
38
  // Claude Code Stop hook → POSTs a session digest to Cortex cloud, which
39
39
  // summarizes server-side and upserts ONE record per session. Node-native
40
+ // SHR-01/T6 — parse a git remote URL into GitHub 'owner/name', or null.
41
+ //
42
+ // Handles the four remote forms git emits: scp-like ssh (git@github.com:o/n.git), ssh://, https://,
43
+ // git://. The HOST CHECK IS LOAD-BEARING, not cosmetic: this string becomes a clearance key, and
44
+ // 'owner/name' on gitlab.com or a self-hosted forge would collide in the identifier namespace with an
45
+ // unrelated GitHub repo of the same name — granting its members read access to each other's sessions.
46
+ export function githubFullName(url) {
47
+ if (!url) return null
48
+ const m = String(url).trim()
49
+ .match(/^(?:git\+)?(?:https?:\/\/|ssh:\/\/|git:\/\/)?(?:[^@/]+@)?github\.com[:/]+([^/]+)\/(.+?)(?:\.git)?\/?$/i)
50
+ if (!m) return null
51
+ const owner = m[1].toLowerCase()
52
+ const name = m[2].toLowerCase()
53
+ if (!owner || !name || name.includes('/')) return null
54
+ return `${owner}/${name}`
55
+ }
56
+
57
+ // SHR-01/T6 — the repo this session is working in, as 'owner/name', or null.
58
+ //
59
+ // D2, FAIL CLOSED: anything that is not unambiguously a GitHub worktree — no repo, no origin remote,
60
+ // a non-GitHub host, git not installed — returns null, and the session is then stamped with NO
61
+ // identifier and stays private. There is deliberately no fallback: a brain-level or hostname-level
62
+ // identifier would be held by every member of the org, so overlap would ALWAYS succeed. That is an
63
+ // accidental org-wide grant, i.e. the default-tier flip that was explicitly declined.
64
+ //
65
+ // `git config --get` is local and does no network I/O. Bounded and swallowed regardless: capture must
66
+ // never break a session, and a missing identifier is a private session, not a broken one.
67
+ export function repoFullNameFrom(cwd) {
68
+ if (!cwd) return null
69
+ try {
70
+ const url = execFileSync('git', ['-C', String(cwd), 'config', '--get', 'remote.origin.url'], {
71
+ encoding: 'utf8', timeout: 2000, stdio: ['ignore', 'pipe', 'ignore'],
72
+ })
73
+ return githubFullName(url)
74
+ } catch {
75
+ return null
76
+ }
77
+ }
78
+
40
79
  // (no bun, no repo clone). Always exits 0 — capture must never break a session.
41
80
 
42
81
  function readStdin() {
@@ -159,6 +198,10 @@ async function captureWork(stdinRaw) {
159
198
  // summarizer (which has been the silent point of failure). Fall back to shipping the transcript
160
199
  // tail only if local extraction is unavailable (e.g. `claude` not on PATH) so we never drop a
161
200
  // session. The server re-validates people/entities — the edge is not trusted.
201
+ // SHR-01/T6: the repo identifier is what lets a PEER read this session (see 0096). Omitted entirely
202
+ // when the cwd is not a GitHub worktree — the record still lands, it just stays private (D2).
203
+ const repoFullName = repoFullNameFrom(hook.cwd)
204
+
162
205
  const common = {
163
206
  source: 'claude-code',
164
207
  project: repo,
@@ -166,6 +209,7 @@ async function captureWork(stdinRaw) {
166
209
  title: `Worked in ${repo}`,
167
210
  payload: { session_id: hook.session_id, cwd: hook.cwd },
168
211
  captureSource: 'hook', // T8: fallback writer — never clobbers a cortex-log ('skill') record
212
+ ...(repoFullName ? { repoFullName } : {}),
169
213
  ...(hydratedFrom.length ? { hydratedFrom } : {}),
170
214
  }
171
215
  const extracted = transcript ? extractSession(transcript) : null
@@ -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
@@ -867,7 +867,7 @@ export async function runServer(version) {
867
867
  'list_records',
868
868
  {
869
869
  title: 'List activity records (filtered)',
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 titles + ids (use an id with story/request_file).',
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).',
871
871
  inputSchema: {
872
872
  type: z.string().optional().describe("record type, e.g. 'meeting', 'comm', 'activity', 'ai_session', 'doc', 'note'"),
873
873
  project: z.string().optional().describe('project key to filter to (e.g. "cortex")'),
@@ -957,9 +957,9 @@ export async function runServer(version) {
957
957
  'set_record_privacy',
958
958
  {
959
959
  title: 'Set a record\'s privacy tier',
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 `story` result\'s Sources list. Only affects records you own — others return an error.',
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.',
961
961
  inputSchema: {
962
- record_id: z.string().describe('the record id (uuid), taken from a story result source'),
962
+ record_id: z.string().describe('the record id (uuid), e.g. the "id:" on a list_records line'),
963
963
  privacy: z.enum(['accessible', 'scoped', 'confidential']).describe('the new access tier'),
964
964
  },
965
965
  },
@@ -1263,7 +1263,7 @@ export async function runServer(version) {
1263
1263
  'request_file',
1264
1264
  {
1265
1265
  title: 'Request the full original of a record',
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 story Sources list or the "id:" on a my_context activity line.',
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.',
1267
1267
  inputSchema: { record_id: z.string().describe('the record id (uuid)') },
1268
1268
  },
1269
1269
  async ({ record_id }) => {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.55",
3
+ "version": "0.9.57",
4
4
  "description": "Connect your AI assistant to Cortex — your org's projects, activity, gaps, and directives, scoped to you.",
5
5
  "type": "module",
6
6
  "bin": {