@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 +4 -1
- package/lib/diagnose.mjs +15 -13
- package/lib/server.mjs +59 -0
- package/package.json +1 -1
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
|
|
72
|
-
if (isJson) { try {
|
|
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
|
-
|
|
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
|
{
|