@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
@@ -0,0 +1,606 @@
1
+ import { constants } from "node:fs";
2
+ import { lstat, mkdir, open, readdir, rename, unlink } from "node:fs/promises";
3
+ import { randomUUID } from "node:crypto";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { localAgentFinancialParserVersion, localAgentQualitativeParserVersion } from "./localAgentLogs.js";
7
+ import { QualitativeIndexCacheError, cachedWindowCoversRequest, hasPrivatePermissions, invocationWindowCanBeNarrowedExactly, isNodeError, noFollowFlag, qualitativeEntryKeySchema, qualitativeEntryValueSchema, qualitativeKeyFingerprint, resolveCacheDirectory, sameQualitativeFileKey, stripRawPaths, syncDirectory, withWriterLock } from "./qualitativeIndexCache.js";
8
+ export const projectIndexStoreDirectoryName = "project-index-v2";
9
+ export const projectIndexStoreLockFileName = ".project-index-v2.lock";
10
+ /** One transcript's derived evidence, all windowed variants included. */
11
+ export const projectIndexMaxDocumentBytes = 8 * 1_024 * 1_024;
12
+ /** Null-window variant plus the newest bounded windows (BLOCKER-2 option i). */
13
+ export const projectIndexMaxWindowedVariants = 4;
14
+ export const projectIndexFinancialParserVersion = localAgentFinancialParserVersion;
15
+ /**
16
+ * The financial value reuses the strict privacy-reduced v1 value contract.
17
+ * Financial parses never carry invocation evidence; the schema keeps that
18
+ * impossible rather than merely unexpected.
19
+ */
20
+ const financialValueSchema = qualitativeEntryValueSchema.refine((value) => value.invocationFile === undefined && value.invocationWindowProof === undefined, { message: "financial entries never carry invocation evidence" });
21
+ const financialKeySchema = z.object({
22
+ schemaVersion: z.literal(2),
23
+ section: z.literal("financial"),
24
+ agent: z.enum(["claude-code", "codex", "gemini-cli"]),
25
+ pathHash: z.string().regex(/^[a-f0-9]{64}$/),
26
+ fileIdentity: z.string().min(11).max(256),
27
+ financialParserVersion: z.literal(projectIndexFinancialParserVersion)
28
+ }).strict();
29
+ const qualitativeVariantSchema = z.object({
30
+ key: qualitativeEntryKeySchema,
31
+ storedAt: z.string().datetime({ offset: true }),
32
+ value: qualitativeEntryValueSchema
33
+ }).strict();
34
+ const financialSectionSchema = z.object({
35
+ key: financialKeySchema,
36
+ storedAt: z.string().datetime({ offset: true }),
37
+ value: financialValueSchema
38
+ }).strict();
39
+ /**
40
+ * Header-pass ownership evidence (A4a consumer). "unknown" is a first-class
41
+ * state: ownership is never guessed from hashes, basenames, or absence.
42
+ */
43
+ const ownershipSchema = z.object({
44
+ status: z.enum(["resolved", "no_calls", "unknown"]),
45
+ /** Ownership binds to one exact file identity; rotation supersedes it. */
46
+ fileIdentity: z.string().min(11).max(256),
47
+ projectRefs: z.array(z.string().regex(/^avref_[a-f0-9]{64}$/)).max(64),
48
+ headerAttribution: z.object({
49
+ status: z.enum(["proven", "unknown"]),
50
+ projectRef: z.string().regex(/^avref_[a-f0-9]{64}$/).optional(),
51
+ /** Header subagent marker — a scheduling hint only, never ownership. */
52
+ isSubagent: z.boolean().optional()
53
+ }).strict().optional()
54
+ }).strict();
55
+ /**
56
+ * Stream checkpoint envelope (design section e). The store validates the
57
+ * envelope strictly and treats the reducer/collector payloads as opaque
58
+ * bounded JSON: the loader owns their structure and re-validates on every
59
+ * resume, so a malformed payload degrades to a restart, never a crash or a
60
+ * reinterpretation. The parser version is pinned as a literal so checkpoints
61
+ * from any other parser contract fail closed as misses.
62
+ *
63
+ * schemaVersion 2: version 1 checkpoints were written before prompt
64
+ * survivors had absolute-path spans stripped, so raw local paths can sit in
65
+ * their reducer state on disk. They are deliberately unreadable AND purged
66
+ * on sight by the read path below — never resumed, never reinterpreted.
67
+ */
68
+ const checkpointDocumentSchema = z.object({
69
+ kind: z.literal("aibill.project_index_checkpoint"),
70
+ schemaVersion: z.literal(2),
71
+ agent: z.enum(["claude-code", "codex", "gemini-cli"]),
72
+ pathHash: z.string().regex(/^[a-f0-9]{64}$/),
73
+ storedAt: z.string().datetime({ offset: true }),
74
+ checkpoint: z.object({
75
+ pin: z.object({
76
+ dev: z.number().finite(),
77
+ ino: z.number().finite(),
78
+ birthtimeMs: z.number().finite()
79
+ }).strict(),
80
+ parserVersion: z.literal(localAgentQualitativeParserVersion),
81
+ collectInvocationEvidence: z.boolean(),
82
+ sinceIso: z.string().datetime({ offset: true }).nullable(),
83
+ offset: z.number().int().nonnegative(),
84
+ prefixProbe: z.object({
85
+ bytes: z.number().int().nonnegative().max(64 * 1_024),
86
+ sha256: z.string().regex(/^[a-f0-9]{64}$/)
87
+ }).strict(),
88
+ reducerState: z.unknown(),
89
+ collectorState: z.unknown().optional()
90
+ }).strict()
91
+ }).strict();
92
+ const documentSchema = z.object({
93
+ kind: z.literal("aibill.project_index_file"),
94
+ schemaVersion: z.literal(2),
95
+ agent: z.enum(["claude-code", "codex", "gemini-cli"]),
96
+ pathHash: z.string().regex(/^[a-f0-9]{64}$/),
97
+ qualitative: z.array(qualitativeVariantSchema).max(projectIndexMaxWindowedVariants),
98
+ financial: financialSectionSchema.optional(),
99
+ ownership: ownershipSchema.optional()
100
+ }).strict();
101
+ export const projectIndexGcGraceMs = 10 * 60 * 1_000;
102
+ /**
103
+ * Sharded per-transcript store. Entry documents are lock-free: writes are
104
+ * private O_EXCL temp files atomically renamed over the shard path, so readers
105
+ * observe either complete version and concurrent same-key writers converge on
106
+ * semantically identical content (storedAt may differ; last writer wins).
107
+ * The writer lock guards only cross-document operations (GC).
108
+ */
109
+ export function createProjectIndexAdapters(options = {}) {
110
+ const memoize = options.memoizeDocuments !== false;
111
+ const documents = new Map();
112
+ const invalidate = (cachePath) => documents.delete(cachePath);
113
+ const document = (cachePath, storeRoot) => {
114
+ if (!memoize)
115
+ return readDocument(join(storeRoot, cachePath));
116
+ let loaded = documents.get(cachePath);
117
+ if (!loaded) {
118
+ loaded = readDocument(join(storeRoot, cachePath)).catch((error) => {
119
+ documents.delete(cachePath);
120
+ throw error;
121
+ });
122
+ documents.set(cachePath, loaded);
123
+ }
124
+ return loaded;
125
+ };
126
+ // Resolve and validate the private cache directory once per adapter
127
+ // instance, exactly like v1's memoized index load: the validation includes
128
+ // git-privacy probes that spawn subprocesses, and repeating them for every
129
+ // sharded document read multiplies a ~40 ms check into whole seconds.
130
+ const resolvedRoots = new Map();
131
+ const storeRootFor = (create) => {
132
+ const mode = create ? "create" : "read";
133
+ let resolved = resolvedRoots.get(mode) ??
134
+ (create ? resolvedRoots.get("read") : undefined);
135
+ if (!resolved) {
136
+ resolved = resolveCacheDirectory(create, options).then(async (cacheDirectory) => {
137
+ const storeRoot = join(cacheDirectory, projectIndexStoreDirectoryName);
138
+ if (create)
139
+ await mkdir(join(storeRoot, "entries"), { recursive: true, mode: 0o700 });
140
+ return storeRoot;
141
+ }).catch((error) => {
142
+ resolvedRoots.delete(mode);
143
+ throw error;
144
+ });
145
+ resolvedRoots.set(mode, resolved);
146
+ // A successful create-mode resolution satisfies read mode too.
147
+ if (create)
148
+ resolvedRoots.set("read", resolved);
149
+ }
150
+ return resolved;
151
+ };
152
+ const withStoreRoot = async (create, operation) => operation(await storeRootFor(create));
153
+ return {
154
+ qualitative: {
155
+ read: async (key) => {
156
+ const parsedKey = parseQualitativeKey(key);
157
+ return withStoreRoot(false, async (storeRoot) => {
158
+ const doc = await document(shardPath(parsedKey.pathHash), storeRoot);
159
+ if (!doc || doc.agent !== parsedKey.agent)
160
+ return undefined;
161
+ return selectQualitativeVariant(doc, parsedKey);
162
+ }).catch(swallowMissingStore);
163
+ },
164
+ write: async (key, value) => {
165
+ const parsedKey = parseQualitativeKey(key);
166
+ const parsedValue = qualitativeEntryValueSchema.parse(stripRawPaths(value));
167
+ assertQualitativeOwnershipInvariant(parsedKey, parsedValue);
168
+ await withStoreRoot(true, async (storeRoot) => {
169
+ const cachePath = shardPath(parsedKey.pathHash);
170
+ // Read-modify-write must start from disk, not the in-process memo: a
171
+ // concurrent process may have merged a variant since this process
172
+ // last read. The residual race is one whole-document rename losing
173
+ // to another (one variant lost, self-healed on the loser's next
174
+ // parse) — the same accepted loss as concurrent v1 writers.
175
+ const current = await readDocument(join(storeRoot, cachePath));
176
+ const next = upsertQualitativeVariant(dropSupersededOwnership(baseDocument(current, parsedKey.agent, parsedKey.pathHash), parsedKey.fileIdentity), { key: parsedKey, storedAt: new Date().toISOString(), value: parsedValue });
177
+ await writeDocument(join(storeRoot, cachePath), next);
178
+ invalidate(cachePath);
179
+ });
180
+ }
181
+ },
182
+ financial: {
183
+ read: async (key) => {
184
+ const parsedKey = financialKeySchema.parse(key);
185
+ return withStoreRoot(false, async (storeRoot) => {
186
+ const doc = await document(shardPath(parsedKey.pathHash), storeRoot);
187
+ if (!doc || doc.agent !== parsedKey.agent || !doc.financial)
188
+ return undefined;
189
+ const stored = doc.financial.key;
190
+ if (stored.fileIdentity !== parsedKey.fileIdentity ||
191
+ stored.financialParserVersion !== parsedKey.financialParserVersion) {
192
+ return undefined;
193
+ }
194
+ // Deep-clone: callers may re-attach probed context to their copy;
195
+ // the in-process memo must never observe those mutations.
196
+ return structuredClone(doc.financial.value);
197
+ }).catch(swallowMissingStore);
198
+ },
199
+ write: async (key, value) => {
200
+ const parsedKey = financialKeySchema.parse(key);
201
+ const parsedValue = financialValueSchema.parse(stripRawPaths(value));
202
+ await withStoreRoot(true, async (storeRoot) => {
203
+ const cachePath = shardPath(parsedKey.pathHash);
204
+ const current = await readDocument(join(storeRoot, cachePath));
205
+ const next = {
206
+ ...dropSupersededOwnership(baseDocument(current, parsedKey.agent, parsedKey.pathHash), parsedKey.fileIdentity),
207
+ financial: { key: parsedKey, storedAt: new Date().toISOString(), value: parsedValue }
208
+ };
209
+ await writeDocument(join(storeRoot, cachePath), documentSchema.parse(next));
210
+ invalidate(cachePath);
211
+ });
212
+ }
213
+ },
214
+ readOwnership: async (agent, pathHash) => withStoreRoot(false, async (storeRoot) => {
215
+ const doc = await document(shardPath(assertPathHash(pathHash)), storeRoot);
216
+ return doc && doc.agent === agent ? doc.ownership : undefined;
217
+ }).catch(swallowMissingStore),
218
+ writeOwnership: async (agent, pathHash, ownership) => {
219
+ const parsedOwnership = ownershipSchema.parse(ownership);
220
+ await withStoreRoot(true, async (storeRoot) => {
221
+ const cachePath = shardPath(assertPathHash(pathHash));
222
+ const current = await readDocument(join(storeRoot, cachePath));
223
+ const next = {
224
+ ...baseDocument(current, agent, pathHash),
225
+ ownership: parsedOwnership
226
+ };
227
+ await writeDocument(join(storeRoot, cachePath), documentSchema.parse(next));
228
+ invalidate(cachePath);
229
+ });
230
+ },
231
+ readStreamCheckpoint: async (agent, pathHash) => withStoreRoot(false, async (storeRoot) => {
232
+ const target = join(storeRoot, checkpointPath(assertPathHash(pathHash)));
233
+ const outcome = await readCheckpointDocument(target);
234
+ if (outcome.status === "invalid") {
235
+ // Superseded (pre-sanitization) or corrupt checkpoints may hold
236
+ // un-sanitized text: purge on sight. The stream restarts from byte
237
+ // zero and rewrites its state under the current contract.
238
+ await unlink(target).catch(() => undefined);
239
+ return undefined;
240
+ }
241
+ if (outcome.status !== "valid")
242
+ return undefined;
243
+ const doc = outcome.doc;
244
+ if (doc.agent !== agent || doc.pathHash !== pathHash)
245
+ return undefined;
246
+ return doc.checkpoint;
247
+ }).catch(swallowMissingStore),
248
+ writeStreamCheckpoint: async (agent, pathHash, checkpoint) => {
249
+ const document = checkpointDocumentSchema.parse({
250
+ kind: "aibill.project_index_checkpoint",
251
+ schemaVersion: 2,
252
+ agent,
253
+ pathHash: assertPathHash(pathHash),
254
+ storedAt: new Date().toISOString(),
255
+ checkpoint
256
+ });
257
+ const contents = `${JSON.stringify(document)}\n`;
258
+ if (Buffer.byteLength(contents, "utf8") > projectIndexMaxDocumentBytes) {
259
+ throw new QualitativeIndexCacheError("oversized", "Project index checkpoint exceeds its private bound.");
260
+ }
261
+ await withStoreRoot(true, async (storeRoot) => {
262
+ const target = join(storeRoot, checkpointPath(pathHash));
263
+ // Mirror readDocument's future-version guard: a newer process's
264
+ // checkpoint is never clobbered by this writer (fail closed).
265
+ if ((await readCheckpointDocument(target)).status === "future") {
266
+ throw new QualitativeIndexCacheError("unsupported_version", "Project index checkpoint was written by a newer aibill version.");
267
+ }
268
+ await writeJsonFileAtomically(target, contents);
269
+ });
270
+ },
271
+ deleteStreamCheckpoint: async (agent, pathHash) => {
272
+ void agent;
273
+ await withStoreRoot(false, async (storeRoot) => {
274
+ await unlink(join(storeRoot, checkpointPath(assertPathHash(pathHash))))
275
+ .catch((error) => {
276
+ if (!isNodeError(error, "ENOENT"))
277
+ throw error;
278
+ });
279
+ }).catch(swallowMissingStore);
280
+ },
281
+ collectGarbage: async (gcOptions = {}) => withWriterLock(options, async (cacheDirectory) => {
282
+ // Orphan sweep with a freshness grace window: an unretained entry is
283
+ // removed only when its last write is older than the grace period, so
284
+ // a concurrent process's seconds-old work is never collected. Crash-
285
+ // orphaned temp files are swept on the same aging rule; unexpected
286
+ // litter is skipped, never fatal.
287
+ const storeRoot = join(cacheDirectory, projectIndexStoreDirectoryName);
288
+ const retain = gcOptions.retainPathHashes;
289
+ if (!retain)
290
+ return { removed: 0 };
291
+ const graceMs = gcOptions.graceMs ?? projectIndexGcGraceMs;
292
+ const cutoff = Date.now() - Math.max(0, graceMs);
293
+ let removed = 0;
294
+ const entriesRoot = join(storeRoot, "entries");
295
+ const shards = await readdir(entriesRoot).catch((error) => {
296
+ if (isNodeError(error, "ENOENT"))
297
+ return [];
298
+ throw error;
299
+ });
300
+ for (const shard of shards) {
301
+ const shardDirectory = join(entriesRoot, shard);
302
+ const files = await readdir(shardDirectory).catch(() => []);
303
+ for (const file of files) {
304
+ const target = join(shardDirectory, file);
305
+ const info = await lstat(target).catch(() => undefined);
306
+ if (!info || !info.isFile() || info.mtimeMs > cutoff)
307
+ continue;
308
+ const isEntry = file.endsWith(".json");
309
+ const isTemp = file.includes(".json.") && file.endsWith(".tmp");
310
+ if (isEntry) {
311
+ const pathHash = file.slice(0, -5);
312
+ if (retain.has(pathHash))
313
+ continue;
314
+ }
315
+ else if (!isTemp) {
316
+ continue;
317
+ }
318
+ const gone = await unlink(target).then(() => true).catch(() => false);
319
+ if (!gone)
320
+ continue;
321
+ if (isEntry) {
322
+ documents.delete(join("entries", shard, file));
323
+ removed += 1;
324
+ }
325
+ }
326
+ }
327
+ // Checkpoints age out on the same retain/grace rule: an orphaned
328
+ // checkpoint is a restart at worst, never lost complete evidence.
329
+ const checkpointsRoot = join(storeRoot, "checkpoints");
330
+ const checkpointFiles = await readdir(checkpointsRoot).catch(() => []);
331
+ for (const file of checkpointFiles) {
332
+ const target = join(checkpointsRoot, file);
333
+ const info = await lstat(target).catch(() => undefined);
334
+ if (!info || !info.isFile() || info.mtimeMs > cutoff)
335
+ continue;
336
+ const isCheckpoint = file.endsWith(".json");
337
+ const isTemp = file.includes(".json.") && file.endsWith(".tmp");
338
+ if (isCheckpoint) {
339
+ if (retain.has(file.slice(0, -5)))
340
+ continue;
341
+ }
342
+ else if (!isTemp) {
343
+ continue;
344
+ }
345
+ const gone = await unlink(target).then(() => true).catch(() => false);
346
+ if (gone && isCheckpoint)
347
+ removed += 1;
348
+ }
349
+ return { removed };
350
+ }, projectIndexStoreLockFileName)
351
+ };
352
+ }
353
+ /**
354
+ * Ownership binds to one exact file identity; any write that observes a newer
355
+ * identity drops the stale attribution rather than serving it (rotation-safe).
356
+ */
357
+ function dropSupersededOwnership(doc, fileIdentity) {
358
+ if (!doc.ownership || doc.ownership.fileIdentity === fileIdentity)
359
+ return doc;
360
+ const { ownership: _superseded, ...rest } = doc;
361
+ return rest;
362
+ }
363
+ /** Mirror v1's assertOwnership write guard: calls must belong to the keyed agent. */
364
+ function assertQualitativeOwnershipInvariant(key, value) {
365
+ if (value.calls.some((call) => call.agent !== key.agent)) {
366
+ throw new QualitativeIndexCacheError("invalid_value", "Project index calls do not belong to the keyed parser.");
367
+ }
368
+ if (value.invocationFile && (!key.collectInvocationEvidence || key.agent !== "codex" ||
369
+ value.invocationFile.contextSignal.agent !== key.agent)) {
370
+ throw new QualitativeIndexCacheError("invalid_value", "Project index invocation evidence does not belong to the keyed parser window.");
371
+ }
372
+ }
373
+ function baseDocument(current, agent, pathHash) {
374
+ if (current && current.agent === agent && current.pathHash === pathHash)
375
+ return current;
376
+ return {
377
+ kind: "aibill.project_index_file",
378
+ schemaVersion: 2,
379
+ agent,
380
+ pathHash,
381
+ qualitative: []
382
+ };
383
+ }
384
+ function selectQualitativeVariant(doc, requestedKey) {
385
+ const fingerprint = qualitativeKeyFingerprint(requestedKey);
386
+ const exact = doc.qualitative.find((variant) => qualitativeKeyFingerprint(variant.key) === fingerprint);
387
+ if (exact)
388
+ return exact.value;
389
+ const compatible = doc.qualitative
390
+ .filter((variant) => sameQualitativeFileKey(variant.key, requestedKey))
391
+ .filter((variant) => cachedWindowCoversRequest(variant.key.sinceIso, requestedKey.sinceIso))
392
+ .filter((variant) => invocationWindowCanBeNarrowedExactly(variant.key, variant.value, requestedKey))
393
+ .sort((left, right) => (sinceSortValue(right.key.sinceIso) - sinceSortValue(left.key.sinceIso) ||
394
+ right.storedAt.localeCompare(left.storedAt)))[0];
395
+ return compatible?.value;
396
+ }
397
+ function upsertQualitativeVariant(current, variant) {
398
+ const fingerprint = qualitativeKeyFingerprint(variant.key);
399
+ const retained = current.qualitative.filter((candidate) => {
400
+ if (qualitativeKeyFingerprint(candidate.key) === fingerprint)
401
+ return false;
402
+ // A new file identity supersedes every variant of the old identity: the
403
+ // transcript changed, so stale windows must never satisfy a future read.
404
+ // Variants of the other invocation-evidence family are preserved
405
+ // (superset-merge: one caller's write never clobbers the other's).
406
+ return candidate.key.fileIdentity === variant.key.fileIdentity &&
407
+ candidate.key.parserVersion === variant.key.parserVersion;
408
+ });
409
+ const bounded = [...retained, variant]
410
+ .sort((left, right) => {
411
+ // Deterministic retention: the null (widest) window outranks everything,
412
+ // then newer windows outrank older ones.
413
+ const leftNull = left.key.sinceIso === null ? 0 : 1;
414
+ const rightNull = right.key.sinceIso === null ? 0 : 1;
415
+ return leftNull - rightNull ||
416
+ sinceSortValue(right.key.sinceIso) - sinceSortValue(left.key.sinceIso);
417
+ })
418
+ .slice(0, projectIndexMaxWindowedVariants);
419
+ const preserved = bounded.some((candidate) => qualitativeKeyFingerprint(candidate.key) === fingerprint)
420
+ ? bounded
421
+ : [variant, ...bounded.slice(0, projectIndexMaxWindowedVariants - 1)];
422
+ return documentSchema.parse({ ...current, qualitative: preserved });
423
+ }
424
+ async function readDocument(filePath) {
425
+ // O_NOFOLLOW is 0 on win32; the explicit lstat keeps symlink fail-closed
426
+ // behavior identical on every platform.
427
+ const linkInfo = await lstat(filePath).catch((error) => {
428
+ if (isNodeError(error, "ENOENT"))
429
+ return undefined;
430
+ throw error;
431
+ });
432
+ if (!linkInfo)
433
+ return undefined;
434
+ if (linkInfo.isSymbolicLink()) {
435
+ throw new QualitativeIndexCacheError("unsafe_file", "Project index entry is a symbolic link.");
436
+ }
437
+ let handle;
438
+ try {
439
+ handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
440
+ const opened = await handle.stat();
441
+ if (!opened.isFile() || !hasPrivatePermissions(opened.mode)) {
442
+ throw new QualitativeIndexCacheError("unsafe_file", "Project index entry is not a private regular file.");
443
+ }
444
+ if (opened.size > projectIndexMaxDocumentBytes) {
445
+ throw new QualitativeIndexCacheError("oversized", "Project index entry exceeds its private bound.");
446
+ }
447
+ const bounded = Buffer.allocUnsafe(Number(opened.size));
448
+ let bytesRead = 0;
449
+ while (bytesRead < bounded.length) {
450
+ const result = await handle.read(bounded, bytesRead, bounded.length - bytesRead, bytesRead);
451
+ if (result.bytesRead === 0)
452
+ break;
453
+ bytesRead += result.bytesRead;
454
+ }
455
+ let value;
456
+ try {
457
+ value = JSON.parse(bounded.subarray(0, bytesRead).toString("utf8"));
458
+ }
459
+ catch {
460
+ // Crash artifacts (zero-length or torn temp promoted by an interrupted
461
+ // rename implementation) read as a miss, never as poison.
462
+ return undefined;
463
+ }
464
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
465
+ const version = value.schemaVersion;
466
+ if (typeof version === "number" && version > 2) {
467
+ // A newer store wrote this document. Fail closed instead of silently
468
+ // clobbering a future format with a v2 rewrite.
469
+ throw new QualitativeIndexCacheError("unsupported_version", "Project index entry was written by a newer aibill version.");
470
+ }
471
+ }
472
+ const parsed = documentSchema.safeParse(value);
473
+ return parsed.success ? parsed.data : undefined;
474
+ }
475
+ catch (error) {
476
+ if (isNodeError(error, "ENOENT"))
477
+ return undefined;
478
+ if (isNodeError(error, "ELOOP")) {
479
+ throw new QualitativeIndexCacheError("unsafe_file", "Project index entry is a symbolic link.");
480
+ }
481
+ throw error;
482
+ }
483
+ finally {
484
+ await handle?.close().catch(() => undefined);
485
+ }
486
+ }
487
+ async function writeDocument(filePath, doc) {
488
+ // Evict-to-fit: an oversized document sheds its oldest bounded windows
489
+ // rather than becoming permanently uncacheable (v1 fitIndex semantics).
490
+ let fitted = doc;
491
+ let contents = `${JSON.stringify(fitted)}\n`;
492
+ while (Buffer.byteLength(contents, "utf8") > projectIndexMaxDocumentBytes &&
493
+ fitted.qualitative.length > 0) {
494
+ fitted = { ...fitted, qualitative: fitted.qualitative.slice(0, -1) };
495
+ contents = `${JSON.stringify(fitted)}\n`;
496
+ }
497
+ if (Buffer.byteLength(contents, "utf8") > projectIndexMaxDocumentBytes) {
498
+ throw new QualitativeIndexCacheError("oversized", "Project index entry exceeds its private bound.");
499
+ }
500
+ await writeJsonFileAtomically(filePath, contents);
501
+ }
502
+ /** fsync-before-rename atomic JSON write shared by entries and checkpoints. */
503
+ async function writeJsonFileAtomically(filePath, contents) {
504
+ const directory = join(filePath, "..");
505
+ await mkdir(directory, { recursive: true, mode: 0o700 });
506
+ const temporaryPath = `${filePath}.${process.pid}.${randomUUID()}.tmp`;
507
+ let handle;
508
+ try {
509
+ handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
510
+ await handle.writeFile(contents, "utf8");
511
+ await handle.sync();
512
+ await handle.close();
513
+ handle = undefined;
514
+ await rename(temporaryPath, filePath);
515
+ await syncDirectory(directory);
516
+ }
517
+ catch (error) {
518
+ await handle?.close().catch(() => undefined);
519
+ await unlink(temporaryPath).catch(() => undefined);
520
+ throw error;
521
+ }
522
+ }
523
+ async function readCheckpointDocument(filePath) {
524
+ const linkInfo = await lstat(filePath).catch((error) => {
525
+ if (isNodeError(error, "ENOENT"))
526
+ return undefined;
527
+ throw error;
528
+ });
529
+ if (!linkInfo)
530
+ return { status: "missing" };
531
+ if (linkInfo.isSymbolicLink()) {
532
+ throw new QualitativeIndexCacheError("unsafe_file", "Project index checkpoint is a symbolic link.");
533
+ }
534
+ let handle;
535
+ try {
536
+ handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
537
+ const opened = await handle.stat();
538
+ if (!opened.isFile() || !hasPrivatePermissions(opened.mode)) {
539
+ throw new QualitativeIndexCacheError("unsafe_file", "Project index checkpoint is not a private regular file.");
540
+ }
541
+ if (opened.size > projectIndexMaxDocumentBytes) {
542
+ throw new QualitativeIndexCacheError("oversized", "Project index checkpoint exceeds its private bound.");
543
+ }
544
+ const bounded = Buffer.allocUnsafe(Number(opened.size));
545
+ let bytesRead = 0;
546
+ while (bytesRead < bounded.length) {
547
+ const result = await handle.read(bounded, bytesRead, bounded.length - bytesRead, bytesRead);
548
+ if (result.bytesRead === 0)
549
+ break;
550
+ bytesRead += result.bytesRead;
551
+ }
552
+ let value;
553
+ try {
554
+ value = JSON.parse(bounded.subarray(0, bytesRead).toString("utf8"));
555
+ }
556
+ catch {
557
+ return { status: "invalid" };
558
+ }
559
+ if (typeof value === "object" && value !== null && !Array.isArray(value)) {
560
+ const record = value;
561
+ if (record.kind === "aibill.project_index_checkpoint" &&
562
+ typeof record.schemaVersion === "number" && record.schemaVersion > 2) {
563
+ // A newer process owns this checkpoint: fail closed as a miss, but
564
+ // never purge or clobber it.
565
+ return { status: "future" };
566
+ }
567
+ }
568
+ const parsed = checkpointDocumentSchema.safeParse(value);
569
+ return parsed.success ? { status: "valid", doc: parsed.data } : { status: "invalid" };
570
+ }
571
+ catch (error) {
572
+ if (isNodeError(error, "ENOENT"))
573
+ return { status: "missing" };
574
+ if (isNodeError(error, "ELOOP")) {
575
+ throw new QualitativeIndexCacheError("unsafe_file", "Project index checkpoint is a symbolic link.");
576
+ }
577
+ throw error;
578
+ }
579
+ finally {
580
+ await handle?.close().catch(() => undefined);
581
+ }
582
+ }
583
+ function checkpointPath(pathHash) {
584
+ return join("checkpoints", `${pathHash}.json`);
585
+ }
586
+ function shardPath(pathHash) {
587
+ return join("entries", pathHash.slice(0, 2), `${pathHash}.json`);
588
+ }
589
+ function parseQualitativeKey(key) {
590
+ return qualitativeEntryKeySchema.parse(key);
591
+ }
592
+ function assertPathHash(pathHash) {
593
+ if (!/^[a-f0-9]{64}$/.test(pathHash)) {
594
+ throw new QualitativeIndexCacheError("invalid_key", "Project index path hash is not a sha256 hex digest.");
595
+ }
596
+ return pathHash;
597
+ }
598
+ function swallowMissingStore(error) {
599
+ if (isNodeError(error, "ENOENT"))
600
+ return undefined;
601
+ throw error;
602
+ }
603
+ function sinceSortValue(value) {
604
+ return value === null ? Number.NEGATIVE_INFINITY : Date.parse(value);
605
+ }
606
+ //# sourceMappingURL=projectIndexStore.js.map
@@ -82,6 +82,64 @@ export type ProviderConnectorInput = {
82
82
  accountId?: string;
83
83
  fetcher?: Fetcher;
84
84
  tokenResolver?: TokenResolver;
85
+ /**
86
+ * Operator-declared reconciliation anchor for this sync. Today only the
87
+ * Cursor connector consumes it; when absent, the Cursor connector also
88
+ * accepts the AI_SPEND_CURSOR_RECONCILE_* environment variables so the
89
+ * shipped CLI can run a reconciliation without new flags.
90
+ */
91
+ reconciliation?: CursorReconciliationExpectation;
92
+ };
93
+ /**
94
+ * A human-read billing anchor for one Cursor reconciliation run: the
95
+ * on-demand ("usage based pricing") total the operator read off the Cursor
96
+ * team dashboard or invoice for the CURRENT subscription cycle. The connector
97
+ * compares its own summed spendCents against this figure; only an in-run
98
+ * match within tolerance can produce verified financial evidence.
99
+ */
100
+ export type CursorReconciliationExpectation = {
101
+ /** Dashboard/invoice on-demand total for the current cycle, in USD. */
102
+ expectedOnDemandUsd: number;
103
+ /**
104
+ * The billing-cycle start date shown next to that figure (YYYY-MM-DD).
105
+ * Must land within one calendar day of the UTC date of the API's
106
+ * subscriptionCycleStart, proving both numbers describe the same window.
107
+ */
108
+ expectedCycleStartDate: string;
109
+ /**
110
+ * Optional absolute comparison tolerance in USD. Defaults to $0.01 (the
111
+ * dashboard rounds to cents). Clamped to at most 1% of the expected total
112
+ * so a huge tolerance can never rubber-stamp a mismatch.
113
+ */
114
+ toleranceUsd?: number;
115
+ };
116
+ export type CursorReconciliationOutcome = {
117
+ status: "verified" | "mismatch" | "not_provable";
118
+ /** Product-authored, terminal-safe explanation of the outcome. */
119
+ note: string;
120
+ connectorTotalUsd?: number;
121
+ expectedOnDemandUsd?: number;
122
+ differenceUsd?: number;
123
+ toleranceUsd?: number;
124
+ /** Provider-reported cycle start for the reconciled window, ISO-8601. */
125
+ cycleStartIso?: string;
126
+ };
127
+ /** Environment variables the Cursor connector reads for a reconciliation run. */
128
+ export declare const cursorReconciliationEnvVars: {
129
+ readonly expectedUsd: "AI_SPEND_CURSOR_RECONCILE_EXPECTED_USD";
130
+ readonly cycleStart: "AI_SPEND_CURSOR_RECONCILE_CYCLE_START";
131
+ readonly toleranceUsd: "AI_SPEND_CURSOR_RECONCILE_TOLERANCE_USD";
132
+ };
133
+ /**
134
+ * Read an operator-declared Cursor reconciliation anchor from the local
135
+ * environment. Absent variables mean "no reconciliation requested"; present
136
+ * but invalid variables fail closed with a reason (records stay estimated)
137
+ * instead of throwing, so a typo can never abort or silently verify a sync.
138
+ * Raw variable values are never echoed into the reason.
139
+ */
140
+ export declare function parseCursorReconciliationEnv(env?: Record<string, string | undefined>): {
141
+ expectation?: CursorReconciliationExpectation;
142
+ invalidReason?: string;
85
143
  };
86
144
  export type ProviderConnectorResult = {
87
145
  provider: string;
@@ -121,7 +179,7 @@ export declare function normalizeAnthropicClaudeCodeUsageResponse(response: unkn
121
179
  export declare function normalizeGitHubCopilotSeatResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
122
180
  export declare function normalizeAnthropicCostResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
123
181
  export declare function normalizeGitHubCopilotMetricsResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
124
- export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions): UsageRecord[];
182
+ export declare function normalizeCursorSpendResponse(response: unknown, options: NormalizerOptions, reconciliation?: CursorReconciliationOutcome): UsageRecord[];
125
183
  export declare function fetchProviderUsageRecords(input: ProviderConnectorInput): Promise<ProviderConnectorResult>;
126
184
  export declare function summarizeProviderFinancials(records: UsageRecord[]): ProviderFinancialSummary;
127
185
  export declare function providerFinancialCompleteness(records: UsageRecord[], coverage: ProviderCoverageStatus): ProviderConnectorResult["completeness"];