@theronap/cortex-mcp 0.9.40 → 0.9.42

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/lib/capture.mjs CHANGED
@@ -189,11 +189,14 @@ async function captureWork(stdinRaw) {
189
189
 
190
190
  let res
191
191
  try {
192
+ // Best-effort background ingest: bound it tight and retry once so a hanging/504-ing server
193
+ // makes a DETACHED worker self-terminate in ~20s instead of lingering on 3 unbounded attempts.
192
194
  res = await fetchCortex(`${base}/api/ingest`, {
193
195
  method: 'POST',
194
196
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
195
197
  body: JSON.stringify(ingestBody),
196
- })
198
+ timeoutMs: 10_000,
199
+ }, { retries: 1 })
197
200
  } catch (e) {
198
201
  // Never break a session — just report and move on.
199
202
  process.stderr.write(`cortex: ${e.message}\n`)
package/lib/diagnose.mjs CHANGED
@@ -68,20 +68,10 @@ const sleep = (ms) => new Promise((r) => setTimeout(r, ms))
68
68
  // 'app' → a real Cortex API error with a JSON message (retriable only if 5xx)
69
69
  export function classify(status, contentType, bodyText, requestId) {
70
70
  const isJson = (contentType ?? '').includes('application/json')
71
- let body = null
72
- if (isJson) { try { body = JSON.parse(bodyText) } catch { /* not json after all */ } }
73
- const appError = body?.error ?? null
71
+ let appError = null
72
+ if (isJson) { try { appError = JSON.parse(bodyText)?.error ?? null } catch { /* not json after all */ } }
74
73
  const rid = requestId ? ` [request id: ${requestId}]` : ''
75
74
 
76
- // Multi-brain: 409 no_active_brain means the caller belongs to >1 brain and hasn't chosen one. It is
77
- // NOT an auth failure — surface the action (set_active_brain) directly, not a "Cortex API 409:" wrapper.
78
- if (status === 409 && body?.code === 'no_active_brain') {
79
- return {
80
- kind: 'app', retriable: false,
81
- message: `${appError ?? 'You belong to more than one brain — call set_active_brain to choose where your writes land.'} (see my_brains for your options)${rid}`,
82
- }
83
- }
84
-
85
75
  if (status === 401 || (isJson && appError === 'invalid token')) {
86
76
  return {
87
77
  kind: 'auth', retriable: false,
@@ -107,11 +97,23 @@ export function classify(status, contentType, bodyText, requestId) {
107
97
  // fetch with retry on TRANSIENT responses only (429, 5xx, and infra-style non-JSON 403).
108
98
  // App-level 401/403-with-JSON are returned immediately (retrying won't change the verdict).
109
99
  // Throws a clear network error if the host is unreachable after retries.
100
+ //
101
+ // PER-ATTEMPT TIMEOUT (added 2026-07-08): every attempt is bounded by an AbortSignal so a
102
+ // HANGING server (not a fast 5xx — an ingest endpoint that just never responds, observed this day)
103
+ // can't stall a hook indefinitely. Without it, `fetch` waits forever and the retry loop made it
104
+ // WORSE — 3 unbounded attempts stacked. Now worst case = (retries+1) * timeoutMs + backoff, and a
105
+ // timed-out attempt is treated as transient (retried, then surfaced as the reach error the callers
106
+ // already swallow). Tunable via CORTEX_HTTP_TIMEOUT_MS; per-call override via opts.timeoutMs.
110
107
  export async function fetchCortex(url, opts = {}, { retries = 2, baseDelayMs = 400 } = {}) {
108
+ const { timeoutMs: optTimeout, signal: callerSignal, ...fetchOpts } = opts
109
+ const timeoutMs = optTimeout ?? (Number(process.env.CORTEX_HTTP_TIMEOUT_MS) || 15_000)
111
110
  let lastErr
112
111
  for (let attempt = 0; attempt <= retries; attempt++) {
113
112
  try {
114
- const res = await fetch(url, opts)
113
+ // Fresh timeout signal per attempt; compose with any caller-supplied signal.
114
+ const timeoutSignal = AbortSignal.timeout(timeoutMs)
115
+ const signal = callerSignal ? AbortSignal.any([callerSignal, timeoutSignal]) : timeoutSignal
116
+ const res = await fetch(url, { ...fetchOpts, signal })
115
117
  const ct = res.headers.get('content-type') ?? ''
116
118
  const transient =
117
119
  res.status === 429 ||
package/lib/server.mjs CHANGED
@@ -152,6 +152,65 @@ export async function runServer(version) {
152
152
  },
153
153
  )
154
154
 
155
+ // Pull/claim attribution ([[cortex-subscription-inference]]): the agent decides which project a
156
+ // messaging thread is about — on ITS subscription, zero server-metered inference — and records it
157
+ // here. The server stamps `project:<slug>` onto the thread's timeline events via the locked-down
158
+ // cortex_labeler role, so the thread threads onto that project's node timeline (retroactive @> join).
159
+ server.registerTool(
160
+ 'attribute_thread',
161
+ {
162
+ title: 'Attribute a messaging thread to a project',
163
+ description: "Record that a messaging thread (email, etc.) belongs to a project — stamps project:<slug> onto the thread's timeline events so its whole history threads onto that project's node timeline. Attribute AS YOU WORK: when you recognize which known project a thread you're looking at is about, attribute it. KNOWN projects only — if you're not confident, don't (a wrong tag pollutes a timeline; leaving it unattributed self-heals). Idempotent (safe to re-call) and reversible (remove:true undoes an attribution you made).",
164
+ inputSchema: {
165
+ threadKey: z.string().describe('the thread identifier — a Gmail threadId, or a full `thread:<id>` key'),
166
+ project: z.string().describe('the slug/key of the EXISTING project the thread belongs to'),
167
+ remove: z.boolean().optional().describe('true to REMOVE a project attribution you previously made (reversibility)'),
168
+ },
169
+ },
170
+ async ({ threadKey, project, remove }) => {
171
+ const res = await fetchCortex(`${BASE}/api/timeline/attribute`, {
172
+ method: 'POST',
173
+ headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
174
+ body: JSON.stringify({ threadKey, project, ...(remove ? { remove: true } : {}) }),
175
+ })
176
+ if (!res.ok) {
177
+ const body = await res.text()
178
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
179
+ }
180
+ const j = await res.json().catch(() => ({}))
181
+ const verb = remove ? 'Removed' : 'Attributed'
182
+ const prep = remove ? 'from' : 'to'
183
+ return { content: [{ type: 'text', text: `${verb} ${j.threadKey ?? threadKey} ${prep} ${j.project ?? `project:${project}`} — ${j.stamped ?? 0} event(s) ${remove ? 'un' : ''}stamped.` }] }
184
+ },
185
+ )
186
+
187
+ // The PULL half: surface the org's unattributed messaging backlog so the agent can triage it and
188
+ // attribute the threads it recognizes (pairs with attribute_thread above).
189
+ server.registerTool(
190
+ 'timeline_pull',
191
+ {
192
+ title: 'Pull unattributed messaging threads to triage',
193
+ description: "Surface messaging threads captured in the org (email, etc.) that AREN'T yet attributed to a project — the backlog awaiting your judgment. Call it as you work; when you recognize which KNOWN project a surfaced thread is about, attribute it with attribute_thread. Threads you don't recognize: leave them (they self-heal as the graph fills). Returns participants + subject + recency per thread — enough to recognize, not the message bodies.",
194
+ inputSchema: { limit: z.number().optional().describe('max threads to return (default 20)') },
195
+ },
196
+ async ({ limit }) => {
197
+ const qs = typeof limit === 'number' ? `?limit=${limit}` : ''
198
+ const res = await fetchCortex(`${BASE}/api/timeline/pull${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
199
+ if (!res.ok) {
200
+ const body = await res.text()
201
+ throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
202
+ }
203
+ const { threads } = await res.json()
204
+ if (!threads?.length) return { content: [{ type: 'text', text: 'No unattributed threads in the backlog.' }] }
205
+ const lines = threads.map((t) => {
206
+ const who = (t.participants ?? []).map((p) => String(p).replace(/^email:/, '')).join(', ')
207
+ const when = t.lastAt ? String(t.lastAt).slice(0, 10) : '—'
208
+ return `- ${t.threadKey} — ${t.subject ?? '(no subject)'} · ${who || 'unknown participants'} · ${t.eventCount} msg · ${when}`
209
+ })
210
+ return { content: [{ type: 'text', text: `Unattributed threads (${threads.length}) — attribute recognized ones with attribute_thread(threadKey, project):\n${lines.join('\n')}` }] }
211
+ },
212
+ )
213
+
155
214
  server.registerTool(
156
215
  'search_org',
157
216
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.40",
3
+ "version": "0.9.42",
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": {