@inetafrica/open-claudia 2.6.57 → 2.6.58

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,13 @@
1
1
  # Changelog
2
2
 
3
+ ## v2.6.58
4
+ - **Tiered recall gate — stop paying 15 seconds for "thanks".** Measured over 700+ live turns, the old discoverer pre-gate returned true whenever *any* seed matched, so it gated just 0.4% of turns — a bare "ok" with an incidental keyword hit still paid the full seed → graph → walker pass (p50 ~15s). A new `recallTier()` classifies each turn into three tiers: **skip** (pleasantries/acks up to 4 words *regardless of seeds*, emoji-only, or ≤2 words with no seeds — no recall at all), **seeds** (short command-like turns ≤4 words — user-origin keyword seeds inject directly with no graph expansion, no walker, no excerpts; the classic-engine baseline at ~ms cost), and **full** (substantive turns — the whole pipeline). The tier is logged per turn and the metrics summary now tracks the seeds-only share alongside the gated share.
5
+ - **Walker candidate cap.** The latency deep-dive showed the walk itself dominates, not process spawn: 15–40 candidates × 600-char excerpts = 10–25KB prompts to the haiku judge on every substantive turn. Full-tier candidates are now priority-ordered — user-origin seeds by match score, then context seeds, then graph-activated nodes by activation — and capped at `RECALL_WALKER_MAX_CANDIDATES` (default 14), so the judge reads the strongest few instead of everything the graph touched.
6
+ - **Episodic memory — recall now remembers how it did things last time.** The discoverer gains a transcript hop: on full-tier turns it queries the project-transcript FTS index (up to `RECALL_EPISODE_LIMIT`, default 3; `RECALL_EPISODES=off` disables) and offers the hits to the walker as `episode:` candidates with their matched snippets. The walker keeps one only if that past exchange holds a decision, method, or outcome directly reusable for the current message — and the fail-open path (walker off/failed) *drops* episodes rather than injecting unjudged snippets. Kept episodes render as a "Past work that looks related" block with the snippet plus a ready-to-run `open-claudia transcript-search … --all` pointer for the full context, and surface in the `/recall` banner as 📓 lines.
7
+ - **Real cost + phase telemetry for recall.** The warm walker's stream-json `total_cost_usd` is cumulative per session; it now tracks the running total and reports the per-walk *delta*, which flows into `recall-metrics.jsonl` per turn (`costUsd`) instead of the flat 0 logged before. Each turn also records `seedMs` / `expandMs` / `walkerMs` and which walker path ran (`warm`/`cold`/`off`/`failed`), so the p50 can be decomposed instead of guessed at.
8
+ - **Weak-use signal from the post-turn reviewer.** Precision metrics only counted explicit 📖 opens as "used". Now, when the reviewer decides a turn's work updated pack X or entity Y, that adjudication is logged as use (`source: "reviewer"`) and co-updated nodes get a Hebbian co-work reinforcement — so packs that quietly shape work without being re-opened stop looking like noise.
9
+ - **Journal dedupe.** The reviewer could append near-identical Journal lines turn after turn (observed: 28 same-day duplicates on one busy pack). `updatePack` now token-Jaccard-compares a new journal line (dates stripped) against the last 5 entries and skips appends at ≥0.75 similarity, flagging `journalDeduped` instead. Adds `test-journal-dedupe.js`.
10
+
3
11
  ## v2.6.57
4
12
  - **Move the inactivity watchdog into the live runner.** Deep review found v2.6.56 put the watchdog in the legacy `bot-agent.js` monolith, but the LaunchAgent runs `bot.js`, which delegates heavy turns through `core/runner.js`. That meant the installed live path still only had the 6-hour hard timeout and could still wedge on a silent child. This release ports the watchdog to `core/runner.js`, updates activity on child stdout/stderr, terminates the process tree after `OC_TURN_IDLE_MS` of silence, suppresses the confusing partial/no-output final message, clears stuck busy state with a failsafe, and drains any queued follow-up messages instead of stranding them. Adds a static regression test that fails if the watchdog only exists in `bot-agent.js` again.
5
13
 
package/README.md CHANGED
@@ -436,6 +436,8 @@ All stored in `~/.open-claudia/`:
436
436
  | `MEMORY_RECALL_MAX_CHARS` | No | Hard cap for auto-injected pack/entity memory per turn (default `9000`, `off` disables auto recall injection) |
437
437
  | `RECALL_ENGINE` | No | Default recall engine when a chat hasn't set one via `/engine` (`classic` or `discoverer`, default `classic`) |
438
438
  | `RECALL_GRAPH_DB` / `RECALL_METRICS` | No | Override the discoverer graph DB path; `off` on metrics disables per-turn recall logging |
439
+ | `RECALL_WALKER_MAX_CANDIDATES` | No | Cap on candidates sent to the discoverer's walker judge per turn (default `14`) |
440
+ | `RECALL_EPISODES` / `RECALL_EPISODE_LIMIT` | No | `off` disables episodic transcript candidates in recall; limit caps transcript hits per turn (default `3`) |
439
441
  | `PROJECT_TRANSCRIPTS` | No | Enable redacted project transcripts (default `true`) |
440
442
  | `TRANSCRIPT_MAX_ENTRY_CHARS` | No | Max chars per transcript entry (default `12000`) |
441
443
  | `TRANSCRIPTS_DIR` / `PACKS_DIR` / `ENTITIES_DIR` | No | Override storage directories |
@@ -312,9 +312,11 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
312
312
  const decision = parseDecision(text);
313
313
  if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && (decision.lessons || []).length === 0)) return;
314
314
  const lines = [];
315
+ const usedIds = []; // nodes this turn's work demonstrably touched
315
316
  for (const a of decision.actions) {
316
317
  try {
317
318
  const r = applyAction(a);
319
+ if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
318
320
  if (r && r.kind === "skipped") {
319
321
  lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
320
322
  } else if (r && r.kind === "create" && r.ability) {
@@ -332,6 +334,7 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
332
334
  try {
333
335
  const r = applyEntityAction(ea);
334
336
  if (r) {
337
+ if (r.slug) usedIds.push(`entity:${r.slug}`);
335
338
  const emoji = ENTITY_EMOJI[r.type] || ENTITY_EMOJI.thing;
336
339
  lines.push(r.kind === "create"
337
340
  ? `${emoji} Now tracking ${r.name} (${r.type}) — ${clipWords(r.note, 160)}`
@@ -341,6 +344,16 @@ function reviewTurn({ userText, assistantText, channelId, announce }) {
341
344
  console.warn(`[pack-review] entity apply failed: ${e.message}`);
342
345
  }
343
346
  }
347
+ // Weak-use signal: the reviewer deciding a pack/entity needed updating is
348
+ // evidence the turn genuinely worked on that node — use, observed after
349
+ // the fact, even when the agent never re-opened the note (it may already
350
+ // have been injected). Feeds the same precision metric as 📖 opens and
351
+ // reinforces co-use edges. This is co-WORK, never co-recall — surfacing
352
+ // alone still reinforces nothing.
353
+ if (usedIds.length) {
354
+ try { require("./recall/metrics").logUse(usedIds, "reviewer"); } catch (e) {}
355
+ try { if (usedIds.length > 1) require("./recall/graph").reinforceSet(usedIds); } catch (e) {}
356
+ }
344
357
  for (const la of (decision.lessons || [])) {
345
358
  try {
346
359
  const r = applyLessonAction(la);
package/core/packs.js CHANGED
@@ -343,8 +343,35 @@ function recordForegroundWrite(dir, { tool, oldString, content } = {}) {
343
343
  } catch (e) {}
344
344
  }
345
345
 
346
+ // Near-duplicate check for journal appends. The reviewer runs after EVERY
347
+ // substantial turn, so a long working session on one topic used to stack
348
+ // dozens of same-day entries that only differ in phrasing (observed: 28 in a
349
+ // day on one pack) — crowding real history out of the capped Journal. Token
350
+ // Jaccard against the most recent entries; dates stripped before comparing.
351
+ function journalTokens(s) {
352
+ return new Set(
353
+ String(s || "").toLowerCase()
354
+ .replace(/^-\s*\[[^\]]*\]\s*/, "")
355
+ .replace(/[^a-z0-9\s]/g, " ")
356
+ .split(/\s+/).filter((w) => w.length > 1)
357
+ );
358
+ }
359
+
360
+ function isNearDupJournal(line, recentEntries) {
361
+ const a = journalTokens(line);
362
+ if (!a.size) return false;
363
+ for (const e of recentEntries) {
364
+ const b = journalTokens(e);
365
+ if (!b.size) continue;
366
+ let inter = 0;
367
+ for (const w of a) if (b.has(w)) inter++;
368
+ if (inter / (a.size + b.size - inter) >= 0.75) return true;
369
+ }
370
+ return false;
371
+ }
372
+
346
373
  // Apply a reviewer mutation. Only supplied fields change; journal entries
347
- // append (capped); state replaces (it represents "current truth").
374
+ // append (capped + deduped); state replaces (it represents "current truth").
348
375
  function updatePack(dir, { description, tags, stance, procedure, state, journal, skill, kind, learned_on } = {}, origin = "user") {
349
376
  const pack = readPack(dir);
350
377
  if (!pack) throw new Error(`no pack: ${dir}`);
@@ -370,9 +397,14 @@ function updatePack(dir, { description, tags, stance, procedure, state, journal,
370
397
  if (typeof state === "string" && state.trim()) { pack.sections.State = state.trim(); applied.push("State"); }
371
398
  if (typeof journal === "string" && journal.trim()) {
372
399
  const entries = pack.sections.Journal.split("\n").filter((l) => l.trim());
373
- entries.push(`- [${today()}] ${journal.trim().replace(/\n+/g, " ")}`);
374
- pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
375
- applied.push("Journal");
400
+ const line = journal.trim().replace(/\n+/g, " ");
401
+ if (isNearDupJournal(line, entries.slice(-5))) {
402
+ pack.journalDeduped = true;
403
+ } else {
404
+ entries.push(`- [${today()}] ${line}`);
405
+ pack.sections.Journal = entries.slice(-MAX_JOURNAL_ENTRIES).join("\n");
406
+ applied.push("Journal");
407
+ }
376
408
  }
377
409
  writePack(pack);
378
410
  if (applied.length) setProvenance(dir, applied, origin);
@@ -590,7 +622,7 @@ function matchPacks(text, { limit = 3, threshold = null } = {}) {
590
622
 
591
623
  module.exports = {
592
624
  PACKS_DIR, SECTIONS, slugify,
593
- listPacks, findPack, readPack, writePack, createPack, updatePack, removePack,
625
+ listPacks, findPack, readPack, writePack, createPack, updatePack, removePack, isNearDupJournal,
594
626
  listSkillPacks, setSkill, listAbilities, setKind, parentByPrefix, setParent, recordApplied, recordCoUse,
595
627
  touchUsed, packNameFromPath, matchPacks, reindex, markIndexDirty,
596
628
  archivePack, restorePack, listArchived,
@@ -26,10 +26,21 @@ const toolsLib = require("../tools");
26
26
  // entity-only). Optional — absent on old node where node:sqlite is missing.
27
27
  let toolGraph = null;
28
28
  try { toolGraph = require("../tool-graph"); } catch (e) { /* old node */ }
29
+ // Episodic memory: FTS over past transcripts (same optionality as the graphs).
30
+ let transcriptIndex = null;
31
+ try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old node */ }
29
32
 
30
33
  const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
31
34
  const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
32
35
  const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
36
+ // Cap on pack/entity candidates handed to the walker. Uncapped, a wordy turn
37
+ // seeds 15-40 nodes × 600-char excerpts — the walker then reads a 10-25KB
38
+ // prompt, which measured as THE dominant recall latency (p50 ~13.5s even with
39
+ // the warm process). Capped, the prompt stays small; priority order below keeps
40
+ // the strongest evidence.
41
+ const MAX_WALKER_CANDIDATES = Number(process.env.RECALL_WALKER_MAX_CANDIDATES || 14);
42
+ const EPISODES_ENABLED = String(process.env.RECALL_EPISODES || "on").toLowerCase() !== "off";
43
+ const EPISODE_LIMIT = Number(process.env.RECALL_EPISODE_LIMIT || 3);
33
44
  const EXCERPT_CHARS = 600;
34
45
  const CONTEXT_CLIP = 3000;
35
46
 
@@ -45,16 +56,30 @@ function maybeSync(packsLib, entitiesLib) {
45
56
  try { graph.syncFromCorpus(packsLib, entitiesLib); } catch (e) {}
46
57
  }
47
58
 
48
- // Cheap pre-gate: terse acknowledgements / pure pleasantries don't need recall.
49
- function needsRecall(userText, seedCount) {
59
+ // Tiered pre-gate. The old boolean gate returned true whenever FTS found ANY
60
+ // seed — with a 147-pack corpus that is virtually every turn (measured: gated
61
+ // 0.4% of 500 turns), so "thanks!" still paid a full 13s walk. Three tiers:
62
+ // skip — pure pleasantry/ack/emoji: no recall at all, regardless of seeds.
63
+ // seeds — short command-like turns (≤4 words): keyword seeds are precise
64
+ // enough; inject user-origin seeds directly and skip graph + walker
65
+ // (the classic baseline, ~ms instead of ~13s).
66
+ // full — substantive turns: graph expansion + walker judgement.
67
+ const PLEASANTRY_RE = /^(ok(ay)?|k|kk|thanks?|thank you|thx|ty|yes|yep|yeah|no|nope|nah|cool|nice|great|perfect|awesome|lovely|done|sure|got it|sounds good|will do|lol|haha|hi|hey|hello|yo|morning|evening|night|good (morning|afternoon|evening|night)|bye|goodnight|cheers|please do|go ahead)\b/;
68
+
69
+ function recallTier(userText, seedCount) {
50
70
  const t = String(userText || "").trim().toLowerCase();
51
- if (!t) return false;
52
- if (seedCount > 0) return true; // FTS already found something — never gate it out
53
- // No seeds: only bother with graph/walker work for a substantive message.
71
+ if (!t) return "skip";
54
72
  const words = t.split(/\s+/).filter(Boolean);
55
- if (words.length <= 2) return false;
56
- if (/^(ok|okay|thanks|thank you|yes|yep|no|nope|cool|nice|great|done|sure|got it|k)\b/.test(t)) return false;
57
- return true;
73
+ if (!/[a-z0-9]/.test(t)) return "skip"; // emoji/punctuation only
74
+ if (words.length <= 4 && PLEASANTRY_RE.test(t)) return "skip";
75
+ if (words.length <= 2 && seedCount === 0) return "skip";
76
+ if (words.length <= 4) return "seeds";
77
+ return "full";
78
+ }
79
+
80
+ // Back-compat boolean view of the tier (exported/tested API).
81
+ function needsRecall(userText, seedCount) {
82
+ return recallTier(userText, seedCount) !== "skip";
58
83
  }
59
84
 
60
85
  function idFor(m) { return m.dir ? `pack:${m.dir}` : `entity:${m.slug}`; }
@@ -95,6 +120,7 @@ const WALKER_SYSTEM = [
95
120
  "Decide which nodes are GENUINELY relevant to what the user is doing now.",
96
121
  "Keep graph-linked nodes ONLY if they actually bear on the task (e.g. a shared theme/commit-style that governs the thing being worked on). Drop incidental keyword overlaps.",
97
122
  "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.",
123
+ "Some candidates are past-conversation episodes (id starts `episode:`). Keep one ONLY if that past exchange holds a decision, method, or outcome directly reusable for the current message — not mere topic overlap.",
98
124
  "For each kept node write a terse why (≤12 words). Quote concrete facts verbatim; never invent.",
99
125
  'Reply with ONLY a JSON array: [{"id":"pack:foo","why":"shared lime theme governs this app"}]. Use [] if none.',
100
126
  ].join("\n");
@@ -102,20 +128,25 @@ const WALKER_SYSTEM = [
102
128
  // Run the walker model on the prompt. Prefer the warm (reused) process for
103
129
  // low latency; on any warm-path error fall back to a cold spawn — identical
104
130
  // model/prompt/contract, so quality is unchanged and recall never silently
105
- // degrades to the classic engine.
131
+ // degrades to the classic engine. Returns { text, costUsd, source }.
106
132
  async function runWalker(prompt) {
107
133
  const opts = { model: WALKER_MODEL, systemPrompt: WALKER_SYSTEM, timeoutMs: WALKER_TIMEOUT_MS };
108
134
  if (warmWalker.isEnabled()) {
109
135
  try {
110
- return await warmWalker.walkWarm(prompt, opts);
136
+ const r = await warmWalker.walkWarm(prompt, opts);
137
+ return { text: r.text, costUsd: r.costUsd, source: "warm" };
111
138
  } catch (e) { /* fall back to cold spawn below */ }
112
139
  }
113
140
  const { text } = await spawnSubagent(prompt, opts);
114
- return text;
141
+ return { text, costUsd: null, source: "cold" };
115
142
  }
116
143
 
144
+ // Judge candidates. Returns { kept: Map|null, costUsd, source, walkerMs } —
145
+ // kept null means fail-open (walker off/failed/unparseable).
117
146
  async function walk(userText, contextText, candidates) {
118
- if (!WALKER_ENABLED || candidates.length === 0) return null;
147
+ if (!WALKER_ENABLED || candidates.length === 0) {
148
+ return { kept: null, costUsd: null, source: "off", walkerMs: 0 };
149
+ }
119
150
  const lines = candidates.map((c) => {
120
151
  const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
121
152
  return `- ${c.id}: ${c.name}${tag}${c.description ? `\n ${c.description}` : ""}${c.excerpt ? `\n excerpt: ${c.excerpt.replace(/\n/g, " ")}` : ""}`;
@@ -130,12 +161,14 @@ async function walk(userText, contextText, candidates) {
130
161
  "",
131
162
  'Reply ONLY with the JSON array of kept nodes and their why, e.g. [{"id":"' + candidates[0].id + '","why":"..."}].',
132
163
  ].join("\n");
164
+ const t0 = Date.now();
133
165
  try {
134
- const text = await runWalker(prompt);
166
+ const { text, costUsd, source } = await runWalker(prompt);
167
+ const walkerMs = Date.now() - t0;
135
168
  const match = String(text || "").match(/\[[\s\S]*\]/);
136
- if (!match) return null;
169
+ if (!match) return { kept: null, costUsd, source, walkerMs };
137
170
  const arr = JSON.parse(match[0]);
138
- if (!Array.isArray(arr)) return null;
171
+ if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs };
139
172
  const known = new Set(candidates.map((c) => c.id));
140
173
  const out = new Map();
141
174
  for (const item of arr) {
@@ -143,9 +176,9 @@ async function walk(userText, contextText, candidates) {
143
176
  out.set(item.id, String(item.why || "").slice(0, 120));
144
177
  }
145
178
  }
146
- return out;
179
+ return { kept: out, costUsd, source, walkerMs };
147
180
  } catch (e) {
148
- return null;
181
+ return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0 };
149
182
  }
150
183
  }
151
184
 
@@ -154,8 +187,6 @@ async function run(ctx) {
154
187
  const { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock } = helpers;
155
188
  const started = Date.now();
156
189
 
157
- maybeSync(packsLib, entitiesLib);
158
-
159
190
  // 1+2: seed via FTS (user words + context), same as classic.
160
191
  let packSeeds = [];
161
192
  let entSeeds = [];
@@ -185,32 +216,55 @@ async function run(ctx) {
185
216
  } catch (e) {}
186
217
 
187
218
  const seedCount = packSeeds.length + entSeeds.length + toolSeedByName.size;
219
+ const seedMs = Date.now() - started;
188
220
 
189
- // 1: pre-gate.
190
- if (!needsRecall(userText, seedCount)) {
191
- metrics.logTurn({ engine: "discoverer", query: userText, gated: true, latencyMs: Date.now() - started });
192
- return { packBlock: "", entityBlock: "", toolBlock: "", packMatches: [], entityMatches: [], toolMatches: [], why: {}, gated: true };
221
+ // 1: tiered pre-gate.
222
+ const tier = recallTier(userText, seedCount);
223
+ if (tier === "skip") {
224
+ metrics.logTurn({ engine: "discoverer", query: userText, gated: true, tier, latencyMs: Date.now() - started });
225
+ return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], episodeMatches: [], why: {}, gated: true };
193
226
  }
194
227
 
195
- // 3: spreading activation from seeds across the graph.
228
+ // 3: spreading activation from seeds across the graph (full tier only — the
229
+ // seeds tier injects keyword seeds directly, no graph, no walker).
196
230
  const seedNodes = [
197
231
  ...packSeeds.map((m) => ({ id: `pack:${m.dir}`, score: m.score || 2 })),
198
232
  ...entSeeds.map((m) => ({ id: `entity:${m.slug}`, score: m.score || 2 })),
199
233
  ];
200
234
  const seedIds = new Set(seedNodes.map((s) => s.id));
201
235
  let activated = new Map();
202
- try { activated = graph.expand(seedNodes, {}); } catch (e) {}
236
+ const expandStarted = Date.now();
237
+ if (tier === "full") {
238
+ maybeSync(packsLib, entitiesLib);
239
+ try { activated = graph.expand(seedNodes, {}); } catch (e) {}
240
+ }
241
+ const expandMs = Date.now() - expandStarted;
203
242
 
204
- // 4: assemble candidates (seeds + activated), build excerpts, run the walker.
205
- const candIds = new Set(seedIds);
206
- for (const id of activated.keys()) candIds.add(id);
243
+ // 4: assemble candidates (seeds + activated) in priority order user seeds
244
+ // by score, then context seeds, then graph-activated by activation — capped
245
+ // at MAX_WALKER_CANDIDATES so the walker prompt stays small and fast. Only
246
+ // the full tier pays for excerpts; the seeds tier never reads them.
247
+ const seedRank = new Map();
248
+ for (const m of packSeeds) seedRank.set(`pack:${m.dir}`, { score: m.score || 2, origin: m.origin });
249
+ for (const m of entSeeds) seedRank.set(`entity:${m.slug}`, { score: m.score || 2, origin: m.origin });
250
+ const orderedIds = [...seedIds].sort((a, b) => {
251
+ const ra = seedRank.get(a) || {}, rb = seedRank.get(b) || {};
252
+ const ua = ra.origin === "user" ? 0 : 1, ub = rb.origin === "user" ? 0 : 1;
253
+ return ua - ub || (rb.score || 0) - (ra.score || 0);
254
+ });
255
+ for (const [id] of [...activated.entries()].sort((x, y) => (y[1].activation || 0) - (x[1].activation || 0))) {
256
+ if (!seedIds.has(id)) orderedIds.push(id);
257
+ }
207
258
  const candidates = [];
208
- for (const id of candIds) {
209
- const base = excerptFor(id, packsLib, entitiesLib);
210
- if (!base) continue;
211
- const act = activated.get(id);
212
- candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
259
+ if (tier === "full") {
260
+ for (const id of orderedIds.slice(0, MAX_WALKER_CANDIDATES)) {
261
+ const base = excerptFor(id, packsLib, entitiesLib);
262
+ if (!base) continue;
263
+ const act = activated.get(id);
264
+ candidates.push({ ...base, activated: !seedIds.has(id), via: act ? act.via : null });
265
+ }
213
266
  }
267
+ const candIds = new Set(tier === "full" ? candidates.map((c) => c.id) : seedIds);
214
268
 
215
269
  // Tool candidates = direct tool seeds + tools that belong to any candidate
216
270
  // pack (declared via the tool's `pack:` header, or used-in-topic per the
@@ -221,7 +275,7 @@ async function run(ctx) {
221
275
  for (const id of candIds) if (id.startsWith("pack:")) candPackDirs.add(id.slice(5));
222
276
  const toolCand = new Map();
223
277
  for (const [name, t] of toolSeedByName) toolCand.set(name, { name, description: t.description, via: null });
224
- if (candPackDirs.size) {
278
+ if (tier === "full" && candPackDirs.size) {
225
279
  try {
226
280
  const byPack = new Map();
227
281
  for (const t of toolsLib.listTools()) {
@@ -247,13 +301,40 @@ async function run(ctx) {
247
301
  } catch (e) {}
248
302
  }
249
303
  }
250
- for (const c of toolCand.values()) {
251
- const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
252
- if (!base) continue;
253
- candidates.push({ ...base, activated: !!c.via, via: c.via });
304
+ if (tier === "full") {
305
+ for (const c of toolCand.values()) {
306
+ const base = excerptFor(`tool:${c.name}`, packsLib, entitiesLib);
307
+ if (!base) continue;
308
+ candidates.push({ ...base, activated: !!c.via, via: c.via });
309
+ }
254
310
  }
255
311
 
256
- const whyById = (await walk(userText, contextText || fullContext, candidates)) || null;
312
+ // Episodic hop: FTS over past transcripts turns up prior sessions on this
313
+ // topic; the walker judges them like any node. This is the bridge from
314
+ // knowledge (packs) to episodes (what we actually did last time) — kept ones
315
+ // inject as a snippet plus a pointer to reopen the conversation window.
316
+ let episodeCands = [];
317
+ if (tier === "full" && EPISODES_ENABLED && transcriptIndex && transcriptIndex.available()) {
318
+ try {
319
+ const { hits } = transcriptIndex.search(userText, { limit: EPISODE_LIMIT });
320
+ episodeCands = (hits || []).map((h) => {
321
+ const terms = [...String(h.snip || "").matchAll(/>>(.+?)<</g)].map((m) => m[1]).slice(0, 4);
322
+ return {
323
+ id: `episode:${require("path").basename(String(h.file || ""), ".jsonl")}:${h.line}`,
324
+ name: `Past conversation${h.project ? ` (${h.project})` : ""}${h.ts ? ` ${String(h.ts).slice(0, 10)}` : ""}`,
325
+ description: "",
326
+ excerpt: clip(String(h.snip || "").replace(/>>|<</g, ""), EXCERPT_CHARS),
327
+ activated: true,
328
+ via: "transcript",
329
+ _terms: terms,
330
+ };
331
+ });
332
+ candidates.push(...episodeCands);
333
+ } catch (e) { /* episodic recall is best-effort */ }
334
+ }
335
+
336
+ const walkRes = await walk(userText, contextText || fullContext, candidates);
337
+ const whyById = walkRes.kept;
257
338
 
258
339
  // Decide the kept set. Walker result wins; on fail-open keep the user-origin
259
340
  // seeds only (classic baseline) and drop graph-expanded/context guesses.
@@ -320,20 +401,40 @@ async function run(ctx) {
320
401
  toolBlock = `\n\n## Tools that may help here\nSurfaced for this turn — run with \`open-claudia tool run <name> [args]\`; read docs/source with \`open-claudia tool show <name>\`:\n${lines.join("\n")}\n`;
321
402
  }
322
403
 
404
+ // Past-conversation episodes the walker kept (fail-open drops them — an
405
+ // unjudged transcript snippet is a guess, not a memory).
406
+ const episodeMatches = episodeCands
407
+ .filter((c) => keptIds.has(c.id))
408
+ .map((c) => ({ id: c.id, name: c.name, excerpt: c.excerpt, terms: c._terms, why: whyById ? whyById.get(c.id) || "" : "" }));
409
+ let episodeBlock = "";
410
+ if (episodeMatches.length) {
411
+ const lines = episodeMatches.map((m) => {
412
+ const q = (m.terms && m.terms.length ? m.terms.join(" ") : String(m.excerpt).split(/\s+/).slice(0, 4).join(" ")).replace(/"/g, "");
413
+ return `- ${m.name}: "${m.excerpt}"${m.why ? ` — ${m.why}` : ""}\n ↳ full context: open-claudia transcript-search "${q}" --all`;
414
+ });
415
+ episodeBlock = `\n\n## Past work that looks related\nFrom earlier conversations — verify before relying on it:\n${lines.join("\n")}\n`;
416
+ }
417
+
323
418
  metrics.logTurn({
324
419
  engine: "discoverer",
325
420
  query: userText,
421
+ tier,
326
422
  seeds: seedNodes,
327
423
  activated: [...activated.entries()].map(([id, a]) => ({ id, activation: a.activation, hop: a.hop })),
328
424
  kept: [...keptIds].map((id) => ({ id, why: whyById ? whyById.get(id) : "" })),
329
425
  gated: false,
330
426
  latencyMs: Date.now() - started,
427
+ seedMs,
428
+ expandMs,
429
+ walkerMs: walkRes.walkerMs,
430
+ walker: walkRes.source,
431
+ costUsd: walkRes.costUsd || 0,
331
432
  });
332
433
 
333
434
  return {
334
- packBlock, entityBlock, toolBlock, packMatches: finalPacks, entityMatches: finalEnts,
335
- toolMatches, why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
435
+ packBlock, entityBlock, toolBlock, episodeBlock, packMatches: finalPacks, entityMatches: finalEnts,
436
+ toolMatches, episodeMatches, why: whyById ? Object.fromEntries(whyById) : {}, gated: false,
336
437
  };
337
438
  }
338
439
 
339
- module.exports = { name: "discoverer", run, needsRecall, walk };
440
+ module.exports = { name: "discoverer", run, needsRecall, recallTier, walk };
@@ -17,8 +17,9 @@ function enabled() {
17
17
  }
18
18
 
19
19
  // Record one recall turn. `entry`:
20
- // { engine, query, seeds:[{id,score}], activated:[{id,activation,hop}],
21
- // kept:[{id,why}], gated (bool), latencyMs, costUsd }
20
+ // { engine, query, tier, seeds:[{id,score}], activated:[{id,activation,hop}],
21
+ // kept:[{id,why}], gated (bool), latencyMs, seedMs, expandMs, walkerMs,
22
+ // walker ("warm"|"cold"|"off"|"failed"), costUsd }
22
23
  function logTurn(entry) {
23
24
  if (!enabled()) return;
24
25
  try {
@@ -26,11 +27,16 @@ function logTurn(entry) {
26
27
  ts: new Date().toISOString(),
27
28
  engine: entry.engine || "discoverer",
28
29
  query: String(entry.query || "").slice(0, 200),
30
+ tier: entry.tier || "",
29
31
  seeds: (entry.seeds || []).map((s) => ({ id: s.id, score: s.score })),
30
32
  activated: (entry.activated || []).map((a) => ({ id: a.id, activation: round(a.activation), hop: a.hop })),
31
33
  kept: (entry.kept || []).map((k) => ({ id: k.id, why: String(k.why || "").slice(0, 200) })),
32
34
  gated: !!entry.gated,
33
35
  latencyMs: entry.latencyMs || 0,
36
+ seedMs: entry.seedMs || 0,
37
+ expandMs: entry.expandMs || 0,
38
+ walkerMs: entry.walkerMs || 0,
39
+ walker: entry.walker || "",
34
40
  costUsd: entry.costUsd || 0,
35
41
  };
36
42
  fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
@@ -38,15 +44,19 @@ function logTurn(entry) {
38
44
  } catch (e) {}
39
45
  }
40
46
 
41
- // Record which nodes were actually opened (📖) this turn. Matched against the
42
- // most recent surfaced sets to derive precision/noise without a second pass
43
- // over the whole log.
44
- function logUse(openedIds) {
47
+ // Record which nodes were actually used this turn. Matched against the most
48
+ // recent surfaced sets to derive precision/noise without a second pass over
49
+ // the whole log. `source` distinguishes how use was observed: "" / undefined
50
+ // for 📖 opens (agent explicitly read the note), "reviewer" for post-turn
51
+ // reviewer adjudication (the turn's work updated that pack/entity).
52
+ function logUse(openedIds, source) {
45
53
  if (!enabled()) return;
46
54
  const ids = [...new Set((openedIds || []).filter(Boolean))];
47
55
  if (!ids.length) return;
48
56
  try {
49
- fs.appendFileSync(LOG_FILE, JSON.stringify({ ts: new Date().toISOString(), use: ids }) + "\n");
57
+ const rec = { ts: new Date().toISOString(), use: ids };
58
+ if (source) rec.source = String(source);
59
+ fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
50
60
  const s = readSummary();
51
61
  s.opens = (s.opens || 0) + ids.length;
52
62
  writeSummary(s);
@@ -66,6 +76,7 @@ function bumpSummary(rec) {
66
76
  const s = readSummary();
67
77
  s.turns = (s.turns || 0) + 1;
68
78
  if (rec.gated) s.gatedTurns = (s.gatedTurns || 0) + 1;
79
+ if (rec.tier === "seeds") s.seedsOnlyTurns = (s.seedsOnlyTurns || 0) + 1;
69
80
  s.seedsTotal = (s.seedsTotal || 0) + rec.seeds.length;
70
81
  s.activatedTotal = (s.activatedTotal || 0) + rec.activated.length;
71
82
  s.keptTotal = (s.keptTotal || 0) + rec.kept.length;
@@ -88,6 +99,8 @@ function summary() {
88
99
  turns,
89
100
  gatedTurns: s.gatedTurns || 0,
90
101
  gatedPct: fmtPct(s.gatedTurns || 0, turns),
102
+ seedsOnlyTurns: s.seedsOnlyTurns || 0,
103
+ seedsOnlyPct: fmtPct(s.seedsOnlyTurns || 0, turns),
91
104
  avgSeeds: turns ? round((s.seedsTotal || 0) / turns) : 0,
92
105
  avgActivated: turns ? round((s.activatedTotal || 0) / turns) : 0,
93
106
  avgKept: turns ? round((s.keptTotal || 0) / turns) : 0,
@@ -30,6 +30,7 @@ let pending = null; // { resolve, reject, timer } for the in-flight walk
30
30
  let chain = Promise.resolve(); // serialises walks (one message in flight)
31
31
  let msgCount = 0; // messages sent to the current process
32
32
  let charCount = 0; // prompt chars sent to the current process
33
+ let costTotal = 0; // cumulative total_cost_usd reported by the session
33
34
 
34
35
  function isEnabled() {
35
36
  return String(process.env.RECALL_WARM_WALKER || "on").toLowerCase() !== "off";
@@ -60,6 +61,7 @@ function spawnChild(cfg) {
60
61
  child = proc;
61
62
  msgCount = 0;
62
63
  charCount = 0;
64
+ costTotal = 0;
63
65
  let buf = "";
64
66
  let asstText = "";
65
67
 
@@ -88,7 +90,14 @@ function spawnChild(cfg) {
88
90
  } else if (evt.type === "result") {
89
91
  const text = (typeof evt.result === "string" && evt.result) ? evt.result : asstText;
90
92
  asstText = "";
91
- if (text && !evt.is_error) settle("resolve", redactSensitive(String(text).trim()));
93
+ // total_cost_usd is cumulative for the stream-json session; the delta
94
+ // from the previous result is this walk's cost.
95
+ let costUsd = null;
96
+ if (typeof evt.total_cost_usd === "number") {
97
+ costUsd = Math.max(0, evt.total_cost_usd - costTotal);
98
+ costTotal = evt.total_cost_usd;
99
+ }
100
+ if (text && !evt.is_error) settle("resolve", { text: redactSensitive(String(text).trim()), costUsd });
92
101
  else settle("reject", new Error("warm walker: empty/error result"));
93
102
  }
94
103
  }
@@ -136,6 +145,8 @@ function doWalk(promptText, opts) {
136
145
 
137
146
  // Serialise: one message in flight at a time. A failed walk must not poison the
138
147
  // queue, so the chain swallows outcomes while callers still see their result.
148
+ // Resolves { text, costUsd } — costUsd is the per-walk delta of the session's
149
+ // cumulative total_cost_usd (null when the CLI doesn't report it).
139
150
  function walkWarm(promptText, opts = {}) {
140
151
  const run = () => doWalk(promptText, opts);
141
152
  const p = chain.then(run, run);
package/core/runner.js CHANGED
@@ -1035,7 +1035,7 @@ async function runClaude(prompt, cwd, replyToMsgId, opts = {}) {
1035
1035
  const fmt = (arr, icon) => arr.map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
1036
1036
  // 🧠 packs/entities recall — gated by /recall.
1037
1037
  if (settings.showRecall) {
1038
- const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤")];
1038
+ const lines = [...fmt(r.packs || [], "📦"), ...fmt(r.entities || [], "👤"), ...fmt(r.episodes || [], "📓")];
1039
1039
  if (lines.length) send(`🧠 <b>Recall this turn</b> (${esc(r.engine)})\n${lines.join("\n")}`, telegramHtmlOpts()).catch(() => {});
1040
1040
  else if (r.gated) send(`🧠 <b>Recall</b> (${esc(r.engine)}): skipped by pre-gate — trivial turn.`, telegramHtmlOpts()).catch(() => {});
1041
1041
  }
@@ -743,6 +743,7 @@ async function promptWithDynamicContext(prompt, opts = {}) {
743
743
  });
744
744
  const { packBlock, entityBlock } = result;
745
745
  const toolBlock = result.toolBlock || "";
746
+ const episodeBlock = result.episodeBlock || "";
746
747
  const why = result.why || {};
747
748
  lastInjected.recall = {
748
749
  engine: engine.name || recall.activeEngineName(settings),
@@ -753,12 +754,14 @@ async function promptWithDynamicContext(prompt, opts = {}) {
753
754
  // Drives the /tooltrace "surfaced" banner; the toolBlock below is the
754
755
  // context the agent actually sees.
755
756
  tools: (result.toolMatches || []).map((m) => ({ name: m.name, why: m.why || why[`tool:${m.name}`] || "" })),
757
+ // Past-conversation episodes the walker kept (drives the /recall banner).
758
+ episodes: (result.episodeMatches || []).map((m) => ({ name: m.name, why: m.why || "" })),
756
759
  };
757
760
  const budgetNote = budget.omitted > 0
758
761
  ? `\n\n## Memory budget\n${budget.omitted} matched memory item${budget.omitted === 1 ? " was" : "s were"} omitted to keep this turn under the recall budget (${budget.maxChars} chars). Use \`open-claudia pack show <dir>\`, \`entity show <slug>\`, or transcript search if deeper context is needed.`
759
762
  : "";
760
763
  const transcriptPointer = opts.includeTranscriptPointer ? `${transcriptPointerNote(state)}\n\n` : "";
761
- return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
764
+ return `${transcriptPointer}${buildDynamicContextBlock()}${packBlock}${entityBlock}${toolBlock}${episodeBlock}${budgetNote}${voiceReplyGuidance()}\n\nCurrent user request:\n${prompt}`;
762
765
  } catch (e) {
763
766
  return prompt;
764
767
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "2.6.57",
3
+ "version": "2.6.58",
4
4
  "description": "Your always-on AI coding assistant — Claude Code, Cursor Agent, and OpenAI Codex via Telegram or Kazee Chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -9,7 +9,7 @@
9
9
  "scripts": {
10
10
  "setup": "node setup.js",
11
11
  "start": "node bot.js",
12
- "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
12
+ "test": "OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node -e \"require('./vault'); console.log('OK')\" && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-runner-watchdog-static.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-usage-accounting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-engine.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-graph.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-recall-discoverer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-project-transcripts-smoke.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-abilities.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-extraction.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-couse.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-transfer.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-tiers.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-ability-merge-guard.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-learning-e2e.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-pack-nesting.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-journal-dedupe.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-read-signal.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tools.js && OPEN_CLAUDIA_TEST=1 WORKSPACE=/tmp/open-claudia-test CLAUDE_PATH=node node test-tool-graph.js"
13
13
  },
14
14
  "files": [
15
15
  "bot.js",
@@ -24,13 +24,21 @@ toolsLib.addTool((() => {
24
24
  return s;
25
25
  })(), { name: "mikrotik-audit", description: "audit the mikrotik router fleet inventory" });
26
26
 
27
- // --- pre-gate ---
27
+ // --- tiered pre-gate ---
28
28
  assert.strictEqual(disc.needsRecall("", 0), false, "empty → no recall");
29
29
  assert.strictEqual(disc.needsRecall("thanks", 0), false, "pleasantry → no recall");
30
30
  assert.strictEqual(disc.needsRecall("ok", 0), false, "terse ack → no recall");
31
31
  assert.strictEqual(disc.needsRecall("yo", 0), false, "two words, no seeds → no recall");
32
32
  assert.strictEqual(disc.needsRecall("how do I deploy the mobile app", 0), true, "substantive → recall");
33
- assert.strictEqual(disc.needsRecall("k", 5), true, "seeds present always recall");
33
+ // The old gate kept ANY turn with seeds (measured: gated 0.4% of turns, so
34
+ // "thanks!" paid a full walk). New contract: pleasantries gate regardless.
35
+ assert.strictEqual(disc.needsRecall("k", 5), false, "terse ack gates even when seeds match");
36
+ assert.strictEqual(disc.recallTier("thanks!", 8), "skip", "pleasantry + seeds → skip");
37
+ assert.strictEqual(disc.recallTier("👍", 3), "skip", "emoji-only → skip");
38
+ assert.strictEqual(disc.recallTier("restart bot", 3), "seeds", "short command → seeds-only tier");
39
+ assert.strictEqual(disc.recallTier("show fup logs", 2), "seeds", "≤4 words → seeds-only tier");
40
+ assert.strictEqual(disc.recallTier("yes we need to see the 100", 2), "full", "substantive yes-prefixed turn → full");
41
+ assert.strictEqual(disc.recallTier("how do I deploy the mobile app", 4), "full", "substantive → full");
34
42
 
35
43
  // --- shared stub corpus + helpers ---
36
44
  const packs = {
@@ -110,5 +118,26 @@ const helpers = { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEnti
110
118
  assert.strictEqual(outNoTool.toolBlock, "", "no tool match → empty toolBlock");
111
119
  assert.strictEqual(outNoTool.toolMatches.length, 0, "no tool match → no toolMatches");
112
120
 
121
+ // seeds tier: a short command-like turn skips graph+walker but still injects
122
+ // keyword seeds directly (classic baseline, ~ms path).
123
+ builtPacks = null;
124
+ const seedsTier = await disc.run({
125
+ userText: "mobile app status", contextText: "", fullContext: "mobile app status",
126
+ packLimit: 6, budget: {}, helpers,
127
+ });
128
+ assert.strictEqual(seedsTier.gated, false, "seeds tier is not gated");
129
+ assert.strictEqual(seedsTier.packBlock, "PACKBLOCK", "seeds tier injects keyword seeds");
130
+ assert.ok(seedsTier.packMatches.some((m) => m.dir === "kazee-mobile"), "seeds tier keeps the user-origin seed");
131
+ assert.strictEqual(seedsTier.episodeBlock, "", "seeds tier never injects episodes");
132
+
133
+ // episodes: with the walker off, transcript hits are candidates but the
134
+ // fail-open path must DROP them — an unjudged snippet never injects.
135
+ const epi = await disc.run({
136
+ userText: "help me deploy the mobile app updater again", contextText: "",
137
+ fullContext: "help me deploy the mobile app updater again", packLimit: 6, budget: {}, helpers,
138
+ });
139
+ assert.strictEqual(epi.episodeBlock, "", "fail-open drops unjudged episodes");
140
+ assert.deepStrictEqual(epi.episodeMatches, [], "fail-open keeps no episodeMatches");
141
+
113
142
  console.log("recall discoverer OK");
114
143
  })().catch((e) => { console.error(e); process.exit(1); });