@inetafrica/open-claudia 2.6.58 → 2.7.0
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/CHANGELOG.md +7 -0
- package/README.md +8 -3
- package/bin/cli.js +5 -1
- package/bin/kpi.js +55 -0
- package/bin/tool.js +177 -23
- package/core/dream.js +384 -11
- package/core/pack-review.js +32 -4
- package/core/recall/discoverer.js +92 -24
- package/core/recall/graph.js +29 -2
- package/core/recall/kpi.js +227 -0
- package/core/recall/metrics.js +198 -4
- package/core/recall/tuning.js +125 -0
- package/core/runner.js +4 -0
- package/core/system-prompt.js +1 -0
- package/core/tool-repo.js +270 -0
- package/core/tools.js +268 -59
- package/core/transcript-index.js +21 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +21 -6
- package/test-tools.js +139 -19
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const graph = require("./graph");
|
|
19
19
|
const metrics = require("./metrics");
|
|
20
|
+
const tuning = require("./tuning");
|
|
20
21
|
const { spawnSubagent } = require("../subagent");
|
|
21
22
|
const warmWalker = require("./warm-walker");
|
|
22
23
|
const toolsLib = require("../tools");
|
|
@@ -33,16 +34,17 @@ try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old nod
|
|
|
33
34
|
const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
|
|
34
35
|
const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
|
|
35
36
|
const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
const MAX_WALKER_CANDIDATES = Number(process.env.RECALL_WALKER_MAX_CANDIDATES || 14);
|
|
37
|
+
// Sizing/tier knobs (walker candidate cap, episode limit, seeds-tier width,
|
|
38
|
+
// cascade on/off) live in recall/tuning.js — dream-adjustable within hard
|
|
39
|
+
// bounds, env pins still win. The walker candidate cap exists because uncapped,
|
|
40
|
+
// a wordy turn seeds 15-40 nodes × 600-char excerpts and the walker prompt
|
|
41
|
+
// measured as THE dominant recall latency (p50 ~13.5s even warm).
|
|
42
42
|
const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
|
|
43
|
-
const EPISODE_LIMIT = Number(process.env.RECALL_EPISODE_LIMIT || 3);
|
|
44
43
|
const EXCERPT_CHARS = 600;
|
|
45
44
|
const CONTEXT_CLIP = 3000;
|
|
45
|
+
// Fraction of cascade auto-verdicts also sent to the judge as an accuracy
|
|
46
|
+
// audit — divergence is logged and the dream disables the cascade if it drifts.
|
|
47
|
+
const SHADOW_RATE = Number(process.env.RECALL_CASCADE_SHADOW_RATE || 0.1);
|
|
46
48
|
|
|
47
49
|
let _lastSync = 0;
|
|
48
50
|
const SYNC_INTERVAL_MS = 60000;
|
|
@@ -73,10 +75,22 @@ function recallTier(userText, seedCount) {
|
|
|
73
75
|
if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
|
|
74
76
|
if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
|
|
75
77
|
if (words.length <= 2 && seedCount === 0) return "skip";
|
|
76
|
-
if (words.length <=
|
|
78
|
+
if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
|
|
77
79
|
return "full";
|
|
78
80
|
}
|
|
79
81
|
|
|
82
|
+
// Evidence-backed pre-verdict for one candidate, from its rolling usage stats.
|
|
83
|
+
// Conservative by design: auto-drop needs a long never-kept/never-used streak;
|
|
84
|
+
// auto-keep needs a high keep-rate AND at least one real use (open/reviewer).
|
|
85
|
+
// Anything without strong history returns null and goes to the judge.
|
|
86
|
+
function cascadeVerdict(st) {
|
|
87
|
+
if (!st) return null;
|
|
88
|
+
const seen = st.seen || 0, kept = st.kept || 0, used = (st.open || 0) + (st.rev || 0);
|
|
89
|
+
if (seen >= 6 && kept === 0 && used === 0) return "drop";
|
|
90
|
+
if (seen >= 5 && kept / seen >= 0.8 && used >= 1) return "keep";
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
80
94
|
// Back-compat boolean view of the tier (exported/tested API).
|
|
81
95
|
function needsRecall(userText, seedCount) {
|
|
82
96
|
return recallTier(userText, seedCount) !== "skip";
|
|
@@ -221,8 +235,8 @@ async function run(ctx) {
|
|
|
221
235
|
// 1: tiered pre-gate.
|
|
222
236
|
const tier = recallTier(userText, seedCount);
|
|
223
237
|
if (tier === "skip") {
|
|
224
|
-
metrics.logTurn({ engine: "discoverer", query: userText, gated: true, tier, latencyMs: Date.now() - started });
|
|
225
|
-
return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true };
|
|
238
|
+
metrics.logTurn({ engine: "discoverer", model: WALKER_MODEL, query: userText, gated: true, tier, latencyMs: Date.now() - started });
|
|
239
|
+
return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true, tier };
|
|
226
240
|
}
|
|
227
241
|
|
|
228
242
|
// 3: spreading activation from seeds across the graph (full tier only — the
|
|
@@ -257,7 +271,7 @@ async function run(ctx) {
|
|
|
257
271
|
}
|
|
258
272
|
const candidates = [];
|
|
259
273
|
if (tier === "full") {
|
|
260
|
-
for (const id of orderedIds.slice(0,
|
|
274
|
+
for (const id of orderedIds.slice(0, tuning.get("walkerMaxCandidates"))) {
|
|
261
275
|
const base = excerptFor(id, packsLib, entitiesLib);
|
|
262
276
|
if (!base) continue;
|
|
263
277
|
const act = activated.get(id);
|
|
@@ -316,7 +330,7 @@ async function run(ctx) {
|
|
|
316
330
|
let episodeCands = [];
|
|
317
331
|
if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
|
|
318
332
|
try {
|
|
319
|
-
const { hits } = transcriptIndex.search(userText, { limit:
|
|
333
|
+
const { hits } = transcriptIndex.search(userText, { limit: tuning.get("episodeLimit") });
|
|
320
334
|
episodeCands = (hits || []).map((h) => {
|
|
321
335
|
const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
|
|
322
336
|
return {
|
|
@@ -333,14 +347,63 @@ async function run(ctx) {
|
|
|
333
347
|
} catch (e) { /* episodic recall is best-effort */ }
|
|
334
348
|
}
|
|
335
349
|
|
|
336
|
-
|
|
350
|
+
// Walker cascade: candidates with strong usage history get an evidence-backed
|
|
351
|
+
// auto-verdict and skip the judge — the model reads only the ambiguous middle
|
|
352
|
+
// (fewer tokens, less noise in the prompt). A random SHADOW_RATE slice of
|
|
353
|
+
// auto-decided candidates is judged anyway and compared, so the shortcut is
|
|
354
|
+
// continuously audited against the model it replaces; on shadowed candidates
|
|
355
|
+
// the judge's verdict wins. Top-3 ranked candidates and episodes (no stable
|
|
356
|
+
// history) always go to the judge.
|
|
357
|
+
const cascadeOn = tier === "full" && tuning.get("cascade") !== "off";
|
|
358
|
+
const autoKept = new Map(); // id -> canned why (honest provenance)
|
|
359
|
+
const autoDropped = [];
|
|
360
|
+
const shadowVerdicts = new Map(); // id -> auto verdict, also sent to judge
|
|
361
|
+
let judgeCands = candidates;
|
|
362
|
+
if (cascadeOn && candidates.length) {
|
|
363
|
+
let nodeHist = {};
|
|
364
|
+
try { nodeHist = metrics.nodeStats().nodes || {}; } catch (e) {}
|
|
365
|
+
const protectedIds = new Set(orderedIds.slice(0, 3));
|
|
366
|
+
judgeCands = [];
|
|
367
|
+
for (const c of candidates) {
|
|
368
|
+
const st = c.id.startsWith("episode:") || protectedIds.has(c.id) ? null : nodeHist[c.id];
|
|
369
|
+
const v = cascadeVerdict(st);
|
|
370
|
+
if (!v) { judgeCands.push(c); continue; }
|
|
371
|
+
if (Math.random() < SHADOW_RATE) { shadowVerdicts.set(c.id, v); judgeCands.push(c); continue; }
|
|
372
|
+
if (v === "keep") autoKept.set(c.id, `kept ${st.kept}/${st.seen} recent similar turns`);
|
|
373
|
+
else autoDropped.push(c.id);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
const walkRes = await walk(userText, contextText || fullContext, judgeCands);
|
|
337
378
|
const whyById = walkRes.kept;
|
|
338
379
|
|
|
339
|
-
|
|
340
|
-
|
|
380
|
+
let shadowChecks = 0, shadowAgree = 0;
|
|
381
|
+
if (whyById && shadowVerdicts.size) {
|
|
382
|
+
for (const [id, v] of shadowVerdicts) {
|
|
383
|
+
const agree = (v === "keep") === whyById.has(id);
|
|
384
|
+
shadowChecks++;
|
|
385
|
+
if (agree) shadowAgree++;
|
|
386
|
+
metrics.logShadow(agree);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Decide the kept set. Walker result + evidence auto-keeps win; on walker
|
|
391
|
+
// fail-open (full tier) keep the strongest 3 user-origin seeds only — an
|
|
392
|
+
// unjudged tool or episode is a guess, not a memory (measured: a failed walk
|
|
393
|
+
// once kept 15 unjudged seeds including WiFi tools for a dream question). The
|
|
394
|
+
// seeds tier keeps its classic contract: all user-origin keyword seeds.
|
|
395
|
+
let whyMap = whyById ? new Map(whyById) : null;
|
|
396
|
+
if (whyMap) for (const [id, why] of autoKept) whyMap.set(id, why);
|
|
341
397
|
let keptIds;
|
|
342
|
-
if (
|
|
343
|
-
keptIds = new Set(
|
|
398
|
+
if (whyMap) {
|
|
399
|
+
keptIds = new Set(whyMap.keys());
|
|
400
|
+
} else if (tier === "full") {
|
|
401
|
+
const rankedUserSeeds = [
|
|
402
|
+
...packSeeds.filter((m) => m.origin === "user").map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
|
|
403
|
+
...entSeeds.filter((m) => m.origin === "user").map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
|
|
404
|
+
].sort((a, b) => b.score - a.score).slice(0, 3).map((s) => s.id);
|
|
405
|
+
keptIds = new Set([...autoKept.keys(), ...rankedUserSeeds]);
|
|
406
|
+
if (autoKept.size) whyMap = new Map(autoKept);
|
|
344
407
|
} else {
|
|
345
408
|
keptIds = new Set(
|
|
346
409
|
[...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
|
|
@@ -374,9 +437,9 @@ async function run(ctx) {
|
|
|
374
437
|
// 5: render via shared builders, then weave in the why-bullets.
|
|
375
438
|
let packBlock = buildPackBlock(finalPacks, budget);
|
|
376
439
|
const entityBlock = buildEntityBlock(finalEnts, budget);
|
|
377
|
-
if (packBlock &&
|
|
440
|
+
if (packBlock && whyMap) {
|
|
378
441
|
const bullets = finalPacks
|
|
379
|
-
.map((m) => ({ m, why:
|
|
442
|
+
.map((m) => ({ m, why: whyMap.get(`pack:${m.dir}`) }))
|
|
380
443
|
.filter((x) => x.why)
|
|
381
444
|
.map((x) => `- ${x.m.name || x.m.dir}: ${x.why}`);
|
|
382
445
|
if (bullets.length) packBlock = `\n\n### Why these surfaced (discoverer)\n${bullets.join("\n")}${packBlock}`;
|
|
@@ -388,7 +451,7 @@ async function run(ctx) {
|
|
|
388
451
|
const toolMatches = [];
|
|
389
452
|
for (const c of toolCand.values()) {
|
|
390
453
|
if (keptIds.has(`tool:${c.name}`)) {
|
|
391
|
-
toolMatches.push({ name: c.name, description: c.description, why:
|
|
454
|
+
toolMatches.push({ name: c.name, description: c.description, why: whyMap ? whyMap.get(`tool:${c.name}`) || "" : "" });
|
|
392
455
|
}
|
|
393
456
|
}
|
|
394
457
|
let toolBlock = "";
|
|
@@ -405,7 +468,7 @@ async function run(ctx) {
|
|
|
405
468
|
// unjudged transcript snippet is a guess, not a memory).
|
|
406
469
|
const episodeMatches = episodeCands
|
|
407
470
|
.filter((c) => keptIds.has(c.id))
|
|
408
|
-
.map((c) => ({ id: c.id, name: c.name, excerpt: c.excerpt, terms: c._terms, why:
|
|
471
|
+
.map((c) => ({ id: c.id, name: c.name, excerpt: c.excerpt, terms: c._terms, why: whyMap ? whyMap.get(c.id) || "" : "" }));
|
|
409
472
|
let episodeBlock = "";
|
|
410
473
|
if (episodeMatches.length) {
|
|
411
474
|
const lines = episodeMatches.map((m) => {
|
|
@@ -417,11 +480,16 @@ async function run(ctx) {
|
|
|
417
480
|
|
|
418
481
|
metrics.logTurn({
|
|
419
482
|
engine: "discoverer",
|
|
483
|
+
model: WALKER_MODEL,
|
|
420
484
|
query: userText,
|
|
421
485
|
tier,
|
|
422
486
|
seeds: seedNodes,
|
|
423
487
|
activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
|
|
424
|
-
|
|
488
|
+
cand: candidates.map((c) => ({ id: c.id, act: !!c.activated })),
|
|
489
|
+
kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "" })),
|
|
490
|
+
auto: (autoKept.size || autoDropped.length || shadowChecks)
|
|
491
|
+
? { kept: [...autoKept.keys()], dropped: autoDropped, shadow: { checks: shadowChecks, agree: shadowAgree } }
|
|
492
|
+
: null,
|
|
425
493
|
gated: false,
|
|
426
494
|
latencyMs: Date.now() - started,
|
|
427
495
|
seedMs,
|
|
@@ -433,8 +501,8 @@ async function run(ctx) {
|
|
|
433
501
|
|
|
434
502
|
return {
|
|
435
503
|
packBlock, entityBlock, toolBlock, episodeBlock, packMatches: finalPacks, entityMatches: finalEnts,
|
|
436
|
-
toolMatches, episodeMatches, why:
|
|
504
|
+
toolMatches, episodeMatches, why: whyMap ? Object.fromEntries(whyMap) : {}, gated: false, tier,
|
|
437
505
|
};
|
|
438
506
|
}
|
|
439
507
|
|
|
440
|
-
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk };
|
|
508
|
+
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict };
|
package/core/recall/graph.js
CHANGED
|
@@ -156,6 +156,33 @@ function reinforceSet(nodeIds, amount = 1) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Negative Hebbian: weaken every edge touching a node that keeps surfacing
|
|
160
|
+
// via the graph but never gets kept or used — firing wrongly, so unwire.
|
|
161
|
+
// Same floor contract as decay(); additionally, `related` edges that fall to
|
|
162
|
+
// their structural base are deleted outright: a link-derived edge re-derives
|
|
163
|
+
// at base weight on the next sync (harmless), while a co-use edge that earned
|
|
164
|
+
// its weight from wrong firings is gone for good.
|
|
165
|
+
function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
|
|
166
|
+
const db = openDb();
|
|
167
|
+
if (!db || !nodeId) return { weakened: 0, pruned: 0 };
|
|
168
|
+
let weakened = 0, pruned = 0;
|
|
169
|
+
try {
|
|
170
|
+
const rows = db.prepare("SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
|
|
171
|
+
for (const e of rows) {
|
|
172
|
+
const floor = BASE_WEIGHT[e.type] || 0;
|
|
173
|
+
const next = Math.max(floor, e.weight * factor);
|
|
174
|
+
if (prune && e.type === "related" && next <= floor + 0.05) {
|
|
175
|
+
db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(e.src, e.dst, e.type);
|
|
176
|
+
pruned++;
|
|
177
|
+
} else if (Math.abs(next - e.weight) > 1e-6) {
|
|
178
|
+
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, e.src, e.dst, e.type);
|
|
179
|
+
weakened++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {}
|
|
183
|
+
return { weakened, pruned };
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
// Exponential time decay on reinforced weight. Structural floor is preserved:
|
|
160
187
|
// an edge never decays below its type's base weight.
|
|
161
188
|
function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
|
|
@@ -377,7 +404,7 @@ module.exports = {
|
|
|
377
404
|
EDGE_TYPES, DEFAULTS, GRAPH_DB,
|
|
378
405
|
available, openDb,
|
|
379
406
|
addEdge, removeEdge, allEdges, neighbors,
|
|
380
|
-
reinforce, reinforceSet, decay, expand,
|
|
381
|
-
syncFromCorpus, parseLinks, pruneOrphans, tend, stats,
|
|
407
|
+
reinforce, reinforceSet, decay, weakenNode, expand,
|
|
408
|
+
syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
|
|
382
409
|
_resetForTest,
|
|
383
410
|
};
|
|
@@ -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) => ({ "&": "&", "<": "<", ">": ">", '"': """ }[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 };
|