dsh-engram 0.7.4 → 0.8.7
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 +100 -6
- package/README.zh.md +7 -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/docs/RELEASE-NOTES-v0.8.7.md +69 -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 +105 -18
- package/lib/ledger.js +200 -10
- package/lib/pack-telemetry.js +10 -3
- package/lib/prune.js +89 -0
- package/lib/push-legs.js +121 -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 +247 -5
|
@@ -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
|
+
}
|
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/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
|
|
403
|
-
if (
|
|
404
|
-
results.set(
|
|
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
|