@inetafrica/open-claudia 3.1.27 → 3.2.0

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,32 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.2.0 — The employee batch: memory hygiene at write time, prospective memory, earned trust, and learning from the owner's judgment calls
4
+
5
+ Three approved plans land together — memory adoption A1-A4 (from the TencentDB-Agent-Memory review), codegraph Phase 1, and employee capabilities C1-C6 — plus the Kazee group speaker-identity fix. Two rules hold throughout: **report-only where trust is involved** (nothing here grants, acts, or loosens an approval rule by itself), and **no new standing context cost** (everything injects only when already-matched content fires, or lands in the nightly dream summary).
6
+
7
+ - **A1 — write-time dedup adjudicator, gated (the only hot-path item).** The post-turn reviewer's journal facts used to append unconditionally; duplicates accumulated all day for the one nightly pass that can lose things (merge) to clean up. Now cheap extractive recall checks each proposed fact against existing journal lines first. The gate keeps the hot path free: no candidates → zero LLM cost, store as proposed; exact-normalized duplicates auto-skip mechanically; only the fuzzy remainder goes to ONE batched low-tier call returning per-fact `store | update | merge | skip`. Fail-open everywhere — any error, timeout, or malformed judgement stores facts as proposed, because dedup failure must never make memory worse than append-only.
8
+ - **A2 — merges keep the absorbed packs' history.** Dream merges used to delete the absorbed pack's entire Journal (surviving only in the backup dir); a merged pack read younger and thinner than the history it represents. Absorbed Journal lines now carry into the survivor — normalized-exact dedup only (never fuzzy: similar-but-distinct events must both survive), date-ascending, the cap keeps newest and drops undated lines first.
9
+ - **A3 — substitutability stamps.** The reviewer self-rates each journal line 0-10 for how fully its wording substitutes for the turn's original words — a checkable claim about the text, not a guess about future importance. The score rides the stored line as a trailing `⟨s:N⟩` marker (announcements stay clean, near-dup similarity ignores it) and protects low-scored, verbatim-precious lines from the dream's journal collapse.
10
+ - **A4 — section budgets are enforced, not requested.** "Keep State under ~150 words" was prose; States drifted. LLM-edited sections now have mechanical ceilings: pack State trims at the cap with a visible cut marker + flag, entity Notes and persona sections trim at theirs, and the review prompt measures State against the target so over-budget turns the reviewer's job into an edit, not an append. Mandate (owner-set policy) and user-origin writes are never trimmed.
11
+ - **codegraph — code-structure queries as a reusable read-only tool (Phase 1).** Machine-side `codegraph` tool wrapping `@colbymchenry/codegraph`, pinned exactly 1.5.0 (native binaries from a solo author — upgrades are deliberate, reviewed events, never `^` drift), `CODEGRAPH_TELEMETRY=0` baked into every invocation, 12 read-only verbs with query verbs auto-syncing the index first. TOOL.md carries the graded trust model: `explore`/`node`/`search`/`files` are high-trust retrieval; `callers`/`impact`/`affected` are **advisory, positive-signal only** (verified misses: CJS namespace calls, some TS class-method sites) — grep remains the completeness check for destructive refactors. Two-week usage evaluation follows; MCP exposure was dropped against the token-efficiency goal.
12
+ - **C1 — prospective memory.** "Next time X comes up, mention Y" — reminders bound to a TOPIC (pack or entity), not a time; crons and watches already own time and condition. They fire as a `📌 Pending:` line when the target surfaces in recall injection or is opened with `pack show`/`entity show`, and are consumed ONLY at commit points — a dropped render can never eat a reminder. One-shot by default with a fired ledger; `--sticky` survives; the dream GCs expired and orphaned entries. New `open-claudia remind` CLI.
13
+ - **C2 — recall misses become permanent tests.** `open-claudia recall-case add "<query>" --expect <slug>` saves a case whenever a miss is corrected; the dream re-grades the suite nightly against the deterministic recall floor (matchPacks + matchEntities — the candidate stage every engine builds on) so the suite runs, and means the same thing, on nights the providers are down. Capped with oldest-first rotation; orphaned cases drop; pass-rate + delta land in the dream's memory-health section. "It feels degraded" is now a number.
14
+ - **C3 — skill certification: trust is earned per verb, and granting stays human.** Tools already log per-verb runs in state.json; a certification tier is now derived from that telemetry — `certified` at ≥5 clean runs with no recent failure, `probation` for 7 days after a failure (only clean runs SINCE the failure re-certify, read from history), `learning` otherwise. `tool show` renders the tier per verb, labeled informational — nothing in the run gate consults it. The dream proposes at most 2 certified gated-tier verbs per night as Always-allow candidates; read-only verbs need no grant, and no auto-grant exists, ever.
15
+ - **C4 — apprenticeship radar: it asks to be taught.** A deterministic 7-day transcript scan spots repeated manual work — the same ad-hoc command re-derived by the agent (inline code + `$`-lines; its own CLI excluded) or the owner asking for the same thing again — at ≥3 occurrences across ≥2 distinct days. The dream's introspection report offers at most 2 as `/learn` candidates with the counts as evidence. Report-only: `/learn` stays owner-triggered, and no evidence means no section, no standing cost.
16
+ - **C5 — denied-approval mining: trained by the owner's judgment calls.** Every Deny tap was training data we dropped. Recently DENIED approval records (never approvals — an approve confirms, it corrects nothing) now feed the post-turn reviewer as fenced DATA with explicit never-instructions framing, on owner-turn reviews only — deny content can never surface via an external speaker's turn. Each deny is fed exactly once (stamped after a parseable decision; parse failure re-feeds). Forge-proof by structure: a deny-sourced lesson's trigger must cite a real record with status denied — the model cannot invent a refusal, and an approved id confers nothing.
17
+ - **C6 — weekly cost self-report.** The dream summary gains a cost section: rolling 7-day totals over the existing per-turn usage log, top burns by project, week-over-week delta, and ONE concrete suggested cut picked by deterministic heuristics. Pure arithmetic — no model call, no new logging surface.
18
+ - **Kazee groups: the Speaker block now knows the env-configured owner.** The prompt's Speaker block resolved identity ONLY through `people.findByHandle`, so an env-configured owner (`KAZEE_OWNER_USER_ID` — no people record, because auto-contact skips owners by design) and any not-yet-recorded group member spoke anonymously, and in group chats the model refused to attribute their messages (the "Ok go" incident, 2026-08-03). The block now falls back to the canonical owner check and the transport-reported display name — without a name ever conferring owner standing (R1 intact).
19
+ - **Eleven new release-chain tests** (chain now 67): journal union, substitutability stamps, section budgets, dedup adjudicator, prospective memory, recall regressions, cost report, speaker context, approval mining, tool certification, apprenticeship radar. The 360° dangling-pathway pass found and closed one real gap pre-release: the C5 deny-feed originally fired on ALL reviewed turns, including external speakers' — now owner-gated with tests proving external turns see nothing and records stay un-mined.
20
+
21
+ ## v3.1.28 — The Telegram reboot loop was self-inflicted: the heal kept tearing down its own recovery
22
+
23
+ - **Symptom.** v3.1.27 shipped to all four Telegram pods and the loop continued (euvy: 6 restarts in 3h). Its fix was real but addressed a different failure — the dominant one never enters `_enterOutagePause` at all (zero "Telegram unreachable" lines in the crash logs). The decisive observation was that the bot reproduced it identically on a laptop — 155 self-exits in a local `bot.log` — which retired the cluster-network and conntrack theories outright. The shape is bursts: healthy for 2.7–4.2h, then ~10 minutes of failed heals, then `process.exit(1)`, repeat.
24
+ - **How it was found.** Reasoning from prod logs had already produced one wrong fix, so this time the local install was instrumented directly (unthrottled lifecycle events to a side file) and a real burst was captured. Two facts invisible to the console log settled it: every polling error after the first carried `_polling._abort === true` — meaning *we* aborted it, not Telegram — and `_lastUpdate` read 0 for 200+ seconds straight across 9 successful restarts. It also retired a number the previous investigation had reported as fact: the "~62s failure cadence" was an artifact of the 1/min log throttle. The real rate was one every ~15s.
25
+ - **Root cause — three stacked deadlocks, each independently fatal to recovery.** (1) `_heal` → `_stopPollingClean` → `_agent.destroy()` kills the in-flight `getUpdates`, which surfaces in `_onPollingHiccup` ~1ms later as `EFATAL: socket hang up`. `_healTimer` had just been nulled at the top of `_heal`, so the "a heal is already scheduled" guard didn't hold and the teardown scheduled *another* heal — which tore the socket down again. Self-sustaining. (2) The backoff capped at 15s while `polling.params.timeout` is 30s, so every restarted poll was aborted at the halfway mark: `_lastUpdate`, the only evidence of recovery, could never advance. (3) `_armHealthyCheck` cleared its own pending timer on every re-arm, so a heal every 15s starved a check that needs 35s of quiet — **zero healthy checks fired in the entire captured burst** — and that callback owns the only reset of `_wedgedSince` and `_healBackoff`. With nothing able to clear the wedge clock, it ran monotonically to the 10-minute backstop. One ordinary `ECONNRESET` was enough to cost ten minutes and a reboot.
26
+ - **Fix.** (1) A time-boxed self-abort window opened in `_stopPollingClean` makes `_onPollingHiccup` ignore the error our own teardown raises; it is never explicitly cleared, so a path that forgets to close it cannot permanently deafen the handler — the worst case is a dropped error that `_armHealthyCheck` re-arms 35s later anyway. (2) The heal ladder (now `_scheduleHeal`) escalates to 45s, clearing the 40s request timeout that bounds one `getUpdates`, so the late rungs give a poll genuine room to land; early rungs stay short because they run while polling is down. (3) Re-arming the healthy check no longer pushes its deadline back — the first arm always fires — while the baseline tracks the newest restart, so a poll that completed *before* the latest restart can't be mistaken for proof the latest restart worked. A failed `startPolling` now schedules its own retry rather than waiting for a hiccup that the suppression window means will never come.
27
+ - **Proof, not assertion.** `testOneNetworkBlipDoesNotSpiralIntoTheWedgeExitLoop` models the poller instead of stubbing it: `destroy()` raises the teardown error for real, a healthy poll takes the full 30s hold to complete, the library re-dials ~300ms after a fault (which is why a heal always had something to destroy), and a virtual clock runs a single `ECONNRESET` well past the 10-minute budget. Against unmodified code it reproduces the production failure exactly — a storm of `socket hang up`, then "Polling wedged >10min and Telegram is reachable — exiting for a clean restart" — and against the fix the bot recovers with the wedge cleared and the ladder reset. Three narrower tests pin each deadlock individually. The previous release's test passed against a bug it did not fix because it drove `_enterOutagePause()` directly, a path production never takes; the lesson — model the interaction, don't stub past it — is why this harness raises the error from `destroy()`.
28
+ - **Correction to v3.1.27's closing note.** That entry wondered whether the wedge-exit backstop earned its keep, observing that "210 restarts never helped". They did: a fresh process is the only thing that escapes the loop, which is exactly why each reboot bought 2.7–4.2h of health. The backstop stays until this fix is proven in the fleet.
29
+
3
30
  ## v3.1.27 — The outage pause stops feeding the wedge-exit clock (the fleet-wide Telegram reboot loop)
4
31
 
5
32
  - **Symptom.** Every Telegram-channel bot was rebooting itself in a loop while no other bot was: rashmi 214 restarts in 7d15h, euvy 210 in 7d17h, ruk-ai 62 in 44h, rahil-coders 11 — 4 of 4 pods running the `telegram` channel. All 12 pods running only `kazee, spaces, web` sat at 0. All 16 share one node and one egress IP, so node, address and NetworkPolicy preset were all controlled for; the *unrestricted* bot (euvy, allow-all egress) restarted most, which retired the egress theory rather than confirming it. The channel was the only discriminator.
package/bin/cli.js CHANGED
@@ -375,6 +375,16 @@ switch (command) {
375
375
  break;
376
376
  }
377
377
 
378
+ case "remind": {
379
+ require("./remind").run(args.slice(1));
380
+ break;
381
+ }
382
+
383
+ case "recall-case": {
384
+ require("./recall-case").run(args.slice(1));
385
+ break;
386
+ }
387
+
378
388
  case "lessons": {
379
389
  require("./lessons").run(args.slice(1));
380
390
  break;
@@ -473,6 +483,8 @@ Memory tools:
473
483
  open-claudia pack list|show|match|archive|restore|archived Context packs: living topic docs (skills + memory)
474
484
  open-claudia tool list|search|show|scaffold|add|set|note|edit|run|migrate|remove Reusable tools: git-tracked dirs (exec + TOOL.md + telemetry), risk-gated runs
475
485
  open-claudia entity list|show|match|note Entity notes: people/places/projects memory
486
+ open-claudia remind "<note>" --pack <dir>|--entity <slug> Prospective memory: fires when that topic next comes up (remind list|remove <id>)
487
+ open-claudia recall-case add "<query>" --expect <slug> Memory regression suite: corrected recall misses become nightly-graded tests (list|run|remove)
476
488
  open-claudia lessons list|add|remove|show Always-loaded learned rules (cross-cutting, promoted after a miss)
477
489
  open-claudia ideas list|add|remove|show Self-improvement backlog (captured by the nightly dream)
478
490
  open-claudia keyring list|get|set|remove Operational creds the agent can reuse (plaintext; vault for secrets)
package/bin/entity.js CHANGED
@@ -31,6 +31,15 @@ function run(args) {
31
31
  const e = entities.findEntity(rest.join(" "));
32
32
  if (!e) { console.error(`No entity: ${rest.join(" ")}`); process.exitCode = 1; return; }
33
33
  console.log(fs.readFileSync(path.join(entities.ENTITIES_DIR, e.slug + ".md"), "utf-8"));
34
+ // C1: topic-bound reminders fire when the target is explicitly opened.
35
+ try {
36
+ const prospective = require("../core/prospective");
37
+ const pending = prospective.pendingFor("entity", e.slug);
38
+ if (pending.length) {
39
+ console.log("\n" + prospective.renderPendingLines(pending));
40
+ prospective.fire(pending, "entity show");
41
+ }
42
+ } catch (err) { /* reminders optional */ }
34
43
  break;
35
44
  }
36
45
 
package/bin/pack.js CHANGED
@@ -113,6 +113,16 @@ function run(args) {
113
113
  for (const t of linked) console.log(`# ${t.name} — ${t.description || "(no description)"}`);
114
114
  }
115
115
  } catch (e) { /* tools optional */ }
116
+ // C1: topic-bound reminders also fire when their target is explicitly
117
+ // opened — the show output IS the surface, so consuming here is safe.
118
+ try {
119
+ const prospective = require("../core/prospective");
120
+ const pending = prospective.pendingFor("pack", p.dir);
121
+ if (pending.length) {
122
+ console.log("\n" + prospective.renderPendingLines(pending));
123
+ prospective.fire(pending, "pack show");
124
+ }
125
+ } catch (e) { /* reminders optional */ }
116
126
  break;
117
127
  }
118
128
 
@@ -0,0 +1,90 @@
1
+ // CLI: memory regression suite — corrected recall misses become permanent
2
+ // test cases, re-graded nightly by the dream (deterministic, no LLM).
3
+ // open-claudia recall-case add "<query>" --expect <slug> [--expect <slug2>] [--source live-miss|manual] [--note "..."]
4
+ // open-claudia recall-case list
5
+ // open-claudia recall-case run [--cap N] — grade now and print per-case results
6
+ // open-claudia recall-case remove <id>
7
+ // Add a case the moment a recall miss gets corrected in chat — same moment a
8
+ // lesson forms. The query should be the user's original phrasing; the expected
9
+ // slug is the pack dir / entity slug that SHOULD have surfaced.
10
+
11
+ const regressions = require("../core/recall-regressions");
12
+
13
+ function usage() {
14
+ console.error('Usage: open-claudia recall-case add "<query>" --expect <slug> [--expect <slug2>] [--source live-miss|manual] [--note "..."]');
15
+ console.error(" open-claudia recall-case list | run [--cap N] | remove <id>");
16
+ process.exitCode = 1;
17
+ }
18
+
19
+ function run(args) {
20
+ const cmd = (args[0] || "").toLowerCase();
21
+
22
+ if (cmd === "" || cmd === "help" || cmd === "--help") return usage();
23
+
24
+ if (cmd === "list") {
25
+ const all = regressions.listCases();
26
+ if (all.length === 0) return console.log("No regression cases yet. Add one with: open-claudia recall-case add \"<query>\" --expect <slug>");
27
+ for (const c of all) {
28
+ const state = c.lastResult ? `${c.lastResult}${c.lastRunAt ? ` @ ${String(c.lastRunAt).slice(0, 10)}` : ""}` : "never run";
29
+ console.log(`${c.id} [${state}] (${c.source})\n query: ${c.query}\n expect: ${c.expectSlugs.join(", ")}${c.note ? `\n note: ${c.note}` : ""}`);
30
+ }
31
+ return;
32
+ }
33
+
34
+ if (cmd === "remove") {
35
+ const id = args[1];
36
+ if (!id) return usage();
37
+ if (!regressions.removeCase(id)) { console.error(`No case: ${id} (open-claudia recall-case list)`); process.exitCode = 1; return; }
38
+ console.log(`Removed case ${id}.`);
39
+ return;
40
+ }
41
+
42
+ if (cmd === "run") {
43
+ let cap;
44
+ const capIdx = args.indexOf("--cap");
45
+ if (capIdx !== -1) cap = Math.max(1, parseInt(args[capIdx + 1], 10) || regressions.NIGHTLY_CAP);
46
+ const before = regressions.listCases();
47
+ if (before.length === 0) return console.log("No regression cases to run.");
48
+ const res = regressions.runSuite(cap ? { cap } : {});
49
+ for (const c of regressions.listCases()) {
50
+ if (!c.lastResult) continue;
51
+ console.log(`${c.lastResult === "pass" ? "✅" : "❌"} ${c.id} ${c.query.slice(0, 60)}${c.lastResult === "fail" ? " (expected: " + c.expectSlugs.join(", ") + ")" : ""}`);
52
+ }
53
+ const rate = res.ran > 0 ? Math.round((res.passed / res.ran) * 100) : 0;
54
+ console.log(`\n${res.passed}/${res.ran} passing (${rate}%)${res.orphaned ? `, ${res.orphaned} orphaned case(s) dropped` : ""}${res.total > res.ran ? `, ${res.total - res.ran} deferred to next rotation` : ""}.`);
55
+ return;
56
+ }
57
+
58
+ if (cmd === "add") {
59
+ const rest = args.slice(1);
60
+ const expect = [];
61
+ let source = "manual", note = "";
62
+ for (let i = 0; i < rest.length; i++) {
63
+ if (rest[i] === "--expect") { expect.push(rest[++i]); continue; }
64
+ if (rest[i] === "--source") { source = rest[++i]; continue; }
65
+ if (rest[i] === "--note") { note = rest[++i] || ""; continue; }
66
+ }
67
+ const query = rest.filter((a, i) => {
68
+ if (a === "--expect" || a === "--source" || a === "--note") return false;
69
+ if (i > 0 && ["--expect", "--source", "--note"].includes(rest[i - 1])) return false;
70
+ return true;
71
+ }).join(" ").trim();
72
+ if (!query || expect.length === 0) return usage();
73
+ // Warn (don't refuse) on an unknown slug — the pack may be created next.
74
+ const packs = require("../core/packs");
75
+ const entities = require("../core/entities");
76
+ for (const s of expect) {
77
+ let known = false;
78
+ try { known = !!packs.readPack(s); } catch (e) {}
79
+ if (!known) { try { known = !!entities.readEntity(s); } catch (e) {} }
80
+ if (!known) console.log(`⚠️ ${s} is not a known pack dir or entity slug right now — the case will grade as orphaned until it exists.`);
81
+ }
82
+ const entry = regressions.addCase({ query, expectSlugs: expect, source, note });
83
+ console.log(`🧪 Case ${entry.id} added — nightly recall must surface ${expect.join(", ")} for: "${query}"`);
84
+ return;
85
+ }
86
+
87
+ usage();
88
+ }
89
+
90
+ module.exports = { run };
package/bin/remind.js ADDED
@@ -0,0 +1,82 @@
1
+ // CLI: prospective memory — "next time X comes up, mention Y".
2
+ // open-claudia remind "<note>" --pack <dir> | --entity <slug> [--sticky] [--expires <YYYY-MM-DD>]
3
+ // open-claudia remind list
4
+ // open-claudia remind remove <id>
5
+ // Reminders are topic-bound, not time-bound: they fire when the target pack/
6
+ // entity next surfaces in recall injection or is opened with pack show /
7
+ // entity show. Time-based reminding belongs to schedule-wakeup / cron-add;
8
+ // condition-based reminding belongs to watch-add.
9
+
10
+ const prospective = require("../core/prospective");
11
+
12
+ function usage() {
13
+ console.error('Usage: open-claudia remind "<note>" --pack <dir> | --entity <slug> [--sticky] [--expires <YYYY-MM-DD>]');
14
+ console.error(" open-claudia remind list | remove <id>");
15
+ process.exitCode = 1;
16
+ }
17
+
18
+ function run(args) {
19
+ const cmd = (args[0] || "").toLowerCase();
20
+
21
+ if (cmd === "" || cmd === "help" || cmd === "--help") return usage();
22
+
23
+ if (cmd === "list") {
24
+ const all = prospective.listReminders();
25
+ if (all.length === 0) return console.log("No pending reminders.");
26
+ for (const r of all) {
27
+ const extras = [r.sticky ? "sticky" : "one-shot", r.expiresAt ? `expires ${String(r.expiresAt).slice(0, 10)}` : ""].filter(Boolean).join(", ");
28
+ console.log(`${r.id} [${r.targetType}:${r.targetSlug}] (${extras}, asked ${String(r.createdAt).slice(0, 10)})\n ${r.note}`);
29
+ }
30
+ return;
31
+ }
32
+
33
+ if (cmd === "remove") {
34
+ const id = args[1];
35
+ if (!id) return usage();
36
+ if (!prospective.removeReminder(id)) { console.error(`No reminder: ${id} (open-claudia remind list)`); process.exitCode = 1; return; }
37
+ console.log(`Removed reminder ${id}.`);
38
+ return;
39
+ }
40
+
41
+ // Default verb: add. The note is everything that isn't a flag.
42
+ const rest = args.slice();
43
+ const takeFlag = (flag, hasValue = true) => {
44
+ const i = rest.indexOf(flag);
45
+ if (i === -1) return null;
46
+ const v = hasValue ? rest[i + 1] : true;
47
+ rest.splice(i, hasValue ? 2 : 1);
48
+ return v == null ? "" : v;
49
+ };
50
+ const packArg = takeFlag("--pack");
51
+ const entityArg = takeFlag("--entity");
52
+ const sticky = takeFlag("--sticky", false) === true;
53
+ const expires = takeFlag("--expires");
54
+ const note = rest.join(" ").trim();
55
+
56
+ if (!note || (!packArg && !entityArg) || (packArg && entityArg)) return usage();
57
+
58
+ // Resolve the target now so a typo surfaces immediately, not as a reminder
59
+ // that silently never fires (dream GC would drop it as an orphan).
60
+ let targetType, targetSlug, targetName;
61
+ if (packArg) {
62
+ const p = require("../core/packs").findPack(packArg);
63
+ if (!p) { console.error(`No pack: ${packArg} (open-claudia pack list)`); process.exitCode = 1; return; }
64
+ targetType = "pack"; targetSlug = p.dir; targetName = p.name || p.dir;
65
+ } else {
66
+ const e = require("../core/entities").findEntity(entityArg);
67
+ if (!e) { console.error(`No entity: ${entityArg} (open-claudia entity list)`); process.exitCode = 1; return; }
68
+ targetType = "entity"; targetSlug = e.slug; targetName = e.name || e.slug;
69
+ }
70
+
71
+ let expiresAt = null;
72
+ if (expires) {
73
+ const t = Date.parse(expires);
74
+ if (!Number.isFinite(t)) { console.error(`Bad --expires date: ${expires} (use ISO, e.g. 2026-09-01)`); process.exitCode = 1; return; }
75
+ expiresAt = new Date(t).toISOString();
76
+ }
77
+
78
+ const entry = prospective.addReminder({ note, targetType, targetSlug, sticky, expiresAt });
79
+ console.log(`📌 Reminder ${entry.id} armed — fires when ${targetType} "${targetName}" next comes up${sticky ? " (sticky: repeats until removed)" : " (one-shot)"}${expiresAt ? `, expires ${expiresAt.slice(0, 10)}` : ""}.`);
80
+ }
81
+
82
+ module.exports = { run };
package/bin/tool.js CHANGED
@@ -214,9 +214,14 @@ async function run(args) {
214
214
  console.log(`Runs: ${t.runCount}${t.lastUsed ? ` · last used ${t.lastUsed.slice(0, 16).replace("T", " ")}` : ""}${exit}`);
215
215
  }
216
216
  if (t.verbs && Object.keys(t.verbs).length) {
217
- const rows = Object.entries(t.verbs).map(([v, s]) =>
218
- ` • ${v}: ${s.runs} run(s), last exit ${s.lastExit}${s.lastSuccess ? `, last success ${s.lastSuccess.slice(0, 10)}` : ""}`);
219
- console.log("Verbs:\n" + rows.join("\n"));
217
+ let certs = {};
218
+ try { certs = tools.certificationForState({ verbs: t.verbs, history: t.history }); } catch (e) {}
219
+ const rows = Object.entries(t.verbs).map(([v, s]) => {
220
+ const c = certs[v];
221
+ const cert = c ? ` · ${c.tier}${c.tier === "certified" ? "" : ` (${c.cleanRuns}/${tools.CERTIFY_CLEAN_RUNS} clean)`}` : "";
222
+ return ` • ${v}: ${s.runs} run(s), last exit ${s.lastExit}${s.lastSuccess ? `, last success ${s.lastSuccess.slice(0, 10)}` : ""}${cert}`;
223
+ });
224
+ console.log("Verbs (certification is earned trust, informational only):\n" + rows.join("\n"));
220
225
  }
221
226
  try {
222
227
  const log = repo.toolLog(t.name, 5);
@@ -44,6 +44,9 @@ class TelegramAdapter {
44
44
  this._deferredExitLogAt = 0; // throttle the "deferring restart, turn in progress" log (1/min)
45
45
  this._lastHiccupLog = 0; // throttle the transient-error log (1/min)
46
46
  this._healBackoff = 0; // grows per consecutive heal, capped
47
+ this._selfAbortUntil = 0; // teardown grace: errors WE caused aren't faults
48
+ this._healthyBaseline = 0; // newest restart the pending healthy-check judges
49
+ this._healthyReArmMsg = ""; // reason text if that check finds no completed poll
47
50
  this._pausedForOutage = false; // host-network outage: poll loop stopped, slow re-probe owns recovery
48
51
  this._outageProbeTimer = null; // the ~30s re-probe loop that runs while paused
49
52
  this._pollWatchdogTimer = null; // detects a silent first/active getUpdates stall
@@ -112,6 +115,15 @@ class TelegramAdapter {
112
115
  // old exit-for-relaunch *was* the restart the bot kept doing on every idle
113
116
  // timeout (a sleep/wake burst could exhaust all 6 attempts in seconds).
114
117
  _onPollingHiccup(msg) {
118
+ // OUR OWN teardown, not a fault. _stopPollingClean destroys the agent, which
119
+ // kills the in-flight getUpdates and surfaces here as "socket hang up" 1-5ms
120
+ // later. Counting it re-armed the heal that had just torn the socket down —
121
+ // a self-sustaining loop that ran until the 10-min backstop exited the
122
+ // process (root-caused 2026-08-03 from a live capture: after the ONE genuine
123
+ // ECONNRESET, every subsequent error carried _polling._abort === true, i.e.
124
+ // we aborted it). The heal re-arms its own follow-up via _armHealthyCheck,
125
+ // so dropping these loses no recovery.
126
+ if (Date.now() < (this._selfAbortUntil || 0)) return;
115
127
  if (this._pausedForOutage) return; // paused for a host outage; the slow re-probe owns recovery
116
128
  // During a 409 streak the conflict handler owns the poll lifecycle; a heal
117
129
  // here would startPolling straight back into the conflict and its errors
@@ -125,8 +137,21 @@ class TelegramAdapter {
125
137
  console.log(`${this._stamp()} Polling hiccup (${msg}) — retrying on a fresh connection.`);
126
138
  }
127
139
  if (this._healTimer) return; // a heal is already scheduled; let it run
128
- this._healBackoff = Math.min(this._healBackoff + 1, 5);
129
- const delay = Math.min(15000, 500 * 2 ** this._healBackoff); // 1s … 15s
140
+ this._scheduleHeal();
141
+ }
142
+
143
+ // The heal ladder MUST out-wait one getUpdates or it can never observe
144
+ // success: a heal tears the socket down, so an interval shorter than the poll
145
+ // hold aborts every attempt before it can complete and _lastUpdate (the only
146
+ // evidence of recovery) stays pinned at 0 forever. The old 15s cap sat at HALF
147
+ // the 30s long-poll hold, which made recovery structurally impossible — a
148
+ // burst could only ever end at the 10-min wedge-exit. The cap now clears the
149
+ // 40s request timeout, which bounds even a hung poll, so the last rungs give a
150
+ // poll genuine room to land. Early rungs stay short: they run while polling is
151
+ // still down, where retrying fast is free and often enough.
152
+ _scheduleHeal() {
153
+ this._healBackoff = Math.min(this._healBackoff + 1, 7);
154
+ const delay = Math.min(45000, 500 * 2 ** this._healBackoff); // 1s, 2s, 4s, 8s, 16s, 32s, 45s
130
155
  this._healTimer = setTimeout(() => this._heal(), delay);
131
156
  }
132
157
 
@@ -141,6 +166,13 @@ class TelegramAdapter {
141
166
  // destroying the agent forces a hung half-open getUpdates to settle NOW
142
167
  // instead of never. The 5s race caps the wait if teardown gets swallowed.
143
168
  async _stopPollingClean(reason) {
169
+ // Open the self-abort window BEFORE destroying the agent: the "socket hang
170
+ // up" it raises lands in _onPollingHiccup within milliseconds. Time-boxed
171
+ // (never an explicit clear) so a path that forgets to close it can't deafen
172
+ // the hiccup handler permanently — the worst case is that a genuine error
173
+ // inside the window is dropped, and _armHealthyCheck still re-arms recovery
174
+ // 35s later. Covers the 5s stop race + 500ms settle + startPolling.
175
+ this._selfAbortUntil = Date.now() + 10000;
144
176
  const stopped = Promise.resolve(this.bot.stopPolling({ reason })).catch(() => {});
145
177
  try { this._agent.destroy(); } catch (e) {}
146
178
  await Promise.race([stopped, new Promise((r) => setTimeout(r, 5000))]);
@@ -244,7 +276,12 @@ class TelegramAdapter {
244
276
  this._pollStartedAt = Date.now();
245
277
  await this.bot.startPolling({ restart: false });
246
278
  } catch (e) {
247
- return; // restart didn't take — the next hiccup reschedules a heal
279
+ // Restart didn't take. This used to fall through to "the next hiccup
280
+ // reschedules a heal", but the self-abort window means the teardown we
281
+ // just did raises no hiccup — so nothing would be scheduled and the
282
+ // adapter would sit dead until the wedge-exit. Schedule it ourselves.
283
+ if (!this._healTimer) this._scheduleHeal();
284
+ return;
248
285
  }
249
286
  this._armHealthyCheck(this._pollStartedAt, "restart completed but no getUpdates within 35s");
250
287
  }
@@ -254,18 +291,29 @@ class TelegramAdapter {
254
291
  // nothing, so a blind timer would declare it healthy and reset _wedgedSince,
255
292
  // hiding the wedge until the 10-min backstop rebooted. Gate the reset on a
256
293
  // completed poll; otherwise re-arm a heal.
294
+ //
295
+ // Re-arming used to clearTimeout the pending check, which starved it: heals
296
+ // ran every 15s, the check needed 35s of quiet, so it NEVER fired — and it
297
+ // owns the only reset of _wedgedSince/_healBackoff, so the wedge clock ran
298
+ // unbroken to the 10-min exit (zero healthcheck events in a 200s+ live
299
+ // capture, 2026-08-03). Now the DEADLINE is never pushed back — the first arm
300
+ // wins and always fires — while the baseline tracks the newest restart, so a
301
+ // poll that completed before the latest restart can't be mistaken for proof
302
+ // that the latest restart worked.
257
303
  _armHealthyCheck(restartedAt, reArmMsg) {
258
- if (this._healthyTimer) clearTimeout(this._healthyTimer);
304
+ this._healthyBaseline = restartedAt;
305
+ this._healthyReArmMsg = reArmMsg;
306
+ if (this._healthyTimer) return;
259
307
  this._healthyTimer = setTimeout(() => {
260
308
  this._healthyTimer = null;
261
309
  const lastCompletedPoll = this.bot?._polling?._lastUpdate || 0;
262
- if (lastCompletedPoll >= restartedAt) {
310
+ if (lastCompletedPoll >= this._healthyBaseline) {
263
311
  if (this._wedgedSince) console.log(`${this._stamp()} Polling healthy again.`);
264
312
  this._wedgedSince = 0;
265
313
  this._healBackoff = 0;
266
314
  this._deferredExitLogAt = 0;
267
315
  } else {
268
- this._onPollingHiccup(reArmMsg);
316
+ this._onPollingHiccup(this._healthyReArmMsg);
269
317
  }
270
318
  }, 35000);
271
319
  }
@@ -0,0 +1,104 @@
1
+ "use strict";
2
+
3
+ // C4: apprenticeship radar. A deterministic 7-day scan of the project
4
+ // transcripts for repeated manual work — the same ad-hoc command re-derived by
5
+ // the agent, or the owner asking for the same thing again and again. The scan
6
+ // only gathers EVIDENCE; the dream's introspection pass turns it into at most
7
+ // two /learn CANDIDATES in its report. Report-only end to end: /learn remains
8
+ // the owner-triggered capture, and nothing here creates a pack or tool.
9
+
10
+ const fs = require("fs");
11
+ const path = require("path");
12
+ const { TRANSCRIPTS_DIR } = require("./config");
13
+
14
+ const WINDOW_DAYS = 7;
15
+ const MIN_REPEATS = 3;
16
+ const MIN_DAYS = 2;
17
+ const MAX_CANDIDATES = 2;
18
+ const MIN_COMMAND_CHARS = 12;
19
+ const MIN_REQUEST_CHARS = 16;
20
+ const MAX_REQUEST_CHARS = 200;
21
+
22
+ function normalize(s) {
23
+ return String(s || "").replace(/\s+/g, " ").trim();
24
+ }
25
+
26
+ // Commands the assistant quoted in a turn: inline code spans plus "$ "-prefixed
27
+ // lines. The bot's own bookkeeping CLI is excluded — /learn-ing `open-claudia
28
+ // task list` would be circular.
29
+ function commandsIn(text) {
30
+ const out = [];
31
+ const t = String(text || "");
32
+ for (const m of t.matchAll(/`([^`\n]{4,160})`/g)) out.push(m[1]);
33
+ for (const m of t.matchAll(/^\s*\$\s+(.{4,160})$/gm)) out.push(m[1]);
34
+ return out
35
+ .map(normalize)
36
+ .filter((c) => c.length >= MIN_COMMAND_CHARS)
37
+ .filter((c) => !c.startsWith("open-claudia "))
38
+ .filter((c) => /^[a-zA-Z_./~]/.test(c) && /\s/.test(c)); // looks like an invocation, not a bare word or path
39
+ }
40
+
41
+ function scanForApprenticeship({
42
+ now = Date.now(), days = WINDOW_DAYS, dir = TRANSCRIPTS_DIR,
43
+ minRepeats = MIN_REPEATS, minDays = MIN_DAYS, max = MAX_CANDIDATES,
44
+ } = {}) {
45
+ let files;
46
+ try { files = fs.readdirSync(dir).filter((f) => f.endsWith(".jsonl")); }
47
+ catch (e) { return []; }
48
+ const from = now - days * 86400000;
49
+ const tally = new Map(); // key → {kind, sample, count, days:Set, files:Set, lastAt}
50
+ const bump = (kind, sample, day, file, at) => {
51
+ const key = `${kind}:${sample.toLowerCase()}`;
52
+ const e = tally.get(key) || { kind, sample, count: 0, days: new Set(), files: new Set(), lastAt: "" };
53
+ e.count += 1;
54
+ e.days.add(day);
55
+ e.files.add(file);
56
+ if (at > e.lastAt) { e.lastAt = at; e.sample = sample; }
57
+ tally.set(key, e);
58
+ };
59
+ for (const f of files) {
60
+ let raw;
61
+ try { raw = fs.readFileSync(path.join(dir, f), "utf-8"); } catch (e) { continue; }
62
+ for (const line of raw.split("\n")) {
63
+ if (!line.trim()) continue;
64
+ let rec;
65
+ try { rec = JSON.parse(line); } catch (e) { continue; }
66
+ const t = Date.parse(rec.timestamp || "");
67
+ if (!Number.isFinite(t) || t < from || t > now) continue;
68
+ const day = new Date(t).toISOString().slice(0, 10);
69
+ if (rec.role === "assistant") {
70
+ for (const cmd of new Set(commandsIn(rec.text))) bump("command", cmd, day, f, rec.timestamp);
71
+ } else if (rec.role === "user") {
72
+ const req = normalize(rec.text);
73
+ if (req.length >= MIN_REQUEST_CHARS && req.length <= MAX_REQUEST_CHARS && !req.startsWith("/")) {
74
+ bump("request", req, day, f, rec.timestamp);
75
+ }
76
+ }
77
+ }
78
+ }
79
+ return [...tally.values()]
80
+ .filter((e) => e.count >= minRepeats && e.days.size >= minDays)
81
+ .sort((a, b) => b.count - a.count || b.days.size - a.days.size)
82
+ .slice(0, max)
83
+ .map((e) => ({ kind: e.kind, sample: e.sample, count: e.count, days: e.days.size, sessions: e.files.size }));
84
+ }
85
+
86
+ // Prompt block for the dream's introspection pass. Empty string when nothing
87
+ // repeated — the section only exists when there is evidence (no standing cost).
88
+ function radarSection(opts = {}) {
89
+ const found = scanForApprenticeship(opts);
90
+ if (!found.length) return "";
91
+ const lines = found.map((c) =>
92
+ `- [${c.kind}] ${c.kind === "command" ? `\`${c.sample}\`` : `"${c.sample}"`} — ${c.count}× across ${c.days} day${c.days === 1 ? "" : "s"} (${c.sessions} session${c.sessions === 1 ? "" : "s"})`);
93
+ return [
94
+ "═══ APPRENTICESHIP RADAR (deterministic scan of the last 7 days of transcripts) ═══",
95
+ "Repeated manual work — the same thing re-derived by hand instead of taught once:",
96
+ ...lines,
97
+ `In your report, offer at most ${MAX_CANDIDATES} of these as /learn candidates ("want to teach me this properly? say /learn after we next do it"), citing the counts above as evidence. Report-only: /learn stays owner-triggered — NEVER capture a pack, tool, or lesson from this radar yourself.`,
98
+ ].join("\n");
99
+ }
100
+
101
+ module.exports = {
102
+ WINDOW_DAYS, MIN_REPEATS, MIN_DAYS, MAX_CANDIDATES,
103
+ commandsIn, scanForApprenticeship, radarSection,
104
+ };
package/core/approvals.js CHANGED
@@ -231,6 +231,43 @@ function consume(id, commandLine) {
231
231
  } finally { releaseRecordLock(held); }
232
232
  }
233
233
 
234
+ // Read-side for the reviewer's denied-approval mining (C5): recent decided
235
+ // records, newest first. The ledger itself is already persisted per-record —
236
+ // this only lists it.
237
+ function listDecided({ withinMs = MAX_AGE_MS, statuses = ["denied", "approved"] } = {}) {
238
+ let entries;
239
+ try { entries = fs.readdirSync(APPROVALS_DIR); } catch (e) { return []; }
240
+ const wanted = new Set(statuses);
241
+ const cutoff = Date.now() - withinMs;
242
+ const out = [];
243
+ for (const f of entries) {
244
+ if (!f.endsWith(".json")) continue;
245
+ let rec;
246
+ try { rec = JSON.parse(fs.readFileSync(path.join(APPROVALS_DIR, f), "utf-8")); } catch (e) { continue; }
247
+ if (!rec || !wanted.has(rec.status)) continue;
248
+ const decidedAt = Date.parse(rec.decidedAt || "");
249
+ if (!Number.isFinite(decidedAt) || decidedAt < cutoff) continue;
250
+ out.push(rec);
251
+ }
252
+ return out.sort((a, b) => Date.parse(b.decidedAt) - Date.parse(a.decidedAt));
253
+ }
254
+
255
+ // Stamp records the reviewer has already been shown, so each deny is fed to
256
+ // the correction-detector exactly once.
257
+ function markMined(ids) {
258
+ for (const id of ids || []) {
259
+ const held = acquireRecordLock(id);
260
+ if (!held) continue;
261
+ try {
262
+ const rec = read(id);
263
+ if (rec && !rec.minedAt) {
264
+ rec.minedAt = new Date().toISOString();
265
+ writeRecord(rec);
266
+ }
267
+ } finally { releaseRecordLock(held); }
268
+ }
269
+ }
270
+
234
271
  module.exports = {
235
272
  APPROVALS_DIR,
236
273
  MAX_COMMAND_BYTES,
@@ -240,6 +277,8 @@ module.exports = {
240
277
  decide,
241
278
  detach,
242
279
  formatApprovedCommand,
280
+ listDecided,
281
+ markMined,
243
282
  read,
244
283
  waitForDecision,
245
284
  };