@theronap/cortex-mcp 0.9.145 → 0.9.147

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
@@ -400,6 +400,16 @@ async function captureWork(stdinRaw) {
400
400
  }
401
401
  const extracted = transcript ? extractSession(transcript) : null
402
402
  if (extracted && /^NOOP\b/i.test(extracted.summary)) { process.stderr.write(`cortex: ${stamp} ${sid} NOT RECORDED — summarizer classified it a no-op session\n`); return }
403
+ // ADR-0059 §5.3 — review obligation candidates before they leave the machine: drop what nobody owes,
404
+ // merge a deadline stated twice, reclassify whose it is. Never adds; fails open (obligation_review.mjs).
405
+ // Only when there are candidates, which most sessions never produce.
406
+ let obligations = extracted?.obligations ?? []
407
+ if (obligations.length) {
408
+ const { reviewObligations, describeReview } = await import('./obligation_review.mjs')
409
+ const rev = reviewObligations(obligations, transcript)
410
+ process.stderr.write(`cortex: ${stamp} ${sid} obligations ${describeReview(obligations.length, rev)}\n`)
411
+ obligations = rev.obligations
412
+ }
403
413
  // `pages` is the edge's PROPOSAL of where this record belongs (ADR-0055). Free-text names, resolved
404
414
  // against real pages server-side and dropped when they match nothing — the same untrusted-edge
405
415
  // contract people/entities use. Omitted entirely when empty so an older server sees no new field.
@@ -413,7 +423,7 @@ async function captureWork(stdinRaw) {
413
423
  // ADR-0059. Obligation candidates the extraction found AND verified verbatim against the
414
424
  // transcript it was shown — the server cannot re-check the quote (it never receives the
415
425
  // transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
416
- ...(extracted.obligations?.length ? { obligations: extracted.obligations } : {}),
426
+ ...(obligations.length ? { obligations } : {}),
417
427
  }
418
428
  : { ...common, transcript }
419
429
 
@@ -167,15 +167,26 @@ const PAGES_FRAGMENT =
167
167
  // ⚠ `evidence` IS VERIFIED HERE, NOT SERVER-SIDE (ADR-0059 §4.1). The cloud receives only the derived
168
168
  // digest and never the transcript, so there is nothing server-side to compare a quote against. The
169
169
  // check is `verbatimIn()` below, run against the SAME slice the model was shown.
170
+ //
171
+ // ⚠ "THE 15TH" IS A STATED DAY (2026-09-11). The old wording — "otherwise null — do NOT invent or infer
172
+ // a date … that was not said" — left a model free to read a day with no month as "not said", and one
173
+ // run of the STRAT 421 lecture did exactly that: no due_at AND no due_phrase for "on the 15th" and
174
+ // "by Tuesday the 22nd", while a repeat of the same run filled both. The phrase is what the server
175
+ // resolves, so it is now REQUIRED whenever the evidence says when. The server also reads the verbatim
176
+ // quote itself when a candidate arrives with neither (resolveDueFromQuote in web/lib/engine/due_phrase.ts),
177
+ // so this wording lowers how often that net is needed rather than being the only thing holding the date.
170
178
  const OBLIGATIONS_FRAGMENT =
171
179
  '"obligations" (array of things someone must DO by a date, stated in this session. Each an object ' +
172
180
  'with "subject" (what must be done, one short phrase), ' +
173
- '"due_at" (a DATE, YYYY-MM-DD, when only a day is stated; a date-time, YYYY-MM-DDTHH:MM, ONLY when a ' +
174
- 'time of day is actually said; otherwise null do NOT invent or infer a date or a time that was not ' +
175
- 'said. A day with no time is kept as a bare date and treated as all-day), ' +
181
+ '"due_at" (a DATE, YYYY-MM-DD, when only a day is stated — and a day said without its month or ' +
182
+ 'year, like "the 15th", IS a stated day: complete it from the record\'s own date; a date-time, ' +
183
+ 'YYYY-MM-DDTHH:MM, ONLY when a ' +
184
+ 'time of day is actually said; null only when no day is stated at all — never invent a day or a ' +
185
+ 'time the evidence does not state. A day with no time is kept as a bare date and treated as all-day), ' +
176
186
  '"due_phrase" (the exact words that say WHEN it is due — "on the 15th", "by Tuesday the 22nd", ' +
177
- '"Saturday", "by 5 pm tomorrow" — copied VERBATIM from the evidence sentence; null if the evidence ' +
178
- 'names no time. The date itself is worked out from these words, so copy them, do not rephrase), ' +
187
+ '"Saturday", "by 5 pm tomorrow" — copied VERBATIM from the evidence sentence. REQUIRED whenever the ' +
188
+ 'evidence says when, even when it leaves out the month or year; null ONLY if the evidence names no ' +
189
+ 'time at all. The date itself is worked out from these words, so copy them, do not rephrase), ' +
179
190
  '"evidence" (the sentence from the session that says so, copied EXACTLY and VERBATIM — it is ' +
180
191
  'checked against the transcript and the whole item is discarded if it does not match), ' +
181
192
  '"obligated_party" (exactly one of: "self" if the person whose session this is must do it; ' +
@@ -285,7 +296,9 @@ export function verbatimIn(needle, haystack) {
285
296
  * `obligated_party` outside the preset is malformed and goes — the server's CHECK constraint would
286
297
  * reject it anyway, and failing here means the record still lands with its other keys intact.
287
298
  */
288
- const normWs = (x) => x.toLowerCase().replace(/[’']/g, '').replace(/\s+/g, ' ').trim()
299
+ // Punctuation is spacing here too: "Tuesday, the 22nd" is the same words as "Tuesday the 22nd", and a
300
+ // phrase dropped over a comma leaves the server with only due_at — the gap a phrase exists to close.
301
+ const normWs = (x) => x.toLowerCase().replace(/[’']/g, '').replace(/[,.;:!?"“”()]/g, ' ').replace(/\s+/g, ' ').trim()
289
302
 
290
303
  export function keepVerifiableObligations(items, shown) {
291
304
  if (!Array.isArray(items)) return []
@@ -0,0 +1,184 @@
1
+ // ADR-0059 §5.3 — A SECOND READ OF THE PROPOSALS, BEFORE THEY LEAVE THE MACHINE.
2
+ //
3
+ // Asked for by Theron Peterson 2026-09-10: "why don't we have an agent review the proposed stuff before
4
+ // it adds anything?" The first three real proposals made the case. One was right. One had the wrong date
5
+ // (fixed in code since, due_phrase.ts). One was not his at all — "before the semester's over, we will
6
+ // golf", a speaker's plan quoted out of a lecture, dismissed as "Never was for me". And the same lecture
7
+ // yielded the same deliverable twice, once as announced ("Saturday") and once as later moved ("by
8
+ // Tuesday the 22nd"). Those are JUDGMENT errors: whose is it, is it real, which statement is final. The
9
+ // verbatim gate cannot see them (every quote was real) and date code cannot either.
10
+ //
11
+ // WHAT THE REVIEWER MAY DO — and the one thing it may not:
12
+ // keep leave it as proposed
13
+ // drop nobody is being asked to do anything (a plan, a hope, an anecdote, course content)
14
+ // reclassify obligated_party → other | none (Model B stores those as context, §4.2)
15
+ // merge the same item as candidate k, where k is the final statement of it
16
+ // ✗ ADD or REWORD — never. It returns decisions about candidates by index; it cannot introduce one,
17
+ // or change a subject, quote or date. A reviewer that could add would be a second extractor with
18
+ // none of the first one's gates.
19
+ //
20
+ // 🔴 IT FAILS OPEN. If the review cannot run — no `claude`, an expired credential, a timeout, output
21
+ // that is not the expected JSON — the candidates go on UNREVIEWED, exactly as they did before this file
22
+ // existed, and the log says so. The reviewer improves what the person sees; it must never be the reason
23
+ // a real deadline is lost. For the same reason the prompt says: when unsure, keep. A wrong keep costs the
24
+ // person one dismissal; a wrong drop is gone.
25
+ //
26
+ // It runs OUTSIDE the summarizer lock (the callers invoke it after extractSession returns): holding the
27
+ // lock through a second model call would let it outlive the lock's staleness window, and another session
28
+ // would reclaim a lock that is still in use. It only runs when there ARE candidates, which most records
29
+ // do not produce, so it costs nothing on an ordinary session.
30
+
31
+ import { spawnSync } from 'node:child_process'
32
+ import { edgeSafeEnv, classifyEdgeFailure } from './edge_extract.mjs'
33
+
34
+ export const REVIEW_MODEL_DEFAULT = 'claude-sonnet-5'
35
+ export const REVIEW_TIMEOUT_MS = 180_000
36
+ const CONTEXT_BEFORE = 1500
37
+ const CONTEXT_AFTER = 1000
38
+ const HEADER_CHARS = 600
39
+ const PARTIES = new Set(['self', 'other', 'none'])
40
+
41
+ /** PURE. Where `evidence` starts in `text`, allowing any whitespace between its words; -1 if absent. */
42
+ export function locate(text, evidence) {
43
+ if (typeof text !== 'string' || typeof evidence !== 'string') return -1
44
+ const words = evidence.trim().split(/\s+/).slice(0, 14).map((w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'))
45
+ if (!words.length || !words[0]) return -1
46
+ const m = new RegExp(words.join('\\s+'), 'i').exec(text)
47
+ return m ? m.index : -1
48
+ }
49
+
50
+ /** PURE. The prompt: the record's opening, each candidate with the text around its quote, the rules. */
51
+ export function buildReviewPrompt(cands, text) {
52
+ const t = typeof text === 'string' ? text : ''
53
+ const blocks = cands.map((c, i) => {
54
+ const at = locate(t, c.evidence)
55
+ const context = at >= 0
56
+ ? t.slice(Math.max(0, at - CONTEXT_BEFORE), at + (c.evidence?.length ?? 0) + CONTEXT_AFTER)
57
+ : c.evidence
58
+ const when = c.due_phrase ? `"${c.due_phrase}"` : c.due_at ? `(model read it as ${c.due_at})` : 'no time stated'
59
+ return `[${i}] subject: "${c.subject}" | obligated_party: ${c.obligated_party} | when: ${when}\n` +
60
+ `quote: "${c.evidence}"\ncontext: «${context.replace(/\s+/g, ' ').trim()}»`
61
+ })
62
+ return [
63
+ 'You are the second reader of obligations that an extractor proposed from one record — a lecture, a ' +
64
+ 'meeting or a work session. The person the record belongs to will see what you keep. You may only ' +
65
+ 'KEEP, DROP, MERGE or RECLASSIFY a candidate. You may never add one, and never change its wording, ' +
66
+ 'its quote or its date.',
67
+ '',
68
+ `The record begins:\n«${t.slice(0, HEADER_CHARS).replace(/\s+/g, ' ').trim()}»`,
69
+ '',
70
+ 'Candidates, each with the text around its quote:',
71
+ '',
72
+ blocks.join('\n\n'),
73
+ '',
74
+ 'Decide each candidate:',
75
+ '- DROP it if nobody is actually being asked to do anything: a plan, a hope, an anecdote, a ' +
76
+ "hypothetical, a speaker's own intention, course content, or history.",
77
+ '- RECLASSIFY it to "other" if someone other than the person this record belongs to must do it, or to ' +
78
+ '"none" if nobody must. In a lecture or meeting that person is an attendee, and a "you" said to the ' +
79
+ 'room includes them.',
80
+ '- MERGE it into candidate k if it is the SAME item as k and k is the later or final statement of it — ' +
81
+ 'for example a deadline that was announced and then changed.',
82
+ '- Otherwise KEEP it.',
83
+ 'When unsure, KEEP: the person reviews what you keep, and a wrong drop is lost for good.',
84
+ '',
85
+ 'Return ONLY minified JSON, one decision per candidate: ' +
86
+ '{"decisions":[{"i":0,"verdict":"keep|drop|merge|reclassify","into":null,"party":null,"why":"at most 12 words"}]}',
87
+ ].join('\n')
88
+ }
89
+
90
+ /** PURE. The decisions array out of the model's reply, or null. */
91
+ export function parseReview(out) {
92
+ if (typeof out !== 'string') return null
93
+ const a = out.indexOf('{')
94
+ const b = out.lastIndexOf('}')
95
+ if (a < 0 || b <= a) return null
96
+ try {
97
+ const j = JSON.parse(out.slice(a, b + 1))
98
+ return Array.isArray(j?.decisions) ? j.decisions : null
99
+ } catch {
100
+ return null
101
+ }
102
+ }
103
+
104
+ /**
105
+ * PURE. Apply decisions to candidates. Anything malformed is IGNORED, which means KEPT: an index out of
106
+ * range, a merge into itself, into a dropped candidate, or into one that is itself merged away (no chains,
107
+ * so two candidates merging into each other both survive rather than both vanishing).
108
+ */
109
+ export function applyReview(cands, decisions) {
110
+ const byI = new Map()
111
+ for (const d of Array.isArray(decisions) ? decisions : []) {
112
+ if (d && Number.isInteger(d.i) && d.i >= 0 && d.i < cands.length && !byI.has(d.i)) byI.set(d.i, d)
113
+ }
114
+ const verdict = (i) => byI.get(i)?.verdict
115
+ const dropped = new Set([...byI.keys()].filter((i) => verdict(i) === 'drop'))
116
+ const merged = []
117
+ for (const [i, d] of byI) {
118
+ const k = d.into
119
+ if (d.verdict === 'merge' && Number.isInteger(k) && k !== i && k >= 0 && k < cands.length && !dropped.has(k) && verdict(k) !== 'merge') {
120
+ merged.push([i, k])
121
+ }
122
+ }
123
+ const mergedAway = new Set(merged.map(([i]) => i))
124
+ const reclassified = []
125
+ const kept = []
126
+ cands.forEach((c, i) => {
127
+ if (dropped.has(i) || mergedAway.has(i)) return
128
+ const d = byI.get(i)
129
+ if (d?.verdict === 'reclassify' && PARTIES.has(d.party) && d.party !== c.obligated_party) {
130
+ reclassified.push([i, d.party])
131
+ kept.push({ ...c, obligated_party: d.party })
132
+ } else {
133
+ kept.push(c)
134
+ }
135
+ })
136
+ return { kept, changes: { dropped: [...dropped], merged, reclassified } }
137
+ }
138
+
139
+ function runClaude(prompt, model) {
140
+ return spawnSync('claude', ['--print', '--model', model], {
141
+ input: prompt,
142
+ // CORTEX_SUMMARIZING: the reviewer's own `claude` session must not re-enter the capture hooks.
143
+ env: edgeSafeEnv(process.env, { CORTEX_SUMMARIZING: '1' }),
144
+ encoding: 'utf8',
145
+ timeout: REVIEW_TIMEOUT_MS,
146
+ maxBuffer: 1024 * 1024,
147
+ })
148
+ }
149
+
150
+ /**
151
+ * Review candidates against the text they came from. Never throws, never adds; on any failure returns
152
+ * the candidates unchanged with `reviewed: false` and the reason.
153
+ *
154
+ * opts: { run(prompt, model) → spawnSync-shaped result, env }
155
+ */
156
+ export function reviewObligations(cands, text, opts = {}) {
157
+ const env = opts.env ?? process.env
158
+ if (!Array.isArray(cands) || cands.length === 0) return { obligations: Array.isArray(cands) ? cands : [], reviewed: false, reason: 'nothing to review' }
159
+ if (env.CORTEX_REVIEW_DISABLED || env.CORTEX_SUMMARIZE_DISABLED) return { obligations: cands, reviewed: false, reason: 'disabled' }
160
+ const model = env.CORTEX_REVIEW_MODEL || REVIEW_MODEL_DEFAULT
161
+ let r
162
+ try {
163
+ r = (opts.run ?? runClaude)(buildReviewPrompt(cands, text), model)
164
+ } catch (e) {
165
+ return { obligations: cands, reviewed: false, reason: `threw: ${e instanceof Error ? e.message : String(e)}` }
166
+ }
167
+ const why = classifyEdgeFailure(r)
168
+ if (why) return { obligations: cands, reviewed: false, reason: why.reason }
169
+ const decisions = parseReview(r.stdout)
170
+ if (!decisions) return { obligations: cands, reviewed: false, reason: 'unparseable' }
171
+ const { kept, changes } = applyReview(cands, decisions)
172
+ return { obligations: kept, reviewed: true, changes, model }
173
+ }
174
+
175
+ /** PURE. One log line for a review outcome. */
176
+ export function describeReview(before, rev) {
177
+ if (!rev.reviewed) return `review skipped [${rev.reason}] — ${before} candidate(s) go on unreviewed`
178
+ const c = rev.changes
179
+ const parts = []
180
+ if (c.dropped.length) parts.push(`dropped ${c.dropped.join(',')}`)
181
+ if (c.merged.length) parts.push(`merged ${c.merged.map(([i, k]) => `${i}→${k}`).join(',')}`)
182
+ if (c.reclassified.length) parts.push(`reclassified ${c.reclassified.map(([i, p]) => `${i}→${p}`).join(',')}`)
183
+ return `review (${rev.model}): kept ${rev.obligations.length} of ${before}${parts.length ? ` — ${parts.join('; ')}` : ''}`
184
+ }
@@ -25,6 +25,7 @@ import { mkdirSync, openSync, readdirSync, readFileSync, writeFileSync, renameSy
25
25
  import { homedir } from 'node:os'
26
26
  import { join, dirname } from 'node:path'
27
27
  import { fileURLToPath } from 'node:url'
28
+ import { reviewObligations, describeReview } from './obligation_review.mjs'
28
29
 
29
30
  export const MAX_PARK_DAYS = 30
30
31
  export const FIRST_RETRY_MS = 5 * 60_000
@@ -52,6 +53,31 @@ export function classifyPost(status) {
52
53
  return 'retry'
53
54
  }
54
55
 
56
+ /**
57
+ * PURE. The log line for a recorded post: what the server did with the candidates, and what DATE
58
+ * information each one carried.
59
+ *
60
+ * 🔴 WHY (2026-09-11). A re-run of a lecture logged "recorded 2 of 3 as proposals" while two of the three
61
+ * had landed with no due date — one having lost the date an earlier read stored. Nothing on this machine
62
+ * said so; it took a query against the table, and what the model had sent was unrecoverable. So the line
63
+ * now carries the server's counts and, per candidate, its SHAPE — phrase, date, both, or neither. Never the
64
+ * subject or the quote: capture.log is created with default permissions, unlike the 0600 parked files,
65
+ * and a candidate's words are the person's content.
66
+ */
67
+ export function describeRecorded(body, sent) {
68
+ const b = body ?? {}
69
+ const counts = [`${b.proposed ?? '?'} new`]
70
+ // An older server answers without these; say nothing rather than "undefined refreshed".
71
+ if (b.refreshed !== undefined) counts.push(`${b.refreshed} refreshed`)
72
+ if (b.replaced !== undefined) counts.push(`${b.replaced} replaced`)
73
+ const shape = (o) => (o?.due_phrase ? (o.due_at ? 'phrase+date' : 'phrase') : o?.due_at ? 'date' : 'NO date')
74
+ const list = Array.isArray(sent) ? sent : []
75
+ let line = `recorded ${list.length} on record ${b.recordId ?? '?'}: ${counts.join(', ')} — sent ${list.map((o, i) => `#${i} ${shape(o)}`).join(', ')}`
76
+ if (b.fromQuote) line += `; ${b.fromQuote} dated from the quote`
77
+ if (b.undated) line += `; ⚠ ${b.undated} left WITHOUT a date`
78
+ return line
79
+ }
80
+
55
81
  /** PURE. The next retry delay: 5 min, doubling, capped at 6 h. */
56
82
  export function backoffMs(attempts) {
57
83
  return Math.min(FIRST_RETRY_MS * 2 ** Math.max(0, attempts - 1), MAX_RETRY_MS)
@@ -103,14 +129,22 @@ export async function processJob(job, deps) {
103
129
  log(`extraction FAILED [${why?.reason ?? 'unknown'}] — this record was NOT checked for obligations`)
104
130
  return { outcome: 'extract-failed', reason: why?.reason ?? null }
105
131
  }
106
- const obligations = Array.isArray(result.obligations) ? result.obligations : []
132
+ let obligations = Array.isArray(result.obligations) ? result.obligations : []
107
133
  if (obligations.length === 0) {
108
134
  log('no obligations found')
109
135
  return { outcome: 'none' }
110
136
  }
137
+ // ADR-0059 §5.3 — the second read (obligation_review.mjs), before anything leaves the machine. It may
138
+ // drop, merge or reclassify, never add; it fails open. Optional here so a caller without it is unchanged.
139
+ const review = deps.review ?? ((o) => ({ obligations: o, reviewed: false, reason: 'no reviewer' }))
140
+ const before = obligations.length
141
+ const rev = review(obligations, job.text)
142
+ log(describeReview(before, rev))
143
+ obligations = rev.obligations
144
+ if (obligations.length === 0) return { outcome: 'none', reviewed: true }
111
145
  const res = await post(job.target, obligations)
112
146
  if (res.action === 'recorded') {
113
- log(`recorded ${res.body?.proposed ?? '?'} of ${obligations.length} as proposals on record ${res.body?.recordId ?? '?'}`)
147
+ log(describeRecorded(res.body, obligations))
114
148
  return { outcome: 'recorded', proposed: res.body?.proposed ?? null }
115
149
  }
116
150
  if (res.action === 'drop') {
@@ -161,7 +195,8 @@ export async function flushParkedObligations(deps) {
161
195
  try { unlinkSync(file) } catch { /* raced with another flush; the write is idempotent */ }
162
196
  flushed += 1
163
197
  log(res.action === 'recorded'
164
- ? `recorded ${res.body?.proposed ?? '?'} parked obligations for ${id} on attempt ${(entry.attempts ?? 0) + 1}`
198
+ ? `recorded ${res.body?.proposed ?? '?'} parked obligations for ${id} on attempt ${(entry.attempts ?? 0) + 1}` +
199
+ (res.body?.undated ? ` — ⚠ ${res.body.undated} left WITHOUT a date` : '')
165
200
  : `DROPPED parked obligations for ${id} — the server answered ${res.status} ${res.body?.error ?? ''}`.trim())
166
201
  continue
167
202
  }
@@ -224,6 +259,7 @@ async function realDeps(target) {
224
259
  return {
225
260
  extract: extractSession,
226
261
  lastSkip: lastEdgeSkip,
262
+ review: (obligations, text) => reviewObligations(obligations, text),
227
263
  post: (t, obligations) => postCandidates({ base, token, target: t, obligations, fetchImpl: fetchCortex }),
228
264
  park: (entry) => writeParked(parkDir(), entry),
229
265
  log: logger(target),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/cortex-mcp",
3
- "version": "0.9.145",
3
+ "version": "0.9.147",
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": {