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 +9 -0
- package/package.json +1 -1
- package/src/commands/status.js +11 -1
- package/src/commands/sync.js +39 -0
- package/src/lib/diagnostics.js +30 -0
- package/src/lib/local-api.js +17 -1
- package/src/lib/rollout.js +622 -0
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
package/src/commands/status.js
CHANGED
|
@@ -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
|
"",
|
package/src/commands/sync.js
CHANGED
|
@@ -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;
|
package/src/lib/diagnostics.js
CHANGED
|
@@ -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,
|
package/src/lib/local-api.js
CHANGED
|
@@ -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 = {
|
|
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
|
+
};
|
package/src/lib/rollout.js
CHANGED
|
@@ -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,
|