dsh-engram 0.6.0 → 0.7.1

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
+ }