@inetafrica/open-claudia 2.6.33 → 2.6.34

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.
@@ -0,0 +1,366 @@
1
+ // Typed-edge recall graph. One small SQLite table of weighted edges over the
2
+ // shared corpus (packs + entities). Node ids are `pack:<dir>` / `entity:<slug>`.
3
+ //
4
+ // Edge types:
5
+ // parent — hierarchy (kazee-mobile → kazee). Derived from pack `parent`.
6
+ // governed-by — cross-cutting concern (kazee-mobile → kazee-theme). Derived
7
+ // from [[links]] that point at a shared concern, or asserted.
8
+ // related — soft association. Derived from [[links]] and reinforced by
9
+ // co-use (Hebbian).
10
+ //
11
+ // Edges are stored directionally but traversed undirected. Each edge carries a
12
+ // `weight` and `last_reinforced`. Structural edges (parent/related from pack
13
+ // metadata) are re-derived idempotently; their weight floor is kept but any
14
+ // extra reinforcement earned through use is preserved.
15
+
16
+ const fs = require("fs");
17
+ const path = require("path");
18
+
19
+ let DatabaseSync = null;
20
+ try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node — graph disabled */ }
21
+
22
+ const CONFIG_DIR = require("../../config-dir");
23
+ const GRAPH_DB = process.env.RECALL_GRAPH_DB
24
+ ? path.resolve(process.env.RECALL_GRAPH_DB)
25
+ : path.join(CONFIG_DIR, "recall-graph.db");
26
+
27
+ const EDGE_TYPES = ["parent", "governed-by", "related"];
28
+
29
+ // Base weights when an edge is first derived from structure.
30
+ const BASE_WEIGHT = { parent: 3, "governed-by": 2.5, related: 1 };
31
+ // Spreading-activation tunables (overridable via env / dream knobs).
32
+ const DEFAULTS = {
33
+ hops: 2,
34
+ threshold: 0.18, // firing threshold as a fraction of the strongest seed
35
+ damping: 0.55, // fraction of activation transmitted per hop
36
+ perNodeTopK: 6, // cap fan-out per node (sparsity)
37
+ maxWeight: 6, // weight at which an edge transmits at full damping
38
+ halfLifeDays: 60, // Hebbian weight half-life for decay()
39
+ };
40
+
41
+ let _db = null;
42
+
43
+ function available() { return !!DatabaseSync; }
44
+
45
+ function openDb() {
46
+ if (!DatabaseSync) return null;
47
+ if (_db) return _db;
48
+ try {
49
+ fs.mkdirSync(path.dirname(GRAPH_DB), { recursive: true, mode: 0o700 });
50
+ const db = new DatabaseSync(GRAPH_DB);
51
+ try { fs.chmodSync(GRAPH_DB, 0o600); } catch (e) {}
52
+ db.exec("PRAGMA journal_mode=WAL");
53
+ db.exec("PRAGMA busy_timeout=3000");
54
+ db.exec(`CREATE TABLE IF NOT EXISTS edges (
55
+ src TEXT NOT NULL,
56
+ dst TEXT NOT NULL,
57
+ type TEXT NOT NULL,
58
+ weight REAL NOT NULL DEFAULT 1,
59
+ last_reinforced TEXT,
60
+ created TEXT,
61
+ PRIMARY KEY (src, dst, type)
62
+ )`);
63
+ db.exec("CREATE INDEX IF NOT EXISTS edges_src ON edges (src)");
64
+ db.exec("CREATE INDEX IF NOT EXISTS edges_dst ON edges (dst)");
65
+ _db = db;
66
+ return db;
67
+ } catch (e) {
68
+ return null;
69
+ }
70
+ }
71
+
72
+ function now() { return new Date().toISOString(); }
73
+
74
+ function normType(t) {
75
+ const s = String(t || "").toLowerCase().trim();
76
+ return EDGE_TYPES.includes(s) ? s : "related";
77
+ }
78
+
79
+ // Upsert an edge. By default keeps the larger of the existing/new weight so a
80
+ // structural re-derive never stomps reinforcement; pass `{ bump }` to add to
81
+ // the current weight (Hebbian) and stamp last_reinforced.
82
+ function addEdge(src, dst, type, opts = {}) {
83
+ const db = openDb();
84
+ if (!db || !src || !dst || src === dst) return false;
85
+ const t = normType(type);
86
+ const weight = Number.isFinite(opts.weight) ? opts.weight : (BASE_WEIGHT[t] || 1);
87
+ const bump = Number.isFinite(opts.bump) ? opts.bump : 0;
88
+ try {
89
+ const existing = db.prepare("SELECT weight FROM edges WHERE src=? AND dst=? AND type=?").get(src, dst, t);
90
+ if (existing) {
91
+ const next = bump ? existing.weight + bump : Math.max(existing.weight, weight);
92
+ const stamp = bump ? now() : null;
93
+ if (stamp) {
94
+ db.prepare("UPDATE edges SET weight=?, last_reinforced=? WHERE src=? AND dst=? AND type=?")
95
+ .run(next, stamp, src, dst, t);
96
+ } else {
97
+ db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, src, dst, t);
98
+ }
99
+ } else {
100
+ db.prepare("INSERT INTO edges (src, dst, type, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
101
+ .run(src, dst, t, weight + bump, bump ? now() : null, now());
102
+ }
103
+ return true;
104
+ } catch (e) {
105
+ return false;
106
+ }
107
+ }
108
+
109
+ function removeEdge(src, dst, type) {
110
+ const db = openDb();
111
+ if (!db) return false;
112
+ try {
113
+ if (type) db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(src, dst, normType(type));
114
+ else db.prepare("DELETE FROM edges WHERE src=? AND dst=?").run(src, dst);
115
+ return true;
116
+ } catch (e) { return false; }
117
+ }
118
+
119
+ function allEdges() {
120
+ const db = openDb();
121
+ if (!db) return [];
122
+ try { return db.prepare("SELECT src, dst, type, weight, last_reinforced FROM edges").all(); }
123
+ catch (e) { return []; }
124
+ }
125
+
126
+ // Undirected neighbours of a node: edges where it is src OR dst.
127
+ function neighbors(nodeId) {
128
+ const db = openDb();
129
+ if (!db || !nodeId) return [];
130
+ try {
131
+ const rows = db.prepare(
132
+ "SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?"
133
+ ).all(nodeId, nodeId);
134
+ return rows.map((r) => ({
135
+ node: r.src === nodeId ? r.dst : r.src,
136
+ type: r.type,
137
+ weight: r.weight,
138
+ }));
139
+ } catch (e) { return []; }
140
+ }
141
+
142
+ // Hebbian co-use reinforcement. Called when two nodes are actually USED
143
+ // together (📖 banner), not merely co-recalled. Bumps a `related` edge both
144
+ // ways so traversal from either side benefits.
145
+ function reinforce(a, b, amount = 1) {
146
+ if (!a || !b || a === b) return;
147
+ addEdge(a, b, "related", { bump: amount });
148
+ addEdge(b, a, "related", { bump: amount });
149
+ }
150
+
151
+ // Reinforce every unordered pair in a used set.
152
+ function reinforceSet(nodeIds, amount = 1) {
153
+ const ids = [...new Set((nodeIds || []).filter(Boolean))];
154
+ for (let i = 0; i < ids.length; i++) {
155
+ for (let j = i + 1; j < ids.length; j++) reinforce(ids[i], ids[j], amount);
156
+ }
157
+ }
158
+
159
+ // Exponential time decay on reinforced weight. Structural floor is preserved:
160
+ // an edge never decays below its type's base weight.
161
+ function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
162
+ const db = openDb();
163
+ if (!db) return 0;
164
+ const rows = allEdges();
165
+ const nowMs = Date.now();
166
+ const ln2 = Math.log(2);
167
+ let changed = 0;
168
+ for (const e of rows) {
169
+ if (!e.last_reinforced) continue;
170
+ const ageDays = (nowMs - Date.parse(e.last_reinforced)) / 86400000;
171
+ if (!(ageDays > 0)) continue;
172
+ const factor = Math.exp(-ln2 * ageDays / Math.max(1, halfLifeDays));
173
+ const floor = BASE_WEIGHT[e.type] || 0;
174
+ const next = Math.max(floor, e.weight * factor);
175
+ if (Math.abs(next - e.weight) > 1e-6) {
176
+ try {
177
+ db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?")
178
+ .run(next, e.src, e.dst, e.type);
179
+ changed++;
180
+ } catch (e2) {}
181
+ }
182
+ }
183
+ return changed;
184
+ }
185
+
186
+ // Spreading activation. `seeds` is [{ id, score }] (score = match strength).
187
+ // Returns Map id -> { activation, hop, via } for nodes that fired above the
188
+ // threshold, EXCLUDING the original seeds (those are already in the result set
189
+ // the caller seeded from). Pure in-memory over an adjacency snapshot.
190
+ function expand(seeds, opts = {}) {
191
+ const o = { ...DEFAULTS, ...opts };
192
+ const out = new Map();
193
+ const seedList = (seeds || []).filter((s) => s && s.id);
194
+ if (!seedList.length || !available()) return out;
195
+
196
+ // Build adjacency once, keeping the strongest edge per (node, neighbour).
197
+ const adj = new Map();
198
+ for (const e of allEdges()) {
199
+ const w = e.weight;
200
+ const push = (from, to) => {
201
+ if (!adj.has(from)) adj.set(from, new Map());
202
+ const m = adj.get(from);
203
+ if (!m.has(to) || m.get(to) < w) m.set(to, w);
204
+ };
205
+ push(e.src, e.dst);
206
+ push(e.dst, e.src);
207
+ }
208
+
209
+ const maxSeed = Math.max(...seedList.map((s) => Number(s.score) || 1), 1);
210
+ const fireThreshold = o.threshold * maxSeed;
211
+ const activation = new Map();
212
+ const seedIds = new Set(seedList.map((s) => s.id));
213
+ let frontier = [];
214
+ for (const s of seedList) {
215
+ const a = Number(s.score) || 1;
216
+ activation.set(s.id, a);
217
+ frontier.push({ id: s.id, a });
218
+ }
219
+
220
+ for (let hop = 1; hop <= o.hops && frontier.length; hop++) {
221
+ const next = new Map();
222
+ for (const { id, a } of frontier) {
223
+ const m = adj.get(id);
224
+ if (!m) continue;
225
+ // top-k strongest neighbours (sparsity)
226
+ const ranked = [...m.entries()].sort((x, y) => y[1] - x[1]).slice(0, o.perNodeTopK);
227
+ for (const [nb, w] of ranked) {
228
+ const transmit = a * (Math.min(w, o.maxWeight) / o.maxWeight) * o.damping;
229
+ if (transmit <= 0) continue;
230
+ const prev = activation.get(nb) || 0;
231
+ const sum = prev + transmit;
232
+ activation.set(nb, sum);
233
+ if (!seedIds.has(nb) && sum >= fireThreshold) {
234
+ const cur = out.get(nb);
235
+ if (!cur || cur.activation < sum) out.set(nb, { activation: sum, hop, via: id });
236
+ // only freshly-fired nodes propagate onward
237
+ const best = next.get(nb) || 0;
238
+ if (transmit > best) next.set(nb, transmit);
239
+ }
240
+ }
241
+ }
242
+ frontier = [...next.entries()].map(([id, a]) => ({ id, a }));
243
+ }
244
+ return out;
245
+ }
246
+
247
+ // ---------------------------------------------------------------------------
248
+ // Structural sync: derive edges from the corpus so the graph is never empty
249
+ // even before any reinforcement. Idempotent — re-runnable any time.
250
+ // pack `parent` frontmatter → parent edge (child → parent)
251
+ // [[link]] in any pack/entity body → related edge (or governed-by if the
252
+ // target is tagged a shared concern)
253
+ // Existing reinforced weights are preserved (addEdge keeps the max).
254
+
255
+ function parseLinks(text) {
256
+ const out = [];
257
+ const re = /\[\[([^\]]+)\]\]/g;
258
+ let m;
259
+ while ((m = re.exec(String(text || "")))) {
260
+ const slug = m[1].trim().toLowerCase();
261
+ if (slug) out.push(slug);
262
+ }
263
+ return out;
264
+ }
265
+
266
+ function isSharedConcern(pack) {
267
+ const tags = (pack.tags || []).map((t) => t.toLowerCase());
268
+ return tags.includes("shared") || tags.includes("concern") || tags.includes("cross-cutting");
269
+ }
270
+
271
+ function syncFromCorpus(packsLib, entitiesLib) {
272
+ if (!openDb()) return { edges: 0 };
273
+ let packs = [];
274
+ let entities = [];
275
+ try { packs = packsLib.listPacks(); } catch (e) {}
276
+ try { entities = entitiesLib ? entitiesLib.listEntities() : []; } catch (e) {}
277
+
278
+ const packByDir = new Map(packs.map((p) => [p.dir, p]));
279
+ const packByName = new Map(packs.map((p) => [p.name.toLowerCase(), p]));
280
+ const entBySlug = new Map(entities.map((e) => [e.slug, e]));
281
+ const entByName = new Map(entities.map((e) => [e.name.toLowerCase(), e]));
282
+
283
+ const resolve = (token) => {
284
+ if (packByDir.has(token)) return `pack:${token}`;
285
+ if (packByName.has(token)) return `pack:${packByName.get(token).dir}`;
286
+ if (entBySlug.has(token)) return `entity:${token}`;
287
+ if (entByName.has(token)) return `entity:${entByName.get(token).slug}`;
288
+ return null;
289
+ };
290
+
291
+ let count = 0;
292
+ for (const p of packs) {
293
+ const id = `pack:${p.dir}`;
294
+ if (p.parent) {
295
+ const parentId = resolve(String(p.parent).toLowerCase());
296
+ if (parentId && addEdge(id, parentId, "parent")) count++;
297
+ }
298
+ const body = [p.sections.Stance, p.sections.Procedure, p.sections.State, p.sections.Journal].join("\n");
299
+ for (const link of parseLinks(body)) {
300
+ const target = resolve(link);
301
+ if (!target || target === id) continue;
302
+ const targetPack = target.startsWith("pack:") ? packByDir.get(target.slice(5)) : null;
303
+ const type = targetPack && isSharedConcern(targetPack) ? "governed-by" : "related";
304
+ if (addEdge(id, target, type)) count++;
305
+ }
306
+ }
307
+ for (const e of entities) {
308
+ const id = `entity:${e.slug}`;
309
+ const body = [e.sections.Notes, e.sections.Log].join("\n");
310
+ for (const link of parseLinks(body)) {
311
+ const target = resolve(link);
312
+ if (!target || target === id) continue;
313
+ if (addEdge(id, target, "related")) count++;
314
+ }
315
+ }
316
+ return { edges: count };
317
+ }
318
+
319
+ // Prune edges whose endpoints no longer exist in the corpus (a pack/entity was
320
+ // removed or archived). Keeps the graph from accumulating dangling links.
321
+ function pruneOrphans(packsLib, entitiesLib) {
322
+ const db = openDb();
323
+ if (!db) return 0;
324
+ const live = new Set();
325
+ try { for (const p of packsLib.listPacks()) live.add(`pack:${p.dir}`); } catch (e) {}
326
+ try { for (const e of (entitiesLib ? entitiesLib.listEntities() : [])) live.add(`entity:${e.slug}`); } catch (e) {}
327
+ if (!live.size) return 0;
328
+ let removed = 0;
329
+ for (const e of allEdges()) {
330
+ if (!live.has(e.src) || !live.has(e.dst)) {
331
+ if (removeEdge(e.src, e.dst, e.type)) removed++;
332
+ }
333
+ }
334
+ return removed;
335
+ }
336
+
337
+ // Nightly maintenance: refresh structural edges, decay reinforced weights,
338
+ // drop orphans. Deterministic + safe — the high-judgment "extract shared
339
+ // concern node" surgery is left to the dream model proper.
340
+ function tend(packsLib, entitiesLib, opts = {}) {
341
+ const synced = syncFromCorpus(packsLib, entitiesLib);
342
+ const decayed = decay(opts);
343
+ const pruned = pruneOrphans(packsLib, entitiesLib);
344
+ return { synced: synced.edges, decayed, pruned, ...stats() };
345
+ }
346
+
347
+ function stats() {
348
+ const rows = allEdges();
349
+ const byType = {};
350
+ for (const e of rows) byType[e.type] = (byType[e.type] || 0) + 1;
351
+ const nodes = new Set();
352
+ for (const e of rows) { nodes.add(e.src); nodes.add(e.dst); }
353
+ return { edges: rows.length, nodes: nodes.size, byType };
354
+ }
355
+
356
+ // Test seam.
357
+ function _resetForTest() { if (_db) { try { _db.close(); } catch (e) {} } _db = null; }
358
+
359
+ module.exports = {
360
+ EDGE_TYPES, DEFAULTS, GRAPH_DB,
361
+ available, openDb,
362
+ addEdge, removeEdge, allEdges, neighbors,
363
+ reinforce, reinforceSet, decay, expand,
364
+ syncFromCorpus, parseLinks, pruneOrphans, tend, stats,
365
+ _resetForTest,
366
+ };
@@ -0,0 +1,33 @@
1
+ // Recall engine registry + selector.
2
+ //
3
+ // One shared corpus (packs/entities), multiple pluggable retrieval engines.
4
+ // Each engine implements: async run(ctx) -> { packBlock, entityBlock,
5
+ // packMatches, entityMatches }. The active engine is chosen per channel via
6
+ // the `recallEngine` setting (set by the /engine slash command), falling back
7
+ // to the RECALL_ENGINE env var, then to "classic". Unknown names fall back to
8
+ // classic so a bad value can never break recall.
9
+
10
+ const classic = require("./classic");
11
+ const discoverer = require("./discoverer");
12
+
13
+ const ENGINES = {
14
+ classic,
15
+ discoverer,
16
+ };
17
+
18
+ function listEngines() {
19
+ return Object.keys(ENGINES);
20
+ }
21
+
22
+ function activeEngineName(settings) {
23
+ const fromSettings = settings && settings.recallEngine;
24
+ const fromEnv = process.env.RECALL_ENGINE;
25
+ const name = String(fromSettings || fromEnv || "classic").toLowerCase();
26
+ return ENGINES[name] ? name : "classic";
27
+ }
28
+
29
+ function getEngine(name) {
30
+ return ENGINES[name] || classic;
31
+ }
32
+
33
+ module.exports = { ENGINES, listEngines, activeEngineName, getEngine };
@@ -0,0 +1,109 @@
1
+ // Recall metrics. One JSONL line per discoverer turn plus a rolling summary,
2
+ // so we can compute precision (surfaced→used), miss-rescue rate, and noise
3
+ // (surfaced-but-never-opened) over time and let the dream tune knobs against
4
+ // real numbers. Engine-agnostic: the classic engine keeps its own legacy
5
+ // recall-debug.jsonl; this is the richer log the discoverer engine writes.
6
+
7
+ const fs = require("fs");
8
+ const path = require("path");
9
+ const CONFIG_DIR = require("../../config-dir");
10
+
11
+ const LOG_FILE = path.join(CONFIG_DIR, "recall-metrics.jsonl");
12
+ const SUMMARY_FILE = path.join(CONFIG_DIR, "recall-metrics-summary.json");
13
+ const MAX_OPEN_TRACK = 400; // recent turns kept to reconcile surfaced→opened
14
+
15
+ function enabled() {
16
+ return String(process.env.RECALL_METRICS || "on").toLowerCase() !== "off";
17
+ }
18
+
19
+ // Record one recall turn. `entry`:
20
+ // { engine, query, seeds:[{id,score}], activated:[{id,activation,hop}],
21
+ // kept:[{id,why}], gated (bool), latencyMs, costUsd }
22
+ function logTurn(entry) {
23
+ if (!enabled()) return;
24
+ try {
25
+ const rec = {
26
+ ts: new Date().toISOString(),
27
+ engine: entry.engine || "discoverer",
28
+ query: String(entry.query || "").slice(0, 200),
29
+ seeds: (entry.seeds || []).map((s) => ({ id: s.id, score: s.score })),
30
+ activated: (entry.activated || []).map((a) => ({ id: a.id, activation: round(a.activation), hop: a.hop })),
31
+ kept: (entry.kept || []).map((k) => ({ id: k.id, why: String(k.why || "").slice(0, 200) })),
32
+ gated: !!entry.gated,
33
+ latencyMs: entry.latencyMs || 0,
34
+ costUsd: entry.costUsd || 0,
35
+ };
36
+ fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
37
+ bumpSummary(rec);
38
+ } catch (e) {}
39
+ }
40
+
41
+ // Record which nodes were actually opened (📖) this turn. Matched against the
42
+ // most recent surfaced sets to derive precision/noise without a second pass
43
+ // over the whole log.
44
+ function logUse(openedIds) {
45
+ if (!enabled()) return;
46
+ const ids = [...new Set((openedIds || []).filter(Boolean))];
47
+ if (!ids.length) return;
48
+ try {
49
+ fs.appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), use: ids }) + "\n");
50
+ const s = readSummary();
51
+ s.opens = (s.opens || 0) + ids.length;
52
+ writeSummary(s);
53
+ } catch (e) {}
54
+ }
55
+
56
+ function round(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
57
+
58
+ function readSummary() {
59
+ try { return JSON.parse(fs.readFileSync(SUMMARY_FILE, "utf8")) || {}; } catch (e) { return {}; }
60
+ }
61
+ function writeSummary(s) {
62
+ try { fs.writeFileSync(SUMMARY_FILE, JSON.stringify(s, null, 2)); } catch (e) {}
63
+ }
64
+
65
+ function bumpSummary(rec) {
66
+ const s = readSummary();
67
+ s.turns = (s.turns || 0) + 1;
68
+ if (rec.gated) s.gatedTurns = (s.gatedTurns || 0) + 1;
69
+ s.seedsTotal = (s.seedsTotal || 0) + rec.seeds.length;
70
+ s.activatedTotal = (s.activatedTotal || 0) + rec.activated.length;
71
+ s.keptTotal = (s.keptTotal || 0) + rec.kept.length;
72
+ // miss-rescue: a kept node that no seed matched (came purely from graph expansion)
73
+ const seedIds = new Set(rec.seeds.map((x) => x.id));
74
+ const rescues = rec.kept.filter((k) => !seedIds.has(k.id)).length;
75
+ if (rescues > 0) { s.rescueTurns = (s.rescueTurns || 0) + 1; s.rescues = (s.rescues || 0) + rescues; }
76
+ s.costUsd = round((s.costUsd || 0) + (rec.costUsd || 0));
77
+ s.latencyMsTotal = (s.latencyMsTotal || 0) + (rec.latencyMs || 0);
78
+ s.updatedAt = new Date().toISOString();
79
+ writeSummary(s);
80
+ }
81
+
82
+ // Human-readable stats view (rendered by `open-claudia recall-stats`).
83
+ function summary() {
84
+ const s = readSummary();
85
+ const turns = s.turns || 0;
86
+ const fmtPct = (n, d) => (d ? `${Math.round((n / d) * 100)}%` : "—");
87
+ return {
88
+ turns,
89
+ gatedTurns: s.gatedTurns || 0,
90
+ gatedPct: fmtPct(s.gatedTurns || 0, turns),
91
+ avgSeeds: turns ? round((s.seedsTotal || 0) / turns) : 0,
92
+ avgActivated: turns ? round((s.activatedTotal || 0) / turns) : 0,
93
+ avgKept: turns ? round((s.keptTotal || 0) / turns) : 0,
94
+ rescueTurns: s.rescueTurns || 0,
95
+ rescuePct: fmtPct(s.rescueTurns || 0, turns),
96
+ rescues: s.rescues || 0,
97
+ opens: s.opens || 0,
98
+ avgLatencyMs: turns ? Math.round((s.latencyMsTotal || 0) / turns) : 0,
99
+ costUsd: s.costUsd || 0,
100
+ updatedAt: s.updatedAt || "",
101
+ };
102
+ }
103
+
104
+ function _resetForTest() {
105
+ try { fs.rmSync(LOG_FILE, { force: true }); } catch (e) {}
106
+ try { fs.rmSync(SUMMARY_FILE, { force: true }); } catch (e) {}
107
+ }
108
+
109
+ module.exports = { LOG_FILE, SUMMARY_FILE, enabled, logTurn, logUse, summary, _resetForTest };
package/core/runner.js CHANGED
@@ -846,6 +846,9 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
846
846
  // actually pulls a full pack/entity via the CLI (it decided it was worth
847
847
  // reading), not when a headline was auto-injected. Deduped per turn through
848
848
  // notifySkill's Set so repeated `pack show` calls don't spam the chat.
849
+ // Nodes the agent actually OPENED this turn (📖). This is the co-use signal
850
+ // the recall graph reinforces on — actually-read, not merely surfaced.
851
+ const openedThisTurn = new Set();
849
852
  const noteRecallFromShell = (command) => {
850
853
  try {
851
854
  const cmd = String(command || "");
@@ -856,6 +859,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
856
859
  const dir = m[1];
857
860
  const pack = packsLib.readPack(dir);
858
861
  const name = (pack && (pack.name || pack.dir)) || dir;
862
+ openedThisTurn.add(`pack:${dir}`);
859
863
  notifySkill(`recall:pack:${dir}`, `📖 Recalled my notes on: ${name}`);
860
864
  }
861
865
  const entRe = /\bentity\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
@@ -863,6 +867,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
863
867
  const slug = m[1];
864
868
  const ent = entitiesLib.readEntity(slug);
865
869
  const name = (ent && (ent.name || ent.slug)) || slug;
870
+ openedThisTurn.add(`entity:${slug}`);
866
871
  notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
867
872
  }
868
873
  } catch (e) { /* announcements are best-effort */ }
@@ -1130,6 +1135,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1130
1135
  if (settings.budget) settings.budget = null;
1131
1136
  state.statusMessageId = null;
1132
1137
 
1138
+ // Hebbian co-use: nodes the agent actually opened together this turn get
1139
+ // their `related` edges reinforced, so future spreading activation pulls
1140
+ // the cluster together. Reinforce on co-USE (📖), never co-recall.
1141
+ if (openedThisTurn.size > 0) {
1142
+ try {
1143
+ const recallGraph = require("./recall/graph");
1144
+ if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
1145
+ require("./recall/metrics").logUse([...openedThisTurn]);
1146
+ } catch (e) { /* best-effort */ }
1147
+ }
1148
+
1133
1149
  // Post-turn pack review: fire-and-forget on a cheap model; never
1134
1150
  // blocks queue drain or the next turn.
1135
1151
  if ((code === 0 || code === null) && assistantText.trim()) {
package/core/state.js CHANGED
@@ -43,7 +43,7 @@ const savedState = (() => {
43
43
  const userStates = new Map();
44
44
 
45
45
  function freshSettings() {
46
- return { model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude", compactWindow: null };
46
+ return { model: null, effort: null, budget: null, permissionMode: null, worktree: false, backend: "claude", compactWindow: null, recallEngine: null };
47
47
  }
48
48
 
49
49
  function freshUsage() {
@@ -623,32 +623,19 @@ async function promptWithDynamicContext(prompt, opts = {}) {
623
623
  const fullContext = [contextText, historyText].filter(Boolean).join("\n\n");
624
624
  const packsLib = require("./packs");
625
625
  const entitiesLib = require("./entities");
626
- let packMatches = [];
627
- let entityMatches = [];
628
626
  const packLimit = packMatchLimit();
629
- try {
630
- packMatches = mergeMatches(
631
- packsLib.matchPacks(userText, { limit: packLimit }),
632
- fullContext ? packsLib.matchPacks(fullContext, { limit: packLimit }) : [],
633
- (m) => m.dir,
634
- );
635
- } catch (e) {}
636
- try {
637
- entityMatches = mergeMatches(
638
- entitiesLib.matchEntities(userText, { limit: 4 }),
639
- fullContext ? entitiesLib.matchEntities(fullContext, { limit: 4 }) : [],
640
- (m) => m.slug,
641
- );
642
- } catch (e) {}
643
- const candPacks = packMatches;
644
- const candEntities = entityMatches;
645
- ({ packMatches, entityMatches } = await filterMatches(userText, fullContext, packMatches, entityMatches));
646
- packMatches = packMatches.slice(0, packLimit);
647
- entityMatches = entityMatches.slice(0, 4);
648
- logRecall(userText, candPacks, candEntities, packMatches, entityMatches);
649
627
  const budget = memoryRecallBudget();
650
- const packBlock = buildPackBlock(packMatches, budget);
651
- const entityBlock = buildEntityBlock(entityMatches, budget);
628
+ let settings = {};
629
+ try { settings = currentState().settings || {}; } catch (e) {}
630
+ const recall = require("./recall");
631
+ const engine = recall.getEngine(recall.activeEngineName(settings));
632
+ const helpers = {
633
+ packsLib, entitiesLib, mergeMatches, filterMatches, logRecall,
634
+ buildPackBlock, buildEntityBlock,
635
+ };
636
+ const { packBlock, entityBlock } = await engine.run({
637
+ userText, contextText, fullContext, packLimit, budget, helpers,
638
+ });
652
639
  const budgetNote = budget.omitted > 0
653
640
  ? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
654
641
  : "";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.33",
3
+ "version": "2.6.34",
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-usage-accounting.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-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"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -30,7 +30,10 @@
30
30
  ".env.example",
31
31
  "README.md",
32
32
  "CHANGELOG.md",
33
- "test-usage-accounting.js"
33
+ "test-usage-accounting.js",
34
+ "test-recall-engine.js",
35
+ "test-recall-graph.js",
36
+ "test-recall-discoverer.js"
34
37
  ],
35
38
  "keywords": [
36
39
  "claude",