@inetafrica/open-claudia 2.6.32 → 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.
- package/CHANGELOG.md +3 -0
- package/bin/cli.js +9 -0
- package/bin/recall.js +64 -0
- package/core/actions.js +8 -0
- package/core/dream.js +25 -2
- package/core/handlers.js +27 -1
- package/core/pack-review.js +1 -1
- package/core/recall/classic.js +49 -0
- package/core/recall/discoverer.js +231 -0
- package/core/recall/graph.js +366 -0
- package/core/recall/index.js +33 -0
- package/core/recall/metrics.js +109 -0
- package/core/runner.js +53 -9
- package/core/state.js +1 -1
- package/core/system-prompt.js +59 -34
- package/package.json +6 -3
- package/test-recall-discoverer.js +83 -0
- package/test-recall-engine.js +54 -0
- package/test-recall-graph.js +94 -0
|
@@ -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
|
@@ -842,16 +842,46 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
842
842
|
} catch (e) { /* announcements are best-effort */ }
|
|
843
843
|
};
|
|
844
844
|
|
|
845
|
+
// Read-side recall: fire "📖 Recalled my notes on …" only when the agent
|
|
846
|
+
// actually pulls a full pack/entity via the CLI (it decided it was worth
|
|
847
|
+
// reading), not when a headline was auto-injected. Deduped per turn through
|
|
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();
|
|
852
|
+
const noteRecallFromShell = (command) => {
|
|
853
|
+
try {
|
|
854
|
+
const cmd = String(command || "");
|
|
855
|
+
if (!cmd.includes("open-claudia")) return;
|
|
856
|
+
let m;
|
|
857
|
+
const packRe = /\bpack\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
|
|
858
|
+
while ((m = packRe.exec(cmd))) {
|
|
859
|
+
const dir = m[1];
|
|
860
|
+
const pack = packsLib.readPack(dir);
|
|
861
|
+
const name = (pack && (pack.name || pack.dir)) || dir;
|
|
862
|
+
openedThisTurn.add(`pack:${dir}`);
|
|
863
|
+
notifySkill(`recall:pack:${dir}`, `📖 Recalled my notes on: ${name}`);
|
|
864
|
+
}
|
|
865
|
+
const entRe = /\bentity\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
|
|
866
|
+
while ((m = entRe.exec(cmd))) {
|
|
867
|
+
const slug = m[1];
|
|
868
|
+
const ent = entitiesLib.readEntity(slug);
|
|
869
|
+
const name = (ent && (ent.name || ent.slug)) || slug;
|
|
870
|
+
openedThisTurn.add(`entity:${slug}`);
|
|
871
|
+
notifySkill(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
|
|
872
|
+
}
|
|
873
|
+
} catch (e) { /* announcements are best-effort */ }
|
|
874
|
+
};
|
|
875
|
+
|
|
845
876
|
const args = await buildClaudeArgs(prompt, opts);
|
|
846
|
-
// Recall announcements
|
|
847
|
-
//
|
|
848
|
-
|
|
849
|
-
|
|
850
|
-
|
|
851
|
-
|
|
852
|
-
|
|
853
|
-
|
|
854
|
-
} catch (e) { /* announcements are best-effort */ }
|
|
877
|
+
// Recall announcements are now fired at READ time, not injection time:
|
|
878
|
+
// matched packs/entities enter context only as small headlines (see
|
|
879
|
+
// system-prompt.js recallHeadline). The "📖 Recalled my notes on …" line is
|
|
880
|
+
// emitted by noteRecallFromShell below when the agent actually runs
|
|
881
|
+
// `open-claudia pack show <dir>` / `entity show <slug>` — so the banner
|
|
882
|
+
// reflects what was read, not what was pushed. (consumeLastInjected is
|
|
883
|
+
// drained here to keep the per-turn buffer from leaking into the next turn.)
|
|
884
|
+
try { require("./system-prompt").consumeLastInjected(); } catch (e) { /* best-effort */ }
|
|
855
885
|
const binaryPath = getActiveBinary();
|
|
856
886
|
const proc = spawn(binaryPath, args, {
|
|
857
887
|
cwd,
|
|
@@ -928,6 +958,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
928
958
|
else if (block.name === "Skill" && input.skill) currentToolDetail = input.skill;
|
|
929
959
|
else currentToolDetail = "";
|
|
930
960
|
noteSkillToolUse(block.name, input);
|
|
961
|
+
if (block.name === "Bash" && input.command) noteRecallFromShell(input.command);
|
|
931
962
|
}
|
|
932
963
|
}
|
|
933
964
|
}
|
|
@@ -937,6 +968,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
937
968
|
const a = tc.shellToolCall.args || {};
|
|
938
969
|
currentTool = "Shell"; toolUses.push("Shell");
|
|
939
970
|
currentToolDetail = (a.description || a.command || "").slice(0, 80);
|
|
971
|
+
if (a.command) noteRecallFromShell(a.command);
|
|
940
972
|
} else if (tc.readToolCall) {
|
|
941
973
|
currentTool = "Read"; toolUses.push("Read");
|
|
942
974
|
currentToolDetail = (tc.readToolCall.args?.path || "").split("/").slice(-2).join("/");
|
|
@@ -983,6 +1015,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
983
1015
|
if (it.type === "command_execution" && it.command) {
|
|
984
1016
|
currentTool = "Shell"; toolUses.push("Shell");
|
|
985
1017
|
currentToolDetail = String(it.command).slice(0, 80);
|
|
1018
|
+
noteRecallFromShell(it.command);
|
|
986
1019
|
} else if (it.type === "file_change" && (it.path || it.file_path)) {
|
|
987
1020
|
currentTool = "Edit"; toolUses.push("Edit");
|
|
988
1021
|
const p = it.path || it.file_path;
|
|
@@ -1102,6 +1135,17 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
|
|
|
1102
1135
|
if (settings.budget) settings.budget = null;
|
|
1103
1136
|
state.statusMessageId = null;
|
|
1104
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
|
+
|
|
1105
1149
|
// Post-turn pack review: fire-and-forget on a cheap model; never
|
|
1106
1150
|
// blocks queue drain or the next turn.
|
|
1107
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() {
|