dsh-context-guardian 0.1.0-alpha.2 → 0.1.0-alpha.4

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 CHANGED
@@ -1,5 +1,36 @@
1
1
  # Changelog
2
2
 
3
+ ## dsh-context-guardian 0.1.0-alpha.4 - pinned goal survives compaction (2026-09-24)
4
+
5
+ Engine only (`cg-engine-3`, unchanged). Every checkpoint now carries the
6
+ session's original request byte-for-byte, so a local model cannot drift off
7
+ the task across one or several compactions.
8
+
9
+ **What.** `extractGoal(nodes)` recovers the pinned goal: a goal block already
10
+ embedded in an earlier checkpoint wins; otherwise the first real user-text
11
+ node (never a tool result, never harness-injected context) is the fallback.
12
+ A later `Goal: ...` user message is captured as a numbered UPDATE, deduped by
13
+ seq (first wins) and replayed in order -- it does not replace the original
14
+ request. `renderGoal(g)` writes the pinned block back (`<<<GOAL ... GOAL>>>`,
15
+ plus one `<<<GOAL update seq N ... GOAL>>>` block per update) at the TOP of
16
+ every deterministic checkpoint, ahead of `RECALL_GUIDE`, and is never counted
17
+ against or cut by any length cap. The same block is also prepended as a new
18
+ first summary entry when the stock LLM summary succeeds, so the pin survives
19
+ regardless of which summarization path a given compaction takes.
20
+
21
+ **Tests.** `tests/goal_pin_smoke.mjs` (new, 11 checks): byte-for-byte survival
22
+ of a 6000-character non-ASCII request across a second compaction, no
23
+ duplication, `Goal:` updates captured/deduped/ordered, tool results and
24
+ harness-injected messages never mistaken for the goal, no goal block at all
25
+ when there is no user text, and the checkpoint-goal-wins-over-later-message
26
+ rule. `tests/engine_smoke.mjs`'s `llm_success_passes_through_untouched` check
27
+ was loosened to assert the stock summary block is present rather than at a
28
+ fixed index, since a pinned-goal block may now be prepended as `summary[0]`.
29
+
30
+ ## dsh-context-guardian 0.1.0-alpha.3 - install from npm (2026-09-20)
31
+
32
+ Docs only. `dsh plugin --profile <name> add dsh-context-guardian` now works by name; the DSH guide says where `engine.js` lands after an npm install and how to point the agent-preset row at it. No code change (engine `cg-engine-3`).
33
+
3
34
  ## dsh-context-guardian 0.1.0-alpha.2 - the DSH compaction engine (2026-09-20)
4
35
 
5
36
  The proxy (`context_guardian.py`) is unchanged. This adds the DSH side.
@@ -18,7 +18,9 @@ DSH mounts compaction inside the AGENT PRESET (the `compaction` group), not in t
18
18
 
19
19
  ## Install the engine (5 minutes)
20
20
 
21
- 1. Get the code: `git clone https://github.com/LuminariSoftwares/context-guardian` (or use your existing checkout). Note the absolute path.
21
+ 1. Get the code — either way works, and you need the absolute path of `engine.js` for step 3:
22
+ - **From npm (recommended):** `dsh plugin --profile <your-profile> add dsh-context-guardian`. The engine then sits at `<DSH_HOME>/profiles/<your-profile>/node_modules/dsh-context-guardian/engine.js`.
23
+ - **From git:** `git clone https://github.com/LuminariSoftwares/context-guardian` anywhere on disk. No `pnpm install` is needed for the engine; it has no dependencies.
22
24
 
23
25
  2. Make your own agent preset if you do not have one: copy the shipped `standard` preset folder to `<DSH_HOME>/.agent-presets/<your-id>/` (it contains `agent.cordis.yml` and `preset.yml`). `<DSH_HOME>` is `~/.dsh` unless you set the `DSH_HOME` environment variable.
24
26
 
@@ -33,7 +35,7 @@ DSH mounts compaction inside the AGENT PRESET (the `compaction` group), not in t
33
35
  tools: [recall, search]
34
36
  ```
35
37
  In the shipped preset the sibling rows (`- id: tool-result-pruner`) start at 4 spaces; match whatever yours use.
36
- Note: `name` is a `file:///` URL to YOUR checkout (forward slashes, also on Windows), or a path starting with `./` relative to the preset folder if you copy `engine.js`, `cg_recall.js` and `vendor/compiler.js` next to it. `numCtx` must be your model's real context window.
38
+ Note: `name` is a `file:///` URL to the `engine.js` from step 1 — for an npm install that is `file:///C:/Users/<you>/.dsh/profiles/<your-profile>/node_modules/dsh-context-guardian/engine.js`. It is a URL to YOUR copy (forward slashes, also on Windows), or a path starting with `./` relative to the preset folder if you copy `engine.js`, `cg_recall.js` and `vendor/compiler.js` next to it. `numCtx` must be your model's real context window.
37
39
 
38
40
  4. Start a NEW session with that preset selected. No restart is needed: DSH re-mounts a preset whose file changed when the next session starts.
39
41
 
package/engine.js CHANGED
@@ -47,6 +47,13 @@ export const name = 'context-guardian-engine'
47
47
  export const inject = ['compaction']
48
48
  export const ENGINE_REV = 'cg-engine-3'
49
49
 
50
+ // Pinned goal markers. The session's original request is pinned into every
51
+ // checkpoint byte-for-byte so a local model never drifts from the task after
52
+ // one (or two) compactions.
53
+ export const GOAL_OPEN = '<<<GOAL'
54
+ export const GOAL_CLOSE = 'GOAL>>>'
55
+ export const GOAL_UPDATE_RE = /^\s*goal\s*:/i
56
+
50
57
  const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url))
51
58
  const MODES = ['llm-then-deterministic', 'deterministic', 'off']
52
59
  const ALL_TOOLS = ['recall', 'search', 'context_rewrite_cost', 'context_compact']
@@ -250,6 +257,83 @@ export function splitInjected(nodes, dropSources) {
250
257
  /** Identifier-like: a path, a dotted/underscored/hyphenated name, or anything with a digit -- not an English word. */
251
258
  const IDENTIFIER_RE = /[_/\\.\-\d]/
252
259
 
260
+ // ── pinned goal ─────────────────────────────────────────────────────────────
261
+ // A `<<<GOAL ... >>>`-delimited block survives compaction inside the checkpoint
262
+ // text; extractGoal reads it back out (from earlier checkpoints and from the
263
+ // live user messages) and renderGoal writes it verbatim onto a fresh one.
264
+
265
+ const GOAL_BLOCK_RE = new RegExp(`${GOAL_OPEN}\\n([\\s\\S]*?)\\n${GOAL_CLOSE}`)
266
+ const GOAL_UPDATE_BLOCK_RE = new RegExp(`${GOAL_OPEN} update seq (\\d+)\\n([\\s\\S]*?)\\n${GOAL_CLOSE}`, 'g')
267
+
268
+ /** Every text block of a message joined with "\n". */
269
+ function messageText(message) {
270
+ const blocks = Array.isArray(message?.content) ? message.content : []
271
+ return blocks.filter(block => block?.type === 'text').map(block => String(block.text ?? '')).join('\n')
272
+ }
273
+
274
+ /** A real user turn: role user, not a checkpoint, no injected source, first block text. */
275
+ function isUserTextNode(message) {
276
+ if (message?.role !== 'user') return false
277
+ if (isCheckpointSource(message.source)) return false
278
+ const source = message.source
279
+ if (source !== undefined && source !== null) return false
280
+ return message.content?.[0]?.type === 'text'
281
+ }
282
+
283
+ /**
284
+ * The session's pinned goal and its `goal:` updates, recovered from `nodes`
285
+ * (walked in the given order). A goal carried by any checkpoint node wins; the
286
+ * first user-text node is the fallback goal. Updates are de-duplicated by seq
287
+ * (first wins) and returned sorted by seq ascending.
288
+ */
289
+ export function extractGoal(nodes) {
290
+ let goal = null
291
+ let goalNode = null
292
+ let firstUserText = null
293
+ const collected = []
294
+ for (const node of nodes ?? []) {
295
+ const message = node?.message
296
+ if (message === undefined || message === null) continue
297
+ if (isCheckpointSource(message.source)) {
298
+ const text = messageText(message)
299
+ if (goal === null) {
300
+ const match = text.match(GOAL_BLOCK_RE)
301
+ if (match !== null) goal = match[1]
302
+ }
303
+ for (const match of text.matchAll(GOAL_UPDATE_BLOCK_RE)) {
304
+ collected.push({ seq: Number(match[1]), text: match[2], node: null })
305
+ }
306
+ } else if (isUserTextNode(message)) {
307
+ const text = messageText(message)
308
+ if (firstUserText === null) firstUserText = { node, text }
309
+ if (GOAL_UPDATE_RE.test(text)) collected.push({ seq: node.seq, text, node })
310
+ }
311
+ }
312
+ if (goal === null && firstUserText !== null) {
313
+ goal = firstUserText.text
314
+ goalNode = firstUserText.node
315
+ }
316
+ const bySeq = new Map()
317
+ for (const update of collected) {
318
+ // Skip the goal node's own text; checkpoint updates carry node=null, so
319
+ // only a real goalNode may suppress an update.
320
+ if ((goalNode !== null && update.node === goalNode) || bySeq.has(update.seq)) continue
321
+ bySeq.set(update.seq, { seq: update.seq, text: update.text })
322
+ }
323
+ return { goal, updates: [...bySeq.values()].sort((a, b) => a.seq - b.seq) }
324
+ }
325
+
326
+ /** Render a pinned-goal block for a checkpoint head. '' when there is no goal. */
327
+ export function renderGoal(g) {
328
+ if (g?.goal === null || g?.goal === undefined) return ''
329
+ let out = "[pinned goal -- the session's first request, verbatim; later 'goal:' updates follow. Keep working toward it.]"
330
+ out += `\n${GOAL_OPEN}\n${g.goal}\n${GOAL_CLOSE}`
331
+ for (const update of g.updates ?? []) {
332
+ out += `\n${GOAL_OPEN} update seq ${update.seq}\n${update.text}\n${GOAL_CLOSE}`
333
+ }
334
+ return out
335
+ }
336
+
253
337
  /** The deterministic checkpoint body for one region. Pure. */
254
338
  export function buildCheckpoint(allRegionNodes, options, pressure, allNodes) {
255
339
  const { kept: nodes, dropped } = splitInjected(allRegionNodes, options.dropSources)
@@ -274,10 +358,16 @@ export function buildCheckpoint(allRegionNodes, options, pressure, allNodes) {
274
358
  })
275
359
  const real = allRegionNodes.filter(node => node.seq >= 0).map(node => node.seq)
276
360
  const range = real.length === 0 ? 'unmapped' : `${Math.min(...real)}-${Math.max(...real)}`
361
+ // The goal is computed from the region PLUS the whole session (region first),
362
+ // so a goal carried by a checkpoint inside the region wins over a later user
363
+ // message. It is never counted against or cut by any cap.
364
+ const goalNodes = allNodes === undefined || allNodes === null ? allRegionNodes : [...allRegionNodes, ...allNodes]
365
+ const goalBlock = renderGoal(extractGoal(goalNodes))
277
366
  const parts = [
278
367
  `[context-guardian checkpoint · deterministic · ${allRegionNodes.length} nodes · seqs ${range} · ~${regionTokens} -> ~${compiled.stats.tokens} tokens · ${COMPILER_REV}]`,
279
- RECALL_GUIDE,
280
368
  ]
369
+ if (goalBlock.length > 0) parts.push(goalBlock)
370
+ parts.push(RECALL_GUIDE)
281
371
  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
372
  parts.push(...compiled.entries)
283
373
  const files = filesWritten(nodes, options.filesListed)
@@ -396,6 +486,17 @@ export function apply(ctx, config) {
396
486
  const result = await original.call(this, input, agent, signal)
397
487
  if (hasText(result)) {
398
488
  record({ event: 'llm', session: String(agent?.session?.id ?? ''), provider: result.provider, model: result.model })
489
+ // Pin the goal on top of the stock summary too, as a NEW first text block.
490
+ let goalBlock = ''
491
+ try {
492
+ const region = mapRegionSeqs(agent?.session, input.messages).nodes
493
+ const session = agent?.session
494
+ const goalNodes = session === undefined || session === null ? region : [...region, ...sessionNodes(session)]
495
+ goalBlock = renderGoal(extractGoal(goalNodes))
496
+ } catch { goalBlock = '' }
497
+ if (goalBlock.length > 0) {
498
+ return { ...result, summary: [{ type: 'text', text: goalBlock }, ...(Array.isArray(result.summary) ? result.summary : [])] }
499
+ }
399
500
  return result
400
501
  }
401
502
  return deterministic(input, agent, 'llm summary was empty')
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-context-guardian",
3
- "version": "0.1.0-alpha.2",
3
+ "version": "0.1.0-alpha.4",
4
4
  "description": "Context Guardian as a native DeepSeek Harness (DSH) bundle: deterministic, seq-pointer-preserving compaction with model-facing recall and search. Companion to dsh-tool-guardian.",
5
5
  "type": "module",
6
6
  "main": "index.js",