@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.
- package/README.md +35 -2
- package/dist/activitySnapshot.d.ts +8 -8
- package/dist/activitySnapshot.js +20 -9
- package/dist/cutList.js +9 -1
- package/dist/glance.js +3 -1
- package/dist/localAgentFormats/gemini.d.ts +55 -0
- package/dist/localAgentFormats/gemini.js +443 -0
- package/dist/localAgentFormats/registry.d.ts +2 -1
- package/dist/localAgentFormats/registry.js +125 -9
- package/dist/localAgentFormats/runtimeRegistry.js +25 -2
- package/dist/localAgentFormats/types.d.ts +13 -4
- package/dist/localAgentLogs.d.ts +25 -4
- package/dist/localAgentLogs.js +215 -17
- package/dist/modelPricing.d.ts +33 -1
- package/dist/modelPricing.js +117 -13
- package/dist/providerConnectors.d.ts +16 -0
- package/dist/providerConnectors.js +304 -33
- package/dist/providerContractStates.generated.d.ts +8 -0
- package/dist/providerContractStates.generated.js +9 -0
- package/dist/schema.d.ts +18 -0
- package/dist/schema.js +26 -0
- package/dist/sourceStatus.d.ts +23 -1
- package/dist/sourceStatus.js +104 -9
- package/dist/stateTrust.js +2 -2
- package/package.json +8 -1
|
@@ -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:
|
|
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
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
186
|
-
|
|
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 [
|
|
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
|