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/engine.js ADDED
@@ -0,0 +1,608 @@
1
+ /**
2
+ * dsh-context-guardian/engine -- the compaction half of Context Guardian, for
3
+ * the AGENT-PRESET plane of DSH.
4
+ *
5
+ * Why a second entry file: DSH mounts compaction inside the agent preset
6
+ * (`compaction` group, isolated realm), not in the profile. The bundle's
7
+ * index.js lives in the profile plane and cannot see `ctx.compaction`. This
8
+ * file is mounted as ONE ROW inside the preset's `compaction` group, after
9
+ * `compaction-basic`:
10
+ *
11
+ * - id: context-guardian
12
+ * name: 'file:///C:/path/to/context-guardian/engine.js' # file URL, or ./relative to the preset
13
+ * config: { mode: llm-then-deterministic }
14
+ *
15
+ * What it does (DSH 0.1.2-alpha.2, source read 2026-09-20):
16
+ * 1. Replaces `summarize()` -- "the sole subclass customization hook" of
17
+ * BasicCompactionEngine -- on the live service instance. compaction-basic
18
+ * still owns selection, stability checks, the durable transaction and the
19
+ * shrink guarantee. Only the text of the checkpoint changes.
20
+ * mode llm-then-deterministic (default): the stock LLM summary runs
21
+ * first; when it throws, returns nothing, or cannot fit in the window
22
+ * at all, the region is compiled deterministically instead. A
23
+ * conversation can no longer be bricked by a failed summary.
24
+ * mode deterministic: never call the model. mode off: install nothing.
25
+ * 2. Deterministic checkpoint = vendored compaction-instant compiler
26
+ * (seq pointers on every line) + files written + a folded keyword index.
27
+ * 3. `recall` / `search` tools and a `/recall` command restore the original
28
+ * text behind any pointer from the append-only session log.
29
+ * 4. Archives every compacted span in context_guardian.py's span format, and
30
+ * snapshots session state on `compaction/start` (what dsh-openwolf did).
31
+ * 5. Idle pressure trigger: when the agent goes idle above `idleCompactRatio`
32
+ * it compacts then, while nobody is waiting on the model.
33
+ *
34
+ * ZERO DSH imports on purpose: a linked checkout outside `$DSH_HOME/profiles`
35
+ * cannot resolve in-box `@deepseek-ai/*` packages. Everything DSH-side arrives
36
+ * through `ctx`.
37
+ *
38
+ * MIT licensed.
39
+ */
40
+ import { appendFileSync, closeSync, mkdirSync, openSync, readdirSync, statSync, unlinkSync, writeFileSync, writeSync } from 'node:fs'
41
+ import { dirname, join } from 'node:path'
42
+ import { fileURLToPath } from 'node:url'
43
+ import { COMPILER_REV, DEFAULT_NOISE_PATTERNS, RECALL_GUIDE, compileNoisePatterns, compileRegion, isCheckpointSource, joinCompiledEntries, parseToolArguments } from './vendor/compiler.js'
44
+ import * as lib from './cg_recall.js'
45
+
46
+ export const name = 'context-guardian-engine'
47
+ export const inject = ['compaction']
48
+ export const ENGINE_REV = 'cg-engine-3'
49
+
50
+ const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url))
51
+ const MODES = ['llm-then-deterministic', 'deterministic', 'off']
52
+ const ALL_TOOLS = ['recall', 'search', 'context_rewrite_cost', 'context_compact']
53
+
54
+ export const DEFAULTS = Object.freeze({
55
+ mode: 'llm-then-deterministic',
56
+ numCtx: 32768,
57
+ reserveOutput: 8192,
58
+ idleCompactRatio: 0.45,
59
+ idleDelayMs: 4000,
60
+ checkpointMaxTokens: 3000,
61
+ textTokens: 200,
62
+ userTextTokens: 400,
63
+ toolCallTokens: 32,
64
+ toolResultExcerptTokens: 64,
65
+ maxRecallTokens: 16000,
66
+ maxSearchHits: 50,
67
+ keywordTerms: 25,
68
+ filesListed: 15,
69
+ keepSpans: 500,
70
+ spanDir: '',
71
+ logPath: '',
72
+ tools: ['recall', 'search'],
73
+ toolArgTools: [],
74
+ hideTools: [],
75
+ // Harness-injected context the harness re-injects by itself: worthless inside a checkpoint.
76
+ dropSources: ['agent-instructions', 'skill-catalog', '@deepseek-ai/dsh-system-prompt', 'repeat-tool-reminder'],
77
+ })
78
+
79
+ /**
80
+ * Fold the environment over the preset row's config.
81
+ * Precedence: env GUARDIAN_* > row config > DEFAULTS. A malformed value is
82
+ * ignored, never half-applied.
83
+ */
84
+ export function resolveEngineOptions(config = {}, env = process.env) {
85
+ const row = config ?? {}
86
+ const pick = (key) => (row[key] === undefined || row[key] === null ? DEFAULTS[key] : row[key])
87
+ const num = (envKey, key, min, max) => {
88
+ const raw = env[envKey]
89
+ if (raw !== undefined && raw !== '') {
90
+ const parsed = Number(raw)
91
+ if (Number.isFinite(parsed) && parsed >= min && parsed <= max) return parsed
92
+ }
93
+ const value = Number(pick(key))
94
+ return Number.isFinite(value) && value >= min && value <= max ? value : DEFAULTS[key]
95
+ }
96
+ const list = (envKey, key) => {
97
+ const raw = env[envKey]
98
+ if (raw !== undefined && raw !== '') return raw.split(',').map(s => s.trim()).filter(Boolean)
99
+ const value = pick(key)
100
+ return Array.isArray(value) ? value.map(String) : DEFAULTS[key]
101
+ }
102
+ const envMode = String(env.GUARDIAN_DSH_MODE ?? '').trim().toLowerCase()
103
+ const rowMode = String(pick('mode')).trim().toLowerCase()
104
+ const mode = MODES.includes(envMode) ? envMode : MODES.includes(rowMode) ? rowMode : DEFAULTS.mode
105
+ const numCtx = Math.floor(num('GUARDIAN_NUM_CTX', 'numCtx', 1024, 4_000_000))
106
+ return {
107
+ mode,
108
+ numCtx,
109
+ reserveOutput: Math.floor(num('GUARDIAN_RESERVE_OUTPUT', 'reserveOutput', 0, 1_000_000)),
110
+ idleCompactRatio: num('GUARDIAN_IDLE_COMPACT_RATIO', 'idleCompactRatio', 0, 0.99),
111
+ idleDelayMs: Math.floor(num('GUARDIAN_IDLE_DELAY_MS', 'idleDelayMs', 0, 3_600_000)),
112
+ checkpointMaxTokens: Math.floor(num('GUARDIAN_CHECKPOINT_MAX_TOKENS', 'checkpointMaxTokens', 200, 1_000_000)),
113
+ textTokens: Math.floor(num('GUARDIAN_TEXT_TOKENS', 'textTokens', 8, 100_000)),
114
+ userTextTokens: Math.floor(num('GUARDIAN_USER_TEXT_TOKENS', 'userTextTokens', 8, 100_000)),
115
+ toolCallTokens: Math.floor(num('GUARDIAN_TOOL_CALL_TOKENS', 'toolCallTokens', 8, 100_000)),
116
+ toolResultExcerptTokens: Math.floor(num('GUARDIAN_TOOL_RESULT_TOKENS', 'toolResultExcerptTokens', 8, 100_000)),
117
+ // One recall may never take more than a quarter of the window it lands in.
118
+ maxRecallTokens: Math.min(Math.floor(num('GUARDIAN_MAX_RECALL_TOKENS', 'maxRecallTokens', 100, 1_000_000)), Math.floor(numCtx / 4)),
119
+ maxSearchHits: Math.floor(num('GUARDIAN_MAX_SEARCH_HITS', 'maxSearchHits', 1, 1000)),
120
+ keywordTerms: Math.floor(num('GUARDIAN_KEYWORD_TERMS', 'keywordTerms', 0, 500)),
121
+ filesListed: Math.floor(num('GUARDIAN_FILES_LISTED', 'filesListed', 0, 200)),
122
+ keepSpans: Math.floor(num('GUARDIAN_KEEP_SPANS', 'keepSpans', 0, 1_000_000)),
123
+ spanDir: env.GUARDIAN_SPAN_DIR || String(pick('spanDir') || '') || join(PACKAGE_ROOT, 'logs', 'guardian_spans'),
124
+ logPath: env.GUARDIAN_DSH_LOG || String(pick('logPath') || '') || join(PACKAGE_ROOT, 'logs', 'guardian_dsh.jsonl'),
125
+ tools: list('GUARDIAN_DSH_TOOLS', 'tools').filter(t => ALL_TOOLS.includes(t)),
126
+ toolArgTools: list('GUARDIAN_TOOL_ARG_TOOLS', 'toolArgTools'),
127
+ hideTools: list('GUARDIAN_HIDE_TOOLS', 'hideTools'),
128
+ dropSources: list('GUARDIAN_DROP_SOURCES', 'dropSources'),
129
+ }
130
+ }
131
+
132
+ /** Conservative token estimate for "will this request fit": chars / 3.5. */
133
+ export function estRequestTokens(value) {
134
+ if (value === undefined || value === null) return 0
135
+ const text = typeof value === 'string' ? value : JSON.stringify(value)
136
+ return Math.ceil(text.length / 3.5)
137
+ }
138
+
139
+ /** Pressure tier of the 30/50/70/90 ladder. */
140
+ export function tierOf(pressure) {
141
+ if (!(pressure >= 0.30)) return 'none'
142
+ if (pressure < 0.50) return 'watch'
143
+ if (pressure < 0.70) return 'idle'
144
+ if (pressure < 0.90) return 'compact'
145
+ return 'emergency'
146
+ }
147
+
148
+ /** How much checkpoint each tier is allowed: the fuller the window, the tighter the checkpoint. */
149
+ const TIER_FACTOR = Object.freeze({ none: 1.25, watch: 1.25, idle: 1.25, compact: 1, emergency: 0.6 })
150
+
151
+ /**
152
+ * Every event of a session as `{seq, message}`; non-message events carry
153
+ * `message: null`. Cached per session by log length (the log is append-only).
154
+ */
155
+ const nodeCache = new WeakMap()
156
+ export function sessionNodes(session) {
157
+ const events = session?.events
158
+ if (!Array.isArray(events) && typeof events?.length !== 'number') return []
159
+ const cached = nodeCache.get(session)
160
+ const nodes = cached?.nodes ?? []
161
+ for (let index = nodes.length; index < events.length; index += 1) {
162
+ const event = events[index]
163
+ let message = null
164
+ try { message = session.deriveEventMessage(event) ?? null } catch { message = null }
165
+ nodes.push({ seq: event?.seq ?? index, message })
166
+ }
167
+ nodeCache.set(session, { nodes })
168
+ return nodes
169
+ }
170
+
171
+ /**
172
+ * Give each summarization-input message its REAL log seq. The engine builds
173
+ * `input.messages` as `shadowedSeqs.map(derive)`, in surface order, so a
174
+ * forward walk over the surface with structural equality finds them. A
175
+ * message that cannot be matched keeps a synthetic negative seq and is
176
+ * reported, never silently mislabelled with someone else's pointer.
177
+ */
178
+ export function mapRegionSeqs(session, messages) {
179
+ const surface = Array.from(session?.surface?.nodes ?? [])
180
+ const events = session?.events ?? []
181
+ const derived = new Map()
182
+ const key = (seq) => {
183
+ if (!derived.has(seq)) {
184
+ let text = null
185
+ try { text = JSON.stringify(session.deriveEventMessage(events[seq]) ?? null) } catch { text = null }
186
+ derived.set(seq, text)
187
+ }
188
+ return derived.get(seq)
189
+ }
190
+ const nodes = []
191
+ let cursor = 0
192
+ let unmatched = 0
193
+ for (const message of messages) {
194
+ const want = JSON.stringify(message)
195
+ let found = -1
196
+ for (let index = cursor; index < surface.length; index += 1) {
197
+ if (key(surface[index]) === want) { found = index; break }
198
+ }
199
+ if (found === -1) {
200
+ unmatched += 1
201
+ nodes.push({ seq: -(nodes.length + 1), message })
202
+ } else {
203
+ nodes.push({ seq: surface[found], message })
204
+ cursor = found + 1
205
+ }
206
+ }
207
+ return { nodes, unmatched }
208
+ }
209
+
210
+ /** Files the region's tool calls wrote or edited, oldest first, distinct, last `limit`. */
211
+ export function filesWritten(nodes, limit) {
212
+ if (!(limit > 0)) return []
213
+ const seen = new Map()
214
+ for (const node of nodes) {
215
+ const content = node.message?.role === 'assistant' ? node.message.content : undefined
216
+ if (!Array.isArray(content)) continue
217
+ for (const block of content) {
218
+ if (block?.type !== 'tool-call' || !/write|edit|patch|create|replace/i.test(String(block.name))) continue
219
+ const args = parseToolArguments(block.arguments)
220
+ const path = args?.path ?? args?.file_path ?? args?.filePath ?? args?.target
221
+ if (typeof path !== 'string' || path.length === 0) continue
222
+ seen.delete(path)
223
+ seen.set(path, node.seq)
224
+ }
225
+ }
226
+ return [...seen].slice(-limit).map(([path, seq]) => ({ path, seq }))
227
+ }
228
+
229
+ /**
230
+ * Split harness-injected context out of a region. A user-role message whose
231
+ * `source.kind` (or `source.plugin`) is listed in `dropSources` is context the
232
+ * harness injects again by itself (workspace instructions, the skill
233
+ * catalogue, runtime snapshots) -- 17 KB of it per injection in the 2026-09-17
234
+ * session. Checkpoints (`plugin: compact`) and tool results are never dropped.
235
+ */
236
+ export function splitInjected(nodes, dropSources) {
237
+ const drop = new Set(dropSources ?? [])
238
+ const kept = []
239
+ const dropped = []
240
+ for (const node of nodes) {
241
+ const source = node.message?.source
242
+ const injected = node.message?.role === 'user' && source !== undefined && source !== null && !isCheckpointSource(source)
243
+ && (drop.has(source.kind) || (source.kind === 'plugin' && drop.has(source.plugin)))
244
+ if (injected) dropped.push(node)
245
+ else kept.push(node)
246
+ }
247
+ return { kept, dropped }
248
+ }
249
+
250
+ /** Identifier-like: a path, a dotted/underscored/hyphenated name, or anything with a digit -- not an English word. */
251
+ const IDENTIFIER_RE = /[_/\\.\-\d]/
252
+
253
+ /** The deterministic checkpoint body for one region. Pure. */
254
+ export function buildCheckpoint(allRegionNodes, options, pressure, allNodes) {
255
+ const { kept: nodes, dropped } = splitInjected(allRegionNodes, options.dropSources)
256
+ const regionTokens = allRegionNodes.reduce((total, node) => total + lib.estTokens(lib.renderMessage(node.message)), 0)
257
+ const tier = tierOf(pressure)
258
+ const cap = Math.max(200, Math.min(Math.floor(options.checkpointMaxTokens * TIER_FACTOR[tier]), Math.floor(regionTokens * 0.5)))
259
+ const checkpointOrdinals = new Map()
260
+ let ordinal = 0
261
+ for (const node of allNodes ?? allRegionNodes) {
262
+ if (node.message !== null && isCheckpointSource(node.message?.source)) checkpointOrdinals.set(node.seq, ordinal += 1)
263
+ }
264
+ const compiled = compileRegion(nodes, {
265
+ textTokens: options.textTokens,
266
+ userTextTokens: options.userTextTokens,
267
+ toolCallTokens: options.toolCallTokens,
268
+ toolResultExcerptTokens: options.toolResultExcerptTokens,
269
+ maxTokens: cap,
270
+ toolArgTools: options.toolArgTools,
271
+ hideTools: options.hideTools,
272
+ noisePatterns: compileNoisePatterns(DEFAULT_NOISE_PATTERNS),
273
+ checkpointOrdinals,
274
+ })
275
+ const real = allRegionNodes.filter(node => node.seq >= 0).map(node => node.seq)
276
+ const range = real.length === 0 ? 'unmapped' : `${Math.min(...real)}-${Math.max(...real)}`
277
+ const parts = [
278
+ `[context-guardian checkpoint · deterministic · ${allRegionNodes.length} nodes · seqs ${range} · ~${regionTokens} -> ~${compiled.stats.tokens} tokens · ${COMPILER_REV}]`,
279
+ RECALL_GUIDE,
280
+ ]
281
+ if (dropped.length > 0) parts.push(`[${dropped.length} harness-injected context messages omitted (instructions, skill list, runtime notes -- the harness re-injects them): seqs ${dropped.map(node => node.seq).join(', ')}]`)
282
+ parts.push(...compiled.entries)
283
+ const files = filesWritten(nodes, options.filesListed)
284
+ if (files.length > 0) parts.push(`FILES WRITTEN (latest last): ${files.map(file => `${file.path} (seq ${file.seq})`).join(', ')}`)
285
+ if (options.keywordTerms > 0) {
286
+ // Only identifiers somebody SAID (user or assistant text): a directory listing in a
287
+ // tool result otherwise fills the index with paths nobody ever talked about.
288
+ const spoken = new Set()
289
+ for (const node of nodes) {
290
+ if (node.message?.content?.[0]?.type === 'tool-result') continue
291
+ for (const block of node.message?.content ?? []) {
292
+ if (block?.type !== 'text') continue
293
+ for (const token of String(block.text ?? '').match(/[A-Za-z_][\w.\-\/\\]{3,}/g) ?? []) spoken.add(token.toLowerCase().replace(/[.,]+$/, ''))
294
+ }
295
+ }
296
+ const terms = lib.foldKeywordIndex(nodes, { maxTerms: options.keywordTerms * 8 })
297
+ .filter(row => IDENTIFIER_RE.test(row.term) && spoken.has(row.term)).slice(0, options.keywordTerms)
298
+ const index = lib.renderKeywordIndex(terms)
299
+ if (index.length > 0) parts.push(index)
300
+ }
301
+ return { text: joinCompiledEntries(parts), stats: compiled.stats, capped: compiled.capped, tier, regionTokens, cap, dropped: dropped.length }
302
+ }
303
+
304
+ /** True when the summary blocks carry at least one non-blank text block. */
305
+ function hasText(result) {
306
+ return Array.isArray(result?.summary) && result.summary.some(block => block?.type === 'text' && String(block.text ?? '').trim().length > 0)
307
+ }
308
+
309
+ const TEXT_OUTPUT = { schema: { type: 'string' }, render: (_args, value) => [{ type: 'text', text: String(value) }] }
310
+
311
+ export function apply(ctx, config) {
312
+ const options = resolveEngineOptions(config)
313
+ const log = ctx.logger ?? console
314
+ const runId = `dsh-${new Date().toISOString().replace(/[-:T]/g, '').slice(0, 14)}-${process.pid}`
315
+ const pressureBySession = new WeakMap()
316
+ const triggerBySession = new WeakMap()
317
+
318
+ const record = (entry) => {
319
+ try {
320
+ mkdirSync(dirname(options.logPath), { recursive: true })
321
+ appendFileSync(options.logPath, `${JSON.stringify({ at: new Date().toISOString(), run: runId, ...entry })}\n`)
322
+ } catch { /* logging must never break compaction */ }
323
+ }
324
+
325
+ if (options.mode === 'off') {
326
+ log.info('context-guardian engine: mode off -- summarize hook NOT installed; compaction-basic is unchanged')
327
+ return
328
+ }
329
+
330
+ // ── span archive (context_guardian.py `write_span` format) ───────────────
331
+ let spanIndex = 0
332
+ const writeSpan = (session, messages, summary) => {
333
+ try {
334
+ const dir = join(options.spanDir, runId)
335
+ mkdirSync(dir, { recursive: true })
336
+ for (let attempt = 0; attempt < 50; attempt += 1) {
337
+ const index = spanIndex + 1 + attempt
338
+ const path = join(dir, `${String(index).padStart(4, '0')}.json`)
339
+ let fd
340
+ try { fd = openSync(path, 'wx') } catch (error) { if (error?.code === 'EEXIST') continue; throw error }
341
+ try {
342
+ writeSync(fd, JSON.stringify({
343
+ run_id: runId, index, at: new Date().toISOString().replace('T', ' ').slice(0, 19), num_ctx: options.numCtx,
344
+ message_count: messages.length, summary, messages, session_id: String(session?.id ?? ''), engine: ENGINE_REV,
345
+ }, null, 1))
346
+ } finally { closeSync(fd) }
347
+ spanIndex = index
348
+ pruneSpans()
349
+ return path
350
+ }
351
+ } catch (error) {
352
+ log.warn(`context-guardian engine: span NOT archived (${error.message}); compaction proceeds`)
353
+ }
354
+ return null
355
+ }
356
+ const pruneSpans = () => {
357
+ try {
358
+ const files = []
359
+ for (const run of readdirSync(options.spanDir)) {
360
+ const dir = join(options.spanDir, run)
361
+ if (!statSync(dir).isDirectory()) continue
362
+ for (const file of readdirSync(dir)) if (/^\d+\.json$/.test(file)) files.push(join(dir, file))
363
+ }
364
+ files.sort()
365
+ for (const doomed of files.slice(0, Math.max(0, files.length - options.keepSpans))) unlinkSync(doomed)
366
+ } catch { /* housekeeping only */ }
367
+ }
368
+
369
+ // ── the summarize hook ───────────────────────────────────────────────────
370
+ const service = ctx.compaction
371
+ const original = service.summarize
372
+ if (typeof original !== 'function') {
373
+ log.error('context-guardian engine: ctx.compaction has no summarize() -- this DSH is not the 0.1.2 compaction-basic engine; nothing installed')
374
+ return
375
+ }
376
+
377
+ const deterministic = (input, agent, reason) => {
378
+ const session = agent?.session
379
+ const { nodes, unmatched } = mapRegionSeqs(session, input.messages)
380
+ const pressure = pressureBySession.get(session) ?? measure(agent)?.pressure ?? 0.8
381
+ const checkpoint = buildCheckpoint(nodes, options, pressure, session === undefined ? nodes : sessionNodes(session))
382
+ const span = writeSpan(session, input.messages, checkpoint.text)
383
+ record({ event: 'deterministic', session: String(session?.id ?? ''), reason, nodes: nodes.length, unmatched, tier: checkpoint.tier, regionTokens: checkpoint.regionTokens, checkpointTokens: checkpoint.stats.tokens, capped: checkpoint.capped, span })
384
+ log.info(`context-guardian engine: deterministic checkpoint (${reason}) -- ${nodes.length} nodes, ~${checkpoint.regionTokens} -> ~${checkpoint.stats.tokens} tokens, tier ${checkpoint.tier}${unmatched > 0 ? `, ${unmatched} unmapped` : ''}`)
385
+ return { summary: [{ type: 'text', text: checkpoint.text }], provider: 'context-guardian', model: COMPILER_REV, rawOutput: checkpoint.text }
386
+ }
387
+
388
+ const summarize = async function (input, agent, signal) {
389
+ if (options.mode === 'deterministic') return deterministic(input, agent, 'mode deterministic')
390
+ // The stock summarizer replays system + tools + the whole region and asks
391
+ // for output on top. When that cannot fit in the window the call is
392
+ // doomed before it is made -- the failure TJ hit on 2026-09-19.
393
+ const need = estRequestTokens(input.system) + estRequestTokens(input.tools) + estRequestTokens(input.messages) + options.reserveOutput
394
+ if (need > options.numCtx) return deterministic(input, agent, `llm summary cannot fit: ~${need} > ${options.numCtx}`)
395
+ try {
396
+ const result = await original.call(this, input, agent, signal)
397
+ if (hasText(result)) {
398
+ record({ event: 'llm', session: String(agent?.session?.id ?? ''), provider: result.provider, model: result.model })
399
+ return result
400
+ }
401
+ return deterministic(input, agent, 'llm summary was empty')
402
+ } catch (error) {
403
+ if (signal?.aborted) throw error
404
+ return deterministic(input, agent, `llm summary failed: ${String(error?.message ?? error).slice(0, 200)}`)
405
+ }
406
+ }
407
+
408
+ Object.defineProperty(service, 'summarize', { value: summarize, configurable: true, writable: true, enumerable: false })
409
+ ctx.effect(() => () => { try { delete service.summarize } catch { /* realm already gone */ } })
410
+ record({ event: 'installed', engine: ENGINE_REV, mode: options.mode, numCtx: options.numCtx, idleCompactRatio: options.idleCompactRatio, tools: options.tools })
411
+ log.info(`context-guardian engine: summarize hook installed (mode ${options.mode}, window ${options.numCtx}, idle ${options.idleCompactRatio}, ${COMPILER_REV}, ${lib.RECALL_REV})`)
412
+
413
+ // ── pressure, snapshot, outcome log ──────────────────────────────────────
414
+ // cordis hides every service a plugin did not declare: `ctx.tokenMeter` reads
415
+ // undefined here even though the host provides it (seen live 2026-09-20).
416
+ // It is optional, so it arrives through ctx.inject rather than `inject`.
417
+ let tokenMeter
418
+ ctx.inject(['tokenMeter'], (inner) => {
419
+ tokenMeter = inner.tokenMeter
420
+ inner.effect(() => () => { tokenMeter = undefined })
421
+ })
422
+ function measure(agent) {
423
+ try {
424
+ const measurement = tokenMeter?.measure?.(agent.session)
425
+ if (measurement === undefined || measurement === null) return undefined
426
+ const tokens = Number(measurement.totalTokens ?? measurement.surfaceTokens ?? 0)
427
+ const pressure = tokens / options.numCtx
428
+ pressureBySession.set(agent.session, pressure)
429
+ return { tokens, surfaceTokens: Number(measurement.surfaceTokens ?? tokens), pressure, usage: measurement.baseline?.kind === 'usage' ? measurement.baseline.usage : undefined, nodes: measurement.nodes ?? [] }
430
+ } catch { return undefined }
431
+ }
432
+
433
+ ctx.on('session/event', (session, event) => {
434
+ if (event?.type === 'compaction/start') {
435
+ const trigger = triggerBySession.get(session) ?? (event.data?.sourceCommandId === undefined ? 'auto' : 'manual')
436
+ const snapshot = {
437
+ at: new Date().toISOString(), trigger, session: String(session?.id ?? ''), compactionId: event.data?.compactionId,
438
+ startSeq: event.seq, surfaceNodes: session?.surface?.nodes?.length ?? 0, pressure: pressureBySession.get(session) ?? null,
439
+ filesWritten: filesWritten(sessionNodes(session), options.filesListed),
440
+ }
441
+ try {
442
+ const dir = join(options.spanDir, runId)
443
+ mkdirSync(dir, { recursive: true })
444
+ writeFileSync(join(dir, `precompact-${String(event.seq).padStart(6, '0')}.json`), JSON.stringify(snapshot, null, 1))
445
+ } catch (error) { log.warn(`context-guardian engine: precompact snapshot not written (${error.message})`) }
446
+ record({ event: 'compaction/start', ...snapshot, filesWritten: snapshot.filesWritten.length })
447
+ } else if (event?.type === 'compaction/end') {
448
+ record({ event: 'compaction/end', session: String(session?.id ?? ''), compactionId: event.data?.compactionId, error: event.data?.error ?? null })
449
+ if (event.data?.error) log.warn(`context-guardian engine: compaction ended with error: ${event.data.error}`)
450
+ triggerBySession.delete(session)
451
+ }
452
+ })
453
+
454
+ // ── idle pressure trigger ────────────────────────────────────────────────
455
+ const idleTimers = new Map()
456
+ const idleFloor = new WeakMap()
457
+ const clearIdle = (agent) => { const timer = idleTimers.get(agent); if (timer !== undefined) { clearTimeout(timer); idleTimers.delete(agent) } }
458
+ const runIdle = async (agent) => {
459
+ idleTimers.delete(agent)
460
+ const before = measure(agent)
461
+ record({ event: 'idle-check', session: String(agent.session?.id ?? ''), tokens: before?.tokens ?? null, pressure: before === undefined ? null : Number(before.pressure.toFixed(4)), threshold: options.idleCompactRatio })
462
+ if (before === undefined || before.pressure < options.idleCompactRatio) return
463
+ // After a failed or empty attempt, wait for the surface to grow 10 % before trying again.
464
+ if (before.tokens < (idleFloor.get(agent.session) ?? 0)) return
465
+ triggerBySession.set(agent.session, 'idle')
466
+ try {
467
+ const result = await ctx.compaction.compactNow(agent, AbortSignal.timeout(600_000))
468
+ const after = measure(agent)
469
+ if (result === null) idleFloor.set(agent.session, before.tokens * 1.1)
470
+ record({ event: 'idle-compaction', session: String(agent.session?.id ?? ''), before: before.tokens, after: after?.tokens ?? null, compacted: result !== null })
471
+ if (result !== null) log.info(`context-guardian engine: idle compaction at ${(before.pressure * 100).toFixed(0)} % -- ~${before.tokens} -> ~${after?.tokens ?? '?'} tokens`)
472
+ } catch (error) {
473
+ idleFloor.set(agent.session, before.tokens * 1.1)
474
+ record({ event: 'idle-compaction', session: String(agent.session?.id ?? ''), before: before.tokens, error: String(error?.code ?? error?.message ?? error).slice(0, 200) })
475
+ } finally { triggerBySession.delete(agent.session) }
476
+ }
477
+ if (options.idleCompactRatio > 0) {
478
+ ctx.on('agent/status', ({ agent, status }) => {
479
+ clearIdle(agent)
480
+ if (status !== 'idle') return
481
+ const timer = setTimeout(() => { void runIdle(agent) }, options.idleDelayMs)
482
+ timer.unref?.()
483
+ idleTimers.set(agent, timer)
484
+ })
485
+ ctx.effect(() => () => { for (const timer of idleTimers.values()) clearTimeout(timer); idleTimers.clear() })
486
+ }
487
+
488
+ // ── reports ──────────────────────────────────────────────────────────────
489
+ const costReport = (agent) => {
490
+ const m = measure(agent)
491
+ if (m === undefined) return 'context-guardian: the token meter is not available in this preset, so pressure cannot be measured.'
492
+ const keep = Math.max(1, Math.ceil(m.nodes.length * 0.16))
493
+ const shadowed = m.nodes.slice(0, Math.max(0, m.nodes.length - keep)).reduce((total, node) => total + Number(node.tokens ?? 0), 0)
494
+ const replacement = Math.min(options.checkpointMaxTokens, Math.floor(shadowed * 0.5))
495
+ const cached = Number(m.usage?.cacheReadTokens ?? 0)
496
+ const cost = lib.rewriteCost({ surfaceTokens: m.tokens, shadowedTokens: shadowed, replacementTokens: replacement, window: options.numCtx, cachedPrefixTokens: cached })
497
+ if (cost.error) return `context-guardian: ${cost.error}`
498
+ const cacheLine = m.usage === undefined ? 'cache: no provider usage yet'
499
+ : m.usage.cacheReadTokens === undefined ? 'cache: provider does not report cache reads'
500
+ : `cache: ${cached} of ${m.usage.inputTokens} input tokens read from cache (${m.usage.inputTokens > 0 ? Math.round(cached / m.usage.inputTokens * 100) : 0} %)`
501
+ return [
502
+ `context: ~${m.tokens} of ${options.numCtx} tokens (${(cost.pressureBefore * 100).toFixed(0)} %), tier ${cost.tier}`,
503
+ `compacting now (estimate): replaces ~${shadowed} tokens with ~${replacement}; saves ~${cost.saved}; pressure -> ${(cost.pressureAfter * 100).toFixed(0)} %`,
504
+ `rewrite cost: ~${cost.reprefillTokens} tokens must be prefilled again because the prompt prefix changes`,
505
+ cacheLine,
506
+ `verdict: ${cost.worthIt ? 'worth compacting' : 'not worth compacting yet'} (ladder: 30 watch · 50 idle · 70 compact · 90 emergency; idle trigger at ${(options.idleCompactRatio * 100).toFixed(0)} %)`,
507
+ ].join('\n')
508
+ }
509
+
510
+ const doRecall = (session, request) => {
511
+ if (!request.ok) return { ok: false, text: `recall: ${request.error}` }
512
+ return lib.recall(sessionNodes(session), request, { maxTokens: options.maxRecallTokens })
513
+ }
514
+
515
+ // ── model-facing tools ───────────────────────────────────────────────────
516
+ const definitions = {
517
+ recall: {
518
+ name: 'recall',
519
+ description: 'Restore ORIGINAL conversation text behind a checkpoint pointer. type "seq" id "3-7" | type "result" id "3" (a tool result) | type "checkpoint" id "1".',
520
+ parameters: {
521
+ type: 'object',
522
+ properties: { type: { type: 'string', enum: ['seq', 'result', 'checkpoint'] }, id: { type: 'string', description: '"7", "3-7"' } },
523
+ required: ['id'],
524
+ },
525
+ output: TEXT_OUTPUT,
526
+ execute: async (args, exec) => doRecall(exec?.agent?.session, lib.parseRecallRequest(args?.type, args?.id)).text,
527
+ },
528
+ search: {
529
+ name: 'search',
530
+ description: 'Find where something was said earlier in this session (including compacted history). Returns seq numbers for recall.',
531
+ parameters: {
532
+ type: 'object',
533
+ properties: { query: { type: 'string' }, regex: { type: 'boolean' } },
534
+ required: ['query'],
535
+ },
536
+ output: TEXT_OUTPUT,
537
+ execute: async (args, exec) => lib.renderSearch(
538
+ lib.search(sessionNodes(exec?.agent?.session), args?.query, { maxHits: options.maxSearchHits, regex: args?.regex === true, contextChars: 80 }),
539
+ String(args?.query ?? ''),
540
+ ),
541
+ },
542
+ context_rewrite_cost: {
543
+ name: 'context_rewrite_cost',
544
+ description: 'Report context pressure, what compacting now would save, and what it costs in re-prefill.',
545
+ parameters: { type: 'object', properties: {} },
546
+ output: TEXT_OUTPUT,
547
+ execute: async (_args, exec) => costReport(exec?.agent),
548
+ },
549
+ context_compact: {
550
+ name: 'context_compact',
551
+ description: 'Ask for the conversation to be compacted as soon as this turn ends. Use before starting a long new task when context is above 50 %.',
552
+ parameters: { type: 'object', properties: {} },
553
+ output: TEXT_OUTPUT,
554
+ execute: async (_args, exec) => {
555
+ const agent = exec?.agent
556
+ if (agent === undefined) return 'context_compact: no agent on this call.'
557
+ idleFloor.delete(agent.session)
558
+ pendingCompact.add(agent)
559
+ return 'Compaction is scheduled for the end of this turn. Finish your current step normally.'
560
+ },
561
+ },
562
+ }
563
+ const pendingCompact = new WeakSet()
564
+ if (options.tools.includes('context_compact')) {
565
+ ctx.on('agent/status', ({ agent, status }) => {
566
+ if (status !== 'idle' || !pendingCompact.has(agent)) return
567
+ pendingCompact.delete(agent)
568
+ triggerBySession.set(agent.session, 'model')
569
+ ctx.compaction.compactNow(agent, AbortSignal.timeout(600_000))
570
+ .then(result => record({ event: 'model-compaction', session: String(agent.session?.id ?? ''), compacted: result !== null }))
571
+ .catch(error => record({ event: 'model-compaction', session: String(agent.session?.id ?? ''), error: String(error?.code ?? error?.message ?? error).slice(0, 200) }))
572
+ .finally(() => triggerBySession.delete(agent.session))
573
+ })
574
+ }
575
+ if (options.tools.length > 0) {
576
+ ctx.inject(['tools'], (inner) => {
577
+ for (const tool of options.tools) inner.tools.register(definitions[tool])
578
+ log.info(`context-guardian engine: tools registered: ${options.tools.join(', ')}`)
579
+ })
580
+ }
581
+
582
+ // ── human commands ───────────────────────────────────────────────────────
583
+ ctx.inject(['commands'], (inner) => {
584
+ inner.commands.register({
585
+ name: 'recall',
586
+ description: 'Show original history: /recall 3-7 · /recall result 3 · /recall checkpoint 1 · /recall find <text>',
587
+ // Without `input` DSH treats a command as argument-free: the web UI runs it bare on
588
+ // select and sends "/recall result 42" typed in full to the MODEL (seen live 2026-09-20).
589
+ input: { hint: '<3-7 | result 3 | checkpoint 1 | find text>' },
590
+ handler: async (invocation) => {
591
+ const raw = String(invocation?.rawInput ?? '').trim()
592
+ const session = invocation?.agent?.session
593
+ const find = /^(?:find|search)\s+(.+)$/i.exec(raw)
594
+ if (find !== null) {
595
+ const result = lib.search(sessionNodes(session), find[1], { maxHits: options.maxSearchHits, regex: false, contextChars: 80 })
596
+ return { kind: result.ok ? 'success' : 'error', text: lib.renderSearch(result, find[1]) }
597
+ }
598
+ const result = doRecall(session, lib.parseRecallCommand(raw))
599
+ return { kind: result.ok ? 'success' : 'error', text: result.text }
600
+ },
601
+ })
602
+ inner.commands.register({
603
+ name: 'context',
604
+ description: 'Context pressure, cache hits and what compacting now would cost',
605
+ handler: async (invocation) => ({ kind: 'success', text: costReport(invocation?.agent) }),
606
+ })
607
+ })
608
+ }