@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,7 @@
6
6
  // 1-2 hops; nodes over the firing threshold join the candidate set. This
7
7
  // auto-pulls governed-by concerns (theme, commit style) the query never
8
8
  // named, which is the whole point of the graph.
9
- // 4. Walker (haiku) — reads the seed+activated candidates' live sections and
9
+ // 4. Walker (provider-owned low tier) — reads the candidates' live sections and
10
10
  // returns the truly-relevant set WITH a one-line "why" per node. Facts are
11
11
  // quoted, not paraphrased. Fail-open: any error keeps the seeds (classic
12
12
  // baseline), so the discoverer never recalls worse than keyword matching.
@@ -18,7 +18,8 @@
18
18
  const graph = require("./graph");
19
19
  const metrics = require("./metrics");
20
20
  const tuning = require("./tuning");
21
- const { spawnSubagent } = require("../subagent");
21
+ const { spawnUtilityAgent } = require("../utility-agent");
22
+ const { resolveUtilityProvider } = require("../providers/utility-policy");
22
23
  const warmWalker = require("./warm-walker");
23
24
  const toolsLib = require("../tools");
24
25
  // Tool-graph is the directed-usage source of truth (pack↔tool edges). The
@@ -90,7 +91,6 @@ function renderToolGaps(gaps) {
90
91
  let transcriptIndex = null;
91
92
  try { transcriptIndex = require("../transcript-index"); } catch (e) { /* old node */ }
92
93
 
93
- const WALKER_MODEL = process.env.RECALL_DISCOVERER_MODEL || "haiku";
94
94
  const WALKER_TIMEOUT_MS = Number(process.env.RECALL_DISCOVERER_TIMEOUT_MS || 25000);
95
95
  const WALKER_ENABLED = String(process.env.RECALL_DISCOVERER_WALKER || "on").toLowerCase() !== "off";
96
96
  // Sizing/tier knobs (walker candidate cap, episode limit, seeds-tier width,
@@ -222,23 +222,71 @@ const WALKER_SYSTEM = [
222
222
  // low latency; on any warm-path error fall back to a cold spawn — identical
223
223
  // model/prompt/contract, so quality is unchanged and recall never silently
224
224
  // degrades to the classic engine. Returns { text, costUsd, source }.
225
- async function runWalker(prompt) {
226
- const opts = { model: WALKER_MODEL, systemPrompt: WALKER_SYSTEM, timeoutMs: WALKER_TIMEOUT_MS };
227
- if (warmWalker.isEnabled()) {
225
+ async function runWalker(prompt, options = {}) {
226
+ const resolveSelection = options.resolveUtilityProvider || resolveUtilityProvider;
227
+ const runUtility = options.spawnUtilityAgent || spawnUtilityAgent;
228
+ const warm = options.warmWalker || warmWalker;
229
+ const selection = resolveSelection({
230
+ purpose: "recall",
231
+ activeProvider: options.provider || null,
232
+ tier: "low",
233
+ model: options.model || null,
234
+ modelEnvPrefix: "RECALL_DISCOVERER_MODEL",
235
+ legacyClaudeModelEnv: "RECALL_DISCOVERER_MODEL",
236
+ });
237
+ const opts = {
238
+ provider: selection.provider,
239
+ model: selection.model,
240
+ tier: "low",
241
+ systemPrompt: WALKER_SYSTEM,
242
+ timeoutMs: WALKER_TIMEOUT_MS,
243
+ };
244
+ if (warm.isEnabled(selection.provider)) {
228
245
  try {
229
- const r = await warmWalker.walkWarm(prompt, opts);
230
- return { text: r.text, costUsd: r.costUsd, source: "warm" };
246
+ const result = await warm.walkWarm(prompt, opts);
247
+ return {
248
+ text: result.text,
249
+ costUsd: result.costUsd,
250
+ source: "warm",
251
+ provider: selection.providerId,
252
+ model: selection.model,
253
+ };
231
254
  } catch (e) { /* fall back to cold spawn below */ }
232
255
  }
233
- const { text } = await spawnSubagent(prompt, opts);
234
- return { text, costUsd: null, source: "cold" };
256
+ const result = await runUtility(prompt, {
257
+ purpose: "recall",
258
+ provider: selection.providerId,
259
+ tier: "low",
260
+ model: selection.model,
261
+ modelEnvPrefix: "RECALL_DISCOVERER_MODEL",
262
+ legacyClaudeModelEnv: "RECALL_DISCOVERER_MODEL",
263
+ systemPrompt: WALKER_SYSTEM,
264
+ readOnly: true,
265
+ outputSchema: {
266
+ type: "array",
267
+ items: {
268
+ type: "object",
269
+ required: ["id"],
270
+ properties: { id: { type: "string" }, why: { type: "string" } },
271
+ },
272
+ },
273
+ timeoutMs: WALKER_TIMEOUT_MS,
274
+ cwd: options.cwd || process.cwd(),
275
+ });
276
+ return {
277
+ text: result.text,
278
+ costUsd: null,
279
+ source: "cold",
280
+ provider: result.provider || selection.providerId,
281
+ model: result.model || selection.model,
282
+ };
235
283
  }
236
284
 
237
285
  // Judge candidates. Returns { kept: Map|null, costUsd, source, walkerMs } —
238
286
  // kept null means fail-open (walker off/failed/unparseable).
239
- async function walk(userText, contextText, candidates) {
287
+ async function walk(userText, contextText, candidates, options = {}) {
240
288
  if (!WALKER_ENABLED || candidates.length === 0) {
241
- return { kept: null, costUsd: null, source: "off", walkerMs: 0 };
289
+ return { kept: null, costUsd: null, source: "off", walkerMs: 0, provider: options.provider || null, model: null };
242
290
  }
243
291
  const lines = candidates.map((c) => {
244
292
  const tag = c.activated ? ` [linked via ${c.via || "graph"}]` : "";
@@ -256,12 +304,12 @@ async function walk(userText, contextText, candidates) {
256
304
  ].join("\n");
257
305
  const t0 = Date.now();
258
306
  try {
259
- const { text, costUsd, source } = await runWalker(prompt);
307
+ const { text, costUsd, source, provider, model } = await runWalker(prompt, options);
260
308
  const walkerMs = Date.now() - t0;
261
309
  const match = String(text || "").match(/\[[\s\S]*\]/);
262
- if (!match) return { kept: null, costUsd, source, walkerMs };
310
+ if (!match) return { kept: null, costUsd, source, walkerMs, provider, model };
263
311
  const arr = JSON.parse(match[0]);
264
- if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs };
312
+ if (!Array.isArray(arr)) return { kept: null, costUsd, source, walkerMs, provider, model };
265
313
  const known = new Set(candidates.map((c) => c.id));
266
314
  const out = new Map();
267
315
  for (const item of arr) {
@@ -269,14 +317,14 @@ async function walk(userText, contextText, candidates) {
269
317
  out.set(item.id, String(item.why || "").slice(0, 120));
270
318
  }
271
319
  }
272
- return { kept: out, costUsd, source, walkerMs };
320
+ return { kept: out, costUsd, source, walkerMs, provider, model };
273
321
  } catch (e) {
274
- return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0 };
322
+ return { kept: null, costUsd: null, source: "failed", walkerMs: Date.now() - t0, provider: options.provider || null, model: null };
275
323
  }
276
324
  }
277
325
 
278
326
  async function run(ctx) {
279
- const { userText, contextText, fullContext, packLimit, budget, helpers } = ctx;
327
+ const { userText, contextText, fullContext, packLimit, budget, helpers, provider } = ctx;
280
328
  const { packsLib, entitiesLib, mergeMatches, buildPackBlock, buildEntityBlock } = helpers;
281
329
  const started = Date.now();
282
330
 
@@ -314,7 +362,7 @@ async function run(ctx) {
314
362
  // 1: tiered pre-gate.
315
363
  const tier = recallTier(userText, seedCount);
316
364
  if (tier === "skip") {
317
- metrics.logTurn({ engine: "discoverer", model: WALKER_MODEL, query: userText, gated: true, tier, latencyMs: Date.now() - started });
365
+ metrics.logTurn({ engine: "discoverer", provider: provider || "", model: "", query: userText, gated: true, tier, latencyMs: Date.now() - started });
318
366
  return { packBlock: "", entityBlock: "", toolBlock: "", episodeBlock: "", packMatches: [], entityMatches: [], toolMatches: [], toolGaps: [], episodeMatches: [], why: {}, gated: true, tier };
319
367
  }
320
368
 
@@ -453,7 +501,7 @@ async function run(ctx) {
453
501
  }
454
502
  }
455
503
 
456
- const walkRes = await walk(userText, contextText || fullContext, judgeCands);
504
+ const walkRes = await walk(userText, contextText || fullContext, judgeCands, { provider: provider || null });
457
505
  const whyById = walkRes.kept;
458
506
 
459
507
  let shadowChecks = 0, shadowAgree = 0;
@@ -576,7 +624,8 @@ async function run(ctx) {
576
624
 
577
625
  metrics.logTurn({
578
626
  engine: "discoverer",
579
- model: WALKER_MODEL,
627
+ provider: walkRes.provider || provider || "",
628
+ model: walkRes.model || "",
580
629
  query: userText,
581
630
  tier,
582
631
  seeds: seedNodes,
@@ -602,4 +651,4 @@ async function run(ctx) {
602
651
  };
603
652
  }
604
653
 
605
- module.exports = { name: "discoverer", run, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
654
+ module.exports = { name: "discoverer", run, runWalker, needsRecall, recallTier, walk, cascadeVerdict, scoutToolGaps, renderToolGaps, episodesAllowedForSpeaker };
@@ -33,6 +33,7 @@ function logTurn(entry) {
33
33
  const rec = {
34
34
  ts: new Date().toISOString(),
35
35
  v: VERSION,
36
+ provider: entry.provider || "",
36
37
  model: entry.model || "",
37
38
  engine: entry.engine || "discoverer",
38
39
  query: String(entry.query || "").slice(0, 200),
@@ -106,6 +107,31 @@ function logReviewerSkip() {
106
107
  } catch (e) {}
107
108
  }
108
109
 
110
+ function logReviewerRun(entry = {}) {
111
+ if (!enabled()) return;
112
+ try {
113
+ const rec = {
114
+ ts: new Date().toISOString(),
115
+ v: VERSION,
116
+ reviewer: {
117
+ provider: String(entry.provider || ""),
118
+ model: String(entry.model || ""),
119
+ status: String(entry.status || "unknown"),
120
+ fallbackUsed: !!entry.fallbackUsed,
121
+ ...(entry.code ? { code: String(entry.code).slice(0, 80) } : {}),
122
+ },
123
+ };
124
+ fs.appendFileSync(LOG_FILE, JSON.stringify(rec) + "\n");
125
+ const summary = readSummary();
126
+ summary.reviewerRuns = (summary.reviewerRuns || 0) + 1;
127
+ const key = `${rec.reviewer.provider || "unknown"}:${rec.reviewer.model || "default"}`;
128
+ summary.reviewerByProviderModel = summary.reviewerByProviderModel || {};
129
+ summary.reviewerByProviderModel[key] = (summary.reviewerByProviderModel[key] || 0) + 1;
130
+ if (rec.reviewer.status === "failed") summary.reviewerFailures = (summary.reviewerFailures || 0) + 1;
131
+ writeSummary(summary);
132
+ } catch (_) {}
133
+ }
134
+
109
135
  function round(n) { return Math.round((Number(n) || 0) * 1000) / 1000; }
110
136
  function today() { return new Date().toISOString().slice(0, 10); }
111
137
 
@@ -311,6 +337,6 @@ function _resetForTest() {
311
337
 
312
338
  module.exports = {
313
339
  LOG_FILE, SUMMARY_FILE, NODE_STATS_FILE, KPI_FILE, VERSION, enabled,
314
- logTurn, logUse, logShadow, logReviewerSkip, appendKpi, readKpi,
340
+ logTurn, logUse, logShadow, logReviewerSkip, logReviewerRun, appendKpi, readKpi,
315
341
  summary, nodeStats, evidence, _resetForTest,
316
342
  };
@@ -113,7 +113,10 @@ function lastChange() {
113
113
  function history() { return readFile().history || []; }
114
114
 
115
115
  function write(data) {
116
- try { fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2)); } catch (e) {}
116
+ try {
117
+ fs.mkdirSync(path.dirname(TUNING_FILE), { recursive: true });
118
+ fs.writeFileSync(TUNING_FILE, JSON.stringify(data, null, 2));
119
+ } catch (e) {}
117
120
  _cache = null; _cacheMtime = -1;
118
121
  }
119
122
 
@@ -1,162 +1,205 @@
1
- // Warm walker: one long-lived `claude` process the discoverer reuses across
2
- // turns, instead of cold-spawning a fresh CLI for every recall.
3
- //
4
- // Why this exists: the Claude CLI initialises lazily on the FIRST message, so
5
- // a cold spawn pays ~3-16s of boot+init every turn. Measured: on a persistent
6
- // stream-json process the first message costs ~3.4s but every message after is
7
- // ~1.4s — init is paid once and amortised. Same model, same system prompt,
8
- // same JSON contract as the cold path, so recall quality is unchanged; only
9
- // the transport differs.
10
- //
11
- // Statelessness: stream-json keeps one growing session, so we recycle the
12
- // process on a small message/char budget to keep accumulated history tiny and
13
- // each walk effectively independent.
14
- //
15
- // Fail-open: any error here throws, and the caller (discoverer.walk) falls
16
- // back to the cold spawnSubagent path — never worse than before, and never a
17
- // drop to the classic engine.
1
+ "use strict";
2
+
3
+ // Optional low-latency transport for providers that explicitly advertise a
4
+ // persistent utility stream. The current native implementation is Claude's
5
+ // stream-json input protocol; Codex declares this capability unsupported and
6
+ // therefore always uses the identical cold utility prompt/contract.
18
7
 
19
8
  const { spawn } = require("child_process");
20
- const { CLAUDE_PATH, botSubprocessEnv } = require("../config");
21
- const { claudeSubprocessEnv } = require("../auth-flow");
9
+ const { StringDecoder } = require("string_decoder");
10
+ const { killProcessTree } = require("../process-tree");
22
11
  const { redactSensitive } = require("../redact");
23
12
 
24
13
  const MAX_MSGS = Number(process.env.RECALL_WARM_WALKER_MAX_MSGS || 12);
25
14
  const MAX_CHARS = Number(process.env.RECALL_WARM_WALKER_MAX_CHARS || 24000);
26
15
  const DEFAULT_TIMEOUT_MS = 25000;
16
+ const KILL_GRACE_MS = Number(process.env.RECALL_WARM_WALKER_KILL_GRACE_MS || 250);
17
+ const MAX_BUFFER_BYTES = Number(process.env.RECALL_WARM_WALKER_MAX_BUFFER_BYTES || 1024 * 1024);
18
+
19
+ let child = null;
20
+ let childProviderId = null;
21
+ let pending = null;
22
+ let chain = Promise.resolve();
23
+ let msgCount = 0;
24
+ let charCount = 0;
25
+ let costTotal = 0;
26
+ let stopPromise = null;
27
27
 
28
- let child = null; // current process, or null when none is alive
29
- let pending = null; // { resolve, reject, timer } for the in-flight walk
30
- let chain = Promise.resolve(); // serialises walks (one message in flight)
31
- let msgCount = 0; // messages sent to the current process
32
- let charCount = 0; // prompt chars sent to the current process
33
- let costTotal = 0; // cumulative total_cost_usd reported by the session
28
+ function isEnabled(provider) {
29
+ if (String(process.env.RECALL_WARM_WALKER || "on").toLowerCase() === "off") return false;
30
+ return provider?.id === "claude"
31
+ && provider.capabilities?.persistentUtilityStream?.support === "native";
32
+ }
34
33
 
35
- function isEnabled() {
36
- return String(process.env.RECALL_WARM_WALKER || "on").toLowerCase() !== "off";
34
+ function settle(kind, value) {
35
+ const current = pending;
36
+ pending = null;
37
+ if (!current) return;
38
+ clearTimeout(current.timer);
39
+ if (kind === "resolve") current.resolve(value);
40
+ else current.reject(value);
37
41
  }
38
42
 
39
- function killChild() {
43
+ function stopChild(reason = "warm walker stopped") {
44
+ if (stopPromise) return stopPromise;
40
45
  const proc = child;
41
46
  child = null;
42
- if (proc) {
43
- try { proc.stdin.end(); } catch (e) {}
44
- try { proc.kill("SIGTERM"); } catch (e) {}
45
- }
47
+ childProviderId = null;
48
+ if (!proc) return Promise.resolve();
49
+ stopPromise = new Promise((resolve) => {
50
+ let done = false;
51
+ let killTimer = null;
52
+ const finish = () => {
53
+ if (done) return;
54
+ done = true;
55
+ if (killTimer) clearTimeout(killTimer);
56
+ stopPromise = null;
57
+ resolve();
58
+ };
59
+ proc.once("close", finish);
60
+ try { proc.stdin.end(); } catch (_) {}
61
+ try { killProcessTree(proc.pid, "SIGTERM"); } catch (_) { try { proc.kill("SIGTERM"); } catch (_) {} }
62
+ killTimer = setTimeout(() => {
63
+ try { killProcessTree(proc.pid, "SIGKILL"); } catch (_) { try { proc.kill("SIGKILL"); } catch (_) {} }
64
+ }, Math.max(1, KILL_GRACE_MS));
65
+ if (proc.exitCode !== null || proc.signalCode !== null) finish();
66
+ });
67
+ settle("reject", new Error(reason));
68
+ return stopPromise;
69
+ }
70
+
71
+ function persistentInvocation(cfg) {
72
+ const provider = cfg.provider;
73
+ if (!isEnabled(provider)) throw new Error(`${provider?.id || "provider"} has no enabled persistent utility stream`);
74
+ const invocation = provider.buildUtilityInvocation({
75
+ purpose: "recall",
76
+ tier: "low",
77
+ model: cfg.model || provider.tierModel("low"),
78
+ coreInstructions: cfg.systemPrompt || "You are Open Claudia's recall walker.",
79
+ dynamicContext: "",
80
+ userPrompt: "",
81
+ readOnly: true,
82
+ fresh: true,
83
+ });
84
+ const args = [...invocation.args];
85
+ const printAt = args.indexOf("-p");
86
+ args.splice(printAt >= 0 ? printAt + 1 : 0, 0, "--input-format", "stream-json");
87
+ return { ...invocation, args, stdin: null };
46
88
  }
47
89
 
48
90
  function spawnChild(cfg) {
49
- const args = [
50
- "-p",
51
- "--input-format", "stream-json",
52
- "--output-format", "stream-json",
53
- "--verbose",
54
- "--model", cfg.model || "haiku",
55
- "--no-session-persistence",
56
- "--append-system-prompt", cfg.systemPrompt || "",
57
- "--dangerously-skip-permissions",
58
- ];
59
- const env = { ...botSubprocessEnv(), ...claudeSubprocessEnv() };
60
- const proc = spawn(CLAUDE_PATH, args, { cwd: process.cwd(), env, stdio: ["pipe", "pipe", "pipe"] });
91
+ const invocation = persistentInvocation(cfg);
92
+ const proc = spawn(invocation.binary, invocation.args, {
93
+ cwd: cfg.cwd || process.cwd(),
94
+ env: {
95
+ ...invocation.env,
96
+ OC_PROVIDER: cfg.provider.id,
97
+ OC_UTILITY_PURPOSE: "recall",
98
+ },
99
+ stdio: ["pipe", "pipe", "pipe"],
100
+ detached: process.platform !== "win32",
101
+ shell: false,
102
+ });
61
103
  child = proc;
104
+ childProviderId = cfg.provider.id;
62
105
  msgCount = 0;
63
106
  charCount = 0;
64
107
  costTotal = 0;
65
- let buf = "";
66
- let asstText = "";
67
-
68
- const settle = (fn, arg) => {
69
- const p = pending;
70
- pending = null;
71
- if (!p) return;
72
- clearTimeout(p.timer);
73
- fn === "resolve" ? p.resolve(arg) : p.reject(arg);
74
- };
75
-
76
- proc.stdout.on("data", (d) => {
108
+ let buffer = "";
109
+ let assistantText = "";
110
+ const stringDecoder = new StringDecoder("utf8");
111
+
112
+ proc.stdout.on("data", (data) => {
77
113
  if (child !== proc) return;
78
- buf += d.toString();
79
- let nl;
80
- while ((nl = buf.indexOf("\n")) >= 0) {
81
- const line = buf.slice(0, nl).trim();
82
- buf = buf.slice(nl + 1);
114
+ buffer += stringDecoder.write(data);
115
+ if (Buffer.byteLength(buffer, "utf8") > MAX_BUFFER_BYTES) {
116
+ stopChild("warm walker output exceeded its buffer limit").catch(() => {});
117
+ return;
118
+ }
119
+ let newline;
120
+ while ((newline = buffer.indexOf("\n")) >= 0) {
121
+ const line = buffer.slice(0, newline).trim();
122
+ buffer = buffer.slice(newline + 1);
83
123
  if (!line) continue;
84
- let evt;
85
- try { evt = JSON.parse(line); } catch (e) { continue; }
86
- if (evt.type === "assistant" && evt.message && Array.isArray(evt.message.content)) {
87
- for (const b of evt.message.content) {
88
- if (b && b.type === "text" && typeof b.text === "string") asstText += b.text;
124
+ let event;
125
+ try { event = JSON.parse(line); } catch (_) { continue; }
126
+ if (event.type === "assistant" && Array.isArray(event.message?.content)) {
127
+ for (const block of event.message.content) {
128
+ if (block?.type === "text" && typeof block.text === "string") assistantText += block.text;
89
129
  }
90
- } else if (evt.type === "result") {
91
- const text = (typeof evt.result === "string" && evt.result) ? evt.result : asstText;
92
- asstText = "";
93
- // total_cost_usd is cumulative for the stream-json session; the delta
94
- // from the previous result is this walk's cost.
130
+ } else if (event.type === "result") {
131
+ const text = typeof event.result === "string" && event.result ? event.result : assistantText;
132
+ assistantText = "";
95
133
  let costUsd = null;
96
- if (typeof evt.total_cost_usd === "number") {
97
- costUsd = Math.max(0, evt.total_cost_usd - costTotal);
98
- costTotal = evt.total_cost_usd;
134
+ if (typeof event.total_cost_usd === "number") {
135
+ costUsd = Math.max(0, event.total_cost_usd - costTotal);
136
+ costTotal = event.total_cost_usd;
99
137
  }
100
- if (text && !evt.is_error) settle("resolve", { text: redactSensitive(String(text).trim()), costUsd });
101
- else settle("reject", new Error("warm walker: empty/error result"));
138
+ if (text && !event.is_error) settle("resolve", { text: redactSensitive(String(text).trim()), costUsd });
139
+ else settle("reject", new Error("warm walker returned an empty or error result"));
102
140
  }
103
141
  }
104
142
  });
105
- proc.stderr.on("data", () => {}); // surfaced via result/close, not needed here
106
- proc.on("error", (e) => {
107
- if (child === proc) child = null;
108
- settle("reject", e);
143
+ proc.stderr.on("data", () => {});
144
+ proc.stdin.on("error", (error) => settle("reject", error));
145
+ proc.on("error", (error) => {
146
+ if (child === proc) { child = null; childProviderId = null; }
147
+ settle("reject", error);
109
148
  });
110
149
  proc.on("close", () => {
111
- if (child === proc) child = null;
112
- settle("reject", new Error("warm walker: process closed"));
150
+ if (child === proc) { child = null; childProviderId = null; }
151
+ settle("reject", new Error("warm walker process closed"));
113
152
  });
114
153
  return proc;
115
154
  }
116
155
 
117
- function doWalk(promptText, opts) {
118
- // Recycle before sending if the current process is over its budget, so each
119
- // walk runs against a small, near-stateless context.
120
- if (child && (msgCount >= MAX_MSGS || charCount >= MAX_CHARS)) killChild();
121
- if (!child) spawnChild({ systemPrompt: opts.systemPrompt, model: opts.model });
156
+ async function doWalk(promptText, opts) {
157
+ if (!isEnabled(opts.provider)) throw new Error(`${opts.provider?.id || "provider"} persistent recall is unavailable`);
158
+ if (child && (childProviderId !== opts.provider.id || msgCount >= MAX_MSGS || charCount >= MAX_CHARS)) {
159
+ await stopChild("warm walker recycled");
160
+ }
161
+ if (!child) spawnChild(opts);
122
162
  const proc = child;
123
163
  const timeoutMs = opts.timeoutMs || DEFAULT_TIMEOUT_MS;
124
-
125
164
  return new Promise((resolve, reject) => {
126
- const timer = setTimeout(() => {
165
+ const timer = setTimeout(async () => {
166
+ if (pending?.proc !== proc) return;
167
+ const current = pending;
127
168
  pending = null;
128
- if (child === proc) killChild(); // process is wedged — drop it
129
- reject(new Error("warm walker: timeout"));
169
+ await stopChild("warm walker timed out");
170
+ current.reject(new Error("warm walker timed out"));
130
171
  }, timeoutMs);
131
- pending = { resolve, reject, timer };
132
- msgCount++;
172
+ pending = { resolve, reject, timer, proc };
173
+ msgCount += 1;
133
174
  charCount += promptText.length;
134
175
  const payload = JSON.stringify({ type: "user", message: { role: "user", content: promptText } }) + "\n";
135
- try {
136
- proc.stdin.write(payload);
137
- } catch (e) {
176
+ try { proc.stdin.write(payload); }
177
+ catch (error) {
138
178
  clearTimeout(timer);
139
179
  pending = null;
140
- killChild();
141
- reject(e);
180
+ stopChild("warm walker write failed").finally(() => reject(error));
142
181
  }
143
182
  });
144
183
  }
145
184
 
146
- // Serialise: one message in flight at a time. A failed walk must not poison the
147
- // queue, so the chain swallows outcomes while callers still see their result.
148
- // Resolves { text, costUsd } — costUsd is the per-walk delta of the session's
149
- // cumulative total_cost_usd (null when the CLI doesn't report it).
150
185
  function walkWarm(promptText, opts = {}) {
151
186
  const run = () => doWalk(promptText, opts);
152
- const p = chain.then(run, run);
153
- chain = p.then(() => {}, () => {});
154
- return p;
187
+ const result = chain.then(run, run);
188
+ chain = result.then(() => {}, () => {});
189
+ return result;
190
+ }
191
+
192
+ async function shutdown() {
193
+ await stopChild("warm walker shutdown");
155
194
  }
156
195
 
157
- function shutdown() { killChild(); }
158
- function stats() { return { alive: !!child, msgCount, charCount, MAX_MSGS, MAX_CHARS }; }
196
+ function stats() {
197
+ return { alive: !!child, providerId: childProviderId, msgCount, charCount, MAX_MSGS, MAX_CHARS };
198
+ }
159
199
 
160
- process.on("exit", () => { try { killChild(); } catch (e) {} });
200
+ process.on("exit", () => {
201
+ if (!child) return;
202
+ try { killProcessTree(child.pid, "SIGKILL"); } catch (_) {}
203
+ });
161
204
 
162
205
  module.exports = { walkWarm, isEnabled, shutdown, stats };