@theronap/agnoclast-mcp 0.9.147 → 0.9.149

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
@@ -404,10 +404,14 @@ async function captureWork(stdinRaw) {
404
404
  // merge a deadline stated twice, reclassify whose it is. Never adds; fails open (obligation_review.mjs).
405
405
  // Only when there are candidates, which most sessions never produce.
406
406
  let obligations = extracted?.obligations ?? []
407
+ // The quotes the review retired (merged into a later statement, or not owed). The ONLY thing that lets
408
+ // the server delete an earlier read's proposal; a read that is merely silent about one deletes nothing.
409
+ let retiredObligations = []
407
410
  if (obligations.length) {
408
- const { reviewObligations, describeReview } = await import('./obligation_review.mjs')
411
+ const { reviewObligations, describeReview, retiredEvidence } = await import('./obligation_review.mjs')
409
412
  const rev = reviewObligations(obligations, transcript)
410
413
  process.stderr.write(`cortex: ${stamp} ${sid} obligations ${describeReview(obligations.length, rev)}\n`)
414
+ retiredObligations = retiredEvidence(obligations, rev)
411
415
  obligations = rev.obligations
412
416
  }
413
417
  // `pages` is the edge's PROPOSAL of where this record belongs (ADR-0055). Free-text names, resolved
@@ -424,6 +428,8 @@ async function captureWork(stdinRaw) {
424
428
  // transcript it was shown — the server cannot re-check the quote (it never receives the
425
429
  // transcript), so these arrive already filtered. Omitted when empty, same as `pages`.
426
430
  ...(obligations.length ? { obligations } : {}),
431
+ // ADR-0059 §5.4: quotes the reviewer retired. Omitted when empty, so an older server sees nothing new.
432
+ ...(retiredObligations.length ? { retiredObligations } : {}),
427
433
  }
428
434
  : { ...common, transcript }
429
435
 
@@ -289,6 +289,51 @@ export function verbatimIn(needle, haystack) {
289
289
  return norm(haystack).includes(n)
290
290
  }
291
291
 
292
+ // ── SNAPPING A NEAR-QUOTE TO ITS SOURCE (ADR-0059 §4.1, added 2026-09-11) ──────────────────────────
293
+ //
294
+ // 🔴 WHY. The STRAT 421 research brief was lost on two separate runs for one word. The model quoted "…but
295
+ // just have it on your radar. That will be September 22nd as well." The lecture says "on THE radar". The
296
+ // gate above is right to refuse that quote, because a paraphrase is not evidence. But refusing the whole
297
+ // CANDIDATE threw away a real, dated deadline whose source passage is right there, differing by one word.
298
+ //
299
+ // So a quote the gate refuses gets one more chance: find the SOURCE passage it came from, and use the
300
+ // SOURCE's words. The quote must be long (SNAP_MIN_WORDS). Its first and last SNAP_ANCHOR_WORDS words must
301
+ // each appear VERBATIM in the source, in order. The span between them must be within
302
+ // SNAP_LENGTH_TOLERANCE of the quote's length. And exactly ONE such span may exist.
303
+ //
304
+ // ⚠ THIS DOES NOT LOOSEN THE GATE. What is stored is still verbatim source text — the transcript's
305
+ // words, not the model's — so "the evidence sentence is in the source" stays true. What changes is who
306
+ // wrote the evidence. A fabrication does not survive: an invented middle must still sit between two exact,
307
+ // in-order, correctly-spaced anchors, and it is then REPLACED by what the source actually says there.
308
+ // That is the text the reviewer and the person read. Not snapped: short quotes (anchors too weak), a
309
+ // quote splicing distant passages (length check), and an anchor pair that matches in two places.
310
+ const SNAP_ANCHOR_WORDS = 6
311
+ const SNAP_MIN_WORDS = 14
312
+ const SNAP_LENGTH_TOLERANCE = 0.25
313
+
314
+ /** PURE. The source passage a near-verbatim quote was taken from (whitespace-normalised), or null. */
315
+ export function snapToSource(quote, source) {
316
+ const q = String(quote ?? '').replace(/\s+/g, ' ').trim()
317
+ const words = q.split(' ')
318
+ if (!q || words.length < SNAP_MIN_WORDS) return null
319
+ const src = String(source ?? '')
320
+ const esc = (w) => w.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
321
+ const anchor = (ws) => new RegExp(ws.map(esc).join('\\s+'), 'g')
322
+ const lo = q.length * (1 - SNAP_LENGTH_TOLERANCE)
323
+ const hi = q.length * (1 + SNAP_LENGTH_TOLERANCE)
324
+ const found = new Set()
325
+ for (const h of src.matchAll(anchor(words.slice(0, SNAP_ANCHOR_WORDS)))) {
326
+ const tail = anchor(words.slice(-SNAP_ANCHOR_WORDS))
327
+ tail.lastIndex = h.index + h[0].length
328
+ for (let t = tail.exec(src); t; t = tail.exec(src)) {
329
+ const span = src.slice(h.index, t.index + t[0].length).replace(/\s+/g, ' ').trim()
330
+ if (span.length > hi) break
331
+ if (span.length >= lo) { found.add(span); break }
332
+ }
333
+ }
334
+ return found.size === 1 ? [...found][0] : null
335
+ }
336
+
292
337
  /**
293
338
  * Drop every obligation candidate that cannot be verified against the text the model was shown.
294
339
  *
@@ -309,18 +354,24 @@ export function keepVerifiableObligations(items, shown) {
309
354
  const evidence = typeof it.evidence === 'string' ? it.evidence.trim() : ''
310
355
  const party = typeof it.obligated_party === 'string' ? it.obligated_party.trim() : ''
311
356
  if (!subject || !evidence || !OBLIGATION_PARTIES.has(party)) continue
312
- if (!verbatimIn(evidence, shown)) continue
357
+ // Verbatim, or a near-quote snapped to the source's own words (snapToSource). Nothing else.
358
+ let ev = evidence
359
+ if (!verbatimIn(ev, shown)) {
360
+ const snapped = snapToSource(ev, shown)
361
+ if (!snapped || !verbatimIn(snapped, shown)) continue
362
+ ev = snapped
363
+ }
313
364
  const due = typeof it.due_at === 'string' && it.due_at.trim() ? it.due_at.trim() : null
314
365
  // ADR-0059 §5.2: the WHEN words, which the server resolves into a date in code. Kept only if they
315
366
  // appear inside the evidence sentence — the check that stops a paraphrase ("Sept 22" for "the
316
367
  // 22nd") or a phrase from some other sentence from deciding the date. Dropped otherwise: the server
317
368
  // then falls back to due_at, exactly as it did before phrases existed.
318
369
  const phraseRaw = typeof it.due_phrase === 'string' ? it.due_phrase.trim() : ''
319
- const due_phrase = phraseRaw && normWs(evidence).includes(normWs(phraseRaw)) ? phraseRaw.slice(0, 120) : null
370
+ const due_phrase = phraseRaw && normWs(ev).includes(normWs(phraseRaw)) ? phraseRaw.slice(0, 120) : null
320
371
  out.push({
321
372
  subject: subject.slice(0, 200), due_at: due,
322
373
  ...(due_phrase ? { due_phrase } : {}),
323
- evidence: evidence.slice(0, 500), obligated_party: party,
374
+ evidence: ev.slice(0, 500), obligated_party: party,
324
375
  })
325
376
  if (out.length >= MAX_OBLIGATIONS) break
326
377
  }
@@ -172,6 +172,29 @@ export function reviewObligations(cands, text, opts = {}) {
172
172
  return { obligations: kept, reviewed: true, changes, model }
173
173
  }
174
174
 
175
+ /**
176
+ * PURE. The quotes this review RETIRED: candidates it merged into a later statement of the same item, or
177
+ * dropped as not an obligation. A quote it kept is never retired, even if another decision named it.
178
+ *
179
+ * 🔴 THIS IS THE ONLY THING THAT MAY DELETE AN EARLIER READ'S PROPOSAL (ADR-0059 §5.4, 2026-09-11). A read
180
+ * that simply does not mention a proposal says nothing about it: the model may have missed it this run, may
181
+ * have paraphrased its quote so the verbatim gate dropped it, or — in a session past 400,000 characters — may
182
+ * never have been SHOWN it (summaryWindow elides the middle). A reviewer that read the sentence in context
183
+ * and judged it superseded or not owed is a statement. So the server deletes exactly these, and nothing for
184
+ * silence. Empty when the review did not run: an unreviewed read retires nothing.
185
+ */
186
+ export function retiredEvidence(cands, rev) {
187
+ if (!rev?.reviewed || !Array.isArray(cands)) return []
188
+ const kept = new Set((rev.obligations ?? []).map((o) => o?.evidence))
189
+ const idx = [...(rev.changes?.dropped ?? []), ...(rev.changes?.merged ?? []).map(([i]) => i)]
190
+ const out = []
191
+ for (const i of idx) {
192
+ const ev = cands[i]?.evidence
193
+ if (typeof ev === 'string' && ev && !kept.has(ev) && !out.includes(ev)) out.push(ev)
194
+ }
195
+ return out
196
+ }
197
+
175
198
  /** PURE. One log line for a review outcome. */
176
199
  export function describeReview(before, rev) {
177
200
  if (!rev.reviewed) return `review skipped [${rev.reason}] — ${before} candidate(s) go on unreviewed`
@@ -25,7 +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
+ import { reviewObligations, describeReview, retiredEvidence } from './obligation_review.mjs'
29
29
 
30
30
  export const MAX_PARK_DAYS = 30
31
31
  export const FIRST_RETRY_MS = 5 * 60_000
@@ -84,13 +84,14 @@ export function backoffMs(attempts) {
84
84
  }
85
85
 
86
86
  /** POST the candidates. Never throws: a network failure is `retry`, like a 5xx. */
87
- export async function postCandidates({ base, token, target, obligations, fetchImpl }) {
87
+ export async function postCandidates({ base, token, target, obligations, retired = [], fetchImpl }) {
88
88
  if (!token) return { action: 'retry', status: 0, body: { error: 'no CORTEX_TOKEN in env or wired config' } }
89
89
  try {
90
90
  const res = await fetchImpl(`${base}/api/obligations/candidates`, {
91
91
  method: 'POST',
92
92
  headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
93
- body: JSON.stringify({ ...target, obligations }),
93
+ // retiredObligations: quotes the reviewer retired (ADR-0059 §5.4). Omitted when empty.
94
+ body: JSON.stringify({ ...target, obligations, ...(retired.length ? { retiredObligations: retired } : {}) }),
94
95
  })
95
96
  const body = await res.json().catch(() => ({}))
96
97
  return { action: classifyPost(res.status), status: res.status, body }
@@ -138,11 +139,15 @@ export async function processJob(job, deps) {
138
139
  // drop, merge or reclassify, never add; it fails open. Optional here so a caller without it is unchanged.
139
140
  const review = deps.review ?? ((o) => ({ obligations: o, reviewed: false, reason: 'no reviewer' }))
140
141
  const before = obligations.length
142
+ const cands = obligations
141
143
  const rev = review(obligations, job.text)
142
144
  log(describeReview(before, rev))
143
145
  obligations = rev.obligations
144
- if (obligations.length === 0) return { outcome: 'none', reviewed: true }
145
- const res = await post(job.target, obligations)
146
+ // What the review RETIRED is the only thing that may delete an earlier read's proposal (retiredEvidence).
147
+ // So a read whose every candidate was retired still posts: the retirement is the whole message.
148
+ const retired = retiredEvidence(cands, rev)
149
+ if (obligations.length === 0 && retired.length === 0) return { outcome: 'none', reviewed: true }
150
+ const res = await post(job.target, obligations, retired)
146
151
  if (res.action === 'recorded') {
147
152
  log(describeRecorded(res.body, obligations))
148
153
  return { outcome: 'recorded', proposed: res.body?.proposed ?? null }
@@ -152,7 +157,7 @@ export async function processJob(job, deps) {
152
157
  return { outcome: 'dropped', status: res.status }
153
158
  }
154
159
  const t = now()
155
- park({ target: job.target, title: job.title ?? null, obligations, parkedAt: t, attempts: 1, nextAttemptAt: t + backoffMs(1) })
160
+ park({ target: job.target, title: job.title ?? null, obligations, ...(retired.length ? { retired } : {}), parkedAt: t, attempts: 1, nextAttemptAt: t + backoffMs(1) })
156
161
  log(`parked ${obligations.length} — ${res.action === 'pending' ? 'the unit is not materialised yet' : `${res.status} ${res.body?.error ?? ''}`.trim()}; retried by later captures`)
157
162
  return { outcome: 'parked', status: res.status }
158
163
  }
@@ -190,7 +195,7 @@ export async function flushParkedObligations(deps) {
190
195
  continue
191
196
  }
192
197
  if ((entry.nextAttemptAt ?? 0) > t) continue
193
- const res = await post(entry.target, entry.obligations)
198
+ const res = await post(entry.target, entry.obligations, entry.retired ?? [])
194
199
  if (res.action === 'recorded' || res.action === 'drop') {
195
200
  try { unlinkSync(file) } catch { /* raced with another flush; the write is idempotent */ }
196
201
  flushed += 1
@@ -260,7 +265,7 @@ async function realDeps(target) {
260
265
  extract: extractSession,
261
266
  lastSkip: lastEdgeSkip,
262
267
  review: (obligations, text) => reviewObligations(obligations, text),
263
- post: (t, obligations) => postCandidates({ base, token, target: t, obligations, fetchImpl: fetchCortex }),
268
+ post: (t, obligations, retired) => postCandidates({ base, token, target: t, obligations, retired, fetchImpl: fetchCortex }),
264
269
  park: (entry) => writeParked(parkDir(), entry),
265
270
  log: logger(target),
266
271
  sleep: (ms) => new Promise((r) => setTimeout(r, ms)),
package/lib/server.mjs CHANGED
@@ -933,7 +933,7 @@ function renderNudge(payload) {
933
933
  inputSchema: {
934
934
  intakeItemId: z.string().describe('intake item uuid'),
935
935
  orgId: z.string().describe('destination brain org uuid'),
936
- documentIds: z.array(z.string()).optional().describe('additional brain_documents ids in orgId (deterministic homes are merged automatically)'),
936
+ documentIds: z.array(z.string()).optional().describe('additional pages in orgId — each a page `ref:` exactly as read_page shows it, or a brain_documents id (deterministic homes are merged automatically)'),
937
937
  title: z.string().optional(),
938
938
  summary: z.string().optional(),
939
939
  source: z.string().optional(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@theronap/agnoclast-mcp",
3
- "version": "0.9.147",
3
+ "version": "0.9.149",
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": {