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_recall.js ADDED
@@ -0,0 +1,559 @@
1
+ // cg_recall.js — pure recall/search/keyword-index/rewrite-cost helpers over an array of conversation NODES.
2
+ // ESM, Node >= 20, zero third-party imports. See CONTRACT_cg_recall.md for the full spec.
3
+ import { pathToFileURL } from 'node:url'
4
+
5
+ /** Revision tag for this recall implementation. */
6
+ export const RECALL_REV = 'cg-recall-1'
7
+
8
+ /**
9
+ * Characters per token for every estimate in this package (cg_memory.js and engine.js import it; the
10
+ * Python proxy's GUARDIAN_CHARS_PER_TOKEN defaults to the same 3.5). Over-reads slightly for English
11
+ * text, which is the safe direction for "will this fit".
12
+ */
13
+ export const CHARS_PER_TOKEN = 3.5
14
+
15
+ /** Rough token estimate: ceil(chars / CHARS_PER_TOKEN); '', null, undefined => 0. */
16
+ export function estTokens(text) {
17
+ if (text === null || text === undefined || text === '') return 0
18
+ return Math.ceil(String(text).length / CHARS_PER_TOKEN)
19
+ }
20
+
21
+ const VALID_TYPES = ['seq', 'result', 'checkpoint']
22
+ const ERR_FORM = 'invalid recall id — use forms like seq "3-7", seq "7", result "3", checkpoint "1"'
23
+ const STOPWORDS = new Set([
24
+ 'this', 'that', 'with', 'from', 'have', 'will', 'your', 'were', 'been', 'they',
25
+ 'their', 'there', 'what', 'when', 'which', 'would', 'could', 'should', 'about',
26
+ 'into', 'then', 'than', 'them', 'some', 'only', 'also', 'more', 'here', 'just', 'like',
27
+ ])
28
+
29
+ /** Parse a single id token or "a-b"/"a..b" range into {from,to,isRange}, or null when invalid. */
30
+ function parseIdRange(id) {
31
+ if (id === null || id === undefined) return null
32
+ if (typeof id === 'number') {
33
+ if (!Number.isFinite(id) || !Number.isInteger(id) || id < 0) return null
34
+ return { from: id, to: id, isRange: false }
35
+ }
36
+ const s = String(id).trim()
37
+ if (s === '') return null
38
+ const m = s.match(/^(-?\d+(?:\.\d+)?)\s*(?:-|\.\.)\s*(-?\d+(?:\.\d+)?)$/)
39
+ if (m) {
40
+ const a = Number(m[1]), b = Number(m[2])
41
+ if (!Number.isInteger(a) || !Number.isInteger(b) || a < 0 || b < 0) return null
42
+ return { from: Math.min(a, b), to: Math.max(a, b), isRange: true }
43
+ }
44
+ const n = Number(s)
45
+ if (!Number.isFinite(n) || !Number.isInteger(n) || n < 0) return null
46
+ return { from: n, to: n, isRange: false }
47
+ }
48
+
49
+ /** Parse a recall {type,id} pair into an ok-shape request or {ok:false,error}. */
50
+ export function parseRecallRequest(type, id) {
51
+ const rawType = (type === undefined || type === null) ? '' : String(type)
52
+ let t = rawType.trim().toLowerCase()
53
+ if (t === '') t = 'seq'
54
+ if (!VALID_TYPES.includes(t)) {
55
+ return { ok: false, error: `unknown recall type ${JSON.stringify(type)} — ${ERR_FORM}` }
56
+ }
57
+ const range = parseIdRange(id)
58
+ if (!range) {
59
+ return { ok: false, error: `bad recall id ${JSON.stringify(id)} — ${ERR_FORM}` }
60
+ }
61
+ if (range.isRange && t !== 'seq') {
62
+ return { ok: false, error: `${t} accepts a single integer, not a range — ${ERR_FORM}` }
63
+ }
64
+ return { ok: true, type: t, from: range.from, to: range.to }
65
+ }
66
+
67
+ /** Parse the argument string of /recall ("seq 3-7", "3-7", "result 3", ...) into the same shape as parseRecallRequest. */
68
+ export function parseRecallCommand(raw) {
69
+ const s = (raw === null || raw === undefined) ? '' : String(raw).trim()
70
+ if (s === '') return { ok: false, error: `empty recall command — ${ERR_FORM}` }
71
+ const m = s.match(/^(\S+)\s+(\S.*)$/)
72
+ if (m && VALID_TYPES.includes(m[1].trim().toLowerCase())) {
73
+ return parseRecallRequest(m[1], m[2])
74
+ }
75
+ return parseRecallRequest(undefined, s)
76
+ }
77
+
78
+ /** Render one nested block (inside a tool-result) to a display line. */
79
+ function renderNestedBlock(block) {
80
+ if (!block || typeof block !== 'object') return null
81
+ if (block.type === 'text') return String(block.text ?? '')
82
+ if (block.type === 'image') return '[image]'
83
+ if (block.type === 'document') return '[document]'
84
+ return `[${block.type}]`
85
+ }
86
+
87
+ /** Render one top-level block, or null when it should be skipped entirely (reasoning). */
88
+ function renderBlock(block) {
89
+ if (!block || typeof block !== 'object') return null
90
+ switch (block.type) {
91
+ case 'text': return String(block.text ?? '')
92
+ case 'reasoning': return null
93
+ case 'tool-call': return `* ${block.name}(${block.arguments})`
94
+ case 'tool-result': {
95
+ const head = block.isError ? `[ERROR result of ${block.toolCallId}]` : `[result of ${block.toolCallId}]`
96
+ const nested = Array.isArray(block.content) ? block.content.map(renderNestedBlock).filter((x) => x !== null) : []
97
+ return [head, ...nested].join('\n')
98
+ }
99
+ case 'image': return '[image]'
100
+ case 'document': return '[document]'
101
+ default: return `[${block.type}]`
102
+ }
103
+ }
104
+
105
+ /** Render a Message to its display text; null/undefined message => ''. */
106
+ export function renderMessage(message) {
107
+ if (!message || !Array.isArray(message.content)) return ''
108
+ const lines = []
109
+ for (const block of message.content) {
110
+ const r = renderBlock(block)
111
+ if (r !== null) lines.push(r)
112
+ }
113
+ return lines.join('\n')
114
+ }
115
+
116
+ /** True when a node is a checkpoint (compact-plugin) user message. */
117
+ function isCheckpoint(node) {
118
+ const src = node.message && node.message.source
119
+ return !!(src && src.kind === 'plugin' && src.plugin === 'compact')
120
+ }
121
+
122
+ /** Render [seq N role]\n<renderMessage> blocks under a token budget; may cut/truncate. */
123
+ function renderNodesWithBudget(matched, maxTokens, to) {
124
+ const rendered = matched.map((n) => `[seq ${n.seq} ${n.message.role}]\n${renderMessage(n.message)}`)
125
+ let text = ''
126
+ const seqs = []
127
+ let truncated = false
128
+ for (let i = 0; i < rendered.length; i++) {
129
+ const candidate = i === 0 ? rendered[0] : `${text}\n\n${rendered[i]}`
130
+ if (estTokens(candidate) <= maxTokens) {
131
+ text = candidate
132
+ seqs.push(matched[i].seq)
133
+ continue
134
+ }
135
+ if (i === 0) {
136
+ text = candidate.slice(0, Math.floor(maxTokens * CHARS_PER_TOKEN))
137
+ seqs.push(matched[0].seq)
138
+ truncated = matched.length > 1 || candidate.length > text.length
139
+ } else {
140
+ truncated = true
141
+ }
142
+ break
143
+ }
144
+ if (truncated) {
145
+ const lastSeq = seqs[seqs.length - 1]
146
+ const lastIdx = matched.findIndex((n) => n.seq === lastSeq)
147
+ const nextNode = matched[lastIdx + 1]
148
+ const firstOmitted = nextNode ? nextNode.seq : lastSeq + 1
149
+ text += `\n[recall truncated — next: recall(type="seq", id="${firstOmitted}-${to}")]`
150
+ }
151
+ return { ok: true, text, seqs, truncated, tokens: estTokens(text) }
152
+ }
153
+
154
+ /** Build the NOT FOUND result, naming the seq range actually present. */
155
+ function notFoundResult(nodesWithMsg) {
156
+ let seqsStr = 'none'
157
+ if (nodesWithMsg.length) {
158
+ const all = nodesWithMsg.map((n) => n.seq)
159
+ seqsStr = `${Math.min(...all)}-${Math.max(...all)}`
160
+ }
161
+ const text = `NOT FOUND: no matching nodes (seqs ${seqsStr})`
162
+ return { ok: false, text, seqs: [], truncated: false, tokens: estTokens(text) }
163
+ }
164
+
165
+ /** Recall rendered node text for a seq range / tool-result / checkpoint request. */
166
+ export function recall(nodes, request, opts = {}) {
167
+ const maxTokens = opts && Number.isFinite(opts.maxTokens) ? opts.maxTokens : 16000
168
+ const list = Array.isArray(nodes) ? nodes.filter((n) => n && typeof n.seq === 'number') : []
169
+ const withMsg = list.filter((n) => n.message)
170
+ if (!request || request.ok !== true) return notFoundResult(withMsg)
171
+ const { type, from, to } = request
172
+
173
+ let matched = []
174
+ if (type === 'seq') {
175
+ matched = withMsg.filter((n) => n.seq >= from && n.seq <= to).sort((a, b) => a.seq - b.seq)
176
+ } else if (type === 'result') {
177
+ const target = list.find((n) => n.seq === from)
178
+ if (target && target.message) {
179
+ const blocks = Array.isArray(target.message.content) ? target.message.content : []
180
+ if (target.message.role === 'user' && blocks[0] && blocks[0].type === 'tool-result') {
181
+ matched = [target]
182
+ } else if (target.message.role === 'assistant') {
183
+ const callIds = blocks.filter((b) => b && b.type === 'tool-call').map((b) => b.id)
184
+ if (callIds.length) {
185
+ matched = withMsg
186
+ .filter((n) => {
187
+ const c = n.message.content
188
+ return Array.isArray(c) && c[0] && c[0].type === 'tool-result' && callIds.includes(c[0].toolCallId)
189
+ })
190
+ .sort((a, b) => a.seq - b.seq)
191
+ }
192
+ }
193
+ }
194
+ } else if (type === 'checkpoint') {
195
+ const checkpoints = withMsg.filter(isCheckpoint).sort((a, b) => a.seq - b.seq)
196
+ const n = checkpoints[from - 1]
197
+ if (n) matched = [n]
198
+ }
199
+
200
+ if (matched.length === 0) return notFoundResult(withMsg)
201
+ return renderNodesWithBudget(matched, maxTokens, to)
202
+ }
203
+
204
+ /** Escape a literal string for use inside a RegExp. */
205
+ function escapeRegExp(s) {
206
+ return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')
207
+ }
208
+
209
+ /** Build a one-line snippet around a match, collapsing whitespace and marking cuts with …. */
210
+ function makeSnippet(text, index, len, contextChars) {
211
+ const start = Math.max(0, index - contextChars)
212
+ const end = Math.min(text.length, index + len + contextChars)
213
+ let snippet = text.slice(start, end).replace(/[\n\t]+/g, ' ')
214
+ if (start > 0) snippet = `…${snippet}`
215
+ if (end < text.length) snippet = `${snippet}…`
216
+ return snippet
217
+ }
218
+
219
+ /** Search rendered node text for a literal substring or regex; one hit per matching node. */
220
+ export function search(nodes, query, opts = {}) {
221
+ const maxHits = Number.isFinite(opts.maxHits) ? opts.maxHits : 50
222
+ const contextChars = Number.isFinite(opts.contextChars) ? opts.contextChars : 80
223
+ const useRegex = !!opts.regex
224
+ if (query === null || query === undefined || String(query).trim() === '') {
225
+ return { ok: false, error: 'search query must be a non-empty string' }
226
+ }
227
+ let re
228
+ try {
229
+ re = new RegExp(useRegex ? String(query) : escapeRegExp(String(query)), 'i')
230
+ } catch (e) {
231
+ return { ok: false, error: `invalid regex: ${e.message}` }
232
+ }
233
+ const list = Array.isArray(nodes) ? nodes.filter((n) => n && n.message) : []
234
+ const matches = []
235
+ for (const n of list) {
236
+ const text = renderMessage(n.message)
237
+ const m = re.exec(text)
238
+ if (m) matches.push({ seq: n.seq, role: n.message.role, snippet: makeSnippet(text, m.index, m[0].length, contextChars) })
239
+ }
240
+ matches.sort((a, b) => a.seq - b.seq)
241
+ const total = matches.length
242
+ return { ok: true, hits: matches.slice(0, maxHits), total, capped: total > maxHits }
243
+ }
244
+
245
+ /** Render a search() result to a compact string for the model. */
246
+ export function renderSearch(result, query) {
247
+ if (!result || result.ok !== true) {
248
+ return `search error: ${result && result.error ? result.error : 'unknown error'}`
249
+ }
250
+ if (result.total === 0) return `0 hits for "${query}"`
251
+ const suffix = result.capped ? ` (showing first ${result.hits.length})` : ''
252
+ const lines = [`${result.total} hits for "${query}"${suffix}`]
253
+ for (const h of result.hits) lines.push(`seq ${h.seq} [${h.role}] ${h.snippet}`)
254
+ lines.push(`NEXT STEP: recall(type="seq", id="${result.hits[0].seq}")`)
255
+ return lines.join('\n')
256
+ }
257
+
258
+ /** Fold rendered node text into a keyword -> seqs index, path/identifier-like terms only. */
259
+ export function foldKeywordIndex(nodes, opts = {}) {
260
+ const maxTerms = Number.isFinite(opts.maxTerms) ? opts.maxTerms : 40
261
+ const minLen = Number.isFinite(opts.minLen) ? opts.minLen : 4
262
+ const maxSeqsPerTerm = Number.isFinite(opts.maxSeqsPerTerm) ? opts.maxSeqsPerTerm : 6
263
+ const list = Array.isArray(nodes) ? nodes.filter((n) => n && n.message) : []
264
+ const termSeqs = new Map()
265
+ const tokenRe = /[A-Za-z_][\w.\-/\\]{3,}/g
266
+ for (const n of list) {
267
+ const text = renderMessage(n.message)
268
+ const seenInNode = new Set()
269
+ let m
270
+ tokenRe.lastIndex = 0
271
+ while ((m = tokenRe.exec(text))) {
272
+ const term = m[0].toLowerCase().replace(/[.,]+$/, '')
273
+ if (term.length < minLen || STOPWORDS.has(term) || seenInNode.has(term)) continue
274
+ seenInNode.add(term)
275
+ if (!termSeqs.has(term)) termSeqs.set(term, [])
276
+ termSeqs.get(term).push(n.seq)
277
+ }
278
+ }
279
+ const entries = []
280
+ for (const [term, seqs] of termSeqs) {
281
+ if (seqs.length >= 2) entries.push({ term, seqs: seqs.slice().sort((a, b) => a - b) })
282
+ }
283
+ entries.sort((a, b) => b.seqs.length - a.seqs.length || (a.term < b.term ? -1 : a.term > b.term ? 1 : 0))
284
+ return entries.slice(0, maxTerms).map((e) => ({ term: e.term, seqs: e.seqs.slice(0, maxSeqsPerTerm) }))
285
+ }
286
+
287
+ /** Render a keyword index to a compact string for the model; '' when empty. */
288
+ export function renderKeywordIndex(index) {
289
+ if (!Array.isArray(index) || index.length === 0) return ''
290
+ const lines = ['KEYWORD INDEX (term → seqs; use recall):']
291
+ for (const e of index) lines.push(`${e.term}: ${e.seqs.join(', ')}`)
292
+ return lines.join('\n')
293
+ }
294
+
295
+ /** Round to 4 decimal places. */
296
+ function round4(x) {
297
+ return Math.round(x * 10000) / 10000
298
+ }
299
+
300
+ /** Estimate whether rewriting (shadowing) a span of context is worth its reprefill cost. */
301
+ export function rewriteCost(input) {
302
+ const src = input && typeof input === 'object' ? input : {}
303
+ const surfaceTokens = src.surfaceTokens
304
+ const shadowedTokens = src.shadowedTokens
305
+ const replacementTokens = src.replacementTokens
306
+ const window = src.window
307
+ const cachedPrefixTokens = src.cachedPrefixTokens === undefined ? 0 : src.cachedPrefixTokens
308
+ const nums = [surfaceTokens, shadowedTokens, replacementTokens, window, cachedPrefixTokens]
309
+ if (!nums.every((v) => Number.isFinite(v)) || window <= 0) {
310
+ return { error: 'rewriteCost requires finite numeric surfaceTokens/shadowedTokens/replacementTokens/window (window > 0) and cachedPrefixTokens' }
311
+ }
312
+ const saved = shadowedTokens - replacementTokens
313
+ const pressureBefore = round4(surfaceTokens / window)
314
+ const pressureAfter = round4((surfaceTokens - saved) / window)
315
+ const reprefillTokens = Math.max(0, surfaceTokens - saved - Math.max(0, cachedPrefixTokens - shadowedTokens))
316
+ const tier = pressureBefore < 0.30 ? 'none'
317
+ : pressureBefore < 0.50 ? 'watch'
318
+ : pressureBefore < 0.70 ? 'idle'
319
+ : pressureBefore < 0.90 ? 'compact'
320
+ : 'emergency'
321
+ const worthIt = saved > 0 && (tier === 'emergency' || tier === 'compact' || saved >= 0.10 * window)
322
+ return { pressureBefore, pressureAfter, saved, reprefillTokens, worthIt, tier }
323
+ }
324
+
325
+ // ---------------------------------------------------------------------------
326
+ // CLI selftest — only runs when this file is the process entry point.
327
+ // ---------------------------------------------------------------------------
328
+
329
+ function runSelftest() {
330
+ const checks = []
331
+ const check = (name, fn) => checks.push([name, fn])
332
+
333
+ const T = (text) => ({ type: 'text', text })
334
+ const okReq = (t, i) => {
335
+ const r = parseRecallRequest(t, i)
336
+ if (!r.ok) throw new Error(`fixture parse failed: ${JSON.stringify(r)}`)
337
+ return r
338
+ }
339
+
340
+ // My own fixture set (distinct from the contract probe's fixtures).
341
+ const myNodes = [
342
+ { seq: 10, message: null },
343
+ { seq: 11, message: { role: 'user', content: [T('open config/app_settings.json and check retry_limit')] } },
344
+ {
345
+ seq: 12,
346
+ message: {
347
+ role: 'assistant',
348
+ content: [
349
+ { type: 'reasoning', text: 'HIDDEN-THOUGHT' },
350
+ T('Looking it up.'),
351
+ { type: 'tool-call', id: 'call-a', name: 'fetch', arguments: '{"file":"config/app_settings.json"}' },
352
+ ],
353
+ },
354
+ },
355
+ {
356
+ seq: 13,
357
+ message: {
358
+ role: 'user',
359
+ content: [{ type: 'tool-result', toolCallId: 'call-a', content: [T('retry_limit = 5 # x.y+z note'), { type: 'document' }] }],
360
+ },
361
+ },
362
+ { seq: 14, message: { role: 'assistant', content: [T('retry_limit is 5 in config/app_settings.json')] } },
363
+ { seq: 15, message: { role: 'user', source: { kind: 'plugin', plugin: 'compact' }, content: [T('CHECKPOINT-ALPHA body')] } },
364
+ { seq: 16, message: { role: 'user', content: [{ type: 'tool-result', toolCallId: 'call-z', isError: true, content: [T('kaboom')] }] } },
365
+ { seq: 17, message: { role: 'user', source: { kind: 'plugin', plugin: 'compact' }, content: [T('CHECKPOINT-BETA body')] } },
366
+ ]
367
+
368
+ check('rev_and_estTokens', () =>
369
+ RECALL_REV === 'cg-recall-1' && estTokens('abcd') === 2 && estTokens('abcdefg') === 2 && estTokens('') === 0 && estTokens(null) === 0 && estTokens(undefined) === 0)
370
+
371
+ check('parse_default_type_and_range_forms', () => {
372
+ const a = parseRecallRequest(undefined, '2-9')
373
+ const b = parseRecallRequest(' SEQ ', ' 9 - 2 ')
374
+ const c = parseRecallRequest('seq', '2..9')
375
+ const d = parseRecallRequest('seq', 5)
376
+ return a.ok && a.type === 'seq' && a.from === 2 && a.to === 9 &&
377
+ b.ok && b.from === 2 && b.to === 9 &&
378
+ c.ok && c.from === 2 && c.to === 9 &&
379
+ d.ok && d.from === 5 && d.to === 5
380
+ })
381
+
382
+ check('parse_rejects_bad_ids_and_types', () => {
383
+ const bad = [
384
+ parseRecallRequest('seq', ''),
385
+ parseRecallRequest('seq', '-4'),
386
+ parseRecallRequest('seq', '2.5'),
387
+ parseRecallRequest('bogus', '3'),
388
+ parseRecallRequest('result', '3-4'),
389
+ parseRecallRequest('checkpoint', '1-2'),
390
+ parseRecallRequest('seq', 'nope'),
391
+ ]
392
+ return bad.every((r) => r.ok === false && typeof r.error === 'string') && bad[0].error.includes('seq "3-7"')
393
+ })
394
+
395
+ check('parse_command_variants', () => {
396
+ const a = parseRecallCommand(' seq 2-9 ')
397
+ const b = parseRecallCommand('2-9')
398
+ const c = parseRecallCommand('result 13')
399
+ const d = parseRecallCommand('checkpoint 1')
400
+ const e = parseRecallCommand(' ')
401
+ const f = parseRecallCommand('13')
402
+ return a.ok && a.from === 2 && a.to === 9 && b.ok && b.type === 'seq' &&
403
+ c.ok && c.type === 'result' && c.from === 13 && d.ok && d.type === 'checkpoint' &&
404
+ e.ok === false && f.ok && f.from === 13
405
+ })
406
+
407
+ check('render_message_skips_reasoning_formats_call', () => {
408
+ const s = renderMessage(myNodes[2].message)
409
+ return !s.includes('HIDDEN-THOUGHT') && s.includes('Looking it up.') &&
410
+ s.includes('* fetch({"file":"config/app_settings.json"})') && renderMessage(null) === '' && renderMessage(undefined) === ''
411
+ })
412
+
413
+ check('render_message_tool_result_and_error', () => {
414
+ const s = renderMessage(myNodes[3].message)
415
+ const e = renderMessage(myNodes[6].message)
416
+ return s.startsWith('[result of call-a]\n') && s.includes('retry_limit = 5') && s.includes('[document]') &&
417
+ e.startsWith('[ERROR result of call-z]') && e.includes('kaboom')
418
+ })
419
+
420
+ check('render_message_never_throws_on_garbage', () => {
421
+ renderMessage({})
422
+ renderMessage({ role: 'user' })
423
+ renderMessage({ role: 'user', content: [null, 42, { type: 'mystery' }] })
424
+ return true
425
+ })
426
+
427
+ check('recall_seq_range_basic', () => {
428
+ const r = recall(myNodes, okReq('seq', '10-14'))
429
+ return r.ok && JSON.stringify(r.seqs) === '[11,12,13,14]' &&
430
+ r.text.includes('[seq 11 user]') && r.text.includes('[seq 14 assistant]') && r.truncated === false && r.tokens === estTokens(r.text)
431
+ })
432
+
433
+ check('recall_seq_budget_truncates_with_next_pointer', () => {
434
+ const r = recall(myNodes, okReq('seq', '11-14'), { maxTokens: 40 })
435
+ return r.ok && r.truncated === true && r.seqs.length >= 1 && r.seqs.length < 4 && r.seqs[0] === 11 &&
436
+ r.text.trimEnd().endsWith(`[recall truncated — next: recall(type="seq", id="${r.seqs[r.seqs.length - 1] + 1}-14")]`)
437
+ })
438
+
439
+ check('recall_first_node_too_big_is_cut_not_dropped', () => {
440
+ const big = [{ seq: 1, message: { role: 'user', content: [T('z'.repeat(6000))] } }, { seq: 2, message: { role: 'user', content: [T('w')] } }]
441
+ const r = recall(big, okReq('seq', '1-2'), { maxTokens: 80 })
442
+ return r.ok && r.truncated === true && r.seqs[0] === 1 && r.text.includes('zzzz') && r.text.length < 800
443
+ })
444
+
445
+ check('recall_result_by_result_seq_and_by_call_seq', () => {
446
+ const a = recall(myNodes, okReq('result', 13))
447
+ const b = recall(myNodes, okReq('result', 12))
448
+ const c = recall(myNodes, okReq('result', 14))
449
+ return a.ok && a.text.includes('retry_limit = 5') && b.ok && JSON.stringify(b.seqs) === '[13]' && c.ok === false && c.text.startsWith('NOT FOUND')
450
+ })
451
+
452
+ check('recall_checkpoint_ordinal', () => {
453
+ const a = recall(myNodes, okReq('checkpoint', 1))
454
+ const b = recall(myNodes, okReq('checkpoint', 2))
455
+ const c = recall(myNodes, okReq('checkpoint', 3))
456
+ return a.ok && a.text.includes('CHECKPOINT-ALPHA') && !a.text.includes('CHECKPOINT-BETA') &&
457
+ b.ok && b.text.includes('CHECKPOINT-BETA') && c.ok === false
458
+ })
459
+
460
+ check('recall_not_found_names_present_range', () => {
461
+ const r = recall(myNodes, okReq('seq', '500-600'))
462
+ return r.ok === false && r.text.startsWith('NOT FOUND') && r.text.includes('seqs 11-17') && r.seqs.length === 0 && r.truncated === false
463
+ })
464
+
465
+ check('search_literal_not_regex_and_case_insensitive', () => {
466
+ const a = search(myNodes, 'x.y+z')
467
+ const b = search(myNodes, 'RETRY_LIMIT')
468
+ return a.ok && a.total === 1 && a.hits[0].seq === 13 && b.ok && b.total === 3 && JSON.stringify(b.hits.map((h) => h.seq)) === '[11,13,14]'
469
+ })
470
+
471
+ check('search_regex_and_invalid_regex_and_empty', () => {
472
+ const a = search(myNodes, 'retry_limit\\s*=\\s*\\d+', { regex: true })
473
+ const b = search(myNodes, '(unclosed', { regex: true })
474
+ const c = search(myNodes, ' ')
475
+ return a.ok && a.total === 1 && b.ok === false && typeof b.error === 'string' && c.ok === false
476
+ })
477
+
478
+ check('search_skips_reasoning', () => search(myNodes, 'HIDDEN-THOUGHT').total === 0)
479
+
480
+ check('search_cap_and_snippet_shape', () => {
481
+ const many = Array.from({ length: 60 }, (_, i) => ({ seq: i + 1, message: { role: 'assistant', content: [T(`${'a'.repeat(300)}\n\tNEEDLE\n${'b'.repeat(300)}`)] } }))
482
+ const r = search(many, 'needle', { maxHits: 5, contextChars: 10 })
483
+ const s = r.hits[0].snippet
484
+ return r.ok && r.total === 60 && r.hits.length === 5 && r.capped === true && !/[\n\t]/.test(s) && s.includes('NEEDLE') && s.startsWith('…') && s.endsWith('…')
485
+ })
486
+
487
+ check('render_search_variants', () => {
488
+ const r = search(myNodes, 'retry_limit')
489
+ const s = renderSearch(r, 'retry_limit')
490
+ const lines = s.trimEnd().split('\n')
491
+ const zero = renderSearch(search(myNodes, 'nothingmatcheshere'), 'nothingmatcheshere')
492
+ const err = renderSearch(search(myNodes, '(unclosed', { regex: true }), '(unclosed')
493
+ return lines[lines.length - 1] === 'NEXT STEP: recall(type="seq", id="11")' && s.includes('3 hits for "retry_limit"') &&
494
+ s.includes('seq 13 [user]') && zero.includes('0 hits') && zero.includes('nothingmatcheshere') && err.startsWith('search error: ')
495
+ })
496
+
497
+ check('keyword_index_basic', () => {
498
+ const idx = foldKeywordIndex(myNodes)
499
+ const terms = idx.map((x) => x.term)
500
+ const cfg = idx.find((x) => x.term === 'config/app_settings.json')
501
+ const rl = idx.find((x) => x.term === 'retry_limit')
502
+ return !!cfg && JSON.stringify(cfg.seqs) === '[11,12,14]' && !!rl && JSON.stringify(rl.seqs) === '[11,13,14]' &&
503
+ !terms.includes('this') && terms.indexOf('config/app_settings.json') < terms.indexOf('retry_limit')
504
+ })
505
+
506
+ check('keyword_index_limits_and_render', () => {
507
+ const many = Array.from({ length: 30 }, (_, i) => ({ seq: i + 1, message: { role: 'user', content: [T(`shared thing${i % 3}zz`)] } }))
508
+ const idx = foldKeywordIndex(many, { maxTerms: 2, maxSeqsPerTerm: 3 })
509
+ const txt = renderKeywordIndex(idx)
510
+ return idx.length === 2 && idx[0].term === 'shared' && JSON.stringify(idx[0].seqs) === '[1,2,3]' &&
511
+ txt.split('\n')[0] === 'KEYWORD INDEX (term → seqs; use recall):' && txt.includes('shared: 1, 2, 3') && renderKeywordIndex([]) === ''
512
+ })
513
+
514
+ check('rewrite_cost_fields_and_tiers', () => {
515
+ const a = rewriteCost({ surfaceTokens: 30000, shadowedTokens: 20000, replacementTokens: 2000, window: 32768, cachedPrefixTokens: 25000 })
516
+ const b = rewriteCost({ surfaceTokens: 8000, shadowedTokens: 3000, replacementTokens: 1000, window: 32768 })
517
+ const t = (s) => rewriteCost({ surfaceTokens: s, shadowedTokens: 10, replacementTokens: 5, window: 1000 }).tier
518
+ return a.saved === 18000 && a.tier === 'emergency' && a.worthIt === true && a.pressureBefore === 0.9155 &&
519
+ a.pressureAfter === 0.3662 && a.reprefillTokens === 7000 &&
520
+ b.tier === 'none' && b.worthIt === false && b.reprefillTokens === 6000 &&
521
+ t(299) === 'none' && t(300) === 'watch' && t(500) === 'idle' && t(700) === 'compact' && t(900) === 'emergency'
522
+ })
523
+
524
+ check('rewrite_cost_error_on_bad_window', () => {
525
+ const c = rewriteCost({ surfaceTokens: 1, shadowedTokens: 1, replacementTokens: 0, window: 0 })
526
+ const d = rewriteCost({ surfaceTokens: NaN, shadowedTokens: 1, replacementTokens: 0, window: 10 })
527
+ const e = rewriteCost({})
528
+ return typeof c.error === 'string' && typeof d.error === 'string' && typeof e.error === 'string'
529
+ })
530
+
531
+ check('never_throws_on_garbage_inputs', () => {
532
+ renderMessage({})
533
+ recall([], okReq('seq', 1))
534
+ recall(null, okReq('seq', 1))
535
+ recall(myNodes, { ok: false })
536
+ search(null, 'x')
537
+ search(myNodes, null)
538
+ foldKeywordIndex(null)
539
+ foldKeywordIndex(myNodes, {})
540
+ rewriteCost(null)
541
+ rewriteCost(undefined)
542
+ return true
543
+ })
544
+
545
+ let passed = 0
546
+ for (const [name, fn] of checks) {
547
+ let ok = false
548
+ try { ok = fn() === true } catch { ok = false }
549
+ console.log(` ${ok ? 'ok ' : 'FAIL'} ${name}`)
550
+ if (ok) passed += 1
551
+ }
552
+ const failed = checks.length - passed
553
+ console.log(`cg_recall selftest: ${checks.length} checks, ${passed} passed, ${failed} failed`)
554
+ process.exit(failed === 0 ? 0 : 1)
555
+ }
556
+
557
+ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href && process.argv.includes('--selftest')) {
558
+ runSelftest()
559
+ }
@@ -0,0 +1,124 @@
1
+ #!/usr/bin/env python3
2
+ """cg_recall_cli.py -- search the span archive context_guardian.py writes on every compaction.
3
+
4
+ context-guardian-recall <term> [--run RUN_ID]
5
+
6
+ A compaction folds older messages into a summary and archives the full text as
7
+ <GUARDIAN_SPAN_DIR>/<run_id>/<NNNN>.json. The summary the model receives names
8
+ this command so the model can find a detail again without reading a span whole
9
+ (a span is, by construction, larger than the room the compaction freed).
10
+
11
+ Case-insensitive substring match over each archived message (text, content
12
+ blocks, tool calls) and the span's own summary. One line per hit:
13
+ `<span file> msg <i> [<role>]: ...snippet...`. Exit 0 with hits, 1 without,
14
+ 2 when the span directory does not exist. Stdlib only, Python 3.10+. MIT.
15
+ """
16
+ from __future__ import annotations
17
+
18
+ import argparse
19
+ import json
20
+ import os
21
+ import sys
22
+ from pathlib import Path
23
+
24
+ PACKAGE_ROOT = Path(__file__).resolve().parent
25
+ SNIPPET_CHARS = 80
26
+ DEFAULT_MAX_HITS = 50
27
+
28
+
29
+ def span_dir() -> Path:
30
+ """The proxy's own resolution: $GUARDIAN_SPAN_DIR (also from the .env beside
31
+ this file, which the proxy loads too), else <here>/logs/guardian_spans."""
32
+ try:
33
+ from dotenv import load_dotenv
34
+ load_dotenv(PACKAGE_ROOT / ".env", override=False)
35
+ except ImportError: # pragma: no cover -- optional, never fatal
36
+ pass
37
+ return Path(os.environ.get("GUARDIAN_SPAN_DIR",
38
+ str(PACKAGE_ROOT / "logs" / "guardian_spans")))
39
+
40
+
41
+ def message_text(m) -> str:
42
+ """Everything searchable in one archived message, as one string."""
43
+ if not isinstance(m, dict):
44
+ return str(m)
45
+ parts = []
46
+ content = m.get("content")
47
+ if isinstance(content, str):
48
+ parts.append(content)
49
+ elif isinstance(content, list):
50
+ for block in content:
51
+ if isinstance(block, dict) and isinstance(block.get("text"), str):
52
+ parts.append(block["text"])
53
+ else:
54
+ parts.append(json.dumps(block, ensure_ascii=False))
55
+ for key in ("tool_calls", "function_call"):
56
+ if m.get(key):
57
+ parts.append(json.dumps(m[key], ensure_ascii=False))
58
+ return "\n".join(parts)
59
+
60
+
61
+ def snippet(text: str, at: int, length: int) -> str:
62
+ start = max(0, at - SNIPPET_CHARS)
63
+ end = min(len(text), at + length + SNIPPET_CHARS)
64
+ out = " ".join(text[start:end].split())
65
+ return ("..." if start > 0 else "") + out + ("..." if end < len(text) else "")
66
+
67
+
68
+ def search(root: Path, term: str, run_id: str | None = None):
69
+ """Yield (path, message index or 'summary', role, snippet) per hit, oldest
70
+ span first. One hit per message. Unreadable span files are skipped."""
71
+ needle = term.lower()
72
+ pattern = f"{run_id}/*.json" if run_id else "*/*.json"
73
+ for path in sorted(root.glob(pattern)):
74
+ try:
75
+ data = json.loads(path.read_text(encoding="utf-8"))
76
+ except (OSError, ValueError):
77
+ continue
78
+ if not isinstance(data, dict):
79
+ continue
80
+ for i, m in enumerate(data.get("messages") or []):
81
+ text = message_text(m)
82
+ at = text.lower().find(needle)
83
+ if at >= 0:
84
+ role = m.get("role", "?") if isinstance(m, dict) else "?"
85
+ yield path, i, role, snippet(text, at, len(term))
86
+ summary = data.get("summary")
87
+ if isinstance(summary, str):
88
+ at = summary.lower().find(needle)
89
+ if at >= 0:
90
+ yield path, "summary", "summary", snippet(summary, at, len(term))
91
+
92
+
93
+ def main(argv=None) -> int:
94
+ parser = argparse.ArgumentParser(
95
+ prog="context-guardian-recall",
96
+ description="Search the spans Context Guardian archived when it compacted a conversation.")
97
+ parser.add_argument("term", help="text to find (case-insensitive)")
98
+ parser.add_argument("--run", metavar="RUN_ID", default=None,
99
+ help="only this proxy run (the directory name under the span dir)")
100
+ parser.add_argument("--max", type=int, default=DEFAULT_MAX_HITS, metavar="N",
101
+ help="stop after N hits (default %(default)s)")
102
+ ns = parser.parse_args(sys.argv[1:] if argv is None else argv)
103
+
104
+ root = span_dir()
105
+ if not root.is_dir():
106
+ print(f"no span archive at {root} (set GUARDIAN_SPAN_DIR)", file=sys.stderr)
107
+ return 2
108
+ hits = 0
109
+ for path, idx, role, text in search(root, ns.term, ns.run):
110
+ label = "summary" if idx == "summary" else f"msg {idx} [{role}]"
111
+ print(f"{path} {label}: {text}")
112
+ hits += 1
113
+ if hits >= ns.max:
114
+ print(f"(stopped at {ns.max} hits; narrow the term or pass --run)")
115
+ break
116
+ if hits == 0:
117
+ where = f"{root / ns.run}" if ns.run else str(root)
118
+ print(f"no match for {ns.term!r} under {where}", file=sys.stderr)
119
+ return 1
120
+ return 0
121
+
122
+
123
+ if __name__ == "__main__":
124
+ sys.exit(main())