dsh-engram 0.6.0 → 0.7.0

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.
@@ -0,0 +1,200 @@
1
+ /**
2
+ * dsh-engram entity pages (PROPOSAL-evolution §4 v0.7-B).
3
+ *
4
+ * `esr_node` used to be a dot on the graph: a name, a kind, edges. An entity
5
+ * PAGE is the readable projection of everything the workspace knows about that
6
+ * thing:
7
+ *
8
+ * truth — current claims (superseded rows excluded), preference first
9
+ * timeline — newest anchored memories
10
+ * open loops — active ESR tasks hanging on the entity
11
+ * conflicts — rows contradicted by / contradicting another row
12
+ * relations — typed links in and out
13
+ *
14
+ * Two renderings:
15
+ * - `renderEntityCard` — ONE line for the [ENGRAM] index (the model sees it
16
+ * every session and drills with `engram_detail(entity=…)`);
17
+ * - `renderEntityPage` — the full page returned by that drill / the GUI.
18
+ *
19
+ * Pure functions over the store; zero LLM, deterministic ordering.
20
+ */
21
+
22
+ import { escapeLt, fmtDate, shortId, truncate } from "./util.js";
23
+ import { evidenceGaps } from "./index-block.js";
24
+ import { memoryIsStale } from "./anchor.js";
25
+
26
+ const TRUTH_KINDS = new Set(["preference", "decision", "fact", "insight", "procedure"]);
27
+
28
+ /** Find an entity by id or name (case-insensitive), or return a virtual one. */
29
+ export function resolveEntity(store, workspace, key) {
30
+ const raw = String(key ?? "").trim();
31
+ if (raw.length === 0) return null;
32
+ const entities = typeof store?.listEntities === "function" ? store.listEntities(workspace) : [];
33
+ const lower = raw.toLowerCase();
34
+ const found = entities.find(
35
+ (entity) => String(entity.id).toLowerCase() === lower || String(entity.name).toLowerCase() === lower,
36
+ );
37
+ if (found !== void 0) return found;
38
+ // A name that has memories/tasks anchored but no registered node still
39
+ // deserves a page — the anchor itself is the identity.
40
+ return { id: raw, workspace, name: raw, kind: "", description: "", createdAt: 0, updatedAt: 0, sessionId: "" };
41
+ }
42
+
43
+ function byUpdatedDesc(a, b) {
44
+ return (b.updatedAt ?? b.createdAt ?? 0) - (a.updatedAt ?? a.createdAt ?? 0);
45
+ }
46
+
47
+ function matchesAnchor(memory, entity, key) {
48
+ const anchor = memory?.entity;
49
+ if (typeof anchor !== "string" || anchor.length === 0) return false;
50
+ return anchor === entity.id || anchor === entity.name || anchor === key;
51
+ }
52
+
53
+ /**
54
+ * Build the page projection for one entity.
55
+ *
56
+ * @param {object} store opened engram domain
57
+ * @param {string} workspace normalized workspace key
58
+ * @param {string} key entity id or display name
59
+ */
60
+ export function buildEntityPage(store, workspace, key, { timelineLimit = 8 } = {}) {
61
+ const entity = resolveEntity(store, workspace, key);
62
+ if (entity === null) return null;
63
+ const anchored = (store.timeline?.(workspace, entity.name || key, 100) ?? [])
64
+ .filter((memory) => matchesAnchor(memory, entity, String(key).trim()));
65
+ const relations = store.memoryRelations?.(workspace) ?? { supersededBy: new Map(), contradictedBy: new Map() };
66
+ const supersededBy = relations.supersededBy ?? new Map();
67
+ const contradictedBy = relations.contradictedBy ?? new Map();
68
+ const fresh = anchored.filter((memory) => !supersededBy.has(memory.id));
69
+
70
+ const truth = fresh
71
+ .filter((memory) => TRUTH_KINDS.has(memory.kind))
72
+ .filter((memory) => !memoryIsStale(memory))
73
+ .sort((a, b) => (Number(b.kind === "preference") - Number(a.kind === "preference")) || byUpdatedDesc(a, b))
74
+ .slice(0, 6);
75
+ const timeline = fresh.slice().sort(byUpdatedDesc).slice(0, timelineLimit);
76
+ const openTasks = (store.listTasks?.(workspace) ?? []).filter(
77
+ (task) => task.entity === entity.id || task.entity === entity.name || task.entity === String(key).trim(),
78
+ );
79
+ const links = [];
80
+ const seen = new Set();
81
+ for (const relation of [
82
+ ...(store.linksFor?.(workspace, entity.id) ?? []),
83
+ ...(entity.name !== entity.id ? store.linksFor?.(workspace, entity.name) ?? [] : []),
84
+ ]) {
85
+ if (seen.has(relation.id)) continue;
86
+ seen.add(relation.id);
87
+ links.push(relation);
88
+ }
89
+ const conflictSeen = new Set();
90
+ const conflicts = [];
91
+ for (const memory of anchored) {
92
+ const contradicted = contradictedBy.get(memory.id)?.[0]?.id ?? null;
93
+ const contradicts = typeof memory.contradicts === "string" && memory.contradicts.length > 0 ? memory.contradicts : null;
94
+ const other = contradicted ?? contradicts;
95
+ if (other === null) continue;
96
+ const pair = [memory.id, other].sort().join("|");
97
+ if (conflictSeen.has(pair)) continue;
98
+ conflictSeen.add(pair);
99
+ conflicts.push({ memory, contradictedById: contradicted, contradictsId: contradicts });
100
+ }
101
+
102
+ const updatedAt = Math.max(
103
+ entity.updatedAt ?? 0,
104
+ ...anchored.map((memory) => memory.updatedAt ?? memory.createdAt ?? 0),
105
+ ...openTasks.map((task) => task.updatedAt ?? 0),
106
+ 0,
107
+ );
108
+
109
+ return {
110
+ entity,
111
+ anchored,
112
+ truth,
113
+ timeline,
114
+ openTasks,
115
+ links,
116
+ conflicts,
117
+ counts: {
118
+ memories: fresh.length,
119
+ openTasks: openTasks.filter((task) => task.state === "active").length,
120
+ conflicts: conflicts.length,
121
+ links: links.length,
122
+ },
123
+ updatedAt,
124
+ };
125
+ }
126
+
127
+ /**
128
+ * ONE index line for an entity with something worth surfacing. The drill
129
+ * pointer is never truncated away: the head (and truth excerpt) shrink first.
130
+ */
131
+ export function renderEntityCard(page, { maxChars = 160 } = {}) {
132
+ if (page === null) return "";
133
+ const name = page.entity.name || page.entity.id;
134
+ const pointer = ` · page: engram_detail(entity="${escapeLt(name)}")`;
135
+ const kind = page.entity.kind ? ` · ${page.entity.kind}` : "";
136
+ let head = `[ENT] ${escapeLt(truncate(name, 40))}${kind} · ${page.counts.memories} mem · ${page.counts.openTasks} open`;
137
+ if (head.length + pointer.length > maxChars) {
138
+ head = truncate(head, Math.max(12, maxChars - pointer.length - 1));
139
+ }
140
+ const truth = page.truth[0];
141
+ if (truth !== void 0) {
142
+ const room = maxChars - head.length - pointer.length - " · truth: ".length - 9;
143
+ if (room >= 12) {
144
+ const text = truncate(String(truth.text).replace(/\s+/g, " ").trim(), room);
145
+ head += ` · truth: ${text} #${shortId(truth.id)}`;
146
+ }
147
+ }
148
+ return `${head}${pointer}`;
149
+ }
150
+
151
+ /** Full page text (engram_detail drill / GUI later). */
152
+ export function renderEntityPage(page, { maxChars = 2400 } = {}) {
153
+ if (page === null) return "no such entity";
154
+ const name = page.entity.name || page.entity.id;
155
+ const lines = [];
156
+ const header = [`ent:${name}`, page.entity.kind, `${page.counts.memories} memory`, `${page.counts.openTasks} open`]
157
+ .filter((part) => typeof part === "string" && part.length > 0)
158
+ .join(" · ");
159
+ lines.push(`${header} · updated ${fmtDate(page.updatedAt)} #${page.entity.id}`);
160
+ if (typeof page.entity.description === "string" && page.entity.description.length > 0) {
161
+ lines.push(`description: ${truncate(page.entity.description, 200)}`);
162
+ }
163
+ if (page.truth.length > 0) {
164
+ lines.push("truth:");
165
+ for (const memory of page.truth) {
166
+ lines.push(`- [${memory.kind}] ${truncate(String(memory.text).replace(/\s+/g, " "), 180)} #${shortId(memory.id)}`);
167
+ }
168
+ }
169
+ if (page.timeline.length > 0) {
170
+ lines.push("timeline:");
171
+ for (const memory of page.timeline) {
172
+ const stale = memoryIsStale(memory) ? " · stale" : "";
173
+ lines.push(`- ${fmtDate(memory.createdAt)} [${memory.kind}] ${truncate(String(memory.text).replace(/\s+/g, " "), 120)} #${shortId(memory.id)}${stale}`);
174
+ }
175
+ }
176
+ if (page.openTasks.length > 0) {
177
+ lines.push("open loops:");
178
+ for (const task of page.openTasks) {
179
+ const gaps = evidenceGaps(task);
180
+ const state = task.state === "stable" ? "STABLE" : gaps.length === 0 ? "READY" : `gap: ${gaps.join(", ")}`;
181
+ lines.push(`- ${task.id} ${truncate(String(task.name), 80)} — ${state}`);
182
+ }
183
+ }
184
+ if (page.links.length > 0) {
185
+ lines.push("relations:");
186
+ for (const relation of page.links.slice(0, 10)) {
187
+ lines.push(`- ${relation.source} --${relation.relation}--> ${relation.target} (${Math.round((relation.confidence ?? 1) * 100)}%)`);
188
+ }
189
+ }
190
+ if (page.conflicts.length > 0) {
191
+ lines.push("conflicts:");
192
+ for (const conflict of page.conflicts) {
193
+ const other = conflict.contradictedById ?? conflict.contradictsId;
194
+ lines.push(`- #${shortId(conflict.memory.id)} ${other === null ? "(unresolved)" : `vs #${shortId(other)}`}`);
195
+ }
196
+ }
197
+ lines.push("drill: engram_detail(id=…) · engram_recall(query) · esr_status");
198
+ const text = lines.join("\n");
199
+ return text.length <= maxChars ? text : `${text.slice(0, maxChars - 1)}…`;
200
+ }
package/lib/episode.js ADDED
@@ -0,0 +1,163 @@
1
+ /**
2
+ * dsh-engram session episode (PROPOSAL-evolution §4 v0.7-A).
3
+ *
4
+ * At the session's teardown edge we distill the event log into ONE compact
5
+ * `episode` memory: what was touched, what ran, what failed (and whether it
6
+ * later succeeded), the written commit subjects, and what is still open.
7
+ *
8
+ * Design constraints:
9
+ * - zero LLM, pure function over `session.events` (survives restarts);
10
+ * - never enters the [ENGRAM] index (long background context, not a symbol);
11
+ * - bounded by construction (≤4 files / ≤4 commands / ≤2 decisions / ≤3 open);
12
+ * - stored only when there is substance (a bare `ls` session produces none);
13
+ * - retention is capped per workspace by the sink in index.js, not by TTL.
14
+ *
15
+ * This is the raw material for the resume brief and, later, entity pages.
16
+ */
17
+
18
+ import { cmdTag } from "./capture.js";
19
+ import { pendingTodosFromSession } from "./todo-sink.js";
20
+
21
+ const EDIT_TOOLS = new Set(["write", "edit", "str_replace_editor"]);
22
+ const RUN_TOOLS = new Set(["bash", "shell", "exec"]);
23
+
24
+ function parseArgs(raw) {
25
+ if (raw !== null && typeof raw === "object") return raw;
26
+ if (typeof raw !== "string" || raw.length === 0) return {};
27
+ try {
28
+ const parsed = JSON.parse(raw);
29
+ return parsed !== null && typeof parsed === "object" ? parsed : {};
30
+ } catch {
31
+ return {};
32
+ }
33
+ }
34
+
35
+ function clip(text, max) {
36
+ const flat = String(text ?? "").replace(/\s+/g, " ").trim();
37
+ return flat.length <= max ? flat : `${flat.slice(0, max - 1)}…`;
38
+ }
39
+
40
+ function stamp(now) {
41
+ const d = new Date(now);
42
+ const p = (n) => String(n).padStart(2, "0");
43
+ return `${d.getFullYear()}-${p(d.getMonth() + 1)}-${p(d.getDate())} ${p(d.getHours())}:${p(d.getMinutes())}`;
44
+ }
45
+
46
+ /** Repo-relative display path when the file lives under the session cwd. */
47
+ function displayPath(path, cwd) {
48
+ const p = String(path ?? "");
49
+ if (typeof cwd === "string" && cwd.length > 0 && p.startsWith(`${cwd}/`)) return p.slice(cwd.length + 1);
50
+ return p;
51
+ }
52
+
53
+ function commitSubject(command) {
54
+ if (!/^\s*git\s+commit\b/.test(command)) return null;
55
+ const m = command.match(/-m\s+["']([^"']{1,80})["']/);
56
+ return m === null ? null : clip(m[1], 60);
57
+ }
58
+
59
+ /**
60
+ * Build the episode record from a disposed session.
61
+ *
62
+ * @param {object} session session with `.events` + `.header.cwd`
63
+ * @param {object} [opts]
64
+ * @returns {{text: string, tags: string[], signal: number, summary: object}|null}
65
+ */
66
+ export function buildEpisode(session, { now = Date.now() } = {}) {
67
+ if (session === null || typeof session !== "object") return null;
68
+ const events = Array.isArray(session.events) ? session.events : [];
69
+ if (events.length === 0) return null;
70
+ const cwd = session?.header?.cwd;
71
+
72
+ const files = new Map(); // display path -> count
73
+ const commands = new Map(); // cmd tag -> row
74
+ const decisions = [];
75
+ const callMeta = new Map(); // callId -> { tag, row }
76
+ let failedRuns = 0;
77
+
78
+ for (const event of events) {
79
+ if (event === null || typeof event !== "object") continue;
80
+ const type = event.type;
81
+ if (type === "tool/call") {
82
+ const data = event.data ?? {};
83
+ const name = String(data.name ?? "").toLowerCase();
84
+ const args = parseArgs(data.arguments);
85
+ const callId = typeof data.callId === "string" ? data.callId : null;
86
+ if (EDIT_TOOLS.has(name)) {
87
+ const path = String(args.path ?? args.file_path ?? args.filePath ?? "");
88
+ if (path.length > 0) {
89
+ const shown = displayPath(path, cwd);
90
+ files.set(shown, (files.get(shown) ?? 0) + 1);
91
+ }
92
+ }
93
+ if (RUN_TOOLS.has(name) && typeof args.command === "string") {
94
+ const tag = cmdTag(args.command);
95
+ if (tag !== null) {
96
+ const row = commands.get(tag) ?? { tag, count: 0, failed: false, fixed: false };
97
+ row.count += 1;
98
+ commands.set(tag, row);
99
+ if (callId !== null) callMeta.set(callId, { tag, row });
100
+ }
101
+ const subject = commitSubject(args.command);
102
+ if (subject !== null) decisions.push(subject);
103
+ }
104
+ continue;
105
+ }
106
+ if (type === "tool/result") {
107
+ const content = event?.data?.message?.content;
108
+ const block = Array.isArray(content) ? content.find((c) => c?.type === "tool-result") : null;
109
+ const callId = block?.toolCallId ?? event?.data?.message?.source?.callId;
110
+ const meta = typeof callId === "string" ? callMeta.get(callId) : void 0;
111
+ if (meta === void 0) continue;
112
+ const isError = block?.isError === true || event?.data?.error !== void 0;
113
+ if (isError) {
114
+ if (!meta.row.failed) failedRuns += 1;
115
+ meta.row.failed = true;
116
+ } else if (meta.row.failed) {
117
+ meta.row.fixed = true;
118
+ }
119
+ }
120
+ }
121
+
122
+ const open = pendingTodosFromSession(session).slice(0, 3);
123
+ const hasSubstance = files.size > 0 || decisions.length > 0 || failedRuns > 0 || open.length > 0;
124
+ if (!hasSubstance) return null;
125
+
126
+ const parts = [];
127
+ if (files.size > 0) {
128
+ const shown = [...files.entries()]
129
+ .sort((a, b) => b[1] - a[1])
130
+ .slice(0, 4)
131
+ .map(([path]) => clip(path, 48));
132
+ parts.push(`files ${shown.join(", ")}`);
133
+ }
134
+ if (commands.size > 0) {
135
+ const rows = [...commands.values()]
136
+ .sort((a, b) => Number(b.failed) - Number(a.failed) || b.count - a.count)
137
+ .slice(0, 4)
138
+ .map((row) => {
139
+ const head = row.tag.replace(/^cmd:/, "").replace(/_/g, " ");
140
+ const mark = row.failed ? (row.fixed ? "→fixed" : "→failed") : "";
141
+ return `${clip(head, 32)}×${row.count}${mark}`;
142
+ });
143
+ parts.push(`cmds ${rows.join(", ")}`);
144
+ }
145
+ if (decisions.length > 0) {
146
+ parts.push(`decisions ${decisions.slice(0, 2).map((d) => `"${clip(d, 40)}"`).join(", ")}`);
147
+ }
148
+ if (open.length > 0) parts.push(`open ${open.map((t) => clip(t, 40)).join("; ")}`);
149
+
150
+ const text = clip(`session episode ${stamp(now)}: ${parts.join(" · ")}`, 900);
151
+ return {
152
+ text,
153
+ signal: 0.25,
154
+ tags: ["episode", "auto-captured", ...(failedRuns > 0 ? ["fix"] : [])],
155
+ summary: {
156
+ files: [...files.keys()],
157
+ commands: [...commands.values()],
158
+ decisions,
159
+ open,
160
+ failedRuns,
161
+ },
162
+ };
163
+ }
@@ -10,6 +10,8 @@
10
10
  */
11
11
 
12
12
  import { label, shortId, fmtDate, escapeLt, truncate, byRecency } from "./util.js";
13
+ import { buildEntityPage, renderEntityCard } from "./entity-page.js";
14
+ import { memoryIsStale } from "./anchor.js";
13
15
 
14
16
  /** Deterministic index line prefix per memory kind. */
15
17
  export function prefixFor(kind) {
@@ -21,6 +23,7 @@ export function prefixFor(kind) {
21
23
  case "handoff": return "H";
22
24
  case "task": return "T";
23
25
  case "preference": return "R";
26
+ case "episode": return "S";
24
27
  default: return "F";
25
28
  }
26
29
  }
@@ -57,6 +60,12 @@ export function selectIndexMemories(store, workspace, config) {
57
60
  const { supersededBy } = store.memoryRelations?.(workspace) ?? { supersededBy: new Map() };
58
61
  return all
59
62
  .filter((m) => !supersededBy.has(m.id))
63
+ // Session episodes are background context, never index lines (they are
64
+ // long and only useful as a resume brief or through engram_detail).
65
+ .filter((m) => m.kind !== "episode")
66
+ // A memory whose file source changed/vanished is no longer current truth
67
+ // for an auto-injected index line (still re-fetchable via engram_detail).
68
+ .filter((m) => !memoryIsStale(m))
60
69
  .filter(
61
70
  (m) =>
62
71
  (isGitPlumbingEcho(m) && (m.hits ?? 0) < config.promoteHits
@@ -108,22 +117,58 @@ function taskStateLine(t) {
108
117
  return `[T] ${fmtDate(t.createdAt)} ${name} — ${suffix} #${t.id.slice(0, 8)}`;
109
118
  }
110
119
 
120
+ /**
121
+ * Entity cards for the index (v0.7-B): entities with anchored memories or open
122
+ * tasks, ranked by activity. Each card is ONE line; the model drills with
123
+ * `engram_detail(entity=…)`. Bounded and deterministic.
124
+ */
125
+ export function selectEntityCards(store, workspace, config = {}) {
126
+ if (config.entityCardsEnabled === false) return [];
127
+ if (store === null || typeof store.listEntities !== "function") return [];
128
+ const max = Math.max(1, Math.min(4, Number.isFinite(config.entityCardsMax) ? Math.trunc(config.entityCardsMax) : 2));
129
+ const pages = [];
130
+ for (const entity of store.listEntities(workspace)) {
131
+ const page = buildEntityPage(store, workspace, entity.id);
132
+ if (page === null) continue;
133
+ if (page.counts.memories === 0 && page.counts.openTasks === 0) continue;
134
+ pages.push(page);
135
+ }
136
+ pages.sort((a, b) => {
137
+ const score = (page) => page.counts.memories + page.counts.openTasks * 2;
138
+ return score(b) - score(a) || String(a.entity.name).localeCompare(String(b.entity.name));
139
+ });
140
+ return pages.slice(0, max);
141
+ }
142
+
111
143
  /**
112
144
  * Render the full [ENGRAM] block (or "" when nothing to show). Pure function;
113
145
  * called by the section provider and cached per session.
114
146
  */
115
147
  export function renderIndex(store, workspace, cwd, config) {
116
148
  const entries = store.workspaceEntries(workspace);
117
- const memories = selectIndexMemories(store, workspace, config);
149
+ const cards = selectEntityCards(store, workspace, config);
150
+ const cardKeys = new Set();
151
+ for (const page of cards) {
152
+ cardKeys.add(page.entity.id);
153
+ cardKeys.add(page.entity.name);
154
+ }
155
+ // Memories already represented by an entity card are not repeated as flat
156
+ // lines — the card carries their current truth and the drill pointer.
157
+ const memories = selectIndexMemories(store, workspace, config)
158
+ .filter((memory) => !(typeof memory.entity === "string" && cardKeys.has(memory.entity)));
118
159
  const tasks = store.listTasks(workspace, { includeStable: false });
119
160
  const links = entries.links;
120
161
 
121
- if (memories.length === 0 && tasks.length === 0 && links === 0) return "";
162
+ if (memories.length === 0 && tasks.length === 0 && links === 0 && cards.length === 0) return "";
122
163
 
123
164
  const lines = [];
124
165
  lines.push(`[ENGRAM] workspace: ${label(cwd)} · ${entries.memories} memories · ${tasks.length} task(s) active · ${links} links · ${entries.nodes} node(s)`);
125
- const entityNames = store.listEntities(workspace).map((e) => e.name);
126
- if (entityNames.length > 0) lines.push(`nodes: ${truncate(entityNames.join(", "), 72)}`);
166
+ for (const page of cards) {
167
+ lines.push(renderEntityCard(page, { maxChars: config.entityCardChars ?? 120 }));
168
+ }
169
+ const shownNames = new Set(cards.map((page) => page.entity.name));
170
+ const otherNames = store.listEntities(workspace).map((e) => e.name).filter((name) => !shownNames.has(name));
171
+ if (otherNames.length > 0) lines.push(`nodes: ${truncate(otherNames.join(", "), 72)}`);
127
172
  for (const m of memories.slice(0, config.indexMaxLines)) lines.push(memoryLine(m, config));
128
173
  for (const t of tasks.slice(0, 4)) lines.push(taskStateLine(t));
129
174
  lines.push("drill: use [RECALL] below · engram_detail <id> (full record) · engram_recall <query> (more) · esr_task/esr_node/esr_link (work)");
@@ -560,3 +605,28 @@ function taskGapSuffix(t) {
560
605
  const gaps = evidenceGaps(t);
561
606
  return gaps.length === 0 ? " · READY to close" : ` · gap: ${gaps.join(", ")}`;
562
607
  }
608
+
609
+ /**
610
+ * [RESUME] brief (PROPOSAL-evolution §4 v0.7-D): the newest recent session
611
+ * episode, rendered as ≤ N chars plus a drill pointer. Frozen per session by
612
+ * the same cache as [ENGRAM]; empty when no recent episode exists.
613
+ */
614
+ export function renderResumeBrief(store, workspace, config = {}, opts = {}) {
615
+ if (config?.resumeBriefEnabled === false) return "";
616
+ if (store === null || typeof store?.listMemories !== "function") return "";
617
+ const days = Number.isFinite(config?.resumeBriefMaxAgeDays)
618
+ ? Math.max(0, config.resumeBriefMaxAgeDays)
619
+ : 7;
620
+ const maxChars = Number.isFinite(config?.resumeBriefMaxChars) && config.resumeBriefMaxChars > 0
621
+ ? Math.trunc(config.resumeBriefMaxChars)
622
+ : 240;
623
+ const now = opts.now ?? Date.now();
624
+ const cutoff = now - days * 86_400_000;
625
+ const episodes = store
626
+ .listMemories(workspace, 200)
627
+ .filter((m) => m.kind === "episode" && (m.createdAt ?? m.updatedAt ?? 0) >= cutoff);
628
+ if (episodes.length === 0) return "";
629
+ const latest = episodes[0]; // listMemories is recency-sorted
630
+ const body = truncate(String(latest.text).replace(/^session episode[^:]*:\s*/, ""), maxChars);
631
+ return `[RESUME] last episode ${fmtDate(latest.createdAt)}: ${body}\ncontext: engram_detail ${latest.id} (full) · continue or esr_status`;
632
+ }
package/lib/index.js CHANGED
@@ -29,7 +29,9 @@ import { resultPackDir, archivePrunedResults, resultRecallTool, sweepExpired } f
29
29
  import { recordBoundaryPruneFire, recordRecallInjection, readPackTelemetry, engramHome } from "./pack-telemetry.js";
30
30
  import { createLedger, estimatePrefixChars } from "./ledger.js";
31
31
  import { createJit } from "./jit.js";
32
- import { renderIndex, renderStartRecall, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
32
+ import { buildEpisode } from "./episode.js";
33
+ import { collectStaleAnchors } from "./anchor.js";
34
+ import { renderIndex, renderStartRecall, renderResumeBrief, firstUserMessageText, renderEsr, esrMethodology, buildEsrSnapshot, buildFrozenEsrBlock } from "./index-block.js";
33
35
  import { makeTriggerRecorder } from "./trigger.js";
34
36
  import { pendingTodosFromSession, sinkPendingTodos } from "./todo-sink.js";
35
37
  import { installEngramSettings, makeEngineConfigSchema, SETTINGS_KEYS } from "./settings.js";
@@ -128,6 +130,24 @@ const DEFAULTS = {
128
130
  jitMaxLines: 3,
129
131
  jitMaxLineChars: 220,
130
132
  engramJitOrder: 40.75,
133
+ // Session episode (v0.7-A): one compact, zero-LLM digest at teardown; feeds
134
+ // the resume brief and never enters the [ENGRAM] index.
135
+ episodeEnabled: true,
136
+ episodeMaxPerWorkspace: 20,
137
+ // Resume brief (v0.7-D): newest recent episode injected at session start.
138
+ resumeBriefEnabled: true,
139
+ resumeBriefMaxAgeDays: 7,
140
+ resumeBriefMaxChars: 240,
141
+ // Entity pages (v0.7-B): one-line cards in the index, full page on drill.
142
+ entityCardsEnabled: true,
143
+ entityCardsMax: 2,
144
+ entityCardChars: 160,
145
+ // Anchor revalidation + layered TTL (v0.7-C): file-anchored memories are
146
+ // re-stat'ed at session start; changed/missing sources are tagged `stale`
147
+ // and dropped from index/truth (never deleted).
148
+ anchorRevalidate: true,
149
+ episodeTtlDays: 14,
150
+ errorTtlDays: 60,
131
151
  // decision-point triggers (P0-A): promote a session plan to an ESR task
132
152
  // when the pending-todo count is at least minTodosForPromote and exceeds
133
153
  // active ESR tasks × todoPromoteRatio (default: pending > active).
@@ -495,7 +515,21 @@ export function apply(ctx, config) {
495
515
  let pendingRecall = false;
496
516
  try {
497
517
  const workspace = resolvePath(cwd);
518
+ // Anchor revalidation (v0.7-C): a bounded stat pass at session start.
519
+ // Stale rows are tagged (for the GUI/GC) and excluded from the render
520
+ // below via memoryIsStale — never deleted, still re-fetchable.
521
+ if (resolved.anchorRevalidate !== false) {
522
+ for (const row of collectStaleAnchors(opened, workspace, { limit: 40 })) {
523
+ void opened.tagMemory(workspace, row.id, "stale")?.catch?.(() => null);
524
+ }
525
+ }
498
526
  block = renderIndex(opened, workspace, cwd, resolved);
527
+ // Resume brief (v0.7-D): the newest recent episode, frozen with the
528
+ // [ENGRAM] block. Empty when there is no recent episode.
529
+ if (resolved.resumeBriefEnabled !== false) {
530
+ const resume = renderResumeBrief(opened, workspace, resolved);
531
+ if (resume.length > 0) block = `${block}\n${resume}`.trim();
532
+ }
499
533
  // Host-side session-start auto-recall (方案 B): append the [RECALL]
500
534
  // block keyed on the session's FIRST user message. The cache only freezes
501
535
  // once there is a query to key on — if the user text isn't in the events
@@ -846,6 +880,53 @@ export function apply(ctx, config) {
846
880
  });
847
881
  disposers.push(disposeSink);
848
882
 
883
+ // session-end episode digest (v0.7-A): one compact, zero-LLM record of the
884
+ // session's files/commands/failures/decisions/open loops. Never indexed;
885
+ // capped per workspace; the resume brief reads the newest recent one.
886
+ const disposeEpisode = ctx.on("session/disposed", (session) => {
887
+ if (resolved.episodeEnabled === false) return;
888
+ const cwd = session?.header?.cwd;
889
+ const sessionId = session?.id;
890
+ if (typeof cwd !== "string" || cwd.length === 0) return;
891
+ if (typeof sessionId !== "string" || sessionId.length === 0) return;
892
+ const episode = buildEpisode(session);
893
+ if (episode === null) return;
894
+ const workspace = wk(cwd);
895
+ const maxEpisodes = Number.isFinite(resolved.episodeMaxPerWorkspace)
896
+ ? Math.max(1, Math.trunc(resolved.episodeMaxPerWorkspace))
897
+ : 20;
898
+ service
899
+ .getDomain()
900
+ .then(async (domain) => {
901
+ await domain.storeMemory(
902
+ {
903
+ workspace,
904
+ kind: "episode",
905
+ text: episode.text,
906
+ tags: episode.tags,
907
+ signal: episode.signal,
908
+ filePath: null,
909
+ sessionId,
910
+ seq: Array.isArray(session.events) ? session.events.length : 0,
911
+ },
912
+ resolved,
913
+ );
914
+ const episodes = domain
915
+ .listMemories(workspace, 200)
916
+ .filter((memory) => memory.kind === "episode");
917
+ for (const old of episodes.slice(maxEpisodes)) {
918
+ try {
919
+ await domain.archiveMemory(workspace, old.id);
920
+ } catch {
921
+ // best-effort retention
922
+ }
923
+ }
924
+ log?.info?.(`engram episode: stored ${episode.text.length} chars in ${workspace}`);
925
+ })
926
+ .catch((error) => log?.warn?.(`engram episode failed: ${String(error)}`));
927
+ });
928
+ disposers.push(disposeEpisode);
929
+
849
930
  // zero-LLM auto-capture
850
931
  if (resolved.autoCapture) {
851
932
  // The capture handler needs a storeMemory face, but the domain opens
package/lib/settings.js CHANGED
@@ -70,6 +70,9 @@ export const SETTINGS_KEYS = [
70
70
  "ledgerEnabled",
71
71
  "ledgerCacheDiscount",
72
72
  "jitEnabled",
73
+ "episodeEnabled",
74
+ "resumeBriefEnabled",
75
+ "entityCardsEnabled",
73
76
  "trustedHosts",
74
77
  ];
75
78
 
@@ -115,6 +118,9 @@ export function makeSettingsSchema(defaults) {
115
118
  ledgerEnabled: z.boolean().default(defaults.ledgerEnabled).description("上下文账本:按会话统计注入成本、剪枝/压缩节省与前缀重写罚金,输出净 token(/stats.ledger 与概览);关掉=不记账、零写入"),
116
119
  ledgerCacheDiscount: z.number().min(0).max(1).default(defaults.ledgerCacheDiscount).description("账本缓存折价(0..1):被剪枝前缀的重写按 (1−该系数) 计罚金;0.1 = 缓存命中只付 10%,重写付 90%(L0 保守假设)"),
117
120
  jitEnabled: z.boolean().default(defaults.jitEnabled).description("JIT 推送:错误复发 / 文件焦点 / goal 完成时把已有记忆压成一行推进上下文(append-only、每会话最多 3 行、无命中沉默;每次推送按前缀重写计入账本)"),
121
+ episodeEnabled: z.boolean().default(defaults.episodeEnabled).description("会话 episode:会话结束时用零 LLM 规则生成一条紧凑纪要(文件/命令/失败→修复/决策/未闭环),不进 [ENGRAM] 索引,供 resume brief 与实体页使用"),
122
+ resumeBriefEnabled: z.boolean().default(defaults.resumeBriefEnabled).description("恢复简报:新会话启动时注入最近 7 天的最新 episode(≤240 字符,含 engram_detail 指针),让隔天继续的项目不用重新交代背景"),
123
+ entityCardsEnabled: z.boolean().default(defaults.entityCardsEnabled).description("实体卡:[ENGRAM] 索引为活跃实体(有锚定记忆或未闭环任务)渲染一行「当前真相 + 打开任务 + engram_detail(entity=…) 指针」,替代平铺记忆行;最多 2 张"),
118
124
  // Security knob, not rendered in the card: hostnames allowed past the
119
125
  // loopback fence for /api/dsh-engram (operator-authorized tunnel hosts).
120
126
  trustedHosts: z.array(z.string()).default([]),