@link-assistant/hive-mind 2.13.4 → 2.14.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.
- package/CHANGELOG.md +29 -0
- package/README.hi.md +2 -0
- package/README.md +2 -0
- package/README.ru.md +2 -0
- package/README.zh.md +2 -0
- package/package.json +1 -1
- package/src/agent.lib.mjs +5 -1
- package/src/claude.lib.mjs +5 -158
- package/src/claude.session-tokens.lib.mjs +180 -0
- package/src/codex.diagnostics.lib.mjs +135 -0
- package/src/codex.lib.mjs +8 -121
- package/src/config.lib.mjs +9 -0
- package/src/docker-sidecar.lib.mjs +276 -0
- package/src/formal-ai-maintenance.lib.mjs +2 -14
- package/src/formal-ai-sidecar.lib.mjs +17 -137
- package/src/gemini.lib.mjs +5 -1
- package/src/git-push-guard.lib.mjs +230 -0
- package/src/git-retry.lib.mjs +97 -0
- package/src/github-pr-idempotency.lib.mjs +83 -0
- package/src/github-rate-limit.lib.mjs +44 -41
- package/src/hive.mjs +8 -150
- package/src/hive.repository-fallback.lib.mjs +125 -0
- package/src/hive.startup-checks.lib.mjs +57 -0
- package/src/isolation-runner.lib.mjs +94 -287
- package/src/isolation-runner.parsers.lib.mjs +292 -0
- package/src/lib.mjs +79 -18
- package/src/opencode.lib.mjs +5 -1
- package/src/qwen.lib.mjs +5 -1
- package/src/router-isolation.lib.mjs +496 -0
- package/src/router-logs.lib.mjs +143 -0
- package/src/router-maintenance.lib.mjs +77 -0
- package/src/router-session-drain.lib.mjs +153 -0
- package/src/router-sidecar.lib.mjs +516 -0
- package/src/router-task-isolation.lib.mjs +121 -0
- package/src/session-monitor.lib.mjs +12 -272
- package/src/session-monitor.queries.lib.mjs +304 -0
- package/src/solve.auto-pr-push-sync.lib.mjs +176 -0
- package/src/solve.auto-pr.lib.mjs +40 -154
- package/src/solve.config.lib.mjs +11 -0
- package/src/solve.mjs +8 -158
- package/src/solve.mode.lib.mjs +191 -0
- package/src/task.config.lib.mjs +5 -0
- package/src/task.mjs +1 -0
- package/src/telegram-bot.mjs +18 -0
- package/src/telegram-solve-queue.lib.mjs +19 -272
- package/src/telegram-solve-queue.throttling.lib.mjs +323 -0
- package/src/transient-errors.lib.mjs +238 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,34 @@
|
|
|
1
1
|
# @link-assistant/hive-mind
|
|
2
2
|
|
|
3
|
+
## 2.14.0
|
|
4
|
+
|
|
5
|
+
### Minor Changes
|
|
6
|
+
|
|
7
|
+
- e796988: Add the experimental `--use-router` option, which stops handing a Docker-isolated task the operator's real subscription. With the flag, `~/.claude`, `~/.claude.json`, `~/.codex` and `~/.agents` are mounted only into a reference-counted `hive-mind-router` sidecar (pinned to `ghcr.io/link-assistant/router:0.119.0`) on an internal Docker network; each task is joined to that network and given its own `la_sk_…` token scoped to the one repository it works on, so every model request lands in that token's own redacted log. The sidecar runs while at least one task holds a lease and is stopped when the last one is released, with the Telegram bot reconciling leases against Docker so a killed bot cannot leave it running forever. At end of task the agent's session data is drained into the router's data volume for audit, and that volume is never removed by any code path.
|
|
8
|
+
|
|
9
|
+
GitHub is routed too: the sidecar terminates TLS itself with a certificate covering `api.github.com`, and the task container resolves that name to the router, so an unmodified `gh` is mediated without holding a GitHub credential of its own. `git` pushes through the router's `/git/*` proxy, which refuses remote-ref deletions and, from router `0.110.0`, non-fast-forward updates as well — it asks GitHub's compare API whether the proposed tip is ahead and fails closed when it cannot tell. A read-only `pre-push` hook in the task covers the same ground locally and for non-routed remotes. `--model formal-ai` is served through the router as a stored OpenAI-compatible provider, so it is audited like every other model.
|
|
10
|
+
|
|
11
|
+
Nothing changes without the flag. The remaining limits are documented and printed before every routed run: destructive GitHub API calls are blocked by HTTP method rather than by effect, so `PUT` branch protection, `PUT` rulesets, `POST` transfer and `PATCH` visibility/archived/default_branch are still forwarded (upstream `link-assistant/router#329`) — which puts branch protection itself within reach of a routed token; the Formal AI sidecar's own upstream calls are not routed; and the router resolves exact model ids only, not aliases, so the tier-shaped surface (`--plan`, `--escalate`, fallback chains) needs dated ids on a routed run. New docs: `docs/ROUTER.md`, `docs/COLLECTING-LOGS.md`, `docs/case-studies/issue-2164/` and `examples/collect-logs.mjs`.
|
|
12
|
+
|
|
13
|
+
## 2.13.5
|
|
14
|
+
|
|
15
|
+
### Patch Changes
|
|
16
|
+
|
|
17
|
+
- 0d0b96a: Fix every error and warning reported by CI/CD (issue #2175).
|
|
18
|
+
|
|
19
|
+
- The release job now lands the version bump through a pull request when a
|
|
20
|
+
repository ruleset rejects the direct push to `main` (GH013), instead of
|
|
21
|
+
retrying it as a lost race and failing the release.
|
|
22
|
+
- The release gate asks the npm registry, not the `.changeset` folder, whether
|
|
23
|
+
the current version is published, so an interrupted release self-heals on the
|
|
24
|
+
next push instead of being silently skipped.
|
|
25
|
+
- Release assets are uploaded with the `gh` CLI, removing the last action
|
|
26
|
+
pinned to the deprecated Node 20 runtime.
|
|
27
|
+
- Eight source files that had drifted into the 1350-line warning band were
|
|
28
|
+
reduced by extracting cohesive modules; behaviour is unchanged.
|
|
29
|
+
|
|
30
|
+
- 3422f03: Retry transient GitHub and git failures instead of aborting the run. A `gh pr create` died 3.1 seconds after it was issued on `GraphQL: Something went wrong while executing your query …` without a single retry: GitHub reports internal GraphQL faults as **HTTP 200 with an `errors[]` payload**, and the transient-error classifier only knew about TCP/TLS faults and the literal strings `http 502`/`503`/`504`, so the failure was treated as permanent. Classification now lives in one place (`src/transient-errors.lib.mjs`), recognises the GraphQL 200-with-errors family, gets its own larger budget (`HIVE_MIND_MAX_GITHUB_TRANSIENT_RETRIES`, default 6), and logs _why_ an error was or was not retried — including GitHub's support reference id, which was previously discarded. Network git commands (`push`/`fetch`/`pull`/`ls-remote`) are now retried too: the wrapper is installed on command-stream's `$` tag rather than at each of the ~36 call sites, `gh pr create` recovers the existing PR URL when a retry hits "a pull request already exists", and a new `gh-rate-limit/no-unretried-git-network` ESLint rule fails the build on any future unguarded git network call in `src/`.
|
|
31
|
+
|
|
3
32
|
## 2.13.4
|
|
4
33
|
|
|
5
34
|
### Patch Changes
|
package/README.hi.md
CHANGED
|
@@ -88,6 +88,8 @@ Hive Mind में औसत प्रोग्रामर से अलग
|
|
|
88
88
|
|
|
89
89
|
Hive Mind अपने प्रबंधित terminal, log, telemetry, upload और GitHub publication paths में पहचाने गए credentials को sanitize करता है। Scanner विफल होने पर publication रोक दिया जाता है, लेकिन यह किसी autonomous tool को किसी असंबंधित exfiltration path का उपयोग करने से नहीं रोकता। Masking contract, supported formats, maintenance process और incident-response guidance के लिए [Credential sanitization](./docs/CREDENTIAL-SANITIZATION.hi.md) देखें।
|
|
90
90
|
|
|
91
|
+
डिफ़ॉल्ट रूप से Docker में आइसोलेट किए गए कार्यों को ऑपरेटर के Claude/Codex क्रेडेंशियल मिल जाते हैं। `--use-router` उन्हें रोक देता है और मॉडल ट्रैफ़िक को `hive-mind-router` sidecar से भेजता है, जिससे हर कार्य को अपना सीमित दायरे वाला टोकन और अपना request लॉग मिलता है — देखें [राउटर आइसोलेशन](./docs/ROUTER.hi.md) (प्रयोगात्मक) और [लॉग एकत्र करना](./docs/COLLECTING-LOGS.hi.md)।
|
|
92
|
+
|
|
91
93
|
`hive.mjs` चलाने के लिए न्यूनतम सिस्टम आवश्यकताएँ:
|
|
92
94
|
|
|
93
95
|
```
|
package/README.md
CHANGED
|
@@ -88,6 +88,8 @@ We strongly recommend:
|
|
|
88
88
|
|
|
89
89
|
Hive Mind sanitizes recognized credentials in its maintained terminal, log, telemetry, upload, and GitHub publication paths. Publication is blocked if the scanner fails, but this does not prevent an autonomous tool from using an unrelated exfiltration path. See [Credential sanitization](./docs/CREDENTIAL-SANITIZATION.md) for the masking contract, supported formats, maintenance process, and incident-response guidance.
|
|
90
90
|
|
|
91
|
+
Docker-isolated tasks receive the operator's Claude/Codex credentials by default. `--use-router` withholds them and routes model traffic through a `hive-mind-router` sidecar instead, giving each task its own scoped token and its own request log — see [Router isolation](./docs/ROUTER.md) (experimental) and [Collecting logs](./docs/COLLECTING-LOGS.md).
|
|
92
|
+
|
|
91
93
|
Minimum system requirements to run `hive.mjs`:
|
|
92
94
|
|
|
93
95
|
```
|
package/README.ru.md
CHANGED
|
@@ -88,6 +88,8 @@ Hive Mind обладает высоким уровнем творчества,
|
|
|
88
88
|
|
|
89
89
|
Hive Mind скрывает распознанные учётные данные в обслуживаемых им путях вывода терминала, журналов, телеметрии, загрузок и публикаций GitHub. При сбое сканера публикация блокируется, однако это не мешает автономному инструменту использовать посторонний канал утечки. Соглашение о маскировании, поддерживаемые форматы, процесс сопровождения и рекомендации по реагированию на инциденты приведены в разделе [Санитизация учётных данных](./docs/CREDENTIAL-SANITIZATION.ru.md).
|
|
90
90
|
|
|
91
|
+
По умолчанию задачи, изолированные в Docker, получают учётные данные Claude/Codex оператора. `--use-router` их не передаёт и вместо этого направляет трафик модели через sidecar `hive-mind-router`, выдавая каждой задаче собственный ограниченный токен и собственный журнал запросов — см. [Изоляция через роутер](./docs/ROUTER.ru.md) (экспериментально) и [Сбор журналов](./docs/COLLECTING-LOGS.ru.md).
|
|
92
|
+
|
|
91
93
|
Минимальные системные требования для запуска `hive.mjs`:
|
|
92
94
|
|
|
93
95
|
```
|
package/README.zh.md
CHANGED
|
@@ -88,6 +88,8 @@ Hive Mind 具备与普通程序员无异的高度创造力。当需求不明确
|
|
|
88
88
|
|
|
89
89
|
Hive Mind 会在其维护的终端、日志、遥测、上传和 GitHub 发布路径中清理可识别的凭证。如果扫描器失败,发布会被阻止;但这无法阻止自主工具通过不相关的路径泄露数据。有关掩码约定、支持的格式、维护流程和事件响应指南,请参阅[凭证清理](./docs/CREDENTIAL-SANITIZATION.zh.md)。
|
|
90
90
|
|
|
91
|
+
默认情况下,Docker 隔离的任务会拿到操作者的 Claude/Codex 凭据。`--use-router` 不再传入这些凭据,而是让模型流量经由 `hive-mind-router` sidecar 转发,使每个任务获得仅限自身的令牌和自己的请求日志——参见[路由器隔离](./docs/ROUTER.zh.md)(实验性)与[收集日志](./docs/COLLECTING-LOGS.zh.md)。
|
|
92
|
+
|
|
91
93
|
运行 `hive.mjs` 的最低系统要求:
|
|
92
94
|
|
|
93
95
|
```
|
package/package.json
CHANGED
package/src/agent.lib.mjs
CHANGED
|
@@ -8,7 +8,11 @@ if (typeof globalThis.use === 'undefined') {
|
|
|
8
8
|
await ensureUseM();
|
|
9
9
|
}
|
|
10
10
|
|
|
11
|
-
const { $ } = await use('command-stream');
|
|
11
|
+
const { $: __rawDollar$ } = await use('command-stream');
|
|
12
|
+
// Issue #2168: retry transient git network failures (push/fetch/pull) the same
|
|
13
|
+
// way `gh` calls are retried, for every command run through this module's `$`.
|
|
14
|
+
const { wrapDollarWithGitRetry } = await import('./git-retry.lib.mjs');
|
|
15
|
+
const $ = wrapDollarWithGitRetry(__rawDollar$);
|
|
12
16
|
const fs = (await use('fs')).promises;
|
|
13
17
|
const path = (await use('path')).default;
|
|
14
18
|
const os = (await use('os')).default;
|
package/src/claude.lib.mjs
CHANGED
|
@@ -14,8 +14,7 @@ import { createInteractiveHandler } from './interactive-mode.lib.mjs';
|
|
|
14
14
|
import { setupBidirectionalHandler, finalizeBidirectionalHandler, validateBidirectionalModeConfig, attachStreamingInput } from './bidirectional-interactive.lib.mjs';
|
|
15
15
|
import { initProgressMonitoring } from './solve.progress-monitoring.lib.mjs';
|
|
16
16
|
import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
|
|
17
|
-
import
|
|
18
|
-
import { createEmptySubSessionUsage, accumulateModelUsage, mergeResultModelUsage, createSubAgentCallEntry, accumulateSubAgentUsage, getRawRequestInputTokens, displaySessionTokenUsage } from './claude.budget-stats.lib.mjs';
|
|
17
|
+
import { createSubAgentCallEntry, accumulateSubAgentUsage, displaySessionTokenUsage } from './claude.budget-stats.lib.mjs';
|
|
19
18
|
import { buildClaudeResumeCommand, buildClaudeAutonomousResumeCommand } from './claude.command-builder.lib.mjs';
|
|
20
19
|
import { beginAnthropicCostScope, seedCumulativeAnthropicCost, addAnthropicRunCost, captureAnthropicResultCost } from './anthropic-cost-accumulator.lib.mjs'; // Issues #1886, #2056, #2119
|
|
21
20
|
import { buildSolveResumeCommand } from './solve.resume-command.lib.mjs'; // Issue #942
|
|
@@ -152,162 +151,10 @@ export const executeClaude = async params => {
|
|
|
152
151
|
// this file under the 1500-line repo cap (see check-file-line-limits CI job).
|
|
153
152
|
import { calculateModelCost } from './claude.cost.lib.mjs';
|
|
154
153
|
export { calculateModelCost };
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
const projectDirName = tempDir.replace(/\//g, '-');
|
|
160
|
-
const sessionFile = path.join(homeDir, '.claude', 'projects', projectDirName, `${sessionId}.jsonl`);
|
|
161
|
-
try {
|
|
162
|
-
await fs.access(sessionFile);
|
|
163
|
-
} catch {
|
|
164
|
-
return null;
|
|
165
|
-
}
|
|
166
|
-
const modelUsage = {};
|
|
167
|
-
// Issue #1501: Deduplicate JSONL entries by message ID (stream-json splits responses)
|
|
168
|
-
const seenMessageIds = new Set();
|
|
169
|
-
let duplicateCount = 0;
|
|
170
|
-
const peakContextByModel = {};
|
|
171
|
-
let globalPeakContext = 0;
|
|
172
|
-
const subSessions = [];
|
|
173
|
-
let currentSubSession = createEmptySubSessionUsage();
|
|
174
|
-
const compactifications = [];
|
|
175
|
-
try {
|
|
176
|
-
const fileContent = await fs.readFile(sessionFile, 'utf8');
|
|
177
|
-
const lines = fileContent.trim().split('\n');
|
|
178
|
-
for (const line of lines) {
|
|
179
|
-
if (!line.trim()) continue;
|
|
180
|
-
try {
|
|
181
|
-
const entry = JSON.parse(line);
|
|
182
|
-
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
|
183
|
-
if (currentSubSession.messageCount > 0) {
|
|
184
|
-
subSessions.push(currentSubSession);
|
|
185
|
-
}
|
|
186
|
-
compactifications.push({
|
|
187
|
-
timestamp: entry.timestamp || null,
|
|
188
|
-
preTokens: entry.compactMetadata?.preTokens || null,
|
|
189
|
-
trigger: entry.compactMetadata?.trigger || 'unknown',
|
|
190
|
-
});
|
|
191
|
-
currentSubSession = createEmptySubSessionUsage();
|
|
192
|
-
continue;
|
|
193
|
-
}
|
|
194
|
-
if (entry.message && entry.message.usage && entry.message.model) {
|
|
195
|
-
// Issue #1501: Skip duplicate JSONL entries (same message ID = same API response)
|
|
196
|
-
const msgId = entry.message.id;
|
|
197
|
-
if (msgId) {
|
|
198
|
-
if (seenMessageIds.has(msgId)) {
|
|
199
|
-
duplicateCount++;
|
|
200
|
-
continue;
|
|
201
|
-
}
|
|
202
|
-
seenMessageIds.add(msgId);
|
|
203
|
-
}
|
|
204
|
-
accumulateModelUsage(modelUsage, entry);
|
|
205
|
-
// Issue #1737: Track peak restored-context input per request.
|
|
206
|
-
// Anthropic splits a request's input into input_tokens,
|
|
207
|
-
// cache_creation_input_tokens, and cache_read_input_tokens; all three
|
|
208
|
-
// count toward "how much context will be restored if I resume here".
|
|
209
|
-
const usage = entry.message.usage;
|
|
210
|
-
const requestContext = getRawRequestInputTokens(usage);
|
|
211
|
-
const model = entry.message.model;
|
|
212
|
-
if (requestContext > (peakContextByModel[model] || 0)) {
|
|
213
|
-
peakContextByModel[model] = requestContext;
|
|
214
|
-
}
|
|
215
|
-
if (requestContext > globalPeakContext) {
|
|
216
|
-
globalPeakContext = requestContext;
|
|
217
|
-
}
|
|
218
|
-
if (usage.input_tokens) currentSubSession.inputTokens += usage.input_tokens;
|
|
219
|
-
if (usage.cache_creation_input_tokens) currentSubSession.cacheCreationTokens += usage.cache_creation_input_tokens;
|
|
220
|
-
if (usage.cache_read_input_tokens) currentSubSession.cacheReadTokens += usage.cache_read_input_tokens;
|
|
221
|
-
if (usage.output_tokens) currentSubSession.outputTokens += usage.output_tokens;
|
|
222
|
-
currentSubSession.messageCount++;
|
|
223
|
-
// Issue #1501: Track peak context and output per sub-session
|
|
224
|
-
if (requestContext > currentSubSession.peakContextUsage) {
|
|
225
|
-
currentSubSession.peakContextUsage = requestContext;
|
|
226
|
-
}
|
|
227
|
-
if ((usage.output_tokens || 0) > currentSubSession.peakOutputUsage) {
|
|
228
|
-
currentSubSession.peakOutputUsage = usage.output_tokens || 0;
|
|
229
|
-
}
|
|
230
|
-
}
|
|
231
|
-
} catch {
|
|
232
|
-
// Skip lines that aren't valid JSON
|
|
233
|
-
continue;
|
|
234
|
-
}
|
|
235
|
-
}
|
|
236
|
-
if (currentSubSession.messageCount > 0) {
|
|
237
|
-
subSessions.push(currentSubSession);
|
|
238
|
-
}
|
|
239
|
-
mergeResultModelUsage(modelUsage, resultModelUsage);
|
|
240
|
-
if (Object.keys(modelUsage).length === 0) {
|
|
241
|
-
return null;
|
|
242
|
-
}
|
|
243
|
-
const modelInfoPromises = Object.keys(modelUsage).map(async modelId => {
|
|
244
|
-
const modelInfo = await fetchModelInfoForUsage(modelId);
|
|
245
|
-
return { modelId, modelInfo };
|
|
246
|
-
});
|
|
247
|
-
const modelInfoResults = await Promise.all(modelInfoPromises);
|
|
248
|
-
const modelInfoMap = {};
|
|
249
|
-
for (const { modelId, modelInfo } of modelInfoResults) {
|
|
250
|
-
if (modelInfo) {
|
|
251
|
-
modelInfoMap[modelId] = modelInfo;
|
|
252
|
-
}
|
|
253
|
-
}
|
|
254
|
-
for (const [modelId, usage] of Object.entries(modelUsage)) {
|
|
255
|
-
const modelInfo = modelInfoMap[modelId];
|
|
256
|
-
// Issue #1501: Attach peak context usage per model
|
|
257
|
-
usage.peakContextUsage = peakContextByModel[modelId] || 0;
|
|
258
|
-
// Calculate cost using pricing API
|
|
259
|
-
if (modelInfo) {
|
|
260
|
-
const costData = calculateModelCost(usage, modelInfo, true);
|
|
261
|
-
usage.costUSD = costData.total;
|
|
262
|
-
usage.costBreakdown = costData.breakdown;
|
|
263
|
-
usage.modelName = modelInfo.name || modelId;
|
|
264
|
-
usage.modelInfo = modelInfo;
|
|
265
|
-
} else {
|
|
266
|
-
usage.costUSD = usage._resultCostUSD ?? null;
|
|
267
|
-
usage.costBreakdown = null;
|
|
268
|
-
usage.modelName = modelId;
|
|
269
|
-
// Issue #1539: Use contextWindow/maxOutputTokens from result JSON as fallback model limits
|
|
270
|
-
const ctx = usage._resultContextWindow,
|
|
271
|
-
out = usage._resultMaxOutputTokens;
|
|
272
|
-
usage.modelInfo = ctx || out ? { limit: { context: ctx || null, output: out || null } } : null;
|
|
273
|
-
}
|
|
274
|
-
}
|
|
275
|
-
let totalInputTokens = 0;
|
|
276
|
-
let totalCacheCreationTokens = 0;
|
|
277
|
-
let totalCacheReadTokens = 0;
|
|
278
|
-
let totalOutputTokens = 0;
|
|
279
|
-
let totalCostDecimal = new Decimal(0);
|
|
280
|
-
let hasCostData = false;
|
|
281
|
-
for (const usage of Object.values(modelUsage)) {
|
|
282
|
-
totalInputTokens += usage.inputTokens;
|
|
283
|
-
totalCacheCreationTokens += usage.cacheCreationTokens;
|
|
284
|
-
totalCacheReadTokens += usage.cacheReadTokens;
|
|
285
|
-
totalOutputTokens += usage.outputTokens;
|
|
286
|
-
if (usage.costUSD !== null) {
|
|
287
|
-
totalCostDecimal = totalCostDecimal.plus(new Decimal(usage.costUSD));
|
|
288
|
-
hasCostData = true;
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
const totalTokens = totalInputTokens + totalCacheCreationTokens + totalOutputTokens;
|
|
292
|
-
return {
|
|
293
|
-
modelUsage,
|
|
294
|
-
inputTokens: totalInputTokens,
|
|
295
|
-
cacheCreationTokens: totalCacheCreationTokens,
|
|
296
|
-
cacheReadTokens: totalCacheReadTokens,
|
|
297
|
-
outputTokens: totalOutputTokens,
|
|
298
|
-
totalTokens,
|
|
299
|
-
totalCostUSD: hasCostData ? totalCostDecimal.toNumber() : null,
|
|
300
|
-
// Issue #1501: Peak context usage (max single-request fill) and dedup stats
|
|
301
|
-
peakContextUsage: globalPeakContext,
|
|
302
|
-
duplicateEntriesSkipped: duplicateCount,
|
|
303
|
-
// Issue #1491/#1501: Sub-session and compactification data (always include for display)
|
|
304
|
-
subSessions,
|
|
305
|
-
compactifications: compactifications.length > 0 ? compactifications : null,
|
|
306
|
-
};
|
|
307
|
-
} catch (readError) {
|
|
308
|
-
throw new Error(`Failed to read session file: ${readError.message}`, { cause: readError });
|
|
309
|
-
}
|
|
310
|
-
};
|
|
154
|
+
// Issue #2175: session token accounting lives in claude.session-tokens.lib.mjs
|
|
155
|
+
// so this file stays under the 1350-line early-warning threshold (issue #1593).
|
|
156
|
+
import { calculateSessionTokens } from './claude.session-tokens.lib.mjs';
|
|
157
|
+
export { calculateSessionTokens };
|
|
311
158
|
// Extracted to claude.stderr.lib.mjs (Issue #477, #1337)
|
|
312
159
|
import { isStderrError } from './claude.stderr.lib.mjs';
|
|
313
160
|
import { ensureAiToolScratchIgnored, filterAiToolScratchFromStatus } from './ai-tool-scratch.lib.mjs';
|
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Per-session token/cost accounting for Claude sessions.
|
|
3
|
+
*
|
|
4
|
+
* Reads the Claude Code session JSONL (`~/.claude/projects/<dir>/<id>.jsonl`),
|
|
5
|
+
* deduplicates the stream-json entries, splits the transcript into sub-sessions
|
|
6
|
+
* at each compact boundary, and prices the result through the model info API.
|
|
7
|
+
*
|
|
8
|
+
* Extracted from claude.lib.mjs (issue #2175) so that file stays under the
|
|
9
|
+
* 1350-line early-warning threshold of the CI file-headroom check (long files
|
|
10
|
+
* cause concurrent PR merge conflicts — issue #1593). Behaviour is unchanged;
|
|
11
|
+
* claude.lib.mjs re-exports this function.
|
|
12
|
+
*
|
|
13
|
+
* @see https://github.com/link-assistant/hive-mind/issues/2175
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { promises as fs } from 'node:fs';
|
|
17
|
+
import os from 'node:os';
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
|
|
20
|
+
import Decimal from 'decimal.js-light';
|
|
21
|
+
|
|
22
|
+
import { accumulateModelUsage, createEmptySubSessionUsage, getRawRequestInputTokens, mergeResultModelUsage } from './claude.budget-stats.lib.mjs';
|
|
23
|
+
import { calculateModelCost } from './claude.cost.lib.mjs';
|
|
24
|
+
import { fetchModelInfo } from './model-info.lib.mjs';
|
|
25
|
+
|
|
26
|
+
export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsage = null, options = {}) => {
|
|
27
|
+
const homeDir = options.homeDir || os.homedir();
|
|
28
|
+
const fetchModelInfoForUsage = options.fetchModelInfo || fetchModelInfo;
|
|
29
|
+
const projectDirName = tempDir.replace(/\//g, '-');
|
|
30
|
+
const sessionFile = path.join(homeDir, '.claude', 'projects', projectDirName, `${sessionId}.jsonl`);
|
|
31
|
+
try {
|
|
32
|
+
await fs.access(sessionFile);
|
|
33
|
+
} catch {
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
const modelUsage = {};
|
|
37
|
+
// Issue #1501: Deduplicate JSONL entries by message ID (stream-json splits responses)
|
|
38
|
+
const seenMessageIds = new Set();
|
|
39
|
+
let duplicateCount = 0;
|
|
40
|
+
const peakContextByModel = {};
|
|
41
|
+
let globalPeakContext = 0;
|
|
42
|
+
const subSessions = [];
|
|
43
|
+
let currentSubSession = createEmptySubSessionUsage();
|
|
44
|
+
const compactifications = [];
|
|
45
|
+
try {
|
|
46
|
+
const fileContent = await fs.readFile(sessionFile, 'utf8');
|
|
47
|
+
const lines = fileContent.trim().split('\n');
|
|
48
|
+
for (const line of lines) {
|
|
49
|
+
if (!line.trim()) continue;
|
|
50
|
+
try {
|
|
51
|
+
const entry = JSON.parse(line);
|
|
52
|
+
if (entry.type === 'system' && entry.subtype === 'compact_boundary') {
|
|
53
|
+
if (currentSubSession.messageCount > 0) {
|
|
54
|
+
subSessions.push(currentSubSession);
|
|
55
|
+
}
|
|
56
|
+
compactifications.push({
|
|
57
|
+
timestamp: entry.timestamp || null,
|
|
58
|
+
preTokens: entry.compactMetadata?.preTokens || null,
|
|
59
|
+
trigger: entry.compactMetadata?.trigger || 'unknown',
|
|
60
|
+
});
|
|
61
|
+
currentSubSession = createEmptySubSessionUsage();
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (entry.message && entry.message.usage && entry.message.model) {
|
|
65
|
+
// Issue #1501: Skip duplicate JSONL entries (same message ID = same API response)
|
|
66
|
+
const msgId = entry.message.id;
|
|
67
|
+
if (msgId) {
|
|
68
|
+
if (seenMessageIds.has(msgId)) {
|
|
69
|
+
duplicateCount++;
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
seenMessageIds.add(msgId);
|
|
73
|
+
}
|
|
74
|
+
accumulateModelUsage(modelUsage, entry);
|
|
75
|
+
// Issue #1737: Track peak restored-context input per request.
|
|
76
|
+
// Anthropic splits a request's input into input_tokens,
|
|
77
|
+
// cache_creation_input_tokens, and cache_read_input_tokens; all three
|
|
78
|
+
// count toward "how much context will be restored if I resume here".
|
|
79
|
+
const usage = entry.message.usage;
|
|
80
|
+
const requestContext = getRawRequestInputTokens(usage);
|
|
81
|
+
const model = entry.message.model;
|
|
82
|
+
if (requestContext > (peakContextByModel[model] || 0)) {
|
|
83
|
+
peakContextByModel[model] = requestContext;
|
|
84
|
+
}
|
|
85
|
+
if (requestContext > globalPeakContext) {
|
|
86
|
+
globalPeakContext = requestContext;
|
|
87
|
+
}
|
|
88
|
+
if (usage.input_tokens) currentSubSession.inputTokens += usage.input_tokens;
|
|
89
|
+
if (usage.cache_creation_input_tokens) currentSubSession.cacheCreationTokens += usage.cache_creation_input_tokens;
|
|
90
|
+
if (usage.cache_read_input_tokens) currentSubSession.cacheReadTokens += usage.cache_read_input_tokens;
|
|
91
|
+
if (usage.output_tokens) currentSubSession.outputTokens += usage.output_tokens;
|
|
92
|
+
currentSubSession.messageCount++;
|
|
93
|
+
// Issue #1501: Track peak context and output per sub-session
|
|
94
|
+
if (requestContext > currentSubSession.peakContextUsage) {
|
|
95
|
+
currentSubSession.peakContextUsage = requestContext;
|
|
96
|
+
}
|
|
97
|
+
if ((usage.output_tokens || 0) > currentSubSession.peakOutputUsage) {
|
|
98
|
+
currentSubSession.peakOutputUsage = usage.output_tokens || 0;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
} catch {
|
|
102
|
+
// Skip lines that aren't valid JSON
|
|
103
|
+
continue;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
if (currentSubSession.messageCount > 0) {
|
|
107
|
+
subSessions.push(currentSubSession);
|
|
108
|
+
}
|
|
109
|
+
mergeResultModelUsage(modelUsage, resultModelUsage);
|
|
110
|
+
if (Object.keys(modelUsage).length === 0) {
|
|
111
|
+
return null;
|
|
112
|
+
}
|
|
113
|
+
const modelInfoPromises = Object.keys(modelUsage).map(async modelId => {
|
|
114
|
+
const modelInfo = await fetchModelInfoForUsage(modelId);
|
|
115
|
+
return { modelId, modelInfo };
|
|
116
|
+
});
|
|
117
|
+
const modelInfoResults = await Promise.all(modelInfoPromises);
|
|
118
|
+
const modelInfoMap = {};
|
|
119
|
+
for (const { modelId, modelInfo } of modelInfoResults) {
|
|
120
|
+
if (modelInfo) {
|
|
121
|
+
modelInfoMap[modelId] = modelInfo;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
for (const [modelId, usage] of Object.entries(modelUsage)) {
|
|
125
|
+
const modelInfo = modelInfoMap[modelId];
|
|
126
|
+
// Issue #1501: Attach peak context usage per model
|
|
127
|
+
usage.peakContextUsage = peakContextByModel[modelId] || 0;
|
|
128
|
+
// Calculate cost using pricing API
|
|
129
|
+
if (modelInfo) {
|
|
130
|
+
const costData = calculateModelCost(usage, modelInfo, true);
|
|
131
|
+
usage.costUSD = costData.total;
|
|
132
|
+
usage.costBreakdown = costData.breakdown;
|
|
133
|
+
usage.modelName = modelInfo.name || modelId;
|
|
134
|
+
usage.modelInfo = modelInfo;
|
|
135
|
+
} else {
|
|
136
|
+
usage.costUSD = usage._resultCostUSD ?? null;
|
|
137
|
+
usage.costBreakdown = null;
|
|
138
|
+
usage.modelName = modelId;
|
|
139
|
+
// Issue #1539: Use contextWindow/maxOutputTokens from result JSON as fallback model limits
|
|
140
|
+
const ctx = usage._resultContextWindow,
|
|
141
|
+
out = usage._resultMaxOutputTokens;
|
|
142
|
+
usage.modelInfo = ctx || out ? { limit: { context: ctx || null, output: out || null } } : null;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
let totalInputTokens = 0;
|
|
146
|
+
let totalCacheCreationTokens = 0;
|
|
147
|
+
let totalCacheReadTokens = 0;
|
|
148
|
+
let totalOutputTokens = 0;
|
|
149
|
+
let totalCostDecimal = new Decimal(0);
|
|
150
|
+
let hasCostData = false;
|
|
151
|
+
for (const usage of Object.values(modelUsage)) {
|
|
152
|
+
totalInputTokens += usage.inputTokens;
|
|
153
|
+
totalCacheCreationTokens += usage.cacheCreationTokens;
|
|
154
|
+
totalCacheReadTokens += usage.cacheReadTokens;
|
|
155
|
+
totalOutputTokens += usage.outputTokens;
|
|
156
|
+
if (usage.costUSD !== null) {
|
|
157
|
+
totalCostDecimal = totalCostDecimal.plus(new Decimal(usage.costUSD));
|
|
158
|
+
hasCostData = true;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const totalTokens = totalInputTokens + totalCacheCreationTokens + totalOutputTokens;
|
|
162
|
+
return {
|
|
163
|
+
modelUsage,
|
|
164
|
+
inputTokens: totalInputTokens,
|
|
165
|
+
cacheCreationTokens: totalCacheCreationTokens,
|
|
166
|
+
cacheReadTokens: totalCacheReadTokens,
|
|
167
|
+
outputTokens: totalOutputTokens,
|
|
168
|
+
totalTokens,
|
|
169
|
+
totalCostUSD: hasCostData ? totalCostDecimal.toNumber() : null,
|
|
170
|
+
// Issue #1501: Peak context usage (max single-request fill) and dedup stats
|
|
171
|
+
peakContextUsage: globalPeakContext,
|
|
172
|
+
duplicateEntriesSkipped: duplicateCount,
|
|
173
|
+
// Issue #1491/#1501: Sub-session and compactification data (always include for display)
|
|
174
|
+
subSessions,
|
|
175
|
+
compactifications: compactifications.length > 0 ? compactifications : null,
|
|
176
|
+
};
|
|
177
|
+
} catch (readError) {
|
|
178
|
+
throw new Error(`Failed to read session file: ${readError.message}`, { cause: readError });
|
|
179
|
+
}
|
|
180
|
+
};
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex diagnostic-line parsing and sub-session reconstruction.
|
|
3
|
+
*
|
|
4
|
+
* Extracted from src/codex.lib.mjs (issue #2175) so that file stays under the
|
|
5
|
+
* 1350-line early-warning threshold that protects concurrent merges (#1593).
|
|
6
|
+
* Behaviour is unchanged.
|
|
7
|
+
*
|
|
8
|
+
* Codex emits its context window, auto-compact limit and successful
|
|
9
|
+
* `/responses/compact` calls only as `codex_otel.log_only:` diagnostic lines on
|
|
10
|
+
* stderr (with RUST_LOG=debug). Those lines are the only evidence that a
|
|
11
|
+
* compactification happened, which is what lets the token usage of a single
|
|
12
|
+
* Codex run be split back into the sub-sessions the user actually experienced.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { getCumulativeContextInputTokens } from './context-fill.lib.mjs';
|
|
16
|
+
|
|
17
|
+
const CODEX_COMPACT_API_ENDPOINT = '/responses/compact';
|
|
18
|
+
const escapeRegExp = value => String(value).replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
19
|
+
const getCodexDiagnosticValue = (line, key) => {
|
|
20
|
+
const match = line.match(new RegExp(`${escapeRegExp(key)}=(?:"([^"]*)"|([^\\s")]+))`));
|
|
21
|
+
return match?.[1] ?? match?.[2] ?? null;
|
|
22
|
+
};
|
|
23
|
+
const getCodexDiagnosticInteger = (line, key) => {
|
|
24
|
+
const value = getCodexDiagnosticValue(line, key);
|
|
25
|
+
if (value === null) return null;
|
|
26
|
+
const parsed = Number.parseInt(value, 10);
|
|
27
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
28
|
+
};
|
|
29
|
+
const getCodexDiagnosticTimestamp = line => {
|
|
30
|
+
const eventTimestamp = getCodexDiagnosticValue(line, 'event.timestamp');
|
|
31
|
+
if (eventTimestamp) return eventTimestamp;
|
|
32
|
+
const logPrefixMatch = line.match(/^\[(\d{4}-\d{2}-\d{2}T[^\]]+Z)\]/u);
|
|
33
|
+
return logPrefixMatch?.[1] ?? null;
|
|
34
|
+
};
|
|
35
|
+
const isSuccessfulCodexCompactRequestLine = line => {
|
|
36
|
+
if (!line.includes('codex_otel.log_only:')) return false;
|
|
37
|
+
if (!line.includes('event.name="codex.api_request"')) return false;
|
|
38
|
+
if (!line.includes(`endpoint="${CODEX_COMPACT_API_ENDPOINT}"`)) return false;
|
|
39
|
+
const statusCode = getCodexDiagnosticInteger(line, 'http.response.status_code');
|
|
40
|
+
return statusCode === null || (statusCode >= 200 && statusCode < 300);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const splitTokenCountEvenly = (total, partCount) => {
|
|
44
|
+
const safeTotal = Math.max(0, Math.round(total || 0));
|
|
45
|
+
const safePartCount = Math.max(1, Math.round(partCount || 1));
|
|
46
|
+
const base = Math.floor(safeTotal / safePartCount);
|
|
47
|
+
let remainder = safeTotal % safePartCount;
|
|
48
|
+
return Array.from({ length: safePartCount }, () => {
|
|
49
|
+
const value = base + (remainder > 0 ? 1 : 0);
|
|
50
|
+
if (remainder > 0) remainder--;
|
|
51
|
+
return value;
|
|
52
|
+
});
|
|
53
|
+
};
|
|
54
|
+
const splitCodexSubSessionInputTokens = (total, partCount, autoCompactTokenLimit = null) => {
|
|
55
|
+
const safeTotal = Math.max(0, Math.round(total || 0));
|
|
56
|
+
const safePartCount = Math.max(1, Math.round(partCount || 1));
|
|
57
|
+
const safeLimit = Number.isFinite(autoCompactTokenLimit) && autoCompactTokenLimit > 0 ? Math.round(autoCompactTokenLimit) : null;
|
|
58
|
+
if (safePartCount <= 1) return [safeTotal];
|
|
59
|
+
if (safeLimit && safeTotal > safeLimit * (safePartCount - 1)) {
|
|
60
|
+
const chunks = [];
|
|
61
|
+
let remaining = safeTotal;
|
|
62
|
+
for (let i = 0; i < safePartCount - 1; i++) {
|
|
63
|
+
const chunk = Math.min(safeLimit, remaining);
|
|
64
|
+
chunks.push(chunk);
|
|
65
|
+
remaining -= chunk;
|
|
66
|
+
}
|
|
67
|
+
chunks.push(Math.max(0, remaining));
|
|
68
|
+
return chunks;
|
|
69
|
+
}
|
|
70
|
+
return splitTokenCountEvenly(safeTotal, safePartCount);
|
|
71
|
+
};
|
|
72
|
+
const splitTokenCountByWeights = (total, weights) => {
|
|
73
|
+
const safeTotal = Math.max(0, Math.round(total || 0));
|
|
74
|
+
const safeWeights = Array.isArray(weights) && weights.length > 0 ? weights.map(weight => Math.max(0, weight || 0)) : [1];
|
|
75
|
+
const weightTotal = safeWeights.reduce((sum, weight) => sum + weight, 0);
|
|
76
|
+
if (weightTotal <= 0) return splitTokenCountEvenly(safeTotal, safeWeights.length);
|
|
77
|
+
let allocated = 0;
|
|
78
|
+
return safeWeights.map((weight, index) => {
|
|
79
|
+
if (index === safeWeights.length - 1) return Math.max(0, safeTotal - allocated);
|
|
80
|
+
const value = Math.floor((safeTotal * weight) / weightTotal);
|
|
81
|
+
allocated += value;
|
|
82
|
+
return value;
|
|
83
|
+
});
|
|
84
|
+
};
|
|
85
|
+
export const rebuildCodexSubSessionsFromCompactifications = tokenUsage => {
|
|
86
|
+
const compactifications = Array.isArray(tokenUsage.compactifications) ? tokenUsage.compactifications : [];
|
|
87
|
+
if (compactifications.length === 0 || (tokenUsage.stepCount || 0) === 0) {
|
|
88
|
+
tokenUsage.subSessions = Array.isArray(tokenUsage.subSessions) ? tokenUsage.subSessions : [];
|
|
89
|
+
return;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const subSessionCount = compactifications.length + 1;
|
|
93
|
+
const inputChunks = splitCodexSubSessionInputTokens(tokenUsage.inputTokens || 0, subSessionCount, tokenUsage.autoCompactTokenLimit);
|
|
94
|
+
const cacheWriteChunks = splitTokenCountByWeights(tokenUsage.cacheWriteTokens || 0, inputChunks);
|
|
95
|
+
const cacheReadChunks = splitTokenCountByWeights(tokenUsage.cacheReadTokens || 0, inputChunks);
|
|
96
|
+
const outputChunks = splitTokenCountByWeights(tokenUsage.outputTokens || 0, inputChunks);
|
|
97
|
+
tokenUsage.subSessions = inputChunks.map((inputTokens, index) => {
|
|
98
|
+
const cacheCreationTokens = cacheWriteChunks[index] || 0;
|
|
99
|
+
const outputTokens = outputChunks[index] || 0;
|
|
100
|
+
return {
|
|
101
|
+
inputTokens,
|
|
102
|
+
cacheCreationTokens,
|
|
103
|
+
cacheReadTokens: cacheReadChunks[index] || 0,
|
|
104
|
+
outputTokens,
|
|
105
|
+
messageCount: null,
|
|
106
|
+
peakContextUsage: getCumulativeContextInputTokens({ inputTokens, cacheCreationTokens }),
|
|
107
|
+
peakOutputUsage: outputTokens,
|
|
108
|
+
estimated: true,
|
|
109
|
+
source: 'codex.compact-diagnostics',
|
|
110
|
+
compactBoundaryBefore: index === 0 ? null : compactifications[index - 1] || null,
|
|
111
|
+
};
|
|
112
|
+
});
|
|
113
|
+
};
|
|
114
|
+
const recordCodexCompactification = (line, tokenUsage) => {
|
|
115
|
+
if (!isSuccessfulCodexCompactRequestLine(line)) return;
|
|
116
|
+
const timestamp = getCodexDiagnosticTimestamp(line);
|
|
117
|
+
const conversationId = getCodexDiagnosticValue(line, 'conversation.id');
|
|
118
|
+
const existing = tokenUsage.compactifications.find(compact => compact.timestamp === timestamp && compact.conversationId === conversationId);
|
|
119
|
+
if (existing) return;
|
|
120
|
+
tokenUsage.compactifications.push({
|
|
121
|
+
timestamp,
|
|
122
|
+
preTokens: null,
|
|
123
|
+
trigger: 'auto',
|
|
124
|
+
source: 'codex.responses.compact',
|
|
125
|
+
conversationId: conversationId || null,
|
|
126
|
+
});
|
|
127
|
+
};
|
|
128
|
+
export const parseCodexDiagnosticLine = (line, tokenUsage) => {
|
|
129
|
+
const contextLimit = getCodexDiagnosticInteger(line, 'context_window') ?? getCodexDiagnosticInteger(line, 'model_context_window');
|
|
130
|
+
if (contextLimit !== null) tokenUsage.contextLimit = contextLimit;
|
|
131
|
+
|
|
132
|
+
const autoCompactTokenLimit = getCodexDiagnosticInteger(line, 'auto_compact_token_limit') ?? getCodexDiagnosticInteger(line, 'model_auto_compact_token_limit');
|
|
133
|
+
if (autoCompactTokenLimit !== null) tokenUsage.autoCompactTokenLimit = autoCompactTokenLimit;
|
|
134
|
+
recordCodexCompactification(line, tokenUsage);
|
|
135
|
+
};
|