farming-code 2.2.14 → 2.2.17
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 +3 -3
- package/README.zh_cn.md +3 -3
- package/THIRD_PARTY_NOTICES.md +17 -1
- package/backend/acp/packaged-codex-acp.js +13 -0
- package/backend/acp-runtime.js +413 -24
- package/backend/agent-manager.js +572 -557
- package/backend/agent-provider-session.js +15 -1
- package/backend/agent-runtime-binding.js +9 -55
- package/backend/chat-runtime.js +28 -0
- package/backend/codex-rollout-follower.js +83 -0
- package/backend/codex-session-archive.js +12 -1
- package/backend/codex-session-history.js +94 -0
- package/backend/codex-transcript.js +2 -1
- package/backend/config-manager.js +25 -17
- package/backend/control-api.js +3 -1
- package/backend/farming-app-cli.js +21 -0
- package/backend/farming-session-store.js +2 -1
- package/backend/native-pty-host-client.js +59 -2
- package/backend/npm-update-helper.js +65 -7
- package/backend/provider-adapters.js +123 -38
- package/backend/provider-session-service.js +138 -34
- package/backend/server.js +94 -105
- package/backend/storage-layout.js +5 -16
- package/backend/update-service.js +94 -5
- package/backend/usage-history-client.js +305 -0
- package/backend/usage-history-scanner.generated.js +2250 -0
- package/backend/usage-history-scanner.ts +2718 -0
- package/backend/usage-history-smoke.js +64 -0
- package/backend/usage-history-worker.js +34 -0
- package/backend/usage-monitor.js +188 -588
- package/backend/vendor/usage-parsers/CC_STATISTICS_LICENSE +21 -0
- package/backend/vendor/usage-parsers/CODEXBAR_LICENSE +21 -0
- package/backend/vendor/usage-parsers/VENDOR.json +45 -0
- package/dist/acp/LICENSE.codex-acp +190 -0
- package/dist/acp/codex-acp-1.1.4.js +30718 -0
- package/dist/assets/App-6yWdwzj8.js +210 -0
- package/dist/assets/{FileEditorMarkdownPreview-UlVd0O4D.js → FileEditorMarkdownPreview-R0jwwLME.js} +4 -4
- package/dist/assets/FileEditorPane-DNy3LAqS.js +2 -0
- package/dist/assets/{IconGlyphs-AU22gPDY.js → IconGlyphs-BsUfqpKN.js} +1 -1
- package/dist/assets/ProjectFilesSection-BhDEUVHA.js +12 -0
- package/dist/assets/{ReviewPage-CeDKDyZh.js → ReviewPage-ChtByBe3.js} +1 -1
- package/dist/assets/{abnfDiagram-VRR7QNED-BgawzDQT.js → abnfDiagram-VRR7QNED-nnglrXFi.js} +1 -1
- package/dist/assets/{architectureDiagram-ZJ3FMSHR-DUShwPg0.js → architectureDiagram-ZJ3FMSHR-BEXMaFfY.js} +1 -1
- package/dist/assets/{blockDiagram-677ZJIJ3-CeGZviXR.js → blockDiagram-677ZJIJ3-1mCezSBk.js} +1 -1
- package/dist/assets/{c4Diagram-LMCZKHZV-CLhYht6S.js → c4Diagram-LMCZKHZV-DhTiI6Uv.js} +1 -1
- package/dist/assets/channel-BVWNatUB.js +1 -0
- package/dist/assets/{chunk-32BRIVSS-LFYpT-Cu.js → chunk-32BRIVSS-CCdsKd73.js} +1 -1
- package/dist/assets/{chunk-52WLFC77-BH6jDw9J.js → chunk-52WLFC77-Gncjk4LF.js} +1 -1
- package/dist/assets/{chunk-C7G6YPKG-B_mIUlb7.js → chunk-C7G6YPKG-CiDVUP82.js} +1 -1
- package/dist/assets/{chunk-EX3LRPZG-C63hVuAJ.js → chunk-EX3LRPZG-z6gXP2_N.js} +1 -1
- package/dist/assets/{chunk-FWX5IMBZ-CboX-WF_.js → chunk-FWX5IMBZ-DMZBccQa.js} +2 -2
- package/dist/assets/{chunk-HOUHSVGY-BOgSd6Un.js → chunk-HOUHSVGY-D8tIeoPk.js} +1 -1
- package/dist/assets/{chunk-ICXQ74PX-C0K-pgZt.js → chunk-ICXQ74PX-b6oQ2Q17.js} +1 -1
- package/dist/assets/{chunk-MOJQB5TN-CVMfwN_7.js → chunk-MOJQB5TN-DAVH_p2a.js} +1 -1
- package/dist/assets/{chunk-OGEWGWER-DLT3pxyw.js → chunk-OGEWGWER-DxoiMCI-.js} +1 -1
- package/dist/assets/{chunk-PUDLZKDR-Cd079yIQ.js → chunk-PUDLZKDR-NsOWh7HM.js} +1 -1
- package/dist/assets/{chunk-Q4XR5HBZ-D0jHa6SO.js → chunk-Q4XR5HBZ-Di4H7L21.js} +1 -1
- package/dist/assets/{chunk-V7JOEXUC-CVDtR75H.js → chunk-V7JOEXUC-DttpZg9Y.js} +1 -1
- package/dist/assets/{chunk-VAUOI2AC-B-W2_9Ot.js → chunk-VAUOI2AC-UHNr_yhX.js} +1 -1
- package/dist/assets/{chunk-VR4S4FIN-FD3kWh32.js → chunk-VR4S4FIN-D-Fmtd4Z.js} +1 -1
- package/dist/assets/{chunk-WYO6CB5R-C2bGaS5r.js → chunk-WYO6CB5R-CUxMtfK2.js} +4 -4
- package/dist/assets/{chunk-ZGVPDNZ5-ymgts0zo.js → chunk-ZGVPDNZ5-CoV2QEl0.js} +1 -1
- package/dist/assets/classDiagram-OUVF2IWQ-Db0ib5iE.js +1 -0
- package/dist/assets/classDiagram-v2-EOCWNBFH-Bkf69XF9.js +1 -0
- package/dist/assets/code-dark-5esj0bRu.css +1 -0
- package/dist/assets/{cynefinDiagram-TSTJHNR4-CwpobXIl.js → cynefinDiagram-TSTJHNR4-0xPI92Lg.js} +1 -1
- package/dist/assets/{dagre-VKFMJZFB-Dkhlfjna.js → dagre-VKFMJZFB-DfuMG4gU.js} +1 -1
- package/dist/assets/{diagram-FQU43EPY-hA8dicp6.js → diagram-FQU43EPY-CT00bLZ6.js} +1 -1
- package/dist/assets/{diagram-G47NLZAW-BTY6JTzO.js → diagram-G47NLZAW-Xbh7zXUx.js} +1 -1
- package/dist/assets/{diagram-NH7WQ7WH-xkaHHX9M.js → diagram-NH7WQ7WH-SRDVPfn7.js} +1 -1
- package/dist/assets/{diagram-OA4YK3LP-gk_fg9UU.js → diagram-OA4YK3LP-CQRli4Np.js} +1 -1
- package/dist/assets/{diagram-WEI45ONY-BvC8xd6T.js → diagram-WEI45ONY-DMf17WQi.js} +1 -1
- package/dist/assets/{ebnfDiagram-CCIWWBDH-CR7RaNat.js → ebnfDiagram-CCIWWBDH-DhpFkgJ5.js} +1 -1
- package/dist/assets/{erDiagram-Q63AITRT-_M-JFPbj.js → erDiagram-Q63AITRT-1IwxWmJo.js} +1 -1
- package/dist/assets/file-icons-DpbrD5ZP.js +1 -0
- package/dist/assets/flowDiagram-23GEKE2U-DYFo2xmB.js +1 -0
- package/dist/assets/{ganttDiagram-NO4QXBWP-Bl8P1yf_.js → ganttDiagram-NO4QXBWP-BNCdfyr1.js} +1 -1
- package/dist/assets/{gitGraphDiagram-IHSO6WYX-DoHa5ysz.js → gitGraphDiagram-IHSO6WYX-pbyWZuiX.js} +1 -1
- package/dist/assets/{index-BA-iH2Kg.js → index-C8ZFKye1.js} +2 -2
- package/dist/assets/{infoDiagram-FWYZ7A6U-DSFcSuUs.js → infoDiagram-FWYZ7A6U-BcTOgDbe.js} +1 -1
- package/dist/assets/{ishikawaDiagram-FXEZZL3T-DdkqullI.js → ishikawaDiagram-FXEZZL3T-DCdvGKrn.js} +1 -1
- package/dist/assets/{journeyDiagram-5HDEW3XC-B4hRnQJr.js → journeyDiagram-5HDEW3XC-BZ-1q_IT.js} +1 -1
- package/dist/assets/{kanban-definition-HUTT4EX6-tm2FZB2E.js → kanban-definition-HUTT4EX6-BIgejCjj.js} +1 -1
- package/dist/assets/{line-CmsjK4kn.js → line-CfptFMmX.js} +1 -1
- package/dist/assets/main-DSO3i_Gs.css +1 -0
- package/dist/assets/{mermaid.core-C7UT_EEL.js → mermaid.core-gSpX30pV.js} +3 -3
- package/dist/assets/{mindmap-definition-LN4V7U3C-DplfjGms.js → mindmap-definition-LN4V7U3C-BKBaI4LW.js} +1 -1
- package/dist/assets/{pegDiagram-2B236MQR-lu3SdkQs.js → pegDiagram-2B236MQR-sBvDY3VX.js} +1 -1
- package/dist/assets/{pieDiagram-ENE6RG2P-3fHbmDf9.js → pieDiagram-ENE6RG2P-DWlWcu08.js} +1 -1
- package/dist/assets/{quadrantDiagram-ABIIQ3AL-a5qoWz-X.js → quadrantDiagram-ABIIQ3AL-DZutUd3J.js} +1 -1
- package/dist/assets/{railroadDiagram-RFXS5EU6-D2TOuXW9.js → railroadDiagram-RFXS5EU6-DQb3lXGM.js} +1 -1
- package/dist/assets/{requirementDiagram-TGXJPOKE-LQBWpUvd.js → requirementDiagram-TGXJPOKE-DqFGXu2e.js} +1 -1
- package/dist/assets/{sankeyDiagram-HTMAVEWB-B1ohYoyK.js → sankeyDiagram-HTMAVEWB-d7ECahnO.js} +1 -1
- package/dist/assets/{sequenceDiagram-DBY2YBRQ-C3vM-hMq.js → sequenceDiagram-DBY2YBRQ-DdsbKqct.js} +1 -1
- package/dist/assets/{stateDiagram-2N3HPSRC-BvFhOryI.js → stateDiagram-2N3HPSRC-P6iSrgwR.js} +1 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-DIYSf_qh.js +1 -0
- package/dist/assets/{swimlanes-5IMT3BWC-DKF_b-2s.js → swimlanes-5IMT3BWC-BGnZJx1M.js} +1 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-BIkrlKrZ.js +8 -0
- package/dist/assets/{timeline-definition-FHXFAJF6-vAs11ux_.js → timeline-definition-FHXFAJF6-rA-r3aUR.js} +1 -1
- package/dist/assets/{vennDiagram-L72KCM5P-BybTX_9B.js → vennDiagram-L72KCM5P-DiVKUjA9.js} +1 -1
- package/dist/assets/{wardleyDiagram-EHGQE667-CicUmMBR.js → wardleyDiagram-EHGQE667-Cfxh7UOn.js} +1 -1
- package/dist/assets/workspace-editor-model-D0Im34S5.js +1 -0
- package/dist/assets/{workspace-editor-monaco-BICJESG0.js → workspace-editor-monaco-BIpqcWf1.js} +1 -1
- package/dist/assets/workspace-editor-monaco-DLlws5Ig.js +1 -0
- package/dist/assets/{workspace-view-state-DvYG_9PH.js → workspace-view-state-BpPINu84.js} +1 -1
- package/dist/assets/{xychartDiagram-FW5EYKEG-BhWDyCX-.js → xychartDiagram-FW5EYKEG--hmryQeL.js} +1 -1
- package/dist/crt-mermaid-renderer.js +313 -313
- package/dist/index.html +1 -1
- package/frontend/session-bridge.js +24 -2
- package/frontend/skins/crt/app.js +203 -85
- package/frontend/skins/crt/index.html +2 -11
- package/frontend/skins/crt/styles/effects.css +2 -34
- package/package.json +30 -12
- package/shared/backend-connection-status.d.ts +25 -0
- package/shared/backend-connection-status.js +40 -0
- package/shared/browser-protocol.js +3 -3
- package/backend/app-server-api.js +0 -917
- package/backend/codex-app-server-home.js +0 -39
- package/backend/codex-app-server-runtime.js +0 -901
- package/dist/assets/App-Bumi772v.js +0 -206
- package/dist/assets/FileEditorPane-Dxx4rKOg.js +0 -2
- package/dist/assets/ProjectFilesSection-DE7Nb9Jk.js +0 -12
- package/dist/assets/channel-Uw90AO0b.js +0 -1
- package/dist/assets/classDiagram-OUVF2IWQ-DKW_dtku.js +0 -1
- package/dist/assets/classDiagram-v2-EOCWNBFH-C8Fi7-yv.js +0 -1
- package/dist/assets/code-dark-DPiuyeSp.css +0 -1
- package/dist/assets/code-mobile-3C2Rr-CN.css +0 -1
- package/dist/assets/file-icons-Bw2qd5iT.js +0 -1
- package/dist/assets/flowDiagram-23GEKE2U-DAiS5Y7G.js +0 -1
- package/dist/assets/main-Bqtb5kuz.css +0 -1
- package/dist/assets/stateDiagram-v2-6OUMAXLB-WMrz4PNo.js +0 -1
- package/dist/assets/swimlanesDiagram-G3AALYLV-BGswC9oC.js +0 -8
- package/dist/assets/workspace-editor-model-BQol4qbA.js +0 -1
- package/dist/assets/workspace-editor-monaco-DRJ7IaXk.js +0 -1
- package/scripts/sync-ghostty-vendor.js +0 -66
- /package/dist/assets/{core-D0LFJkDt.js → core-B9MwRjDE.js} +0 -0
|
@@ -0,0 +1,2718 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Persistent local token-history scanner for Farming.
|
|
3
|
+
*
|
|
4
|
+
* Codex cumulative-counter accounting is a TypeScript adaptation of CodexBar
|
|
5
|
+
* v0.45.2 (commit 91560ca98e776b96fdf910d4a0423c2f0c07a3b9), specifically
|
|
6
|
+
* CostUsageScanner.swift and CodexSubagentRolloutShape.swift. CodexBar is MIT
|
|
7
|
+
* licensed, Copyright (c) 2026 Peter Steinberger. See THIRD_PARTY_NOTICES.md.
|
|
8
|
+
*
|
|
9
|
+
* Claude message parsing follows the usage/message-id normalization semantics
|
|
10
|
+
* of cc-statistics 1.1.0 (commit c98be0af52bbc7f09a1f277747744ace48d9e014),
|
|
11
|
+
* also MIT licensed. Farming owns the filesystem checkpoints, SQLite schema,
|
|
12
|
+
* scan budgeting, and browser-facing result shape.
|
|
13
|
+
*
|
|
14
|
+
* This module intentionally uses only Node built-ins. It runs in a Worker so
|
|
15
|
+
* synchronous SQLite and filesystem operations cannot block Farming's server.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import fs from 'node:fs';
|
|
19
|
+
import path from 'node:path';
|
|
20
|
+
import crypto from 'node:crypto';
|
|
21
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
22
|
+
|
|
23
|
+
const SCHEMA_VERSION = 11;
|
|
24
|
+
const SOURCE_VERSION = 'farming-usage-ts-1-codexbar-v0.45.2';
|
|
25
|
+
const PREFIX_BYTES = 64 * 1024;
|
|
26
|
+
const READ_CHUNK_BYTES = 1024 * 1024;
|
|
27
|
+
const MAX_LINE_BYTES = 32 * 1024 * 1024;
|
|
28
|
+
const TRUNCATED_EDGE_BYTES = 512 * 1024;
|
|
29
|
+
const DEFAULT_RETENTION_DAYS = 52 * 7;
|
|
30
|
+
const DEFAULT_RECENT_RAW_MS = 24 * 60 * 60 * 1000;
|
|
31
|
+
const DEFAULT_SCAN_BUDGET_MS = 5_000;
|
|
32
|
+
const SEEN_TOTALS_LIMIT = 64;
|
|
33
|
+
const DIRECTORY_AUDIT_BATCH = 256;
|
|
34
|
+
const DIRECTORY_ENTRY_BATCH = 256;
|
|
35
|
+
const SOURCE_AUDIT_BATCH = 128;
|
|
36
|
+
|
|
37
|
+
type Provider = 'codex' | 'claude';
|
|
38
|
+
type Totals = {
|
|
39
|
+
input: number;
|
|
40
|
+
cached: number;
|
|
41
|
+
output: number;
|
|
42
|
+
cacheWrite: number;
|
|
43
|
+
};
|
|
44
|
+
type UsageEvent = {
|
|
45
|
+
timestamp: number;
|
|
46
|
+
sessionId: string;
|
|
47
|
+
totalTokens: number;
|
|
48
|
+
inputTokens: number;
|
|
49
|
+
outputTokens: number;
|
|
50
|
+
cacheReadTokens: number;
|
|
51
|
+
cacheWriteTokens: number;
|
|
52
|
+
unattributedTokens: number;
|
|
53
|
+
};
|
|
54
|
+
type CodexState = {
|
|
55
|
+
sessionId: string;
|
|
56
|
+
leafSessionId: string | null;
|
|
57
|
+
parentSessionId: string | null;
|
|
58
|
+
forkTimestamp: number | null;
|
|
59
|
+
projectPath: string;
|
|
60
|
+
lastAssistantTimestamp: number | null;
|
|
61
|
+
copiedPrefix: boolean;
|
|
62
|
+
isSubagent: boolean;
|
|
63
|
+
ownBoundarySeen: boolean;
|
|
64
|
+
pendingOwnedBoundary: boolean;
|
|
65
|
+
inheritedRaw: Totals | null;
|
|
66
|
+
counted: Totals | null;
|
|
67
|
+
rawBaseline: Totals | null;
|
|
68
|
+
watermark: Totals | null;
|
|
69
|
+
seenRawTotals: Totals[];
|
|
70
|
+
divergent: boolean;
|
|
71
|
+
interleaved: boolean;
|
|
72
|
+
skippedUnresolvedForkTotal: boolean;
|
|
73
|
+
quotas: Record<string, { timestamp: number | null; rateLimits: unknown }>;
|
|
74
|
+
};
|
|
75
|
+
type ClaudeState = {
|
|
76
|
+
sessionId: string;
|
|
77
|
+
projectPath: string;
|
|
78
|
+
};
|
|
79
|
+
type ParserState = CodexState | ClaudeState;
|
|
80
|
+
type SourceCandidate = {
|
|
81
|
+
provider: Provider;
|
|
82
|
+
filePath: string;
|
|
83
|
+
stat: fs.Stats;
|
|
84
|
+
sessionId: string;
|
|
85
|
+
parentSessionId: string | null;
|
|
86
|
+
forkTimestamp: number | null;
|
|
87
|
+
copiedPrefix: boolean;
|
|
88
|
+
parentBaseline: Totals | null;
|
|
89
|
+
};
|
|
90
|
+
type Metrics = Record<string, number | boolean | Record<Provider, number>>;
|
|
91
|
+
type CollectRequest = {
|
|
92
|
+
cacheFile: string;
|
|
93
|
+
legacyCacheFile?: string;
|
|
94
|
+
nowMs?: number;
|
|
95
|
+
retentionDays?: number;
|
|
96
|
+
recentRawMs?: number;
|
|
97
|
+
scanBudgetMs?: number;
|
|
98
|
+
roots?: Partial<Record<Provider, string[]>>;
|
|
99
|
+
};
|
|
100
|
+
|
|
101
|
+
const ZERO: Totals = { input: 0, cached: 0, output: 0, cacheWrite: 0 };
|
|
102
|
+
const TOKEN_FIELDS = ['input', 'cached', 'output', 'cacheWrite'] as const;
|
|
103
|
+
|
|
104
|
+
const SCHEMA_SQL = `
|
|
105
|
+
CREATE TABLE IF NOT EXISTS metadata (
|
|
106
|
+
key TEXT PRIMARY KEY,
|
|
107
|
+
value TEXT NOT NULL
|
|
108
|
+
) WITHOUT ROWID;
|
|
109
|
+
CREATE TABLE IF NOT EXISTS source_files (
|
|
110
|
+
path TEXT PRIMARY KEY,
|
|
111
|
+
provider TEXT NOT NULL,
|
|
112
|
+
directory_path TEXT NOT NULL,
|
|
113
|
+
directory_generation INTEGER NOT NULL,
|
|
114
|
+
size INTEGER NOT NULL,
|
|
115
|
+
mtime_ms REAL NOT NULL,
|
|
116
|
+
committed_offset INTEGER NOT NULL,
|
|
117
|
+
file_dev INTEGER NOT NULL,
|
|
118
|
+
file_ino INTEGER NOT NULL,
|
|
119
|
+
prefix_sha256 TEXT NOT NULL,
|
|
120
|
+
suffix_sha256 TEXT NOT NULL,
|
|
121
|
+
parser_state TEXT NOT NULL,
|
|
122
|
+
session_id TEXT NOT NULL,
|
|
123
|
+
scan_complete INTEGER NOT NULL,
|
|
124
|
+
last_checked_ms INTEGER NOT NULL
|
|
125
|
+
) WITHOUT ROWID;
|
|
126
|
+
CREATE INDEX IF NOT EXISTS source_files_provider_session
|
|
127
|
+
ON source_files(provider, session_id);
|
|
128
|
+
CREATE INDEX IF NOT EXISTS source_files_directory
|
|
129
|
+
ON source_files(directory_path);
|
|
130
|
+
CREATE INDEX IF NOT EXISTS source_files_audit
|
|
131
|
+
ON source_files(last_checked_ms, mtime_ms);
|
|
132
|
+
CREATE INDEX IF NOT EXISTS source_files_recent
|
|
133
|
+
ON source_files(mtime_ms DESC);
|
|
134
|
+
CREATE INDEX IF NOT EXISTS source_files_directory_generation
|
|
135
|
+
ON source_files(directory_path, directory_generation);
|
|
136
|
+
CREATE TABLE IF NOT EXISTS discovery_directories (
|
|
137
|
+
path TEXT PRIMARY KEY,
|
|
138
|
+
provider TEXT NOT NULL,
|
|
139
|
+
root_path TEXT NOT NULL,
|
|
140
|
+
parent_path TEXT NOT NULL,
|
|
141
|
+
is_root INTEGER NOT NULL,
|
|
142
|
+
present INTEGER NOT NULL,
|
|
143
|
+
pending INTEGER NOT NULL,
|
|
144
|
+
scan_generation INTEGER NOT NULL,
|
|
145
|
+
parent_generation INTEGER NOT NULL,
|
|
146
|
+
mtime_ms REAL NOT NULL,
|
|
147
|
+
file_dev INTEGER NOT NULL,
|
|
148
|
+
file_ino INTEGER NOT NULL,
|
|
149
|
+
last_checked_ms INTEGER NOT NULL
|
|
150
|
+
) WITHOUT ROWID;
|
|
151
|
+
CREATE INDEX IF NOT EXISTS discovery_directories_pending
|
|
152
|
+
ON discovery_directories(present, pending, is_root DESC, last_checked_ms);
|
|
153
|
+
CREATE INDEX IF NOT EXISTS discovery_directories_audit
|
|
154
|
+
ON discovery_directories(present, is_root DESC, last_checked_ms);
|
|
155
|
+
CREATE INDEX IF NOT EXISTS discovery_directories_parent_generation
|
|
156
|
+
ON discovery_directories(parent_path, present, parent_generation);
|
|
157
|
+
CREATE TABLE IF NOT EXISTS discovery_source_queue (
|
|
158
|
+
path TEXT PRIMARY KEY,
|
|
159
|
+
provider TEXT NOT NULL,
|
|
160
|
+
action TEXT NOT NULL,
|
|
161
|
+
priority REAL NOT NULL,
|
|
162
|
+
queued_at_ms INTEGER NOT NULL
|
|
163
|
+
) WITHOUT ROWID;
|
|
164
|
+
CREATE INDEX IF NOT EXISTS discovery_source_queue_priority
|
|
165
|
+
ON discovery_source_queue(action, priority, queued_at_ms);
|
|
166
|
+
CREATE TABLE IF NOT EXISTS codex_quota_candidates (
|
|
167
|
+
source_path TEXT NOT NULL,
|
|
168
|
+
limit_id TEXT NOT NULL,
|
|
169
|
+
timestamp_ms INTEGER,
|
|
170
|
+
rate_limits_json TEXT NOT NULL,
|
|
171
|
+
PRIMARY KEY(source_path, limit_id)
|
|
172
|
+
) WITHOUT ROWID;
|
|
173
|
+
CREATE INDEX IF NOT EXISTS codex_quota_candidates_latest
|
|
174
|
+
ON codex_quota_candidates(timestamp_ms DESC);
|
|
175
|
+
CREATE TABLE IF NOT EXISTS cache_stats (
|
|
176
|
+
singleton INTEGER PRIMARY KEY CHECK(singleton = 1),
|
|
177
|
+
codex_files INTEGER NOT NULL,
|
|
178
|
+
claude_files INTEGER NOT NULL,
|
|
179
|
+
codex_incomplete INTEGER NOT NULL,
|
|
180
|
+
claude_incomplete INTEGER NOT NULL,
|
|
181
|
+
committed_bytes INTEGER NOT NULL,
|
|
182
|
+
hourly_rows INTEGER NOT NULL,
|
|
183
|
+
recent_rows INTEGER NOT NULL,
|
|
184
|
+
dedupe_rows INTEGER NOT NULL,
|
|
185
|
+
pending_directories INTEGER NOT NULL,
|
|
186
|
+
queued_sources INTEGER NOT NULL
|
|
187
|
+
);
|
|
188
|
+
INSERT OR IGNORE INTO cache_stats VALUES(1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0);
|
|
189
|
+
CREATE TABLE IF NOT EXISTS usage_hourly (
|
|
190
|
+
source_path TEXT NOT NULL,
|
|
191
|
+
provider TEXT NOT NULL,
|
|
192
|
+
session_id TEXT NOT NULL,
|
|
193
|
+
bucket_start_ms INTEGER NOT NULL,
|
|
194
|
+
input_tokens INTEGER NOT NULL,
|
|
195
|
+
output_tokens INTEGER NOT NULL,
|
|
196
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
197
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
198
|
+
event_count INTEGER NOT NULL,
|
|
199
|
+
PRIMARY KEY(source_path, provider, session_id, bucket_start_ms),
|
|
200
|
+
FOREIGN KEY(source_path) REFERENCES source_files(path) ON DELETE CASCADE
|
|
201
|
+
) WITHOUT ROWID;
|
|
202
|
+
CREATE INDEX IF NOT EXISTS usage_hourly_provider_time
|
|
203
|
+
ON usage_hourly(provider, bucket_start_ms);
|
|
204
|
+
CREATE TABLE IF NOT EXISTS usage_provider_hourly (
|
|
205
|
+
provider TEXT NOT NULL,
|
|
206
|
+
bucket_start_ms INTEGER NOT NULL,
|
|
207
|
+
input_tokens INTEGER NOT NULL,
|
|
208
|
+
output_tokens INTEGER NOT NULL,
|
|
209
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
210
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
211
|
+
event_count INTEGER NOT NULL,
|
|
212
|
+
PRIMARY KEY(provider, bucket_start_ms)
|
|
213
|
+
) WITHOUT ROWID;
|
|
214
|
+
CREATE TABLE IF NOT EXISTS recent_events (
|
|
215
|
+
source_path TEXT NOT NULL,
|
|
216
|
+
event_key TEXT NOT NULL,
|
|
217
|
+
provider TEXT NOT NULL,
|
|
218
|
+
session_id TEXT NOT NULL,
|
|
219
|
+
timestamp_ms INTEGER NOT NULL,
|
|
220
|
+
input_tokens INTEGER NOT NULL,
|
|
221
|
+
output_tokens INTEGER NOT NULL,
|
|
222
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
223
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
224
|
+
PRIMARY KEY(source_path, event_key),
|
|
225
|
+
FOREIGN KEY(source_path) REFERENCES source_files(path) ON DELETE CASCADE
|
|
226
|
+
) WITHOUT ROWID;
|
|
227
|
+
CREATE INDEX IF NOT EXISTS recent_events_provider_time
|
|
228
|
+
ON recent_events(provider, timestamp_ms);
|
|
229
|
+
CREATE TABLE IF NOT EXISTS claude_messages (
|
|
230
|
+
owner_session_id TEXT NOT NULL,
|
|
231
|
+
message_id TEXT NOT NULL,
|
|
232
|
+
source_path TEXT NOT NULL,
|
|
233
|
+
timestamp_ms INTEGER NOT NULL,
|
|
234
|
+
input_tokens INTEGER NOT NULL,
|
|
235
|
+
output_tokens INTEGER NOT NULL,
|
|
236
|
+
cache_read_tokens INTEGER NOT NULL,
|
|
237
|
+
cache_write_tokens INTEGER NOT NULL,
|
|
238
|
+
PRIMARY KEY(owner_session_id, message_id)
|
|
239
|
+
) WITHOUT ROWID;
|
|
240
|
+
CREATE INDEX IF NOT EXISTS claude_messages_time
|
|
241
|
+
ON claude_messages(timestamp_ms);
|
|
242
|
+
CREATE TRIGGER IF NOT EXISTS source_files_stats_insert
|
|
243
|
+
AFTER INSERT ON source_files BEGIN
|
|
244
|
+
UPDATE cache_stats SET
|
|
245
|
+
codex_files = codex_files + (NEW.provider = 'codex'),
|
|
246
|
+
claude_files = claude_files + (NEW.provider = 'claude'),
|
|
247
|
+
codex_incomplete = codex_incomplete + (NEW.provider = 'codex' AND NEW.scan_complete = 0),
|
|
248
|
+
claude_incomplete = claude_incomplete + (NEW.provider = 'claude' AND NEW.scan_complete = 0),
|
|
249
|
+
committed_bytes = committed_bytes + NEW.committed_offset
|
|
250
|
+
WHERE singleton = 1;
|
|
251
|
+
END;
|
|
252
|
+
CREATE TRIGGER IF NOT EXISTS source_files_stats_delete
|
|
253
|
+
AFTER DELETE ON source_files BEGIN
|
|
254
|
+
UPDATE cache_stats SET
|
|
255
|
+
codex_files = codex_files - (OLD.provider = 'codex'),
|
|
256
|
+
claude_files = claude_files - (OLD.provider = 'claude'),
|
|
257
|
+
codex_incomplete = codex_incomplete - (OLD.provider = 'codex' AND OLD.scan_complete = 0),
|
|
258
|
+
claude_incomplete = claude_incomplete - (OLD.provider = 'claude' AND OLD.scan_complete = 0),
|
|
259
|
+
committed_bytes = committed_bytes - OLD.committed_offset
|
|
260
|
+
WHERE singleton = 1;
|
|
261
|
+
END;
|
|
262
|
+
CREATE TRIGGER IF NOT EXISTS source_files_stats_update
|
|
263
|
+
AFTER UPDATE OF provider, scan_complete, committed_offset ON source_files BEGIN
|
|
264
|
+
UPDATE cache_stats SET
|
|
265
|
+
codex_files = codex_files - (OLD.provider = 'codex') + (NEW.provider = 'codex'),
|
|
266
|
+
claude_files = claude_files - (OLD.provider = 'claude') + (NEW.provider = 'claude'),
|
|
267
|
+
codex_incomplete = codex_incomplete
|
|
268
|
+
- (OLD.provider = 'codex' AND OLD.scan_complete = 0)
|
|
269
|
+
+ (NEW.provider = 'codex' AND NEW.scan_complete = 0),
|
|
270
|
+
claude_incomplete = claude_incomplete
|
|
271
|
+
- (OLD.provider = 'claude' AND OLD.scan_complete = 0)
|
|
272
|
+
+ (NEW.provider = 'claude' AND NEW.scan_complete = 0),
|
|
273
|
+
committed_bytes = committed_bytes - OLD.committed_offset + NEW.committed_offset
|
|
274
|
+
WHERE singleton = 1;
|
|
275
|
+
END;
|
|
276
|
+
CREATE TRIGGER IF NOT EXISTS usage_hourly_stats_insert
|
|
277
|
+
AFTER INSERT ON usage_hourly BEGIN
|
|
278
|
+
UPDATE cache_stats SET hourly_rows = hourly_rows + 1 WHERE singleton = 1;
|
|
279
|
+
END;
|
|
280
|
+
CREATE TRIGGER IF NOT EXISTS usage_hourly_stats_delete
|
|
281
|
+
AFTER DELETE ON usage_hourly BEGIN
|
|
282
|
+
UPDATE cache_stats SET hourly_rows = hourly_rows - 1 WHERE singleton = 1;
|
|
283
|
+
END;
|
|
284
|
+
CREATE TRIGGER IF NOT EXISTS recent_events_stats_insert
|
|
285
|
+
AFTER INSERT ON recent_events BEGIN
|
|
286
|
+
UPDATE cache_stats SET recent_rows = recent_rows + 1 WHERE singleton = 1;
|
|
287
|
+
END;
|
|
288
|
+
CREATE TRIGGER IF NOT EXISTS recent_events_stats_delete
|
|
289
|
+
AFTER DELETE ON recent_events BEGIN
|
|
290
|
+
UPDATE cache_stats SET recent_rows = recent_rows - 1 WHERE singleton = 1;
|
|
291
|
+
END;
|
|
292
|
+
CREATE TRIGGER IF NOT EXISTS claude_messages_stats_insert
|
|
293
|
+
AFTER INSERT ON claude_messages BEGIN
|
|
294
|
+
UPDATE cache_stats SET dedupe_rows = dedupe_rows + 1 WHERE singleton = 1;
|
|
295
|
+
END;
|
|
296
|
+
CREATE TRIGGER IF NOT EXISTS claude_messages_stats_delete
|
|
297
|
+
AFTER DELETE ON claude_messages BEGIN
|
|
298
|
+
UPDATE cache_stats SET dedupe_rows = dedupe_rows - 1 WHERE singleton = 1;
|
|
299
|
+
END;
|
|
300
|
+
CREATE TRIGGER IF NOT EXISTS discovery_directories_stats_insert
|
|
301
|
+
AFTER INSERT ON discovery_directories WHEN NEW.present = 1 AND NEW.pending = 1 BEGIN
|
|
302
|
+
UPDATE cache_stats SET pending_directories = pending_directories + 1 WHERE singleton = 1;
|
|
303
|
+
END;
|
|
304
|
+
CREATE TRIGGER IF NOT EXISTS discovery_directories_stats_delete
|
|
305
|
+
AFTER DELETE ON discovery_directories WHEN OLD.present = 1 AND OLD.pending = 1 BEGIN
|
|
306
|
+
UPDATE cache_stats SET pending_directories = pending_directories - 1 WHERE singleton = 1;
|
|
307
|
+
END;
|
|
308
|
+
CREATE TRIGGER IF NOT EXISTS discovery_directories_stats_update
|
|
309
|
+
AFTER UPDATE OF present, pending ON discovery_directories
|
|
310
|
+
WHEN (OLD.present = 1 AND OLD.pending = 1) != (NEW.present = 1 AND NEW.pending = 1)
|
|
311
|
+
BEGIN
|
|
312
|
+
UPDATE cache_stats SET pending_directories = pending_directories
|
|
313
|
+
- (OLD.present = 1 AND OLD.pending = 1)
|
|
314
|
+
+ (NEW.present = 1 AND NEW.pending = 1)
|
|
315
|
+
WHERE singleton = 1;
|
|
316
|
+
END;
|
|
317
|
+
CREATE TRIGGER IF NOT EXISTS discovery_source_queue_stats_insert
|
|
318
|
+
AFTER INSERT ON discovery_source_queue BEGIN
|
|
319
|
+
UPDATE cache_stats SET queued_sources = queued_sources + 1 WHERE singleton = 1;
|
|
320
|
+
END;
|
|
321
|
+
CREATE TRIGGER IF NOT EXISTS discovery_source_queue_stats_delete
|
|
322
|
+
AFTER DELETE ON discovery_source_queue BEGIN
|
|
323
|
+
UPDATE cache_stats SET queued_sources = queued_sources - 1 WHERE singleton = 1;
|
|
324
|
+
END;
|
|
325
|
+
`;
|
|
326
|
+
|
|
327
|
+
function integer(value: unknown): number {
|
|
328
|
+
const parsed = Number(value);
|
|
329
|
+
return Number.isFinite(parsed) ? Math.max(0, Math.trunc(parsed)) : 0;
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function timestampMs(value: unknown): number | null {
|
|
333
|
+
if (typeof value === 'number') {
|
|
334
|
+
if (!Number.isFinite(value) || value <= 0) return null;
|
|
335
|
+
return Math.trunc(value < 10_000_000_000 ? value * 1000 : value);
|
|
336
|
+
}
|
|
337
|
+
if (typeof value !== 'string' || !value) return null;
|
|
338
|
+
if (/^\d+$/.test(value)) return timestampMs(Number(value));
|
|
339
|
+
const parsed = Date.parse(value);
|
|
340
|
+
return Number.isFinite(parsed) ? parsed : null;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
function cloneTotals(value: Totals | null | undefined): Totals | null {
|
|
344
|
+
return value ? { ...value } : null;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
function addTotals(left: Totals | null, right: Totals): Totals {
|
|
348
|
+
const base = left || ZERO;
|
|
349
|
+
return {
|
|
350
|
+
input: base.input + right.input,
|
|
351
|
+
cached: base.cached + right.cached,
|
|
352
|
+
output: base.output + right.output,
|
|
353
|
+
cacheWrite: base.cacheWrite + right.cacheWrite,
|
|
354
|
+
};
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
function minTotals(left: Totals, right: Totals): Totals {
|
|
358
|
+
return {
|
|
359
|
+
input: Math.min(left.input, right.input),
|
|
360
|
+
cached: Math.min(left.cached, right.cached),
|
|
361
|
+
output: Math.min(left.output, right.output),
|
|
362
|
+
cacheWrite: Math.min(left.cacheWrite, right.cacheWrite),
|
|
363
|
+
};
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function maxTotals(left: Totals | null, right: Totals): Totals {
|
|
367
|
+
if (!left) return { ...right };
|
|
368
|
+
return {
|
|
369
|
+
input: Math.max(left.input, right.input),
|
|
370
|
+
cached: Math.max(left.cached, right.cached),
|
|
371
|
+
output: Math.max(left.output, right.output),
|
|
372
|
+
cacheWrite: Math.max(left.cacheWrite, right.cacheWrite),
|
|
373
|
+
};
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
function subtractTotals(current: Totals, baseline: Totals | null): Totals {
|
|
377
|
+
const base = baseline || ZERO;
|
|
378
|
+
return {
|
|
379
|
+
input: Math.max(0, current.input - base.input),
|
|
380
|
+
cached: Math.max(0, current.cached - base.cached),
|
|
381
|
+
output: Math.max(0, current.output - base.output),
|
|
382
|
+
cacheWrite: Math.max(0, current.cacheWrite - base.cacheWrite),
|
|
383
|
+
};
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function totalsEqual(left: Totals | null, right: Totals | null): boolean {
|
|
387
|
+
if (!left || !right) return left === right;
|
|
388
|
+
return TOKEN_FIELDS.every(field => left[field] === right[field]);
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
function totalsAtLeast(left: Totals, right: Totals): boolean {
|
|
392
|
+
return TOKEN_FIELDS.every(field => left[field] >= right[field]);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
function totalsAtMost(left: Totals, right: Totals): boolean {
|
|
396
|
+
return TOKEN_FIELDS.every(field => left[field] <= right[field]);
|
|
397
|
+
}
|
|
398
|
+
|
|
399
|
+
function hasUsage(value: Totals | null): value is Totals {
|
|
400
|
+
return Boolean(value && TOKEN_FIELDS.some(field => value[field] > 0));
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
function localHourMs(value: number): number {
|
|
404
|
+
const date = new Date(value);
|
|
405
|
+
date.setMinutes(0, 0, 0);
|
|
406
|
+
return date.getTime();
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
function removeDatabaseFiles(filePath: string): void {
|
|
410
|
+
for (const suffix of ['', '-wal', '-shm']) {
|
|
411
|
+
try {
|
|
412
|
+
fs.rmSync(`${filePath}${suffix}`, { force: true });
|
|
413
|
+
} catch (error) {
|
|
414
|
+
if (suffix === '') throw error;
|
|
415
|
+
}
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
function databaseCompatible(filePath: string): boolean {
|
|
420
|
+
if (!fs.existsSync(filePath)) return true;
|
|
421
|
+
try {
|
|
422
|
+
const database = new DatabaseSync(filePath, { readOnly: true });
|
|
423
|
+
try {
|
|
424
|
+
const tables = database.prepare(
|
|
425
|
+
"SELECT name FROM sqlite_master WHERE type = 'table' AND name NOT LIKE 'sqlite_%'",
|
|
426
|
+
).all() as Array<{ name: string }>;
|
|
427
|
+
if (tables.length === 0) return true;
|
|
428
|
+
const schema = database.prepare(
|
|
429
|
+
"SELECT value FROM metadata WHERE key = 'schema_version'",
|
|
430
|
+
).get() as { value?: string } | undefined;
|
|
431
|
+
const source = database.prepare(
|
|
432
|
+
"SELECT value FROM metadata WHERE key = 'source_version'",
|
|
433
|
+
).get() as { value?: string } | undefined;
|
|
434
|
+
const names = new Set(tables.map(row => row.name));
|
|
435
|
+
const triggers = new Set((database.prepare(
|
|
436
|
+
"SELECT name FROM sqlite_master WHERE type = 'trigger'",
|
|
437
|
+
).all() as Array<{ name: string }>).map(row => row.name));
|
|
438
|
+
const stats = database.prepare(
|
|
439
|
+
'SELECT singleton FROM cache_stats WHERE singleton = 1',
|
|
440
|
+
).get() as { singleton?: number } | undefined;
|
|
441
|
+
return schema?.value === String(SCHEMA_VERSION)
|
|
442
|
+
&& source?.value === SOURCE_VERSION
|
|
443
|
+
&& [
|
|
444
|
+
'source_files',
|
|
445
|
+
'usage_hourly',
|
|
446
|
+
'usage_provider_hourly',
|
|
447
|
+
'recent_events',
|
|
448
|
+
'claude_messages',
|
|
449
|
+
'discovery_directories',
|
|
450
|
+
'discovery_source_queue',
|
|
451
|
+
'codex_quota_candidates',
|
|
452
|
+
'cache_stats',
|
|
453
|
+
]
|
|
454
|
+
.every(name => names.has(name))
|
|
455
|
+
&& [
|
|
456
|
+
'source_files_stats_insert',
|
|
457
|
+
'source_files_stats_delete',
|
|
458
|
+
'source_files_stats_update',
|
|
459
|
+
'usage_hourly_stats_insert',
|
|
460
|
+
'usage_hourly_stats_delete',
|
|
461
|
+
'recent_events_stats_insert',
|
|
462
|
+
'recent_events_stats_delete',
|
|
463
|
+
'claude_messages_stats_insert',
|
|
464
|
+
'claude_messages_stats_delete',
|
|
465
|
+
'discovery_directories_stats_insert',
|
|
466
|
+
'discovery_directories_stats_delete',
|
|
467
|
+
'discovery_directories_stats_update',
|
|
468
|
+
'discovery_source_queue_stats_insert',
|
|
469
|
+
'discovery_source_queue_stats_delete',
|
|
470
|
+
].every(name => triggers.has(name))
|
|
471
|
+
&& stats?.singleton === 1;
|
|
472
|
+
} finally {
|
|
473
|
+
database.close();
|
|
474
|
+
}
|
|
475
|
+
} catch {
|
|
476
|
+
return false;
|
|
477
|
+
}
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
function openDatabase(filePath: string): { database: DatabaseSync; rebuilt: boolean } {
|
|
481
|
+
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
|
482
|
+
const compatible = databaseCompatible(filePath);
|
|
483
|
+
if (!compatible) {
|
|
484
|
+
closeCacheDirectoryCursors(filePath);
|
|
485
|
+
removeDatabaseFiles(filePath);
|
|
486
|
+
}
|
|
487
|
+
const database = new DatabaseSync(filePath);
|
|
488
|
+
database.exec('PRAGMA journal_mode=WAL');
|
|
489
|
+
database.exec('PRAGMA synchronous=NORMAL');
|
|
490
|
+
database.exec('PRAGMA foreign_keys=ON');
|
|
491
|
+
database.exec('PRAGMA busy_timeout=30000');
|
|
492
|
+
database.exec(SCHEMA_SQL);
|
|
493
|
+
database.prepare(
|
|
494
|
+
"INSERT OR REPLACE INTO metadata(key, value) VALUES('schema_version', ?)",
|
|
495
|
+
).run(String(SCHEMA_VERSION));
|
|
496
|
+
database.prepare(
|
|
497
|
+
"INSERT OR REPLACE INTO metadata(key, value) VALUES('source_version', ?)",
|
|
498
|
+
).run(SOURCE_VERSION);
|
|
499
|
+
try {
|
|
500
|
+
fs.chmodSync(filePath, 0o600);
|
|
501
|
+
} catch {
|
|
502
|
+
// Best effort on filesystems without POSIX modes.
|
|
503
|
+
}
|
|
504
|
+
return { database, rebuilt: !compatible };
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
function prefixHash(filePath: string, size?: number): string {
|
|
508
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
509
|
+
try {
|
|
510
|
+
const lengthLimit = Math.min(PREFIX_BYTES, size ?? PREFIX_BYTES);
|
|
511
|
+
const buffer = Buffer.allocUnsafe(lengthLimit);
|
|
512
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
513
|
+
return crypto.createHash('sha256').update(buffer.subarray(0, length)).digest('hex');
|
|
514
|
+
} finally {
|
|
515
|
+
fs.closeSync(descriptor);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
function suffixHash(filePath: string, size?: number): string {
|
|
520
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
521
|
+
try {
|
|
522
|
+
const fileSize = size ?? fs.fstatSync(descriptor).size;
|
|
523
|
+
const length = Math.min(fileSize, PREFIX_BYTES);
|
|
524
|
+
const buffer = Buffer.allocUnsafe(length);
|
|
525
|
+
const read = fs.readSync(
|
|
526
|
+
descriptor,
|
|
527
|
+
buffer,
|
|
528
|
+
0,
|
|
529
|
+
length,
|
|
530
|
+
Math.max(0, fileSize - length),
|
|
531
|
+
);
|
|
532
|
+
return crypto.createHash('sha256').update(buffer.subarray(0, read)).digest('hex');
|
|
533
|
+
} finally {
|
|
534
|
+
fs.closeSync(descriptor);
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
function claudeOwnerSession(filePath: string): string {
|
|
539
|
+
const parent = path.dirname(filePath);
|
|
540
|
+
if (path.basename(parent) === 'subagents') {
|
|
541
|
+
return path.basename(path.dirname(parent));
|
|
542
|
+
}
|
|
543
|
+
return path.basename(filePath, '.jsonl');
|
|
544
|
+
}
|
|
545
|
+
|
|
546
|
+
function defaultState(provider: Provider, filePath: string): ParserState {
|
|
547
|
+
if (provider === 'claude') {
|
|
548
|
+
return {
|
|
549
|
+
sessionId: claudeOwnerSession(filePath),
|
|
550
|
+
projectPath: '',
|
|
551
|
+
};
|
|
552
|
+
}
|
|
553
|
+
return {
|
|
554
|
+
sessionId: path.basename(filePath, '.jsonl'),
|
|
555
|
+
leafSessionId: null,
|
|
556
|
+
parentSessionId: null,
|
|
557
|
+
forkTimestamp: null,
|
|
558
|
+
projectPath: '',
|
|
559
|
+
lastAssistantTimestamp: null,
|
|
560
|
+
copiedPrefix: false,
|
|
561
|
+
isSubagent: false,
|
|
562
|
+
ownBoundarySeen: false,
|
|
563
|
+
pendingOwnedBoundary: false,
|
|
564
|
+
inheritedRaw: null,
|
|
565
|
+
counted: null,
|
|
566
|
+
rawBaseline: null,
|
|
567
|
+
watermark: null,
|
|
568
|
+
seenRawTotals: [],
|
|
569
|
+
divergent: false,
|
|
570
|
+
interleaved: false,
|
|
571
|
+
skippedUnresolvedForkTotal: false,
|
|
572
|
+
quotas: {},
|
|
573
|
+
};
|
|
574
|
+
}
|
|
575
|
+
|
|
576
|
+
function normalizedCodexTotals(value: unknown): Totals | null {
|
|
577
|
+
if (!value || typeof value !== 'object') return null;
|
|
578
|
+
const usage = value as Record<string, unknown>;
|
|
579
|
+
const cached = integer(usage.cached_input_tokens ?? usage.cache_read_input_tokens);
|
|
580
|
+
const rawInput = integer(usage.input_tokens);
|
|
581
|
+
const result = {
|
|
582
|
+
// Codex input_tokens includes cached_input_tokens. Farming displays the
|
|
583
|
+
// mutually-exclusive breakdown while retaining CodexBar's cumulative math.
|
|
584
|
+
input: Math.max(0, rawInput - cached),
|
|
585
|
+
cached,
|
|
586
|
+
output: integer(usage.output_tokens),
|
|
587
|
+
cacheWrite: integer(usage.cache_creation_input_tokens),
|
|
588
|
+
};
|
|
589
|
+
return hasUsage(result) ? result : null;
|
|
590
|
+
}
|
|
591
|
+
|
|
592
|
+
function claudeTotals(value: unknown): Totals | null {
|
|
593
|
+
if (!value || typeof value !== 'object') return null;
|
|
594
|
+
const usage = value as Record<string, unknown>;
|
|
595
|
+
const result = {
|
|
596
|
+
input: integer(usage.input_tokens),
|
|
597
|
+
cached: integer(usage.cache_read_input_tokens),
|
|
598
|
+
output: integer(usage.output_tokens),
|
|
599
|
+
cacheWrite: integer(usage.cache_creation_input_tokens),
|
|
600
|
+
};
|
|
601
|
+
return hasUsage(result) ? result : null;
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
function parseLargeJsonEdge(first: Buffer, tail: Buffer): Record<string, unknown> | null {
|
|
605
|
+
const text = `${first.toString('utf8')}\n${tail.toString('utf8')}`;
|
|
606
|
+
const timestamp = /"timestamp"\s*:\s*"([^"]+)"/.exec(text)?.[1];
|
|
607
|
+
const topType = /"type"\s*:\s*"(assistant|event_msg|session_meta|response_item|turn_context)"/
|
|
608
|
+
.exec(text)?.[1];
|
|
609
|
+
if (!topType) return null;
|
|
610
|
+
if (topType === 'assistant') {
|
|
611
|
+
const sessionId = /"sessionId"\s*:\s*"([^"]+)"/.exec(text)?.[1];
|
|
612
|
+
const messageId = /"id"\s*:\s*"([^"]+)"/.exec(text)?.[1];
|
|
613
|
+
// Claude serializes the canonical message.usage after message.content.
|
|
614
|
+
// Nested tool input may itself contain a usage object, so the last retained
|
|
615
|
+
// occurrence is the only safe edge candidate for an oversized row.
|
|
616
|
+
const usageStart = text.lastIndexOf('"usage"');
|
|
617
|
+
const usageText = usageStart >= 0 ? text.slice(usageStart, usageStart + 2048) : '';
|
|
618
|
+
return {
|
|
619
|
+
type: 'assistant',
|
|
620
|
+
timestamp,
|
|
621
|
+
sessionId,
|
|
622
|
+
message: {
|
|
623
|
+
id: messageId,
|
|
624
|
+
usage: {
|
|
625
|
+
input_tokens: integer(/"input_tokens"\s*:\s*(\d+)/.exec(usageText)?.[1]),
|
|
626
|
+
output_tokens: integer(/"output_tokens"\s*:\s*(\d+)/.exec(usageText)?.[1]),
|
|
627
|
+
cache_read_input_tokens: integer(
|
|
628
|
+
/"cache_read_input_tokens"\s*:\s*(\d+)/.exec(usageText)?.[1],
|
|
629
|
+
),
|
|
630
|
+
cache_creation_input_tokens: integer(
|
|
631
|
+
/"cache_creation_input_tokens"\s*:\s*(\d+)/.exec(usageText)?.[1],
|
|
632
|
+
),
|
|
633
|
+
},
|
|
634
|
+
},
|
|
635
|
+
};
|
|
636
|
+
}
|
|
637
|
+
if (
|
|
638
|
+
topType !== 'event_msg'
|
|
639
|
+
|| !/"payload"\s*:\s*\{\s*"type"\s*:\s*"token_count"/.test(text)
|
|
640
|
+
) {
|
|
641
|
+
return null;
|
|
642
|
+
}
|
|
643
|
+
const totalStart = text.indexOf('"total_token_usage"');
|
|
644
|
+
const lastStart = text.indexOf('"last_token_usage"');
|
|
645
|
+
const totalsText = totalStart >= 0 ? text.slice(totalStart, totalStart + 1024) : '';
|
|
646
|
+
const lastText = lastStart >= 0 ? text.slice(lastStart, lastStart + 1024) : '';
|
|
647
|
+
const fields = (source: string) => ({
|
|
648
|
+
input_tokens: integer(/"input_tokens"\s*:\s*(\d+)/.exec(source)?.[1]),
|
|
649
|
+
cached_input_tokens: integer(/"cached_input_tokens"\s*:\s*(\d+)/.exec(source)?.[1]),
|
|
650
|
+
output_tokens: integer(/"output_tokens"\s*:\s*(\d+)/.exec(source)?.[1]),
|
|
651
|
+
});
|
|
652
|
+
return {
|
|
653
|
+
type: 'event_msg',
|
|
654
|
+
timestamp,
|
|
655
|
+
payload: {
|
|
656
|
+
type: 'token_count',
|
|
657
|
+
info: {
|
|
658
|
+
total_token_usage: fields(totalsText),
|
|
659
|
+
last_token_usage: fields(lastText),
|
|
660
|
+
},
|
|
661
|
+
},
|
|
662
|
+
};
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
function interesting(provider: Provider, buffer: Buffer): boolean {
|
|
666
|
+
if (provider === 'claude') return buffer.includes(Buffer.from('"assistant"'));
|
|
667
|
+
return [
|
|
668
|
+
'"session_meta"',
|
|
669
|
+
'"token_count"',
|
|
670
|
+
'"agent_message"',
|
|
671
|
+
'"response_item"',
|
|
672
|
+
'"turn_context"',
|
|
673
|
+
'"task_started"',
|
|
674
|
+
'"inter_agent_communication_metadata"',
|
|
675
|
+
].some(marker => buffer.includes(Buffer.from(marker)));
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
function parseRecord(
|
|
679
|
+
provider: Provider,
|
|
680
|
+
fullLine: Buffer | null,
|
|
681
|
+
first: Buffer,
|
|
682
|
+
tail: Buffer,
|
|
683
|
+
): Record<string, unknown> | null {
|
|
684
|
+
const searchable = fullLine || Buffer.concat([first, tail]);
|
|
685
|
+
if (!interesting(provider, searchable)) return null;
|
|
686
|
+
if (fullLine) {
|
|
687
|
+
try {
|
|
688
|
+
const parsed = JSON.parse(fullLine.toString('utf8'));
|
|
689
|
+
return parsed && typeof parsed === 'object' ? parsed : null;
|
|
690
|
+
} catch {
|
|
691
|
+
return null;
|
|
692
|
+
}
|
|
693
|
+
}
|
|
694
|
+
return parseLargeJsonEdge(first, tail);
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
function divergentDelta(
|
|
698
|
+
rawBaseline: Totals | null,
|
|
699
|
+
countedBaseline: Totals | null,
|
|
700
|
+
current: Totals,
|
|
701
|
+
): Totals {
|
|
702
|
+
const raw = rawBaseline || ZERO;
|
|
703
|
+
const counted = countedBaseline || ZERO;
|
|
704
|
+
const component = (rawValue: number, countedValue: number, currentValue: number) => (
|
|
705
|
+
currentValue >= rawValue
|
|
706
|
+
? Math.max(0, currentValue - rawValue)
|
|
707
|
+
: Math.max(0, currentValue - countedValue)
|
|
708
|
+
);
|
|
709
|
+
return {
|
|
710
|
+
input: component(raw.input, counted.input, current.input),
|
|
711
|
+
cached: component(raw.cached, counted.cached, current.cached),
|
|
712
|
+
output: component(raw.output, counted.output, current.output),
|
|
713
|
+
cacheWrite: component(raw.cacheWrite, counted.cacheWrite, current.cacheWrite),
|
|
714
|
+
};
|
|
715
|
+
}
|
|
716
|
+
|
|
717
|
+
function containedDelta(
|
|
718
|
+
watermark: Totals | null,
|
|
719
|
+
counted: Totals | null,
|
|
720
|
+
current: Totals,
|
|
721
|
+
): Totals {
|
|
722
|
+
const water = watermark || ZERO;
|
|
723
|
+
const base = counted || ZERO;
|
|
724
|
+
const component = (waterValue: number, countedValue: number, currentValue: number) => (
|
|
725
|
+
currentValue >= waterValue
|
|
726
|
+
? Math.max(0, currentValue - Math.max(waterValue, countedValue))
|
|
727
|
+
: Math.max(0, currentValue - countedValue)
|
|
728
|
+
);
|
|
729
|
+
return {
|
|
730
|
+
input: component(water.input, base.input, current.input),
|
|
731
|
+
cached: component(water.cached, base.cached, current.cached),
|
|
732
|
+
output: component(water.output, base.output, current.output),
|
|
733
|
+
cacheWrite: component(water.cacheWrite, base.cacheWrite, current.cacheWrite),
|
|
734
|
+
};
|
|
735
|
+
}
|
|
736
|
+
|
|
737
|
+
/**
|
|
738
|
+
* CodexBar's cumulative-counter reducer. The monotonic watermark is the
|
|
739
|
+
* load-bearing protection against Ultra-mode high/low lineage flips; the
|
|
740
|
+
* bounded exact-value set is only an optimization for repeated telemetry.
|
|
741
|
+
*/
|
|
742
|
+
function applyCodexTotals(
|
|
743
|
+
state: CodexState,
|
|
744
|
+
last: Totals | null,
|
|
745
|
+
total: Totals | null,
|
|
746
|
+
): Totals | null {
|
|
747
|
+
if (total && state.seenRawTotals.some(value => totalsEqual(value, total))) {
|
|
748
|
+
return null;
|
|
749
|
+
}
|
|
750
|
+
if (total && state.watermark && TOKEN_FIELDS.some(
|
|
751
|
+
field => total[field] < state.watermark![field],
|
|
752
|
+
)) {
|
|
753
|
+
state.interleaved = true;
|
|
754
|
+
}
|
|
755
|
+
const watermarkBaseline = state.watermark || state.rawBaseline;
|
|
756
|
+
let delta: Totals | null = null;
|
|
757
|
+
if (last) {
|
|
758
|
+
delta = last;
|
|
759
|
+
if (total) {
|
|
760
|
+
if (state.interleaved) {
|
|
761
|
+
delta = minTotals(last, containedDelta(watermarkBaseline, state.counted, total));
|
|
762
|
+
} else {
|
|
763
|
+
const totalDelta = subtractTotals(total, watermarkBaseline);
|
|
764
|
+
if (
|
|
765
|
+
!state.divergent
|
|
766
|
+
&& totalsAtLeast(total, watermarkBaseline || ZERO)
|
|
767
|
+
&& totalsAtMost(totalDelta, last)
|
|
768
|
+
) {
|
|
769
|
+
delta = totalDelta;
|
|
770
|
+
}
|
|
771
|
+
}
|
|
772
|
+
state.counted = addTotals(state.counted, delta);
|
|
773
|
+
state.rawBaseline = { ...total };
|
|
774
|
+
if (!totalsEqual(state.rawBaseline, state.counted)) state.divergent = true;
|
|
775
|
+
} else {
|
|
776
|
+
state.counted = addTotals(state.counted, delta);
|
|
777
|
+
state.rawBaseline = cloneTotals(state.counted);
|
|
778
|
+
state.watermark = maxTotals(state.watermark, state.counted!);
|
|
779
|
+
}
|
|
780
|
+
} else if (total) {
|
|
781
|
+
delta = state.interleaved
|
|
782
|
+
? containedDelta(watermarkBaseline, state.counted, total)
|
|
783
|
+
: state.divergent
|
|
784
|
+
? divergentDelta(watermarkBaseline, state.counted, total)
|
|
785
|
+
: subtractTotals(total, watermarkBaseline);
|
|
786
|
+
state.counted = addTotals(state.counted, delta);
|
|
787
|
+
state.rawBaseline = { ...total };
|
|
788
|
+
if (!totalsEqual(state.rawBaseline, state.counted)) state.divergent = true;
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
if (total) {
|
|
792
|
+
state.watermark = maxTotals(state.watermark, total);
|
|
793
|
+
if (!state.seenRawTotals.some(value => totalsEqual(value, total))) {
|
|
794
|
+
state.seenRawTotals.push({ ...total });
|
|
795
|
+
state.seenRawTotals = state.seenRawTotals.slice(-SEEN_TOTALS_LIMIT);
|
|
796
|
+
}
|
|
797
|
+
}
|
|
798
|
+
return hasUsage(delta) ? delta : null;
|
|
799
|
+
}
|
|
800
|
+
|
|
801
|
+
function parseCodexMeta(record: Record<string, unknown>): {
|
|
802
|
+
id: string | null;
|
|
803
|
+
parentId: string | null;
|
|
804
|
+
timestamp: number | null;
|
|
805
|
+
cwd: string;
|
|
806
|
+
isSubagent: boolean;
|
|
807
|
+
} | null {
|
|
808
|
+
if (record.type !== 'session_meta') return null;
|
|
809
|
+
const payload = record.payload && typeof record.payload === 'object'
|
|
810
|
+
? record.payload as Record<string, unknown>
|
|
811
|
+
: {};
|
|
812
|
+
const source = payload.source && typeof payload.source === 'object'
|
|
813
|
+
? payload.source as Record<string, unknown>
|
|
814
|
+
: {};
|
|
815
|
+
const subagent = source.subagent && typeof source.subagent === 'object'
|
|
816
|
+
? source.subagent as Record<string, unknown>
|
|
817
|
+
: {};
|
|
818
|
+
const spawn = subagent.thread_spawn && typeof subagent.thread_spawn === 'object'
|
|
819
|
+
? subagent.thread_spawn as Record<string, unknown>
|
|
820
|
+
: {};
|
|
821
|
+
const id = typeof payload.id === 'string' && payload.id.trim() ? payload.id.trim() : null;
|
|
822
|
+
const explicitParent = typeof payload.forked_from_id === 'string'
|
|
823
|
+
? payload.forked_from_id.trim()
|
|
824
|
+
: '';
|
|
825
|
+
const spawnedParent = typeof spawn.parent_thread_id === 'string'
|
|
826
|
+
? spawn.parent_thread_id.trim()
|
|
827
|
+
: '';
|
|
828
|
+
return {
|
|
829
|
+
id,
|
|
830
|
+
parentId: explicitParent || spawnedParent || null,
|
|
831
|
+
timestamp: timestampMs(payload.timestamp) || timestampMs(record.timestamp),
|
|
832
|
+
cwd: typeof payload.cwd === 'string' ? payload.cwd : '',
|
|
833
|
+
isSubagent: Boolean(source.subagent),
|
|
834
|
+
};
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
function updateCodexMetadata(state: CodexState, record: Record<string, unknown>): void {
|
|
838
|
+
const metadata = parseCodexMeta(record);
|
|
839
|
+
if (!metadata) return;
|
|
840
|
+
if (!state.leafSessionId) {
|
|
841
|
+
state.leafSessionId = metadata.id;
|
|
842
|
+
if (metadata.id) state.sessionId = metadata.id;
|
|
843
|
+
state.parentSessionId = metadata.parentId;
|
|
844
|
+
state.forkTimestamp = metadata.timestamp;
|
|
845
|
+
state.isSubagent = metadata.isSubagent;
|
|
846
|
+
if (metadata.cwd) state.projectPath = metadata.cwd;
|
|
847
|
+
return;
|
|
848
|
+
}
|
|
849
|
+
if (metadata.id && metadata.id !== state.leafSessionId) {
|
|
850
|
+
state.copiedPrefix = true;
|
|
851
|
+
state.ownBoundarySeen = false;
|
|
852
|
+
state.pendingOwnedBoundary = false;
|
|
853
|
+
if (!state.parentSessionId) state.parentSessionId = metadata.id;
|
|
854
|
+
return;
|
|
855
|
+
}
|
|
856
|
+
if (!state.parentSessionId && metadata.parentId) {
|
|
857
|
+
state.parentSessionId = metadata.parentId;
|
|
858
|
+
state.forkTimestamp = metadata.timestamp || state.forkTimestamp;
|
|
859
|
+
}
|
|
860
|
+
if (!state.projectPath && metadata.cwd) state.projectPath = metadata.cwd;
|
|
861
|
+
}
|
|
862
|
+
|
|
863
|
+
function codexRecord(
|
|
864
|
+
record: Record<string, unknown>,
|
|
865
|
+
state: CodexState,
|
|
866
|
+
): { usage: Totals | null; timestamp: number | null; quota: unknown } {
|
|
867
|
+
updateCodexMetadata(state, record);
|
|
868
|
+
const recordTimestamp = timestampMs(record.timestamp);
|
|
869
|
+
const payload = record.payload && typeof record.payload === 'object'
|
|
870
|
+
? record.payload as Record<string, unknown>
|
|
871
|
+
: {};
|
|
872
|
+
if (
|
|
873
|
+
record.type === 'event_msg'
|
|
874
|
+
&& payload.type === 'agent_message'
|
|
875
|
+
&& typeof payload.message === 'string'
|
|
876
|
+
) {
|
|
877
|
+
state.pendingOwnedBoundary = false;
|
|
878
|
+
state.lastAssistantTimestamp = recordTimestamp;
|
|
879
|
+
return { usage: null, timestamp: recordTimestamp, quota: null };
|
|
880
|
+
}
|
|
881
|
+
if (record.type === 'response_item') {
|
|
882
|
+
state.pendingOwnedBoundary = false;
|
|
883
|
+
const visibleAssistant = payload.type === 'function_call'
|
|
884
|
+
|| payload.type === 'web_search_call'
|
|
885
|
+
|| (payload.type === 'message' && payload.role === 'assistant');
|
|
886
|
+
if (visibleAssistant) state.lastAssistantTimestamp = recordTimestamp;
|
|
887
|
+
return { usage: null, timestamp: recordTimestamp, quota: null };
|
|
888
|
+
}
|
|
889
|
+
if (record.type === 'turn_context') {
|
|
890
|
+
if (state.copiedPrefix && state.isSubagent) state.pendingOwnedBoundary = true;
|
|
891
|
+
return { usage: null, timestamp: recordTimestamp, quota: null };
|
|
892
|
+
}
|
|
893
|
+
if (payload.type === 'inter_agent_communication_metadata') {
|
|
894
|
+
if (state.copiedPrefix && state.isSubagent && state.pendingOwnedBoundary) {
|
|
895
|
+
state.ownBoundarySeen = true;
|
|
896
|
+
}
|
|
897
|
+
state.pendingOwnedBoundary = false;
|
|
898
|
+
return { usage: null, timestamp: recordTimestamp, quota: null };
|
|
899
|
+
}
|
|
900
|
+
if (payload.type === 'task_started') {
|
|
901
|
+
if (state.copiedPrefix && !state.isSubagent) state.ownBoundarySeen = true;
|
|
902
|
+
return { usage: null, timestamp: recordTimestamp, quota: null };
|
|
903
|
+
}
|
|
904
|
+
if (record.type !== 'event_msg' || payload.type !== 'token_count') {
|
|
905
|
+
state.pendingOwnedBoundary = false;
|
|
906
|
+
return { usage: null, timestamp: recordTimestamp, quota: null };
|
|
907
|
+
}
|
|
908
|
+
state.pendingOwnedBoundary = false;
|
|
909
|
+
|
|
910
|
+
const info = payload.info && typeof payload.info === 'object'
|
|
911
|
+
? payload.info as Record<string, unknown>
|
|
912
|
+
: {};
|
|
913
|
+
const total = normalizedCodexTotals(info.total_token_usage);
|
|
914
|
+
const last = normalizedCodexTotals(info.last_token_usage);
|
|
915
|
+
const quota = payload.rate_limits && typeof payload.rate_limits === 'object'
|
|
916
|
+
? payload.rate_limits
|
|
917
|
+
: null;
|
|
918
|
+
|
|
919
|
+
if (state.copiedPrefix && !state.inheritedRaw) {
|
|
920
|
+
if (!state.ownBoundarySeen) {
|
|
921
|
+
if (total) state.inheritedRaw = maxTotals(state.inheritedRaw, total);
|
|
922
|
+
return { usage: null, timestamp: recordTimestamp, quota };
|
|
923
|
+
}
|
|
924
|
+
}
|
|
925
|
+
|
|
926
|
+
let adjustedTotal = total;
|
|
927
|
+
if (state.parentSessionId) {
|
|
928
|
+
if (state.inheritedRaw && total) {
|
|
929
|
+
adjustedTotal = subtractTotals(total, state.inheritedRaw);
|
|
930
|
+
} else if (!state.copiedPrefix && !state.skippedUnresolvedForkTotal) {
|
|
931
|
+
state.skippedUnresolvedForkTotal = true;
|
|
932
|
+
if (last) {
|
|
933
|
+
if (total) state.inheritedRaw = subtractTotals(total, last);
|
|
934
|
+
adjustedTotal = { ...last };
|
|
935
|
+
} else {
|
|
936
|
+
state.inheritedRaw = cloneTotals(total);
|
|
937
|
+
return { usage: null, timestamp: recordTimestamp, quota };
|
|
938
|
+
}
|
|
939
|
+
} else if (!state.copiedPrefix && !state.inheritedRaw) {
|
|
940
|
+
if (!last) return { usage: null, timestamp: recordTimestamp, quota };
|
|
941
|
+
if (total) state.inheritedRaw = subtractTotals(total, last);
|
|
942
|
+
adjustedTotal = { ...last };
|
|
943
|
+
}
|
|
944
|
+
}
|
|
945
|
+
const usage = applyCodexTotals(state, last, adjustedTotal);
|
|
946
|
+
return {
|
|
947
|
+
usage,
|
|
948
|
+
timestamp: state.lastAssistantTimestamp || recordTimestamp,
|
|
949
|
+
quota,
|
|
950
|
+
};
|
|
951
|
+
}
|
|
952
|
+
|
|
953
|
+
function claudeRecord(
|
|
954
|
+
record: Record<string, unknown>,
|
|
955
|
+
state: ClaudeState,
|
|
956
|
+
): { usage: Totals | null; timestamp: number | null; messageId: string | null } {
|
|
957
|
+
if (record.type !== 'assistant') {
|
|
958
|
+
return { usage: null, timestamp: null, messageId: null };
|
|
959
|
+
}
|
|
960
|
+
const message = record.message && typeof record.message === 'object'
|
|
961
|
+
? record.message as Record<string, unknown>
|
|
962
|
+
: {};
|
|
963
|
+
const usage = claudeTotals(message.usage);
|
|
964
|
+
if (typeof record.cwd === 'string' && record.cwd) state.projectPath = record.cwd;
|
|
965
|
+
if (typeof record.sessionId === 'string' && record.sessionId && !state.sessionId) {
|
|
966
|
+
state.sessionId = record.sessionId;
|
|
967
|
+
}
|
|
968
|
+
return {
|
|
969
|
+
usage,
|
|
970
|
+
timestamp: timestampMs(record.timestamp),
|
|
971
|
+
messageId: typeof message.id === 'string' && message.id ? message.id : null,
|
|
972
|
+
};
|
|
973
|
+
}
|
|
974
|
+
|
|
975
|
+
function hourlyAdd(
|
|
976
|
+
database: DatabaseSync,
|
|
977
|
+
sourcePath: string,
|
|
978
|
+
provider: Provider,
|
|
979
|
+
sessionId: string,
|
|
980
|
+
timestamp: number,
|
|
981
|
+
usage: Totals,
|
|
982
|
+
eventCount = 1,
|
|
983
|
+
): void {
|
|
984
|
+
database.prepare(`
|
|
985
|
+
INSERT INTO usage_hourly(
|
|
986
|
+
source_path, provider, session_id, bucket_start_ms,
|
|
987
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens, event_count
|
|
988
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
989
|
+
ON CONFLICT(source_path, provider, session_id, bucket_start_ms) DO UPDATE SET
|
|
990
|
+
input_tokens = input_tokens + excluded.input_tokens,
|
|
991
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
992
|
+
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
993
|
+
cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
|
|
994
|
+
event_count = event_count + excluded.event_count
|
|
995
|
+
`).run(
|
|
996
|
+
sourcePath,
|
|
997
|
+
provider,
|
|
998
|
+
sessionId,
|
|
999
|
+
localHourMs(timestamp),
|
|
1000
|
+
usage.input,
|
|
1001
|
+
usage.output,
|
|
1002
|
+
usage.cached,
|
|
1003
|
+
usage.cacheWrite,
|
|
1004
|
+
eventCount,
|
|
1005
|
+
);
|
|
1006
|
+
database.prepare(`
|
|
1007
|
+
INSERT INTO usage_provider_hourly(
|
|
1008
|
+
provider, bucket_start_ms, input_tokens, output_tokens,
|
|
1009
|
+
cache_read_tokens, cache_write_tokens, event_count
|
|
1010
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?)
|
|
1011
|
+
ON CONFLICT(provider, bucket_start_ms) DO UPDATE SET
|
|
1012
|
+
input_tokens = input_tokens + excluded.input_tokens,
|
|
1013
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
1014
|
+
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
1015
|
+
cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
|
|
1016
|
+
event_count = event_count + excluded.event_count
|
|
1017
|
+
`).run(
|
|
1018
|
+
provider,
|
|
1019
|
+
localHourMs(timestamp),
|
|
1020
|
+
usage.input,
|
|
1021
|
+
usage.output,
|
|
1022
|
+
usage.cached,
|
|
1023
|
+
usage.cacheWrite,
|
|
1024
|
+
eventCount,
|
|
1025
|
+
);
|
|
1026
|
+
if (eventCount < 0) {
|
|
1027
|
+
database.prepare(`
|
|
1028
|
+
DELETE FROM usage_provider_hourly
|
|
1029
|
+
WHERE provider = ? AND bucket_start_ms = ? AND event_count <= 0
|
|
1030
|
+
`).run(provider, localHourMs(timestamp));
|
|
1031
|
+
}
|
|
1032
|
+
}
|
|
1033
|
+
|
|
1034
|
+
function hourlyDelta(
|
|
1035
|
+
database: DatabaseSync,
|
|
1036
|
+
sourcePath: string,
|
|
1037
|
+
provider: Provider,
|
|
1038
|
+
sessionId: string,
|
|
1039
|
+
timestamp: number,
|
|
1040
|
+
usage: Totals,
|
|
1041
|
+
direction: 1 | -1,
|
|
1042
|
+
): void {
|
|
1043
|
+
const scaled = {
|
|
1044
|
+
input: usage.input * direction,
|
|
1045
|
+
cached: usage.cached * direction,
|
|
1046
|
+
output: usage.output * direction,
|
|
1047
|
+
cacheWrite: usage.cacheWrite * direction,
|
|
1048
|
+
};
|
|
1049
|
+
hourlyAdd(database, sourcePath, provider, sessionId, timestamp, scaled, direction);
|
|
1050
|
+
if (direction < 0) {
|
|
1051
|
+
database.prepare(`
|
|
1052
|
+
DELETE FROM usage_hourly
|
|
1053
|
+
WHERE source_path = ? AND provider = ? AND session_id = ?
|
|
1054
|
+
AND bucket_start_ms = ? AND event_count <= 0
|
|
1055
|
+
`).run(sourcePath, provider, sessionId, localHourMs(timestamp));
|
|
1056
|
+
}
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
function recentUpsert(
|
|
1060
|
+
database: DatabaseSync,
|
|
1061
|
+
sourcePath: string,
|
|
1062
|
+
eventKey: string,
|
|
1063
|
+
provider: Provider,
|
|
1064
|
+
sessionId: string,
|
|
1065
|
+
timestamp: number,
|
|
1066
|
+
usage: Totals,
|
|
1067
|
+
): void {
|
|
1068
|
+
database.prepare(`
|
|
1069
|
+
INSERT INTO recent_events(
|
|
1070
|
+
source_path, event_key, provider, session_id, timestamp_ms,
|
|
1071
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
1072
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
1073
|
+
ON CONFLICT(source_path, event_key) DO UPDATE SET
|
|
1074
|
+
provider = excluded.provider,
|
|
1075
|
+
session_id = excluded.session_id,
|
|
1076
|
+
timestamp_ms = excluded.timestamp_ms,
|
|
1077
|
+
input_tokens = excluded.input_tokens,
|
|
1078
|
+
output_tokens = excluded.output_tokens,
|
|
1079
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
1080
|
+
cache_write_tokens = excluded.cache_write_tokens
|
|
1081
|
+
`).run(
|
|
1082
|
+
sourcePath,
|
|
1083
|
+
eventKey,
|
|
1084
|
+
provider,
|
|
1085
|
+
sessionId,
|
|
1086
|
+
timestamp,
|
|
1087
|
+
usage.input,
|
|
1088
|
+
usage.output,
|
|
1089
|
+
usage.cached,
|
|
1090
|
+
usage.cacheWrite,
|
|
1091
|
+
);
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function claudeMessageUpsert(
|
|
1095
|
+
database: DatabaseSync,
|
|
1096
|
+
sourcePath: string,
|
|
1097
|
+
ownerSessionId: string,
|
|
1098
|
+
messageId: string,
|
|
1099
|
+
timestamp: number,
|
|
1100
|
+
usage: Totals,
|
|
1101
|
+
recentBoundary: number,
|
|
1102
|
+
): void {
|
|
1103
|
+
const existing = database.prepare(`
|
|
1104
|
+
SELECT source_path, timestamp_ms, input_tokens, output_tokens,
|
|
1105
|
+
cache_read_tokens, cache_write_tokens
|
|
1106
|
+
FROM claude_messages
|
|
1107
|
+
WHERE owner_session_id = ? AND message_id = ?
|
|
1108
|
+
`).get(ownerSessionId, messageId) as Record<string, unknown> | undefined;
|
|
1109
|
+
if (
|
|
1110
|
+
existing
|
|
1111
|
+
&& (
|
|
1112
|
+
Number(existing.output_tokens) > usage.output
|
|
1113
|
+
|| (
|
|
1114
|
+
Number(existing.output_tokens) === usage.output
|
|
1115
|
+
&& String(existing.source_path) <= sourcePath
|
|
1116
|
+
)
|
|
1117
|
+
)
|
|
1118
|
+
) {
|
|
1119
|
+
return;
|
|
1120
|
+
}
|
|
1121
|
+
if (existing) {
|
|
1122
|
+
const oldUsage = {
|
|
1123
|
+
input: Number(existing.input_tokens),
|
|
1124
|
+
output: Number(existing.output_tokens),
|
|
1125
|
+
cached: Number(existing.cache_read_tokens),
|
|
1126
|
+
cacheWrite: Number(existing.cache_write_tokens),
|
|
1127
|
+
};
|
|
1128
|
+
hourlyDelta(
|
|
1129
|
+
database,
|
|
1130
|
+
String(existing.source_path),
|
|
1131
|
+
'claude',
|
|
1132
|
+
ownerSessionId,
|
|
1133
|
+
Number(existing.timestamp_ms),
|
|
1134
|
+
oldUsage,
|
|
1135
|
+
-1,
|
|
1136
|
+
);
|
|
1137
|
+
database.prepare(
|
|
1138
|
+
'DELETE FROM recent_events WHERE source_path = ? AND event_key = ?',
|
|
1139
|
+
).run(String(existing.source_path), `message:${messageId}`);
|
|
1140
|
+
}
|
|
1141
|
+
database.prepare(`
|
|
1142
|
+
INSERT INTO claude_messages(
|
|
1143
|
+
owner_session_id, message_id, source_path, timestamp_ms,
|
|
1144
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
1145
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?)
|
|
1146
|
+
ON CONFLICT(owner_session_id, message_id) DO UPDATE SET
|
|
1147
|
+
source_path = excluded.source_path,
|
|
1148
|
+
timestamp_ms = excluded.timestamp_ms,
|
|
1149
|
+
input_tokens = excluded.input_tokens,
|
|
1150
|
+
output_tokens = excluded.output_tokens,
|
|
1151
|
+
cache_read_tokens = excluded.cache_read_tokens,
|
|
1152
|
+
cache_write_tokens = excluded.cache_write_tokens
|
|
1153
|
+
WHERE excluded.output_tokens > claude_messages.output_tokens
|
|
1154
|
+
OR (
|
|
1155
|
+
excluded.output_tokens = claude_messages.output_tokens
|
|
1156
|
+
AND excluded.source_path < claude_messages.source_path
|
|
1157
|
+
)
|
|
1158
|
+
`).run(
|
|
1159
|
+
ownerSessionId,
|
|
1160
|
+
messageId,
|
|
1161
|
+
sourcePath,
|
|
1162
|
+
timestamp,
|
|
1163
|
+
usage.input,
|
|
1164
|
+
usage.output,
|
|
1165
|
+
usage.cached,
|
|
1166
|
+
usage.cacheWrite,
|
|
1167
|
+
);
|
|
1168
|
+
hourlyDelta(
|
|
1169
|
+
database,
|
|
1170
|
+
sourcePath,
|
|
1171
|
+
'claude',
|
|
1172
|
+
ownerSessionId,
|
|
1173
|
+
timestamp,
|
|
1174
|
+
usage,
|
|
1175
|
+
1,
|
|
1176
|
+
);
|
|
1177
|
+
if (timestamp >= recentBoundary) {
|
|
1178
|
+
recentUpsert(
|
|
1179
|
+
database,
|
|
1180
|
+
sourcePath,
|
|
1181
|
+
`message:${messageId}`,
|
|
1182
|
+
'claude',
|
|
1183
|
+
ownerSessionId,
|
|
1184
|
+
timestamp,
|
|
1185
|
+
usage,
|
|
1186
|
+
);
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
|
|
1190
|
+
function sourceRow(database: DatabaseSync, filePath: string): Record<string, unknown> | undefined {
|
|
1191
|
+
return database.prepare('SELECT * FROM source_files WHERE path = ?').get(filePath) as
|
|
1192
|
+
Record<string, unknown> | undefined;
|
|
1193
|
+
}
|
|
1194
|
+
|
|
1195
|
+
function directoryGeneration(database: DatabaseSync, directoryPath: string): number {
|
|
1196
|
+
const row = database.prepare(
|
|
1197
|
+
'SELECT scan_generation FROM discovery_directories WHERE path = ?',
|
|
1198
|
+
).get(directoryPath) as { scan_generation?: number } | undefined;
|
|
1199
|
+
return Number(row?.scan_generation || 0);
|
|
1200
|
+
}
|
|
1201
|
+
|
|
1202
|
+
function insertSource(
|
|
1203
|
+
database: DatabaseSync,
|
|
1204
|
+
candidate: SourceCandidate,
|
|
1205
|
+
state: ParserState,
|
|
1206
|
+
hash: string,
|
|
1207
|
+
): void {
|
|
1208
|
+
database.prepare(`
|
|
1209
|
+
INSERT INTO source_files(
|
|
1210
|
+
path, provider, directory_path, directory_generation,
|
|
1211
|
+
size, mtime_ms, committed_offset, file_dev, file_ino,
|
|
1212
|
+
prefix_sha256, suffix_sha256, parser_state, session_id, scan_complete,
|
|
1213
|
+
last_checked_ms
|
|
1214
|
+
) VALUES(?, ?, ?, ?, ?, ?, 0, ?, ?, ?, ?, ?, ?, 0, ?)
|
|
1215
|
+
`).run(
|
|
1216
|
+
candidate.filePath,
|
|
1217
|
+
candidate.provider,
|
|
1218
|
+
path.dirname(candidate.filePath),
|
|
1219
|
+
directoryGeneration(database, path.dirname(candidate.filePath)),
|
|
1220
|
+
candidate.stat.size,
|
|
1221
|
+
candidate.stat.mtimeMs,
|
|
1222
|
+
Number(candidate.stat.dev),
|
|
1223
|
+
Number(candidate.stat.ino),
|
|
1224
|
+
hash,
|
|
1225
|
+
suffixHash(candidate.filePath, candidate.stat.size),
|
|
1226
|
+
JSON.stringify(state),
|
|
1227
|
+
state.sessionId,
|
|
1228
|
+
Date.now(),
|
|
1229
|
+
);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
function deleteSource(database: DatabaseSync, filePath: string): void {
|
|
1233
|
+
database.prepare(`
|
|
1234
|
+
INSERT INTO usage_provider_hourly(
|
|
1235
|
+
provider, bucket_start_ms, input_tokens, output_tokens,
|
|
1236
|
+
cache_read_tokens, cache_write_tokens, event_count
|
|
1237
|
+
)
|
|
1238
|
+
SELECT provider, bucket_start_ms, -input_tokens, -output_tokens,
|
|
1239
|
+
-cache_read_tokens, -cache_write_tokens, -event_count
|
|
1240
|
+
FROM usage_hourly
|
|
1241
|
+
WHERE source_path = ?
|
|
1242
|
+
ON CONFLICT(provider, bucket_start_ms) DO UPDATE SET
|
|
1243
|
+
input_tokens = input_tokens + excluded.input_tokens,
|
|
1244
|
+
output_tokens = output_tokens + excluded.output_tokens,
|
|
1245
|
+
cache_read_tokens = cache_read_tokens + excluded.cache_read_tokens,
|
|
1246
|
+
cache_write_tokens = cache_write_tokens + excluded.cache_write_tokens,
|
|
1247
|
+
event_count = event_count + excluded.event_count
|
|
1248
|
+
`).run(filePath);
|
|
1249
|
+
database.prepare(
|
|
1250
|
+
'DELETE FROM usage_provider_hourly WHERE event_count <= 0',
|
|
1251
|
+
).run();
|
|
1252
|
+
database.prepare('DELETE FROM claude_messages WHERE source_path = ?').run(filePath);
|
|
1253
|
+
database.prepare('DELETE FROM codex_quota_candidates WHERE source_path = ?').run(filePath);
|
|
1254
|
+
database.prepare('DELETE FROM source_files WHERE path = ?').run(filePath);
|
|
1255
|
+
}
|
|
1256
|
+
|
|
1257
|
+
function processRecord(
|
|
1258
|
+
database: DatabaseSync,
|
|
1259
|
+
candidate: SourceCandidate,
|
|
1260
|
+
state: ParserState,
|
|
1261
|
+
record: Record<string, unknown>,
|
|
1262
|
+
lineEnd: number,
|
|
1263
|
+
recentBoundary: number,
|
|
1264
|
+
metrics: Metrics,
|
|
1265
|
+
): void {
|
|
1266
|
+
if (candidate.provider === 'codex') {
|
|
1267
|
+
const codexState = state as CodexState;
|
|
1268
|
+
const result = codexRecord(record, codexState);
|
|
1269
|
+
if (result.quota && typeof result.quota === 'object') {
|
|
1270
|
+
const quota = result.quota as Record<string, unknown>;
|
|
1271
|
+
const key = String(quota.limit_id || quota.limitId || '');
|
|
1272
|
+
const previous = codexState.quotas[key];
|
|
1273
|
+
const observedAt = timestampMs(record.timestamp);
|
|
1274
|
+
if (!previous || observedAt === null || previous.timestamp === null || observedAt >= previous.timestamp) {
|
|
1275
|
+
codexState.quotas[key] = { timestamp: observedAt, rateLimits: result.quota };
|
|
1276
|
+
database.prepare(`
|
|
1277
|
+
INSERT INTO codex_quota_candidates(
|
|
1278
|
+
source_path, limit_id, timestamp_ms, rate_limits_json
|
|
1279
|
+
) VALUES(?, ?, ?, ?)
|
|
1280
|
+
ON CONFLICT(source_path, limit_id) DO UPDATE SET
|
|
1281
|
+
timestamp_ms = excluded.timestamp_ms,
|
|
1282
|
+
rate_limits_json = excluded.rate_limits_json
|
|
1283
|
+
WHERE excluded.timestamp_ms IS NULL
|
|
1284
|
+
OR codex_quota_candidates.timestamp_ms IS NULL
|
|
1285
|
+
OR excluded.timestamp_ms >= codex_quota_candidates.timestamp_ms
|
|
1286
|
+
`).run(
|
|
1287
|
+
candidate.filePath,
|
|
1288
|
+
key,
|
|
1289
|
+
observedAt,
|
|
1290
|
+
JSON.stringify(result.quota),
|
|
1291
|
+
);
|
|
1292
|
+
}
|
|
1293
|
+
}
|
|
1294
|
+
if (!result.usage || !result.timestamp) return;
|
|
1295
|
+
hourlyAdd(
|
|
1296
|
+
database,
|
|
1297
|
+
candidate.filePath,
|
|
1298
|
+
'codex',
|
|
1299
|
+
codexState.sessionId,
|
|
1300
|
+
result.timestamp,
|
|
1301
|
+
result.usage,
|
|
1302
|
+
);
|
|
1303
|
+
if (result.timestamp >= recentBoundary) {
|
|
1304
|
+
recentUpsert(
|
|
1305
|
+
database,
|
|
1306
|
+
candidate.filePath,
|
|
1307
|
+
`offset:${lineEnd}`,
|
|
1308
|
+
'codex',
|
|
1309
|
+
codexState.sessionId,
|
|
1310
|
+
result.timestamp,
|
|
1311
|
+
result.usage,
|
|
1312
|
+
);
|
|
1313
|
+
}
|
|
1314
|
+
metrics.parsed_events = Number(metrics.parsed_events) + 1;
|
|
1315
|
+
return;
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
const claudeState = state as ClaudeState;
|
|
1319
|
+
const result = claudeRecord(record, claudeState);
|
|
1320
|
+
if (!result.usage || !result.timestamp) return;
|
|
1321
|
+
if (result.messageId) {
|
|
1322
|
+
claudeMessageUpsert(
|
|
1323
|
+
database,
|
|
1324
|
+
candidate.filePath,
|
|
1325
|
+
claudeState.sessionId,
|
|
1326
|
+
result.messageId,
|
|
1327
|
+
result.timestamp,
|
|
1328
|
+
result.usage,
|
|
1329
|
+
recentBoundary,
|
|
1330
|
+
);
|
|
1331
|
+
} else {
|
|
1332
|
+
hourlyAdd(
|
|
1333
|
+
database,
|
|
1334
|
+
candidate.filePath,
|
|
1335
|
+
'claude',
|
|
1336
|
+
claudeState.sessionId,
|
|
1337
|
+
result.timestamp,
|
|
1338
|
+
result.usage,
|
|
1339
|
+
);
|
|
1340
|
+
if (result.timestamp >= recentBoundary) {
|
|
1341
|
+
recentUpsert(
|
|
1342
|
+
database,
|
|
1343
|
+
candidate.filePath,
|
|
1344
|
+
`offset:${lineEnd}`,
|
|
1345
|
+
'claude',
|
|
1346
|
+
claudeState.sessionId,
|
|
1347
|
+
result.timestamp,
|
|
1348
|
+
result.usage,
|
|
1349
|
+
);
|
|
1350
|
+
}
|
|
1351
|
+
}
|
|
1352
|
+
metrics.parsed_events = Number(metrics.parsed_events) + 1;
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
function isCompleteJsonValue(
|
|
1356
|
+
filePath: string,
|
|
1357
|
+
startOffset: number,
|
|
1358
|
+
endOffset: number,
|
|
1359
|
+
): boolean {
|
|
1360
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
1361
|
+
let physicalOffset = startOffset;
|
|
1362
|
+
let started = false;
|
|
1363
|
+
let finished = false;
|
|
1364
|
+
let inString = false;
|
|
1365
|
+
let escaped = false;
|
|
1366
|
+
let invalid = false;
|
|
1367
|
+
const stack: number[] = [];
|
|
1368
|
+
try {
|
|
1369
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
1370
|
+
while (physicalOffset < endOffset && !invalid) {
|
|
1371
|
+
const length = fs.readSync(
|
|
1372
|
+
descriptor,
|
|
1373
|
+
buffer,
|
|
1374
|
+
0,
|
|
1375
|
+
Math.min(buffer.length, endOffset - physicalOffset),
|
|
1376
|
+
physicalOffset,
|
|
1377
|
+
);
|
|
1378
|
+
if (length === 0) break;
|
|
1379
|
+
physicalOffset += length;
|
|
1380
|
+
for (const byte of buffer.subarray(0, length)) {
|
|
1381
|
+
if (!started) {
|
|
1382
|
+
if (byte === 0x20 || byte === 0x09 || byte === 0x0d || byte === 0x0a) continue;
|
|
1383
|
+
if (byte !== 0x7b && byte !== 0x5b) {
|
|
1384
|
+
invalid = true;
|
|
1385
|
+
break;
|
|
1386
|
+
}
|
|
1387
|
+
started = true;
|
|
1388
|
+
stack.push(byte);
|
|
1389
|
+
continue;
|
|
1390
|
+
}
|
|
1391
|
+
if (finished) {
|
|
1392
|
+
if (byte !== 0x20 && byte !== 0x09 && byte !== 0x0d && byte !== 0x0a) {
|
|
1393
|
+
invalid = true;
|
|
1394
|
+
break;
|
|
1395
|
+
}
|
|
1396
|
+
continue;
|
|
1397
|
+
}
|
|
1398
|
+
if (inString) {
|
|
1399
|
+
if (escaped) {
|
|
1400
|
+
escaped = false;
|
|
1401
|
+
} else if (byte === 0x5c) {
|
|
1402
|
+
escaped = true;
|
|
1403
|
+
} else if (byte === 0x22) {
|
|
1404
|
+
inString = false;
|
|
1405
|
+
}
|
|
1406
|
+
continue;
|
|
1407
|
+
}
|
|
1408
|
+
if (byte === 0x22) {
|
|
1409
|
+
inString = true;
|
|
1410
|
+
} else if (byte === 0x7b || byte === 0x5b) {
|
|
1411
|
+
stack.push(byte);
|
|
1412
|
+
} else if (byte === 0x7d || byte === 0x5d) {
|
|
1413
|
+
const expected = byte === 0x7d ? 0x7b : 0x5b;
|
|
1414
|
+
if (stack.pop() !== expected) {
|
|
1415
|
+
invalid = true;
|
|
1416
|
+
break;
|
|
1417
|
+
}
|
|
1418
|
+
if (stack.length === 0) finished = true;
|
|
1419
|
+
}
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
return physicalOffset >= endOffset
|
|
1423
|
+
&& started
|
|
1424
|
+
&& finished
|
|
1425
|
+
&& !inString
|
|
1426
|
+
&& !escaped
|
|
1427
|
+
&& !invalid
|
|
1428
|
+
&& stack.length === 0;
|
|
1429
|
+
} finally {
|
|
1430
|
+
fs.closeSync(descriptor);
|
|
1431
|
+
}
|
|
1432
|
+
}
|
|
1433
|
+
|
|
1434
|
+
function scanFile(
|
|
1435
|
+
database: DatabaseSync,
|
|
1436
|
+
candidate: SourceCandidate,
|
|
1437
|
+
recentBoundary: number,
|
|
1438
|
+
deadline: number,
|
|
1439
|
+
metrics: Metrics,
|
|
1440
|
+
): boolean {
|
|
1441
|
+
let row = sourceRow(database, candidate.filePath);
|
|
1442
|
+
if (
|
|
1443
|
+
row
|
|
1444
|
+
&& row.provider === candidate.provider
|
|
1445
|
+
&& Number(row.size) === candidate.stat.size
|
|
1446
|
+
&& Number(row.mtime_ms) === candidate.stat.mtimeMs
|
|
1447
|
+
&& Number(row.scan_complete) === 1
|
|
1448
|
+
) {
|
|
1449
|
+
metrics.reused_files = Number(metrics.reused_files) + 1;
|
|
1450
|
+
return true;
|
|
1451
|
+
}
|
|
1452
|
+
|
|
1453
|
+
let rebuild = !row || row.provider !== candidate.provider;
|
|
1454
|
+
if (row && !rebuild) {
|
|
1455
|
+
const sameIdentity = Number(row.file_dev) === Number(candidate.stat.dev)
|
|
1456
|
+
&& Number(row.file_ino) === Number(candidate.stat.ino);
|
|
1457
|
+
if (candidate.stat.size < Number(row.committed_offset) || !sameIdentity) {
|
|
1458
|
+
rebuild = true;
|
|
1459
|
+
} else if (candidate.stat.mtimeMs !== Number(row.mtime_ms)) {
|
|
1460
|
+
if (
|
|
1461
|
+
prefixHash(candidate.filePath, Number(row.size)) !== row.prefix_sha256
|
|
1462
|
+
|| suffixHash(candidate.filePath, Number(row.size)) !== row.suffix_sha256
|
|
1463
|
+
) {
|
|
1464
|
+
rebuild = true;
|
|
1465
|
+
} else if (
|
|
1466
|
+
candidate.stat.size === Number(row.size)
|
|
1467
|
+
&& Number(row.scan_complete) === 1
|
|
1468
|
+
) {
|
|
1469
|
+
database.prepare(
|
|
1470
|
+
'UPDATE source_files SET mtime_ms = ?, file_dev = ?, file_ino = ?, '
|
|
1471
|
+
+ 'last_checked_ms = ? WHERE path = ?',
|
|
1472
|
+
).run(
|
|
1473
|
+
candidate.stat.mtimeMs,
|
|
1474
|
+
Number(candidate.stat.dev),
|
|
1475
|
+
Number(candidate.stat.ino),
|
|
1476
|
+
Date.now(),
|
|
1477
|
+
candidate.filePath,
|
|
1478
|
+
);
|
|
1479
|
+
metrics.reused_files = Number(metrics.reused_files) + 1;
|
|
1480
|
+
return true;
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
}
|
|
1484
|
+
|
|
1485
|
+
if (rebuild) {
|
|
1486
|
+
if (row) {
|
|
1487
|
+
deleteSource(database, candidate.filePath);
|
|
1488
|
+
metrics[`${candidate.provider}_cache_rebuilt`] = true;
|
|
1489
|
+
}
|
|
1490
|
+
const state = defaultState(candidate.provider, candidate.filePath);
|
|
1491
|
+
if (candidate.provider === 'codex') {
|
|
1492
|
+
const codexState = state as CodexState;
|
|
1493
|
+
codexState.sessionId = candidate.sessionId;
|
|
1494
|
+
codexState.parentSessionId = candidate.parentSessionId;
|
|
1495
|
+
codexState.forkTimestamp = candidate.forkTimestamp;
|
|
1496
|
+
codexState.copiedPrefix = candidate.copiedPrefix;
|
|
1497
|
+
codexState.inheritedRaw = cloneTotals(candidate.parentBaseline);
|
|
1498
|
+
}
|
|
1499
|
+
insertSource(database, candidate, state, prefixHash(candidate.filePath));
|
|
1500
|
+
row = sourceRow(database, candidate.filePath);
|
|
1501
|
+
metrics.rebuilt_files = Number(metrics.rebuilt_files) + 1;
|
|
1502
|
+
} else {
|
|
1503
|
+
metrics.appended_files = Number(metrics.appended_files) + 1;
|
|
1504
|
+
}
|
|
1505
|
+
if (!row) throw new Error(`Unable to create usage checkpoint for ${candidate.filePath}`);
|
|
1506
|
+
|
|
1507
|
+
const state = JSON.parse(String(row.parser_state)) as ParserState;
|
|
1508
|
+
let committedOffset = Number(row.committed_offset);
|
|
1509
|
+
let physicalOffset = committedOffset;
|
|
1510
|
+
let fullChunks: Buffer[] = [];
|
|
1511
|
+
let lineBytes = 0;
|
|
1512
|
+
let first = Buffer.alloc(0);
|
|
1513
|
+
let tail = Buffer.alloc(0);
|
|
1514
|
+
let truncated = false;
|
|
1515
|
+
let stoppedForBudget = false;
|
|
1516
|
+
|
|
1517
|
+
const resetLine = () => {
|
|
1518
|
+
fullChunks = [];
|
|
1519
|
+
lineBytes = 0;
|
|
1520
|
+
first = Buffer.alloc(0);
|
|
1521
|
+
tail = Buffer.alloc(0);
|
|
1522
|
+
truncated = false;
|
|
1523
|
+
};
|
|
1524
|
+
const appendPart = (part: Buffer) => {
|
|
1525
|
+
lineBytes += part.length;
|
|
1526
|
+
if (!truncated && lineBytes <= MAX_LINE_BYTES) {
|
|
1527
|
+
fullChunks.push(part);
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (!truncated) {
|
|
1531
|
+
const combined = Buffer.concat(fullChunks);
|
|
1532
|
+
first = combined.subarray(0, TRUNCATED_EDGE_BYTES);
|
|
1533
|
+
tail = combined.subarray(Math.max(0, combined.length - TRUNCATED_EDGE_BYTES));
|
|
1534
|
+
fullChunks = [];
|
|
1535
|
+
truncated = true;
|
|
1536
|
+
}
|
|
1537
|
+
if (first.length < TRUNCATED_EDGE_BYTES) {
|
|
1538
|
+
const needed = TRUNCATED_EDGE_BYTES - first.length;
|
|
1539
|
+
first = Buffer.concat([first, part.subarray(0, needed)]);
|
|
1540
|
+
}
|
|
1541
|
+
tail = Buffer.concat([tail, part]);
|
|
1542
|
+
if (tail.length > TRUNCATED_EDGE_BYTES) {
|
|
1543
|
+
tail = tail.subarray(tail.length - TRUNCATED_EDGE_BYTES);
|
|
1544
|
+
}
|
|
1545
|
+
};
|
|
1546
|
+
const finishLine = (lineEnd: number, requireCompleteJson = false): boolean => {
|
|
1547
|
+
const fullLine = truncated ? null : Buffer.concat(fullChunks);
|
|
1548
|
+
if (requireCompleteJson) {
|
|
1549
|
+
if (fullLine) {
|
|
1550
|
+
try {
|
|
1551
|
+
JSON.parse(fullLine.toString('utf8'));
|
|
1552
|
+
} catch {
|
|
1553
|
+
return false;
|
|
1554
|
+
}
|
|
1555
|
+
} else if (!isCompleteJsonValue(candidate.filePath, committedOffset, lineEnd)) {
|
|
1556
|
+
return false;
|
|
1557
|
+
}
|
|
1558
|
+
}
|
|
1559
|
+
const record = parseRecord(candidate.provider, fullLine, first, tail);
|
|
1560
|
+
if (record) {
|
|
1561
|
+
processRecord(
|
|
1562
|
+
database,
|
|
1563
|
+
candidate,
|
|
1564
|
+
state,
|
|
1565
|
+
record,
|
|
1566
|
+
lineEnd,
|
|
1567
|
+
recentBoundary,
|
|
1568
|
+
metrics,
|
|
1569
|
+
);
|
|
1570
|
+
}
|
|
1571
|
+
committedOffset = lineEnd;
|
|
1572
|
+
resetLine();
|
|
1573
|
+
return true;
|
|
1574
|
+
};
|
|
1575
|
+
|
|
1576
|
+
const descriptor = fs.openSync(candidate.filePath, 'r');
|
|
1577
|
+
try {
|
|
1578
|
+
const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
1579
|
+
while (true) {
|
|
1580
|
+
const length = fs.readSync(
|
|
1581
|
+
descriptor,
|
|
1582
|
+
chunk,
|
|
1583
|
+
0,
|
|
1584
|
+
chunk.length,
|
|
1585
|
+
physicalOffset,
|
|
1586
|
+
);
|
|
1587
|
+
if (length === 0) break;
|
|
1588
|
+
metrics.bytes_read = Number(metrics.bytes_read) + length;
|
|
1589
|
+
const block = chunk.subarray(0, length);
|
|
1590
|
+
let cursor = 0;
|
|
1591
|
+
while (cursor < block.length) {
|
|
1592
|
+
const newline = block.indexOf(0x0a, cursor);
|
|
1593
|
+
const end = newline < 0 ? block.length : newline + 1;
|
|
1594
|
+
appendPart(Buffer.from(block.subarray(cursor, end)));
|
|
1595
|
+
physicalOffset += end - cursor;
|
|
1596
|
+
cursor = end;
|
|
1597
|
+
if (newline >= 0) finishLine(physicalOffset);
|
|
1598
|
+
}
|
|
1599
|
+
if (Date.now() >= deadline && lineBytes === 0 && physicalOffset < candidate.stat.size) {
|
|
1600
|
+
stoppedForBudget = true;
|
|
1601
|
+
break;
|
|
1602
|
+
}
|
|
1603
|
+
}
|
|
1604
|
+
} finally {
|
|
1605
|
+
fs.closeSync(descriptor);
|
|
1606
|
+
}
|
|
1607
|
+
|
|
1608
|
+
const finalStat = fs.statSync(candidate.filePath);
|
|
1609
|
+
if (!stoppedForBudget && physicalOffset >= finalStat.size && lineBytes > 0) {
|
|
1610
|
+
finishLine(physicalOffset, true);
|
|
1611
|
+
}
|
|
1612
|
+
const scanComplete = !stoppedForBudget
|
|
1613
|
+
&& physicalOffset >= finalStat.size
|
|
1614
|
+
&& committedOffset >= finalStat.size;
|
|
1615
|
+
database.prepare(`
|
|
1616
|
+
UPDATE source_files SET
|
|
1617
|
+
size = ?, mtime_ms = ?, committed_offset = ?, file_dev = ?, file_ino = ?,
|
|
1618
|
+
prefix_sha256 = ?, suffix_sha256 = ?, parser_state = ?, session_id = ?,
|
|
1619
|
+
scan_complete = ?, last_checked_ms = ?
|
|
1620
|
+
WHERE path = ?
|
|
1621
|
+
`).run(
|
|
1622
|
+
finalStat.size,
|
|
1623
|
+
finalStat.mtimeMs,
|
|
1624
|
+
committedOffset,
|
|
1625
|
+
Number(finalStat.dev),
|
|
1626
|
+
Number(finalStat.ino),
|
|
1627
|
+
prefixHash(candidate.filePath, finalStat.size),
|
|
1628
|
+
suffixHash(candidate.filePath, finalStat.size),
|
|
1629
|
+
JSON.stringify(state),
|
|
1630
|
+
state.sessionId,
|
|
1631
|
+
scanComplete ? 1 : 0,
|
|
1632
|
+
Date.now(),
|
|
1633
|
+
candidate.filePath,
|
|
1634
|
+
);
|
|
1635
|
+
metrics[scanComplete ? 'scanned_files' : 'partial_files'] =
|
|
1636
|
+
Number(metrics[scanComplete ? 'scanned_files' : 'partial_files']) + 1;
|
|
1637
|
+
return scanComplete;
|
|
1638
|
+
}
|
|
1639
|
+
|
|
1640
|
+
function probeCodex(filePath: string): {
|
|
1641
|
+
sessionId: string;
|
|
1642
|
+
parentSessionId: string | null;
|
|
1643
|
+
forkTimestamp: number | null;
|
|
1644
|
+
copiedPrefix: boolean;
|
|
1645
|
+
} {
|
|
1646
|
+
const fallback = path.basename(filePath, '.jsonl');
|
|
1647
|
+
let descriptor: number | null = null;
|
|
1648
|
+
try {
|
|
1649
|
+
descriptor = fs.openSync(filePath, 'r');
|
|
1650
|
+
// Codex writes the authoritative session_meta first. A small bounded probe
|
|
1651
|
+
// avoids rereading megabytes from every uncached rollout during discovery.
|
|
1652
|
+
const buffer = Buffer.allocUnsafe(PREFIX_BYTES);
|
|
1653
|
+
const length = fs.readSync(descriptor, buffer, 0, buffer.length, 0);
|
|
1654
|
+
const lines = buffer.subarray(0, length).toString('utf8').split('\n');
|
|
1655
|
+
let leaf: ReturnType<typeof parseCodexMeta> = null;
|
|
1656
|
+
let copiedPrefix = false;
|
|
1657
|
+
for (const line of lines) {
|
|
1658
|
+
if (!line.includes('session_meta')) continue;
|
|
1659
|
+
try {
|
|
1660
|
+
const parsed = JSON.parse(line);
|
|
1661
|
+
const metadata = parseCodexMeta(parsed);
|
|
1662
|
+
if (!metadata) continue;
|
|
1663
|
+
if (!leaf) {
|
|
1664
|
+
leaf = metadata;
|
|
1665
|
+
} else if (metadata.id && metadata.id !== leaf.id) {
|
|
1666
|
+
copiedPrefix = true;
|
|
1667
|
+
}
|
|
1668
|
+
} catch {
|
|
1669
|
+
// A partial final probe line is expected.
|
|
1670
|
+
}
|
|
1671
|
+
}
|
|
1672
|
+
return {
|
|
1673
|
+
sessionId: leaf?.id || fallback,
|
|
1674
|
+
parentSessionId: leaf?.parentId || null,
|
|
1675
|
+
forkTimestamp: leaf?.timestamp || null,
|
|
1676
|
+
copiedPrefix,
|
|
1677
|
+
};
|
|
1678
|
+
} catch {
|
|
1679
|
+
return {
|
|
1680
|
+
sessionId: fallback,
|
|
1681
|
+
parentSessionId: null,
|
|
1682
|
+
forkTimestamp: null,
|
|
1683
|
+
copiedPrefix: false,
|
|
1684
|
+
};
|
|
1685
|
+
} finally {
|
|
1686
|
+
if (descriptor !== null) fs.closeSync(descriptor);
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
|
|
1690
|
+
function codexRawTotalsAt(
|
|
1691
|
+
filePath: string,
|
|
1692
|
+
cutoff: number,
|
|
1693
|
+
metrics: Metrics,
|
|
1694
|
+
): Totals | null {
|
|
1695
|
+
const descriptor = fs.openSync(filePath, 'r');
|
|
1696
|
+
let physicalOffset = 0;
|
|
1697
|
+
let chunks: Buffer[] = [];
|
|
1698
|
+
let lineBytes = 0;
|
|
1699
|
+
let latest: Totals | null = null;
|
|
1700
|
+
let accumulated: Totals | null = null;
|
|
1701
|
+
let done = false;
|
|
1702
|
+
const consume = () => {
|
|
1703
|
+
if (lineBytes === 0 || lineBytes > 512 * 1024) {
|
|
1704
|
+
chunks = [];
|
|
1705
|
+
lineBytes = 0;
|
|
1706
|
+
return;
|
|
1707
|
+
}
|
|
1708
|
+
try {
|
|
1709
|
+
const record = JSON.parse(Buffer.concat(chunks).toString('utf8')) as
|
|
1710
|
+
Record<string, unknown>;
|
|
1711
|
+
const observedAt = timestampMs(record.timestamp);
|
|
1712
|
+
if (observedAt && observedAt > cutoff) {
|
|
1713
|
+
done = true;
|
|
1714
|
+
return;
|
|
1715
|
+
}
|
|
1716
|
+
const payload = record.payload && typeof record.payload === 'object'
|
|
1717
|
+
? record.payload as Record<string, unknown>
|
|
1718
|
+
: {};
|
|
1719
|
+
if (record.type !== 'event_msg' || payload.type !== 'token_count') return;
|
|
1720
|
+
const info = payload.info && typeof payload.info === 'object'
|
|
1721
|
+
? payload.info as Record<string, unknown>
|
|
1722
|
+
: {};
|
|
1723
|
+
const total = normalizedCodexTotals(info.total_token_usage);
|
|
1724
|
+
const last = normalizedCodexTotals(info.last_token_usage);
|
|
1725
|
+
if (total) {
|
|
1726
|
+
latest = total;
|
|
1727
|
+
accumulated = total;
|
|
1728
|
+
} else if (last) {
|
|
1729
|
+
accumulated = addTotals(accumulated, last);
|
|
1730
|
+
latest = accumulated;
|
|
1731
|
+
}
|
|
1732
|
+
} catch {
|
|
1733
|
+
// Only complete, canonical token rows contribute to a parent snapshot.
|
|
1734
|
+
} finally {
|
|
1735
|
+
chunks = [];
|
|
1736
|
+
lineBytes = 0;
|
|
1737
|
+
}
|
|
1738
|
+
};
|
|
1739
|
+
try {
|
|
1740
|
+
const buffer = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
1741
|
+
while (!done) {
|
|
1742
|
+
const length = fs.readSync(
|
|
1743
|
+
descriptor,
|
|
1744
|
+
buffer,
|
|
1745
|
+
0,
|
|
1746
|
+
buffer.length,
|
|
1747
|
+
physicalOffset,
|
|
1748
|
+
);
|
|
1749
|
+
if (length === 0) break;
|
|
1750
|
+
metrics.bytes_read = Number(metrics.bytes_read) + length;
|
|
1751
|
+
const block = buffer.subarray(0, length);
|
|
1752
|
+
let cursor = 0;
|
|
1753
|
+
while (cursor < block.length && !done) {
|
|
1754
|
+
const newline = block.indexOf(0x0a, cursor);
|
|
1755
|
+
const end = newline < 0 ? block.length : newline + 1;
|
|
1756
|
+
const part = Buffer.from(block.subarray(cursor, end));
|
|
1757
|
+
lineBytes += part.length;
|
|
1758
|
+
if (lineBytes <= 512 * 1024) chunks.push(part);
|
|
1759
|
+
physicalOffset += part.length;
|
|
1760
|
+
cursor = end;
|
|
1761
|
+
if (newline >= 0) consume();
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
if (!done && lineBytes > 0) consume();
|
|
1765
|
+
return cloneTotals(latest);
|
|
1766
|
+
} finally {
|
|
1767
|
+
fs.closeSync(descriptor);
|
|
1768
|
+
}
|
|
1769
|
+
}
|
|
1770
|
+
|
|
1771
|
+
function rowEvent(row: Record<string, unknown>): UsageEvent {
|
|
1772
|
+
const input = Number(row.input_tokens);
|
|
1773
|
+
const output = Number(row.output_tokens);
|
|
1774
|
+
const cached = Number(row.cache_read_tokens);
|
|
1775
|
+
const cacheWrite = Number(row.cache_write_tokens);
|
|
1776
|
+
return {
|
|
1777
|
+
timestamp: Number(row.timestamp_ms),
|
|
1778
|
+
sessionId: String(row.session_id),
|
|
1779
|
+
totalTokens: input + output + cached + cacheWrite,
|
|
1780
|
+
inputTokens: input,
|
|
1781
|
+
outputTokens: output,
|
|
1782
|
+
cacheReadTokens: cached,
|
|
1783
|
+
cacheWriteTokens: cacheWrite,
|
|
1784
|
+
unattributedTokens: 0,
|
|
1785
|
+
};
|
|
1786
|
+
}
|
|
1787
|
+
|
|
1788
|
+
function providerEvents(
|
|
1789
|
+
database: DatabaseSync,
|
|
1790
|
+
provider: Provider,
|
|
1791
|
+
retentionCutoff: number,
|
|
1792
|
+
recentBoundary: number,
|
|
1793
|
+
): UsageEvent[] {
|
|
1794
|
+
const rows = database.prepare(`
|
|
1795
|
+
SELECT provider || '-aggregate' AS session_id, bucket_start_ms AS timestamp_ms,
|
|
1796
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
1797
|
+
FROM usage_provider_hourly
|
|
1798
|
+
WHERE provider = ? AND bucket_start_ms >= ? AND bucket_start_ms < ?
|
|
1799
|
+
UNION ALL
|
|
1800
|
+
SELECT session_id, timestamp_ms,
|
|
1801
|
+
input_tokens, output_tokens, cache_read_tokens, cache_write_tokens
|
|
1802
|
+
FROM recent_events
|
|
1803
|
+
WHERE provider = ? AND timestamp_ms >= ?
|
|
1804
|
+
ORDER BY timestamp_ms, session_id
|
|
1805
|
+
`).all(
|
|
1806
|
+
provider,
|
|
1807
|
+
localHourMs(retentionCutoff),
|
|
1808
|
+
recentBoundary,
|
|
1809
|
+
provider,
|
|
1810
|
+
recentBoundary,
|
|
1811
|
+
) as Array<Record<string, unknown>>;
|
|
1812
|
+
return rows.map(rowEvent);
|
|
1813
|
+
}
|
|
1814
|
+
|
|
1815
|
+
function latestQuotas(database: DatabaseSync): unknown[] {
|
|
1816
|
+
const rows = database.prepare(`
|
|
1817
|
+
SELECT timestamp_ms, rate_limits_json
|
|
1818
|
+
FROM codex_quota_candidates
|
|
1819
|
+
ORDER BY timestamp_ms DESC
|
|
1820
|
+
LIMIT 32
|
|
1821
|
+
`).all() as Array<{ timestamp_ms: number | null; rate_limits_json: string }>;
|
|
1822
|
+
const candidates: Array<{ timestamp: number | null; rateLimits: unknown }> = [];
|
|
1823
|
+
for (const row of rows) {
|
|
1824
|
+
try {
|
|
1825
|
+
candidates.push({
|
|
1826
|
+
timestamp: row.timestamp_ms === null ? null : Number(row.timestamp_ms),
|
|
1827
|
+
rateLimits: JSON.parse(row.rate_limits_json),
|
|
1828
|
+
});
|
|
1829
|
+
} catch {
|
|
1830
|
+
// A malformed disposable cache row is ignored until its source is rebuilt.
|
|
1831
|
+
}
|
|
1832
|
+
}
|
|
1833
|
+
return candidates;
|
|
1834
|
+
}
|
|
1835
|
+
|
|
1836
|
+
function scalarCount(database: DatabaseSync, sql: string): number {
|
|
1837
|
+
const row = database.prepare(sql).get() as { count: number };
|
|
1838
|
+
return Number(row.count);
|
|
1839
|
+
}
|
|
1840
|
+
|
|
1841
|
+
function metadataValue(database: DatabaseSync, key: string): string {
|
|
1842
|
+
const row = database.prepare('SELECT value FROM metadata WHERE key = ?').get(key) as
|
|
1843
|
+
{ value?: string } | undefined;
|
|
1844
|
+
return String(row?.value || '');
|
|
1845
|
+
}
|
|
1846
|
+
|
|
1847
|
+
function setMetadataValue(database: DatabaseSync, key: string, value: string): void {
|
|
1848
|
+
database.prepare(
|
|
1849
|
+
'INSERT OR REPLACE INTO metadata(key, value) VALUES(?, ?)',
|
|
1850
|
+
).run(key, value);
|
|
1851
|
+
}
|
|
1852
|
+
|
|
1853
|
+
function configuredRoots(roots: Partial<Record<Provider, string[]>>): Array<{
|
|
1854
|
+
provider: Provider;
|
|
1855
|
+
rootPath: string;
|
|
1856
|
+
}> {
|
|
1857
|
+
const configured: Array<{ provider: Provider; rootPath: string }> = [];
|
|
1858
|
+
for (const provider of ['codex', 'claude'] as const) {
|
|
1859
|
+
for (const root of Array.from(new Set(roots[provider] || [])).sort()) {
|
|
1860
|
+
configured.push({ provider, rootPath: path.resolve(root) });
|
|
1861
|
+
}
|
|
1862
|
+
}
|
|
1863
|
+
return configured;
|
|
1864
|
+
}
|
|
1865
|
+
|
|
1866
|
+
function resetDiscovery(database: DatabaseSync, cacheFile: string): void {
|
|
1867
|
+
closeCacheDirectoryCursors(cacheFile);
|
|
1868
|
+
database.exec(`
|
|
1869
|
+
DELETE FROM discovery_source_queue;
|
|
1870
|
+
DELETE FROM discovery_directories;
|
|
1871
|
+
DELETE FROM codex_quota_candidates;
|
|
1872
|
+
DELETE FROM claude_messages;
|
|
1873
|
+
DELETE FROM usage_provider_hourly;
|
|
1874
|
+
DELETE FROM source_files;
|
|
1875
|
+
`);
|
|
1876
|
+
}
|
|
1877
|
+
|
|
1878
|
+
function ensureDiscoveryRoots(
|
|
1879
|
+
database: DatabaseSync,
|
|
1880
|
+
roots: Partial<Record<Provider, string[]>>,
|
|
1881
|
+
nowMs: number,
|
|
1882
|
+
cacheFile: string,
|
|
1883
|
+
): void {
|
|
1884
|
+
const configured = configuredRoots(roots);
|
|
1885
|
+
const signature = JSON.stringify(configured);
|
|
1886
|
+
if (metadataValue(database, 'roots_signature') !== signature) {
|
|
1887
|
+
resetDiscovery(database, cacheFile);
|
|
1888
|
+
setMetadataValue(database, 'roots_signature', signature);
|
|
1889
|
+
setMetadataValue(database, 'discovery_ready', '0');
|
|
1890
|
+
}
|
|
1891
|
+
const configuredPaths = new Set(configured.map(entry => entry.rootPath));
|
|
1892
|
+
database.prepare(
|
|
1893
|
+
'DELETE FROM discovery_directories WHERE is_root = 1 AND path NOT IN ('
|
|
1894
|
+
+ (configured.length ? configured.map(() => '?').join(',') : "''")
|
|
1895
|
+
+ ')',
|
|
1896
|
+
).run(...configured.map(entry => entry.rootPath));
|
|
1897
|
+
for (const { provider, rootPath } of configured) {
|
|
1898
|
+
database.prepare(`
|
|
1899
|
+
INSERT INTO discovery_directories(
|
|
1900
|
+
path, provider, root_path, parent_path, is_root, present, pending,
|
|
1901
|
+
scan_generation, parent_generation, mtime_ms, file_dev, file_ino,
|
|
1902
|
+
last_checked_ms
|
|
1903
|
+
) VALUES(?, ?, ?, '', 1, 1, 1, 0, 0, -1, -1, -1, 0)
|
|
1904
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
1905
|
+
provider = excluded.provider,
|
|
1906
|
+
root_path = excluded.root_path,
|
|
1907
|
+
is_root = 1,
|
|
1908
|
+
present = 1,
|
|
1909
|
+
last_checked_ms = MIN(discovery_directories.last_checked_ms, ?)
|
|
1910
|
+
`).run(rootPath, provider, rootPath, nowMs);
|
|
1911
|
+
}
|
|
1912
|
+
if (configuredPaths.size === 0) setMetadataValue(database, 'discovery_ready', '1');
|
|
1913
|
+
}
|
|
1914
|
+
|
|
1915
|
+
function queueSource(
|
|
1916
|
+
database: DatabaseSync,
|
|
1917
|
+
provider: Provider,
|
|
1918
|
+
filePath: string,
|
|
1919
|
+
action: 'upsert' | 'remove',
|
|
1920
|
+
priority: number,
|
|
1921
|
+
nowMs: number,
|
|
1922
|
+
): void {
|
|
1923
|
+
database.prepare(`
|
|
1924
|
+
INSERT INTO discovery_source_queue(path, provider, action, priority, queued_at_ms)
|
|
1925
|
+
VALUES(?, ?, ?, ?, ?)
|
|
1926
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
1927
|
+
provider = excluded.provider,
|
|
1928
|
+
action = excluded.action,
|
|
1929
|
+
priority = excluded.priority,
|
|
1930
|
+
queued_at_ms = excluded.queued_at_ms
|
|
1931
|
+
`).run(filePath, provider, action, priority, nowMs);
|
|
1932
|
+
}
|
|
1933
|
+
|
|
1934
|
+
type DirectoryCursor = {
|
|
1935
|
+
directory: fs.Dir;
|
|
1936
|
+
startStat: fs.Stats;
|
|
1937
|
+
generation: number;
|
|
1938
|
+
};
|
|
1939
|
+
const directoryCursors = new Map<string, DirectoryCursor>();
|
|
1940
|
+
|
|
1941
|
+
function directoryCursorKey(cacheFile: string, directoryPath: string): string {
|
|
1942
|
+
return `${path.resolve(cacheFile)}\0${directoryPath}`;
|
|
1943
|
+
}
|
|
1944
|
+
|
|
1945
|
+
function closeDirectoryCursor(cacheFile: string, directoryPath: string): void {
|
|
1946
|
+
const key = directoryCursorKey(cacheFile, directoryPath);
|
|
1947
|
+
const cursor = directoryCursors.get(key);
|
|
1948
|
+
if (!cursor) return;
|
|
1949
|
+
directoryCursors.delete(key);
|
|
1950
|
+
try {
|
|
1951
|
+
cursor.directory.closeSync();
|
|
1952
|
+
} catch {
|
|
1953
|
+
// The directory may already have disappeared.
|
|
1954
|
+
}
|
|
1955
|
+
}
|
|
1956
|
+
|
|
1957
|
+
function closeCacheDirectoryCursors(cacheFile: string): void {
|
|
1958
|
+
const prefix = `${path.resolve(cacheFile)}\0`;
|
|
1959
|
+
for (const [key, cursor] of directoryCursors) {
|
|
1960
|
+
if (!key.startsWith(prefix)) continue;
|
|
1961
|
+
directoryCursors.delete(key);
|
|
1962
|
+
try {
|
|
1963
|
+
cursor.directory.closeSync();
|
|
1964
|
+
} catch {
|
|
1965
|
+
// The directory may already have disappeared.
|
|
1966
|
+
}
|
|
1967
|
+
}
|
|
1968
|
+
}
|
|
1969
|
+
|
|
1970
|
+
function markDirectoryTreeMissing(
|
|
1971
|
+
database: DatabaseSync,
|
|
1972
|
+
directoryPath: string,
|
|
1973
|
+
nowMs: number,
|
|
1974
|
+
cacheFile: string,
|
|
1975
|
+
): void {
|
|
1976
|
+
closeDirectoryCursor(cacheFile, directoryPath);
|
|
1977
|
+
const prefix = `${directoryPath}${path.sep}%`;
|
|
1978
|
+
database.prepare(
|
|
1979
|
+
'UPDATE discovery_directories SET present = 0, pending = 0, last_checked_ms = ? '
|
|
1980
|
+
+ 'WHERE path = ? OR path LIKE ?',
|
|
1981
|
+
).run(nowMs, directoryPath, prefix);
|
|
1982
|
+
database.prepare(`
|
|
1983
|
+
INSERT INTO discovery_source_queue(path, provider, action, priority, queued_at_ms)
|
|
1984
|
+
SELECT path, provider, 'remove', -1, ?
|
|
1985
|
+
FROM source_files
|
|
1986
|
+
WHERE directory_path = ? OR directory_path LIKE ?
|
|
1987
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
1988
|
+
action = CASE
|
|
1989
|
+
WHEN discovery_source_queue.action = 'upsert' THEN 'upsert'
|
|
1990
|
+
ELSE 'remove'
|
|
1991
|
+
END,
|
|
1992
|
+
queued_at_ms = MIN(discovery_source_queue.queued_at_ms, excluded.queued_at_ms)
|
|
1993
|
+
`).run(nowMs, directoryPath, prefix);
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
function auditDirectories(
|
|
1997
|
+
database: DatabaseSync,
|
|
1998
|
+
nowMs: number,
|
|
1999
|
+
deadline: number,
|
|
2000
|
+
metrics: Metrics,
|
|
2001
|
+
cacheFile: string,
|
|
2002
|
+
): void {
|
|
2003
|
+
const rows = database.prepare(`
|
|
2004
|
+
SELECT * FROM discovery_directories
|
|
2005
|
+
WHERE present = 1
|
|
2006
|
+
ORDER BY is_root DESC, last_checked_ms ASC
|
|
2007
|
+
LIMIT ?
|
|
2008
|
+
`).all(DIRECTORY_AUDIT_BATCH) as Array<Record<string, unknown>>;
|
|
2009
|
+
for (const row of rows) {
|
|
2010
|
+
if (Date.now() >= deadline) break;
|
|
2011
|
+
const directoryPath = String(row.path);
|
|
2012
|
+
try {
|
|
2013
|
+
const stat = fs.statSync(directoryPath);
|
|
2014
|
+
const changed = !stat.isDirectory()
|
|
2015
|
+
|| Number(row.file_dev) !== Number(stat.dev)
|
|
2016
|
+
|| Number(row.file_ino) !== Number(stat.ino)
|
|
2017
|
+
|| Number(row.mtime_ms) !== stat.mtimeMs;
|
|
2018
|
+
database.prepare(`
|
|
2019
|
+
UPDATE discovery_directories SET
|
|
2020
|
+
present = ?, pending = CASE WHEN ? THEN 1 ELSE pending END,
|
|
2021
|
+
mtime_ms = ?, file_dev = ?, file_ino = ?, last_checked_ms = ?
|
|
2022
|
+
WHERE path = ?
|
|
2023
|
+
`).run(
|
|
2024
|
+
stat.isDirectory() ? 1 : 0,
|
|
2025
|
+
changed ? 1 : 0,
|
|
2026
|
+
stat.mtimeMs,
|
|
2027
|
+
Number(stat.dev),
|
|
2028
|
+
Number(stat.ino),
|
|
2029
|
+
nowMs,
|
|
2030
|
+
directoryPath,
|
|
2031
|
+
);
|
|
2032
|
+
if (!stat.isDirectory()) {
|
|
2033
|
+
markDirectoryTreeMissing(database, directoryPath, nowMs, cacheFile);
|
|
2034
|
+
}
|
|
2035
|
+
} catch {
|
|
2036
|
+
markDirectoryTreeMissing(database, directoryPath, nowMs, cacheFile);
|
|
2037
|
+
}
|
|
2038
|
+
metrics.audited_directories = Number(metrics.audited_directories) + 1;
|
|
2039
|
+
}
|
|
2040
|
+
}
|
|
2041
|
+
|
|
2042
|
+
function scanPendingDirectories(
|
|
2043
|
+
database: DatabaseSync,
|
|
2044
|
+
nowMs: number,
|
|
2045
|
+
deadline: number,
|
|
2046
|
+
metrics: Metrics,
|
|
2047
|
+
cacheFile: string,
|
|
2048
|
+
): void {
|
|
2049
|
+
const remainingEntryBudget = DIRECTORY_ENTRY_BATCH
|
|
2050
|
+
- Number(metrics.enumerated_entries);
|
|
2051
|
+
if (remainingEntryBudget <= 0) return;
|
|
2052
|
+
const row = database.prepare(`
|
|
2053
|
+
SELECT * FROM discovery_directories
|
|
2054
|
+
WHERE present = 1 AND pending = 1
|
|
2055
|
+
ORDER BY is_root DESC, last_checked_ms ASC
|
|
2056
|
+
LIMIT 1
|
|
2057
|
+
`).get() as Record<string, unknown> | undefined;
|
|
2058
|
+
if (!row || Date.now() >= deadline) return;
|
|
2059
|
+
const directoryPath = String(row.path);
|
|
2060
|
+
const provider = String(row.provider) as Provider;
|
|
2061
|
+
const cursorKey = directoryCursorKey(cacheFile, directoryPath);
|
|
2062
|
+
let cursor = directoryCursors.get(cursorKey);
|
|
2063
|
+
try {
|
|
2064
|
+
if (!cursor) {
|
|
2065
|
+
const startStat = fs.statSync(directoryPath);
|
|
2066
|
+
const generation = Number(row.scan_generation) + 1;
|
|
2067
|
+
cursor = {
|
|
2068
|
+
directory: fs.opendirSync(directoryPath),
|
|
2069
|
+
startStat,
|
|
2070
|
+
generation,
|
|
2071
|
+
};
|
|
2072
|
+
directoryCursors.set(cursorKey, cursor);
|
|
2073
|
+
database.prepare(
|
|
2074
|
+
'UPDATE discovery_directories '
|
|
2075
|
+
+ 'SET scan_generation = ?, last_checked_ms = ? WHERE path = ?',
|
|
2076
|
+
).run(generation, nowMs, directoryPath);
|
|
2077
|
+
}
|
|
2078
|
+
} catch {
|
|
2079
|
+
markDirectoryTreeMissing(database, directoryPath, nowMs, cacheFile);
|
|
2080
|
+
return;
|
|
2081
|
+
}
|
|
2082
|
+
|
|
2083
|
+
const entries: fs.Dirent[] = [];
|
|
2084
|
+
let complete = false;
|
|
2085
|
+
try {
|
|
2086
|
+
while (entries.length < remainingEntryBudget && Date.now() < deadline) {
|
|
2087
|
+
const entry = cursor.directory.readSync();
|
|
2088
|
+
if (!entry) {
|
|
2089
|
+
complete = true;
|
|
2090
|
+
break;
|
|
2091
|
+
}
|
|
2092
|
+
entries.push(entry);
|
|
2093
|
+
}
|
|
2094
|
+
} catch {
|
|
2095
|
+
closeDirectoryCursor(cacheFile, directoryPath);
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
|
|
2099
|
+
database.exec('BEGIN IMMEDIATE');
|
|
2100
|
+
try {
|
|
2101
|
+
for (const entry of entries) {
|
|
2102
|
+
const child = path.join(directoryPath, entry.name);
|
|
2103
|
+
if (entry.isDirectory()) {
|
|
2104
|
+
let childStat: fs.Stats;
|
|
2105
|
+
try {
|
|
2106
|
+
childStat = fs.statSync(child);
|
|
2107
|
+
} catch {
|
|
2108
|
+
continue;
|
|
2109
|
+
}
|
|
2110
|
+
database.prepare(`
|
|
2111
|
+
INSERT INTO discovery_directories(
|
|
2112
|
+
path, provider, root_path, parent_path, is_root, present, pending,
|
|
2113
|
+
scan_generation, parent_generation, mtime_ms, file_dev, file_ino,
|
|
2114
|
+
last_checked_ms
|
|
2115
|
+
) VALUES(?, ?, ?, ?, 0, 1, 1, 0, ?, ?, ?, ?, 0)
|
|
2116
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
2117
|
+
present = 1,
|
|
2118
|
+
parent_generation = excluded.parent_generation,
|
|
2119
|
+
pending = CASE
|
|
2120
|
+
WHEN discovery_directories.file_dev != excluded.file_dev
|
|
2121
|
+
OR discovery_directories.file_ino != excluded.file_ino
|
|
2122
|
+
OR discovery_directories.mtime_ms != excluded.mtime_ms
|
|
2123
|
+
THEN 1 ELSE discovery_directories.pending
|
|
2124
|
+
END,
|
|
2125
|
+
mtime_ms = excluded.mtime_ms,
|
|
2126
|
+
file_dev = excluded.file_dev,
|
|
2127
|
+
file_ino = excluded.file_ino
|
|
2128
|
+
`).run(
|
|
2129
|
+
child,
|
|
2130
|
+
provider,
|
|
2131
|
+
String(row.root_path),
|
|
2132
|
+
directoryPath,
|
|
2133
|
+
cursor.generation,
|
|
2134
|
+
childStat.mtimeMs,
|
|
2135
|
+
Number(childStat.dev),
|
|
2136
|
+
Number(childStat.ino),
|
|
2137
|
+
);
|
|
2138
|
+
} else if (entry.isFile() && entry.name.endsWith('.jsonl')) {
|
|
2139
|
+
try {
|
|
2140
|
+
const stat = fs.statSync(child);
|
|
2141
|
+
const cached = sourceRow(database, child);
|
|
2142
|
+
if (cached) {
|
|
2143
|
+
database.prepare(
|
|
2144
|
+
'UPDATE source_files SET directory_generation = ? WHERE path = ?',
|
|
2145
|
+
).run(cursor.generation, child);
|
|
2146
|
+
}
|
|
2147
|
+
if (
|
|
2148
|
+
!cached
|
|
2149
|
+
|| Number(cached.size) !== stat.size
|
|
2150
|
+
|| Number(cached.mtime_ms) !== stat.mtimeMs
|
|
2151
|
+
|| Number(cached.file_dev) !== Number(stat.dev)
|
|
2152
|
+
|| Number(cached.file_ino) !== Number(stat.ino)
|
|
2153
|
+
|| Number(cached.scan_complete) !== 1
|
|
2154
|
+
) {
|
|
2155
|
+
queueSource(database, provider, child, 'upsert', stat.mtimeMs, nowMs);
|
|
2156
|
+
} else {
|
|
2157
|
+
database.prepare(
|
|
2158
|
+
'UPDATE source_files SET last_checked_ms = ? WHERE path = ?',
|
|
2159
|
+
).run(nowMs, child);
|
|
2160
|
+
metrics.reused_files = Number(metrics.reused_files) + 1;
|
|
2161
|
+
}
|
|
2162
|
+
} catch {
|
|
2163
|
+
// A later directory pass or source audit retries transient entries.
|
|
2164
|
+
}
|
|
2165
|
+
}
|
|
2166
|
+
}
|
|
2167
|
+
|
|
2168
|
+
if (complete) {
|
|
2169
|
+
const endStat = fs.statSync(directoryPath);
|
|
2170
|
+
const stable = Number(cursor.startStat.dev) === Number(endStat.dev)
|
|
2171
|
+
&& Number(cursor.startStat.ino) === Number(endStat.ino)
|
|
2172
|
+
&& cursor.startStat.mtimeMs === endStat.mtimeMs;
|
|
2173
|
+
if (stable) {
|
|
2174
|
+
const missingChildren = database.prepare(`
|
|
2175
|
+
SELECT path FROM discovery_directories
|
|
2176
|
+
WHERE parent_path = ? AND present = 1 AND parent_generation < ?
|
|
2177
|
+
`).all(directoryPath, cursor.generation) as Array<{ path: string }>;
|
|
2178
|
+
for (const child of missingChildren) {
|
|
2179
|
+
markDirectoryTreeMissing(database, child.path, nowMs, cacheFile);
|
|
2180
|
+
}
|
|
2181
|
+
database.prepare(`
|
|
2182
|
+
INSERT INTO discovery_source_queue(path, provider, action, priority, queued_at_ms)
|
|
2183
|
+
SELECT path, provider, 'remove', -1, ?
|
|
2184
|
+
FROM source_files
|
|
2185
|
+
WHERE directory_path = ? AND directory_generation < ?
|
|
2186
|
+
ON CONFLICT(path) DO UPDATE SET
|
|
2187
|
+
action = 'remove',
|
|
2188
|
+
priority = -1,
|
|
2189
|
+
queued_at_ms = excluded.queued_at_ms
|
|
2190
|
+
`).run(nowMs, directoryPath, cursor.generation);
|
|
2191
|
+
}
|
|
2192
|
+
database.prepare(`
|
|
2193
|
+
UPDATE discovery_directories SET
|
|
2194
|
+
present = 1, pending = ?, mtime_ms = ?, file_dev = ?, file_ino = ?,
|
|
2195
|
+
last_checked_ms = ?
|
|
2196
|
+
WHERE path = ?
|
|
2197
|
+
`).run(
|
|
2198
|
+
stable ? 0 : 1,
|
|
2199
|
+
endStat.mtimeMs,
|
|
2200
|
+
Number(endStat.dev),
|
|
2201
|
+
Number(endStat.ino),
|
|
2202
|
+
nowMs,
|
|
2203
|
+
directoryPath,
|
|
2204
|
+
);
|
|
2205
|
+
}
|
|
2206
|
+
database.exec('COMMIT');
|
|
2207
|
+
} catch (error) {
|
|
2208
|
+
database.exec('ROLLBACK');
|
|
2209
|
+
closeDirectoryCursor(cacheFile, directoryPath);
|
|
2210
|
+
if (
|
|
2211
|
+
error
|
|
2212
|
+
&& typeof error === 'object'
|
|
2213
|
+
&& 'code' in error
|
|
2214
|
+
&& error.code === 'ENOENT'
|
|
2215
|
+
) {
|
|
2216
|
+
markDirectoryTreeMissing(database, directoryPath, nowMs, cacheFile);
|
|
2217
|
+
return;
|
|
2218
|
+
}
|
|
2219
|
+
throw error;
|
|
2220
|
+
} finally {
|
|
2221
|
+
if (complete) closeDirectoryCursor(cacheFile, directoryPath);
|
|
2222
|
+
}
|
|
2223
|
+
metrics.enumerated_entries = Number(metrics.enumerated_entries) + entries.length;
|
|
2224
|
+
if (complete) {
|
|
2225
|
+
metrics.scanned_directories = Number(metrics.scanned_directories) + 1;
|
|
2226
|
+
if (
|
|
2227
|
+
Number(metrics.enumerated_entries) < DIRECTORY_ENTRY_BATCH
|
|
2228
|
+
&& Date.now() < deadline
|
|
2229
|
+
) {
|
|
2230
|
+
scanPendingDirectories(database, nowMs, deadline, metrics, cacheFile);
|
|
2231
|
+
}
|
|
2232
|
+
}
|
|
2233
|
+
}
|
|
2234
|
+
|
|
2235
|
+
function auditSources(
|
|
2236
|
+
database: DatabaseSync,
|
|
2237
|
+
nowMs: number,
|
|
2238
|
+
deadline: number,
|
|
2239
|
+
metrics: Metrics,
|
|
2240
|
+
): void {
|
|
2241
|
+
const rows = database.prepare(`
|
|
2242
|
+
SELECT * FROM source_files
|
|
2243
|
+
ORDER BY last_checked_ms ASC
|
|
2244
|
+
LIMIT ?
|
|
2245
|
+
`).all(SOURCE_AUDIT_BATCH) as Array<Record<string, unknown>>;
|
|
2246
|
+
const recent = database.prepare(`
|
|
2247
|
+
SELECT * FROM source_files
|
|
2248
|
+
ORDER BY mtime_ms DESC
|
|
2249
|
+
LIMIT ?
|
|
2250
|
+
`).all(Math.min(32, SOURCE_AUDIT_BATCH)) as Array<Record<string, unknown>>;
|
|
2251
|
+
const byPath = new Map([...recent, ...rows].map(row => [String(row.path), row]));
|
|
2252
|
+
for (const row of byPath.values()) {
|
|
2253
|
+
if (Date.now() >= deadline) break;
|
|
2254
|
+
const filePath = String(row.path);
|
|
2255
|
+
try {
|
|
2256
|
+
const stat = fs.statSync(filePath);
|
|
2257
|
+
const changed = stat.size !== Number(row.size)
|
|
2258
|
+
|| stat.mtimeMs !== Number(row.mtime_ms)
|
|
2259
|
+
|| Number(stat.dev) !== Number(row.file_dev)
|
|
2260
|
+
|| Number(stat.ino) !== Number(row.file_ino)
|
|
2261
|
+
|| Number(row.scan_complete) !== 1;
|
|
2262
|
+
if (changed) {
|
|
2263
|
+
queueSource(
|
|
2264
|
+
database,
|
|
2265
|
+
String(row.provider) as Provider,
|
|
2266
|
+
filePath,
|
|
2267
|
+
'upsert',
|
|
2268
|
+
stat.mtimeMs,
|
|
2269
|
+
nowMs,
|
|
2270
|
+
);
|
|
2271
|
+
} else {
|
|
2272
|
+
database.prepare(
|
|
2273
|
+
'UPDATE source_files SET last_checked_ms = ? WHERE path = ?',
|
|
2274
|
+
).run(nowMs, filePath);
|
|
2275
|
+
metrics.reused_files = Number(metrics.reused_files) + 1;
|
|
2276
|
+
}
|
|
2277
|
+
} catch {
|
|
2278
|
+
queueSource(
|
|
2279
|
+
database,
|
|
2280
|
+
String(row.provider) as Provider,
|
|
2281
|
+
filePath,
|
|
2282
|
+
'remove',
|
|
2283
|
+
-1,
|
|
2284
|
+
nowMs,
|
|
2285
|
+
);
|
|
2286
|
+
}
|
|
2287
|
+
metrics.audited_files = Number(metrics.audited_files) + 1;
|
|
2288
|
+
}
|
|
2289
|
+
}
|
|
2290
|
+
|
|
2291
|
+
function candidateFromPath(
|
|
2292
|
+
database: DatabaseSync,
|
|
2293
|
+
provider: Provider,
|
|
2294
|
+
filePath: string,
|
|
2295
|
+
): SourceCandidate {
|
|
2296
|
+
const stat = fs.statSync(filePath);
|
|
2297
|
+
const cached = sourceRow(database, filePath);
|
|
2298
|
+
if (provider === 'codex') {
|
|
2299
|
+
let probe: ReturnType<typeof probeCodex>;
|
|
2300
|
+
if (cached?.provider === 'codex') {
|
|
2301
|
+
try {
|
|
2302
|
+
const state = JSON.parse(String(cached.parser_state)) as CodexState;
|
|
2303
|
+
probe = {
|
|
2304
|
+
sessionId: state.sessionId,
|
|
2305
|
+
parentSessionId: state.parentSessionId,
|
|
2306
|
+
forkTimestamp: state.forkTimestamp,
|
|
2307
|
+
copiedPrefix: state.copiedPrefix,
|
|
2308
|
+
};
|
|
2309
|
+
} catch {
|
|
2310
|
+
probe = probeCodex(filePath);
|
|
2311
|
+
}
|
|
2312
|
+
} else {
|
|
2313
|
+
probe = probeCodex(filePath);
|
|
2314
|
+
}
|
|
2315
|
+
return { provider, filePath, stat, ...probe, parentBaseline: null };
|
|
2316
|
+
}
|
|
2317
|
+
return {
|
|
2318
|
+
provider,
|
|
2319
|
+
filePath,
|
|
2320
|
+
stat,
|
|
2321
|
+
sessionId: claudeOwnerSession(filePath),
|
|
2322
|
+
parentSessionId: null,
|
|
2323
|
+
forkTimestamp: null,
|
|
2324
|
+
copiedPrefix: false,
|
|
2325
|
+
parentBaseline: null,
|
|
2326
|
+
};
|
|
2327
|
+
}
|
|
2328
|
+
|
|
2329
|
+
function moveSource(
|
|
2330
|
+
database: DatabaseSync,
|
|
2331
|
+
row: Record<string, unknown>,
|
|
2332
|
+
candidate: SourceCandidate,
|
|
2333
|
+
): void {
|
|
2334
|
+
const oldPath = String(row.path);
|
|
2335
|
+
database.prepare(`
|
|
2336
|
+
INSERT INTO source_files(
|
|
2337
|
+
path, provider, directory_path, directory_generation,
|
|
2338
|
+
size, mtime_ms, committed_offset, file_dev, file_ino,
|
|
2339
|
+
prefix_sha256, suffix_sha256, parser_state, session_id, scan_complete,
|
|
2340
|
+
last_checked_ms
|
|
2341
|
+
) VALUES(?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
2342
|
+
`).run(
|
|
2343
|
+
candidate.filePath,
|
|
2344
|
+
candidate.provider,
|
|
2345
|
+
path.dirname(candidate.filePath),
|
|
2346
|
+
directoryGeneration(database, path.dirname(candidate.filePath)),
|
|
2347
|
+
candidate.stat.size,
|
|
2348
|
+
candidate.stat.mtimeMs,
|
|
2349
|
+
Number(row.committed_offset),
|
|
2350
|
+
Number(candidate.stat.dev),
|
|
2351
|
+
Number(candidate.stat.ino),
|
|
2352
|
+
String(row.prefix_sha256),
|
|
2353
|
+
suffixHash(candidate.filePath, candidate.stat.size),
|
|
2354
|
+
String(row.parser_state),
|
|
2355
|
+
String(row.session_id),
|
|
2356
|
+
candidate.stat.size === Number(row.size) && Number(row.scan_complete) === 1 ? 1 : 0,
|
|
2357
|
+
Date.now(),
|
|
2358
|
+
);
|
|
2359
|
+
database.prepare(
|
|
2360
|
+
'UPDATE usage_hourly SET source_path = ? WHERE source_path = ?',
|
|
2361
|
+
).run(candidate.filePath, oldPath);
|
|
2362
|
+
database.prepare(
|
|
2363
|
+
'UPDATE recent_events SET source_path = ? WHERE source_path = ?',
|
|
2364
|
+
).run(candidate.filePath, oldPath);
|
|
2365
|
+
database.prepare(
|
|
2366
|
+
'UPDATE claude_messages SET source_path = ? WHERE source_path = ?',
|
|
2367
|
+
).run(candidate.filePath, oldPath);
|
|
2368
|
+
database.prepare(
|
|
2369
|
+
'UPDATE codex_quota_candidates SET source_path = ? WHERE source_path = ?',
|
|
2370
|
+
).run(candidate.filePath, oldPath);
|
|
2371
|
+
database.prepare('DELETE FROM source_files WHERE path = ?').run(oldPath);
|
|
2372
|
+
}
|
|
2373
|
+
|
|
2374
|
+
function prepareCodexCandidate(
|
|
2375
|
+
database: DatabaseSync,
|
|
2376
|
+
candidate: SourceCandidate,
|
|
2377
|
+
metrics: Metrics,
|
|
2378
|
+
): boolean {
|
|
2379
|
+
if (sourceRow(database, candidate.filePath)) return true;
|
|
2380
|
+
const existing = database.prepare(
|
|
2381
|
+
"SELECT * FROM source_files WHERE provider = 'codex' AND session_id = ? AND path != ? "
|
|
2382
|
+
+ 'ORDER BY mtime_ms DESC LIMIT 1',
|
|
2383
|
+
).get(candidate.sessionId, candidate.filePath) as Record<string, unknown> | undefined;
|
|
2384
|
+
if (!existing) return true;
|
|
2385
|
+
const sameIdentity = Number(existing.file_dev) === Number(candidate.stat.dev)
|
|
2386
|
+
&& Number(existing.file_ino) === Number(candidate.stat.ino);
|
|
2387
|
+
if (sameIdentity && !fs.existsSync(String(existing.path))) {
|
|
2388
|
+
moveSource(database, existing, candidate);
|
|
2389
|
+
metrics.moved_files = Number(metrics.moved_files) + 1;
|
|
2390
|
+
return true;
|
|
2391
|
+
}
|
|
2392
|
+
const existingArchived = String(existing.path).includes(`${path.sep}archived_sessions${path.sep}`);
|
|
2393
|
+
const candidateArchived = candidate.filePath.includes(`${path.sep}archived_sessions${path.sep}`);
|
|
2394
|
+
const candidateWins = (existingArchived && !candidateArchived)
|
|
2395
|
+
|| (existingArchived === candidateArchived
|
|
2396
|
+
&& candidate.stat.mtimeMs > Number(existing.mtime_ms));
|
|
2397
|
+
if (!candidateWins) return false;
|
|
2398
|
+
deleteSource(database, String(existing.path));
|
|
2399
|
+
return true;
|
|
2400
|
+
}
|
|
2401
|
+
|
|
2402
|
+
function resolveParentBaselineFromDatabase(
|
|
2403
|
+
database: DatabaseSync,
|
|
2404
|
+
candidate: SourceCandidate,
|
|
2405
|
+
cache: Map<string, Totals | null>,
|
|
2406
|
+
metrics: Metrics,
|
|
2407
|
+
): Totals | null {
|
|
2408
|
+
if (!candidate.parentSessionId || !candidate.forkTimestamp) return null;
|
|
2409
|
+
const key = `${candidate.parentSessionId}:${candidate.forkTimestamp}`;
|
|
2410
|
+
if (cache.has(key)) return cloneTotals(cache.get(key));
|
|
2411
|
+
const row = database.prepare(
|
|
2412
|
+
"SELECT path FROM source_files WHERE provider = 'codex' AND session_id = ? LIMIT 1",
|
|
2413
|
+
).get(candidate.parentSessionId) as { path?: string } | undefined;
|
|
2414
|
+
const baseline = row?.path && row.path !== candidate.filePath
|
|
2415
|
+
? codexRawTotalsAt(row.path, candidate.forkTimestamp, metrics)
|
|
2416
|
+
: null;
|
|
2417
|
+
cache.set(key, cloneTotals(baseline));
|
|
2418
|
+
return baseline;
|
|
2419
|
+
}
|
|
2420
|
+
|
|
2421
|
+
function processSourceQueue(
|
|
2422
|
+
database: DatabaseSync,
|
|
2423
|
+
recentBoundary: number,
|
|
2424
|
+
deadline: number,
|
|
2425
|
+
metrics: Metrics,
|
|
2426
|
+
): void {
|
|
2427
|
+
const parentCache = new Map<string, Totals | null>();
|
|
2428
|
+
while (Date.now() < deadline) {
|
|
2429
|
+
const task = (database.prepare(`
|
|
2430
|
+
SELECT * FROM discovery_source_queue
|
|
2431
|
+
WHERE action = 'upsert'
|
|
2432
|
+
ORDER BY priority ASC, queued_at_ms ASC
|
|
2433
|
+
LIMIT 1
|
|
2434
|
+
`).get() || database.prepare(`
|
|
2435
|
+
SELECT * FROM discovery_source_queue
|
|
2436
|
+
WHERE action = 'remove'
|
|
2437
|
+
ORDER BY priority ASC, queued_at_ms ASC
|
|
2438
|
+
LIMIT 1
|
|
2439
|
+
`).get()) as Record<string, unknown> | undefined;
|
|
2440
|
+
if (!task) return;
|
|
2441
|
+
const filePath = String(task.path);
|
|
2442
|
+
if (String(task.action) === 'remove') {
|
|
2443
|
+
database.exec('BEGIN IMMEDIATE');
|
|
2444
|
+
try {
|
|
2445
|
+
database.prepare(
|
|
2446
|
+
'DELETE FROM discovery_source_queue WHERE path = ?',
|
|
2447
|
+
).run(filePath);
|
|
2448
|
+
try {
|
|
2449
|
+
const stat = fs.statSync(filePath);
|
|
2450
|
+
queueSource(
|
|
2451
|
+
database,
|
|
2452
|
+
String(task.provider) as Provider,
|
|
2453
|
+
filePath,
|
|
2454
|
+
'upsert',
|
|
2455
|
+
stat.mtimeMs,
|
|
2456
|
+
Date.now(),
|
|
2457
|
+
);
|
|
2458
|
+
} catch (error) {
|
|
2459
|
+
if (
|
|
2460
|
+
!error
|
|
2461
|
+
|| typeof error !== 'object'
|
|
2462
|
+
|| !('code' in error)
|
|
2463
|
+
|| error.code !== 'ENOENT'
|
|
2464
|
+
) {
|
|
2465
|
+
throw error;
|
|
2466
|
+
}
|
|
2467
|
+
deleteSource(database, filePath);
|
|
2468
|
+
metrics.removed_files = Number(metrics.removed_files) + 1;
|
|
2469
|
+
}
|
|
2470
|
+
database.exec('COMMIT');
|
|
2471
|
+
} catch (error) {
|
|
2472
|
+
database.exec('ROLLBACK');
|
|
2473
|
+
throw error;
|
|
2474
|
+
}
|
|
2475
|
+
continue;
|
|
2476
|
+
}
|
|
2477
|
+
try {
|
|
2478
|
+
const candidate = candidateFromPath(
|
|
2479
|
+
database,
|
|
2480
|
+
String(task.provider) as Provider,
|
|
2481
|
+
filePath,
|
|
2482
|
+
);
|
|
2483
|
+
database.exec('BEGIN IMMEDIATE');
|
|
2484
|
+
let complete = false;
|
|
2485
|
+
try {
|
|
2486
|
+
if (candidate.provider === 'codex') {
|
|
2487
|
+
if (!prepareCodexCandidate(database, candidate, metrics)) {
|
|
2488
|
+
database.prepare('DELETE FROM discovery_source_queue WHERE path = ?').run(filePath);
|
|
2489
|
+
database.exec('COMMIT');
|
|
2490
|
+
continue;
|
|
2491
|
+
}
|
|
2492
|
+
if (
|
|
2493
|
+
!sourceRow(database, filePath)
|
|
2494
|
+
&& candidate.parentSessionId
|
|
2495
|
+
&& !database.prepare(
|
|
2496
|
+
"SELECT 1 FROM source_files WHERE provider = 'codex' AND session_id = ? LIMIT 1",
|
|
2497
|
+
).get(candidate.parentSessionId)
|
|
2498
|
+
) {
|
|
2499
|
+
const otherUpsert = database.prepare(
|
|
2500
|
+
"SELECT 1 FROM discovery_source_queue "
|
|
2501
|
+
+ "WHERE action = 'upsert' AND path != ? LIMIT 1",
|
|
2502
|
+
).get(filePath);
|
|
2503
|
+
if (otherUpsert) {
|
|
2504
|
+
const row = database.prepare(
|
|
2505
|
+
"SELECT priority + 1 AS priority FROM discovery_source_queue "
|
|
2506
|
+
+ "WHERE action = 'upsert' ORDER BY priority DESC LIMIT 1",
|
|
2507
|
+
).get() as { priority: number };
|
|
2508
|
+
database.prepare(
|
|
2509
|
+
'UPDATE discovery_source_queue SET priority = ?, queued_at_ms = ? WHERE path = ?',
|
|
2510
|
+
).run(Number(row.priority), Date.now(), filePath);
|
|
2511
|
+
database.exec('COMMIT');
|
|
2512
|
+
continue;
|
|
2513
|
+
}
|
|
2514
|
+
}
|
|
2515
|
+
if (!sourceRow(database, filePath) && candidate.parentSessionId) {
|
|
2516
|
+
candidate.parentBaseline = resolveParentBaselineFromDatabase(
|
|
2517
|
+
database,
|
|
2518
|
+
candidate,
|
|
2519
|
+
parentCache,
|
|
2520
|
+
metrics,
|
|
2521
|
+
);
|
|
2522
|
+
}
|
|
2523
|
+
}
|
|
2524
|
+
complete = scanFile(database, candidate, recentBoundary, deadline, metrics);
|
|
2525
|
+
database.exec('COMMIT');
|
|
2526
|
+
} catch (error) {
|
|
2527
|
+
database.exec('ROLLBACK');
|
|
2528
|
+
throw error;
|
|
2529
|
+
}
|
|
2530
|
+
if (complete) {
|
|
2531
|
+
database.prepare('DELETE FROM discovery_source_queue WHERE path = ?').run(filePath);
|
|
2532
|
+
} else {
|
|
2533
|
+
return;
|
|
2534
|
+
}
|
|
2535
|
+
} catch (error) {
|
|
2536
|
+
if (
|
|
2537
|
+
error
|
|
2538
|
+
&& typeof error === 'object'
|
|
2539
|
+
&& 'code' in error
|
|
2540
|
+
&& error.code === 'ENOENT'
|
|
2541
|
+
) {
|
|
2542
|
+
database.exec('BEGIN IMMEDIATE');
|
|
2543
|
+
try {
|
|
2544
|
+
deleteSource(database, filePath);
|
|
2545
|
+
database.prepare(
|
|
2546
|
+
'DELETE FROM discovery_source_queue WHERE path = ?',
|
|
2547
|
+
).run(filePath);
|
|
2548
|
+
database.exec('COMMIT');
|
|
2549
|
+
} catch (cleanupError) {
|
|
2550
|
+
database.exec('ROLLBACK');
|
|
2551
|
+
throw cleanupError;
|
|
2552
|
+
}
|
|
2553
|
+
metrics.removed_files = Number(metrics.removed_files) + 1;
|
|
2554
|
+
continue;
|
|
2555
|
+
}
|
|
2556
|
+
metrics.errors = Number(metrics.errors) + 1;
|
|
2557
|
+
const errors = metrics.errors_by_provider as Record<Provider, number>;
|
|
2558
|
+
errors[String(task.provider) as Provider] += 1;
|
|
2559
|
+
return;
|
|
2560
|
+
}
|
|
2561
|
+
}
|
|
2562
|
+
}
|
|
2563
|
+
|
|
2564
|
+
export function collectUsage(request: CollectRequest): Record<string, unknown> {
|
|
2565
|
+
const cacheFile = path.resolve(request.cacheFile);
|
|
2566
|
+
const legacyCacheFile = request.legacyCacheFile
|
|
2567
|
+
? path.resolve(request.legacyCacheFile)
|
|
2568
|
+
: path.join(path.dirname(cacheFile), 'cc-statistics-usage-v1.sqlite3');
|
|
2569
|
+
if (legacyCacheFile !== cacheFile && fs.existsSync(legacyCacheFile)) {
|
|
2570
|
+
removeDatabaseFiles(legacyCacheFile);
|
|
2571
|
+
}
|
|
2572
|
+
const nowMs = request.nowMs || Date.now();
|
|
2573
|
+
const retentionDays = Math.max(1, request.retentionDays || DEFAULT_RETENTION_DAYS);
|
|
2574
|
+
const recentRawMs = Math.max(60_000, request.recentRawMs || DEFAULT_RECENT_RAW_MS);
|
|
2575
|
+
const scanBudgetMs = Math.max(100, request.scanBudgetMs || DEFAULT_SCAN_BUDGET_MS);
|
|
2576
|
+
const { database, rebuilt } = openDatabase(cacheFile);
|
|
2577
|
+
const deadline = Date.now() + scanBudgetMs;
|
|
2578
|
+
const metrics: Metrics = {
|
|
2579
|
+
discovered_files: 0,
|
|
2580
|
+
scanned_directories: 0,
|
|
2581
|
+
audited_directories: 0,
|
|
2582
|
+
enumerated_entries: 0,
|
|
2583
|
+
audited_files: 0,
|
|
2584
|
+
scanned_files: 0,
|
|
2585
|
+
partial_files: 0,
|
|
2586
|
+
reused_files: 0,
|
|
2587
|
+
appended_files: 0,
|
|
2588
|
+
rebuilt_files: 0,
|
|
2589
|
+
parsed_events: 0,
|
|
2590
|
+
bytes_read: 0,
|
|
2591
|
+
pruned_events: 0,
|
|
2592
|
+
removed_files: 0,
|
|
2593
|
+
moved_files: 0,
|
|
2594
|
+
errors: 0,
|
|
2595
|
+
errors_by_provider: { codex: 0, claude: 0 },
|
|
2596
|
+
cache_rebuilt: rebuilt,
|
|
2597
|
+
codex_cache_rebuilt: false,
|
|
2598
|
+
claude_cache_rebuilt: false,
|
|
2599
|
+
};
|
|
2600
|
+
let completed = false;
|
|
2601
|
+
|
|
2602
|
+
try {
|
|
2603
|
+
const retentionCutoff = nowMs - retentionDays * 24 * 60 * 60 * 1000;
|
|
2604
|
+
const recentCutoff = Math.max(retentionCutoff, nowMs - recentRawMs);
|
|
2605
|
+
const recentBoundary = localHourMs(recentCutoff);
|
|
2606
|
+
database.exec('BEGIN IMMEDIATE');
|
|
2607
|
+
try {
|
|
2608
|
+
ensureDiscoveryRoots(database, request.roots || {}, nowMs, cacheFile);
|
|
2609
|
+
auditDirectories(database, nowMs, deadline, metrics, cacheFile);
|
|
2610
|
+
database.exec('COMMIT');
|
|
2611
|
+
} catch (error) {
|
|
2612
|
+
database.exec('ROLLBACK');
|
|
2613
|
+
throw error;
|
|
2614
|
+
}
|
|
2615
|
+
scanPendingDirectories(database, nowMs, deadline, metrics, cacheFile);
|
|
2616
|
+
if (Date.now() < deadline) auditSources(database, nowMs, deadline, metrics);
|
|
2617
|
+
if (Date.now() < deadline) {
|
|
2618
|
+
processSourceQueue(database, recentBoundary, deadline, metrics);
|
|
2619
|
+
}
|
|
2620
|
+
|
|
2621
|
+
database.exec('BEGIN IMMEDIATE');
|
|
2622
|
+
try {
|
|
2623
|
+
let pruned = 0;
|
|
2624
|
+
pruned += Number(database.prepare(
|
|
2625
|
+
'DELETE FROM usage_hourly WHERE bucket_start_ms < ?',
|
|
2626
|
+
).run(localHourMs(retentionCutoff)).changes);
|
|
2627
|
+
database.prepare(
|
|
2628
|
+
'DELETE FROM usage_provider_hourly WHERE bucket_start_ms < ?',
|
|
2629
|
+
).run(localHourMs(retentionCutoff));
|
|
2630
|
+
pruned += Number(database.prepare(
|
|
2631
|
+
'DELETE FROM recent_events WHERE timestamp_ms < ?',
|
|
2632
|
+
).run(recentBoundary).changes);
|
|
2633
|
+
pruned += Number(database.prepare(
|
|
2634
|
+
'DELETE FROM claude_messages WHERE timestamp_ms < ?',
|
|
2635
|
+
).run(retentionCutoff).changes);
|
|
2636
|
+
metrics.pruned_events = pruned;
|
|
2637
|
+
database.exec('COMMIT');
|
|
2638
|
+
} catch (error) {
|
|
2639
|
+
database.exec('ROLLBACK');
|
|
2640
|
+
throw error;
|
|
2641
|
+
}
|
|
2642
|
+
|
|
2643
|
+
const stats = database.prepare(
|
|
2644
|
+
'SELECT * FROM cache_stats WHERE singleton = 1',
|
|
2645
|
+
).get() as Record<string, number>;
|
|
2646
|
+
const pendingDirectories = Number(stats.pending_directories);
|
|
2647
|
+
const pendingFiles = Number(stats.queued_sources);
|
|
2648
|
+
const incompleteFiles = Number(stats.codex_incomplete)
|
|
2649
|
+
+ Number(stats.claude_incomplete);
|
|
2650
|
+
const scanComplete = pendingDirectories === 0
|
|
2651
|
+
&& pendingFiles === 0
|
|
2652
|
+
&& incompleteFiles === 0
|
|
2653
|
+
&& Number(metrics.errors) === 0;
|
|
2654
|
+
if (scanComplete) setMetadataValue(database, 'discovery_ready', '1');
|
|
2655
|
+
const discoveryReady = metadataValue(database, 'discovery_ready') === '1';
|
|
2656
|
+
const discovered = {
|
|
2657
|
+
codex: Number(stats.codex_files),
|
|
2658
|
+
claude: Number(stats.claude_files),
|
|
2659
|
+
};
|
|
2660
|
+
metrics.discovered_files = discovered.codex + discovered.claude;
|
|
2661
|
+
metrics.scan_complete = scanComplete;
|
|
2662
|
+
metrics.pending_files = pendingFiles;
|
|
2663
|
+
metrics.pending_directories = pendingDirectories;
|
|
2664
|
+
metrics.discovery_ready = discoveryReady;
|
|
2665
|
+
metrics.hourly_rows = Number(stats.hourly_rows);
|
|
2666
|
+
metrics.recent_rows = Number(stats.recent_rows);
|
|
2667
|
+
metrics.committed_bytes = Number(stats.committed_bytes);
|
|
2668
|
+
metrics.dedupe_rows = Number(stats.dedupe_rows);
|
|
2669
|
+
metrics.codex_fingerprint_rows = 0;
|
|
2670
|
+
metrics.codex_session_rows = discovered.codex;
|
|
2671
|
+
metrics.codex_session_cache_rows = 0;
|
|
2672
|
+
const providerComplete = {
|
|
2673
|
+
codex: discoveryReady && Number(stats.codex_incomplete) === 0,
|
|
2674
|
+
claude: discoveryReady && Number(stats.claude_incomplete) === 0,
|
|
2675
|
+
};
|
|
2676
|
+
|
|
2677
|
+
const providers: Record<Provider, Record<string, unknown>> = {
|
|
2678
|
+
codex: {},
|
|
2679
|
+
claude: {},
|
|
2680
|
+
};
|
|
2681
|
+
for (const provider of ['codex', 'claude'] as const) {
|
|
2682
|
+
const providerErrors = (metrics.errors_by_provider as Record<Provider, number>)[provider];
|
|
2683
|
+
const available = discovered[provider] > 0 && providerErrors === 0;
|
|
2684
|
+
providers[provider] = {
|
|
2685
|
+
source: provider === 'codex'
|
|
2686
|
+
? 'Farming local history · CodexBar 0.45.2-derived'
|
|
2687
|
+
: 'Farming local history · cc-statistics 1.1.0-derived',
|
|
2688
|
+
events: !providerComplete[provider]
|
|
2689
|
+
? []
|
|
2690
|
+
: providerEvents(database, provider, retentionCutoff, recentBoundary),
|
|
2691
|
+
quotaCandidates: provider === 'codex' ? latestQuotas(database) : [],
|
|
2692
|
+
fileCount: discovered[provider],
|
|
2693
|
+
available,
|
|
2694
|
+
partial: !providerComplete[provider],
|
|
2695
|
+
...(!available
|
|
2696
|
+
? {
|
|
2697
|
+
reason: providerErrors
|
|
2698
|
+
? `${providerErrors} session file(s) could not be parsed.`
|
|
2699
|
+
: 'No local session files were found.',
|
|
2700
|
+
}
|
|
2701
|
+
: {}),
|
|
2702
|
+
};
|
|
2703
|
+
}
|
|
2704
|
+
const result = {
|
|
2705
|
+
schemaVersion: SCHEMA_VERSION,
|
|
2706
|
+
source: SOURCE_VERSION,
|
|
2707
|
+
sampledAt: nowMs,
|
|
2708
|
+
retentionDays,
|
|
2709
|
+
providers,
|
|
2710
|
+
cache: { ...metrics, path: cacheFile },
|
|
2711
|
+
};
|
|
2712
|
+
completed = true;
|
|
2713
|
+
return result;
|
|
2714
|
+
} finally {
|
|
2715
|
+
if (!completed) closeCacheDirectoryCursors(cacheFile);
|
|
2716
|
+
database.close();
|
|
2717
|
+
}
|
|
2718
|
+
}
|