@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.
@@ -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}], gated (bool), latencyMs, seedMs, expandMs, walkerMs,
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.opens = (s.opens || 0) + ids.length;
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 = { 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 };
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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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
  }
@@ -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).