dsh-engram 0.7.1 → 0.7.4

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/episode.js CHANGED
@@ -6,7 +6,9 @@
6
6
  * later succeeded), the written commit subjects, and what is still open.
7
7
  *
8
8
  * Design constraints:
9
- * - zero LLM, pure function over `session.events` (survives restarts);
9
+ * - zero LLM, pure function over the session event log (read through
10
+ * `readSessionEvents`, so it survives restarts and DSH ≥0.2.x, where the
11
+ * old `session.events` getter no longer exists);
10
12
  * - never enters the [ENGRAM] index (long background context, not a symbol);
11
13
  * - bounded by construction (≤4 files / ≤4 commands / ≤2 decisions / ≤3 open);
12
14
  * - stored only when there is substance (a bare `ls` session produces none);
@@ -17,6 +19,7 @@
17
19
 
18
20
  import { cmdTag } from "./capture.js";
19
21
  import { pendingTodosFromSession } from "./todo-sink.js";
22
+ import { readSessionEvents } from "./session-events.js";
20
23
 
21
24
  const EDIT_TOOLS = new Set(["write", "edit", "str_replace_editor"]);
22
25
  const RUN_TOOLS = new Set(["bash", "shell", "exec"]);
@@ -59,13 +62,15 @@ function commitSubject(command) {
59
62
  /**
60
63
  * Build the episode record from a disposed session.
61
64
  *
62
- * @param {object} session session with `.events` + `.header.cwd`
65
+ * @param {object} session host session (`.header.cwd` + a readable event log)
63
66
  * @param {object} [opts]
67
+ * @param {number} [opts.now] clock injection for the header stamp
68
+ * @param {object} [opts.sessionQuery] `ctx.sessionQuery` — how ≥0.2.x logs are read
64
69
  * @returns {{text: string, tags: string[], signal: number, summary: object}|null}
65
70
  */
66
- export function buildEpisode(session, { now = Date.now() } = {}) {
71
+ export function buildEpisode(session, { now = Date.now(), sessionQuery = void 0 } = {}) {
67
72
  if (session === null || typeof session !== "object") return null;
68
- const events = Array.isArray(session.events) ? session.events : [];
73
+ const events = readSessionEvents(session, sessionQuery);
69
74
  if (events.length === 0) return null;
70
75
  const cwd = session?.header?.cwd;
71
76
 
@@ -119,7 +124,7 @@ export function buildEpisode(session, { now = Date.now() } = {}) {
119
124
  }
120
125
  }
121
126
 
122
- const open = pendingTodosFromSession(session).slice(0, 3);
127
+ const open = pendingTodosFromSession(session, sessionQuery).slice(0, 3);
123
128
  const hasSubstance = files.size > 0 || decisions.length > 0 || failedRuns > 0 || open.length > 0;
124
129
  if (!hasSubstance) return null;
125
130
 
@@ -12,6 +12,7 @@
12
12
  import { label, shortId, fmtDate, escapeLt, truncate, byRecency } from "./util.js";
13
13
  import { buildEntityPage, renderEntityCard } from "./entity-page.js";
14
14
  import { memoryIsStale } from "./anchor.js";
15
+ import { readSessionEvents } from "./session-events.js";
15
16
 
16
17
  /** Deterministic index line prefix per memory kind. */
17
18
  export function prefixFor(kind) {
@@ -190,13 +191,18 @@ function enforceCharCap(lines, maxChars) {
190
191
  }
191
192
 
192
193
  /** Text of the session's first user message (the opening instruction of the
193
- * conversation). Walk `session.events` for the first `user/message` event and
194
+ * conversation). Walk the session log for the first `user/message` event and
194
195
  * join its `data.content` text blocks; fall back to the first text-bearing
195
196
  * event ("goal"/reminder start) guardedly. Returns "" when there is none.
196
- * Pure, deterministic — the auto-recall (B) keys its query off this. */
197
- export function firstUserMessageText(session) {
197
+ * Pure, deterministic — the auto-recall (B) keys its query off this.
198
+ *
199
+ * The log is read through `readSessionEvents` because DSH 0.2.x dropped the
200
+ * `session.events` getter: reading it directly returned `[]` on every real
201
+ * session, which silently killed the whole [RECALL] push leg (and, via
202
+ * `pendingRecall`, kept the index block from ever being cached). */
203
+ export function firstUserMessageText(session, sessionQuery = void 0) {
198
204
  if (session === null || typeof session !== "object") return "";
199
- const events = Array.isArray(session.events) ? session.events : [];
205
+ const events = readSessionEvents(session, sessionQuery);
200
206
  if (events.length === 0) return "";
201
207
  const textOf = (data) => {
202
208
  if (data === null || typeof data !== "object") return "";
package/lib/index.js CHANGED
@@ -32,6 +32,7 @@ import { createJit } from "./jit.js";
32
32
  import { buildEpisode } from "./episode.js";
33
33
  import { collectStaleAnchors } from "./anchor.js";
34
34
  import { renderIndex, renderStartRecall, renderResumeBrief, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
35
+ import { sessionEventsSource, sessionSeq } from "./session-events.js";
35
36
  import { makeTriggerRecorder } from "./trigger.js";
36
37
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
37
38
  import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
@@ -522,6 +523,8 @@ export function apply(ctx, config) {
522
523
  // [ESR] block below follows the same frozen-snapshot rule but additionally
523
524
  // accumulates this-session actionables (see esrBlockFor).
524
525
  const sessionCache = new WeakMap();
526
+ /** One-time-per-process notice: the host's session log is unreadable. */
527
+ let warnedUnreadableLog = false;
525
528
  const blockFor = (agent) => {
526
529
  if (agent === void 0) return "";
527
530
  const session = agent.session;
@@ -560,9 +563,20 @@ export function apply(ctx, config) {
560
563
  // next assembly retry, so a new conversation never permanently misses its
561
564
  // recalled memory.
562
565
  if (resolved.autoRecallOnStart !== false) {
563
- const query = firstUserMessageText(session);
566
+ const query = firstUserMessageText(session, sessionQuery);
564
567
  if (query.length === 0) {
565
- pendingRecall = true;
568
+ // Retry on the next assembly ONLY when the log is readable and the
569
+ // opening message simply has not been appended yet. An unreadable
570
+ // log (host API drift — see lib/session-events.js) will never gain
571
+ // one, and retrying forever would also keep the [ENGRAM] block
572
+ // uncached, churning the prefix every turn.
573
+ pendingRecall = sessionEventsSource(session, sessionQuery) !== "none";
574
+ if (!pendingRecall && !warnedUnreadableLog) {
575
+ warnedUnreadableLog = true;
576
+ log?.warn?.(
577
+ "engram: this host exposes no readable session event log (no session.events, no ctx.sessionQuery, no snapshotEvents) — [RECALL] / episode / todo-sink stay dormant. Reporting once per session.",
578
+ );
579
+ }
566
580
  } else {
567
581
  const recall = renderStartRecall(opened, workspace, resolved, { query, allowRecencyFallback: false });
568
582
  if (recall.length > 0) {
@@ -901,7 +915,7 @@ export function apply(ctx, config) {
901
915
  const sessionId = session?.id;
902
916
  if (typeof cwd !== "string" || cwd.length === 0) return;
903
917
  if (typeof sessionId !== "string" || sessionId.length === 0) return;
904
- const todos = pendingTodosFromSession(session);
918
+ const todos = pendingTodosFromSession(session, sessionQuery);
905
919
  if (todos.length === 0) return;
906
920
  const workspace = wk(cwd);
907
921
  service
@@ -932,7 +946,7 @@ export function apply(ctx, config) {
932
946
  const sessionId = session?.id;
933
947
  if (typeof cwd !== "string" || cwd.length === 0) return;
934
948
  if (typeof sessionId !== "string" || sessionId.length === 0) return;
935
- const episode = buildEpisode(session);
949
+ const episode = buildEpisode(session, { sessionQuery });
936
950
  if (episode === null) return;
937
951
  const workspace = wk(cwd);
938
952
  const maxEpisodes = Number.isFinite(resolved.episodeMaxPerWorkspace)
@@ -950,7 +964,7 @@ export function apply(ctx, config) {
950
964
  signal: episode.signal,
951
965
  filePath: null,
952
966
  sessionId,
953
- seq: Array.isArray(session.events) ? session.events.length : 0,
967
+ seq: sessionSeq(session, sessionQuery),
954
968
  },
955
969
  resolved,
956
970
  );
@@ -1019,7 +1033,7 @@ export function apply(ctx, config) {
1019
1033
  // over-threshold tool results at todo/goal subtask boundaries through
1020
1034
  // DSH's own deterministic toolResultPruner, gated by a repayment clock.
1021
1035
  const l2Dir = resultPackDir();
1022
- const disposeBoundaryPrune = installBoundaryPrune(ctx, resolved, log, {
1036
+ const boundaryHooks = {
1023
1037
  // ResultPack L2 hook: archive the pruned middles (redacted) + append
1024
1038
  // a handle tail on each pruned node. Fail-contained in result-pack.js.
1025
1039
  // Also records durable activation telemetry (SoL-Pi M15/C13) —
@@ -1051,11 +1065,28 @@ export function apply(ctx, config) {
1051
1065
  log,
1052
1066
  });
1053
1067
  },
1054
- }, { allowFire: boundaryGate });
1055
- disposers.push(disposeBoundaryPrune);
1056
- // BoundaryPrune is installed here, so this is the moment L1 provably can
1057
- // (or cannot) fire — record it for the durable status snapshot.
1058
- boundaryPruneState = boundaryPruneAvailability(ctx, resolved);
1068
+ };
1069
+ // Arm L1 through `ctx.inject`, never a bare `ctx.get`: on a full web
1070
+ // profile the host pruner's fiber activates a tick or two after this
1071
+ // plugin (measured ~100 ms with dsh-context + dshmarket mounted), and a
1072
+ // strict lookup at install time is then empty — the old direct install
1073
+ // gave up and L1 stayed dormant for the whole process, silently killing
1074
+ // the only ledger credit path. `ctx.inject` runs the callback as soon as
1075
+ // the service is active (and re-runs it after a reload).
1076
+ if (resolved.boundaryPrune !== false) {
1077
+ ctx.inject(["toolResultPruner"], (prunerCtx) => {
1078
+ const dispose = installBoundaryPrune(prunerCtx, resolved, log, boundaryHooks, {
1079
+ allowFire: boundaryGate,
1080
+ });
1081
+ // L1 provably can (or cannot) fire from here on: record it for the
1082
+ // durable snapshot, then rewrite it — the first flush may have landed
1083
+ // before the pruner activated, and this status line is what users
1084
+ // read when "节省" stays at 0.
1085
+ boundaryPruneState = boundaryPruneAvailability(prunerCtx, resolved);
1086
+ flushStatus();
1087
+ return dispose;
1088
+ });
1089
+ }
1059
1090
 
1060
1091
  // ResultPack L2 tool (default ON with L1 since v0.6): `result_recall(callId,
1061
1092
  // offset?)` pages back an archived middle. Independent of the memory
package/lib/preference.js CHANGED
@@ -28,6 +28,7 @@
28
28
  */
29
29
 
30
30
  import { workspaceKey as wk } from "./util.js";
31
+ import { sessionSeq } from "./session-events.js";
31
32
 
32
33
  /** Direction of the preference statement, used in the captured text. */
33
34
  export const PREFERENCE_DIRECTION = {
@@ -244,7 +245,7 @@ export function installPreferenceCapture(ctx, storeAdapter, config, log, stats)
244
245
  signal: PREFERENCE_SIGNAL,
245
246
  filePath: null,
246
247
  sessionId,
247
- seq: Array.isArray(session.events) ? session.events.length : 0,
248
+ seq: sessionSeq(session),
248
249
  },
249
250
  config,
250
251
  )
@@ -0,0 +1,126 @@
1
+ /**
2
+ * Session event-log access across DSH generations.
3
+ *
4
+ * WHY THIS EXISTS (2026-10-20, the second "silent zero"):
5
+ * DSH ≤0.1.x exposed the conversation log as `session.events`, a plain frozen
6
+ * array. DSH 0.2.x removed that getter — `Session.prototype` now carries only
7
+ * `eventAt` / `snapshotEvents` / `ownEvents` / `deriveEventMessage`, and the
8
+ * synchronous full-log read is deprecated host-internally in favour of
9
+ * `ctx.sessionQuery.live(session)` leases. Every
10
+ * `Array.isArray(session.events)` read in this plugin therefore yielded `[]`
11
+ * on the running host, silently disabling three whole features while their
12
+ * unit tests (which mock `{ events: [...] }`) stayed green:
13
+ *
14
+ * - `[RECALL]` session-start auto-recall never materialized (no first user
15
+ * message → no query), and worse, `index.js` refused to cache the
16
+ * `[ENGRAM]` block ("pendingRecall") on every assembly, so the index was
17
+ * re-rendered per turn and never metered into the ledger;
18
+ * - session episodes were never written → `[RESUME]` briefs never fired;
19
+ * - the session-end todo sink never found a pending todo → no ESR drafts.
20
+ *
21
+ * The lesson is the same one as the session-log rename (memory #8): a
22
+ * host-API read must feature-detect and REPORT, never silently degrade to an
23
+ * empty value. `sessionEventsSource()` names the path taken so the status
24
+ * snapshot can surface `none` instead of pretending the log was empty.
25
+ *
26
+ * Resolution order (first working wins):
27
+ * 1. `session.events` — legacy hosts (≤0.1.x), and every unit test;
28
+ * 2. `sessionQuery.live(session)`— sanctioned ≥0.2.x lease (lazy snapshot);
29
+ * 3. `session.snapshotEvents()` — context-free fallback (deprecated host-side,
30
+ * still the only sync full-log read a plugin has);
31
+ * 4. `[]` — nothing readable; callers degrade and
32
+ * `sessionEventsSource` reports "none".
33
+ */
34
+
35
+ /** Shared empty array: callers only read, so one frozen instance is enough. */
36
+ const NO_EVENTS = Object.freeze([]);
37
+
38
+ /**
39
+ * Which read path a session exposes, without materializing the log.
40
+ * @param {object} session
41
+ * @param {object} [sessionQuery] the `ctx.sessionQuery` service, if mounted
42
+ * @returns {"legacy"|"sessionQuery"|"snapshotEvents"|"none"}
43
+ */
44
+ export function sessionEventsSource(session, sessionQuery = void 0) {
45
+ if (session === null || typeof session !== "object") return "none";
46
+ if (Array.isArray(session.events)) return "legacy";
47
+ if (hasLive(sessionQuery)) return "sessionQuery";
48
+ if (typeof session.snapshotEvents === "function") return "snapshotEvents";
49
+ return "none";
50
+ }
51
+
52
+ function hasLive(sessionQuery) {
53
+ return (
54
+ sessionQuery !== null &&
55
+ typeof sessionQuery === "object" &&
56
+ typeof sessionQuery.live === "function"
57
+ );
58
+ }
59
+
60
+ /**
61
+ * Read a session's event log across DSH generations (see module header).
62
+ *
63
+ * Returns a frozen-or-plain array of events; never throws. A session that
64
+ * genuinely has no events and a session whose log is unreadable both return
65
+ * `[]` — use {@link sessionEventsSource} when the difference matters.
66
+ *
67
+ * @param {object} session host session (`agent.session`, or the arg of
68
+ * `session/disposed`)
69
+ * @param {object} [sessionQuery] the `ctx.sessionQuery` service, if mounted
70
+ * @returns {ReadonlyArray<object>}
71
+ */
72
+ export function readSessionEvents(session, sessionQuery = void 0) {
73
+ if (session === null || typeof session !== "object") return NO_EVENTS;
74
+
75
+ // 1. Legacy: DSH ≤0.1.x (and every unit-test double).
76
+ if (Array.isArray(session.events)) return session.events;
77
+
78
+ // 2. Sanctioned ≥0.2.x: a live lease exposes a lazily materialized snapshot
79
+ // up to the session's current seq. Dispose it immediately — we only need
80
+ // the array, not the retention or the projection payload.
81
+ if (hasLive(sessionQuery)) {
82
+ let lease;
83
+ try {
84
+ lease = sessionQuery.live(session);
85
+ const events = lease?.events;
86
+ if (Array.isArray(events)) return events;
87
+ } catch {
88
+ // fall through to snapshotEvents
89
+ } finally {
90
+ try {
91
+ lease?.[Symbol.dispose]?.();
92
+ } catch {
93
+ // best-effort lease release
94
+ }
95
+ }
96
+ }
97
+
98
+ // 3. Context-free fallback.
99
+ if (typeof session.snapshotEvents === "function") {
100
+ try {
101
+ const end = typeof session.seq === "number" ? session.seq : void 0;
102
+ const events = end === void 0 ? session.snapshotEvents() : session.snapshotEvents(0, end);
103
+ if (Array.isArray(events)) return events;
104
+ } catch {
105
+ // unreadable — degrade
106
+ }
107
+ }
108
+
109
+ // 4. Nothing.
110
+ return NO_EVENTS;
111
+ }
112
+
113
+ /**
114
+ * The session's current event count, for provenance `seq` stamps.
115
+ *
116
+ * `seq` is the next free seq on ≥0.2.x (`session.seq`), so it equals the log
117
+ * length; on legacy hosts the array length is the same number. Kept here so
118
+ * the four write sites (capture / tools / preference / episode) agree.
119
+ */
120
+ export function sessionSeq(session, sessionQuery = void 0) {
121
+ if (session === null || typeof session !== "object") return 0;
122
+ const seq = session.seq;
123
+ if (typeof seq === "number" && Number.isFinite(seq) && seq >= 0) return Math.trunc(seq);
124
+ const events = readSessionEvents(session, sessionQuery);
125
+ return events.length;
126
+ }
package/lib/todo-sink.js CHANGED
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { pendingTodos } from "./trigger.js";
26
26
  import { shortId, uuid } from "./util.js";
27
+ import { readSessionEvents } from "./session-events.js";
27
28
 
28
29
  /** Description stamped on every auto-sunk draft (mirrors the GUI button's note). */
29
30
  export const SINK_DESCRIPTION = "源自会话计划(会话结束自动沉淀)";
@@ -35,9 +36,9 @@ export const SINK_DESCRIPTION = "源自会话计划(会话结束自动沉淀
35
36
  * Deterministic — this is a pure function of the session log, so it survives
36
37
  * process restarts (no reliance on the process-local recorder snapshot).
37
38
  */
38
- export function pendingTodosFromSession(session) {
39
+ export function pendingTodosFromSession(session, sessionQuery = void 0) {
39
40
  if (session === null || typeof session !== "object") return [];
40
- const events = Array.isArray(session.events) ? session.events : [];
41
+ const events = readSessionEvents(session, sessionQuery);
41
42
  for (let i = events.length - 1; i >= 0; i--) {
42
43
  const event = events[i];
43
44
  if (event === null || typeof event !== "object" || event.type !== "todo/write") continue;
package/lib/tools.js CHANGED
@@ -22,6 +22,7 @@ import { buildEsrStatusView } from "./index-block.js";
22
22
  import { buildEntityPage, renderEntityPage } from "./entity-page.js";
23
23
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
24
24
  import { hasCJK, searchSessionsCJK } from "./session-cjk.js";
25
+ import { sessionSeq } from "./session-events.js";
25
26
 
26
27
  const KINDS = [
27
28
  "preference",
@@ -51,7 +52,7 @@ function callerOf(exec) {
51
52
  if (agent === void 0) throw new EngramError("ENGRAM_MISSING_AGENT", "engram tools require an agent-bound caller");
52
53
  const cwd = agent.session?.header?.cwd;
53
54
  if (cwd === void 0 || cwd.length === 0) throw new EngramError("ENGRAM_UNAUTHORIZED", "this session has no workspace cwd; engram memory is workspace-scoped");
54
- return { agent, cwd: wk(cwd), sessionId: agent.session.id, seq: agent.session.events?.length ?? 0 };
55
+ return { agent, cwd: wk(cwd), sessionId: agent.session.id, seq: sessionSeq(agent.session) };
55
56
  }
56
57
 
57
58
  function parse(zodSchema, raw, tool) {
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.7.1",
4
+ "version": "0.7.4",
5
5
  "packageManager": "pnpm@11.22.0",
6
6
  "license": "MIT",
7
7
  "repository": {