@inetafrica/open-claudia 2.6.58 → 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.
- package/README.md +8 -3
- package/bin/cli.js +4 -0
- package/bin/kpi.js +55 -0
- 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/transcript-index.js +21 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +21 -6
package/core/recall/metrics.js
CHANGED
|
@@ -10,7 +10,13 @@ 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";
|
|
@@ -18,19 +24,24 @@ function enabled() {
|
|
|
18
24
|
|
|
19
25
|
// Record one recall turn. `entry`:
|
|
20
26
|
// { engine, query, tier, seeds:[{id,score}], activated:[{id,activation,hop}],
|
|
21
|
-
// kept:[{id,why}],
|
|
27
|
+
// cand:[{id,act}], kept:[{id,why}], auto:{kept:[],dropped:[],shadow:{checks,agree}},
|
|
28
|
+
// gated (bool), latencyMs, seedMs, expandMs, walkerMs,
|
|
22
29
|
// walker ("warm"|"cold"|"off"|"failed"), costUsd }
|
|
23
30
|
function logTurn(entry) {
|
|
24
31
|
if (!enabled()) return;
|
|
25
32
|
try {
|
|
26
33
|
const rec = {
|
|
27
34
|
ts: new Date().toISOString(),
|
|
35
|
+
v: VERSION,
|
|
36
|
+
model: entry.model || "",
|
|
28
37
|
engine: entry.engine || "discoverer",
|
|
29
38
|
query: String(entry.query || "").slice(0, 200),
|
|
30
39
|
tier: entry.tier || "",
|
|
31
40
|
seeds: (entry.seeds || []).map((s) => ({ id: s.id, score: s.score })),
|
|
32
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 })),
|
|
33
43
|
kept: (entry.kept || []).map((k) => ({ id: k.id, why: String(k.why || "").slice(0, 200) })),
|
|
44
|
+
auto: entry.auto || null,
|
|
34
45
|
gated: !!entry.gated,
|
|
35
46
|
latencyMs: entry.latencyMs || 0,
|
|
36
47
|
seedMs: entry.seedMs || 0,
|
|
@@ -41,6 +52,7 @@ function logTurn(entry) {
|
|
|
41
52
|
};
|
|
42
53
|
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
|
|
43
54
|
bumpSummary(rec);
|
|
55
|
+
bumpNodeStats(rec);
|
|
44
56
|
} catch (e) {}
|
|
45
57
|
}
|
|
46
58
|
|
|
@@ -54,16 +66,48 @@ function logUse(openedIds, source) {
|
|
|
54
66
|
const ids = [...new Set((openedIds || []).filter(Boolean))];
|
|
55
67
|
if (!ids.length) return;
|
|
56
68
|
try {
|
|
57
|
-
const rec = { ts: new Date().toISOString(), use: ids };
|
|
69
|
+
const rec = { ts: new Date().toISOString(), v: VERSION, use: ids };
|
|
58
70
|
if (source) rec.source = String(source);
|
|
59
71
|
fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
|
|
60
72
|
const s = readSummary();
|
|
61
|
-
s.
|
|
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 {
|
|
103
|
+
const s = readSummary();
|
|
104
|
+
s.reviewerSkipped = (s.reviewerSkipped || 0) + 1;
|
|
62
105
|
writeSummary(s);
|
|
63
106
|
} catch (e) {}
|
|
64
107
|
}
|
|
65
108
|
|
|
66
109
|
function round(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
|
|
110
|
+
function today() { return new Date().toISOString().slice(0, 10); }
|
|
67
111
|
|
|
68
112
|
function readSummary() {
|
|
69
113
|
try { return JSON.parse(fs.readFileSync(SUMMARY_FILE, "utf8")) || {}; } catch (e) { return {}; }
|
|
@@ -72,6 +116,79 @@ function writeSummary(s) {
|
|
|
72
116
|
try { fs.writeFileSync(SUMMARY_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
|
|
73
117
|
}
|
|
74
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
|
+
|
|
75
192
|
function bumpSummary(rec) {
|
|
76
193
|
const s = readSummary();
|
|
77
194
|
s.turns = (s.turns || 0) + 1;
|
|
@@ -86,6 +203,16 @@ function bumpSummary(rec) {
|
|
|
86
203
|
if (rescues > 0) { s.rescueTurns = (s.rescueTurns || 0) + 1; s.rescues = (s.rescues || 0) + rescues; }
|
|
87
204
|
s.costUsd = round((s.costUsd || 0) + (rec.costUsd || 0));
|
|
88
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
|
+
}
|
|
89
216
|
s.updatedAt = new Date().toISOString();
|
|
90
217
|
writeSummary(s);
|
|
91
218
|
}
|
|
@@ -108,15 +235,82 @@ function summary() {
|
|
|
108
235
|
rescuePct: fmtPct(s.rescueTurns || 0, turns),
|
|
109
236
|
rescues: s.rescues || 0,
|
|
110
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 }])),
|
|
111
245
|
avgLatencyMs: turns ? Math.round((s.latencyMsTotal || 0) / turns) : 0,
|
|
112
246
|
costUsd: s.costUsd || 0,
|
|
113
247
|
updatedAt: s.updatedAt || "",
|
|
114
248
|
};
|
|
115
249
|
}
|
|
116
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
|
+
|
|
117
305
|
function _resetForTest() {
|
|
118
306
|
try { fs.rmSync(LOG_FILE, { force: true }); } catch (e) {}
|
|
119
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) {}
|
|
120
310
|
}
|
|
121
311
|
|
|
122
|
-
module.exports = {
|
|
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 };
|
package/core/runner.js
CHANGED
|
@@ -1027,10 +1027,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1027
1027
|
// `open-claudia pack show <dir>` / `entity show <slug>` — so the banner
|
|
1028
1028
|
// reflects what was read, not what was pushed. (consumeLastInjected is
|
|
1029
1029
|
// drained here to keep the per-turn buffer from leaking into the next turn.)
|
|
1030
|
+
let turnRecallTier = "";
|
|
1030
1031
|
try {
|
|
1031
1032
|
const injected = require("./system-prompt").consumeLastInjected();
|
|
1032
1033
|
if (injected && injected.recall) {
|
|
1033
1034
|
const r = injected.recall;
|
|
1035
|
+
turnRecallTier = String(r.tier || "");
|
|
1034
1036
|
const esc = (s) => String(s).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
1035
1037
|
const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
|
|
1036
1038
|
// 🧠 packs/entities recall — gated by /recall.
|
|
@@ -1517,10 +1519,12 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1517
1519
|
// Post-turn pack review: fire-and-forget on a cheap model; never
|
|
1518
1520
|
// blocks queue drain or the next turn.
|
|
1519
1521
|
if ((code === 0 || code === null) && assistantText.trim()) {
|
|
1522
|
+
const WRITE_TOOLS = new Set(["Edit", "Write", "NotebookEdit", "Bash", "Shell", "Skill", "Task", "Agent"]);
|
|
1520
1523
|
packReview.reviewTurn({
|
|
1521
1524
|
userText: prompt,
|
|
1522
1525
|
assistantText,
|
|
1523
1526
|
channelId,
|
|
1527
|
+
signals: { tier: turnRecallTier, wrote: toolUses.some((t) => WRITE_TOOLS.has(t)) },
|
|
1524
1528
|
announce: (text) => chatContext.run(store, () => send(text)),
|
|
1525
1529
|
});
|
|
1526
1530
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -748,6 +748,7 @@ async function promptWithDynamicContext(prompt, opts = {}) {
|
|
|
748
748
|
lastInjected.recall = {
|
|
749
749
|
engine: engine.name || recall.activeEngineName(settings),
|
|
750
750
|
gated: !!result.gated,
|
|
751
|
+
tier: result.tier || "",
|
|
751
752
|
packs: (result.packMatches || []).map((m) => ({ name: m.name || m.dir, why: why[`pack:${m.dir}`] || "" })),
|
|
752
753
|
entities: (result.entityMatches || []).map((m) => ({ name: m.name || m.slug, why: why[`entity:${m.slug}`] || "" })),
|
|
753
754
|
// Tools surfaced as first-class discoverer nodes this turn (name + why).
|
package/core/transcript-index.js
CHANGED
|
@@ -145,6 +145,26 @@ function rebuild(transcriptsDir = defaultTranscriptsDir()) {
|
|
|
145
145
|
return indexAll(transcriptsDir);
|
|
146
146
|
}
|
|
147
147
|
|
|
148
|
+
// Nightly upkeep: catch up new appends, then drop index rows for transcript
|
|
149
|
+
// files that no longer exist on disk — dead sessions stop matching and the
|
|
150
|
+
// FTS table stays proportional to what is actually recallable.
|
|
151
|
+
function tend(transcriptsDir = defaultTranscriptsDir()) {
|
|
152
|
+
const db = open(transcriptsDir);
|
|
153
|
+
if (!db) return { available: false, added: 0, prunedFiles: 0 };
|
|
154
|
+
const { added } = indexAll(transcriptsDir);
|
|
155
|
+
let prunedFiles = 0;
|
|
156
|
+
try {
|
|
157
|
+
const rows = db.prepare("SELECT path FROM files").all();
|
|
158
|
+
for (const r of rows) {
|
|
159
|
+
if (fs.existsSync(r.path)) continue;
|
|
160
|
+
db.prepare("DELETE FROM entries WHERE file = ?").run(r.path);
|
|
161
|
+
db.prepare("DELETE FROM files WHERE path = ?").run(r.path);
|
|
162
|
+
prunedFiles++;
|
|
163
|
+
}
|
|
164
|
+
} catch (e) {}
|
|
165
|
+
return { available: true, added, prunedFiles };
|
|
166
|
+
}
|
|
167
|
+
|
|
148
168
|
// FTS5 MATCH chokes on unbalanced quotes/operators in natural queries, so
|
|
149
169
|
// by default each term is quoted (implicit AND). Pass raw=true for full
|
|
150
170
|
// FTS5 query syntax (NEAR, OR, prefix*).
|
|
@@ -184,4 +204,4 @@ function stats(transcriptsDir = defaultTranscriptsDir()) {
|
|
|
184
204
|
}
|
|
185
205
|
}
|
|
186
206
|
|
|
187
|
-
module.exports = { available, open, indexFile, indexAll, rebuild, search, stats, sanitizeQuery, defaultTranscriptsDir };
|
|
207
|
+
module.exports = { available, open, indexFile, indexAll, rebuild, tend, search, stats, sanitizeQuery, defaultTranscriptsDir };
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "2.6.
|
|
3
|
+
"version": "2.6.59",
|
|
4
4
|
"description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
"scripts": {
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
|
|
12
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-evolution.js"
|
|
13
13
|
},
|
|
14
14
|
"files": [
|
|
15
15
|
"bot.js",
|
|
@@ -1,6 +1,9 @@
|
|
|
1
1
|
// Discoverer engine: pre-gate, seed → (graph) → fail-open injection. The LLM
|
|
2
2
|
// walker is disabled here (RECALL_DISCOVERER_WALKER=off) so the engine takes
|
|
3
|
-
// its deterministic fail-open path:
|
|
3
|
+
// its deterministic fail-open path. Contract: the seeds tier keeps ALL
|
|
4
|
+
// user-origin keyword seeds (packs, entities, tools); the full tier without a
|
|
5
|
+
// judge keeps only the top-3 user-origin pack/entity seeds — an unjudged tool
|
|
6
|
+
// or episode is a guess, not a memory.
|
|
4
7
|
const assert = require("assert");
|
|
5
8
|
const fs = require("fs");
|
|
6
9
|
const os = require("os");
|
|
@@ -101,16 +104,28 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
|
|
|
101
104
|
});
|
|
102
105
|
assert.strictEqual(typeof out2.packBlock, "string");
|
|
103
106
|
|
|
104
|
-
// tool seeding: a
|
|
105
|
-
// renders
|
|
107
|
+
// tool seeding (seeds tier): a ≤4-word command matching a tool keeps it as a
|
|
108
|
+
// user-origin seed and renders the toolBlock; no tool match → empty toolBlock
|
|
106
109
|
const outTool = await disc.run({
|
|
107
|
-
userText: "audit
|
|
108
|
-
fullContext: "audit
|
|
110
|
+
userText: "audit mikrotik router fleet", contextText: "",
|
|
111
|
+
fullContext: "audit mikrotik router fleet", packLimit: 6, budget: {}, helpers,
|
|
109
112
|
});
|
|
110
|
-
assert.
|
|
113
|
+
assert.strictEqual(outTool.tier, "seeds", "4-word command lands on the seeds tier");
|
|
114
|
+
assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (seeds-tier user seed)");
|
|
111
115
|
assert.ok(/mikrotik-audit/.test(outTool.toolBlock), "kept tool renders into the toolBlock");
|
|
112
116
|
assert.ok(/Tools that may help here/.test(outTool.toolBlock), "toolBlock carries its heading");
|
|
113
117
|
|
|
118
|
+
// full-tier fail-open: with no judge, an unjudged TOOL is a guess — dropped.
|
|
119
|
+
// (Measured before the fix: a failed walk kept 15 unjudged seeds including
|
|
120
|
+
// WiFi tools for a dream question.) Pack/entity user seeds still inject.
|
|
121
|
+
const outToolFull = await disc.run({
|
|
122
|
+
userText: "audit the mikrotik router fleet inventory please", contextText: "",
|
|
123
|
+
fullContext: "audit the mikrotik router fleet inventory please", packLimit: 6, budget: {}, helpers,
|
|
124
|
+
});
|
|
125
|
+
assert.strictEqual(outToolFull.tier, "full", "substantive ask lands on the full tier");
|
|
126
|
+
assert.strictEqual(outToolFull.toolMatches.length, 0, "fail-open drops unjudged tools at full tier");
|
|
127
|
+
assert.strictEqual(outToolFull.toolBlock, "", "no unjudged toolBlock at full tier");
|
|
128
|
+
|
|
114
129
|
const outNoTool = await disc.run({
|
|
115
130
|
userText: "help with the mobile app", contextText: "", fullContext: "help with the mobile app",
|
|
116
131
|
packLimit: 6, budget: {}, helpers,
|