@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.
@@ -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).
@@ -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.58",
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: keep user-origin seeds, drop guesses.
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 tool matching the user text surfaces as a kept tool match and
105
- // renders into toolBlock; a turn that matches no tool yields an empty toolBlock
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 the mikrotik router fleet", contextText: "",
108
- fullContext: "audit the mikrotik router fleet", packLimit: 6, budget: {}, helpers,
110
+ userText: "audit mikrotik router fleet", contextText: "",
111
+ fullContext: "audit mikrotik router fleet", packLimit: 6, budget: {}, helpers,
109
112
  });
110
- assert.ok(outTool.toolMatches.some((m) => m.name === "mikrotik-audit"), "matching tool is kept (fail-open user seed)");
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,