@inetafrica/open-claudia 3.0.37 → 3.0.40

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 CHANGED
@@ -1,5 +1,14 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.40 — Sharper recall: surfaces the actual matched facts, drops weakly-relevant ones (third cut of the Pass-4 release)
4
+
5
+ - **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).
6
+ - **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).
7
+ - **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).
8
+ - **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.
9
+ - **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.
10
+ - **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.)
11
+
3
12
  ## v3.0.37 — Silent background watches, and pasted secrets are accepted on any provider
4
13
 
5
14
  - **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 walker for an assistant's long-term memory.",
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
- "Decide which nodes are GENUINELY relevant to what the user is doing now.",
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 write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
227
- 'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
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
- // Judge candidates. Returns { kept: Map|null, costUsd, source, walkerMs } —
295
- // kept null means fail-open (walker off/failed/unparseable).
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 and their why, e.g. [{"id":"' + candidates[0].id + '","why":"..."}].',
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") autoKept.set(c.id, `kept ${st.kept}/${st.seen} recent similar turns`);
509
- else autoDropped.push(c.id);
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
- let packBlock = buildPackBlock(finalPacks, budget);
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 };
@@ -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>`;
@@ -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;
@@ -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
- // Progressive mode injects only the live sections (Stance = how to think,
557
- // State = where we are) plus a pointer; full mode also inlines Procedure
558
- // and the recent Journal.
559
- // Headline = description + Stance only (Stance carries hard rules/prefs that
560
- // must always apply when the topic comes up). Everything else is pulled on
561
- // demand. Progressive = Stance + State. Full = Stance + Procedure + State.
562
- const sections = headline
563
- ? ["Stance"]
564
- : progressive
565
- ? ["Stance", "State"]
566
- : ["Stance", "Procedure", "State"];
567
- for (const section of sections) {
568
- const body = (pack.sections[section] || "").trim();
569
- if (body) parts.push(`#### ${section}\n${body}`);
570
- }
571
- if (headline) {
572
- const hidden = ["State", "Procedure", "Journal"].filter((s) => (pack.sections[s] || "").trim());
573
- if (hidden.length) parts.push(`#### More\n${hidden.join(" + ")} not shown — run \`open-claudia pack show ${pack.dir}\` to read them.`);
574
- } else if (progressive) {
575
- const hasMore = (pack.sections.Procedure || "").trim() || (pack.sections.Journal || "").trim();
576
- if (hasMore) parts.push(`#### More\nProcedure + Journal not shown — run \`open-claudia pack show ${pack.dir}\` to read them.`);
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
- const journal = (pack.sections.Journal || "").trim().split("\n").filter(Boolean).slice(-10).join("\n");
579
- if (journal) parts.push(`#### Journal (recent)\n${journal}`);
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 = headline
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.37",
3
+ "version": "3.0.40",
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,20 @@ 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
- assert.strictEqual(pkg.version, "3.0.37", "release version must remain unchanged without explicit approval");
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
+ assert.strictEqual(pkg.version, "3.0.40", "release version must remain unchanged without explicit approval");
50
63
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
51
64
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
52
65
  }
@@ -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); });