@inetafrica/open-claudia 3.0.43 → 3.1.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.
Files changed (44) hide show
  1. package/CHANGELOG.md +44 -0
  2. package/README.md +9 -5
  3. package/bin/tool.js +15 -5
  4. package/bot.js +78 -3
  5. package/core/boot-health.js +84 -0
  6. package/core/doctor-memory.js +213 -0
  7. package/core/doctor.js +5 -0
  8. package/core/dream.js +11 -9
  9. package/core/entities.js +47 -3
  10. package/core/handlers.js +118 -1
  11. package/core/keyring.js +24 -4
  12. package/core/memory-mutation-queue.js +42 -11
  13. package/core/pack-review.js +60 -4
  14. package/core/packs.js +47 -3
  15. package/core/providers/claude-events.js +36 -3
  16. package/core/providers/events.js +11 -0
  17. package/core/recall/discoverer.js +22 -6
  18. package/core/recall/graph.js +84 -52
  19. package/core/recall/tuning.js +1 -2
  20. package/core/router.js +18 -0
  21. package/core/run-context.js +10 -4
  22. package/core/runner.js +111 -2
  23. package/core/safe-mode.js +227 -0
  24. package/core/state.js +50 -2
  25. package/core/system-prompt.js +43 -3
  26. package/core/tool-graph.js +99 -71
  27. package/core/tools.js +3 -2
  28. package/core/turn-observer.js +23 -1
  29. package/package.json +3 -3
  30. package/test-extractive-injection.js +21 -0
  31. package/test-fixtures/fake-agent-cli.js +29 -1
  32. package/test-fixtures/sim-no-sqlite.js +14 -0
  33. package/test-memory-mutation-queue.js +33 -3
  34. package/test-provider-compaction.js +5 -0
  35. package/test-provider-core-prompt.js +3 -0
  36. package/test-provider-events.js +71 -0
  37. package/test-provider-language.js +61 -1
  38. package/test-provider-recall.js +33 -0
  39. package/test-provider-runner.js +50 -0
  40. package/test-recall-discoverer.js +8 -0
  41. package/test-recall-graph.js +66 -11
  42. package/test-tool-graph.js +67 -16
  43. package/test-tool-manifest.js +32 -0
  44. package/test-tools.js +31 -0
@@ -117,8 +117,78 @@ assert.deepStrictEqual(claudeAuth, [{
117
117
  message: "Authentication failed",
118
118
  authError: true,
119
119
  usageLimit: false,
120
+ refusal: false,
120
121
  }]);
121
122
 
123
+ // Refusal surfaced only through the CLI's flattened error text (no structured
124
+ // stop_details): flagged, reason sentence + request id recovered from the text.
125
+ const refusalText = "API Error: Claude Code is unable to respond to this request, which appears to violate our Usage Policy (https://www.anthropic.com/legal/aup). This request triggered restrictions on violative cyber content and was blocked under Anthropic's Usage Policy. To learn more, see https://platform.claude.com/docs/en/build-with-claude/refusals-and-fallback. Try rephrasing the request or attempting a different approach.\n\nRequest ID: req_011CdCrJnhWPXeW3PsPFJEb5";
126
+ const claudeRefusal = normalizeClaudeEvent({
127
+ type: "result",
128
+ is_error: true,
129
+ subtype: "error_during_execution",
130
+ result: refusalText,
131
+ }, createClaudeParserState());
132
+ assert.strictEqual(claudeRefusal.length, 1);
133
+ assert.strictEqual(claudeRefusal[0].type, "error");
134
+ assert.strictEqual(claudeRefusal[0].refusal, true);
135
+ assert.strictEqual(claudeRefusal[0].authError, false);
136
+ assert.strictEqual(claudeRefusal[0].usageLimit, false);
137
+ assert.strictEqual(claudeRefusal[0].requestId, "req_011CdCrJnhWPXeW3PsPFJEb5");
138
+ assert.match(claudeRefusal[0].refusalExplanation, /violative cyber content/);
139
+
140
+ // Structured stop_details from the assistant message wins over text parsing.
141
+ const refusalState = createClaudeParserState();
142
+ normalizeClaudeEvent({
143
+ type: "assistant",
144
+ message: {
145
+ content: [],
146
+ stop_reason: "refusal",
147
+ stop_details: { type: "refusal", category: "cyber", explanation: "This request was declined because it could enable cyber harm." },
148
+ },
149
+ }, refusalState);
150
+ const structuredRefusal = normalizeClaudeEvent({
151
+ type: "result",
152
+ is_error: true,
153
+ subtype: "error_during_execution",
154
+ result: "API Error: request blocked",
155
+ }, refusalState);
156
+ assert.strictEqual(structuredRefusal[0].refusal, true);
157
+ assert.strictEqual(structuredRefusal[0].refusalCategory, "cyber");
158
+ assert.match(structuredRefusal[0].refusalExplanation, /cyber harm/);
159
+
160
+ // A refusal the CLI reports as a "successful" empty turn must fail with the
161
+ // reason instead of settling as an empty "(no output)" result.
162
+ const silentState = createClaudeParserState();
163
+ normalizeClaudeEvent({
164
+ type: "assistant",
165
+ message: { content: [], stop_reason: "refusal", stop_details: { type: "refusal", category: null, explanation: null } },
166
+ }, silentState);
167
+ const silent = normalizeClaudeEvent({
168
+ type: "result",
169
+ subtype: "success",
170
+ is_error: false,
171
+ session_id: "claude-refusal-session",
172
+ result: "",
173
+ }, silentState);
174
+ assert.strictEqual(silent.at(-1).type, "error");
175
+ assert.strictEqual(silent.at(-1).refusal, true);
176
+
177
+ // A refusal stop_reason with answer text already streamed must NOT hijack the
178
+ // successful result path.
179
+ const partialState = createClaudeParserState();
180
+ normalizeClaudeEvent({
181
+ type: "assistant",
182
+ message: { content: [{ type: "text", text: "partial answer" }], stop_reason: "refusal" },
183
+ }, partialState);
184
+ assert.strictEqual(normalizeClaudeEvent({
185
+ type: "result",
186
+ subtype: "success",
187
+ is_error: false,
188
+ session_id: "claude-partial-session",
189
+ result: "",
190
+ }, partialState).at(-1).type, "result");
191
+
122
192
  const codexState = createCodexParserState();
123
193
  assert.deepStrictEqual(normalizeCodexEvent(samples.codex.session, codexState), [
124
194
  { type: "session", sessionId: "codex-thread-1" },
@@ -151,6 +221,7 @@ assert.deepStrictEqual(codexAuth, [{
151
221
  message: "401 Unauthorized",
152
222
  authError: true,
153
223
  usageLimit: false,
224
+ refusal: false,
154
225
  }]);
155
226
 
156
227
  const decoderError = { type: "decoder.error", code: "MALFORMED_JSON", message: "Malformed provider JSONL record" };
@@ -86,7 +86,67 @@ assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
86
86
  // every 30s, resuming cleanly the moment connectivity returns. Poll-health logs
87
87
  // are timestamped. Confined to channels/telegram/adapter.js; new cases in
88
88
  // test-telegram-poll-recovery.js cover the pause+resume and the gated exit.
89
- assert.strictEqual(pkg.version, "3.0.43", "release version must remain unchanged without explicit approval");
89
+ // 3.0.44 approved by Sumeet 2026-07-20 ("push"): safety-refusal handling +
90
+ // poisoned-session recovery. The API's stop_reason "refusal" scores the WHOLE
91
+ // conversation, so a session whose context tripped the classifier refused every
92
+ // resumed turn (the flagged material was re-sent each time) and the user only
93
+ // saw a raw AUP error dump with no way forward but knowing to type /new.
94
+ // Refusals are now detected structurally (stop_details category/explanation,
95
+ // text-parse fallback on the flattened CLI error) and mapped to PROVIDER_REFUSAL
96
+ // with a clear diagnostic (reason, request ID, /new + rephrase + model options).
97
+ // Recovery: the refused session is QUARANTINED (bounded persisted map in user
98
+ // state; never resumed again — natural turns, explicit /continue, and rotated
99
+ // ids all forced fresh) and the refused prompt is auto-retried exactly ONCE in
100
+ // a fresh session behind a ♻️ notice. A refusal on already-fresh context gets
101
+ // no retry (the new message itself is the problem); capture runs never retry.
102
+ // 3.0.45 approved by Sumeet 2026-07-20 ("Ok so you ready to implement?"): first
103
+ // fix batch from the 2026-07-20 blind code review. Recall-graph decay moves to
104
+ // decay-on-read (the nightly rewrite compounded — a 60d half-life emptied in
105
+ // ~10 nights; one-time user_version 0→1 restamp, dream-tunable half-life);
106
+ // keyring/tool-state/tuning writes become crash-safe (atomic temp+fsync+rename,
107
+ // keyring .bak snapshot with read-through repair); recall failures alert after
108
+ // 3 consecutive memoryless turns instead of degrading silently; the recall
109
+ // pre-gate strips pleasantry prefixes so "ok deploy … now" recalls; tool-run
110
+ // creds are preflighted BEFORE an approval token is consumed (a missing key no
111
+ // longer burns the human's Approve); tool-graph parsing splits on | and &;
112
+ // raw NUL bytes in discoverer.js/state.js become \u0000 escapes; README
113
+ // corrected — discoverer is the shipped default engine, classic the opt-out.
114
+ // 3.0.45 second wave folded in per Sumeet 2026-07-20 ("i think just wok
115
+ // through all these features and fold them into .45?"): the memory-substrate
116
+ // revival. Root cause: pods on node:20 have no node:sqlite, so pack FTS, the
117
+ // recall graph and episodes silently never initialized — rahilcoder ran 16
118
+ // days with seedsTotal=0 while repeating corrected branding mistakes, and its
119
+ // reviewer starved 87% of runs on a stale mutation lock (container pid-1
120
+ // reuse made a crashed incarnation's lock look alive forever). Ships:
121
+ // Dockerfile.base FROM node:24-slim (+ CI build image node:24); a /doctor
122
+ // memory-health section (substrate hard-fail, graph stats, 24h recall
123
+ // pipeline, reviewer health, mutation lock, deferred reviews, corpus caps,
124
+ // disk) with a deduped boot-time substrate alert and a post-upgrade doctor
125
+ // gate on all three /upgrade paths; a pure-JS lexical seed floor under
126
+ // matchPacks/matchEntities (recall degrades to keyword matching, never to
127
+ // OFF — parity-tested with and without the sim-no-sqlite shim); the
128
+ // mutation-queue lock gains a boot id + ownerless-lock grace so pid-1 reuse
129
+ // can't wedge it, plus a reviewer retry+defer seatbelt; LOCKED Stance lines
130
+ // hoist under a BINDING CONSTRAINTS header and the reviewer must carry them
131
+ // verbatim and journal violations; compaction recall is fixed (summary
132
+ // meta-turn skips recall; the fresh seed matches on condensed brief + open
133
+ // task titles, not prompt boilerplate).
134
+ // 3.1.0 approved by Sumeet 2026-07-20 ("yes that's fine but if in hard boot
135
+ // loop it shoyuld give the user a way to get back online and diagnose or fall
136
+ // back into a safe mode or something to have it fixed or set up or downgrade,
137
+ // actually i think that's improtant as well to have a downgrade, i think add
138
+ // this all in and let's bump the minor to 3.1.0"): the substrate-revival
139
+ // fold graduates to a minor because boot semantics change. node:sqlite is now
140
+ // REQUIRED — a missing substrate boots into SAFE MODE (adapters + diagnostic
141
+ // slash commands only; no agent turns, crons, or memory writes) instead of
142
+ // silently degrading; MEMORY_ALLOW_DEGRADED=1 or a one-shot /safemode
143
+ // continue is the explicit keyword-only escape hatch. A boot crash loop (3
144
+ // consecutive boots dying before 5min stable uptime) also lands in safe mode
145
+ // so the operator keeps a live chat to rescue from instead of a dead pod.
146
+ // New /downgrade rolls back to the previously-running version from boot
147
+ // history (strict semver-validated target); /upgrade//downgrade clear the
148
+ // crash counter as deliberate fix attempts. package.json engines >=24.
149
+ assert.strictEqual(pkg.version, "3.1.0", "release version must remain unchanged without explicit approval");
90
150
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
91
151
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
92
152
  }
@@ -253,6 +253,39 @@ async function main() {
253
253
  recall.getEngine = originalGetEngine;
254
254
  assert.strictEqual(admittedProvider, "codex");
255
255
 
256
+ // ── compaction recall policy: the summary meta-turn skips recall entirely;
257
+ // the seed turn matches on the runner-supplied recallQuery (condensed
258
+ // brief + task titles), never the seed prompt's boilerplate ──
259
+ const summarySkip = await systemPrompt.buildOptionalRecallContext(
260
+ "Summarize this conversation for a fresh compacted continuation.",
261
+ {
262
+ state: mutableState,
263
+ runContext: { provider: "claude", purpose: "compact-summary", project: { name: "project-a", dir: root }, sessionId: null, runId: "compact-sum" },
264
+ },
265
+ );
266
+ assert.strictEqual(summarySkip.metadata.status, "omitted", "summary turn skips recall");
267
+ assert.strictEqual(summarySkip.metadata.reason, "compaction_meta_turn");
268
+ assert.strictEqual(summarySkip.dynamicContext, "");
269
+
270
+ let seedQueryText = null;
271
+ recall.getEngine = () => ({
272
+ name: "fixture",
273
+ async run(ctx) {
274
+ seedQueryText = ctx.userText;
275
+ return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [] };
276
+ },
277
+ });
278
+ await systemPrompt.buildOptionalRecallContext(
279
+ "This is a compacted continuation of a previous Open Claudia session. Treat the following summary as the prior conversation context.",
280
+ {
281
+ state: mutableState,
282
+ runContext: { provider: "claude", purpose: "compact-seed", recallQuery: "coders africa branding fonts\nFinalize v3.0.45", project: { name: "project-a", dir: root }, sessionId: null, runId: "compact-seed" },
283
+ },
284
+ );
285
+ recall.getEngine = originalGetEngine;
286
+ assert.strictEqual(seedQueryText, "coders africa branding fonts\nFinalize v3.0.45",
287
+ "seed recall matches on recallQuery, not the seed prompt");
288
+
256
289
  for (const file of ["core/recall-filter.js", "core/recall/discoverer.js", "core/recall/warm-walker.js"]) {
257
290
  const source = fs.readFileSync(path.join(__dirname, file), "utf8");
258
291
  assert.ok(!source.includes("spawnSubagent"), `${file} no longer uses the compatibility sub-agent path`);
@@ -262,6 +262,56 @@ async function main() {
262
262
  assert.strictEqual(authFailure.error.code, "PROVIDER_UNAUTHENTICATED");
263
263
  assert.match(authFailure.diagnostic, /Authentication failed/i);
264
264
 
265
+ // A safety refusal on a resumed session must surface as PROVIDER_REFUSAL
266
+ // with the structured reason (category/explanation/request id) intact — the
267
+ // inputs the poisoned-session recovery quarantines and retries on.
268
+ const refusalCapture = path.join(root, "refusal-capture.json");
269
+ const refusalProviders = makeProviders(
270
+ { claude: refusalCapture, codex: captures.codex },
271
+ { claude: { FAKE_AGENT_SCENARIO: "refusal" } },
272
+ );
273
+ mutable.activeSessions["project-a"].claude = "claude-poisoned-session";
274
+ const refusalService = createRunnerService({
275
+ getState: () => mutable,
276
+ getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
277
+ resolveProvider: (id) => refusalProviders[id],
278
+ buildTurnPrompt: simpleBundle,
279
+ cleanup: async () => {},
280
+ });
281
+ const refused = await refusalService.runAgentCapture("REFUSAL SENTINEL", projectA);
282
+ assert.strictEqual(refused.ok, false);
283
+ assert.strictEqual(refused.admittedSessionId, "claude-poisoned-session", "refusal run resumed the poisoned session");
284
+ assert.strictEqual(refused.error.code, "PROVIDER_REFUSAL");
285
+ assert.strictEqual(refused.error.details.refusal, true);
286
+ assert.strictEqual(refused.error.details.refusalCategory, "cyber");
287
+ assert.match(refused.error.details.refusalExplanation, /cyber harm/);
288
+ assert.strictEqual(refused.error.details.requestId, "req_fixture123");
289
+ assert.match(refused.diagnostic, /Usage Policy/);
290
+ assert.match(refused.diagnostic, /req_fixture123/);
291
+ const refusalArgv = JSON.parse(fs.readFileSync(refusalCapture, "utf8")).argv;
292
+ assert.ok(refusalArgv.includes("claude-poisoned-session"), "refusal run resumed via --resume");
293
+
294
+ // The quarantine guard: once the session is quarantined, the SAME turn shape
295
+ // is admitted fresh — no --resume, healthy success in a new session.
296
+ mutable.quarantinedSessions = {
297
+ "claude-poisoned-session": { provider: "claude", project: "project-a", reason: "refusal", at: Date.now() },
298
+ };
299
+ const recoveredProviders = makeProviders({ claude: refusalCapture, codex: captures.codex });
300
+ const recoveredService = createRunnerService({
301
+ getState: () => mutable,
302
+ getStore: () => ({ channelId: "fixture-channel", transport: "telegram" }),
303
+ resolveProvider: (id) => recoveredProviders[id],
304
+ buildTurnPrompt: simpleBundle,
305
+ cleanup: async () => {},
306
+ });
307
+ const recovered = await recoveredService.runAgentCapture("RECOVERY SENTINEL", projectA);
308
+ assert.strictEqual(recovered.ok, true);
309
+ assert.strictEqual(recovered.admittedSessionId, null, "quarantined session must not be admitted");
310
+ assert.strictEqual(recovered.sessionId, "claude-fresh-session");
311
+ const recoveredArgv = JSON.parse(fs.readFileSync(refusalCapture, "utf8")).argv;
312
+ assert.ok(!recoveredArgv.includes("--resume"), "quarantined resume is forced fresh");
313
+ assert.ok(!recoveredArgv.includes("claude-poisoned-session"));
314
+
265
315
  console.log("provider runner OK");
266
316
  }
267
317
 
@@ -53,6 +53,14 @@ assert.strictEqual(disc.recallTier("restart bot", 3), "seeds", "short command
53
53
  assert.strictEqual(disc.recallTier("show fup logs", 2), "seeds", "≤4 words → seeds-only tier");
54
54
  assert.strictEqual(disc.recallTier("yes we need to see the 100", 2), "full", "substantive yes-prefixed turn → full");
55
55
  assert.strictEqual(disc.recallTier("how do I deploy the mobile app", 4), "full", "substantive → full");
56
+ // B2: a pleasantry prefix is stripped BEFORE tiering, so a short real request
57
+ // wearing an ack is no longer swallowed by the ≤4-word pleasantry skip.
58
+ assert.strictEqual(disc.recallTier("ok deploy kazee now", 3), "seeds", "ack-prefixed 4-word command → seeds, not skip");
59
+ assert.strictEqual(disc.recallTier("yes deploy it", 4), "seeds", "approval + command → seeds, not skip");
60
+ assert.strictEqual(disc.recallTier("thanks! restart euvy bot", 3), "seeds", "thanks-prefixed command → seeds, not skip");
61
+ assert.strictEqual(disc.needsRecall("ok deploy kazee now", 0), true, "prefixed command needs recall");
62
+ assert.strictEqual(disc.recallTier("ok thanks sounds good perfect", 5), "skip", "stacked pure pleasantries skip at any length");
63
+ assert.strictEqual(disc.recallTier("morning check the kazee deploys", 2), "seeds", "greeting stripped → remainder tiers as a short command");
56
64
 
57
65
  // --- shared stub corpus + helpers ---
58
66
  const packs = {
@@ -7,6 +7,9 @@ const path = require("path");
7
7
 
8
8
  const tmp = fs.mkdtempSync(path.join(os.tmpdir(), "recall-graph-"));
9
9
  process.env.RECALL_GRAPH_DB = path.join(tmp, "graph.db");
10
+ // Pin the read-time decay half-life so assertions are deterministic (env pin
11
+ // beats any tuning file the dream may have written).
12
+ process.env.RECALL_DECAY_HALF_LIFE_DAYS = "30";
10
13
 
11
14
  const graph = require("./core/recall/graph");
12
15
 
@@ -51,18 +54,33 @@ for (const [, v] of act) assert.ok(v.activation > 0 && v.hop >= 1, "activation h
51
54
  // seeds with no edges produce nothing
52
55
  assert.strictEqual(graph.expand([{ id: "pack:orphanseed", score: 5 }]).size, 0);
53
56
 
54
- // --- decay (never below structural floor) ---
55
- graph.addEdge("pack:d", "pack:e", "parent"); // base weight 3, no last_reinforced → untouched
56
- graph.reinforce("pack:d", "pack:f", 10);
57
- // backdate the reinforced edge well past the half-life
57
+ // --- decay-on-read (never below structural floor; reads never rewrite storage) ---
58
+ graph.addEdge("pack:d", "pack:e", "parent"); // base weight 3, no last_reinforced → never decays
59
+ graph.reinforce("pack:d", "pack:f", 10); // stored 11, stamped now
58
60
  const db = graph.openDb();
59
- const old = new Date(Date.now() - 400 * 86400000).toISOString();
60
- db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(old, "pack:d", "pack:f");
61
- graph.decay({ halfLifeDays: 30 });
62
- const df = graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:f");
63
- assert.ok(df.weight < 11 && df.weight >= 1, "reinforced edge decayed toward floor");
64
- const de = graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:e");
65
- assert.strictEqual(de.weight, 3, "structural edge with no reinforcement untouched by decay");
61
+ const oneHL = new Date(Date.now() - 30 * 86400000).toISOString(); // exactly one pinned half-life
62
+ db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(oneHL, "pack:d", "pack:f");
63
+ const dfEff = graph.neighbors("pack:d").find((n) => n.node === "pack:f").weight;
64
+ assert.ok(dfEff > 5.3 && dfEff < 5.7, `one half-life on read ≈ 5.5 (${dfEff})`);
65
+ const deEff = graph.neighbors("pack:d").find((n) => n.node === "pack:e").weight;
66
+ assert.strictEqual(deEff, 3, "structural edge with no reinforcement never decays");
67
+ // far past the half-life the effective weight clamps at the type's base
68
+ const longAgo = new Date(Date.now() - 400 * 86400000).toISOString();
69
+ db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(longAgo, "pack:d", "pack:f");
70
+ const floored = graph.neighbors("pack:d").find((n) => n.node === "pack:f").weight;
71
+ assert.strictEqual(floored, 1, "effective weight clamps at the related floor");
72
+ // storage untouched by all those reads
73
+ assert.strictEqual(graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:f").weight, 11,
74
+ "reads never rewrite the stored weight");
75
+
76
+ // REGRESSION (P1 compounding decay): the nightly weight-rewrite path is gone.
77
+ assert.strictEqual(graph.decay, undefined, "decay() rewrite removed — decay is read-time only");
78
+
79
+ // reinforcement settles accrued decay, THEN bumps — never bumps the stale stored value
80
+ db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=?").run(oneHL, "pack:d", "pack:f");
81
+ graph.reinforce("pack:d", "pack:f", 1); // effective ≈ 5.5 + 1
82
+ const settledDF = graph.allEdges().find((e) => e.src === "pack:d" && e.dst === "pack:f").weight;
83
+ assert.ok(settledDF > 6.3 && settledDF < 6.7, `settle-then-bump ≈ 6.5, not 12 (${settledDF})`);
66
84
 
67
85
  // --- structural sync from a stubbed corpus ---
68
86
  const packsStub = {
@@ -88,7 +106,44 @@ const pruned = graph.pruneOrphans(packsStub, entStub);
88
106
  assert.ok(pruned >= 1, "orphan edge pruned");
89
107
  assert.ok(!graph.allEdges().some((e) => e.src === "pack:ghost"), "ghost edge gone");
90
108
 
109
+ // --- REGRESSION (P1): repeated nightly tend() must leave stored weights
110
+ // bit-identical — the old decay() rewrite shrank them again every night
111
+ // (compounding: full age reapplied to an already-decayed weight). ---
112
+ graph.reinforce("pack:kazee-mobile", "pack:kazee", 9); // stored 10, stamped now
113
+ db.prepare("UPDATE edges SET last_reinforced=? WHERE src=? AND dst=? AND type='related'")
114
+ .run(oneHL, "pack:kazee-mobile", "pack:kazee");
115
+ const kmStored = () => graph.allEdges()
116
+ .find((e) => e.src === "pack:kazee-mobile" && e.dst === "pack:kazee" && e.type === "related").weight;
117
+ const before3 = kmStored();
118
+ for (let i = 0; i < 3; i++) graph.tend(packsStub, entStub);
119
+ assert.strictEqual(kmStored(), before3, "three nightly tends never rewrite a reinforced weight");
120
+ const kmEff = graph.neighbors("pack:kazee-mobile")
121
+ .find((n) => n.node === "pack:kazee" && n.type === "related").weight;
122
+ assert.ok(kmEff > 4.7 && kmEff < 5.3, `effective after 3 tends is still one half-life ≈ 5 (${kmEff})`);
123
+
91
124
  // parseLinks
92
125
  assert.deepStrictEqual(graph.parseLinks("see [[Foo-Bar]] and [[baz]]"), ["foo-bar", "baz"]);
93
126
 
127
+ // --- v1 migration: a legacy DB (pre decay-on-read) gets its clock restarted on
128
+ // first open — stored weights are treated as current, nothing else changes. ---
129
+ graph._resetForTest();
130
+ process.env.RECALL_GRAPH_DB = path.join(tmp, "legacy.db");
131
+ {
132
+ const { DatabaseSync } = require("node:sqlite");
133
+ const raw = new DatabaseSync(process.env.RECALL_GRAPH_DB);
134
+ raw.exec(`CREATE TABLE edges (src TEXT NOT NULL, dst TEXT NOT NULL, type TEXT NOT NULL,
135
+ weight REAL NOT NULL DEFAULT 1, last_reinforced TEXT, created TEXT, PRIMARY KEY (src, dst, type))`);
136
+ raw.prepare("INSERT INTO edges (src, dst, type, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
137
+ .run("pack:l1", "pack:l2", "related", 4, new Date(Date.now() - 200 * 86400000).toISOString(), new Date().toISOString());
138
+ raw.close();
139
+ }
140
+ delete require.cache[require.resolve("./core/recall/graph")];
141
+ const graph2 = require("./core/recall/graph");
142
+ const legacy = graph2.allEdges().find((e) => e.src === "pack:l1");
143
+ assert.ok(Date.parse(legacy.last_reinforced) > Date.now() - 60000, "migration restamps legacy reinforced rows");
144
+ assert.strictEqual(legacy.weight, 4, "migration leaves the stored weight untouched");
145
+ const legacyEff = graph2.neighbors("pack:l1").find((n) => n.node === "pack:l2").weight;
146
+ assert.ok(legacyEff > 3.9, `restamped legacy row reads at ~full stored weight (${legacyEff})`);
147
+ graph2._resetForTest();
148
+
94
149
  console.log("recall graph OK");
@@ -53,25 +53,35 @@ graph.addFollow("ensure-src", "ensure-dst"); // weight 1, last_reinforced=null
53
53
  const ensured = graph.followers("ensure-src");
54
54
  assert.strictEqual(ensured[0].weight, 1, "ensure edge weighs 1");
55
55
 
56
- // ── decay: backdate a reinforced edge far into the past, then it prunes (no floor) ──
56
+ // ── read-time decay + prune: backdate a reinforced edge far into the past —
57
+ // reads hide it immediately, prune() physically deletes it (no floor) ──
57
58
  const db = graph.openDb();
58
59
  const longAgo = new Date(Date.now() - 365 * 86400000).toISOString();
59
60
  db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(longAgo, "auth", "list");
60
- let res = graph.decay({ halfLifeDays: 1, pruneBelow: 0.15 });
61
+ assert.ok(!graph.followers("auth").some((r) => r.name === "list"), "faded edge invisible to reads before any prune");
62
+ let res = graph.prune({ halfLifeDays: 1, pruneBelow: 0.15 });
61
63
  assert.ok(res.pruned >= 1, "a year-old edge under the floor is pruned");
62
- assert.ok(!graph.followers("auth").some((r) => r.name === "list"), "pruned edge is gone");
64
+ assert.ok(!graph.allEdges().some((e) => e.src === "auth" && e.dst === "list"), "pruned edge physically gone");
63
65
 
64
- // the ensure edge (last_reinforced=null) was skipped by decay, not pruned
65
- assert.ok(graph.followers("ensure-src").some((r) => r.name === "ensure-dst"), "un-reinforced edge survives decay");
66
+ // the ensure edge (last_reinforced=null) neither fades on read nor prunes
67
+ assert.ok(graph.followers("ensure-src").some((r) => r.name === "ensure-dst"), "un-reinforced edge survives prune");
66
68
 
67
- // ── decay that reduces but keeps an edge above the floor ──
69
+ // ── read-time decay that reduces but keeps an edge; storage never rewritten ──
68
70
  graph.addFollow("warm-a", "warm-b", { bump: 10 });
69
- const recent = new Date(Date.now() - 30 * 86400000).toISOString();
70
- db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(recent, "warm-a", "warm-b");
71
- graph.decay({ halfLifeDays: 30, pruneBelow: 0.15 }); // one half-life → ~5
71
+ const oneHL = new Date(Date.now() - 45 * 86400000).toISOString(); // one default half-life
72
+ db.prepare("UPDATE tool_edges SET last_reinforced=? WHERE src=? AND dst=?").run(oneHL, "warm-a", "warm-b");
72
73
  const warm = graph.followers("warm-a")[0];
73
74
  assert.ok(warm && warm.name === "warm-b", "warm edge survives a single half-life");
74
- assert.ok(warm.weight < 10 && warm.weight > 0.15, `warm edge decayed but kept (${warm.weight})`);
75
+ assert.ok(warm.weight > 4.7 && warm.weight < 5.3, `read-time weight ≈ half of stored (${warm.weight})`);
76
+ assert.strictEqual(graph.allEdges().find((e) => e.src === "warm-a").weight, 10, "reads never rewrite the stored weight");
77
+
78
+ // ── settle-then-bump: reinforcing a decayed edge bumps its EFFECTIVE weight ──
79
+ graph.addFollow("warm-a", "warm-b", { bump: 1 }); // ≈ 5 + 1
80
+ const settled = graph.allEdges().find((e) => e.src === "warm-a" && e.dst === "warm-b").weight;
81
+ assert.ok(settled > 5.7 && settled < 6.3, `settle-then-bump ≈ 6, not 11 (${settled})`);
82
+
83
+ // REGRESSION (P1 compounding decay): the nightly weight-rewrite path is gone.
84
+ assert.strictEqual(graph.decay, undefined, "decay() rewrite removed — prune() only deletes");
75
85
 
76
86
  // ── removeEdge ──
77
87
  assert.strictEqual(graph.removeEdge("warm-a", "warm-b"), true, "removeEdge succeeds");
@@ -84,10 +94,11 @@ const removed = graph.pruneOrphans(fakeLib);
84
94
  assert.ok(removed >= 1, "edge pointing at an unregistered tool ('dead') is pruned");
85
95
  assert.ok(!graph.followers("live").length, "live→dead removed because dead is not a live tool");
86
96
 
87
- // ── tend() is the nightly entry: decays + prunes orphans + returns stats ──
97
+ // ── tend() is the nightly entry: prunes faded + orphaned edges, returns stats ──
88
98
  const t = graph.tend(fakeLib);
89
99
  assert.ok(typeof t.edges === "number" && typeof t.nodes === "number", "tend returns stats");
90
- assert.ok(typeof t.pruned === "number" && typeof t.decayed === "number", "tend returns decay/prune counts");
100
+ assert.ok(typeof t.pruned === "number", "tend returns prune count");
101
+ assert.strictEqual(t.decayed, undefined, "tend has no decay pass (decay is read-time)");
91
102
 
92
103
  // ── stats / allEdges sanity ──
93
104
  const s = graph.stats();
@@ -111,6 +122,16 @@ assert.strictEqual(runs[0].name, "spaces", "first run is spaces");
111
122
  assert.strictEqual(runs[0].shape, "spaces docs --task-id <id> <path>", "first run shaped");
112
123
  assert.strictEqual(runs[1].name, "other", "second run is other (args did not bleed across &&)");
113
124
  assert.strictEqual(graph.parseToolRuns("ls -la").length, 0, "non-tool command yields no runs");
125
+ // B17: every shell composition operator splits — the single pipe was missed,
126
+ // so piped compositions taught no follows-edge.
127
+ const piped = graph.parseToolRuns("open-claudia tool run kticket list | open-claudia tool run report weekly");
128
+ assert.deepStrictEqual(piped.map((r) => r.name), ["kticket", "report"], "single-pipe composition parses both runs");
129
+ assert.strictEqual(piped[0].shape, "kticket list", "piped first run's args don't bleed into the second");
130
+ const bg = graph.parseToolRuns("open-claudia tool run a1 go & open-claudia tool run b2 go");
131
+ assert.deepStrictEqual(bg.map((r) => r.name), ["a1", "b2"], "background & splits too");
132
+ const quotedPipe = graph.parseToolRuns('open-claudia tool run kchat send "a | b" && open-claudia tool run other list');
133
+ assert.deepStrictEqual(quotedPipe.map((r) => r.name), ["kchat", "other"], "a pipe inside a quoted argument does not split");
134
+ assert.strictEqual(quotedPipe[0].shape, "kchat send <text>", "quoted argument still masks to <text>");
114
135
 
115
136
  // ── recordPackTool: insert, then bump accumulates; packTools surfaces strongest ──
116
137
  graph.recordPackTool("san-marco-agreements", "spaces", { command: "spaces docs --task-id <id> <path>", bump: 1 });
@@ -132,13 +153,15 @@ assert.ok(graph.toolPacks("spaces").some((u) => u.pack === "san-marco-agreements
132
153
  assert.strictEqual(graph.stats().packToolEdges, graph.allPackToolEdges().length, "stats packToolEdges matches allPackToolEdges");
133
154
  assert.ok(graph.stats().packToolEdges >= 2, "at least two pack↔tool edges present");
134
155
 
135
- // ── decay: a year-old pack↔tool edge under the floor is pruned ──
156
+ // ── prune: a year-old pack↔tool edge fades on read and is physically deleted ──
136
157
  const ptDb = graph.openDb();
137
158
  ptDb.prepare("UPDATE pack_tool_edges SET last_reinforced=? WHERE pack=? AND tool=?")
138
159
  .run(new Date(Date.now() - 365 * 86400000).toISOString(), "san-marco-agreements", "spaces");
139
- const decayRes = graph.decay({ halfLifeDays: 1, pruneBelow: 0.15 });
140
- assert.ok(decayRes.pruned >= 1, "stale pack↔tool edge pruned by decay");
141
- assert.strictEqual(graph.packTools("san-marco-agreements").length, 0, "pruned pack↔tool edges gone");
160
+ assert.strictEqual(graph.packTools("san-marco-agreements").length, 0, "faded pack↔tool edges invisible to reads");
161
+ const pruneRes = graph.prune({ halfLifeDays: 1, pruneBelow: 0.15 });
162
+ assert.ok(pruneRes.pruned >= 1, "stale pack↔tool edge pruned");
163
+ assert.strictEqual(graph.allPackToolEdges().filter((e) => e.pack === "san-marco-agreements").length, 0,
164
+ "pruned pack↔tool rows physically gone");
142
165
 
143
166
  // ── pruneOrphans: drops edges for a removed tool and (with packsLib) a removed pack ──
144
167
  graph.recordPackTool("live-pack", "live-tool", { command: "live-tool go", bump: 1 });
@@ -152,5 +175,33 @@ assert.ok(graph.packTools("live-pack").some((u) => u.tool === "live-tool"), "liv
152
175
  assert.ok(!graph.packTools("live-pack").some((u) => u.tool === "ghost-tool"), "missing-tool edge gone");
153
176
  assert.strictEqual(graph.packTools("ghost-pack").length, 0, "missing-pack edge gone");
154
177
 
178
+ // ── v1 migration: legacy DBs (pre decay-on-read) get their clocks restarted on
179
+ // first open — stored weights are treated as current in both tables ──
155
180
  graph._resetForTest();
181
+ process.env.TOOL_GRAPH_DB = path.join(tmp, "legacy.db");
182
+ {
183
+ const { DatabaseSync } = require("node:sqlite");
184
+ const raw = new DatabaseSync(process.env.TOOL_GRAPH_DB);
185
+ const old = new Date(Date.now() - 200 * 86400000).toISOString();
186
+ raw.exec(`CREATE TABLE tool_edges (src TEXT NOT NULL, dst TEXT NOT NULL,
187
+ weight REAL NOT NULL DEFAULT 1, last_reinforced TEXT, created TEXT, PRIMARY KEY (src, dst))`);
188
+ raw.exec(`CREATE TABLE pack_tool_edges (pack TEXT NOT NULL, tool TEXT NOT NULL,
189
+ command TEXT NOT NULL DEFAULT '', intent TEXT NOT NULL DEFAULT '',
190
+ weight REAL NOT NULL DEFAULT 1, last_reinforced TEXT, created TEXT, PRIMARY KEY (pack, tool, command))`);
191
+ raw.prepare("INSERT INTO tool_edges (src, dst, weight, last_reinforced, created) VALUES (?,?,?,?,?)")
192
+ .run("l-a", "l-b", 3, old, old);
193
+ raw.prepare("INSERT INTO pack_tool_edges (pack, tool, command, weight, last_reinforced, created) VALUES (?,?,?,?,?,?)")
194
+ .run("l-pack", "l-tool", "l-tool go", 2, old, old);
195
+ raw.close();
196
+ }
197
+ delete require.cache[require.resolve("./core/tool-graph")];
198
+ const graph2 = require("./core/tool-graph");
199
+ const lEdge = graph2.allEdges().find((e) => e.src === "l-a");
200
+ const lPt = graph2.allPackToolEdges().find((e) => e.pack === "l-pack");
201
+ assert.ok(Date.parse(lEdge.last_reinforced) > Date.now() - 60000, "migration restamps legacy tool edges");
202
+ assert.ok(Date.parse(lPt.last_reinforced) > Date.now() - 60000, "migration restamps legacy pack↔tool edges");
203
+ assert.strictEqual(lEdge.weight, 3, "stored weight untouched by migration");
204
+ assert.strictEqual(graph2.followers("l-a")[0].weight, 3, "restamped edge reads at full stored weight");
205
+ graph2._resetForTest();
206
+
156
207
  console.log("tool-graph OK");
@@ -106,5 +106,37 @@ const approvals = require("./core/approvals");
106
106
  approvals.decide(rec3.id, "denied", "c1");
107
107
  assert.strictEqual(await approvals.waitForDecision(rec3.id, { timeoutMs: 5000, pollMs: 50 }), "denied");
108
108
 
109
+ // ── B10: missing keyring creds are preflighted BEFORE the one-shot approval
110
+ // token is consumed. The old order burned the human's decision on a run that
111
+ // could never start; the same token must survive and redeem after the key
112
+ // is set, with the byte-identical command.
113
+ const toolCli = require("./bin/tool");
114
+ const b10src = path.join(tmp, "vaulted.sh");
115
+ fs.writeFileSync(b10src, "#!/usr/bin/env bash\necho ran-vaulted\n");
116
+ tools.addTool(b10src, { name: "vaulted", description: "needs a cred", risk: "destructive", requires: ["vaulted_api_key"] });
117
+ const b10cmd = approvals.formatApprovedCommand(["open-claudia", "tool", "run", "vaulted", "go"]);
118
+ const b10rec = approvals.create({ tool: "vaulted", tier: "destructive", command: b10cmd, channelId: "c1", adapter: "tg" });
119
+ approvals.decide(b10rec.id, "approved", "c1");
120
+ const errs = [];
121
+ const realErr = console.error;
122
+ console.error = (...a) => errs.push(a.join(" "));
123
+ try {
124
+ process.exitCode = 0;
125
+ await toolCli.run(["run", "vaulted", "go", "--approval", b10rec.id]);
126
+ assert.strictEqual(process.exitCode, 1, "missing creds refuse the run with exit 1, not an approval error");
127
+ assert.ok(errs.some((l) => /missing keyring keys vaulted_api_key/.test(l)), "failure names the missing key");
128
+ assert.ok(errs.some((l) => /NOT consumed/.test(l)), "agent is told the token survived");
129
+ assert.strictEqual(approvals.read(b10rec.id).consumedAt, undefined, "the approval token is NOT burned by the creds failure");
130
+
131
+ fs.writeFileSync(process.env.KEYRING_FILE, JSON.stringify({ vaulted_api_key: "k" }));
132
+ process.exitCode = 0;
133
+ await toolCli.run(["run", "vaulted", "go", "--approval", b10rec.id]);
134
+ assert.strictEqual(process.exitCode, 0, "the same token redeems once the key exists");
135
+ assert.ok(approvals.read(b10rec.id).consumedAt, "token consumed exactly by the real run");
136
+ } finally {
137
+ console.error = realErr;
138
+ process.exitCode = 0;
139
+ }
140
+
109
141
  console.log("tool manifest OK");
110
142
  })().catch((e) => { console.error(e); process.exit(1); });
package/test-tools.js CHANGED
@@ -490,4 +490,35 @@ assert.ok(tools.removeTool("hello"), "removeTool returns the removed tool");
490
490
  assert.strictEqual(tools.findTool("hello"), null, "removed tool is gone");
491
491
  assert.ok(!fs.existsSync(path.join(process.env.TOOLS_DIR, "hello")), "removed tool's directory is gone");
492
492
 
493
+ // ── keyring durability (B6/B13): atomic save + .bak fallback on corruption ──
494
+ // A half-written keyring must not silently strip every tool of credentials:
495
+ // save() snapshots the last-good file, load() falls back to it and says so
496
+ // loudly on stderr (once per process) instead of returning {} in silence.
497
+ const keyring = require("./core/keyring");
498
+ const KR = process.env.KEYRING_FILE;
499
+ fs.rmSync(KR, { force: true });
500
+ fs.rmSync(KR + ".bak", { force: true });
501
+ keyring.set("kr_a", "1");
502
+ assert.ok(!fs.existsSync(KR + ".bak"), "first save has no previous version to snapshot");
503
+ keyring.set("kr_b", "2");
504
+ assert.deepStrictEqual(JSON.parse(fs.readFileSync(KR + ".bak", "utf-8")), { kr_a: "1" },
505
+ "second save snapshots the previous good version to .bak");
506
+ assert.strictEqual(fs.statSync(KR).mode & 0o777, 0o600, "keyring file is 0600 from birth");
507
+ const krErrs = [];
508
+ const realErr = console.error;
509
+ console.error = (...a) => krErrs.push(a.join(" "));
510
+ try {
511
+ fs.writeFileSync(KR, "{truncated-by-crash"); // simulate a torn write
512
+ assert.deepStrictEqual(keyring.all(), { kr_a: "1" }, "corrupt primary falls back to .bak snapshot");
513
+ assert.strictEqual(keyring.get("kr_a"), "1", "get() reads through the fallback");
514
+ keyring.all();
515
+ assert.strictEqual(krErrs.filter((l) => /corrupt/.test(l)).length, 1, "corruption is warned exactly once per process");
516
+ } finally {
517
+ console.error = realErr;
518
+ }
519
+ keyring.set("kr_c", "3"); // next save repairs the primary from the fallback view
520
+ assert.deepStrictEqual(JSON.parse(fs.readFileSync(KR, "utf-8")), { kr_a: "1", kr_c: "3" },
521
+ "save after corruption rewrites a valid primary from the recovered data");
522
+ assert.ok(!fs.readdirSync(tmp).some((f) => f.endsWith(".tmp")), "no stray temp files left behind by atomic writes");
523
+
493
524
  console.log("tools OK");