@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,930 @@
1
+ import { constants } from "node:fs";
2
+ import { execFile as execFileCallback } from "node:child_process";
3
+ import { chmod, lstat, mkdir, open, realpath, rename, unlink } from "node:fs/promises";
4
+ import { createHash, randomUUID } from "node:crypto";
5
+ import { homedir } from "node:os";
6
+ import { dirname, join, relative, resolve } from "node:path";
7
+ import { setTimeout as delay } from "node:timers/promises";
8
+ import { promisify } from "node:util";
9
+ import { z } from "zod";
10
+ import { localAgentQualitativeParserVersion } from "./localAgentLogs.js";
11
+ export const qualitativeIndexCacheEnvironmentVariable = "AIBILL_CACHE_DIR";
12
+ export const qualitativeIndexCacheFileName = "qualitative-index-v1.json";
13
+ export const qualitativeIndexCacheLockFileName = ".qualitative-index-v1.lock";
14
+ export const qualitativeIndexCacheMaxBytes = 32 * 1_024 * 1_024;
15
+ export const qualitativeIndexCacheMaxEntryBytes = 8 * 1_024 * 1_024;
16
+ export const qualitativeIndexCacheMaxEntries = 256;
17
+ const defaultLockTimeoutMs = 2_000;
18
+ const staleLockMs = 30_000;
19
+ const lockPollMs = 20;
20
+ const lockMetadataMaxBytes = 512;
21
+ const execFile = promisify(execFileCallback);
22
+ export class QualitativeIndexCacheError extends Error {
23
+ code;
24
+ constructor(code, message) {
25
+ super(message);
26
+ this.name = "QualitativeIndexCacheError";
27
+ this.code = code;
28
+ }
29
+ }
30
+ const finiteNonnegativeInteger = z.number().int().nonnegative().max(Number.MAX_SAFE_INTEGER);
31
+ const boundedString = z.string().min(1).max(4_096);
32
+ const opaqueIdentifier = z.string().min(1).max(1_024);
33
+ const isoTimestamp = z.string().datetime({ offset: true });
34
+ const agentSchema = z.enum(["claude-code", "codex", "gemini-cli"]);
35
+ const tokenUsageSchema = z.object({
36
+ inputTokens: finiteNonnegativeInteger,
37
+ outputTokens: finiteNonnegativeInteger,
38
+ cacheReadTokens: finiteNonnegativeInteger.optional(),
39
+ cacheWrite5mTokens: finiteNonnegativeInteger.optional(),
40
+ cacheWrite1hTokens: finiteNonnegativeInteger.optional(),
41
+ thoughtTokens: finiteNonnegativeInteger.optional(),
42
+ toolTokens: finiteNonnegativeInteger.optional()
43
+ }).strict();
44
+ const turnUsageSchema = tokenUsageSchema.extend({
45
+ contextTokens: finiteNonnegativeInteger,
46
+ totalTokens: finiteNonnegativeInteger,
47
+ source: z.enum([
48
+ "assistant_message_usage",
49
+ "transcript_last_token_usage",
50
+ "call_usage"
51
+ ])
52
+ }).strict();
53
+ const tokenComponentEvidenceSchema = z.object({
54
+ inputTokens: z.literal("observed"),
55
+ outputTokens: z.literal("observed"),
56
+ cacheReadTokens: z.enum(["observed", "not_separately_reported"]),
57
+ cacheWriteTokens: z.enum(["observed", "partial", "not_separately_reported"]),
58
+ thoughtTokens: z.enum(["observed", "not_separately_reported"]),
59
+ toolTokens: z.enum(["observed", "not_separately_reported"]),
60
+ calculatedTotalTokens: z.enum(["calculated_complete", "calculated_partial"]),
61
+ reportedTotalTokens: z.enum(["provider_reported", "not_reported"])
62
+ }).strict();
63
+ const completionSchema = z.object({
64
+ status: z.literal("completed"),
65
+ evidence: z.enum(["claude_turn_duration", "codex_task_complete"]),
66
+ observedAt: isoTimestamp
67
+ }).strict();
68
+ const subagentCompletionSchema = z.object({
69
+ subagentId: opaqueIdentifier,
70
+ observedAt: isoTimestamp
71
+ }).strict();
72
+ const geminiEvidenceSchema = z.object({
73
+ input: finiteNonnegativeInteger.optional(),
74
+ output: finiteNonnegativeInteger.optional(),
75
+ cached: finiteNonnegativeInteger.optional(),
76
+ thoughts: finiteNonnegativeInteger.optional(),
77
+ tool: finiteNonnegativeInteger.optional(),
78
+ total: finiteNonnegativeInteger.optional(),
79
+ cacheAccounting: z.enum(["included", "none", "unknown"])
80
+ }).strict();
81
+ const rateLimitWindowSchema = z.object({
82
+ kind: z.enum(["five-hour", "weekly", "custom"]),
83
+ name: z.string().min(1).max(128),
84
+ usedPercent: z.number().min(0).max(100),
85
+ windowMinutes: finiteNonnegativeInteger.refine((value) => value > 0),
86
+ resetsAt: isoTimestamp
87
+ }).strict();
88
+ const rateLimitsSchema = z.object({
89
+ observedAt: isoTimestamp,
90
+ limitId: opaqueIdentifier.optional(),
91
+ planType: z.string().min(1).max(256).optional(),
92
+ windows: z.array(rateLimitWindowSchema).max(16)
93
+ }).strict();
94
+ const activitySchema = z.object({
95
+ summary: z.string().min(1).max(512),
96
+ kind: z.enum(["task", "automation", "agent", "file", "project"]),
97
+ action: z.enum([
98
+ "building",
99
+ "refining",
100
+ "fixing",
101
+ "testing",
102
+ "auditing",
103
+ "researching",
104
+ "configuring",
105
+ "publishing",
106
+ "running",
107
+ "working"
108
+ ]),
109
+ source: z.enum(["agent_title", "user_prompts", "file_activity", "project"]),
110
+ promptCount: finiteNonnegativeInteger,
111
+ toolCallCount: finiteNonnegativeInteger,
112
+ files: z.array(z.string().min(1).max(512).refine(isBasename)).max(5),
113
+ isSubagent: z.boolean(),
114
+ parentSessionId: opaqueIdentifier.optional()
115
+ }).strict();
116
+ /**
117
+ * Deliberately omits `workingDirectory`. A cache hit does not need the raw
118
+ * absolute path to reproduce token/action evidence, and persisting it would
119
+ * violate the opaque-path boundary promised by this index.
120
+ */
121
+ const callSchema = z.object({
122
+ agent: agentSchema,
123
+ callId: opaqueIdentifier.optional(),
124
+ model: boundedString,
125
+ timestamp: isoTimestamp,
126
+ startedAt: isoTimestamp.optional(),
127
+ project: z.string().min(1).max(512).refine(isBasename).optional(),
128
+ workingDirectoryRef: z.string().regex(/^avref_[a-f0-9]{64}$/).optional(),
129
+ latestTurnUsage: turnUsageSchema.optional(),
130
+ usageScope: z.enum(["turn", "session_cumulative"]).optional(),
131
+ usageSupport: z.enum(["complete", "unsupported_token_shape"]).optional(),
132
+ reportedTotalTokens: finiteNonnegativeInteger.optional(),
133
+ tokenComponentEvidence: tokenComponentEvidenceSchema.optional(),
134
+ sourceVersion: z.string().min(1).max(64).optional(),
135
+ completion: completionSchema.optional(),
136
+ geminiTokenEvidence: geminiEvidenceSchema.optional(),
137
+ usage: tokenUsageSchema,
138
+ sessionId: opaqueIdentifier.optional(),
139
+ subagentId: opaqueIdentifier.optional(),
140
+ subagentCompletions: z.array(subagentCompletionSchema).max(10_000).optional(),
141
+ rateLimits: rateLimitsSchema.optional(),
142
+ activity: activitySchema.optional()
143
+ }).strict();
144
+ const invocationCountSchema = z.object({
145
+ name: z.string().min(1).max(1_024),
146
+ count: finiteNonnegativeInteger.refine((value) => value > 0)
147
+ }).strict();
148
+ const nestedSessionSchema = z.object({
149
+ sessionId: opaqueIdentifier.optional(),
150
+ isSubagent: z.boolean(),
151
+ parentSessionId: opaqueIdentifier.optional()
152
+ }).strict();
153
+ const contextSignalSchema = z.object({
154
+ agent: z.enum(["claude-code", "codex"]),
155
+ sessionId: opaqueIdentifier.optional(),
156
+ lastActivityAt: isoTimestamp.optional(),
157
+ compactionEvents: finiteNonnegativeInteger,
158
+ fileReads: z.array(invocationCountSchema).max(10_000),
159
+ repeatedFileReads: z.array(invocationCountSchema).max(10_000),
160
+ isSubagent: z.boolean(),
161
+ parentSessionId: opaqueIdentifier.optional(),
162
+ nestedSessions: z.array(nestedSessionSchema).max(10_000).optional(),
163
+ readCoverage: z.literal("explicit_read_tools_only")
164
+ }).strict();
165
+ const invocationFileSchema = z.object({
166
+ invocations: z.array(invocationCountSchema).max(10_000),
167
+ invokedMcpTools: z.array(z.string().min(1).max(1_024)).max(10_000),
168
+ invokedSkills: z.array(z.string().min(1).max(1_024)).max(10_000),
169
+ invokedSubagents: z.array(z.string().min(1).max(1_024)).max(10_000),
170
+ invokedCommands: z.array(z.string().min(1).max(1_024)).max(10_000),
171
+ assistantTurns: finiteNonnegativeInteger,
172
+ contextSignal: contextSignalSchema
173
+ }).strict();
174
+ const invocationWindowProofSchema = z.object({
175
+ earliestCountedAt: isoTimestamp.optional(),
176
+ allCountedEventsTimestamped: z.boolean()
177
+ }).strict();
178
+ const diagnosticSchema = z.object({
179
+ code: z.enum([
180
+ "malformed_jsonl",
181
+ "malformed_session_file",
182
+ "unsupported_token_shape"
183
+ ]),
184
+ count: finiteNonnegativeInteger.refine((value) => value > 0)
185
+ }).strict();
186
+ const keySchema = z.object({
187
+ schemaVersion: z.literal(1),
188
+ // Entries persisted by an older parser contract fail closed as misses —
189
+ // never reinterpreted under the streaming-era parser.
190
+ parserVersion: z.literal(localAgentQualitativeParserVersion),
191
+ agent: agentSchema,
192
+ pathHash: z.string().regex(/^[a-f0-9]{64}$/),
193
+ fileIdentity: z.string().min(11).max(256).regex(/^\d+(?:\.\d+)?:\d+(?:\.\d+)?:\d+(?:\.\d+)?:\d+(?:\.\d+)?:\d+(?:\.\d+)?:\d+(?:\.\d+)?$/),
194
+ sinceIso: isoTimestamp.nullable(),
195
+ collectInvocationEvidence: z.boolean()
196
+ }).strict();
197
+ const valueSchema = z.object({
198
+ calls: z.array(callSchema).max(100_000),
199
+ invocationFile: invocationFileSchema.optional(),
200
+ invocationWindowProof: invocationWindowProofSchema.optional(),
201
+ diagnostics: z.array(diagnosticSchema).max(10_000)
202
+ }).strict();
203
+ const entrySchema = z.object({
204
+ key: keySchema,
205
+ storedAt: isoTimestamp,
206
+ value: valueSchema
207
+ }).strict();
208
+ const indexSchema = z.object({
209
+ kind: z.literal("aibill.qualitative_index"),
210
+ schemaVersion: z.literal(1),
211
+ entries: z.array(entrySchema).max(qualitativeIndexCacheMaxEntries)
212
+ }).strict();
213
+ /**
214
+ * Strict v1 entry contracts, exported for the v2 sharded project-index store.
215
+ * The v2 store persists the same privacy-reduced shapes under a different
216
+ * layout; sharing the schemas keeps the two stores provably consistent.
217
+ */
218
+ export const qualitativeEntryKeySchema = keySchema;
219
+ export const qualitativeEntryValueSchema = valueSchema;
220
+ /** Resolve the fixed warm-index path without trusting or creating it. */
221
+ export function qualitativeIndexCachePath(options = {}) {
222
+ return join(configuredCacheDirectory(options), qualitativeIndexCacheFileName);
223
+ }
224
+ /**
225
+ * Build the adapter consumed by `loadLocalAgentUsage({ qualitativeIndex })`.
226
+ * Reads fail closed and writes are serialized, atomic, private, and bounded.
227
+ */
228
+ export function createQualitativeIndexCacheAdapter(options = {}) {
229
+ // One command can ask about many selected files. Parse the private index at
230
+ // most once in this process instead of allocating/validating the whole JSON
231
+ // document for every file lookup. Transcript identity is still rechecked by
232
+ // the loader before any cached value is accepted.
233
+ let loadedIndex;
234
+ const index = () => {
235
+ loadedIndex ??= loadCachedIndex(options).catch((error) => {
236
+ // A caller may repair or replace malformed local state between reads.
237
+ // Share one failing attempt, then permit a fresh safe validation.
238
+ loadedIndex = undefined;
239
+ throw error;
240
+ });
241
+ return loadedIndex;
242
+ };
243
+ return {
244
+ read: async (key) => selectCachedValue(parseKey(key), await index()),
245
+ write: async (key, value) => {
246
+ await writeCachedValue(key, value, options);
247
+ // A second process may have merged another entry while this writer held
248
+ // the lock. Reload once on the next read rather than treating this
249
+ // process's pre-write snapshot as authoritative.
250
+ loadedIndex = undefined;
251
+ }
252
+ };
253
+ }
254
+ async function loadCachedIndex(options) {
255
+ let directory;
256
+ try {
257
+ directory = await resolveCacheDirectory(false, options);
258
+ }
259
+ catch (error) {
260
+ if (isNodeError(error, "ENOENT"))
261
+ return undefined;
262
+ throw normalizeError(error, "unsafe_directory");
263
+ }
264
+ const result = await readIndexFile(directory, options);
265
+ if (result.status === "missing")
266
+ return undefined;
267
+ if (result.status === "error") {
268
+ throw new QualitativeIndexCacheError(result.code, "The private qualitative index could not be read safely.");
269
+ }
270
+ return result.index;
271
+ }
272
+ function selectCachedValue(candidateKey, index) {
273
+ if (!index)
274
+ return undefined;
275
+ const fingerprint = keyFingerprint(candidateKey);
276
+ const exact = index.entries.find((candidate) => (keyFingerprint(candidate.key) === fingerprint));
277
+ if (exact)
278
+ return exact.value;
279
+ // A normal `last N days` invocation advances its exact cutoff every run.
280
+ // Reuse an older, wider parse only when the immutable file/parser identity
281
+ // matches and the cached evidence can be narrowed to the requested instant
282
+ // without guessing. Calls are filtered again by the loader after cache
283
+ // lookup. Codex invocation counts need an additional proof because their
284
+ // persisted form is aggregated rather than event-level.
285
+ const compatible = index.entries
286
+ .filter((entry) => sameQualitativeFileKey(entry.key, candidateKey))
287
+ .filter((entry) => cachedWindowCoversRequest(entry.key.sinceIso, candidateKey.sinceIso))
288
+ .filter((entry) => invocationWindowCanBeNarrowedExactly(entry.key, entry.value, candidateKey))
289
+ .sort((left, right) => (sinceSortValue(right.key.sinceIso) - sinceSortValue(left.key.sinceIso) ||
290
+ right.storedAt.localeCompare(left.storedAt)))[0];
291
+ return compatible?.value;
292
+ }
293
+ async function writeCachedValue(key, value, options) {
294
+ const candidateKey = parseKey(key);
295
+ const candidateValue = parseValue(stripRawPaths(value));
296
+ assertOwnership(candidateKey, candidateValue);
297
+ const candidate = {
298
+ key: candidateKey,
299
+ storedAt: new Date().toISOString(),
300
+ value: candidateValue
301
+ };
302
+ const entryBytes = Buffer.byteLength(JSON.stringify(candidate), "utf8");
303
+ if (entryBytes > boundedEntryBytes(options.maxEntryBytes)) {
304
+ throw new QualitativeIndexCacheError("oversized", "Qualitative index entry exceeds its private cache bound.");
305
+ }
306
+ return withWriterLock(options, async (directory) => {
307
+ const result = await readIndexFile(directory, options);
308
+ if (result.status === "error" && (result.code === "unsafe_file" || result.code === "permission" ||
309
+ result.code === "unsupported_version" || result.code === "oversized")) {
310
+ throw new QualitativeIndexCacheError(result.code, "Refusing to replace an unsafe qualitative index.");
311
+ }
312
+ const existing = result.status === "ok" ? result.index.entries : [];
313
+ const fingerprint = keyFingerprint(candidateKey);
314
+ const retained = existing.filter((entry) => keyFingerprint(entry.key) !== fingerprint);
315
+ const bounded = fitIndex([...retained, candidate], candidate, options);
316
+ await atomicWriteIndex(directory, bounded, options);
317
+ return bounded;
318
+ });
319
+ }
320
+ function fitIndex(entries, candidate, options) {
321
+ const maxEntries = boundedEntryCount(options.maxEntries);
322
+ const maxBytes = boundedIndexBytes(options.maxBytes);
323
+ const others = entries
324
+ .filter((entry) => entry !== candidate)
325
+ .sort((left, right) => (left.storedAt.localeCompare(right.storedAt) ||
326
+ keyFingerprint(left.key).localeCompare(keyFingerprint(right.key))));
327
+ let fitted = [...others, candidate];
328
+ while (fitted.length > maxEntries || serializedIndexBytes(fitted) > maxBytes) {
329
+ if (others.length === 0) {
330
+ throw new QualitativeIndexCacheError("oversized", "Qualitative index entry cannot fit its private cache bound.");
331
+ }
332
+ const oldest = others.shift();
333
+ fitted = fitted.filter((entry) => entry !== oldest);
334
+ }
335
+ return indexSchema.parse({
336
+ kind: "aibill.qualitative_index",
337
+ schemaVersion: 1,
338
+ entries: fitted
339
+ });
340
+ }
341
+ function serializedIndexBytes(entries) {
342
+ return Buffer.byteLength(JSON.stringify({
343
+ kind: "aibill.qualitative_index",
344
+ schemaVersion: 1,
345
+ entries
346
+ }), "utf8") + 1;
347
+ }
348
+ async function readIndexFile(directory, options) {
349
+ const filePath = join(directory, qualitativeIndexCacheFileName);
350
+ let fileInfo;
351
+ try {
352
+ fileInfo = await lstat(filePath);
353
+ }
354
+ catch (error) {
355
+ if (isNodeError(error, "ENOENT"))
356
+ return { status: "missing" };
357
+ return { status: "error", code: readErrorCode(error, "io") };
358
+ }
359
+ if (fileInfo.isSymbolicLink() || !fileInfo.isFile() || !hasPrivatePermissions(fileInfo.mode)) {
360
+ return { status: "error", code: "unsafe_file" };
361
+ }
362
+ const maxBytes = boundedIndexBytes(options.maxBytes);
363
+ if (fileInfo.size > maxBytes)
364
+ return { status: "error", code: "oversized" };
365
+ let handle;
366
+ try {
367
+ handle = await open(filePath, constants.O_RDONLY | noFollowFlag());
368
+ const openedInfo = await handle.stat();
369
+ if (!openedInfo.isFile() || !hasPrivatePermissions(openedInfo.mode)) {
370
+ return { status: "error", code: "unsafe_file" };
371
+ }
372
+ if (openedInfo.size > maxBytes)
373
+ return { status: "error", code: "oversized" };
374
+ // Allocate for the observed private file plus one growth sentinel, not the
375
+ // entire 32 MiB production ceiling on every command.
376
+ const bounded = Buffer.allocUnsafe(Math.min(maxBytes + 1, openedInfo.size + 1));
377
+ let bytesRead = 0;
378
+ while (bytesRead < bounded.length) {
379
+ const result = await handle.read(bounded, bytesRead, bounded.length - bytesRead, bytesRead);
380
+ if (result.bytesRead === 0)
381
+ break;
382
+ bytesRead += result.bytesRead;
383
+ }
384
+ if (bytesRead > maxBytes)
385
+ return { status: "error", code: "oversized" };
386
+ const completedInfo = await handle.stat();
387
+ if (!completedInfo.isFile() || completedInfo.dev !== openedInfo.dev ||
388
+ completedInfo.ino !== openedInfo.ino || completedInfo.size !== openedInfo.size ||
389
+ completedInfo.mtimeMs !== openedInfo.mtimeMs ||
390
+ completedInfo.ctimeMs !== openedInfo.ctimeMs) {
391
+ return { status: "error", code: "io" };
392
+ }
393
+ let value;
394
+ try {
395
+ value = JSON.parse(bounded.subarray(0, bytesRead).toString("utf8"));
396
+ }
397
+ catch {
398
+ return { status: "error", code: "malformed" };
399
+ }
400
+ if (isRecord(value) && value.schemaVersion !== undefined && value.schemaVersion !== 1) {
401
+ return { status: "error", code: "unsupported_version" };
402
+ }
403
+ const parsed = indexSchema.safeParse(value);
404
+ if (!parsed.success)
405
+ return { status: "error", code: "malformed" };
406
+ if (parsed.data.entries.length > boundedEntryCount(options.maxEntries)) {
407
+ return { status: "error", code: "oversized" };
408
+ }
409
+ return { status: "ok", index: parsed.data };
410
+ }
411
+ catch (error) {
412
+ if (isNodeError(error, "ELOOP"))
413
+ return { status: "error", code: "unsafe_file" };
414
+ return { status: "error", code: readErrorCode(error, "io") };
415
+ }
416
+ finally {
417
+ await handle?.close().catch(() => undefined);
418
+ }
419
+ }
420
+ async function atomicWriteIndex(directory, index, options) {
421
+ const contents = `${JSON.stringify(index)}\n`;
422
+ if (Buffer.byteLength(contents, "utf8") > boundedIndexBytes(options.maxBytes)) {
423
+ throw new QualitativeIndexCacheError("oversized", "Qualitative index exceeds its private cache bound.");
424
+ }
425
+ const filePath = join(directory, qualitativeIndexCacheFileName);
426
+ const existing = await lstat(filePath).catch((error) => {
427
+ if (isNodeError(error, "ENOENT"))
428
+ return undefined;
429
+ throw error;
430
+ });
431
+ if (existing?.isSymbolicLink() || (existing && !existing.isFile())) {
432
+ throw new QualitativeIndexCacheError("unsafe_file", "Qualitative index path is not a regular file.");
433
+ }
434
+ if (existing && !hasPrivatePermissions(existing.mode)) {
435
+ throw new QualitativeIndexCacheError("unsafe_file", "Qualitative index file is not private.");
436
+ }
437
+ const temporaryPath = join(directory, `.${qualitativeIndexCacheFileName}.${process.pid}.${randomUUID()}.tmp`);
438
+ let handle;
439
+ try {
440
+ handle = await open(temporaryPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
441
+ await handle.writeFile(contents, "utf8");
442
+ await handle.sync();
443
+ await handle.close();
444
+ handle = undefined;
445
+ await rename(temporaryPath, filePath);
446
+ await chmod(filePath, 0o600);
447
+ await syncDirectory(directory);
448
+ }
449
+ catch (error) {
450
+ await handle?.close().catch(() => undefined);
451
+ await unlink(temporaryPath).catch(() => undefined);
452
+ throw error;
453
+ }
454
+ }
455
+ export async function withWriterLock(options, operation, lockFileName = qualitativeIndexCacheLockFileName) {
456
+ const directory = await resolveCacheDirectory(true, options);
457
+ const lockPath = join(directory, lockFileName);
458
+ const timeout = boundedLockTimeout(options.lockTimeoutMs);
459
+ const started = Date.now();
460
+ let lockHandle;
461
+ let lockIdentity;
462
+ while (!lockHandle) {
463
+ let candidateHandle;
464
+ let candidateIdentity;
465
+ try {
466
+ const owner = { pid: process.pid, token: randomUUID() };
467
+ candidateHandle = await open(lockPath, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
468
+ const info = await candidateHandle.stat();
469
+ candidateIdentity = { ...owner, dev: info.dev, ino: info.ino };
470
+ await candidateHandle.writeFile(`${JSON.stringify(owner)}\n`, "utf8");
471
+ await candidateHandle.sync();
472
+ lockHandle = candidateHandle;
473
+ lockIdentity = candidateIdentity;
474
+ candidateHandle = undefined;
475
+ candidateIdentity = undefined;
476
+ }
477
+ catch (error) {
478
+ await candidateHandle?.close().catch(() => undefined);
479
+ if (candidateIdentity)
480
+ await releaseOwnedLock(lockPath, candidateIdentity);
481
+ if (!isNodeError(error, "EEXIST")) {
482
+ if (isNodeError(error, "ELOOP")) {
483
+ throw new QualitativeIndexCacheError("unsafe_file", "Qualitative index writer lock is a symbolic link.");
484
+ }
485
+ throw error;
486
+ }
487
+ await removeStaleLock(lockPath);
488
+ if (Date.now() - started >= timeout) {
489
+ throw new QualitativeIndexCacheError("lock_timeout", "Timed out waiting for the qualitative index writer lock.");
490
+ }
491
+ await delay(lockPollMs);
492
+ }
493
+ }
494
+ try {
495
+ return await operation(directory);
496
+ }
497
+ finally {
498
+ await lockHandle.close().catch(() => undefined);
499
+ if (lockIdentity)
500
+ await releaseOwnedLock(lockPath, lockIdentity);
501
+ }
502
+ }
503
+ export async function resolveCacheDirectory(create, options) {
504
+ const usesDefaultDirectory = !options.cacheDirectory?.trim() &&
505
+ !process.env[qualitativeIndexCacheEnvironmentVariable]?.trim();
506
+ if (usesDefaultDirectory) {
507
+ await ensureDefaultParent(options.homeDirectory ?? homedir(), create);
508
+ }
509
+ const requested = configuredCacheDirectory(options);
510
+ let createdDirectory = false;
511
+ let info = await lstat(requested).catch((error) => {
512
+ if (isNodeError(error, "ENOENT"))
513
+ return undefined;
514
+ throw error;
515
+ });
516
+ if (!info && create) {
517
+ if (usesDefaultDirectory) {
518
+ await mkdir(requested, { mode: 0o700 }).catch((error) => {
519
+ if (!isNodeError(error, "EEXIST"))
520
+ throw error;
521
+ });
522
+ }
523
+ else {
524
+ await mkdir(requested, { recursive: true, mode: 0o700 });
525
+ }
526
+ createdDirectory = true;
527
+ info = await lstat(requested);
528
+ }
529
+ if (!info) {
530
+ const error = new Error("Private qualitative index directory does not exist.");
531
+ error.code = "ENOENT";
532
+ throw error;
533
+ }
534
+ if (info.isSymbolicLink() || !info.isDirectory()) {
535
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private qualitative index directory is not a real directory.");
536
+ }
537
+ if (!hasPrivatePermissions(info.mode)) {
538
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private qualitative index directory has unsafe permissions.");
539
+ }
540
+ const canonical = await realpath(requested);
541
+ const confirmed = await lstat(requested);
542
+ if (confirmed.isSymbolicLink() || !confirmed.isDirectory()) {
543
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private qualitative index directory changed during validation.");
544
+ }
545
+ // Never chmod an existing caller-supplied directory. A typo such as a repo
546
+ // root or shared folder must fail without changing the host filesystem.
547
+ if (createdDirectory)
548
+ await chmod(canonical, 0o700);
549
+ return canonical;
550
+ }
551
+ async function ensureDefaultParent(homeDirectory, create) {
552
+ const parent = join(homeDirectory, ".aibill");
553
+ let info = await lstat(parent).catch((error) => {
554
+ if (isNodeError(error, "ENOENT"))
555
+ return undefined;
556
+ throw error;
557
+ });
558
+ if (!info && create) {
559
+ await mkdir(parent, { mode: 0o700 }).catch((error) => {
560
+ if (!isNodeError(error, "EEXIST"))
561
+ throw error;
562
+ });
563
+ info = await lstat(parent);
564
+ }
565
+ if (!info) {
566
+ const error = new Error("Private aibill directory does not exist.");
567
+ error.code = "ENOENT";
568
+ throw error;
569
+ }
570
+ if (info.isSymbolicLink() || !info.isDirectory()) {
571
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill directory is not a real directory.");
572
+ }
573
+ if (!create && !hasPrivatePermissions(info.mode)) {
574
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill directory has unsafe permissions.");
575
+ }
576
+ if (create)
577
+ await chmod(parent, 0o700);
578
+ await ensureDefaultCacheGitPrivacy(parent, create);
579
+ }
580
+ async function ensureDefaultCacheGitPrivacy(aibillDirectory, create) {
581
+ const gitRoot = await findEnclosingGitRoot(aibillDirectory);
582
+ if (!gitRoot)
583
+ return;
584
+ const marker = join(aibillDirectory, ".gitignore");
585
+ let handle;
586
+ try {
587
+ handle = await open(marker, constants.O_RDONLY | noFollowFlag());
588
+ }
589
+ catch (error) {
590
+ if (!isNodeError(error, "ENOENT"))
591
+ throw error;
592
+ if (!create) {
593
+ const missing = new Error("Private aibill Git privacy marker does not exist.");
594
+ missing.code = "ENOENT";
595
+ throw missing;
596
+ }
597
+ handle = await open(marker, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | noFollowFlag(), 0o600);
598
+ await handle.writeFile("*\n", "utf8");
599
+ await handle.sync();
600
+ await handle.close();
601
+ handle = await open(marker, constants.O_RDONLY | noFollowFlag());
602
+ }
603
+ try {
604
+ const info = await handle.stat();
605
+ if (!info.isFile() || !hasPrivatePermissions(info.mode) || info.size !== 2) {
606
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill Git privacy marker is unsafe.");
607
+ }
608
+ const buffer = Buffer.alloc(2);
609
+ const { bytesRead } = await handle.read(buffer, 0, 2, 0);
610
+ if (bytesRead !== 2 || buffer.toString("utf8") !== "*\n") {
611
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill Git privacy marker is invalid.");
612
+ }
613
+ }
614
+ finally {
615
+ await handle.close().catch(() => undefined);
616
+ }
617
+ const relativeDirectory = relative(gitRoot, aibillDirectory);
618
+ const tracked = await execFile("git", ["-C", gitRoot, "ls-files", "--", relativeDirectory], {
619
+ encoding: "utf8",
620
+ maxBuffer: 64 * 1024
621
+ }).then(({ stdout }) => stdout.trim()).catch(() => {
622
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill cache tracking status could not be verified.");
623
+ });
624
+ if (tracked) {
625
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill cache is already tracked by Git.");
626
+ }
627
+ const ignored = await execFile("git", [
628
+ "-C", gitRoot, "check-ignore", "--quiet", "--no-index", "--",
629
+ join(relativeDirectory, "cache", "privacy-probe.json")
630
+ ]).then(() => true).catch(() => false);
631
+ if (!ignored) {
632
+ throw new QualitativeIndexCacheError("unsafe_directory", "Private aibill cache is not proven ignored by Git.");
633
+ }
634
+ }
635
+ async function findEnclosingGitRoot(path) {
636
+ let current = resolve(path);
637
+ while (true) {
638
+ const gitEntry = await lstat(join(current, ".git")).catch((error) => {
639
+ if (isNodeError(error, "ENOENT") || isNodeError(error, "ENOTDIR"))
640
+ return undefined;
641
+ throw error;
642
+ });
643
+ if (gitEntry)
644
+ return current;
645
+ const parent = dirname(current);
646
+ if (parent === current)
647
+ return undefined;
648
+ current = parent;
649
+ }
650
+ }
651
+ function configuredCacheDirectory(options) {
652
+ const configured = options.cacheDirectory?.trim() ||
653
+ process.env[qualitativeIndexCacheEnvironmentVariable]?.trim();
654
+ return resolve(configured && configured.length > 0
655
+ ? configured
656
+ : join(options.homeDirectory ?? homedir(), ".aibill", "cache"));
657
+ }
658
+ async function removeStaleLock(lockPath) {
659
+ let handle;
660
+ try {
661
+ handle = await open(lockPath, constants.O_RDONLY | noFollowFlag());
662
+ const info = await handle.stat();
663
+ if (!info.isFile() || !hasPrivatePermissions(info.mode)) {
664
+ throw new QualitativeIndexCacheError("unsafe_file", "Qualitative index writer lock is not private.");
665
+ }
666
+ if (Date.now() - info.mtimeMs <= staleLockMs)
667
+ return;
668
+ const owner = await readLockOwner(handle);
669
+ if (!owner || processIsAlive(owner.pid))
670
+ return;
671
+ await handle.close();
672
+ handle = undefined;
673
+ await releaseOwnedLock(lockPath, { ...owner, dev: info.dev, ino: info.ino });
674
+ }
675
+ catch (error) {
676
+ if (isNodeError(error, "ENOENT"))
677
+ return;
678
+ if (isNodeError(error, "ELOOP")) {
679
+ throw new QualitativeIndexCacheError("unsafe_file", "Qualitative index writer lock is a symbolic link.");
680
+ }
681
+ throw error;
682
+ }
683
+ finally {
684
+ await handle?.close().catch(() => undefined);
685
+ }
686
+ }
687
+ async function releaseOwnedLock(lockPath, identity) {
688
+ let handle;
689
+ try {
690
+ handle = await open(lockPath, constants.O_RDONLY | noFollowFlag());
691
+ const info = await handle.stat();
692
+ if (!info.isFile() || info.dev !== identity.dev || info.ino !== identity.ino)
693
+ return;
694
+ const owner = await readLockOwner(handle);
695
+ if (!owner || owner.pid !== identity.pid || owner.token !== identity.token)
696
+ return;
697
+ await handle.close();
698
+ handle = undefined;
699
+ const confirmed = await lstat(lockPath).catch((error) => {
700
+ if (isNodeError(error, "ENOENT"))
701
+ return undefined;
702
+ throw error;
703
+ });
704
+ if (!confirmed || confirmed.isSymbolicLink() ||
705
+ confirmed.dev !== identity.dev || confirmed.ino !== identity.ino) {
706
+ return;
707
+ }
708
+ await unlink(lockPath).catch((error) => {
709
+ if (!isNodeError(error, "ENOENT"))
710
+ throw error;
711
+ });
712
+ }
713
+ catch (error) {
714
+ if (!isNodeError(error, "ENOENT") && !isNodeError(error, "ELOOP"))
715
+ throw error;
716
+ }
717
+ finally {
718
+ await handle?.close().catch(() => undefined);
719
+ }
720
+ }
721
+ async function readLockOwner(handle) {
722
+ const buffer = Buffer.allocUnsafe(lockMetadataMaxBytes + 1);
723
+ const result = await handle.read(buffer, 0, buffer.length, 0);
724
+ if (result.bytesRead === 0 || result.bytesRead > lockMetadataMaxBytes)
725
+ return undefined;
726
+ let value;
727
+ try {
728
+ value = JSON.parse(buffer.subarray(0, result.bytesRead).toString("utf8"));
729
+ }
730
+ catch {
731
+ return undefined;
732
+ }
733
+ if (!isRecord(value) || !Number.isSafeInteger(value.pid) || Number(value.pid) <= 0 ||
734
+ typeof value.token !== "string" || value.token.length < 16 || value.token.length > 128) {
735
+ return undefined;
736
+ }
737
+ return { pid: Number(value.pid), token: value.token };
738
+ }
739
+ function processIsAlive(pid) {
740
+ try {
741
+ process.kill(pid, 0);
742
+ return true;
743
+ }
744
+ catch (error) {
745
+ return !isNodeError(error, "ESRCH");
746
+ }
747
+ }
748
+ export async function syncDirectory(directory) {
749
+ let handle;
750
+ try {
751
+ handle = await open(directory, constants.O_RDONLY);
752
+ await handle.sync().catch((error) => {
753
+ if (!isNodeError(error, "EINVAL") && !isNodeError(error, "ENOTSUP"))
754
+ throw error;
755
+ });
756
+ }
757
+ finally {
758
+ await handle?.close().catch(() => undefined);
759
+ }
760
+ }
761
+ export function stripRawPaths(value) {
762
+ return {
763
+ calls: value.calls.map((call) => {
764
+ const { workingDirectory: _privateWorkingDirectory, ...privacyReduced } = call;
765
+ const workingDirectoryRef = call.workingDirectoryRef ??
766
+ (call.workingDirectory ? projectRefForWorkingDirectory(call.workingDirectory) : undefined);
767
+ return {
768
+ ...privacyReduced,
769
+ ...(workingDirectoryRef ? { workingDirectoryRef } : {})
770
+ };
771
+ }),
772
+ ...(value.invocationFile ? { invocationFile: value.invocationFile } : {}),
773
+ ...(value.invocationWindowProof
774
+ ? { invocationWindowProof: value.invocationWindowProof }
775
+ : {}),
776
+ diagnostics: value.diagnostics
777
+ };
778
+ }
779
+ function projectRefForWorkingDirectory(directory) {
780
+ return `avref_${createHash("sha256")
781
+ .update("project-working-directory")
782
+ .update("\u0000")
783
+ .update(directory)
784
+ .digest("hex")}`;
785
+ }
786
+ function parseKey(value) {
787
+ const result = keySchema.safeParse(value);
788
+ if (!result.success) {
789
+ throw new QualitativeIndexCacheError("invalid_key", "Qualitative index key does not match the strict v1 contract.");
790
+ }
791
+ return result.data;
792
+ }
793
+ function parseValue(value) {
794
+ const result = valueSchema.safeParse(value);
795
+ if (!result.success) {
796
+ throw new QualitativeIndexCacheError("invalid_value", "Qualitative index value does not match the privacy-reduced v1 contract.");
797
+ }
798
+ return result.data;
799
+ }
800
+ function assertOwnership(key, value) {
801
+ if (value.calls.some((call) => call.agent !== key.agent)) {
802
+ throw new QualitativeIndexCacheError("invalid_value", "Qualitative index calls do not belong to the keyed parser.");
803
+ }
804
+ if (value.invocationFile) {
805
+ if (!key.collectInvocationEvidence || key.agent !== "codex" ||
806
+ value.invocationFile.contextSignal.agent !== key.agent) {
807
+ throw new QualitativeIndexCacheError("invalid_value", "Qualitative invocation evidence does not belong to the keyed parser window.");
808
+ }
809
+ }
810
+ if (value.invocationWindowProof && (!value.invocationFile ||
811
+ !key.collectInvocationEvidence || key.agent !== "codex")) {
812
+ throw new QualitativeIndexCacheError("invalid_value", "Qualitative invocation window proof does not belong to the keyed parser window.");
813
+ }
814
+ }
815
+ export function qualitativeKeyFingerprint(key) {
816
+ return keyFingerprint(key);
817
+ }
818
+ function keyFingerprint(key) {
819
+ return createHash("sha256").update(JSON.stringify([
820
+ key.schemaVersion,
821
+ key.parserVersion,
822
+ key.agent,
823
+ key.pathHash,
824
+ key.fileIdentity,
825
+ key.sinceIso,
826
+ key.collectInvocationEvidence
827
+ ])).digest("hex");
828
+ }
829
+ export function sameQualitativeFileKey(left, right) {
830
+ return left.schemaVersion === right.schemaVersion &&
831
+ left.parserVersion === right.parserVersion &&
832
+ left.agent === right.agent &&
833
+ left.pathHash === right.pathHash &&
834
+ left.fileIdentity === right.fileIdentity &&
835
+ left.collectInvocationEvidence === right.collectInvocationEvidence;
836
+ }
837
+ /** Whether the cached parser window is an exact superset of the request. */
838
+ export function cachedWindowCoversRequest(cachedSinceIso, requestedSinceIso) {
839
+ if (requestedSinceIso === null)
840
+ return cachedSinceIso === null;
841
+ if (cachedSinceIso === null)
842
+ return true;
843
+ return Date.parse(cachedSinceIso) <= Date.parse(requestedSinceIso);
844
+ }
845
+ /**
846
+ * Calls remain exact after the loader's timestamp filter. Aggregated Codex
847
+ * invocation evidence cannot generally be subtracted at a later cutoff, so a
848
+ * cross-window hit is allowed only when the root session started inside the
849
+ * requested window (all counted root events are therefore in-window), or the
850
+ * wider cached window observed no countable invocation evidence at all.
851
+ */
852
+ export function invocationWindowCanBeNarrowedExactly(cachedKey, value, requestedKey) {
853
+ if (!requestedKey.collectInvocationEvidence ||
854
+ cachedKey.sinceIso === requestedKey.sinceIso) {
855
+ return true;
856
+ }
857
+ const requestedSinceIso = requestedKey.sinceIso;
858
+ if (requestedSinceIso === null)
859
+ return false;
860
+ const invocation = value.invocationFile;
861
+ if (!invocation)
862
+ return false;
863
+ const proof = value.invocationWindowProof;
864
+ if (!proof || !proof.allCountedEventsTimestamped)
865
+ return false;
866
+ const hasCountedEvidence = invocation.assistantTurns > 0 ||
867
+ invocation.contextSignal.compactionEvents > 0 ||
868
+ invocation.invocations.length > 0 ||
869
+ invocation.invokedMcpTools.length > 0 ||
870
+ invocation.invokedSkills.length > 0 ||
871
+ invocation.invokedSubagents.length > 0 ||
872
+ invocation.invokedCommands.length > 0 ||
873
+ invocation.contextSignal.fileReads.length > 0 ||
874
+ invocation.contextSignal.repeatedFileReads.length > 0;
875
+ if (!hasCountedEvidence)
876
+ return proof.earliestCountedAt === undefined;
877
+ if (proof.earliestCountedAt === undefined)
878
+ return false;
879
+ const requestedSinceMs = Date.parse(requestedSinceIso);
880
+ return Date.parse(proof.earliestCountedAt) >= requestedSinceMs;
881
+ }
882
+ function sinceSortValue(value) {
883
+ return value === null ? Number.NEGATIVE_INFINITY : Date.parse(value);
884
+ }
885
+ function boundedIndexBytes(value) {
886
+ return boundedPositiveInteger(value, qualitativeIndexCacheMaxBytes);
887
+ }
888
+ function boundedEntryBytes(value) {
889
+ return boundedPositiveInteger(value, qualitativeIndexCacheMaxEntryBytes);
890
+ }
891
+ function boundedEntryCount(value) {
892
+ return boundedPositiveInteger(value, qualitativeIndexCacheMaxEntries);
893
+ }
894
+ function boundedPositiveInteger(value, maximum) {
895
+ if (value === undefined || !Number.isSafeInteger(value) || value <= 0)
896
+ return maximum;
897
+ return Math.min(maximum, value);
898
+ }
899
+ function boundedLockTimeout(value) {
900
+ if (value === undefined || !Number.isFinite(value))
901
+ return defaultLockTimeoutMs;
902
+ return Math.max(0, Math.min(10_000, Math.floor(value)));
903
+ }
904
+ function normalizeError(error, fallback) {
905
+ if (error instanceof QualitativeIndexCacheError)
906
+ return error;
907
+ if (isNodeError(error, "EACCES") || isNodeError(error, "EPERM")) {
908
+ return new QualitativeIndexCacheError("permission", "Private qualitative index permission was denied.");
909
+ }
910
+ return new QualitativeIndexCacheError(fallback, "Private qualitative index operation failed safely.");
911
+ }
912
+ function readErrorCode(error, fallback) {
913
+ return isNodeError(error, "EACCES") || isNodeError(error, "EPERM") ? "permission" : fallback;
914
+ }
915
+ export function noFollowFlag() {
916
+ return typeof constants.O_NOFOLLOW === "number" ? constants.O_NOFOLLOW : 0;
917
+ }
918
+ export function hasPrivatePermissions(mode) {
919
+ return process.platform === "win32" || (mode & 0o077) === 0;
920
+ }
921
+ function isBasename(value) {
922
+ return value !== "." && value !== ".." && !value.includes("/") && !value.includes("\\");
923
+ }
924
+ function isRecord(value) {
925
+ return typeof value === "object" && value !== null && !Array.isArray(value);
926
+ }
927
+ export function isNodeError(error, code) {
928
+ return error instanceof Error && error.code === code;
929
+ }
930
+ //# sourceMappingURL=qualitativeIndexCache.js.map