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/README.md +40 -1
- package/README.zh.md +30 -0
- package/docs/RELEASE-NOTES-v0.9.1.md +112 -0
- package/docs/RELEASE-NOTES-v0.9.2.md +83 -0
- package/lib/api.js +8 -2
- package/lib/client.js +1017 -625
- package/lib/context-gc.js +93 -10
- package/lib/esrModel.mjs +25 -6
- package/lib/index.js +24 -4
- package/lib/mental.js +47 -10
- package/package.json +4 -1
- package/scripts/host-mount-probe.mjs +180 -0
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/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(
|
|
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
|
|
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
|
-
|
|
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/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(
|
|
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
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
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.
|
|
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);
|