dsh-engram 0.7.2 → 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/README.md +11 -1
- package/README.zh.md +7 -1
- package/docs/RELEASE-NOTES-v0.7.3.md +30 -0
- package/docs/RELEASE-NOTES-v0.7.4.md +71 -0
- package/lib/capture.js +2 -1
- package/lib/client.js +1760 -1758
- package/lib/episode.js +10 -5
- package/lib/index-block.js +10 -4
- package/lib/index.js +19 -5
- package/lib/preference.js +2 -1
- package/lib/session-events.js +126 -0
- package/lib/todo-sink.js +3 -2
- package/lib/tools.js +2 -1
- package/package.json +1 -1
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
|
|
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
|
|
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 =
|
|
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
|
|
package/lib/index-block.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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:
|
|
967
|
+
seq: sessionSeq(session, sessionQuery),
|
|
954
968
|
},
|
|
955
969
|
resolved,
|
|
956
970
|
);
|
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:
|
|
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 =
|
|
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
|
|
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.
|
|
4
|
+
"version": "0.7.4",
|
|
5
5
|
"packageManager": "pnpm@11.22.0",
|
|
6
6
|
"license": "MIT",
|
|
7
7
|
"repository": {
|