dsh-engram 0.7.0 → 0.7.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/client.js CHANGED
@@ -2209,11 +2209,11 @@ function EngramTelemetry({ api, workspace }) {
2209
2209
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: { ...hb2.grid, marginTop: 8 }, children: [
2210
2210
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: hb2.statCard, children: [
2211
2211
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { ...hb2.statNum, color: "#059669" }, children: tokenCount(ledgerBucket.avoidedTokens) }),
2212
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: hb2.statLabel, children: "\u8282\u7701 \xB7 \u526A\u679D/\u538B\u7F29" })
2212
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: hb2.statLabel, children: "\u8282\u7701 \xB7 \u526A\u679D/\u538B\u7F29\uFF08\u5DF2\u5165\u8D26\uFF09" })
2213
2213
  ] }),
2214
2214
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: hb2.statCard, children: [
2215
2215
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { ...hb2.statNum, color: "#d97706" }, children: tokenCount(-ledgerBucket.injectedTokens) }),
2216
- /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: hb2.statLabel, children: "\u6CE8\u5165\u6210\u672C \xB7 \u7D22\u5F15/\u53EC\u56DE/ESR" })
2216
+ /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: hb2.statLabel, children: "\u6CE8\u5165\u6210\u672C \xB7 \u7D22\u5F15/\u53EC\u56DE/ESR\uFF08\u6BCF\u8BF7\u6C42\u91CD\u8BA1\uFF09" })
2217
2217
  ] }),
2218
2218
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: hb2.statCard, children: [
2219
2219
  /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { ...hb2.statNum, color: "#dc2626" }, children: tokenCount(-Math.round(ledgerBucket.rewritesCostChars / ledger.charsPerToken)) }),
@@ -2224,6 +2224,7 @@ function EngramTelemetry({ api, workspace }) {
2224
2224
  ] })
2225
2225
  ] })
2226
2226
  ] }),
2227
+ ledgerBucket.avoidedTokens === 0 && (ledgerBucket.injectedTokens > 0 || ledgerBucket.rewriteCount > 0) && /* @__PURE__ */ (0, import_jsx_runtime5.jsx)("div", { style: { ...hb2.sub, marginTop: 6 }, children: "\u8282\u7701\u4ECD\u4E3A 0\uFF1A\u672C\u5DE5\u4F5C\u533A\u8FD8\u6CA1\u6709\u526A\u679D/\u538B\u7F29\u8282\u7701\u5165\u8D26\u2014\u2014L1 \u8FB9\u754C\u526A\u679D\u9700\u8981 host \u5E73\u9762\u7684 toolResultPruner\uFF08DSH \u2265 0.2.0 \u7531 bundle patch \u91CD\u65B0\u542F\u7528\u8BE5\u884C\uFF09\uFF0C\u538B\u7F29\u8282\u7701\u7531 Context GC \u5F15\u64CE\u5728 checkpoint \u65F6\u4E0A\u62A5\uFF1B\u51C0\u503C\u4E3A\u8D1F = \u6CE8\u5165\u6210\u672C + \u91CD\u5199\u7F5A\u91D1\u7684\u5408\u8BA1\u3002" }),
2227
2228
  /* @__PURE__ */ (0, import_jsx_runtime5.jsxs)("div", { style: { ...hb2.sub, marginTop: 6 }, children: [
2228
2229
  "\u53E3\u5F84\uFF1Achars/",
2229
2230
  ledger.charsPerToken,
package/lib/compaction.js CHANGED
@@ -62,6 +62,17 @@ export function apply(ctx, config = {}) {
62
62
  if (!domain) return null;
63
63
  return collectPointerContext(domain, wk(workspace), report);
64
64
  },
65
+ // Context Ledger bridge: this row runs inside the agent preset's
66
+ // isolate realm, where the host plugin's ledger closure is not
67
+ // reachable. The host plugin provides `engramLedger` as a plain
68
+ // (non-isolated) service, so compaction savings are credited to the
69
+ // one durable ledger instead of vanishing with the realm.
70
+ onCompacted: ({ session, workspace, charsSaved }) => {
71
+ try {
72
+ const hub = ctx.get("engramLedger");
73
+ hub?.noteCompaction?.(session?.id, workspace, { charsSaved });
74
+ } catch { /* contained: accounting never breaks compaction */ }
75
+ },
65
76
  }
66
77
  : {};
67
78
 
package/lib/context-gc.js CHANGED
@@ -73,6 +73,38 @@ export function blockText(content) {
73
73
  return out.trim();
74
74
  }
75
75
 
76
+ /** Recursive visible-text char count over DSH content blocks (code points). */
77
+ function contentTextChars(value) {
78
+ if (typeof value === "string") return [...value].length;
79
+ if (Array.isArray(value)) {
80
+ let chars = 0;
81
+ for (const part of value) chars += contentTextChars(part);
82
+ return chars;
83
+ }
84
+ if (value === null || typeof value !== "object") return 0;
85
+ let chars = 0;
86
+ // Text blocks, tool-call arguments and nested tool-result content are all
87
+ // replayed context — count each once.
88
+ if (typeof value.text === "string") chars += [...value.text].length;
89
+ if (typeof value.arguments === "string") chars += [...value.arguments].length;
90
+ if (value.content !== undefined) chars += contentTextChars(value.content);
91
+ return chars;
92
+ }
93
+
94
+ /**
95
+ * Total visible text characters (Unicode code points) across a message list —
96
+ * the size side of the Context Ledger credit: a compaction replaces this span
97
+ * with the checkpoint, so `span − summary` is the part that stops replaying.
98
+ * @param {Array<object>} messages
99
+ * @returns {number}
100
+ */
101
+ export function messagesTextChars(messages) {
102
+ if (!Array.isArray(messages)) return 0;
103
+ let chars = 0;
104
+ for (const message of messages) chars += contentTextChars(message?.content);
105
+ return chars;
106
+ }
107
+
76
108
  /**
77
109
  * The durable checkpoint framing the harness applies to a compaction summary
78
110
  * before accepting it (`frameSummary()` in dsh-compaction-basic): the summary
@@ -762,6 +794,21 @@ export async function loadContextGcEngine(ctx, config = {}, deps = {}) {
762
794
  }
763
795
  summaryText = fit.text;
764
796
 
797
+ // Context Ledger credit: the span the checkpoint replaces stops
798
+ // replaying, so `span − summary` is a measured saving the accounting
799
+ // layer never had (the host does not expose compaction sizes). Best
800
+ // effort — accounting must never break the compaction path.
801
+ try {
802
+ if (typeof this.gcDeps?.onCompacted === "function") {
803
+ const spanChars = messagesTextChars(messages);
804
+ const summaryChars = [...summaryText].length;
805
+ const charsSaved = spanChars - summaryChars;
806
+ if (charsSaved > 0) {
807
+ this.gcDeps.onCompacted({ session: agent?.session, workspace: ws, charsSaved, spanChars, summaryChars });
808
+ }
809
+ }
810
+ } catch { /* contained: a credit bug must never fail a compaction */ }
811
+
765
812
  const result = { summary: [{ type: "text", text: summaryText }] };
766
813
  if (narrative.llm && typeof narrative.llm === "object") Object.assign(result, narrative.llm);
767
814
  // The harness's durable `compaction/summary` record ALWAYS carries
package/lib/index.js CHANGED
@@ -24,7 +24,7 @@ import { registerTools } from "./tools.js";
24
24
  import { makeCaptureHandler } from "./capture.js";
25
25
  import { installGoalCapture } from "./goal-capture.js";
26
26
  import { installPreferenceCapture } from "./preference.js";
27
- import { installBoundaryPrune } from "./boundary-prune.js";
27
+ import { boundaryPruneAvailability, installBoundaryPrune } from "./boundary-prune.js";
28
28
  import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } from "./result-pack.js";
29
29
  import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
30
30
  import { createLedger, estimatePrefixChars } from "./ledger.js";
@@ -359,6 +359,27 @@ export function apply(ctx, config) {
359
359
  log,
360
360
  });
361
361
 
362
+ // Expose the ledger to the per-session web plane. Agent presets mount
363
+ // `dsh-engram/compaction` in their own isolate realm, so a closure from this
364
+ // apply() is unreachable there; a plain (non-isolated) service value is the
365
+ // bridge: the row credits compaction savings through it and `ledger` stays
366
+ // the single writer of the durable rollup.
367
+ if (ledger !== null) {
368
+ try {
369
+ // `ctx.provide` is fiber-scoped (cordis removes the service when this
370
+ // plugin unloads), so the returned disposer needs no bookkeeping here.
371
+ ctx.provide?.("engramLedger", {
372
+ noteCompaction: (sessionId, workspace, opts) => ledger.noteCompaction(sessionId, workspace, opts),
373
+ notePrune: (sessionId, workspace, opts) => ledger.notePrune(sessionId, workspace, opts),
374
+ noteRewrite: (sessionId, workspace, opts) => ledger.noteRewrite(sessionId, workspace, opts),
375
+ summary: () => ledger.summary(),
376
+ flush: () => ledger.flush(),
377
+ });
378
+ } catch (error) {
379
+ log?.warn?.(`engram ledger service unavailable: ${String(error?.message ?? error)}`);
380
+ }
381
+ }
382
+
362
383
  // Ledger-driven off-switch for BoundaryPrune (PROPOSAL-evolution v0.6-B):
363
384
  // per-workspace, 30s-cached, reads the same summary /stats exposes. Evidence
364
385
  // can only turn pruning OFF. No ledger → allow (repayment gate still runs).
@@ -422,7 +443,9 @@ export function apply(ctx, config) {
422
443
  /** Live Context GC status (API overview + durable status file). */
423
444
  compactionStatus: {
424
445
  host: "pending",
425
- web: { action: "pending", presets: [] },
446
+ // `disabled` (not `pending`) when the web opt-in is off: the status file
447
+ // must not look like a wiring run that never finished.
448
+ web: resolved.autoWebCompaction === false ? { action: "disabled", presets: [] } : { action: "pending", presets: [] },
426
449
  config: { autoWebCompaction: resolved.autoWebCompaction, gcReplacesCompaction: resolved.gcReplacesCompaction, gcNarrative: resolved.gcNarrative },
427
450
  },
428
451
  /** Recompute the exact [ENGRAM] block for a workspace (GUI overview cost). */
@@ -666,6 +689,16 @@ export function apply(ctx, config) {
666
689
  return null;
667
690
  }
668
691
  },
692
+ // Context Ledger credit: the Context GC engine measures the span it
693
+ // replaced with a checkpoint (`span − summary` chars) and reports it
694
+ // here; the ledger stops the "savings are always 0" asymmetry.
695
+ onCompacted: ({ session, workspace, charsSaved }) => {
696
+ try {
697
+ ledger?.noteCompaction(session?.id, workspace ?? wk(session?.header?.cwd), { charsSaved });
698
+ } catch (error) {
699
+ log?.warn?.(`engram ledger compaction credit failed: ${String(error)}`);
700
+ }
701
+ },
669
702
  });
670
703
  disposers.push(() => {
671
704
  // The engine's service registration is fiber-scoped and auto-removed on
@@ -680,6 +713,10 @@ export function apply(ctx, config) {
680
713
  let hostSettled = false;
681
714
  let webSettled = resolved.autoWebCompaction === false;
682
715
  let statusTimer = void 0;
716
+ // Authoritative L1 availability, captured where BoundaryPrune is actually
717
+ // installed (the pruner may land after the first status flush on a slow
718
+ // boot; the live check is only a fallback).
719
+ let boundaryPruneState = void 0;
683
720
  const flushStatus = () => {
684
721
  if (!hostSettled || !webSettled) return;
685
722
  if (statusTimer !== void 0) { clearTimeout(statusTimer); statusTimer = void 0; }
@@ -687,6 +724,9 @@ export function apply(ctx, config) {
687
724
  planes: resolved.autoWebCompaction === false ? ["host"] : ["host", "web"],
688
725
  host: service.compactionStatus.host,
689
726
  web: service.compactionStatus.web,
727
+ // L1 availability is the missing credit path behind a "节省 always 0"
728
+ // ledger: record whether BoundaryPrune can actually fire here.
729
+ boundaryPrune: boundaryPruneState ?? boundaryPruneAvailability(ctx, resolved),
690
730
  config: service.compactionStatus.config,
691
731
  configSource: {
692
732
  autoWebCompaction: resolved.autoWebCompaction,
@@ -713,12 +753,15 @@ export function apply(ctx, config) {
713
753
  // inside the agent preset's own realm, which a profile patch cannot reach.
714
754
  // When the `agentPresets` service is present, swap the stock
715
755
  // `compaction-basic` row for `dsh-engram/compaction` in every stock-layout
716
- // preset — the default one plus the whole roster (shipped + user roots) —
717
- // so a session composed from any preset gets Context GC. Surgical,
718
- // idempotent, backed up, validated; a preset with a custom compaction
719
- // layout (or none) is never touched. The row's engine config is derived
720
- // from the settings knobs (`gcReplacesCompaction` / `gcNarrative`), so
721
- // changing them re-provisions the web rows on the next boot. Flip
756
+ // preset. The row is located structurally (indentation-agnostic) inside the
757
+ // `compaction` group, so both preset generations are covered: legacy
758
+ // compositions with `preset.path` (DSH ≤ 0.1.x) and the declarative
759
+ // `@deepseek-ai/dsh-web-app/presets/<id>.patch.yml` files (DSH ≥ 0.2.0,
760
+ // where the row sits at 14 spaces and the id maps to the shipped file).
761
+ // Surgical, idempotent, backed up, validated; a preset with a custom
762
+ // compaction layout (or none) is never touched. The row's engine config is
763
+ // derived from the settings knobs (`gcReplacesCompaction` / `gcNarrative`),
764
+ // so changing them re-provisions the web rows on the next boot. Flip
722
765
  // `autoWebCompaction:false` to stop auto-wiring on later boots (it does
723
766
  // not un-wire already-wired presets — use `npm run web-compaction:revert`
724
767
  // for that). Best-effort: a failure only warns.
@@ -976,7 +1019,7 @@ export function apply(ctx, config) {
976
1019
  // over-threshold tool results at todo/goal subtask boundaries through
977
1020
  // DSH's own deterministic toolResultPruner, gated by a repayment clock.
978
1021
  const l2Dir = resultPackDir();
979
- const disposeBoundaryPrune = installBoundaryPrune(ctx, resolved, log, {
1022
+ const boundaryHooks = {
980
1023
  // ResultPack L2 hook: archive the pruned middles (redacted) + append
981
1024
  // a handle tail on each pruned node. Fail-contained in result-pack.js.
982
1025
  // Also records durable activation telemetry (SoL-Pi M15/C13) —
@@ -1008,8 +1051,28 @@ export function apply(ctx, config) {
1008
1051
  log,
1009
1052
  });
1010
1053
  },
1011
- }, { allowFire: boundaryGate });
1012
- disposers.push(disposeBoundaryPrune);
1054
+ };
1055
+ // Arm L1 through `ctx.inject`, never a bare `ctx.get`: on a full web
1056
+ // profile the host pruner's fiber activates a tick or two after this
1057
+ // plugin (measured ~100 ms with dsh-context + dshmarket mounted), and a
1058
+ // strict lookup at install time is then empty — the old direct install
1059
+ // gave up and L1 stayed dormant for the whole process, silently killing
1060
+ // the only ledger credit path. `ctx.inject` runs the callback as soon as
1061
+ // the service is active (and re-runs it after a reload).
1062
+ if (resolved.boundaryPrune !== false) {
1063
+ ctx.inject(["toolResultPruner"], (prunerCtx) => {
1064
+ const dispose = installBoundaryPrune(prunerCtx, resolved, log, boundaryHooks, {
1065
+ allowFire: boundaryGate,
1066
+ });
1067
+ // L1 provably can (or cannot) fire from here on: record it for the
1068
+ // durable snapshot, then rewrite it — the first flush may have landed
1069
+ // before the pruner activated, and this status line is what users
1070
+ // read when "节省" stays at 0.
1071
+ boundaryPruneState = boundaryPruneAvailability(prunerCtx, resolved);
1072
+ flushStatus();
1073
+ return dispose;
1074
+ });
1075
+ }
1013
1076
 
1014
1077
  // ResultPack L2 tool (default ON with L1 since v0.6): `result_recall(callId,
1015
1078
  // offset?)` pages back an archived middle. Independent of the memory