tokentracker-cli 0.5.71 → 0.5.73

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.
Files changed (24) hide show
  1. package/README.md +9 -0
  2. package/dashboard/dist/assets/{Card-5ebScQbM.js → Card-D_q1XGfK.js} +1 -1
  3. package/dashboard/dist/assets/{DashboardPage-B32RhUB1.js → DashboardPage-CuBSoNgI.js} +1 -1
  4. package/dashboard/dist/assets/{FadeIn-CHjFUAEe.js → FadeIn-ClpHby-T.js} +1 -1
  5. package/dashboard/dist/assets/{IpCheckPage-CrbKufId.js → IpCheckPage-B5TpHE6G.js} +1 -1
  6. package/dashboard/dist/assets/{LeaderboardPage-CVKBhGzY.js → LeaderboardPage-BOrtGeIf.js} +1 -1
  7. package/dashboard/dist/assets/LeaderboardProfilePage-C8xexZ08.js +1 -0
  8. package/dashboard/dist/assets/{LimitsPage-BwQ4isbM.js → LimitsPage-CERWQyIU.js} +1 -1
  9. package/dashboard/dist/assets/{SettingsPage-Y3Znzs1i.js → SettingsPage-DUwt4x-H.js} +1 -1
  10. package/dashboard/dist/assets/{WidgetsPage-tNojCBaj.js → WidgetsPage-Cmr2tbD7.js} +1 -1
  11. package/dashboard/dist/assets/{download-CQASvwEL.js → download-M8e1PJC-.js} +1 -1
  12. package/dashboard/dist/assets/{leaderboard-columns-DFG1TWe9.js → leaderboard-columns-Dcg9r7R2.js} +1 -1
  13. package/dashboard/dist/assets/{main-Cv6YpYdZ.js → main-D1VdJk4V.js} +2 -2
  14. package/dashboard/dist/assets/{use-limits-display-prefs-BUrHqTkA.js → use-limits-display-prefs-CSj55sfK.js} +1 -1
  15. package/dashboard/dist/assets/{use-usage-limits-D-gv2i1R.js → use-usage-limits-BuWINUAm.js} +1 -1
  16. package/dashboard/dist/index.html +1 -1
  17. package/dashboard/dist/share.html +1 -1
  18. package/package.json +1 -1
  19. package/src/commands/status.js +11 -1
  20. package/src/commands/sync.js +65 -1
  21. package/src/lib/diagnostics.js +30 -0
  22. package/src/lib/local-api.js +176 -57
  23. package/src/lib/rollout.js +667 -4
  24. package/dashboard/dist/assets/LeaderboardProfilePage-CbpzxvLA.js +0 -1
@@ -676,12 +676,21 @@ async function parseOpenclawSessionFile({
676
676
 
677
677
  const model = normalizeModelInput(msg.model) || DEFAULT_MODEL;
678
678
 
679
+ // Per CLAUDE.md: cached_input_tokens = cache reads,
680
+ // cache_creation_input_tokens = cache writes. Also re-derive total_tokens
681
+ // as input + output + cache_creation + cache_read so cost math works
682
+ // even when the source's own totalTokens is stale or rounded.
683
+ const inputTok = Number(usage.input || 0);
684
+ const cacheReadTok = Number(usage.cacheRead || 0);
685
+ const cacheWriteTok = Number(usage.cacheWrite || 0);
686
+ const outputTok = Number(usage.output || 0);
679
687
  const delta = {
680
- input_tokens: Number(usage.input || 0),
681
- cached_input_tokens: Number((usage.cacheRead || 0) + (usage.cacheWrite || 0)),
682
- output_tokens: Number(usage.output || 0),
688
+ input_tokens: inputTok,
689
+ cached_input_tokens: cacheReadTok,
690
+ cache_creation_input_tokens: cacheWriteTok,
691
+ output_tokens: outputTok,
683
692
  reasoning_output_tokens: 0,
684
- total_tokens: Number(usage.totalTokens || 0),
693
+ total_tokens: inputTok + outputTok + cacheReadTok + cacheWriteTok,
685
694
  conversation_count: 1,
686
695
  };
687
696
 
@@ -2083,6 +2092,7 @@ function sameGeminiTotals(a, b) {
2083
2092
  return (
2084
2093
  a.input_tokens === b.input_tokens &&
2085
2094
  a.cached_input_tokens === b.cached_input_tokens &&
2095
+ a.cache_creation_input_tokens === b.cache_creation_input_tokens &&
2086
2096
  a.output_tokens === b.output_tokens &&
2087
2097
  a.reasoning_output_tokens === b.reasoning_output_tokens &&
2088
2098
  a.total_tokens === b.total_tokens
@@ -2097,12 +2107,20 @@ function diffGeminiTotals(current, previous) {
2097
2107
  const totalReset = (current.total_tokens || 0) < (previous.total_tokens || 0);
2098
2108
  if (totalReset) return current;
2099
2109
 
2110
+ // Must include cache_creation_input_tokens in both the equality check and
2111
+ // the delta — OpenCode routes through this diff and its cache.write number
2112
+ // would otherwise be permanently reported as zero. Gemini itself always
2113
+ // emits cache_creation=0 so the extra field is a no-op for Gemini.
2100
2114
  const delta = {
2101
2115
  input_tokens: Math.max(0, (current.input_tokens || 0) - (previous.input_tokens || 0)),
2102
2116
  cached_input_tokens: Math.max(
2103
2117
  0,
2104
2118
  (current.cached_input_tokens || 0) - (previous.cached_input_tokens || 0),
2105
2119
  ),
2120
+ cache_creation_input_tokens: Math.max(
2121
+ 0,
2122
+ (current.cache_creation_input_tokens || 0) - (previous.cache_creation_input_tokens || 0),
2123
+ ),
2106
2124
  output_tokens: Math.max(0, (current.output_tokens || 0) - (previous.output_tokens || 0)),
2107
2125
  reasoning_output_tokens: Math.max(
2108
2126
  0,
@@ -2651,6 +2669,16 @@ function readKiroDbTokens(dbPath, sinceId) {
2651
2669
  // The fallback file does not include per-row timestamps, so newly appended rows are
2652
2670
  // bucketed using the file mtime observed during this sync. We track a separate JSONL
2653
2671
  // cursor so it never shares state with the SQLite path.
2672
+ function countKiroJsonlLines(jsonlPath) {
2673
+ if (!jsonlPath || !fssync.existsSync(jsonlPath)) return 0;
2674
+ try {
2675
+ const raw = fssync.readFileSync(jsonlPath, "utf8");
2676
+ return raw.split("\n").filter((l) => l.trim()).length;
2677
+ } catch (_e) {
2678
+ return 0;
2679
+ }
2680
+ }
2681
+
2654
2682
  function readKiroJsonlTokens(jsonlPath, sinceLineIndex) {
2655
2683
  if (!jsonlPath || !fssync.existsSync(jsonlPath)) {
2656
2684
  return { rows: [], lineCount: 0, reset: false };
@@ -2795,6 +2823,14 @@ async function parseKiroIncremental({ dbPath, jsonlPath, cursors, queuePath, onP
2795
2823
  if (fssync.existsSync(resolvedDbPath)) {
2796
2824
  rows = readKiroDbTokens(resolvedDbPath, lastDbId);
2797
2825
  usingDb = true;
2826
+ // DB and JSONL are siblings for the same usage events. If the DB ever
2827
+ // disappears (corrupted / wiped) and we fall back to JSONL in a later
2828
+ // run, we must not re-read lines that the DB path already consumed.
2829
+ // Advance the JSONL line cursor to the current file tail.
2830
+ if (fssync.existsSync(resolvedJsonlPath)) {
2831
+ const tailLineCount = countKiroJsonlLines(resolvedJsonlPath);
2832
+ if (tailLineCount > nextJsonlLine) nextJsonlLine = tailLineCount;
2833
+ }
2798
2834
  } else if (fssync.existsSync(resolvedJsonlPath)) {
2799
2835
  const jsonlResult = readKiroJsonlTokens(resolvedJsonlPath, lastJsonlLine);
2800
2836
  rows = jsonlResult.rows;
@@ -3029,6 +3065,625 @@ function resolveKimiDefaultModel(env = process.env) {
3029
3065
  }
3030
3066
  // ─────────────────────────────────────────────────────────────────────────────
3031
3067
 
3068
+ // ─────────────────────────────────────────────────────────────────────────────
3069
+ // Kiro CLI — reads historical conversation state from
3070
+ // ~/Library/Application Support/kiro-cli/data.sqlite3 (table conversations_v2).
3071
+ // Kiro CLI does NOT store explicit token counts locally. Each request row
3072
+ // carries: user_prompt_length (chars), response_size (chars), model_id,
3073
+ // request_start_timestamp_ms, message_id. We approximate tokens at 4 chars /
3074
+ // token. Source is merged with Kiro IDE (source='kiro') and canonicalized
3075
+ // model names are used so CLI and IDE rows collapse when they refer to the
3076
+ // same underlying Bedrock model. Cursor state is per-request-id so mutable
3077
+ // requests can be reprocessed (subtract-old/add-new on fingerprint change).
3078
+ // ─────────────────────────────────────────────────────────────────────────────
3079
+
3080
+ const KIRO_CLI_CHARS_PER_TOKEN = 4;
3081
+
3082
+ function resolveKiroCliDbPath(env = process.env) {
3083
+ if (env.KIRO_CLI_DB_PATH) return env.KIRO_CLI_DB_PATH;
3084
+ const home = env.HOME || require("node:os").homedir();
3085
+ return path.join(home, "Library", "Application Support", "kiro-cli", "data.sqlite3");
3086
+ }
3087
+
3088
+ // Lists ~/.kiro/sessions/cli/{uuid}.json files. Includes files whose sibling
3089
+ // .lock is present — we read those as tail-only snapshots so a running
3090
+ // session's completed turns still land in the queue on the next sync. The
3091
+ // .json files are rewritten atomically by kiro-cli on each turn flush, so
3092
+ // a stale read just means we'll pick up the rest next time.
3093
+ function resolveKiroCliSessionFiles(env = process.env) {
3094
+ const home = require("node:os").homedir();
3095
+ const kiroHome = env.KIRO_HOME || path.join(home, ".kiro");
3096
+ const sessionsDir = path.join(kiroHome, "sessions", "cli");
3097
+ if (!fssync.existsSync(sessionsDir)) return [];
3098
+ const files = [];
3099
+ try {
3100
+ for (const entry of fssync.readdirSync(sessionsDir)) {
3101
+ if (!entry.endsWith(".json")) continue;
3102
+ files.push(path.join(sessionsDir, entry));
3103
+ }
3104
+ } catch {
3105
+ // ignore read errors
3106
+ }
3107
+ return files;
3108
+ }
3109
+
3110
+ // Build char-count maps from a .jsonl sibling file. Lets us approximate
3111
+ // per-turn tokens when the live session's input_token_count /
3112
+ // output_token_count fields are 0 (kiro-cli does not persist real token
3113
+ // counts; billing is credit-based).
3114
+ //
3115
+ // Returns:
3116
+ // byMessage: message_id -> assistant+toolUse char count
3117
+ // messageKind: message_id -> jsonl event kind
3118
+ // turnPromptChars: turn_index -> input chars attributed to that turn
3119
+ //
3120
+ // Input attribution: Kiro CLI's turn.message_ids only records
3121
+ // AssistantMessage / ToolResults ids, NEVER the user Prompt id. So the
3122
+ // Prompt event is invisible if you look it up by message_id. To recover
3123
+ // the per-turn user input, we walk the jsonl in timestamp order and buffer
3124
+ // Prompt chars until the next AssistantMessage that belongs to a turn
3125
+ // (turnMessageIds provides that mapping). The first such AssistantMessage
3126
+ // "claims" the buffered Prompt chars for its turn, and the buffer resets.
3127
+ // Later cycles within the same turn (Assistant → ToolResults → Assistant)
3128
+ // do not re-attribute.
3129
+ function readKiroCliMessageChars(jsonlPath, turnMessageIds) {
3130
+ const result = {
3131
+ byMessage: new Map(),
3132
+ messageKind: new Map(),
3133
+ turnPromptChars: new Map(),
3134
+ };
3135
+ if (!jsonlPath || !fssync.existsSync(jsonlPath)) return result;
3136
+ let raw;
3137
+ try {
3138
+ raw = fssync.readFileSync(jsonlPath, "utf8");
3139
+ } catch {
3140
+ return result;
3141
+ }
3142
+ const midToTurn =
3143
+ turnMessageIds instanceof Map ? turnMessageIds : new Map();
3144
+ const attributedTurns = new Set();
3145
+ let pendingPromptChars = 0;
3146
+ for (const line of raw.split("\n")) {
3147
+ if (!line.trim()) continue;
3148
+ let evt;
3149
+ try {
3150
+ evt = JSON.parse(line);
3151
+ } catch {
3152
+ continue;
3153
+ }
3154
+ const data = evt?.data;
3155
+ if (!data || typeof data !== "object") continue;
3156
+ const mid = data.message_id;
3157
+ if (!mid) continue;
3158
+ const content = Array.isArray(data.content) ? data.content : [];
3159
+ let chars = 0;
3160
+ for (const c of content) {
3161
+ if (!c || typeof c !== "object") continue;
3162
+ if (c.kind === "text" && typeof c.data === "string") {
3163
+ chars += c.data.length;
3164
+ } else if (c.kind === "toolUse" && c.data && typeof c.data === "object") {
3165
+ // tool-use invocations count toward output; stringify the input payload
3166
+ try {
3167
+ chars += JSON.stringify(c.data.input || {}).length;
3168
+ } catch {
3169
+ // ignore
3170
+ }
3171
+ }
3172
+ }
3173
+ result.byMessage.set(mid, (result.byMessage.get(mid) || 0) + chars);
3174
+ if (!result.messageKind.has(mid)) result.messageKind.set(mid, evt.kind);
3175
+
3176
+ if (evt.kind === "Prompt") {
3177
+ pendingPromptChars += chars;
3178
+ } else if (evt.kind === "AssistantMessage" && midToTurn.has(mid)) {
3179
+ const turnIdx = midToTurn.get(mid);
3180
+ if (!attributedTurns.has(turnIdx)) {
3181
+ result.turnPromptChars.set(turnIdx, pendingPromptChars);
3182
+ attributedTurns.add(turnIdx);
3183
+ pendingPromptChars = 0;
3184
+ }
3185
+ }
3186
+ }
3187
+ return result;
3188
+ }
3189
+
3190
+ // Extract flat per-turn records from a live session .json + its .jsonl
3191
+ // sibling. Returns [{ request_id, model_id, request_start_timestamp_ms,
3192
+ // input_tokens, output_tokens }]. We use the same request_id dedup slot as
3193
+ // the SQLite path so mutations (turn rewritten on next flush) go through
3194
+ // the subtract-old/add-new path in parseKiroCliIncremental.
3195
+ function readKiroCliSessionTurns(jsonPath) {
3196
+ if (!jsonPath || !fssync.existsSync(jsonPath)) return [];
3197
+ let parsed;
3198
+ try {
3199
+ parsed = JSON.parse(fssync.readFileSync(jsonPath, "utf8"));
3200
+ } catch {
3201
+ return [];
3202
+ }
3203
+ if (!parsed || typeof parsed !== "object") return [];
3204
+ const turns = Array.isArray(
3205
+ parsed?.session_state?.conversation_metadata?.user_turn_metadatas,
3206
+ )
3207
+ ? parsed.session_state.conversation_metadata.user_turn_metadatas
3208
+ : [];
3209
+ if (turns.length === 0) return [];
3210
+
3211
+ const modelInfo = parsed?.session_state?.rts_model_state?.model_info || null;
3212
+ const sessionModelId =
3213
+ (modelInfo && (modelInfo.model_id || modelInfo.model_name)) || null;
3214
+ const sessionId =
3215
+ typeof parsed.session_id === "string" ? parsed.session_id : path.basename(jsonPath, ".json");
3216
+
3217
+ // Build turn_index -> Set(message_id) so the jsonl walker can attribute
3218
+ // orphaned Prompt events (not referenced by turn.message_ids) to the
3219
+ // right turn. The turn.message_ids list only contains AssistantMessage
3220
+ // and ToolResults ids; Prompt ids appear in the jsonl stream only.
3221
+ const turnMessageIds = new Map();
3222
+ for (let i = 0; i < turns.length; i++) {
3223
+ const t = turns[i];
3224
+ if (!t || !Array.isArray(t.message_ids)) continue;
3225
+ for (const mid of t.message_ids) {
3226
+ if (typeof mid === "string" && mid) turnMessageIds.set(mid, i);
3227
+ }
3228
+ }
3229
+
3230
+ // Load sibling .jsonl for char-count fallback.
3231
+ const jsonlPath = jsonPath.replace(/\.json$/, ".jsonl");
3232
+ const charMap = readKiroCliMessageChars(jsonlPath, turnMessageIds);
3233
+
3234
+ const flat = [];
3235
+ for (let turnIdx = 0; turnIdx < turns.length; turnIdx++) {
3236
+ const turn = turns[turnIdx];
3237
+ if (!turn || typeof turn !== "object") continue;
3238
+ const loopRand =
3239
+ (turn.loop_id && (turn.loop_id.rand ?? turn.loop_id.seed)) || null;
3240
+ const messageIds = Array.isArray(turn.message_ids) ? turn.message_ids : [];
3241
+ const requestId = loopRand != null ? `${sessionId}:${loopRand}` : (messageIds[0] || null);
3242
+ if (!requestId) continue;
3243
+
3244
+ // Prefer real token counts if kiro-cli populated them.
3245
+ let inputTokens = toNonNegativeInt(turn.input_token_count);
3246
+ let outputTokens = toNonNegativeInt(turn.output_token_count);
3247
+
3248
+ if (inputTokens === 0 && outputTokens === 0) {
3249
+ // Fall back to char-count approximation. Input chars come from the
3250
+ // sequential Prompt attribution (see readKiroCliMessageChars);
3251
+ // output chars come from AssistantMessage+toolUse bodies referenced
3252
+ // by turn.message_ids.
3253
+ const promptChars = charMap.turnPromptChars.get(turnIdx) || 0;
3254
+ let assistantChars = 0;
3255
+ for (const mid of messageIds) {
3256
+ const chars = charMap.byMessage.get(mid) || 0;
3257
+ const kind = charMap.messageKind.get(mid);
3258
+ if (kind === "AssistantMessage") assistantChars += chars;
3259
+ }
3260
+ inputTokens = Math.floor(promptChars / KIRO_CLI_CHARS_PER_TOKEN);
3261
+ outputTokens = Math.floor(assistantChars / KIRO_CLI_CHARS_PER_TOKEN);
3262
+ }
3263
+
3264
+ // Timestamp: end_timestamp is an ISO string; coerce to ms.
3265
+ const tsRaw = turn.end_timestamp || turn.start_timestamp;
3266
+ const tsMs = tsRaw ? Date.parse(tsRaw) : NaN;
3267
+ if (!Number.isFinite(tsMs) || tsMs <= 0) continue;
3268
+
3269
+ flat.push({
3270
+ request_id: requestId,
3271
+ session_model_id: sessionModelId,
3272
+ message_id: messageIds[0] || null,
3273
+ model_id: turn.model_id || sessionModelId,
3274
+ request_start_timestamp_ms: tsMs,
3275
+ // For the parser, we feed the ALREADY-approximated tokens directly via
3276
+ // a special sentinel field. The parser will divide chars by
3277
+ // KIRO_CLI_CHARS_PER_TOKEN; bypass that by pre-multiplying here.
3278
+ user_prompt_length: inputTokens * KIRO_CLI_CHARS_PER_TOKEN,
3279
+ response_size: outputTokens * KIRO_CLI_CHARS_PER_TOKEN,
3280
+ });
3281
+ }
3282
+ return flat;
3283
+ }
3284
+
3285
+ // Canonicalize a Kiro-CLI-emitted model id so IDE and CLI rows collapse when
3286
+ // they refer to the same underlying Bedrock model. Examples:
3287
+ // anthropic.claude-sonnet-4-20250514-v1:0 -> claude-sonnet-4
3288
+ // claude-opus-4.6 -> claude-opus-4.6
3289
+ // claude-sonnet-4.5 -> claude-sonnet-4.5
3290
+ // auto -> null (caller uses 'kiro-cli-agent')
3291
+ // <unknown/falsy> -> null (caller falls back to 'kiro-cli-agent')
3292
+ //
3293
+ // "auto" is treated as unknown because Kiro CLI's auto-routing does not
3294
+ // expose the underlying Bedrock model id in the session file. Returning
3295
+ // null lets pricing fall into the kiro-cli-agent bucket (sonnet-4 rates)
3296
+ // rather than the literal "auto" string which matches Cursor's composer-1
3297
+ // pricing by accident.
3298
+ function canonicalizeKiroCliModelId(raw) {
3299
+ if (!raw || typeof raw !== "string") return null;
3300
+ let name = raw.trim();
3301
+ if (!name) return null;
3302
+ name = name.toLowerCase();
3303
+ if (name === "auto") return null;
3304
+ // Strip provider prefix (anthropic., aws., openai., or a full Bedrock ARN).
3305
+ name = name.replace(
3306
+ /^(?:arn:aws:bedrock:[^:]*:[^:]*:(?:foundation-model\/)?|anthropic\.|openai\.|aws\.)/,
3307
+ "",
3308
+ );
3309
+ // Strip Bedrock revision suffix `:N`.
3310
+ name = name.replace(/:\d+$/, "");
3311
+ // Strip date + vN suffix (e.g. "-20250514-v1"), or lone "-vN", or lone date.
3312
+ name = name.replace(/-\d{8}-v\d+$/i, "");
3313
+ name = name.replace(/-v\d+$/i, "");
3314
+ name = name.replace(/-\d{8}$/, "");
3315
+ // Strip trailing ".v1" or similar Anthropic-on-Bedrock tails if present.
3316
+ name = name.replace(/\.v\d+$/i, "");
3317
+ return name || null;
3318
+ }
3319
+
3320
+ // Read Kiro CLI requests using SQL-side json_extract so we don't pull the
3321
+ // full (93 MB-ish) conversations_v2 blob back through sqlite3 -json.
3322
+ function readKiroCliRequests(dbPath) {
3323
+ if (!dbPath || !fssync.existsSync(dbPath)) return [];
3324
+ let raw;
3325
+ try {
3326
+ raw = cp.execFileSync(
3327
+ "sqlite3",
3328
+ [
3329
+ "-json",
3330
+ dbPath,
3331
+ "SELECT conversation_id, " +
3332
+ "json_extract(value, '$.model_info.model_id') AS session_model_id, " +
3333
+ "json_extract(value, '$.user_turn_metadata.requests') AS requests_json " +
3334
+ "FROM conversations_v2 " +
3335
+ "WHERE json_extract(value, '$.user_turn_metadata.requests') IS NOT NULL",
3336
+ ],
3337
+ { encoding: "utf8", maxBuffer: 128 * 1024 * 1024, timeout: 120_000 },
3338
+ );
3339
+ } catch {
3340
+ return [];
3341
+ }
3342
+ if (!raw || !raw.trim()) return [];
3343
+ let rows;
3344
+ try {
3345
+ rows = JSON.parse(raw);
3346
+ } catch {
3347
+ return [];
3348
+ }
3349
+ if (!Array.isArray(rows)) return [];
3350
+ const flat = [];
3351
+ for (const row of rows) {
3352
+ let requests;
3353
+ try {
3354
+ requests = JSON.parse(row.requests_json || "[]");
3355
+ } catch {
3356
+ continue;
3357
+ }
3358
+ if (!Array.isArray(requests)) continue;
3359
+ for (const r of requests) {
3360
+ if (!r || typeof r !== "object") continue;
3361
+ flat.push({
3362
+ conversation_id: row.conversation_id,
3363
+ session_model_id: row.session_model_id || null,
3364
+ request_id: r.request_id || null,
3365
+ message_id: r.message_id || null,
3366
+ user_prompt_length: r.user_prompt_length,
3367
+ response_size: r.response_size,
3368
+ model_id: r.model_id || null,
3369
+ request_start_timestamp_ms: r.request_start_timestamp_ms,
3370
+ });
3371
+ }
3372
+ }
3373
+ return flat;
3374
+ }
3375
+
3376
+ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onProgress, env } = {}) {
3377
+ await ensureDir(path.dirname(queuePath));
3378
+ const kiroCliState =
3379
+ cursors.kiroCli && typeof cursors.kiroCli === "object" ? cursors.kiroCli : {};
3380
+ const seenIds = new Set(Array.isArray(kiroCliState.seenIds) ? kiroCliState.seenIds : []);
3381
+
3382
+ // Back-compat branch: if caller explicitly passes sessionFiles (an array of
3383
+ // per-session .json paths, the old contract used in tests/fixtures), read
3384
+ // them as user_turn_metadatas. New default path below reads the SQLite DB.
3385
+ if (Array.isArray(sessionFiles)) {
3386
+ return parseKiroCliFromSessionFiles({
3387
+ sessionFiles,
3388
+ cursors,
3389
+ queuePath,
3390
+ onProgress,
3391
+ env,
3392
+ kiroCliState,
3393
+ seenIds,
3394
+ });
3395
+ }
3396
+
3397
+ const resolvedEnv = env || process.env;
3398
+ const dbPath = resolveKiroCliDbPath(resolvedEnv);
3399
+
3400
+ // Combine two sources under the same (source='kiro', cursors.kiroCli)
3401
+ // namespace: historical rows from the SQLite DB plus live session state
3402
+ // from ~/.kiro/sessions/cli/{uuid}.json (covers turns from a running
3403
+ // session that hasn't flushed to SQLite yet). Request IDs are disjoint
3404
+ // (SQLite uses request_id UUID; sessions use {sessionId}:{loop_id.rand}),
3405
+ // so no cross-source dedup is needed.
3406
+ const flatDb = fssync.existsSync(dbPath) ? readKiroCliRequests(dbPath) : [];
3407
+ const sessionFilesList = resolveKiroCliSessionFiles(resolvedEnv);
3408
+ const flatSessions = [];
3409
+ for (const jsonPath of sessionFilesList) {
3410
+ for (const turn of readKiroCliSessionTurns(jsonPath)) {
3411
+ flatSessions.push(turn);
3412
+ }
3413
+ }
3414
+ const flat = flatDb.concat(flatSessions);
3415
+ // Per-request state replaces the old seenIds set. Each entry captures
3416
+ // what we contributed for that request_id last time, so a later mutation
3417
+ // (same request_id, different fingerprint) can subtract-old/add-new
3418
+ // instead of being skipped forever.
3419
+ const requestState =
3420
+ kiroCliState.requests && typeof kiroCliState.requests === "object"
3421
+ ? { ...kiroCliState.requests }
3422
+ : {};
3423
+
3424
+ if (flat.length === 0) {
3425
+ cursors.kiroCli = {
3426
+ ...kiroCliState,
3427
+ requests: requestState,
3428
+ updatedAt: new Date().toISOString(),
3429
+ };
3430
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
3431
+ }
3432
+
3433
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
3434
+ const touchedBuckets = new Set();
3435
+ const cb = typeof onProgress === "function" ? onProgress : null;
3436
+ let recordsProcessed = 0;
3437
+ let eventsAggregated = 0;
3438
+
3439
+ for (let i = 0; i < flat.length; i++) {
3440
+ const r = flat[i];
3441
+ recordsProcessed++;
3442
+
3443
+ const requestId = r.request_id || r.message_id;
3444
+ if (!requestId) continue;
3445
+
3446
+ const promptChars = toNonNegativeInt(r.user_prompt_length);
3447
+ const responseChars = toNonNegativeInt(r.response_size);
3448
+ const approxInput = Math.floor(promptChars / KIRO_CLI_CHARS_PER_TOKEN);
3449
+ const approxOutput = Math.floor(responseChars / KIRO_CLI_CHARS_PER_TOKEN);
3450
+
3451
+ const tsMs = Number(r.request_start_timestamp_ms);
3452
+ if (!Number.isFinite(tsMs) || tsMs <= 0) continue;
3453
+ const bucketStart = toUtcHalfHourStart(new Date(tsMs).toISOString());
3454
+ if (!bucketStart) continue;
3455
+
3456
+ const rawModel = r.model_id || r.session_model_id;
3457
+ const canonical = canonicalizeKiroCliModelId(rawModel);
3458
+ const model = canonical || "kiro-cli-agent";
3459
+
3460
+ // Fingerprint captures every field whose change should cause a re-bucket.
3461
+ const fingerprint = `${promptChars}:${responseChars}:${model}:${tsMs}`;
3462
+ const prev = requestState[requestId];
3463
+ if (prev && prev.fingerprint === fingerprint) continue; // unchanged
3464
+
3465
+ // Subtract the prior contribution (if any) from its prior bucket so the
3466
+ // bucket's absolute totals reflect the CURRENT truth, not the historical
3467
+ // truth. enqueueTouchedBuckets will emit the net delta at flush time.
3468
+ if (prev && (prev.input_tokens || prev.output_tokens)) {
3469
+ const prevBucket = getHourlyBucket(hourlyState, "kiro", prev.model, prev.bucketStart);
3470
+ addTotals(prevBucket.totals, {
3471
+ input_tokens: -prev.input_tokens,
3472
+ cached_input_tokens: 0,
3473
+ cache_creation_input_tokens: 0,
3474
+ output_tokens: -prev.output_tokens,
3475
+ reasoning_output_tokens: 0,
3476
+ total_tokens: -(prev.input_tokens + prev.output_tokens),
3477
+ conversation_count: -1,
3478
+ });
3479
+ touchedBuckets.add(bucketKey("kiro", prev.model, prev.bucketStart));
3480
+ }
3481
+
3482
+ // Add the new contribution.
3483
+ if (approxInput > 0 || approxOutput > 0) {
3484
+ const bucket = getHourlyBucket(hourlyState, "kiro", model, bucketStart);
3485
+ addTotals(bucket.totals, {
3486
+ input_tokens: approxInput,
3487
+ cached_input_tokens: 0,
3488
+ cache_creation_input_tokens: 0,
3489
+ output_tokens: approxOutput,
3490
+ reasoning_output_tokens: 0,
3491
+ total_tokens: approxInput + approxOutput,
3492
+ conversation_count: 1,
3493
+ });
3494
+ touchedBuckets.add(bucketKey("kiro", model, bucketStart));
3495
+ eventsAggregated++;
3496
+ }
3497
+
3498
+ // Always record the cursor entry (even for zero-token requests) so we
3499
+ // don't re-count later if Kiro rewrites this request with real data.
3500
+ requestState[requestId] = {
3501
+ fingerprint,
3502
+ bucketStart,
3503
+ model,
3504
+ input_tokens: approxInput,
3505
+ output_tokens: approxOutput,
3506
+ };
3507
+
3508
+ if (cb && i % 50 === 0) {
3509
+ cb({
3510
+ index: i + 1,
3511
+ total: flat.length,
3512
+ recordsProcessed,
3513
+ eventsAggregated,
3514
+ bucketsQueued: touchedBuckets.size,
3515
+ });
3516
+ }
3517
+ }
3518
+
3519
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
3520
+ const updatedAt = new Date().toISOString();
3521
+ hourlyState.updatedAt = updatedAt;
3522
+ cursors.hourly = hourlyState;
3523
+ cursors.kiroCli = { ...kiroCliState, requests: requestState, updatedAt };
3524
+
3525
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
3526
+ }
3527
+
3528
+ // Back-compat path: per-session .json files (the old fixture shape). Emits
3529
+ // exact tokens if the fixture happens to carry them (which the test fixture
3530
+ // does). Used only by the test/rollout-parser.test.js fixture tests.
3531
+ async function parseKiroCliFromSessionFiles({
3532
+ sessionFiles,
3533
+ cursors,
3534
+ queuePath,
3535
+ onProgress,
3536
+ env,
3537
+ kiroCliState,
3538
+ seenIds,
3539
+ }) {
3540
+ const fileOffsets =
3541
+ kiroCliState.fileOffsets && typeof kiroCliState.fileOffsets === "object"
3542
+ ? { ...kiroCliState.fileOffsets }
3543
+ : {};
3544
+ if (sessionFiles.length === 0) {
3545
+ cursors.kiroCli = {
3546
+ ...kiroCliState,
3547
+ seenIds: Array.from(seenIds),
3548
+ fileOffsets,
3549
+ updatedAt: new Date().toISOString(),
3550
+ };
3551
+ return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
3552
+ }
3553
+
3554
+ const hourlyState = normalizeHourlyState(cursors?.hourly);
3555
+ const touchedBuckets = new Set();
3556
+ const cb = typeof onProgress === "function" ? onProgress : null;
3557
+ let recordsProcessed = 0;
3558
+ let eventsAggregated = 0;
3559
+
3560
+ for (let fileIdx = 0; fileIdx < sessionFiles.length; fileIdx++) {
3561
+ const filePath = sessionFiles[fileIdx];
3562
+ let stat;
3563
+ try {
3564
+ stat = fssync.statSync(filePath);
3565
+ } catch {
3566
+ continue;
3567
+ }
3568
+
3569
+ const prevEntry = fileOffsets[filePath] || {};
3570
+ const prevMtime = Number(prevEntry.mtimeMs) || 0;
3571
+ const prevLastIndex = Number.isFinite(Number(prevEntry.lastIndex))
3572
+ ? Number(prevEntry.lastIndex)
3573
+ : -1;
3574
+ if (prevMtime && stat.mtimeMs <= prevMtime) continue;
3575
+
3576
+ let parsed;
3577
+ try {
3578
+ parsed = JSON.parse(fssync.readFileSync(filePath, "utf8"));
3579
+ } catch {
3580
+ continue;
3581
+ }
3582
+ if (!parsed || typeof parsed !== "object") continue;
3583
+
3584
+ const turns = Array.isArray(
3585
+ parsed?.session_state?.conversation_metadata?.user_turn_metadatas,
3586
+ )
3587
+ ? parsed.session_state.conversation_metadata.user_turn_metadatas
3588
+ : [];
3589
+ const sessionId = typeof parsed.session_id === "string" ? parsed.session_id : filePath;
3590
+ const sessionModelId =
3591
+ (parsed?.session_state?.rts_model_state?.model_info &&
3592
+ (parsed.session_state.rts_model_state.model_info.model_id ||
3593
+ parsed.session_state.rts_model_state.model_info.modelId)) ||
3594
+ null;
3595
+
3596
+ let maxIndex = prevLastIndex;
3597
+ for (let i = 0; i < turns.length; i++) {
3598
+ if (i <= prevLastIndex) continue;
3599
+ const turn = turns[i];
3600
+ if (!turn || typeof turn !== "object") continue;
3601
+ recordsProcessed++;
3602
+
3603
+ const input = toNonNegativeInt(turn.input_tokens);
3604
+ const output = toNonNegativeInt(turn.output_tokens);
3605
+ const cacheRead = toNonNegativeInt(
3606
+ turn.cache_read_input_tokens ?? turn.cached_input_tokens,
3607
+ );
3608
+ const cacheCreation = toNonNegativeInt(
3609
+ turn.cache_creation_input_tokens ?? turn.cache_write_input_tokens,
3610
+ );
3611
+ const reasoning = toNonNegativeInt(turn.reasoning_output_tokens);
3612
+ if (input === 0 && output === 0 && cacheRead === 0 && cacheCreation === 0) {
3613
+ maxIndex = i;
3614
+ continue;
3615
+ }
3616
+
3617
+ const ts = turn.timestamp || turn.created_at || turn.updated_at;
3618
+ if (!ts) continue;
3619
+ const bucketStart = toUtcHalfHourStart(ts);
3620
+ if (!bucketStart) continue;
3621
+
3622
+ const turnMessageId =
3623
+ typeof turn.message_id === "string" && turn.message_id ? turn.message_id : null;
3624
+ const dedupKey = turnMessageId ? `${sessionId}:${turnMessageId}` : null;
3625
+ if (dedupKey && seenIds.has(dedupKey)) {
3626
+ maxIndex = i;
3627
+ continue;
3628
+ }
3629
+
3630
+ const rawModel =
3631
+ turn.model_id ||
3632
+ turn.modelId ||
3633
+ (turn.model_info && (turn.model_info.model_id || turn.model_info.modelId)) ||
3634
+ sessionModelId;
3635
+ const normalized = rawModel ? normalizeKiroModelName(rawModel) : null;
3636
+ const model = normalized || "kiro-cli-agent";
3637
+
3638
+ const delta = {
3639
+ input_tokens: input,
3640
+ cached_input_tokens: cacheRead,
3641
+ cache_creation_input_tokens: cacheCreation,
3642
+ output_tokens: output,
3643
+ reasoning_output_tokens: reasoning,
3644
+ total_tokens: input + output + cacheRead + cacheCreation + reasoning,
3645
+ conversation_count: 1,
3646
+ };
3647
+
3648
+ const bucket = getHourlyBucket(hourlyState, "kiro", model, bucketStart);
3649
+ addTotals(bucket.totals, delta);
3650
+ touchedBuckets.add(bucketKey("kiro", model, bucketStart));
3651
+ if (dedupKey) seenIds.add(dedupKey);
3652
+ maxIndex = i;
3653
+ eventsAggregated++;
3654
+
3655
+ if (cb) {
3656
+ cb({
3657
+ index: fileIdx + 1,
3658
+ total: sessionFiles.length,
3659
+ recordsProcessed,
3660
+ eventsAggregated,
3661
+ bucketsQueued: touchedBuckets.size,
3662
+ });
3663
+ }
3664
+ }
3665
+
3666
+ fileOffsets[filePath] = {
3667
+ mtimeMs: stat.mtimeMs,
3668
+ size: stat.size,
3669
+ lastIndex: maxIndex,
3670
+ };
3671
+ }
3672
+
3673
+ const seenArr = Array.from(seenIds);
3674
+ const cappedSeen = seenArr.length > 10_000 ? seenArr.slice(seenArr.length - 10_000) : seenArr;
3675
+
3676
+ const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
3677
+ const updatedAt = new Date().toISOString();
3678
+ hourlyState.updatedAt = updatedAt;
3679
+ cursors.hourly = hourlyState;
3680
+ cursors.kiroCli = { ...kiroCliState, seenIds: cappedSeen, fileOffsets, updatedAt };
3681
+
3682
+ return { recordsProcessed, eventsAggregated, bucketsQueued };
3683
+ }
3684
+
3685
+ // ─────────────────────────────────────────────────────────────────────────────
3686
+
3032
3687
  function resolveKimiWireFiles(env = process.env) {
3033
3688
  const home = require("node:os").homedir();
3034
3689
  const kimiHome = env.KIMI_HOME || path.join(home, ".kimi");
@@ -3381,4 +4036,12 @@ module.exports = {
3381
4036
  resolveKimiWireFiles,
3382
4037
  resolveKimiDefaultModel,
3383
4038
  parseKimiIncremental,
4039
+ resolveKiroCliSessionFiles,
4040
+ resolveKiroCliDbPath,
4041
+ parseKiroCliIncremental,
4042
+ // Exposed for regression tests covering cache-token accounting.
4043
+ normalizeGeminiTokens,
4044
+ normalizeOpencodeTokens,
4045
+ sameGeminiTotals,
4046
+ diffGeminiTotals,
3384
4047
  };