@theronap/cortex-mcp 0.9.144 → 0.9.146
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 +11 -1
- package/lib/edge_extract.mjs +19 -1
- package/lib/obligation_review.mjs +184 -0
- package/lib/obligations_worker.mjs +11 -1
- package/package.json +1 -1
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
|
-
...(
|
|
426
|
+
...(obligations.length ? { obligations } : {}),
|
|
417
427
|
}
|
|
418
428
|
: { ...common, transcript }
|
|
419
429
|
|
package/lib/edge_extract.mjs
CHANGED
|
@@ -173,6 +173,9 @@ const OBLIGATIONS_FRAGMENT =
|
|
|
173
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
174
|
'time of day is actually said; otherwise null — do NOT invent or infer a date or a time that was not ' +
|
|
175
175
|
'said. A day with no time is kept as a bare date and treated as all-day), ' +
|
|
176
|
+
'"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), ' +
|
|
176
179
|
'"evidence" (the sentence from the session that says so, copied EXACTLY and VERBATIM — it is ' +
|
|
177
180
|
'checked against the transcript and the whole item is discarded if it does not match), ' +
|
|
178
181
|
'"obligated_party" (exactly one of: "self" if the person whose session this is must do it; ' +
|
|
@@ -180,6 +183,9 @@ const OBLIGATIONS_FRAGMENT =
|
|
|
180
183
|
'The hard part is "none", so read it carefully: a real date about a real person is still "none" ' +
|
|
181
184
|
'when nobody owes anything — "so-and-so\'s birthday is today" has every surface feature of a ' +
|
|
182
185
|
'deadline and is not one. Historical and course-content dates are "none" too. ' +
|
|
186
|
+
'If the SAME item is given a deadline more than once, or its deadline is changed later ("due ' +
|
|
187
|
+
'Saturday", then later "actually, plan on Tuesday the 22nd"), report it ONCE, using the LAST statement ' +
|
|
188
|
+
'for its evidence, due_phrase and due_at. ' +
|
|
183
189
|
'Include an item when the session genuinely states an obligation, and do not stretch to find one; ' +
|
|
184
190
|
'an empty array is correct for a session that contains no deadlines.)'
|
|
185
191
|
|
|
@@ -279,6 +285,8 @@ export function verbatimIn(needle, haystack) {
|
|
|
279
285
|
* `obligated_party` outside the preset is malformed and goes — the server's CHECK constraint would
|
|
280
286
|
* reject it anyway, and failing here means the record still lands with its other keys intact.
|
|
281
287
|
*/
|
|
288
|
+
const normWs = (x) => x.toLowerCase().replace(/[’']/g, '').replace(/\s+/g, ' ').trim()
|
|
289
|
+
|
|
282
290
|
export function keepVerifiableObligations(items, shown) {
|
|
283
291
|
if (!Array.isArray(items)) return []
|
|
284
292
|
const out = []
|
|
@@ -290,7 +298,17 @@ export function keepVerifiableObligations(items, shown) {
|
|
|
290
298
|
if (!subject || !evidence || !OBLIGATION_PARTIES.has(party)) continue
|
|
291
299
|
if (!verbatimIn(evidence, shown)) continue
|
|
292
300
|
const due = typeof it.due_at === 'string' && it.due_at.trim() ? it.due_at.trim() : null
|
|
293
|
-
|
|
301
|
+
// ADR-0059 §5.2: the WHEN words, which the server resolves into a date in code. Kept only if they
|
|
302
|
+
// appear inside the evidence sentence — the check that stops a paraphrase ("Sept 22" for "the
|
|
303
|
+
// 22nd") or a phrase from some other sentence from deciding the date. Dropped otherwise: the server
|
|
304
|
+
// then falls back to due_at, exactly as it did before phrases existed.
|
|
305
|
+
const phraseRaw = typeof it.due_phrase === 'string' ? it.due_phrase.trim() : ''
|
|
306
|
+
const due_phrase = phraseRaw && normWs(evidence).includes(normWs(phraseRaw)) ? phraseRaw.slice(0, 120) : null
|
|
307
|
+
out.push({
|
|
308
|
+
subject: subject.slice(0, 200), due_at: due,
|
|
309
|
+
...(due_phrase ? { due_phrase } : {}),
|
|
310
|
+
evidence: evidence.slice(0, 500), obligated_party: party,
|
|
311
|
+
})
|
|
294
312
|
if (out.length >= MAX_OBLIGATIONS) break
|
|
295
313
|
}
|
|
296
314
|
return out
|
|
@@ -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
|
|
@@ -103,11 +104,19 @@ export async function processJob(job, deps) {
|
|
|
103
104
|
log(`extraction FAILED [${why?.reason ?? 'unknown'}] — this record was NOT checked for obligations`)
|
|
104
105
|
return { outcome: 'extract-failed', reason: why?.reason ?? null }
|
|
105
106
|
}
|
|
106
|
-
|
|
107
|
+
let obligations = Array.isArray(result.obligations) ? result.obligations : []
|
|
107
108
|
if (obligations.length === 0) {
|
|
108
109
|
log('no obligations found')
|
|
109
110
|
return { outcome: 'none' }
|
|
110
111
|
}
|
|
112
|
+
// ADR-0059 §5.3 — the second read (obligation_review.mjs), before anything leaves the machine. It may
|
|
113
|
+
// drop, merge or reclassify, never add; it fails open. Optional here so a caller without it is unchanged.
|
|
114
|
+
const review = deps.review ?? ((o) => ({ obligations: o, reviewed: false, reason: 'no reviewer' }))
|
|
115
|
+
const before = obligations.length
|
|
116
|
+
const rev = review(obligations, job.text)
|
|
117
|
+
log(describeReview(before, rev))
|
|
118
|
+
obligations = rev.obligations
|
|
119
|
+
if (obligations.length === 0) return { outcome: 'none', reviewed: true }
|
|
111
120
|
const res = await post(job.target, obligations)
|
|
112
121
|
if (res.action === 'recorded') {
|
|
113
122
|
log(`recorded ${res.body?.proposed ?? '?'} of ${obligations.length} as proposals on record ${res.body?.recordId ?? '?'}`)
|
|
@@ -224,6 +233,7 @@ async function realDeps(target) {
|
|
|
224
233
|
return {
|
|
225
234
|
extract: extractSession,
|
|
226
235
|
lastSkip: lastEdgeSkip,
|
|
236
|
+
review: (obligations, text) => reviewObligations(obligations, text),
|
|
227
237
|
post: (t, obligations) => postCandidates({ base, token, target: t, obligations, fetchImpl: fetchCortex }),
|
|
228
238
|
park: (entry) => writeParked(parkDir(), entry),
|
|
229
239
|
log: logger(target),
|
package/package.json
CHANGED