dsh-context-guardian 0.1.0-alpha.2

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