@agent-finops/core 0.8.0 → 0.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.
Files changed (49) hide show
  1. package/README.md +5 -3
  2. package/dist/actionPlanner.d.ts +140 -0
  3. package/dist/actionPlanner.js +938 -0
  4. package/dist/actionVerification.d.ts +1240 -0
  5. package/dist/actionVerification.js +1028 -0
  6. package/dist/activitySnapshot.d.ts +101 -9
  7. package/dist/activitySnapshot.js +145 -6
  8. package/dist/activitySnapshotCache.d.ts +8 -1
  9. package/dist/activitySnapshotCache.js +103 -7
  10. package/dist/agentEconomicsReceipt.d.ts +58 -58
  11. package/dist/analyze.js +3 -1
  12. package/dist/cutList.js +1 -1
  13. package/dist/glance.d.ts +30 -2
  14. package/dist/glance.js +265 -84
  15. package/dist/index.d.ts +11 -2
  16. package/dist/index.js +10 -1
  17. package/dist/insights.js +3 -1
  18. package/dist/localAgentFormats/gemini.js +2 -2
  19. package/dist/localAgentFormats/registry.js +6 -2
  20. package/dist/localAgentFormats/runtimeRegistry.js +5 -2
  21. package/dist/localAgentFormats/types.d.ts +2 -1
  22. package/dist/localAgentLogs.d.ts +362 -3
  23. package/dist/localAgentLogs.js +1964 -165
  24. package/dist/modelPricing.d.ts +1 -1
  25. package/dist/modelPricing.js +4 -1
  26. package/dist/planMath.js +12 -7
  27. package/dist/projectEconomics.d.ts +617 -0
  28. package/dist/projectEconomics.js +620 -0
  29. package/dist/projectEconomicsBuilder.d.ts +89 -0
  30. package/dist/projectEconomicsBuilder.js +473 -0
  31. package/dist/projectIndexStore.d.ts +545 -0
  32. package/dist/projectIndexStore.js +606 -0
  33. package/dist/providerConnectors.d.ts +59 -1
  34. package/dist/providerConnectors.js +192 -12
  35. package/dist/qualitativeIndexCache.d.ts +494 -0
  36. package/dist/qualitativeIndexCache.js +930 -0
  37. package/dist/resultCard.d.ts +350 -0
  38. package/dist/resultCard.js +604 -0
  39. package/dist/runtimeCommands.d.ts +21 -0
  40. package/dist/runtimeCommands.js +27 -0
  41. package/dist/scanGuard.d.ts +3 -1
  42. package/dist/scanGuard.js +164 -4
  43. package/dist/schema.d.ts +31 -31
  44. package/dist/sessionVitals.d.ts +145 -0
  45. package/dist/sessionVitals.js +521 -0
  46. package/dist/sourceRegistry.js +90 -52
  47. package/dist/toolInvocations.d.ts +40 -1
  48. package/dist/toolInvocations.js +101 -20
  49. package/package.json +1 -1
@@ -13,9 +13,9 @@ export const ingestionLanes = [
13
13
  },
14
14
  {
15
15
  id: "browser_account_ui",
16
- label: "Browser Account UI",
16
+ label: "Browser Account UI (schema scaffold; ingestion unavailable)",
17
17
  sourceTypes: ["browser_account"],
18
- defaultFinancialEvidence: "verified"
18
+ defaultFinancialEvidence: "missing"
19
19
  },
20
20
  {
21
21
  id: "local_cli_tool_detection",
@@ -31,58 +31,79 @@ export const ingestionLanes = [
31
31
  }
32
32
  ];
33
33
  export const supportedSourceTypes = ingestionLanes.flatMap((lane) => lane.sourceTypes);
34
+ const browserAccountScaffoldMissingField = "browser-account ingestion is a schema scaffold and is not implemented";
34
35
  export const providerCatalog = [
35
36
  {
36
37
  id: "openai",
37
38
  label: "OpenAI / Codex account usage",
38
39
  preferredSourceType: "provider_api",
39
40
  preferredAccessMethod: "api",
40
- verifiedFields: ["organization costs", "project usage", "model usage", "api key usage"],
41
- missingFields: ["admin API token reference", "organization id"]
41
+ verifiedFields: ["organization costs", "completions usage by project, user, model, API key, and service tier"],
42
+ missingFields: [
43
+ "OpenAI Admin API key reference",
44
+ "non-completions usage families",
45
+ "ChatGPT/Codex workspace seats, credits, and final invoice settlement"
46
+ ]
42
47
  },
43
48
  {
44
49
  id: "anthropic",
45
50
  label: "Anthropic / Claude / Claude Code",
46
51
  preferredSourceType: "provider_api",
47
52
  preferredAccessMethod: "api",
48
- verifiedFields: ["organization cost report", "Claude Code usage", "workspace/user usage"],
49
- missingFields: ["admin API token reference", "organization id"],
50
- fallbackConnector: "browser_account"
53
+ verifiedFields: ["organization cost report", "Claude Code analytics by workspace and user"],
54
+ missingFields: [
55
+ "Anthropic Admin API key reference",
56
+ "Messages Usage API detail",
57
+ "Claude Enterprise Analytics and final invoice settlement"
58
+ ]
51
59
  },
52
60
  {
53
61
  id: "github-copilot",
54
62
  label: "GitHub Copilot",
55
63
  preferredSourceType: "provider_api",
56
64
  preferredAccessMethod: "api",
57
- verifiedFields: ["Copilot usage metrics", "seat usage", "premium request usage"],
58
- missingFields: ["GitHub token reference", "organization or enterprise slug"],
59
- fallbackConnector: "browser_account"
65
+ verifiedFields: ["Copilot usage metrics", "seat assignments and reported plan types"],
66
+ missingFields: [
67
+ "GitHub admin token reference and organization or enterprise slug",
68
+ "AI-credit gross, discount, and net billing",
69
+ "license invoice settlement"
70
+ ]
60
71
  },
61
72
  {
62
73
  id: "codex",
63
74
  label: "Codex / OpenAI coding tools",
64
75
  preferredSourceType: "provider_api",
65
76
  preferredAccessMethod: "api",
66
- verifiedFields: ["OpenAI project usage", "OpenAI costs", "tool/project attribution"],
67
- missingFields: ["OpenAI admin API token reference", "project mapping"],
68
- fallbackConnector: "browser_account"
77
+ verifiedFields: ["OpenAI organization costs", "OpenAI completions usage"],
78
+ missingFields: [
79
+ "OpenAI Admin API key reference",
80
+ "ChatGPT/Codex workspace seats, credits, and subscription billing",
81
+ "workspace-to-local-project mapping"
82
+ ]
69
83
  },
70
84
  {
71
85
  id: "cursor",
72
86
  label: "Cursor",
73
87
  preferredSourceType: "provider_api",
74
88
  preferredAccessMethod: "api",
75
- verifiedFields: ["Cursor Admin API spend", "team usage", "seat usage"],
76
- missingFields: ["Cursor admin API key reference or approved browser account session"],
77
- fallbackConnector: "browser_account"
89
+ verifiedFields: ["Cursor Admin API team-member spend aggregate"],
90
+ missingFields: [
91
+ "Cursor team Admin API key reference",
92
+ "filtered usage-event detail and aggregate reconciliation",
93
+ "seat contract and final invoice settlement"
94
+ ]
78
95
  },
79
96
  {
80
97
  id: "gemini",
81
98
  label: "Google Gemini",
82
99
  preferredSourceType: "provider_api",
83
100
  preferredAccessMethod: "api",
84
- verifiedFields: ["Google/Vertex billing export", "model usage"],
85
- missingFields: ["approved billing export or API source"]
101
+ verifiedFields: [],
102
+ missingFields: [
103
+ "Google Cloud Billing export or approved billing API source",
104
+ "Gemini CLI authentication and billing mode",
105
+ "provider-reported billed money"
106
+ ]
86
107
  },
87
108
  {
88
109
  id: "langfuse",
@@ -136,35 +157,35 @@ export const providerCatalog = [
136
157
  export const providerConnectorCatalog = [
137
158
  {
138
159
  provider: "openai",
139
- preferredAuthMode: "oauth",
140
- fallbackAuthModes: ["api_token_ref", "browser_session"],
141
- scopes: ["organization:usage:read", "organization:costs:read", "projects:read"],
160
+ preferredAuthMode: "api_token_ref",
161
+ fallbackAuthModes: [],
162
+ scopes: ["Organization Admin API usage read", "Organization Admin API costs read"],
142
163
  tokenStorage: "local_reference_only",
143
- setupHint: "Prefer OAuth/admin consent for org usage and costs; fallback to a local keychain/token reference or dashboard export."
164
+ setupHint: "Use a local env reference to an OpenAI Organization Admin API key with usage and costs access; raw keys are never accepted on the command line."
144
165
  },
145
166
  {
146
167
  provider: "anthropic",
147
- preferredAuthMode: "oauth",
148
- fallbackAuthModes: ["api_token_ref", "browser_session"],
149
- scopes: ["organization:usage:read", "organization:costs:read", "claude_code:usage:read"],
168
+ preferredAuthMode: "api_token_ref",
169
+ fallbackAuthModes: [],
170
+ scopes: ["Claude Console Admin cost report read", "Claude Code analytics read"],
150
171
  tokenStorage: "local_reference_only",
151
- setupHint: "Prefer org/admin OAuth or admin API token reference for Claude cost and Claude Code usage reports."
172
+ setupHint: "Use a local env reference to a Claude Console organization Admin API key; Claude Enterprise Analytics requires a different key and is not implemented."
152
173
  },
153
174
  {
154
175
  provider: "github-copilot",
155
- preferredAuthMode: "oauth",
156
- fallbackAuthModes: ["api_token_ref", "browser_session"],
157
- scopes: ["copilot:usage:read", "enterprise:read", "org:read"],
176
+ preferredAuthMode: "api_token_ref",
177
+ fallbackAuthModes: [],
178
+ scopes: ["fine-grained Administration: read", "organization or enterprise billing access"],
158
179
  tokenStorage: "local_reference_only",
159
- setupHint: "Prefer GitHub App/OAuth read-only org or enterprise consent for Copilot seats and usage metrics."
180
+ setupHint: "Use a local env reference to a GitHub token with read-only organization or enterprise Copilot metrics and seat access; AI-credit billing is not implemented."
160
181
  },
161
182
  {
162
183
  provider: "cursor",
163
184
  preferredAuthMode: "api_token_ref",
164
- fallbackAuthModes: ["browser_session", "manual_export"],
165
- scopes: ["admin:*", "team:usage:read", "team:spend:read"],
185
+ fallbackAuthModes: [],
186
+ scopes: ["Cursor team Admin API spend read"],
166
187
  tokenStorage: "local_reference_only",
167
- setupHint: "Use Cursor Admin API key reference for Enterprise team usage/spend when available; fallback to Browser Account UI or manual export."
188
+ setupHint: "Use a local env reference to a Cursor team Admin API key for the team spend aggregate; filtered usage-event and invoice reconciliation are not implemented."
168
189
  }
169
190
  ];
170
191
  export const defaultDeniedGlobs = [
@@ -215,6 +236,7 @@ export function addApprovedSource(registry, source, now = new Date()) {
215
236
  if (source.readOnly === false) {
216
237
  throw new Error("Approved source boundaries must remain read-only.");
217
238
  }
239
+ const browserScaffold = source.type === "browser_account";
218
240
  const nextSource = {
219
241
  id: source.id,
220
242
  type: source.type,
@@ -223,15 +245,17 @@ export function addApprovedSource(registry, source, now = new Date()) {
223
245
  ...(source.provider ? { provider: source.provider } : {}),
224
246
  readOnly: true,
225
247
  approvedAt: timestamp,
226
- scope: source.scope ?? defaultScopeForSource(source.type),
248
+ scope: browserScaffold ? defaultScopeForSource(source.type) : (source.scope ?? defaultScopeForSource(source.type)),
227
249
  lane: source.lane ?? laneForSourceType(source.type),
228
250
  accessMethod: source.accessMethod ?? accessMethodForSourceType(source.type),
229
251
  boundaryApproval: source.boundaryApproval ?? "approved",
230
- validationCoverage: source.validationCoverage ?? "untested",
231
- financialEvidence: source.type === "local_folder" ? "missing" : (source.financialEvidence ?? "missing"),
232
- fieldsVerified: source.fieldsVerified ?? [],
233
- fieldsEstimated: source.fieldsEstimated ?? [],
234
- fieldsMissing: source.fieldsMissing ?? [],
252
+ validationCoverage: browserScaffold ? "untested" : (source.validationCoverage ?? "untested"),
253
+ financialEvidence: source.type === "local_folder" || browserScaffold ? "missing" : (source.financialEvidence ?? "missing"),
254
+ fieldsVerified: browserScaffold ? [] : (source.fieldsVerified ?? []),
255
+ fieldsEstimated: browserScaffold ? [] : (source.fieldsEstimated ?? []),
256
+ fieldsMissing: browserScaffold
257
+ ? Array.from(new Set([...(source.fieldsMissing ?? []), browserAccountScaffoldMissingField]))
258
+ : (source.fieldsMissing ?? []),
235
259
  authMode: source.authMode,
236
260
  authScopes: source.authScopes,
237
261
  tokenStorage: source.tokenStorage,
@@ -250,6 +274,7 @@ export function createProviderConnectorStub(provider, type = providerCatalog.fin
250
274
  const catalogEntry = providerCatalog.find((entry) => entry.id === provider);
251
275
  const connectorEntry = providerConnectorCatalog.find((entry) => entry.provider === provider);
252
276
  const id = slugifySourceId(`${provider}-${type}`);
277
+ const browserScaffold = type === "browser_account";
253
278
  return {
254
279
  id,
255
280
  type,
@@ -265,9 +290,14 @@ export function createProviderConnectorStub(provider, type = providerCatalog.fin
265
290
  // A successful provider result promotes this axis explicitly.
266
291
  validationCoverage: "untested",
267
292
  financialEvidence: "missing",
268
- fieldsVerified: catalogEntry?.verifiedFields ?? [],
293
+ fieldsVerified: browserScaffold ? [] : (catalogEntry?.verifiedFields ?? []),
269
294
  fieldsEstimated: [],
270
- fieldsMissing: catalogEntry?.missingFields ?? ["approved account/API/export source"],
295
+ fieldsMissing: browserScaffold
296
+ ? Array.from(new Set([
297
+ ...(catalogEntry?.missingFields ?? []),
298
+ browserAccountScaffoldMissingField
299
+ ]))
300
+ : (catalogEntry?.missingFields ?? ["approved account/API/export source"]),
271
301
  authMode: authModeForConnectorType(type, connectorEntry),
272
302
  authScopes: connectorEntry?.scopes ?? [],
273
303
  tokenStorage: tokenStorageForConnectorType(type, connectorEntry)
@@ -377,6 +407,7 @@ function normalizeApprovedSource(value) {
377
407
  : isFinancialEvidence(value.verification)
378
408
  ? value.verification
379
409
  : "missing";
410
+ const browserScaffold = value.type === "browser_account";
380
411
  return {
381
412
  id: value.id,
382
413
  type: value.type,
@@ -385,17 +416,21 @@ function normalizeApprovedSource(value) {
385
416
  ...(provider ? { provider } : {}),
386
417
  readOnly: true,
387
418
  approvedAt: value.approvedAt,
388
- scope: value.scope,
419
+ scope: browserScaffold ? defaultScopeForSource(value.type) : value.scope,
389
420
  lane: value.lane,
390
421
  accessMethod: value.accessMethod,
391
422
  boundaryApproval: "approved",
392
- validationCoverage: isValidationCoverage(value.validationCoverage)
393
- ? value.validationCoverage
394
- : "untested",
395
- financialEvidence: value.type === "local_folder" ? "missing" : migratedEvidence,
396
- fieldsVerified: [...value.fieldsVerified],
397
- fieldsEstimated: [...value.fieldsEstimated],
398
- fieldsMissing: [...value.fieldsMissing],
423
+ validationCoverage: browserScaffold
424
+ ? "untested"
425
+ : isValidationCoverage(value.validationCoverage)
426
+ ? value.validationCoverage
427
+ : "untested",
428
+ financialEvidence: value.type === "local_folder" || browserScaffold ? "missing" : migratedEvidence,
429
+ fieldsVerified: browserScaffold ? [] : [...value.fieldsVerified],
430
+ fieldsEstimated: browserScaffold ? [] : [...value.fieldsEstimated],
431
+ fieldsMissing: browserScaffold
432
+ ? Array.from(new Set([...value.fieldsMissing, browserAccountScaffoldMissingField]))
433
+ : [...value.fieldsMissing],
399
434
  ...(authMode ? { authMode } : {}),
400
435
  ...(value.authScopes ? { authScopes: [...value.authScopes] } : {}),
401
436
  ...(tokenStorage ? { tokenStorage } : {}),
@@ -443,7 +478,7 @@ export function buildMissingSourcePrompts(signals, registry) {
443
478
  prompts.push({
444
479
  provider,
445
480
  status: "detected_unverified",
446
- reason: `${provider} was detected locally, but no approved provider/API/browser/export boundary has current financial evidence. Connector validation is reported separately.`,
481
+ reason: `${provider} was detected locally, but no approved implemented source has current financial evidence. Connector validation is reported separately.`,
447
482
  detectedEvidence: detectedSignals.map((signal) => signal.evidence),
448
483
  suggestedConnector: `connect ${provider} --type ${preferredType}`,
449
484
  suggestedSourceTypes
@@ -490,7 +525,10 @@ function hasCurrentProviderFinancialEvidence(registry, provider) {
490
525
  return source.boundaryApproval === "approved" &&
491
526
  source.validationCoverage !== "failed" &&
492
527
  source.financialEvidence !== "missing" &&
493
- source.type !== "local_tool_detection";
528
+ source.type !== "local_tool_detection" &&
529
+ // browser_account is reserved in the schema but has no ingestion
530
+ // implementation. Repository state cannot promote the scaffold.
531
+ source.type !== "browser_account";
494
532
  });
495
533
  }
496
534
  function validationCoverageForSource(provider, type) {
@@ -523,7 +561,7 @@ function defaultScopeForSource(type) {
523
561
  return "Read-only provider API/account usage source. Store token references only; no raw secrets. No billing changes. No cloud upload.";
524
562
  }
525
563
  if (type === "browser_account") {
526
- return "Read-only Browser Account UI source. User logs in locally; agent never sees passwords; 2FA/CAPTCHA handoff; audit all page reads/downloads.";
564
+ return "Schema scaffold only. Browser-account ingestion is not implemented and cannot produce financial evidence or satisfy a missing-source prompt.";
527
565
  }
528
566
  if (type === "local_tool_detection") {
529
567
  return "Read-only local CLI/tool detection path. Detection is not official provider-reported cost; connect an account API or export to add that evidence.";
@@ -100,6 +100,11 @@ export type ParsedInvocationFile = {
100
100
  assistantTurns: number;
101
101
  contextSignal: SessionContextSignal;
102
102
  };
103
+ /** Private-index proof used to narrow one aggregated Codex window exactly. */
104
+ export type ParsedInvocationWindowProof = {
105
+ earliestCountedAt?: string;
106
+ allCountedEventsTimestamped: boolean;
107
+ };
103
108
  export type ToolInvocationOptions = {
104
109
  /** default: join(homedir(), ".claude", "projects") */
105
110
  claudeProjectsDir?: string;
@@ -118,13 +123,47 @@ export type ToolInvocationOptions = {
118
123
  export declare function parseClaudeCodeInvocations(content: string, sinceMs?: number): ParsedInvocationFile;
119
124
  /** Parse ONE Codex rollout's tool/skill/subagent invocations. */
120
125
  export declare function parseCodexInvocations(content: string, sinceMs?: number): ParsedInvocationFile;
126
+ /**
127
+ * Serializable snapshot of one Codex invocation collector, used by the
128
+ * checkpointed streaming path to carry aggregation across bounded runs. It
129
+ * contains only what the finished summary itself persists — tool/skill/
130
+ * command names, file basenames, opaque session ids, counters and window
131
+ * proof timestamps — never raw transcript text or absolute paths.
132
+ */
133
+ export type CodexInvocationCollectorSnapshot = {
134
+ counts: Array<[string, number]>;
135
+ mcpTools: string[];
136
+ skills: string[];
137
+ subagents: string[];
138
+ commands: string[];
139
+ fileReads: Array<[string, number]>;
140
+ assistantTurns: number;
141
+ sessionId?: string;
142
+ rootSessionMetaSeen: boolean;
143
+ rootStartedAtMs?: number;
144
+ rootTaskStarted: boolean;
145
+ lastActivityAt?: string;
146
+ compactionEvents: number;
147
+ isSubagent: boolean;
148
+ parentSessionId?: string;
149
+ nestedSessions: Array<[string, NestedSessionMetadata]>;
150
+ earliestCountedMs?: number;
151
+ allCountedEventsTimestamped: boolean;
152
+ };
153
+ /** Fail-closed structural check for a restored collector snapshot. */
154
+ export declare function isCodexInvocationCollectorSnapshot(value: unknown): value is CodexInvocationCollectorSnapshot;
121
155
  /**
122
156
  * Stateful Codex invocation parser used to share localAgentLogs' JSONL pass.
123
157
  * One collector is created per rollout file and discarded after `finish()`.
158
+ * A checkpointed stream restores a prior run's snapshot; the window (sinceMs)
159
+ * must be the one the snapshot was created under — the caller pins it in the
160
+ * checkpoint envelope.
124
161
  */
125
- export declare function createCodexInvocationCollector(sinceMs?: number): {
162
+ export declare function createCodexInvocationCollector(sinceMs?: number, restored?: CodexInvocationCollectorSnapshot): {
126
163
  consume: (entry: Record<string, unknown>) => void;
127
164
  finish: () => ParsedInvocationFile;
165
+ windowProof: () => ParsedInvocationWindowProof;
166
+ snapshot: () => CodexInvocationCollectorSnapshot;
128
167
  };
129
168
  /** Scan this machine's Claude Code + Codex transcripts and aggregate invocations. */
130
169
  export declare function loadToolInvocations(options?: ToolInvocationOptions): Promise<InvocationSummary>;
@@ -132,27 +132,64 @@ export function parseCodexInvocations(content, sinceMs) {
132
132
  }
133
133
  return collector.finish();
134
134
  }
135
+ /** Fail-closed structural check for a restored collector snapshot. */
136
+ export function isCodexInvocationCollectorSnapshot(value) {
137
+ if (typeof value !== "object" || value === null || Array.isArray(value))
138
+ return false;
139
+ const record = value;
140
+ const stringNumberPairs = (input) => (Array.isArray(input) && input.every((pair) => (Array.isArray(pair) && pair.length === 2 &&
141
+ typeof pair[0] === "string" &&
142
+ Number.isSafeInteger(pair[1]) && Number(pair[1]) >= 0)));
143
+ const stringArray = (input) => (Array.isArray(input) && input.every((item) => typeof item === "string"));
144
+ const optionalString = (input) => (input === undefined || typeof input === "string");
145
+ const optionalFinite = (input) => (input === undefined || typeof input === "number" && Number.isFinite(input));
146
+ return stringNumberPairs(record.counts) &&
147
+ stringArray(record.mcpTools) &&
148
+ stringArray(record.skills) &&
149
+ stringArray(record.subagents) &&
150
+ stringArray(record.commands) &&
151
+ stringNumberPairs(record.fileReads) &&
152
+ Number.isSafeInteger(record.assistantTurns) && Number(record.assistantTurns) >= 0 &&
153
+ optionalString(record.sessionId) &&
154
+ typeof record.rootSessionMetaSeen === "boolean" &&
155
+ optionalFinite(record.rootStartedAtMs) &&
156
+ typeof record.rootTaskStarted === "boolean" &&
157
+ optionalString(record.lastActivityAt) &&
158
+ Number.isSafeInteger(record.compactionEvents) && Number(record.compactionEvents) >= 0 &&
159
+ typeof record.isSubagent === "boolean" &&
160
+ optionalString(record.parentSessionId) &&
161
+ Array.isArray(record.nestedSessions) && record.nestedSessions.every((pair) => (Array.isArray(pair) && pair.length === 2 && typeof pair[0] === "string" &&
162
+ typeof pair[1] === "object" && pair[1] !== null &&
163
+ typeof pair[1].isSubagent === "boolean")) &&
164
+ optionalFinite(record.earliestCountedMs) &&
165
+ typeof record.allCountedEventsTimestamped === "boolean";
166
+ }
135
167
  /**
136
168
  * Stateful Codex invocation parser used to share localAgentLogs' JSONL pass.
137
169
  * One collector is created per rollout file and discarded after `finish()`.
170
+ * A checkpointed stream restores a prior run's snapshot; the window (sinceMs)
171
+ * must be the one the snapshot was created under — the caller pins it in the
172
+ * checkpoint envelope.
138
173
  */
139
- export function createCodexInvocationCollector(sinceMs) {
140
- const counts = new Map();
141
- const mcpTools = new Set();
142
- const skills = new Set();
143
- const subagents = new Set();
144
- const commands = new Set();
145
- const fileReads = new Map();
146
- let assistantTurns = 0;
147
- let sessionId;
148
- let rootSessionMetaSeen = false;
149
- let rootStartedAtMs;
150
- let rootTaskStarted = false;
151
- let lastActivityAt;
152
- let compactionEvents = 0;
153
- let isSubagent = false;
154
- let parentSessionId;
155
- const nestedSessions = new Map();
174
+ export function createCodexInvocationCollector(sinceMs, restored) {
175
+ const counts = new Map(restored?.counts ?? []);
176
+ const mcpTools = new Set(restored?.mcpTools ?? []);
177
+ const skills = new Set(restored?.skills ?? []);
178
+ const subagents = new Set(restored?.subagents ?? []);
179
+ const commands = new Set(restored?.commands ?? []);
180
+ const fileReads = new Map(restored?.fileReads ?? []);
181
+ let assistantTurns = restored?.assistantTurns ?? 0;
182
+ let sessionId = restored?.sessionId;
183
+ let rootSessionMetaSeen = restored?.rootSessionMetaSeen ?? false;
184
+ let rootStartedAtMs = restored?.rootStartedAtMs;
185
+ let rootTaskStarted = restored?.rootTaskStarted ?? false;
186
+ let lastActivityAt = restored?.lastActivityAt;
187
+ let compactionEvents = restored?.compactionEvents ?? 0;
188
+ let isSubagent = restored?.isSubagent ?? false;
189
+ let parentSessionId = restored?.parentSessionId;
190
+ const nestedSessions = new Map(restored?.nestedSessions ?? []);
191
+ let earliestCountedMs = restored?.earliestCountedMs;
192
+ let allCountedEventsTimestamped = restored?.allCountedEventsTimestamped ?? true;
156
193
  const resetObservedEvidence = () => {
157
194
  counts.clear();
158
195
  mcpTools.clear();
@@ -162,6 +199,18 @@ export function createCodexInvocationCollector(sinceMs) {
162
199
  fileReads.clear();
163
200
  assistantTurns = 0;
164
201
  compactionEvents = 0;
202
+ earliestCountedMs = undefined;
203
+ allCountedEventsTimestamped = true;
204
+ };
205
+ const recordCountedTimestamp = (entry) => {
206
+ const timestamp = Date.parse(stringOf(entry.timestamp) ?? "");
207
+ if (!Number.isFinite(timestamp)) {
208
+ allCountedEventsTimestamped = false;
209
+ return;
210
+ }
211
+ earliestCountedMs = earliestCountedMs === undefined
212
+ ? timestamp
213
+ : Math.min(earliestCountedMs, timestamp);
165
214
  };
166
215
  const consume = (entry) => {
167
216
  const payload = isRecord(entry.payload) ? entry.payload : undefined;
@@ -211,18 +260,23 @@ export function createCodexInvocationCollector(sinceMs) {
211
260
  return;
212
261
  // Codex writes both a top-level `compacted` entry and a separate
213
262
  // `context_compacted` event for one compaction. Count only the former.
214
- if (entry.type === "compacted")
263
+ if (entry.type === "compacted") {
215
264
  compactionEvents += 1;
265
+ recordCountedTimestamp(entry);
266
+ }
216
267
  // Codex emits one turn_context per model turn.
217
268
  if (entry.type === "turn_context") {
218
269
  assistantTurns += 1;
270
+ recordCountedTimestamp(entry);
219
271
  return;
220
272
  }
221
273
  if (payload.type === "message" && payload.role === "user") {
222
274
  for (const text of codexTextValues(payload.content)) {
223
275
  const command = /^\s*\/([A-Za-z0-9:_-]+)/.exec(text)?.[1];
224
- if (command)
276
+ if (command) {
225
277
  commands.add(command);
278
+ recordCountedTimestamp(entry);
279
+ }
226
280
  }
227
281
  return;
228
282
  }
@@ -233,6 +287,7 @@ export function createCodexInvocationCollector(sinceMs) {
233
287
  if (!name)
234
288
  return;
235
289
  counts.set(name, (counts.get(name) ?? 0) + 1);
290
+ recordCountedTimestamp(entry);
236
291
  const input = codexToolInput(payload);
237
292
  if (name.startsWith("mcp__")) {
238
293
  mcpTools.add(name);
@@ -280,7 +335,33 @@ export function createCodexInvocationCollector(sinceMs) {
280
335
  })
281
336
  };
282
337
  };
283
- return { consume, finish };
338
+ const windowProof = () => ({
339
+ ...(earliestCountedMs === undefined
340
+ ? {}
341
+ : { earliestCountedAt: new Date(earliestCountedMs).toISOString() }),
342
+ allCountedEventsTimestamped
343
+ });
344
+ const snapshot = () => ({
345
+ counts: [...counts.entries()],
346
+ mcpTools: [...mcpTools],
347
+ skills: [...skills],
348
+ subagents: [...subagents],
349
+ commands: [...commands],
350
+ fileReads: [...fileReads.entries()],
351
+ assistantTurns,
352
+ ...(sessionId !== undefined ? { sessionId } : {}),
353
+ rootSessionMetaSeen,
354
+ ...(rootStartedAtMs !== undefined ? { rootStartedAtMs } : {}),
355
+ rootTaskStarted,
356
+ ...(lastActivityAt !== undefined ? { lastActivityAt } : {}),
357
+ compactionEvents,
358
+ isSubagent,
359
+ ...(parentSessionId !== undefined ? { parentSessionId } : {}),
360
+ nestedSessions: [...nestedSessions.entries()],
361
+ ...(earliestCountedMs !== undefined ? { earliestCountedMs } : {}),
362
+ allCountedEventsTimestamped
363
+ });
364
+ return { consume, finish, windowProof, snapshot };
284
365
  }
285
366
  /** Scan this machine's Claude Code + Codex transcripts and aggregate invocations. */
286
367
  export async function loadToolInvocations(options = {}) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@agent-finops/core",
3
- "version": "0.8.0",
3
+ "version": "0.9.0",
4
4
  "type": "module",
5
5
  "main": "./dist/index.js",
6
6
  "types": "./dist/index.d.ts",