@inetafrica/open-claudia 3.0.35 → 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.
@@ -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;
package/core/router.js CHANGED
@@ -22,8 +22,10 @@ const {
22
22
  clearPendingClaudeAuth, clearPendingCodexAuth,
23
23
  sendClaudeAuthStatusSummary, sendCodexAuthStatusSummary,
24
24
  } = require("./auth-flow");
25
- const { redactSensitive } = require("./redact");
25
+ const { redactSensitive, registerSecrets } = require("./redact");
26
26
  const { MAX_FILE_SIZE, MAX_VOICE_SIZE, FILES_DIR } = require("./config");
27
+ const keyring = require("./keyring");
28
+ const { classifyPastedCredential } = require("./credential-detect");
27
29
  const channelWizard = require("./channel-wizard");
28
30
  require("./handlers"); // side-effect: register slash commands
29
31
 
@@ -272,6 +274,51 @@ async function handleDocument(envelope) {
272
274
  } catch (err) { await send(`Failed: ${err.message}`); }
273
275
  }
274
276
 
277
+ // Provider-agnostic capture of a credential pasted into chat with no auth
278
+ // flow active. Before v3.0.37 such a paste was merely deleted and then still
279
+ // forwarded to the model — which, under Codex, refused it as a policy
280
+ // violation. Now the router classifies it and stores it out-of-band (Claude
281
+ // token → .env/vault, OpenAI key → Codex CLI, other recognised secrets →
282
+ // operational keyring), deletes the message, confirms, and stops. The model
283
+ // never sees the secret. Returns true when it fully handled the message
284
+ // (caller must stop), false to let the turn proceed normally.
285
+ async function captureLooseCredential(text, envelope) {
286
+ const disposition = classifyPastedCredential(text);
287
+ switch (disposition.kind) {
288
+ case "claude": {
289
+ await deleteMessage(envelope.messageId);
290
+ registerSecrets(text);
291
+ saveClaudeOAuthToken(text);
292
+ await send(`Saved your Claude token to .env${vault.isUnlocked() ? " and vault" : ""} and deleted your message so it isn't left in chat. Restart with /restart so I pick it up.`);
293
+ return true;
294
+ }
295
+ case "openai": {
296
+ await deleteMessage(envelope.messageId);
297
+ registerSecrets(text);
298
+ const result = await saveCodexApiKeyWithCli(text);
299
+ await send(result.ok
300
+ ? "Saved your OpenAI API key via the Codex CLI and deleted your message. I never printed it."
301
+ : `Deleted your message, but the Codex CLI couldn't store that key: ${redactSensitive(result.output).slice(-400)}`);
302
+ return true;
303
+ }
304
+ case "keyring": {
305
+ await deleteMessage(envelope.messageId);
306
+ registerSecrets(disposition.value);
307
+ keyring.set(disposition.name, disposition.value);
308
+ await send(`Stored that secret in the operational keyring as \`${disposition.name}\` and deleted your message. Manage it with /keyring.`);
309
+ return true;
310
+ }
311
+ case "scrub": {
312
+ // Ambiguous long token: strip it from the chat but let the turn proceed
313
+ // (it may be an id/arg the agent legitimately needs to act on).
314
+ await deleteMessage(envelope.messageId);
315
+ return false;
316
+ }
317
+ default:
318
+ return false;
319
+ }
320
+ }
321
+
275
322
  async function handleText(envelope) {
276
323
  if (isDuplicate(envelope)) return;
277
324
  const state = currentState();
@@ -448,17 +495,14 @@ async function handleText(envelope) {
448
495
  }
449
496
 
450
497
  const text = envelope.text || "";
498
+
499
+ // Loose credential paste with no auth flow active: store it out-of-band and
500
+ // stop, instead of forwarding a secret to the model (Codex refuses those).
501
+ if (await captureLooseCredential(text.trim(), envelope)) return;
502
+
451
503
  const runContext = admitRunContext(text, state.currentSession.dir, envelope.messageId, { purpose: "foreground" });
452
504
  const attachments = [];
453
505
  const imagePaths = [];
454
- const credPatterns = [
455
- /^(sk-ant-|sk-|glpat-|ghp_|gho_|github_pat_|xoxb-|xoxp-|AKIA|AIza)/,
456
- /^[A-Za-z0-9_-]{20,}$/,
457
- /^(Bearer |token:|key:|secret:|password:)/i,
458
- ];
459
- if (credPatterns.some((p) => p.test(text.trim()))) {
460
- await deleteMessage(envelope.messageId);
461
- }
462
506
 
463
507
  let prompt = text;
464
508
  const reply = envelope.reply;
package/core/scheduler.js CHANGED
@@ -10,6 +10,8 @@ const crypto = require("crypto");
10
10
  const fs = require("fs");
11
11
  const path = require("path");
12
12
  const cron = require("node-cron");
13
+ const { spawn } = require("child_process");
14
+ const CONFIG_DIR = require("../config-dir");
13
15
 
14
16
  const {
15
17
  CRONS_FILE,
@@ -34,6 +36,10 @@ const { redactSensitive } = require("./redact");
34
36
  const MISSED_WAKEUP_GRACE_MS = 60 * 60 * 1000;
35
37
  const RETRY_DELAY_MS = 30 * 1000;
36
38
  const BUSY_DELAY_MS = 30 * 1000;
39
+ // A watch's `check` command is capped so a hung poll can't wedge the tick.
40
+ const WATCH_CHECK_TIMEOUT_MS = 30 * 1000;
41
+ const WATCH_LOG_FILE = process.env.OPEN_CLAUDIA_WATCH_LOG_FILE || path.join(CONFIG_DIR, "watch.log");
42
+ const WATCHES_ENABLED_FILE = process.env.OPEN_CLAUDIA_WATCHES_ENABLED_FILE || path.join(CONFIG_DIR, "watches-enabled");
37
43
 
38
44
  function uniqueId(prefix = "occ") {
39
45
  return typeof crypto.randomUUID === "function"
@@ -53,6 +59,13 @@ function terminalRunSucceeded(result) {
53
59
  return true;
54
60
  }
55
61
 
62
+ // A one-shot fires exactly once then is removed after a successful run: a
63
+ // wakeup, or a watch (a cron carrying a shell `check`) created without repeat
64
+ // polling. A repeat watch stays pending and keeps polling like any cron.
65
+ function isOneShot(job) {
66
+ return job.kind === "wakeup" || (!!job.check && !job.repeat);
67
+ }
68
+
56
69
  function providerIsRunnable(status) {
57
70
  if (!status || status.availability?.state !== "available") return false;
58
71
  if (status.compatibility?.state === "incompatible") return false;
@@ -96,6 +109,30 @@ function createSchedulerService(dependencies = {}) {
96
109
  const runInProvider = dependencies.runAgent || ((...args) => require("./runner").runAgent(...args));
97
110
  const admit = dependencies.admitRunContext || ((...args) => require("./runner").admitRunContext(...args));
98
111
  const timers = new Map();
112
+ const checksInFlight = new Set();
113
+ // Poll a watch's `check`: exit 0 → the watched condition is met, wake the
114
+ // agent; non-zero → still idle, skip silently. Runs in the job's project
115
+ // dir with the process env, output discarded (exit code is the contract),
116
+ // hard-capped by WATCH_CHECK_TIMEOUT_MS. Injectable so tests stay hermetic.
117
+ const runWatchCheck = dependencies.runWatchCheck || ((command, job) => new Promise((resolve) => {
118
+ const cwd = (job && projectDescriptor(job).dir) || workspace || process.cwd();
119
+ let settled = false;
120
+ const finish = (code, timedOut) => {
121
+ if (settled) return;
122
+ settled = true;
123
+ try { clearTimer(timer); } catch (_) {}
124
+ resolve({ code, timedOut: !!timedOut });
125
+ };
126
+ let child;
127
+ try {
128
+ child = spawn("sh", ["-c", String(command)], { cwd, env: process.env, stdio: "ignore" });
129
+ } catch (error) {
130
+ return resolve({ code: 127, timedOut: false });
131
+ }
132
+ const timer = setTimer(() => { try { child.kill("SIGKILL"); } catch (_) {} finish(124, true); }, WATCH_CHECK_TIMEOUT_MS);
133
+ child.on("close", (code) => finish(code == null ? 1 : code, false));
134
+ child.on("error", () => finish(127, false));
135
+ }));
99
136
  let adapters = Array.isArray(dependencies.adapters) ? dependencies.adapters.slice() : [];
100
137
 
101
138
  function setAdapters(list) {
@@ -186,7 +223,7 @@ function createSchedulerService(dependencies = {}) {
186
223
 
187
224
  function wrappedPrompt(job, target) {
188
225
  const firedAt = new Date(now()).toISOString();
189
- const header = job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
226
+ const header = job.check ? "Watch triggered" : job.kind === "cron" ? "Scheduled cron" : "Scheduled wakeup";
190
227
  if (target.fallbackFrom) {
191
228
  return [
192
229
  `[${header} "${job.label}" fired at ${firedAt}; occurrence ${job.occurrenceId}]`,
@@ -270,7 +307,7 @@ function createSchedulerService(dependencies = {}) {
270
307
  catch (error) { return { ok: false, status: "failed", error: stableFailure(error), result }; }
271
308
  if (!marked) return { ok: false, status: "failed", error: { code: "JOB_PERSIST_FAILED", message: "Scheduled success marker was not persisted" }, result };
272
309
 
273
- if (job.kind === "wakeup") {
310
+ if (isOneShot(job)) {
274
311
  try {
275
312
  const removed = jobsStore.remove(job.id);
276
313
  if (!removed) throw new Error("successful one-shot could not be removed after its durable success marker");
@@ -308,12 +345,34 @@ function createSchedulerService(dependencies = {}) {
308
345
  if (job.status === "running" && Number(job.nextAttemptAt || 0) > now()) {
309
346
  return { ok: false, status: "running", skipped: true, inFlight: true };
310
347
  }
311
- if (job.status === "succeeded" && job.kind === "wakeup") {
348
+ if (job.status === "succeeded" && isOneShot(job)) {
312
349
  try { jobsStore.remove(job.id); } catch (_) {}
313
350
  unscheduleJob(job.id);
314
351
  return { ok: true, status: "succeeded", finalized: true };
315
352
  }
316
353
 
354
+ // Watch poll gate. A watch is a cron carrying a shell `check`; each tick
355
+ // runs the check and only wakes the agent when it exits 0. A non-zero exit
356
+ // is the normal idle case: skip silently, mark nothing, leave the job
357
+ // pending so the next tick polls again. Never marks failure; never chats.
358
+ if (job.check) {
359
+ if (!watchesEnabled()) {
360
+ logWatch(job, "skipped (watches disabled globally)");
361
+ return { ok: false, status: "watch-disabled", skipped: true };
362
+ }
363
+ if (checksInFlight.has(job.id)) return { ok: false, status: "watch-checking", skipped: true };
364
+ checksInFlight.add(job.id);
365
+ let check;
366
+ try { check = await runWatchCheck(job.check, job); }
367
+ catch (_) { check = { code: 1, timedOut: false }; }
368
+ finally { checksInFlight.delete(job.id); }
369
+ if (!check || check.code !== 0) {
370
+ logWatch(job, check && check.timedOut ? "idle (check timed out)" : `idle (check exit ${check ? check.code : "?"})`);
371
+ return { ok: false, status: "watch-idle", skipped: true };
372
+ }
373
+ logWatch(job, "triggered (check passed) — waking agent");
374
+ }
375
+
317
376
  const adapter = resolveAdapter(job);
318
377
  if (!adapter) {
319
378
  try { return markFailure(job, { code: "SCHEDULED_ADAPTER_UNAVAILABLE", message: "Saved channel adapter is unavailable" }); }
@@ -385,7 +444,7 @@ function createSchedulerService(dependencies = {}) {
385
444
  return { ok: false, status: "failed", error: stableFailure(error) };
386
445
  }
387
446
 
388
- try { await send(`Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
447
+ try { await send(job.check ? `🔔 Watch "${job.label}" triggered` : `Wakeup: ${job.label}`); } catch (_) { /* advisory only */ }
389
448
  let result;
390
449
  try { result = await runInProvider(prompt, runContext.cwd, null, { ...runOptions, runContext }); }
391
450
  catch (error) {
@@ -403,7 +462,7 @@ function createSchedulerService(dependencies = {}) {
403
462
  function scheduleJob(job) {
404
463
  unscheduleJob(job.id);
405
464
  if (!job || job.disabled || ["archived", "dead-letter"].includes(job.status)) return false;
406
- if (job.status === "succeeded" && job.kind === "wakeup") {
465
+ if (job.status === "succeeded" && isOneShot(job)) {
407
466
  try { jobsStore.remove(job.id); } catch (_) {}
408
467
  return false;
409
468
  }
@@ -514,6 +573,41 @@ function createSchedulerService(dependencies = {}) {
514
573
  return job;
515
574
  }
516
575
 
576
+ // Global master switch for all watches (owner's /watch off). Absent file →
577
+ // enabled (default ON). Only the poll gate consults it; wakeups/crons are
578
+ // untouched.
579
+ function watchesEnabled() {
580
+ try { return fs.readFileSync(WATCHES_ENABLED_FILE, "utf8").trim() !== "off"; }
581
+ catch (_) { return true; }
582
+ }
583
+
584
+ function setWatchesEnabled(on) {
585
+ try { fs.writeFileSync(WATCHES_ENABLED_FILE, on ? "on" : "off"); } catch (_) {}
586
+ return !!on;
587
+ }
588
+
589
+ // Append-only audit of watch poll activity. NEVER sent to chat — this is the
590
+ // whole point of a watch: the polling stays out of the conversation, only a
591
+ // real trigger wakes the agent and surfaces in chat.
592
+ function logWatch(job, event) {
593
+ const line = `[${new Date(now()).toISOString()}] job=${job.id} label=${JSON.stringify(job.label || "")} ${event}\n`;
594
+ try { fs.appendFileSync(WATCH_LOG_FILE, line); } catch (_) {}
595
+ }
596
+
597
+ // A watch is a cron carrying a shell `check`; it rides the migration-verified
598
+ // jobs store untouched (check/repeat survive normalizeRunnableJob as extra
599
+ // fields). Default schedule polls every minute; default is one-shot.
600
+ function addWatch(spec) {
601
+ return addJob({
602
+ ...spec,
603
+ kind: "cron",
604
+ schedule: spec.schedule || "* * * * *",
605
+ check: String(spec.check || "").trim(),
606
+ repeat: !!spec.repeat,
607
+ label: spec.label || String(spec.prompt || "watch").slice(0, 60),
608
+ });
609
+ }
610
+
517
611
  function removeJob(id) {
518
612
  unscheduleJob(id);
519
613
  return jobsStore.remove(id);
@@ -529,6 +623,9 @@ function createSchedulerService(dependencies = {}) {
529
623
  scheduleJob,
530
624
  unscheduleJob,
531
625
  addJob,
626
+ addWatch,
627
+ watchesEnabled,
628
+ setWatchesEnabled,
532
629
  removeJob,
533
630
  stopAll,
534
631
  fireJob,
@@ -561,6 +658,9 @@ module.exports = {
561
658
  scheduleJob: service.scheduleJob,
562
659
  unscheduleJob: service.unscheduleJob,
563
660
  addJob: service.addJob,
661
+ addWatch: service.addWatch,
662
+ watchesEnabled: service.watchesEnabled,
663
+ setWatchesEnabled: service.setWatchesEnabled,
564
664
  removeJob: service.removeJob,
565
665
  stopAll: service.stopAll,
566
666
  fireJob: service.fireJob,