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/CHANGELOG.md +321 -0
- package/LICENSE +21 -0
- package/README.md +255 -0
- package/THIRD_PARTY_NOTICES.md +26 -0
- package/cg_recall.js +552 -0
- package/configure.py +186 -0
- package/context_guardian.py +1636 -0
- package/cordis.patch.yml +41 -0
- package/docs/dsh-integration.md +110 -0
- package/engine.js +608 -0
- package/index.js +253 -0
- package/modules/cg_bridge.py +251 -0
- package/package.json +74 -0
- package/requirements.txt +4 -0
- package/vendor/LICENSE.dsh-compaction-instant +28 -0
- package/vendor/compiler.js +851 -0
- package/vendor/region.js +455 -0
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
|
+
}
|