@ipv9/tokentracker-cli 0.39.38 → 0.39.39
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 +41 -9
- package/dashboard/dist/assets/{Card-DD4s0V6T.js → Card-BbRYRVXf.js} +1 -1
- package/dashboard/dist/assets/DashboardPage-DYKHv0m3.js +60 -0
- package/dashboard/dist/assets/{FadeIn-CUSBK0jm.js → FadeIn-CjAacJ5t.js} +1 -1
- package/dashboard/dist/assets/{IpCheckPage-PHF4lPax.js → IpCheckPage-BGALK8Vy.js} +1 -1
- package/dashboard/dist/assets/{LimitsPage-dS9C9Vdt.js → LimitsPage-LkZGPYvg.js} +2 -2
- package/dashboard/dist/assets/{LocalOnlyNotice-DCmtS_j5.js → LocalOnlyNotice-Dxo2vFMQ.js} +1 -1
- package/dashboard/dist/assets/PopoverPopup-DP9fwdtl.js +1 -0
- package/dashboard/dist/assets/Select-DPE53NmM.js +1 -0
- package/dashboard/dist/assets/SelectItemText-D69v77is.js +12 -0
- package/dashboard/dist/assets/SettingsPage-Dxzqpi02.js +1 -0
- package/dashboard/dist/assets/SkillsPage-Bo7e39AB.js +1 -0
- package/dashboard/dist/assets/WidgetsPage-HHcIr_Eq.js +1 -0
- package/dashboard/dist/assets/WrappedPage-C-_jLXXU.js +1 -0
- package/dashboard/dist/assets/{arrow-up-right-CjaBQnwI.js → arrow-up-right-BJvJHUGj.js} +1 -1
- package/dashboard/dist/assets/{download-DLBqkSwp.js → download-CnzY_EgN.js} +1 -1
- package/dashboard/dist/assets/format-1w-q5n2f.js +1 -0
- package/dashboard/dist/assets/main-1kxAVM3m.css +1 -0
- package/dashboard/dist/assets/main-3gBEI6Jl.js +694 -0
- package/dashboard/dist/assets/mock-data-CJOMGcL9.js +1 -0
- package/dashboard/dist/assets/use-limits-display-prefs-D1nT9-mr.js +1 -0
- package/dashboard/dist/assets/use-native-settings-Dh6NzqjY.js +1 -0
- package/dashboard/dist/assets/use-usage-limits-BXR6lzGf.js +1 -0
- package/dashboard/dist/assets/useCurrency-Hhs5jbWw.js +1 -0
- package/dashboard/dist/index.html +15 -27
- package/dashboard/dist/llms.txt +0 -2
- package/dashboard/dist/sitemap.xml +0 -12
- package/package.json +7 -4
- package/src/cli.js +4 -13
- package/src/commands/doctor.js +1 -3
- package/src/commands/init.js +63 -53
- package/src/commands/serve.js +2 -50
- package/src/commands/status.js +1 -55
- package/src/commands/sync.js +134 -227
- package/src/lib/{browser-auth.js → browser-open.js} +3 -149
- package/src/lib/claude-categorizer.js +204 -32
- package/src/lib/codex-context-breakdown.js +83 -7
- package/src/lib/codex-rollout-parser.js +52 -10
- package/src/lib/diagnostics.js +1 -49
- package/src/lib/doctor.js +12 -87
- package/src/lib/file-identity.js +69 -0
- package/src/lib/init-flow.js +6 -3
- package/src/lib/local-api.js +6 -364
- package/src/lib/pricing/curated-overrides.json +1 -1
- package/src/lib/pricing/seed-snapshot.json +1 -1
- package/src/lib/rollout.js +392 -144
- package/src/lib/runtime-config.js +0 -32
- package/src/lib/sqlite-reader.js +83 -8
- package/dashboard/dist/assets/ActivityHeatmap-BrehqUIb.js +0 -42
- package/dashboard/dist/assets/DashboardPage-CyUShBRJ.js +0 -19
- package/dashboard/dist/assets/DevicePage-BA74Su1l.js +0 -1
- package/dashboard/dist/assets/DialogTitle-DoKsz5wi.js +0 -1
- package/dashboard/dist/assets/InsforgeUserHeaderControls-Bk_U2dHr.js +0 -1
- package/dashboard/dist/assets/LandingPage-Bbeu84Iu.js +0 -4356
- package/dashboard/dist/assets/LeaderboardAvatar-ByqeB6bM.js +0 -1
- package/dashboard/dist/assets/LeaderboardPage-BeWXF00S.js +0 -6
- package/dashboard/dist/assets/LeaderboardProfileModal-C4yvbZd_.js +0 -72
- package/dashboard/dist/assets/LeaderboardProfilePage-D3cbSh5o.js +0 -1
- package/dashboard/dist/assets/LoginPage-Ch3UQoWj.js +0 -1
- package/dashboard/dist/assets/PopoverPopup-DjG0gjlA.js +0 -1
- package/dashboard/dist/assets/Select-HzIjWJpE.js +0 -1
- package/dashboard/dist/assets/SelectItemText-f0tCwtvZ.js +0 -1
- package/dashboard/dist/assets/SettingsPage-DJOZAlZy.js +0 -1
- package/dashboard/dist/assets/SkillsPage-BQcyJIdx.js +0 -1
- package/dashboard/dist/assets/WidgetsPage-B_vxuV09.js +0 -1
- package/dashboard/dist/assets/WrappedPage-CZN9Y3Vz.js +0 -1
- package/dashboard/dist/assets/index-BeoRn2gJ.js +0 -2
- package/dashboard/dist/assets/info-BAIxlHIB.js +0 -1
- package/dashboard/dist/assets/main-DAcSUW2t.css +0 -1
- package/dashboard/dist/assets/main-pi07CfeC.js +0 -1004
- package/dashboard/dist/assets/use-limits-display-prefs-CMfASZsh.js +0 -1
- package/dashboard/dist/assets/use-native-settings-CfPik-Ao.js +0 -1
- package/dashboard/dist/assets/use-spring-wsmJQRqb.js +0 -1
- package/dashboard/dist/assets/use-usage-limits-Bt0Dp6kx.js +0 -1
- package/dashboard/dist/assets/useCurrency-CsaAMc3j.js +0 -1
- package/dashboard/dist/assets/useOpenInteractionType-CPzi5Z8M.js +0 -12
- package/dashboard/dist/share.html +0 -140
- package/src/commands/device-login.js +0 -161
- package/src/lib/upload-throttle.js +0 -148
package/src/lib/rollout.js
CHANGED
|
@@ -5,7 +5,11 @@ const readline = require("node:readline");
|
|
|
5
5
|
|
|
6
6
|
const crypto = require("node:crypto");
|
|
7
7
|
const { ensureDir } = require("./fs");
|
|
8
|
-
const {
|
|
8
|
+
const {
|
|
9
|
+
readSqliteJsonRows,
|
|
10
|
+
readSqliteJsonRowsWithStatus,
|
|
11
|
+
SQLITE_READ_MISSING,
|
|
12
|
+
} = require("./sqlite-reader");
|
|
9
13
|
|
|
10
14
|
const DEFAULT_SOURCE = "codex";
|
|
11
15
|
const DEFAULT_MODEL = "unknown";
|
|
@@ -49,28 +53,10 @@ async function listClaudeProjectFiles(projectsDir) {
|
|
|
49
53
|
return out;
|
|
50
54
|
}
|
|
51
55
|
|
|
52
|
-
//
|
|
53
|
-
//
|
|
54
|
-
//
|
|
55
|
-
|
|
56
|
-
// file that is still being appended to, the window would extend into the
|
|
57
|
-
// not-yet-written append zone, so plain growth (not rotation) would shift
|
|
58
|
-
// the hash and produce a false "rotated" signal.
|
|
59
|
-
function readFileHeadSignature(filePath) {
|
|
60
|
-
try {
|
|
61
|
-
const fd = fssync.openSync(filePath, "r");
|
|
62
|
-
try {
|
|
63
|
-
const buf = Buffer.alloc(256);
|
|
64
|
-
const bytes = fssync.readSync(fd, buf, 0, 256, 0);
|
|
65
|
-
if (bytes < 256) return "";
|
|
66
|
-
return crypto.createHash("sha1").update(buf.subarray(0, bytes)).digest("hex");
|
|
67
|
-
} finally {
|
|
68
|
-
fssync.closeSync(fd);
|
|
69
|
-
}
|
|
70
|
-
} catch {
|
|
71
|
-
return "";
|
|
72
|
-
}
|
|
73
|
-
}
|
|
56
|
+
// Moved to ./file-identity so the context-breakdown caches can share it
|
|
57
|
+
// rather than grow a second copy. Imported under the same name, so all 19
|
|
58
|
+
// call sites below are unaffected.
|
|
59
|
+
const { readFileHeadSignature } = require("./file-identity");
|
|
74
60
|
|
|
75
61
|
async function listGeminiSessionFiles(tmpDir) {
|
|
76
62
|
const out = [];
|
|
@@ -151,6 +137,7 @@ async function parseRolloutIncremental({
|
|
|
151
137
|
const startOffset = sameFile ? prev.offset || 0 : 0;
|
|
152
138
|
const lastTotal = sameFile ? prev.lastTotal || null : null;
|
|
153
139
|
const lastModel = sameFile ? prev.lastModel || null : null;
|
|
140
|
+
const forkState = sameFile ? prev.codexFork || null : null;
|
|
154
141
|
|
|
155
142
|
const projectContext = projectEnabled
|
|
156
143
|
? await resolveProjectContextForFile({
|
|
@@ -169,6 +156,7 @@ async function parseRolloutIncremental({
|
|
|
169
156
|
startOffset,
|
|
170
157
|
lastTotal,
|
|
171
158
|
lastModel,
|
|
159
|
+
forkState,
|
|
172
160
|
hourlyState,
|
|
173
161
|
touchedBuckets,
|
|
174
162
|
source: fileSource,
|
|
@@ -186,6 +174,7 @@ async function parseRolloutIncremental({
|
|
|
186
174
|
offset: result.endOffset,
|
|
187
175
|
lastTotal: result.lastTotal,
|
|
188
176
|
lastModel: result.lastModel,
|
|
177
|
+
codexFork: result.codexFork || null,
|
|
189
178
|
head: headSig,
|
|
190
179
|
updatedAt: new Date().toISOString(),
|
|
191
180
|
};
|
|
@@ -796,6 +785,7 @@ async function parseRolloutFile({
|
|
|
796
785
|
startOffset,
|
|
797
786
|
lastTotal,
|
|
798
787
|
lastModel,
|
|
788
|
+
forkState,
|
|
799
789
|
hourlyState,
|
|
800
790
|
touchedBuckets,
|
|
801
791
|
source,
|
|
@@ -810,7 +800,8 @@ async function parseRolloutFile({
|
|
|
810
800
|
const st = await fs.stat(filePath);
|
|
811
801
|
const endOffset = st.size;
|
|
812
802
|
if (startOffset >= endOffset) {
|
|
813
|
-
|
|
803
|
+
const carriedFork = forkState === "child" || forkState === "replay" ? forkState : null;
|
|
804
|
+
return { endOffset, lastTotal, lastModel, eventsAggregated: 0, codexFork: carriedFork };
|
|
814
805
|
}
|
|
815
806
|
|
|
816
807
|
const stream = fssync.createReadStream(filePath, { encoding: "utf8", start: startOffset });
|
|
@@ -823,14 +814,37 @@ async function parseRolloutFile({
|
|
|
823
814
|
let currentProjectKey = projectKey || null;
|
|
824
815
|
let eventsAggregated = 0;
|
|
825
816
|
|
|
817
|
+
// Codex subagent-fork accounting (issue #75). A rollout spawned via
|
|
818
|
+
// session_meta.source.subagent.thread_spawn replays the parent thread's
|
|
819
|
+
// token_count history before a deterministic
|
|
820
|
+
// "inter_agent_communication_metadata" boundary, then the genuine child
|
|
821
|
+
// turns. codexFork tracks that lifecycle across incremental reads:
|
|
822
|
+
// null -> not a fork (or not yet known); accounting on
|
|
823
|
+
// "replay" -> fork detected, boundary not seen; accounting off
|
|
824
|
+
// "child" -> boundary passed; accounting on
|
|
825
|
+
// The cumulative total_token_usage counter is continuous across the
|
|
826
|
+
// boundary, so we still advance the delta baseline through the replay
|
|
827
|
+
// (without accumulating) and let the first child delta be measured from the
|
|
828
|
+
// last replayed cumulative.
|
|
829
|
+
let codexFork = forkState === "child" || forkState === "replay" ? forkState : null;
|
|
830
|
+
let accounting = codexFork !== "replay";
|
|
831
|
+
|
|
826
832
|
for await (const line of rl) {
|
|
827
833
|
if (!line) continue;
|
|
834
|
+
// Format contract with Codex (issue #75): fork detection and the replay
|
|
835
|
+
// boundary are matched by these exact record-type substrings. If a future
|
|
836
|
+
// Codex format renames/reshapes them, this prefilter drops the line and the
|
|
837
|
+
// fork silently reverts to counting replayed history — update these literals
|
|
838
|
+
// (and the tests in test/codex-fork-history.test.js) as a conscious change.
|
|
828
839
|
const maybeTokenCount = line.includes('"token_count"');
|
|
840
|
+
const maybeBoundary =
|
|
841
|
+
!maybeTokenCount && line.includes('"inter_agent_communication_metadata"');
|
|
829
842
|
const maybeTurnContext =
|
|
830
843
|
!maybeTokenCount &&
|
|
844
|
+
!maybeBoundary &&
|
|
831
845
|
(line.includes('"turn_context"') || line.includes('"session_meta"')) &&
|
|
832
|
-
(line.includes('"model"') || line.includes('"cwd"'));
|
|
833
|
-
if (!maybeTokenCount && !maybeTurnContext) continue;
|
|
846
|
+
(line.includes('"model"') || line.includes('"cwd"') || line.includes('"thread_spawn"'));
|
|
847
|
+
if (!maybeTokenCount && !maybeTurnContext && !maybeBoundary) continue;
|
|
834
848
|
|
|
835
849
|
let obj;
|
|
836
850
|
try {
|
|
@@ -839,11 +853,27 @@ async function parseRolloutFile({
|
|
|
839
853
|
continue;
|
|
840
854
|
}
|
|
841
855
|
|
|
856
|
+
// Boundary between replayed parent history and genuine child turns.
|
|
857
|
+
if (obj?.type === "inter_agent_communication_metadata") {
|
|
858
|
+
accounting = true;
|
|
859
|
+
if (codexFork === "replay") codexFork = "child";
|
|
860
|
+
continue;
|
|
861
|
+
}
|
|
862
|
+
|
|
842
863
|
if (
|
|
843
864
|
(obj?.type === "turn_context" || obj?.type === "session_meta") &&
|
|
844
865
|
obj?.payload &&
|
|
845
866
|
typeof obj.payload === "object"
|
|
846
867
|
) {
|
|
868
|
+
if (
|
|
869
|
+
obj.type === "session_meta" &&
|
|
870
|
+
obj.payload.source?.subagent?.thread_spawn &&
|
|
871
|
+
codexFork !== "child"
|
|
872
|
+
) {
|
|
873
|
+
// Fork detected before any boundary in this file: enter replay mode.
|
|
874
|
+
codexFork = "replay";
|
|
875
|
+
accounting = false;
|
|
876
|
+
}
|
|
847
877
|
if (typeof obj.payload.model === "string") {
|
|
848
878
|
model = obj.payload.model;
|
|
849
879
|
}
|
|
@@ -878,6 +908,17 @@ async function parseRolloutFile({
|
|
|
878
908
|
const totalUsage = info.total_token_usage;
|
|
879
909
|
|
|
880
910
|
const delta = pickDelta(lastUsage, totalUsage, totals);
|
|
911
|
+
if (!accounting) {
|
|
912
|
+
// Replay mode (subagent fork before the boundary): advance the delta
|
|
913
|
+
// baseline so the first child delta is measured from the last replayed
|
|
914
|
+
// cumulative, but do not attribute the replayed parent history to this
|
|
915
|
+
// child. Advance unconditionally here — pickDelta can return null for a
|
|
916
|
+
// zero/duplicate delta, and we still want the baseline to track the
|
|
917
|
+
// replay. This branch is scoped to forks so the shared non-fork path
|
|
918
|
+
// below keeps its original guard order untouched.
|
|
919
|
+
if (totalUsage && typeof totalUsage === "object") totals = totalUsage;
|
|
920
|
+
continue;
|
|
921
|
+
}
|
|
881
922
|
if (!delta) continue;
|
|
882
923
|
delta.conversation_count = 1;
|
|
883
924
|
|
|
@@ -905,7 +946,30 @@ async function parseRolloutFile({
|
|
|
905
946
|
eventsAggregated += 1;
|
|
906
947
|
}
|
|
907
948
|
|
|
908
|
-
|
|
949
|
+
// Fail closed for a partially-written fork: the boundary never arrived, so
|
|
950
|
+
// the replay is not yet safely separable from the child. Do not advance the
|
|
951
|
+
// cursor or the persisted baseline — the completed file is re-read next pass.
|
|
952
|
+
// This degrades safely (0 tokens, never over-count) but is silent, so surface
|
|
953
|
+
// it under debug: a file that stays here across many passes is a boundary
|
|
954
|
+
// that was malformed/renamed, not merely still being written.
|
|
955
|
+
if (codexFork === "replay") {
|
|
956
|
+
if (process.env.TOKENTRACKER_DEBUG) {
|
|
957
|
+
process.stderr.write(
|
|
958
|
+
`[codex-fork] ${filePath}: subagent fork has no ` +
|
|
959
|
+
`inter_agent_communication_metadata boundary yet; deferring ` +
|
|
960
|
+
`(0 tokens ingested, cursor held at ${startOffset})\n`,
|
|
961
|
+
);
|
|
962
|
+
}
|
|
963
|
+
return {
|
|
964
|
+
endOffset: startOffset,
|
|
965
|
+
lastTotal,
|
|
966
|
+
lastModel: model,
|
|
967
|
+
eventsAggregated: 0,
|
|
968
|
+
codexFork: "replay",
|
|
969
|
+
};
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
return { endOffset, lastTotal: totals, lastModel: model, eventsAggregated, codexFork };
|
|
909
973
|
}
|
|
910
974
|
|
|
911
975
|
async function parseClaudeFile({
|
|
@@ -3388,7 +3452,7 @@ function resolveKimiDefaultModel(env = process.env) {
|
|
|
3388
3452
|
// token. Source is merged with Kiro IDE (source='kiro') and canonicalized
|
|
3389
3453
|
// model names are used so CLI and IDE rows collapse when they refer to the
|
|
3390
3454
|
// same underlying Bedrock model. Cursor state is per-request-id so mutable
|
|
3391
|
-
// requests can be reprocessed (
|
|
3455
|
+
// requests can be reprocessed (a grown request adds only its growth, #65).
|
|
3392
3456
|
// ─────────────────────────────────────────────────────────────────────────────
|
|
3393
3457
|
|
|
3394
3458
|
const KIRO_CLI_CHARS_PER_TOKEN = 4;
|
|
@@ -3526,7 +3590,7 @@ async function readKiroCliMessageChars(jsonlPath, turnMessageIds) {
|
|
|
3526
3590
|
// sibling. Returns [{ request_id, model_id, request_start_timestamp_ms,
|
|
3527
3591
|
// input_tokens, output_tokens }]. We use the same request_id dedup slot as
|
|
3528
3592
|
// the SQLite path so mutations (turn rewritten on next flush) go through
|
|
3529
|
-
// the
|
|
3593
|
+
// the positive-only per-request delta in parseKiroCliIncremental.
|
|
3530
3594
|
async function readKiroCliSessionTurns(jsonPath) {
|
|
3531
3595
|
if (!jsonPath || !fssync.existsSync(jsonPath)) return [];
|
|
3532
3596
|
let parsed;
|
|
@@ -3700,15 +3764,27 @@ function readKiroCliRequests(dbPath, env = process.env, sqliteOptions = {}) {
|
|
|
3700
3764
|
"json_extract(value, '$.user_turn_metadata.requests') AS requests_json " +
|
|
3701
3765
|
"FROM conversations_v2 " +
|
|
3702
3766
|
"WHERE json_extract(value, '$.user_turn_metadata.requests') IS NOT NULL";
|
|
3703
|
-
|
|
3767
|
+
// #66 built the {ok, reason} taxonomy for exactly this caller. There is
|
|
3768
|
+
// deliberately no GATE — a degraded read only shrinks the result, which
|
|
3769
|
+
// clamps to a zero delta — but "no gate needed" is not "no signal needed".
|
|
3770
|
+
// Without this an operator whose database has been corrupt or locked for
|
|
3771
|
+
// weeks sees a clean sync and a healthy status line, because
|
|
3772
|
+
// warnSqliteUnavailable fires only for a missing sqlite BACKEND.
|
|
3773
|
+
const readResult = readSqliteJsonRowsWithStatus(dbPath, sql, {
|
|
3704
3774
|
label: "Kiro CLI",
|
|
3705
3775
|
env,
|
|
3706
3776
|
maxBuffer: 128 * 1024 * 1024,
|
|
3707
3777
|
timeout: 120_000,
|
|
3708
3778
|
...sqliteOptions,
|
|
3709
3779
|
});
|
|
3780
|
+
if (!readResult.ok && readResult.reason !== SQLITE_READ_MISSING) {
|
|
3781
|
+
const stderr = (sqliteOptions && sqliteOptions.stderr) || process.stderr;
|
|
3782
|
+
stderr.write(
|
|
3783
|
+
`[kiro-cli] SQLite read degraded (${readResult.reason}); Kiro CLI totals may be stale this sync\n`,
|
|
3784
|
+
);
|
|
3785
|
+
}
|
|
3710
3786
|
const flat = [];
|
|
3711
|
-
for (const row of rows) {
|
|
3787
|
+
for (const row of readResult.rows) {
|
|
3712
3788
|
let requests;
|
|
3713
3789
|
try {
|
|
3714
3790
|
requests = JSON.parse(row.requests_json || "[]");
|
|
@@ -3731,7 +3807,63 @@ function readKiroCliRequests(dbPath, env = process.env, sqliteOptions = {}) {
|
|
|
3731
3807
|
});
|
|
3732
3808
|
}
|
|
3733
3809
|
}
|
|
3734
|
-
return flat;
|
|
3810
|
+
return { ok: readResult.ok, reason: readResult.reason, rows: flat };
|
|
3811
|
+
}
|
|
3812
|
+
|
|
3813
|
+
// ── #65: per-request, never-pruned, positive-only watermark ────────────────
|
|
3814
|
+
//
|
|
3815
|
+
// The bug: `readKiroCliRequests` reads the whole database with NO age filter,
|
|
3816
|
+
// while `cursors.kiroCli.requests` was pruned at 90 days AND capped at 20,000
|
|
3817
|
+
// entries. The memory window was strictly smaller than the read window, so an
|
|
3818
|
+
// evicted-but-still-returned request was re-added on every sync, forever.
|
|
3819
|
+
//
|
|
3820
|
+
// The fix is the smallest one that actually closes it: keep that same
|
|
3821
|
+
// per-request record, stop pruning it, and only ever ADD the growth.
|
|
3822
|
+
//
|
|
3823
|
+
// INTENT — TWO RULES, AND EVERY DEFECT THIS FILE HAS SEEN CAME FROM BREAKING
|
|
3824
|
+
// ONE OF THEM. Do not "optimise" either away.
|
|
3825
|
+
//
|
|
3826
|
+
// 1. NEVER PRUNE. The reader has no age or count filter, so anything
|
|
3827
|
+
// dropped from this map is re-added in full on the next sync. That IS
|
|
3828
|
+
// issue #65. Dropping only the age cap and keeping the count cap merely
|
|
3829
|
+
// converts an age-triggered recount into a volume-triggered one.
|
|
3830
|
+
//
|
|
3831
|
+
// 2. THE DELTA IS CLAMPED AT ZERO — this parser never subtracts. Attribution
|
|
3832
|
+
// is only ever needed to justify a DECREASE, so never decreasing removes
|
|
3833
|
+
// the need for it entirely:
|
|
3834
|
+
// • a shrinking read (deleted session, corrupt file, unreadable or
|
|
3835
|
+
// vanished database, partial parse) produces a zero delta, so no
|
|
3836
|
+
// health gate is needed to protect against it;
|
|
3837
|
+
// • `source="kiro"` hourly buckets are shared with the Kiro IDE parser,
|
|
3838
|
+
// and since we only add we never need to know the IDE's share, so no
|
|
3839
|
+
// change to the shared `cursors.hourly` schema is needed either.
|
|
3840
|
+
//
|
|
3841
|
+
// Keying by request identity rather than by bucket also removes two whole
|
|
3842
|
+
// failure modes that a bucket-keyed version had: a request replaced by a
|
|
3843
|
+
// different request of equal value in the same bucket is still counted, and a
|
|
3844
|
+
// request that changes bucket or loses its model id contributes a zero delta
|
|
3845
|
+
// instead of opening a phantom bucket.
|
|
3846
|
+
//
|
|
3847
|
+
// Cost, stated plainly: a genuine downward correction never propagates, and a
|
|
3848
|
+
// request that moves to another bucket leaves its earlier contribution behind
|
|
3849
|
+
// in the old one. State size grows with Kiro's own history rather than being
|
|
3850
|
+
// bounded — that is the deliberate trade for correctness, and the number
|
|
3851
|
+
// belongs in the release notes rather than in a prune policy.
|
|
3852
|
+
const KIRO_WATERMARK_VERSION = 2;
|
|
3853
|
+
|
|
3854
|
+
// A stored value is only usable as a floor when it is a finite, non-negative
|
|
3855
|
+
// number. Anything else is corruption from a user-writable cursor file, not a
|
|
3856
|
+
// zero: reading it as zero would make `delta = want - 0 = want` and re-add the
|
|
3857
|
+
// whole contribution. Both call sites must use this — an earlier version had
|
|
3858
|
+
// one loop fall back to 0 and its sibling fall back to `want`, which laundered
|
|
3859
|
+
// corruption into a legitimate-looking zero.
|
|
3860
|
+
function kiroWatermarkFloor(cell, field, fallback) {
|
|
3861
|
+
if (!cell || typeof cell !== "object") return fallback;
|
|
3862
|
+
const value = cell[field];
|
|
3863
|
+
if (typeof value !== "number" || !Number.isFinite(value) || value < 0) {
|
|
3864
|
+
return fallback;
|
|
3865
|
+
}
|
|
3866
|
+
return value;
|
|
3735
3867
|
}
|
|
3736
3868
|
|
|
3737
3869
|
async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onProgress, env, sqliteOptions } = {}) {
|
|
@@ -3765,12 +3897,16 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
3765
3897
|
// SQLite carries a persisted request_id UUID; session files synthesize
|
|
3766
3898
|
// `${sessionId}:${loop_id.rand}`. When kiro-cli migrates a live session
|
|
3767
3899
|
// into SQLite the same turn lands under a new request_id — the cross-
|
|
3768
|
-
//
|
|
3900
|
+
// same turn lands under a new request_id; the watermark makes that a
|
|
3901
|
+
// no-op because the bucket and its value are unchanged (see #65).
|
|
3902
|
+
// (historical note: a cross-source retraction pass used to live below)
|
|
3903
|
+
// was: source retraction pass matched session_id ↔
|
|
3769
3904
|
// SQLite conversation_id OR continuation_id to subtract the orphan
|
|
3770
3905
|
// session-file cursor entry before the new SQLite row is processed.
|
|
3771
|
-
const
|
|
3906
|
+
const dbRead = fssync.existsSync(dbPath)
|
|
3772
3907
|
? readKiroCliRequests(dbPath, resolvedEnv, sqliteOptions)
|
|
3773
|
-
: [];
|
|
3908
|
+
: { ok: true, reason: null, rows: [] };
|
|
3909
|
+
const flatDb = dbRead.rows;
|
|
3774
3910
|
const sessionFilesList = resolveKiroCliSessionFiles(resolvedEnv);
|
|
3775
3911
|
let flatSessions = [];
|
|
3776
3912
|
for (const jsonPath of sessionFilesList) {
|
|
@@ -3779,7 +3915,7 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
3779
3915
|
}
|
|
3780
3916
|
// Per-request state replaces the old seenIds set. Each entry captures
|
|
3781
3917
|
// what we contributed for that request_id last time, so a later mutation
|
|
3782
|
-
// (same request_id, different fingerprint)
|
|
3918
|
+
// (same request_id, different fingerprint) contributes only its growth
|
|
3783
3919
|
// instead of being skipped forever.
|
|
3784
3920
|
const requestState =
|
|
3785
3921
|
kiroCliState.requests && typeof kiroCliState.requests === "object"
|
|
@@ -3824,53 +3960,17 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
3824
3960
|
migratedMsgIds.add(row.message_id);
|
|
3825
3961
|
}
|
|
3826
3962
|
if (migratedConvIds.size > 0) {
|
|
3827
|
-
//
|
|
3828
|
-
//
|
|
3829
|
-
//
|
|
3830
|
-
//
|
|
3831
|
-
//
|
|
3832
|
-
//
|
|
3833
|
-
|
|
3834
|
-
|
|
3835
|
-
|
|
3836
|
-
|
|
3837
|
-
|
|
3838
|
-
let sid = null;
|
|
3839
|
-
if (typeof prev.session_id === "string" && prev.session_id) {
|
|
3840
|
-
sid = prev.session_id;
|
|
3841
|
-
} else {
|
|
3842
|
-
const colon = reqId.indexOf(":");
|
|
3843
|
-
if (colon > 0) sid = reqId.slice(0, colon);
|
|
3844
|
-
}
|
|
3845
|
-
if (!sid || !migratedConvIds.has(sid)) continue;
|
|
3846
|
-
toRetract.push([reqId, prev, sid]);
|
|
3847
|
-
}
|
|
3848
|
-
for (const [reqId, prev, sid] of toRetract) {
|
|
3849
|
-
if (prev.input_tokens || prev.output_tokens) {
|
|
3850
|
-
const prevBucket = getHourlyBucket(
|
|
3851
|
-
hourlyState,
|
|
3852
|
-
"kiro",
|
|
3853
|
-
prev.model,
|
|
3854
|
-
prev.bucketStart,
|
|
3855
|
-
);
|
|
3856
|
-
addTotals(prevBucket.totals, {
|
|
3857
|
-
input_tokens: -prev.input_tokens,
|
|
3858
|
-
cached_input_tokens: 0,
|
|
3859
|
-
cache_creation_input_tokens: 0,
|
|
3860
|
-
output_tokens: -prev.output_tokens,
|
|
3861
|
-
reasoning_output_tokens: 0,
|
|
3862
|
-
total_tokens: -(prev.input_tokens + prev.output_tokens),
|
|
3863
|
-
conversation_count: -1,
|
|
3864
|
-
});
|
|
3865
|
-
touchedBuckets.add(bucketKey("kiro", prev.model, prev.bucketStart));
|
|
3866
|
-
}
|
|
3867
|
-
delete requestState[reqId];
|
|
3868
|
-
if (debugEnabled) {
|
|
3869
|
-
process.stderr.write(
|
|
3870
|
-
`[kiro-cli] retracted migrated session entry (conv ${sid})\n`,
|
|
3871
|
-
);
|
|
3872
|
-
}
|
|
3873
|
-
}
|
|
3963
|
+
// #65: the cursor-retraction pass that used to live here is gone. It
|
|
3964
|
+
// walked the age- and count-capped `requestState` to subtract a migrated
|
|
3965
|
+
// conversation's earlier contribution — the very state whose eviction
|
|
3966
|
+
// causes #65. Under a positive-only watermark it is unnecessary: the same
|
|
3967
|
+
// turn arriving from SQLite instead of a session file lands in the same
|
|
3968
|
+
// (model, bucketStart) with the same value, so `desired` is unchanged and
|
|
3969
|
+
// the delta is zero.
|
|
3970
|
+
//
|
|
3971
|
+
// The turn-granular filter below DOES have to stay: it removes a turn that
|
|
3972
|
+
// is present in BOTH readers within a SINGLE sync, which would otherwise
|
|
3973
|
+
// inflate `desired` itself.
|
|
3874
3974
|
// Turn-granular filter: drop a session-file turn only when at least
|
|
3875
3975
|
// one of its assistant/tool_result message_ids is present in SQLite
|
|
3876
3976
|
// (i.e. this specific turn has been flushed). Newer turns in the
|
|
@@ -3910,11 +4010,16 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
3910
4010
|
// Clamp + cap BEFORE flushing so the early-return path applies the
|
|
3911
4011
|
// same guarantees as the main path (fixes a skip that flushed
|
|
3912
4012
|
// negative conversation_counts and left the cap unapplied).
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
4013
|
+
// Clamp only. The cap is deliberately NOT applied here either: pruning a
|
|
4014
|
+
// request from this map means the reader re-adds it in full next sync,
|
|
4015
|
+
// which is issue #65 (INTENT rule 1).
|
|
4016
|
+
for (const key of touchedBuckets) {
|
|
4017
|
+
const b = hourlyState.buckets && hourlyState.buckets[key];
|
|
4018
|
+
if (b && b.totals && b.totals.conversation_count < 0) {
|
|
4019
|
+
b.totals.conversation_count = 0;
|
|
4020
|
+
}
|
|
4021
|
+
}
|
|
4022
|
+
const cappedEarly = requestState;
|
|
3918
4023
|
const bucketsQueued = await enqueueTouchedBuckets({
|
|
3919
4024
|
queuePath,
|
|
3920
4025
|
hourlyState,
|
|
@@ -3923,18 +4028,39 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
3923
4028
|
const updatedAt = new Date().toISOString();
|
|
3924
4029
|
hourlyState.updatedAt = updatedAt;
|
|
3925
4030
|
cursors.hourly = hourlyState;
|
|
3926
|
-
|
|
4031
|
+
// Stamp the era marker only when doing so cannot be mistaken later for
|
|
4032
|
+
// "already adopted". For a cursor that has prior state but no marker — a
|
|
4033
|
+
// pre-#65 install — stamping here would make the NEXT sync skip adoption
|
|
4034
|
+
// and re-add the entire corpus on top of the existing inflation. Leave
|
|
4035
|
+
// such a cursor pre-era so adoption still fires when data appears.
|
|
4036
|
+
const priorEra = kiroCliState.watermarkVersion === KIRO_WATERMARK_VERSION;
|
|
4037
|
+
const priorState = Object.keys(kiroCliState).length > 0;
|
|
4038
|
+
cursors.kiroCli = {
|
|
4039
|
+
...kiroCliState,
|
|
4040
|
+
requests: cappedEarly,
|
|
4041
|
+
...(!priorState || priorEra ? { watermarkVersion: KIRO_WATERMARK_VERSION } : {}),
|
|
4042
|
+
updatedAt,
|
|
4043
|
+
};
|
|
3927
4044
|
return { recordsProcessed: 0, eventsAggregated: 0, bucketsQueued };
|
|
3928
4045
|
}
|
|
3929
4046
|
const cb = typeof onProgress === "function" ? onProgress : null;
|
|
3930
4047
|
let recordsProcessed = 0;
|
|
3931
|
-
|
|
4048
|
+
|
|
4049
|
+
// What the CURRENT reader output says each request contributed. Deltas are
|
|
4050
|
+
// taken per request against the watermark after the loop.
|
|
4051
|
+
const desiredRequests = {};
|
|
4052
|
+
let seenRequests = 0;
|
|
3932
4053
|
|
|
3933
4054
|
for (let i = 0; i < flat.length; i++) {
|
|
3934
4055
|
const r = flat[i];
|
|
3935
4056
|
recordsProcessed++;
|
|
3936
4057
|
|
|
3937
|
-
|
|
4058
|
+
// Identity must be STABLE ACROSS TIERS. When kiro-cli flushes a live
|
|
4059
|
+
// session into SQLite the same turn reappears under a brand-new
|
|
4060
|
+
// request_id, so keying on request_id first would count it twice — the
|
|
4061
|
+
// cross-source double count that TASK-007's retraction pass used to undo.
|
|
4062
|
+
// `message_id` survives that migration, so prefer it.
|
|
4063
|
+
const requestId = r.message_id || r.request_id;
|
|
3938
4064
|
if (!requestId) continue;
|
|
3939
4065
|
|
|
3940
4066
|
const promptChars = toNonNegativeInt(r.user_prompt_length);
|
|
@@ -3953,54 +4079,46 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
3953
4079
|
|
|
3954
4080
|
// Fingerprint captures every field whose change should cause a re-bucket.
|
|
3955
4081
|
const fingerprint = `${promptChars}:${responseChars}:${model}:${tsMs}`;
|
|
3956
|
-
|
|
3957
|
-
|
|
3958
|
-
|
|
3959
|
-
//
|
|
3960
|
-
//
|
|
3961
|
-
//
|
|
3962
|
-
|
|
3963
|
-
|
|
3964
|
-
|
|
3965
|
-
|
|
3966
|
-
|
|
3967
|
-
|
|
3968
|
-
|
|
3969
|
-
|
|
3970
|
-
|
|
3971
|
-
|
|
3972
|
-
|
|
3973
|
-
|
|
3974
|
-
|
|
3975
|
-
|
|
3976
|
-
|
|
3977
|
-
|
|
3978
|
-
|
|
3979
|
-
|
|
3980
|
-
|
|
3981
|
-
|
|
3982
|
-
|
|
3983
|
-
|
|
3984
|
-
|
|
3985
|
-
|
|
3986
|
-
|
|
3987
|
-
|
|
3988
|
-
|
|
3989
|
-
|
|
3990
|
-
|
|
3991
|
-
|
|
3992
|
-
// Always record the cursor entry (even for zero-token requests) so we
|
|
3993
|
-
// don't re-count later if Kiro rewrites this request with real data.
|
|
3994
|
-
// Bug-2: tag session-origin entries with session_id so the retraction
|
|
3995
|
-
// pass can identify them regardless of request_id format (the
|
|
3996
|
-
// no-loop_id fallback produces a bare UUID with no colon, which would
|
|
3997
|
-
// otherwise be indistinguishable from SQLite's UUID keys).
|
|
3998
|
-
requestState[requestId] = {
|
|
4082
|
+
// `requests` is an ARRAY per turn while `message_id` plausibly identifies
|
|
4083
|
+
// the TURN, so two distinct requests can share one identity. Last-write-
|
|
4084
|
+
// wins would silently halve them, and an undercount here is unrecoverable.
|
|
4085
|
+
// Accumulate instead, keeping each underlying request's own share so a
|
|
4086
|
+
// repeated row replaces rather than adds. Cross-tier duplicates of the
|
|
4087
|
+
// same turn are already removed by the turn-granular filter above, so
|
|
4088
|
+
// whatever still shares an identity here is genuinely distinct work.
|
|
4089
|
+
//
|
|
4090
|
+
// This is safe whether or not kiro-cli actually emits shared message_ids:
|
|
4091
|
+
// with one request per turn it reduces to the single-entry case exactly.
|
|
4092
|
+
const partKey = r.request_id || r.message_id;
|
|
4093
|
+
if (!desiredRequests[requestId]) seenRequests += 1;
|
|
4094
|
+
// The pre-watermark cursor keyed `request_id || message_id`; this one keys
|
|
4095
|
+
// `message_id || request_id`. Carry the other key so adoption can probe
|
|
4096
|
+
// both — without it every legacy entry misses and adoption sees an empty
|
|
4097
|
+
// cursor.
|
|
4098
|
+
const altKey = r.request_id && r.message_id
|
|
4099
|
+
? (requestId === r.message_id ? r.request_id : r.message_id)
|
|
4100
|
+
: null;
|
|
4101
|
+
const entry = desiredRequests[requestId] || {
|
|
4102
|
+
altKey,
|
|
4103
|
+
parts: new Map(),
|
|
4104
|
+
input_tokens: 0,
|
|
4105
|
+
output_tokens: 0,
|
|
4106
|
+
};
|
|
4107
|
+
entry.parts.set(partKey, { input: approxInput, output: approxOutput });
|
|
4108
|
+
let sumInput = 0;
|
|
4109
|
+
let sumOutput = 0;
|
|
4110
|
+
for (const part of entry.parts.values()) {
|
|
4111
|
+
sumInput += part.input;
|
|
4112
|
+
sumOutput += part.output;
|
|
4113
|
+
}
|
|
4114
|
+
desiredRequests[requestId] = {
|
|
4115
|
+
...entry,
|
|
4116
|
+
altKey: entry.altKey || altKey,
|
|
3999
4117
|
fingerprint,
|
|
4000
4118
|
bucketStart,
|
|
4001
4119
|
model,
|
|
4002
|
-
input_tokens:
|
|
4003
|
-
output_tokens:
|
|
4120
|
+
input_tokens: sumInput,
|
|
4121
|
+
output_tokens: sumOutput,
|
|
4004
4122
|
...(r.session_id ? { session_id: r.session_id } : {}),
|
|
4005
4123
|
};
|
|
4006
4124
|
|
|
@@ -4009,23 +4127,152 @@ async function parseKiroCliIncremental({ sessionFiles, cursors, queuePath, onPro
|
|
|
4009
4127
|
index: i + 1,
|
|
4010
4128
|
total: flat.length,
|
|
4011
4129
|
recordsProcessed,
|
|
4012
|
-
|
|
4130
|
+
// Deltas are applied after this loop; report requests seen so far.
|
|
4131
|
+
// Counter, not Object.keys — this reader has no age limit, so
|
|
4132
|
+
// recomputing it every 50 records is quadratic in total history.
|
|
4133
|
+
eventsAggregated: seenRequests,
|
|
4013
4134
|
bucketsQueued: touchedBuckets.size,
|
|
4014
4135
|
});
|
|
4015
4136
|
}
|
|
4016
4137
|
}
|
|
4017
4138
|
|
|
4018
|
-
|
|
4019
|
-
|
|
4020
|
-
|
|
4021
|
-
|
|
4022
|
-
|
|
4139
|
+
// ── Apply max(0, current - watermark), per request ───────────────────────
|
|
4140
|
+
// `requestState` IS the watermark: the highest contribution this parser has
|
|
4141
|
+
// ever applied for each request id. It is never pruned (INTENT rule 1).
|
|
4142
|
+
const watermarkEra = kiroCliState.watermarkVersion === KIRO_WATERMARK_VERSION;
|
|
4143
|
+
const hasPriorState = Object.keys(kiroCliState).length > 0;
|
|
4144
|
+
|
|
4145
|
+
// A cursor written by a NEWER version must be left alone rather than
|
|
4146
|
+
// silently rewritten down to this schema.
|
|
4147
|
+
if (
|
|
4148
|
+
typeof kiroCliState.watermarkVersion === "number" &&
|
|
4149
|
+
kiroCliState.watermarkVersion > KIRO_WATERMARK_VERSION
|
|
4150
|
+
) {
|
|
4151
|
+
return { recordsProcessed, eventsAggregated: 0, bucketsQueued: 0 };
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
// Adoption: a pre-watermark cursor's tokens are already in the hourly
|
|
4155
|
+
// buckets — inflated, because that is #65. Seed each request's watermark
|
|
4156
|
+
// from what we see now and apply no delta, so the existing total is frozen
|
|
4157
|
+
// rather than added to a second time. This freezes prior inflation; it does
|
|
4158
|
+
// not repair it, because repair would need the CLI's share of a shared kiro
|
|
4159
|
+
// bucket and the hourly state does not record that. Say so in the release
|
|
4160
|
+
// notes. Requests arriving AFTER adoption are absent from the watermark and
|
|
4161
|
+
// are therefore counted in full.
|
|
4162
|
+
// Adoption is decided PER REQUEST, never as one flag for the whole sync. A
|
|
4163
|
+
// single `adopting` latch froze every request it saw — including ones that
|
|
4164
|
+
// had never reached a bucket — and permanently lost all work created in the
|
|
4165
|
+
// upgrade window.
|
|
4166
|
+
//
|
|
4167
|
+
// For a pre-watermark cursor the question "was this request already added?"
|
|
4168
|
+
// has three answers, and the old cursor's own prune policy resolves the
|
|
4169
|
+
// ambiguous one:
|
|
4170
|
+
// • a prior record exists (under EITHER key scheme — the old cursor keyed
|
|
4171
|
+
// `request_id || message_id`, this one keys `message_id || request_id`,
|
|
4172
|
+
// so both must be probed or every entry misses) -> already counted, and
|
|
4173
|
+
// its recorded value is the floor, so later growth still lands;
|
|
4174
|
+
// • no record, and the bucket is OLDER than the old age cap -> it was
|
|
4175
|
+
// pruned, i.e. counted but forgotten -> freeze it;
|
|
4176
|
+
// • no record, and the bucket is NEWER than the cap -> the old cursor
|
|
4177
|
+
// would still be holding it if it had ever been counted, so it is
|
|
4178
|
+
// genuinely new -> count it in full.
|
|
4179
|
+
const migrating = hasPriorState && !watermarkEra;
|
|
4180
|
+
const legacyPruneCutoffMs = Date.now() - KIRO_CLI_CURSOR_MAX_AGE_MS;
|
|
4181
|
+
const priorFor = (want, requestId) => {
|
|
4182
|
+
const direct = requestState[requestId];
|
|
4183
|
+
if (direct) return direct;
|
|
4184
|
+
// Probe the other key scheme so a pre-watermark cursor is not misread as
|
|
4185
|
+
// empty.
|
|
4186
|
+
const alt = want.altKey ? requestState[want.altKey] : null;
|
|
4187
|
+
return alt || null;
|
|
4188
|
+
};
|
|
4189
|
+
const presumedCounted = (want) => {
|
|
4190
|
+
if (!migrating) return false;
|
|
4191
|
+
const ts = Date.parse(want.bucketStart);
|
|
4192
|
+
return Number.isFinite(ts) && ts < legacyPruneCutoffMs;
|
|
4193
|
+
};
|
|
4194
|
+
|
|
4195
|
+
let eventsAggregated = 0;
|
|
4196
|
+
let adoptionComplete = true;
|
|
4197
|
+
for (const [requestId, want] of Object.entries(desiredRequests)) {
|
|
4198
|
+
const prior = priorFor(want, requestId);
|
|
4199
|
+
const frozen = !prior && presumedCounted(want);
|
|
4200
|
+
if (!prior && migrating && !frozen && !dbRead.ok) {
|
|
4201
|
+
// A degraded read during the migration sync cannot tell "new" from
|
|
4202
|
+
// "the DB just did not report it", so adoption is not finished.
|
|
4203
|
+
adoptionComplete = false;
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
const floorInput = frozen
|
|
4207
|
+
? want.input_tokens
|
|
4208
|
+
: prior
|
|
4209
|
+
? kiroWatermarkFloor(prior, "input_tokens", want.input_tokens)
|
|
4210
|
+
: 0;
|
|
4211
|
+
const floorOutput = frozen
|
|
4212
|
+
? want.output_tokens
|
|
4213
|
+
: prior
|
|
4214
|
+
? kiroWatermarkFloor(prior, "output_tokens", want.output_tokens)
|
|
4215
|
+
: 0;
|
|
4216
|
+
|
|
4217
|
+
const dInput = Math.max(0, want.input_tokens - floorInput);
|
|
4218
|
+
const dOutput = Math.max(0, want.output_tokens - floorOutput);
|
|
4219
|
+
|
|
4220
|
+
// `counted` is RECORDED, not inferred. Inferring it from `!prior` meant a
|
|
4221
|
+
// request first seen with zero tokens never contributed its conversation
|
|
4222
|
+
// when it later grew — Kiro rewriting a request with real data is normal.
|
|
4223
|
+
const alreadyCounted = prior ? prior.counted === true : frozen;
|
|
4224
|
+
const dConv = !alreadyCounted && (dInput > 0 || dOutput > 0) ? 1 : 0;
|
|
4225
|
+
|
|
4226
|
+
requestState[requestId] = {
|
|
4227
|
+
fingerprint: want.fingerprint,
|
|
4228
|
+
bucketStart: want.bucketStart,
|
|
4229
|
+
model: want.model,
|
|
4230
|
+
input_tokens: Math.max(floorInput, want.input_tokens),
|
|
4231
|
+
output_tokens: Math.max(floorOutput, want.output_tokens),
|
|
4232
|
+
counted: alreadyCounted || dConv === 1,
|
|
4233
|
+
...(want.session_id ? { session_id: want.session_id } : {}),
|
|
4234
|
+
};
|
|
4235
|
+
|
|
4236
|
+
if (!dInput && !dOutput && !dConv) continue;
|
|
4237
|
+
|
|
4238
|
+
const bucket = getHourlyBucket(hourlyState, "kiro", want.model, want.bucketStart);
|
|
4239
|
+
addTotals(bucket.totals, {
|
|
4240
|
+
input_tokens: dInput,
|
|
4241
|
+
cached_input_tokens: 0,
|
|
4242
|
+
cache_creation_input_tokens: 0,
|
|
4243
|
+
output_tokens: dOutput,
|
|
4244
|
+
reasoning_output_tokens: 0,
|
|
4245
|
+
total_tokens: dInput + dOutput,
|
|
4246
|
+
conversation_count: dConv,
|
|
4247
|
+
});
|
|
4248
|
+
touchedBuckets.add(bucketKey("kiro", want.model, want.bucketStart));
|
|
4249
|
+
eventsAggregated += 1;
|
|
4250
|
+
}
|
|
4251
|
+
|
|
4252
|
+
// Clamp only; the cap is deliberately NOT applied (INTENT rule 1).
|
|
4253
|
+
for (const key of touchedBuckets) {
|
|
4254
|
+
const bucket = hourlyState.buckets && hourlyState.buckets[key];
|
|
4255
|
+
if (bucket && bucket.totals && bucket.totals.conversation_count < 0) {
|
|
4256
|
+
bucket.totals.conversation_count = 0;
|
|
4257
|
+
}
|
|
4258
|
+
}
|
|
4023
4259
|
|
|
4024
4260
|
const bucketsQueued = await enqueueTouchedBuckets({ queuePath, hourlyState, touchedBuckets });
|
|
4025
4261
|
const updatedAt = new Date().toISOString();
|
|
4026
4262
|
hourlyState.updatedAt = updatedAt;
|
|
4027
4263
|
cursors.hourly = hourlyState;
|
|
4028
|
-
cursors.kiroCli = {
|
|
4264
|
+
cursors.kiroCli = {
|
|
4265
|
+
...kiroCliState,
|
|
4266
|
+
requests: requestState,
|
|
4267
|
+
// Stamp only when this sync could actually complete the migration. The
|
|
4268
|
+
// early-return path applies the same caution; applying it there and not
|
|
4269
|
+
// here let one degraded read end migration prematurely and re-count the
|
|
4270
|
+
// whole corpus on the next healthy sync.
|
|
4271
|
+
...(!migrating || adoptionComplete
|
|
4272
|
+
? { watermarkVersion: KIRO_WATERMARK_VERSION }
|
|
4273
|
+
: {}),
|
|
4274
|
+
updatedAt,
|
|
4275
|
+
};
|
|
4029
4276
|
|
|
4030
4277
|
return { recordsProcessed, eventsAggregated, bucketsQueued };
|
|
4031
4278
|
}
|
|
@@ -4042,7 +4289,8 @@ function clampAndCapKiroCliState({ requestState, hourlyState, touchedBuckets })
|
|
|
4042
4289
|
// TASK-010: clamp conversation_count to >= 0 on Kiro-touched buckets
|
|
4043
4290
|
// only. The shared enqueueTouchedBuckets is left untouched so
|
|
4044
4291
|
// legitimate negatives from the 10 other parsers are not masked. Kiro
|
|
4045
|
-
// negatives come from the
|
|
4292
|
+
// negatives can only come from the other kiro writers now; this parser
|
|
4293
|
+
// never emits one (#65).
|
|
4046
4294
|
for (const key of touchedBuckets) {
|
|
4047
4295
|
const bucket = hourlyState.buckets && hourlyState.buckets[key];
|
|
4048
4296
|
if (bucket && bucket.totals && bucket.totals.conversation_count < 0) {
|