@link-assistant/hive-mind 2.13.4 → 2.13.5

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,24 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 2.13.5
4
+
5
+ ### Patch Changes
6
+
7
+ - 0d0b96a: Fix every error and warning reported by CI/CD (issue #2175).
8
+
9
+ - The release job now lands the version bump through a pull request when a
10
+ repository ruleset rejects the direct push to `main` (GH013), instead of
11
+ retrying it as a lost race and failing the release.
12
+ - The release gate asks the npm registry, not the `.changeset` folder, whether
13
+ the current version is published, so an interrupted release self-heals on the
14
+ next push instead of being silently skipped.
15
+ - Release assets are uploaded with the `gh` CLI, removing the last action
16
+ pinned to the deprecated Node 20 runtime.
17
+ - Eight source files that had drifted into the 1350-line warning band were
18
+ reduced by extracting cohesive modules; behaviour is unchanged.
19
+
20
+ - 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/`.
21
+
3
22
  ## 2.13.4
4
23
 
5
24
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "2.13.4",
3
+ "version": "2.13.5",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
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;
@@ -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 Decimal from 'decimal.js-light';
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
- export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsage = null, options = {}) => {
156
- const os = (await use('os')).default;
157
- const homeDir = options.homeDir || os.homedir();
158
- const fetchModelInfoForUsage = options.fetchModelInfo || fetchModelInfo;
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
+ };