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/CHANGELOG.md +555 -0
- package/LICENSE +21 -0
- package/README.md +412 -0
- package/THIRD_PARTY_NOTICES.md +30 -0
- package/cg_anchors.js +203 -0
- package/cg_autorecall.js +215 -0
- package/cg_configure.py +205 -0
- package/cg_doctor.py +449 -0
- package/cg_handoff.js +253 -0
- package/cg_memory.js +509 -0
- package/cg_postcheck.js +173 -0
- package/cg_provenance.js +206 -0
- package/cg_recall.js +559 -0
- package/cg_recall_cli.py +124 -0
- package/configure.py +10 -0
- package/context_guardian.py +1991 -0
- package/cordis.patch.yml +42 -0
- package/docs/dsh-integration.md +229 -0
- package/docs/dsh-upstream-append-only.md +37 -0
- package/engine.js +1407 -0
- package/index.js +255 -0
- package/modules/cg_bridge.py +251 -0
- package/package.json +88 -0
- package/requirements.txt +4 -0
- package/setup.mjs +629 -0
- package/update_check.js +184 -0
- package/vendor/LICENSE.dsh-compaction-instant +28 -0
- package/vendor/compiler.js +851 -0
- package/vendor/region.js +455 -0
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
|
+
}
|
package/cg_recall_cli.py
ADDED
|
@@ -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())
|