dsh-engram 0.9.0 → 0.9.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/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
- constructor() {
684
- super(ctx, { auto: true, ...config });
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
- * Fully contained: an unimportable backend or a duplicate `compaction`
704
- * provider (cordis refuses second registration) only warns and returns null —
705
- * the host keeps whatever compaction it already has, never a crash.
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
- new Engine();
805
+ await mountEngine(ctx, Engine);
727
806
  } catch (error) {
728
- // Duplicate `compaction` provider (base row not disabled) or constructor
729
- // refusal: keep whatever is already registered, never crash the host.
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
- constructor() {
771
- super(ctx, engineConfig);
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/esrModel.mjs CHANGED
@@ -1,6 +1,29 @@
1
1
  // <autogenerated by client/build-model.mjs — do not edit directly>
2
2
  // Compiles client/src/esrModel.ts for the node test runner.
3
3
 
4
+ // client/src/i18n/index.ts
5
+ var DEFAULT_LOCALE = "en-US";
6
+ var translate = (key) => key;
7
+ var localeTag = DEFAULT_LOCALE;
8
+ function activeLocale() {
9
+ return localeTag;
10
+ }
11
+ function format(text, params) {
12
+ return text.replace(
13
+ /\{(\w+)\}/g,
14
+ (match, name) => Object.prototype.hasOwnProperty.call(params, name) ? String(params[name]) : match
15
+ );
16
+ }
17
+ function stringParams(params) {
18
+ const out = {};
19
+ for (const [name, value] of Object.entries(params)) out[name] = String(value);
20
+ return out;
21
+ }
22
+ function tr(key, params) {
23
+ const text = params === void 0 ? translate(key) : translate(key, stringParams(params));
24
+ return params === void 0 ? text : format(text, params);
25
+ }
26
+
4
27
  // client/src/esrModel.ts
5
28
  function taskGaps(t) {
6
29
  const gaps = [];
@@ -23,7 +46,7 @@ function shortAgent(a) {
23
46
  return bare.replace(/^session[-_]/i, "").replace(/^agent[-_]/i, "").slice(0, 14);
24
47
  }
25
48
  function fmtDateShort(ts) {
26
- return ts ? new Date(ts).toLocaleDateString("zh-CN", { month: "short", day: "numeric" }) : "\u2013";
49
+ return ts ? new Date(ts).toLocaleDateString(activeLocale(), { month: "short", day: "numeric" }) : "\u2013";
27
50
  }
28
51
  function buildTasksMarkdown(tasks) {
29
52
  const esc = (s) => String(s ?? "").replace(/\|/g, "\\|").replace(/\n/g, " ");
@@ -31,11 +54,7 @@ function buildTasksMarkdown(tasks) {
31
54
  const ev = `${t.artifact ? "artifact\u2713" : "artifact\u2717"} \xB7 ${t.evaluation ? "eval\u2713" : "eval\u2717"} \xB7 ${(t.memoryRefs?.length ?? 0) > 0 ? "ref\u2713" : "ref\u2717"}`;
32
55
  return `| ${t.state} | ${esc(t.name)} | ${esc(t.workspace.replace(/^.*[\\/]/, ""))} | ${esc(taskGaps(t).join(", ") || "\u2014")} | ${ev} | ${fmtDateShort(t.createdAt ?? 0)} |`;
33
56
  });
34
- return `# ESR \u4EFB\u52A1\u5BFC\u51FA \xB7 ${(/* @__PURE__ */ new Date()).toISOString().slice(0, 10)}
35
-
36
- | \u72B6\u6001 | \u4EFB\u52A1 | \u5DE5\u4F5C\u533A | \u8BC1\u636E\u7F3A\u53E3 | \u8BC1\u636E | \u521B\u5EFA |
37
- |---|---|---|---|---|---|
38
- ${rows.join("\n") || "(\u65E0\u4EFB\u52A1)"}`;
57
+ return tr("# ESR \u4EFB\u52A1\u5BFC\u51FA \xB7 {0}\n\n| \u72B6\u6001 | \u4EFB\u52A1 | \u5DE5\u4F5C\u533A | \u8BC1\u636E\u7F3A\u53E3 | \u8BC1\u636E | \u521B\u5EFA |\n|---|---|---|---|---|---|\n{1}", { "0": (/* @__PURE__ */ new Date()).toISOString().slice(0, 10), "1": rows.join("\n") || tr("(\u65E0\u4EFB\u52A1)") });
39
58
  }
40
59
  var POLL_MS = 2e4;
41
60
  export {
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
- const workspace = resolvePath(cwd);
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, resolvePath(cwd), resolved, { sessionId: session.id });
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, resolvePath(cwd), resolved, {
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/lib/mental.js CHANGED
@@ -17,6 +17,42 @@ import { hashText } from "./util.js";
17
17
 
18
18
  const DEFAULT_MAX_AGE_MS = 10 * 60 * 1000;
19
19
 
20
+ /**
21
+ * Standing-answer copy. The language is a RENDER parameter, not a cache key:
22
+ * the cached row and the `esr_model` tool keep their default (zh) copy, while
23
+ * the GUI asks the route for its active language. Russian is the language this
24
+ * client currently ships.
25
+ */
26
+ const COPY = {
27
+ zh: {
28
+ allWorkspaces: "全部工作区",
29
+ heading: "常驻摘要",
30
+ tasks: (active, ready, stable) => `- 任务:${active} 进行中(${ready} 就绪)· ${stable} 已闭环`,
31
+ graph: (nodes, links) => `- 实体图:${nodes} 节点 · ${links} 链接`,
32
+ observation: (n, evidence) => `- 观测:${n} 条信念(累计 ${evidence} 证据)`,
33
+ memories: (n, kindHist) => `- 记忆:${n} 条${kindHist ? `(${kindHist})` : ""}`,
34
+ topBeliefs: "重点信念:",
35
+ risks: "未闭环风险:",
36
+ riskLine: (name, gaps, ageDays) => `- ${name} · ${gaps} · ${ageDays}天`,
37
+ },
38
+ ru: {
39
+ allWorkspaces: "Все пространства",
40
+ heading: "постоянная сводка",
41
+ tasks: (active, ready, stable) => `- Задачи: ${active} в работе (${ready} готовы) · закрыто: ${stable}`,
42
+ graph: (nodes, links) => `- Граф сущностей: узлов ${nodes} · связей ${links}`,
43
+ observation: (n, evidence) => `- Наблюдения: ${n} · доказательств всего: ${evidence}`,
44
+ memories: (n, kindHist) => `- Память: ${n}${kindHist ? ` (${kindHist})` : ""}`,
45
+ topBeliefs: "Ключевые убеждения:",
46
+ risks: "Незакрытые риски:",
47
+ riskLine: (name, gaps, ageDays) => `- ${name} · ${gaps} · ${ageDays} дн.`,
48
+ },
49
+ };
50
+
51
+ /** Copy table for a render language; unknown languages fall back to the source language. */
52
+ function copyFor(lang) {
53
+ return COPY[lang] ?? COPY.zh;
54
+ }
55
+
20
56
  /** Escape a name for markdown (pipes/newlines). */
21
57
  function mdCell(value) {
22
58
  return String(value ?? "")
@@ -29,10 +65,11 @@ function mdCell(value) {
29
65
  * Aggregate the workspace into a standing-answer markdown blob. Purely derives
30
66
  * from already-indexed data — safe to run on every read.
31
67
  */
32
- export function compileSummary(domain, ws, { now = Date.now() } = {}) {
68
+ export function compileSummary(domain, ws, { now = Date.now(), lang = "zh" } = {}) {
69
+ const L = copyFor(lang);
33
70
  const summary = domain.summarize();
34
71
  const scope = ws && summary.workspaces[ws] ? { ...summary.workspaces[ws] } : { ...summary.totals };
35
- const label = ws ? String(ws).replace(/^.*[\\/]/, "") : "全部工作区";
72
+ const label = ws ? String(ws).replace(/^.*[\\/]/, "") : L.allWorkspaces;
36
73
 
37
74
  let tasks = [];
38
75
  try {
@@ -53,7 +90,7 @@ export function compileSummary(domain, ws, { now = Date.now() } = {}) {
53
90
  if (gaps.length === 0) ready += 1;
54
91
  else {
55
92
  const ageDays = Math.max(0, Math.floor((now - (t.createdAt ?? now)) / 86400000));
56
- risky.push(`- ${mdCell(t.name)} · ${gaps.join("/")} · ${ageDays}天`);
93
+ risky.push(L.riskLine(mdCell(t.name), gaps.join("/"), ageDays));
57
94
  }
58
95
  }
59
96
  const stable = tasks.filter((t) => t.state === "stable").length;
@@ -77,18 +114,18 @@ export function compileSummary(domain, ws, { now = Date.now() } = {}) {
77
114
  }
78
115
 
79
116
  const lines = [
80
- `## ${label} · 常驻摘要`,
81
- `- 任务:${active.length} 进行中(${ready} 就绪)· ${stable} 已闭环`,
82
- `- 实体图:${scope.nodes ?? 0} 节点 · ${scope.links ?? 0} 链接`,
83
- `- 观测:${obs.length} 条信念(累计 ${obsEvidence} 证据)`,
84
- `- 记忆:${scope.memories ?? 0} 条${kindHist ? `(${kindHist})` : ""}`,
117
+ `## ${label} · ${L.heading}`,
118
+ L.tasks(active.length, ready, stable),
119
+ L.graph(scope.nodes ?? 0, scope.links ?? 0),
120
+ L.observation(obs.length, obsEvidence),
121
+ L.memories(scope.memories ?? 0, kindHist),
85
122
  ];
86
123
  if (topObs.length > 0) {
87
- lines.push("", "重点信念:");
124
+ lines.push("", L.topBeliefs);
88
125
  for (const o of topObs) lines.push(`- ${mdCell(o.text)} — ×${o.proof?.count ?? 0}`);
89
126
  }
90
127
  if (risky.length > 0) {
91
- lines.push("", "未闭环风险:");
128
+ lines.push("", L.risks);
92
129
  lines.push(...risky.slice(0, 5));
93
130
  }
94
131
  return lines.join("\n");
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.0",
4
+ "version": "0.9.2",
5
5
  "packageManager": "pnpm@11.22.0",
6
6
  "license": "MIT",
7
7
  "repository": {
@@ -46,6 +46,9 @@
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",
50
+ "i18n:check": "node tools/i18n/wrap.mjs --check",
51
+ "i18n:wrap": "node tools/i18n/wrap.mjs",
49
52
  "playbook:audit": "node eval/playbook-audit.mjs",
50
53
  "prepublishOnly": "node scripts/prepublish-check.mjs",
51
54
  "quality:ab": "node eval/quality-ab.mjs",
@@ -0,0 +1,180 @@
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
+ // Enough of the Session surface for the real token meter to fold an empty
72
+ // log: it walks \`seq\` through \`eventAt()\` and lazily builds projection
73
+ // cells through \`snapshotEvents()\`. Without these it throws before the
74
+ // engine ever reads its own ctx, which would prove nothing.
75
+ const agent = {
76
+ session: {
77
+ id: "probe-session",
78
+ header: { id: "probe-session", cwd: "/tmp" },
79
+ inheritedEventCount: 0,
80
+ seq: 0,
81
+ messages: [],
82
+ events: [],
83
+ eventAt: () => void 0,
84
+ snapshotEvents: () => [],
85
+ requestHeader: () => ({ config: { provider: "deepseek-official", model: "deepseek-flash" } }),
86
+ },
87
+ options: {},
88
+ };
89
+ const out = await ctx.compaction.compactIfNeeded(agent, "pressure", new AbortController().signal);
90
+ result.pressure = { ok: true, result: out ?? null };
91
+ } catch (error) {
92
+ result.pressure = { ok: false, error: String(error?.message ?? error), stack: String(error?.stack ?? "").split("\\n").slice(0, 6) };
93
+ }
94
+ fs.writeFileSync(OUT, JSON.stringify(result, null, 1));
95
+ }, 8000);
96
+ ctx.effect(() => () => clearTimeout(timer));
97
+ }
98
+ `;
99
+
100
+ const PROBE_PKG = JSON.stringify(
101
+ { name: "dsh-engram-host-probe", version: "1.0.0", private: true, type: "module", main: "index.js", files: ["index.js"] },
102
+ null,
103
+ 1,
104
+ );
105
+
106
+ function packProbe(dest) {
107
+ const src = join(dest, "probe-src");
108
+ mkdirSync(src, { recursive: true });
109
+ writeFileSync(join(src, "index.js"), PROBE_SOURCE);
110
+ writeFileSync(join(src, "package.json"), PROBE_PKG);
111
+ const out = sh("npm", ["pack", "--pack-destination", dest], { cwd: src });
112
+ return join(dest, out.trim().split("\n").at(-1).trim());
113
+ }
114
+
115
+ function overlay(file, gc) {
116
+ writeFileSync(
117
+ file,
118
+ [
119
+ "# one-off overlay: the probe row, plus the GC-mode config when asked",
120
+ gc ? "- id: engram\n config:\n gcReplacesCompaction: true" : "# (default config)",
121
+ "- insert:\n - id: engram-host-probe\n name: dsh-engram-host-probe",
122
+ "",
123
+ ].join("\n"),
124
+ );
125
+ }
126
+
127
+ const home = mkdtempSync(join(tmpdir(), "engram-host-probe-"));
128
+ const outFile = join(home, "probe.json");
129
+ const env = { ...process.env, DSH_HOME: home, PROBE_OUT: outFile };
130
+ let child;
131
+ let exitCode = 2;
132
+
133
+ try {
134
+ const tgz = TGZ ? resolve(TGZ) : packRepo(join(home, "pkg"));
135
+ if (!existsSync(tgz)) throw new Error(`tarball not found: ${tgz}`);
136
+ console.log(`plugin: ${tgz}`);
137
+
138
+ sh(DSH_BIN, ["--profile", "probe", "--from-default-profile", "web", "--dump-config"], { env });
139
+ sh(DSH_BIN, ["plugin", "--profile", "probe", "add", tgz], { env, stdio: ["ignore", "pipe", "inherit"] });
140
+ sh(DSH_BIN, ["plugin", "--profile", "probe", "add", packProbe(join(home, "probe-tgz"))], {
141
+ env,
142
+ stdio: ["ignore", "pipe", "inherit"],
143
+ });
144
+
145
+ const patch = join(home, "probe.patch.yml");
146
+ overlay(patch, GC_MODE);
147
+
148
+ console.log(`booting dsh ${GC_MODE ? "(gcReplacesCompaction: true) " : ""}on :${PORT} …`);
149
+ child = spawn(
150
+ DSH_BIN,
151
+ ["--profile", "probe", "--patch", patch, "--port", String(PORT), "--no-open"],
152
+ { env, detached: true, stdio: ["ignore", "pipe", "pipe"] },
153
+ );
154
+ let log = "";
155
+ child.stdout.on("data", (d) => (log += d));
156
+ child.stderr.on("data", (d) => (log += d));
157
+
158
+ const deadline = Date.now() + WAIT_MS;
159
+ while (Date.now() < deadline && !existsSync(outFile)) {
160
+ await new Promise((r) => setTimeout(r, 1500));
161
+ }
162
+
163
+ if (!existsSync(outFile)) {
164
+ console.error(`no reading after ${WAIT_MS}ms — host log tail:\n${log.slice(-2000)}`);
165
+ } else {
166
+ const result = JSON.parse(readFileSync(outFile, "utf8"));
167
+ console.log(JSON.stringify(result, null, 1));
168
+ exitCode = result.pressure?.ok === true ? 0 : 1;
169
+ }
170
+ } finally {
171
+ if (child?.pid) {
172
+ try {
173
+ process.kill(-child.pid, "SIGTERM");
174
+ } catch {}
175
+ }
176
+ if (!KEEP) rmSync(home, { recursive: true, force: true });
177
+ else console.log(`kept ${home}`);
178
+ }
179
+
180
+ process.exit(exitCode);