@inetafrica/open-claudia 3.0.5 → 3.0.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -1,5 +1,10 @@
1
1
  # Changelog
2
2
 
3
+ ## v3.0.6 — cache-bust burn is now a one-line query
4
+
5
+ - **Usage records carry a `coldStart` flag.** The restart→cache-bust burn (each bot restart re-bills the whole accumulated window as cache *writes* instead of ~13×-cheaper cache *reads*) was invisible to every token-count analysis — same token counts, different price class — and took a forensic reconstruction of the 22-day usage history to quantify (~8% of all spend, ~$12/day at its worst). Now the first usage record a process writes for each session is tagged `coldStart: true`: a cold start mid-conversation is the restart fingerprint, so the burn is one jq filter away instead of a modelling exercise. Records already carried `cacheReadTokens`/`cacheCreationTokens`; this adds the missing "was the cache necessarily cold?" dimension.
6
+ - **Agent-space / OpenClaw:** no new deps, no new env, no schema change (additive JSONL field); Docker image builds identically. `usageSessionColdStart` covered by new assertions in `test-usage-accounting.js`.
7
+
3
8
  ## v3.0.5 — the conflict handler owns the poll
4
9
 
5
10
  - **Hiccup heals no longer fight the 409 backoff.** During a 409 conflict streak the conflict handler pauses polling and retries on its own schedule — but a network hiccup (`ECONNRESET`) arriving mid-streak would schedule its own heal, which called `startPolling` straight back into the conflict and reset the streak clock. This was the live v2.15 burn mechanism: its own ghost long-poll after an ECONNRESET heal raised a 409, the old code exited on any 409, launchd respawned it (~1/hr, 208 lifetime startups), and every restart busted the prompt cache so the next turn re-billed the whole accumulated window uncached. Hiccup handling is now a no-op while a conflict streak is active — the conflict handler owns the poll lifecycle.
@@ -66,7 +66,7 @@ class TelegramAdapter {
66
66
  // remaining adapters can boot, and let the independent watchdog recover a
67
67
  // poll that never completes and emits no polling_error.
68
68
  this._pollStartedAt = Date.now();
69
- Promise.resolve(this.bot.startPolling()).catch((error) => {
69
+ Promise.resolve(this.bot.startPolling({ restart: false })).catch((error) => {
70
70
  this._onPollingHiccup(error?.message || "Telegram polling failed to start");
71
71
  });
72
72
  if (!this._pollWatchdogTimer) {
@@ -87,7 +87,7 @@ class TelegramAdapter {
87
87
  if (this._healthyTimer) { clearTimeout(this._healthyTimer); this._healthyTimer = null; }
88
88
  if (this._conflictTimer) { clearTimeout(this._conflictTimer); this._conflictTimer = null; }
89
89
  if (this._conflictClearTimer) { clearTimeout(this._conflictClearTimer); this._conflictClearTimer = null; }
90
- try { await this.bot.stopPolling(); } catch (e) {}
90
+ try { await this._stopPollingClean("adapter stop"); } catch (e) {}
91
91
  }
92
92
 
93
93
  // A transient network error on the long-poll. The network is almost never
@@ -116,6 +116,22 @@ class TelegramAdapter {
116
116
  this._healTimer = setTimeout(() => this._heal(), delay);
117
117
  }
118
118
 
119
+ // stopPolling({cancel:true}) in node-telegram-bot-api LEAKS the poll loop:
120
+ // Bluebird cancellation skips .then/.catch but still runs the chain's
121
+ // .finally, which sees `_abort === false` and reschedules _polling() — so
122
+ // every cancel-stop left a zombie loop behind. Each zombie's getUpdates
123
+ // 409'd the live one, every 409 triggered another cancel-stop/start, and
124
+ // the pile-up (15+ concurrent Telegram sockets on 2026-07-12) presented as
125
+ // a phantom "second poller" that only a process restart cleared. A PLAIN
126
+ // stop sets `_abort = true` before the request settles (no zombie), and
127
+ // destroying the agent forces a hung half-open getUpdates to settle NOW
128
+ // instead of never. The 5s race caps the wait if teardown gets swallowed.
129
+ async _stopPollingClean(reason) {
130
+ const stopped = Promise.resolve(this.bot.stopPolling({ reason })).catch(() => {});
131
+ try { this._agent.destroy(); } catch (e) {}
132
+ await Promise.race([stopped, new Promise((r) => setTimeout(r, 5000))]);
133
+ }
134
+
119
135
  async _heal() {
120
136
  this._healTimer = null;
121
137
  // Backstop only: if polling has been unbroken-wedged for 10 minutes, a
@@ -130,15 +146,11 @@ class TelegramAdapter {
130
146
  process.exit(1);
131
147
  }
132
148
  try {
133
- try {
134
- // `stopPolling()` without cancellation waits for the active getUpdates
135
- // request to settle. A half-open socket may never settle, which wedges
136
- // this recovery routine itself. Abort the request before restarting.
137
- await this.bot.stopPolling({ cancel: true, reason: "stale Telegram long-poll recovery" });
138
- } catch (e) {}
139
- try { this._agent.destroy(); } catch (e) {} // drop any lingering pooled socket
149
+ await this._stopPollingClean("stale Telegram long-poll recovery");
140
150
  await new Promise((r) => setTimeout(r, 500));
141
- await this.bot.startPolling();
151
+ // restart:false — the lib's restart path is another cancel-stop (zombie
152
+ // factory); we already stopped cleanly, so only start if truly idle.
153
+ await this.bot.startPolling({ restart: false });
142
154
  } catch (e) {
143
155
  return; // restart didn't take — the next hiccup reschedules a heal
144
156
  }
@@ -177,11 +189,12 @@ class TelegramAdapter {
177
189
  if (this._conflictTimer) return; // a paused retry is already queued
178
190
  const pauseMs = persisted ? 60000 : 15000;
179
191
  console.error(`409 Conflict: another poller holds this token — pausing ${Math.round(pauseMs / 1000)}s, then retrying (we hold the instance lock; never exiting).`);
180
- // Stop hammering now; the timer below dials back in.
181
- Promise.resolve(this.bot.stopPolling({ cancel: true, reason: "409 conflict backoff" })).catch(() => {});
192
+ // Stop hammering now (clean stop: no zombie loop, ghost socket severed);
193
+ // the timer below dials back in.
194
+ Promise.resolve(this._stopPollingClean("409 conflict backoff")).catch(() => {});
182
195
  this._conflictTimer = setTimeout(async () => {
183
196
  this._conflictTimer = null;
184
- try { await this.bot.startPolling(); } catch (e) {}
197
+ try { await this.bot.startPolling({ restart: false }); } catch (e) {}
185
198
  // No further 409 for 45s ⇒ the other poller is gone; end the streak.
186
199
  this._conflictClearTimer = setTimeout(() => {
187
200
  this._conflictClearTimer = null;
package/core/runner.js CHANGED
@@ -183,6 +183,7 @@ function logTurnUsage(state, usage, costUsd, announce, opts = {}) {
183
183
  billedContextTokens: parts.context,
184
184
  costUsd: typeof costUsd === "number" ? costUsd : 0,
185
185
  };
186
+ if (typeof opts.coldStart === "boolean") record.coldStart = opts.coldStart;
186
187
  if (opts.rawUsage && opts.rawUsage !== usage) {
187
188
  const raw = usageParts(opts.rawUsage, backend);
188
189
  record.rawInputTokens = raw.input;
@@ -775,6 +776,12 @@ function createRunnerService(dependencies = {}) {
775
776
  return immediateFailure(provider, error);
776
777
  }
777
778
 
779
+ // Prompt bundle is final: let the service render pre-spawn debug banners
780
+ // (/recall, /tooltrace) from its recall metadata. Best-effort by contract.
781
+ if (dependencies.onPromptBundle) {
782
+ try { await dependencies.onPromptBundle(bundle, runContext); } catch (e) {}
783
+ }
784
+
778
785
  // Pre-spawn cancel checkpoint: /stop during recall/prompt-build sets a
779
786
  // flag instead of killing a process (there is none yet). Honour it here,
780
787
  // after the slow prompt build and before anything spawns. Persistence
@@ -1425,6 +1432,18 @@ async function persistForegroundTranscript(result, runContext, state = currentSt
1425
1432
  );
1426
1433
  }
1427
1434
 
1435
+ // Sessions this PROCESS has already recorded usage for. The first record per
1436
+ // session since boot is a cold start: the prompt cache is necessarily cold, so
1437
+ // the whole accumulated window re-bills as cache writes (~13x the read rate).
1438
+ // Restart burn is therefore one query away: coldStart=true mid-conversation.
1439
+ const warmUsageSessions = new Set();
1440
+ function usageSessionColdStart(sessionId, warmSet = warmUsageSessions) {
1441
+ if (!sessionId) return true;
1442
+ if (warmSet.has(sessionId)) return false;
1443
+ warmSet.add(sessionId);
1444
+ return true;
1445
+ }
1446
+
1428
1447
  async function persistForegroundUsage(result, runContext, state = currentState(), store = chatContext.getStore()) {
1429
1448
  if (!result.usage) return;
1430
1449
  const previousUsageBySession = structuredClone(state.usageBySession || {});
@@ -1468,6 +1487,7 @@ async function persistForegroundUsage(result, runContext, state = currentState()
1468
1487
  usageScope,
1469
1488
  liveContextTokens,
1470
1489
  rawUsage,
1490
+ coldStart: usageSessionColdStart(usageSessionId),
1471
1491
  strict: true,
1472
1492
  },
1473
1493
  );
@@ -1748,7 +1768,10 @@ function createStreamPreview({ state, store }) {
1748
1768
  function createForegroundService({ state, store, stopTyping, capture, ownsAdmissionLock, persistCapture = true }) {
1749
1769
  const persistEffects = !capture || persistCapture;
1750
1770
  const preview = capture ? null : createStreamPreview({ state, store });
1751
- return createRunnerService({
1771
+ // Chat-visibility + learning-signal layer (skill/pack/tool announcements,
1772
+ // /recall + /tooltrace banners, recall/tool graph feeding). Foreground only.
1773
+ const observer = capture ? null : require("./turn-observer").createTurnObserver({ state, store });
1774
+ const service = createRunnerService({
1752
1775
  getState: () => state,
1753
1776
  getStore: () => store,
1754
1777
  resolveProvider: resolveForegroundProvider,
@@ -1774,12 +1797,19 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1774
1797
  state.cancelRequested = false;
1775
1798
  return true;
1776
1799
  },
1800
+ onPromptBundle(bundle, runContext) {
1801
+ if (!observer || runContext.purpose !== "foreground") return;
1802
+ observer.onRecallSurfaced(bundle.recallMetadata);
1803
+ },
1777
1804
  onEvent(event, runContext) {
1778
1805
  if (["text_delta", "text_final", "tool_start", "result", "error"].includes(event.type)) state.thinkingPhase = false;
1779
1806
  if (event.type === "tool_start" && ["Shell", "Bash"].includes(event.name)) {
1780
1807
  const command = typeof event.detail === "string" ? event.detail : event.detail?.command;
1781
1808
  if (command) { try { require("./tool-guard").noteRawOp(command); } catch (_) {} }
1782
1809
  }
1810
+ if (observer && event.type === "tool_start" && runContext.purpose === "foreground") {
1811
+ try { observer.onToolStart(event.name, event.detail); } catch (_) {}
1812
+ }
1783
1813
  if (preview && event.type === "text_final" && typeof event.text === "string" && event.text) {
1784
1814
  preview.append(event.text, runContext);
1785
1815
  }
@@ -1800,6 +1830,8 @@ function createForegroundService({ state, store, stopTyping, capture, ownsAdmiss
1800
1830
  },
1801
1831
  idleTimeoutMs: Math.max(60000, parseInt(config.OC_TURN_IDLE_MS || process.env.OC_TURN_IDLE_MS || "", 10) || 10 * 60 * 1000),
1802
1832
  });
1833
+ service.turnObserver = observer;
1834
+ return service;
1803
1835
  }
1804
1836
 
1805
1837
  async function handoffMemoryReview(result, runContext, store) {
@@ -1877,6 +1909,11 @@ async function executeAdmittedRun(runContext, state, store, capture, ownsAdmissi
1877
1909
  finalizeSideChatQueueItem(item, runContext, result);
1878
1910
  }
1879
1911
  }
1912
+ // Feed the recall/tool graphs from what this turn actually did — before the
1913
+ // async reviewer runs, so it sees applied_on already set (no double-announce).
1914
+ if (!capture && service.turnObserver && runContext.purpose === "foreground") {
1915
+ try { service.turnObserver.onTurnSettled(!!result.ok); } catch (e) { /* best-effort */ }
1916
+ }
1880
1917
  if (!internalCapture) await handoffMemoryReview(result, runContext, store);
1881
1918
  if (!internalCapture && state.settings?.backend === runContext.provider && state.settings.budget) {
1882
1919
  state.settings.budget = null;
@@ -2056,6 +2093,7 @@ module.exports = {
2056
2093
  effectiveCompactThreshold,
2057
2094
  usageParts,
2058
2095
  peakContextTokens,
2096
+ usageSessionColdStart,
2059
2097
  clampUsageDelta,
2060
2098
  normalizeCodexUsage,
2061
2099
  applyUsageToState,
@@ -0,0 +1,244 @@
1
+ // Turn observer — the chat-visibility + learning-signal layer for a
2
+ // foreground run, ported from the v2.15 runner (dropped wholesale in the v3
3
+ // rewrite, which left /recall and /tooltrace as dead toggles and the recall +
4
+ // tool graphs starving: read by the discoverer and the dream, written by
5
+ // no-one). One observer is created per foreground run and wired to three
6
+ // moments: prompt-bundle ready (banners), each tool_start event
7
+ // (announcements + activity capture), and turn settled (graph feeding).
8
+ //
9
+ // Everything here is best-effort and owner-only: traces and announcements
10
+ // must never leak to a guarded external speaker, and no failure in this layer
11
+ // may break the turn.
12
+
13
+ const { chatContext, currentAdapter } = require("./context");
14
+ const { send } = require("./io");
15
+
16
+ function esc(s) {
17
+ return String(s).replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
18
+ }
19
+
20
+ function htmlOpts(store) {
21
+ try {
22
+ const adapter = chatContext.run(store, () => currentAdapter());
23
+ return adapter?.type === "telegram" ? { parseMode: "HTML" } : {};
24
+ } catch (e) { return {}; }
25
+ }
26
+
27
+ function createTurnObserver({ state, store }) {
28
+ const settings = state.settings || {};
29
+ // Fail closed: if the speaker can't be classified, treat them as guarded
30
+ // and emit nothing — internal activity must never reach a non-owner.
31
+ let guarded = true;
32
+ try { guarded = chatContext.run(store, () => require("./relationship").isCurrentSpeakerGuarded()); }
33
+ catch (e) { guarded = true; }
34
+
35
+ const packsLib = require("./packs");
36
+ const entitiesLib = require("./entities");
37
+ const toolsLib = require("./tools");
38
+ const skillsLib = require("./skills");
39
+
40
+ // Deduped per turn: a node touched twice announces once.
41
+ const notified = new Set();
42
+ const notify = (key, text, opts) => {
43
+ if (guarded) return;
44
+ if (notified.has(key)) return;
45
+ notified.add(key);
46
+ try { chatContext.run(store, () => send(text, opts).catch(() => {})); }
47
+ catch (e) { /* announcements are best-effort */ }
48
+ };
49
+ const notifyToolTrace = (key, text) => {
50
+ if (!settings.showToolTrace) return;
51
+ notify(key, text);
52
+ };
53
+
54
+ // Nodes the agent actually OPENED this turn (📖) — the co-use signal the
55
+ // recall graph reinforces on: actually-read, not merely surfaced.
56
+ const openedThisTurn = new Set();
57
+ // Reusable tools the agent RAN this turn, in order, as { name, shape }.
58
+ // Order matters (a tool chain is a pipeline) — feeds the follows-graph.
59
+ const toolRunsThisTurn = [];
60
+
61
+ // 🧠 /recall + 🔧 /tooltrace banners, fired once the prompt bundle (and its
62
+ // recall metadata) exists — before the provider spawns, like v2.15.
63
+ const onRecallSurfaced = (metadata) => {
64
+ if (guarded) return;
65
+ const r = metadata;
66
+ if (!r || r.omitted) return;
67
+ const fmt = (arr, icon) => (arr || []).map((x) => (x.why ? `${icon} <b>${esc(x.name)}</b> — ${esc(x.why)}` : `${icon} <b>${esc(x.name)}</b>`));
68
+ if (settings.showRecall) {
69
+ const lines = [...fmt(r.packs, "📦"), ...fmt(r.entities, "👤"), ...fmt(r.episodes, "📓")];
70
+ if (lines.length) notify("banner:recall", `🧠 <b>Recall this turn</b> (${esc(r.engine || "")})\n${lines.join("\n")}`, htmlOpts(store));
71
+ else if (r.gated) notify("banner:recall", `🧠 <b>Recall</b> (${esc(r.engine || "")}): skipped by pre-gate — trivial turn.`, htmlOpts(store));
72
+ }
73
+ if (settings.showToolTrace) {
74
+ const toolLines = fmt(r.tools, "🔧");
75
+ if (toolLines.length) notify("banner:tools", `🔧 <b>Tools surfaced this turn</b>\n${toolLines.join("\n")}`, htmlOpts(store));
76
+ }
77
+ };
78
+
79
+ // Write-side announcements: a Skill invocation, or a Write/Edit landing in
80
+ // a pack / entity / tool / legacy-skill directory.
81
+ const noteWriteTool = (toolName, input) => {
82
+ try {
83
+ if (toolName === "Skill" && input?.skill) {
84
+ notify(`use:${input.skill}`, `Using skill: ${input.skill}`);
85
+ return;
86
+ }
87
+ const filePath = input?.file_path || input?.filePath;
88
+ if ((toolName === "Write" || toolName === "Edit") && filePath) {
89
+ const packDir = packsLib.packNameFromPath(filePath);
90
+ if (packDir) {
91
+ if (packsLib.readPack(packDir)) notify(`pack:${packDir}`, `✏️ Updating my notes on ${packDir}…`);
92
+ else notify(`pack:${packDir}`, `📦 Starting a new pack: ${packDir} — open-claudia pack show ${packDir} to peek.`);
93
+ try {
94
+ packsLib.recordForegroundWrite(packDir, {
95
+ tool: toolName,
96
+ oldString: input?.old_string ?? input?.oldString,
97
+ content: input?.content,
98
+ });
99
+ } catch (e) {}
100
+ return;
101
+ }
102
+ const entSlug = entitiesLib.entityNameFromPath(filePath);
103
+ if (entSlug) {
104
+ if (entitiesLib.readEntity(entSlug)) notify(`entity:${entSlug}`, `👤 Updating what I know about ${entSlug}…`);
105
+ else notify(`entity:${entSlug}`, `👤 New entity noted: ${entSlug} — open-claudia entity show ${entSlug} to peek.`);
106
+ return;
107
+ }
108
+ const toolName2 = toolsLib.toolNameFromPath(filePath);
109
+ if (toolName2) {
110
+ if (toolsLib.findTool(toolName2)) notifyToolTrace(`tool:${toolName2}`, `🔧 Updating tool: ${toolName2} — open-claudia tool show ${toolName2} to inspect.`);
111
+ else notifyToolTrace(`tool:${toolName2}`, `🔧 New tool: ${toolName2} — open-claudia tool run ${toolName2} to use it.`);
112
+ return;
113
+ }
114
+ const dir = skillsLib.skillNameFromPath(filePath);
115
+ if (!dir) return;
116
+ // The tool_use event precedes the actual write, so existence now
117
+ // distinguishes a brand-new skill from a patch.
118
+ if (skillsLib.skillExists(dir)) notify(`write:${dir}`, `Updating skill: ${dir}`);
119
+ else notify(`write:${dir}`, `Learning new skill: ${dir} — /skills show ${dir} to inspect, /skills remove ${dir} to drop it.`);
120
+ }
121
+ } catch (e) { /* announcements are best-effort */ }
122
+ };
123
+
124
+ // Read-side recall from shell: "📖 Recalled my notes on …" fires when the
125
+ // agent actually pulls a full pack/entity via the CLI (it decided it was
126
+ // worth reading), not when a headline was auto-injected. Also records tool
127
+ // runs (🔧 lines gated by /tooltrace) and `tool add` saves.
128
+ const noteRecallFromShell = (command) => {
129
+ try {
130
+ const cmd = String(command || "");
131
+ if (!cmd.includes("open-claudia")) return;
132
+ let m;
133
+ const packRe = /\bpack\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
134
+ while ((m = packRe.exec(cmd))) {
135
+ const dir = m[1];
136
+ const pack = packsLib.readPack(dir);
137
+ const name = (pack && (pack.name || pack.dir)) || dir;
138
+ openedThisTurn.add(`pack:${dir}`);
139
+ notify(`recall:pack:${dir}`, `📖 Recalled my notes on: ${name}`);
140
+ }
141
+ const entRe = /\bentity\s+show\s+["']?([a-z0-9][\w.-]*)/gi;
142
+ while ((m = entRe.exec(cmd))) {
143
+ const slug = m[1];
144
+ const ent = entitiesLib.readEntity(slug);
145
+ const name = (ent && (ent.name || ent.slug)) || slug;
146
+ openedThisTurn.add(`entity:${slug}`);
147
+ notify(`recall:entity:${slug}`, `📖 Recalled my notes on: ${name}`);
148
+ }
149
+ try {
150
+ for (const run of require("./tool-graph").parseToolRuns(cmd)) {
151
+ toolRunsThisTurn.push(run);
152
+ const verb = (String(run.shape || "").split(/\s+/)[1] || "");
153
+ let doc = "";
154
+ try { doc = toolsLib.verbDoc(run.name, verb); } catch (e) {}
155
+ notifyToolTrace(`ran:${run.name}:${verb}`, `🔧 ${run.shape || run.name}${doc ? ` — ${doc}` : ""}`);
156
+ }
157
+ } catch (e) { /* graph optional (old node) */ }
158
+ // `open-claudia tool add <src> [--name X]` copies an external file in,
159
+ // so the Write/Edit path above can't see it — detect it here.
160
+ const addRe = /\btool\s+add\s+(\S+)([^\n;|&]*)/gi;
161
+ while ((m = addRe.exec(cmd))) {
162
+ let name = (m[2].match(/--name\s+["']?([^\s"']+)/i) || [])[1];
163
+ if (!name) name = (String(m[1]).split(/[\\/]/).pop() || "").replace(/\.[^.]+$/, "");
164
+ name = toolsLib.sanitizeName(name);
165
+ if (name) notifyToolTrace(`added:${name}`, `🛠️ Saved tool: ${name} — open-claudia tool show ${name} to inspect.`);
166
+ }
167
+ } catch (e) { /* announcements are best-effort */ }
168
+ };
169
+
170
+ // The same 📖 signal when the agent opens a note with the Read tool instead
171
+ // of the CLI. Deduped against the CLI path via the shared notify key.
172
+ const noteRecallFromReadPath = (filePath) => {
173
+ try {
174
+ const node = require("./recall/read-signal").recallNodeFromPath(filePath);
175
+ if (!node) return;
176
+ openedThisTurn.add(`${node.kind}:${node.id}`);
177
+ notify(`recall:${node.kind}:${node.id}`, `📖 Recalled my notes on: ${node.name}`);
178
+ } catch (e) { /* announcements are best-effort */ }
179
+ };
180
+
181
+ const onToolStart = (name, detail) => {
182
+ const input = detail && typeof detail === "object" ? detail : {};
183
+ if (["Bash", "Shell"].includes(name)) {
184
+ const command = typeof detail === "string" ? detail : input.command;
185
+ if (command) noteRecallFromShell(command);
186
+ return;
187
+ }
188
+ if (name === "Read") {
189
+ const filePath = input.file_path || input.filePath;
190
+ if (filePath) noteRecallFromReadPath(filePath);
191
+ return;
192
+ }
193
+ noteWriteTool(name, input);
194
+ };
195
+
196
+ // End-of-turn graph feeding. Only learn from turns that actually completed:
197
+ // an errored turn is not evidence a recalled pattern "helped".
198
+ const onTurnSettled = (succeeded) => {
199
+ if (!succeeded) return;
200
+ // Hebbian co-use: nodes actually opened together get their `related`
201
+ // edges reinforced — co-USE (📖), never co-recall.
202
+ if (openedThisTurn.size > 0) {
203
+ try {
204
+ const recallGraph = require("./recall/graph");
205
+ if (openedThisTurn.size > 1) recallGraph.reinforceSet([...openedThisTurn]);
206
+ require("./recall/metrics").logUse([...openedThisTurn]);
207
+ } catch (e) { /* best-effort */ }
208
+ }
209
+ // Directed tool-graph: tools run in succession become follows-edges.
210
+ if (toolRunsThisTurn.length > 1) {
211
+ try { require("./tool-graph").reinforceSequence(toolRunsThisTurn.map((t) => t.name)); }
212
+ catch (e) { /* best-effort */ }
213
+ }
214
+ // Tool usage telemetry (runCount + lastUsed sidecar).
215
+ if (toolRunsThisTurn.length) {
216
+ try { for (const t of toolRunsThisTurn) toolsLib.recordRun(t.name); }
217
+ catch (e) { /* best-effort */ }
218
+ }
219
+ // Pack↔tool contextual edges: "this tool was used in this topic, as `…`".
220
+ if (toolRunsThisTurn.length && openedThisTurn.size) {
221
+ try {
222
+ const tg = require("./tool-graph");
223
+ const packDirs = [...openedThisTurn].filter((n) => n.startsWith("pack:")).map((n) => n.slice(5));
224
+ for (const dir of packDirs) {
225
+ for (const run of toolRunsThisTurn) tg.recordPackTool(dir, run.name, { command: run.shape, bump: 1 });
226
+ }
227
+ } catch (e) { /* best-effort */ }
228
+ }
229
+ // Ability transfer: an ability pack opened alongside a project pack was
230
+ // demonstrably applied there — grow applied_on now, before the reviewer.
231
+ if (openedThisTurn.size > 1) {
232
+ try {
233
+ for (const t of packsLib.recordCoUse([...openedThisTurn])) {
234
+ notify(`applied:${t.ability}:${t.project}`,
235
+ `🧩 Reused the "${t.abilityName}" ability on ${t.projectName} — it transfers there now too.`);
236
+ }
237
+ } catch (e) { /* best-effort */ }
238
+ }
239
+ };
240
+
241
+ return { onRecallSurfaced, onToolStart, onTurnSettled };
242
+ }
243
+
244
+ module.exports = { createTurnObserver };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@inetafrica/open-claudia",
3
- "version": "3.0.5",
3
+ "version": "3.0.7",
4
4
  "description": "An always-on, provider-agnostic coding-agent harness for Claude Code and OpenAI Codex via chat",
5
5
  "main": "bot.js",
6
6
  "bin": {
@@ -32,11 +32,12 @@ assert.doesNotMatch(setup, /Your Claude Code bot is connected|Description=Claude
32
32
 
33
33
  const pkg = JSON.parse(read("package.json"));
34
34
  assert.match(pkg.description, /provider-agnostic coding-agent harness/i);
35
- // 3.0.5 approved by Sumeet 2026-07-12 ("no do it on the latest co0de and push
36
- // a new version" 409-conflict/heal/wedge guards so a token fight never
37
- // restarts the bot and busts the prompt cache; walker stays on per his call
38
- // that v2's walker was fine and v3's is identical).
39
- assert.strictEqual(pkg.version, "3.0.5", "release version must remain unchanged without explicit approval");
35
+ // 3.0.7 approved by Sumeet 2026-07-12 ("Do the full fix and I'll upgrade"
36
+ // plain-stop + agent-destroy kills the zombie poll loop behind the recurring
37
+ // "another poller" 409 storms, and the v2.15 turn-observer trace layer is
38
+ // restored so /tooltrace and /recall work again and the recall/tool graphs
39
+ // are fed).
40
+ assert.strictEqual(pkg.version, "3.0.7", "release version must remain unchanged without explicit approval");
40
41
  for (const keyword of ["claude", "codex", "coding-agent", "provider-agnostic"]) {
41
42
  assert.ok(pkg.keywords.includes(keyword), `package keyword missing: ${keyword}`);
42
43
  }
@@ -39,9 +39,16 @@ async function testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog() {
39
39
  }
40
40
  }
41
41
 
42
- async function testHealCancelsWedgedLongPollBeforeRestarting() {
42
+ // stopPolling({cancel:true}) leaks the poll loop (Bluebird runs the chain's
43
+ // .finally on cancel with `_abort` still false, rescheduling _polling) — the
44
+ // zombie loops then 409 each other forever (the 15-socket pile-up of
45
+ // 2026-07-12). Recovery must therefore use a PLAIN stop (sets _abort before
46
+ // the request settles) and destroy the agent so a hung half-open getUpdates
47
+ // settles now instead of never.
48
+ async function testHealStopsPlainlyDestroysAgentAndRestartsIdle() {
43
49
  const calls = [];
44
50
  let stopOptions;
51
+ let startOptions;
45
52
  const adapter = Object.create(TelegramAdapter.prototype);
46
53
  adapter._healTimer = null;
47
54
  adapter._healthyTimer = null;
@@ -55,7 +62,8 @@ async function testHealCancelsWedgedLongPollBeforeRestarting() {
55
62
  stopOptions = options;
56
63
  calls.push("stop");
57
64
  },
58
- async startPolling() {
65
+ async startPolling(options) {
66
+ startOptions = options;
59
67
  calls.push("start");
60
68
  },
61
69
  };
@@ -75,17 +83,93 @@ async function testHealCancelsWedgedLongPollBeforeRestarting() {
75
83
  global.clearTimeout = realClearTimeout;
76
84
  }
77
85
 
78
- assert.deepStrictEqual(
79
- stopOptions,
80
- { cancel: true, reason: "stale Telegram long-poll recovery" },
81
- "recovery must cancel a wedged getUpdates request rather than await it forever",
82
- );
83
- assert.deepStrictEqual(calls, ["stop", "destroy", "start"]);
86
+ assert.strictEqual(stopOptions && stopOptions.cancel, undefined,
87
+ "recovery must NOT cancel-stop: Bluebird cancellation leaks a zombie poll loop that 409s the next one");
88
+ assert.strictEqual(stopOptions && stopOptions.reason, "stale Telegram long-poll recovery");
89
+ assert.deepStrictEqual(calls, ["stop", "destroy", "start"],
90
+ "recovery must sever the hung socket (agent.destroy) between stop and start");
91
+ assert.strictEqual(startOptions && startOptions.restart, false,
92
+ "restart must be idle-only: the lib's restart path is another cancel-stop (zombie factory)");
93
+ }
94
+
95
+ async function testStopPollingCleanDoesNotHangOnAWedgedStop() {
96
+ const adapter = Object.create(TelegramAdapter.prototype);
97
+ adapter._agent = { destroy() {} };
98
+ adapter.bot = {
99
+ stopPolling() { return new Promise(() => {}); }, // never settles
100
+ };
101
+
102
+ const realSetTimeout = global.setTimeout;
103
+ global.setTimeout = (fn, ms) => {
104
+ if (ms === 5000) fn(); // the cap fires
105
+ return { ms };
106
+ };
107
+ try {
108
+ const outcome = await Promise.race([
109
+ adapter._stopPollingClean("test").then(() => "returned"),
110
+ new Promise((resolve) => realSetTimeout(() => resolve("hung"), 25)),
111
+ ]);
112
+ assert.strictEqual(outcome, "returned", "_stopPollingClean must cap the wait when the stop promise never settles");
113
+ } finally {
114
+ global.setTimeout = realSetTimeout;
115
+ }
116
+ }
117
+
118
+ async function test409ConflictSeversGhostSocketAndRestartsIdle() {
119
+ const calls = [];
120
+ let stopOptions;
121
+ let startOptions;
122
+ const adapter = Object.create(TelegramAdapter.prototype);
123
+ adapter.ownerChatId = null;
124
+ adapter._conflictSince = 0;
125
+ adapter._conflictTimer = null;
126
+ adapter._conflictClearTimer = null;
127
+ adapter._conflictAlertAt = 0;
128
+ adapter._conflictAlerted = false;
129
+ adapter._agent = {
130
+ destroy() { calls.push("destroy"); },
131
+ };
132
+ adapter.bot = {
133
+ async stopPolling(options) {
134
+ stopOptions = options;
135
+ calls.push("stop");
136
+ },
137
+ async startPolling(options) {
138
+ startOptions = options;
139
+ calls.push("start");
140
+ },
141
+ };
142
+
143
+ const timers = [];
144
+ const realSetTimeout = global.setTimeout;
145
+ const realClearTimeout = global.clearTimeout;
146
+ global.setTimeout = (fn, ms) => { timers.push({ fn, ms }); return { ms }; };
147
+ global.clearTimeout = () => {};
148
+
149
+ try {
150
+ adapter._on409Conflict();
151
+ // Let the fire-and-forget clean stop settle.
152
+ await new Promise((resolve) => realSetTimeout(resolve, 5));
153
+ assert.strictEqual(stopOptions && stopOptions.cancel, undefined,
154
+ "409 backoff must NOT cancel-stop (zombie loop leak)");
155
+ assert.ok(calls.includes("destroy"),
156
+ "409 backoff must destroy the agent to sever the ghost getUpdates holding the token");
157
+ const retry = timers.find((t) => t.ms === 15000);
158
+ assert.ok(retry, "409 backoff must queue a paused retry");
159
+ await retry.fn();
160
+ assert.strictEqual(startOptions && startOptions.restart, false,
161
+ "409 retry must restart idle-only, never through the lib's cancel-restart path");
162
+ } finally {
163
+ global.setTimeout = realSetTimeout;
164
+ global.clearTimeout = realClearTimeout;
165
+ }
84
166
  }
85
167
 
86
168
  (async () => {
87
169
  await testStartDoesNotWaitForFirstLongPollAndArmsStallWatchdog();
88
- await testHealCancelsWedgedLongPollBeforeRestarting();
170
+ await testHealStopsPlainlyDestroysAgentAndRestartsIdle();
171
+ await testStopPollingCleanDoesNotHangOnAWedgedStop();
172
+ await test409ConflictSeversGhostSocketAndRestartsIdle();
89
173
  console.log("telegram poll recovery OK");
90
174
  })().catch((err) => {
91
175
  console.error(err.stack || err);
@@ -10,6 +10,7 @@ const {
10
10
  applyUsageToState,
11
11
  shouldAutoCompact,
12
12
  peakContextTokens,
13
+ usageSessionColdStart,
13
14
  } = require("./core/runner");
14
15
  const stateApi = require("./core/state");
15
16
  const { normalizeUsageRecord, loadUsageHistory, USAGE_HISTORY_FILE } = require("./core/usage-log");
@@ -176,5 +177,17 @@ assert.strictEqual(peakContextTokens([
176
177
  { type: "usage", scope: "provider_call", usage: { input_tokens: 70, cached_input_tokens: 30, output_tokens: 5 } },
177
178
  ], "codex"), 70, "codex context counts input only, matching usageParts");
178
179
 
180
+ // usageSessionColdStart: the first usage record per session since process boot
181
+ // is a cold start (prompt cache empty — the restart-burn fingerprint); every
182
+ // later record for that session in the same process is warm.
183
+ {
184
+ const warm = new Set();
185
+ assert.strictEqual(usageSessionColdStart("sess-a", warm), true, "first record for a session is cold");
186
+ assert.strictEqual(usageSessionColdStart("sess-a", warm), false, "second record for the same session is warm");
187
+ assert.strictEqual(usageSessionColdStart("sess-b", warm), true, "a different session is cold on its first record");
188
+ assert.strictEqual(usageSessionColdStart(null, warm), true, "no session id → always treated cold");
189
+ assert.strictEqual(usageSessionColdStart(null, warm), true, "null never warms");
190
+ }
191
+
179
192
  fs.rmSync(configDir, { recursive: true, force: true });
180
193
  console.log("usage accounting OK");