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.
- package/README.md +155 -39
- package/README.zh.md +119 -36
- package/cordis.patch.yml +18 -5
- package/docs/CONTEXT-GC-GUIDE.zh.md +35 -6
- package/docs/PROPOSAL-evolution.zh.md +25 -1
- package/docs/RELEASE-NOTES-v0.7.0.md +57 -0
- package/docs/RELEASE-NOTES-v0.7.1.md +88 -0
- package/lib/anchor.js +124 -0
- package/lib/boundary-prune.js +27 -0
- package/lib/client.js +7 -2
- package/lib/compaction.js +11 -0
- package/lib/context-gc.js +47 -0
- package/lib/entity-page.js +200 -0
- package/lib/episode.js +163 -0
- package/lib/index-block.js +74 -4
- package/lib/index.js +136 -9
- package/lib/settings.js +6 -0
- package/lib/store.js +48 -5
- package/lib/tools.js +25 -3
- package/lib/web-provision.js +324 -91
- package/package.json +1 -1
- package/scripts/dsh-engram.mjs +196 -53
|
@@ -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
|
+
}
|
package/lib/index-block.js
CHANGED
|
@@ -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
|
|
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
|
|
126
|
-
|
|
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
|
+
}
|