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.
- package/README.md +93 -6
- package/README.zh.md +6 -2
- package/docs/CONTEXT-GC-GUIDE.zh.md +15 -1
- package/docs/G6-quality-ab.zh.md +540 -0
- package/docs/GATES.zh.md +41 -0
- package/docs/PROPOSAL-evolution.zh.md +12 -0
- package/docs/RELEASE-NOTES-v0.7.10.md +49 -0
- package/docs/RELEASE-NOTES-v0.7.11.md +31 -0
- package/docs/RELEASE-NOTES-v0.7.12.md +22 -0
- package/docs/RELEASE-NOTES-v0.7.13.md +41 -0
- package/docs/RELEASE-NOTES-v0.7.14.md +37 -0
- package/docs/RELEASE-NOTES-v0.7.15.md +36 -0
- package/docs/RELEASE-NOTES-v0.7.16.md +42 -0
- package/docs/RELEASE-NOTES-v0.7.5.md +134 -0
- package/docs/RELEASE-NOTES-v0.7.6.md +74 -0
- package/docs/RELEASE-NOTES-v0.7.7.md +60 -0
- package/docs/RELEASE-NOTES-v0.7.8.md +56 -0
- package/docs/RELEASE-NOTES-v0.7.9.md +46 -0
- package/docs/RELEASE-NOTES-v0.8.0.md +59 -0
- package/docs/RELEASE-NOTES-v0.8.1.md +70 -0
- package/docs/RELEASE-NOTES-v0.8.2.md +62 -0
- package/docs/RELEASE-NOTES-v0.8.3.md +65 -0
- package/docs/RELEASE-NOTES-v0.8.4.md +56 -0
- package/docs/RELEASE-NOTES-v0.8.5.md +60 -0
- package/docs/RELEASE-NOTES-v0.8.6.md +71 -0
- package/eval/gate-audit.mjs +538 -0
- package/lib/api.js +70 -0
- package/lib/approvals.js +84 -0
- package/lib/boundary-prune.js +71 -5
- package/lib/capsule.js +134 -0
- package/lib/capture.js +83 -5
- package/lib/client.js +66 -0
- package/lib/compaction.js +4 -11
- package/lib/context-gc.js +63 -2
- package/lib/index-block.js +79 -4
- package/lib/index.js +91 -18
- package/lib/ledger.js +200 -10
- package/lib/pack-telemetry.js +10 -3
- package/lib/prune.js +89 -0
- package/lib/session-events.js +44 -2
- package/lib/skill-export.js +289 -0
- package/lib/status-insights.js +5 -0
- package/lib/status.js +138 -0
- package/lib/store.js +34 -0
- package/lib/usage.js +32 -3
- package/lib/web-provision.js +59 -14
- package/package.json +15 -8
- package/scripts/dsh-engram.mjs +229 -4
package/lib/approvals.js
ADDED
|
@@ -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
|
+
}
|
package/lib/boundary-prune.js
CHANGED
|
@@ -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(
|
|
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
|
|
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)
|
|
277
|
-
|
|
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
|
-
|
|
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)
|
|
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))
|
|
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
|
-
//
|
|
66
|
-
//
|
|
67
|
-
//
|
|
68
|
-
//
|
|
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
|
|