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/index.js
ADDED
|
@@ -0,0 +1,253 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* dsh-context-guardian -- Context Guardian as a native DSH bundle plugin.
|
|
3
|
+
*
|
|
4
|
+
* PHASE 1 SCAFFOLD. This file wires the plugin into DSH (config schema,
|
|
5
|
+
* settings section, the vendored compiler, the optional Python bridge). It
|
|
6
|
+
* does NOT touch compaction yet: no listener, no provider, no tools. Those
|
|
7
|
+
* are Phase 3 / 3b and are marked below where they land. Until then
|
|
8
|
+
* @deepseek-ai/dsh-compaction-basic behaves exactly as the profile has it.
|
|
9
|
+
*
|
|
10
|
+
* Written against DSH 0.1.2-alpha.2 (source read 2026-09-19):
|
|
11
|
+
* - settings: packages/settings/settings -> ctx.settings.installSection(owner, ns, schema, entry, hooks)
|
|
12
|
+
* - compaction: packages/compaction/compaction-basic -- `summarize()` is the
|
|
13
|
+
* engine's sole subclass customization hook
|
|
14
|
+
* - log seam: there is no "PreCompact" registration in this DSH; the
|
|
15
|
+
* durable signal is a `session/event` whose type is
|
|
16
|
+
* `compaction/start` (what dsh-openwolf listens to today)
|
|
17
|
+
*
|
|
18
|
+
* vendor/compiler.js and vendor/region.js are unmodified from
|
|
19
|
+
* dsh-compaction-instant@0.1.4 (MIT, TsFreddie; VCC principle by lllyasviel) --
|
|
20
|
+
* see vendor/LICENSE.dsh-compaction-instant. region.js imports
|
|
21
|
+
* @deepseek-ai/dsh-compaction and @deepseek-ai/dsh-llm, so it is loaded only
|
|
22
|
+
* where Phase 3b needs it, never at plugin load.
|
|
23
|
+
*
|
|
24
|
+
* MIT licensed.
|
|
25
|
+
*/
|
|
26
|
+
import { spawn } from 'node:child_process'
|
|
27
|
+
import { existsSync } from 'node:fs'
|
|
28
|
+
import { dirname, join } from 'node:path'
|
|
29
|
+
import { createInterface } from 'node:readline'
|
|
30
|
+
import { fileURLToPath } from 'node:url'
|
|
31
|
+
import Schema from '@deepseek-ai/schemastery'
|
|
32
|
+
import { COMPILER_REV, DEFAULT_ARG_TOOLS } from './vendor/compiler.js'
|
|
33
|
+
|
|
34
|
+
export const name = 'dsh-context-guardian'
|
|
35
|
+
|
|
36
|
+
/** Nothing is required at load in Phase 1; settings attaches when present. */
|
|
37
|
+
export const inject = []
|
|
38
|
+
|
|
39
|
+
/** Settings namespace (must match /^[a-z][a-z0-9-]*$/). */
|
|
40
|
+
export const SETTINGS_NAMESPACE = 'context-guardian'
|
|
41
|
+
|
|
42
|
+
/** Resolved from the module URL -- never from process.cwd(). */
|
|
43
|
+
const PACKAGE_ROOT = dirname(fileURLToPath(import.meta.url))
|
|
44
|
+
const BRIDGE_SCRIPT = join(PACKAGE_ROOT, 'modules', 'cg_bridge.py')
|
|
45
|
+
|
|
46
|
+
const ratio = () => Schema.number().min(0.05).max(0.99)
|
|
47
|
+
|
|
48
|
+
export const Config = Schema.object({
|
|
49
|
+
python: Schema.string().default('')
|
|
50
|
+
.description('Python for the optional bridge. Empty = $GUARDIAN_PYTHON, then a .venv beside this package, then python/python3 on PATH.'),
|
|
51
|
+
numCtx: Schema.natural().default(32768)
|
|
52
|
+
.description('Model context window the ratios are measured against (GUARDIAN_NUM_CTX overrides).'),
|
|
53
|
+
compactThreshold: ratio().default(0.85)
|
|
54
|
+
.description('Hard compaction trigger (GUARDIAN_COMPACT_THRESHOLD overrides).'),
|
|
55
|
+
idleCompactRatio: ratio().default(0.45)
|
|
56
|
+
.description('Proactive trigger when the agent goes idle (GUARDIAN_IDLE_COMPACT_RATIO overrides).'),
|
|
57
|
+
keepRecentMessages: Schema.natural().default(8)
|
|
58
|
+
.description('Most recent messages never compacted (GUARDIAN_KEEP_RECENT_MESSAGES overrides).'),
|
|
59
|
+
reserveOutput: Schema.natural().default(8192)
|
|
60
|
+
.description('Tokens held back for the reply (GUARDIAN_RESERVE_OUTPUT overrides).'),
|
|
61
|
+
spanDir: Schema.string().default('')
|
|
62
|
+
.description('Span archive. Empty = $GUARDIAN_SPAN_DIR, then the proxy\'s own default.'),
|
|
63
|
+
maxRecallTokens: Schema.natural().default(16000)
|
|
64
|
+
.description('Cap on one recall restore (GUARDIAN_MAX_RECALL_TOKENS overrides).'),
|
|
65
|
+
maxSearchHits: Schema.natural().default(50)
|
|
66
|
+
.description('Cap on one search (GUARDIAN_MAX_SEARCH_HITS overrides).'),
|
|
67
|
+
toolArgTools: Schema.array(Schema.string()).default([])
|
|
68
|
+
.description('Tools whose compiled line keeps its key argument. Empty = the compiler\'s DEFAULT_ARG_TOOLS.'),
|
|
69
|
+
hideTools: Schema.array(Schema.string()).default([])
|
|
70
|
+
.description('Tools that produce no compiled line at all.'),
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Fold the environment over a resolved section. GUARDIAN_* are the names
|
|
75
|
+
* context_guardian.py and its .env already use.
|
|
76
|
+
* Precedence: env > settings user layer > patch-row config > schema default.
|
|
77
|
+
* A malformed or out-of-range env value is ignored, never half-applied.
|
|
78
|
+
*/
|
|
79
|
+
export function resolveOptions(section, env = process.env) {
|
|
80
|
+
const int = (key, fallback) => {
|
|
81
|
+
const raw = Number(env[key])
|
|
82
|
+
return Number.isInteger(raw) && raw > 0 ? raw : fallback
|
|
83
|
+
}
|
|
84
|
+
const frac = (key, fallback) => {
|
|
85
|
+
const raw = Number(env[key])
|
|
86
|
+
return Number.isFinite(raw) && raw >= 0.05 && raw <= 0.99 ? raw : fallback
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
python: env.GUARDIAN_PYTHON?.trim() || section.python || defaultPython(),
|
|
90
|
+
numCtx: int('GUARDIAN_NUM_CTX', section.numCtx),
|
|
91
|
+
compactThreshold: frac('GUARDIAN_COMPACT_THRESHOLD', section.compactThreshold),
|
|
92
|
+
idleCompactRatio: frac('GUARDIAN_IDLE_COMPACT_RATIO', section.idleCompactRatio),
|
|
93
|
+
keepRecentMessages: int('GUARDIAN_KEEP_RECENT_MESSAGES', section.keepRecentMessages),
|
|
94
|
+
reserveOutput: int('GUARDIAN_RESERVE_OUTPUT', section.reserveOutput),
|
|
95
|
+
spanDir: env.GUARDIAN_SPAN_DIR?.trim() || section.spanDir || '',
|
|
96
|
+
maxRecallTokens: int('GUARDIAN_MAX_RECALL_TOKENS', section.maxRecallTokens),
|
|
97
|
+
maxSearchHits: int('GUARDIAN_MAX_SEARCH_HITS', section.maxSearchHits),
|
|
98
|
+
toolArgTools: section.toolArgTools?.length > 0 ? [...section.toolArgTools] : [...DEFAULT_ARG_TOOLS],
|
|
99
|
+
hideTools: [...(section.hideTools ?? [])],
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** Cross-field rule the schema cannot express; refuses the WRITE that breaks it. */
|
|
104
|
+
export function validateSection(section) {
|
|
105
|
+
if (section.idleCompactRatio >= section.compactThreshold) {
|
|
106
|
+
throw new Error(`context-guardian: idleCompactRatio (${section.idleCompactRatio}) must be below compactThreshold (${section.compactThreshold})`)
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
function defaultPython() {
|
|
111
|
+
const venv = process.platform === 'win32'
|
|
112
|
+
? join(PACKAGE_ROOT, '.venv', 'Scripts', 'python.exe')
|
|
113
|
+
: join(PACKAGE_ROOT, '.venv', 'bin', 'python')
|
|
114
|
+
if (existsSync(venv)) return venv
|
|
115
|
+
return process.platform === 'win32' ? 'python' : 'python3'
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** The optional Python child; see modules/cg_bridge.py for the op list. */
|
|
119
|
+
export class PythonBridge {
|
|
120
|
+
constructor(options, logger) {
|
|
121
|
+
this.options = options
|
|
122
|
+
this.logger = logger
|
|
123
|
+
this.child = null
|
|
124
|
+
this.nextId = 1
|
|
125
|
+
this.pending = new Map()
|
|
126
|
+
this.stopped = false
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
ensureStarted() {
|
|
130
|
+
if (this.child !== null) return
|
|
131
|
+
if (this.stopped) throw new Error('context-guardian bridge is stopped')
|
|
132
|
+
const env = { ...process.env, PYTHONUNBUFFERED: '1', PYTHONIOENCODING: 'utf-8' }
|
|
133
|
+
if (this.options.spanDir) env.GUARDIAN_SPAN_DIR ??= this.options.spanDir
|
|
134
|
+
const child = spawn(this.options.python, [BRIDGE_SCRIPT], {
|
|
135
|
+
cwd: PACKAGE_ROOT,
|
|
136
|
+
env,
|
|
137
|
+
stdio: ['pipe', 'pipe', 'pipe'],
|
|
138
|
+
windowsHide: true, // no console window, no focus steal
|
|
139
|
+
})
|
|
140
|
+
this.child = child
|
|
141
|
+
createInterface({ input: child.stdout }).on('line', line => this.onLine(line))
|
|
142
|
+
createInterface({ input: child.stderr }).on('line', line => this.logger.debug(`[bridge] ${line}`))
|
|
143
|
+
child.on('error', error => this.onExit(`spawn failed (${this.options.python}): ${error.message}`))
|
|
144
|
+
child.on('exit', (code, signal) => this.onExit(`exited code=${code} signal=${signal}`))
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
onLine(line) {
|
|
148
|
+
let frame
|
|
149
|
+
try {
|
|
150
|
+
frame = JSON.parse(line)
|
|
151
|
+
} catch {
|
|
152
|
+
this.logger.warn(`context-guardian bridge: non-JSON line on stdout dropped (${line.slice(0, 120)})`)
|
|
153
|
+
return
|
|
154
|
+
}
|
|
155
|
+
const waiter = this.pending.get(frame.id)
|
|
156
|
+
if (waiter === undefined) return
|
|
157
|
+
this.pending.delete(frame.id)
|
|
158
|
+
clearTimeout(waiter.timer)
|
|
159
|
+
if (frame.ok === true) waiter.resolve(frame.result)
|
|
160
|
+
else waiter.reject(new Error(String(frame.error ?? 'bridge error')))
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
onExit(reason) {
|
|
164
|
+
if (this.child === null) return
|
|
165
|
+
this.child = null
|
|
166
|
+
for (const waiter of this.pending.values()) {
|
|
167
|
+
clearTimeout(waiter.timer)
|
|
168
|
+
waiter.reject(new Error(`context-guardian bridge ${reason}`))
|
|
169
|
+
}
|
|
170
|
+
this.pending.clear()
|
|
171
|
+
if (!this.stopped) this.logger.error(`context-guardian bridge ${reason}`)
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
request(op, params = {}, timeoutMs = 30000) {
|
|
175
|
+
this.ensureStarted()
|
|
176
|
+
const id = this.nextId++
|
|
177
|
+
return new Promise((resolve, reject) => {
|
|
178
|
+
const timer = setTimeout(() => {
|
|
179
|
+
this.pending.delete(id)
|
|
180
|
+
reject(new Error(`context-guardian bridge: "${op}" timed out after ${timeoutMs}ms`))
|
|
181
|
+
}, timeoutMs)
|
|
182
|
+
this.pending.set(id, { resolve, reject, timer })
|
|
183
|
+
this.child.stdin.write(`${JSON.stringify({ id, op, ...params })}\n`)
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
stop() {
|
|
188
|
+
this.stopped = true
|
|
189
|
+
const child = this.child
|
|
190
|
+
if (child === null) return
|
|
191
|
+
try {
|
|
192
|
+
child.stdin.write(`${JSON.stringify({ id: 0, op: 'shutdown' })}\n`)
|
|
193
|
+
child.stdin.end()
|
|
194
|
+
} catch { /* already gone */ }
|
|
195
|
+
const killer = setTimeout(() => child.kill(), 3000)
|
|
196
|
+
killer.unref()
|
|
197
|
+
child.once('exit', () => clearTimeout(killer))
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
export function apply(ctx, config) {
|
|
202
|
+
validateSection(config) // a bad patch row fails the load, loudly
|
|
203
|
+
|
|
204
|
+
let source = () => config
|
|
205
|
+
let bridge = null
|
|
206
|
+
|
|
207
|
+
const stopBridge = () => {
|
|
208
|
+
if (bridge === null) return
|
|
209
|
+
bridge.stop()
|
|
210
|
+
bridge = null
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** Current options; every Phase 3 consumer reads through this, never a copy. */
|
|
214
|
+
const options = () => resolveOptions(source())
|
|
215
|
+
|
|
216
|
+
/** Spawned on first use only -- Phase 1 never calls it on its own. */
|
|
217
|
+
// eslint-disable-next-line no-unused-vars
|
|
218
|
+
const getBridge = () => {
|
|
219
|
+
bridge ??= new PythonBridge(options(), ctx.logger)
|
|
220
|
+
return bridge
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
ctx.inject(['settings'], (settingsCtx) => {
|
|
224
|
+
settingsCtx.settings.installSection(ctx, SETTINGS_NAMESPACE, Config, config, {
|
|
225
|
+
validate: validateSection,
|
|
226
|
+
setSource: (current) => {
|
|
227
|
+
source = current
|
|
228
|
+
},
|
|
229
|
+
// Thresholds are read through options() at decision time, so a change
|
|
230
|
+
// needs no work here; only the interpreter / span dir are spawn-time.
|
|
231
|
+
onChange: () => {
|
|
232
|
+
if (bridge === null) return
|
|
233
|
+
const next = options()
|
|
234
|
+
if (next.python !== bridge.options.python || next.spanDir !== bridge.options.spanDir) stopBridge()
|
|
235
|
+
},
|
|
236
|
+
})
|
|
237
|
+
})
|
|
238
|
+
|
|
239
|
+
ctx.effect(() => stopBridge)
|
|
240
|
+
|
|
241
|
+
const now = options()
|
|
242
|
+
ctx.logger.info(`context-guardian scaffold loaded (compiler ${COMPILER_REV}; window ${now.numCtx}, hard ${now.compactThreshold}, idle ${now.idleCompactRatio}) -- compaction untouched in Phase 1`)
|
|
243
|
+
|
|
244
|
+
// ── PHASE 3 lands here ────────────────────────────────────────────────────
|
|
245
|
+
// 3.1 snapshot on ctx.on('session/event', ...) where event type is
|
|
246
|
+
// 'compaction/start' (absorbing dsh-openwolf's compactionSurvival, 3d)
|
|
247
|
+
// 3.2 compaction: subclass @deepseek-ai/dsh-compaction-basic and override
|
|
248
|
+
// summarize() with vendor/compiler.js compileRegion() -- 3b / 3c
|
|
249
|
+
// 3b recall + search tools via ctx.tools.register(defineTool(...)), the
|
|
250
|
+
// /recall command, toolArgTools / hideTools, recall + search budgets
|
|
251
|
+
// 3c context_rewrite_cost, idle trigger, tier ladder, keyword index,
|
|
252
|
+
// context_compact
|
|
253
|
+
}
|
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
cg_bridge.py -- private bridge between the dsh-context-guardian DSH plugin
|
|
4
|
+
(Node) and the Python Context Guardian in this repo.
|
|
5
|
+
|
|
6
|
+
Inside DSH, compaction itself is done in-process by the plugin (the vendored
|
|
7
|
+
deterministic compiler) -- no Python on the hot path. This bridge exists so the
|
|
8
|
+
plugin and the HTTP proxy stay ONE product: it reports the proxy's resolved
|
|
9
|
+
GUARDIAN_* configuration and reads the span archive the proxy has already
|
|
10
|
+
written, so recall/search can reach history compacted before the plugin
|
|
11
|
+
existed. The proxy (`context-guardian` / `context_guardian.py`) is untouched
|
|
12
|
+
and keeps working for anyone who prefers that path.
|
|
13
|
+
|
|
14
|
+
PROTOCOL -- one JSON object per line, both directions, UTF-8 (same framing as
|
|
15
|
+
dsh-tool-guardian's tg_bridge.py):
|
|
16
|
+
request {"id": <int>, "op": "<name>", ...params}
|
|
17
|
+
response {"id": <int>, "ok": true, "result": {...}}
|
|
18
|
+
{"id": <int>, "ok": false, "error": "<Type>: <message>"}
|
|
19
|
+
|
|
20
|
+
hello -> versions + interpreter. Imports NOTHING heavy; liveness probe.
|
|
21
|
+
config -> the proxy's resolved settings (imports context_guardian,
|
|
22
|
+
which needs its requirements.txt installed; fails loud if not)
|
|
23
|
+
spans {"limit": <int>?} -> newest span files in the archive
|
|
24
|
+
shutdown -> exits 0
|
|
25
|
+
|
|
26
|
+
python cg_bridge.py serve on stdio
|
|
27
|
+
python cg_bridge.py --selftest offline checks, stdlib only
|
|
28
|
+
|
|
29
|
+
Python >= 3.10 -- same floor as context_guardian.py. MIT licensed.
|
|
30
|
+
"""
|
|
31
|
+
from __future__ import annotations
|
|
32
|
+
|
|
33
|
+
import io
|
|
34
|
+
import json
|
|
35
|
+
import os
|
|
36
|
+
import platform
|
|
37
|
+
import sys
|
|
38
|
+
import tempfile
|
|
39
|
+
from pathlib import Path
|
|
40
|
+
|
|
41
|
+
BRIDGE_VERSION = "0.1.0"
|
|
42
|
+
|
|
43
|
+
# context_guardian.py lives one level above modules/, in the repo AND in the
|
|
44
|
+
# published npm package. Resolved from __file__, never from the working dir.
|
|
45
|
+
PACKAGE_ROOT = Path(__file__).resolve().parent.parent
|
|
46
|
+
|
|
47
|
+
CONFIG_FIELDS = ("GUARDIAN_PORT", "GUARDIAN_HOST", "UPSTREAM_URL", "NUM_CTX",
|
|
48
|
+
"COMPACT_THRESHOLD", "RESERVE_OUTPUT", "KEEP_RECENT_MESSAGES",
|
|
49
|
+
"CHARS_PER_TOKEN_ESTIMATE", "KEEP_SPANS", "KEEP_SUMMARIES")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def default_span_dir() -> Path:
|
|
53
|
+
"""The proxy's own rule, restated without importing it: env, else
|
|
54
|
+
<package>/logs/guardian_spans."""
|
|
55
|
+
return Path(os.environ.get("GUARDIAN_SPAN_DIR",
|
|
56
|
+
str(PACKAGE_ROOT / "logs" / "guardian_spans")))
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _load_proxy():
|
|
60
|
+
if str(PACKAGE_ROOT) not in sys.path:
|
|
61
|
+
sys.path.insert(0, str(PACKAGE_ROOT))
|
|
62
|
+
import context_guardian # noqa: PLC0415 (heavy: fastapi/httpx; on demand only)
|
|
63
|
+
return context_guardian
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Bridge:
|
|
67
|
+
def __init__(self, load_proxy=_load_proxy, span_dir=None):
|
|
68
|
+
self._load_proxy = load_proxy # injectable: selftest passes a fake
|
|
69
|
+
self._span_dir = span_dir
|
|
70
|
+
|
|
71
|
+
def op_hello(self, _req: dict) -> dict:
|
|
72
|
+
return {"bridge": BRIDGE_VERSION,
|
|
73
|
+
"python": platform.python_version(),
|
|
74
|
+
"executable": sys.executable,
|
|
75
|
+
"package_root": str(PACKAGE_ROOT),
|
|
76
|
+
"pid": os.getpid()}
|
|
77
|
+
|
|
78
|
+
def op_config(self, _req: dict) -> dict:
|
|
79
|
+
cg = self._load_proxy()
|
|
80
|
+
out = {name: getattr(cg, name) for name in CONFIG_FIELDS if hasattr(cg, name)}
|
|
81
|
+
out["SPAN_DIR"] = str(getattr(cg, "SPAN_DIR", default_span_dir()))
|
|
82
|
+
out["version"] = getattr(cg, "__version__", "unknown")
|
|
83
|
+
return out
|
|
84
|
+
|
|
85
|
+
def op_spans(self, req: dict) -> dict:
|
|
86
|
+
root = Path(self._span_dir) if self._span_dir else default_span_dir()
|
|
87
|
+
limit = max(1, min(int(req.get("limit") or 50), 500))
|
|
88
|
+
if not root.is_dir():
|
|
89
|
+
# Absent is reported as absent, never as an empty archive.
|
|
90
|
+
return {"dir": str(root), "exists": False, "spans": []}
|
|
91
|
+
files = sorted((p for p in root.iterdir() if p.is_file()),
|
|
92
|
+
key=lambda p: p.stat().st_mtime, reverse=True)
|
|
93
|
+
return {"dir": str(root), "exists": True, "total": len(files),
|
|
94
|
+
"spans": [{"name": p.name, "bytes": p.stat().st_size,
|
|
95
|
+
"mtime": p.stat().st_mtime} for p in files[:limit]]}
|
|
96
|
+
|
|
97
|
+
def op_shutdown(self, _req: dict) -> dict:
|
|
98
|
+
return {"exit": True}
|
|
99
|
+
|
|
100
|
+
def dispatch(self, req: dict) -> dict:
|
|
101
|
+
rid = req.get("id")
|
|
102
|
+
op = str(req.get("op") or "")
|
|
103
|
+
fn = getattr(self, "op_" + op, None)
|
|
104
|
+
if fn is None:
|
|
105
|
+
return {"id": rid, "ok": False, "error": "UnknownOp: %r" % op}
|
|
106
|
+
try:
|
|
107
|
+
return {"id": rid, "ok": True, "result": fn(req)}
|
|
108
|
+
except Exception as exc: # noqa: BLE001
|
|
109
|
+
return {"id": rid, "ok": False,
|
|
110
|
+
"error": "%s: %s" % (type(exc).__name__, exc)}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def serve(stdin, proto, bridge=None) -> int:
|
|
114
|
+
bridge = bridge or Bridge()
|
|
115
|
+
for line in stdin:
|
|
116
|
+
line = line.strip()
|
|
117
|
+
if not line:
|
|
118
|
+
continue
|
|
119
|
+
try:
|
|
120
|
+
req = json.loads(line)
|
|
121
|
+
if not isinstance(req, dict):
|
|
122
|
+
raise ValueError("frame is not an object")
|
|
123
|
+
except ValueError as exc:
|
|
124
|
+
reply = {"id": None, "ok": False, "error": "BadFrame: %s" % exc}
|
|
125
|
+
else:
|
|
126
|
+
reply = bridge.dispatch(req)
|
|
127
|
+
proto.write(json.dumps(reply, ensure_ascii=False) + "\n")
|
|
128
|
+
proto.flush()
|
|
129
|
+
if reply.get("ok") and (reply.get("result") or {}).get("exit"):
|
|
130
|
+
return 0
|
|
131
|
+
return 0
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
# ------------------------------------------------------------- selftest -----
|
|
135
|
+
|
|
136
|
+
class _FakeProxy:
|
|
137
|
+
__version__ = "9.9.9"
|
|
138
|
+
NUM_CTX = 32768
|
|
139
|
+
COMPACT_THRESHOLD = 0.85
|
|
140
|
+
SPAN_DIR = Path("fake-spans")
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
def _check_hello_needs_no_proxy_import() -> bool:
|
|
144
|
+
def boom():
|
|
145
|
+
raise AssertionError("hello must not import the proxy")
|
|
146
|
+
r = Bridge(load_proxy=boom).dispatch({"id": 1, "op": "hello"})
|
|
147
|
+
return r.get("ok") is True and r["result"]["bridge"] == BRIDGE_VERSION
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _check_config_reports_proxy_constants() -> bool:
|
|
151
|
+
r = Bridge(load_proxy=lambda: _FakeProxy).dispatch({"id": 2, "op": "config"})
|
|
152
|
+
res = r.get("result") or {}
|
|
153
|
+
return (r.get("ok") is True and res.get("NUM_CTX") == 32768
|
|
154
|
+
and res.get("COMPACT_THRESHOLD") == 0.85
|
|
155
|
+
and res.get("version") == "9.9.9" and res.get("SPAN_DIR") == "fake-spans")
|
|
156
|
+
|
|
157
|
+
|
|
158
|
+
def _check_config_import_failure_is_loud() -> bool:
|
|
159
|
+
def missing():
|
|
160
|
+
raise ImportError("No module named 'fastapi'")
|
|
161
|
+
r = Bridge(load_proxy=missing).dispatch({"id": 3, "op": "config"})
|
|
162
|
+
return r.get("ok") is False and "fastapi" in str(r.get("error"))
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def _check_missing_span_dir_is_absent_not_empty() -> bool:
|
|
166
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
167
|
+
r = Bridge(span_dir=str(Path(tmp) / "nope")).dispatch({"id": 4, "op": "spans"})
|
|
168
|
+
res = r.get("result") or {}
|
|
169
|
+
return r.get("ok") is True and res.get("exists") is False and res.get("spans") == []
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _check_spans_lists_newest_first_with_limit() -> bool:
|
|
173
|
+
with tempfile.TemporaryDirectory() as tmp:
|
|
174
|
+
for i, name in enumerate(("a.json", "b.json", "c.json")):
|
|
175
|
+
p = Path(tmp) / name
|
|
176
|
+
p.write_text("{}", encoding="utf-8")
|
|
177
|
+
os.utime(p, (1000 + i, 1000 + i))
|
|
178
|
+
r = Bridge(span_dir=tmp).dispatch({"id": 5, "op": "spans", "limit": 2})
|
|
179
|
+
res = r.get("result") or {}
|
|
180
|
+
return (res.get("total") == 3
|
|
181
|
+
and [s["name"] for s in res.get("spans", [])] == ["c.json", "b.json"])
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def _check_default_span_dir_is_under_package_root() -> bool:
|
|
185
|
+
saved = os.environ.pop("GUARDIAN_SPAN_DIR", None)
|
|
186
|
+
try:
|
|
187
|
+
d = default_span_dir()
|
|
188
|
+
finally:
|
|
189
|
+
if saved is not None:
|
|
190
|
+
os.environ["GUARDIAN_SPAN_DIR"] = saved
|
|
191
|
+
return (d == PACKAGE_ROOT / "logs" / "guardian_spans"
|
|
192
|
+
and (PACKAGE_ROOT / "context_guardian.py").is_file()
|
|
193
|
+
and PACKAGE_ROOT.name != "modules")
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def _check_serve_roundtrip_and_shutdown_exits() -> bool:
|
|
197
|
+
stdin = io.StringIO('{"id": 7, "op": "hello"}\nnot json\n{"id": 8, "op": "shutdown"}\n'
|
|
198
|
+
'{"id": 9, "op": "hello"}\n')
|
|
199
|
+
proto = io.StringIO()
|
|
200
|
+
code = serve(stdin, proto)
|
|
201
|
+
frames = [json.loads(x) for x in proto.getvalue().splitlines()]
|
|
202
|
+
return (code == 0 and len(frames) == 3
|
|
203
|
+
and frames[0]["id"] == 7 and frames[0]["ok"] is True
|
|
204
|
+
and "BadFrame" in frames[1]["error"]
|
|
205
|
+
and frames[2]["result"]["exit"] is True)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
CHECKS = [
|
|
209
|
+
("hello_needs_no_proxy_import", _check_hello_needs_no_proxy_import),
|
|
210
|
+
("config_reports_proxy_constants", _check_config_reports_proxy_constants),
|
|
211
|
+
("config_import_failure_is_loud", _check_config_import_failure_is_loud),
|
|
212
|
+
("missing_span_dir_is_absent_not_empty", _check_missing_span_dir_is_absent_not_empty),
|
|
213
|
+
("spans_lists_newest_first_with_limit", _check_spans_lists_newest_first_with_limit),
|
|
214
|
+
("default_span_dir_is_under_package_root", _check_default_span_dir_is_under_package_root),
|
|
215
|
+
("serve_roundtrip_and_shutdown_exits", _check_serve_roundtrip_and_shutdown_exits),
|
|
216
|
+
]
|
|
217
|
+
|
|
218
|
+
|
|
219
|
+
def selftest() -> int:
|
|
220
|
+
passed = 0
|
|
221
|
+
for name, fn in CHECKS:
|
|
222
|
+
try:
|
|
223
|
+
ok = fn() is True
|
|
224
|
+
except Exception as exc: # noqa: BLE001
|
|
225
|
+
ok = False
|
|
226
|
+
print(" FAIL %s (%s: %s)" % (name, type(exc).__name__, exc))
|
|
227
|
+
else:
|
|
228
|
+
print(" %s %s" % ("ok " if ok else "FAIL", name))
|
|
229
|
+
if ok:
|
|
230
|
+
passed += 1
|
|
231
|
+
total = len(CHECKS)
|
|
232
|
+
print("cg_bridge selftest: %d checks, %d passed, %d failed"
|
|
233
|
+
% (total, passed, total - passed))
|
|
234
|
+
return 0 if total > 0 and passed == total else 1
|
|
235
|
+
|
|
236
|
+
|
|
237
|
+
def main(argv=None) -> int:
|
|
238
|
+
argv = list(sys.argv[1:] if argv is None else argv)
|
|
239
|
+
if "--selftest" in argv:
|
|
240
|
+
return selftest()
|
|
241
|
+
if "--version" in argv:
|
|
242
|
+
print(BRIDGE_VERSION)
|
|
243
|
+
return 0
|
|
244
|
+
proto = io.TextIOWrapper(sys.stdout.buffer, encoding="utf-8", newline="\n")
|
|
245
|
+
stdin = io.TextIOWrapper(sys.stdin.buffer, encoding="utf-8", errors="replace")
|
|
246
|
+
sys.stdout = sys.stderr # nothing but `proto` may reach the real stdout
|
|
247
|
+
return serve(stdin, proto)
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
if __name__ == "__main__":
|
|
251
|
+
raise SystemExit(main())
|
package/package.json
ADDED
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "dsh-context-guardian",
|
|
3
|
+
"version": "0.1.0-alpha.2",
|
|
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
|
+
"type": "module",
|
|
6
|
+
"main": "index.js",
|
|
7
|
+
"exports": {
|
|
8
|
+
".": "./index.js",
|
|
9
|
+
"./engine": "./engine.js",
|
|
10
|
+
"./package.json": "./package.json"
|
|
11
|
+
},
|
|
12
|
+
"files": [
|
|
13
|
+
"index.js",
|
|
14
|
+
"engine.js",
|
|
15
|
+
"cg_recall.js",
|
|
16
|
+
"cordis.patch.yml",
|
|
17
|
+
"modules/cg_bridge.py",
|
|
18
|
+
"vendor/compiler.js",
|
|
19
|
+
"vendor/region.js",
|
|
20
|
+
"vendor/LICENSE.dsh-compaction-instant",
|
|
21
|
+
"context_guardian.py",
|
|
22
|
+
"configure.py",
|
|
23
|
+
"requirements.txt",
|
|
24
|
+
"docs/dsh-integration.md",
|
|
25
|
+
"THIRD_PARTY_NOTICES.md",
|
|
26
|
+
"README.md",
|
|
27
|
+
"CHANGELOG.md",
|
|
28
|
+
"LICENSE"
|
|
29
|
+
],
|
|
30
|
+
"dsh": {
|
|
31
|
+
"bundle": {
|
|
32
|
+
"patch": "./cordis.patch.yml"
|
|
33
|
+
}
|
|
34
|
+
},
|
|
35
|
+
"scripts": {
|
|
36
|
+
"check": "node --check index.js && node --check engine.js && node --check cg_recall.js && node --check vendor/compiler.js",
|
|
37
|
+
"selftest": "python modules/cg_bridge.py --selftest",
|
|
38
|
+
"test:node": "node tests/probes/probe_cg_recall.mjs && node cg_recall.js --selftest && node tests/engine_smoke.mjs"
|
|
39
|
+
},
|
|
40
|
+
"engines": {
|
|
41
|
+
"node": "^22.19.0 || >=24.0.0"
|
|
42
|
+
},
|
|
43
|
+
"dependencies": {
|
|
44
|
+
"@deepseek-ai/schemastery": "^3.18.2"
|
|
45
|
+
},
|
|
46
|
+
"peerDependencies": {
|
|
47
|
+
"@deepseek-ai/cordis": "^4.0.2",
|
|
48
|
+
"@deepseek-ai/dsh-compaction": ">=0.1.2-alpha.2",
|
|
49
|
+
"@deepseek-ai/dsh-llm": ">=0.1.2-alpha.2",
|
|
50
|
+
"@deepseek-ai/dsh-tools": ">=0.1.2-alpha.2"
|
|
51
|
+
},
|
|
52
|
+
"keywords": [
|
|
53
|
+
"dsh",
|
|
54
|
+
"dsh-plugin",
|
|
55
|
+
"dsh-bundle",
|
|
56
|
+
"deepseek-harness",
|
|
57
|
+
"compaction",
|
|
58
|
+
"context-window",
|
|
59
|
+
"context-management",
|
|
60
|
+
"recall",
|
|
61
|
+
"local-llm",
|
|
62
|
+
"ollama"
|
|
63
|
+
],
|
|
64
|
+
"license": "MIT",
|
|
65
|
+
"author": "LuminariSoftwares",
|
|
66
|
+
"homepage": "https://github.com/LuminariSoftwares/context-guardian",
|
|
67
|
+
"repository": {
|
|
68
|
+
"type": "git",
|
|
69
|
+
"url": "git+https://github.com/LuminariSoftwares/context-guardian.git"
|
|
70
|
+
},
|
|
71
|
+
"bugs": {
|
|
72
|
+
"url": "https://github.com/LuminariSoftwares/context-guardian/issues"
|
|
73
|
+
}
|
|
74
|
+
}
|
package/requirements.txt
ADDED
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
compiler.js and region.js in this directory are vendored, unmodified, from
|
|
2
|
+
dsh-compaction-instant@0.1.4 (npm; author TsFreddie), md5:
|
|
3
|
+
e74bb51763795abd1a839e61de4875fa compiler.js
|
|
4
|
+
dc719f0745a532dd80ef1a3125f3bf6d region.js
|
|
5
|
+
The compiler ports the principle of https://github.com/lllyasviel/VCC
|
|
6
|
+
(skills/conversation-compiler/scripts/VCC.py). Their original headers are kept.
|
|
7
|
+
|
|
8
|
+
MIT License
|
|
9
|
+
|
|
10
|
+
Copyright (c) 2026 TsFreddie
|
|
11
|
+
|
|
12
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
13
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
14
|
+
in the Software without restriction, including without limitation the rights
|
|
15
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
16
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
17
|
+
furnished to do so, subject to the following conditions:
|
|
18
|
+
|
|
19
|
+
The above copyright notice and this permission notice shall be included in all
|
|
20
|
+
copies or substantial portions of the Software.
|
|
21
|
+
|
|
22
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
23
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
24
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
25
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
26
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
27
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
28
|
+
SOFTWARE.
|