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.
- package/README.md +89 -27
- package/README.zh.md +71 -23
- package/docs/PROPOSAL-evolution.zh.md +25 -1
- package/docs/RELEASE-NOTES-v0.7.0.md +57 -0
- package/lib/anchor.js +124 -0
- package/lib/client.js +4 -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 +82 -1
- package/lib/settings.js +6 -0
- package/lib/store.js +48 -5
- package/lib/tools.js +25 -3
- package/package.json +1 -1
|
@@ -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
|
+
}
|
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 {
|
|
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([]),
|