@agent-finops/core 0.7.3 → 0.8.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,443 @@
1
+ import { createHash } from "node:crypto";
2
+ import { isAbsolute } from "node:path";
3
+ class DiagnosticCounter {
4
+ #counts = new Map();
5
+ add(code, count = 1) {
6
+ this.#counts.set(code, (this.#counts.get(code) ?? 0) + count);
7
+ }
8
+ values() {
9
+ return [...this.#counts].map(([code, count]) => ({ code, count }));
10
+ }
11
+ }
12
+ /** Parse a Gemini chat file according to its caller-supplied extension. */
13
+ export function parseGeminiSession(content, options) {
14
+ const extension = extensionOf(options.filePath);
15
+ if (extension === ".jsonl")
16
+ return parseGeminiJsonlSession(content, options);
17
+ if (extension === ".json")
18
+ return parseGeminiJsonSession(content, options);
19
+ // A registry should normally constrain extensions. This fallback keeps the
20
+ // standalone API useful without guessing a malformed JSON document is JSONL.
21
+ try {
22
+ JSON.parse(content);
23
+ return parseGeminiJsonSession(content, options);
24
+ }
25
+ catch {
26
+ return parseGeminiJsonlSession(content, options);
27
+ }
28
+ }
29
+ /** Parse the legacy whole-conversation JSON representation. */
30
+ export function parseGeminiJsonSession(content, options) {
31
+ const state = createState(options);
32
+ let parsed;
33
+ try {
34
+ parsed = JSON.parse(content);
35
+ }
36
+ catch {
37
+ state.diagnostics.add("malformed_json");
38
+ return finish(state);
39
+ }
40
+ if (Array.isArray(parsed)) {
41
+ processRecordSequence(parsed, state);
42
+ return finish(state);
43
+ }
44
+ if (!isRecord(parsed)) {
45
+ state.diagnostics.add("unsupported_token_shape");
46
+ return finish(state);
47
+ }
48
+ state.metadata = mergeMetadata(state.metadata, parsed);
49
+ if (Object.prototype.hasOwnProperty.call(parsed, "messages")) {
50
+ if (!Array.isArray(parsed.messages)) {
51
+ state.diagnostics.add("unsupported_token_shape");
52
+ return finish(state);
53
+ }
54
+ for (const message of parsed.messages) {
55
+ if (isRecord(message))
56
+ processMessage(message, state);
57
+ }
58
+ }
59
+ else {
60
+ processRecord(parsed, state);
61
+ }
62
+ return finish(state);
63
+ }
64
+ /** Parse the append-only current JSONL message representation. */
65
+ export function parseGeminiJsonlSession(content, options) {
66
+ // Gemini's loader remains backward compatible with a legacy conversation
67
+ // document even when the file has a .jsonl suffix. Accept that exact shape
68
+ // before falling back to the append-only line reader.
69
+ try {
70
+ const whole = JSON.parse(content);
71
+ if (Array.isArray(whole) || (isRecord(whole) && Array.isArray(whole.messages))) {
72
+ return parseGeminiJsonSession(content, options);
73
+ }
74
+ }
75
+ catch {
76
+ // A normal JSONL stream is not one JSON document.
77
+ }
78
+ const state = createState(options);
79
+ for (const line of content.split(/\r?\n/)) {
80
+ if (!line.trim())
81
+ continue;
82
+ let parsed;
83
+ try {
84
+ parsed = JSON.parse(line);
85
+ }
86
+ catch {
87
+ state.diagnostics.add("malformed_jsonl");
88
+ continue;
89
+ }
90
+ if (!isRecord(parsed)) {
91
+ state.diagnostics.add("unsupported_token_shape");
92
+ continue;
93
+ }
94
+ processRecord(parsed, state);
95
+ }
96
+ return finish(state);
97
+ }
98
+ function createState(options) {
99
+ return {
100
+ metadata: {},
101
+ keyedCalls: new Map(),
102
+ diagnostics: new DiagnosticCounter(),
103
+ filePath: options.filePath,
104
+ ...(options.sinceMs !== undefined ? { sinceMs: options.sinceMs } : {})
105
+ };
106
+ }
107
+ function processRecordSequence(values, state) {
108
+ for (const value of values) {
109
+ if (isRecord(value))
110
+ processRecord(value, state);
111
+ }
112
+ }
113
+ function processRecord(record, state) {
114
+ if (isRecord(record.$set)) {
115
+ const update = record.$set;
116
+ state.metadata = mergeMetadata(state.metadata, update);
117
+ if (Object.prototype.hasOwnProperty.call(update, "messages")) {
118
+ if (!Array.isArray(update.messages)) {
119
+ state.diagnostics.add("unsupported_token_shape");
120
+ return;
121
+ }
122
+ // A checkpoint can replace conversational state, but earlier model work
123
+ // was still incurred usage. Reprocessing keyed messages updates matching
124
+ // ids without deleting prior unique token-bearing calls.
125
+ for (const message of update.messages) {
126
+ if (isRecord(message))
127
+ processMessage(message, state);
128
+ }
129
+ }
130
+ return;
131
+ }
132
+ if (nonEmptyString(record.$rewindTo)) {
133
+ // Rewind changes resumable conversation state, not already incurred model
134
+ // usage. Financial evidence therefore keeps earlier token-bearing calls.
135
+ return;
136
+ }
137
+ state.metadata = mergeMetadata(state.metadata, record);
138
+ if (Object.prototype.hasOwnProperty.call(record, "messages")) {
139
+ if (!Array.isArray(record.messages)) {
140
+ state.diagnostics.add("unsupported_token_shape");
141
+ return;
142
+ }
143
+ for (const message of record.messages) {
144
+ if (isRecord(message))
145
+ processMessage(message, state);
146
+ }
147
+ return;
148
+ }
149
+ if (record.type === "gemini") {
150
+ processMessage(record, state);
151
+ }
152
+ else if (Object.prototype.hasOwnProperty.call(record, "tokens")) {
153
+ // Evolving token-bearing envelopes are evidence of coverage we cannot yet
154
+ // normalize. Surface partial coverage instead of silently omitting them.
155
+ state.diagnostics.add("unsupported_token_shape");
156
+ }
157
+ }
158
+ function processMessage(message, state) {
159
+ if (message.type !== "gemini") {
160
+ if (Object.prototype.hasOwnProperty.call(message, "tokens")) {
161
+ state.diagnostics.add("unsupported_token_shape");
162
+ }
163
+ return;
164
+ }
165
+ const messageId = nonEmptyString(message.id);
166
+ // A later tokenless duplicate must not erase an earlier complete snapshot.
167
+ // A unique tokenless Gemini response is still missing financial evidence,
168
+ // so retain it as unsupported instead of silently calling coverage complete.
169
+ if ((!Object.prototype.hasOwnProperty.call(message, "tokens") || message.tokens === null) &&
170
+ messageId &&
171
+ state.keyedCalls.get(messageId)?.usageSupport === "complete") {
172
+ return;
173
+ }
174
+ const timestamp = firstIsoTimestamp(message.timestamp, message.createdAt, message.created_at);
175
+ if (!timestamp) {
176
+ state.diagnostics.add("missing_timestamp");
177
+ return;
178
+ }
179
+ if (state.sinceMs !== undefined && Date.parse(timestamp) < state.sinceMs)
180
+ return;
181
+ const parsedTokens = parseTokens(message.tokens);
182
+ const metadata = mergeMetadata(state.metadata, message);
183
+ const attribution = explicitAttribution(message, metadata) ??
184
+ opaqueAttribution(state.filePath, metadata.projectHash);
185
+ const sessionId = firstString(message.sessionId, message.session_id, metadata.sessionId);
186
+ if (!messageId || !sessionId) {
187
+ // A stable session + message identity is required to prevent copied or
188
+ // checkpointed chat files from being counted twice across the chats tree.
189
+ state.diagnostics.add("unsupported_token_shape");
190
+ return;
191
+ }
192
+ const sourceVersion = sourceVersionOf(message) ?? metadata.sourceVersion;
193
+ const model = firstString(message.model, metadata.model) ?? "gemini-cli-unknown";
194
+ const startedAt = isoTimestamp(metadata.startedAt);
195
+ const call = {
196
+ agent: "gemini-cli",
197
+ callId: messageId,
198
+ model,
199
+ timestamp,
200
+ ...(startedAt ? { startedAt } : {}),
201
+ ...(attribution?.project ? { project: attribution.project } : {}),
202
+ ...(attribution?.workingDirectory
203
+ ? { workingDirectory: attribution.workingDirectory }
204
+ : {}),
205
+ ...(sessionId ? { sessionId } : {}),
206
+ usageScope: "turn",
207
+ usageSupport: parsedTokens.supported ? "complete" : "unsupported_token_shape",
208
+ ...(parsedTokens.reportedTotalTokens !== undefined
209
+ ? { reportedTotalTokens: parsedTokens.reportedTotalTokens }
210
+ : {}),
211
+ ...(sourceVersion ? { sourceVersion } : {}),
212
+ usage: parsedTokens.usage,
213
+ geminiTokenEvidence: parsedTokens.evidence
214
+ };
215
+ // Delete first so iteration order also reflects the authoritative snapshot.
216
+ state.keyedCalls.delete(messageId);
217
+ state.keyedCalls.set(messageId, call);
218
+ }
219
+ function parseTokens(value) {
220
+ if (!isRecord(value)) {
221
+ return {
222
+ usage: { inputTokens: 0, outputTokens: 0, cacheReadTokens: 0 },
223
+ evidence: { cacheAccounting: "unknown" },
224
+ supported: false
225
+ };
226
+ }
227
+ const input = tokenField(value, "input");
228
+ const output = tokenField(value, "output");
229
+ const cached = tokenField(value, "cached");
230
+ const thoughts = tokenField(value, "thoughts");
231
+ const tool = tokenField(value, "tool");
232
+ const total = tokenField(value, "total");
233
+ const detailedFieldsValid = thoughts.present && thoughts.value !== undefined &&
234
+ tool.present && tool.value !== undefined;
235
+ const requiredFieldsValid = input.present && input.value !== undefined &&
236
+ output.present && output.value !== undefined &&
237
+ cached.present && cached.value !== undefined &&
238
+ total.present && total.value !== undefined;
239
+ const rawInput = input.value;
240
+ const rawOutput = output.value;
241
+ const rawCached = cached.value;
242
+ const rawThoughts = thoughts.value ?? 0;
243
+ const rawTool = tool.value ?? 0;
244
+ const rawTotal = total.value;
245
+ let cacheAccounting = "unknown";
246
+ if (requiredFieldsValid && detailedFieldsValid &&
247
+ rawInput !== undefined && rawOutput !== undefined &&
248
+ rawCached !== undefined && rawTotal !== undefined) {
249
+ const includedTotal = rawInput + rawOutput + rawThoughts + rawTool;
250
+ if (Number.isSafeInteger(includedTotal)) {
251
+ if (rawCached === 0 && rawTotal === includedTotal) {
252
+ cacheAccounting = "none";
253
+ }
254
+ else if (rawTotal === includedTotal && rawCached <= rawInput) {
255
+ cacheAccounting = "included";
256
+ }
257
+ }
258
+ }
259
+ const supported = requiredFieldsValid && detailedFieldsValid &&
260
+ cacheAccounting !== "unknown";
261
+ const normalizedInput = cacheAccounting === "included"
262
+ ? (rawInput ?? 0) - (rawCached ?? 0)
263
+ : cacheAccounting === "none"
264
+ ? rawInput ?? 0
265
+ : 0;
266
+ // Cached is an independently reported component. Retain it even when the
267
+ // fresh-input split is ambiguous; fresh input remains zero in that state so
268
+ // this never double-counts an uncertain overlap.
269
+ const normalizedCache = rawCached ?? 0;
270
+ return {
271
+ usage: {
272
+ inputTokens: normalizedInput,
273
+ outputTokens: rawOutput ?? 0,
274
+ ...(cached.value !== undefined ? { cacheReadTokens: normalizedCache } : {}),
275
+ ...(thoughts.value !== undefined ? { thoughtTokens: rawThoughts } : {}),
276
+ ...(tool.value !== undefined ? { toolTokens: rawTool } : {})
277
+ },
278
+ evidence: {
279
+ ...(rawInput !== undefined ? { input: rawInput } : {}),
280
+ ...(rawOutput !== undefined ? { output: rawOutput } : {}),
281
+ ...(rawCached !== undefined ? { cached: rawCached } : {}),
282
+ ...(thoughts.value !== undefined ? { thoughts: thoughts.value } : {}),
283
+ ...(tool.value !== undefined ? { tool: tool.value } : {}),
284
+ ...(rawTotal !== undefined ? { total: rawTotal } : {}),
285
+ cacheAccounting
286
+ },
287
+ supported,
288
+ ...(rawTotal !== undefined ? { reportedTotalTokens: rawTotal } : {})
289
+ };
290
+ }
291
+ function tokenField(record, key) {
292
+ if (!Object.prototype.hasOwnProperty.call(record, key))
293
+ return { present: false };
294
+ const value = record[key];
295
+ return typeof value === "number" && Number.isSafeInteger(value) && value >= 0
296
+ ? { present: true, value }
297
+ : { present: true };
298
+ }
299
+ function mergeMetadata(current, record) {
300
+ const sessionId = firstString(record.sessionId, record.session_id);
301
+ const projectHash = nonEmptyString(record.projectHash);
302
+ const startedAt = firstIsoTimestamp(record.startTime, record.startedAt);
303
+ const model = nonEmptyString(record.model);
304
+ const sourceVersion = sourceVersionOf(record);
305
+ const explicit = explicitAttributionFromRecord(record, projectHash ?? current.projectHash);
306
+ return {
307
+ ...current,
308
+ ...(sessionId ? { sessionId } : {}),
309
+ ...(projectHash ? { projectHash } : {}),
310
+ ...(startedAt ? { startedAt } : {}),
311
+ ...(model ? { model } : {}),
312
+ ...(sourceVersion ? { sourceVersion } : {}),
313
+ ...(explicit?.project ? { project: explicit.project } : {}),
314
+ ...(explicit?.workingDirectory
315
+ ? { workingDirectory: explicit.workingDirectory }
316
+ : {})
317
+ };
318
+ }
319
+ function sourceVersionOf(record) {
320
+ return safeVersionString(record.geminiCliVersion) ?? safeVersionString(record.cliVersion);
321
+ }
322
+ function safeVersionString(value) {
323
+ const parsed = nonEmptyString(value);
324
+ return parsed && parsed.length <= 64 && /^[A-Za-z0-9][A-Za-z0-9.+_-]*$/.test(parsed)
325
+ ? parsed
326
+ : undefined;
327
+ }
328
+ function explicitAttribution(record, metadata) {
329
+ return explicitAttributionFromRecord(record, metadata.projectHash) ??
330
+ (metadata.project || metadata.workingDirectory
331
+ ? {
332
+ ...(metadata.project ? { project: metadata.project } : {}),
333
+ ...(metadata.workingDirectory
334
+ ? { workingDirectory: metadata.workingDirectory }
335
+ : {})
336
+ }
337
+ : undefined);
338
+ }
339
+ function explicitAttributionFromRecord(record, projectHash) {
340
+ const pathValue = firstString(record.cwd, record.workingDirectory, record.working_directory, record.projectPath, record.project_path);
341
+ if (pathValue && isAbsolutePath(pathValue)) {
342
+ const normalized = trimTrailingSeparators(pathValue);
343
+ const project = lastPathSegment(normalized);
344
+ if (project)
345
+ return { project, workingDirectory: normalized };
346
+ }
347
+ const project = nonEmptyString(record.project);
348
+ if (!project || project === projectHash || looksLikeProjectHash(project))
349
+ return undefined;
350
+ if (project.includes("/") || project.includes("\\"))
351
+ return undefined;
352
+ return { project };
353
+ }
354
+ function opaqueAttribution(filePath, metadataProjectHash) {
355
+ const pathProjectHash = projectHashFromPath(filePath);
356
+ const recordedProjectHash = metadataProjectHash && looksLikeProjectHash(metadataProjectHash)
357
+ ? metadataProjectHash
358
+ : undefined;
359
+ // Conflicting opaque identities are not attribution evidence. Financial
360
+ // tokens remain usable, but project ownership stays unattributed.
361
+ if (pathProjectHash && recordedProjectHash && pathProjectHash !== recordedProjectHash) {
362
+ return undefined;
363
+ }
364
+ const projectHash = pathProjectHash ?? recordedProjectHash;
365
+ if (!projectHash)
366
+ return undefined;
367
+ const alias = createHash("sha256").update(projectHash).digest("hex").slice(0, 12);
368
+ return { project: `gemini-project-${alias}` };
369
+ }
370
+ function projectHashFromPath(filePath) {
371
+ const parts = filePath.split(/[\\/]+/).filter(Boolean);
372
+ for (let index = parts.length - 1; index >= 0; index -= 1) {
373
+ if (parts[index] !== "chats" || index === 0)
374
+ continue;
375
+ const value = parts[index - 1];
376
+ if (value && looksLikeProjectHash(value))
377
+ return value;
378
+ }
379
+ return undefined;
380
+ }
381
+ function looksLikeProjectHash(value) {
382
+ return /^[a-f\d]{64}$/i.test(value);
383
+ }
384
+ function isAbsolutePath(value) {
385
+ return isAbsolute(value) || /^[A-Za-z]:[\\/]/.test(value) || /^\\\\/.test(value);
386
+ }
387
+ function trimTrailingSeparators(value) {
388
+ const trimmed = value.replace(/[\\/]+$/, "");
389
+ return trimmed || value;
390
+ }
391
+ function lastPathSegment(value) {
392
+ return value.split(/[\\/]+/).filter(Boolean).at(-1);
393
+ }
394
+ function finish(state) {
395
+ const calls = [...state.keyedCalls.values()]
396
+ .sort((left, right) => left.timestamp.localeCompare(right.timestamp));
397
+ const unsupported = calls.filter((call) => (call.usageSupport === "unsupported_token_shape")).length;
398
+ if (unsupported > 0)
399
+ state.diagnostics.add("unsupported_token_shape", unsupported);
400
+ return {
401
+ calls,
402
+ diagnostics: state.diagnostics.values()
403
+ };
404
+ }
405
+ function extensionOf(filePath) {
406
+ const basename = filePath.split(/[\\/]+/).at(-1) ?? "";
407
+ const index = basename.lastIndexOf(".");
408
+ return index >= 0 ? basename.slice(index).toLowerCase() : "";
409
+ }
410
+ function firstString(...values) {
411
+ for (const value of values) {
412
+ const parsed = nonEmptyString(value);
413
+ if (parsed)
414
+ return parsed;
415
+ }
416
+ return undefined;
417
+ }
418
+ function nonEmptyString(value) {
419
+ if (typeof value !== "string")
420
+ return undefined;
421
+ const trimmed = value.trim();
422
+ return trimmed ? trimmed : undefined;
423
+ }
424
+ function firstIsoTimestamp(...values) {
425
+ for (const value of values) {
426
+ const parsed = isoTimestamp(value);
427
+ if (parsed)
428
+ return parsed;
429
+ }
430
+ return undefined;
431
+ }
432
+ function isoTimestamp(value) {
433
+ if (typeof value !== "string" || !value.trim())
434
+ return undefined;
435
+ const milliseconds = Date.parse(value);
436
+ if (!Number.isFinite(milliseconds))
437
+ return undefined;
438
+ return new Date(milliseconds).toISOString();
439
+ }
440
+ function isRecord(value) {
441
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
442
+ }
443
+ //# sourceMappingURL=gemini.js.map
@@ -2,7 +2,8 @@ import type { LocalAgentFormatDescriptor, LocalAgentFormatId } from "./types.js"
2
2
  export declare const localAgentFormatDescriptors: readonly LocalAgentFormatDescriptor[];
3
3
  export declare function localAgentFormatDescriptor(id: LocalAgentFormatId): LocalAgentFormatDescriptor | undefined;
4
4
  export declare function localAgentFormatLabel(id: LocalAgentFormatId): string;
5
- export declare function localAgentFormatSupports(id: LocalAgentFormatId, capability: keyof LocalAgentFormatDescriptor["capabilities"]): boolean;
5
+ export declare function localAgentFormatSupports(id: string | undefined, capability: keyof LocalAgentFormatDescriptor["capabilities"]): boolean;
6
6
  export declare function matchesLocalAgentFormatFile(descriptor: LocalAgentFormatDescriptor, filePath: string): boolean;
7
+ export declare function matchesLocalAgentDetectionFile(descriptor: LocalAgentFormatDescriptor, filePath: string): boolean;
7
8
  export declare function validateLocalAgentFormatDescriptors(registry?: readonly LocalAgentFormatDescriptor[]): void;
8
9
  //# sourceMappingURL=registry.d.ts.map
@@ -1,4 +1,4 @@
1
- import { basename } from "node:path";
1
+ import { basename, dirname } from "node:path";
2
2
  const descriptors = [
3
3
  {
4
4
  schemaVersion: 1,
@@ -24,13 +24,15 @@ const descriptors = [
24
24
  operation: "claude-code sessions"
25
25
  },
26
26
  capabilities: {
27
+ actionPlanning: true,
27
28
  activity: true,
28
29
  contextHealth: true,
29
30
  financialFastPath: true,
30
31
  glance: true,
31
32
  invocationEvidence: false,
32
33
  planContext: true,
33
- rateLimits: false
34
+ rateLimits: false,
35
+ statuslineSnapshot: true
34
36
  },
35
37
  financialRead: "full_jsonl",
36
38
  validationNote: "Local transcript parsing is exercised against live logs; dollar values remain API-rate estimates.",
@@ -92,13 +94,15 @@ const descriptors = [
92
94
  operation: "codex sessions"
93
95
  },
94
96
  capabilities: {
97
+ actionPlanning: true,
95
98
  activity: true,
96
99
  contextHealth: true,
97
100
  financialFastPath: true,
98
101
  glance: true,
99
102
  invocationEvidence: true,
100
103
  planContext: true,
101
- rateLimits: true
104
+ rateLimits: true,
105
+ statuslineSnapshot: true
102
106
  },
103
107
  financialRead: "bounded_event_jsonl",
104
108
  validationNote: "Local parsing was replayed against live logs; total-only token shapes and unknown aliases remain missing rather than becoming estimated $0.",
@@ -135,6 +139,87 @@ const descriptors = [
135
139
  ]
136
140
  },
137
141
  fixtures: ["codex-v1"]
142
+ },
143
+ {
144
+ schemaVersion: 1,
145
+ id: "gemini-cli",
146
+ order: 30,
147
+ label: "Gemini CLI",
148
+ provider: "google",
149
+ defaultHomeRelative: [".gemini", "tmp"],
150
+ legacyDirectoryOption: "geminiSessionsDir",
151
+ discovery: {
152
+ extensions: [".json", ".jsonl"],
153
+ ancestorBasename: "chats",
154
+ detectionBasename: "logs.json"
155
+ },
156
+ confidenceDefaults: {
157
+ validationCoverage: "fixture_verified",
158
+ pricedFinancialEvidence: "estimated",
159
+ unpricedFinancialEvidence: "missing",
160
+ sourceConfidence: "estimated"
161
+ },
162
+ sourceRecord: {
163
+ id: "local-agent-logs",
164
+ name: "Local agent session logs",
165
+ providerCostType: "local_agent_logs",
166
+ observedFrom: "gemini-cli chats session JSON/JSONL (this machine)",
167
+ usageGranularity: "daily_aggregate",
168
+ operation: "gemini-cli sessions"
169
+ },
170
+ capabilities: {
171
+ actionPlanning: false,
172
+ activity: false,
173
+ contextHealth: false,
174
+ financialFastPath: true,
175
+ glance: false,
176
+ invocationEvidence: false,
177
+ planContext: false,
178
+ rateLimits: false,
179
+ statuslineSnapshot: false
180
+ },
181
+ financialRead: "full_session_files",
182
+ validationNote: "Gemini CLI session support is experimental and fixture-verified; evolving, incomplete, or modality-ambiguous token shapes remain missing rather than becoming estimated $0.",
183
+ docs: {
184
+ format: "JSON and JSON Lines under ~/.gemini/tmp/<opaque-project-id>/chats/**/*.{json,jsonl}",
185
+ howRead: [
186
+ "Read supported Gemini response records from chats session JSON or JSONL files, including nested subagent sessions.",
187
+ "For repeated JSONL message ids, retain the last token-bearing version so streaming updates are not double-counted.",
188
+ "Use logs.json only to detect that Gemini CLI is present; it never enters the financial parser or creates a financial row."
189
+ ],
190
+ fieldsRead: [
191
+ "timestamp, model, and explicit input, output, cached, thought, tool, and total token components",
192
+ "session identity and usable session-carried project metadata when present",
193
+ "Gemini CLI version metadata when the session format reports it"
194
+ ],
195
+ verified: [
196
+ "Synthetic recorded fixtures lock supported legacy JSON and current JSONL shapes, including cache-overlap and duplicate-message handling."
197
+ ],
198
+ estimated: [
199
+ "Complete, internally consistent Gemini 2.5 Pro token components are priced at published API rates as API-equivalent value only when the request's pricing tier is unambiguous.",
200
+ "Thought tokens are output-priced and tool tokens input-priced only when the explicit split is supported."
201
+ ],
202
+ notVerified: [
203
+ "Gemini CLI chat files are evolving and this reader is experimental, not live-verified.",
204
+ "API-equivalent value is not billed spend, subscription cost, savings, or ROI.",
205
+ "Session token counters do not establish authentication billing mode, free-tier coverage, subscription coverage, grounding/search fees, tool-specific fees, or context-cache storage duration charges.",
206
+ "Unknown models, missing components, and inconsistent totals remain missing financial evidence rather than estimated $0.",
207
+ "Gemini 2.5 Flash and Flash-Lite rows remain missing because chats summaries omit token modality while published audio and non-audio rates differ."
208
+ ],
209
+ privacy: [
210
+ "Parsing and aggregation run locally; raw prompts and responses are not returned by the parser registry.",
211
+ "Opaque project hashes are never reversed, guessed, or exposed; an opaque local alias is used when no usable project field exists.",
212
+ "aibill never sits in the inference path and never stores, prints, or proxies provider credentials."
213
+ ],
214
+ limitations: [
215
+ "The <opaque-project-id> directory is not treated as a readable project path.",
216
+ "logs.json contains prompt-history/resume metadata, not financial token evidence.",
217
+ "Normalized inputTokens/outputTokens are inclusive headline totals; cached/tool/thought fields are component subsets and must not be added again.",
218
+ "A Gemini 2.5 Pro request remains unpriced when separate prompt and tool-token counts straddle the published 200k pricing threshold.",
219
+ "Rewound JSONL turns still represent incurred usage; this reader does not reconstruct a cost-free final conversation."
220
+ ]
221
+ },
222
+ fixtures: ["gemini-cli-v1"]
138
223
  }
139
224
  ];
140
225
  export const localAgentFormatDescriptors = Object.freeze([...descriptors]
@@ -147,18 +232,25 @@ export function localAgentFormatLabel(id) {
147
232
  return localAgentFormatDescriptor(id)?.label ?? id;
148
233
  }
149
234
  export function localAgentFormatSupports(id, capability) {
150
- return localAgentFormatDescriptor(id)?.capabilities[capability] === true;
235
+ return localAgentFormatDescriptors.some((descriptor) => (descriptor.id === id && descriptor.capabilities[capability] === true));
151
236
  }
152
237
  export function matchesLocalAgentFormatFile(descriptor, filePath) {
153
238
  const name = basename(filePath);
154
- if (descriptor.discovery.extension && !name.endsWith(descriptor.discovery.extension))
239
+ const extensions = descriptor.discovery.extensions ?? (descriptor.discovery.extension ? [descriptor.discovery.extension] : []);
240
+ if (extensions.length > 0 && !extensions.some((extension) => name.endsWith(extension)))
155
241
  return false;
156
242
  if (descriptor.discovery.basename && name !== descriptor.discovery.basename)
157
243
  return false;
158
244
  if (descriptor.discovery.basenamePrefix && !name.startsWith(descriptor.discovery.basenamePrefix))
159
245
  return false;
246
+ if (descriptor.discovery.ancestorBasename && !hasAncestorBasename(filePath, descriptor.discovery.ancestorBasename))
247
+ return false;
160
248
  return true;
161
249
  }
250
+ export function matchesLocalAgentDetectionFile(descriptor, filePath) {
251
+ return Boolean(descriptor.discovery.detectionBasename &&
252
+ basename(filePath) === descriptor.discovery.detectionBasename);
253
+ }
162
254
  export function validateLocalAgentFormatDescriptors(registry = localAgentFormatDescriptors) {
163
255
  const ids = new Set();
164
256
  const orders = new Set();
@@ -179,13 +271,24 @@ export function validateLocalAgentFormatDescriptors(registry = localAgentFormatD
179
271
  throw new Error(`Unsafe default local-agent root for ${descriptor.id}.`);
180
272
  }
181
273
  const discovery = descriptor.discovery;
182
- if (!discovery.extension && !discovery.basename && !discovery.basenamePrefix) {
274
+ if (!discovery.extension && !discovery.extensions?.length &&
275
+ !discovery.basename && !discovery.basenamePrefix) {
183
276
  throw new Error(`Local-agent format ${descriptor.id} must declare a bounded file rule.`);
184
277
  }
185
- if (discovery.extension && !/^\.[A-Za-z0-9]+$/.test(discovery.extension)) {
186
- throw new Error(`Unsafe discovery extension for ${descriptor.id}.`);
278
+ for (const extension of [
279
+ ...(discovery.extension ? [discovery.extension] : []),
280
+ ...(discovery.extensions ?? [])
281
+ ]) {
282
+ if (!/^\.[A-Za-z0-9]+$/.test(extension)) {
283
+ throw new Error(`Unsafe discovery extension for ${descriptor.id}.`);
284
+ }
187
285
  }
188
- for (const value of [discovery.basename, discovery.basenamePrefix]) {
286
+ for (const value of [
287
+ discovery.basename,
288
+ discovery.basenamePrefix,
289
+ discovery.ancestorBasename,
290
+ discovery.detectionBasename
291
+ ]) {
189
292
  if (value && (!/^[A-Za-z0-9][A-Za-z0-9._-]*$/.test(value) || /[\\/\u0000]/.test(value))) {
190
293
  throw new Error(`Unsafe discovery filename rule for ${descriptor.id}.`);
191
294
  }
@@ -204,6 +307,8 @@ export function validateLocalAgentFormatDescriptors(registry = localAgentFormatD
204
307
  }
205
308
  function freezeDescriptor(descriptor) {
206
309
  Object.freeze(descriptor.defaultHomeRelative);
310
+ if (descriptor.discovery.extensions)
311
+ Object.freeze(descriptor.discovery.extensions);
207
312
  Object.freeze(descriptor.discovery);
208
313
  Object.freeze(descriptor.confidenceDefaults);
209
314
  Object.freeze(descriptor.sourceRecord);
@@ -216,4 +321,15 @@ function freezeDescriptor(descriptor) {
216
321
  Object.freeze(descriptor.fixtures);
217
322
  return Object.freeze(descriptor);
218
323
  }
324
+ function hasAncestorBasename(filePath, expected) {
325
+ let current = dirname(filePath);
326
+ while (true) {
327
+ if (basename(current) === expected)
328
+ return true;
329
+ const parent = dirname(current);
330
+ if (parent === current)
331
+ return false;
332
+ current = parent;
333
+ }
334
+ }
219
335
  //# sourceMappingURL=registry.js.map