tledger 0.3.0 → 0.4.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.
@@ -0,0 +1,198 @@
1
+ # Durable ledger operations
2
+
3
+ Token Ledger keeps two local artifacts with different responsibilities:
4
+
5
+ - `token-ledger-ledger.sqlite` is the durable, deduplicated source of truth.
6
+ - `token-ledger-snapshot-v3.json.gz` is a bounded report cache that can be
7
+ regenerated from the ledger and currently available Codex sources.
8
+
9
+ The durable database, writer guard, WAL, journal, and shared-memory files live
10
+ only in the app-owned private `~/.token-ledger` directory. Snapshot exports may
11
+ be written elsewhere, but output selection never relocates SQLite state.
12
+
13
+ Deleting or replacing the report cache must not be treated as deleting ledger
14
+ history. Conversely, a successfully parsed report cache is not proof that its
15
+ revision matches the current ledger.
16
+
17
+ ## Health signals
18
+
19
+ Generated snapshots expose the durable schema version, revision, quota-identity
20
+ contract, hashed Codex home identity, retention horizons,
21
+ `legacySnapshotStatus`, and separate legacy-quota diagnostics under
22
+ `metadata.durableLedger`. Coverage also includes source-state counts,
23
+ `sourceIncomplete`, compacted and migrated bucket counts, and the same legacy
24
+ statuses.
25
+
26
+ The main legacy statuses are:
27
+
28
+ - `migrated`: the legacy v3 snapshot had a provable collection scope and a
29
+ matching hashed Codex-home identity, so its history was imported as
30
+ estimated compacted rows.
31
+ - `collection-scope-unverified`: the snapshot did not prove whether it included
32
+ archived sources or a time cutoff.
33
+ - `codex-home-unverified`: the snapshot did not carry a verifiable hashed Codex
34
+ home identity.
35
+ - `codex-home-mismatch`: the snapshot belonged to a different Codex home.
36
+
37
+ The last three statuses mean legacy history was deliberately excluded. Exact
38
+ rollout collection still proceeds, and PNG reports show `LEGACY HISTORY
39
+ SKIPPED` so the omission is not silent.
40
+
41
+ Legacy quota rows have a narrower gate than usage history. They migrate only
42
+ when the source snapshot explicitly declares the current quota-identity
43
+ contract. `legacyQuotaStatus` reports `migrated`, `not-present`,
44
+ `skipped-contract-unverified`, `skipped-contract-mismatch`, or
45
+ `skipped-invalid`. A whole-snapshot provenance-gate failure instead reports
46
+ `skipped-<legacySnapshotStatus>`, such as
47
+ `skipped-collection-scope-unverified`, `skipped-codex-home-unverified`, or
48
+ `skipped-codex-home-mismatch`. `legacyQuotaRowsSkipped` records the excluded
49
+ array length; zero with `skipped-invalid` means the malformed non-array shape
50
+ had no reliable row count. Markerless, older, or semantically invalid quota
51
+ rows are never guessed or re-keyed; safe usage and thread history still
52
+ migrate.
53
+
54
+ A missing legacy snapshot is a completed absence check. By contrast, an
55
+ existing malformed usage/thread history, unreadable, oversized, non-regular,
56
+ or non-v3 snapshot
57
+ stops with `ERR_DURABLE_LEDGER_LEGACY_SNAPSHOT`. That failure does not advance
58
+ the ledger revision, mark the one-shot migration as checked, or publish a new
59
+ cache. Preserve the unreadable artifact in a private backup, then repair or
60
+ replace it with the matching legacy v3 snapshot and retry. If migration is
61
+ intentionally declined, move the preserved artifact out of the configured
62
+ snapshot path; the next successful refresh records that absence as checked.
63
+
64
+ `sourceIncomplete` means a previously observed source is missing, tombstoned,
65
+ truncated, or replaced. It is a provenance warning, not proof that SQLite is
66
+ corrupt. A true append keeps stable provenance; a larger same-inode rewrite is
67
+ classified as a replacement rather than an append.
68
+
69
+ A source scan with malformed JSON or an invalid token record is evidence-only:
70
+ its coverage counters and source watermark are retained, but none of its token,
71
+ quota, tool, position, ownership, origin, or thread operations can change the
72
+ last complete durable interpretation. The affected file's usage is excluded
73
+ from totals until it scans cleanly. To recover, fix or remove the malformed
74
+ line, then refresh. If that scan also detected a replacement, the source state
75
+ records a pending reconciliation. The bit survives lifecycle moves, missing or
76
+ tombstoned state, and truncation, then clears atomically only after a clean
77
+ complete scan reconciles every source-owned membership and position.
78
+
79
+ Quota labels are stored separately as explicit, timestamped source evidence.
80
+ Malformed quota scans cannot update that evidence. Missing or tombstoned
81
+ sources are ineligible at read time, and archived-source labels are ineligible
82
+ under `--no-archived`; observation rows are never destructively relabeled.
83
+
84
+ ## Schema and privacy upgrades
85
+
86
+ Schema-v1 preview ledgers with reconstructable migration scope upgrade to v2
87
+ inside one SQLite transaction. The upgrade removes stored working directories,
88
+ Git remotes, and raw source values, then compacts the database so those bytes do
89
+ not remain in free pages. New v2 writes store no raw versions of those values.
90
+
91
+ Schema v3 keeps the durable source-position index bounded: `event_key` in
92
+ `source_event_positions` is now a 64-character SHA-256 digest, while the full
93
+ event key remains in the observation or compaction-membership row that owns it.
94
+ Existing v2 ledgers rewrite those position values once on first write. New
95
+ ledgers use SQLite incremental auto-vacuum, and successful writes reclaim a
96
+ large freelist when it crosses both an absolute and percentage threshold;
97
+ small or busy databases defer maintenance to a later write.
98
+
99
+ During importer commits, durable usage and tool rows are materialized through a
100
+ synchronous SQLite cursor into the disk-backed usage spool. The public
101
+ `readDurableLedger()` result remains array-shaped for callers, but the refresh
102
+ hot path retains only the current row and one compacted row's membership
103
+ iterator at a time. Legacy migrated buckets are the bounded exception because
104
+ their residuals must remain available while current observations are compared.
105
+ Quota and thread metadata continue to use their existing collection paths; a
106
+ future change can stream those independently if their retention grows enough
107
+ to justify it.
108
+
109
+ The quota-identity contract upgrades separately. Opening a markerless ledger or
110
+ the prior `codex-limit-id-v1` contract for a write transaction discards all
111
+ pre-contract exact and migrated quota rows with their memberships, then records
112
+ the current contract atomically. Only a subsequent clean source scan can
113
+ repopulate canonical quota history. Rollback restores both the prior rows and
114
+ prior marker. An unknown or future contract stops before mutation with
115
+ `ERR_DURABLE_LEDGER_QUOTA_CONTRACT`; do not downgrade or rewrite that ledger.
116
+
117
+ Quota identity has one irreducible local boundary. Codex rollout
118
+ `RateLimitSnapshot` records carry a provider limit id, but no ChatGPT user or
119
+ account id. Token Ledger therefore separates quota pools by the canonical
120
+ provider limit id only within one account identity per `CODEX_HOME`; an omitted
121
+ or blank id is the default `codex` pool. Limit names and plan labels are display
122
+ metadata, never identity. Reusing one `CODEX_HOME` and durable ledger across
123
+ different ChatGPT users can stitch equal `codex` ids into one quota timeline;
124
+ use separate Codex homes and ledger locations when account isolation matters.
125
+
126
+ An early schema-v1 ledger that contains migrated history without a
127
+ reconstructable scope stops with `ERR_DURABLE_LEDGER_MIGRATION_SCOPE`. That
128
+ database is left unchanged because guessing its scope could either double-count
129
+ or erase usage.
130
+
131
+ ## Crash safety and non-destructive repair
132
+
133
+ 1. Stop concurrent Token Ledger refreshes and copy the ledger plus any SQLite
134
+ `-wal` and `-shm` sidecars to a private backup location.
135
+ 2. For `ERR_DURABLE_LEDGER_CODEX_HOME`, select the Codex home that originally
136
+ created the ledger. Do not rebind the ledger to a different home; use a
137
+ separate OS user profile when account-level isolation is required.
138
+ 3. For `ERR_DURABLE_LEDGER_MIGRATION_SCOPE`, keep the preview ledger as a
139
+ backup. Recreate the private app-owned ledger and rebuild exact history from
140
+ the matching Codex sources. Import a legacy snapshot only when its collection
141
+ scope and Codex-home fingerprint are both known.
142
+ 4. For a stale or oversized report cache, preserve the ledger and retry with a
143
+ compressed output, `--since`, or `--no-archived`. Removing only the cache is
144
+ safe when a rebuild is desired.
145
+ 5. Treat missing or rewritten rollout history as incomplete provenance. Do not
146
+ delete the ledger to make that warning disappear.
147
+
148
+ Snapshot encoding is staged in a private temporary file. Source watermarks are
149
+ validated twice before the SQLite commit and once immediately after it. The
150
+ SQLite commit atomically writes a complete candidate revision. That candidate
151
+ remains pending until the post-commit source check succeeds and its recovery
152
+ marker is cleared. Coordinated readers use the same writer guard, so they
153
+ never observe an in-progress transaction.
154
+
155
+ The staged report cache is published only after post-commit source validation
156
+ succeeds and the pending marker is cleared. If sources changed after the final
157
+ pre-commit check, the candidate is reverted through its undo log, the staged
158
+ cache is discarded, and collection retries from the new source inventory.
159
+ An interrupted validation or failed marker-clear transaction also leaves a
160
+ pending candidate that the next writer reverts before collecting again.
161
+ Observations from such an incomplete refresh are not accepted history; their
162
+ recovery requires the source files to remain available.
163
+
164
+ A crash before commit is rolled back by SQLite. A crash after the candidate
165
+ commit but before finalization can leave that candidate visible to a direct
166
+ ledger reader, but it remains provisional and will be unwound on refresh.
167
+ After finalization, a cache-publication failure leaves the accepted ledger
168
+ ahead of the prior cache. The cache's revision mismatch forces an automatic
169
+ refresh instead of labeling it verified-current. Successfully finalized
170
+ history remains available when its source files disappear.
171
+
172
+ Before staging a replacement cache, Token Ledger also removes a
173
+ same-destination temporary file only when it is an ordinary, single-link file
174
+ owned by the current user and its recorded process is demonstrably gone. No
175
+ ledger-sized baseline or restore copy is created during refresh.
176
+
177
+ ## Repeatable refresh benchmark
178
+
179
+ The benchmark can generate actual old token-count events, prove that the
180
+ durable ledger compacted them, verify the durable token total and revision, and
181
+ measure cold plus warm refreshes:
182
+
183
+ ```bash
184
+ for events in 100 1000 5000 10000; do
185
+ npm run benchmark:refresh -- \
186
+ --files 24 \
187
+ --token-events "$events" \
188
+ --warm-runs 3 \
189
+ --event-age-days 4000
190
+ done
191
+ ```
192
+
193
+ Record the operating system, CPU, memory, Node and npm versions with the result.
194
+ Use `warmMedianWallTimeMs` for the steady-state refresh comparison and retain
195
+ `coldWallTimeMs` separately. A valid compaction run has nonzero
196
+ `durableCompactedBuckets`, a `durableRevision` equal to `warmRuns`, the expected
197
+ `durableTotalTokens`, and zero parse errors. Running several benchmarks in
198
+ parallel invalidates timing comparisons.
@@ -0,0 +1,41 @@
1
+ # Token Ledger 0.4.0
2
+
3
+ ## Highlights
4
+
5
+ - One-day PNG reports now show hourly token usage and cache efficiency, with
6
+ a partial current hour and support for daylight-saving transitions.
7
+ - Add `--private` to a report to replace project names with ranked anonymous
8
+ labels. Usage figures stay visible, the source snapshot stays unchanged,
9
+ and the default output filename ends in `-private.png`.
10
+ - Recognize Astra in usage reports. Models without bundled prices remain
11
+ explicitly unrated in purchased-credit and API-equivalent estimates.
12
+ - Keep model names and token amounts readable in narrow bars, with floating
13
+ labels for small segments and captions for small model-mix shares.
14
+ - Show unrecorded fast-mode status separately from confirmed fast usage.
15
+
16
+ ## Correctness and reliability
17
+
18
+ - Allocate compacted usage across local calendar and hourly boundaries, retain
19
+ persisted call counts, and compare equivalent partial reporting periods.
20
+ - Keep report cutoffs fixed during collection and disclose allocated estimates.
21
+ - Repair token timestamps that precede their own recorded turn, preserve token
22
+ totals, and mark the replacement time as estimated.
23
+ - Reject impossible quota-window timestamps so valid meter history remains
24
+ visible, and keep meter attribution separate from daily token precision.
25
+ - Correct detailed-call coverage for total-only records while retaining valid
26
+ partial coverage in compacted history.
27
+ - Preserve competing snapshot replacements during publication checks.
28
+ - Redact local project paths during legacy migration and report materialization,
29
+ and redact local paths in CLI and collector diagnostics.
30
+ - Reject malformed quota readings, handle fragmented terminal key sequences,
31
+ and tolerate XML-invalid label characters during PNG encoding.
32
+ - Improve cross-month labels, narrow chart spacing, peak-hour labels, and
33
+ fallback to token bars when observed drain is unavailable.
34
+ - Preserve persisted thread-title updates and isolate benchmark state from the
35
+ live ledger.
36
+
37
+ ## Release checks and requirements
38
+
39
+ Requires Node.js 22.13 or newer. CI and prepublish checks include regular and
40
+ stress tests, lint, and offline installation of the packed artifact with CLI
41
+ and PNG smoke tests. Token Ledger remains a local-only CLI.
Binary file
@@ -0,0 +1,225 @@
1
+ const DAY_MS = 24 * 60 * 60 * 1_000;
2
+ const BOUNDARY_SEARCH_RADIUS_MS = 3 * DAY_MS;
3
+ const BOUNDARY_SAMPLE_STEP_MS = 6 * 60 * 60 * 1_000;
4
+ const BOUNDARY_PROBE_STEP_MS = 60 * 60 * 1_000;
5
+ const MAX_BOUNDARY_PROBES = 72;
6
+ const CALENDAR_DATE_PATTERN = /^\d{4}-\d{2}-\d{2}$/;
7
+
8
+ function dateStringFromParts(year, month, day) {
9
+ return [year, month, day]
10
+ .map((value, index) => String(value).padStart(index === 0 ? 4 : 2, "0"))
11
+ .join("-");
12
+ }
13
+
14
+ function parseCalendarDate(dateString) {
15
+ if (!CALENDAR_DATE_PATTERN.test(dateString)) {
16
+ throw new Error(`Invalid calendar date: ${dateString}`);
17
+ }
18
+ const [year, month, day] = dateString.split("-").map(Number);
19
+ const check = new Date(0);
20
+ check.setUTCFullYear(year, month - 1, day);
21
+ check.setUTCHours(0, 0, 0, 0);
22
+ if (
23
+ check.getUTCFullYear() !== year ||
24
+ check.getUTCMonth() + 1 !== month ||
25
+ check.getUTCDate() !== day
26
+ ) {
27
+ throw new Error(`Invalid calendar date: ${dateString}`);
28
+ }
29
+ return { year, month, day };
30
+ }
31
+
32
+ function calendarDate(dateString) {
33
+ const { year, month, day } = parseCalendarDate(dateString);
34
+ const date = new Date(0);
35
+ date.setUTCFullYear(year, month - 1, day);
36
+ date.setUTCHours(0, 0, 0, 0);
37
+ return date;
38
+ }
39
+
40
+ function utcMilliseconds(year, month, day, hour, minute, second) {
41
+ const date = new Date(0);
42
+ date.setUTCFullYear(year, month - 1, day);
43
+ date.setUTCHours(hour, minute, second, 0);
44
+ return date.getTime();
45
+ }
46
+
47
+ function numericDateTimeParts(timestampMs, formatter) {
48
+ const parts = formatter.formatToParts(new Date(timestampMs));
49
+ return Object.fromEntries(
50
+ parts
51
+ .filter((part) => part.type !== "literal" && part.type !== "timeZoneName")
52
+ .map((part) => [part.type, Number(part.value)]),
53
+ );
54
+ }
55
+
56
+ function timeZoneOffsetMs(timestampMs, formatter) {
57
+ const values = numericDateTimeParts(timestampMs, formatter);
58
+ const instantSecondMs = Math.floor(timestampMs / 1_000) * 1_000;
59
+ return (
60
+ utcMilliseconds(
61
+ values.year,
62
+ values.month,
63
+ values.day,
64
+ values.hour,
65
+ values.minute,
66
+ values.second,
67
+ ) - instantSecondMs
68
+ );
69
+ }
70
+
71
+ function localDateStringWithFormatter(timestampMs, formatter) {
72
+ const values = numericDateTimeParts(timestampMs, formatter);
73
+ return dateStringFromParts(values.year, values.month, values.day);
74
+ }
75
+
76
+ function findEarliestLocalDateInstant(candidateMs, dateString, formatter) {
77
+ let high = candidateMs;
78
+ let low = high - BOUNDARY_PROBE_STEP_MS;
79
+ let probes = 0;
80
+ while (
81
+ localDateStringWithFormatter(low, formatter) === dateString &&
82
+ probes < MAX_BOUNDARY_PROBES
83
+ ) {
84
+ high = low;
85
+ low -= BOUNDARY_PROBE_STEP_MS;
86
+ probes += 1;
87
+ }
88
+ if (localDateStringWithFormatter(low, formatter) === dateString) {
89
+ throw new Error(`Could not find the start of local date ${dateString}.`);
90
+ }
91
+ while (high - low > 1) {
92
+ const middle = Math.floor((low + high) / 2);
93
+ if (localDateStringWithFormatter(middle, formatter) === dateString) {
94
+ high = middle;
95
+ } else {
96
+ low = middle;
97
+ }
98
+ }
99
+ return high;
100
+ }
101
+
102
+ function candidateOffsets(utcGuess, formatter) {
103
+ // Keep both sides of a nearby offset transition available. A fixed-point
104
+ // midnight guess alone can land in the preceding local date when midnight
105
+ // was skipped.
106
+ const offsets = new Set();
107
+ for (
108
+ let delta = -BOUNDARY_SEARCH_RADIUS_MS;
109
+ delta <= BOUNDARY_SEARCH_RADIUS_MS;
110
+ delta += BOUNDARY_SAMPLE_STEP_MS
111
+ ) {
112
+ offsets.add(timeZoneOffsetMs(utcGuess + delta, formatter));
113
+ }
114
+
115
+ let probe = utcGuess;
116
+ for (let index = 0; index < 8; index += 1) {
117
+ const offset = timeZoneOffsetMs(probe, formatter);
118
+ offsets.add(offset);
119
+ const next = utcGuess - offset;
120
+ if (next === probe) break;
121
+ probe = next;
122
+ }
123
+ return offsets;
124
+ }
125
+
126
+ export function createTimeZoneFormatter(timeZone) {
127
+ return new Intl.DateTimeFormat("en-US", {
128
+ timeZone,
129
+ timeZoneName: "longOffset",
130
+ year: "numeric",
131
+ month: "2-digit",
132
+ day: "2-digit",
133
+ hour: "2-digit",
134
+ minute: "2-digit",
135
+ second: "2-digit",
136
+ hourCycle: "h23",
137
+ });
138
+ }
139
+
140
+ export function validateTimeZone(timeZone) {
141
+ try {
142
+ createTimeZoneFormatter(timeZone);
143
+ } catch {
144
+ throw new Error(`Unknown IANA timezone: ${timeZone}`);
145
+ }
146
+ }
147
+
148
+ export function shiftCalendarDate(dateString, amount) {
149
+ const { year, month, day } = parseCalendarDate(dateString);
150
+ const delta = Number(amount);
151
+ if (!Number.isSafeInteger(delta)) {
152
+ throw new Error(`Calendar date shift must be an integer: ${amount}`);
153
+ }
154
+ const date = new Date(0);
155
+ date.setUTCFullYear(year, month - 1, day + delta);
156
+ date.setUTCHours(0, 0, 0, 0);
157
+ return dateStringFromParts(
158
+ date.getUTCFullYear(),
159
+ date.getUTCMonth() + 1,
160
+ date.getUTCDate(),
161
+ );
162
+ }
163
+
164
+ export function localDateString(timestampMs, timeZone, formatter) {
165
+ const dateFormatter = formatter ?? createTimeZoneFormatter(timeZone);
166
+ return localDateStringWithFormatter(timestampMs, dateFormatter);
167
+ }
168
+
169
+ export function todayInTimeZone(timeZone, formatter) {
170
+ return localDateString(Date.now(), timeZone, formatter);
171
+ }
172
+
173
+ // The boundary is the first valid instant belonging to the requested local
174
+ // date. If a time-zone transition skips the entire date, the boundary is
175
+ // collapsed onto the next representable date, so the skipped date is empty.
176
+ // When midnight is repeated, the earliest occurrence is selected.
177
+ export function localDateBoundary(dateString, timeZone, formatter) {
178
+ parseCalendarDate(dateString);
179
+ const dateFormatter = formatter ?? createTimeZoneFormatter(timeZone);
180
+ let boundaryDateString = dateString;
181
+
182
+ for (let skippedDays = 0; skippedDays <= 370; skippedDays += 1) {
183
+ const utcGuess = calendarDate(boundaryDateString).getTime();
184
+ const starts = [];
185
+ for (const offset of candidateOffsets(utcGuess, dateFormatter)) {
186
+ const candidateMs = utcGuess - offset;
187
+ if (
188
+ localDateStringWithFormatter(candidateMs, dateFormatter) !==
189
+ boundaryDateString
190
+ ) {
191
+ continue;
192
+ }
193
+ starts.push(
194
+ findEarliestLocalDateInstant(
195
+ candidateMs,
196
+ boundaryDateString,
197
+ dateFormatter,
198
+ ),
199
+ );
200
+ }
201
+ if (starts.length > 0) return new Date(Math.min(...starts));
202
+ boundaryDateString = shiftCalendarDate(boundaryDateString, 1);
203
+ }
204
+
205
+ throw new Error(`Could not resolve a local calendar boundary near ${dateString}.`);
206
+ }
207
+
208
+ export function formatCalendarDate(dateString, options) {
209
+ return new Intl.DateTimeFormat("en-US", {
210
+ ...options,
211
+ timeZone: "UTC",
212
+ }).format(calendarDate(dateString));
213
+ }
214
+
215
+ export function calendarDateParts(dateString, options) {
216
+ const parts = new Intl.DateTimeFormat("en-US", {
217
+ ...options,
218
+ timeZone: "UTC",
219
+ }).formatToParts(calendarDate(dateString));
220
+ return Object.fromEntries(
221
+ parts
222
+ .filter((part) => part.type !== "literal")
223
+ .map((part) => [part.type, part.value]),
224
+ );
225
+ }
@@ -0,0 +1,100 @@
1
+ const ISO_TIMESTAMP_PATTERN =
2
+ /^(?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2})T(?<hour>\d{2}):(?<minute>\d{2})(?::(?<second>\d{2})(?:\.(?<fraction>\d+))?)?(?<timeZone>Z|[+-]\d{2}:\d{2})$/;
3
+
4
+ function invalidCollectionSince() {
5
+ return new Error("--since requires a valid ISO timestamp.");
6
+ }
7
+
8
+ export function normalizeCollectionSince(value) {
9
+ if (value === null || value === undefined) return null;
10
+ if (value instanceof Date) {
11
+ if (!Number.isFinite(value.getTime())) throw invalidCollectionSince();
12
+ return value.toISOString();
13
+ }
14
+
15
+ const match = ISO_TIMESTAMP_PATTERN.exec(value);
16
+ if (!match) throw invalidCollectionSince();
17
+ const date = new Date(value);
18
+ if (!Number.isFinite(date.getTime())) throw invalidCollectionSince();
19
+
20
+ const components = match.groups;
21
+ const year = Number(components.year);
22
+ const month = Number(components.month);
23
+ const day = Number(components.day);
24
+ const hour = Number(components.hour);
25
+ const minute = Number(components.minute);
26
+ const second = components.second ? Number(components.second) : 0;
27
+ const milliseconds = components.fraction
28
+ ? Number(components.fraction.slice(0, 3).padEnd(3, "0"))
29
+ : 0;
30
+ const timeZone = components.timeZone;
31
+ const offsetMinutes =
32
+ timeZone === "Z"
33
+ ? 0
34
+ : (timeZone[0] === "-" ? -1 : 1) *
35
+ (Number(timeZone.slice(1, 3)) * 60 + Number(timeZone.slice(4, 6)));
36
+ const localDate = new Date(date.getTime() + offsetMinutes * 60 * 1_000);
37
+ if (
38
+ localDate.getUTCFullYear() !== year ||
39
+ localDate.getUTCMonth() + 1 !== month ||
40
+ localDate.getUTCDate() !== day ||
41
+ localDate.getUTCHours() !== hour ||
42
+ localDate.getUTCMinutes() !== minute ||
43
+ localDate.getUTCSeconds() !== second ||
44
+ localDate.getUTCMilliseconds() !== milliseconds
45
+ ) {
46
+ throw invalidCollectionSince();
47
+ }
48
+ return date.toISOString();
49
+ }
50
+
51
+ export function collectionScope(options = {}) {
52
+ return {
53
+ since: normalizeCollectionSince(options.since),
54
+ includeArchived: options.includeArchived !== false,
55
+ };
56
+ }
57
+
58
+ export function snapshotCollectionScope(snapshot = {}) {
59
+ const collection = snapshot?.provenance?.collection;
60
+ if (!collection) return null;
61
+ if (
62
+ !Object.prototype.hasOwnProperty.call(collection, "since") ||
63
+ (collection.includeArchived !== true && collection.includeArchived !== false)
64
+ ) {
65
+ return null;
66
+ }
67
+ let since;
68
+ try {
69
+ since = normalizeCollectionSince(collection.since);
70
+ } catch {
71
+ return null;
72
+ }
73
+ if (since !== collection.since) return null;
74
+ return { since, includeArchived: collection.includeArchived };
75
+ }
76
+
77
+ export function snapshotMatchesCollectionScope(snapshot, scope) {
78
+ const actual = snapshotCollectionScope(snapshot);
79
+ return Boolean(
80
+ actual &&
81
+ actual.since === scope.since &&
82
+ actual.includeArchived === scope.includeArchived,
83
+ );
84
+ }
85
+
86
+ export function historyScopeLabel(snapshot = {}) {
87
+ const scope = snapshotCollectionScope(snapshot);
88
+ if (!scope || (scope.since === null && scope.includeArchived)) return null;
89
+
90
+ const details = [];
91
+ if (scope.since !== null) details.push(`before ${scope.since}`);
92
+ if (!scope.includeArchived) details.push("archived sessions excluded");
93
+ return `TRUNCATED HISTORY${details.length ? ` · ${details.join(" · ")}` : ""}`;
94
+ }
95
+
96
+ export function snapshotCollectionCutoffMs(snapshot = {}) {
97
+ const scope = snapshotCollectionScope(snapshot);
98
+ if (!scope?.since) return null;
99
+ return Date.parse(scope.since);
100
+ }