@ashlr/hub 3.7.0 → 3.9.0
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/CHANGELOG.md +257 -0
- package/dist/build-identity.json +2 -2
- package/dist/cli/resource-profile.js +95 -21
- package/dist/cli/resource-profile.js.map +1 -1
- package/dist/core/policy/local-only.d.ts +142 -2
- package/dist/core/policy/local-only.js +204 -15
- package/dist/core/policy/local-only.js.map +1 -1
- package/dist/core/resources/native-profile.d.ts +56 -0
- package/dist/core/resources/native-profile.js +368 -6
- package/dist/core/resources/native-profile.js.map +1 -1
- package/dist/core/run/budget.d.ts +22 -8
- package/dist/core/run/budget.js +80 -12
- package/dist/core/run/budget.js.map +1 -1
- package/dist/core/run/model-catalog.js +8 -1
- package/dist/core/run/model-catalog.js.map +1 -1
- package/dist/core/run/sandboxed-engine.js +3 -3
- package/dist/core/run/sandboxed-engine.js.map +1 -1
- package/dist/core/universe/builtins/preparation/manifest.json +1 -1
- package/dist/core/universe/builtins/preparation/preparation-bridge.mjs +69 -10
- package/dist/core/universe/builtins/preparation/preparation-verification-fixtures.mjs +68 -9
- package/dist/core/verse/adapters/claude.d.ts +49 -2
- package/dist/core/verse/adapters/claude.js +317 -56
- package/dist/core/verse/adapters/claude.js.map +1 -1
- package/dist/core/verse/adapters/codex.d.ts +81 -5
- package/dist/core/verse/adapters/codex.js +409 -47
- package/dist/core/verse/adapters/codex.js.map +1 -1
- package/dist/core/verse/adapters/grok.d.ts +13 -1
- package/dist/core/verse/adapters/grok.js +28 -2
- package/dist/core/verse/adapters/grok.js.map +1 -1
- package/dist/core/verse/adapters/index.d.ts +32 -0
- package/dist/core/verse/adapters/index.js +2 -0
- package/dist/core/verse/adapters/index.js.map +1 -1
- package/dist/core/verse/codex-rollout.d.ts +284 -0
- package/dist/core/verse/codex-rollout.js +786 -0
- package/dist/core/verse/codex-rollout.js.map +1 -0
- package/dist/core/verse/context-fit.d.ts +63 -0
- package/dist/core/verse/context-fit.js +355 -0
- package/dist/core/verse/context-fit.js.map +1 -0
- package/dist/core/verse/context-math.d.ts +215 -0
- package/dist/core/verse/context-math.js +365 -0
- package/dist/core/verse/context-math.js.map +1 -0
- package/dist/core/verse/local-models.d.ts +137 -1
- package/dist/core/verse/local-models.js +209 -5
- package/dist/core/verse/local-models.js.map +1 -1
- package/dist/core/verse/model-windows.d.ts +217 -0
- package/dist/core/verse/model-windows.js +616 -0
- package/dist/core/verse/model-windows.js.map +1 -0
- package/dist/core/verse/preferences.d.ts +123 -0
- package/dist/core/verse/preferences.js +403 -0
- package/dist/core/verse/preferences.js.map +1 -0
- package/dist/core/verse/project-memory.d.ts +112 -0
- package/dist/core/verse/project-memory.js +295 -0
- package/dist/core/verse/project-memory.js.map +1 -0
- package/dist/core/verse/seats.d.ts +106 -4
- package/dist/core/verse/seats.js +291 -89
- package/dist/core/verse/seats.js.map +1 -1
- package/dist/core/verse/session-engine.d.ts +117 -2
- package/dist/core/verse/session-engine.js +763 -65
- package/dist/core/verse/session-engine.js.map +1 -1
- package/dist/core/verse/session-handoff.d.ts +94 -0
- package/dist/core/verse/session-handoff.js +551 -0
- package/dist/core/verse/session-handoff.js.map +1 -0
- package/dist/core/verse/session-search.d.ts +63 -0
- package/dist/core/verse/session-search.js +213 -0
- package/dist/core/verse/session-search.js.map +1 -0
- package/dist/core/verse/session-store.d.ts +16 -2
- package/dist/core/verse/session-store.js +80 -6
- package/dist/core/verse/session-store.js.map +1 -1
- package/dist/core/verse/types.d.ts +333 -1
- package/dist/core/verse/types.js +54 -2
- package/dist/core/verse/types.js.map +1 -1
- package/dist/core/verse/verse-api.d.ts +40 -5
- package/dist/core/verse/verse-api.js +724 -25
- package/dist/core/verse/verse-api.js.map +1 -1
- package/dist/core/web/api.d.ts +14 -4
- package/dist/core/web/api.js +26 -11
- package/dist/core/web/api.js.map +1 -1
- package/dist/core/web/public/next/assets/{App-vtaHB9mp.js → App-XURmiAAp.js} +5 -5
- package/dist/core/web/public/next/assets/{App-vtaHB9mp.js.map → App-XURmiAAp.js.map} +1 -1
- package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js → ApprovalsSection-BriUzoZc.js} +2 -2
- package/dist/core/web/public/next/assets/{ApprovalsSection-B-bFJ0I9.js.map → ApprovalsSection-BriUzoZc.js.map} +1 -1
- package/dist/core/web/public/next/assets/AutonomySection-C4GBsQVc.js +2 -0
- package/dist/core/web/public/next/assets/{AutonomySection-DoqmsCjw.js.map → AutonomySection-C4GBsQVc.js.map} +1 -1
- package/dist/core/web/public/next/assets/ChatSection-BpOBOUvg.css +1 -0
- package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js +84 -0
- package/dist/core/web/public/next/assets/ChatSection-hlgPBRco.js.map +1 -0
- package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js +2 -0
- package/dist/core/web/public/next/assets/ConfirmDialog-BdQYI-Ng.js.map +1 -0
- package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js → Dialog-BxdIfmFH.js} +2 -2
- package/dist/core/web/public/next/assets/{Dialog-CPM9JxBc.js.map → Dialog-BxdIfmFH.js.map} +1 -1
- package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js → DiffViewer-DmeRWuc4.js} +2 -2
- package/dist/core/web/public/next/assets/{DiffViewer-B84qVef9.js.map → DiffViewer-DmeRWuc4.js.map} +1 -1
- package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js → McpSection-q34T7Tho.js} +2 -2
- package/dist/core/web/public/next/assets/{McpSection-CEUpV_5X.js.map → McpSection-q34T7Tho.js.map} +1 -1
- package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js → MutationTokenDialog-CFAkwOEt.js} +2 -2
- package/dist/core/web/public/next/assets/{MutationTokenDialog-DA8t97XT.js.map → MutationTokenDialog-CFAkwOEt.js.map} +1 -1
- package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js → ResourcePoolConsoleApp-DHc8GFOY.js} +4 -4
- package/dist/core/web/public/next/assets/{ResourcePoolConsoleApp-Bq7Vyud0.js.map → ResourcePoolConsoleApp-DHc8GFOY.js.map} +1 -1
- package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js → RouteErrorBoundary-ZL9F4qUn.js} +2 -2
- package/dist/core/web/public/next/assets/{RouteErrorBoundary-Badio_uO.js.map → RouteErrorBoundary-ZL9F4qUn.js.map} +1 -1
- package/dist/core/web/public/next/assets/{Meter-CLcr-4wS.css → Segmented-8cf0Fypn.css} +1 -1
- package/dist/core/web/public/next/assets/{Meter--h3MmZS-.js → Segmented-DbRjVye3.js} +2 -2
- package/dist/core/web/public/next/assets/Segmented-DbRjVye3.js.map +1 -0
- package/dist/core/web/public/next/assets/SettingsSection-nJGGGYqv.js +2 -0
- package/dist/core/web/public/next/assets/{SettingsSection-BwrpQlpP.js.map → SettingsSection-nJGGGYqv.js.map} +1 -1
- package/dist/core/web/public/next/assets/UniverseConsoleApp-6GJydgo4.js +2 -0
- package/dist/core/web/public/next/assets/{UniverseConsoleApp-_FSohAdN.js.map → UniverseConsoleApp-6GJydgo4.js.map} +1 -1
- package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js → UniverseView-DLDtNYL8.js} +2 -2
- package/dist/core/web/public/next/assets/{UniverseView-DqcXbed4.js.map → UniverseView-DLDtNYL8.js.map} +1 -1
- package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js +2 -0
- package/dist/core/web/public/next/assets/UsageSection-Cf9d9iFH.js.map +1 -0
- package/dist/core/web/public/next/assets/VerseConsoleApp-DsdKQoTH.css +1 -0
- package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js +3 -0
- package/dist/core/web/public/next/assets/VerseConsoleApp-y0uOHixd.js.map +1 -0
- package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.js → charts-CVooqJWN.js} +2 -2
- package/dist/core/web/public/next/assets/{charts-CTZJ-JrH.js.map → charts-CVooqJWN.js.map} +1 -1
- package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js +2 -0
- package/dist/core/web/public/next/assets/context-model-Bqv8DVuH.js.map +1 -0
- package/dist/core/web/public/next/assets/global-eaY9GyX3.js +2 -0
- package/dist/core/web/public/next/assets/global-eaY9GyX3.js.map +1 -0
- package/dist/core/web/public/next/assets/{icons-D9vlf85u.js → icons-vex8rclC.js} +2 -2
- package/dist/core/web/public/next/assets/{icons-D9vlf85u.js.map → icons-vex8rclC.js.map} +1 -1
- package/dist/core/web/public/next/assets/{index-CZamKRUg.js → index-D2Eik-zt.js} +3 -3
- package/dist/core/web/public/next/assets/{index-CZamKRUg.js.map → index-D2Eik-zt.js.map} +1 -1
- package/dist/core/web/public/next/assets/{queries-B8eBnFox.js → queries-bAYU0VNy.js} +2 -2
- package/dist/core/web/public/next/assets/{queries-B8eBnFox.js.map → queries-bAYU0VNy.js.map} +1 -1
- package/dist/core/web/public/next/assets/use-guarded-action-C7QrP7Yp.js +2 -0
- package/dist/core/web/public/next/assets/{use-guarded-action-bzrVVAYq.js.map → use-guarded-action-C7QrP7Yp.js.map} +1 -1
- package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js +2 -0
- package/dist/core/web/public/next/assets/verse-model-TfDk9cky.js.map +1 -0
- package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js → workspace-model-CHol5RVp.js} +2 -2
- package/dist/core/web/public/next/assets/{workspace-model-v0a1gl-C.js.map → workspace-model-CHol5RVp.js.map} +1 -1
- package/dist/core/web/public/next/index.html +1 -1
- package/dist/release-dependency-inventory.json +1 -1
- package/docs/README.md +1 -0
- package/package.json +1 -1
- package/dist/core/web/public/next/assets/AutonomySection-DoqmsCjw.js +0 -2
- package/dist/core/web/public/next/assets/ChatSection-BfnLe5dT.css +0 -1
- package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js +0 -80
- package/dist/core/web/public/next/assets/ChatSection-vNPIJffd.js.map +0 -1
- package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js +0 -2
- package/dist/core/web/public/next/assets/ConfirmDialog-Dd2APQhB.js.map +0 -1
- package/dist/core/web/public/next/assets/Meter--h3MmZS-.js.map +0 -1
- package/dist/core/web/public/next/assets/SettingsSection-BwrpQlpP.js +0 -2
- package/dist/core/web/public/next/assets/UniverseConsoleApp-_FSohAdN.js +0 -2
- package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js +0 -2
- package/dist/core/web/public/next/assets/UsageSection-Bq6Daaby.js.map +0 -1
- package/dist/core/web/public/next/assets/VerseConsoleApp-CMkCizvL.css +0 -1
- package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js +0 -3
- package/dist/core/web/public/next/assets/VerseConsoleApp-CsIQv9id.js.map +0 -1
- package/dist/core/web/public/next/assets/global-D6bTxIRn.js +0 -2
- package/dist/core/web/public/next/assets/global-D6bTxIRn.js.map +0 -1
- package/dist/core/web/public/next/assets/use-guarded-action-bzrVVAYq.js +0 -2
- package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js +0 -2
- package/dist/core/web/public/next/assets/verse-model-CvtK6vP6.js.map +0 -1
|
@@ -0,0 +1,786 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Codex rollout reader — the ONLY place Verse learns a codex turn's exact
|
|
3
|
+
* context occupancy, its window, its compactions and its true per-turn usage.
|
|
4
|
+
*
|
|
5
|
+
* WHY THIS EXISTS. `codex exec --json` never prints per-call usage or the
|
|
6
|
+
* window: its `turn.completed.usage` is a SUM over every model call in the
|
|
7
|
+
* turn (a 20-call turn reports ~20× the live prompt), and codex seeds that sum
|
|
8
|
+
* from the rollout when a thread is resumed, so on `exec resume` it is the
|
|
9
|
+
* THREAD's running total rather than the turn's. The CLI's own session file
|
|
10
|
+
* does carry the truth, one `token_count` per model call:
|
|
11
|
+
*
|
|
12
|
+
* <CODEX_HOME>/sessions/YYYY/MM/DD/rollout-<local ts>-<thread id>.jsonl
|
|
13
|
+
* {"timestamp":…,"type":"event_msg","payload":{"type":"token_count","info":{
|
|
14
|
+
* "total_token_usage":{…cumulative…},"last_token_usage":{…this call…},
|
|
15
|
+
* "model_context_window":258400}}}
|
|
16
|
+
* {"timestamp":…,"type":"event_msg","payload":{"type":"task_started","model_context_window":258400}}
|
|
17
|
+
* {"timestamp":…,"type":"compacted","payload":{…replacement history, often MEGABYTES…}}
|
|
18
|
+
* {"timestamp":…,"type":"token_usage_record","payload":{"thread_id":…,"turn_token_usage":{…}}} (0.149+)
|
|
19
|
+
*
|
|
20
|
+
* (Shapes verified against 4,001 rollouts on this machine, CLI 0.136 – 0.155.)
|
|
21
|
+
*
|
|
22
|
+
* WHAT "OCCUPANCY" MEANS HERE. `last_token_usage.total_tokens` — the last
|
|
23
|
+
* call's prompt plus its reply. That is the quantity codex itself compares to
|
|
24
|
+
* its auto-compaction limit (no uncompacted call ever exceeded 244,800 = 90% of
|
|
25
|
+
* the raw 272k window, across 62,104 calls), and it is what the NEXT call's
|
|
26
|
+
* prompt starts from, so the meter and the compaction tick share one unit.
|
|
27
|
+
* Right after a compaction codex writes a `token_count` whose cumulative total
|
|
28
|
+
* is unchanged but whose `last_token_usage` is the post-compaction size — so a
|
|
29
|
+
* reading is taken from EVERY token_count, while usage is only summed for
|
|
30
|
+
* records whose cumulative total moved (a real model call).
|
|
31
|
+
*
|
|
32
|
+
* BOUNDS. A `compacted` line is routinely 2–17 MB (it embeds the replacement
|
|
33
|
+
* history), so nothing here ever holds a whole line in memory: lines are
|
|
34
|
+
* scanned in chunks and only their first CODEX_ROLLOUT_LINE_HEAD_BYTES are
|
|
35
|
+
* kept. The small records (token_count ≤ 1 KB) are parsed whole; an oversized
|
|
36
|
+
* line is classified from its head alone. First reads look at most
|
|
37
|
+
* CODEX_ROLLOUT_TAIL_BYTES back from EOF (doubling to a hard cap only when the
|
|
38
|
+
* turn's start is not in that window); incremental reads scan at most
|
|
39
|
+
* CODEX_ROLLOUT_MAX_SCAN_BYTES. A missing, pruned, rewritten or unreadable file
|
|
40
|
+
* yields null / "no reading" — nothing here throws.
|
|
41
|
+
*
|
|
42
|
+
* PRIVACY. Paths computed here name native-profile directories (account
|
|
43
|
+
* identity). They are used to read files and are never returned in events,
|
|
44
|
+
* logged, or persisted.
|
|
45
|
+
*/
|
|
46
|
+
import { closeSync, fstatSync, lstatSync, openSync, readdirSync, readFileSync, readSync } from 'node:fs';
|
|
47
|
+
import { basename, dirname, isAbsolute, join, normalize } from 'node:path';
|
|
48
|
+
// ---------------------------------------------------------------------------
|
|
49
|
+
// Bounds
|
|
50
|
+
// ---------------------------------------------------------------------------
|
|
51
|
+
/** First read of a rollout: at most this much from EOF (spec: ≤ 2 MB). */
|
|
52
|
+
export const CODEX_ROLLOUT_TAIL_BYTES = 2 * 1024 * 1024;
|
|
53
|
+
/**
|
|
54
|
+
* When the turn's first record is not inside the first tail window (a turn
|
|
55
|
+
* that wrote megabytes before the first poll — i.e. an early compaction), the
|
|
56
|
+
* window doubles up to this cap. Past it the turn is accounted as partial.
|
|
57
|
+
*/
|
|
58
|
+
export const CODEX_ROLLOUT_MAX_TAIL_BYTES = 16 * 1024 * 1024;
|
|
59
|
+
/** One incremental read scans at most this much; beyond it the reader jumps to the tail and records a gap. */
|
|
60
|
+
export const CODEX_ROLLOUT_MAX_SCAN_BYTES = 32 * 1024 * 1024;
|
|
61
|
+
/** Bytes of each line retained for classification. Every record we parse whole is far smaller. */
|
|
62
|
+
export const CODEX_ROLLOUT_LINE_HEAD_BYTES = 64 * 1024;
|
|
63
|
+
const READ_CHUNK_BYTES = 256 * 1024;
|
|
64
|
+
/** Day directories a fallback thread search may open (≈ two years of daily use). */
|
|
65
|
+
const MAX_DAY_DIRS = 750;
|
|
66
|
+
/** profile.json is a few hundred bytes; anything bigger is not a profile. */
|
|
67
|
+
const MAX_PROFILE_BYTES = 64 * 1024;
|
|
68
|
+
const MAX_CACHE_ENTRIES = 128;
|
|
69
|
+
const NEWLINE = 0x0a;
|
|
70
|
+
function isObject(value) {
|
|
71
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
72
|
+
}
|
|
73
|
+
function count(value) {
|
|
74
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
75
|
+
}
|
|
76
|
+
function positiveIntOrNull(value) {
|
|
77
|
+
return typeof value === 'number' && Number.isFinite(value) && value > 0 ? Math.floor(value) : null;
|
|
78
|
+
}
|
|
79
|
+
export const ZERO_CODEX_TOTALS = Object.freeze({
|
|
80
|
+
inputTokens: 0,
|
|
81
|
+
cachedInputTokens: 0,
|
|
82
|
+
cacheWriteInputTokens: 0,
|
|
83
|
+
outputTokens: 0,
|
|
84
|
+
reasoningOutputTokens: 0,
|
|
85
|
+
totalTokens: 0,
|
|
86
|
+
});
|
|
87
|
+
/** A codex usage block → totals, or null when it is not one. `total_tokens` falls back to input + output. */
|
|
88
|
+
export function codexTotals(value) {
|
|
89
|
+
if (!isObject(value))
|
|
90
|
+
return null;
|
|
91
|
+
const hasAny = ['input_tokens', 'output_tokens', 'total_tokens'].some((key) => typeof value[key] === 'number');
|
|
92
|
+
if (!hasAny)
|
|
93
|
+
return null;
|
|
94
|
+
const inputTokens = count(value['input_tokens']);
|
|
95
|
+
const outputTokens = count(value['output_tokens']);
|
|
96
|
+
const reported = count(value['total_tokens']);
|
|
97
|
+
return {
|
|
98
|
+
inputTokens,
|
|
99
|
+
cachedInputTokens: count(value['cached_input_tokens']),
|
|
100
|
+
cacheWriteInputTokens: count(value['cache_write_input_tokens']),
|
|
101
|
+
outputTokens,
|
|
102
|
+
reasoningOutputTokens: count(value['reasoning_output_tokens']),
|
|
103
|
+
totalTokens: reported > 0 ? reported : inputTokens + outputTokens,
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
export function addCodexTotals(a, b) {
|
|
107
|
+
return {
|
|
108
|
+
inputTokens: a.inputTokens + b.inputTokens,
|
|
109
|
+
cachedInputTokens: a.cachedInputTokens + b.cachedInputTokens,
|
|
110
|
+
cacheWriteInputTokens: a.cacheWriteInputTokens + b.cacheWriteInputTokens,
|
|
111
|
+
outputTokens: a.outputTokens + b.outputTokens,
|
|
112
|
+
reasoningOutputTokens: a.reasoningOutputTokens + b.reasoningOutputTokens,
|
|
113
|
+
totalTokens: a.totalTokens + b.totalTokens,
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
/** a − b, each bucket clamped at 0 (a counter that went backwards is a reset, never negative usage). */
|
|
117
|
+
export function subtractCodexTotals(a, b) {
|
|
118
|
+
return {
|
|
119
|
+
inputTokens: Math.max(0, a.inputTokens - b.inputTokens),
|
|
120
|
+
cachedInputTokens: Math.max(0, a.cachedInputTokens - b.cachedInputTokens),
|
|
121
|
+
cacheWriteInputTokens: Math.max(0, a.cacheWriteInputTokens - b.cacheWriteInputTokens),
|
|
122
|
+
outputTokens: Math.max(0, a.outputTokens - b.outputTokens),
|
|
123
|
+
reasoningOutputTokens: Math.max(0, a.reasoningOutputTokens - b.reasoningOutputTokens),
|
|
124
|
+
totalTokens: Math.max(0, a.totalTokens - b.totalTokens),
|
|
125
|
+
};
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* The live occupancy one `token_count` describes: its last call's
|
|
129
|
+
* `total_tokens` (prompt + reply), the figure codex compacts against.
|
|
130
|
+
*/
|
|
131
|
+
export function codexOccupancy(last) {
|
|
132
|
+
return last.totalTokens > 0 ? last.totalTokens : last.inputTokens + last.outputTokens;
|
|
133
|
+
}
|
|
134
|
+
function timestampMs(value) {
|
|
135
|
+
if (typeof value !== 'string' || value.length > 64)
|
|
136
|
+
return null;
|
|
137
|
+
const ms = Date.parse(value);
|
|
138
|
+
return Number.isFinite(ms) ? ms : null;
|
|
139
|
+
}
|
|
140
|
+
/** Classify one COMPLETE, small line. Unknown or malformed lines → null. */
|
|
141
|
+
function classifyParsed(obj, offset) {
|
|
142
|
+
const at = timestampMs(obj['timestamp']);
|
|
143
|
+
const type = obj['type'];
|
|
144
|
+
const payload = isObject(obj['payload']) ? obj['payload'] : null;
|
|
145
|
+
if (type === 'compacted')
|
|
146
|
+
return { kind: 'compacted', offset, at };
|
|
147
|
+
if (!payload)
|
|
148
|
+
return null;
|
|
149
|
+
if (type === 'event_msg') {
|
|
150
|
+
const inner = payload['type'];
|
|
151
|
+
if (inner === 'token_count') {
|
|
152
|
+
// `info` is null on a rate-limit-only refresh: no reading in it.
|
|
153
|
+
const info = isObject(payload['info']) ? payload['info'] : null;
|
|
154
|
+
if (!info)
|
|
155
|
+
return null;
|
|
156
|
+
const total = codexTotals(info['total_token_usage']);
|
|
157
|
+
const last = codexTotals(info['last_token_usage']);
|
|
158
|
+
if (!total || !last)
|
|
159
|
+
return null;
|
|
160
|
+
return { kind: 'token-count', offset, at, total, last, modelContextWindow: positiveIntOrNull(info['model_context_window']) };
|
|
161
|
+
}
|
|
162
|
+
if (inner === 'task_started') {
|
|
163
|
+
return { kind: 'task-started', offset, at, modelContextWindow: positiveIntOrNull(payload['model_context_window']) };
|
|
164
|
+
}
|
|
165
|
+
return null;
|
|
166
|
+
}
|
|
167
|
+
if (type === 'token_usage_record') {
|
|
168
|
+
const threadId = typeof payload['thread_id'] === 'string' ? payload['thread_id'] : null;
|
|
169
|
+
return { kind: 'token-usage-record', offset, at, threadId, turn: codexTotals(payload['turn_token_usage']) };
|
|
170
|
+
}
|
|
171
|
+
return null;
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* An oversized line cannot be parsed, but every rollout line starts
|
|
175
|
+
* `{"timestamp":"…","type":"…",…` — so a `compacted` record (the only big line
|
|
176
|
+
* this reader cares about) is recognisable from its first bytes. The type is
|
|
177
|
+
* only trusted when it appears BEFORE `"payload"`, i.e. at the top level.
|
|
178
|
+
*/
|
|
179
|
+
function classifyHead(head, offset) {
|
|
180
|
+
const payloadAt = head.indexOf('"payload"');
|
|
181
|
+
const prefix = payloadAt === -1 ? head.slice(0, 512) : head.slice(0, payloadAt);
|
|
182
|
+
if (!/"type"\s*:\s*"compacted"/.test(prefix))
|
|
183
|
+
return null;
|
|
184
|
+
const ts = /"timestamp"\s*:\s*"([^"]{1,64})"/.exec(prefix);
|
|
185
|
+
return { kind: 'compacted', offset, at: ts ? timestampMs(ts[1]) : null };
|
|
186
|
+
}
|
|
187
|
+
function classifyLine(head, offset, length) {
|
|
188
|
+
if (length <= head.length) {
|
|
189
|
+
const text = head.toString('utf8').trim();
|
|
190
|
+
if (!text.startsWith('{'))
|
|
191
|
+
return null;
|
|
192
|
+
let parsed;
|
|
193
|
+
try {
|
|
194
|
+
parsed = JSON.parse(text);
|
|
195
|
+
}
|
|
196
|
+
catch {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
return isObject(parsed) ? classifyParsed(parsed, offset) : null;
|
|
200
|
+
}
|
|
201
|
+
return classifyHead(head.toString('utf8'), offset);
|
|
202
|
+
}
|
|
203
|
+
/**
|
|
204
|
+
* Scan complete lines in [from, to), keeping only each line's head. When
|
|
205
|
+
* `skipFirstPartial`, bytes up to and including the first newline belong to a
|
|
206
|
+
* line that started before `from` and are discarded.
|
|
207
|
+
*/
|
|
208
|
+
function scanLines(fd, from, to, budget, skipFirstPartial, onLine) {
|
|
209
|
+
const chunk = Buffer.allocUnsafe(READ_CHUNK_BYTES);
|
|
210
|
+
const headParts = [];
|
|
211
|
+
let headBytes = 0;
|
|
212
|
+
let lineStart = from;
|
|
213
|
+
let skipping = skipFirstPartial;
|
|
214
|
+
let pos = from;
|
|
215
|
+
let scanned = 0;
|
|
216
|
+
const keep = (slice) => {
|
|
217
|
+
if (skipping || headBytes >= CODEX_ROLLOUT_LINE_HEAD_BYTES || slice.length === 0)
|
|
218
|
+
return;
|
|
219
|
+
const take = slice.subarray(0, CODEX_ROLLOUT_LINE_HEAD_BYTES - headBytes);
|
|
220
|
+
headParts.push(Buffer.from(take));
|
|
221
|
+
headBytes += take.length;
|
|
222
|
+
};
|
|
223
|
+
while (pos < to) {
|
|
224
|
+
if (scanned >= budget)
|
|
225
|
+
return { end: skipping ? pos : lineStart, pos, exhausted: true };
|
|
226
|
+
const want = Math.min(READ_CHUNK_BYTES, to - pos);
|
|
227
|
+
let n;
|
|
228
|
+
try {
|
|
229
|
+
n = readSync(fd, chunk, 0, want, pos);
|
|
230
|
+
}
|
|
231
|
+
catch {
|
|
232
|
+
break;
|
|
233
|
+
}
|
|
234
|
+
if (n <= 0)
|
|
235
|
+
break;
|
|
236
|
+
let cursor = 0;
|
|
237
|
+
while (cursor < n) {
|
|
238
|
+
const nl = chunk.indexOf(NEWLINE, cursor);
|
|
239
|
+
if (nl === -1 || nl >= n) {
|
|
240
|
+
keep(chunk.subarray(cursor, n));
|
|
241
|
+
cursor = n;
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
keep(chunk.subarray(cursor, nl));
|
|
245
|
+
const lineEnd = pos + nl;
|
|
246
|
+
if (!skipping) {
|
|
247
|
+
const length = lineEnd - lineStart;
|
|
248
|
+
if (length > 0)
|
|
249
|
+
onLine(Buffer.concat(headParts, headBytes), lineStart, length);
|
|
250
|
+
}
|
|
251
|
+
skipping = false;
|
|
252
|
+
headParts.length = 0;
|
|
253
|
+
headBytes = 0;
|
|
254
|
+
lineStart = lineEnd + 1;
|
|
255
|
+
cursor = nl + 1;
|
|
256
|
+
}
|
|
257
|
+
pos += n;
|
|
258
|
+
scanned += n;
|
|
259
|
+
}
|
|
260
|
+
return { end: skipping ? pos : lineStart, pos, exhausted: false };
|
|
261
|
+
}
|
|
262
|
+
function byteBefore(fd, offset) {
|
|
263
|
+
if (offset <= 0)
|
|
264
|
+
return null;
|
|
265
|
+
const one = Buffer.allocUnsafe(1);
|
|
266
|
+
try {
|
|
267
|
+
return readSync(fd, one, 0, 1, offset - 1) === 1 ? one[0] : null;
|
|
268
|
+
}
|
|
269
|
+
catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
}
|
|
273
|
+
/** Open a rollout for reading: a regular, non-symlinked file, or null. */
|
|
274
|
+
function openRollout(file) {
|
|
275
|
+
try {
|
|
276
|
+
const link = lstatSync(file);
|
|
277
|
+
if (!link.isFile() || link.isSymbolicLink())
|
|
278
|
+
return null;
|
|
279
|
+
}
|
|
280
|
+
catch {
|
|
281
|
+
return null;
|
|
282
|
+
}
|
|
283
|
+
let fd;
|
|
284
|
+
try {
|
|
285
|
+
fd = openSync(file, 'r');
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
return null;
|
|
289
|
+
}
|
|
290
|
+
try {
|
|
291
|
+
const stat = fstatSync(fd);
|
|
292
|
+
if (!stat.isFile() || !Number.isSafeInteger(stat.size) || stat.size < 0) {
|
|
293
|
+
closeSync(fd);
|
|
294
|
+
return null;
|
|
295
|
+
}
|
|
296
|
+
return { fd, size: stat.size, ino: stat.ino };
|
|
297
|
+
}
|
|
298
|
+
catch {
|
|
299
|
+
try {
|
|
300
|
+
closeSync(fd);
|
|
301
|
+
}
|
|
302
|
+
catch { /* already closed */ }
|
|
303
|
+
return null;
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
function readRange(fd, size, from, budget, midLine) {
|
|
307
|
+
const start = Math.max(0, Math.min(from, size));
|
|
308
|
+
const records = [];
|
|
309
|
+
// A start that is not a line boundary cuts the line it lands in: drop it.
|
|
310
|
+
const cut = midLine || (start > 0 && byteBefore(fd, start) !== NEWLINE);
|
|
311
|
+
const scan = scanLines(fd, start, size, budget, cut, (head, offset, length) => {
|
|
312
|
+
const record = classifyLine(head, offset, length);
|
|
313
|
+
if (record)
|
|
314
|
+
records.push(record);
|
|
315
|
+
});
|
|
316
|
+
return { size, start, end: scan.end, records, skipped: scan.exhausted };
|
|
317
|
+
}
|
|
318
|
+
/**
|
|
319
|
+
* Complete records from the last `maxBytes` of a rollout (never more than
|
|
320
|
+
* CODEX_ROLLOUT_MAX_TAIL_BYTES). A line cut by the window start is dropped.
|
|
321
|
+
* Null when the file is missing or unreadable.
|
|
322
|
+
*/
|
|
323
|
+
export function readCodexRolloutTail(file, maxBytes = CODEX_ROLLOUT_TAIL_BYTES) {
|
|
324
|
+
const opened = openRollout(file);
|
|
325
|
+
if (!opened)
|
|
326
|
+
return null;
|
|
327
|
+
try {
|
|
328
|
+
const window = Math.max(1, Math.min(Math.floor(maxBytes), CODEX_ROLLOUT_MAX_TAIL_BYTES));
|
|
329
|
+
return readRange(opened.fd, opened.size, Math.max(0, opened.size - window), window, false);
|
|
330
|
+
}
|
|
331
|
+
finally {
|
|
332
|
+
try {
|
|
333
|
+
closeSync(opened.fd);
|
|
334
|
+
}
|
|
335
|
+
catch { /* already closed */ }
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
/**
|
|
339
|
+
* Complete records from byte `from` to EOF, scanning at most `maxScanBytes`.
|
|
340
|
+
* Null when the file is missing, unreadable, or SHORTER than `from` (rewritten
|
|
341
|
+
* or truncated — the caller must start over rather than trust its offset).
|
|
342
|
+
*/
|
|
343
|
+
export function readCodexRolloutFrom(file, from, maxScanBytes = CODEX_ROLLOUT_MAX_SCAN_BYTES) {
|
|
344
|
+
const opened = openRollout(file);
|
|
345
|
+
if (!opened)
|
|
346
|
+
return null;
|
|
347
|
+
try {
|
|
348
|
+
if (!Number.isSafeInteger(from) || from < 0 || from > opened.size)
|
|
349
|
+
return null;
|
|
350
|
+
return readRange(opened.fd, opened.size, from, Math.max(1, maxScanBytes), false);
|
|
351
|
+
}
|
|
352
|
+
finally {
|
|
353
|
+
try {
|
|
354
|
+
closeSync(opened.fd);
|
|
355
|
+
}
|
|
356
|
+
catch { /* already closed */ }
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
/**
|
|
360
|
+
* One-shot extraction from a rollout's tail (or from `afterOffset`, when
|
|
361
|
+
* given). Missing or pruned file → null; never throws.
|
|
362
|
+
*/
|
|
363
|
+
export function summarizeCodexRollout(file, opts = {}) {
|
|
364
|
+
const read = opts.afterOffset !== undefined
|
|
365
|
+
? readCodexRolloutFrom(file, opts.afterOffset, opts.maxBytes ?? CODEX_ROLLOUT_MAX_SCAN_BYTES)
|
|
366
|
+
: readCodexRolloutTail(file, opts.maxBytes ?? CODEX_ROLLOUT_TAIL_BYTES);
|
|
367
|
+
if (!read)
|
|
368
|
+
return null;
|
|
369
|
+
let lastTokenCount = null;
|
|
370
|
+
let taskStartedWindow = null;
|
|
371
|
+
let compactions = 0;
|
|
372
|
+
const after = opts.afterOffset ?? 0;
|
|
373
|
+
for (const record of read.records) {
|
|
374
|
+
if (record.kind === 'token-count') {
|
|
375
|
+
lastTokenCount = {
|
|
376
|
+
lastTotalTokens: codexOccupancy(record.last),
|
|
377
|
+
lastInputTokens: record.last.inputTokens,
|
|
378
|
+
modelContextWindow: record.modelContextWindow,
|
|
379
|
+
total: record.total,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
else if (record.kind === 'task-started') {
|
|
383
|
+
taskStartedWindow = record.modelContextWindow ?? taskStartedWindow;
|
|
384
|
+
}
|
|
385
|
+
else if (record.kind === 'compacted' && record.offset >= after) {
|
|
386
|
+
compactions += 1;
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
return { lastTokenCount, taskStartedWindow, compactions, size: read.size, end: read.end };
|
|
390
|
+
}
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
// Locating a seat's rollouts
|
|
393
|
+
// ---------------------------------------------------------------------------
|
|
394
|
+
function boundedSet(map, key, value) {
|
|
395
|
+
if (map.size >= MAX_CACHE_ENTRIES && !map.has(key)) {
|
|
396
|
+
const oldest = map.keys().next().value;
|
|
397
|
+
if (oldest !== undefined)
|
|
398
|
+
map.delete(oldest);
|
|
399
|
+
}
|
|
400
|
+
map.set(key, value);
|
|
401
|
+
}
|
|
402
|
+
const nativeStateCache = new Map();
|
|
403
|
+
function isCleanAbsolute(value) {
|
|
404
|
+
return typeof value === 'string' && value.length > 1 && value.length <= 4096 && isAbsolute(value) &&
|
|
405
|
+
normalize(value) === value && !value.includes('\0');
|
|
406
|
+
}
|
|
407
|
+
/**
|
|
408
|
+
* The CODEX_HOME a seat's launcher pins, derived from the launcher argv the
|
|
409
|
+
* session was created with — the same `<profile>/launcher.mjs` ⇒
|
|
410
|
+
* `<profile>/native-state` relation `accounts.ts` (codexSessionRoots) and
|
|
411
|
+
* `mcp-seat-view.ts` use, preferring the profile's own `profile.json`
|
|
412
|
+
* `nativeStatePath` when it is readable.
|
|
413
|
+
*
|
|
414
|
+
* Null for a launcher-less seat: Verse never reads the unpinned global
|
|
415
|
+
* `~/.codex`, whose rollouts belong to other tools and other accounts.
|
|
416
|
+
*/
|
|
417
|
+
export function codexNativeStatePath(launcher) {
|
|
418
|
+
if (!Array.isArray(launcher))
|
|
419
|
+
return null;
|
|
420
|
+
const launcherPath = [...launcher].reverse().find((part) => isCleanAbsolute(part) && basename(part) === 'launcher.mjs');
|
|
421
|
+
if (!launcherPath)
|
|
422
|
+
return null;
|
|
423
|
+
const cached = nativeStateCache.get(launcherPath);
|
|
424
|
+
if (cached)
|
|
425
|
+
return cached;
|
|
426
|
+
const profileDir = dirname(launcherPath);
|
|
427
|
+
let resolved = join(profileDir, 'native-state');
|
|
428
|
+
try {
|
|
429
|
+
const manifestPath = join(profileDir, 'profile.json');
|
|
430
|
+
const stat = lstatSync(manifestPath);
|
|
431
|
+
if (stat.isFile() && !stat.isSymbolicLink() && stat.size <= MAX_PROFILE_BYTES) {
|
|
432
|
+
const manifest = JSON.parse(readFileSync(manifestPath, 'utf8'));
|
|
433
|
+
if (isObject(manifest)) {
|
|
434
|
+
const provider = manifest['provider'];
|
|
435
|
+
if (provider !== undefined && provider !== 'codex')
|
|
436
|
+
return null;
|
|
437
|
+
if (isCleanAbsolute(manifest['nativeStatePath']))
|
|
438
|
+
resolved = manifest['nativeStatePath'];
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
}
|
|
442
|
+
catch {
|
|
443
|
+
// No manifest (or an unreadable one): the launcher's sibling is the pinned home.
|
|
444
|
+
}
|
|
445
|
+
boundedSet(nativeStateCache, launcherPath, resolved);
|
|
446
|
+
return resolved;
|
|
447
|
+
}
|
|
448
|
+
/**
|
|
449
|
+
* Codex thread ids are UUIDs (v7 on every CLI this was verified against). The
|
|
450
|
+
* id is interpolated into a filename match, so anything else is refused.
|
|
451
|
+
*/
|
|
452
|
+
const THREAD_ID_RE = /^[0-9A-Fa-f]{8}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{4}-[0-9A-Fa-f]{12}$/;
|
|
453
|
+
export function isCodexThreadId(value) {
|
|
454
|
+
return typeof value === 'string' && THREAD_ID_RE.test(value);
|
|
455
|
+
}
|
|
456
|
+
/** Creation time embedded in a UUIDv7 (first 48 bits, epoch ms), else null. */
|
|
457
|
+
export function uuidV7Millis(id) {
|
|
458
|
+
if (!isCodexThreadId(id) || id[14] !== '7')
|
|
459
|
+
return null;
|
|
460
|
+
const ms = Number.parseInt(id.slice(0, 8) + id.slice(9, 13), 16);
|
|
461
|
+
// Sanity window: 2020 … 2100. Anything outside is not a timestamp.
|
|
462
|
+
return Number.isSafeInteger(ms) && ms > 1_577_836_800_000 && ms < 4_102_444_800_000 ? ms : null;
|
|
463
|
+
}
|
|
464
|
+
function pad2(n) {
|
|
465
|
+
return n < 10 ? `0${n}` : String(n);
|
|
466
|
+
}
|
|
467
|
+
/** `YYYY/MM/DD` in LOCAL time — codex names both the directory and the file from local time. */
|
|
468
|
+
function localDayPath(ms) {
|
|
469
|
+
const d = new Date(ms);
|
|
470
|
+
return [String(d.getFullYear()), pad2(d.getMonth() + 1), pad2(d.getDate())];
|
|
471
|
+
}
|
|
472
|
+
function safeReaddir(dir) {
|
|
473
|
+
try {
|
|
474
|
+
return readdirSync(dir);
|
|
475
|
+
}
|
|
476
|
+
catch {
|
|
477
|
+
return [];
|
|
478
|
+
}
|
|
479
|
+
}
|
|
480
|
+
function rolloutIn(dir, suffix) {
|
|
481
|
+
let best = null;
|
|
482
|
+
for (const name of safeReaddir(dir)) {
|
|
483
|
+
if (!name.startsWith('rollout-') || !name.endsWith(suffix))
|
|
484
|
+
continue;
|
|
485
|
+
const path = join(dir, name);
|
|
486
|
+
try {
|
|
487
|
+
const stat = lstatSync(path);
|
|
488
|
+
if (!stat.isFile() || stat.isSymbolicLink())
|
|
489
|
+
continue;
|
|
490
|
+
if (!best || stat.mtimeMs > best.mtime)
|
|
491
|
+
best = { path, mtime: stat.mtimeMs };
|
|
492
|
+
}
|
|
493
|
+
catch {
|
|
494
|
+
// Pruned between readdir and lstat.
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return best?.path ?? null;
|
|
498
|
+
}
|
|
499
|
+
const rolloutCache = new Map();
|
|
500
|
+
function stillRollout(path) {
|
|
501
|
+
try {
|
|
502
|
+
const stat = lstatSync(path);
|
|
503
|
+
return stat.isFile() && !stat.isSymbolicLink();
|
|
504
|
+
}
|
|
505
|
+
catch {
|
|
506
|
+
return false;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
/**
|
|
510
|
+
* Path of the rollout for `threadId` under `<nativeStatePath>/sessions`, or
|
|
511
|
+
* null when there is none (never run, pruned, `--ephemeral`, unreadable).
|
|
512
|
+
*
|
|
513
|
+
* Cheap path first: a UUIDv7 id carries its creation instant, so the file is
|
|
514
|
+
* in that LOCAL day's directory (±1 day for a timezone change or a thread
|
|
515
|
+
* created just before midnight). Otherwise a bounded newest-first descent.
|
|
516
|
+
* If several files match (never observed), the most recently written wins.
|
|
517
|
+
*/
|
|
518
|
+
export function locateCodexRollout(nativeStatePath, threadId, opts = {}) {
|
|
519
|
+
if (!isCleanAbsolute(nativeStatePath) || !isCodexThreadId(threadId))
|
|
520
|
+
return null;
|
|
521
|
+
const sessionsRoot = join(nativeStatePath, 'sessions');
|
|
522
|
+
const key = `${sessionsRoot}\0${threadId}`;
|
|
523
|
+
const cached = rolloutCache.get(key);
|
|
524
|
+
if (cached && stillRollout(cached))
|
|
525
|
+
return cached;
|
|
526
|
+
if (cached)
|
|
527
|
+
rolloutCache.delete(key);
|
|
528
|
+
const suffix = `-${threadId}.jsonl`;
|
|
529
|
+
const remember = (path) => {
|
|
530
|
+
if (path)
|
|
531
|
+
boundedSet(rolloutCache, key, path);
|
|
532
|
+
return path;
|
|
533
|
+
};
|
|
534
|
+
const created = uuidV7Millis(threadId);
|
|
535
|
+
if (created !== null) {
|
|
536
|
+
const day = 24 * 60 * 60 * 1000;
|
|
537
|
+
const tried = new Set();
|
|
538
|
+
for (const ms of [created, created - day, created + day]) {
|
|
539
|
+
const dir = join(sessionsRoot, ...localDayPath(ms));
|
|
540
|
+
if (tried.has(dir))
|
|
541
|
+
continue;
|
|
542
|
+
tried.add(dir);
|
|
543
|
+
const found = rolloutIn(dir, suffix);
|
|
544
|
+
if (found)
|
|
545
|
+
return remember(found);
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (opts.fullScan === false)
|
|
549
|
+
return null;
|
|
550
|
+
let dirsOpened = 0;
|
|
551
|
+
const years = safeReaddir(sessionsRoot).filter((n) => /^\d{4}$/.test(n)).sort().reverse();
|
|
552
|
+
for (const year of years) {
|
|
553
|
+
const months = safeReaddir(join(sessionsRoot, year)).filter((n) => /^\d{2}$/.test(n)).sort().reverse();
|
|
554
|
+
for (const month of months) {
|
|
555
|
+
const days = safeReaddir(join(sessionsRoot, year, month)).filter((n) => /^\d{2}$/.test(n)).sort().reverse();
|
|
556
|
+
for (const dayName of days) {
|
|
557
|
+
if (++dirsOpened > MAX_DAY_DIRS)
|
|
558
|
+
return null;
|
|
559
|
+
const found = rolloutIn(join(sessionsRoot, year, month, dayName), suffix);
|
|
560
|
+
if (found)
|
|
561
|
+
return remember(found);
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
return null;
|
|
566
|
+
}
|
|
567
|
+
/** Test seam: forget cached profile and rollout paths. */
|
|
568
|
+
export function resetCodexRolloutCaches() {
|
|
569
|
+
nativeStateCache.clear();
|
|
570
|
+
rolloutCache.clear();
|
|
571
|
+
}
|
|
572
|
+
export function createCodexTurnTracker() {
|
|
573
|
+
return {
|
|
574
|
+
file: null,
|
|
575
|
+
ino: null,
|
|
576
|
+
offset: 0,
|
|
577
|
+
midLine: false,
|
|
578
|
+
initialized: false,
|
|
579
|
+
regionFound: false,
|
|
580
|
+
gap: false,
|
|
581
|
+
baseline: null,
|
|
582
|
+
prevTotalTokens: null,
|
|
583
|
+
calls: 0,
|
|
584
|
+
callSum: { ...ZERO_CODEX_TOTALS },
|
|
585
|
+
firstCall: null,
|
|
586
|
+
lastTotals: null,
|
|
587
|
+
turnUsage: null,
|
|
588
|
+
reading: null,
|
|
589
|
+
taskStartedWindow: null,
|
|
590
|
+
compactions: [],
|
|
591
|
+
};
|
|
592
|
+
}
|
|
593
|
+
/** Reset everything a (re)discovery recomputes, keeping only the file identity. */
|
|
594
|
+
function resetAccounting(tracker) {
|
|
595
|
+
const fresh = createCodexTurnTracker();
|
|
596
|
+
Object.assign(tracker, { ...fresh, file: tracker.file, ino: tracker.ino });
|
|
597
|
+
}
|
|
598
|
+
function applyRecord(tracker, record, inTurn, threadId) {
|
|
599
|
+
switch (record.kind) {
|
|
600
|
+
case 'token-count': {
|
|
601
|
+
tracker.reading = {
|
|
602
|
+
tokens: codexOccupancy(record.last),
|
|
603
|
+
window: record.modelContextWindow ?? tracker.taskStartedWindow,
|
|
604
|
+
inTurn,
|
|
605
|
+
offset: record.offset,
|
|
606
|
+
};
|
|
607
|
+
if (!inTurn) {
|
|
608
|
+
tracker.baseline = record.total;
|
|
609
|
+
tracker.prevTotalTokens = record.total.totalTokens;
|
|
610
|
+
return;
|
|
611
|
+
}
|
|
612
|
+
const newCall = tracker.prevTotalTokens === null || record.total.totalTokens !== tracker.prevTotalTokens;
|
|
613
|
+
if (newCall) {
|
|
614
|
+
tracker.calls += 1;
|
|
615
|
+
tracker.callSum = addCodexTotals(tracker.callSum, record.last);
|
|
616
|
+
tracker.firstCall ??= { total: record.total, last: record.last };
|
|
617
|
+
}
|
|
618
|
+
tracker.lastTotals = record.total;
|
|
619
|
+
tracker.prevTotalTokens = record.total.totalTokens;
|
|
620
|
+
for (const compaction of tracker.compactions) {
|
|
621
|
+
if (compaction.postTokens === null)
|
|
622
|
+
compaction.postTokens = tracker.reading.tokens;
|
|
623
|
+
}
|
|
624
|
+
return;
|
|
625
|
+
}
|
|
626
|
+
case 'compacted':
|
|
627
|
+
if (inTurn)
|
|
628
|
+
tracker.compactions.push({ preTokens: tracker.reading?.tokens ?? null, postTokens: null });
|
|
629
|
+
return;
|
|
630
|
+
case 'task-started':
|
|
631
|
+
if (inTurn)
|
|
632
|
+
tracker.taskStartedWindow = record.modelContextWindow ?? tracker.taskStartedWindow;
|
|
633
|
+
return;
|
|
634
|
+
case 'token-usage-record':
|
|
635
|
+
if (inTurn && record.turn && (threadId === null || record.threadId === null || record.threadId === threadId)) {
|
|
636
|
+
tracker.turnUsage = record.turn;
|
|
637
|
+
}
|
|
638
|
+
return;
|
|
639
|
+
default:
|
|
640
|
+
return;
|
|
641
|
+
}
|
|
642
|
+
}
|
|
643
|
+
/**
|
|
644
|
+
* Turn-start discovery. Records written at or after `startedAtMs` (the spawn
|
|
645
|
+
* instant) belong to this turn: an `exec` process runs exactly one turn, and
|
|
646
|
+
* the previous turn's process had exited before this one was spawned.
|
|
647
|
+
*/
|
|
648
|
+
function discover(tracker, fd, size, startedAtMs, threadId) {
|
|
649
|
+
for (let window = CODEX_ROLLOUT_TAIL_BYTES;; window *= 2) {
|
|
650
|
+
const capped = Math.min(window, CODEX_ROLLOUT_MAX_TAIL_BYTES);
|
|
651
|
+
const read = readRange(fd, size, Math.max(0, size - capped), capped, false);
|
|
652
|
+
const atFileStart = read.start === 0;
|
|
653
|
+
let regionIndex = -1;
|
|
654
|
+
for (let i = read.records.length - 1; i >= 0; i--) {
|
|
655
|
+
const at = read.records[i].at;
|
|
656
|
+
if (at !== null && at < startedAtMs) {
|
|
657
|
+
regionIndex = i + 1;
|
|
658
|
+
break;
|
|
659
|
+
}
|
|
660
|
+
}
|
|
661
|
+
const regionFound = regionIndex !== -1 || atFileStart;
|
|
662
|
+
if (regionIndex === -1)
|
|
663
|
+
regionIndex = 0;
|
|
664
|
+
const baselineInWindow = read.records.slice(0, regionIndex).some((r) => r.kind === 'token-count');
|
|
665
|
+
const settled = (regionFound && (baselineInWindow || atFileStart)) || capped >= CODEX_ROLLOUT_MAX_TAIL_BYTES;
|
|
666
|
+
if (!settled)
|
|
667
|
+
continue;
|
|
668
|
+
resetAccounting(tracker);
|
|
669
|
+
tracker.initialized = true;
|
|
670
|
+
tracker.regionFound = regionFound;
|
|
671
|
+
tracker.gap = !regionFound || read.skipped;
|
|
672
|
+
// No token_count before this turn in a window that reaches the file start: the thread had no calls yet.
|
|
673
|
+
if (atFileStart) {
|
|
674
|
+
tracker.baseline = { ...ZERO_CODEX_TOTALS };
|
|
675
|
+
tracker.prevTotalTokens = 0;
|
|
676
|
+
}
|
|
677
|
+
read.records.forEach((record, index) => applyRecord(tracker, record, index >= regionIndex, threadId));
|
|
678
|
+
if (!regionFound)
|
|
679
|
+
tracker.baseline = null;
|
|
680
|
+
tracker.offset = read.end;
|
|
681
|
+
tracker.midLine = false;
|
|
682
|
+
return;
|
|
683
|
+
}
|
|
684
|
+
}
|
|
685
|
+
/**
|
|
686
|
+
* Bring a turn's tracker up to date with `file`. Returns false when the file
|
|
687
|
+
* cannot be read (the tracker keeps what it had). Bounded: one discovery of
|
|
688
|
+
* ≤ CODEX_ROLLOUT_MAX_TAIL_BYTES, then ≤ CODEX_ROLLOUT_MAX_SCAN_BYTES per call.
|
|
689
|
+
*/
|
|
690
|
+
export function advanceCodexTurnTracker(tracker, file, startedAtMs, threadId = null) {
|
|
691
|
+
const opened = openRollout(file);
|
|
692
|
+
if (!opened)
|
|
693
|
+
return false;
|
|
694
|
+
try {
|
|
695
|
+
const rewritten = tracker.file !== file || tracker.ino !== opened.ino || opened.size < tracker.offset;
|
|
696
|
+
if (!tracker.initialized || rewritten) {
|
|
697
|
+
tracker.file = file;
|
|
698
|
+
tracker.ino = opened.ino;
|
|
699
|
+
// A rewritten file (0.155 `migrate-rollouts`, a prune) invalidates every
|
|
700
|
+
// offset: rediscover from timestamps, which recomputes the turn exactly.
|
|
701
|
+
discover(tracker, opened.fd, opened.size, startedAtMs, threadId);
|
|
702
|
+
return true;
|
|
703
|
+
}
|
|
704
|
+
if (opened.size === tracker.offset)
|
|
705
|
+
return true;
|
|
706
|
+
let from = tracker.offset;
|
|
707
|
+
let midLine = tracker.midLine;
|
|
708
|
+
if (opened.size - from > CODEX_ROLLOUT_MAX_SCAN_BYTES) {
|
|
709
|
+
// Too much appeared at once: keep the newest tail, account the rest as a gap.
|
|
710
|
+
from = opened.size - CODEX_ROLLOUT_TAIL_BYTES;
|
|
711
|
+
midLine = byteBefore(opened.fd, from) !== NEWLINE;
|
|
712
|
+
tracker.gap = true;
|
|
713
|
+
}
|
|
714
|
+
const read = readRange(opened.fd, opened.size, from, CODEX_ROLLOUT_MAX_SCAN_BYTES, midLine);
|
|
715
|
+
for (const record of read.records)
|
|
716
|
+
applyRecord(tracker, record, true, threadId);
|
|
717
|
+
if (read.skipped && read.end <= from) {
|
|
718
|
+
// One line longer than the whole scan budget: step past what was read
|
|
719
|
+
// and drop the rest of that line next time.
|
|
720
|
+
tracker.offset = Math.min(opened.size, from + CODEX_ROLLOUT_MAX_SCAN_BYTES);
|
|
721
|
+
tracker.midLine = true;
|
|
722
|
+
tracker.gap = true;
|
|
723
|
+
}
|
|
724
|
+
else {
|
|
725
|
+
tracker.offset = read.end;
|
|
726
|
+
tracker.midLine = false;
|
|
727
|
+
}
|
|
728
|
+
return true;
|
|
729
|
+
}
|
|
730
|
+
finally {
|
|
731
|
+
try {
|
|
732
|
+
closeSync(opened.fd);
|
|
733
|
+
}
|
|
734
|
+
catch { /* already closed */ }
|
|
735
|
+
}
|
|
736
|
+
}
|
|
737
|
+
function sameUsage(a, b) {
|
|
738
|
+
return a.inputTokens === b.inputTokens && a.cachedInputTokens === b.cachedInputTokens && a.outputTokens === b.outputTokens;
|
|
739
|
+
}
|
|
740
|
+
/**
|
|
741
|
+
* This turn's true usage, from the best evidence available.
|
|
742
|
+
*
|
|
743
|
+
* WHY `turn.completed` IS NOT TRUSTED ON ITS OWN: codex prints its session's
|
|
744
|
+
* `total_token_usage` there, and on `exec resume` that session is seeded from
|
|
745
|
+
* the rollout — so turn 5 reports turns 1–5. Summing it per turn (which is
|
|
746
|
+
* what the engine does with `usage` events) would count turn 1 five times.
|
|
747
|
+
* The rollout's per-call records are the ground truth either way; the printed
|
|
748
|
+
* figure is used only when the rollout cannot answer, and even then a figure
|
|
749
|
+
* that equals the rollout's running total is converted to a delta.
|
|
750
|
+
*
|
|
751
|
+
* Verified on this machine's rollouts (221 turns with a CLI per-turn record):
|
|
752
|
+
* the per-call sum equals codex's own `turn_token_usage` exactly on every
|
|
753
|
+
* fully observed turn WITHOUT a compaction; with one, the record is larger by
|
|
754
|
+
* that compaction request's usage, which codex never writes as a
|
|
755
|
+
* `token_count` (its running total does not move either). The record is
|
|
756
|
+
* therefore preferred when present (0.149+); on older CLIs no codex surface —
|
|
757
|
+
* rollout or stdout — counts compaction requests, and neither does this.
|
|
758
|
+
*
|
|
759
|
+
* @param reported `turn.completed.usage` (null when the turn printed none)
|
|
760
|
+
* @param firstTurn true when this turn STARTED the thread — its printed
|
|
761
|
+
* figure is per-turn by construction
|
|
762
|
+
*/
|
|
763
|
+
export function codexTurnUsage(tracker, reported, firstTurn) {
|
|
764
|
+
if (tracker?.turnUsage)
|
|
765
|
+
return { totals: tracker.turnUsage, source: 'turn-record' };
|
|
766
|
+
if (tracker && tracker.calls > 0 && tracker.regionFound && !tracker.gap) {
|
|
767
|
+
return { totals: tracker.callSum, source: 'calls' };
|
|
768
|
+
}
|
|
769
|
+
if (reported) {
|
|
770
|
+
if (firstTurn || !tracker?.lastTotals || !sameUsage(reported, tracker.lastTotals)) {
|
|
771
|
+
return { totals: reported, source: 'reported' };
|
|
772
|
+
}
|
|
773
|
+
// The printed figure IS the running total.
|
|
774
|
+
if (tracker.baseline)
|
|
775
|
+
return { totals: subtractCodexTotals(reported, tracker.baseline), source: 'reported-delta' };
|
|
776
|
+
if (tracker.firstCall) {
|
|
777
|
+
const before = subtractCodexTotals(tracker.firstCall.total, tracker.firstCall.last);
|
|
778
|
+
return { totals: subtractCodexTotals(reported, before), source: 'calls-partial' };
|
|
779
|
+
}
|
|
780
|
+
return { totals: reported, source: 'reported' };
|
|
781
|
+
}
|
|
782
|
+
if (tracker && tracker.calls > 0)
|
|
783
|
+
return { totals: tracker.callSum, source: 'calls-partial' };
|
|
784
|
+
return null;
|
|
785
|
+
}
|
|
786
|
+
//# sourceMappingURL=codex-rollout.js.map
|