@inetafrica/open-claudia 3.0.37 → 3.0.41
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 +14 -0
- package/bin/pack.js +19 -2
- package/core/packs.js +51 -1
- package/core/recall/discoverer.js +123 -20
- package/core/recall/kpi.js +23 -2
- package/core/recall/tuning.js +18 -0
- package/core/runner.js +4 -1
- package/core/system-prompt.js +65 -27
- package/package.json +7 -2
- package/test-cokept-warming.js +117 -0
- package/test-extractive-injection.js +70 -0
- package/test-pack-search.js +79 -0
- package/test-provider-language.js +20 -1
- package/test-recall-evolution.js +9 -1
- package/test-score-gate.js +104 -0
- package/test-walker-score.js +69 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,19 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## v3.0.41 — Verbose off now means a clean chat: the "Let me…" narration no longer lingers
|
|
4
|
+
|
|
5
|
+
- **With `/verbose` off, the interim progress message is now removed once a turn settles, so the chat is left with only the final answer.** During a multi-step turn the bot posts a throttled "working…" preview that shows the short "Let me check…/Let me look at…" narration emitted before each tool call. That preview used to *stay* — settling into a permanent message — regardless of `/verbose`, which only ever gated the extended-thinking (🧠) stream. So even with verbose off you'd be left with the narration trail as a leftover message that reads like thinking-out-loud. `finalizeStreamPreview` now deletes that preview when verbose is off; `/verbose on` still keeps the narration trail. Live progress during long turns is unchanged either way — the cleanup only affects what remains after the turn ends.
|
|
6
|
+
- **Agent-space / OpenClaw:** no new deps, env, or schema change; Docker image builds FROM the existing `:base`. Pure delivery-path change in `core/runner.js`; existing pods pick it up on their next approved upgrade.
|
|
7
|
+
|
|
8
|
+
## v3.0.40 — Sharper recall: surfaces the actual matched facts, drops weakly-relevant ones (third cut of the Pass-4 release)
|
|
9
|
+
|
|
10
|
+
- **On-open pack/entity injection is now extractive, not a headline.** Opening a pack used to inject its one-line description, hiding the facts that actually matched in State/Procedure/Journal. It now injects the Stance verbatim plus the specific fuzzy-matched lines (section-tagged), so the model sees the real matched content rather than a summary of it. Toggle with `RECALL_EXTRACTIVE` (default on).
|
|
11
|
+
- **A walker score-gate drops weakly-relevant nodes.** The cheap recall walker is a pure router now — it emits `{id, why, score 0-100}` and never paraphrases. Nodes it keeps below the relevance floor (`RECALL_MIN_RELEVANCE_SCORE`, default 55) are gated out on full-tier walks, and the cascade is tightened so sticky auto-keeps are routed back to the judge instead of leaking in. Fails open (a garbled walk bypasses the gate).
|
|
12
|
+
- **A KPI ruler stamps each version.** Per-turn recall records now carry kept-node scores, gate activity, and warmings; the KPI table/HTML report aggregate them per version so each release freezes as a comparable baseline (`relevance`, `gate`, `warm` columns).
|
|
13
|
+
- **Co-kept Hebbian warming ships dark (default OFF).** A weaker-than-co-open reinforcement for pairs the walker co-injected, guarded by score floor + a message-grounded endpoint + a top-N cap. It writes persistent graph state so it can't be validated in one session — left off (`RECALL_COKEPT_WARMING`) pending real-traffic evaluation.
|
|
14
|
+
- **Escape hatch + measurement caveat.** `/engine classic` reverts to the prior behaviour if anything regresses. Two reviewer-side measurement refinements (used-the-right-fact line-level adjudication, and shadow-delta scoring) were deferred to the separate in-flight shadow-delta task — they change how recall is *measured*, not what ships here.
|
|
15
|
+
- **Agent-space / OpenClaw:** no new deps, no new env required (all `RECALL_*` knobs have defaults), no schema change; Docker image builds FROM the existing `:base`. Covered by new `test-pack-search.js`, `test-walker-score.js`, `test-extractive-injection.js`, `test-score-gate.js`, and `test-cokept-warming.js`. Existing pods pick this up on their next approved upgrade. (Two earlier cuts failed CI before publishing anything: v3.0.38 bumped the version but not the release-pin guard in `test-provider-language.js`; v3.0.39 fixed that but `test-cokept-warming.js` asserted a sqlite-graph write that no-ops on CI's `node:20` — it now skips that path when `node:sqlite` is absent, like `test-recall-graph.js`. Both were verified locally by running the suite with `node:sqlite` simulated absent, reproducing the CI environment.)
|
|
16
|
+
|
|
3
17
|
## v3.0.37 — Silent background watches, and pasted secrets are accepted on any provider
|
|
4
18
|
|
|
5
19
|
- **You can now arm a watch: a background poll that stays out of the chat and only wakes me when its condition is actually met.** A watch is just a cron carrying a shell `check` command — each tick runs the check with output discarded, and *only* an exit 0 wakes the agent (with a `🔔 Watch "<label>" triggered` notice and a normal reply). A non-zero exit is the idle case: nothing hits the chat, the poll's activity goes to an append-only `watch.log`, and the job stays pending for the next tick. Watches default to **one-shot** (fire once, then remove) so a matched condition can't spam you; add `--repeat` to keep polling. Create/manage from the CLI (`open-claudia watch-add "<check>" "<wake-prompt>" [--every "<cron>"] [--repeat]`, `watch-list`, `watch-remove`), and the owner can flip the global master switch with **`/watch on|off`** (watches are owner-gated because the check is raw shell). Rides the migration-verified jobs store untouched — `check`/`repeat` survive as extra fields, zero schema change.
|
package/bin/pack.js
CHANGED
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
// CLI: inspect and manage context packs (living skills+memory docs).
|
|
2
2
|
// open-claudia pack list
|
|
3
3
|
// open-claudia pack show <dir>
|
|
4
|
-
// open-claudia pack match "<text>" — debug the router
|
|
4
|
+
// open-claudia pack match "<text>" — debug the router (whole-pack)
|
|
5
|
+
// open-claudia pack search "<query>" — per-line extractive search
|
|
5
6
|
// open-claudia pack migrate — fold ~/.claude/skills into packs
|
|
6
7
|
// open-claudia pack remove <dir>
|
|
7
8
|
// open-claudia pack reindex
|
|
@@ -124,6 +125,22 @@ function run(args) {
|
|
|
124
125
|
break;
|
|
125
126
|
}
|
|
126
127
|
|
|
128
|
+
case "search": {
|
|
129
|
+
let dir = null, k = 8;
|
|
130
|
+
const words = [];
|
|
131
|
+
for (let i = 0; i < rest.length; i++) {
|
|
132
|
+
if (rest[i] === "--pack") { dir = rest[++i]; continue; }
|
|
133
|
+
if (rest[i] === "--k") { k = Math.max(1, parseInt(rest[++i], 10) || 8); continue; }
|
|
134
|
+
words.push(rest[i]);
|
|
135
|
+
}
|
|
136
|
+
const query = words.join(" ");
|
|
137
|
+
if (!query) { console.error('Usage: pack search "<query>" [--pack <dir>] [--k N]'); process.exitCode = 1; return; }
|
|
138
|
+
const hits = packs.searchLines(query, { dir, k });
|
|
139
|
+
if (hits.length === 0) return console.log("No matching lines.");
|
|
140
|
+
for (const h of hits) console.log(`${String(h.score).padStart(3)} ${h.dir}/${h.section}:${h.lineno} ${h.text}`);
|
|
141
|
+
break;
|
|
142
|
+
}
|
|
143
|
+
|
|
127
144
|
case "remove": {
|
|
128
145
|
const removed = packs.removePack(rest[0]);
|
|
129
146
|
if (!removed) { console.error(`No pack: ${rest[0]}`); process.exitCode = 1; return; }
|
|
@@ -176,7 +193,7 @@ function run(args) {
|
|
|
176
193
|
}
|
|
177
194
|
|
|
178
195
|
default:
|
|
179
|
-
console.log("Usage: open-claudia pack [list|skills|abilities|skill <dir> [on|off]|kind <dir> <ability|context>|show <dir>|match \"<text>\"|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
|
|
196
|
+
console.log("Usage: open-claudia pack [list|skills|abilities|skill <dir> [on|off]|kind <dir> <ability|context>|show <dir>|match \"<text>\"|search \"<query>\" [--pack <dir>] [--k N]|migrate|remove <dir>|archive <dir>|restore <dir>|archived|reindex]");
|
|
180
197
|
}
|
|
181
198
|
}
|
|
182
199
|
|
package/core/packs.js
CHANGED
|
@@ -641,11 +641,61 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
|
|
|
641
641
|
.map(([dir, score]) => ({ dir, name: names.get(dir) || dir, score }));
|
|
642
642
|
}
|
|
643
643
|
|
|
644
|
+
// Extractive per-line search. Where matchPacks routes whole packs, this
|
|
645
|
+
// returns the individual lines that best match the query, tagged by section,
|
|
646
|
+
// so the injector can surface a verbatim fact from anywhere in a pack — not
|
|
647
|
+
// just Stance. Deliberately JS-side (no second FTS table) so it can never
|
|
648
|
+
// disturb the load-bearing whole-pack index in reindex(). Fuzz comes from
|
|
649
|
+
// substring matching: term "deploy" also hits "deployment"/"deployed".
|
|
650
|
+
function searchLines(query, { dir = null, k = 8, minScore = 1 } = {}) {
|
|
651
|
+
const terms = queryTerms(query);
|
|
652
|
+
if (terms.length === 0) return [];
|
|
653
|
+
let packs;
|
|
654
|
+
if (dir) {
|
|
655
|
+
const p = readPack(dir);
|
|
656
|
+
packs = p ? [p] : [];
|
|
657
|
+
} else {
|
|
658
|
+
packs = listPacks().filter((p) => !p.archived);
|
|
659
|
+
}
|
|
660
|
+
const hits = [];
|
|
661
|
+
for (const p of packs) {
|
|
662
|
+
for (const section of SECTIONS) {
|
|
663
|
+
const body = p.sections?.[section] || "";
|
|
664
|
+
if (!body) continue;
|
|
665
|
+
const lines = body.split("\n");
|
|
666
|
+
for (let i = 0; i < lines.length; i++) {
|
|
667
|
+
const trimmed = lines[i].trim();
|
|
668
|
+
if (trimmed.length < 3) continue; // skip blanks / stray punctuation
|
|
669
|
+
const low = trimmed.toLowerCase();
|
|
670
|
+
let distinct = 0;
|
|
671
|
+
let total = 0;
|
|
672
|
+
for (const t of terms) {
|
|
673
|
+
let idx = low.indexOf(t);
|
|
674
|
+
if (idx === -1) continue;
|
|
675
|
+
distinct++;
|
|
676
|
+
let occ = 0;
|
|
677
|
+
while (idx !== -1 && occ < 3) { occ++; idx = low.indexOf(t, idx + t.length); }
|
|
678
|
+
total += occ;
|
|
679
|
+
}
|
|
680
|
+
if (distinct < 1) continue;
|
|
681
|
+
const score = distinct * 10 + Math.min(total - distinct, 5);
|
|
682
|
+
if (score < minScore) continue;
|
|
683
|
+
hits.push({ dir: p.dir, section, lineno: i + 1, text: trimmed, score });
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
hits.sort((a, b) => b.score - a.score
|
|
688
|
+
|| a.dir.localeCompare(b.dir)
|
|
689
|
+
|| SECTIONS.indexOf(a.section) - SECTIONS.indexOf(b.section)
|
|
690
|
+
|| a.lineno - b.lineno);
|
|
691
|
+
return hits.slice(0, Math.max(1, Math.min(50, k)));
|
|
692
|
+
}
|
|
693
|
+
|
|
644
694
|
module.exports = {
|
|
645
695
|
PACKS_DIR, SECTIONS, slugify,
|
|
646
696
|
listPacks, findPack, readPack, writePack, createPack, updatePack, removePack, isNearDupJournal,
|
|
647
697
|
listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
|
|
648
|
-
touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
|
|
698
|
+
touchUsed, packNameFromPath, matchPacks, searchLines, reindex, markIndexDirty,
|
|
649
699
|
archivePack, restorePack, listArchived,
|
|
650
700
|
readProvenance, provenanceOf, setProvenance, recordForegroundWrite,
|
|
651
701
|
};
|
|
@@ -217,14 +217,18 @@ function excerptFor(id, packsLib, entitiesLib) {
|
|
|
217
217
|
}
|
|
218
218
|
|
|
219
219
|
const WALKER_SYSTEM = [
|
|
220
|
-
"You are the recall
|
|
220
|
+
"You are the recall ROUTER for an assistant's long-term memory.",
|
|
221
221
|
"You are given the user's message, recent conversation, and candidate memory nodes (some matched by keyword, some pulled in by graph links).",
|
|
222
|
-
"
|
|
222
|
+
"Your job is to route: decide which nodes GENUINELY bear on what the user is doing now, and how strongly.",
|
|
223
|
+
"You are a router, NOT a summarizer: never paraphrase, rewrite, or invent the contents of a node. The verbatim fact will be surfaced separately; your why is only a pointer to WHY the node is relevant.",
|
|
223
224
|
"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.",
|
|
224
225
|
"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.",
|
|
225
226
|
"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.",
|
|
226
|
-
"For each kept node
|
|
227
|
-
|
|
227
|
+
"For each kept node give three fields:",
|
|
228
|
+
" - id: the candidate's id, verbatim.",
|
|
229
|
+
" - why: a terse pointer (≤12 words) to why it's relevant — quote a concrete anchor verbatim, never a paraphrase of the whole node.",
|
|
230
|
+
" - score: an integer 0–100 for how strongly this node bears on THIS message. 100 = the message is directly about it; 60 = plausibly useful; below 50 = weak/incidental.",
|
|
231
|
+
'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app","score":85}]. Use [] if none.',
|
|
228
232
|
].join("\n");
|
|
229
233
|
|
|
230
234
|
// Run the walker model on the prompt. Prefer the warm (reused) process for
|
|
@@ -276,7 +280,7 @@ async function runWalker(prompt, options = {}) {
|
|
|
276
280
|
items: {
|
|
277
281
|
type: "object",
|
|
278
282
|
required: ["id"],
|
|
279
|
-
properties: { id: { type: "string" }, why: { type: "string" } },
|
|
283
|
+
properties: { id: { type: "string" }, why: { type: "string" }, score: { type: "number" } },
|
|
280
284
|
},
|
|
281
285
|
},
|
|
282
286
|
timeoutMs: WALKER_TIMEOUT_MS,
|
|
@@ -291,11 +295,47 @@ async function runWalker(prompt, options = {}) {
|
|
|
291
295
|
};
|
|
292
296
|
}
|
|
293
297
|
|
|
294
|
-
//
|
|
295
|
-
//
|
|
298
|
+
// A walker score is an integer 0–100. Missing/garbage → 60: a neutral pass so
|
|
299
|
+
// a parse hiccup never silently buries a node the walker meant to keep.
|
|
300
|
+
function clampScore(v) {
|
|
301
|
+
const n = Number(v);
|
|
302
|
+
if (!Number.isFinite(n)) return 60;
|
|
303
|
+
return Math.max(0, Math.min(100, Math.round(n)));
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
// WS5: which co-kept pairs earn a weak warming bump. Co-KEPT (walker co-injected
|
|
307
|
+
// them this turn) is noisier than co-OPEN (the model actually opened them), so
|
|
308
|
+
// it warms only under three guards: both endpoints scored ≥ minScore (high-
|
|
309
|
+
// confidence keeps), the pair contains ≥1 message-grounded (user-origin) seed —
|
|
310
|
+
// never let two pure graph hops reinforce each other into a phantom edge — and
|
|
311
|
+
// only the top `maxPairs` strongest pairs warm. Pack/entity graph nodes only
|
|
312
|
+
// (tools and episodes are not graph nodes). Pure + deterministic, so it is
|
|
313
|
+
// unit-tested without touching the graph. Returns [[a, b], ...].
|
|
314
|
+
function cokeptPairs(keptIds, scoreById, groundedIds, { minScore = 60, maxPairs = 3 } = {}) {
|
|
315
|
+
if (!scoreById || !(scoreById.size >= 0)) return [];
|
|
316
|
+
const grounded = groundedIds instanceof Set ? groundedIds : new Set(groundedIds || []);
|
|
317
|
+
const eligible = [...keptIds].filter((id) =>
|
|
318
|
+
(id.startsWith("pack:") || id.startsWith("entity:")) &&
|
|
319
|
+
scoreById.has(id) && scoreById.get(id) >= minScore);
|
|
320
|
+
const pairs = [];
|
|
321
|
+
for (let i = 0; i < eligible.length; i++) {
|
|
322
|
+
for (let j = i + 1; j < eligible.length; j++) {
|
|
323
|
+
const a = eligible[i], b = eligible[j];
|
|
324
|
+
if (!grounded.has(a) && !grounded.has(b)) continue; // ≥1 message-grounded endpoint
|
|
325
|
+
pairs.push({ a, b, strength: scoreById.get(a) + scoreById.get(b) });
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
pairs.sort((x, y) => y.strength - x.strength || (x.a + "" + x.b).localeCompare(y.a + "" + y.b));
|
|
329
|
+
return pairs.slice(0, Math.max(0, maxPairs)).map((p) => [p.a, p.b]);
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
// Judge candidates. Returns { kept: Map|null, scores: Map|null, costUsd, source,
|
|
333
|
+
// walkerMs } — kept/scores null means fail-open (walker off/failed/unparseable).
|
|
334
|
+
// kept is id→why(string); scores is the parallel id→0-100 relevance from the
|
|
335
|
+
// router, consumed by the injection score-gate and asymmetric warming.
|
|
296
336
|
async function walk(userText, contextText, candidates, options = {}) {
|
|
297
337
|
if (!WALKER_ENABLED || candidates.length === 0) {
|
|
298
|
-
return { kept: null, costUsd: null, source: "off", walkerMs: 0, provider: options.provider || null, model: null };
|
|
338
|
+
return { kept: null, scores: null, costUsd: null, source: "off", walkerMs: 0, provider: options.provider || null, model: null };
|
|
299
339
|
}
|
|
300
340
|
const lines = candidates.map((c) => {
|
|
301
341
|
const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
|
|
@@ -309,26 +349,28 @@ async function walk(userText, contextText, candidates, options = {}) {
|
|
|
309
349
|
"Candidate memory nodes:",
|
|
310
350
|
...lines,
|
|
311
351
|
"",
|
|
312
|
-
'Reply ONLY with the JSON array of kept nodes
|
|
352
|
+
'Reply ONLY with the JSON array of kept nodes, each with id, why, and score (0–100), e.g. [{"id":"' + candidates[0].id + '","why":"...","score":80}].',
|
|
313
353
|
].join("\n");
|
|
314
354
|
const t0 = Date.now();
|
|
315
355
|
try {
|
|
316
356
|
const { text, costUsd, source, provider, model } = await runWalker(prompt, options);
|
|
317
357
|
const walkerMs = Date.now() - t0;
|
|
318
358
|
const match = String(text || "").match(/\[[\s\S]*\]/);
|
|
319
|
-
if (!match) return { kept: null, costUsd, source, walkerMs, provider, model };
|
|
359
|
+
if (!match) return { kept: null, scores: null, costUsd, source, walkerMs, provider, model };
|
|
320
360
|
const arr = JSON.parse(match[0]);
|
|
321
|
-
if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs, provider, model };
|
|
361
|
+
if (!Array.isArray(arr)) return { kept: null, scores: null, costUsd, source, walkerMs, provider, model };
|
|
322
362
|
const known = new Set(candidates.map((c) => c.id));
|
|
323
363
|
const out = new Map();
|
|
364
|
+
const scores = new Map();
|
|
324
365
|
for (const item of arr) {
|
|
325
366
|
if (item && typeof item.id === "string" && known.has(item.id)) {
|
|
326
367
|
out.set(item.id, String(item.why || "").slice(0, 120));
|
|
368
|
+
scores.set(item.id, clampScore(item.score));
|
|
327
369
|
}
|
|
328
370
|
}
|
|
329
|
-
return { kept: out, costUsd, source, walkerMs, provider, model };
|
|
371
|
+
return { kept: out, scores, costUsd, source, walkerMs, provider, model };
|
|
330
372
|
} catch (e) {
|
|
331
|
-
return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0, provider: options.provider || null, model: null };
|
|
373
|
+
return { kept: null, scores: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0, provider: options.provider || null, model: null };
|
|
332
374
|
}
|
|
333
375
|
}
|
|
334
376
|
|
|
@@ -491,6 +533,14 @@ async function run(ctx) {
|
|
|
491
533
|
// the judge's verdict wins. Top-3 ranked candidates and episodes (no stable
|
|
492
534
|
// history) always go to the judge.
|
|
493
535
|
const cascadeOn = tier === "full" && tuning.get("cascade") !== "off";
|
|
536
|
+
// Score-gate config (WS4). When a relevance floor is set, the walker's real
|
|
537
|
+
// per-turn score decides injection — so the cascade must NOT auto-KEEP from
|
|
538
|
+
// history: a sticky pack's history score is always high and would re-leak the
|
|
539
|
+
// very irrelevant-recall defect the gate exists to fix. Auto-DROP still fires
|
|
540
|
+
// (dead nodes cost tokens and never help); would-be-keeps are routed to the
|
|
541
|
+
// judge instead, where they earn a real current-turn score and face the gate.
|
|
542
|
+
const minScore = tier === "full" ? tuning.get("minRelevanceScore") : 0;
|
|
543
|
+
const gateConfigured = minScore > 0;
|
|
494
544
|
const autoKept = new Map(); // id -> canned why (honest provenance)
|
|
495
545
|
const autoDropped = [];
|
|
496
546
|
const shadowVerdicts = new Map(); // id -> auto verdict, also sent to judge
|
|
@@ -505,13 +555,16 @@ async function run(ctx) {
|
|
|
505
555
|
const v = cascadeVerdict(st);
|
|
506
556
|
if (!v) { judgeCands.push(c); continue; }
|
|
507
557
|
if (Math.random() < SHADOW_RATE) { shadowVerdicts.set(c.id, v); judgeCands.push(c); continue; }
|
|
508
|
-
if (v === "keep")
|
|
509
|
-
|
|
558
|
+
if (v === "keep") {
|
|
559
|
+
if (gateConfigured) { judgeCands.push(c); continue; } // no history auto-keep under the gate — the judge scores it
|
|
560
|
+
autoKept.set(c.id, `kept ${st.kept}/${st.seen} recent similar turns`);
|
|
561
|
+
} else autoDropped.push(c.id);
|
|
510
562
|
}
|
|
511
563
|
}
|
|
512
564
|
|
|
513
|
-
const walkRes = await walk(userText, contextText || fullContext, judgeCands, { provider: provider || null });
|
|
565
|
+
const walkRes = await walk(userText, contextText || fullContext, judgeCands, { provider: provider || null, ...(ctx.walkerOptions || {}) });
|
|
514
566
|
const whyById = walkRes.kept;
|
|
567
|
+
const scoreById = walkRes.scores; // parallel id→0-100 router relevance (null on fail-open)
|
|
515
568
|
|
|
516
569
|
let shadowChecks = 0, shadowAgree = 0;
|
|
517
570
|
if (whyById && shadowVerdicts.size) {
|
|
@@ -529,6 +582,19 @@ async function run(ctx) {
|
|
|
529
582
|
// once kept 15 unjudged seeds including WiFi tools for a dream question). The
|
|
530
583
|
// seeds tier keeps its classic contract: all user-origin keyword seeds.
|
|
531
584
|
let whyMap = whyById ? new Map(whyById) : null;
|
|
585
|
+
// Score-gate on injection (WS4 keystone): drop any walker-kept node scoring
|
|
586
|
+
// below the relevance floor, so a weakly-linked node no longer gets injected.
|
|
587
|
+
// Only fires when the walker produced real scores (full tier, successful walk);
|
|
588
|
+
// fail-open (scores null) skips the gate. A successful walk that gates every
|
|
589
|
+
// node leaves an empty (non-null) whyMap → inject nothing, which is the
|
|
590
|
+
// correct signal "nothing was relevant" (NOT the fail-open seed fallback,
|
|
591
|
+
// which is reserved for a walk that never produced a verdict at all).
|
|
592
|
+
const gatedOut = [];
|
|
593
|
+
if (whyMap && gateConfigured && scoreById) {
|
|
594
|
+
for (const id of [...whyMap.keys()]) {
|
|
595
|
+
if (scoreById.has(id) && scoreById.get(id) < minScore) { whyMap.delete(id); gatedOut.push(id); }
|
|
596
|
+
}
|
|
597
|
+
}
|
|
532
598
|
if (whyMap) for (const [id, why] of autoKept) whyMap.set(id, why);
|
|
533
599
|
let keptIds;
|
|
534
600
|
if (whyMap) {
|
|
@@ -570,8 +636,23 @@ async function run(ctx) {
|
|
|
570
636
|
const finalPacks = packMatches.slice(0, packLimit);
|
|
571
637
|
const finalEnts = entityMatches.slice(0, 4);
|
|
572
638
|
|
|
573
|
-
// 5: render via shared builders, then weave in the why-bullets.
|
|
574
|
-
|
|
639
|
+
// 5: render via shared builders, then weave in the why-bullets. Extractive
|
|
640
|
+
// mode (default on) surfaces the verbatim lines that matched this message from
|
|
641
|
+
// anywhere in each pack — the router already scored relevance; here we hand
|
|
642
|
+
// the model the actual facts, not just a Stance headline (Defect-A fix). Line
|
|
643
|
+
// hits come from the WS1 per-line search, scoped per final pack.
|
|
644
|
+
const extractiveOn = tuning.get("extractive") !== "off" && typeof packsLib.searchLines === "function";
|
|
645
|
+
let matchedLines = null;
|
|
646
|
+
if (extractiveOn && finalPacks.length) {
|
|
647
|
+
matchedLines = new Map();
|
|
648
|
+
for (const m of finalPacks) {
|
|
649
|
+
try {
|
|
650
|
+
const hits = packsLib.searchLines(userText, { dir: m.dir, k: 8 });
|
|
651
|
+
if (hits && hits.length) matchedLines.set(m.dir, hits);
|
|
652
|
+
} catch (e) { /* a pack with no line hits just shows Stance */ }
|
|
653
|
+
}
|
|
654
|
+
}
|
|
655
|
+
let packBlock = buildPackBlock(finalPacks, budget, extractiveOn ? { extractive: true, matchedLines } : {});
|
|
575
656
|
const entityBlock = buildEntityBlock(finalEnts, budget);
|
|
576
657
|
if (packBlock && whyMap) {
|
|
577
658
|
const bullets = finalPacks
|
|
@@ -631,6 +712,25 @@ async function run(ctx) {
|
|
|
631
712
|
episodeBlock = `\n\n## Past work that looks related\nFrom earlier conversations — verify before relying on it:\n${lines.join("\n")}\n`;
|
|
632
713
|
}
|
|
633
714
|
|
|
715
|
+
// WS5: asymmetric co-kept warming (dark-launch OFF). Feed a weak Hebbian
|
|
716
|
+
// signal from the walker's co-injected, high-confidence, message-grounded
|
|
717
|
+
// keeps — a fraction of a real co-use bump. Best-effort: never breaks recall.
|
|
718
|
+
let cokeptWarmed = [];
|
|
719
|
+
if (tier === "full" && tuning.get("cokeptWarming") === "on" && scoreById && keptIds.size > 1) {
|
|
720
|
+
try {
|
|
721
|
+
const grounded = new Set([
|
|
722
|
+
...packSeeds.filter((m) => m.origin === "user").map((m) => `pack:${m.dir}`),
|
|
723
|
+
...entSeeds.filter((m) => m.origin === "user").map((m) => `entity:${m.slug}`),
|
|
724
|
+
]);
|
|
725
|
+
cokeptWarmed = cokeptPairs(keptIds, scoreById, grounded, {
|
|
726
|
+
minScore: tuning.get("warmMinScore"),
|
|
727
|
+
maxPairs: tuning.get("cokeptMaxPairs"),
|
|
728
|
+
});
|
|
729
|
+
const amount = tuning.get("cokeptAmount");
|
|
730
|
+
for (const [a, b] of cokeptWarmed) graph.reinforce(a, b, amount);
|
|
731
|
+
} catch (e) { cokeptWarmed = []; }
|
|
732
|
+
}
|
|
733
|
+
|
|
634
734
|
metrics.logTurn({
|
|
635
735
|
engine: "discoverer",
|
|
636
736
|
provider: walkRes.provider || provider || "",
|
|
@@ -640,10 +740,13 @@ async function run(ctx) {
|
|
|
640
740
|
seeds: seedNodes,
|
|
641
741
|
activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
|
|
642
742
|
cand: candidates.map((c) => ({ id: c.id, act: !!c.activated })),
|
|
643
|
-
kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "" })),
|
|
743
|
+
kept: [...keptIds].map((id) => ({ id, why: whyMap ? whyMap.get(id) : "", score: scoreById && scoreById.has(id) ? scoreById.get(id) : null })),
|
|
644
744
|
auto: (autoKept.size || autoDropped.length || shadowChecks)
|
|
645
745
|
? { kept: [...autoKept.keys()], dropped: autoDropped, shadow: { checks: shadowChecks, agree: shadowAgree } }
|
|
646
746
|
: null,
|
|
747
|
+
gate: gateConfigured ? { min: minScore, out: gatedOut } : undefined,
|
|
748
|
+
extracted: matchedLines && matchedLines.size ? [...matchedLines].map(([dir, hits]) => ({ dir, n: hits.length })) : undefined,
|
|
749
|
+
warmed: cokeptWarmed.length ? cokeptWarmed : undefined,
|
|
647
750
|
gated: false,
|
|
648
751
|
latencyMs: Date.now() - started,
|
|
649
752
|
seedMs,
|
|
@@ -660,4 +763,4 @@ async function run(ctx) {
|
|
|
660
763
|
};
|
|
661
764
|
}
|
|
662
765
|
|
|
663
|
-
module.exports = { name: "discoverer", run, runWalker, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
|
|
766
|
+
module.exports = { name: "discoverer", run, runWalker, needsRecall, recallTier, walk, cascadeVerdict, cokeptPairs, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
|
package/core/recall/kpi.js
CHANGED
|
@@ -16,6 +16,9 @@ function emptyBucket(v) {
|
|
|
16
16
|
rescueTurns: 0, rescues: 0, walkerFailed: 0,
|
|
17
17
|
latencyMsTotal: 0, costUsd: 0, autoKept: 0, autoDropped: 0,
|
|
18
18
|
opens: 0, reviewerUses: 0, byTier: {},
|
|
19
|
+
// Pass-4 signals (WS0): walker relevance scores on kept nodes, score-gate
|
|
20
|
+
// activity, and co-kept warmings — the ruler for the accuracy/precision work.
|
|
21
|
+
keptScoreSum: 0, keptScoreN: 0, gateActiveTurns: 0, gateFiredTurns: 0, gatedNodes: 0, warmings: 0,
|
|
19
22
|
};
|
|
20
23
|
}
|
|
21
24
|
|
|
@@ -54,6 +57,14 @@ function scanLog() {
|
|
|
54
57
|
const seedIds = new Set((rec.seeds || []).map((s) => s.id));
|
|
55
58
|
const rescues = kept.filter((k) => !seedIds.has(k.id)).length;
|
|
56
59
|
if (rescues > 0) { b.rescueTurns++; b.rescues += rescues; }
|
|
60
|
+
for (const k of kept) if (typeof k.score === "number") { b.keptScoreSum += k.score; b.keptScoreN++; }
|
|
61
|
+
if (rec.gate) {
|
|
62
|
+
b.gateActiveTurns++;
|
|
63
|
+
const out = (rec.gate.out || []).length;
|
|
64
|
+
b.gatedNodes += out;
|
|
65
|
+
if (out > 0) b.gateFiredTurns++;
|
|
66
|
+
}
|
|
67
|
+
if (Array.isArray(rec.warmed)) b.warmings += rec.warmed.length;
|
|
57
68
|
if (rec.auto) {
|
|
58
69
|
b.autoKept += (rec.auto.kept || []).length;
|
|
59
70
|
b.autoDropped += (rec.auto.dropped || []).length;
|
|
@@ -91,6 +102,10 @@ function aggregate() {
|
|
|
91
102
|
walkerFailPct: pct(b.walkerFailed, b.turns - b.gated),
|
|
92
103
|
autoKept: b.autoKept,
|
|
93
104
|
autoDropped: b.autoDropped,
|
|
105
|
+
avgKeptScore: b.keptScoreN ? Math.round(b.keptScoreSum / b.keptScoreN) : null,
|
|
106
|
+
gatedNodes: b.gatedNodes,
|
|
107
|
+
gateFiredPct: pct(b.gateFiredTurns, b.gateActiveTurns),
|
|
108
|
+
warmings: b.warmings,
|
|
94
109
|
});
|
|
95
110
|
}
|
|
96
111
|
rows.sort((a, b) => (a.from || "").localeCompare(b.from || ""));
|
|
@@ -107,6 +122,9 @@ function renderTable(rows) {
|
|
|
107
122
|
["turns", (r) => String(r.turns)],
|
|
108
123
|
["rescue", (r) => (r.rescuePct == null ? "—" : r.rescuePct + "%")],
|
|
109
124
|
["noise", (r) => (r.noisePct == null ? "—" : r.noisePct + "%")],
|
|
125
|
+
["relevance", (r) => (r.avgKeptScore == null ? "—" : String(r.avgKeptScore))],
|
|
126
|
+
["gate", (r) => (r.gateFiredPct == null ? "—" : `${r.gateFiredPct}% / ${r.gatedNodes}`)],
|
|
127
|
+
["warm", (r) => String(r.warmings || 0)],
|
|
110
128
|
["avg", (r) => (r.avgLatencyMs / 1000).toFixed(1) + "s"],
|
|
111
129
|
["gated", (r) => (r.gatedPct == null ? "—" : r.gatedPct + "%")],
|
|
112
130
|
["$ / turn", (r) => "$" + r.costPerTurnUsd.toFixed(3)],
|
|
@@ -177,6 +195,8 @@ function htmlReport() {
|
|
|
177
195
|
barChart("Average recall latency", rows.map((r) => ({ label: label(r), value: r.avgLatencyMs / 1000 })), { fmt: (v) => v.toFixed(1) + "s" }),
|
|
178
196
|
barChart("Recall cost per turn", rows.map((r) => ({ label: label(r), value: r.costPerTurnUsd })), { fmt: (v) => "$" + v.toFixed(3) }),
|
|
179
197
|
barChart("Gated turns — pleasantries skipped for free", rows.map((r) => ({ label: label(r), value: r.gatedPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
198
|
+
barChart("Average relevance of kept memories — walker score 0–100 (higher = the model kept more clearly-relevant nodes)",
|
|
199
|
+
rows.map((r) => ({ label: label(r), value: r.avgKeptScore })), { fmt: (v) => Math.round(v), max: 100 }),
|
|
180
200
|
barChart("Walker failure rate", rows.map((r) => ({ label: label(r), value: r.walkerFailPct })), { fmt: (v) => Math.round(v) + "%", max: 100 }),
|
|
181
201
|
].filter(Boolean).map((s) => `<div class="card">${s}</div>`).join("\n");
|
|
182
202
|
|
|
@@ -191,8 +211,9 @@ function htmlReport() {
|
|
|
191
211
|
|
|
192
212
|
const tierRows = rows.map((r) => {
|
|
193
213
|
const t = Object.entries(r.byTier).map(([k, v]) => `${k}: ${v.turns}× @ ${(v.avgMs / 1000).toFixed(1)}s`).join(" · ") || "—";
|
|
214
|
+
const rel = `${r.avgKeptScore == null ? "—" : r.avgKeptScore} / ${r.gateFiredPct == null ? "—" : r.gateFiredPct + "%·" + r.gatedNodes} / ${r.warmings || 0}`;
|
|
194
215
|
return `<tr><td>${esc(r.v)}</td><td>${esc(r.from)}→${esc(r.to)}</td><td>${r.turns}</td><td>${t}</td>` +
|
|
195
|
-
`<td>${r.opens} / ${r.reviewerUses}</td><td>${r.autoKept} / ${r.autoDropped}</td><td>${esc(r.models.join(", ") || "—")}</td></tr>`;
|
|
216
|
+
`<td>${r.opens} / ${r.reviewerUses}</td><td>${r.autoKept} / ${r.autoDropped}</td><td>${esc(rel)}</td><td>${esc(r.models.join(", ") || "—")}</td></tr>`;
|
|
196
217
|
}).join("\n");
|
|
197
218
|
|
|
198
219
|
return `<!doctype html>
|
|
@@ -212,7 +233,7 @@ th{color:#8b98a5;font-weight:600}
|
|
|
212
233
|
${charts}
|
|
213
234
|
${trendChart ? `<div class="card">${trendChart}</div>` : ""}
|
|
214
235
|
<div class="card"><table>
|
|
215
|
-
<tr><th>version</th><th>window</th><th>turns</th><th>latency by tier</th><th>opens / reviewer</th><th>cascade kept / dropped</th><th>walker model</th></tr>
|
|
236
|
+
<tr><th>version</th><th>window</th><th>turns</th><th>latency by tier</th><th>opens / reviewer</th><th>cascade kept / dropped</th><th>relevance / gate·nodes / warm</th><th>walker model</th></tr>
|
|
216
237
|
${tierRows}
|
|
217
238
|
</table></div>
|
|
218
239
|
</body></html>`;
|
package/core/recall/tuning.js
CHANGED
|
@@ -18,6 +18,24 @@ const KNOBS = {
|
|
|
18
18
|
seedsTierMaxWords: { def: 4, min: 2, max: 6, env: "RECALL_SEEDS_TIER_MAX_WORDS" },
|
|
19
19
|
decayHalfLifeDays: { def: 60, min: 10, max: 90, env: "RECALL_DECAY_HALF_LIFE_DAYS" },
|
|
20
20
|
cascade: { def: "on", values: ["on", "off"], env: "RECALL_CASCADE" },
|
|
21
|
+
// Extractive on-open injection: surface Stance verbatim + the actual lines
|
|
22
|
+
// that matched this message (from anywhere in the pack), not just a headline.
|
|
23
|
+
extractive: { def: "on", values: ["on", "off"], env: "RECALL_EXTRACTIVE" },
|
|
24
|
+
// Injection score-gate (keystone): a walker-kept node scoring below this floor
|
|
25
|
+
// (0–100) is not injected. Set 0 to disable the gate entirely (and restore
|
|
26
|
+
// history-based cascade auto-keep). Default 55 = drop weak/incidental links.
|
|
27
|
+
minRelevanceScore: { def: 55, min: 0, max: 100, env: "RECALL_MIN_RELEVANCE_SCORE" },
|
|
28
|
+
// Asymmetric co-kept warming (WS5). Co-OPEN (the model actually opened the
|
|
29
|
+
// nodes) warms at full strength; co-KEPT (the walker co-injected them) is a
|
|
30
|
+
// weaker, noisier signal. Dark-launched OFF: it writes persistent graph state,
|
|
31
|
+
// so it can't be validated in a single test session — flip it on deliberately.
|
|
32
|
+
cokeptWarming: { def: "off", values: ["on", "off"], env: "RECALL_COKEPT_WARMING" },
|
|
33
|
+
// How much a co-kept pair bumps its `related` edge (vs 1.0 for a real co-use).
|
|
34
|
+
cokeptAmount: { def: 0.15, min: 0, max: 1, env: "RECALL_COKEPT_AMOUNT" },
|
|
35
|
+
// Only co-kept nodes scoring at/above this floor warm — high-confidence keeps.
|
|
36
|
+
warmMinScore: { def: 60, min: 0, max: 100, env: "RECALL_WARM_MIN_SCORE" },
|
|
37
|
+
// Cap on how many (strongest) co-kept pairs warm per turn.
|
|
38
|
+
cokeptMaxPairs: { def: 3, min: 1, max: 10, env: "RECALL_COKEPT_MAX_PAIRS" },
|
|
21
39
|
};
|
|
22
40
|
|
|
23
41
|
let _cache = null;
|
package/core/runner.js
CHANGED
|
@@ -1560,8 +1560,11 @@ async function finalizeStreamPreview(state, result) {
|
|
|
1560
1560
|
if (buffer) await editMessage(id, buffer.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1561
1561
|
return;
|
|
1562
1562
|
}
|
|
1563
|
+
// Verbose OFF: the narration ("Let me…") is transient progress only — delete
|
|
1564
|
+
// the preview so settled chat carries just the final answer. Verbose ON keeps
|
|
1565
|
+
// the narration trail. Either way the live preview still showed progress mid-run.
|
|
1563
1566
|
const narration = String(result.narrationText || "").trim();
|
|
1564
|
-
if (narration) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1567
|
+
if (narration && state.settings?.showThinking) await editMessage(id, narration.slice(-4000), telegramHtmlOpts({ streaming: true }));
|
|
1565
1568
|
else await deleteMessage(id);
|
|
1566
1569
|
} catch (_) { /* preview cleanup is best-effort; the final answer still goes out */ }
|
|
1567
1570
|
}
|
package/core/system-prompt.js
CHANGED
|
@@ -471,6 +471,7 @@ function buildDynamicContextBlock(opts = {}) {
|
|
|
471
471
|
const packsInjectedFor = new Map(); // `${adapterId}:${channelId}:${dir}` -> `${sessionId}:${updated}`
|
|
472
472
|
const PACK_INJECT_MAX_CHARS = 4000;
|
|
473
473
|
const DEFAULT_MEMORY_RECALL_MAX_CHARS = 9000;
|
|
474
|
+
const EXTRACTIVE_LINES_PER_SECTION = 4;
|
|
474
475
|
|
|
475
476
|
// Progressive disclosure: when on, a matched pack is injected as a cheap
|
|
476
477
|
// teaser (Stance + State + a pointer) rather than its full body. Heavy
|
|
@@ -539,6 +540,7 @@ function consumeLastInjected() {
|
|
|
539
540
|
}
|
|
540
541
|
|
|
541
542
|
function formatPackForContext(pack, packsLib, opts = {}) {
|
|
543
|
+
const extractive = !!opts.extractive;
|
|
542
544
|
const headline = !!opts.headline;
|
|
543
545
|
const progressive = !!opts.progressive;
|
|
544
546
|
const clip = (s, n) => (s.length > n ? s.slice(0, n) + "\n…[truncated — read the full pack file]" : s);
|
|
@@ -553,30 +555,61 @@ function formatPackForContext(pack, packsLib, opts = {}) {
|
|
|
553
555
|
const childLines = children.map((c) => `- ${c.dir}: ${c.description || c.name}`).join("\n");
|
|
554
556
|
parts.push(`Sub-packs (dig deeper with \`open-claudia pack show <dir>\`):\n${childLines}`);
|
|
555
557
|
}
|
|
556
|
-
//
|
|
557
|
-
//
|
|
558
|
-
//
|
|
559
|
-
//
|
|
560
|
-
//
|
|
561
|
-
//
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
const
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
558
|
+
// Extractive mode (Defect-A fix): the old headline showed Stance only, so a
|
|
559
|
+
// fact living in State/Procedure/Journal never loaded even when it was the
|
|
560
|
+
// whole reason the pack matched. Here we surface Stance verbatim (it carries
|
|
561
|
+
// the always-on rules/prefs) PLUS the actual lines from anywhere in the pack
|
|
562
|
+
// that fuzzy-matched this message, tagged by section — verbatim, never a
|
|
563
|
+
// paraphrase. Progressive/headline/full below are the pre-extractive modes,
|
|
564
|
+
// still used by the classic engine and as the fallback when no lines matched.
|
|
565
|
+
if (extractive) {
|
|
566
|
+
const stance = (pack.sections.Stance || "").trim();
|
|
567
|
+
if (stance) parts.push(`#### Stance\n${stance}`);
|
|
568
|
+
const stanceLow = stance.toLowerCase();
|
|
569
|
+
const bySection = new Map();
|
|
570
|
+
for (const h of (Array.isArray(opts.matchedLines) ? opts.matchedLines : [])) {
|
|
571
|
+
if (!h || h.section === "Stance") continue; // Stance already shown in full
|
|
572
|
+
const text = String(h.text || "").trim();
|
|
573
|
+
if (!text) continue;
|
|
574
|
+
if (stanceLow && stanceLow.includes(text.toLowerCase())) continue; // dedupe vs Stance
|
|
575
|
+
if (!bySection.has(h.section)) bySection.set(h.section, []);
|
|
576
|
+
const arr = bySection.get(h.section);
|
|
577
|
+
if (arr.length < EXTRACTIVE_LINES_PER_SECTION && !arr.includes(text)) arr.push(text);
|
|
578
|
+
}
|
|
579
|
+
let matchedAny = false;
|
|
580
|
+
for (const section of ["Procedure", "State", "Journal"]) {
|
|
581
|
+
const arr = bySection.get(section);
|
|
582
|
+
if (!arr || !arr.length) continue;
|
|
583
|
+
matchedAny = true;
|
|
584
|
+
parts.push(`#### ${section} (matched)\n${arr.map((t) => (/^\s*[-*]/.test(t) ? t : `- ${t}`)).join("\n")}`);
|
|
585
|
+
}
|
|
586
|
+
parts.push(`#### More\n${matchedAny ? "Showing Stance + the lines matched to this message." : "Showing Stance."} Full pack (State/Procedure/Journal): \`open-claudia pack show ${pack.dir}\`.`);
|
|
577
587
|
} else {
|
|
578
|
-
|
|
579
|
-
|
|
588
|
+
// Progressive mode injects only the live sections (Stance = how to think,
|
|
589
|
+
// State = where we are) plus a pointer; full mode also inlines Procedure
|
|
590
|
+
// and the recent Journal.
|
|
591
|
+
// Headline = description + Stance only (Stance carries hard rules/prefs that
|
|
592
|
+
// must always apply when the topic comes up). Everything else is pulled on
|
|
593
|
+
// demand. Progressive = Stance + State. Full = Stance + Procedure + State.
|
|
594
|
+
const sections = headline
|
|
595
|
+
? ["Stance"]
|
|
596
|
+
: progressive
|
|
597
|
+
? ["Stance", "State"]
|
|
598
|
+
: ["Stance", "Procedure", "State"];
|
|
599
|
+
for (const section of sections) {
|
|
600
|
+
const body = (pack.sections[section] || "").trim();
|
|
601
|
+
if (body) parts.push(`#### ${section}\n${body}`);
|
|
602
|
+
}
|
|
603
|
+
if (headline) {
|
|
604
|
+
const hidden = ["State", "Procedure", "Journal"].filter((s) => (pack.sections[s] || "").trim());
|
|
605
|
+
if (hidden.length) parts.push(`#### More\n${hidden.join(" + ")} not shown — run \`open-claudia pack show ${pack.dir}\` to read them.`);
|
|
606
|
+
} else if (progressive) {
|
|
607
|
+
const hasMore = (pack.sections.Procedure || "").trim() || (pack.sections.Journal || "").trim();
|
|
608
|
+
if (hasMore) parts.push(`#### More\nProcedure + Journal not shown — run \`open-claudia pack show ${pack.dir}\` to read them.`);
|
|
609
|
+
} else {
|
|
610
|
+
const journal = (pack.sections.Journal || "").trim().split("\n").filter(Boolean).slice(-10).join("\n");
|
|
611
|
+
if (journal) parts.push(`#### Journal (recent)\n${journal}`);
|
|
612
|
+
}
|
|
580
613
|
}
|
|
581
614
|
// Pack↔tool usages: concrete reusable-tool invocations recorded while this
|
|
582
615
|
// pack was active, so the agent sees HOW a tool was used in this topic (not
|
|
@@ -603,7 +636,9 @@ function buildPackBlock(matches, budget, opts = {}) {
|
|
|
603
636
|
const channelId = currentChannelId();
|
|
604
637
|
const runIdentity = promptDedupeIdentity(opts);
|
|
605
638
|
const progressive = packProgressive();
|
|
639
|
+
const extractive = opts.extractive === true;
|
|
606
640
|
const headline = recallHeadline();
|
|
641
|
+
const matchedByDir = opts.matchedLines instanceof Map ? opts.matchedLines : new Map();
|
|
607
642
|
const collector = opts.recallCollector || lastInjected;
|
|
608
643
|
const blocks = [];
|
|
609
644
|
const used = [];
|
|
@@ -619,7 +654,7 @@ function buildPackBlock(matches, budget, opts = {}) {
|
|
|
619
654
|
// as the task tree), so the pack survives compaction without paying
|
|
620
655
|
// to re-stamp an anchor on every intervening turn.
|
|
621
656
|
if (packsInjectedFor.get(key) === stamp) continue;
|
|
622
|
-
const block = formatPackForContext(pack, packsLib, { progressive, headline });
|
|
657
|
+
const block = formatPackForContext(pack, packsLib, { progressive, headline, extractive, matchedLines: extractive ? (matchedByDir.get(m.dir) || []) : undefined });
|
|
623
658
|
if (!tryUseRecallBudget(budget, block)) continue;
|
|
624
659
|
packsInjectedFor.set(key, stamp);
|
|
625
660
|
collector.packs.push(pack.name || m.dir);
|
|
@@ -627,7 +662,9 @@ function buildPackBlock(matches, budget, opts = {}) {
|
|
|
627
662
|
}
|
|
628
663
|
if (used.length > 0) setImmediate(() => { try { packsLib.touchUsed(used); } catch (e) {} });
|
|
629
664
|
if (blocks.length === 0) return "";
|
|
630
|
-
const intro =
|
|
665
|
+
const intro = extractive
|
|
666
|
+
? `Long-term topic context auto-matched to this message. Each pack shows its Stance (always-on rules/prefs) plus the specific lines that matched — verbatim, not a summary. These are facts already recalled for you; pull the full State/Procedure/Journal with \`open-claudia pack show <dir>\` when you need more. If the user asked for a skill by name, \`pack show\` the matching pack to load its Procedure. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.`
|
|
667
|
+
: headline
|
|
631
668
|
? `Long-term topic context auto-matched to this message — these are *headlines* (description + Stance only), not full packs. Decide which are actually relevant and pull the full State/Procedure/Journal on demand with \`open-claudia pack show <dir>\`. If the user asked for a skill by name, \`pack show\` the matching pack to load its Procedure. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.`
|
|
632
669
|
: `Long-term topic context auto-matched to this message. If the user asked for a skill by name, treat the matching pack's Procedure section as that Open Claudia skill. Source files live under ${packsLib.PACKS_DIR}/<dir>/PACK.md — read or edit them directly when deeper context or a correction is needed.`;
|
|
633
670
|
return `\n\n## Active Open Claudia skills / context packs\n${intro}\n\n${blocks.join("\n\n---\n\n")}`;
|
|
@@ -974,8 +1011,8 @@ async function buildOptionalRecallContext(prompt, opts = {}) {
|
|
|
974
1011
|
{ provider: recallProvider },
|
|
975
1012
|
),
|
|
976
1013
|
logRecall,
|
|
977
|
-
buildPackBlock: (matches, recallBudget) => buildPackBlock(matches, recallBudget, recallOpts),
|
|
978
|
-
buildEntityBlock: (matches, recallBudget) => buildEntityBlock(matches, recallBudget, recallOpts),
|
|
1014
|
+
buildPackBlock: (matches, recallBudget, engineOpts) => buildPackBlock(matches, recallBudget, { ...recallOpts, ...(engineOpts || {}) }),
|
|
1015
|
+
buildEntityBlock: (matches, recallBudget, engineOpts) => buildEntityBlock(matches, recallBudget, { ...recallOpts, ...(engineOpts || {}) }),
|
|
979
1016
|
};
|
|
980
1017
|
const result = await engine.run({
|
|
981
1018
|
userText, contextText, fullContext, packLimit, budget, helpers, provider: recallProvider,
|
|
@@ -1129,6 +1166,7 @@ module.exports = {
|
|
|
1129
1166
|
PromptConstructionError,
|
|
1130
1167
|
buildCoreInstructions,
|
|
1131
1168
|
buildOptionalRecallContext,
|
|
1169
|
+
formatPackForContext,
|
|
1132
1170
|
buildDynamicContextBlock,
|
|
1133
1171
|
buildExternalModeBlock,
|
|
1134
1172
|
buildSpacesTaskModeBlock,
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@inetafrica/open-claudia",
|
|
3
|
-
"version": "3.0.
|
|
3
|
+
"version": "3.0.41",
|
|
4
4
|
"description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
|
|
5
5
|
"main": "bot.js",
|
|
6
6
|
"bin": {
|
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
"setup": "node setup.js",
|
|
11
11
|
"start": "node bot.js",
|
|
12
12
|
"pretest": "node test-provider-fixture.js && node test-provider-core-prompt.js && node test-provider-prompt-parity.js && node test-provider-env-isolation.js && node test-provider-resume-parity.js && node test-provider-bootstrap.js && node test-provider-registry.js && node test-provider-stream-decoder.js && node test-provider-events.js && node test-provider-claude.js && node test-provider-codex.js && node test-provider-runner.js && node test-provider-run-lifecycle.js && node test-provider-gateway.js && node test-provider-side-chat.js && node test-single-runtime.js && node test-provider-migration-backup.js && node test-provider-state-migration.js && node test-provider-session-history.js && node test-provider-session-commands.js && node test-provider-compaction.js && node test-provider-scheduler.js && node test-credential-capture.js && node test-cursor-removal.js && node test-utility-provider-policy.js && node test-provider-subagent.js && node test-provider-recall.js && node test-provider-pack-review.js && node test-memory-mutation-queue.js && node test-provider-dream.js && node test-provider-enforcer.js && node test-provider-tool-hooks.js && node test-provider-boot-matrix.js && node test-provider-capabilities.js && node test-provider-language.js && node test-provider-coupling-audit.js && node test-provider-parity-e2e.js && node test-kazee-channel-health.js && node test-prompt-budget.js && node test-agency-ledger.js && node test-agency-deliberate.js && node test-guardrail.js && node test-spaces-comment-content.js",
|
|
13
|
-
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
|
|
13
|
+
"test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-walker-score.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-score-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-cokept-warming.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-pack-search.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-extractive-injection.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tooling-mode.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-tool-manifest.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-evolution.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-approval-async.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-unified-identity.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-queue-routing.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-packs.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-speaker-persona.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-persona-pipeline.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-recall-relationship-gate.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-relationship.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-enforcer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-identity-prune.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-kazee-message-id.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-delivery-contract.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-run-lock.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-web-sessions.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-poll-recovery.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-409-grace.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-telegram-conflict-heal-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-single-instance.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-streaming-split.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=./test-fixtures/fake-agent-cli.js FAKE_AGENT_KIND=claude AGENT_ENV_PASSTHROUGH=FAKE_AGENT_KIND node test-spaces-autoenable.js"
|
|
14
14
|
},
|
|
15
15
|
"files": [
|
|
16
16
|
"bot.js",
|
|
@@ -38,6 +38,9 @@
|
|
|
38
38
|
"test-recall-engine.js",
|
|
39
39
|
"test-recall-graph.js",
|
|
40
40
|
"test-recall-discoverer.js",
|
|
41
|
+
"test-walker-score.js",
|
|
42
|
+
"test-score-gate.js",
|
|
43
|
+
"test-cokept-warming.js",
|
|
41
44
|
"test-project-transcripts-smoke.js",
|
|
42
45
|
"test-abilities.js",
|
|
43
46
|
"test-ability-extraction.js",
|
|
@@ -47,6 +50,8 @@
|
|
|
47
50
|
"test-ability-merge-guard.js",
|
|
48
51
|
"test-learning-e2e.js",
|
|
49
52
|
"test-pack-nesting.js",
|
|
53
|
+
"test-pack-search.js",
|
|
54
|
+
"test-extractive-injection.js",
|
|
50
55
|
"test-read-signal.js",
|
|
51
56
|
"test-tools.js",
|
|
52
57
|
"test-tool-graph.js",
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
// WS5: asymmetric co-kept warming. Co-OPEN warming (the model actually opened
|
|
2
|
+
// the nodes together) fires at full strength elsewhere; co-KEPT (the walker
|
|
3
|
+
// co-injected them) is a weaker, noisier signal, so it warms by a small amount
|
|
4
|
+
// and only under guards — both endpoints high-confidence (score ≥ warmMinScore),
|
|
5
|
+
// ≥1 message-grounded (user-origin) endpoint, and only the top N pairs. The
|
|
6
|
+
// feature is dark-launched OFF (it writes persistent graph state, so it can't be
|
|
7
|
+
// validated inside one test session). This test covers the pure pair-selection
|
|
8
|
+
// (cokeptPairs) and the end-to-end warming write through disc.run() when ON.
|
|
9
|
+
const assert = require("assert");
|
|
10
|
+
const fs = require("fs");
|
|
11
|
+
const os = require("os");
|
|
12
|
+
const path = require("path");
|
|
13
|
+
|
|
14
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-warm-"));
|
|
15
|
+
process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
|
|
16
|
+
process.env.RECALL_DISCOVERER_WALKER = "on";
|
|
17
|
+
process.env.RECALL_METRICS = "off";
|
|
18
|
+
process.env.TOOLS_DIR = path.join(tmp, "tools");
|
|
19
|
+
process.env.RECALL_CASCADE = "off";
|
|
20
|
+
delete process.env.RECALL_MIN_RELEVANCE_SCORE; // default gate (55) — 80/70 both survive
|
|
21
|
+
delete process.env.RECALL_COKEPT_WARMING; // default OFF unless a case flips it
|
|
22
|
+
|
|
23
|
+
const disc = require("./core/recall/discoverer");
|
|
24
|
+
const graph = require("./core/recall/graph");
|
|
25
|
+
|
|
26
|
+
// ── pure: cokeptPairs guards + ranking ───────────────────────────────────────
|
|
27
|
+
const S = (obj) => new Map(Object.entries(obj));
|
|
28
|
+
|
|
29
|
+
// basic: a high-score grounded pair is warmed
|
|
30
|
+
assert.deepStrictEqual(
|
|
31
|
+
disc.cokeptPairs(new Set(["pack:a", "pack:b"]), S({ "pack:a": 80, "pack:b": 70 }), new Set(["pack:a"]), { minScore: 60, maxPairs: 3 }),
|
|
32
|
+
[["pack:a", "pack:b"]], "grounded high-score pair warms");
|
|
33
|
+
|
|
34
|
+
// grounding guard: neither endpoint is a message-grounded seed → no warming
|
|
35
|
+
assert.deepStrictEqual(
|
|
36
|
+
disc.cokeptPairs(new Set(["pack:a", "pack:b"]), S({ "pack:a": 90, "pack:b": 90 }), new Set(), { minScore: 60, maxPairs: 3 }),
|
|
37
|
+
[], "two pure graph hops never reinforce each other (no grounded endpoint)");
|
|
38
|
+
|
|
39
|
+
// score guard: an endpoint below the floor is dropped, killing the only pair
|
|
40
|
+
assert.deepStrictEqual(
|
|
41
|
+
disc.cokeptPairs(new Set(["pack:a", "pack:b"]), S({ "pack:a": 80, "pack:b": 50 }), new Set(["pack:a"]), { minScore: 60, maxPairs: 3 }),
|
|
42
|
+
[], "a below-floor endpoint removes the pair");
|
|
43
|
+
|
|
44
|
+
// node-type guard: only pack/entity graph nodes count — tools/episodes are not graph nodes
|
|
45
|
+
assert.deepStrictEqual(
|
|
46
|
+
disc.cokeptPairs(new Set(["pack:a", "tool:t", "episode:e"]), S({ "pack:a": 90, "tool:t": 90, "episode:e": 90 }), new Set(["pack:a"]), { minScore: 60, maxPairs: 3 }),
|
|
47
|
+
[], "tools and episodes are excluded from co-kept warming");
|
|
48
|
+
|
|
49
|
+
// entity nodes are eligible too
|
|
50
|
+
assert.deepStrictEqual(
|
|
51
|
+
disc.cokeptPairs(new Set(["entity:e1", "pack:a"]), S({ "entity:e1": 70, "pack:a": 65 }), new Set(["entity:e1"]), { minScore: 60, maxPairs: 3 }),
|
|
52
|
+
[["entity:e1", "pack:a"]], "entity↔pack pair warms when grounded + high-score");
|
|
53
|
+
|
|
54
|
+
// cap + ranking: strongest pairs first, capped at maxPairs
|
|
55
|
+
const three = disc.cokeptPairs(
|
|
56
|
+
new Set(["pack:a", "pack:b", "pack:c"]),
|
|
57
|
+
S({ "pack:a": 90, "pack:b": 80, "pack:c": 70 }),
|
|
58
|
+
new Set(["pack:a", "pack:b", "pack:c"]), { minScore: 60, maxPairs: 2 });
|
|
59
|
+
assert.deepStrictEqual(three, [["pack:a", "pack:b"], ["pack:a", "pack:c"]], "top-2 strongest pairs by combined score");
|
|
60
|
+
const one = disc.cokeptPairs(
|
|
61
|
+
new Set(["pack:a", "pack:b", "pack:c"]),
|
|
62
|
+
S({ "pack:a": 90, "pack:b": 80, "pack:c": 70 }),
|
|
63
|
+
new Set(["pack:a"]), { minScore: 60, maxPairs: 1 });
|
|
64
|
+
assert.deepStrictEqual(one, [["pack:a", "pack:b"]], "maxPairs=1 keeps only the single strongest pair");
|
|
65
|
+
|
|
66
|
+
// ── integration: warming ON writes a reinforced edge; OFF writes none ─────────
|
|
67
|
+
const packs = {
|
|
68
|
+
alpha: { dir: "alpha", name: "Alpha", description: "alpha pack", parent: null, tags: [], sections: { Stance: "alpha stance", Procedure: "", State: "", Journal: "" } },
|
|
69
|
+
beta: { dir: "beta", name: "Beta", description: "beta pack", parent: null, tags: [], sections: { Stance: "beta stance", Procedure: "", State: "", Journal: "" } },
|
|
70
|
+
};
|
|
71
|
+
const packsLib = {
|
|
72
|
+
matchPacks: (text) => /widget/.test(text) ? [{ dir: "alpha", name: "Alpha", score: 5 }, { dir: "beta", name: "Beta", score: 4 }] : [],
|
|
73
|
+
readPack: (dir) => packs[dir] || null,
|
|
74
|
+
listPacks: () => Object.values(packs),
|
|
75
|
+
searchLines: () => [],
|
|
76
|
+
PACKS_DIR: tmp,
|
|
77
|
+
};
|
|
78
|
+
const entitiesLib = { matchEntities: () => [], readEntity: () => null, listEntities: () => [], ENTITIES_DIR: tmp };
|
|
79
|
+
const mergeMatches = (primary, secondary, keyOf) => {
|
|
80
|
+
const out = primary.map((m) => ({ ...m, origin: "user" }));
|
|
81
|
+
const seen = new Set(primary.map(keyOf));
|
|
82
|
+
for (const m of secondary) if (!seen.has(keyOf(m))) out.push({ ...m, origin: "context" });
|
|
83
|
+
return out;
|
|
84
|
+
};
|
|
85
|
+
const buildPackBlock = (matches) => (matches.length ? "PACKBLOCK" : "");
|
|
86
|
+
const buildEntityBlock = (matches) => (matches.length ? "ENTITYBLOCK" : "");
|
|
87
|
+
const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock };
|
|
88
|
+
const walkerReturning = (text) => ({
|
|
89
|
+
warmWalker: { isEnabled: () => false },
|
|
90
|
+
resolveUtilityProvider: () => ({ provider: {}, providerId: "fake", model: "fake-model" }),
|
|
91
|
+
spawnUtilityAgent: async () => ({ text, provider: "fake", model: "fake-model" }),
|
|
92
|
+
});
|
|
93
|
+
const QUERY = "tell me about the widget deployment process in detail";
|
|
94
|
+
const KEEP_BOTH = '[{"id":"pack:alpha","why":"relevant","score":80},{"id":"pack:beta","why":"related","score":70}]';
|
|
95
|
+
const reinforcedBetween = (a, b) => graph.allEdges().some((e) =>
|
|
96
|
+
((e.src === a && e.dst === b) || (e.src === b && e.dst === a)) && e.type === "related" && e.last_reinforced);
|
|
97
|
+
|
|
98
|
+
(async () => {
|
|
99
|
+
// The integration path writes/reads the sqlite-backed graph. CI runs node:20
|
|
100
|
+
// (no node:sqlite), where graph.reinforce()/allEdges() are no-ops — so, like
|
|
101
|
+
// test-recall-graph.js, skip the graph-dependent assertions when the graph is
|
|
102
|
+
// unavailable. The pure cokeptPairs() unit tests above already ran and cover
|
|
103
|
+
// the selection logic on every node version.
|
|
104
|
+
if (!graph.available()) { console.log("cokept warming OK (integration skipped — no node:sqlite)"); return; }
|
|
105
|
+
// OFF (default): both packs kept, but NO reinforced edge is written
|
|
106
|
+
const off = await disc.run({ userText: QUERY, contextText: "", fullContext: QUERY, packLimit: 6, budget: {}, helpers, walkerOptions: walkerReturning(KEEP_BOTH) });
|
|
107
|
+
assert.ok(off.packMatches.some((m) => m.dir === "alpha") && off.packMatches.some((m) => m.dir === "beta"), "both packs kept");
|
|
108
|
+
assert.strictEqual(reinforcedBetween("pack:alpha", "pack:beta"), false, "warming OFF (default) writes no reinforced edge");
|
|
109
|
+
|
|
110
|
+
// ON: the co-kept, high-confidence, grounded pair warms its related edge
|
|
111
|
+
process.env.RECALL_COKEPT_WARMING = "on";
|
|
112
|
+
await disc.run({ userText: QUERY, contextText: "", fullContext: QUERY, packLimit: 6, budget: {}, helpers, walkerOptions: walkerReturning(KEEP_BOTH) });
|
|
113
|
+
assert.strictEqual(reinforcedBetween("pack:alpha", "pack:beta"), true, "warming ON reinforces the co-kept pair's related edge");
|
|
114
|
+
delete process.env.RECALL_COKEPT_WARMING;
|
|
115
|
+
|
|
116
|
+
console.log("cokept warming OK");
|
|
117
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// WS3: extractive on-open injection. The old headline surfaced Stance only, so
|
|
2
|
+
// a fact living in State/Procedure/Journal never loaded even when it was the
|
|
3
|
+
// reason the pack matched (Defect A — the felt regression). Extractive mode
|
|
4
|
+
// renders Stance verbatim PLUS the exact lines that matched this message, from
|
|
5
|
+
// anywhere in the pack, tagged by section and never paraphrased. Covers: cross-
|
|
6
|
+
// section surfacing, verbatim text, section tags, Stance dedupe, per-section
|
|
7
|
+
// cap, Stance-only fallback, and the unchanged classic headline path.
|
|
8
|
+
const assert = require("assert");
|
|
9
|
+
const { formatPackForContext } = require("./core/system-prompt");
|
|
10
|
+
|
|
11
|
+
const pack = {
|
|
12
|
+
dir: "deploy-notes",
|
|
13
|
+
name: "Deploy Notes",
|
|
14
|
+
description: "how we ship",
|
|
15
|
+
parent: null,
|
|
16
|
+
tags: [],
|
|
17
|
+
sections: {
|
|
18
|
+
Stance: "Prefer GitOps for what runs in the cluster.\nNever force-push to main.",
|
|
19
|
+
Procedure: "Run the smoke test before every deploy.\nTag the release then push via git-release.",
|
|
20
|
+
State: "The ArgoCD sync for payments is currently paused.\nWaiting on Emmanuel's review.",
|
|
21
|
+
Journal: "2026-07-01 shipped v3.\n2026-07-02 rolled back v3 then re-shipped.\n2026-06-30 unrelated note about pizza.",
|
|
22
|
+
},
|
|
23
|
+
};
|
|
24
|
+
const packsLib = { readPack: () => null, listPacks: () => [] };
|
|
25
|
+
|
|
26
|
+
// ── extractive: Stance verbatim + the matched State line, tagged, not paraphrased ──
|
|
27
|
+
const matched = [
|
|
28
|
+
{ section: "State", lineno: 1, text: "The ArgoCD sync for payments is currently paused.", score: 30 },
|
|
29
|
+
{ section: "Procedure", lineno: 2, text: "Tag the release then push via git-release.", score: 20 },
|
|
30
|
+
];
|
|
31
|
+
const out = formatPackForContext(pack, packsLib, { extractive: true, matchedLines: matched });
|
|
32
|
+
|
|
33
|
+
assert.ok(/#### Stance\nPrefer GitOps for what runs in the cluster\./.test(out), "Stance shown verbatim");
|
|
34
|
+
assert.ok(/Never force-push to main\./.test(out), "full Stance body preserved");
|
|
35
|
+
assert.ok(/#### State \(matched\)/.test(out), "matched State section is tagged");
|
|
36
|
+
assert.ok(out.includes("The ArgoCD sync for payments is currently paused."), "the matched State fact is surfaced verbatim (Defect-A fix)");
|
|
37
|
+
assert.ok(/#### Procedure \(matched\)/.test(out), "matched Procedure section is tagged");
|
|
38
|
+
assert.ok(out.includes("Tag the release then push via git-release."), "matched Procedure line surfaced verbatim");
|
|
39
|
+
// Un-matched noise must NOT be dumped: the pizza journal line never matched.
|
|
40
|
+
assert.ok(!out.includes("pizza"), "unmatched Journal noise is not injected");
|
|
41
|
+
assert.ok(!out.includes("Waiting on Emmanuel"), "unmatched State line is not injected");
|
|
42
|
+
assert.ok(/open-claudia pack show deploy-notes/.test(out), "points to the full pack for more");
|
|
43
|
+
|
|
44
|
+
// ── Stance dedupe: a matched line that IS a Stance line is not repeated ──
|
|
45
|
+
const dupMatched = [{ section: "Stance", text: "Never force-push to main.", score: 20 },
|
|
46
|
+
{ section: "State", text: "Prefer GitOps for what runs in the cluster.", score: 20 }]; // same text as a Stance line
|
|
47
|
+
const outDup = formatPackForContext(pack, packsLib, { extractive: true, matchedLines: dupMatched });
|
|
48
|
+
// "Prefer GitOps..." appears once (in Stance), not again under State (matched).
|
|
49
|
+
const gitopsCount = (outDup.match(/Prefer GitOps for what runs in the cluster\./g) || []).length;
|
|
50
|
+
assert.strictEqual(gitopsCount, 1, "a matched line equal to a Stance line is not duplicated");
|
|
51
|
+
|
|
52
|
+
// ── per-section cap: at most EXTRACTIVE_LINES_PER_SECTION (4) lines per section ──
|
|
53
|
+
const many = Array.from({ length: 9 }, (_, i) => ({ section: "Journal", text: `2026-07-${10 + i} note number ${i}`, score: 10 }));
|
|
54
|
+
const outMany = formatPackForContext(pack, packsLib, { extractive: true, matchedLines: many });
|
|
55
|
+
const journalBullets = (outMany.match(/note number \d/g) || []).length;
|
|
56
|
+
assert.ok(journalBullets <= 4, `per-section cap holds (got ${journalBullets})`);
|
|
57
|
+
|
|
58
|
+
// ── Stance-only fallback: extractive with no matched lines → Stance + pointer, no (matched) blocks ──
|
|
59
|
+
const outNone = formatPackForContext(pack, packsLib, { extractive: true, matchedLines: [] });
|
|
60
|
+
assert.ok(/#### Stance/.test(outNone), "Stance still shown when nothing matched");
|
|
61
|
+
assert.ok(!/\(matched\)/.test(outNone), "no (matched) sections when there were no line hits");
|
|
62
|
+
assert.ok(/Showing Stance\./.test(outNone), "fallback pointer explains only Stance is shown");
|
|
63
|
+
|
|
64
|
+
// ── classic headline path unchanged: Stance only, heavier sections hidden ──
|
|
65
|
+
const outHeadline = formatPackForContext(pack, packsLib, { headline: true });
|
|
66
|
+
assert.ok(/#### Stance/.test(outHeadline), "headline still shows Stance");
|
|
67
|
+
assert.ok(!outHeadline.includes("The ArgoCD sync for payments"), "headline does NOT inline State (unchanged legacy behavior)");
|
|
68
|
+
assert.ok(/not shown/.test(outHeadline), "headline still points to hidden sections");
|
|
69
|
+
|
|
70
|
+
console.log("extractive injection OK");
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
// WS1: extractive per-line search (packs.searchLines). Unlike matchPacks,
|
|
2
|
+
// which routes whole packs, searchLines returns the individual lines that
|
|
3
|
+
// best match a query, tagged by section — the substrate the extractive
|
|
4
|
+
// injector uses to surface a verbatim fact from anywhere in a pack, not just
|
|
5
|
+
// Stance. Covers: cross-section retrieval, section tagging, verbatim text,
|
|
6
|
+
// distinct-term scoring, dir scoping, k cap, stopword/empty guards, archived
|
|
7
|
+
// exclusion.
|
|
8
|
+
const assert = require("assert");
|
|
9
|
+
const fs = require("fs");
|
|
10
|
+
const os = require("os");
|
|
11
|
+
const path = require("path");
|
|
12
|
+
|
|
13
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "pack-search-"));
|
|
14
|
+
process.env.PACKS_DIR = path.join(tmp, "packs");
|
|
15
|
+
|
|
16
|
+
const packs = require("./core/packs");
|
|
17
|
+
|
|
18
|
+
packs.createPack({
|
|
19
|
+
dir: "deploy-notes",
|
|
20
|
+
name: "Deploy Notes",
|
|
21
|
+
description: "how we ship",
|
|
22
|
+
stance: "Prefer GitOps for what runs in the cluster.",
|
|
23
|
+
procedure: "Run the smoke test before every deploy.\nTag the release then push via git-release.",
|
|
24
|
+
state: "The ArgoCD sync for payments is currently paused.",
|
|
25
|
+
journal: "2026-07-01 deployed v3 to the cluster without incident.",
|
|
26
|
+
});
|
|
27
|
+
packs.createPack({
|
|
28
|
+
dir: "billing",
|
|
29
|
+
name: "Billing",
|
|
30
|
+
description: "invoices and refunds",
|
|
31
|
+
stance: "Refunds always require a manager approval.",
|
|
32
|
+
procedure: "Generate the invoice, then email it to the customer.",
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
// ── cross-section: a fact in Procedure/State/Journal is retrievable, proving
|
|
36
|
+
// searchLines reaches beyond Stance (the felt-regression fix). ──
|
|
37
|
+
const argo = packs.searchLines("argocd sync payments");
|
|
38
|
+
assert.ok(argo.length > 0, "finds a matching line");
|
|
39
|
+
assert.strictEqual(argo[0].dir, "deploy-notes", "matched the right pack");
|
|
40
|
+
assert.strictEqual(argo[0].section, "State", "line is tagged with its section (State, not Stance)");
|
|
41
|
+
assert.ok(/ArgoCD sync for payments/.test(argo[0].text), "returns the verbatim source line");
|
|
42
|
+
assert.ok(argo[0].lineno >= 1, "reports a 1-based line number");
|
|
43
|
+
assert.ok(argo[0].score > 0, "reports a positive score");
|
|
44
|
+
|
|
45
|
+
// ── verbatim: returned text is the source line trimmed, never paraphrased ──
|
|
46
|
+
const smoke = packs.searchLines("smoke test deploy", { dir: "deploy-notes" });
|
|
47
|
+
assert.ok(smoke.some((h) => h.text === "Run the smoke test before every deploy." && h.section === "Procedure"),
|
|
48
|
+
"verbatim line preserved exactly, correct section");
|
|
49
|
+
|
|
50
|
+
// ── scoring: a line covering more distinct query terms outranks one covering
|
|
51
|
+
// fewer, so the injector's top hit is the densest match. ──
|
|
52
|
+
const rank = packs.searchLines("release git-release push tag");
|
|
53
|
+
assert.strictEqual(rank[0].section, "Procedure", "the line with the most distinct query terms ranks first");
|
|
54
|
+
assert.ok(/git-release/.test(rank[0].text), "top hit is the multi-term line");
|
|
55
|
+
|
|
56
|
+
// ── dir scoping: --pack restricts the search to one pack ──
|
|
57
|
+
const scoped = packs.searchLines("approval invoice refund", { dir: "billing" });
|
|
58
|
+
assert.ok(scoped.length > 0, "dir-scoped search returns billing lines");
|
|
59
|
+
assert.ok(scoped.every((h) => h.dir === "billing"), "dir scoping excludes other packs");
|
|
60
|
+
|
|
61
|
+
// ── k cap: never returns more than k hits ──
|
|
62
|
+
const capped = packs.searchLines("the", { k: 2 }); // 'the' is a stopword → no terms
|
|
63
|
+
assert.strictEqual(capped.length, 0, "a stopword-only query yields no terms and no hits");
|
|
64
|
+
const capped2 = packs.searchLines("deploy invoice refund approval release", { k: 2 });
|
|
65
|
+
assert.ok(capped2.length <= 2, "k caps the number of hits");
|
|
66
|
+
|
|
67
|
+
// ── guards: empty query → [] ──
|
|
68
|
+
assert.deepStrictEqual(packs.searchLines(""), [], "empty query returns nothing");
|
|
69
|
+
assert.deepStrictEqual(packs.searchLines(" "), [], "whitespace query returns nothing");
|
|
70
|
+
|
|
71
|
+
// ── no match → [] ──
|
|
72
|
+
assert.deepStrictEqual(packs.searchLines("kubernetes helm quantum"), [], "no matching lines returns []");
|
|
73
|
+
|
|
74
|
+
// ── archived packs are excluded from a corpus-wide search ──
|
|
75
|
+
packs.archivePack("billing");
|
|
76
|
+
const afterArchive = packs.searchLines("approval invoice refund");
|
|
77
|
+
assert.ok(afterArchive.every((h) => h.dir !== "billing"), "archived pack lines are not surfaced");
|
|
78
|
+
|
|
79
|
+
console.log("pack search OK");
|
|
@@ -46,7 +46,26 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
|
|
|
46
46
|
// provider-agnostic loose-credential capture (a pasted secret is stored
|
|
47
47
|
// out-of-band and the turn stopped before the model — Codex refuses secrets in
|
|
48
48
|
// its prompt, so the harness intercepts the paste itself).
|
|
49
|
-
|
|
49
|
+
// 3.0.40 approved by Sumeet 2026-07-18 ("please go ahead and work through end
|
|
50
|
+
// to end till it's fully built and deploy out a new version for me ... I want
|
|
51
|
+
// you to fully complete the plan then let me know to upgrade and test"): the
|
|
52
|
+
// Pass-4 recall overhaul. Third cut — v3.0.38 bumped the version but forgot to
|
|
53
|
+
// move THIS pin (pretest caught it); v3.0.39 fixed the pin but a new test
|
|
54
|
+
// (test-cokept-warming.js) asserted a sqlite-graph write that no-ops on CI's
|
|
55
|
+
// node:20 (no node:sqlite), so it now skips that path like test-recall-graph.js.
|
|
56
|
+
// Payload unchanged: extractive on-open injection (Stance verbatim + fuzzy-
|
|
57
|
+
// matched, section-tagged lines) surfaces the actual matched facts instead of a
|
|
58
|
+
// headline; a walker score-gate drops weakly-relevant nodes below the relevance
|
|
59
|
+
// floor and tightens the cascade so sticky auto-keeps route back to the judge; a
|
|
60
|
+
// KPI ruler stamps per-version relevance/gate/warm columns. Co-kept Hebbian
|
|
61
|
+
// warming ships dark (default OFF).
|
|
62
|
+
// 3.0.41 approved by Sumeet 2026-07-18 ("Yes I think that's fine please fix it
|
|
63
|
+
// and push it out"): verbose-off narration cleanup. The interim stream preview
|
|
64
|
+
// showed the pre-tool "Let me…" narration and then settled to keep it as a
|
|
65
|
+
// permanent message regardless of /verbose — only extended-thinking was gated.
|
|
66
|
+
// finalizeStreamPreview now deletes the settled preview when /verbose is off, so
|
|
67
|
+
// chat carries only the final answer; verbose ON still keeps the narration trail.
|
|
68
|
+
assert.strictEqual(pkg.version, "3.0.41", "release version must remain unchanged without explicit approval");
|
|
50
69
|
for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
|
|
51
70
|
assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
|
|
52
71
|
}
|
package/test-recall-evolution.js
CHANGED
|
@@ -168,7 +168,7 @@ assert.ok(fs.existsSync(path.join(backupRoot, "packs", "backfill-me")), "deduped
|
|
|
168
168
|
metrics._resetForTest();
|
|
169
169
|
const L = (o) => JSON.stringify(o) + "\n";
|
|
170
170
|
fs.writeFileSync(metrics.LOG_FILE, [
|
|
171
|
-
L({ ts: "2026-06-01T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a" }, { id: "pack:b" }], latencyMs: 10000, costUsd: 0.01 }),
|
|
171
|
+
L({ ts: "2026-06-01T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a", score: 80 }, { id: "pack:b", score: 60 }], gate: { min: 55, out: ["pack:z"] }, warmed: [["pack:a", "pack:b"]], latencyMs: 10000, costUsd: 0.01 }),
|
|
172
172
|
L({ ts: "2026-06-01T00:01:00Z", v: "2.6.50", use: ["pack:a"] }),
|
|
173
173
|
L({ ts: "2026-06-02T00:00:00Z", v: "2.6.50", model: "haiku", tier: "full", gated: true, latencyMs: 5 }),
|
|
174
174
|
L({ ts: "2026-07-01T00:00:00Z", v: "2.6.59", model: "haiku", tier: "seeds", seeds: [{ id: "pack:a" }], kept: [{ id: "pack:a" }], latencyMs: 2000, costUsd: 0, auto: { kept: ["pack:a"], dropped: ["pack:z"] } }),
|
|
@@ -183,10 +183,17 @@ assert.strictEqual(v50.gatedPct, 50);
|
|
|
183
183
|
assert.strictEqual(v50.rescuePct, 100, "kept-without-seed counts as a rescue turn");
|
|
184
184
|
assert.strictEqual(v50.noisePct, 50, "one of two kept nodes was used");
|
|
185
185
|
assert.deepStrictEqual(v50.models, ["haiku"]);
|
|
186
|
+
// Pass-4 (WS0) ruler columns: relevance (avg walker score), gate-pass, warmings
|
|
187
|
+
assert.strictEqual(v50.avgKeptScore, 70, "relevance = mean walker score across kept nodes (80,60)");
|
|
188
|
+
assert.strictEqual(v50.gatedNodes, 1, "score-gate dropped one node this window");
|
|
189
|
+
assert.strictEqual(v50.gateFiredPct, 100, "the one gate-active turn fired");
|
|
190
|
+
assert.strictEqual(v50.warmings, 1, "one co-kept warming applied");
|
|
186
191
|
const v59 = rows.find((r) => r.v === "2.6.59");
|
|
187
192
|
assert.strictEqual(v59.rescuePct, 0);
|
|
188
193
|
assert.strictEqual(v59.autoKept, 1);
|
|
189
194
|
assert.strictEqual(v59.autoDropped, 1);
|
|
195
|
+
assert.strictEqual(v59.avgKeptScore, null, "a window with no scored keeps reports null relevance, not 0");
|
|
196
|
+
assert.strictEqual(v59.warmings, 0, "no warmings logged → zero");
|
|
190
197
|
assert.strictEqual(rows.find((r) => r.v === kpi.LEGACY_VERSION).walkerFailPct, 100);
|
|
191
198
|
|
|
192
199
|
metrics.appendKpi({ trigger: "test", health: { rescueRate: 0.25, noiseRate: 0.5, avgLatencyMs: 8000 }, window: { turns: 40 } });
|
|
@@ -197,6 +204,7 @@ assert.ok(snaps[0].v && snaps[0].ts, "snapshots are version-stamped");
|
|
|
197
204
|
|
|
198
205
|
const table = kpi.renderTable(rows);
|
|
199
206
|
assert.ok(/2\.6\.50/.test(table) && /rescue/.test(table), "table renders versions and metrics");
|
|
207
|
+
assert.ok(/relevance/.test(table) && /warm/.test(table), "table surfaces the Pass-4 ruler columns");
|
|
200
208
|
const html = kpi.htmlReport();
|
|
201
209
|
assert.ok(/<svg/.test(html) && /2\.6\.59/.test(html) && /rescue/i.test(html), "HTML report has charts and versions");
|
|
202
210
|
const outPath = kpi.writeHtmlReport(path.join(tmp, "kpi.html"));
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
// WS4: injection score-gate (the keystone). A walker-kept node scoring below
|
|
2
|
+
// the relevance floor (minRelevanceScore, default 55) is NOT injected — this is
|
|
3
|
+
// what stops a weakly-linked/incidental pack from riding along (Defect B, the
|
|
4
|
+
// cascade precision leak). The gate only fires on a full-tier successful walk;
|
|
5
|
+
// a fail-open walk (garbage/empty output → scores null) bypasses it and takes
|
|
6
|
+
// the classic top-3 user-seed path. Crucially, a successful walk that gates
|
|
7
|
+
// EVERY node leaves an empty (non-null) kept map → inject nothing — which is a
|
|
8
|
+
// different signal from fail-open (keep the strongest seeds). This test drives
|
|
9
|
+
// the gate end-to-end through disc.run() with an injected fake walker.
|
|
10
|
+
const assert = require("assert");
|
|
11
|
+
const fs = require("fs");
|
|
12
|
+
const os = require("os");
|
|
13
|
+
const path = require("path");
|
|
14
|
+
|
|
15
|
+
const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-gate-"));
|
|
16
|
+
process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
|
|
17
|
+
process.env.RECALL_DISCOVERER_WALKER = "on"; // walker ON — we inject a fake one
|
|
18
|
+
process.env.RECALL_METRICS = "off";
|
|
19
|
+
process.env.TOOLS_DIR = path.join(tmp, "tools");
|
|
20
|
+
process.env.RECALL_CASCADE = "off"; // send both candidates straight to the judge (no history/shadow randomness)
|
|
21
|
+
delete process.env.RECALL_MIN_RELEVANCE_SCORE; // ensure the default floor (55) applies, not an operator pin
|
|
22
|
+
|
|
23
|
+
const disc = require("./core/recall/discoverer");
|
|
24
|
+
|
|
25
|
+
// Two packs; matchPacks returns BOTH for a "widget" query, so both become
|
|
26
|
+
// full-tier candidates that reach the walker as pack:alpha / pack:beta.
|
|
27
|
+
const packs = {
|
|
28
|
+
alpha: { dir: "alpha", name: "Alpha", description: "alpha pack",
|
|
29
|
+
parent: null, tags: [], sections: { Stance: "alpha stance", Procedure: "", State: "", Journal: "" } },
|
|
30
|
+
beta: { dir: "beta", name: "Beta", description: "beta pack",
|
|
31
|
+
parent: null, tags: [], sections: { Stance: "beta stance", Procedure: "", State: "", Journal: "" } },
|
|
32
|
+
};
|
|
33
|
+
const packsLib = {
|
|
34
|
+
matchPacks: (text) => /widget/.test(text)
|
|
35
|
+
? [{ dir: "alpha", name: "Alpha", score: 5 }, { dir: "beta", name: "Beta", score: 4 }]
|
|
36
|
+
: [],
|
|
37
|
+
readPack: (dir) => packs[dir] || null,
|
|
38
|
+
listPacks: () => Object.values(packs),
|
|
39
|
+
searchLines: () => [], // extractive path present but yields no line hits here — keeps the block simple
|
|
40
|
+
PACKS_DIR: tmp,
|
|
41
|
+
};
|
|
42
|
+
const entitiesLib = { matchEntities: () => [], readEntity: () => null, listEntities: () => [], ENTITIES_DIR: tmp };
|
|
43
|
+
const mergeMatches = (primary, secondary, keyOf) => {
|
|
44
|
+
const out = primary.map((m) => ({ ...m, origin: "user" }));
|
|
45
|
+
const seen = new Set(primary.map(keyOf));
|
|
46
|
+
for (const m of secondary) if (!seen.has(keyOf(m))) out.push({ ...m, origin: "context" });
|
|
47
|
+
return out;
|
|
48
|
+
};
|
|
49
|
+
let builtPacks = null;
|
|
50
|
+
const buildPackBlock = (matches) => { builtPacks = matches; return matches.length ? "PACKBLOCK" : ""; };
|
|
51
|
+
const buildEntityBlock = (matches) => (matches.length ? "ENTITYBLOCK" : "");
|
|
52
|
+
const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock };
|
|
53
|
+
|
|
54
|
+
// Inject a fake walker: force the cold path (warm disabled), a fixed provider,
|
|
55
|
+
// and a canned agent reply. walk() parses the JSON array out of `text`.
|
|
56
|
+
function walkerReturning(text) {
|
|
57
|
+
return {
|
|
58
|
+
warmWalker: { isEnabled: () => false },
|
|
59
|
+
resolveUtilityProvider: () => ({ provider: {}, providerId: "fake", model: "fake-model" }),
|
|
60
|
+
spawnUtilityAgent: async () => ({ text, provider: "fake", model: "fake-model" }),
|
|
61
|
+
};
|
|
62
|
+
}
|
|
63
|
+
const QUERY = "tell me about the widget deployment process in detail";
|
|
64
|
+
const runGate = (walkerText, extra = {}) => disc.run({
|
|
65
|
+
userText: QUERY, contextText: "", fullContext: QUERY,
|
|
66
|
+
packLimit: 6, budget: {}, helpers, walkerOptions: walkerReturning(walkerText), ...extra,
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
(async () => {
|
|
70
|
+
// ── Test 1: default floor 55 — alpha@80 kept, beta@40 gated out ──────────────
|
|
71
|
+
builtPacks = null;
|
|
72
|
+
const res1 = await runGate('[{"id":"pack:alpha","why":"directly relevant","score":80},{"id":"pack:beta","why":"weakly linked","score":40}]');
|
|
73
|
+
assert.strictEqual(res1.tier, "full", "substantive widget query lands on the full tier");
|
|
74
|
+
assert.ok(res1.packMatches.some((m) => m.dir === "alpha"), "alpha (score 80 ≥ 55) is kept");
|
|
75
|
+
assert.ok(!res1.packMatches.some((m) => m.dir === "beta"), "beta (score 40 < 55) is gated out of injection");
|
|
76
|
+
assert.ok(res1.why["pack:alpha"], "kept alpha carries its why");
|
|
77
|
+
assert.ok(!res1.why["pack:beta"], "gated beta is absent from the why map");
|
|
78
|
+
assert.ok(builtPacks && builtPacks.length === 1 && builtPacks[0].dir === "alpha", "only alpha reaches the block builder");
|
|
79
|
+
|
|
80
|
+
// ── Test 2: gate disabled (floor 0) — beta@40 now survives ───────────────────
|
|
81
|
+
process.env.RECALL_MIN_RELEVANCE_SCORE = "0";
|
|
82
|
+
const res2 = await runGate('[{"id":"pack:alpha","why":"relevant","score":80},{"id":"pack:beta","why":"weak","score":40}]');
|
|
83
|
+
assert.ok(res2.packMatches.some((m) => m.dir === "alpha") && res2.packMatches.some((m) => m.dir === "beta"),
|
|
84
|
+
"floor 0 → gate off → both kept, including beta@40");
|
|
85
|
+
delete process.env.RECALL_MIN_RELEVANCE_SCORE;
|
|
86
|
+
|
|
87
|
+
// ── Test 3: fail-open — garbage walker output (scores null) bypasses the gate ─
|
|
88
|
+
const res3 = await runGate("this is not json at all, sorry");
|
|
89
|
+
assert.ok(res3.packMatches.some((m) => m.dir === "alpha") && res3.packMatches.some((m) => m.dir === "beta"),
|
|
90
|
+
"unparseable walk → fail-open keeps the strongest user seeds, gate is bypassed");
|
|
91
|
+
|
|
92
|
+
// ── Test 4: all-low scores at floor 55 — inject NOTHING (empty, not fail-open) ─
|
|
93
|
+
// The distinguishing case: a successful walk that gates every node must leave
|
|
94
|
+
// an empty kept set (packMatches [], packBlock ""), NOT fall back to the
|
|
95
|
+
// fail-open top-3 seed path (which would wrongly keep both).
|
|
96
|
+
builtPacks = null;
|
|
97
|
+
const res4 = await runGate('[{"id":"pack:alpha","why":"meh","score":30},{"id":"pack:beta","why":"meh","score":20}]');
|
|
98
|
+
assert.strictEqual(res4.packMatches.length, 0, "all scores below floor → nothing injected (empty non-null kept map)");
|
|
99
|
+
assert.strictEqual(res4.packBlock, "", "empty kept set renders no packBlock");
|
|
100
|
+
assert.deepStrictEqual(res4.why, {}, "empty kept set → empty why map (distinct from fail-open)");
|
|
101
|
+
assert.strictEqual(builtPacks && builtPacks.length, 0, "the block builder is handed zero packs, proving this is NOT the fail-open path");
|
|
102
|
+
|
|
103
|
+
console.log("score gate OK");
|
|
104
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
// WS2: the walker's score contract. The router now emits {id, why, score};
|
|
2
|
+
// walk() parses score into a parallel id→0-100 map while kept stays id→why, so
|
|
3
|
+
// downstream (injection score-gate, asymmetric warming) gets a relevance signal
|
|
4
|
+
// without reshaping every why-consumer. A missing/garbage score defaults to a
|
|
5
|
+
// neutral 60 so a parse hiccup never silently buries a node the router kept.
|
|
6
|
+
const assert = require("assert");
|
|
7
|
+
|
|
8
|
+
process.env.RECALL_DISCOVERER_WALKER = "on"; // ensure WALKER_ENABLED at module load
|
|
9
|
+
const disc = require("./core/recall/discoverer");
|
|
10
|
+
|
|
11
|
+
const candidates = [
|
|
12
|
+
{ id: "pack:alpha", name: "Alpha" },
|
|
13
|
+
{ id: "pack:beta", name: "Beta" },
|
|
14
|
+
{ id: "pack:gamma", name: "Gamma" },
|
|
15
|
+
{ id: "pack:delta", name: "Delta" },
|
|
16
|
+
{ id: "pack:epsilon", name: "Epsilon" },
|
|
17
|
+
];
|
|
18
|
+
|
|
19
|
+
// Drive the real runWalker → parse path with an injected cold-spawn agent.
|
|
20
|
+
function fakeWalk(replyArray, text) {
|
|
21
|
+
return disc.walk("do the alpha thing", "", candidates, {
|
|
22
|
+
warmWalker: { isEnabled: () => false }, // force the cold spawn path
|
|
23
|
+
resolveUtilityProvider: () => ({ provider: {}, providerId: "fake", model: "fake-model" }),
|
|
24
|
+
spawnUtilityAgent: async () => ({
|
|
25
|
+
text: text != null ? text : JSON.stringify(replyArray),
|
|
26
|
+
provider: "fake",
|
|
27
|
+
model: "fake-model",
|
|
28
|
+
}),
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
(async () => {
|
|
33
|
+
const res = await fakeWalk([
|
|
34
|
+
{ id: "pack:alpha", why: "directly about alpha", score: 92 },
|
|
35
|
+
{ id: "pack:beta", why: "missing score defaults neutral" }, // → 60
|
|
36
|
+
{ id: "pack:gamma", why: "over the top", score: 150 }, // → 100
|
|
37
|
+
{ id: "pack:delta", why: "negative", score: -20 }, // → 0
|
|
38
|
+
{ id: "pack:epsilon", why: "fractional", score: 33.7 }, // → 34
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
assert.ok(res.kept instanceof Map, "kept is a Map");
|
|
42
|
+
assert.ok(res.scores instanceof Map, "scores is a parallel Map");
|
|
43
|
+
assert.strictEqual(res.kept.get("pack:alpha"), "directly about alpha", "why preserved verbatim (kept stays id→why string)");
|
|
44
|
+
assert.strictEqual(res.scores.get("pack:alpha"), 92, "explicit score parsed");
|
|
45
|
+
assert.strictEqual(res.scores.get("pack:beta"), 60, "missing score → neutral 60 (fail-open, never buried)");
|
|
46
|
+
assert.strictEqual(res.scores.get("pack:gamma"), 100, "score clamped to 100");
|
|
47
|
+
assert.strictEqual(res.scores.get("pack:delta"), 0, "score clamped to 0");
|
|
48
|
+
assert.strictEqual(res.scores.get("pack:epsilon"), 34, "fractional score rounded");
|
|
49
|
+
|
|
50
|
+
// Hallucinated ids (not among candidates) are dropped from BOTH maps.
|
|
51
|
+
const res2 = await fakeWalk([
|
|
52
|
+
{ id: "pack:alpha", why: "ok", score: 70 },
|
|
53
|
+
{ id: "pack:HALLUCINATED", why: "invented", score: 99 },
|
|
54
|
+
]);
|
|
55
|
+
assert.ok(res2.kept.has("pack:alpha"), "known id kept");
|
|
56
|
+
assert.ok(!res2.kept.has("pack:HALLUCINATED"), "unknown id dropped from kept");
|
|
57
|
+
assert.ok(!res2.scores.has("pack:HALLUCINATED"), "unknown id dropped from scores");
|
|
58
|
+
|
|
59
|
+
// Non-numeric score → neutral 60, never NaN.
|
|
60
|
+
const res3 = await fakeWalk([{ id: "pack:alpha", why: "x", score: "high" }]);
|
|
61
|
+
assert.strictEqual(res3.scores.get("pack:alpha"), 60, "non-numeric score → neutral 60");
|
|
62
|
+
|
|
63
|
+
// Unparseable output → fail-open: both maps null, no throw.
|
|
64
|
+
const res4 = await fakeWalk(null, "sorry, I cannot help with that");
|
|
65
|
+
assert.strictEqual(res4.kept, null, "no JSON array → kept null (fail-open)");
|
|
66
|
+
assert.strictEqual(res4.scores, null, "no JSON array → scores null (fail-open)");
|
|
67
|
+
|
|
68
|
+
console.log("walker score OK");
|
|
69
|
+
})().catch((e) => { console.error(e); process.exit(1); });
|