@link-assistant/hive-mind 1.40.1 → 1.40.2

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,16 @@
1
1
  # @link-assistant/hive-mind
2
2
 
3
+ ## 1.40.2
4
+
5
+ ### Patch Changes
6
+
7
+ - 3dbbe9c: fix: improve context, token and cost estimation accuracy for multi-model sessions (#1508)
8
+ - Merge resultModelUsage from Claude Code result JSON into JSONL-based calculations to include sub-agent model tokens (e.g., Haiku) that are missing from JSONL
9
+ - Split token and context usage per model in budget stats PR comments
10
+ - Show per-model cost breakdown in budget stats
11
+ - Fix sub-sessions being duplicated under each model heading in multi-model mode
12
+ - Add verbose diagnostics indicating when token data is sourced from result JSON vs JSONL
13
+
3
14
  ## 1.40.1
4
15
 
5
16
  ### Patch Changes
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@link-assistant/hive-mind",
3
- "version": "1.40.1",
3
+ "version": "1.40.2",
4
4
  "description": "AI-powered issue solver and hive mind for collaborative problem solving",
5
5
  "main": "src/hive.mjs",
6
6
  "type": "module",
@@ -200,6 +200,49 @@ export const displayBudgetStats = async (usage, tokenUsage, log) => {
200
200
  await log(` Total output tokens: ${formatNumber(usage.outputTokens)}`);
201
201
  };
202
202
 
203
+ /**
204
+ * Merge resultModelUsage from Claude Code result JSON into JSONL-based modelUsage map.
205
+ * Issue #1508: The JSONL file may miss sub-agent model entries (e.g., Haiku used internally),
206
+ * while resultModelUsage from the success result event has the authoritative per-model breakdown.
207
+ * @param {Object} modelUsage - Map of model ID to accumulated usage from JSONL parsing
208
+ * @param {Object} resultModelUsage - Per-model usage from Claude Code result JSON event
209
+ */
210
+ export const mergeResultModelUsage = (modelUsage, resultModelUsage) => {
211
+ if (!resultModelUsage || typeof resultModelUsage !== 'object') return;
212
+ for (const [modelId, resultUsage] of Object.entries(resultModelUsage)) {
213
+ if (modelId.startsWith('<') && modelId.endsWith('>')) continue;
214
+ if (!modelUsage[modelId]) {
215
+ modelUsage[modelId] = {
216
+ inputTokens: resultUsage.inputTokens || 0,
217
+ cacheCreationTokens: resultUsage.cacheCreationInputTokens || 0,
218
+ cacheCreation5mTokens: 0,
219
+ cacheCreation1hTokens: 0,
220
+ cacheReadTokens: resultUsage.cacheReadInputTokens || 0,
221
+ outputTokens: resultUsage.outputTokens || 0,
222
+ webSearchRequests: resultUsage.webSearchRequests || 0,
223
+ _sourceResultJson: true,
224
+ };
225
+ if (resultUsage.costUSD != null) {
226
+ modelUsage[modelId]._resultCostUSD = resultUsage.costUSD;
227
+ }
228
+ } else {
229
+ const jsonlUsage = modelUsage[modelId];
230
+ const jsonlTotal = jsonlUsage.inputTokens + jsonlUsage.cacheCreationTokens + jsonlUsage.cacheReadTokens + jsonlUsage.outputTokens;
231
+ const resultTotal = (resultUsage.inputTokens || 0) + (resultUsage.cacheCreationInputTokens || 0) + (resultUsage.cacheReadInputTokens || 0) + (resultUsage.outputTokens || 0);
232
+ if (resultTotal > jsonlTotal) {
233
+ jsonlUsage.inputTokens = resultUsage.inputTokens || 0;
234
+ jsonlUsage.cacheCreationTokens = resultUsage.cacheCreationInputTokens || 0;
235
+ jsonlUsage.cacheReadTokens = resultUsage.cacheReadInputTokens || 0;
236
+ jsonlUsage.outputTokens = resultUsage.outputTokens || 0;
237
+ jsonlUsage._sourceResultJson = true;
238
+ }
239
+ if (resultUsage.costUSD != null) {
240
+ jsonlUsage._resultCostUSD = resultUsage.costUSD;
241
+ }
242
+ }
243
+ }
244
+ };
245
+
203
246
  /**
204
247
  * Format a token count with K/M suffix for compact display
205
248
  * @param {number} tokens - Token count
@@ -212,9 +255,43 @@ const formatTokensCompact = tokens => {
212
255
  };
213
256
 
214
257
  /**
215
- * Build budget stats string for GitHub PR comments (Issue #1491, #1501)
258
+ * Format sub-sessions list for budget stats display
259
+ * @param {Array} subSessions - Array of sub-session usage objects
260
+ * @param {number|null} contextLimit - Context window limit for the model
261
+ * @param {number|null} outputLimit - Output token limit for the model
262
+ * @returns {string} Formatted sub-sessions string
263
+ */
264
+ const formatSubSessionsList = (subSessions, contextLimit, outputLimit) => {
265
+ let result = '\n\nSub sessions (between compact events):';
266
+ for (let i = 0; i < subSessions.length; i++) {
267
+ const sub = subSessions[i];
268
+ const subPeakContext = sub.peakContextUsage || 0;
269
+ const subTotalInput = sub.inputTokens + sub.cacheCreationTokens + sub.cacheReadTokens;
270
+ let line = `\n${i + 1}. `;
271
+ if (contextLimit && subPeakContext > 0) {
272
+ const pct = ((subPeakContext / contextLimit) * 100).toFixed(0);
273
+ line += `${formatTokensCompact(subPeakContext)} / ${formatTokensCompact(contextLimit)} input tokens (${pct}%)`;
274
+ } else {
275
+ line += `${formatTokensCompact(subTotalInput)} input tokens`;
276
+ }
277
+ if (outputLimit) {
278
+ const outPct = ((sub.outputTokens / outputLimit) * 100).toFixed(0);
279
+ line += `; ${formatTokensCompact(sub.outputTokens)} / ${formatTokensCompact(outputLimit)} output tokens (${outPct}%)`;
280
+ } else {
281
+ line += `; ${formatTokensCompact(sub.outputTokens)} output tokens`;
282
+ }
283
+ result += line;
284
+ }
285
+ return result;
286
+ };
287
+
288
+ /**
289
+ * Build budget stats string for GitHub PR comments (Issue #1491, #1501, #1508)
216
290
  * Format requested by user: sub-sessions between compactification events,
217
291
  * per-model breakdown, cumulative totals with cached tokens shown separately.
292
+ * Issue #1508: When multiple models are used, token and context usage is now split by model.
293
+ * Sub-sessions are shown as a global section (not duplicated per model) since JSONL
294
+ * sub-session tracking is global across all models.
218
295
  * @param {Object} tokenUsage - Token usage data from calculateSessionTokens
219
296
  * @param {Object|null} streamTokenUsage - Token usage from stream JSON events (used for comparison, not displayed)
220
297
  * @returns {string} Formatted markdown string for PR comment
@@ -229,6 +306,20 @@ export const buildBudgetStatsString = tokenUsage => {
229
306
  const modelIds = Object.keys(tokenUsage.modelUsage);
230
307
  const isMultiModel = modelIds.length > 1;
231
308
 
309
+ // Issue #1508: For multi-model sessions, show sub-sessions once (globally), not per-model
310
+ // Sub-sessions track compactification boundaries which are session-wide, not model-specific
311
+ const subSessions = tokenUsage.subSessions || [];
312
+ const hasMultipleSubSessions = subSessions.length > 1;
313
+
314
+ if (isMultiModel && hasMultipleSubSessions) {
315
+ // Issue #1508: For multi-model sessions, show global sub-sessions once (not per-model),
316
+ // since sub-sessions track compactification boundaries which are session-wide.
317
+ // Per-model context/output limits are shown below under each model heading.
318
+ const primaryModelId = modelIds[0];
319
+ const primaryUsage = tokenUsage.modelUsage[primaryModelId];
320
+ stats += formatSubSessionsList(subSessions, primaryUsage.modelInfo?.limit?.context, primaryUsage.modelInfo?.limit?.output);
321
+ }
322
+
232
323
  for (const modelId of modelIds) {
233
324
  const usage = tokenUsage.modelUsage[modelId];
234
325
  const modelName = usage.modelName || modelId;
@@ -237,34 +328,29 @@ export const buildBudgetStatsString = tokenUsage => {
237
328
 
238
329
  if (isMultiModel) stats += `\n\n**${modelName}:**`;
239
330
 
240
- // Sub-session display (Issue #1501: show per sub-session stats)
241
- const subSessions = tokenUsage.subSessions || [];
242
- const hasMultipleSubSessions = subSessions.length > 1;
243
-
244
- if (hasMultipleSubSessions) {
245
- // Multiple sub-sessions: show numbered list
246
- stats += '\n\nSub sessions (between compact events):';
247
- for (let i = 0; i < subSessions.length; i++) {
248
- const sub = subSessions[i];
249
- const subPeakContext = sub.peakContextUsage || 0;
250
- const subTotalInput = sub.inputTokens + sub.cacheCreationTokens + sub.cacheReadTokens;
251
- let line = `\n${i + 1}. `;
252
- if (contextLimit && subPeakContext > 0) {
253
- const pct = ((subPeakContext / contextLimit) * 100).toFixed(0);
254
- line += `${formatTokensCompact(subPeakContext)} / ${formatTokensCompact(contextLimit)} input tokens (${pct}%)`;
255
- } else {
256
- line += `${formatTokensCompact(subTotalInput)} input tokens`;
257
- }
258
- if (outputLimit) {
259
- const outPct = ((sub.outputTokens / outputLimit) * 100).toFixed(0);
260
- line += `; ${formatTokensCompact(sub.outputTokens)} / ${formatTokensCompact(outputLimit)} output tokens (${outPct}%)`;
331
+ if (!isMultiModel && hasMultipleSubSessions) {
332
+ // Single-model + multiple sub-sessions: show sub-sessions under that model
333
+ stats += formatSubSessionsList(subSessions, contextLimit, outputLimit);
334
+ } else if (!isMultiModel && !hasMultipleSubSessions) {
335
+ // Single-model + single sub-session: simplified format with context/output limits
336
+ const peakContext = usage.peakContextUsage || 0;
337
+ if (contextLimit) {
338
+ if (peakContext > 0) {
339
+ const pct = ((peakContext / contextLimit) * 100).toFixed(0);
340
+ stats += `\n- Max context window: ${formatTokensCompact(peakContext)} / ${formatTokensCompact(contextLimit)} input tokens (${pct}%)`;
261
341
  } else {
262
- line += `; ${formatTokensCompact(sub.outputTokens)} output tokens`;
342
+ const totalInput = usage.inputTokens + usage.cacheCreationTokens + usage.cacheReadTokens;
343
+ const pct = ((totalInput / contextLimit) * 100).toFixed(0);
344
+ stats += `\n- Context window: ${formatTokensCompact(totalInput)} / ${formatTokensCompact(contextLimit)} tokens (${pct}%)`;
263
345
  }
264
- stats += line;
346
+ }
347
+ if (outputLimit) {
348
+ const outPct = ((usage.outputTokens / outputLimit) * 100).toFixed(0);
349
+ stats += `\n- Max output tokens: ${formatTokensCompact(usage.outputTokens)} / ${formatTokensCompact(outputLimit)} output tokens (${outPct}%)`;
265
350
  }
266
351
  } else {
267
- // Single sub-session (or no sub-sessions): simplified format
352
+ // Multi-model (single or multiple sub-sessions): show per-model context/output limits
353
+ // Issue #1508: Context window and max output tokens should be split by model
268
354
  const peakContext = usage.peakContextUsage || 0;
269
355
  if (contextLimit) {
270
356
  if (peakContext > 0) {
@@ -282,12 +368,17 @@ export const buildBudgetStatsString = tokenUsage => {
282
368
  }
283
369
  }
284
370
 
285
- // Cumulative totals: input tokens + cached shown separately
371
+ // Cumulative totals per model: input tokens + cached shown separately
286
372
  const totalInputNonCached = usage.inputTokens + usage.cacheCreationTokens;
287
373
  const cachedTokens = usage.cacheReadTokens;
288
374
  stats += `\n\nTotal input tokens: ${formatTokensCompact(totalInputNonCached)}`;
289
375
  if (cachedTokens > 0) stats += ` + ${formatTokensCompact(cachedTokens)} cached`;
290
376
  stats += `\nTotal output tokens: ${formatTokensCompact(usage.outputTokens)} output`;
377
+
378
+ // Issue #1508: Show per-model cost when available
379
+ if (usage.costUSD !== null && usage.costUSD !== undefined) {
380
+ stats += `\nCost: $${usage.costUSD.toFixed(6)}`;
381
+ }
291
382
  }
292
383
  }
293
384
 
@@ -12,7 +12,7 @@ import { timeouts, retryLimits, claudeCode, getClaudeEnv, getThinkingLevelToToke
12
12
  import { detectUsageLimit, formatUsageLimitMessage } from './usage-limit.lib.mjs';
13
13
  import { createInteractiveHandler } from './interactive-mode.lib.mjs';
14
14
  import { sanitizeObjectStrings } from './unicode-sanitization.lib.mjs';
15
- import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison } from './claude.budget-stats.lib.mjs';
15
+ import { displayBudgetStats, createEmptySubSessionUsage, accumulateModelUsage, displayModelUsage, displayCostComparison, mergeResultModelUsage } from './claude.budget-stats.lib.mjs';
16
16
  import { buildClaudeResumeCommand } from './claude.command-builder.lib.mjs';
17
17
  import { handleClaudeRuntimeSwitch } from './claude.runtime-switch.lib.mjs'; // see issue #1141
18
18
  import { CLAUDE_MODELS as availableModels } from './models/index.mjs'; // Issue #1221
@@ -480,7 +480,7 @@ export const calculateModelCost = (usage, modelInfo, includeBreakdown = false) =
480
480
  }
481
481
  return totalCost;
482
482
  };
483
- export const calculateSessionTokens = async (sessionId, tempDir) => {
483
+ export const calculateSessionTokens = async (sessionId, tempDir, resultModelUsage = null) => {
484
484
  const os = (await use('os')).default;
485
485
  const homeDir = os.homedir();
486
486
  // Construct the path to the session JSONL file
@@ -576,6 +576,7 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
576
576
  if (currentSubSession.messageCount > 0) {
577
577
  subSessions.push(currentSubSession);
578
578
  }
579
+ mergeResultModelUsage(modelUsage, resultModelUsage);
579
580
  // If no usage data was found, return null
580
581
  if (Object.keys(modelUsage).length === 0) {
581
582
  return null;
@@ -605,7 +606,7 @@ export const calculateSessionTokens = async (sessionId, tempDir) => {
605
606
  usage.modelName = modelInfo.name || modelId;
606
607
  usage.modelInfo = modelInfo; // Store complete model info
607
608
  } else {
608
- usage.costUSD = null;
609
+ usage.costUSD = usage._resultCostUSD ?? null;
609
610
  usage.costBreakdown = null;
610
611
  usage.modelName = modelId;
611
612
  usage.modelInfo = null;
@@ -1307,7 +1308,7 @@ export const executeClaudeCommand = async params => {
1307
1308
  // Calculate and display total token usage from session JSONL file
1308
1309
  if (sessionId && tempDir) {
1309
1310
  try {
1310
- const tokenUsage = await calculateSessionTokens(sessionId, tempDir);
1311
+ const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
1311
1312
  if (tokenUsage) {
1312
1313
  // Issue #1501: Log deduplication stats in verbose mode
1313
1314
  if (tokenUsage.duplicateEntriesSkipped > 0) {
@@ -1320,9 +1321,14 @@ export const executeClaudeCommand = async params => {
1320
1321
  // Display per-model breakdown
1321
1322
  if (tokenUsage.modelUsage) {
1322
1323
  const modelIds = Object.keys(tokenUsage.modelUsage);
1324
+ const modelsFromResult = modelIds.filter(id => tokenUsage.modelUsage[id]._sourceResultJson);
1325
+ if (modelsFromResult.length > 0) {
1326
+ await log(`📊 Token data supplemented from result JSON for: ${modelsFromResult.join(', ')}`, { verbose: true });
1327
+ }
1323
1328
  for (const modelId of modelIds) {
1324
1329
  const usage = tokenUsage.modelUsage[modelId];
1325
- await log(`\n 📊 ${usage.modelName || modelId}:`);
1330
+ const sourceNote = usage._sourceResultJson ? ' (from result JSON)' : '';
1331
+ await log(`\n 📊 ${usage.modelName || modelId}:${sourceNote}`);
1326
1332
  await displayModelUsage(usage, log);
1327
1333
  // Display budget stats if flag is enabled
1328
1334
  if (argv.tokensBudgetStats && usage.modelInfo?.limit) {
@@ -399,7 +399,7 @@ export async function attachLogToGitHub(options) {
399
399
  if (totalCostUSD === null && sessionId && tempDir && !errorMessage) {
400
400
  try {
401
401
  const { calculateSessionTokens } = await import('./claude.lib.mjs');
402
- const tokenUsage = await calculateSessionTokens(sessionId, tempDir);
402
+ const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
403
403
  if (tokenUsage) {
404
404
  if (tokenUsage.totalCostUSD !== null && tokenUsage.totalCostUSD !== undefined) {
405
405
  totalCostUSD = tokenUsage.totalCostUSD;
@@ -1120,6 +1120,20 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
1120
1120
  latestAnthropicCost = toolResult.anthropicTotalCostUSD;
1121
1121
  }
1122
1122
 
1123
+ // Issue #1508: Compute budget stats for auto-restart-until-mergeable log comment
1124
+ let autoMergeBudgetStatsData = null;
1125
+ if (argv.tokensBudgetStats && latestSessionId && tempDir) {
1126
+ try {
1127
+ const { calculateSessionTokens } = await import('./claude.lib.mjs');
1128
+ const tokenUsage = await calculateSessionTokens(latestSessionId, tempDir, toolResult.resultModelUsage);
1129
+ if (tokenUsage) {
1130
+ autoMergeBudgetStatsData = { tokenUsage, streamTokenUsage: toolResult.streamTokenUsage || null };
1131
+ }
1132
+ } catch (budgetError) {
1133
+ if (argv.verbose) await log(` ⚠️ Could not calculate budget stats: ${budgetError.message}`, { verbose: true });
1134
+ }
1135
+ }
1136
+
1123
1137
  // Attach log if enabled
1124
1138
  const shouldAttachLogs = argv.attachLogs || argv['attach-logs'];
1125
1139
  if (prNumber && shouldAttachLogs) {
@@ -1149,6 +1163,9 @@ Once the billing issue is resolved, you can re-run the CI checks or push a new c
1149
1163
  // Issue #1225: Pass model and tool info for PR comments
1150
1164
  requestedModel: argv.model,
1151
1165
  tool: argv.tool || 'claude',
1166
+ // Issue #1508: Include budget stats (context/token/cost) for auto-restart log
1167
+ resultModelUsage: toolResult.resultModelUsage || null,
1168
+ budgetStatsData: autoMergeBudgetStatsData,
1152
1169
  });
1153
1170
  await log(formatAligned('', '✅ Session log uploaded to PR', '', 2));
1154
1171
  }
@@ -502,7 +502,7 @@ export const verifyResults = async (owner, repo, branchName, issueNumber, prNumb
502
502
  if (argv.tokensBudgetStats && sessionId && tempDir) {
503
503
  try {
504
504
  const { calculateSessionTokens } = await import('./claude.lib.mjs');
505
- const tokenUsage = await calculateSessionTokens(sessionId, tempDir);
505
+ const tokenUsage = await calculateSessionTokens(sessionId, tempDir, resultModelUsage);
506
506
  if (tokenUsage) {
507
507
  budgetStatsData = { tokenUsage, streamTokenUsage };
508
508
  }
@@ -300,6 +300,8 @@ export const watchForFeedback = async params => {
300
300
  // Issue #1225: Pass model and tool info for PR comments
301
301
  requestedModel: argv.model,
302
302
  tool: argv.tool || 'claude',
303
+ // Issue #1508: Pass model usage for failure log (cost info per model)
304
+ resultModelUsage: toolResult.resultModelUsage || null,
303
305
  });
304
306
 
305
307
  if (logUploadSuccess) {
@@ -338,6 +340,20 @@ export const watchForFeedback = async params => {
338
340
  }
339
341
  }
340
342
 
343
+ // Issue #1508: Compute budget stats for auto-restart log comment
344
+ let autoRestartBudgetStatsData = null;
345
+ if (argv.tokensBudgetStats && latestSessionId && tempDir) {
346
+ try {
347
+ const { calculateSessionTokens } = await import('./claude.lib.mjs');
348
+ const tokenUsage = await calculateSessionTokens(latestSessionId, tempDir, toolResult.resultModelUsage);
349
+ if (tokenUsage) {
350
+ autoRestartBudgetStatsData = { tokenUsage, streamTokenUsage: toolResult.streamTokenUsage || null };
351
+ }
352
+ } catch (budgetError) {
353
+ if (argv.verbose) await log(` ⚠️ Could not calculate budget stats: ${budgetError.message}`, { verbose: true });
354
+ }
355
+ }
356
+
341
357
  // Issue #1107: Attach log after each auto-restart session with its own cost estimation
342
358
  // This ensures each restart has its own log comment instead of one combined log at the end
343
359
  const shouldAttachLogs = argv.attachLogs || argv['attach-logs'];
@@ -369,6 +385,9 @@ export const watchForFeedback = async params => {
369
385
  // Issue #1225: Pass model and tool info for PR comments
370
386
  requestedModel: argv.model,
371
387
  tool: argv.tool || 'claude',
388
+ // Issue #1508: Include budget stats (context/token/cost) for auto-restart log
389
+ resultModelUsage: toolResult.resultModelUsage || null,
390
+ budgetStatsData: autoRestartBudgetStatsData,
372
391
  });
373
392
 
374
393
  if (logUploadSuccess) {