@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
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).
|