@inetafrica/open-claudia 2.14.8 → 3.0.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 (157) hide show
  1. package/.env.example +30 -4
  2. package/CHANGELOG.md +21 -0
  3. package/README.md +87 -66
  4. package/bin/agent.js +44 -18
  5. package/bin/cli.js +30 -28
  6. package/bin/dream.js +5 -11
  7. package/bin/loopback-client.js +29 -5
  8. package/bin/schedule.js +19 -5
  9. package/bin/tool.js +23 -5
  10. package/bot-agent.js +5 -2350
  11. package/bot.js +105 -7
  12. package/channels/telegram/adapter.js +6 -1
  13. package/channels/voice/adapter.js +1 -0
  14. package/channels/voice/manage.js +43 -5
  15. package/config-dir.js +3 -11
  16. package/core/actions.js +149 -40
  17. package/core/approvals.js +136 -29
  18. package/core/auth-flow.js +103 -19
  19. package/core/config-dir.js +19 -0
  20. package/core/config.js +127 -69
  21. package/core/doctor.js +111 -57
  22. package/core/dream.js +219 -62
  23. package/core/enforcer.js +0 -0
  24. package/core/entities.js +2 -1
  25. package/core/fsutil.js +55 -0
  26. package/core/handlers.js +503 -217
  27. package/core/ideas.js +2 -1
  28. package/core/identity.js +8 -11
  29. package/core/io.js +24 -3
  30. package/core/jobs.js +589 -72
  31. package/core/lessons.js +3 -2
  32. package/core/loopback.js +45 -8
  33. package/core/memory-mutation-queue.js +241 -0
  34. package/core/migration-backup.js +1060 -0
  35. package/core/pack-review.js +295 -88
  36. package/core/packs.js +4 -3
  37. package/core/persona.js +2 -1
  38. package/core/process-tree.js +19 -2
  39. package/core/provider-migration.js +39 -0
  40. package/core/provider-status.js +80 -0
  41. package/core/providers/claude-events.js +117 -0
  42. package/core/providers/claude-hook.js +114 -0
  43. package/core/providers/claude.js +296 -0
  44. package/core/providers/codex-events.js +121 -0
  45. package/core/providers/codex-hook.js +280 -0
  46. package/core/providers/codex.js +286 -0
  47. package/core/providers/contract.js +153 -0
  48. package/core/providers/env.js +148 -0
  49. package/core/providers/events.js +170 -0
  50. package/core/providers/hook-command.js +22 -0
  51. package/core/providers/index.js +240 -0
  52. package/core/providers/utility-policy.js +269 -0
  53. package/core/recall/classic.js +2 -2
  54. package/core/recall/discoverer.js +71 -22
  55. package/core/recall/metrics.js +27 -1
  56. package/core/recall/tuning.js +4 -1
  57. package/core/recall/warm-walker.js +151 -108
  58. package/core/recall-filter.js +86 -61
  59. package/core/router.js +79 -7
  60. package/core/run-context.js +185 -0
  61. package/core/runner.js +1415 -1282
  62. package/core/scheduler.js +515 -210
  63. package/core/side-chat.js +346 -0
  64. package/core/state.js +1096 -95
  65. package/core/subagent.js +72 -98
  66. package/core/system-prompt.js +462 -279
  67. package/core/tool-guard.js +73 -39
  68. package/core/tools.js +22 -5
  69. package/core/transcripts.js +30 -1
  70. package/core/usage-log.js +19 -4
  71. package/core/utility-agent.js +654 -0
  72. package/core/web-auth.js +29 -13
  73. package/core/web-sessions.js +78 -0
  74. package/docs/CHANNEL_DESIGN.md +181 -0
  75. package/docs/MULTI_CHANNEL_PLAN.md +254 -0
  76. package/docs/PROVIDER_MIGRATION.md +67 -0
  77. package/health.js +50 -39
  78. package/package.json +51 -4
  79. package/setup.js +198 -38
  80. package/soul.md +39 -0
  81. package/test-ability-extraction.js +5 -0
  82. package/test-approval-async.js +63 -4
  83. package/test-cursor-removal.js +334 -0
  84. package/test-delivery-contract.js +88 -0
  85. package/test-enforcer.js +70 -27
  86. package/test-fixtures/current-provider-parity.json +132 -0
  87. package/test-fixtures/fake-agent-cli.js +477 -0
  88. package/test-fixtures/migrations/claude-only/jobs.json +3 -0
  89. package/test-fixtures/migrations/claude-only/sessions.json +5 -0
  90. package/test-fixtures/migrations/claude-only/state.json +5 -0
  91. package/test-fixtures/migrations/cursor-selected/crons.json +3 -0
  92. package/test-fixtures/migrations/cursor-selected/jobs.json +3 -0
  93. package/test-fixtures/migrations/cursor-selected/sessions.json +5 -0
  94. package/test-fixtures/migrations/cursor-selected/state.json +6 -0
  95. package/test-fixtures/migrations/dual-provider/jobs.json +3 -0
  96. package/test-fixtures/migrations/dual-provider/sessions.json +7 -0
  97. package/test-fixtures/migrations/dual-provider/state.json +10 -0
  98. package/test-fixtures/migrations/malformed-partial/jobs.json +1 -0
  99. package/test-fixtures/migrations/malformed-partial/sessions.json +1 -0
  100. package/test-fixtures/migrations/malformed-partial/sessions.json.bak +3 -0
  101. package/test-fixtures/migrations/malformed-partial/state.json +1 -0
  102. package/test-fixtures/migrations/malformed-partial/state.json.bak +5 -0
  103. package/test-fixtures/migrations/missing-project/jobs.json +3 -0
  104. package/test-fixtures/migrations/missing-project/sessions.json +3 -0
  105. package/test-fixtures/migrations/missing-project/state.json +4 -0
  106. package/test-fixtures/migrations/multi-user/jobs.json +4 -0
  107. package/test-fixtures/migrations/multi-user/sessions.json +4 -0
  108. package/test-fixtures/migrations/multi-user/state.json +6 -0
  109. package/test-fixtures/provider-event-samples.json +17 -0
  110. package/test-learning-e2e.js +5 -0
  111. package/test-memory-mutation-queue.js +191 -0
  112. package/test-provider-boot-matrix.js +399 -0
  113. package/test-provider-bootstrap.js +158 -0
  114. package/test-provider-capabilities.js +232 -0
  115. package/test-provider-claude.js +206 -0
  116. package/test-provider-codex.js +228 -0
  117. package/test-provider-compaction.js +371 -0
  118. package/test-provider-core-prompt.js +264 -0
  119. package/test-provider-coupling-audit.js +90 -0
  120. package/test-provider-dream.js +312 -0
  121. package/test-provider-enforcer.js +252 -0
  122. package/test-provider-env-isolation.js +150 -0
  123. package/test-provider-events.js +141 -0
  124. package/test-provider-fixture.js +332 -0
  125. package/test-provider-gateway.js +508 -0
  126. package/test-provider-language.js +89 -0
  127. package/test-provider-migration-backup.js +424 -0
  128. package/test-provider-pack-review.js +436 -0
  129. package/test-provider-parity-e2e.js +537 -0
  130. package/test-provider-prompt-parity.js +89 -0
  131. package/test-provider-recall.js +271 -0
  132. package/test-provider-registry.js +251 -0
  133. package/test-provider-resume-parity.js +41 -0
  134. package/test-provider-run-lifecycle.js +349 -0
  135. package/test-provider-runner.js +271 -0
  136. package/test-provider-scheduler.js +689 -0
  137. package/test-provider-session-commands.js +185 -0
  138. package/test-provider-session-history.js +205 -0
  139. package/test-provider-side-chat.js +337 -0
  140. package/test-provider-state-migration.js +316 -0
  141. package/test-provider-stream-decoder.js +69 -0
  142. package/test-provider-subagent.js +228 -0
  143. package/test-provider-tool-hooks.js +360 -0
  144. package/test-recall-discoverer.js +1 -0
  145. package/test-recall-engine.js +3 -3
  146. package/test-recall-evolution.js +18 -0
  147. package/test-run-lock.js +63 -0
  148. package/test-runner-watchdog-static.js +16 -8
  149. package/test-single-runtime.js +35 -0
  150. package/test-telegram-poll-recovery.js +56 -0
  151. package/test-tool-guard.js +56 -0
  152. package/test-tools.js +19 -0
  153. package/test-unified-identity.js +3 -1
  154. package/test-usage-accounting.js +92 -20
  155. package/test-utility-provider-policy.js +486 -0
  156. package/test-web-sessions.js +74 -0
  157. package/web.js +159 -45
@@ -6,7 +6,9 @@
6
6
  // mutation is announced in chat (one line), per the no-silent-learning
7
7
  // rule.
8
8
 
9
- const { spawnSubagent } = require("./subagent");
9
+ const crypto = require("crypto");
10
+ const { spawnUtilityAgent, cancelUtilityAgents } = require("./utility-agent");
11
+ const { memoryMutationQueue } = require("./memory-mutation-queue");
10
12
  const packs = require("./packs");
11
13
  const entities = require("./entities");
12
14
  const lessons = require("./lessons");
@@ -15,15 +17,21 @@ const { redactSensitive } = require("./redact");
15
17
 
16
18
  const MIN_TURN_CHARS = 400;
17
19
  const MAX_TEXT_CHARS = 16000;
18
- // Create/merge decisions need real judgement (the cheap model over-created
19
- // per-version/per-facet packs), so default to the latest Sonnet. Override with
20
- // PACK_REVIEW_MODEL if cost/latency of running it on every substantial turn matters.
21
- const REVIEW_MODEL = process.env.PACK_REVIEW_MODEL || "claude-sonnet-4-6";
22
20
  const MAX_ACTIONS = 2;
23
21
  const MAX_ENTITY_ACTIONS = 3;
24
22
  // Lessons are precious always-on budget — at most one promotion per turn,
25
23
  // and only when the turn shows a genuine miss.
26
24
  const MAX_LESSON_ACTIONS = 1;
25
+ const REVIEW_OUTPUT_SCHEMA = Object.freeze({
26
+ type: "object",
27
+ required: ["actions", "entities", "lessons"],
28
+ additionalProperties: false,
29
+ properties: {
30
+ actions: { type: "array", items: { type: "object" } },
31
+ entities: { type: "array", items: { type: "object" } },
32
+ lessons: { type: "array", items: { type: "object" } },
33
+ },
34
+ });
27
35
 
28
36
  function enabled() {
29
37
  return (process.env.PACK_REVIEW || "on").toLowerCase() !== "off";
@@ -381,8 +389,9 @@ function shouldSkipReview({ userText, assistantText, signals = {} }) {
381
389
  return null;
382
390
  }
383
391
 
384
- // Fire-and-forget. `announce` is an async (text) => void bound to the
385
- // originating channel; failures are logged, never thrown into the turn.
392
+ // Callers may hand this off without awaiting it, but each review stays tracked
393
+ // until model, serialized mutation, and announcement settlement. Announcement
394
+ // failures never escape into the foreground turn.
386
395
  // Provenance (2.5): stamp appended Log/Journal lines with the contributing
387
396
  // person when they're NOT the owner. The owner is the default author, so
388
397
  // tagging their contributions would be pure noise — the signal is precisely
@@ -393,100 +402,298 @@ function provenanceTag(source) {
393
402
  return `(via ${String(source.name).trim()}) `;
394
403
  }
395
404
 
396
- function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks, source }) {
397
- if (!enabled()) return;
398
- const skip = shouldSkipReview({ userText, assistantText, signals });
399
- if (skip) {
400
- if (skip !== "short") { // count only the new gate's savings, not the old floor
401
- try { require("./recall/metrics").logReviewerSkip(); } catch (e) {}
405
+ function contentVersion(value) {
406
+ return crypto.createHash("sha256").update(JSON.stringify(value ?? null)).digest("hex");
407
+ }
408
+
409
+ function snapshotMemoryVersions() {
410
+ const packVersions = {};
411
+ const entityVersions = {};
412
+ try {
413
+ for (const pack of packs.listPacks()) packVersions[pack.dir] = contentVersion(pack);
414
+ } catch (_) {}
415
+ try {
416
+ for (const entity of entities.listEntities()) entityVersions[entity.slug] = contentVersion(entity);
417
+ } catch (_) {}
418
+ return { packs: packVersions, entities: entityVersions };
419
+ }
420
+
421
+ function unchangedPack(dir, versions) {
422
+ const known = Object.prototype.hasOwnProperty.call(versions?.packs || {}, dir);
423
+ if (!known) {
424
+ try { return packs.readPack(dir) === null; }
425
+ catch (_) { return false; }
426
+ }
427
+ const expected = versions.packs[dir];
428
+ try { return contentVersion(packs.readPack(dir)) === expected; }
429
+ catch (_) { return false; }
430
+ }
431
+
432
+ function safeEntityAction(action, versions) {
433
+ let existing = null;
434
+ try { existing = entities.findEntity(action?.name); } catch (_) {}
435
+ if (!existing) return action;
436
+ const known = Object.prototype.hasOwnProperty.call(versions?.entities || {}, existing.slug);
437
+ const expected = known ? versions.entities[existing.slug] : null;
438
+ if (known && contentVersion(existing) === expected) return action;
439
+ // A dream or another review changed this entity while the model was
440
+ // deciding. Keep the append-only observation, but never replace newer
441
+ // current-truth/persona sections with a stale proposal.
442
+ return {
443
+ name: action.name,
444
+ type: action.type || existing.type,
445
+ log: action.log || "",
446
+ };
447
+ }
448
+
449
+ function applyDecisionBatch({ decision, activePacks, versions, source }) {
450
+ const activeSet = new Set(Array.isArray(activePacks) ? activePacks : []);
451
+ for (const dir of [...activeSet]) if (!unchangedPack(dir, versions)) activeSet.delete(dir);
452
+ const lines = [];
453
+ const usedIds = [];
454
+ const errors = [];
455
+ const tag = provenanceTag(source);
456
+
457
+ for (const original of decision.actions) {
458
+ const action = structuredClone(original);
459
+ try {
460
+ if (tag && typeof action.journal === "string" && action.journal.trim()) action.journal = tag + action.journal.trim();
461
+ const result = applyAction(action, activeSet);
462
+ if (result && (result.kind === "update" || result.kind === "create") && result.dir) usedIds.push(`pack:${result.dir}`);
463
+ if (result?.kind === "skipped") {
464
+ lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${result.reason}).`);
465
+ } else if (result?.kind === "create" && result.ability) {
466
+ lines.push(`🧩 New ability: ${result.name}${result.learned_on ? ` (learned on ${result.learned_on})` : ""}\n${clipWords(result.note, 180)}\n↳ open-claudia pack show ${result.dir}`);
467
+ } else if (result?.kind === "create") {
468
+ lines.push(`📦 New pack: ${result.name}${result.parent ? ` (filed under ${result.parent})` : ""}\n${clipWords(result.note, 180)}\n↳ open-claudia pack show ${result.dir}`);
469
+ } else if (result) {
470
+ const marks = [];
471
+ if (result.appliedTo) marks.push(` 🧩 (now also applies to ${result.appliedTo})`);
472
+ if (result.overwroteUserState) marks.push(" ⚠️ replaced a user-written State — check it kept your facts");
473
+ if (result.blindSkipped) marks.push(" (section rewrite dropped: pack wasn't active or changed while review was pending — journal only)");
474
+ lines.push(`✏️ ${result.name} — ${clipWords(result.note, 180)}${marks.join("")}\n↳ open-claudia pack show ${result.dir}`);
475
+ }
476
+ } catch (error) {
477
+ errors.push(error);
478
+ console.warn(`[pack-review] apply failed: ${error.message}`);
402
479
  }
403
- return;
404
480
  }
405
481
 
406
- const activeSet = Array.isArray(activePacks) && activePacks.length ? new Set(activePacks) : null;
407
- const prompt = buildReviewPrompt(redactSensitive(String(userText || "")), redactSensitive(String(assistantText || "")), activePacks);
408
-
409
- spawnSubagent(prompt, {
410
- model: REVIEW_MODEL,
411
- channelId,
412
- timeoutMs: 3 * 60 * 1000,
413
- systemPrompt: "You are a background memory reviewer. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
414
- }).then(({ text }) => {
415
- const decision = parseDecision(text);
416
- if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && (decision.lessons || []).length === 0)) return;
417
- const lines = [];
418
- const usedIds = []; // nodes this turn's work demonstrably touched
419
- const tag = provenanceTag(source);
420
- for (const a of decision.actions) {
421
- try {
422
- if (tag && typeof a.journal === "string" && a.journal.trim()) a.journal = tag + a.journal.trim();
423
- const r = applyAction(a, activeSet);
424
- if (r && (r.kind === "update" || r.kind === "create") && r.dir) usedIds.push(`pack:${r.dir}`);
425
- if (r && r.kind === "skipped") {
426
- lines.push(`🛡️ Skipped a memory write that looked like an injected instruction (${r.reason}).`);
427
- } else if (r && r.kind === "create" && r.ability) {
428
- lines.push(`🧩 New ability: ${r.name}${r.learned_on ? ` (learned on ${r.learned_on})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
429
- } else if (r && r.kind === "create") {
430
- lines.push(`📦 New pack: ${r.name}${r.parent ? ` (filed under ${r.parent})` : ""}\n${clipWords(r.note, 180)}\n↳ open-claudia pack show ${r.dir}`);
431
- } else if (r) {
432
- const marks = [];
433
- if (r.appliedTo) marks.push(` 🧩 (now also applies to ${r.appliedTo})`);
434
- if (r.overwroteUserState) marks.push(" ⚠️ replaced a user-written State — check it kept your facts");
435
- if (r.blindSkipped) marks.push(" (section rewrite dropped: pack wasn't active this turn — journal only)");
436
- lines.push(`✏️ ${r.name} — ${clipWords(r.note, 180)}${marks.join("")}\n↳ open-claudia pack show ${r.dir}`);
437
- }
438
- } catch (e) {
439
- console.warn(`[pack-review] apply failed: ${e.message}`);
482
+ for (const original of decision.entities) {
483
+ const action = structuredClone(original);
484
+ try {
485
+ if (tag && typeof action.log === "string" && action.log.trim()) action.log = tag + action.log.trim();
486
+ const result = applyEntityAction(safeEntityAction(action, versions));
487
+ if (result) {
488
+ if (result.slug) usedIds.push(`entity:${result.slug}`);
489
+ const emoji = ENTITY_EMOJI[result.type] || ENTITY_EMOJI.thing;
490
+ lines.push(result.kind === "create"
491
+ ? `${emoji} Now tracking ${result.name} (${result.type}) — ${clipWords(result.note, 160)}`
492
+ : `${emoji} ${result.name} ${clipWords(result.note, 160)}`);
440
493
  }
494
+ } catch (error) {
495
+ errors.push(error);
496
+ console.warn(`[pack-review] entity apply failed: ${error.message}`);
441
497
  }
442
- for (const ea of decision.entities) {
443
- try {
444
- if (tag && typeof ea.log === "string" && ea.log.trim()) ea.log = tag + ea.log.trim();
445
- const r = applyEntityAction(ea);
446
- if (r) {
447
- if (r.slug) usedIds.push(`entity:${r.slug}`);
448
- const emoji = ENTITY_EMOJI[r.type] || ENTITY_EMOJI.thing;
449
- lines.push(r.kind === "create"
450
- ? `${emoji} Now tracking ${r.name} (${r.type}) — ${clipWords(r.note, 160)}`
451
- : `${emoji} ${r.name} — ${clipWords(r.note, 160)}`);
498
+ }
499
+
500
+ if (usedIds.length) {
501
+ try { require("./recall/metrics").logUse(usedIds, "reviewer"); } catch (_) {}
502
+ try { if (usedIds.length > 1) require("./recall/graph").reinforceSet(usedIds); } catch (_) {}
503
+ }
504
+
505
+ for (const original of decision.lessons || []) {
506
+ try {
507
+ const result = applyLessonAction(structuredClone(original));
508
+ if (!result) continue;
509
+ if (result.kind === "skipped") {
510
+ if (result.reason && /exfil|override|base64/.test(result.reason)) {
511
+ lines.push(`🛡️ Skipped a lesson that looked like an injected instruction (${result.reason}).`);
452
512
  }
453
- } catch (e) {
454
- console.warn(`[pack-review] entity apply failed: ${e.message}`);
513
+ } else if (result.kind === "create") {
514
+ lines.push(`📌 New lesson I got this wrong before, so I'll always keep it loaded now:\n"${clipWords(result.text, 200)}"${result.overCap ? "\n(That's over the lessons cap — the nightly dream will tidy.)" : ""}`);
515
+ } else if (result.kind === "reinforce") {
516
+ lines.push(`📌 Reinforced a lesson I keep needing — clearly worth keeping front of mind:\n"${clipWords(result.text, 200)}"`);
455
517
  }
518
+ } catch (error) {
519
+ errors.push(error);
520
+ console.warn(`[pack-review] lesson apply failed: ${error.message}`);
521
+ }
522
+ }
523
+
524
+ if (errors.length) {
525
+ const error = new AggregateError(errors, `${errors.length} memory review mutation(s) failed`);
526
+ error.applied = { lines, usedIds };
527
+ throw error;
528
+ }
529
+ return { lines, usedIds };
530
+ }
531
+
532
+ function reviewerMetric(entry) {
533
+ try { require("./recall/metrics").logReviewerRun(entry); } catch (_) {}
534
+ }
535
+
536
+ function createPackReviewer(dependencies = {}) {
537
+ const runUtility = dependencies.spawnUtilityAgent || spawnUtilityAgent;
538
+ const queue = dependencies.mutationQueue || memoryMutationQueue;
539
+ const cancelUtilities = dependencies.cancelUtilityAgents || cancelUtilityAgents;
540
+ const handles = new Map();
541
+ let accepting = true;
542
+
543
+ function track(promise, metadata) {
544
+ const id = typeof crypto.randomUUID === "function" ? crypto.randomUUID() : `${Date.now()}-${Math.random()}`;
545
+ handles.set(id, { id, promise, ...metadata });
546
+ promise.then(() => handles.delete(id), () => handles.delete(id));
547
+ return promise;
548
+ }
549
+
550
+ function reviewTurn({ userText, assistantText, channelId, announce, signals, activePacks, source, provider, runId } = {}) {
551
+ if (!enabled()) return Promise.resolve({ status: "skipped", reason: "disabled" });
552
+ const skip = shouldSkipReview({ userText, assistantText, signals });
553
+ if (skip) {
554
+ if (skip !== "short") { try { require("./recall/metrics").logReviewerSkip(); } catch (_) {} }
555
+ return Promise.resolve({ status: "skipped", reason: skip });
456
556
  }
457
- // Weak-use signal: the reviewer deciding a pack/entity needed updating is
458
- // evidence the turn genuinely worked on that node use, observed after
459
- // the fact, even when the agent never re-opened the note (it may already
460
- // have been injected). Feeds the same precision metric as 📖 opens and
461
- // reinforces co-use edges. This is co-WORK, never co-recall — surfacing
462
- // alone still reinforces nothing.
463
- if (usedIds.length) {
464
- try { require("./recall/metrics").logUse(usedIds, "reviewer"); } catch (e) {}
465
- try { if (usedIds.length > 1) require("./recall/graph").reinforceSet(usedIds); } catch (e) {}
557
+ if (!accepting) {
558
+ queue.record({ id: null, label: "review", status: "pending_intent_rejected", provider: provider || null, runId: runId || null });
559
+ return Promise.resolve({ status: "not_admitted", reason: "review shutdown in progress" });
466
560
  }
467
- for (const la of (decision.lessons || [])) {
561
+
562
+ const capturedProvider = provider || null;
563
+ const versions = snapshotMemoryVersions();
564
+ const prompt = buildReviewPrompt(
565
+ redactSensitive(String(userText || "")),
566
+ redactSensitive(String(assistantText || "")),
567
+ activePacks,
568
+ );
569
+ const work = (async () => {
570
+ let utilityResult = null;
468
571
  try {
469
- const r = applyLessonAction(la);
470
- if (!r) continue;
471
- if (r.kind === "skipped") {
472
- if (r.reason && /exfil|override|base64/.test(r.reason)) {
473
- lines.push(`🛡️ Skipped a lesson that looked like an injected instruction (${r.reason}).`);
474
- }
475
- } else if (r.kind === "create") {
476
- lines.push(`📌 New lesson — I got this wrong before, so I'll always keep it loaded now:\n"${clipWords(r.text, 200)}"${r.overCap ? "\n(That's over the lessons cap — the nightly dream will tidy.)" : ""}`);
477
- } else if (r.kind === "reinforce") {
478
- lines.push(`📌 Reinforced a lesson I keep needing — clearly worth keeping front of mind:\n"${clipWords(r.text, 200)}"`);
572
+ utilityResult = await runUtility(prompt, {
573
+ purpose: "review",
574
+ activeProvider: capturedProvider,
575
+ tier: "medium",
576
+ modelEnvPrefix: "PACK_REVIEW_MODEL",
577
+ legacyClaudeModelEnv: "PACK_REVIEW_MODEL",
578
+ readOnly: true,
579
+ outputSchema: REVIEW_OUTPUT_SCHEMA,
580
+ timeoutMs: 3 * 60 * 1000,
581
+ cwd: process.cwd(),
582
+ systemPrompt: "You are a background memory reviewer. Reply with ONLY the requested JSON object. No prose, no markdown, no tool use.",
583
+ });
584
+ const decision = parseDecision(utilityResult.json ? JSON.stringify(utilityResult.json) : utilityResult.text);
585
+ if (!decision || (decision.actions.length === 0 && decision.entities.length === 0 && decision.lessons.length === 0)) {
586
+ reviewerMetric({ provider: utilityResult.provider, model: utilityResult.model, status: "no_change", fallbackUsed: utilityResult.fallbackUsed });
587
+ return { status: "no_change", provider: utilityResult.provider, model: utilityResult.model, fallbackUsed: !!utilityResult.fallbackUsed };
588
+ }
589
+ const applied = await queue.enqueue("review", () => applyDecisionBatch({
590
+ decision,
591
+ activePacks,
592
+ versions,
593
+ source,
594
+ }), {
595
+ provider: utilityResult.provider,
596
+ model: utilityResult.model,
597
+ purpose: "review",
598
+ runId,
599
+ });
600
+ let announcementFailed = false;
601
+ if (applied.lines.length && typeof announce === "function") {
602
+ try { await announce(formatAnnouncement(applied.lines)); }
603
+ catch (_) { announcementFailed = true; }
604
+ }
605
+ reviewerMetric({ provider: utilityResult.provider, model: utilityResult.model, status: "applied", fallbackUsed: utilityResult.fallbackUsed });
606
+ return {
607
+ status: "applied",
608
+ provider: utilityResult.provider,
609
+ model: utilityResult.model,
610
+ fallbackUsed: !!utilityResult.fallbackUsed,
611
+ lines: applied.lines,
612
+ announcementFailed,
613
+ };
614
+ } catch (error) {
615
+ const resolvedProvider = utilityResult?.provider || capturedProvider || "";
616
+ const partialLines = error.applied?.lines || [];
617
+ let announcementFailed = false;
618
+ if (partialLines.length && typeof announce === "function") {
619
+ try { await announce(formatAnnouncement(partialLines)); }
620
+ catch (_) { announcementFailed = true; }
479
621
  }
480
- } catch (e) {
481
- console.warn(`[pack-review] lesson apply failed: ${e.message}`);
622
+ reviewerMetric({ provider: resolvedProvider, model: utilityResult?.model || "", status: "failed", code: error.code || "REVIEW_FAILED" });
623
+ console.warn(`[pack-review] reviewer failed: ${error.message}`);
624
+ return {
625
+ status: "failed",
626
+ provider: resolvedProvider || null,
627
+ model: utilityResult?.model || null,
628
+ lines: partialLines,
629
+ announcementFailed,
630
+ error: { code: error.code || "REVIEW_FAILED", message: redactSensitive(error.message) },
631
+ };
482
632
  }
633
+ })();
634
+ return track(work, { provider: capturedProvider, channelId: channelId || null, runId: runId || null });
635
+ }
636
+
637
+ function stopAdmission(reason = "shutdown") {
638
+ accepting = false;
639
+ queue.record({ id: null, label: "review", status: "review_admission_stopped", reason: String(reason).slice(0, 160) });
640
+ }
641
+
642
+ async function drainReviews({ timeoutMs = 5000, cancelOnTimeout = false } = {}) {
643
+ stopAdmission("shutdown drain");
644
+ queue.stopAdmission("shutdown drain");
645
+ const snapshot = [...handles.values()];
646
+ let timer;
647
+ const settled = Promise.allSettled(snapshot.map((handle) => handle.promise)).then(() => true);
648
+ const drained = await Promise.race([
649
+ settled,
650
+ new Promise((resolve) => { timer = setTimeout(() => resolve(false), timeoutMs); }),
651
+ ]);
652
+ clearTimeout(timer);
653
+ if (!drained) {
654
+ for (const handle of snapshot.filter((entry) => handles.has(entry.id))) {
655
+ queue.record({ id: handle.id, label: "review", status: "pending_on_shutdown", provider: handle.provider, runId: handle.runId });
656
+ }
657
+ if (cancelOnTimeout && typeof cancelUtilities === "function") await cancelUtilities({ timeoutMs });
483
658
  }
484
- if (lines.length > 0 && typeof announce === "function") {
485
- announce(formatAnnouncement(lines)).catch(() => {});
659
+ let queueResult = await queue.drain({ timeoutMs });
660
+ if (!queueResult.drained && cancelOnTimeout && typeof cancelUtilities === "function") {
661
+ await cancelUtilities({ timeoutMs });
662
+ queueResult = await queue.drain({ timeoutMs });
486
663
  }
487
- }).catch((e) => {
488
- console.warn(`[pack-review] reviewer failed: ${e.message}`);
489
- });
664
+ return {
665
+ drained: drained && queueResult.drained,
666
+ pendingReviews: [...handles.values()].map(({ id, provider: providerId, runId: reviewRunId }) => ({ id, provider: providerId, runId: reviewRunId })),
667
+ pendingMutations: queueResult.pending,
668
+ };
669
+ }
670
+
671
+ return {
672
+ activeReviewCount: () => handles.size,
673
+ drainReviews,
674
+ reviewTurn,
675
+ stopAdmission,
676
+ };
490
677
  }
491
678
 
492
- module.exports = { reviewTurn, shouldSkipReview, parseDecision, applyAction, applyEntityAction, applyLessonAction, buildReviewPrompt, clipWords, provenanceTag, ENTITY_EMOJI };
679
+ const defaultReviewer = createPackReviewer();
680
+
681
+ module.exports = {
682
+ ENTITY_EMOJI,
683
+ REVIEW_OUTPUT_SCHEMA,
684
+ applyAction,
685
+ applyDecisionBatch,
686
+ applyEntityAction,
687
+ applyLessonAction,
688
+ buildReviewPrompt,
689
+ clipWords,
690
+ contentVersion,
691
+ createPackReviewer,
692
+ drainReviews: defaultReviewer.drainReviews,
693
+ parseDecision,
694
+ provenanceTag,
695
+ reviewTurn: defaultReviewer.reviewTurn,
696
+ shouldSkipReview,
697
+ snapshotMemoryVersions,
698
+ stopReviewAdmission: defaultReviewer.stopAdmission,
699
+ };
package/core/packs.js CHANGED
@@ -11,6 +11,7 @@
11
11
 
12
12
  const fs = require("fs");
13
13
  const path = require("path");
14
+ const { atomicWriteFileSync } = require("./fsutil");
14
15
 
15
16
  let DatabaseSync = null;
16
17
  try { ({ DatabaseSync } = require("node:sqlite")); } catch (e) { /* old node — matching disabled */ }
@@ -246,7 +247,7 @@ function writePack(pack) {
246
247
  ensureDir();
247
248
  const dir = path.join(PACKS_DIR, pack.dir);
248
249
  fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
249
- fs.writeFileSync(packFile(pack.dir), serialize(pack), { mode: 0o600 });
250
+ atomicWriteFileSync(packFile(pack.dir), serialize(pack), { mode: 0o600, backup: true });
250
251
  markIndexDirty();
251
252
  return pack.dir;
252
253
  }
@@ -313,7 +314,7 @@ function setProvenance(dir, sectionNames, origin) {
313
314
  cur.ts = new Date().toISOString();
314
315
  try {
315
316
  fs.mkdirSync(path.join(PACKS_DIR, dir), { recursive: true, mode: 0o700 });
316
- fs.writeFileSync(provFile(dir), JSON.stringify(cur, null, 2), { mode: 0o600 });
317
+ atomicWriteFileSync(provFile(dir), JSON.stringify(cur, null, 2), { mode: 0o600, backup: true });
317
318
  } catch (e) {}
318
319
  }
319
320
 
@@ -465,7 +466,7 @@ function archivePack(nameOrDir) {
465
466
  ensureDir();
466
467
  fs.mkdirSync(archiveRoot(), { recursive: true, mode: 0o700 });
467
468
  pack.archived = new Date().toISOString();
468
- try { fs.writeFileSync(packFile(pack.dir), serialize(pack), { mode: 0o600 }); } catch (e) {}
469
+ try { atomicWriteFileSync(packFile(pack.dir), serialize(pack), { mode: 0o600, backup: true }); } catch (e) {}
469
470
  const dest = path.join(archiveRoot(), pack.dir);
470
471
  fs.rmSync(dest, { recursive: true, force: true });
471
472
  fs.renameSync(path.join(PACKS_DIR, pack.dir), dest);
package/core/persona.js CHANGED
@@ -7,6 +7,7 @@
7
7
 
8
8
  const fs = require("fs");
9
9
  const path = require("path");
10
+ const { atomicWriteFileSync } = require("./fsutil");
10
11
 
11
12
  const CONFIG_DIR = require("../config-dir");
12
13
  const PERSONA_FILE = process.env.PERSONA_FILE ? path.resolve(process.env.PERSONA_FILE) : path.join(CONFIG_DIR, "persona.md");
@@ -40,7 +41,7 @@ function savePersona(text) {
40
41
  throw new Error(`persona must be ${MIN_PERSONA_CHARS}-${MAX_PERSONA_CHARS} chars (got ${t.length})`);
41
42
  }
42
43
  fs.mkdirSync(path.dirname(PERSONA_FILE), { recursive: true, mode: 0o700 });
43
- fs.writeFileSync(PERSONA_FILE, t + "\n", { mode: 0o600 });
44
+ atomicWriteFileSync(PERSONA_FILE, t + "\n", { mode: 0o600, backup: true });
44
45
  return PERSONA_FILE;
45
46
  }
46
47
 
@@ -28,13 +28,30 @@ function descendantPids(rootPid) {
28
28
  }
29
29
 
30
30
  function killProcessTree(rootPid, signal = "SIGTERM") {
31
- if (!rootPid) return;
31
+ if (!rootPid) return [];
32
32
  const descendants = descendantPids(rootPid);
33
33
  try { process.kill(-rootPid, signal); } catch (e) {}
34
34
  try { process.kill(rootPid, signal); } catch (e) {}
35
35
  for (const pid of descendants) {
36
36
  try { process.kill(pid, signal); } catch (e) {}
37
37
  }
38
+ return descendants;
38
39
  }
39
40
 
40
- module.exports = { descendantPids, killProcessTree };
41
+ function pidExists(pid) {
42
+ try { process.kill(pid, 0); return true; }
43
+ catch (error) { return error.code === "EPERM"; }
44
+ }
45
+
46
+ async function waitForPidsExit(pids, timeoutMs = 1000) {
47
+ const pending = new Set((pids || []).map(Number).filter((pid) => Number.isSafeInteger(pid) && pid > 0));
48
+ const deadline = Date.now() + Math.max(0, Number(timeoutMs) || 0);
49
+ while (pending.size) {
50
+ for (const pid of pending) if (!pidExists(pid)) pending.delete(pid);
51
+ if (!pending.size || Date.now() >= deadline) break;
52
+ await new Promise((resolve) => setTimeout(resolve, 10));
53
+ }
54
+ return [...pending];
55
+ }
56
+
57
+ module.exports = { descendantPids, killProcessTree, waitForPidsExit };
@@ -0,0 +1,39 @@
1
+ "use strict";
2
+
3
+ const {
4
+ defaultMigrationSources,
5
+ ensureMigrationSnapshot,
6
+ requireProviderRemovalReady,
7
+ } = require("./migration-backup");
8
+
9
+ function completeProviderMigration(options = {}) {
10
+ const runtime = options.runtime || require("./config");
11
+ const configDir = options.configDir || runtime.CONFIG_DIR;
12
+ const sources = options.sources || defaultMigrationSources({
13
+ configDir,
14
+ stateFile: options.stateFile || runtime.STATE_FILE,
15
+ sessionsFile: options.sessionsFile || runtime.SESSIONS_FILE,
16
+ jobsFile: options.jobsFile || runtime.JOBS_FILE,
17
+ cronsFile: options.cronsFile || runtime.CRONS_FILE,
18
+ });
19
+ const snapshot = options.snapshot || ensureMigrationSnapshot({ configDir, sources });
20
+
21
+ // Load persistence modules only after the byte-for-byte rollback snapshot is
22
+ // durable. Each save owns its prepare -> atomic write -> verify -> activate
23
+ // cycle, so an interrupted run resumes safely on the next startup.
24
+ const state = options.stateApi || require("./state");
25
+ const jobs = options.jobsApi || require("./jobs");
26
+ state.saveState({ strict: true });
27
+ state.saveSessions(state.loadSessions(), { strict: true });
28
+ jobs.ensureMigrated({ strict: true });
29
+
30
+ const ready = requireProviderRemovalReady({ configDir, sources });
31
+ return {
32
+ snapshotDir: ready.snapshotDir,
33
+ stage: ready.journal.stage,
34
+ components: ready.journal.components,
35
+ reusedSnapshot: snapshot.reused === true,
36
+ };
37
+ }
38
+
39
+ module.exports = { completeProviderMigration };
@@ -0,0 +1,80 @@
1
+ "use strict";
2
+
3
+ const config = require("./config");
4
+ const defaultRegistry = require("./providers");
5
+
6
+ function recoveryFor(provider, status) {
7
+ if (status.availability.state === "unconfigured") {
8
+ return `Install ${provider.label}, or configure ${provider.id === "claude" ? "CLAUDE_PATH" : "CODEX_PATH"}.`;
9
+ }
10
+ if (status.availability.state === "missing") {
11
+ return `Fix ${provider.id === "claude" ? "CLAUDE_PATH" : "CODEX_PATH"}, or remove it to use PATH discovery.`;
12
+ }
13
+ if (status.compatibility.state === "incompatible") {
14
+ return `Update ${provider.label}: ${status.compatibility.reason || "the installed CLI is incompatible"}`;
15
+ }
16
+ if (status.compatibility.state !== "compatible") {
17
+ return `Could not confirm ${provider.label} compatibility: ${status.compatibility.reason || "local probe failed"}`;
18
+ }
19
+ if (status.auth.state === "unauthenticated") return provider.authHelp();
20
+ if (status.auth.state === "unknown") {
21
+ return `Could not confirm ${provider.label} authentication: ${status.auth.reason || "local status probe failed"}`;
22
+ }
23
+ return null;
24
+ }
25
+
26
+ function inspectProviderConfiguration(options = {}) {
27
+ const registry = options.registry || defaultRegistry;
28
+ const preferred = options.defaultProvider === undefined
29
+ ? config.DEFAULT_PROVIDER
30
+ : options.defaultProvider;
31
+ const executableStatus = options.executableStatus || ((providerId) => (
32
+ config.providerExecutableStatus(providerId, options.env || process.env)
33
+ ));
34
+ const probeAuth = options.probeAuth !== false;
35
+ const providers = registry.listProviders().map((provider) => {
36
+ const executable = executableStatus(provider.id);
37
+ if (executable.state !== "available") {
38
+ const status = {
39
+ id: provider.id,
40
+ label: provider.label,
41
+ configured: executable.configured === true,
42
+ availability: {
43
+ state: executable.state,
44
+ executable: null,
45
+ reason: executable.reason,
46
+ },
47
+ compatibility: { state: "not_checked", version: null, reason: "Provider executable is unavailable" },
48
+ auth: { state: "not_checked", reason: "Provider executable is unavailable" },
49
+ };
50
+ return { ...status, recovery: recoveryFor(provider, status) };
51
+ }
52
+ const registryStatus = registry.providerStatus(provider.id, { probeAuth });
53
+ const status = {
54
+ ...registryStatus,
55
+ configured: executable.configured === true,
56
+ availability: {
57
+ ...registryStatus.availability,
58
+ executable: executable.executable || registryStatus.availability.executable,
59
+ },
60
+ };
61
+ return { ...status, recovery: recoveryFor(provider, status) };
62
+ });
63
+ const selected = registry.selectDefaultProvider(preferred);
64
+ const selectedProvider = selected.providerId
65
+ ? providers.find((provider) => provider.id === selected.providerId)
66
+ : null;
67
+ return {
68
+ providers,
69
+ defaultProvider: {
70
+ ...selected,
71
+ label: selectedProvider?.label || null,
72
+ configuredValue: preferred || null,
73
+ },
74
+ };
75
+ }
76
+
77
+ module.exports = {
78
+ inspectProviderConfiguration,
79
+ recoveryFor,
80
+ };