@theronap/cortex-mcp 0.9.93 → 0.9.95
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 +31 -1
- package/lib/server.mjs +34 -0
- package/package.json +1 -1
package/lib/capture.mjs
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import { readFileSync } from 'fs'
|
|
1
|
+
import { readFileSync, accessSync, constants } from 'fs'
|
|
2
2
|
import { spawn, execFileSync } from 'child_process'
|
|
3
3
|
import { homedir } from 'os'
|
|
4
4
|
import { resolve, dirname, join } from 'path'
|
|
@@ -180,6 +180,16 @@ function readStdin() {
|
|
|
180
180
|
// though only the tail ships) and append the flagged spans.
|
|
181
181
|
const LIFECYCLE_RE = /\b(paus\w*|resum\w*|shipp?\w*|kill\w*|cancel\w*|on hold|sunset\w*)\b/i
|
|
182
182
|
|
|
183
|
+
// Whether the Stop hook's transcript_path actually resolves to a READABLE file.
|
|
184
|
+
//
|
|
185
|
+
// This distinguishes "no transcript was ever written" from "transcript exists but yielded no prose" —
|
|
186
|
+
// transcriptTail() flattens both into the same empty string, which is exactly why the empty-session
|
|
187
|
+
// guard below could never see the first case.
|
|
188
|
+
export function transcriptReadable(path) {
|
|
189
|
+
if (!path) return false
|
|
190
|
+
try { accessSync(path, constants.R_OK); return true } catch { return false }
|
|
191
|
+
}
|
|
192
|
+
|
|
183
193
|
function transcriptTail(path) {
|
|
184
194
|
let raw = ''
|
|
185
195
|
try { raw = readFileSync(path, 'utf8') } catch { return '' }
|
|
@@ -272,6 +282,26 @@ async function captureWork(stdinRaw) {
|
|
|
272
282
|
// must never be transmitted or stored. See redact.mjs.
|
|
273
283
|
const transcript = hook.transcript_path ? redactSecrets(transcriptTail(hook.transcript_path)) : ''
|
|
274
284
|
|
|
285
|
+
// A transcript_path that was SUPPLIED but is unreadable means the session ran with transcript writes
|
|
286
|
+
// disabled — `claude --print --no-session-persistence`, or the SDK's `persistSession:false`. Verified
|
|
287
|
+
// A/B 2026-08-18: with the flag, Claude Code writes no JSONL at all; without it, one appears.
|
|
288
|
+
//
|
|
289
|
+
// The guard below cannot catch that case, and never could: `session_id` is ALWAYS present in a Stop
|
|
290
|
+
// payload, so `!transcript && !hook.session_id` can only ever fire on the session_id half. Capture
|
|
291
|
+
// therefore sailed straight on and shipped `{...common, transcript: ''}` — a contentless record that
|
|
292
|
+
// ALSO lost its repo stamp (repoFullNamesFrom throws on the missing file and is swallowed), leaving it
|
|
293
|
+
// permanently private. That is worse than capturing nothing: it reports `cortex: captured`, and it
|
|
294
|
+
// inflates the very record count that capture health is judged by (there is no freshness monitor).
|
|
295
|
+
//
|
|
296
|
+
// Safe to skip rather than degrade, because capture is not one-shot: the Stop hook fires after EVERY
|
|
297
|
+
// assistant turn and the server upserts ONE record per session_id. A transcript that is merely
|
|
298
|
+
// unreadable for a moment is picked up by the next turn, so a transient miss self-heals; only a
|
|
299
|
+
// genuinely persistence-disabled session is dropped, and that session has no content to capture.
|
|
300
|
+
if (hook.transcript_path && !transcriptReadable(hook.transcript_path)) {
|
|
301
|
+
process.stderr.write('cortex: transcript_path unreadable (session persistence disabled?), skipping\n')
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
|
|
275
305
|
if (!transcript && !hook.session_id) { process.stderr.write('cortex: empty session, skipping\n'); return }
|
|
276
306
|
|
|
277
307
|
// T11: digest node refs surfaced to this session (stashed by the MCP server's my_context, keyed by
|
package/lib/server.mjs
CHANGED
|
@@ -916,6 +916,40 @@ export async function runServer(version) {
|
|
|
916
916
|
},
|
|
917
917
|
)
|
|
918
918
|
|
|
919
|
+
server.registerTool(
|
|
920
|
+
'search_spam_email',
|
|
921
|
+
{
|
|
922
|
+
title: 'Search Gmail Spam intentionally',
|
|
923
|
+
description: 'Search a connected Gmail Spam folder only when the user explicitly asks you to find a message that may have been marked as Spam. This is read-only: results are returned for this request only and are NOT added to Agnoclast records, timeline, or future context. Give a specific Gmail search such as a sender, subject words, or date. Normal Gmail sync never reads Spam.',
|
|
924
|
+
inputSchema: {
|
|
925
|
+
query: z.string().min(2).describe('specific Gmail search within Spam, e.g. `from:billing@example.com`, `subject:(appointment reminder)`, or `after:2026/08/01`'),
|
|
926
|
+
account: z.string().email().optional().describe('which connected Gmail account to search when more than one is available'),
|
|
927
|
+
limit: z.number().int().min(1).max(10).optional().describe('maximum matches to return (default 5; max 10)'),
|
|
928
|
+
},
|
|
929
|
+
},
|
|
930
|
+
async ({ query, account, limit }) => {
|
|
931
|
+
const res = await fetchCortex(`${BASE}/api/gmail/search-spam`, {
|
|
932
|
+
method: 'POST',
|
|
933
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
934
|
+
body: JSON.stringify({ query, ...(account ? { account } : {}), ...(limit ? { limit } : {}) }),
|
|
935
|
+
})
|
|
936
|
+
const body = await res.json().catch(() => null)
|
|
937
|
+
if (!res.ok) {
|
|
938
|
+
if (res.status === 409 && Array.isArray(body?.accounts)) {
|
|
939
|
+
return toolError(`Choose a connected Gmail account and call again with account: ${body.accounts.join(', ')}`)
|
|
940
|
+
}
|
|
941
|
+
return toolError(body?.error ?? `Spam search failed (${res.status}).`)
|
|
942
|
+
}
|
|
943
|
+
const rows = Array.isArray(body?.messages) ? body.messages : []
|
|
944
|
+
if (!rows.length) return { content: [{ type: 'text', text: `No Spam matches in ${body?.account ?? 'the connected Gmail account'}. Nothing was saved to Agnoclast.` }] }
|
|
945
|
+
const lines = [`Spam matches in ${body.account} (read-only; not saved to Agnoclast):`]
|
|
946
|
+
for (const m of rows) {
|
|
947
|
+
lines.push(`- ${m.date || 'unknown date'} · ${m.from || 'unknown sender'} · ${m.subject}\n ${m.snippet || '(no preview)'}\n ${m.sourceUri}`)
|
|
948
|
+
}
|
|
949
|
+
return { content: [{ type: 'text', text: lines.join('\n') }] }
|
|
950
|
+
},
|
|
951
|
+
)
|
|
952
|
+
|
|
919
953
|
server.registerTool(
|
|
920
954
|
'grep',
|
|
921
955
|
{
|