dsh-engram 0.7.2 → 0.8.6

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.
Files changed (54) hide show
  1. package/README.md +104 -7
  2. package/README.zh.md +13 -3
  3. package/docs/CONTEXT-GC-GUIDE.zh.md +15 -1
  4. package/docs/G6-quality-ab.zh.md +540 -0
  5. package/docs/GATES.zh.md +41 -0
  6. package/docs/PROPOSAL-evolution.zh.md +12 -0
  7. package/docs/RELEASE-NOTES-v0.7.10.md +49 -0
  8. package/docs/RELEASE-NOTES-v0.7.11.md +31 -0
  9. package/docs/RELEASE-NOTES-v0.7.12.md +22 -0
  10. package/docs/RELEASE-NOTES-v0.7.13.md +41 -0
  11. package/docs/RELEASE-NOTES-v0.7.14.md +37 -0
  12. package/docs/RELEASE-NOTES-v0.7.15.md +36 -0
  13. package/docs/RELEASE-NOTES-v0.7.16.md +42 -0
  14. package/docs/RELEASE-NOTES-v0.7.3.md +30 -0
  15. package/docs/RELEASE-NOTES-v0.7.4.md +71 -0
  16. package/docs/RELEASE-NOTES-v0.7.5.md +134 -0
  17. package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
  18. package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
  19. package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
  20. package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
  21. package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
  22. package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
  23. package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
  24. package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
  25. package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
  26. package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
  27. package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
  28. package/eval/gate-audit.mjs +538 -0
  29. package/lib/api.js +70 -0
  30. package/lib/approvals.js +84 -0
  31. package/lib/boundary-prune.js +71 -5
  32. package/lib/capsule.js +134 -0
  33. package/lib/capture.js +85 -6
  34. package/lib/client.js +1813 -1745
  35. package/lib/compaction.js +4 -11
  36. package/lib/context-gc.js +63 -2
  37. package/lib/episode.js +10 -5
  38. package/lib/index-block.js +89 -8
  39. package/lib/index.js +108 -21
  40. package/lib/ledger.js +200 -10
  41. package/lib/pack-telemetry.js +10 -3
  42. package/lib/preference.js +2 -1
  43. package/lib/prune.js +89 -0
  44. package/lib/session-events.js +168 -0
  45. package/lib/skill-export.js +289 -0
  46. package/lib/status-insights.js +5 -0
  47. package/lib/status.js +138 -0
  48. package/lib/store.js +34 -0
  49. package/lib/todo-sink.js +3 -2
  50. package/lib/tools.js +2 -1
  51. package/lib/usage.js +32 -3
  52. package/lib/web-provision.js +59 -14
  53. package/package.json +15 -8
  54. package/scripts/dsh-engram.mjs +229 -4
@@ -0,0 +1,289 @@
1
+ /**
2
+ * v0.8-B: export evidence-backed procedures as standard DSH Skills.
3
+ *
4
+ * Why this exists: the [ENGRAM] index line is a prompt hint — it disappears when
5
+ * a session compacts and it never leaves this machine's memory store. A DSH
6
+ * Skill is a different surface: the host discovers it from `.dsh/skills`,
7
+ * advertises it in the session catalog, and any agent (or subagent) can load the
8
+ * FULL text on demand. So a procedure that has proven itself 3 times deserves to
9
+ * exist as a real artifact, not just a line of recalled text.
10
+ *
11
+ * The contract with the host is verified, not assumed
12
+ * (`@deepseek-ai/dsh-skill-filesystem`):
13
+ * - roots: `<project>/.dsh/skills` (rank 100), `<project>/.agents/skills`,
14
+ * `$DSH_HOME/skills`, `$AGENTS_HOME/skills`;
15
+ * - a skill is a DIRECTORY containing `SKILL.md` (or a flat `*.md`);
16
+ * - frontmatter MUST carry `name` and `description`, `name` matching
17
+ * /^[a-z0-9]+(?:-[a-z0-9]+)*$/; `whenToUse` and `disable-model-invocation` /
18
+ * `user-invocable` are optional.
19
+ *
20
+ * Two safety rules, because this module writes into a directory the host reads:
21
+ * 1. only files carrying SKILL_MARKER are ever rewritten or deleted — a
22
+ * hand-written skill in the same root is never touched;
23
+ * 2. nothing is exported until it passes the v0.8-A evidence gate
24
+ * (≥3 verified successes, verified within 14 days, not invalidated).
25
+ */
26
+ import fs from "node:fs";
27
+ import path from "node:path";
28
+
29
+ import { isProvenProcedure, procedureInvalidated } from "./index-block.js";
30
+ import { fmtDate, shortId } from "./util.js";
31
+
32
+ /** Written into every generated SKILL.md; the ownership marker for safe cleanup. */
33
+ export const SKILL_MARKER = "<!-- generated-by: dsh-engram skill-export -->";
34
+
35
+ /** Where DSH's filesystem provider looks, highest precedence first. */
36
+ export function defaultSkillDir(projectRoot) {
37
+ return path.join(projectRoot, ".dsh", "skills");
38
+ }
39
+
40
+ /** Fold arbitrary text into the host's kebab-case skill-name grammar. */
41
+ export function kebab(text) {
42
+ return String(text ?? "")
43
+ .normalize("NFKD")
44
+ .replace(/[^\x00-\x7F]/g, "-")
45
+ .toLowerCase()
46
+ .replace(/[^a-z0-9]+/g, "-")
47
+ .replace(/^-+|-+$/g, "")
48
+ .replace(/-{2,}/g, "-");
49
+ }
50
+
51
+ /**
52
+ * `cmd:npm_test` → `npm-test`; the `cmd:` prefix is dropped because every
53
+ * exported skill is a command fix. Falls back to the memory id so the slug is
54
+ * never empty (an empty slug would produce an invalid skill name).
55
+ */
56
+ export function skillSlug(row) {
57
+ const tag = (row?.tags ?? []).find((t) => typeof t === "string" && t.startsWith("cmd:"));
58
+ const base = kebab(tag !== undefined ? tag.slice(4) : row?.id ?? "");
59
+ const trimmed = base.slice(0, 48).replace(/-+$/, "");
60
+ return trimmed.length > 0 ? trimmed : `mem-${shortId(row?.id ?? "0")}`;
61
+ }
62
+
63
+ /** Host-legal skill name (regex from @deepseek-ai/dsh-skill). */
64
+ export function skillName(row) {
65
+ return `engram-fix-${skillSlug(row)}`;
66
+ }
67
+
68
+ /** The command a procedure is about: its `cmd:` tag decoded, else the row text. */
69
+ export function procedureCommand(row) {
70
+ const tag = (row?.tags ?? []).find((t) => typeof t === "string" && t.startsWith("cmd:"));
71
+ if (tag === undefined) return String(row?.text ?? "");
72
+ return tag.slice(4).replace(/_/g, " ");
73
+ }
74
+
75
+ /** One line for the host catalog: what it is, and how well it is attested. */
76
+ export function skillDescription(row, now = Date.now()) {
77
+ const command = procedureCommand(row);
78
+ const count = row?.evidenceCount ?? 0;
79
+ const verified = row?.lastVerifiedAt !== null && row?.lastVerifiedAt !== undefined
80
+ ? fmtDate(row.lastVerifiedAt)
81
+ : "unknown";
82
+ return `Verified fix for \`${command}\` — held ${count}×, last verified ${verified}; engram procedure #${shortId(row?.id ?? "")}.`;
83
+ }
84
+
85
+ export function skillWhenToUse(row) {
86
+ const command = procedureCommand(row);
87
+ return `When \`${command}\` fails again and you want the fix that already held repeatedly on this machine.`;
88
+ }
89
+
90
+ /** Newest resolved error for the same command — the failure signature. */
91
+ export function failureSignature(rows, row) {
92
+ const tag = (row?.tags ?? []).find((t) => typeof t === "string" && t.startsWith("cmd:"));
93
+ if (tag === undefined) return null;
94
+ const error = (rows ?? [])
95
+ .filter(
96
+ (m) =>
97
+ m?.kind === "error" &&
98
+ (m.tags ?? []).includes(tag) &&
99
+ (m.tags ?? []).includes("resolved") &&
100
+ m.id !== row.id,
101
+ )
102
+ .sort((a, b) => (b.createdAt ?? 0) - (a.createdAt ?? 0))[0];
103
+ if (error === undefined) return null;
104
+ return String(error.text ?? "").split("\n").slice(0, 3).join("\n").slice(0, 300);
105
+ }
106
+
107
+ /** Deterministic SKILL.md for one promoted procedure. */
108
+ export function renderSkill(row, rows, { now = Date.now() } = {}) {
109
+ const command = procedureCommand(row);
110
+ const count = row?.evidenceCount ?? 0;
111
+ const first = row?.createdAt ?? null;
112
+ const last = row?.lastVerifiedAt ?? null;
113
+ const signature = failureSignature(rows, row);
114
+ const lines = [
115
+ "---",
116
+ `name: ${skillName(row)}`,
117
+ `description: ${JSON.stringify(skillDescription(row, now))}`,
118
+ `whenToUse: ${JSON.stringify(skillWhenToUse(row))}`,
119
+ "---",
120
+ "",
121
+ SKILL_MARKER,
122
+ `<!-- evidence: ${count} verified successes · last ${last !== null ? new Date(last).toISOString() : "unknown"} · memory ${row?.id ?? ""} -->`,
123
+ "",
124
+ `# Fix that holds: \`${command}\``,
125
+ "",
126
+ "## Evidence",
127
+ "",
128
+ `- verified successes: **${count}**`,
129
+ `- first captured: ${first !== null ? new Date(first).toISOString() : "unknown"}`,
130
+ `- last verified: ${last !== null ? new Date(last).toISOString() : "unknown"}`,
131
+ `- engram memory: \`${row?.id ?? ""}\``,
132
+ "",
133
+ ];
134
+ if (signature !== null) {
135
+ lines.push("## The failure it closes", "", "```", signature, "```", "");
136
+ }
137
+ lines.push(
138
+ "## How to use this",
139
+ "",
140
+ `1. Run \`${command}\` in this workspace first — the skill is evidence of a past`,
141
+ " success, not a guarantee of the current state.",
142
+ "2. If it passes, the procedure still holds; nothing else to do.",
143
+ "3. If it fails, this skill is stale: dsh-engram invalidates the underlying",
144
+ " procedure on that failure (its `[ENGRAM]` line disappears), so treat the",
145
+ " fresh error as the truth and this note as history.",
146
+ "",
147
+ "## Provenance",
148
+ "",
149
+ "Generated by `dsh-engram skill export` from the memory store. Editing this",
150
+ "file is fine, but the next export of the same procedure rewrites it while the",
151
+ "marker above is present.",
152
+ "",
153
+ );
154
+ return lines.join("\n");
155
+ }
156
+
157
+ /**
158
+ * Pure planning step: which skills should exist, with what content. No I/O, so
159
+ * the rules are testable without touching a filesystem.
160
+ */
161
+ export function planSkillExport(rows, { config, now = Date.now(), dir, approved = null, requireApproval = false }) {
162
+ const byName = new Map();
163
+ for (const row of rows ?? []) {
164
+ if (row === null || typeof row !== "object") continue;
165
+ if ((row.kind ?? "") !== "procedure") continue;
166
+ if (row.status === "archived") continue;
167
+ if (procedureInvalidated(row)) continue;
168
+ if (!isProvenProcedure(row, { ...config, nowMs: now })) continue;
169
+ if (!(row.tags ?? []).includes("auto-captured")) continue;
170
+ // v0.8-D: writing a file the host advertises needs a human yes, not only
171
+ // three successful runs. `approved` is the set of ids the user confirmed.
172
+ if (requireApproval && !(approved?.has?.(row.id) ?? false)) continue;
173
+ const name = skillName(row);
174
+ const previous = byName.get(name);
175
+ if (previous !== undefined && (previous.lastVerifiedAt ?? 0) >= (row.lastVerifiedAt ?? 0)) continue;
176
+ byName.set(name, row);
177
+ }
178
+ return [...byName.entries()]
179
+ .sort(([a], [b]) => a.localeCompare(b))
180
+ .map(([name, row]) => ({
181
+ name,
182
+ slug: skillSlug(row),
183
+ memoryId: row.id,
184
+ content: renderSkill(row, rows, { now }),
185
+ // Directory name is irrelevant to the host; using the skill name keeps
186
+ // the tree readable and the sweep comparison trivial.
187
+ // `dir` is optional: the GUI asks what WOULD be written without a target.
188
+ dir: typeof dir === "string" && dir.length > 0 ? path.join(dir, name) : null,
189
+ }));
190
+ }
191
+
192
+ /**
193
+ * Apply a plan to disk.
194
+ *
195
+ * - writes only when the bytes differ (repeated exports are no-ops, so the
196
+ * host's watcher does not churn);
197
+ * - refuses to touch a SKILL.md without SKILL_MARKER (human-owned);
198
+ * - deletes generated SKILL.md files whose procedure no longer qualifies —
199
+ * a broken procedure must not keep advertising itself as a skill.
200
+ */
201
+ export function exportSkills(rows, { config, dir, now = Date.now(), dryRun = false, approved = null, requireApproval = false } = {}) {
202
+ const plan = planSkillExport(rows, { config, now, dir, approved, requireApproval });
203
+ const report = { written: [], unchanged: [], removed: [], skipped: [], pending: [], errors: [] };
204
+ // What an approval prompt would offer: proven, auto-captured, not yet approved.
205
+ if (requireApproval) {
206
+ for (const row of rows ?? []) {
207
+ if (row === null || typeof row !== "object") continue;
208
+ if ((row.kind ?? "") !== "procedure") continue;
209
+ if (row.status === "archived" || procedureInvalidated(row)) continue;
210
+ if (!isProvenProcedure(row, { ...config, nowMs: now })) continue;
211
+ if (!(row.tags ?? []).includes("auto-captured")) continue;
212
+ if (approved?.has?.(row.id) ?? false) continue;
213
+ report.pending.push({ id: row.id, name: skillName(row), description: skillDescription(row, now) });
214
+ }
215
+ }
216
+ const wanted = new Set(plan.map((skill) => skill.name));
217
+
218
+ if (!dryRun) {
219
+ try {
220
+ fs.mkdirSync(dir, { recursive: true });
221
+ } catch (error) {
222
+ report.errors.push(`mkdir ${dir}: ${String(error)}`);
223
+ return report;
224
+ }
225
+ }
226
+
227
+ for (const skill of plan) {
228
+ const file = path.join(skill.dir, "SKILL.md");
229
+ let existing = null;
230
+ try {
231
+ existing = fs.readFileSync(file, "utf8");
232
+ } catch {
233
+ existing = null;
234
+ }
235
+ if (existing !== null && !existing.includes(SKILL_MARKER)) {
236
+ report.skipped.push(`${skill.name} (human-owned SKILL.md at ${file})`);
237
+ continue;
238
+ }
239
+ if (existing === skill.content) {
240
+ report.unchanged.push(skill.name);
241
+ continue;
242
+ }
243
+ if (!dryRun) {
244
+ try {
245
+ fs.mkdirSync(skill.dir, { recursive: true });
246
+ fs.writeFileSync(file, skill.content, "utf8");
247
+ } catch (error) {
248
+ report.errors.push(`${skill.name}: ${String(error)}`);
249
+ continue;
250
+ }
251
+ }
252
+ report.written.push(skill.name);
253
+ }
254
+
255
+ // Stale sweep: only our own marker files, only when their skill is not wanted
256
+ // any more (invalidated, expired, or the row was archived).
257
+ let entries = [];
258
+ try {
259
+ entries = fs.readdirSync(dir, { withFileTypes: true });
260
+ } catch {
261
+ entries = [];
262
+ }
263
+ for (const entry of entries) {
264
+ if (!entry.isDirectory()) continue;
265
+ const file = path.join(dir, entry.name, "SKILL.md");
266
+ let content = null;
267
+ try {
268
+ content = fs.readFileSync(file, "utf8");
269
+ } catch {
270
+ continue;
271
+ }
272
+ if (!content.includes(SKILL_MARKER)) continue;
273
+ const name = /^name:\s*(.+)$/m.exec(content)?.[1]?.trim();
274
+ if (name !== undefined && wanted.has(name)) continue;
275
+ if (!dryRun) {
276
+ try {
277
+ fs.rmSync(file);
278
+ // Remove the directory too, but only when it holds nothing else.
279
+ if (fs.readdirSync(path.join(dir, entry.name)).length === 0) fs.rmdirSync(path.join(dir, entry.name));
280
+ } catch (error) {
281
+ report.errors.push(`remove ${entry.name}: ${String(error)}`);
282
+ continue;
283
+ }
284
+ }
285
+ report.removed.push(name ?? entry.name);
286
+ }
287
+
288
+ return report;
289
+ }
@@ -0,0 +1,5 @@
1
+ /**
2
+ * Shared predicates for CLI reporting (status/doctor). Thin re-exports so the
3
+ * CLI never imports the plugin entry (which pulls DSH packages).
4
+ */
5
+ export { isProvenProcedure, procedureInvalidated as invalidatedProcedure } from "./index-block.js";
package/lib/status.js CHANGED
@@ -25,6 +25,25 @@ export function contextGcStatusPath() {
25
25
  return join(engramHomeDir(), "engram", "context-gc.status.json");
26
26
  }
27
27
 
28
+ /** How much a snapshot can say about the credit paths: 0 = nothing armed. */
29
+ function rankHost(host) {
30
+ if (host === "context-gc") return 3;
31
+ if (host === "default") return 2;
32
+ if (host === "unavailable") return 1;
33
+ return 0;
34
+ }
35
+
36
+ /** Whether a pid is still running (EPERM counts as alive). */
37
+ function isAlivePid(pid) {
38
+ if (!Number.isInteger(pid) || pid <= 0) return false;
39
+ try {
40
+ process.kill(pid, 0);
41
+ return true;
42
+ } catch (error) {
43
+ return error?.code === "EPERM";
44
+ }
45
+ }
46
+
28
47
  /**
29
48
  * Atomically write the status snapshot (tmp + rename). Never throws for the
30
49
  * caller's convenience; a failure is logged by the caller if it cares.
@@ -33,6 +52,25 @@ export function contextGcStatusPath() {
33
52
  */
34
53
  export async function writeContextGcStatus(payload) {
35
54
  const path = contextGcStatusPath();
55
+ // Two plugin instances can settle in one process (the host row and a sibling
56
+ // realm that lost the `compaction` provider). Last-writer-wins then reports a
57
+ // working host as "unavailable" — a status file that lies is worse than none,
58
+ // so within one pid a *worse* snapshot never overwrites a better one.
59
+ const incoming = rankHost(payload?.host) + (payload?.boundaryPrune?.armed === true ? 1 : 0);
60
+ const existing = await readContextGcStatus().catch(() => void 0);
61
+ if (existing !== void 0 && existing.pid !== void 0) {
62
+ const current = rankHost(existing.host) + (existing?.boundaryPrune?.armed === true ? 1 : 0);
63
+ if (incoming < current) {
64
+ // Same pid: never downgrade (a sibling realm instance shares the process).
65
+ if (existing.pid === payload?.pid) return;
66
+ // Different pid: a LIVE writer keeps the file. Without this, any stray
67
+ // short-lived process that loads the plugin (a test run, a one-shot CLI,
68
+ // a headless probe with factory defaults) overwrites the running server's
69
+ // snapshot with "host unavailable / L1 not armed" — the status file lies,
70
+ // and every downstream reader believes it.
71
+ if (isAlivePid(existing.pid)) return;
72
+ }
73
+ }
36
74
  await mkdir(dirname(path), { recursive: true });
37
75
  const tmp = `${path}.tmp`;
38
76
  await writeFile(
@@ -55,3 +93,103 @@ export async function readContextGcStatus() {
55
93
  return undefined;
56
94
  }
57
95
  }
96
+
97
+ /** Append-only log of compaction attempts (JSONL): the evidence that the
98
+ * Context GC path actually ran — and the error when it did not. Without it a
99
+ * broken engine looks exactly like a working one: the host silently falls
100
+ * back to its default summarizer and the ledger credits nothing. */
101
+ export function gcAttemptPath() {
102
+ return join(engramHomeDir(), "engram", "compaction-attempts.jsonl");
103
+ }
104
+
105
+ /**
106
+ * Record one attempt. Best effort: accounting/diagnostics must never break the
107
+ * compaction path. Keeps the tail bounded (rewrites to the last 20 lines past
108
+ * 32 KB) so a long-lived install cannot grow the file forever.
109
+ * @param {{ok: boolean, [key: string]: any}} payload
110
+ * @returns {Promise<void>}
111
+ */
112
+ export async function recordGcAttempt(payload) {
113
+ try {
114
+ const file = gcAttemptPath();
115
+ await mkdir(dirname(file), { recursive: true });
116
+ const line = `${JSON.stringify({ at: new Date().toISOString(), ...payload })}\n`;
117
+ const { appendFile, readFile: read, stat, writeFile: write } = await import("node:fs/promises");
118
+ await appendFile(file, line, "utf8");
119
+ const info = await stat(file).catch(() => null);
120
+ if (info !== null && info.size > 32 * 1024) {
121
+ const text = await read(file, "utf8").catch(() => "");
122
+ const tail = text.trimEnd().split("\n").slice(-20).join("\n");
123
+ await write(file, `${tail}\n`, "utf8");
124
+ }
125
+ } catch {
126
+ /* diagnostics must never throw */
127
+ }
128
+ }
129
+
130
+ /** The most recent attempts (newest last), for the CLI and gate audit. */
131
+ export async function readGcAttempts(limit = 20) {
132
+ try {
133
+ const { readFile } = await import("node:fs/promises");
134
+ const text = await readFile(gcAttemptPath(), "utf8");
135
+ return text
136
+ .split("\n")
137
+ .filter((line) => line.trim().length > 0)
138
+ .slice(-limit)
139
+ .map((line) => {
140
+ try {
141
+ return JSON.parse(line);
142
+ } catch {
143
+ return null;
144
+ }
145
+ })
146
+ .filter(Boolean);
147
+ } catch {
148
+ return [];
149
+ }
150
+ }
151
+
152
+ /** Append-only log of boundary-prune attempts (JSONL). Same purpose as the
153
+ * compaction attempts: L1 armed-but-never-firing must be distinguishable from
154
+ * "no boundary ever happened". Every boundary the listener sees is recorded —
155
+ * including ones the repayment/adaptive gate holds — with the plane it ran on
156
+ * (host vs agent-preset realm), the pruned count and the chars removed. */
157
+ export function pruneAttemptPath() {
158
+ return join(engramHomeDir(), "engram", "boundary-attempts.jsonl");
159
+ }
160
+
161
+ /** @param {{plane?: string, boundary?: any, pruned?: number, charsRemoved?: number, [key: string]: any}} payload */
162
+ export async function recordPruneAttempt(payload) {
163
+ try {
164
+ const file = pruneAttemptPath();
165
+ await mkdir(dirname(file), { recursive: true });
166
+ const line = `${JSON.stringify({ at: new Date().toISOString(), ...payload })}\n`;
167
+ const { appendFile, readFile: read, stat, writeFile: write } = await import("node:fs/promises");
168
+ await appendFile(file, line, "utf8");
169
+ const info = await stat(file).catch(() => null);
170
+ if (info !== null && info.size > 32 * 1024) {
171
+ const text = await read(file, "utf8").catch(() => "");
172
+ const tail = text.trimEnd().split("\n").slice(-20).join("\n");
173
+ await write(file, `${tail}\n`, "utf8");
174
+ }
175
+ } catch {
176
+ /* diagnostics must never throw */
177
+ }
178
+ }
179
+
180
+ /** The most recent prune attempts (oldest first), for support and the CLI. */
181
+ export async function readPruneAttempts(limit = 50) {
182
+ try {
183
+ const text = await readFile(pruneAttemptPath(), "utf8");
184
+ const lines = text.trimEnd().split("\n").filter(Boolean).slice(-limit);
185
+ return lines.map((line) => {
186
+ try {
187
+ return JSON.parse(line);
188
+ } catch {
189
+ return null;
190
+ }
191
+ }).filter(Boolean);
192
+ } catch {
193
+ return [];
194
+ }
195
+ }
package/lib/store.js CHANGED
@@ -90,6 +90,15 @@ const MemorySchema = z.object({
90
90
  source: z.string().nullable().default(null),
91
91
  /** Optional trigger/condition under which this memory should be recalled. */
92
92
  conditions: z.string().nullable().default(null),
93
+ /** v0.8-A: procedure evidence. Count of verified successes since promotion
94
+ * (1 at auto-capture time — one success is never enough to generalize). */
95
+ evidenceCount: z.number().int().nonnegative().default(0),
96
+ /** Last verified-success time (epoch ms). */
97
+ lastVerifiedAt: z.number().int().nullable().default(null),
98
+ /** Last time the same command failed again (epoch ms). */
99
+ lastFailedAt: z.number().int().nullable().default(null),
100
+ /** Memory id of the failure that invalidated this procedure. */
101
+ invalidatedBy: z.string().nullable().default(null),
93
102
  });
94
103
 
95
104
  const TaskSchema = z.object({
@@ -556,6 +565,10 @@ export async function openEngramDomain(facility) {
556
565
  contradicts,
557
566
  source: entry.source ?? null,
558
567
  conditions: entry.conditions ?? null,
568
+ evidenceCount: entry.evidenceCount ?? 0,
569
+ lastVerifiedAt: entry.lastVerifiedAt ?? null,
570
+ lastFailedAt: entry.lastFailedAt ?? null,
571
+ invalidatedBy: entry.invalidatedBy ?? null,
559
572
  expiresAt,
560
573
  };
561
574
  await memories.put(stored.id, stored);
@@ -688,6 +701,27 @@ export async function openEngramDomain(facility) {
688
701
  await memories.put(id, { ...m, tags });
689
702
  return true;
690
703
  },
704
+ /**
705
+ * v0.8-A procedure evidence accounting (zero LLM, non-appending).
706
+ *
707
+ * A `fixed:` procedure is only a claim until it keeps holding. Every later
708
+ * run of the same command either credits it (a verified success) or
709
+ * invalidates it (the command failed again) — the second is immediate and
710
+ * demotes the row out of the index, because a procedure that just broke is
711
+ * worse than no procedure: the next agent would copy it verbatim.
712
+ *
713
+ * Never touches `updatedAt`/`hits`: evidence is not recall, and crediting a
714
+ * success must not re-rank the row in recency order.
715
+ */
716
+ async recordProcedureOutcome(workspace, id, { ok, at = now(), errorId = null } = {}) {
717
+ const m = memories.get(id);
718
+ if (m === void 0 || m.workspace !== workspace || m.kind !== "procedure" || m.status !== "active") return null;
719
+ const next = ok
720
+ ? { ...m, evidenceCount: (m.evidenceCount ?? 0) + 1, lastVerifiedAt: at }
721
+ : { ...m, lastFailedAt: at, invalidatedBy: errorId ?? m.invalidatedBy ?? null, signal: Math.min(m.signal ?? 0.5, 0.2) };
722
+ await memories.put(id, next);
723
+ return next;
724
+ },
691
725
  /** Lazy TTL sweep (write path only; async). Returns archived count. */
692
726
  async expireSweep(workspace) {
693
727
  let n = 0;
package/lib/todo-sink.js CHANGED
@@ -24,6 +24,7 @@
24
24
 
25
25
  import { pendingTodos } from "./trigger.js";
26
26
  import { shortId, uuid } from "./util.js";
27
+ import { readSessionEvents } from "./session-events.js";
27
28
 
28
29
  /** Description stamped on every auto-sunk draft (mirrors the GUI button's note). */
29
30
  export const SINK_DESCRIPTION = "源自会话计划(会话结束自动沉淀)";
@@ -35,9 +36,9 @@ export const SINK_DESCRIPTION = "源自会话计划(会话结束自动沉淀
35
36
  * Deterministic — this is a pure function of the session log, so it survives
36
37
  * process restarts (no reliance on the process-local recorder snapshot).
37
38
  */
38
- export function pendingTodosFromSession(session) {
39
+ export function pendingTodosFromSession(session, sessionQuery = void 0) {
39
40
  if (session === null || typeof session !== "object") return [];
40
- const events = Array.isArray(session.events) ? session.events : [];
41
+ const events = readSessionEvents(session, sessionQuery);
41
42
  for (let i = events.length - 1; i >= 0; i--) {
42
43
  const event = events[i];
43
44
  if (event === null || typeof event !== "object" || event.type !== "todo/write") continue;
package/lib/tools.js CHANGED
@@ -22,6 +22,7 @@ import { buildEsrStatusView } from "./index-block.js";
22
22
  import { buildEntityPage, renderEntityPage } from "./entity-page.js";
23
23
  import { truncate, shortId, slugId, fmtDate, escapeLt, workspaceKey as wk, uuid } from "./util.js";
24
24
  import { hasCJK, searchSessionsCJK } from "./session-cjk.js";
25
+ import { sessionSeq } from "./session-events.js";
25
26
 
26
27
  const KINDS = [
27
28
  "preference",
@@ -51,7 +52,7 @@ function callerOf(exec) {
51
52
  if (agent === void 0) throw new EngramError("ENGRAM_MISSING_AGENT", "engram tools require an agent-bound caller");
52
53
  const cwd = agent.session?.header?.cwd;
53
54
  if (cwd === void 0 || cwd.length === 0) throw new EngramError("ENGRAM_UNAUTHORIZED", "this session has no workspace cwd; engram memory is workspace-scoped");
54
- return { agent, cwd: wk(cwd), sessionId: agent.session.id, seq: agent.session.events?.length ?? 0 };
55
+ return { agent, cwd: wk(cwd), sessionId: agent.session.id, seq: sessionSeq(agent.session) };
55
56
  }
56
57
 
57
58
  function parse(zodSchema, raw, tool) {
package/lib/usage.js CHANGED
@@ -265,6 +265,35 @@ function blockText(content) {
265
265
  return out;
266
266
  }
267
267
 
268
+ /**
269
+ * Normalize one logged `tool/result` event across session formats:
270
+ *
271
+ * v1–v3 data.message.content = [{ type: "tool-result", toolCallId, isError, content: [{ text }] }]
272
+ * v4 data.message = { toolCallId, isError: true?, content: [{ type: "text", text }] }
273
+ *
274
+ * DSH 0.2.x writes v4. Reading only the wrapped shape (as `/stats` did until
275
+ * v0.7.5) makes every failure count and every recall-hits tally a silent zero —
276
+ * the same class of bug as `session.events` disappearing. Returns null when the
277
+ * event is not a tool result.
278
+ */
279
+ export function readToolResult(event) {
280
+ const message = event?.data?.message;
281
+ if (message === null || typeof message !== "object") return null;
282
+ const blocks = Array.isArray(message.content) ? message.content : [];
283
+ const wrapped = blocks.find((b) => b?.type === "tool-result");
284
+ if (wrapped !== void 0) {
285
+ return {
286
+ callId: wrapped.toolCallId ?? message.toolCallId,
287
+ text: blockText(wrapped.content),
288
+ isError: wrapped.isError === true,
289
+ };
290
+ }
291
+ if (typeof message.toolCallId === "string" || blocks.some((b) => typeof b?.text === "string")) {
292
+ return { callId: message.toolCallId, text: blockText(blocks), isError: message.isError === true };
293
+ }
294
+ return null;
295
+ }
296
+
268
297
  /**
269
298
  * Count tool calls across recent session logs.
270
299
  * @param opts - { root, days, now } — root is injectable for tests.
@@ -399,9 +428,9 @@ export function collectUsageStats({ root = sessionsRoot(), days = 14, now = Date
399
428
  continue;
400
429
  }
401
430
  if (j?.type !== "tool/result") continue;
402
- const block = j.data?.message?.content?.find((c) => c?.type === "tool-result");
403
- if (block?.toolCallId) {
404
- results.set(block.toolCallId, { isError: block.isError === true, text: blockText(block.content) });
431
+ const parsed = readToolResult(j);
432
+ if (parsed !== null && typeof parsed.callId === "string" && parsed.callId.length > 0) {
433
+ results.set(parsed.callId, { isError: parsed.isError, text: parsed.text });
405
434
  }
406
435
  }
407
436
  let windowLeft = -1; // stream index of last hit-recall