tokentracker-cli 0.5.72 → 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.
package/README.md CHANGED
@@ -143,6 +143,15 @@ Upgrade with `brew upgrade --cask mm7894215/tokentracker/tokentracker`. The tap
143
143
  | **GitHub Copilot** | ✅ Auto | OpenTelemetry file exporter (`COPILOT_OTEL_FILE_EXPORTER_PATH`) |
144
144
  | **Kimi Code** | ✅ Auto | Passive `wire.jsonl` reader (`~/.kimi/sessions/**/wire.jsonl`) |
145
145
 
146
+ > **Do I need to install any plugin or hook manually?** No. `tokentracker` (or `tokentracker init`) handles everything on first run:
147
+ > - **Hook-based** tools (Claude Code, Codex, Gemini, Every Code) — we write a SessionEnd hook or TOML notify entry into the tool's own config.
148
+ > - **Plugin-based** tools (OpenCode, **OpenClaw**) — the plugin ships inside the npm package (`~/.tokentracker/app/openclaw-plugin/`). We link it via the tool's own CLI (`openclaw plugins install --link …` + `enable`). No download, no drag-and-drop.
149
+ > - **Passive readers** (Cursor, Kiro, Hermes, Kimi Code, Copilot) — nothing is installed into those tools. We only read files they already produce (SQLite DB, JSONL, OTEL export).
150
+ >
151
+ > Run `tokentracker status` anytime to verify every integration's state. If something shows `skipped`, the `detail` column explains why (e.g. tool CLI not on `PATH`, config unreadable).
152
+ >
153
+ > Deeper dives: [OpenClaw integration & troubleshooting](docs/openclaw-integration.md).
154
+
146
155
  Missing your tool? [Open an issue](https://github.com/mm7894215/TokenTracker/issues/new) — adding new providers is usually one parser file away.
147
156
 
148
157
  ---
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "tokentracker-cli",
3
- "version": "0.5.72",
3
+ "version": "0.5.73",
4
4
  "description": "Token usage tracker for AI agent CLIs (Claude Code, Codex, Cursor, Kiro, Gemini, OpenCode, OpenClaw, Hermes, GitHub Copilot)",
5
5
  "main": "src/cli.js",
6
6
  "bin": {
@@ -20,7 +20,7 @@ const { collectTrackerDiagnostics } = require("../lib/diagnostics");
20
20
  const { probeOpenclawHookState } = require("../lib/openclaw-hook");
21
21
  const { probeOpenclawSessionPluginState } = require("../lib/openclaw-session-plugin");
22
22
  const { resolveTrackerPaths } = require("../lib/tracker-paths");
23
- const { resolveKimiWireFiles } = require("../lib/rollout");
23
+ const { resolveKimiWireFiles, resolveKiroCliDbPath } = require("../lib/rollout");
24
24
 
25
25
  async function cmdStatus(argv = []) {
26
26
  const opts = parseArgs(argv);
@@ -118,6 +118,13 @@ async function cmdStatus(argv = []) {
118
118
  const kimiHome = process.env.KIMI_HOME || path.join(home, ".kimi");
119
119
  const kimiInstalled = fssync.existsSync(path.join(kimiHome, "sessions"));
120
120
 
121
+ // Kiro CLI — reads from SQLite at
122
+ // ~/Library/Application Support/kiro-cli/data.sqlite3. End-user dashboards
123
+ // show CLI and IDE merged under a single "Kiro" brand; this status line
124
+ // surfaces the CLI sub-path separately for operators.
125
+ const kiroCliDbPath = resolveKiroCliDbPath(process.env);
126
+ const kiroCliInstalled = fssync.existsSync(kiroCliDbPath);
127
+
121
128
  const copilotToken = readCopilotOauthToken({ home });
122
129
  const copilotOtel = describeCopilotOtelStatus({ home, env: process.env });
123
130
  const copilotLines = formatCopilotLines({ token: copilotToken, otel: copilotOtel });
@@ -147,6 +154,9 @@ async function cmdStatus(argv = []) {
147
154
  kimiInstalled
148
155
  ? `- Kimi Code: passive reader (${kimiWireFiles.length} wire.jsonl file${kimiWireFiles.length !== 1 ? "s" : ""} found)`
149
156
  : null,
157
+ kiroCliInstalled
158
+ ? `- Kiro CLI: SQLite data.sqlite3 found (tokens approximated from char lengths, merged under 'kiro' source)`
159
+ : null,
150
160
  ...copilotLines,
151
161
  ...subscriptionLines,
152
162
  "",
@@ -27,6 +27,9 @@ const {
27
27
  parseCopilotIncremental,
28
28
  resolveKimiWireFiles,
29
29
  parseKimiIncremental,
30
+ resolveKiroCliSessionFiles,
31
+ resolveKiroCliDbPath,
32
+ parseKiroCliIncremental,
30
33
  } = require("../lib/rollout");
31
34
  const { createProgress, renderBar, formatNumber, formatBytes } = require("../lib/progress");
32
35
  const {
@@ -359,6 +362,40 @@ async function cmdSync(argv) {
359
362
  });
360
363
  }
361
364
 
365
+ // ── Kiro CLI (reads ~/Library/Application Support/kiro-cli/data.sqlite3
366
+ // AND live sessions under ~/.kiro/sessions/cli/{uuid}.json) ──
367
+ // Runs IN PARALLEL with the Kiro IDE branch above — NOT instead of it.
368
+ // Both emit source='kiro' so totals merge transparently; cursor state
369
+ // is isolated in cursors.kiroCli. Kiro CLI does not persist explicit
370
+ // token counts (billing is credit-based on Bedrock); we approximate at
371
+ // 4 chars/token from user prompt chars and assistant response chars.
372
+ let kiroCliResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
373
+ const kiroCliDb = resolveKiroCliDbPath(process.env);
374
+ const kiroCliSessionFiles = resolveKiroCliSessionFiles(process.env);
375
+ if (fssync.existsSync(kiroCliDb) || kiroCliSessionFiles.length > 0) {
376
+ if (progress?.enabled) {
377
+ progress.start(`Parsing Kiro CLI ${renderBar(0)} | buckets 0`);
378
+ }
379
+ try {
380
+ kiroCliResult = await parseKiroCliIncremental({
381
+ cursors,
382
+ queuePath,
383
+ env: process.env,
384
+ onProgress: (p) => {
385
+ if (!progress?.enabled) return;
386
+ const pct = p.total > 0 ? p.index / p.total : 1;
387
+ progress.update(
388
+ `Parsing Kiro CLI ${renderBar(pct)} ${formatNumber(p.index)}/${formatNumber(p.total)} convs | buckets ${formatNumber(p.bucketsQueued)}`,
389
+ );
390
+ },
391
+ });
392
+ } catch (err) {
393
+ if (!opts.auto) {
394
+ process.stderr.write(`Kiro CLI sync: ${err.message}\n`);
395
+ }
396
+ }
397
+ }
398
+
362
399
  // ── Kimi (passive wire.jsonl reader) ──
363
400
  let kimiResult = { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued: 0 };
364
401
  const kimiWireFiles = resolveKimiWireFiles(process.env);
@@ -496,6 +533,7 @@ async function cmdSync(argv) {
496
533
  opencodeResult.filesProcessed +
497
534
  cursorResult.recordsProcessed +
498
535
  kiroResult.recordsProcessed +
536
+ kiroCliResult.recordsProcessed +
499
537
  hermesResult.recordsProcessed +
500
538
  kimiResult.recordsProcessed +
501
539
  copilotResult.recordsProcessed;
@@ -507,6 +545,7 @@ async function cmdSync(argv) {
507
545
  opencodeResult.bucketsQueued +
508
546
  cursorResult.bucketsQueued +
509
547
  kiroResult.bucketsQueued +
548
+ kiroCliResult.bucketsQueued +
510
549
  hermesResult.bucketsQueued +
511
550
  kimiResult.bucketsQueued +
512
551
  copilotResult.bucketsQueued;
@@ -16,6 +16,7 @@ const { normalizeState: normalizeUploadState } = require("./upload-throttle");
16
16
  const { probeOpenclawHookState } = require("./openclaw-hook");
17
17
  const { probeOpenclawSessionPluginState } = require("./openclaw-session-plugin");
18
18
  const { resolveTrackerPaths } = require("./tracker-paths");
19
+ const { resolveKiroCliDbPath } = require("./rollout");
19
20
 
20
21
  async function collectTrackerDiagnostics({
21
22
  home = os.homedir(),
@@ -81,6 +82,25 @@ async function collectTrackerDiagnostics({
81
82
  });
82
83
  const openclawHookState = await probeOpenclawHookState({ home, trackerDir, env: process.env });
83
84
 
85
+ // Kiro IDE and Kiro CLI sub-path presence — merged under one "kiro" source
86
+ // at token/cost aggregation level; operators need visibility of both
87
+ // sub-paths here for debugging.
88
+ const kiroIdeDevDataDir = path.join(
89
+ home,
90
+ "Library",
91
+ "Application Support",
92
+ "Kiro",
93
+ "User",
94
+ "globalStorage",
95
+ "kiro.kiroagent",
96
+ "dev_data",
97
+ );
98
+ const kiroIdePresent =
99
+ (await safeStatSize(path.join(kiroIdeDevDataDir, "devdata.sqlite"))) > 0 ||
100
+ (await safeStatSize(path.join(kiroIdeDevDataDir, "tokens_generated.jsonl"))) > 0;
101
+ const kiroCliDbPath = resolveKiroCliDbPath(process.env);
102
+ const kiroCliPresent = require("node:fs").existsSync(kiroCliDbPath);
103
+
84
104
  const lastSuccessAt = uploadThrottle.lastSuccessMs
85
105
  ? new Date(uploadThrottle.lastSuccessMs).toISOString()
86
106
  : null;
@@ -104,6 +124,16 @@ async function collectTrackerDiagnostics({
104
124
  claude_config: redactValue(claudeConfigPath, home),
105
125
  gemini_config: redactValue(geminiSettingsPath, home),
106
126
  opencode_config: redactValue(opencodeConfigDir, home),
127
+ kiro_ide_dev_data: redactValue(kiroIdeDevDataDir, home),
128
+ kiro_cli_db: redactValue(kiroCliDbPath, home),
129
+ },
130
+ kiro: {
131
+ ide_present: kiroIdePresent,
132
+ cli_present: kiroCliPresent,
133
+ cli_approximation:
134
+ "Kiro CLI does not persist explicit token counts (billing is credit-based on Bedrock). Tokens are approximated at 4 chars/token from user prompt chars and assistant response chars. Source rows that came through this path have model='kiro-cli-agent' when the underlying model is unknown (auto-routing); known Bedrock ARNs canonicalize to their short name (e.g. claude-sonnet-4).",
135
+ merge_policy:
136
+ "Kiro IDE and Kiro CLI both emit source='kiro' in queue.jsonl so token, cost, heatmap, and leaderboard aggregations merge transparently. Use this block to distinguish sub-path contributions.",
107
137
  },
108
138
  config: {
109
139
  base_url: typeof config?.baseUrl === "string" ? config.baseUrl : null,
@@ -63,6 +63,14 @@ const MODEL_PRICING = {
63
63
  "kimi-for-coding": { input: 0.6, output: 2, cache_read: 0.15 },
64
64
  "kimi-k2.5": { input: 0.6, output: 2, cache_read: 0.15 },
65
65
  "kimi-k2.5-free": { input: 0, output: 0, cache_read: 0 },
66
+ // ── AWS Kiro (Kiro IDE + Kiro CLI — both route through Bedrock, most
67
+ // commonly claude-sonnet-4; rates mirror the sonnet-4 table below so
68
+ // costs stay consistent with the real underlying model when a Bedrock
69
+ // model_id isn't exposed). Mirrored byte-for-byte in
70
+ // dashboard/edge-patches/tokentracker-leaderboard-refresh.ts for
71
+ // leaderboard estimated_cost_usd. ──
72
+ "kiro-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 },
73
+ "kiro-cli-agent": { input: 3, output: 15, cache_read: 0.3, cache_write: 3.75 },
66
74
  // ── Misc / Free ──
67
75
  "glm-4.7-free": { input: 0, output: 0, cache_read: 0 },
68
76
  "nemotron-3-super-free": { input: 0, output: 0, cache_read: 0 },
@@ -90,6 +98,7 @@ function getModelPricing(model) {
90
98
  if (lower.includes("gemini-3")) return MODEL_PRICING["gemini-3-flash-preview"];
91
99
  if (lower.includes("gemini-2.5")) return MODEL_PRICING["gemini-2.5-pro"];
92
100
  if (lower.includes("kimi")) return MODEL_PRICING["kimi-k2.5"];
101
+ if (lower.includes("kiro")) return MODEL_PRICING["kiro-cli-agent"];
93
102
  if (lower.includes("composer")) return MODEL_PRICING["composer-1"];
94
103
  if (lower === "auto") return MODEL_PRICING["composer-1"];
95
104
  return ZERO_PRICING;
@@ -1061,4 +1070,11 @@ function createLocalApiHandler({ queuePath }) {
1061
1070
  };
1062
1071
  }
1063
1072
 
1064
- module.exports = { createLocalApiHandler, resolveQueuePath };
1073
+ module.exports = {
1074
+ createLocalApiHandler,
1075
+ resolveQueuePath,
1076
+ // Exported for cross-consumer tests (pricing + native contract lock).
1077
+ MODEL_PRICING,
1078
+ getModelPricing,
1079
+ computeRowCost,
1080
+ };
@@ -3065,6 +3065,625 @@ function resolveKimiDefaultModel(env = process.env) {
3065
3065
  }
3066
3066
  // ─────────────────────────────────────────────────────────────────────────────
3067
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
+
3068
3687
  function resolveKimiWireFiles(env = process.env) {
3069
3688
  const home = require("node:os").homedir();
3070
3689
  const kimiHome = env.KIMI_HOME || path.join(home, ".kimi");
@@ -3417,6 +4036,9 @@ module.exports = {
3417
4036
  resolveKimiWireFiles,
3418
4037
  resolveKimiDefaultModel,
3419
4038
  parseKimiIncremental,
4039
+ resolveKiroCliSessionFiles,
4040
+ resolveKiroCliDbPath,
4041
+ parseKiroCliIncremental,
3420
4042
  // Exposed for regression tests covering cache-token accounting.
3421
4043
  normalizeGeminiTokens,
3422
4044
  normalizeOpencodeTokens,