dsh-engram 0.9.0 → 0.9.1
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/README.md +4 -1
- package/docs/RELEASE-NOTES-v0.9.1.md +112 -0
- package/lib/context-gc.js +93 -10
- package/lib/index.js +24 -4
- package/package.json +2 -1
- package/scripts/host-mount-probe.mjs +172 -0
package/README.md
CHANGED
|
@@ -1269,9 +1269,12 @@ Defaults are token-conscious; override any key via the profile patch
|
|
|
1269
1269
|
## Development
|
|
1270
1270
|
|
|
1271
1271
|
```sh
|
|
1272
|
-
npm test #
|
|
1272
|
+
npm test # 532 tests: core + web API + GC + usage-observability + Context GC + ESR triggers + BoundaryPrune + preference learning + Context Ledger + JIT push (node:test)
|
|
1273
1273
|
npm run eval # offline recall + structure benchmark (deterministic)
|
|
1274
1274
|
npm run build:client
|
|
1275
|
+
npm run host:probe # boot a throwaway real dsh host and assert the compaction engine's
|
|
1276
|
+
# pressure path end to end (no model call; --gc for GC mode, --tgz <old>
|
|
1277
|
+
# for the regression control) — issues #12/#13
|
|
1275
1278
|
```
|
|
1276
1279
|
|
|
1277
1280
|
### Testing & evaluation (two layers of real testing)
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
# dsh-engram v0.9.1
|
|
2
|
+
|
|
3
|
+
**两个「自己说自己活着」的部件:一个在 Windows 上从第一次启动起就没亮过,一个接管了
|
|
4
|
+
本机 compaction 却一次也没压缩过。**
|
|
5
|
+
|
|
6
|
+
从 `v0.9.0` 到这里只有两个 commit,修的是同一类故障——**状态面显示正常,功能面从未发生**。
|
|
7
|
+
这类故障单测抓不到,因为它不是逻辑错了,而是两个面用的键、或者两个面用的上下文不一致:
|
|
8
|
+
写的时候用了一套,读/挂的时候用了另一套。两次都是先有人在真实环境里看见「那个东西没出现」,
|
|
9
|
+
才回头找到这条缝。
|
|
10
|
+
|
|
11
|
+
## 修复
|
|
12
|
+
|
|
13
|
+
### 1. `[ENGRAM]` 块在 Windows 上恒为空(issue #10)
|
|
14
|
+
|
|
15
|
+
`blockFor` / `esrBlockFor` 用裸 `path.resolve(cwd)` 推会话工作区,而所有写入路径——以及
|
|
16
|
+
渲染调用下面两行的账本——用的是 `workspaceKey()`。win32 上写入端会把整个 key 小写
|
|
17
|
+
(盘符默认大写),于是原始 key 匹配到 0 行:`[ENGRAM]` 渲染成空串,`sessionCache` 再把
|
|
18
|
+
空串冻住整场会话,看起来像「记忆库是空的」。
|
|
19
|
+
|
|
20
|
+
`[RESUME]` 与 `[ESR]` 快照/冻结块是同一个第二 key 派生,一起修。三处调用点现在共用
|
|
21
|
+
`wk(cwd)`,`lib/index.js` 不再 import `node:path`——没有第二条派生路可走。
|
|
22
|
+
`test/workspace-key.test.mjs` 不依赖 Windows 主机也能复现这对键,另外用源码不变量把调用点
|
|
23
|
+
锁住。
|
|
24
|
+
|
|
25
|
+
### 2. 宿主面 compaction 引擎从未真正挂上(issues #12 / #13)
|
|
26
|
+
|
|
27
|
+
`BasicCompactionEngine` 声明 `static inject = ["llm", "tokenMeter", "sessions"]`,但
|
|
28
|
+
`static inject` 只有被 loader **当插件加载**时才转成供给好的依赖图。`mountCompactionEngine`
|
|
29
|
+
用的是 `new Engine(ctx)`,而且这个 `ctx` 来自 engram 自己那条 `inject = ["tools"]` 的 fiber,
|
|
30
|
+
于是 `compactIfNeeded()` 的第一句 `this.ctx.tokenMeter` 直接从 ctx 代理里抛
|
|
31
|
+
`cannot get property "tokenMeter" without inject`——可选链救不了,getter 本身在抛。
|
|
32
|
+
|
|
33
|
+
两个后端把同一条错误吞成了两种表情:
|
|
34
|
+
|
|
35
|
+
- 注册 `agent/request(prepend)` 的后端 → 错误冒到模型切换上,界面提示
|
|
36
|
+
"select a model with a larger context window"(误导,跟模型大小无关);
|
|
37
|
+
- 在 `agent/pre-step` 里读的后端 → 被 catch 成 `step compaction failed … continuing the turn`,
|
|
38
|
+
引擎挂着、`context-gc.status.json` 一直写着 `host: "context-gc"`、却从不压缩。
|
|
39
|
+
|
|
40
|
+
现在 `mountCompactionEngine` 改为 `ctx.plugin(Engine)`,两个子类都接
|
|
41
|
+
`constructor(engineCtx = ctx)`,loader 在引擎自己的 fiber 上供给三个服务;engram 自身仍只
|
|
42
|
+
inject `["tools"]`,minimal/headless profile 不因此多背依赖。
|
|
43
|
+
|
|
44
|
+
顺手处理的三个坑(都有测试钉住):
|
|
45
|
+
|
|
46
|
+
- **`fiber.await()` 不是就绪信号**:inject 缺失时 fiber 从不启动,`await()` 立即 resolve
|
|
47
|
+
且 resolve 的是 undefined。改成等引擎自身构造的回调;宿主永不供给服务时挂载保持 pending,
|
|
48
|
+
`lib/index.js` 用 15s 上界把 `host: "unavailable"` 写进 status 并 warn 一行,不再静默。
|
|
49
|
+
- **重复 provider 必须在挂载前拦**(`ctx.get("compaction") !== undefined` → 抛)。否则 cordis
|
|
50
|
+
从 fiber 内部拒绝并 `logger.error` 打栈,而「base 的 `compaction-basic` 行还启用着」本是
|
|
51
|
+
设计内的情况,不是事故。
|
|
52
|
+
- **错误路径**:`MountedEngine` 里 `try { super() } catch (e) { settle.reject(e); throw e }`,
|
|
53
|
+
否则构造失败会让挂载静默挂死(内部 `catch {}` 把异常吃了)。
|
|
54
|
+
|
|
55
|
+
## 这次怎么验的
|
|
56
|
+
|
|
57
|
+
单测跑的是真 cordis + 真 backend(`test/compaction-mount.test.mjs`,含两个反向对照:
|
|
58
|
+
一个断言 `compactIfNeeded()` 确实经注入的 meter 实测,一个保留 `new` 的做法仍复现原报错)。
|
|
59
|
+
但「真 backend」还不是「真宿主」,所以补了一次真机探针:
|
|
60
|
+
|
|
61
|
+
**做法** —— 把本仓库 `npm pack` 成 tarball,在 `/tmp` 里起一个一次性 `DSH_HOME`,用
|
|
62
|
+
`dsh --from-default-profile web` 造 profile,`dsh plugin --profile probe add <tgz>` 装上,
|
|
63
|
+
再用一份 overlay patch 插一个只做一件事的探针插件:启动 8 秒后调
|
|
64
|
+
`ctx.compaction.compactIfNeeded(agent, "pressure")`,把结果写进文件;`dsh --port 3199 web`
|
|
65
|
+
起真宿主,读文件。这套流程已固化成 `npm run host:probe`(`scripts/host-mount-probe.mjs`,
|
|
66
|
+
`--gc` 切 GC 模式、`--tgz <old.tgz>` 当对照;不联网、不调模型,退出码 0 = 通 / 1 = 引擎报错)——
|
|
67
|
+
下面这张表的每一行都能用一条命令重跑。
|
|
68
|
+
|
|
69
|
+
**读数**(`dsh 0.2.1-rc.2`,本机):
|
|
70
|
+
|
|
71
|
+
| 装上的是 | `status.host` | `compactIfNeeded("pressure")` |
|
|
72
|
+
|---|---|---|
|
|
73
|
+
| 本版(fixed,默认配置) | `default` | `ok: true`,`null`(空会话无需压缩) |
|
|
74
|
+
| 本版(fixed,`gcReplacesCompaction: true`) | `context-gc` | `ok: true`,`null` |
|
|
75
|
+
| 父提交 `42a9552`(对照) | `default` | `ok: false` —— `cannot get property "tokenMeter" without inject` |
|
|
76
|
+
|
|
77
|
+
第三行是关键:同一套探针、同一个宿主,把插件换回父提交的 tarball,原报错原样复现。也就是说
|
|
78
|
+
前两行的绿不是「没测到那条路」,是那条路真的通了。
|
|
79
|
+
|
|
80
|
+
## 其他
|
|
81
|
+
|
|
82
|
+
- 贡献者 PR #11(客户端 i18n + 俄语目录,457 条)已在本机全量验证(合并无冲突、`build:client`
|
|
83
|
+
与提交产物逐字节一致、527/525 只有本机 fence 失败),但**不在本版**:它改的是 `client/`,
|
|
84
|
+
等 review 合并后再发。
|
|
85
|
+
- 配置键与数据格式与 `0.9.0` / `0.8.8` 完全一致,**无需迁移**。
|
|
86
|
+
|
|
87
|
+
## DSH 版本支持
|
|
88
|
+
|
|
89
|
+
- **支持的 DSH 范围**:`>=0.1.2-alpha.2 <0.3.0-0`
|
|
90
|
+
- **peer 声明**:
|
|
91
|
+
- `@deepseek-ai/dsh-compaction`: `>=0.1.2-alpha.2 <0.3.0-0`
|
|
92
|
+
- `@deepseek-ai/dsh-compaction-basic`: `>=0.1.2-alpha.2 <0.3.0-0`
|
|
93
|
+
- `@deepseek-ai/dsh-llm`: `>=0.1.2-alpha.2 <0.3.0-0`
|
|
94
|
+
- `@deepseek-ai/dsh-settings`: `>=0.1.2-alpha.2 <0.3.0-0`
|
|
95
|
+
- `@deepseek-ai/dsh-storage-domain`: `>=0.1.2-alpha.2 <0.3.0-0`
|
|
96
|
+
- **验证版本**:`dsh 0.2.1-rc.2`(本机 CLI + web;宿主面 compaction 挂载另用一次性 profile 实测,
|
|
97
|
+
见上)
|
|
98
|
+
- **最后验证日期**:2026-11-09
|
|
99
|
+
- **安装方式已验证**:`dsh plugin --profile <name> add <tgz|dsh-engram>`
|
|
100
|
+
|
|
101
|
+
## 验证
|
|
102
|
+
|
|
103
|
+
```
|
|
104
|
+
npm test → 532 tests, 530 pass
|
|
105
|
+
(2 个失败仅 test/g6-sandbox.test.mjs:需要 OS 级 fence,本机没有,
|
|
106
|
+
父提交上同样失败)
|
|
107
|
+
npm run typecheck → ok
|
|
108
|
+
npm run build:client → 无 diff(bundle 新鲜)
|
|
109
|
+
npm run prepublishOnly → packed tarball 里 host + client bundle 双入口 import OK
|
|
110
|
+
npm pack → 476.8 kB / 96 files(client bundle 298476 bytes)
|
|
111
|
+
npm run host:probe → exit 0(`--tgz <父提交 tarball>` 复跑:exit 1,原报错)
|
|
112
|
+
```
|
package/lib/context-gc.js
CHANGED
|
@@ -660,6 +660,25 @@ export function buildContextGcSummary(report, context, { turns, narrative }) {
|
|
|
660
660
|
return parts.join("\n");
|
|
661
661
|
}
|
|
662
662
|
|
|
663
|
+
/**
|
|
664
|
+
* Services `BasicCompactionEngine` reads straight off `this.ctx`:
|
|
665
|
+
* `tokenMeter.measure()` on every pressure check, `sessions.flush()` on a manual
|
|
666
|
+
* compaction, `llm.resolveModelInfo()` for the model's context window.
|
|
667
|
+
*
|
|
668
|
+
* These are declared by the backend's own `static inject`, and that static only
|
|
669
|
+
* takes effect when the class is loaded as a PLUGIN. Until this fix the mount
|
|
670
|
+
* used `new Engine(ctx)` — no loader, no fiber, no provisioning — so the first
|
|
671
|
+
* `this.ctx.tokenMeter` threw `cannot get property "tokenMeter" without inject`
|
|
672
|
+
* out of cordis's context proxy. The engine's own
|
|
673
|
+
* `ctx?.on(...)` listeners kept working (events are root-wide), which is why the
|
|
674
|
+
* breakage looked like "compaction silently does nothing" on one backend
|
|
675
|
+
* version and "model switch rejected" on another (issues #12/#13).
|
|
676
|
+
*
|
|
677
|
+
* Mounted through `ctx.plugin(Engine)` the loader provisions exactly this list
|
|
678
|
+
* on the engine's own fiber, so the reads resolve for real.
|
|
679
|
+
*/
|
|
680
|
+
const ENGINE_INJECT = ["llm", "tokenMeter", "sessions"];
|
|
681
|
+
|
|
663
682
|
/**
|
|
664
683
|
* Load a mountable compaction engine CLASS for the host/preset plane.
|
|
665
684
|
*
|
|
@@ -669,6 +688,11 @@ export function buildContextGcSummary(report, context, { turns, narrative }) {
|
|
|
669
688
|
* base `compaction-basic` row was disabled by the profile patch, so the
|
|
670
689
|
* `compaction` service never disappears while dsh-engram is installed.
|
|
671
690
|
*
|
|
691
|
+
* Both subclasses take the ctx they are given (a plugin mount passes the
|
|
692
|
+
* fiber's own context — the one with {@link ENGINE_INJECT} provisioned) and
|
|
693
|
+
* fall back to the captured `ctx` so a plain `new Engine()` still works where
|
|
694
|
+
* no loader is available.
|
|
695
|
+
*
|
|
672
696
|
* Returns `null` when `@deepseek-ai/dsh-compaction-basic` is not importable
|
|
673
697
|
* (graceful — the host keeps whatever compaction it still has).
|
|
674
698
|
*/
|
|
@@ -680,14 +704,61 @@ export async function loadCompactionEngine(ctx, config = {}, deps = {}, { mode =
|
|
|
680
704
|
// the shipped default LLM summarizer), so when the base row was disabled
|
|
681
705
|
// by the profile patch the `compaction` service still exists.
|
|
682
706
|
return class DefaultCompactionEngine extends mod.BasicCompactionEngine {
|
|
683
|
-
|
|
684
|
-
|
|
707
|
+
static inject = [...ENGINE_INJECT];
|
|
708
|
+
constructor(engineCtx = ctx) {
|
|
709
|
+
super(engineCtx, { auto: true, ...config });
|
|
685
710
|
}
|
|
686
711
|
};
|
|
687
712
|
}
|
|
688
713
|
return loadContextGcEngine(ctx, config, deps);
|
|
689
714
|
}
|
|
690
715
|
|
|
716
|
+
/**
|
|
717
|
+
* Start the engine as a cordis PLUGIN, so the loader provisions the injects its
|
|
718
|
+
* backend declared (`llm`/`tokenMeter`/`sessions`) on a fiber of its own.
|
|
719
|
+
*
|
|
720
|
+
* A ctx without `plugin` (unit-test fakes, vendored hosts) gets the direct
|
|
721
|
+
* construction fallback: the engine then only sees what that ctx already
|
|
722
|
+
* exposes — the shape the unit-test fakes still construct directly.
|
|
723
|
+
*/
|
|
724
|
+
async function mountEngine(ctx, Engine) {
|
|
725
|
+
if (typeof ctx?.plugin !== "function") {
|
|
726
|
+
new Engine();
|
|
727
|
+
return;
|
|
728
|
+
}
|
|
729
|
+
// Refuse a second provider up front. cordis would reject it too, but only
|
|
730
|
+
// from inside the fiber — which logs a startup error for a case this module
|
|
731
|
+
// handles by design (the base `compaction-basic` row still enabled).
|
|
732
|
+
if (ctx.get?.("compaction") !== void 0) {
|
|
733
|
+
throw new Error('service "compaction" has already been registered — keeping the existing compaction service');
|
|
734
|
+
}
|
|
735
|
+
// The loader activates the fiber asynchronously, and `fiber.await()` is NOT a
|
|
736
|
+
// readiness signal: with an inject still missing the fiber never starts, yet
|
|
737
|
+
// await() resolves immediately (no in-flight lifecycle work). So the engine
|
|
738
|
+
// itself reports construction — which is exactly when the injects were
|
|
739
|
+
// provisioned and the `compaction` service registered. A rejected inject set
|
|
740
|
+
// leaves this pending, the honest state for a host that lacks the services.
|
|
741
|
+
let settle;
|
|
742
|
+
const mounted = new Promise((resolve, reject) => {
|
|
743
|
+
settle = { resolve, reject };
|
|
744
|
+
});
|
|
745
|
+
class MountedEngine extends Engine {
|
|
746
|
+
constructor(engineCtx) {
|
|
747
|
+
try {
|
|
748
|
+
super(engineCtx);
|
|
749
|
+
} catch (error) {
|
|
750
|
+
// Construction refused (duplicate provider, bad config): surface it to
|
|
751
|
+
// the caller and rethrow so cordis marks the fiber failed.
|
|
752
|
+
settle.reject(error);
|
|
753
|
+
throw error;
|
|
754
|
+
}
|
|
755
|
+
settle.resolve(true);
|
|
756
|
+
}
|
|
757
|
+
}
|
|
758
|
+
ctx.plugin(MountedEngine);
|
|
759
|
+
await mounted;
|
|
760
|
+
}
|
|
761
|
+
|
|
691
762
|
/**
|
|
692
763
|
* Mount the compaction engine onto `ctx` — THE shared entry both planes use
|
|
693
764
|
* (host plane: the main engram plugin's `apply`; preset plane: the
|
|
@@ -700,9 +771,17 @@ export async function loadCompactionEngine(ctx, config = {}, deps = {}, { mode =
|
|
|
700
771
|
* reaches engram/ESR state for pointer classification — the caller supplies
|
|
701
772
|
* its own lazy-domain binding.
|
|
702
773
|
*
|
|
703
|
-
*
|
|
704
|
-
*
|
|
705
|
-
*
|
|
774
|
+
* The engine is mounted AS A PLUGIN (`ctx.plugin(Engine)`), never with
|
|
775
|
+
* `new Engine(ctx)`: only the loader creates the fiber that provisions the
|
|
776
|
+
* backend's `llm`/`tokenMeter`/`sessions` injects. The host plane mounts from a
|
|
777
|
+
* fiber that injects just `tools`, so constructing directly left the engine
|
|
778
|
+
* reading services its ctx was not allowed to see (issues #12/#13).
|
|
779
|
+
*
|
|
780
|
+
* Fully contained: an unimportable backend, a missing service, or a duplicate
|
|
781
|
+
* `compaction` provider (cordis refuses second registration) only warns and
|
|
782
|
+
* returns null — the host keeps whatever compaction it already has, never a
|
|
783
|
+
* crash. A host that never provides the three services leaves the mount fiber
|
|
784
|
+
* pending, so callers that report status must bound their own wait.
|
|
706
785
|
*
|
|
707
786
|
* @returns {Promise<Function|null>} the mounted engine class, or null.
|
|
708
787
|
*/
|
|
@@ -723,10 +802,11 @@ export async function mountCompactionEngine(ctx, config = {}, deps = {}) {
|
|
|
723
802
|
return null;
|
|
724
803
|
}
|
|
725
804
|
try {
|
|
726
|
-
|
|
805
|
+
await mountEngine(ctx, Engine);
|
|
727
806
|
} catch (error) {
|
|
728
|
-
// Duplicate `compaction` provider (base row not disabled)
|
|
729
|
-
// refusal: keep whatever is already
|
|
807
|
+
// Duplicate `compaction` provider (base row not disabled), a rejected
|
|
808
|
+
// plugin load, or constructor refusal: keep whatever is already
|
|
809
|
+
// registered, never crash the host.
|
|
730
810
|
ctx?.logger?.warn?.(
|
|
731
811
|
`engram context-gc mount skipped (${error instanceof Error ? error.message : String(error)}) — keeping the existing compaction service`,
|
|
732
812
|
);
|
|
@@ -767,8 +847,11 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
|
|
|
767
847
|
const engineConfig = { auto: true, ...config };
|
|
768
848
|
|
|
769
849
|
return class ContextGcEngine extends Basic {
|
|
770
|
-
|
|
771
|
-
|
|
850
|
+
/** Declared for the loader: these are read as `this.ctx.<name>` below. */
|
|
851
|
+
static inject = [...ENGINE_INJECT];
|
|
852
|
+
/** `ctx.plugin(Engine)` passes the fiber's ctx; `new Engine()` uses the captured one. */
|
|
853
|
+
constructor(engineCtx = ctx) {
|
|
854
|
+
super(engineCtx, engineConfig);
|
|
772
855
|
this.gcDeps = deps;
|
|
773
856
|
/** Last credit computed by `_contextGc`, surfaced in the attempt log. */
|
|
774
857
|
this.gcLastCredit = void 0;
|
package/lib/index.js
CHANGED
|
@@ -18,7 +18,6 @@
|
|
|
18
18
|
* `search_sessions` FTS fallback.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
|
-
import { resolve as resolvePath } from "node:path";
|
|
22
21
|
import { openEngramDomain } from "./store.js";
|
|
23
22
|
import { registerTools } from "./tools.js";
|
|
24
23
|
import { makeCaptureHandler } from "./capture.js";
|
|
@@ -561,7 +560,12 @@ export function apply(ctx, config) {
|
|
|
561
560
|
let block = "";
|
|
562
561
|
let pendingRecall = false;
|
|
563
562
|
try {
|
|
564
|
-
|
|
563
|
+
// Read paths MUST key the store exactly like the write paths do — on
|
|
564
|
+
// win32 `workspaceKey` lowercases the whole key (drive letters are
|
|
565
|
+
// uppercase by default), so a raw `resolve(cwd)` here finds zero rows
|
|
566
|
+
// and freezes an empty [ENGRAM]/[RESUME] block for the whole session
|
|
567
|
+
// (issue #10, and the missing [RESUME] in #9).
|
|
568
|
+
const workspace = wk(cwd);
|
|
565
569
|
// Anchor revalidation (v0.7-C): a bounded stat pass at session start.
|
|
566
570
|
// Stale rows are tagged (for the GUI/GC) and excluded from the render
|
|
567
571
|
// below via memoryIsStale — never deleted, still re-fetchable.
|
|
@@ -659,7 +663,7 @@ export function apply(ctx, config) {
|
|
|
659
663
|
let snapshot = esrSnapshotCache.get(session);
|
|
660
664
|
if (snapshot === void 0) {
|
|
661
665
|
try {
|
|
662
|
-
snapshot = buildEsrSnapshot(opened,
|
|
666
|
+
snapshot = buildEsrSnapshot(opened, wk(cwd), resolved, { sessionId: session.id });
|
|
663
667
|
} catch (error) {
|
|
664
668
|
log?.warn?.(`engram esr render failed: ${String(error)}`);
|
|
665
669
|
return "";
|
|
@@ -673,7 +677,7 @@ export function apply(ctx, config) {
|
|
|
673
677
|
}
|
|
674
678
|
let block = "";
|
|
675
679
|
try {
|
|
676
|
-
block = buildFrozenEsrBlock(opened,
|
|
680
|
+
block = buildFrozenEsrBlock(opened, wk(cwd), resolved, {
|
|
677
681
|
recorder,
|
|
678
682
|
sessionId: session.id,
|
|
679
683
|
holder,
|
|
@@ -792,6 +796,22 @@ export function apply(ctx, config) {
|
|
|
792
796
|
service.compactionStatus.host = "unavailable";
|
|
793
797
|
flushStatus();
|
|
794
798
|
});
|
|
799
|
+
// The engine is mounted as a plugin so the loader provisions its
|
|
800
|
+
// `llm`/`tokenMeter`/`sessions` injects (issues #12/#13). A host that never
|
|
801
|
+
// provides them leaves that fiber pending forever, which would keep
|
|
802
|
+
// `hostSettled` false and the status file unwritten — the same "wiring run
|
|
803
|
+
// that never finished" the web plane's timer below exists to avoid. Bound
|
|
804
|
+
// the wait and report the missing deps instead of staying silent.
|
|
805
|
+
const hostStatusTimer = setTimeout(() => {
|
|
806
|
+
if (hostSettled) return;
|
|
807
|
+
hostSettled = true;
|
|
808
|
+
service.compactionStatus.host = "unavailable";
|
|
809
|
+
ctx?.logger?.warn?.(
|
|
810
|
+
"engram context-gc: compaction mount still pending after 15s — this host does not provide llm/tokenMeter/sessions to the engine; the host keeps its own compaction",
|
|
811
|
+
);
|
|
812
|
+
flushStatus();
|
|
813
|
+
}, 15_000);
|
|
814
|
+
hostStatusTimer.unref?.();
|
|
795
815
|
|
|
796
816
|
// Web-plane auto-wiring (Context GC): web sessions resolve `compaction`
|
|
797
817
|
// inside the agent preset's own realm, which a profile patch cannot reach.
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "dsh-engram",
|
|
3
3
|
"description": "Minimalist long-term memory for DeepSeek Harness, distilling symbolic-index + pi-esr ideas: zero-LLM auto-capture, a symbolic [ENGRAM] index with progressive disclosure, and an ESR-lite evidence-closure protocol (esr_task / esr_node / esr_close / esr_link). Real agent-behaviour telemetry (usage rollup + /stats) and a deterministic offline recall benchmark (npm run eval). Hot path is model-free; storage on ctx.storageDomain; web memory viewer, observability panel and config card through DSH's native settings slots.",
|
|
4
|
-
"version": "0.9.
|
|
4
|
+
"version": "0.9.1",
|
|
5
5
|
"packageManager": "pnpm@11.22.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|
|
@@ -46,6 +46,7 @@
|
|
|
46
46
|
"dsh-engram:doctor": "node scripts/dsh-engram.mjs doctor",
|
|
47
47
|
"eval": "node eval/recall-bench.mjs",
|
|
48
48
|
"gate:audit": "node eval/gate-audit.mjs",
|
|
49
|
+
"host:probe": "node scripts/host-mount-probe.mjs",
|
|
49
50
|
"playbook:audit": "node eval/playbook-audit.mjs",
|
|
50
51
|
"prepublishOnly": "node scripts/prepublish-check.mjs",
|
|
51
52
|
"quality:ab": "node eval/quality-ab.mjs",
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* 宿主面 compaction 挂载的真机探针(issues #12/#13 的回归证据)。
|
|
4
|
+
*
|
|
5
|
+
* 单测的「真 cordis + 真 backend」已经能复现那条缝,但它证明不了「真 dsh 宿主会把
|
|
6
|
+
* llm/tokenMeter/sessions 供给到引擎自己的 fiber 上」。这个脚本把那次一次性验证
|
|
7
|
+
* 固化成一条命令:
|
|
8
|
+
*
|
|
9
|
+
* 1. `npm pack` 本仓库(或 --tgz 指定的 tarball)
|
|
10
|
+
* 2. 在 mkdtemp 出来的 DSH_HOME 里,用 `--from-default-profile web` 造一个一次性
|
|
11
|
+
* profile,`dsh plugin add` 装上被测 tarball
|
|
12
|
+
* 3. 再装一个只做一件事的探针插件:boot 后 8s 调
|
|
13
|
+
* `ctx.compaction.compactIfNeeded(agent, "pressure")`,把结果写进 JSON
|
|
14
|
+
* 4. `dsh --profile probe --patch <overlay> --port <n> --no-open` 起真宿主
|
|
15
|
+
* 5. 读 JSON、杀进程、按结果给退出码
|
|
16
|
+
*
|
|
17
|
+
* 用法:
|
|
18
|
+
* node scripts/host-mount-probe.mjs # 本仓库,默认配置
|
|
19
|
+
* node scripts/host-mount-probe.mjs --gc # gcReplacesCompaction: true
|
|
20
|
+
* node scripts/host-mount-probe.mjs --tgz old.tgz # 对照:装上旧 tarball 应复现报错
|
|
21
|
+
* node scripts/host-mount-probe.mjs --port 3199 --keep
|
|
22
|
+
*
|
|
23
|
+
* 退出码:0 = pressure 路径跑通;1 = 引擎报了 inject 错;2 = 没拿到读数(boot 失败)。
|
|
24
|
+
* 不联网、不调模型:只 boot 宿主,不跑任何会话。
|
|
25
|
+
*/
|
|
26
|
+
|
|
27
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
28
|
+
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
29
|
+
import { tmpdir } from "node:os";
|
|
30
|
+
import { dirname, join, resolve } from "node:path";
|
|
31
|
+
import { fileURLToPath } from "node:url";
|
|
32
|
+
|
|
33
|
+
const ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..");
|
|
34
|
+
const DSH_BIN = process.env.PROBE_DSH_BIN || "dsh";
|
|
35
|
+
|
|
36
|
+
function arg(name, fallback = undefined) {
|
|
37
|
+
const i = process.argv.indexOf(name);
|
|
38
|
+
return i === -1 ? fallback : process.argv[i + 1] ?? fallback;
|
|
39
|
+
}
|
|
40
|
+
const GC_MODE = process.argv.includes("--gc");
|
|
41
|
+
const KEEP = process.argv.includes("--keep");
|
|
42
|
+
const PORT = Number(arg("--port", "3199"));
|
|
43
|
+
const TGZ = arg("--tgz");
|
|
44
|
+
const WAIT_MS = Number(arg("--wait", "60000"));
|
|
45
|
+
|
|
46
|
+
function sh(cmd, args, opts = {}) {
|
|
47
|
+
return execFileSync(cmd, args, { encoding: "utf8", stdio: ["ignore", "pipe", "pipe"], ...opts });
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function packRepo(dest) {
|
|
51
|
+
mkdirSync(dest, { recursive: true });
|
|
52
|
+
const out = sh("npm", ["pack", "--pack-destination", dest], { cwd: ROOT });
|
|
53
|
+
const name = out.trim().split("\n").at(-1).trim();
|
|
54
|
+
return join(dest, name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** The probe plugin: no config, no deps, one timer, one write. */
|
|
58
|
+
const PROBE_SOURCE = `import fs from "node:fs";
|
|
59
|
+
export const name = "engram-host-probe";
|
|
60
|
+
export const inject = ["compaction"];
|
|
61
|
+
const OUT = process.env.PROBE_OUT;
|
|
62
|
+
export function apply(ctx) {
|
|
63
|
+
const timer = setTimeout(async () => {
|
|
64
|
+
const result = { at: new Date().toISOString(), pid: process.pid };
|
|
65
|
+
try {
|
|
66
|
+
const status = JSON.parse(fs.readFileSync(process.env.DSH_HOME + "/engram/context-gc.status.json", "utf8"));
|
|
67
|
+
result.host = status.host;
|
|
68
|
+
result.config = status.config;
|
|
69
|
+
} catch (error) { result.statusError = String(error?.message ?? error); }
|
|
70
|
+
try {
|
|
71
|
+
const agent = {
|
|
72
|
+
session: {
|
|
73
|
+
id: "probe-session",
|
|
74
|
+
header: { cwd: "/tmp" },
|
|
75
|
+
messages: [],
|
|
76
|
+
events: [],
|
|
77
|
+
requestHeader: () => ({ config: { provider: "deepseek-official", model: "deepseek-flash" } }),
|
|
78
|
+
},
|
|
79
|
+
options: {},
|
|
80
|
+
};
|
|
81
|
+
const out = await ctx.compaction.compactIfNeeded(agent, "pressure", new AbortController().signal);
|
|
82
|
+
result.pressure = { ok: true, result: out ?? null };
|
|
83
|
+
} catch (error) {
|
|
84
|
+
result.pressure = { ok: false, error: String(error?.message ?? error) };
|
|
85
|
+
}
|
|
86
|
+
fs.writeFileSync(OUT, JSON.stringify(result, null, 1));
|
|
87
|
+
}, 8000);
|
|
88
|
+
ctx.effect(() => () => clearTimeout(timer));
|
|
89
|
+
}
|
|
90
|
+
`;
|
|
91
|
+
|
|
92
|
+
const PROBE_PKG = JSON.stringify(
|
|
93
|
+
{ name: "dsh-engram-host-probe", version: "1.0.0", private: true, type: "module", main: "index.js", files: ["index.js"] },
|
|
94
|
+
null,
|
|
95
|
+
1,
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
function packProbe(dest) {
|
|
99
|
+
const src = join(dest, "probe-src");
|
|
100
|
+
mkdirSync(src, { recursive: true });
|
|
101
|
+
writeFileSync(join(src, "index.js"), PROBE_SOURCE);
|
|
102
|
+
writeFileSync(join(src, "package.json"), PROBE_PKG);
|
|
103
|
+
const out = sh("npm", ["pack", "--pack-destination", dest], { cwd: src });
|
|
104
|
+
return join(dest, out.trim().split("\n").at(-1).trim());
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function overlay(file, gc) {
|
|
108
|
+
writeFileSync(
|
|
109
|
+
file,
|
|
110
|
+
[
|
|
111
|
+
"# one-off overlay: the probe row, plus the GC-mode config when asked",
|
|
112
|
+
gc ? "- id: engram\n config:\n gcReplacesCompaction: true" : "# (default config)",
|
|
113
|
+
"- insert:\n - id: engram-host-probe\n name: dsh-engram-host-probe",
|
|
114
|
+
"",
|
|
115
|
+
].join("\n"),
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const home = mkdtempSync(join(tmpdir(), "engram-host-probe-"));
|
|
120
|
+
const outFile = join(home, "probe.json");
|
|
121
|
+
const env = { ...process.env, DSH_HOME: home, PROBE_OUT: outFile };
|
|
122
|
+
let child;
|
|
123
|
+
let exitCode = 2;
|
|
124
|
+
|
|
125
|
+
try {
|
|
126
|
+
const tgz = TGZ ? resolve(TGZ) : packRepo(join(home, "pkg"));
|
|
127
|
+
if (!existsSync(tgz)) throw new Error(`tarball not found: ${tgz}`);
|
|
128
|
+
console.log(`plugin: ${tgz}`);
|
|
129
|
+
|
|
130
|
+
sh(DSH_BIN, ["--profile", "probe", "--from-default-profile", "web", "--dump-config"], { env });
|
|
131
|
+
sh(DSH_BIN, ["plugin", "--profile", "probe", "add", tgz], { env, stdio: ["ignore", "pipe", "inherit"] });
|
|
132
|
+
sh(DSH_BIN, ["plugin", "--profile", "probe", "add", packProbe(join(home, "probe-tgz"))], {
|
|
133
|
+
env,
|
|
134
|
+
stdio: ["ignore", "pipe", "inherit"],
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
const patch = join(home, "probe.patch.yml");
|
|
138
|
+
overlay(patch, GC_MODE);
|
|
139
|
+
|
|
140
|
+
console.log(`booting dsh ${GC_MODE ? "(gcReplacesCompaction: true) " : ""}on :${PORT} …`);
|
|
141
|
+
child = spawn(
|
|
142
|
+
DSH_BIN,
|
|
143
|
+
["--profile", "probe", "--patch", patch, "--port", String(PORT), "--no-open"],
|
|
144
|
+
{ env, detached: true, stdio: ["ignore", "pipe", "pipe"] },
|
|
145
|
+
);
|
|
146
|
+
let log = "";
|
|
147
|
+
child.stdout.on("data", (d) => (log += d));
|
|
148
|
+
child.stderr.on("data", (d) => (log += d));
|
|
149
|
+
|
|
150
|
+
const deadline = Date.now() + WAIT_MS;
|
|
151
|
+
while (Date.now() < deadline && !existsSync(outFile)) {
|
|
152
|
+
await new Promise((r) => setTimeout(r, 1500));
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
if (!existsSync(outFile)) {
|
|
156
|
+
console.error(`no reading after ${WAIT_MS}ms — host log tail:\n${log.slice(-2000)}`);
|
|
157
|
+
} else {
|
|
158
|
+
const result = JSON.parse(readFileSync(outFile, "utf8"));
|
|
159
|
+
console.log(JSON.stringify(result, null, 1));
|
|
160
|
+
exitCode = result.pressure?.ok === true ? 0 : 1;
|
|
161
|
+
}
|
|
162
|
+
} finally {
|
|
163
|
+
if (child?.pid) {
|
|
164
|
+
try {
|
|
165
|
+
process.kill(-child.pid, "SIGTERM");
|
|
166
|
+
} catch {}
|
|
167
|
+
}
|
|
168
|
+
if (!KEEP) rmSync(home, { recursive: true, force: true });
|
|
169
|
+
else console.log(`kept ${home}`);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
process.exit(exitCode);
|