dsh-engram 0.7.2 → 0.8.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +104 -7
- package/README.zh.md +13 -3
- 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.3.md +30 -0
- package/docs/RELEASE-NOTES-v0.7.4.md +71 -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 +85 -6
- package/lib/client.js +1813 -1745
- package/lib/compaction.js +4 -11
- package/lib/context-gc.js +63 -2
- package/lib/episode.js +10 -5
- package/lib/index-block.js +89 -8
- package/lib/index.js +108 -21
- package/lib/ledger.js +200 -10
- package/lib/pack-telemetry.js +10 -3
- package/lib/preference.js +2 -1
- package/lib/prune.js +89 -0
- package/lib/session-events.js +168 -0
- 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/todo-sink.js +3 -2
- package/lib/tools.js +2 -1
- 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
|
@@ -0,0 +1,538 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Gate audit — turn docs/PROPOSAL-evolution.zh.md §6 into numbers you can read
|
|
4
|
+
* off the machine instead of remembering.
|
|
5
|
+
*
|
|
6
|
+
* WHY (v0.7.5): the proposal pre-declared six gates, but nothing computed them.
|
|
7
|
+
* The ledger aggregated per session into a daily rollup, so the first gate
|
|
8
|
+
* ("净收益 > 0 的会话 ≥ 50%") was arithmetically unanswerable. Two failure
|
|
9
|
+
* modes cost us a week each already (session-log rename #8, `session.events`
|
|
10
|
+
* removal in 0.2.x): both were invisible because nobody read the number that
|
|
11
|
+
* would have been zero.
|
|
12
|
+
*
|
|
13
|
+
* Everything here is a READ over local data — no model, no network (unless you
|
|
14
|
+
* pass `--api` to pull the exact rendered block size from a running server):
|
|
15
|
+
*
|
|
16
|
+
* ~/.dsh/engram/ledger.json per-session net rows
|
|
17
|
+
* ~/.dsh/sessions/**\/session*.zstd tool calls, injected headers
|
|
18
|
+
* ~/.dsh/storages/dsh_engram.json memory rows (stale share)
|
|
19
|
+
*
|
|
20
|
+
* Usage:
|
|
21
|
+
* node eval/gate-audit.mjs # last 14 days, this machine
|
|
22
|
+
* node eval/gate-audit.mjs --days 30 --md # markdown table
|
|
23
|
+
* node eval/gate-audit.mjs --api http://127.0.0.1:3080/api/dsh-engram
|
|
24
|
+
* node eval/gate-audit.mjs --out docs/GATES.zh.md --md
|
|
25
|
+
*
|
|
26
|
+
* Verdicts are `PASS` / `FAIL` / `未测` — never a guess. A gate with no data
|
|
27
|
+
* says so; that is the whole point (「结果态优先」).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import fs from "node:fs";
|
|
31
|
+
import os from "node:os";
|
|
32
|
+
import path from "node:path";
|
|
33
|
+
|
|
34
|
+
import { decodeZstd, readToolResult, recallStatsFromOutput, sessionLogFile, sessionsRoot } from "../lib/usage.js";
|
|
35
|
+
import { readLedger } from "../lib/ledger.js";
|
|
36
|
+
|
|
37
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
38
|
+
const LONG_SESSION_REQUESTS = 200;
|
|
39
|
+
/** Below this many rows a share is noise: one short session must not paint the
|
|
40
|
+
* gate FAIL (or PASS). The gate says 未测 until there is a distribution. */
|
|
41
|
+
const MIN_SESSION_ROWS = 5;
|
|
42
|
+
const MIN_LONG_ROWS = 3; // §6: 长会话 = ≥200 请求
|
|
43
|
+
const ENGRAM_TOOL_RE = /^(engram_|esr_)/;
|
|
44
|
+
|
|
45
|
+
function parseArgs(argv) {
|
|
46
|
+
const out = { days: 14, md: false, out: null, api: null, home: null, g7: null, g6: null };
|
|
47
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
48
|
+
const arg = argv[i];
|
|
49
|
+
if (arg === "--days") out.days = Number(argv[++i]) || 14;
|
|
50
|
+
else if (arg === "--md") out.md = true;
|
|
51
|
+
else if (arg === "--out") out.out = argv[++i] ?? null;
|
|
52
|
+
else if (arg === "--api") out.api = (argv[++i] ?? "").replace(/\/+$/, "") || null;
|
|
53
|
+
// `npm run bench:ledger` prints the worst per-operation share; feed it back
|
|
54
|
+
// so the gate is a measurement, not a "未测" placeholder.
|
|
55
|
+
else if (arg === "--g7") out.g7 = Number(argv[++i]);
|
|
56
|
+
else if (arg === "--g6") out.g6 = argv[++i] ?? null;
|
|
57
|
+
else if (arg === "--home") out.home = argv[++i] ?? null;
|
|
58
|
+
}
|
|
59
|
+
return out;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function listDir(dir) {
|
|
63
|
+
try {
|
|
64
|
+
return fs.readdirSync(dir, { withFileTypes: true }).filter((e) => e.isDirectory()).map((e) => e.name);
|
|
65
|
+
} catch {
|
|
66
|
+
return [];
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** Facts we can read from one session log (bounded: one pass, no retention). */
|
|
71
|
+
function scanSession(file) {
|
|
72
|
+
const raw = decodeZstd(file);
|
|
73
|
+
if (typeof raw !== "string" || raw.length === 0) return null;
|
|
74
|
+
const recallCallIds = new Set();
|
|
75
|
+
const facts = {
|
|
76
|
+
workspace: "",
|
|
77
|
+
tools: new Map(),
|
|
78
|
+
recallQueries: 0,
|
|
79
|
+
recallWithHits: 0,
|
|
80
|
+
detailCalls: 0,
|
|
81
|
+
headers: 0,
|
|
82
|
+
pushSeen: false,
|
|
83
|
+
};
|
|
84
|
+
for (const line of raw.split("\n")) {
|
|
85
|
+
if (line.length === 0) continue;
|
|
86
|
+
let event;
|
|
87
|
+
try {
|
|
88
|
+
event = JSON.parse(line);
|
|
89
|
+
} catch {
|
|
90
|
+
continue;
|
|
91
|
+
}
|
|
92
|
+
if (event?.type === "session") {
|
|
93
|
+
if (typeof event.cwd === "string" && event.cwd.length > 0) facts.workspace = event.cwd;
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (event?.type === "tool/call") {
|
|
97
|
+
const name = event.data?.name;
|
|
98
|
+
if (typeof name !== "string" || name.length === 0) continue;
|
|
99
|
+
facts.tools.set(name, (facts.tools.get(name) ?? 0) + 1);
|
|
100
|
+
if (name === "engram_detail") facts.detailCalls += 1;
|
|
101
|
+
const callId = event.data?.callId;
|
|
102
|
+
if (name === "engram_recall" && typeof callId === "string") recallCallIds.add(callId);
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
if (event?.type === "tool/result") {
|
|
106
|
+
const parsed = readToolResult(event);
|
|
107
|
+
if (parsed === null || typeof parsed.callId !== "string") continue;
|
|
108
|
+
// Only recall results count: the tally is call-correlated, so a text
|
|
109
|
+
// match on "- " alone is not evidence of a hit.
|
|
110
|
+
if (!recallCallIds.has(parsed.callId)) continue;
|
|
111
|
+
facts.recallQueries += 1;
|
|
112
|
+
if (recallStatsFromOutput(parsed.text).withHits > 0) facts.recallWithHits += 1;
|
|
113
|
+
continue;
|
|
114
|
+
}
|
|
115
|
+
// The push leg's own signature: the block only renders when the host-side
|
|
116
|
+
// recall produced hits. Where it lands depends on the host generation:
|
|
117
|
+
// · request/header — the assembled system prompt for each request;
|
|
118
|
+
// · system/message — the section re-sent after a compaction / new turn.
|
|
119
|
+
// Both are prompt-side, so assistant text quoting the block never counts.
|
|
120
|
+
if (event?.type === "request/header" || event?.type === "system/message") {
|
|
121
|
+
if (event.type === "request/header") facts.headers += 1;
|
|
122
|
+
if (!facts.pushSeen && PUSH_RE.test(JSON.stringify(event.data ?? ""))) facts.pushSeen = true;
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
return facts;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/**
|
|
129
|
+
* Which savings paths are actually armed? NetTokens has two sides and the
|
|
130
|
+
* credit side is wired to services that may not exist on a given host:
|
|
131
|
+
* · L1 BoundaryPrune needs a `toolResultPruner` service;
|
|
132
|
+
* · compaction credit needs engram to BE the compaction provider.
|
|
133
|
+
* When neither is armed nothing can ever be credited, so `net` can only be
|
|
134
|
+
* ≤ 0 — a FAIL there blames the mechanism for a configuration, and a PASS is
|
|
135
|
+
* impossible. Report it as unmeasurable instead of guessing.
|
|
136
|
+
*/
|
|
137
|
+
function readCreditPaths(engramHome, live = void 0) {
|
|
138
|
+
// The live instance is authoritative: the on-disk snapshot is written by
|
|
139
|
+
// whichever plugin instance settles LAST, and a sibling instance that lost
|
|
140
|
+
// the `compaction` provider writes "unavailable" over a working host. Prefer
|
|
141
|
+
// the running plugin's own view when the caller passed --api.
|
|
142
|
+
if (live?.compaction !== void 0) {
|
|
143
|
+
const c = live.compaction;
|
|
144
|
+
const presets = (c?.web?.presets ?? []).filter((p) => p?.action !== "skipped");
|
|
145
|
+
const prune = c?.boundaryPrune?.armed === true;
|
|
146
|
+
const hostCompaction = c?.host === "context-gc" || c?.host === "default";
|
|
147
|
+
const webCompaction = (c?.web?.action === "wired" || c?.web?.action === "already") && presets.length > 0;
|
|
148
|
+
return {
|
|
149
|
+
known: true,
|
|
150
|
+
armed: prune || hostCompaction || webCompaction,
|
|
151
|
+
note: `live · L1 ${prune ? "armed" : "off"} · host compaction ${c?.host ?? "?"} · web ${c?.web?.action ?? "?"}`,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
const file = path.join(engramHome ?? path.join(os.homedir(), ".dsh", "engram"), "context-gc.status.json");
|
|
155
|
+
let status;
|
|
156
|
+
try {
|
|
157
|
+
status = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
158
|
+
} catch {
|
|
159
|
+
return { known: false, armed: false, note: "no context-gc.status.json — credit paths unknown" };
|
|
160
|
+
}
|
|
161
|
+
const prune = status?.boundaryPrune?.armed === true;
|
|
162
|
+
const hostCompaction = status?.host === "context-gc" || status?.host === "default";
|
|
163
|
+
// "wired" only counts when it names the presets it rewrote: a report with no
|
|
164
|
+
// presets changed nothing, and a preset row that fails to load looks exactly
|
|
165
|
+
// like one that works until a compaction is attempted (see the attempt log).
|
|
166
|
+
// "already" = the row was in place from an earlier provisioning pass; only a
|
|
167
|
+
// preset with a custom layout is skipped, and those are listed separately.
|
|
168
|
+
const webAction = status?.web?.action;
|
|
169
|
+
const webPresets = (status?.web?.presets ?? []).filter((p) => p?.action !== "skipped");
|
|
170
|
+
const webCompaction = (webAction === "wired" || webAction === "already") && webPresets.length > 0;
|
|
171
|
+
const armed = prune || hostCompaction || webCompaction;
|
|
172
|
+
const parts = [
|
|
173
|
+
`L1 ${prune ? "armed" : "off"}`,
|
|
174
|
+
`host compaction ${status?.host ?? "?"}`,
|
|
175
|
+
`web ${status?.web?.action ?? "?"}`,
|
|
176
|
+
];
|
|
177
|
+
return { known: true, armed, note: parts.join(" · ") };
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function scanSessions({ root = sessionsRoot(), days = 14, now = Date.now() } = {}) {
|
|
181
|
+
const cutoff = now - days * DAY_MS;
|
|
182
|
+
const sessions = [];
|
|
183
|
+
for (const bucket of listDir(root)) {
|
|
184
|
+
if (!bucket.startsWith("--")) continue;
|
|
185
|
+
const bdir = path.join(root, bucket);
|
|
186
|
+
for (const sid of listDir(bdir)) {
|
|
187
|
+
const file = sessionLogFile(path.join(bdir, sid));
|
|
188
|
+
let st;
|
|
189
|
+
try {
|
|
190
|
+
st = fs.statSync(file);
|
|
191
|
+
} catch {
|
|
192
|
+
continue;
|
|
193
|
+
}
|
|
194
|
+
if (st.mtimeMs < cutoff) continue;
|
|
195
|
+
const facts = scanSession(file);
|
|
196
|
+
if (facts === null) continue;
|
|
197
|
+
sessions.push({ sessionId: sid, dir: path.join(bdir, sid), mtime: st.mtimeMs, ...facts });
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
return sessions.sort((a, b) => b.mtime - a.mtime);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/** Stale share over the memory table (read-only; the store stays the truth). */
|
|
204
|
+
function readMemoryShare(dshHome) {
|
|
205
|
+
const candidates = [
|
|
206
|
+
path.join(dshHome, "storages", "dsh_engram.json"),
|
|
207
|
+
path.join(dshHome, ".dsh", "storages", "dsh_engram.json"),
|
|
208
|
+
];
|
|
209
|
+
for (const file of candidates) {
|
|
210
|
+
let parsed;
|
|
211
|
+
try {
|
|
212
|
+
parsed = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
213
|
+
} catch {
|
|
214
|
+
continue;
|
|
215
|
+
}
|
|
216
|
+
const table = parsed?.tables?.memories;
|
|
217
|
+
if (table === null || table === void 0 || typeof table !== "object") continue;
|
|
218
|
+
const rows = Object.values(table).filter((row) => row !== null && typeof row === "object" && row.kind !== "episode");
|
|
219
|
+
const stale = rows.filter((row) => Array.isArray(row.tags) && row.tags.includes("stale"));
|
|
220
|
+
const superseded = rows.filter((row) => typeof row.supersededBy === "string" && row.supersededBy.length > 0);
|
|
221
|
+
return { file, total: rows.length, stale: stale.length, superseded: superseded.length };
|
|
222
|
+
}
|
|
223
|
+
return null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function pct(part, whole) {
|
|
227
|
+
if (!Number.isFinite(whole) || whole <= 0) return null;
|
|
228
|
+
return (part / whole) * 100;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function fmtPct(value) {
|
|
232
|
+
return value === null ? "未测" : `${value.toFixed(1)}%`;
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
function fmtNum(value) {
|
|
236
|
+
if (!Number.isFinite(value)) return "—";
|
|
237
|
+
return Math.round(value).toLocaleString("en-US");
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function verdict(ok) {
|
|
241
|
+
return ok === null ? "未测" : ok ? "PASS" : "FAIL";
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/** The §6 gate table, computed from whatever data actually exists. */
|
|
245
|
+
/**
|
|
246
|
+
* Read a G6 artifact (`quality:ab run` → runs.json).
|
|
247
|
+
*
|
|
248
|
+
* The gate is only as honest as its input: an unparsable file is reported as
|
|
249
|
+
* absent, never guessed. A run below the pair floor carries its own `未测`
|
|
250
|
+
* verdict from the harness, so the floor rule lives in one place (the tool that
|
|
251
|
+
* produced the number).
|
|
252
|
+
*/
|
|
253
|
+
function readG6(file) {
|
|
254
|
+
if (file === null || file === undefined) return null;
|
|
255
|
+
try {
|
|
256
|
+
const report = JSON.parse(fs.readFileSync(file, "utf8"));
|
|
257
|
+
if (report === null || typeof report !== "object") return null;
|
|
258
|
+
return { ...report, source: path.relative(process.cwd(), file) };
|
|
259
|
+
} catch {
|
|
260
|
+
return null;
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function auditGates({ days = 14, now = Date.now(), home = void 0, api = null, root = void 0, live = void 0, g7 = null, g6 = null } = {}) {
|
|
265
|
+
const engramHome = home ?? void 0;
|
|
266
|
+
const ledger = readLedger({ home: engramHome });
|
|
267
|
+
const sessions = scanSessions({ root: root ?? sessionsRoot(), days, now });
|
|
268
|
+
const dshHome = home !== void 0 ? home : (process.env.DSH_HOME ?? path.join(os.homedir(), ".dsh"));
|
|
269
|
+
const store = readMemoryShare(dshHome);
|
|
270
|
+
// The gate's credit side: without an armed savings path, `avoided` is 0 by
|
|
271
|
+
// construction and NetTokens measures only what engram costs.
|
|
272
|
+
const credit = readCreditPaths(engramHome, live);
|
|
273
|
+
|
|
274
|
+
// ── G1 NetTokens: per-session distribution (rows only; no rows = no claim)
|
|
275
|
+
const rows = ledger.sessions.filter((row) => row.requests > 0);
|
|
276
|
+
// G1's denominator is the sessions that HAD something to save. A session
|
|
277
|
+
// with no prune and no compaction can only be net-negative (it pays injection
|
|
278
|
+
// and credits nothing), so counting it measures "did the mechanism run here",
|
|
279
|
+
// not "did it pay off". 14 days on this machine: 21 of 33 mounted sessions
|
|
280
|
+
// had a saving opportunity — the other 12 are structurally negative.
|
|
281
|
+
const opportunityRows = rows.filter((row) => (row.prunedChars ?? 0) + (row.compactedChars ?? 0) > 0);
|
|
282
|
+
const positive = opportunityRows.filter((row) => row.netChars > 0);
|
|
283
|
+
const longRows = opportunityRows.filter((row) => row.requests >= LONG_SESSION_REQUESTS);
|
|
284
|
+
const longPositive = longRows.filter((row) => row.netChars > 0);
|
|
285
|
+
const netShare = pct(positive.length, opportunityRows.length);
|
|
286
|
+
const longShare = pct(longPositive.length, longRows.length);
|
|
287
|
+
|
|
288
|
+
// ── G2 Activation: mounted sessions where any engram/esr tool fired
|
|
289
|
+
const mounted = sessions.filter((s) => s.tools.size > 0);
|
|
290
|
+
const activated = mounted.filter((s) => [...s.tools.keys()].some((name) => ENGRAM_TOOL_RE.test(name)));
|
|
291
|
+
const pushFired = mounted.filter((s) => s.pushSeen);
|
|
292
|
+
const activation = pct(activated.length, mounted.length);
|
|
293
|
+
const pushShare = pct(pushFired.length, mounted.length);
|
|
294
|
+
|
|
295
|
+
// ── G3 DrillRate: detail calls per recall-with-hits
|
|
296
|
+
const recallWithHits = sessions.reduce((sum, s) => sum + s.recallWithHits, 0);
|
|
297
|
+
const detailCalls = sessions.reduce((sum, s) => sum + s.detailCalls, 0);
|
|
298
|
+
const drillRate = recallWithHits > 0 ? detailCalls / recallWithHits : null;
|
|
299
|
+
|
|
300
|
+
// ── G4 StaleRate: superseded rows still present (index exclusion is the gate)
|
|
301
|
+
const staleRate = store === null ? null : pct(store.superseded, store.total);
|
|
302
|
+
|
|
303
|
+
return {
|
|
304
|
+
auditedAt: now,
|
|
305
|
+
window: { days, now, sessions: sessions.length, mounted: mounted.length },
|
|
306
|
+
ledger: {
|
|
307
|
+
rows: rows.length,
|
|
308
|
+
opportunityRows: opportunityRows.length,
|
|
309
|
+
totals: ledger.totals,
|
|
310
|
+
positive: positive.length,
|
|
311
|
+
longRows: longRows.length,
|
|
312
|
+
},
|
|
313
|
+
activation: {
|
|
314
|
+
activated: activated.length,
|
|
315
|
+
share: activation,
|
|
316
|
+
pushFired: pushFired.length,
|
|
317
|
+
pushShare,
|
|
318
|
+
},
|
|
319
|
+
drill: { recallWithHits, detailCalls, rate: drillRate },
|
|
320
|
+
store,
|
|
321
|
+
staleRate,
|
|
322
|
+
api,
|
|
323
|
+
gates: [
|
|
324
|
+
{
|
|
325
|
+
id: "G1",
|
|
326
|
+
name: "NetTokens · 净收益 > 0 的会话占比(有节省机会的会话)",
|
|
327
|
+
value: fmtPct(netShare),
|
|
328
|
+
target: "≥ 50%",
|
|
329
|
+
verdict:
|
|
330
|
+
!credit.armed || opportunityRows.length < MIN_SESSION_ROWS ? "未测" : verdict(netShare >= 50),
|
|
331
|
+
note: !credit.armed
|
|
332
|
+
? `${positive.length}/${opportunityRows.length} opportunity rows, but no savings path is armed (${credit.note}) — net can only be ≤ 0`
|
|
333
|
+
: opportunityRows.length < MIN_SESSION_ROWS
|
|
334
|
+
? `${positive.length}/${opportunityRows.length} opportunity rows of ${rows.length} — need ≥ ${MIN_SESSION_ROWS} for a distribution`
|
|
335
|
+
: `${positive.length}/${opportunityRows.length} sessions with a saving opportunity (${rows.length} rows total)`,
|
|
336
|
+
},
|
|
337
|
+
{
|
|
338
|
+
id: "G1b",
|
|
339
|
+
name: "NetTokens · 长会话(≥200 请求)占比",
|
|
340
|
+
value: fmtPct(longShare),
|
|
341
|
+
target: "≥ 70%",
|
|
342
|
+
verdict:
|
|
343
|
+
!credit.armed || longRows.length < MIN_LONG_ROWS ? "未测" : verdict(longShare >= 70),
|
|
344
|
+
note: `${longPositive.length}/${longRows.length} long opportunity sessions`,
|
|
345
|
+
},
|
|
346
|
+
{
|
|
347
|
+
id: "G2",
|
|
348
|
+
name: "Activation · 机制激活率(挂载会话 fire ≥1 次)",
|
|
349
|
+
value: fmtPct(activation),
|
|
350
|
+
target: "≥ 60%",
|
|
351
|
+
verdict: mounted.length === 0 ? "未测" : verdict(activation >= 60),
|
|
352
|
+
note: `${activated.length}/${mounted.length} mounted sessions`,
|
|
353
|
+
},
|
|
354
|
+
{
|
|
355
|
+
id: "G2b",
|
|
356
|
+
name: "Push · [RECALL] 注入命中会话占比",
|
|
357
|
+
value: fmtPct(pushShare),
|
|
358
|
+
target: "观测项(无预声明门)",
|
|
359
|
+
verdict: mounted.length === 0 ? "未测" : "—",
|
|
360
|
+
note: `${pushFired.length}/${mounted.length} sessions carry a [RECALL] block in their prompt`,
|
|
361
|
+
},
|
|
362
|
+
{
|
|
363
|
+
id: "G3",
|
|
364
|
+
name: "DrillRate · detail / recall-with-hits",
|
|
365
|
+
value: drillRate === null ? "未测" : drillRate.toFixed(2),
|
|
366
|
+
target: "观测项(推送是否被采纳)",
|
|
367
|
+
verdict: drillRate === null ? "未测" : "—",
|
|
368
|
+
note: `${detailCalls} detail / ${recallWithHits} recall-with-hits`,
|
|
369
|
+
},
|
|
370
|
+
{
|
|
371
|
+
id: "G4",
|
|
372
|
+
name: "StaleRate · 被 supersede 的行占比",
|
|
373
|
+
value: fmtPct(staleRate),
|
|
374
|
+
target: "< 1%",
|
|
375
|
+
verdict: staleRate === null ? "未测" : verdict(staleRate < 1),
|
|
376
|
+
note: store === null ? "no storage file" : `${store.superseded}/${store.total} memories (${store.stale} tagged stale)`,
|
|
377
|
+
},
|
|
378
|
+
{
|
|
379
|
+
id: "G5",
|
|
380
|
+
name: "索引 ≤ 700 chars",
|
|
381
|
+
value: "未测",
|
|
382
|
+
target: "≤ 700c",
|
|
383
|
+
verdict: "未测",
|
|
384
|
+
note: api === null ? "pass --api <base>?workspace=<ws> to read the rendered block size" : `via ${api}/preview`,
|
|
385
|
+
},
|
|
386
|
+
{
|
|
387
|
+
id: "G6",
|
|
388
|
+
name: "Quality Δ · 配对任务成功率差",
|
|
389
|
+
// G6 cannot be derived from logs: it needs the paired A/B artifact. Read
|
|
390
|
+
// it from `quality:ab run`'s runs.json — never from a hand-typed number.
|
|
391
|
+
value: g6 === null || g6.deltaPp === null || g6.deltaPp === undefined ? "未测" : `${g6.deltaPp.toFixed(1)}pp`,
|
|
392
|
+
target: g6?.gate?.target ?? "Δ ≥ −2pp",
|
|
393
|
+
verdict: g6 === null ? "未测" : (g6.gate?.verdict ?? "未测"),
|
|
394
|
+
note: g6 === null
|
|
395
|
+
? "needs paired A/B runs (not derivable from logs) — pass --g6 <runs.json>"
|
|
396
|
+
: `${g6.paired?.pairs ?? 0} 对配对 · ${g6.tasks?.length ?? 0} 任务 × ${g6.seeds ?? 1} 种子 · ${g6.source ?? ""}`.trim(),
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
id: "G7",
|
|
400
|
+
name: "账本自身开销",
|
|
401
|
+
value: Number.isFinite(g7) ? `${g7.toFixed(4)}%` : "未测",
|
|
402
|
+
target: "< 0.5% 请求时延",
|
|
403
|
+
verdict: Number.isFinite(g7) ? (g7 < 0.5 ? "PASS" : "FAIL") : "未测",
|
|
404
|
+
note: Number.isFinite(g7)
|
|
405
|
+
? "from `npm run bench:ledger` (worst op vs a 300ms request floor)"
|
|
406
|
+
: "run `npm run bench:ledger` and pass --g7 <pct>",
|
|
407
|
+
},
|
|
408
|
+
],
|
|
409
|
+
};
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
/**
|
|
413
|
+
* Fill G5 from the running host: the rendered [ENGRAM] block size is a
|
|
414
|
+
* render-time fact, not something the disk can answer. Offline → 未测.
|
|
415
|
+
*/
|
|
416
|
+
/** `[RECALL] recall · 3 hit(s)` / `[RECALL] newest memory · 3 hit(s)` — the
|
|
417
|
+
* rendered block, never the static `drill: use [RECALL] below` instruction. */
|
|
418
|
+
const PUSH_RE = /\[RECALL\] (?:recall|newest memory) · \d+ hit\(s\)/;
|
|
419
|
+
|
|
420
|
+
/** The running plugin's own compaction view (authoritative over the file). */
|
|
421
|
+
async function fetchLiveCompaction(api) {
|
|
422
|
+
if (api === null || api === void 0) return void 0;
|
|
423
|
+
const [base, query = ""] = String(api).split("?");
|
|
424
|
+
const workspace = new URLSearchParams(query).get("workspace") ?? process.cwd();
|
|
425
|
+
try {
|
|
426
|
+
const response = await fetch(`${base}/overview?workspace=${encodeURIComponent(workspace)}`);
|
|
427
|
+
if (!response.ok) return void 0;
|
|
428
|
+
const body = await response.json();
|
|
429
|
+
return body?.compaction === void 0 ? void 0 : { compaction: body.compaction };
|
|
430
|
+
} catch {
|
|
431
|
+
return void 0;
|
|
432
|
+
}
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
async function fillIndexGate(report) {
|
|
436
|
+
const gate = report.gates.find((entry) => entry.id === "G5");
|
|
437
|
+
if (gate === void 0 || report.api === null || report.api === void 0) return;
|
|
438
|
+
const [base, query = ""] = String(report.api).split("?");
|
|
439
|
+
const workspace = new URLSearchParams(query).get("workspace") ?? process.cwd();
|
|
440
|
+
const url = `${base}/preview?workspace=${encodeURIComponent(workspace)}`;
|
|
441
|
+
try {
|
|
442
|
+
const response = await fetch(url);
|
|
443
|
+
if (!response.ok) throw new Error(`HTTP ${response.status}`);
|
|
444
|
+
const body = await response.json();
|
|
445
|
+
const chars = body?.meta?.engram?.chars;
|
|
446
|
+
if (!Number.isFinite(chars)) throw new Error("no meta.engram.chars");
|
|
447
|
+
gate.value = `${chars}c`;
|
|
448
|
+
if (chars === 0) {
|
|
449
|
+
// An empty block trivially fits the budget — that is not the gate talking.
|
|
450
|
+
gate.verdict = "未测";
|
|
451
|
+
gate.note = `${workspace} · empty [ENGRAM] block (no memories for this workspace) — pass ?workspace=<repo>`;
|
|
452
|
+
} else {
|
|
453
|
+
gate.verdict = chars <= 700 ? "PASS" : "FAIL";
|
|
454
|
+
gate.note = `${workspace} · ${body?.meta?.counts?.memories ?? "?"} memories · ${body?.meta?.engram?.lines ?? "?"} lines`;
|
|
455
|
+
}
|
|
456
|
+
} catch (error) {
|
|
457
|
+
gate.note = `api unreachable (${String(error.message)}) — offline`;
|
|
458
|
+
}
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
/** Newest per-session rows, for eyeballing what the gates averaged over. */
|
|
462
|
+
function renderRows(ledger, limit = 10) {
|
|
463
|
+
const rows = ledger.sessions.filter((row) => row.requests > 0).slice(0, limit);
|
|
464
|
+
if (rows.length === 0) return "_no per-session rows yet — restart the host so v0.7.5 starts writing them_";
|
|
465
|
+
const lines = ["| session | requests | injected | avoided | net | workspace |", "|---|---|---|---|---|---|"];
|
|
466
|
+
for (const row of rows) {
|
|
467
|
+
lines.push(
|
|
468
|
+
`| ${row.sessionId.slice(0, 14)}… | ${fmtNum(row.requests)} | ${fmtNum(row.injectedChars)}c | ${fmtNum(row.avoidedChars)}c | ${fmtNum(row.netChars)}c | ${row.workspace.split("/").pop()} |`,
|
|
469
|
+
);
|
|
470
|
+
}
|
|
471
|
+
return lines.join("\n");
|
|
472
|
+
}
|
|
473
|
+
|
|
474
|
+
function renderMarkdown(report) {
|
|
475
|
+
const out = [];
|
|
476
|
+
out.push(`# 门审计(gate audit)· ${new Date(report.auditedAt ?? Date.now()).toISOString().slice(0, 10)}`);
|
|
477
|
+
out.push("");
|
|
478
|
+
out.push(`窗口:最近 ${report.window.days} 天 · 扫描 ${report.window.sessions} 个会话(其中 ${report.window.mounted} 个挂载了工具)· 账本行 ${report.ledger.rows} 条`);
|
|
479
|
+
out.push("");
|
|
480
|
+
out.push("| 门 | 指标 | 实测 | 目标 | 判定 | 备注 |");
|
|
481
|
+
out.push("|---|---|---|---|---|---|");
|
|
482
|
+
for (const gate of report.gates) {
|
|
483
|
+
out.push(`| ${gate.id} | ${gate.name} | ${gate.value} | ${gate.target} | **${gate.verdict}** | ${gate.note} |`);
|
|
484
|
+
}
|
|
485
|
+
out.push("");
|
|
486
|
+
out.push("## 账本合计");
|
|
487
|
+
out.push("");
|
|
488
|
+
const t = report.ledger.totals;
|
|
489
|
+
out.push(`- injected ${fmtNum(t.injectedChars)}c(≈${fmtNum(t.injectedTokens)} tok)· avoided ${fmtNum(t.avoidedChars)}c(≈${fmtNum(t.avoidedTokens)} tok)`);
|
|
490
|
+
out.push(`- rewrites ${fmtNum(t.rewriteCount)} 次 / ${fmtNum(t.rewriteChars)}c(按 cacheDiscount 计罚 ${fmtNum(t.rewritesCostChars)}c)`);
|
|
491
|
+
out.push(`- **net ${fmtNum(t.netChars)}c ≈ ${fmtNum(t.netTokens)} tok**;开源会话 ${report.ledger.rows} 条,其中净收益为正 ${report.ledger.positive} 条`);
|
|
492
|
+
out.push("");
|
|
493
|
+
out.push("## 最近会话(按会话落行)");
|
|
494
|
+
out.push("");
|
|
495
|
+
out.push(renderRows({ sessions: report.ledgerRows ?? [] }));
|
|
496
|
+
out.push("");
|
|
497
|
+
out.push("> 口径:token = chars/4(与 GUI 一致);rewrites 罚金 = 前缀字符 ×(1 − cacheDiscount),默认 0.1。");
|
|
498
|
+
out.push("> 未测即未测——不猜。G5 需要运行中的宿主(`--api`),G7 需要 `npm run bench:ledger` + `--g7 <pct>`,");
|
|
499
|
+
out.push("> G6 需要配对实验:`npm run quality:ab -- run …`(协议见 docs/G6-quality-ab.zh.md,< 10 对配对一律报未测)。");
|
|
500
|
+
out.push("> G1 的分母是「有节省机会」的会话(`prunedChars + compactedChars > 0`):没有任何剪枝/压缩的会话结构上必为负,");
|
|
501
|
+
out.push("> 把它们算进分母测的是激活率而不是收益。机会会话 < 5 时只报数值、不给判定:一个会话不是分布。");
|
|
502
|
+
out.push("> 绝对值说明:injected 按名义价逐请求记,而宿主前缀缓存实际约 10% 付费;");
|
|
503
|
+
out.push("> 两侧同比例缩放不改变 net 的**正负号**(G1 判的就是符号),所以门不受影响,但「节省 token」的绝对量偏悲观。");
|
|
504
|
+
return out.join("\n");
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
async function main() {
|
|
508
|
+
const args = parseArgs(process.argv.slice(2));
|
|
509
|
+
const home = args.home ?? void 0;
|
|
510
|
+
const live = await fetchLiveCompaction(args.api);
|
|
511
|
+
const report = auditGates({ days: args.days, home, api: args.api, live, g7: args.g7, g6: readG6(args.g6) });
|
|
512
|
+
await fillIndexGate(report);
|
|
513
|
+
const ledger = readLedger({ home });
|
|
514
|
+
report.ledgerRows = ledger.sessions;
|
|
515
|
+
|
|
516
|
+
if (args.md || args.out !== null) {
|
|
517
|
+
const md = renderMarkdown(report);
|
|
518
|
+
if (args.out !== null) {
|
|
519
|
+
fs.mkdirSync(path.dirname(args.out), { recursive: true });
|
|
520
|
+
fs.writeFileSync(args.out, `${md}\n`, "utf8");
|
|
521
|
+
console.log(`wrote ${args.out}`);
|
|
522
|
+
}
|
|
523
|
+
if (args.md || args.out === null) console.log(md);
|
|
524
|
+
return;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
console.log(`gate audit · last ${report.window.days}d · ${report.window.sessions} sessions (${report.window.mounted} mounted) · ${report.ledger.rows} ledger rows`);
|
|
528
|
+
for (const gate of report.gates) {
|
|
529
|
+
console.log(` ${gate.id.padEnd(4)} ${gate.verdict.padEnd(4)} ${gate.value.padEnd(8)} ≤/≥ ${gate.target.padEnd(10)} ${gate.name}`);
|
|
530
|
+
}
|
|
531
|
+
const t = report.ledger.totals;
|
|
532
|
+
console.log(` net ${fmtNum(t.netChars)}c ≈ ${fmtNum(t.netTokens)} tok · injected ${fmtNum(t.injectedChars)}c · avoided ${fmtNum(t.avoidedChars)}c`);
|
|
533
|
+
console.log(" (--md for the table, --out docs/GATES.zh.md to write it)");
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
537
|
+
await main();
|
|
538
|
+
}
|
package/lib/api.js
CHANGED
|
@@ -13,6 +13,8 @@
|
|
|
13
13
|
import { uuid, shortId } from "./util.js";
|
|
14
14
|
import { collectToolCounts, collectUsageStats } from "./usage.js";
|
|
15
15
|
import { SETTINGS_NS, WIRE_SETTINGS_NS, LEGACY_SETTINGS_NS } from "./settings.js";
|
|
16
|
+
import { approvedIds, isApproved, loadApprovals, recordApproval, revokeApproval } from "./approvals.js";
|
|
17
|
+
import { planSkillExport, skillDescription, skillName } from "./skill-export.js";
|
|
16
18
|
|
|
17
19
|
/** Route family prefix (kept in sync with the client half). */
|
|
18
20
|
export const API_PREFIX = "/api/dsh-engram";
|
|
@@ -656,6 +658,73 @@ export function makeEngramRoutes(service) {
|
|
|
656
658
|
};
|
|
657
659
|
};
|
|
658
660
|
|
|
661
|
+
/**
|
|
662
|
+
* v0.8-D approvals surface.
|
|
663
|
+
*
|
|
664
|
+
* GET → what the exporter would write now (evidence-passed procedures), which
|
|
665
|
+
* of them are approved, and what is waiting. `pending` comes from
|
|
666
|
+
* `planSkillExport`, the same planner `skill export` uses, so the GUI
|
|
667
|
+
* cannot show a list the CLI would skip.
|
|
668
|
+
* POST → { id, action: "approve" | "revoke", note? }
|
|
669
|
+
*
|
|
670
|
+
* The approvals record lives OUTSIDE the memory store (a pool must not approve
|
|
671
|
+
* itself); `service.engramHome` is where every other piece of local state lives.
|
|
672
|
+
*/
|
|
673
|
+
const approvalsView = (workspace) => {
|
|
674
|
+
const rows = service.openedDomain?.()?.searchMemories?.({ workspace, limit: 1000 }) ?? [];
|
|
675
|
+
const home = typeof service.engramHome === "function" ? service.engramHome() : service.engramHome;
|
|
676
|
+
const state = loadApprovals(home);
|
|
677
|
+
const approved = new Set(approvedIds(state, workspace));
|
|
678
|
+
// Same numbers the CLI uses; read from the live config so a profile that
|
|
679
|
+
// raised the evidence floor cannot be told something different by the GUI.
|
|
680
|
+
const config = {
|
|
681
|
+
procedureMinEvidence: service.config?.procedureMinEvidence ?? 3,
|
|
682
|
+
procedureMaxAgeDays: service.config?.procedureMaxAgeDays ?? 14,
|
|
683
|
+
promoteHits: service.config?.promoteHits ?? 3,
|
|
684
|
+
};
|
|
685
|
+
// `requireApproval: true` would return only approved rows, which is the
|
|
686
|
+
// opposite of a pending list — ask for everything the evidence gate accepts
|
|
687
|
+
// and split it by the approval record.
|
|
688
|
+
const candidates = planSkillExport(rows, { config, dir: void 0, approved, requireApproval: false });
|
|
689
|
+
const pending = new Set(candidates.map((skill) => skill.memoryId).filter((id) => !approved.has(id)));
|
|
690
|
+
const items = rows
|
|
691
|
+
.filter((row) => isApproved(state, workspace, row.id) || pending.has(row.id))
|
|
692
|
+
.map((row) => ({
|
|
693
|
+
id: row.id,
|
|
694
|
+
workspace,
|
|
695
|
+
name: skillName(row),
|
|
696
|
+
description: skillDescription(row),
|
|
697
|
+
approved: isApproved(state, workspace, row.id),
|
|
698
|
+
evidence: row.evidenceCount ?? 0,
|
|
699
|
+
lastVerifiedAt: row.lastVerifiedAt ?? null,
|
|
700
|
+
}));
|
|
701
|
+
return { workspace, items };
|
|
702
|
+
};
|
|
703
|
+
|
|
704
|
+
const readApprovals = async (req, res) => {
|
|
705
|
+
const domain = await withDomain(res);
|
|
706
|
+
if (domain === void 0) return;
|
|
707
|
+
const params = readQuery(req);
|
|
708
|
+
const workspace = params.get("workspace") || void 0;
|
|
709
|
+
if (!workspace) return writeError(res, 400, "workspace is required");
|
|
710
|
+
return writeJson(res, 200, approvalsView(workspace));
|
|
711
|
+
};
|
|
712
|
+
|
|
713
|
+
const writeApprovals = async (req, res) => {
|
|
714
|
+
const domain = await withDomain(res);
|
|
715
|
+
if (domain === void 0) return;
|
|
716
|
+
const body = await readJsonBody(req);
|
|
717
|
+
if (body.error) return writeError(res, 400, body.error);
|
|
718
|
+
const { workspace, id, action, note } = body.value ?? {};
|
|
719
|
+
if (typeof workspace !== "string" || workspace.length === 0) return writeError(res, 400, "workspace is required");
|
|
720
|
+
if (typeof id !== "string" || id.length === 0) return writeError(res, 400, "id is required");
|
|
721
|
+
const home = typeof service.engramHome === "function" ? service.engramHome() : service.engramHome;
|
|
722
|
+
if (action === "revoke") revokeApproval(home, workspace, id);
|
|
723
|
+
else if (action === "approve") recordApproval(home, workspace, id, { note: typeof note === "string" ? note : null });
|
|
724
|
+
else return writeError(res, 400, "action must be approve or revoke");
|
|
725
|
+
return writeJson(res, 200, approvalsView(workspace));
|
|
726
|
+
};
|
|
727
|
+
|
|
659
728
|
return [
|
|
660
729
|
make("GET", "/overview", overview),
|
|
661
730
|
make("GET", "/memories", memories),
|
|
@@ -675,6 +744,7 @@ export function makeEngramRoutes(service) {
|
|
|
675
744
|
make("POST", "/memories/delete", remove),
|
|
676
745
|
make("POST", "/tasks/close", closeTask),
|
|
677
746
|
make("POST", "/gc", gcRun),
|
|
747
|
+
makeUnion("/approvals", { GET: readApprovals, POST: writeApprovals }),
|
|
678
748
|
make("GET", "/export", exportData),
|
|
679
749
|
make("POST", "/import", importData),
|
|
680
750
|
];
|