@inetafrica/open-claudia 2.6.57 → 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.
- package/CHANGELOG.md +8 -0
- package/README.md +8 -1
- package/bin/cli.js +4 -0
- package/bin/kpi.js +55 -0
- package/core/dream.js +384 -11
- package/core/pack-review.js +45 -4
- package/core/packs.js +37 -5
- package/core/recall/discoverer.js +219 -50
- package/core/recall/graph.js +29 -2
- package/core/recall/kpi.js +227 -0
- package/core/recall/metrics.js +216 -9
- package/core/recall/tuning.js +125 -0
- package/core/recall/warm-walker.js +12 -1
- package/core/runner.js +5 -1
- package/core/system-prompt.js +5 -1
- package/core/transcript-index.js +21 -1
- package/package.json +2 -2
- package/test-recall-discoverer.js +52 -8
package/core/packs.js
CHANGED
|
@@ -343,8 +343,35 @@ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
|
|
|
343
343
|
} catch (e) {}
|
|
344
344
|
}
|
|
345
345
|
|
|
346
|
+
// Near-duplicate check for journal appends. The reviewer runs after EVERY
|
|
347
|
+
// substantial turn, so a long working session on one topic used to stack
|
|
348
|
+
// dozens of same-day entries that only differ in phrasing (observed: 28 in a
|
|
349
|
+
// day on one pack) — crowding real history out of the capped Journal. Token
|
|
350
|
+
// Jaccard against the most recent entries; dates stripped before comparing.
|
|
351
|
+
function journalTokens(s) {
|
|
352
|
+
return new Set(
|
|
353
|
+
String(s || "").toLowerCase()
|
|
354
|
+
.replace(/^-\s*\[[^\]]*\]\s*/, "")
|
|
355
|
+
.replace(/[^a-z0-9\s]/g, " ")
|
|
356
|
+
.split(/\s+/).filter((w) => w.length > 1)
|
|
357
|
+
);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
function isNearDupJournal(line, recentEntries) {
|
|
361
|
+
const a = journalTokens(line);
|
|
362
|
+
if (!a.size) return false;
|
|
363
|
+
for (const e of recentEntries) {
|
|
364
|
+
const b = journalTokens(e);
|
|
365
|
+
if (!b.size) continue;
|
|
366
|
+
let inter = 0;
|
|
367
|
+
for (const w of a) if (b.has(w)) inter++;
|
|
368
|
+
if (inter / (a.size + b.size - inter) >= 0.75) return true;
|
|
369
|
+
}
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
|
|
346
373
|
// Apply a reviewer mutation. Only supplied fields change; journal entries
|
|
347
|
-
// append (capped); state replaces (it represents "current truth").
|
|
374
|
+
// append (capped + deduped); state replaces (it represents "current truth").
|
|
348
375
|
function updatePack(dir, { description, tags, stance, procedure, state, journal, skill, kind, learned_on } = {}, origin = "user") {
|
|
349
376
|
const pack = readPack(dir);
|
|
350
377
|
if (!pack) throw new Error(`no pack: ${dir}`);
|
|
@@ -370,9 +397,14 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
|
|
|
370
397
|
if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
|
|
371
398
|
if (typeof journal === "string" && journal.trim()) {
|
|
372
399
|
const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
400
|
+
const line = journal.trim().replace(/\n+/g, " ");
|
|
401
|
+
if (isNearDupJournal(line, entries.slice(-5))) {
|
|
402
|
+
pack.journalDeduped = true;
|
|
403
|
+
} else {
|
|
404
|
+
entries.push(`- [${today()}] ${line}`);
|
|
405
|
+
pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
|
|
406
|
+
applied.push("Journal");
|
|
407
|
+
}
|
|
376
408
|
}
|
|
377
409
|
writePack(pack);
|
|
378
410
|
if (applied.length) setProvenance(dir, applied, origin);
|
|
@@ -590,7 +622,7 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
|
|
|
590
622
|
|
|
591
623
|
module.exports = {
|
|
592
624
|
PACKS_DIR, SECTIONS, slugify,
|
|
593
|
-
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
|
|
625
|
+
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack, isNearDupJournal,
|
|
594
626
|
listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
|
|
595
627
|
touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
|
|
596
628
|
archivePack, restorePack, listArchived,
|
|
@@ -17,6 +17,7 @@
|
|
|
17
17
|
|
|
18
18
|
const graph = require("./graph");
|
|
19
19
|
const metrics = require("./metrics");
|
|
20
|
+
const tuning = require("./tuning");
|
|
20
21
|
const { spawnSubagent } = require("../subagent");
|
|
21
22
|
const warmWalker = require("./warm-walker");
|
|
22
23
|
const toolsLib = require("../tools");
|
|
@@ -26,12 +27,24 @@ const toolsLib = require("../tools");
|
|
|
26
27
|
// entity-only). Optional — absent on old node where node:sqlite is missing.
|
|
27
28
|
let toolGraph = null;
|
|
28
29
|
try { toolGraph = require("../tool-graph"); } catch (e) { /* old node */ }
|
|
30
|
+
// Episodic memory: FTS over past transcripts (same optionality as the graphs).
|
|
31
|
+
let transcriptIndex = null;
|
|
32
|
+
try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old node */ }
|
|
29
33
|
|
|
30
34
|
const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
|
|
31
35
|
const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
|
|
32
36
|
const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
|
|
37
|
+
// Sizing/tier knobs (walker candidate cap, episode limit, seeds-tier width,
|
|
38
|
+
// cascade on/off) live in recall/tuning.js — dream-adjustable within hard
|
|
39
|
+
// bounds, env pins still win. The walker candidate cap exists because uncapped,
|
|
40
|
+
// a wordy turn seeds 15-40 nodes × 600-char excerpts and the walker prompt
|
|
41
|
+
// measured as THE dominant recall latency (p50 ~13.5s even warm).
|
|
42
|
+
const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
|
|
33
43
|
const EXCERPT_CHARS = 600;
|
|
34
44
|
const CONTEXT_CLIP = 3000;
|
|
45
|
+
// Fraction of cascade auto-verdicts also sent to the judge as an accuracy
|
|
46
|
+
// audit — divergence is logged and the dream disables the cascade if it drifts.
|
|
47
|
+
const SHADOW_RATE = Number(process.env.RECALL_CASCADE_SHADOW_RATE || 0.1);
|
|
35
48
|
|
|
36
49
|
let _lastSync = 0;
|
|
37
50
|
const SYNC_INTERVAL_MS = 60000;
|
|
@@ -45,16 +58,42 @@ function maybeSync(packsLib, entitiesLib) {
|
|
|
45
58
|
try { graph.syncFromCorpus(packsLib, entitiesLib); } catch (e) {}
|
|
46
59
|
}
|
|
47
60
|
|
|
48
|
-
//
|
|
49
|
-
|
|
61
|
+
// Tiered pre-gate. The old boolean gate returned true whenever FTS found ANY
|
|
62
|
+
// seed — with a 147-pack corpus that is virtually every turn (measured: gated
|
|
63
|
+
// 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
|
|
64
|
+
// skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
|
|
65
|
+
// seeds — short command-like turns (≤4 words): keyword seeds are precise
|
|
66
|
+
// enough; inject user-origin seeds directly and skip graph + walker
|
|
67
|
+
// (the classic baseline, ~ms instead of ~13s).
|
|
68
|
+
// full — substantive turns: graph expansion + walker judgement.
|
|
69
|
+
const PLEASANTRY_RE = /^(ok(ay)?|k|kk|thanks?|thank you|thx|ty|yes|yep|yeah|no|nope|nah|cool|nice|great|perfect|awesome|lovely|done|sure|got it|sounds good|will do|lol|haha|hi|hey|hello|yo|morning|evening|night|good (morning|afternoon|evening|night)|bye|goodnight|cheers|please do|go ahead)\b/;
|
|
70
|
+
|
|
71
|
+
function recallTier(userText, seedCount) {
|
|
50
72
|
const t = String(userText || "").trim().toLowerCase();
|
|
51
|
-
if (!t) return
|
|
52
|
-
if (seedCount > 0) return true; // FTS already found something — never gate it out
|
|
53
|
-
// No seeds: only bother with graph/walker work for a substantive message.
|
|
73
|
+
if (!t) return "skip";
|
|
54
74
|
const words = t.split(/\s+/).filter(Boolean);
|
|
55
|
-
if (
|
|
56
|
-
if (
|
|
57
|
-
return
|
|
75
|
+
if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
|
|
76
|
+
if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
|
|
77
|
+
if (words.length <= 2 && seedCount === 0) return "skip";
|
|
78
|
+
if (words.length <= tuning.get("seedsTierMaxWords")) return "seeds";
|
|
79
|
+
return "full";
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Evidence-backed pre-verdict for one candidate, from its rolling usage stats.
|
|
83
|
+
// Conservative by design: auto-drop needs a long never-kept/never-used streak;
|
|
84
|
+
// auto-keep needs a high keep-rate AND at least one real use (open/reviewer).
|
|
85
|
+
// Anything without strong history returns null and goes to the judge.
|
|
86
|
+
function cascadeVerdict(st) {
|
|
87
|
+
if (!st) return null;
|
|
88
|
+
const seen = st.seen || 0, kept = st.kept || 0, used = (st.open || 0) + (st.rev || 0);
|
|
89
|
+
if (seen >= 6 && kept === 0 && used === 0) return "drop";
|
|
90
|
+
if (seen >= 5 && kept / seen >= 0.8 && used >= 1) return "keep";
|
|
91
|
+
return null;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// Back-compat boolean view of the tier (exported/tested API).
|
|
95
|
+
function needsRecall(userText, seedCount) {
|
|
96
|
+
return recallTier(userText, seedCount) !== "skip";
|
|
58
97
|
}
|
|
59
98
|
|
|
60
99
|
function idFor(m) { return m.dir ? `pack:${m.dir}` : `entity:${m.slug}`; }
|
|
@@ -95,6 +134,7 @@ const WALKER_SYSTEM = [
|
|
|
95
134
|
"Decide which nodes are GENUINELY relevant to what the user is doing now.",
|
|
96
135
|
"Keep graph-linked nodes ONLY if they actually bear on the task (e.g. a shared theme/commit-style that governs the thing being worked on). Drop incidental keyword overlaps.",
|
|
97
136
|
"Some candidates are reusable tools (id starts `tool:`). Keep a tool ONLY if running it would actually help with THIS task; drop tools merely related by keyword.",
|
|
137
|
+
"Some candidates are past-conversation episodes (id starts `episode:`). Keep one ONLY if that past exchange holds a decision, method, or outcome directly reusable for the current message — not mere topic overlap.",
|
|
98
138
|
"For each kept node write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
|
|
99
139
|
'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
|
|
100
140
|
].join("\n");
|
|
@@ -102,20 +142,25 @@ const WALKER_SYSTEM = [
|
|
|
102
142
|
// Run the walker model on the prompt. Prefer the warm (reused) process for
|
|
103
143
|
// low latency; on any warm-path error fall back to a cold spawn — identical
|
|
104
144
|
// model/prompt/contract, so quality is unchanged and recall never silently
|
|
105
|
-
// degrades to the classic engine.
|
|
145
|
+
// degrades to the classic engine. Returns { text, costUsd, source }.
|
|
106
146
|
async function runWalker(prompt) {
|
|
107
147
|
const opts = { model: WALKER_MODEL, systemPrompt: WALKER_SYSTEM, timeoutMs: WALKER_TIMEOUT_MS };
|
|
108
148
|
if (warmWalker.isEnabled()) {
|
|
109
149
|
try {
|
|
110
|
-
|
|
150
|
+
const r = await warmWalker.walkWarm(prompt, opts);
|
|
151
|
+
return { text: r.text, costUsd: r.costUsd, source: "warm" };
|
|
111
152
|
} catch (e) { /* fall back to cold spawn below */ }
|
|
112
153
|
}
|
|
113
154
|
const { text } = await spawnSubagent(prompt, opts);
|
|
114
|
-
return text;
|
|
155
|
+
return { text, costUsd: null, source: "cold" };
|
|
115
156
|
}
|
|
116
157
|
|
|
158
|
+
// Judge candidates. Returns { kept: Map|null, costUsd, source, walkerMs } —
|
|
159
|
+
// kept null means fail-open (walker off/failed/unparseable).
|
|
117
160
|
async function walk(userText, contextText, candidates) {
|
|
118
|
-
if (!WALKER_ENABLED || candidates.length === 0)
|
|
161
|
+
if (!WALKER_ENABLED || candidates.length === 0) {
|
|
162
|
+
return { kept: null, costUsd: null, source: "off", walkerMs: 0 };
|
|
163
|
+
}
|
|
119
164
|
const lines = candidates.map((c) => {
|
|
120
165
|
const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
|
|
121
166
|
return `- ${c.id}: ${c.name}${tag}${c.description ? `\n ${c.description}` : ""}${c.excerpt ? `\n excerpt: ${c.excerpt.replace(/\n/g, " ")}` : ""}`;
|
|
@@ -130,12 +175,14 @@ async function walk(userText, contextText, candidates) {
|
|
|
130
175
|
"",
|
|
131
176
|
'Reply ONLY with the JSON array of kept nodes and their why, e.g. [{"id":"' + candidates[0].id + '","why":"..."}].',
|
|
132
177
|
].join("\n");
|
|
178
|
+
const t0 = Date.now();
|
|
133
179
|
try {
|
|
134
|
-
const text = await runWalker(prompt);
|
|
180
|
+
const { text, costUsd, source } = await runWalker(prompt);
|
|
181
|
+
const walkerMs = Date.now() - t0;
|
|
135
182
|
const match = String(text || "").match(/\[[\s\S]*\]/);
|
|
136
|
-
if (!match) return null;
|
|
183
|
+
if (!match) return { kept: null, costUsd, source, walkerMs };
|
|
137
184
|
const arr = JSON.parse(match[0]);
|
|
138
|
-
if (!Array.isArray(arr)) return null;
|
|
185
|
+
if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs };
|
|
139
186
|
const known = new Set(candidates.map((c) => c.id));
|
|
140
187
|
const out = new Map();
|
|
141
188
|
for (const item of arr) {
|
|
@@ -143,9 +190,9 @@ async function walk(userText, contextText, candidates) {
|
|
|
143
190
|
out.set(item.id, String(item.why || "").slice(0, 120));
|
|
144
191
|
}
|
|
145
192
|
}
|
|
146
|
-
return out;
|
|
193
|
+
return { kept: out, costUsd, source, walkerMs };
|
|
147
194
|
} catch (e) {
|
|
148
|
-
return null;
|
|
195
|
+
return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0 };
|
|
149
196
|
}
|
|
150
197
|
}
|
|
151
198
|
|
|
@@ -154,8 +201,6 @@ async function run(ctx) {
|
|
|
154
201
|
const { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock } = helpers;
|
|
155
202
|
const started = Date.now();
|
|
156
203
|
|
|
157
|
-
maybeSync(packsLib, entitiesLib);
|
|
158
|
-
|
|
159
204
|
// 1+2: seed via FTS (user words + context), same as classic.
|
|
160
205
|
let packSeeds = [];
|
|
161
206
|
let entSeeds = [];
|
|
@@ -185,32 +230,55 @@ async function run(ctx) {
|
|
|
185
230
|
} catch (e) {}
|
|
186
231
|
|
|
187
232
|
const seedCount = packSeeds.length + entSeeds.length + toolSeedByName.size;
|
|
233
|
+
const seedMs = Date.now() - started;
|
|
188
234
|
|
|
189
|
-
// 1: pre-gate.
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
235
|
+
// 1: tiered pre-gate.
|
|
236
|
+
const tier = recallTier(userText, seedCount);
|
|
237
|
+
if (tier === "skip") {
|
|
238
|
+
metrics.logTurn({ engine: "discoverer", model: WALKER_MODEL, query: userText, gated: true, tier, latencyMs: Date.now() - started });
|
|
239
|
+
return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true, tier };
|
|
193
240
|
}
|
|
194
241
|
|
|
195
|
-
// 3: spreading activation from seeds across the graph
|
|
242
|
+
// 3: spreading activation from seeds across the graph (full tier only — the
|
|
243
|
+
// seeds tier injects keyword seeds directly, no graph, no walker).
|
|
196
244
|
const seedNodes = [
|
|
197
245
|
...packSeeds.map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
|
|
198
246
|
...entSeeds.map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
|
|
199
247
|
];
|
|
200
248
|
const seedIds = new Set(seedNodes.map((s) => s.id));
|
|
201
249
|
let activated = new Map();
|
|
202
|
-
|
|
250
|
+
const expandStarted = Date.now();
|
|
251
|
+
if (tier === "full") {
|
|
252
|
+
maybeSync(packsLib, entitiesLib);
|
|
253
|
+
try { activated = graph.expand(seedNodes, {}); } catch (e) {}
|
|
254
|
+
}
|
|
255
|
+
const expandMs = Date.now() - expandStarted;
|
|
203
256
|
|
|
204
|
-
// 4: assemble candidates (seeds + activated)
|
|
205
|
-
|
|
206
|
-
|
|
257
|
+
// 4: assemble candidates (seeds + activated) in priority order — user seeds
|
|
258
|
+
// by score, then context seeds, then graph-activated by activation — capped
|
|
259
|
+
// at MAX_WALKER_CANDIDATES so the walker prompt stays small and fast. Only
|
|
260
|
+
// the full tier pays for excerpts; the seeds tier never reads them.
|
|
261
|
+
const seedRank = new Map();
|
|
262
|
+
for (const m of packSeeds) seedRank.set(`pack:${m.dir}`, { score: m.score || 2, origin: m.origin });
|
|
263
|
+
for (const m of entSeeds) seedRank.set(`entity:${m.slug}`, { score: m.score || 2, origin: m.origin });
|
|
264
|
+
const orderedIds = [...seedIds].sort((a, b) => {
|
|
265
|
+
const ra = seedRank.get(a) || {}, rb = seedRank.get(b) || {};
|
|
266
|
+
const ua = ra.origin === "user" ? 0 : 1, ub = rb.origin === "user" ? 0 : 1;
|
|
267
|
+
return ua - ub || (rb.score || 0) - (ra.score || 0);
|
|
268
|
+
});
|
|
269
|
+
for (const [id] of [...activated.entries()].sort((x, y) => (y[1].activation || 0) - (x[1].activation || 0))) {
|
|
270
|
+
if (!seedIds.has(id)) orderedIds.push(id);
|
|
271
|
+
}
|
|
207
272
|
const candidates = [];
|
|
208
|
-
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
273
|
+
if (tier === "full") {
|
|
274
|
+
for (const id of orderedIds.slice(0, tuning.get("walkerMaxCandidates"))) {
|
|
275
|
+
const base = excerptFor(id, packsLib, entitiesLib);
|
|
276
|
+
if (!base) continue;
|
|
277
|
+
const act = activated.get(id);
|
|
278
|
+
candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
|
|
279
|
+
}
|
|
213
280
|
}
|
|
281
|
+
const candIds = new Set(tier === "full" ? candidates.map((c) => c.id) : seedIds);
|
|
214
282
|
|
|
215
283
|
// Tool candidates = direct tool seeds + tools that belong to any candidate
|
|
216
284
|
// pack (declared via the tool's `pack:` header, or used-in-topic per the
|
|
@@ -221,7 +289,7 @@ async function run(ctx) {
|
|
|
221
289
|
for (const id of candIds) if (id.startsWith("pack:")) candPackDirs.add(id.slice(5));
|
|
222
290
|
const toolCand = new Map();
|
|
223
291
|
for (const [name, t] of toolSeedByName) toolCand.set(name, { name, description: t.description, via: null });
|
|
224
|
-
if (candPackDirs.size) {
|
|
292
|
+
if (tier === "full" && candPackDirs.size) {
|
|
225
293
|
try {
|
|
226
294
|
const byPack = new Map();
|
|
227
295
|
for (const t of toolsLib.listTools()) {
|
|
@@ -247,19 +315,95 @@ async function run(ctx) {
|
|
|
247
315
|
} catch (e) {}
|
|
248
316
|
}
|
|
249
317
|
}
|
|
250
|
-
|
|
251
|
-
const
|
|
252
|
-
|
|
253
|
-
|
|
318
|
+
if (tier === "full") {
|
|
319
|
+
for (const c of toolCand.values()) {
|
|
320
|
+
const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
|
|
321
|
+
if (!base) continue;
|
|
322
|
+
candidates.push({ ...base, activated: !!c.via, via: c.via });
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
// Episodic hop: FTS over past transcripts turns up prior sessions on this
|
|
327
|
+
// topic; the walker judges them like any node. This is the bridge from
|
|
328
|
+
// knowledge (packs) to episodes (what we actually did last time) — kept ones
|
|
329
|
+
// inject as a snippet plus a pointer to reopen the conversation window.
|
|
330
|
+
let episodeCands = [];
|
|
331
|
+
if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
|
|
332
|
+
try {
|
|
333
|
+
const { hits } = transcriptIndex.search(userText, { limit: tuning.get("episodeLimit") });
|
|
334
|
+
episodeCands = (hits || []).map((h) => {
|
|
335
|
+
const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
|
|
336
|
+
return {
|
|
337
|
+
id: `episode:${require("path").basename(String(h.file || ""), ".jsonl")}:${h.line}`,
|
|
338
|
+
name: `Past conversation${h.project ? ` (${h.project})` : ""}${h.ts ? ` ${String(h.ts).slice(0, 10)}` : ""}`,
|
|
339
|
+
description: "",
|
|
340
|
+
excerpt: clip(String(h.snip || "").replace(/>>|<</g, ""), EXCERPT_CHARS),
|
|
341
|
+
activated: true,
|
|
342
|
+
via: "transcript",
|
|
343
|
+
_terms: terms,
|
|
344
|
+
};
|
|
345
|
+
});
|
|
346
|
+
candidates.push(...episodeCands);
|
|
347
|
+
} catch (e) { /* episodic recall is best-effort */ }
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
// Walker cascade: candidates with strong usage history get an evidence-backed
|
|
351
|
+
// auto-verdict and skip the judge — the model reads only the ambiguous middle
|
|
352
|
+
// (fewer tokens, less noise in the prompt). A random SHADOW_RATE slice of
|
|
353
|
+
// auto-decided candidates is judged anyway and compared, so the shortcut is
|
|
354
|
+
// continuously audited against the model it replaces; on shadowed candidates
|
|
355
|
+
// the judge's verdict wins. Top-3 ranked candidates and episodes (no stable
|
|
356
|
+
// history) always go to the judge.
|
|
357
|
+
const cascadeOn = tier === "full" && tuning.get("cascade") !== "off";
|
|
358
|
+
const autoKept = new Map(); // id -> canned why (honest provenance)
|
|
359
|
+
const autoDropped = [];
|
|
360
|
+
const shadowVerdicts = new Map(); // id -> auto verdict, also sent to judge
|
|
361
|
+
let judgeCands = candidates;
|
|
362
|
+
if (cascadeOn && candidates.length) {
|
|
363
|
+
let nodeHist = {};
|
|
364
|
+
try { nodeHist = metrics.nodeStats().nodes || {}; } catch (e) {}
|
|
365
|
+
const protectedIds = new Set(orderedIds.slice(0, 3));
|
|
366
|
+
judgeCands = [];
|
|
367
|
+
for (const c of candidates) {
|
|
368
|
+
const st = c.id.startsWith("episode:") || protectedIds.has(c.id) ? null : nodeHist[c.id];
|
|
369
|
+
const v = cascadeVerdict(st);
|
|
370
|
+
if (!v) { judgeCands.push(c); continue; }
|
|
371
|
+
if (Math.random() < SHADOW_RATE) { shadowVerdicts.set(c.id, v); judgeCands.push(c); continue; }
|
|
372
|
+
if (v === "keep") autoKept.set(c.id, `kept ${st.kept}/${st.seen} recent similar turns`);
|
|
373
|
+
else autoDropped.push(c.id);
|
|
374
|
+
}
|
|
254
375
|
}
|
|
255
376
|
|
|
256
|
-
const
|
|
377
|
+
const walkRes = await walk(userText, contextText || fullContext, judgeCands);
|
|
378
|
+
const whyById = walkRes.kept;
|
|
257
379
|
|
|
258
|
-
|
|
259
|
-
|
|
380
|
+
let shadowChecks = 0, shadowAgree = 0;
|
|
381
|
+
if (whyById && shadowVerdicts.size) {
|
|
382
|
+
for (const [id, v] of shadowVerdicts) {
|
|
383
|
+
const agree = (v === "keep") === whyById.has(id);
|
|
384
|
+
shadowChecks++;
|
|
385
|
+
if (agree) shadowAgree++;
|
|
386
|
+
metrics.logShadow(agree);
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
// Decide the kept set. Walker result + evidence auto-keeps win; on walker
|
|
391
|
+
// fail-open (full tier) keep the strongest 3 user-origin seeds only — an
|
|
392
|
+
// unjudged tool or episode is a guess, not a memory (measured: a failed walk
|
|
393
|
+
// once kept 15 unjudged seeds including WiFi tools for a dream question). The
|
|
394
|
+
// seeds tier keeps its classic contract: all user-origin keyword seeds.
|
|
395
|
+
let whyMap = whyById ? new Map(whyById) : null;
|
|
396
|
+
if (whyMap) for (const [id, why] of autoKept) whyMap.set(id, why);
|
|
260
397
|
let keptIds;
|
|
261
|
-
if (
|
|
262
|
-
keptIds = new Set(
|
|
398
|
+
if (whyMap) {
|
|
399
|
+
keptIds = new Set(whyMap.keys());
|
|
400
|
+
} else if (tier === "full") {
|
|
401
|
+
const rankedUserSeeds = [
|
|
402
|
+
...packSeeds.filter((m) => m.origin === "user").map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
|
|
403
|
+
...entSeeds.filter((m) => m.origin === "user").map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
|
|
404
|
+
].sort((a, b) => b.score - a.score).slice(0, 3).map((s) => s.id);
|
|
405
|
+
keptIds = new Set([...autoKept.keys(), ...rankedUserSeeds]);
|
|
406
|
+
if (autoKept.size) whyMap = new Map(autoKept);
|
|
263
407
|
} else {
|
|
264
408
|
keptIds = new Set(
|
|
265
409
|
[...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
|
|
@@ -293,9 +437,9 @@ async function run(ctx) {
|
|
|
293
437
|
// 5: render via shared builders, then weave in the why-bullets.
|
|
294
438
|
let packBlock = buildPackBlock(finalPacks, budget);
|
|
295
439
|
const entityBlock = buildEntityBlock(finalEnts, budget);
|
|
296
|
-
if (packBlock &&
|
|
440
|
+
if (packBlock && whyMap) {
|
|
297
441
|
const bullets = finalPacks
|
|
298
|
-
.map((m) => ({ m, why:
|
|
442
|
+
.map((m) => ({ m, why: whyMap.get(`pack:${m.dir}`) }))
|
|
299
443
|
.filter((x) => x.why)
|
|
300
444
|
.map((x) => `- ${x.m.name || x.m.dir}: ${x.why}`);
|
|
301
445
|
if (bullets.length) packBlock = `\n\n### Why these surfaced (discoverer)\n${bullets.join("\n")}${packBlock}`;
|
|
@@ -307,7 +451,7 @@ async function run(ctx) {
|
|
|
307
451
|
const toolMatches = [];
|
|
308
452
|
for (const c of toolCand.values()) {
|
|
309
453
|
if (keptIds.has(`tool:${c.name}`)) {
|
|
310
|
-
toolMatches.push({ name: c.name, description: c.description, why:
|
|
454
|
+
toolMatches.push({ name: c.name, description: c.description, why: whyMap ? whyMap.get(`tool:${c.name}`) || "" : "" });
|
|
311
455
|
}
|
|
312
456
|
}
|
|
313
457
|
let toolBlock = "";
|
|
@@ -320,20 +464,45 @@ async function run(ctx) {
|
|
|
320
464
|
toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — run with \`open-claudia tool run <name> [args]\`; read docs/source with \`open-claudia tool show <name>\`:\n${lines.join("\n")}\n`;
|
|
321
465
|
}
|
|
322
466
|
|
|
467
|
+
// Past-conversation episodes the walker kept (fail-open drops them — an
|
|
468
|
+
// unjudged transcript snippet is a guess, not a memory).
|
|
469
|
+
const episodeMatches = episodeCands
|
|
470
|
+
.filter((c) => keptIds.has(c.id))
|
|
471
|
+
.map((c) => ({ id: c.id, name: c.name, excerpt: c.excerpt, terms: c._terms, why: whyMap ? whyMap.get(c.id) || "" : "" }));
|
|
472
|
+
let episodeBlock = "";
|
|
473
|
+
if (episodeMatches.length) {
|
|
474
|
+
const lines = episodeMatches.map((m) => {
|
|
475
|
+
const q = (m.terms && m.terms.length ? m.terms.join(" ") : String(m.excerpt).split(/\s+/).slice(0, 4).join(" ")).replace(/"/g, "");
|
|
476
|
+
return `- ${m.name}: "${m.excerpt}"${m.why ? ` — ${m.why}` : ""}\n ↳ full context: open-claudia transcript-search "${q}" --all`;
|
|
477
|
+
});
|
|
478
|
+
episodeBlock = `\n\n## Past work that looks related\nFrom earlier conversations — verify before relying on it:\n${lines.join("\n")}\n`;
|
|
479
|
+
}
|
|
480
|
+
|
|
323
481
|
metrics.logTurn({
|
|
324
482
|
engine: "discoverer",
|
|
483
|
+
model: WALKER_MODEL,
|
|
325
484
|
query: userText,
|
|
485
|
+
tier,
|
|
326
486
|
seeds: seedNodes,
|
|
327
487
|
activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
|
|
328
|
-
|
|
488
|
+
cand: candidates.map((c) => ({ id: c.id, act: !!c.activated })),
|
|
489
|
+
kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "" })),
|
|
490
|
+
auto: (autoKept.size || autoDropped.length || shadowChecks)
|
|
491
|
+
? { kept: [...autoKept.keys()], dropped: autoDropped, shadow: { checks: shadowChecks, agree: shadowAgree } }
|
|
492
|
+
: null,
|
|
329
493
|
gated: false,
|
|
330
494
|
latencyMs: Date.now() - started,
|
|
495
|
+
seedMs,
|
|
496
|
+
expandMs,
|
|
497
|
+
walkerMs: walkRes.walkerMs,
|
|
498
|
+
walker: walkRes.source,
|
|
499
|
+
costUsd: walkRes.costUsd || 0,
|
|
331
500
|
});
|
|
332
501
|
|
|
333
502
|
return {
|
|
334
|
-
packBlock, entityBlock, toolBlock, packMatches: finalPacks, entityMatches: finalEnts,
|
|
335
|
-
toolMatches, why:
|
|
503
|
+
packBlock, entityBlock, toolBlock, episodeBlock, packMatches: finalPacks, entityMatches: finalEnts,
|
|
504
|
+
toolMatches, episodeMatches, why: whyMap ? Object.fromEntries(whyMap) : {}, gated: false, tier,
|
|
336
505
|
};
|
|
337
506
|
}
|
|
338
507
|
|
|
339
|
-
module.exports = { name: "discoverer", run, needsRecall, walk };
|
|
508
|
+
module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict };
|
package/core/recall/graph.js
CHANGED
|
@@ -156,6 +156,33 @@ function reinforceSet(nodeIds, amount = 1) {
|
|
|
156
156
|
}
|
|
157
157
|
}
|
|
158
158
|
|
|
159
|
+
// Negative Hebbian: weaken every edge touching a node that keeps surfacing
|
|
160
|
+
// via the graph but never gets kept or used — firing wrongly, so unwire.
|
|
161
|
+
// Same floor contract as decay(); additionally, `related` edges that fall to
|
|
162
|
+
// their structural base are deleted outright: a link-derived edge re-derives
|
|
163
|
+
// at base weight on the next sync (harmless), while a co-use edge that earned
|
|
164
|
+
// its weight from wrong firings is gone for good.
|
|
165
|
+
function weakenNode(nodeId, { factor = 0.6, prune = true } = {}) {
|
|
166
|
+
const db = openDb();
|
|
167
|
+
if (!db || !nodeId) return { weakened: 0, pruned: 0 };
|
|
168
|
+
let weakened = 0, pruned = 0;
|
|
169
|
+
try {
|
|
170
|
+
const rows = db.prepare("SELECT src, dst, type, weight FROM edges WHERE src=? OR dst=?").all(nodeId, nodeId);
|
|
171
|
+
for (const e of rows) {
|
|
172
|
+
const floor = BASE_WEIGHT[e.type] || 0;
|
|
173
|
+
const next = Math.max(floor, e.weight * factor);
|
|
174
|
+
if (prune && e.type === "related" && next <= floor + 0.05) {
|
|
175
|
+
db.prepare("DELETE FROM edges WHERE src=? AND dst=? AND type=?").run(e.src, e.dst, e.type);
|
|
176
|
+
pruned++;
|
|
177
|
+
} else if (Math.abs(next - e.weight) > 1e-6) {
|
|
178
|
+
db.prepare("UPDATE edges SET weight=? WHERE src=? AND dst=? AND type=?").run(next, e.src, e.dst, e.type);
|
|
179
|
+
weakened++;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
} catch (e) {}
|
|
183
|
+
return { weakened, pruned };
|
|
184
|
+
}
|
|
185
|
+
|
|
159
186
|
// Exponential time decay on reinforced weight. Structural floor is preserved:
|
|
160
187
|
// an edge never decays below its type's base weight.
|
|
161
188
|
function decay({ halfLifeDays = DEFAULTS.halfLifeDays } = {}) {
|
|
@@ -377,7 +404,7 @@ module.exports = {
|
|
|
377
404
|
EDGE_TYPES, DEFAULTS, GRAPH_DB,
|
|
378
405
|
available, openDb,
|
|
379
406
|
addEdge, removeEdge, allEdges, neighbors,
|
|
380
|
-
reinforce, reinforceSet, decay, expand,
|
|
381
|
-
syncFromCorpus, parseLinks, pruneOrphans, tend, stats,
|
|
407
|
+
reinforce, reinforceSet, decay, weakenNode, expand,
|
|
408
|
+
syncFromCorpus, parseLinks, pruneOrphans, tend, stats, isSharedConcern,
|
|
382
409
|
_resetForTest,
|
|
383
410
|
};
|