@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 +5 -0
- package/channels/telegram/adapter.js +26 -13
- package/core/runner.js +39 -1
- package/core/turn-observer.js +244 -0
- package/package.json +1 -1
- package/test-provider-language.js +6 -5
- package/test-telegram-poll-recovery.js +93 -9
- package/test-usage-accounting.js +13 -0
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.
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
181
|
-
|
|
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
|
-
|
|
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, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
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
|
@@ -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.
|
|
36
|
-
//
|
|
37
|
-
//
|
|
38
|
-
//
|
|
39
|
-
|
|
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
|
-
|
|
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.
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
assert.
|
|
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
|
|
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);
|
package/test-usage-accounting.js
CHANGED
|
@@ -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");
|