dsh-engram 0.7.4 → 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 (48) hide show
  1. package/README.md +93 -6
  2. package/README.zh.md +6 -2
  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.5.md +134 -0
  15. package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
  16. package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
  17. package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
  18. package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
  19. package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
  20. package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
  21. package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
  22. package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
  23. package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
  24. package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
  25. package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
  26. package/eval/gate-audit.mjs +538 -0
  27. package/lib/api.js +70 -0
  28. package/lib/approvals.js +84 -0
  29. package/lib/boundary-prune.js +71 -5
  30. package/lib/capsule.js +134 -0
  31. package/lib/capture.js +83 -5
  32. package/lib/client.js +66 -0
  33. package/lib/compaction.js +4 -11
  34. package/lib/context-gc.js +63 -2
  35. package/lib/index-block.js +79 -4
  36. package/lib/index.js +91 -18
  37. package/lib/ledger.js +200 -10
  38. package/lib/pack-telemetry.js +10 -3
  39. package/lib/prune.js +89 -0
  40. package/lib/session-events.js +44 -2
  41. package/lib/skill-export.js +289 -0
  42. package/lib/status-insights.js +5 -0
  43. package/lib/status.js +138 -0
  44. package/lib/store.js +34 -0
  45. package/lib/usage.js +32 -3
  46. package/lib/web-provision.js +59 -14
  47. package/package.json +15 -8
  48. package/scripts/dsh-engram.mjs +229 -4
@@ -0,0 +1,84 @@
1
+ /**
2
+ * v0.8-D: the approval gate for artifacts that leave the memory store.
3
+ *
4
+ * Why this exists: v0.8-A made a procedure promotable by evidence, and v0.8-B
5
+ * turns promoted procedures into files in `.dsh/skills` that the host
6
+ * advertises to every agent. Evidence proves a command *worked*; it does not
7
+ * prove the instruction is *sound* — three successful runs of a harmful recipe
8
+ * are still three successful runs. So the two surfaces are gated differently on
9
+ * purpose:
10
+ *
11
+ * - the [ENGRAM] index line is cheap, reversible and private → evidence gate
12
+ * (automatic, v0.8-A);
13
+ * - a skill file is durable, discoverable and shared → evidence gate **plus**
14
+ * an explicit human yes (this module).
15
+ *
16
+ * The record lives next to the ledger, not inside the memory store: an approval
17
+ * is a human act, and mixing it into the pool would let the same pool "approve"
18
+ * itself. Shape:
19
+ *
20
+ * { "version": 1, "workspaces": { "<ws>": { "<memoryId>": { at, note } } } }
21
+ */
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+
25
+ export const APPROVALS_VERSION = 1;
26
+
27
+ export function approvalsPath(home) {
28
+ return path.join(home, "playbook-approvals.json");
29
+ }
30
+
31
+ /** Read the approval file; a missing or corrupt file means "nothing approved". */
32
+ export function loadApprovals(home) {
33
+ try {
34
+ const parsed = JSON.parse(fs.readFileSync(approvalsPath(home), "utf8"));
35
+ if (parsed === null || typeof parsed !== "object") return empty();
36
+ return {
37
+ version: APPROVALS_VERSION,
38
+ workspaces: parsed.workspaces !== null && typeof parsed.workspaces === "object" ? parsed.workspaces : {},
39
+ };
40
+ } catch {
41
+ return empty();
42
+ }
43
+ }
44
+
45
+ function empty() {
46
+ return { version: APPROVALS_VERSION, workspaces: {} };
47
+ }
48
+
49
+ /** Approved ids for one workspace (empty when the workspace has none). */
50
+ export function approvedIds(state, workspace) {
51
+ const bucket = state?.workspaces?.[workspace];
52
+ return bucket !== null && typeof bucket === "object" ? Object.keys(bucket) : [];
53
+ }
54
+
55
+ export function isApproved(state, workspace, memoryId) {
56
+ return state?.workspaces?.[workspace]?.[memoryId] !== undefined;
57
+ }
58
+
59
+ function save(home, state) {
60
+ const file = approvalsPath(home);
61
+ fs.mkdirSync(path.dirname(file), { recursive: true });
62
+ const tmp = `${file}.tmp`;
63
+ fs.writeFileSync(tmp, `${JSON.stringify(state, null, 2)}\n`, "utf8");
64
+ fs.renameSync(tmp, file);
65
+ return state;
66
+ }
67
+
68
+ export function recordApproval(home, workspace, memoryId, { note = null, at = Date.now() } = {}) {
69
+ if (typeof memoryId !== "string" || memoryId.length === 0) throw new Error("approve needs a memory id");
70
+ const state = loadApprovals(home);
71
+ state.workspaces[workspace] = state.workspaces[workspace] ?? {};
72
+ state.workspaces[workspace][memoryId] = { at, note };
73
+ return save(home, state);
74
+ }
75
+
76
+ export function revokeApproval(home, workspace, memoryId) {
77
+ const state = loadApprovals(home);
78
+ const bucket = state.workspaces[workspace];
79
+ if (bucket !== undefined) {
80
+ delete bucket[memoryId];
81
+ if (Object.keys(bucket).length === 0) delete state.workspaces[workspace];
82
+ }
83
+ return save(home, state);
84
+ }
@@ -86,12 +86,34 @@ export function isBoundary(event, state) {
86
86
  }
87
87
 
88
88
  /** Sessions per workspace with ≥1 request, as (count, medianRequests) map — L0 repayment input. */
89
+ /**
90
+ * One key for "which workspace is this", from either side of the lookup.
91
+ *
92
+ * Session buckets are named after the cwd with `/` flattened to `-`
93
+ * (`--Users-me-dsh-loom--`), while the live session header carries the absolute
94
+ * path (`/Users/me/dsh-loom`). Comparing those two strings never matches, and an
95
+ * unmatched workspace falls back to the bare floor — which makes the repayment
96
+ * gate (`requests + minRemaining > expectedTotal`) true for every session past
97
+ * its first request, i.e. L1 armed and permanently silent. Normalising both
98
+ * sides is what makes the history lookup real.
99
+ * @param {string | undefined | null} workspace
100
+ * @returns {string}
101
+ */
102
+ export function normalizeWorkspaceKey(workspace) {
103
+ if (typeof workspace !== "string") return "";
104
+ const flat = workspace
105
+ .trim()
106
+ .replace(/[-\/\\]+/g, "-")
107
+ .replace(/^-+|-+$/g, "");
108
+ return process.platform === "win32" ? flat.toLowerCase() : flat;
109
+ }
110
+
89
111
  export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "sessions")) {
90
112
  const byWorkspace = new Map();
91
113
  try {
92
114
  for (const bucket of fs.readdirSync(root)) {
93
115
  if (!bucket.startsWith("--")) continue;
94
- const ws = decodeURIComponent(bucket.replace(/^--/, "").replace(/--$/, ""));
116
+ const ws = normalizeWorkspaceKey(decodeURIComponent(bucket.replace(/^--+/, "").replace(/--+$/, "")));
95
117
  let sessions = 0;
96
118
  const bdir = path.join(root, bucket);
97
119
  for (const sid of fs.readdirSync(bdir)) {
@@ -120,7 +142,7 @@ export function workspaceHistory(root = path.join(os.homedir(), ".dsh", "session
120
142
  * must not starve them).
121
143
  */
122
144
  export function expectedSessionRequests(requestsSoFar, history, workspace, floor = DEFAULT_MIN_REMAINING) {
123
- const entry = history?.get(workspace);
145
+ const entry = history?.get(workspace) ?? history?.get(normalizeWorkspaceKey(workspace));
124
146
  const prior = entry && entry.sessions > 0 ? Math.max(floor, EMPIRICAL_MEDIAN_REQUESTS) : floor;
125
147
  if (requestsSoFar <= prior) return prior;
126
148
  // past the prior: project the session to run at least as long again as it
@@ -189,6 +211,15 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
189
211
  // this is purely evidence-based off, never speculative on.
190
212
  const adaptive = config?.boundaryPruneAdaptive !== false;
191
213
  const allowFire = typeof opts?.allowFire === "function" ? opts.allowFire : () => true;
214
+ // Diagnostics hook: every boundary the listener actually SEES is reported
215
+ // (skipped by the repayment gate, held by the ledger gate, or fired) so that
216
+ // "armed but never fires" can be told apart from "no boundary ever came".
217
+ // A throwing hook must never disturb the session pipeline.
218
+ const report = (kind, fields = {}) => {
219
+ try {
220
+ opts?.onAttempt?.({ kind, ...fields });
221
+ } catch { /* diagnostics only */ }
222
+ };
192
223
  const blockedNotified = new Set();
193
224
 
194
225
  // Optional threshold override (ResultPack L0, T2048 landing): the host pruner
@@ -258,7 +289,7 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
258
289
  const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
259
290
  if (state.requests + minRemaining <= expectedTotal) {
260
291
  // reuse the same fire path via a synthetic boundary
261
- return fireBoundary(session, { kind: "step" }, state, expectedTotal);
292
+ return fireDeferred(session, { kind: "step" }, state, expectedTotal);
262
293
  }
263
294
  }
264
295
  }
@@ -273,14 +304,40 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
273
304
  // requests beyond the current count.
274
305
  const workspace = wk(session?.header?.cwd);
275
306
  const expectedTotal = expectedSessionRequests(state.requests, history, workspace, minRemaining);
276
- if (state.requests + minRemaining > expectedTotal) return;
277
- return fireBoundary(session, boundary, state, expectedTotal);
307
+ if (state.requests + minRemaining > expectedTotal) {
308
+ report("skipped", { boundary, requests: state.requests, expectedTotal, workspace });
309
+ return;
310
+ }
311
+ return fireDeferred(session, boundary, state, expectedTotal);
278
312
  } catch (error) {
279
313
  // fully contained — a prune bug must never disturb the session pipeline
314
+ report("error", { where: "listener", message: String(error) });
280
315
  log?.warn?.(`engram boundary-prune threw: ${String(error)}`);
281
316
  }
282
317
  };
283
318
 
319
+ /**
320
+ * Fire OUTSIDE the `commit()` call stack.
321
+ *
322
+ * `session/event` observers run while the session is publishing an event
323
+ * (`commit()` sets `appending` for the duration). Pruning appends its own
324
+ * `compaction/prune` event, and DSH refuses a reentrant append ("session
325
+ * append cannot reenter while another append is being published"). Firing
326
+ * synchronously therefore works only while there is NOTHING to prune — the
327
+ * exact shape of "armed, reports fired, prunes zero, forever". The microtask
328
+ * runs after commit() returns, where append is legal again.
329
+ */
330
+ const fireDeferred = (session, boundary, state, expectedTotal) => {
331
+ queueMicrotask(() => {
332
+ try {
333
+ fireBoundary(session, boundary, state, expectedTotal);
334
+ } catch (error) {
335
+ report("error", { where: "fire", boundary, message: String(error) });
336
+ log?.warn?.(`engram boundary-prune: fire threw: ${String(error)}`);
337
+ }
338
+ });
339
+ };
340
+
284
341
  /** Shared fire path for every boundary source (event + step-cadence). */
285
342
  const fireBoundary = (session, boundary, state, expectedTotal) => {
286
343
  if (adaptive) {
@@ -292,6 +349,7 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
292
349
  allowed = true; // a gate bug must never disable pruning
293
350
  }
294
351
  if (!allowed) {
352
+ report("held", { boundary, requests: state.requests, workspace });
295
353
  if (!blockedNotified.has(workspace)) {
296
354
  blockedNotified.add(workspace);
297
355
  log?.info?.(
@@ -301,8 +359,16 @@ export function installBoundaryPrune(ctx, config, log, hooks = {}, opts = {}) {
301
359
  return;
302
360
  }
303
361
  }
362
+ const prunerWorkspace = wk(session?.header?.cwd);
304
363
  const result = activePruner.pruneSession(session);
305
364
  const prunedNow = result?.pruned?.length ?? 0;
365
+ report("fired", {
366
+ boundary,
367
+ requests: state.requests,
368
+ pruned: prunedNow,
369
+ charsRemoved: Number.isFinite(result?.charsRemoved) ? result.charsRemoved : 0,
370
+ workspace: prunerWorkspace,
371
+ });
306
372
  if (prunedNow > 0) {
307
373
  state.prunes += 1;
308
374
  state.prunedResults += prunedNow;
package/lib/capsule.js ADDED
@@ -0,0 +1,134 @@
1
+ /**
2
+ * v0.8-C (advisory): subagent handoff capsules.
3
+ *
4
+ * The proposal wants the parent agent to receive a ≤500-token capsule instead of
5
+ * a child's full report ("conclusion / evidence / open questions / next step").
6
+ * That substitution needs a seam the host does not expose today: `tools/result`
7
+ * is emitted through `ctx.events.dispatch("emit", …)` and the emitter's own
8
+ * comment says it notifies "observers without exposing a mutation or error
9
+ * channel into the outcome" — a listener cannot rewrite what the parent reads.
10
+ *
11
+ * So this module ships the half that IS reachable, and is honest about the
12
+ * difference:
13
+ *
14
+ * - it distills a child's report into a deterministic capsule and stores it as
15
+ * a `handoff` memory, so the conclusion survives the child session and can be
16
+ * recalled after a compaction (the full text stays re-fetchable in the child's
17
+ * session log);
18
+ * - it records the source/capsule sizes in the memory itself, so the
19
+ * "how much would substitution have saved" question is measurable instead of
20
+ * asserted.
21
+ *
22
+ * Zero LLM: the capsule is extracted by structure (headings, bullets, question
23
+ * marks), never summarized by a model.
24
+ */
25
+
26
+ export const CAPSULE_TAG = "subagent-capsule";
27
+
28
+ /** Tool names whose result is a subagent's final report. */
29
+ export function isSubagentTool(name) {
30
+ return typeof name === "string" && (name === "subagent" || name === "subagent_fork" || name === "agent");
31
+ }
32
+
33
+ const HEADING_RE = /^\s{0,3}#{1,6}\s+(.*)$/;
34
+ const BULLET_RE = /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/;
35
+ const EVIDENCE_RE = /(test|pass|fail|bench|artifact|eval|commit|✔|✓|✗|→|path|\/[\w.-]+\.(?:js|mjs|ts|md|json|yml|yaml|py|go|rs)\b|#\d|sha|https?:)/i;
36
+ const OPEN_RE = /(\?|todo|open question|unresolved|blocked|未决|待定|风险|risk)/i;
37
+ const NEXT_RE = /(next step|next:|下一步|建议|recommend|should (?:then|now)|follow-?up|remaining)/i;
38
+
39
+ function cleanLine(line) {
40
+ return String(line ?? "")
41
+ .replace(/^[-*+]\s+/, "")
42
+ .replace(/\s+/g, " ")
43
+ .trim();
44
+ }
45
+
46
+ /**
47
+ * Deterministic capsule of a long report.
48
+ *
49
+ * Sections are best-effort structural extraction; the footer always states what
50
+ * was compressed and where the original lives, so a reader can tell a capsule
51
+ * from a report.
52
+ */
53
+ export function buildCapsule(text, { maxChars = 1800, source = "subagent report" } = {}) {
54
+ const full = String(text ?? "").trim();
55
+ if (full.length === 0) return { capsule: "", sourceChars: 0, capsuleChars: 0 };
56
+ // Keep raw lines: bullet/heading detection needs their prefixes, rendering does not.
57
+ const raw = full.split("\n").filter((l) => l.trim().length > 0);
58
+ const isHeading = (l) => /^\s{0,3}#{1,6}\s+/.test(l);
59
+ const bulletOf = (l) => /^\s*(?:[-*+]|\d+[.)])\s+(.*)$/.exec(l)?.[1] ?? null;
60
+
61
+ // Conclusion: the opening prose before the first heading/bullet, bounded so a
62
+ // wall of text cannot become the "conclusion".
63
+ const conclusionLines = [];
64
+ for (const line of raw) {
65
+ if (isHeading(line) || bulletOf(line) !== null) {
66
+ if (conclusionLines.length > 0) break;
67
+ continue;
68
+ }
69
+ conclusionLines.push(cleanLine(line));
70
+ if (conclusionLines.join(" ").length > 300) break;
71
+ }
72
+ const conclusion = conclusionLines.join(" ").slice(0, 300);
73
+
74
+ const bullets = raw.map(bulletOf).filter((b) => b !== null).map(cleanLine);
75
+ const evidence = bullets.filter((b) => EVIDENCE_RE.test(b)).slice(0, 5);
76
+ const open = bullets.filter((b) => OPEN_RE.test(b)).slice(0, 3);
77
+ const next = bullets.filter((b) => NEXT_RE.test(b) && !open.includes(b)).slice(0, 3);
78
+
79
+ const parts = [];
80
+ const structured = evidence.length > 0 || open.length > 0 || next.length > 0;
81
+ if (structured) {
82
+ if (conclusion.length > 0) parts.push(`**结论**:${conclusion}`);
83
+ if (evidence.length > 0) parts.push(`**证据**:\n${evidence.map((e) => `- ${e}`).join("\n")}`);
84
+ if (open.length > 0) parts.push(`**未决**:\n${open.map((e) => `- ${e}`).join("\n")}`);
85
+ if (next.length > 0) parts.push(`**下一步**:\n${next.map((e) => `- ${e}`).join("\n")}`);
86
+ } else {
87
+ // Unstructured prose: keep both ends and mark the elision instead of
88
+ // pretending a summary happened.
89
+ const head = raw.slice(0, 2).map(cleanLine).join("\n");
90
+ const tail = raw.slice(-2).map(cleanLine).join("\n");
91
+ parts.push(`**无结构可提炼(保留首尾)**:\n${head}\n…\n${tail}`);
92
+ }
93
+
94
+ const footer = (chars) =>
95
+ `\n\n---\n胶囊:${source} ${full.length} → ${chars} 字符;全文在子代理会话日志里(可用 session 搜索回取)。`;
96
+ const room = Math.max(200, maxChars - 120);
97
+ let body = parts.join("\n\n");
98
+ if (body.length > room) body = `${body.slice(0, room).trimEnd()}…`;
99
+
100
+ let capsule = `${body}${footer(body.length)}`;
101
+ // A "compression" that is not smaller is a bug, not a summary: fall back to a
102
+ // straight head/tail cut that is guaranteed to fit.
103
+ if (capsule.length >= full.length) {
104
+ const cut = Math.max(120, Math.floor((full.length - 200) * 0.7));
105
+ const head = full.slice(0, cut).trimEnd();
106
+ const tail = full.slice(-Math.min(80, Math.max(0, full.length - cut - 1))).trim();
107
+ body = `**无结构可提炼(保留首尾)**:\n${head}…\n…${tail}`;
108
+ capsule = `${body}${footer(body.length)}`;
109
+ if (capsule.length >= full.length) capsule = full.slice(0, Math.max(80, full.length - 1));
110
+ }
111
+ return { capsule, sourceChars: full.length, capsuleChars: capsule.length };
112
+ }
113
+
114
+ /** Read the text out of a tool result (string, content blocks, or value fields). */
115
+ export function resultText(result) {
116
+ const value = result?.value;
117
+ if (typeof value === "string" && value.length > 0) return value;
118
+ if (value !== null && typeof value === "object") {
119
+ if (Array.isArray(value.content)) {
120
+ const joined = value.content
121
+ .filter((b) => b?.type === "text" && typeof b.text === "string")
122
+ .map((b) => b.text)
123
+ .join("\n");
124
+ if (joined.length > 0) return joined;
125
+ }
126
+ if (typeof value.text === "string" && value.text.length > 0) return value.text;
127
+ }
128
+ const content = result?.content;
129
+ if (Array.isArray(content)) {
130
+ const joined = content.filter((b) => b?.type === "text" && typeof b.text === "string").map((b) => b.text).join("\n");
131
+ if (joined.length > 0) return joined;
132
+ }
133
+ return null;
134
+ }
package/lib/capture.js CHANGED
@@ -22,6 +22,7 @@
22
22
 
23
23
  import { workspaceKey as workspaceKeyOf } from "./util.js";
24
24
  import { sessionSeq } from "./session-events.js";
25
+ import { buildCapsule, isSubagentTool, resultText } from "./capsule.js";
25
26
 
26
27
  const GIT_MILESTONE_SIGNAL = 0.5;
27
28
  const GIT_COMMIT_SIGNAL = 0.55;
@@ -51,7 +52,7 @@ export function cmdTag(command) {
51
52
  * error rows. Deterministic; fires only on genuine same-command
52
53
  * success-after-failure.
53
54
  */
54
- export function fixClosureEntry(name, command, result, previousErrors) {
55
+ export function fixClosureEntry(name, command, result, previousErrors, at = Date.now()) {
55
56
  if (!["bash", "shell", "exec"].includes(name)) return null;
56
57
  if (result?.isError === true) return null; // not a success
57
58
  if (typeof command !== "string" || command.trim().length === 0) return null;
@@ -71,11 +72,38 @@ export function fixClosureEntry(name, command, result, previousErrors) {
71
72
  signal: FIX_CLOSURE_SIGNAL,
72
73
  tags: ["procedure", "auto-captured", "fix", tag, ...(isTest ? ["test"] : [])],
73
74
  filePath: null,
75
+ // One success is never enough to generalize (v0.8-A): the row starts with
76
+ // a single evidence point and has to earn an index line.
77
+ evidenceCount: 1,
78
+ lastVerifiedAt: at,
74
79
  },
75
80
  resolvedIds: failing.map((m) => m.id),
76
81
  };
77
82
  }
78
83
 
84
+ /**
85
+ * v0.8-A: what should happen to an existing `fixed:` procedure when the SAME
86
+ * command runs again? Returns `{ procedureId, ok }` or null.
87
+ *
88
+ * Success → credit one evidence point; failure → invalidate immediately.
89
+ * Deterministic, zero LLM, and scoped to the workspace's active rows.
90
+ */
91
+ export function procedureOutcome(name, command, result, rows) {
92
+ if (!isCommandRun(name)) return null;
93
+ if (typeof command !== "string" || command.trim().length === 0) return null;
94
+ const tag = cmdTag(command);
95
+ if (tag === null) return null;
96
+ const procedure = (rows ?? []).find(
97
+ (m) =>
98
+ m?.kind === "procedure" &&
99
+ m.status === "active" &&
100
+ /^fixed:/.test(String(m.text ?? "")) &&
101
+ (m.tags ?? []).includes(tag),
102
+ );
103
+ if (procedure === void 0) return null;
104
+ return { procedureId: procedure.id, ok: result?.isError !== true };
105
+ }
106
+
79
107
  /** Git operations that carry lasting meaning (milestones). */
80
108
  const GIT_OPS = [
81
109
  // commit: only with an explicit -m subject — that's the semantic content.
@@ -154,9 +182,42 @@ export function makeCaptureHandler(store, config, log, stats) {
154
182
 
155
183
  const args = (exec.arguments ?? {});
156
184
  const git = extractGit(name, args.command);
185
+ // Evidence accounting applies to EVERY command run, not only the runs that
186
+ // close a failure: a procedure only keeps its index line while it keeps
187
+ // holding (v0.8-A).
188
+ const recentRows = store.listMemories !== void 0 ? store.listMemories(workspaceKeyOf(cwd), 200) : [];
189
+ const outcome = procedureOutcome(name, args.command, result, recentRows);
190
+ const workspace = workspaceKeyOf(cwd);
191
+ const applyOutcome = (errorId = null) => {
192
+ if (outcome === null) return;
193
+ void store
194
+ .recordProcedureOutcome?.(workspace, outcome.procedureId, { ok: outcome.ok, errorId })
195
+ ?.catch?.(() => null);
196
+ };
157
197
  let entry = null;
158
198
 
159
- if (git !== null) {
199
+ // v0.8-C (advisory): a subagent's report is durable knowledge worth
200
+ // keeping even though the host will not let us shrink what the parent
201
+ // reads (see lib/capsule.js). Long reports become a capsule memory.
202
+ const subagentReport = isSubagentTool(name) && result?.isError !== true ? resultText(result) : null;
203
+ const capsuleMin = config.capsuleMinSourceChars ?? 1500;
204
+ if (subagentReport !== null && subagentReport.length >= capsuleMin) {
205
+ const { capsule } = buildCapsule(subagentReport, {
206
+ maxChars: config.capsuleMaxChars ?? 1800,
207
+ source: String(args.description ?? name),
208
+ });
209
+ entry = {
210
+ kind: "handoff",
211
+ text: capsule,
212
+ signal: 0.55,
213
+ tags: ["handoff", "auto-captured", "subagent-capsule", `agent:${String(args.description ?? name).slice(0, 60)}`],
214
+ filePath: null,
215
+ };
216
+ }
217
+
218
+ if (entry !== null) {
219
+ // already drafted above
220
+ } else if (git !== null) {
160
221
  entry = { kind: "decision", text: git.text, signal: git.signal, tags: git.tags };
161
222
  } else if (result?.isError === true) {
162
223
  // Errors take precedence over tool-name classification: a failed edit
@@ -199,7 +260,13 @@ export function makeCaptureHandler(store, config, log, stats) {
199
260
  }
200
261
  }
201
262
 
202
- if (entry === null) return;
263
+ if (entry === null) {
264
+ // No new memory to store, but a command run still carries evidence for
265
+ // an existing procedure (v0.8-A): credit the success, or invalidate on
266
+ // failure. This is the common case — most runs produce no memory at all.
267
+ applyOutcome();
268
+ return;
269
+ }
203
270
  if (stats) {
204
271
  stats.total += 1;
205
272
  if (entry.kind === "decision") stats.git += 1;
@@ -209,7 +276,14 @@ export function makeCaptureHandler(store, config, log, stats) {
209
276
 
210
277
  // exact-duplicate guard across the recent window (store re-dedups by hash anyway)
211
278
  const key = `${entry.kind}|${entry.text}`;
212
- if (recent.has(key)) return;
279
+ if (recent.has(key)) {
280
+ // Exact repeat within the window: nothing new to store, but the run is
281
+ // still evidence for or against the procedure (v0.8-A). A repeated
282
+ // failure invalidates by the row it repeats, not by a phantom id.
283
+ const existing = recentRows.find((m) => m.kind === entry.kind && (m.text ?? "") === entry.text);
284
+ applyOutcome(existing?.id ?? null);
285
+ return;
286
+ }
213
287
  recent.add(key);
214
288
  if (recent.size > 500) {
215
289
  const first = recent.values().next().value;
@@ -226,17 +300,21 @@ export function makeCaptureHandler(store, config, log, stats) {
226
300
  tags: entry.tags,
227
301
  signal: entry.signal,
228
302
  filePath: entry.filePath ?? null,
303
+ // v0.8-A: a closure row carries its first evidence point at birth.
304
+ evidenceCount: entry.evidenceCount ?? 0,
305
+ lastVerifiedAt: entry.lastVerifiedAt ?? null,
229
306
  sessionId,
230
307
  seq: sessionSeq(agent.session),
231
308
  },
232
309
  config,
233
310
  )
234
- .then(() => {
311
+ .then((saved) => {
235
312
  // Failure→fix closure: mark the earlier error rows resolved (tag
236
313
  // append only — never re-ranks them, per store.tagMemory's contract).
237
314
  for (const id of resolvedIds) {
238
315
  void store.tagMemory?.(workspaceKeyOf(cwd), id, "resolved")?.catch?.(() => null);
239
316
  }
317
+ applyOutcome(saved?.id ?? null);
240
318
  })
241
319
  .catch((error) => log?.warn?.(`engram auto-capture failed: ${String(error)}`));
242
320
  } catch (error) {
package/lib/client.js CHANGED
@@ -74,6 +74,23 @@ var EngramApi = class {
74
74
  async tasks(workspace, includeStable = false) {
75
75
  return readJson(await fetch(`${API_PREFIX}/tasks${query({ workspace, includeStable: includeStable ? "1" : void 0 })}`));
76
76
  }
77
+ /**
78
+ * v0.8-D approvals: evidence-passed procedures the exporter would write, split
79
+ * into approved/pending. The server recomputes it from the same planner the
80
+ * CLI uses, so a click here can never approve something `skill export` skips.
81
+ */
82
+ async approvals(workspace) {
83
+ return readJson(await fetch(`${API_PREFIX}/approvals${query({ workspace })}`));
84
+ }
85
+ async setApproval(workspace, id, action, note) {
86
+ return readJson(
87
+ await fetch(`${API_PREFIX}/approvals`, {
88
+ method: "POST",
89
+ headers: { "content-type": "application/json" },
90
+ body: JSON.stringify({ workspace, id, action, note })
91
+ })
92
+ );
93
+ }
77
94
  /** Real tool-call rollup over recent session logs (host /toolstats). */
78
95
  async toolStats(days = 14) {
79
96
  return readJson(await fetch(`${API_PREFIX}/toolstats?days=${days}`));
@@ -3377,6 +3394,8 @@ function EngramSection({ api, t, scope }) {
3377
3394
  const [tasks, setTasks] = (0, import_react8.useState)([]);
3378
3395
  const [links, setLinks] = (0, import_react8.useState)([]);
3379
3396
  const [nodes, setNodes] = (0, import_react8.useState)([]);
3397
+ const [approvals, setApprovals] = (0, import_react8.useState)([]);
3398
+ const [approvalBusy, setApprovalBusy] = (0, import_react8.useState)(null);
3380
3399
  const [detail, setDetail] = (0, import_react8.useState)(null);
3381
3400
  const [detailNotice, setDetailNotice] = (0, import_react8.useState)(null);
3382
3401
  const [error, setError] = (0, import_react8.useState)(null);
@@ -3416,6 +3435,7 @@ function EngramSection({ api, t, scope }) {
3416
3435
  const ov = await api.overview();
3417
3436
  setOverview(ov);
3418
3437
  const wsList = workspace ? [workspace] : Object.keys(ov.workspaces);
3438
+ const approvalGroups = await Promise.all(wsList.map((w) => api.approvals(w).catch(() => ({ items: [] }))));
3419
3439
  const [mem, taskGroups2, linkGroups2, nodeGroups2] = await Promise.all([
3420
3440
  api.memories({
3421
3441
  workspace: workspace || void 0,
@@ -3431,6 +3451,7 @@ function EngramSection({ api, t, scope }) {
3431
3451
  setTasks(taskGroups2.flatMap((x) => x.items));
3432
3452
  setLinks(linkGroups2.flatMap((x) => x.items));
3433
3453
  setNodes(nodeGroups2.flatMap((x) => x.items));
3454
+ setApprovals(approvalGroups.flatMap((x) => x.items));
3434
3455
  } catch (e) {
3435
3456
  setError(e instanceof Error ? e.message : String(e));
3436
3457
  } finally {
@@ -3588,6 +3609,18 @@ function EngramSection({ api, t, scope }) {
3588
3609
  (0, import_react8.useEffect)(() => {
3589
3610
  if (memPage >= memPageCount) setMemPage(Math.max(0, memPageCount - 1));
3590
3611
  }, [memPage, memPageCount]);
3612
+ const toggleApproval = async (item) => {
3613
+ setApprovalBusy(item.id);
3614
+ setError(null);
3615
+ try {
3616
+ await api.setApproval(item.workspace ?? workspace, item.id, item.approved ? "revoke" : "approve");
3617
+ await refresh();
3618
+ } catch (e) {
3619
+ setError(e instanceof Error ? e.message : String(e));
3620
+ } finally {
3621
+ setApprovalBusy(null);
3622
+ }
3623
+ };
3591
3624
  const createNewTask = async () => {
3592
3625
  const ws = newTaskWs || workspace || workspaces[0] || "";
3593
3626
  if (!ws || newTaskName.trim() === "") return;
@@ -3678,6 +3711,39 @@ function EngramSection({ api, t, scope }) {
3678
3711
  ] }),
3679
3712
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { display: "flex", gap: 12, alignItems: "flex-start" }, children: [
3680
3713
  /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { flex: "1 1 auto", minWidth: 0 }, children: [
3714
+ view === "mem" && approvals.length > 0 && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { ...s3.card, marginBottom: 12, borderColor: "#f0c36d" }, children: [
3715
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { fontWeight: 700, fontSize: 13, marginBottom: 6 }, children: [
3716
+ "Playbook \u5F85\u6279\u51C6\uFF08",
3717
+ approvals.filter((a) => !a.approved).length,
3718
+ " \u6761 / \u5171 ",
3719
+ approvals.length,
3720
+ "\uFF09"
3721
+ ] }),
3722
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { fontSize: 12, opacity: 0.75, marginBottom: 8 }, children: [
3723
+ "\u8BC1\u636E\u5DF2\u8FC7\u5173\uFF08\u22653 \u6B21\u9A8C\u8BC1\u300114 \u5929\u5185\uFF09\u3002\u5199\u6587\u4EF6\u7ED9\u5BBF\u4E3B\u5E7F\u544A\u9700\u8981\u4F60\u70B9\u5934 \u2014\u2014 \u8BC1\u636E\u8BC1\u660E\u547D\u4EE4\u8DD1\u901A\u8FC7\uFF0C\u4E0D\u8BC1\u660E\u6307\u4EE4\u662F\u5BF9\u7684\u3002 \u6279\u51C6\u540E\uFF1A",
3724
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("code", { children: "npx dsh-engram skill export" }),
3725
+ "\u3002"
3726
+ ] }),
3727
+ approvals.map((item) => /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("div", { style: { display: "flex", gap: 8, alignItems: "center", padding: "4px 0", fontSize: 12 }, children: [
3728
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("code", { style: { fontWeight: 600 }, children: item.name }),
3729
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)("span", { style: { opacity: 0.7 }, children: [
3730
+ item.id.slice(0, 8),
3731
+ " \xB7 \u8BC1\u636E ",
3732
+ item.evidence,
3733
+ " \u6B21"
3734
+ ] }),
3735
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)("span", { style: { opacity: 0.7, flex: "1 1 auto", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }, children: item.description }),
3736
+ /* @__PURE__ */ (0, import_jsx_runtime8.jsx)(
3737
+ "button",
3738
+ {
3739
+ style: s3.btn,
3740
+ disabled: approvalBusy === item.id,
3741
+ onClick: () => void toggleApproval(item),
3742
+ children: item.approved ? "\u64A4\u9500" : "\u6279\u51C6"
3743
+ }
3744
+ )
3745
+ ] }, item.id))
3746
+ ] }),
3681
3747
  view === "mem" && /* @__PURE__ */ (0, import_jsx_runtime8.jsxs)(import_jsx_runtime8.Fragment, { children: [
3682
3748
  overview && (() => {
3683
3749
  const memNum = wsCounts ? wsCounts.memories : overview.totals.memories;
package/lib/compaction.js CHANGED
@@ -62,17 +62,10 @@ export function apply(ctx, config = {}) {
62
62
  if (!domain) return null;
63
63
  return collectPointerContext(domain, wk(workspace), report);
64
64
  },
65
- // Context Ledger bridge: this row runs inside the agent preset's
66
- // isolate realm, where the host plugin's ledger closure is not
67
- // reachable. The host plugin provides `engramLedger` as a plain
68
- // (non-isolated) service, so compaction savings are credited to the
69
- // one durable ledger instead of vanishing with the realm.
70
- onCompacted: ({ session, workspace, charsSaved }) => {
71
- try {
72
- const hub = ctx.get("engramLedger");
73
- hub?.noteCompaction?.(session?.id, workspace, { charsSaved });
74
- } catch { /* contained: accounting never breaks compaction */ }
75
- },
65
+ // No ledger bridge here: crediting inside `summarize()` claims a
66
+ // saving before the harness decides whether it commits the
67
+ // checkpoint (a rejected/overridden summary saves nothing). The host
68
+ // plugin credits from the committed `compaction/summary` event.
76
69
  }
77
70
  : {};
78
71