@inetafrica/open-claudia 2.6.57 β†’ 2.6.59

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.
@@ -0,0 +1,227 @@
1
+ // Per-version KPI aggregation over recall telemetry. Ground truth is the
2
+ // per-turn JSONL (every record version+model stamped); the nightly dream
3
+ // snapshots in recall-kpi.jsonl are the time series. Deterministic and
4
+ // read-only β€” powers `open-claudia kpi` (text table, JSON, and a
5
+ // self-contained HTML report with inline SVG charts, no CDN).
6
+
7
+ const fs = require("fs");
8
+ const metrics = require("./metrics");
9
+
10
+ const LEGACY_VERSION = "pre-2.6.59"; // records logged before version stamping shipped
11
+
12
+ function emptyBucket(v) {
13
+ return {
14
+ v, models: new Set(), firstTs: "", lastTs: "",
15
+ turns: 0, gated: 0, seedsTier: 0, keptTotal: 0,
16
+ rescueTurns: 0, rescues: 0, walkerFailed: 0,
17
+ latencyMsTotal: 0, costUsd: 0, autoKept: 0, autoDropped: 0,
18
+ opens: 0, reviewerUses: 0, byTier: {},
19
+ };
20
+ }
21
+
22
+ // Stream the per-turn log into per-version buckets. Use-records (πŸ“– opens /
23
+ // reviewer adjudications) are attributed to the version that observed the use.
24
+ function scanLog() {
25
+ let lines = [];
26
+ try { lines = fs.readFileSync(metrics.LOG_FILE, "utf8").split("\n").filter(Boolean); }
27
+ catch (e) { return new Map(); }
28
+ const byV = new Map();
29
+ const bucket = (v) => { if (!byV.has(v)) byV.set(v, emptyBucket(v)); return byV.get(v); };
30
+ for (const line of lines) {
31
+ let rec; try { rec = JSON.parse(line); } catch (e) { continue; }
32
+ const b = bucket(rec.v || LEGACY_VERSION);
33
+ if (!b.firstTs) b.firstTs = rec.ts || "";
34
+ if (rec.ts) b.lastTs = rec.ts;
35
+ if (Array.isArray(rec.use)) {
36
+ if (rec.source === "reviewer") b.reviewerUses += rec.use.length;
37
+ else b.opens += rec.use.length;
38
+ continue;
39
+ }
40
+ b.turns++;
41
+ if (rec.model) b.models.add(rec.model);
42
+ b.latencyMsTotal += rec.latencyMs || 0;
43
+ b.costUsd += rec.costUsd || 0;
44
+ if (rec.tier) {
45
+ const t = b.byTier[rec.tier] || { n: 0, ms: 0 };
46
+ t.n++; t.ms += rec.latencyMs || 0;
47
+ b.byTier[rec.tier] = t;
48
+ }
49
+ if (rec.gated) { b.gated++; continue; }
50
+ if (rec.tier === "seeds") b.seedsTier++;
51
+ if (rec.walker === "failed") b.walkerFailed++;
52
+ const kept = rec.kept || [];
53
+ b.keptTotal += kept.length;
54
+ const seedIds = new Set((rec.seeds || []).map((s) => s.id));
55
+ const rescues = kept.filter((k) => !seedIds.has(k.id)).length;
56
+ if (rescues > 0) { b.rescueTurns++; b.rescues += rescues; }
57
+ if (rec.auto) {
58
+ b.autoKept += (rec.auto.kept || []).length;
59
+ b.autoDropped += (rec.auto.dropped || []).length;
60
+ }
61
+ }
62
+ return byV;
63
+ }
64
+
65
+ const r1 = (n) => Math.round(n * 10) / 10;
66
+ const pct = (n, d) => (d ? Math.round((n / d) * 100) : null);
67
+
68
+ // One comparable row per version, ordered oldest→newest by first sighting.
69
+ function aggregate() {
70
+ const rows = [];
71
+ for (const b of scanLog().values()) {
72
+ const used = b.opens + b.reviewerUses;
73
+ rows.push({
74
+ v: b.v,
75
+ models: [...b.models].sort(),
76
+ from: (b.firstTs || "").slice(0, 10),
77
+ to: (b.lastTs || "").slice(0, 10),
78
+ turns: b.turns,
79
+ gatedPct: pct(b.gated, b.turns),
80
+ seedsTierPct: pct(b.seedsTier, b.turns),
81
+ keptTotal: b.keptTotal,
82
+ avgKept: b.turns - b.gated ? r1(b.keptTotal / (b.turns - b.gated)) : 0,
83
+ rescuePct: pct(b.rescueTurns, b.turns - b.gated),
84
+ noisePct: b.keptTotal ? Math.max(0, 100 - Math.round((used / b.keptTotal) * 100)) : null,
85
+ opens: b.opens,
86
+ reviewerUses: b.reviewerUses,
87
+ avgLatencyMs: b.turns ? Math.round(b.latencyMsTotal / b.turns) : 0,
88
+ byTier: Object.fromEntries(Object.entries(b.byTier).map(([t, x]) => [t, { turns: x.n, avgMs: x.n ? Math.round(x.ms / x.n) : 0 }])),
89
+ costUsd: r1(b.costUsd * 10) / 10,
90
+ costPerTurnUsd: b.turns ? Math.round((b.costUsd / b.turns) * 10000) / 10000 : 0,
91
+ walkerFailPct: pct(b.walkerFailed, b.turns - b.gated),
92
+ autoKept: b.autoKept,
93
+ autoDropped: b.autoDropped,
94
+ });
95
+ }
96
+ rows.sort((a, b) => (a.from || "").localeCompare(b.from || ""));
97
+ return rows;
98
+ }
99
+
100
+ function trend() { return metrics.readKpi(); }
101
+
102
+ // ── text table (CLI) ──────────────────────────────────────────────────
103
+ function renderTable(rows) {
104
+ if (!rows.length) return "No recall telemetry yet.";
105
+ const cols = [
106
+ ["version", (r) => r.v],
107
+ ["turns", (r) => String(r.turns)],
108
+ ["rescue", (r) => (r.rescuePct == null ? "β€”" : r.rescuePct + "%")],
109
+ ["noise", (r) => (r.noisePct == null ? "β€”" : r.noisePct + "%")],
110
+ ["avg", (r) => (r.avgLatencyMs / 1000).toFixed(1) + "s"],
111
+ ["gated", (r) => (r.gatedPct == null ? "β€”" : r.gatedPct + "%")],
112
+ ["$ / turn", (r) => "$" + r.costPerTurnUsd.toFixed(3)],
113
+ ["cascade", (r) => (r.autoKept || r.autoDropped ? `${r.autoKept}/${r.autoDropped}` : "β€”")],
114
+ ["walker-fail", (r) => (r.walkerFailPct == null ? "β€”" : r.walkerFailPct + "%")],
115
+ ["window", (r) => `${r.from}β†’${r.to}`],
116
+ ["models", (r) => r.models.join(",") || "β€”"],
117
+ ];
118
+ const widths = cols.map(([h, f]) => Math.max(h.length, ...rows.map((r) => f(r).length)));
119
+ const line = (cells) => cells.map((c, i) => c.padEnd(widths[i])).join(" ");
120
+ const out = [line(cols.map(([h]) => h)), line(widths.map((w) => "-".repeat(w)))];
121
+ for (const r of rows) out.push(line(cols.map(([, f]) => f(r))));
122
+ return out.join("\n");
123
+ }
124
+
125
+ // ── HTML report (inline SVG, no dependencies) ─────────────────────────
126
+ const esc = (s) => String(s).replace(/[&<>"]/g, (c) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;" }[c]));
127
+ const PALETTE = ["#4f8ef7", "#f7734f", "#3dbf7a", "#b06ef7", "#f0b429", "#e8618c", "#38bdc8"];
128
+
129
+ function barChart(title, items, { fmt = (v) => String(v), max = null } = {}) {
130
+ const data = items.filter((i) => i.value != null && Number.isFinite(i.value));
131
+ if (!data.length) return "";
132
+ const W = 560, ROW = 30, LABEL = 150, PAD = 8;
133
+ const H = data.length * ROW + 34;
134
+ const top = max != null ? max : Math.max(...data.map((d) => d.value), 1e-9);
135
+ const bars = data.map((d, i) => {
136
+ const w = Math.max(2, Math.round((d.value / top) * (W - LABEL - 90)));
137
+ const y = 30 + i * ROW;
138
+ return `<text x="${LABEL - PAD}" y="${y + 15}" text-anchor="end" class="lb">${esc(d.label)}</text>` +
139
+ `<rect x="${LABEL}" y="${y + 3}" width="${w}" height="${ROW - 10}" rx="4" fill="${PALETTE[i % PALETTE.length]}"/>` +
140
+ `<text x="${LABEL + w + 6}" y="${y + 15}" class="vl">${esc(fmt(d.value))}</text>`;
141
+ }).join("");
142
+ return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">` +
143
+ `<text x="0" y="16" class="tt">${esc(title)}</text>${bars}</svg>`;
144
+ }
145
+
146
+ function lineChart(title, series, { fmt = (v) => String(v) } = {}) {
147
+ const all = series.flatMap((s) => s.points.map((p) => p.y)).filter((y) => Number.isFinite(y));
148
+ const n = Math.max(...series.map((s) => s.points.length), 0);
149
+ if (!all.length || n < 2) return "";
150
+ const W = 560, H = 190, L = 46, B = 24, T = 26;
151
+ const top = Math.max(...all, 1e-9);
152
+ const x = (i) => L + (i / (n - 1)) * (W - L - 12);
153
+ const y = (v) => T + (1 - v / top) * (H - T - B);
154
+ const paths = series.map((s, si) => {
155
+ const pts = s.points.map((p, i) => `${x(i).toFixed(1)},${y(p.y).toFixed(1)}`).join(" ");
156
+ return `<polyline points="${pts}" fill="none" stroke="${PALETTE[si % PALETTE.length]}" stroke-width="2"/>`;
157
+ }).join("");
158
+ const legend = series.map((s, si) =>
159
+ `<circle cx="${L + si * 130}" cy="${H - 8}" r="4" fill="${PALETTE[si % PALETTE.length]}"/>` +
160
+ `<text x="${L + si * 130 + 9}" y="${H - 4}" class="lb">${esc(s.label)}</text>`).join("");
161
+ const grid = [0, 0.5, 1].map((f) =>
162
+ `<line x1="${L}" y1="${y(top * f)}" x2="${W - 12}" y2="${y(top * f)}" class="gr"/>` +
163
+ `<text x="${L - 5}" y="${y(top * f) + 4}" text-anchor="end" class="vl">${esc(fmt(top * f))}</text>`).join("");
164
+ return `<svg viewBox="0 0 ${W} ${H}" xmlns="http://www.w3.org/2000/svg">` +
165
+ `<text x="0" y="16" class="tt">${esc(title)}</text>${grid}${paths}${legend}</svg>`;
166
+ }
167
+
168
+ function htmlReport() {
169
+ const rows = aggregate();
170
+ const snaps = trend().filter((s) => s.health && s.window && s.window.turns > 0);
171
+ const label = (r) => `${r.v} (${r.turns}t)`;
172
+ const charts = [
173
+ barChart("Graph rescue rate β€” % of judged turns where memory was saved by the graph, not keywords (higher = better transfer)",
174
+ rows.map((r) => ({ label: label(r), value: r.rescuePct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
175
+ barChart("Noise β€” % of injected memories never opened or credited by the reviewer (lower is better)",
176
+ rows.map((r) => ({ label: label(r), value: r.noisePct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
177
+ barChart("Average recall latency", rows.map((r) => ({ label: label(r), value: r.avgLatencyMs / 1000 })), { fmt: (v) => v.toFixed(1) + "s" }),
178
+ barChart("Recall cost per turn", rows.map((r) => ({ label: label(r), value: r.costPerTurnUsd })), { fmt: (v) => "$" + v.toFixed(3) }),
179
+ barChart("Gated turns β€” pleasantries skipped for free", rows.map((r) => ({ label: label(r), value: r.gatedPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
180
+ barChart("Walker failure rate", rows.map((r) => ({ label: label(r), value: r.walkerFailPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
181
+ ].filter(Boolean).map((s) => `<div class="card">${s}</div>`).join("\n");
182
+
183
+ const trendChart = snaps.length >= 2 ? lineChart(
184
+ "Nightly trend (per dream window)",
185
+ [
186
+ { label: "rescue %", points: snaps.map((s) => ({ y: (s.health.rescueRate || 0) * 100 })) },
187
+ { label: "noise %", points: snaps.map((s) => ({ y: (s.health.noiseRate || 0) * 100 })) },
188
+ { label: "latency s", points: snaps.map((s) => ({ y: (s.health.avgLatencyMs || 0) / 1000 })) },
189
+ ],
190
+ { fmt: (v) => Math.round(v) }) : "";
191
+
192
+ const tierRows = rows.map((r) => {
193
+ const t = Object.entries(r.byTier).map(([k, v]) => `${k}: ${v.turns}Γ— @ ${(v.avgMs / 1000).toFixed(1)}s`).join(" Β· ") || "β€”";
194
+ return `<tr><td>${esc(r.v)}</td><td>${esc(r.from)}β†’${esc(r.to)}</td><td>${r.turns}</td><td>${t}</td>` +
195
+ `<td>${r.opens} / ${r.reviewerUses}</td><td>${r.autoKept} / ${r.autoDropped}</td><td>${esc(r.models.join(", ") || "β€”")}</td></tr>`;
196
+ }).join("\n");
197
+
198
+ return `<!doctype html>
199
+ <html><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1">
200
+ <title>Open Claudia β€” recall KPIs</title>
201
+ <style>
202
+ body{font-family:-apple-system,Segoe UI,Roboto,sans-serif;background:#101418;color:#e6edf3;margin:0;padding:24px;max-width:660px;margin:auto}
203
+ h1{font-size:20px;margin:0 0 4px} .sub{color:#8b98a5;font-size:13px;margin-bottom:20px}
204
+ .card{background:#161c23;border:1px solid #232c36;border-radius:10px;padding:14px;margin-bottom:14px}
205
+ svg{width:100%;height:auto} .tt{fill:#e6edf3;font-size:12px;font-weight:600} .lb{fill:#aeb8c2;font-size:11px}
206
+ .vl{fill:#8b98a5;font-size:10px} .gr{stroke:#232c36;stroke-width:1}
207
+ table{width:100%;border-collapse:collapse;font-size:12px} td,th{padding:6px 8px;border-bottom:1px solid #232c36;text-align:left}
208
+ th{color:#8b98a5;font-weight:600}
209
+ </style></head><body>
210
+ <h1>🧠 Open Claudia β€” recall KPIs by version</h1>
211
+ <div class="sub">Generated ${new Date().toISOString().slice(0, 16).replace("T", " ")} Β· ${rows.reduce((a, r) => a + r.turns, 0)} recall turns Β· ${snaps.length} dream snapshot${snaps.length === 1 ? "" : "s"}</div>
212
+ ${charts}
213
+ ${trendChart ? `<div class="card">${trendChart}</div>` : ""}
214
+ <div class="card"><table>
215
+ <tr><th>version</th><th>window</th><th>turns</th><th>latency by tier</th><th>opens / reviewer</th><th>cascade kept / dropped</th><th>walker model</th></tr>
216
+ ${tierRows}
217
+ </table></div>
218
+ </body></html>`;
219
+ }
220
+
221
+ function writeHtmlReport(outPath) {
222
+ const p = outPath || require("path").join(require("../../config-dir"), "kpi-report.html");
223
+ fs.writeFileSync(p, htmlReport());
224
+ return p;
225
+ }
226
+
227
+ module.exports = { LEGACY_VERSION, aggregate, trend, renderTable, htmlReport, writeHtmlReport };
@@ -10,50 +10,104 @@ const CONFIG_DIR = require("../../config-dir");
10
10
 
11
11
  const LOG_FILE = path.join(CONFIG_DIR, "recall-metrics.jsonl");
12
12
  const SUMMARY_FILE = path.join(CONFIG_DIR, "recall-metrics-summary.json");
13
+ const NODE_STATS_FILE = path.join(CONFIG_DIR, "recall-node-stats.json");
14
+ const KPI_FILE = path.join(CONFIG_DIR, "recall-kpi.jsonl");
15
+ let VERSION = "";
16
+ try { VERSION = require("../../package.json").version || ""; } catch (e) {}
13
17
  const MAX_OPEN_TRACK = 400; // recent turns kept to reconcile surfacedβ†’opened
18
+ const MAX_NODES = 800; // node-stats map cap; lowest-activity entries pruned past this
19
+ const MAX_CO_RESCUE = 300;
14
20
 
15
21
  function enabled() {
16
22
  return String(process.env.RECALL_METRICS || "on").toLowerCase() !== "off";
17
23
  }
18
24
 
19
25
  // Record one recall turn. `entry`:
20
- // { engine, query, seeds:[{id,score}], activated:[{id,activation,hop}],
21
- // kept:[{id,why}], gated (bool), latencyMs, costUsd }
26
+ // { engine, query, tier, seeds:[{id,score}], activated:[{id,activation,hop}],
27
+ // cand:[{id,act}], kept:[{id,why}], auto:{kept:[],dropped:[],shadow:{checks,agree}},
28
+ // gated (bool), latencyMs, seedMs, expandMs, walkerMs,
29
+ // walker ("warm"|"cold"|"off"|"failed"), costUsd }
22
30
  function logTurn(entry) {
23
31
  if (!enabled()) return;
24
32
  try {
25
33
  const rec = {
26
34
  ts: new Date().toISOString(),
35
+ v: VERSION,
36
+ model: entry.model || "",
27
37
  engine: entry.engine || "discoverer",
28
38
  query: String(entry.query || "").slice(0, 200),
39
+ tier: entry.tier || "",
29
40
  seeds: (entry.seeds || []).map((s) => ({ id: s.id, score: s.score })),
30
41
  activated: (entry.activated || []).map((a) => ({ id: a.id, activation: round(a.activation), hop: a.hop })),
42
+ cand: (entry.cand || []).map((c) => ({ id: c.id, act: !!c.act })),
31
43
  kept: (entry.kept || []).map((k) => ({ id: k.id, why: String(k.why || "").slice(0, 200) })),
44
+ auto: entry.auto || null,
32
45
  gated: !!entry.gated,
33
46
  latencyMs: entry.latencyMs || 0,
47
+ seedMs: entry.seedMs || 0,
48
+ expandMs: entry.expandMs || 0,
49
+ walkerMs: entry.walkerMs || 0,
50
+ walker: entry.walker || "",
34
51
  costUsd: entry.costUsd || 0,
35
52
  };
36
53
  fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
37
54
  bumpSummary(rec);
55
+ bumpNodeStats(rec);
38
56
  } catch (e) {}
39
57
  }
40
58
 
41
- // Record which nodes were actually opened (πŸ“–) this turn. Matched against the
42
- // most recent surfaced sets to derive precision/noise without a second pass
43
- // over the whole log.
44
- function logUse(openedIds) {
59
+ // Record which nodes were actually used this turn. Matched against the most
60
+ // recent surfaced sets to derive precision/noise without a second pass over
61
+ // the whole log. `source` distinguishes how use was observed: "" / undefined
62
+ // for πŸ“– opens (agent explicitly read the note), "reviewer" for post-turn
63
+ // reviewer adjudication (the turn's work updated that pack/entity).
64
+ function logUse(openedIds, source) {
45
65
  if (!enabled()) return;
46
66
  const ids = [...new Set((openedIds || []).filter(Boolean))];
47
67
  if (!ids.length) return;
48
68
  try {
49
- fs.appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), use: ids }) + "\n");
69
+ const rec = { ts: new Date().toISOString(), v: VERSION, use: ids };
70
+ if (source) rec.source = String(source);
71
+ fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
72
+ const s = readSummary();
73
+ if (source === "reviewer") s.reviewerUses = (s.reviewerUses || 0) + ids.length;
74
+ else s.opens = (s.opens || 0) + ids.length;
75
+ writeSummary(s);
76
+ const ns = readNodeStats();
77
+ for (const id of ids) {
78
+ const n = nodeEntry(ns, id);
79
+ if (source === "reviewer") n.rev = (n.rev || 0) + 1;
80
+ else n.open = (n.open || 0) + 1;
81
+ n.last = today();
82
+ }
83
+ writeNodeStats(ns);
84
+ } catch (e) {}
85
+ }
86
+
87
+ // One walker-cascade shadow sample compared: the deterministic verdict vs the
88
+ // judge's. Divergence here is what tells the dream to distrust the cascade.
89
+ function logShadow(agree) {
90
+ if (!enabled()) return;
91
+ try {
92
+ const s = readSummary();
93
+ s.shadowChecks = (s.shadowChecks || 0) + 1;
94
+ if (agree) s.shadowAgree = (s.shadowAgree || 0) + 1;
95
+ writeSummary(s);
96
+ } catch (e) {}
97
+ }
98
+
99
+ // Reviewer skip-gate fired: a turn deterministically judged not memory-worthy.
100
+ function logReviewerSkip() {
101
+ if (!enabled()) return;
102
+ try {
50
103
  const s = readSummary();
51
- s.opens = (s.opens || 0) + ids.length;
104
+ s.reviewerSkipped = (s.reviewerSkipped || 0) + 1;
52
105
  writeSummary(s);
53
106
  } catch (e) {}
54
107
  }
55
108
 
56
109
  function round(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
110
+ function today() { return new Date().toISOString().slice(0, 10); }
57
111
 
58
112
  function readSummary() {
59
113
  try { return JSON.parse(fs.readFileSync(SUMMARY_FILE, "utf8")) || {}; } catch (e) { return {}; }
@@ -62,10 +116,84 @@ function writeSummary(s) {
62
116
  try { fs.writeFileSync(SUMMARY_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
63
117
  }
64
118
 
119
+ function readNodeStats() {
120
+ try { return JSON.parse(fs.readFileSync(NODE_STATS_FILE, "utf8")) || { nodes: {}, coRescue: {} }; }
121
+ catch (e) { return { nodes: {}, coRescue: {} }; }
122
+ }
123
+ function writeNodeStats(ns) {
124
+ try {
125
+ pruneNodeStats(ns);
126
+ ns.updatedAt = new Date().toISOString();
127
+ fs.writeFileSync(NODE_STATS_FILE, JSON.stringify(ns));
128
+ } catch (e) {}
129
+ }
130
+ function nodeEntry(ns, id) {
131
+ ns.nodes = ns.nodes || {};
132
+ if (!ns.nodes[id]) ns.nodes[id] = { seen: 0, act: 0, kept: 0, open: 0, rev: 0, resc: 0 };
133
+ return ns.nodes[id];
134
+ }
135
+ function pruneNodeStats(ns) {
136
+ const ids = Object.keys(ns.nodes || {});
137
+ if (ids.length > MAX_NODES) {
138
+ const scored = ids.map((id) => {
139
+ const n = ns.nodes[id];
140
+ return { id, w: (n.seen || 0) + (n.kept || 0) * 3 + (n.open || 0) * 3 + (n.rev || 0) * 3 };
141
+ }).sort((a, b) => a.w - b.w);
142
+ for (const { id } of scored.slice(0, ids.length - MAX_NODES)) delete ns.nodes[id];
143
+ }
144
+ const pairs = Object.entries(ns.coRescue || {});
145
+ if (pairs.length > MAX_CO_RESCUE) {
146
+ ns.coRescue = Object.fromEntries(pairs.sort((a, b) => b[1] - a[1]).slice(0, MAX_CO_RESCUE));
147
+ }
148
+ }
149
+
150
+ // Per-node rolling counters β€” the evidence base for the walker cascade
151
+ // (keep-rates), negative Hebbian (activated-never-kept), and dream targeting.
152
+ // seen = surfaced as a candidate; act = of those, arrived via graph expansion;
153
+ // kept = walker/cascade kept it; open/rev = actually used; resc = kept without
154
+ // a seed match (graph rescue).
155
+ function bumpNodeStats(rec) {
156
+ if (rec.gated) return;
157
+ const ns = readNodeStats();
158
+ const keptIds = new Set(rec.kept.map((k) => k.id));
159
+ const seedIds = new Set(rec.seeds.map((x) => x.id));
160
+ const candList = rec.cand && rec.cand.length
161
+ ? rec.cand
162
+ : [...rec.seeds.map((s) => ({ id: s.id, act: false })), ...rec.activated.map((a) => ({ id: a.id, act: true }))];
163
+ const seenIds = new Set();
164
+ for (const c of candList) {
165
+ if (seenIds.has(c.id) || c.id.startsWith("episode:")) continue;
166
+ seenIds.add(c.id);
167
+ const n = nodeEntry(ns, c.id);
168
+ n.seen++;
169
+ if (c.act) n.act++;
170
+ if (keptIds.has(c.id)) n.kept++;
171
+ n.last = today();
172
+ }
173
+ const rescued = [];
174
+ for (const k of rec.kept) {
175
+ if (k.id.startsWith("episode:")) continue;
176
+ if (!seenIds.has(k.id)) { const n = nodeEntry(ns, k.id); n.seen++; n.kept++; n.last = today(); }
177
+ if (!seedIds.has(k.id)) { nodeEntry(ns, k.id).resc++; rescued.push(k.id); }
178
+ }
179
+ // Co-rescue pairs: nodes repeatedly rescued together are missing a direct
180
+ // edge β€” the dream adds one deterministically past a threshold.
181
+ rescued.sort();
182
+ ns.coRescue = ns.coRescue || {};
183
+ for (let i = 0; i < rescued.length; i++) {
184
+ for (let j = i + 1; j < rescued.length; j++) {
185
+ const key = `${rescued[i]}|${rescued[j]}`;
186
+ ns.coRescue[key] = (ns.coRescue[key] || 0) + 1;
187
+ }
188
+ }
189
+ writeNodeStats(ns);
190
+ }
191
+
65
192
  function bumpSummary(rec) {
66
193
  const s = readSummary();
67
194
  s.turns = (s.turns || 0) + 1;
68
195
  if (rec.gated) s.gatedTurns = (s.gatedTurns || 0) + 1;
196
+ if (rec.tier === "seeds") s.seedsOnlyTurns = (s.seedsOnlyTurns || 0) + 1;
69
197
  s.seedsTotal = (s.seedsTotal || 0) + rec.seeds.length;
70
198
  s.activatedTotal = (s.activatedTotal || 0) + rec.activated.length;
71
199
  s.keptTotal = (s.keptTotal || 0) + rec.kept.length;
@@ -75,6 +203,16 @@ function bumpSummary(rec) {
75
203
  if (rescues > 0) { s.rescueTurns = (s.rescueTurns || 0) + 1; s.rescues = (s.rescues || 0) + rescues; }
76
204
  s.costUsd = round((s.costUsd || 0) + (rec.costUsd || 0));
77
205
  s.latencyMsTotal = (s.latencyMsTotal || 0) + (rec.latencyMs || 0);
206
+ if (rec.tier) {
207
+ s.byTier = s.byTier || {};
208
+ const t = s.byTier[rec.tier] || { n: 0, ms: 0 };
209
+ t.n++; t.ms += rec.latencyMs || 0;
210
+ s.byTier[rec.tier] = t;
211
+ }
212
+ if (rec.auto) {
213
+ s.autoKept = (s.autoKept || 0) + ((rec.auto.kept || []).length);
214
+ s.autoDropped = (s.autoDropped || 0) + ((rec.auto.dropped || []).length);
215
+ }
78
216
  s.updatedAt = new Date().toISOString();
79
217
  writeSummary(s);
80
218
  }
@@ -88,6 +226,8 @@ function summary() {
88
226
  turns,
89
227
  gatedTurns: s.gatedTurns || 0,
90
228
  gatedPct: fmtPct(s.gatedTurns || 0, turns),
229
+ seedsOnlyTurns: s.seedsOnlyTurns || 0,
230
+ seedsOnlyPct: fmtPct(s.seedsOnlyTurns || 0, turns),
91
231
  avgSeeds: turns ? round((s.seedsTotal || 0) / turns) : 0,
92
232
  avgActivated: turns ? round((s.activatedTotal || 0) / turns) : 0,
93
233
  avgKept: turns ? round((s.keptTotal || 0) / turns) : 0,
@@ -95,15 +235,82 @@ function summary() {
95
235
  rescuePct: fmtPct(s.rescueTurns || 0, turns),
96
236
  rescues: s.rescues || 0,
97
237
  opens: s.opens || 0,
238
+ reviewerUses: s.reviewerUses || 0,
239
+ reviewerSkipped: s.reviewerSkipped || 0,
240
+ autoKept: s.autoKept || 0,
241
+ autoDropped: s.autoDropped || 0,
242
+ shadowChecks: s.shadowChecks || 0,
243
+ shadowAgreePct: fmtPct(s.shadowAgree || 0, s.shadowChecks || 0),
244
+ byTier: Object.fromEntries(Object.entries(s.byTier || {}).map(([t, v]) => [t, { turns: v.n, avgMs: v.n ? Math.round(v.ms / v.n) : 0 }])),
98
245
  avgLatencyMs: turns ? Math.round((s.latencyMsTotal || 0) / turns) : 0,
99
246
  costUsd: s.costUsd || 0,
100
247
  updatedAt: s.updatedAt || "",
101
248
  };
102
249
  }
103
250
 
251
+ function nodeStats() { return readNodeStats(); }
252
+
253
+ // Dream-facing evidence pack: raw summary (for windowed deltas against a prior
254
+ // snapshot) plus targeted node lists the consolidation/tuning passes act on.
255
+ function evidence({ minSeen = 6, coRescueMin = 3 } = {}) {
256
+ const s = readSummary();
257
+ const ns = readNodeStats();
258
+ const nodes = ns.nodes || {};
259
+ const neverKept = [];
260
+ const highKeep = [];
261
+ const topRescued = [];
262
+ for (const [id, n] of Object.entries(nodes)) {
263
+ const used = (n.open || 0) + (n.rev || 0);
264
+ if ((n.seen || 0) >= minSeen && (n.kept || 0) === 0 && used === 0) {
265
+ neverKept.push({ id, seen: n.seen, act: n.act || 0 });
266
+ }
267
+ if ((n.seen || 0) >= 5 && (n.kept || 0) / n.seen >= 0.8 && used >= 1) {
268
+ highKeep.push({ id, seen: n.seen, kept: n.kept, used });
269
+ }
270
+ if ((n.resc || 0) >= 2) topRescued.push({ id, rescues: n.resc });
271
+ }
272
+ neverKept.sort((a, b) => b.seen - a.seen);
273
+ topRescued.sort((a, b) => b.rescues - a.rescues);
274
+ const coRescuePairs = Object.entries(ns.coRescue || {})
275
+ .filter(([, n]) => n >= coRescueMin)
276
+ .map(([key, n]) => { const [a, b] = key.split("|"); return { a, b, n }; })
277
+ .sort((x, y) => y.n - x.n);
278
+ return {
279
+ summary: s,
280
+ neverKept: neverKept.slice(0, 25),
281
+ highKeep: highKeep.slice(0, 25),
282
+ topRescued: topRescued.slice(0, 15),
283
+ coRescuePairs: coRescuePairs.slice(0, 15),
284
+ };
285
+ }
286
+
287
+ // One version-stamped KPI snapshot per dream night. This is the durable
288
+ // series `open-claudia kpi` aggregates to compare harness versions/models β€”
289
+ // per-turn ground truth stays in LOG_FILE, this is the cheap nightly rollup.
290
+ function appendKpi(snapshot) {
291
+ if (!enabled()) return;
292
+ try {
293
+ fs.appendFileSync(KPI_FILE, JSON.stringify({ ts: new Date().toISOString(), v: VERSION, ...snapshot }) + "\n");
294
+ } catch (e) {}
295
+ }
296
+
297
+ function readKpi() {
298
+ try {
299
+ return fs.readFileSync(KPI_FILE, "utf8").split("\n").filter(Boolean).map((l) => {
300
+ try { return JSON.parse(l); } catch (e) { return null; }
301
+ }).filter(Boolean);
302
+ } catch (e) { return []; }
303
+ }
304
+
104
305
  function _resetForTest() {
105
306
  try { fs.rmSync(LOG_FILE, { force: true }); } catch (e) {}
106
307
  try { fs.rmSync(SUMMARY_FILE, { force: true }); } catch (e) {}
308
+ try { fs.rmSync(NODE_STATS_FILE, { force: true }); } catch (e) {}
309
+ try { fs.rmSync(KPI_FILE, { force: true }); } catch (e) {}
107
310
  }
108
311
 
109
- module.exports = { LOG_FILE, SUMMARY_FILE, enabled, logTurn, logUse, summary, _resetForTest };
312
+ module.exports = {
313
+ LOG_FILE, SUMMARY_FILE, NODE_STATS_FILE, KPI_FILE, VERSION, enabled,
314
+ logTurn, logUse, logShadow, logReviewerSkip, appendKpi, readKpi,
315
+ summary, nodeStats, evidence, _resetForTest,
316
+ };
@@ -0,0 +1,125 @@
1
+ // Dream-tunable recall knobs. The nightly dream is the ONLY writer; recall
2
+ // paths read lazily (mtime-cached) so a knob change applies without a bot
3
+ // restart. Every knob is hard-bounded here β€” a wild model proposal can never
4
+ // push a value outside its safe range. Precedence: env var (operator pin)
5
+ // > tuning file (dream) > default.
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const CONFIG_DIR = require("../../config-dir");
10
+
11
+ const TUNING_FILE = path.join(CONFIG_DIR, "recall-tuning.json");
12
+ const MAX_HISTORY = 40;
13
+
14
+ // name -> { def, min, max, env } (numeric) or { def, values, env } (enum).
15
+ const KNOBS = {
16
+ walkerMaxCandidates: { def: 14, min: 8, max: 20, env: "RECALL_WALKER_MAX_CANDIDATES" },
17
+ episodeLimit: { def: 3, min: 1, max: 5, env: "RECALL_EPISODE_LIMIT" },
18
+ seedsTierMaxWords: { def: 4, min: 2, max: 6, env: "RECALL_SEEDS_TIER_MAX_WORDS" },
19
+ decayHalfLifeDays: { def: 60, min: 10, max: 90, env: "RECALL_DECAY_HALF_LIFE_DAYS" },
20
+ cascade: { def: "on", values: ["on", "off"], env: "RECALL_CASCADE" },
21
+ };
22
+
23
+ let _cache = null;
24
+ let _cacheMtime = -1;
25
+
26
+ function readFile() {
27
+ let mtime = 0;
28
+ try { mtime = fs.statSync(TUNING_FILE).mtimeMs; } catch (e) { mtime = 0; }
29
+ if (_cache && mtime === _cacheMtime) return _cache;
30
+ let data = {};
31
+ try { data = JSON.parse(fs.readFileSync(TUNING_FILE, "utf8")) || {}; } catch (e) { data = {}; }
32
+ _cache = data;
33
+ _cacheMtime = mtime;
34
+ return data;
35
+ }
36
+
37
+ function clampKnob(name, value) {
38
+ const spec = KNOBS[name];
39
+ if (!spec) return null;
40
+ if (spec.values) {
41
+ const v = String(value).toLowerCase();
42
+ return spec.values.includes(v) ? v : spec.def;
43
+ }
44
+ const n = Number(value);
45
+ if (!Number.isFinite(n)) return spec.def;
46
+ return Math.min(spec.max, Math.max(spec.min, n));
47
+ }
48
+
49
+ // Effective value of a knob, honouring env pin > tuning file > default.
50
+ function get(name) {
51
+ const spec = KNOBS[name];
52
+ if (!spec) return undefined;
53
+ if (spec.env && process.env[spec.env] !== undefined && process.env[spec.env] !== "") {
54
+ return clampKnob(name, process.env[spec.env]);
55
+ }
56
+ const data = readFile();
57
+ if (data.knobs && data.knobs[name] !== undefined) return clampKnob(name, data.knobs[name]);
58
+ return spec.def;
59
+ }
60
+
61
+ function knobTable() {
62
+ return Object.entries(KNOBS).map(([name, spec]) => ({
63
+ name,
64
+ current: get(name),
65
+ default: spec.def,
66
+ min: spec.min,
67
+ max: spec.max,
68
+ values: spec.values,
69
+ pinned: !!(spec.env && process.env[spec.env]),
70
+ }));
71
+ }
72
+
73
+ // Dream-only writer. Applies ONE bounded change with provenance; returns the
74
+ // applied {knob, from, to} or null when rejected (unknown knob / no-op / env-pinned).
75
+ function set(name, value, { reason = "", evidence = "" } = {}) {
76
+ const spec = KNOBS[name];
77
+ if (!spec) return null;
78
+ if (spec.env && process.env[spec.env]) return null; // operator pin wins β€” don't fight it
79
+ const from = get(name);
80
+ const to = clampKnob(name, value);
81
+ if (to === from) return null;
82
+ const data = readFile();
83
+ data.knobs = data.knobs || {};
84
+ data.knobs[name] = to;
85
+ data.history = data.history || [];
86
+ data.history.push({ ts: new Date().toISOString(), knob: name, from, to, reason: String(reason).slice(0, 300), evidence: String(evidence).slice(0, 300) });
87
+ data.history = data.history.slice(-MAX_HISTORY);
88
+ write(data);
89
+ return { knob: name, from, to };
90
+ }
91
+
92
+ // Revert the most recent change of a knob (auto-rollback path).
93
+ function revert(name, { reason = "" } = {}) {
94
+ const data = readFile();
95
+ if (!data.knobs || data.knobs[name] === undefined) return null;
96
+ const hist = (data.history || []).filter((h) => h.knob === name);
97
+ const last = hist[hist.length - 1];
98
+ if (!last) return null;
99
+ const from = data.knobs[name];
100
+ data.knobs[name] = clampKnob(name, last.from);
101
+ data.history.push({ ts: new Date().toISOString(), knob: name, from, to: data.knobs[name], reason: `rollback: ${reason}`.slice(0, 300) });
102
+ data.history = data.history.slice(-MAX_HISTORY);
103
+ write(data);
104
+ return { knob: name, from, to: data.knobs[name] };
105
+ }
106
+
107
+ function lastChange() {
108
+ const data = readFile();
109
+ const hist = (data.history || []).filter((h) => !/^rollback:/.test(h.reason || ""));
110
+ return hist[hist.length - 1] || null;
111
+ }
112
+
113
+ function history() { return readFile().history || []; }
114
+
115
+ function write(data) {
116
+ try { fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2)); } catch (e) {}
117
+ _cache = null; _cacheMtime = -1;
118
+ }
119
+
120
+ function _resetForTest() {
121
+ try { fs.rmSync(TUNING_FILE, { force: true }); } catch (e) {}
122
+ _cache = null; _cacheMtime = -1;
123
+ }
124
+
125
+ module.exports = { TUNING_FILE, KNOBS, get, set, revert, lastChange, history, knobTable, clampKnob, _resetForTest };