dsh-context-guardian 0.1.0-alpha.10

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/cg_memory.js ADDED
@@ -0,0 +1,509 @@
1
+ // cg_memory.js -- durable, deterministic long-term memory for context-guardian checkpoints.
2
+ // Six categories of short facts pulled out of conversation NODES with no model call, merged into
3
+ // one JSON file after every compaction and rendered as a budgeted block at the top of a checkpoint.
4
+ // ESM, Node >= 22, zero third-party imports. Exports: MEMORY_REV, MEMORY_VERSION, CATEGORIES, emptyMemory,
5
+ // normalizeText, extractMemory, mergeMemory, renderMemory, memoryStats, itemsForSession, loadMemory, saveMemory.
6
+ import { mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs'
7
+ import { dirname } from 'node:path'
8
+ import { applyProvenance, renderPinText } from './cg_provenance.js'
9
+ import { CHARS_PER_TOKEN } from './cg_recall.js'
10
+
11
+ /** Revision tag for this memory implementation. */
12
+ export const MEMORY_REV = 'cg-memory-2' // 2026-10-01: pins category + session-scoped render
13
+
14
+ /** Schema version of the on-disk memory document. */
15
+ export const MEMORY_VERSION = 1
16
+
17
+ /** The seven memory categories, in canonical order. `pins` = facts the user explicitly asked to keep (2026-10-01). */
18
+ export const CATEGORIES = Object.freeze(['pins', 'decisions', 'constraints', 'files', 'todos', 'errors', 'preferences'])
19
+
20
+ /** Per-category item caps applied by mergeMemory. */
21
+ export const DEFAULT_MAX_PER_CATEGORY = Object.freeze({
22
+ pins: 60,
23
+ decisions: 40,
24
+ constraints: 40,
25
+ files: 200,
26
+ todos: 40,
27
+ errors: 20,
28
+ preferences: 40,
29
+ })
30
+
31
+ /** Rendered block order: also the budget admission priority order. */
32
+ const ORDER = Object.freeze(['pins', 'constraints', 'preferences', 'decisions', 'todos', 'files', 'errors'])
33
+
34
+ /** Categories that belong to ONE session: rendered only for that session, so one run's facts never leak into another. */
35
+ const SESSION_SCOPED = new Set(['pins', 'files', 'errors', 'todos'])
36
+
37
+ /** Default render budget, in estimated tokens. */
38
+ const DEFAULT_MAX_TOKENS = 1200
39
+
40
+ /** Header line of the rendered memory block. */
41
+ const HEADER = '[memory -- durable notes kept across compactions and sessions by context-guardian; newest last]'
42
+
43
+ const MAX_TEXT = 300
44
+ const MAX_PIN_TEXT = 400
45
+
46
+ /**
47
+ * A user line that asks for something to be kept (2026-10-01, bench1001: 'Important, keep this for later in the
48
+ * conversation: ...' matched no pattern, so 12 user-declared facts were never saved and the stock summary lost them).
49
+ * Deliberately narrow: an explicit keep/remember request, not every line that says 'important'.
50
+ */
51
+ const PIN_RE = /\b(?:keep\s+(?:this|that|these|it)(?:\s+\w+){0,3}\s+for\s+later|for\s+later\s+(?:use|reference)|remember\s+(?:this|that|these)|(?:do\s+not|don't)\s+forget|make\s+a\s+note|note\s+(?:this|that)\s+for\s+later|pin\s+(?:this|that))\b/i
52
+ const MAX_ERROR_TEXT = 200
53
+
54
+ /** Explicit `prefix: fact` markers, matched at line start after bullet removal. */
55
+ const PREFIXES = [
56
+ [/^decision:\s*/i, 'decisions'],
57
+ [/^decided:\s*/i, 'decisions'],
58
+ [/^remember:\s*/i, 'decisions'],
59
+ [/^constraint:\s*/i, 'constraints'],
60
+ [/^rule:\s*/i, 'constraints'],
61
+ [/^next\s+step:\s*/i, 'todos'],
62
+ [/^todo:\s*/i, 'todos'],
63
+ [/^next:\s*/i, 'todos'],
64
+ [/^error:\s*/i, 'errors'],
65
+ [/^preference:\s*/i, 'preferences'],
66
+ [/^prefer:\s*/i, 'preferences'],
67
+ ]
68
+
69
+ /** User-only strong patterns; the whole candidate line is the fact. */
70
+ const STRONG = [
71
+ [/^(?:please\s+)?(?:never|always|do not|don't|must not|must)\b/i, 'constraints'],
72
+ [/^i\s*(?:prefer|like|want|'d rather|would rather)\b/i, 'preferences'],
73
+ [/^(?:let'?s|we(?:'ll| will)|we are going to|we're going to)\s+(?:use|go with|keep|switch to|stick with)\b/i, 'decisions'],
74
+ ]
75
+
76
+ const BULLET_RE = /^(?:[-*+]\s+|\d+[.)]\s+)/
77
+ const CHECKBOX_RE = /^[-*+]\s*\[([ xX])\]\s*(.*)$/
78
+ const ERROR_RE = /\b(?:Error|Exception|Traceback|FAILED|ENOENT|EACCES|EPERM)\b/
79
+ const FILES_WRITTEN_RE = /^files\s+written\s*\(latest last\)\s*:\s*(.*)$/i
80
+ const FILES_LINE_RE = /^files:\s*(.*)$/i
81
+ const SEQ_TAG_RE = /^(.+?) \(seq (\d+)\)$/
82
+ const WRITE_CALL_RE = /write|edit|patch|create|replace/i
83
+
84
+ /** Trim, then hard-cap a fact at `max` chars by cutting and appending an ellipsis. */
85
+ function clamp(text, max = MAX_TEXT) {
86
+ const s = String(text ?? '').trim()
87
+ return s.length > max ? s.slice(0, max - 3) + '...' : s
88
+ }
89
+
90
+ /** A fresh, empty memory document. */
91
+ export function emptyMemory() {
92
+ return { version: MEMORY_VERSION, updated: null, items: [] }
93
+ }
94
+
95
+ /** Rough token estimate: ceil(chars / CHARS_PER_TOKEN), the package-wide constant from cg_recall.js. */
96
+ export function estTokens(text) {
97
+ return Math.ceil(String(text).length / CHARS_PER_TOKEN)
98
+ }
99
+
100
+ /** Lowercase, collapse whitespace runs, trim, strip trailing sentence punctuation. */
101
+ export function normalizeText(text) {
102
+ if (text === null || text === undefined) return ''
103
+ return String(text).toLowerCase().replace(/\s+/g, ' ').trim().replace(/[.,;:!]+$/, '')
104
+ }
105
+
106
+ /** Merge key for an item: category plus normalized fact (paths stay case-sensitive). */
107
+ function mergeKey(cat, text, session) {
108
+ const scope = cat === 'pins' ? String(session ?? '') : ''
109
+ return cat + '\u0000' + scope + '\u0000' + (cat === 'files' ? String(text) : normalizeText(text))
110
+ }
111
+
112
+ /** Oldest first: smaller `last`, then smaller seq. */
113
+ function oldestFirst(a, b) {
114
+ const la = a.last || ''
115
+ const lb = b.last || ''
116
+ if (la !== lb) return la < lb ? -1 : 1
117
+ return (a.seq || 0) - (b.seq || 0)
118
+ }
119
+
120
+ /** Newest first: larger `last`, then larger seq. */
121
+ function newestFirst(a, b) {
122
+ return -oldestFirst(a, b)
123
+ }
124
+
125
+ /** Validate and copy one item, or null when it cannot be stored. */
126
+ function normalizeItem(raw) {
127
+ if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return null
128
+ if (CATEGORIES.indexOf(raw.cat) < 0) return null
129
+ if (typeof raw.text !== 'string' || raw.text.trim() === '') return null
130
+ return {
131
+ cat: raw.cat,
132
+ text: raw.text,
133
+ seq: Number.isFinite(raw.seq) ? raw.seq : 0,
134
+ session: typeof raw.session === 'string' ? raw.session : '',
135
+ first: typeof raw.first === 'string' ? raw.first : null,
136
+ last: typeof raw.last === 'string' ? raw.last : null,
137
+ count: Number.isFinite(raw.count) && raw.count >= 1 ? Math.floor(raw.count) : 1,
138
+ done: raw.done === true,
139
+ }
140
+ }
141
+
142
+ /** Deep-copy a memory document; anything invalid becomes emptyMemory(). */
143
+ function sanitizeMemory(memory) {
144
+ if (!memory || typeof memory !== 'object' || Array.isArray(memory) || !Array.isArray(memory.items)) {
145
+ return emptyMemory()
146
+ }
147
+ const items = []
148
+ for (const raw of memory.items) {
149
+ const it = normalizeItem(raw)
150
+ if (it) items.push(it)
151
+ }
152
+ return {
153
+ version: MEMORY_VERSION,
154
+ updated: typeof memory.updated === 'string' ? memory.updated : null,
155
+ items,
156
+ }
157
+ }
158
+
159
+ /** Build a fresh extract item. */
160
+ function makeItem(cat, text, seq, session, done = false) {
161
+ return { cat, text, seq, session, first: null, last: null, count: 1, done }
162
+ }
163
+
164
+ /** Rule 1-3: candidate lines out of one text block. */
165
+ function fromText(text, seq, session, isUser, out) {
166
+ for (const rawLine of String(text ?? '').split('\n')) {
167
+ const line = rawLine.trim()
168
+ if (line.length < 8) continue
169
+ const box = line.match(CHECKBOX_RE)
170
+ if (box) {
171
+ const body = box[2].trim()
172
+ if (body !== '') out.push(makeItem('todos', clamp(body), seq, session, box[1] !== ' '))
173
+ continue
174
+ }
175
+ const bare = line.replace(BULLET_RE, '')
176
+ if (isUser) {
177
+ const pin = bare.match(PIN_RE)
178
+ if (pin) {
179
+ // Keep from the start of the sentence that holds the request: the facts follow it.
180
+ const cut = Math.max(bare.lastIndexOf('. ', pin.index) + 2, 0)
181
+ out.push(makeItem('pins', clamp(bare.slice(cut), MAX_PIN_TEXT), seq, session))
182
+ continue
183
+ }
184
+ }
185
+ let hit = false
186
+ for (const pair of PREFIXES) {
187
+ const m = bare.match(pair[0])
188
+ if (!m) continue
189
+ hit = true
190
+ const body = bare.slice(m[0].length).trim()
191
+ if (body !== '') out.push(makeItem(pair[1], clamp(body), seq, session))
192
+ break
193
+ }
194
+ if (hit) continue
195
+ if (!isUser) continue
196
+ for (const pair of STRONG) {
197
+ if (pair[0].test(bare)) {
198
+ out.push(makeItem(pair[1], clamp(bare), seq, session))
199
+ break
200
+ }
201
+ }
202
+ }
203
+ }
204
+
205
+ /** Rule 4: one errors item out of a tool-result that failed. */
206
+ function fromToolResult(block, seq, session, out) {
207
+ const inner = Array.isArray(block.content) ? block.content : []
208
+ const parts = []
209
+ for (const b of inner) {
210
+ if (b && typeof b === 'object' && b.type === 'text') parts.push(String(b.text ?? ''))
211
+ }
212
+ const lines = parts.join('\n').split('\n').map((l) => l.trim())
213
+ const hit = lines.find((l) => ERROR_RE.test(l))
214
+ if (hit === undefined && block.isError !== true) return
215
+ const chosen = hit !== undefined ? hit : (lines.find((l) => l !== '') || '')
216
+ if (chosen === '') return
217
+ out.push(makeItem('errors', clamp(chosen, MAX_ERROR_TEXT), seq, session))
218
+ }
219
+
220
+ /** Rule 5: a files item out of a mutating tool call. */
221
+ function fromToolCall(block, seq, session, out) {
222
+ const name = typeof block.name === 'string' ? block.name : ''
223
+ if (!WRITE_CALL_RE.test(name)) return
224
+ let args = block.arguments
225
+ if (typeof args === 'string') {
226
+ try { args = JSON.parse(args) } catch { return }
227
+ }
228
+ if (!args || typeof args !== 'object' || Array.isArray(args)) return
229
+ const p = args.path ?? args.file_path ?? args.filePath ?? args.target
230
+ if (typeof p !== 'string' || p.trim() === '') return
231
+ out.push(makeItem('files', clamp(p.trim()), seq, session))
232
+ }
233
+
234
+ /** Rule 6: files items recorded by an earlier checkpoint's rendered block. */
235
+ function fromCheckpoint(text, seq, session, out) {
236
+ for (const rawLine of String(text ?? '').split('\n')) {
237
+ const line = rawLine.trim()
238
+ const written = line.match(FILES_WRITTEN_RE)
239
+ if (written) {
240
+ for (const piece of written[1].split(', ')) {
241
+ const tag = piece.trim().match(SEQ_TAG_RE)
242
+ if (tag) out.push(makeItem('files', clamp(tag[1]), Number(tag[2]), session))
243
+ }
244
+ continue
245
+ }
246
+ const rendered = line.match(FILES_LINE_RE)
247
+ if (rendered) {
248
+ for (const piece of rendered[1].split(', ')) {
249
+ const p = piece.trim()
250
+ if (p !== '') out.push(makeItem('files', clamp(p), seq, session))
251
+ }
252
+ }
253
+ }
254
+ }
255
+
256
+ /** Pull durable facts out of conversation nodes, in node order. */
257
+ export function extractMemory(nodes, opts = {}) {
258
+ const session = opts && typeof opts.session === 'string' ? opts.session : ''
259
+ const out = []
260
+ if (!Array.isArray(nodes)) return out
261
+ for (const node of nodes) {
262
+ if (!node || typeof node !== 'object') continue
263
+ const message = node.message
264
+ if (!message || typeof message !== 'object') continue
265
+ const seq = Number.isFinite(node.seq) ? node.seq : 0
266
+ const source = message.source
267
+ const injected = source !== undefined && source !== null
268
+ const isCheckpoint = injected && typeof source === 'object' &&
269
+ source.kind === 'plugin' && source.plugin === 'compact'
270
+ const content = Array.isArray(message.content) ? message.content : []
271
+ const first = content[0]
272
+ const firstIsBlock = first !== null && typeof first === 'object'
273
+ const isToolResultNode = firstIsBlock && first.type === 'tool-result'
274
+ const isUserText = message.role === 'user' && !injected && !isCheckpoint && firstIsBlock && first.type === 'text'
275
+ const isAssistant = message.role === 'assistant' && !isCheckpoint
276
+ for (const block of content) {
277
+ if (!block || typeof block !== 'object') continue
278
+ if (block.type === 'tool-call') {
279
+ fromToolCall(block, seq, session, out)
280
+ continue
281
+ }
282
+ if (block.type !== 'text') continue
283
+ if (isCheckpoint) fromCheckpoint(String(block.text ?? ''), seq, session, out)
284
+ else if (isUserText || isAssistant) fromText(block.text, seq, session, isUserText, out)
285
+ }
286
+ if (isToolResultNode) fromToolResult(first, seq, session, out)
287
+ }
288
+ return out
289
+ }
290
+
291
+ /** Merge items into a NEW memory document; neither argument is mutated. */
292
+ export function mergeMemory(memory, items, opts = {}) {
293
+ const now = opts && typeof opts.now === 'string' ? opts.now : new Date().toISOString()
294
+ const caps = Object.assign({}, DEFAULT_MAX_PER_CATEGORY, (opts && opts.maxPerCategory) || {})
295
+ const next = sanitizeMemory(memory)
296
+ const index = new Map()
297
+ for (const it of next.items) index.set(mergeKey(it.cat, it.text, it.session), it)
298
+ if (Array.isArray(items)) {
299
+ for (const raw of items) {
300
+ const it = normalizeItem(raw)
301
+ if (!it) continue
302
+ const key = mergeKey(it.cat, it.text, it.session)
303
+ const existing = index.get(key)
304
+ if (existing) {
305
+ existing.count += 1
306
+ existing.last = now
307
+ existing.seq = it.seq
308
+ existing.session = it.session
309
+ existing.done = existing.done || it.done
310
+ continue
311
+ }
312
+ const added = {
313
+ cat: it.cat,
314
+ text: it.text,
315
+ seq: it.seq,
316
+ session: it.session,
317
+ first: now,
318
+ last: now,
319
+ count: 1,
320
+ done: it.done,
321
+ }
322
+ next.items.push(added)
323
+ index.set(key, added)
324
+ }
325
+ }
326
+ for (const cat of CATEGORIES) {
327
+ const cap = caps[cat]
328
+ if (typeof cap !== 'number' || !Number.isFinite(cap) || cap < 0) continue
329
+ const ofCat = next.items.filter((it) => it.cat === cat)
330
+ if (ofCat.length <= cap) continue
331
+ const ranked = ofCat.slice().sort(oldestFirst)
332
+ for (let i = 0; i < ofCat.length - cap; i++) {
333
+ const victim = ranked[i]
334
+ const at = next.items.indexOf(victim)
335
+ if (at >= 0) next.items.splice(at, 1)
336
+ }
337
+ }
338
+ return { version: MEMORY_VERSION, updated: now, items: next.items }
339
+ }
340
+
341
+ /** One rendered line for a single item. */
342
+ function renderLine(cat, item) {
343
+ if (cat === 'todos') return '- [ ] ' + item.text
344
+ if (cat === 'errors') return '- ' + item.text + ' (seq ' + item.seq + ')'
345
+ return '- ' + item.text
346
+ }
347
+
348
+ /** Category header line, or '' when this category is not rendered. */
349
+ function renderHeader(cat) {
350
+ if (cat === 'errors') return 'errors (latest):'
351
+ if (cat === 'pins') return 'pinned by the user (verbatim -- keep; answer from these, never from memory of the summary):'
352
+ return cat + ':'
353
+ }
354
+
355
+ /** Render the memory as a budgeted block for the top of a checkpoint. */
356
+ export function renderMemory(memory, opts = {}) {
357
+ const max = opts && Number.isFinite(opts.maxTokens) ? opts.maxTokens : DEFAULT_MAX_TOKENS
358
+ // A session id scopes pins/files/errors/todos to that session; no id keeps the old, unscoped behaviour.
359
+ const session = opts && typeof opts.session === 'string' && opts.session !== '' ? opts.session : null
360
+ const items = []
361
+ const raw = memory && typeof memory === 'object' && Array.isArray(memory.items) ? memory.items : []
362
+ for (const r of raw) {
363
+ const it = normalizeItem(r)
364
+ if (!it) continue
365
+ if (session !== null && SESSION_SCOPED.has(it.cat) && it.session !== session && it.session !== '') continue
366
+ items.push(it)
367
+ }
368
+ // C-Pin provenance (2026-10-03): a pinned value that a LATER pin changed never renders again,
369
+ // and a pin whose every keyed fact changed is retired. memory.json keeps them all (audit).
370
+ const provenance = applyProvenance(items.filter((it) => it.cat === 'pins'))
371
+ for (let i = items.length - 1, k = provenance.length - 1; i >= 0; i--) {
372
+ if (items[i].cat !== 'pins') continue
373
+ const p = provenance[k--]
374
+ if (p.superseded) items.splice(i, 1)
375
+ else items[i] = Object.assign({}, items[i], { text: renderPinText(p) })
376
+ }
377
+ if (items.length === 0) return ''
378
+ const acc = [HEADER]
379
+ const admitted = { pins: [], constraints: [], preferences: [], decisions: [], todos: [], errors: [], files: [] }
380
+ for (const cat of ORDER) {
381
+ if (cat === 'files') {
382
+ const cands = items.filter((it) => it.cat === 'files').sort(newestFirst)
383
+ const picked = []
384
+ for (const c of cands) {
385
+ const line = 'files: ' + picked.concat([c.text]).join(', ')
386
+ const base = picked.length === 0 ? acc : acc.slice(0, -1)
387
+ if (estTokens(base.concat([line]).join('\n')) <= max) {
388
+ picked.push(c.text)
389
+ if (picked.length === 1) acc.push(line)
390
+ else acc[acc.length - 1] = line
391
+ } else break
392
+ }
393
+ if (picked.length > 0) admitted.files = picked
394
+ continue
395
+ }
396
+ const cands = items.filter((it) => it.cat === cat && !(cat === 'todos' && it.done)).sort(newestFirst)
397
+ let opened = false
398
+ for (const c of cands) {
399
+ const line = renderLine(cat, c)
400
+ const base = opened ? acc : acc.concat([renderHeader(cat)])
401
+ if (estTokens(base.concat([line]).join('\n')) <= max) {
402
+ if (!opened) {
403
+ acc.push(renderHeader(cat))
404
+ opened = true
405
+ }
406
+ admitted[cat].push(c)
407
+ acc.push(line)
408
+ } else break
409
+ }
410
+ }
411
+ const lines = [HEADER]
412
+ for (const cat of ORDER) {
413
+ const picked = admitted[cat]
414
+ if (picked.length === 0) continue
415
+ if (cat === 'files') {
416
+ lines.push('files: ' + picked.slice().reverse().join(', '))
417
+ continue
418
+ }
419
+ lines.push(renderHeader(cat))
420
+ for (const it of picked.slice().sort(oldestFirst)) lines.push(renderLine(cat, it))
421
+ }
422
+ if (lines.length === 1) return ''
423
+ return lines.join('\n')
424
+ }
425
+
426
+ /** Item counts per category plus the number of open todos. */
427
+ export function memoryStats(memory) {
428
+ const byCategory = {}
429
+ for (const cat of CATEGORIES) byCategory[cat] = 0
430
+ const items = memory && typeof memory === 'object' && Array.isArray(memory.items) ? memory.items : []
431
+ let total = 0
432
+ let openTodos = 0
433
+ for (const it of items) {
434
+ const item = normalizeItem(it)
435
+ if (!item) continue
436
+ total += 1
437
+ byCategory[item.cat] += 1
438
+ if (item.cat === 'todos' && !item.done) openTodos += 1
439
+ }
440
+ return { total, byCategory, openTodos }
441
+ }
442
+
443
+ /** The stored items belonging to one session. */
444
+ export function itemsForSession(memory, session) {
445
+ const items = memory && typeof memory === 'object' && Array.isArray(memory.items) ? memory.items : []
446
+ const out = []
447
+ for (const raw of items) {
448
+ const it = normalizeItem(raw)
449
+ if (it && it.session === session) out.push(it)
450
+ }
451
+ return out
452
+ }
453
+
454
+ /** Rename an unreadable memory file aside, best effort. */
455
+ function quarantine(path) {
456
+ try {
457
+ renameSync(path, path + '.corrupt-' + Date.now())
458
+ } catch {
459
+ // best effort only
460
+ }
461
+ }
462
+
463
+ /** Read a memory file; never throws. */
464
+ export function loadMemory(path) {
465
+ let raw
466
+ try {
467
+ raw = readFileSync(path, 'utf8')
468
+ } catch (err) {
469
+ if (err && err.code === 'ENOENT') return { memory: emptyMemory(), status: 'missing' }
470
+ return { memory: emptyMemory(), status: 'corrupt', error: String((err && err.message) || err) }
471
+ }
472
+ let data
473
+ try {
474
+ data = JSON.parse(raw)
475
+ } catch (err) {
476
+ quarantine(path)
477
+ return { memory: emptyMemory(), status: 'corrupt', error: String((err && err.message) || err) }
478
+ }
479
+ if (!data || typeof data !== 'object' || Array.isArray(data) || !Array.isArray(data.items)) {
480
+ quarantine(path)
481
+ return { memory: emptyMemory(), status: 'corrupt', error: 'memory file has no items array' }
482
+ }
483
+ const items = []
484
+ for (const it of data.items) {
485
+ const item = normalizeItem(it)
486
+ if (item) items.push(item)
487
+ }
488
+ return {
489
+ memory: { version: MEMORY_VERSION, updated: typeof data.updated === 'string' ? data.updated : null, items },
490
+ status: 'loaded',
491
+ }
492
+ }
493
+
494
+ /** Write a memory file atomically; never throws. */
495
+ export function saveMemory(path, memory) {
496
+ let tmp = null
497
+ try {
498
+ mkdirSync(dirname(path), { recursive: true })
499
+ tmp = path + '.tmp-' + process.pid
500
+ writeFileSync(tmp, JSON.stringify(memory, null, 2) + '\n', 'utf8')
501
+ renameSync(tmp, path)
502
+ return true
503
+ } catch {
504
+ if (tmp !== null) {
505
+ try { unlinkSync(tmp) } catch { /* best effort only */ }
506
+ }
507
+ return false
508
+ }
509
+ }
@@ -0,0 +1,173 @@
1
+ // cg_postcheck.js -- context-guardian postcheck (C-Recall stage b: steer a final answer that states a stale pinned value).
2
+ //
3
+ // Pure module: finds statements in the model's final answer that state a STALE value
4
+ // for a subject the user has pinned, and renders a one-shot steering correction.
5
+ //
6
+ // Only imports allowed by the contract.
7
+ import { pathToFileURL } from 'node:url'
8
+ import { applyProvenance } from './cg_provenance.js'
9
+
10
+ export const STEER_TAG = '[context-guardian check]'
11
+
12
+ const MAX_REPORTS = 3
13
+ const STEER_MAX = 400
14
+ // A value must look like an identifier/codename: has a digit, two capitals, or a dash.
15
+ const VALUE_SHAPE = /[0-9]|[A-Z]{2}|-/
16
+
17
+ function escapeRe(s) {
18
+ return String(s).replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
19
+ }
20
+
21
+ function stripTrailing(value) {
22
+ return String(value).replace(/[.:]+$/, '')
23
+ }
24
+
25
+ // subject -> [{key, value, seq}] for every fact that no later pin superseded.
26
+ export function currentFacts(pins) {
27
+ const out = new Map()
28
+ let copies
29
+ try {
30
+ copies = applyProvenance(Array.isArray(pins) ? pins : [])
31
+ } catch {
32
+ return out
33
+ }
34
+ if (!Array.isArray(copies)) return out
35
+ for (const p of copies) {
36
+ if (!p || typeof p !== 'object') continue
37
+ const facts = Array.isArray(p.facts) ? p.facts : []
38
+ for (const f of facts) {
39
+ if (!f || typeof f !== 'object') continue
40
+ if (f.superseded) continue
41
+ const key = typeof f.key === 'string' ? f.key : String(f.key ?? '')
42
+ if (!key) continue
43
+ const subject = key.split(' ')[0].toLowerCase()
44
+ if (!subject) continue
45
+ const value = f.value === undefined || f.value === null ? '' : String(f.value)
46
+ let list = out.get(subject)
47
+ if (!list) { list = []; out.set(subject, list) }
48
+ const dup = list.some(e => e.key === key && String(e.value).toLowerCase() === value.toLowerCase())
49
+ if (dup) continue
50
+ list.push({ key, value, seq: p.seq })
51
+ }
52
+ }
53
+ return out
54
+ }
55
+
56
+ // Every "<subject> ...: value" style assertion in the text, in reading order.
57
+ export function assertions(finalText, subjects) {
58
+ const text = String(finalText ?? '')
59
+ const found = []
60
+ for (const raw of (Array.isArray(subjects) ? subjects : [])) {
61
+ if (raw === undefined || raw === null) continue
62
+ const subject = String(raw)
63
+ if (!subject) continue
64
+ let re
65
+ try {
66
+ re = new RegExp(
67
+ '["\']?\\b' + escapeRe(subject) +
68
+ "\\b(?:'s(?:\\s+release)?\\s+[a-z]+)?[\"']?\\s*(?::|=|is now|is)\\s*[\"']?([A-Za-z0-9][A-Za-z0-9_./:-]*)",
69
+ 'gi',
70
+ )
71
+ } catch {
72
+ continue
73
+ }
74
+ let m
75
+ while ((m = re.exec(text)) !== null) {
76
+ if (m[0] === '') { re.lastIndex++; continue }
77
+ found.push({ subject: subject.toLowerCase(), value: stripTrailing(m[1]), index: m.index })
78
+ }
79
+ }
80
+ found.sort((a, b) => a.index - b.index)
81
+ return found.map(e => ({ subject: e.subject, value: e.value }))
82
+ }
83
+
84
+ // Assertions that contradict a subject's single current pinned fact.
85
+ export function staleAssertions(finalText, pins) {
86
+ const facts = currentFacts(pins)
87
+ const subjects = []
88
+ const current = new Map()
89
+ for (const [subject, list] of facts) {
90
+ if (list.length !== 1) continue
91
+ subjects.push(subject)
92
+ current.set(subject, list[0])
93
+ }
94
+ if (subjects.length === 0) return []
95
+
96
+ let seen
97
+ try {
98
+ seen = assertions(finalText, subjects)
99
+ } catch {
100
+ return []
101
+ }
102
+ if (!Array.isArray(seen)) return []
103
+
104
+ const reported = new Set()
105
+ const out = []
106
+ for (const a of seen) {
107
+ if (out.length >= MAX_REPORTS) break
108
+ if (!a || reported.has(a.subject)) continue
109
+ const value = String(a.value ?? '')
110
+ if (!VALUE_SHAPE.test(value)) continue
111
+ const cur = current.get(a.subject)
112
+ if (!cur) continue
113
+ if (value.toLowerCase() === String(cur.value).toLowerCase()) continue
114
+ reported.add(a.subject)
115
+ out.push({ subject: a.subject, said: value, current: cur.value, key: cur.key, seq: cur.seq })
116
+ }
117
+ return out
118
+ }
119
+
120
+ // Render the one-shot steering message (capped at STEER_MAX characters).
121
+ export function steerText(stale) {
122
+ if (!Array.isArray(stale) || stale.length === 0) return ''
123
+ let s = STEER_TAG + ' Before you finish, correct these against the pinned facts -- ' +
124
+ stale.map(s2 => s2.key + ': you wrote ' + s2.said + ', the current pinned value is ' + s2.current + ' (seq ' + s2.seq + ')').join('; ') + '.'
125
+ if (s.length > STEER_MAX) s = s.slice(0, STEER_MAX - 1) + '…'
126
+ return s
127
+ }
128
+
129
+ export function selftest() {
130
+ const plant = { text: 'Important, keep this for later in the conversation: the release codename for project Heron is MAPLE-123; and the release codename for project Kite is RIVER-456.', seq: 3 }
131
+ const update = { text: 'Important, keep this for later in the conversation: the release codename for project Heron is now CEDAR-789.', seq: 30 }
132
+ const pins = [plant, update]
133
+ const checks = [
134
+ ['current_facts_skip_superseded', () => {
135
+ const f = currentFacts(pins)
136
+ return f.get('heron').length === 1 && f.get('heron')[0].value === 'CEDAR-789' && f.get('kite')[0].value === 'RIVER-456'
137
+ }],
138
+ ['json_answer_with_stale_value', () => {
139
+ const s = staleAssertions('{"Heron": "MAPLE-123", "Kite": "RIVER-456"}', pins)
140
+ return s.length === 1 && s[0].subject === 'heron' && s[0].said === 'MAPLE-123' && s[0].current === 'CEDAR-789' && s[0].seq === 30
141
+ }],
142
+ ['prose_possessive_assertion', () => staleAssertions("Heron's codename is MAPLE-123.", pins).length === 1],
143
+ ['current_value_no_finding', () => staleAssertions('{"Heron": "CEDAR-789", "Kite": "river-456"}', pins).length === 0],
144
+ ['non_value_words_ignored', () => staleAssertions('Heron: see above', pins).length === 0],
145
+ ['unknown_subject_ignored', () => staleAssertions('{"Owl": "X-1"}', pins).length === 0],
146
+ ['steer_text_tagged_and_capped', () => {
147
+ const t = steerText(staleAssertions('{"Heron": "MAPLE-123"}', pins))
148
+ const long = steerText(Array.from({ length: 20 }, (_, i) => ({ key: 'k' + i + ' codename', said: 'A-' + i, current: 'B-' + i, seq: i })))
149
+ return t.startsWith(STEER_TAG) && t.includes('CEDAR-789') && long.length <= 400 && steerText([]) === ''
150
+ }],
151
+ ['assertions_in_text_order', () => JSON.stringify(assertions('Kite = K-1 then Heron: H-2', ['heron', 'kite'])) === JSON.stringify([{ subject: 'kite', value: 'K-1' }, { subject: 'heron', value: 'H-2' }])],
152
+ ]
153
+ let passed = 0
154
+ for (const [name, fn] of checks) {
155
+ let ok = false
156
+ let label = name
157
+ try {
158
+ ok = Boolean(fn())
159
+ } catch (err) {
160
+ label = name + ' (raised ' + (err && err.name) + ': ' + (err && err.message) + ')'
161
+ }
162
+ if (ok) passed += 1
163
+ console.log((ok ? 'ok ' : 'FAIL ') + label)
164
+ }
165
+ const failed = checks.length - passed
166
+ console.log('cg_postcheck selftest: ' + checks.length + ' checks, ' + passed + ' passed, ' + failed + ' failed')
167
+ return { checks: checks.length, passed, failed }
168
+ }
169
+
170
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href && process.argv.includes('--selftest')) {
171
+ const r = selftest()
172
+ process.exit(r.failed === 0 && r.checks > 0 ? 0 : 1)
173
+ }