segreant 0.1.0 → 0.2.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 (142) hide show
  1. package/LICENSING.md +5 -3
  2. package/README.md +57 -19
  3. package/bin/segreant-statusline.mjs +20 -0
  4. package/bin/segreant.mjs +19 -8
  5. package/dist/alerts/detect.js +13 -0
  6. package/dist/billing/epistemic.js +4 -0
  7. package/dist/billing/importer.js +29 -10
  8. package/dist/billing/reconcile.js +122 -32
  9. package/dist/billing/scope.js +7 -0
  10. package/dist/budget/guard.js +33 -4
  11. package/dist/cli/allocCmd.js +73 -4
  12. package/dist/cli/askModelPrices.js +59 -0
  13. package/dist/cli/backupCmd.js +2 -1
  14. package/dist/cli/basisLines.js +45 -0
  15. package/dist/cli/billingCmd.js +68 -9
  16. package/dist/cli/capitalCmd.js +2 -1
  17. package/dist/cli/connectCmd.js +50 -9
  18. package/dist/cli/egressCmd.js +15 -3
  19. package/dist/cli/evidenceCmd.js +6 -3
  20. package/dist/cli/flags.js +68 -0
  21. package/dist/cli/homeCmd.js +117 -0
  22. package/dist/cli/importCmd.js +210 -54
  23. package/dist/cli/launchCmd.js +2 -1
  24. package/dist/cli/opsCmd.js +49 -2
  25. package/dist/cli/planCmd.js +181 -0
  26. package/dist/cli/pricingDeclareCmd.js +178 -0
  27. package/dist/cli/quotaCmd.js +190 -0
  28. package/dist/cli/runCmd.js +203 -51
  29. package/dist/cli/showCmd.js +37 -11
  30. package/dist/cli/teamCmd.js +5 -5
  31. package/dist/cli/ui.js +24 -8
  32. package/dist/cli/valueCmd.js +189 -14
  33. package/dist/cli.js +102 -21
  34. package/dist/config.js +95 -6
  35. package/dist/connect/antigravity.js +310 -0
  36. package/dist/connect/claudeCode.js +43 -3
  37. package/dist/connect/codex.js +38 -1
  38. package/dist/connect/importShared.js +58 -5
  39. package/dist/connect/opencode.js +37 -9
  40. package/dist/connect/protobuf.js +126 -0
  41. package/dist/connect/registry.js +46 -0
  42. package/dist/connect/repoRoot.js +112 -0
  43. package/dist/cost/basis.js +168 -0
  44. package/dist/cost/declarations.js +37 -0
  45. package/dist/cost/exactPricing.js +4 -1
  46. package/dist/cost/firstSight.js +98 -0
  47. package/dist/cost/overrides.js +230 -0
  48. package/dist/cost/pricing.js +228 -15
  49. package/dist/cost/reprice.js +130 -0
  50. package/dist/dashboard/claim-support.js +11 -8
  51. package/dist/dashboard/contracts.js +23 -3
  52. package/dist/dashboard/routes.js +980 -112
  53. package/dist/dashboard/sessions.js +345 -0
  54. package/dist/dashboard/settings.js +2 -1
  55. package/dist/dashboard/value-cache.js +105 -0
  56. package/dist/dashboard/web/app/components/claimInspector.js +6 -5
  57. package/dist/dashboard/web/app/components/drawer.js +105 -35
  58. package/dist/dashboard/web/app/components/ruleFields.js +144 -0
  59. package/dist/dashboard/web/app/components/spine.js +54 -24
  60. package/dist/dashboard/web/app/components/workflow.js +82 -0
  61. package/dist/dashboard/web/app/core/actions.js +734 -23
  62. package/dist/dashboard/web/app/core/api.js +37 -1
  63. package/dist/dashboard/web/app/core/chain.js +11 -4
  64. package/dist/dashboard/web/app/core/claimLayers.js +179 -51
  65. package/dist/dashboard/web/app/core/claimTypes.js +30 -0
  66. package/dist/dashboard/web/app/core/fmt.js +19 -2
  67. package/dist/dashboard/web/app/core/generated-contract.js +23 -3
  68. package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
  69. package/dist/dashboard/web/app/core/generated-types.js +1 -1
  70. package/dist/dashboard/web/app/core/griffin.js +10 -0
  71. package/dist/dashboard/web/app/core/jobs.js +12 -0
  72. package/dist/dashboard/web/app/core/period.js +35 -0
  73. package/dist/dashboard/web/app/core/periodWords.js +10 -0
  74. package/dist/dashboard/web/app/core/periods.js +32 -0
  75. package/dist/dashboard/web/app/core/pricingActions.js +264 -0
  76. package/dist/dashboard/web/app/core/privacyText.js +78 -0
  77. package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
  78. package/dist/dashboard/web/app/core/registry.js +79 -46
  79. package/dist/dashboard/web/app/core/slug.js +9 -0
  80. package/dist/dashboard/web/app/core/theme.js +48 -0
  81. package/dist/dashboard/web/app/core/view.js +69 -0
  82. package/dist/dashboard/web/app/main.js +197 -15
  83. package/dist/dashboard/web/app/views/allocation.js +249 -61
  84. package/dist/dashboard/web/app/views/control.js +57 -16
  85. package/dist/dashboard/web/app/views/data.js +4 -4
  86. package/dist/dashboard/web/app/views/evidence.js +336 -87
  87. package/dist/dashboard/web/app/views/privacy.js +13 -0
  88. package/dist/dashboard/web/app/views/spend.js +30 -15
  89. package/dist/dashboard/web/app/views/statement.js +189 -0
  90. package/dist/dashboard/web/app/views/system.js +44 -4
  91. package/dist/dashboard/web/app/views/value.js +105 -59
  92. package/dist/dashboard/web/app/views/workbench.js +312 -0
  93. package/dist/dashboard/web/classic.html +16 -4
  94. package/dist/dashboard/web/index.html +1 -1
  95. package/dist/dashboard/web/styles/app.css +536 -23
  96. package/dist/demo/seed.js +153 -14
  97. package/dist/economics/ledger.js +1 -1
  98. package/dist/egress/policy.js +129 -5
  99. package/dist/export/allocationCsv.js +70 -0
  100. package/dist/export/billingCsv.js +6 -2
  101. package/dist/export/csv.js +10 -2
  102. package/dist/export/economic.js +6 -2
  103. package/dist/export/statementCsv.js +104 -0
  104. package/dist/git/correlate.js +86 -9
  105. package/dist/git/quality.js +107 -24
  106. package/dist/git/repoScope.js +207 -0
  107. package/dist/git/survivalStore.js +186 -0
  108. package/dist/guide.js +9 -4
  109. package/dist/plans/detect.js +147 -0
  110. package/dist/proxy/server.js +134 -14
  111. package/dist/proxy/usage.js +9 -4
  112. package/dist/quota/limits.js +138 -0
  113. package/dist/quota/statusline.js +68 -0
  114. package/dist/quota/view.js +122 -0
  115. package/dist/scan/knownApps.js +6 -2
  116. package/dist/scan/scan.js +9 -0
  117. package/dist/store/allocation.js +14 -0
  118. package/dist/store/db.js +259 -34
  119. package/dist/store/realization.js +78 -14
  120. package/dist/store/schema.js +59 -1
  121. package/dist/store/scopeEvidence.js +67 -0
  122. package/dist/util/pool.js +19 -0
  123. package/dist/util/time.js +7 -0
  124. package/dist/value/realization.js +321 -30
  125. package/dist/value/report.js +28 -8
  126. package/dist/value/selfReported.js +10 -9
  127. package/docs/ARCHITECTURE.md +8 -4
  128. package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
  129. package/docs/CLI-JSON-CONTRACT.md +38 -0
  130. package/docs/DATA-BOUNDARIES.md +66 -6
  131. package/docs/FAQ.md +43 -9
  132. package/docs/GETTING-STARTED.md +19 -12
  133. package/docs/GUIDE.md +81 -18
  134. package/docs/INTEGRATIONS.md +9 -9
  135. package/docs/METHODOLOGY.md +3 -2
  136. package/docs/NEUTRALITY.md +1 -1
  137. package/docs/PRIVACY.md +51 -0
  138. package/docs/README.md +1 -0
  139. package/docs/RELEASE-GATE.md +52 -7
  140. package/docs/THREAT-MODEL.md +2 -1
  141. package/package.json +3 -2
  142. package/pricing/models.json +1366 -60
package/dist/config.js CHANGED
@@ -37,6 +37,10 @@ function validNullableUsd(value) {
37
37
  export function validateBudgetConfig(value) {
38
38
  if (!isRecord(value))
39
39
  throw new ConfigValidationError('budget must be an object');
40
+ const allowed = ['dailyUsd', 'dailySoftUsd', 'sessionUsd', 'runawayWindowSec', 'runawayMaxUsd', 'capIncludesImported'];
41
+ if (Object.keys(value).some((key) => !allowed.includes(key))) {
42
+ throw new ConfigValidationError('budget contains an unknown field; check the configured cap names');
43
+ }
40
44
  for (const key of ['dailyUsd', 'dailySoftUsd', 'sessionUsd', 'runawayMaxUsd']) {
41
45
  if (!validNullableUsd(value[key])) {
42
46
  throw new ConfigValidationError(`budget.${key} must be null or a finite non-negative USD value`);
@@ -133,6 +137,10 @@ function exactUsdCap(value, label) {
133
137
  }
134
138
  const CAP_CACHE = new WeakMap();
135
139
  export function exactBudgetCaps(cfg) {
140
+ // A caller can supply or mutate the live proxy config without loadConfig.
141
+ // Validate before consulting the cache: a new unknown key must never reuse
142
+ // previously valid, unlimited caps.
143
+ validateBudgetConfig(cfg);
136
144
  const source = CAP_KEYS.map((key) => cfg[key]);
137
145
  const cached = CAP_CACHE.get(cfg);
138
146
  if (cached && cached.source.length === source.length
@@ -187,6 +195,32 @@ function validateFeatureOverrides(value) {
187
195
  throw new ConfigValidationError(`features.${key} must be boolean`);
188
196
  }
189
197
  }
198
+ export const PLAN_SOURCES = ['claude-code', 'codex'];
199
+ /** Plan prices are bounded and shaped exactly; anything else stops the load. */
200
+ export function validatePlansConfig(value) {
201
+ if (!isRecord(value))
202
+ throw new ConfigValidationError('plans must be an object of tool -> { monthlyUsd, plan, setAt }');
203
+ for (const [source, price] of Object.entries(value)) {
204
+ if (!PLAN_SOURCES.includes(source)) {
205
+ throw new ConfigValidationError(`plans.${source}: unknown tool (expected one of ${PLAN_SOURCES.join(', ')})`);
206
+ }
207
+ if (!isRecord(price))
208
+ throw new ConfigValidationError(`plans.${source} must be an object`);
209
+ const usdValue = price.monthlyUsd;
210
+ if (typeof usdValue !== 'number' || !Number.isFinite(usdValue) || usdValue < 0 || usdValue > 100_000) {
211
+ throw new ConfigValidationError(`plans.${source}.monthlyUsd must be a number from 0 to 100000`);
212
+ }
213
+ if (price.plan !== null && (typeof price.plan !== 'string' || price.plan.length > 40)) {
214
+ throw new ConfigValidationError(`plans.${source}.plan must be null or a short name`);
215
+ }
216
+ if (typeof price.setAt !== 'string' || Number.isNaN(Date.parse(price.setAt))) {
217
+ throw new ConfigValidationError(`plans.${source}.setAt must be a date`);
218
+ }
219
+ const extra = Object.keys(price).filter((k) => !['monthlyUsd', 'plan', 'setAt'].includes(k));
220
+ if (extra.length > 0)
221
+ throw new ConfigValidationError(`plans.${source}: unexpected field ${extra[0]}`);
222
+ }
223
+ }
190
224
  export const DEFAULT_CONFIG = {
191
225
  features: { ...FEATURE_DEFAULTS },
192
226
  port: 8090,
@@ -238,6 +272,7 @@ export const DEFAULT_CONFIG = {
238
272
  manifestUrl: null,
239
273
  maxAgeDays: 30,
240
274
  autoRefresh: false,
275
+ lookupUnknownModels: true,
241
276
  },
242
277
  perUser: {
243
278
  enabled: false,
@@ -252,6 +287,7 @@ export const DEFAULT_CONFIG = {
252
287
  retentionDays: 180,
253
288
  metadataOnly: false,
254
289
  proposalRetentionDays: 30,
290
+ plans: {},
255
291
  };
256
292
  /**
257
293
  * The environment overrides. `SEGREANT_*` is the only family the product reads.
@@ -306,6 +342,23 @@ export class DemoReadOnlyError extends Error {
306
342
  export function isDemo() {
307
343
  return envOverride('DEMO') === '1';
308
344
  }
345
+ /**
346
+ * Raised when a command would write real usage into the sample store. In demo
347
+ * mode the open store is demo.db, so an import there would sit inside totals
348
+ * labelled as synthetic data while claiming to be isolated.
349
+ */
350
+ export class DemoWriteBlockedError extends Error {
351
+ code = 'DEMO_WRITE_BLOCKED';
352
+ constructor(what) {
353
+ super(`Demo mode holds sample data only, so ${what} was not written. Run it without --demo to bring in your own usage; the sample data stays in demo.db.`);
354
+ this.name = 'DemoWriteBlockedError';
355
+ }
356
+ }
357
+ /** Fail closed before any import writes into a store that is demo data. */
358
+ export function refuseDemoWrite(what) {
359
+ if (isDemo())
360
+ throw new DemoWriteBlockedError(what);
361
+ }
309
362
  /** Remove the demo database (and its WAL/SHM sidecars) for a clean re-seed. */
310
363
  export function unlinkDemoDb() {
311
364
  for (const suffix of ['', '-wal', '-shm']) {
@@ -385,17 +438,21 @@ function sanitizePort(value, fallback) {
385
438
  * an array of exact rule shapes, and boolean enabled flags.
386
439
  */
387
440
  function sanitizeEgressConfig(value) {
388
- if (!isRecord(value))
441
+ // Absent is the default; present but malformed fails closed.
442
+ if (value === undefined)
389
443
  return { mode: 'local_locked', rules: [] };
444
+ const invalid = { mode: 'invalid', rules: [] };
445
+ if (!isRecord(value))
446
+ return invalid;
390
447
  if (value.mode !== 'local_locked' && value.mode !== 'controlled_cloud') {
391
- return { mode: 'local_locked', rules: [] };
448
+ return invalid;
392
449
  }
393
450
  const keys = Object.keys(value).sort();
394
451
  if (keys.length !== 2 || keys[0] !== 'mode' || keys[1] !== 'rules') {
395
- return { mode: 'local_locked', rules: [] };
452
+ return invalid;
396
453
  }
397
454
  if (!Array.isArray(value.rules) || !value.rules.every((rule) => validateEgressRule(rule).length === 0)) {
398
- return { mode: 'local_locked', rules: [] };
455
+ return invalid;
399
456
  }
400
457
  return {
401
458
  mode: value.mode,
@@ -411,13 +468,20 @@ export function loadConfig() {
411
468
  else {
412
469
  let raw;
413
470
  try {
414
- raw = JSON.parse(readFileSync(path, 'utf8'));
471
+ // Windows PowerShell 5.1 writes UTF-8 with a byte-order mark
472
+ // (`Set-Content -Encoding utf8`). The mark is not content, so it is
473
+ // skipped rather than reported as a parse error.
474
+ const text = readFileSync(path, 'utf8');
475
+ raw = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text);
415
476
  }
416
477
  catch (error) {
417
478
  throw new ConfigValidationError(`cannot parse ${path}; repair or restore ${path}.bak before starting Segreant (${error instanceof Error ? error.message : String(error)})`);
418
479
  }
419
480
  if (!isRecord(raw))
420
481
  throw new ConfigValidationError(`configuration root in ${path} must be an object`);
482
+ if (Object.keys(raw).some((key) => !Object.hasOwn(DEFAULT_CONFIG, key))) {
483
+ throw new ConfigValidationError('configuration contains an unknown field; check the configured section names');
484
+ }
421
485
  if (Object.hasOwn(raw, 'features'))
422
486
  validateFeatureOverrides(raw.features);
423
487
  cfg = deepMerge(DEFAULT_CONFIG, raw);
@@ -431,8 +495,12 @@ export function loadConfig() {
431
495
  port: sanitizePort(cfg.port, DEFAULT_CONFIG.port),
432
496
  dashboardPort: sanitizePort(cfg.dashboardPort, DEFAULT_CONFIG.dashboardPort),
433
497
  };
498
+ if (typeof cfg.pricing.lookupUnknownModels !== 'boolean') {
499
+ throw new ConfigValidationError('pricing.lookupUnknownModels must be true or false');
500
+ }
434
501
  validateBudgetConfig(cfg.budget);
435
502
  validateFeaturesConfig(cfg.features);
503
+ validatePlansConfig(cfg.plans);
436
504
  return isDemo() ? withDemoDefaults(cfg) : cfg;
437
505
  }
438
506
  function configLockOwner(path) {
@@ -513,6 +581,27 @@ function assertConfigMutationLock(lock) {
513
581
  throw new ConfigValidationError('config mutation lock ownership cannot be proven');
514
582
  }
515
583
  }
584
+ /**
585
+ * `invalid` is how a malformed egress section loads; it is never written. A
586
+ * write that does not touch egress (a cap, a plan price) keeps the section
587
+ * exactly as it is on disk, so the user's rules survive to be repaired. Writing
588
+ * the marker replaced the section with `{"mode":"invalid","rules":[]}`, and the
589
+ * rules were gone once the next write rotated the backup (independent review,
590
+ * 2026-10-18). A write that repairs egress sets a real mode and is written as is.
591
+ */
592
+ function withPersistedEgress(config, path) {
593
+ if (config.egress.mode !== 'invalid')
594
+ return config;
595
+ let onDisk;
596
+ try {
597
+ const text = readFileSync(path, 'utf8');
598
+ onDisk = JSON.parse(text.charCodeAt(0) === 0xfeff ? text.slice(1) : text)['egress'];
599
+ }
600
+ catch (error) {
601
+ throw new ConfigValidationError(`the egress section of ${path} is malformed and could not be kept as it is; repair it before changing other settings (${error instanceof Error ? error.message : String(error)})`);
602
+ }
603
+ return { ...config, egress: onDisk };
604
+ }
516
605
  function persistConfigUnlocked(config) {
517
606
  ensureHome();
518
607
  validateBudgetConfig(config.budget);
@@ -520,7 +609,7 @@ function persistConfigUnlocked(config) {
520
609
  const path = configPath();
521
610
  const tempPath = `${path}.tmp-${randomUUID()}`;
522
611
  const backupPath = `${path}.bak`;
523
- const text = JSON.stringify(config, null, 2) + '\n';
612
+ const text = JSON.stringify(withPersistedEgress(config, path), null, 2) + '\n';
524
613
  let descriptor = null;
525
614
  try {
526
615
  // Preserve the previous known-good config before replacing it. The target
@@ -0,0 +1,310 @@
1
+ /**
2
+ * Native metering for Google Antigravity (the IDE agent and the `agy` CLI).
3
+ *
4
+ * Antigravity keeps one SQLite file per conversation under
5
+ * ~/.gemini/antigravity/conversations and ~/.gemini/antigravity-cli/conversations.
6
+ * Each model call is a row of `gen_metadata` holding a protobuf
7
+ * `CortexStepGeneratorMetadata`; its `chat_model` (field 1) carries the call's
8
+ * `ModelUsageStats` (field 4) and the model that answered (`response_model`,
9
+ * field 19). Field numbers follow the published Windsurf/Antigravity schema
10
+ * (`exa.cortex_pb` and `exa.codeium_common_pb`, as mirrored in open-source
11
+ * Antigravity tooling); nothing is guessed from values.
12
+ *
13
+ * ModelUsageStats: 2 input_tokens · 3 output_tokens · 4 cache_write_tokens ·
14
+ * 5 cache_read_tokens · 9 thinking_output_tokens
15
+ *
16
+ * The same usage is repeated in each step's metadata and in `retry_infos`; only
17
+ * the generator's own usage is counted, once. The call's time is its first
18
+ * step's `created_at`; the workspace folder comes from the trajectory metadata.
19
+ *
20
+ * Read-only: each database is opened read-only, so Antigravity keeps writing.
21
+ * Antigravity prices nothing locally, so every row is list cost from the rate
22
+ * card; a model the card does not carry (Gemini 3.x today) gets the labelled
23
+ * fallback estimate, never an invented price.
24
+ */
25
+ import { existsSync, readdirSync, statSync } from 'node:fs';
26
+ import { homedir } from 'node:os';
27
+ import { join } from 'node:path';
28
+ import { fileURLToPath } from 'node:url';
29
+ import { DatabaseSync } from 'node:sqlite';
30
+ import { computeCost } from '../cost/pricing.js';
31
+ import { emptyImportSummary, recordInsert, createRepoResolver, noteRelabel, markImportTruncated, fileStampForImport, noteFileImported, noteFileUnchanged, truncationMark, } from './importShared.js';
32
+ import { pbFields, pbMessage, pbString, pbTimestampMs, pbUint, pbUints } from './protobuf.js';
33
+ import { RESOURCE_LIMITS } from '../util/resource-limits.js';
34
+ /** ~/.gemini, where Antigravity keeps its data. null = not installed. */
35
+ export function defaultAntigravityRoot() {
36
+ const root = join(homedir(), '.gemini');
37
+ return ['antigravity', 'antigravity-cli'].some((d) => existsSync(join(root, d, 'conversations'))) ? root : null;
38
+ }
39
+ function measureRead(profile, read) {
40
+ if (profile === undefined)
41
+ return read();
42
+ const start = performance.now();
43
+ try {
44
+ return read();
45
+ }
46
+ finally {
47
+ profile.readMs += performance.now() - start;
48
+ }
49
+ }
50
+ function providerFor(model) {
51
+ const m = model.toLowerCase();
52
+ if (m.startsWith('claude'))
53
+ return 'anthropic';
54
+ if (/^(gpt|o\d)/.test(m))
55
+ return 'openai';
56
+ return 'google';
57
+ }
58
+ function workspacePath(uri) {
59
+ if (uri === null || !uri.startsWith('file://'))
60
+ return null;
61
+ try {
62
+ return fileURLToPath(uri);
63
+ }
64
+ catch {
65
+ return null;
66
+ }
67
+ }
68
+ /** Every model call in one conversation database. Pure over the database's rows. */
69
+ export function readAntigravityConversation(db, fallbackId, profile) {
70
+ const tableNames = new Set(measureRead(profile, () => db.prepare("SELECT name FROM sqlite_master WHERE type = 'table'").all()).map((t) => t.name));
71
+ if (!tableNames.has('gen_metadata'))
72
+ return [];
73
+ let trajectoryId = fallbackId;
74
+ let cwd = null;
75
+ let startedMs = null;
76
+ if (tableNames.has('trajectory_metadata_blob')) {
77
+ const meta = measureRead(profile, () => db.prepare('SELECT data FROM trajectory_metadata_blob LIMIT 1').get());
78
+ if (meta?.data) {
79
+ const decodeStart = profile === undefined ? 0 : performance.now();
80
+ const f = pbFields(meta.data);
81
+ const workspace = pbMessage(f, 1);
82
+ cwd = workspace === null ? null : workspacePath(pbString(workspace, 1));
83
+ startedMs = pbTimestampMs(pbMessage(f, 2));
84
+ trajectoryId = pbString(f, 3) ?? trajectoryId;
85
+ if (profile !== undefined)
86
+ profile.decodeMs += performance.now() - decodeStart;
87
+ }
88
+ }
89
+ const stepTimes = new Map();
90
+ if (tableNames.has('steps')) {
91
+ const steps = measureRead(profile, () => db.prepare('SELECT idx, metadata FROM steps').all());
92
+ if (profile !== undefined)
93
+ profile.stepRows += steps.length;
94
+ const decodeStart = profile === undefined ? 0 : performance.now();
95
+ for (const s of steps) {
96
+ if (!s.metadata)
97
+ continue;
98
+ const t = pbTimestampMs(pbMessage(pbFields(s.metadata), 1));
99
+ if (t !== null)
100
+ stepTimes.set(s.idx, t);
101
+ }
102
+ if (profile !== undefined)
103
+ profile.decodeMs += performance.now() - decodeStart;
104
+ }
105
+ const calls = [];
106
+ const generators = measureRead(profile, () => db.prepare('SELECT idx, data FROM gen_metadata ORDER BY idx').all());
107
+ if (profile !== undefined)
108
+ profile.generatorRows += generators.length;
109
+ const decodeStart = profile === undefined ? 0 : performance.now();
110
+ for (const g of generators) {
111
+ if (!g.data)
112
+ continue;
113
+ const gen = pbFields(g.data);
114
+ const chat = pbMessage(gen, 1);
115
+ if (chat === null)
116
+ continue; // an injected response: no model call
117
+ const usage = pbMessage(chat, 4);
118
+ if (usage === null)
119
+ continue;
120
+ const inputTokens = pbUint(usage, 2);
121
+ const outputTokens = pbUint(usage, 3);
122
+ const cacheWriteTokens = pbUint(usage, 4);
123
+ const cacheReadTokens = pbUint(usage, 5);
124
+ if (inputTokens + outputTokens + cacheWriteTokens + cacheReadTokens === 0)
125
+ continue;
126
+ const model = pbString(chat, 19) || `antigravity-model-${pbUint(chat, 3)}`;
127
+ const firstStep = pbUints(gen, 2)[0];
128
+ const ts = (firstStep !== undefined ? stepTimes.get(firstStep) : undefined) ?? startedMs;
129
+ if (ts === null || ts === undefined)
130
+ continue;
131
+ calls.push({
132
+ requestId: `antigravity:${trajectoryId}:${g.idx}`,
133
+ sessionId: trajectoryId,
134
+ tsEpochMs: ts,
135
+ model,
136
+ provider: providerFor(model),
137
+ cwd,
138
+ inputTokens,
139
+ outputTokens,
140
+ cacheWriteTokens,
141
+ cacheReadTokens,
142
+ reasoningTokens: pbUint(usage, 9),
143
+ });
144
+ }
145
+ if (profile !== undefined) {
146
+ profile.decodeMs += performance.now() - decodeStart;
147
+ profile.decodedCalls += calls.length;
148
+ }
149
+ return calls;
150
+ }
151
+ function conversationFiles(root) {
152
+ const out = [];
153
+ for (const sub of ['antigravity', 'antigravity-cli']) {
154
+ const dir = join(root, sub, 'conversations');
155
+ let names = [];
156
+ try {
157
+ names = readdirSync(dir);
158
+ }
159
+ catch {
160
+ continue;
161
+ }
162
+ for (const n of names)
163
+ if (n.endsWith('.db'))
164
+ out.push(join(dir, n));
165
+ }
166
+ return out;
167
+ }
168
+ /** Imports in periodic commits rather than one commit per row (see Store.importBatch). */
169
+ export async function importAntigravity(store, opts = {}) {
170
+ const batch = store.importBatch();
171
+ try {
172
+ return await importAntigravityRows(store, opts);
173
+ }
174
+ finally {
175
+ batch.end();
176
+ }
177
+ }
178
+ async function importAntigravityRows(store, opts) {
179
+ const started = performance.now();
180
+ const profile = opts.onProfile === undefined ? undefined : {
181
+ filesRead: 0, stepRows: 0, generatorRows: 0, decodedCalls: 0, eligibleCalls: 0,
182
+ insertedRows: 0, readMs: 0, decodeMs: 0, filterMs: 0,
183
+ attributionMs: 0, pricingMs: 0, persistMs: 0, totalMs: 0,
184
+ };
185
+ const root = opts.root ?? defaultAntigravityRoot();
186
+ const source = opts.source ?? 'antigravity';
187
+ const sinceMs = opts.sinceMs ?? 0;
188
+ if (root === null) {
189
+ const empty = emptyImportSummary(0);
190
+ if (profile !== undefined) {
191
+ profile.totalMs = performance.now() - started;
192
+ opts.onProfile?.(profile);
193
+ }
194
+ return empty;
195
+ }
196
+ const files = conversationFiles(root);
197
+ const summary = emptyImportSummary(files.length);
198
+ const resolveProject = createRepoResolver();
199
+ let rows = 0;
200
+ for (const file of files) {
201
+ // A live conversation writes to its WAL first; the main file's size and time
202
+ // do not move until a checkpoint, so such a file is always read again.
203
+ let walLive = false;
204
+ try {
205
+ walLive = statSync(`${file}-wal`).size > 0;
206
+ }
207
+ catch {
208
+ walLive = false;
209
+ }
210
+ const stamp = fileStampForImport(store, source, file, walLive ? { ...opts, rescan: true } : opts);
211
+ if (stamp === 'deferred') {
212
+ summary.filesDeferred = (summary.filesDeferred ?? 0) + 1;
213
+ continue;
214
+ }
215
+ if (stamp === 'unchanged') {
216
+ noteFileUnchanged(store, source, file, summary);
217
+ continue;
218
+ }
219
+ const truncationsBefore = truncationMark(summary);
220
+ let db;
221
+ try {
222
+ db = new DatabaseSync(file, { readOnly: true });
223
+ }
224
+ catch {
225
+ continue; // locked or not a database: skipped, never a crash
226
+ }
227
+ if (profile !== undefined)
228
+ profile.filesRead += 1;
229
+ let calls = [];
230
+ try {
231
+ calls = readAntigravityConversation(db, file.replace(/^.*[\\/]/, '').replace(/\.db$/, ''), profile);
232
+ }
233
+ catch {
234
+ calls = [];
235
+ }
236
+ finally {
237
+ db.close();
238
+ }
239
+ for (const c of calls) {
240
+ const filterStart = profile === undefined ? 0 : performance.now();
241
+ const tooOld = c.tsEpochMs < sinceMs;
242
+ if (profile !== undefined)
243
+ profile.filterMs += performance.now() - filterStart;
244
+ if (tooOld)
245
+ continue;
246
+ if (++rows > RESOURCE_LIMITS.importRows) {
247
+ markImportTruncated(summary, 'rows');
248
+ break;
249
+ }
250
+ if (profile !== undefined)
251
+ profile.eligibleCalls += 1;
252
+ const attributionStart = profile === undefined ? 0 : performance.now();
253
+ const attribution = await resolveProject(c.cwd, 'antigravity');
254
+ if (profile !== undefined)
255
+ profile.attributionMs += performance.now() - attributionStart;
256
+ noteRelabel(summary, attribution);
257
+ // The rate card's lookup follows the model across providers (rateFor), so a
258
+ // Gemini row finds the Google table through any named provider.
259
+ const pricingStart = profile === undefined ? 0 : performance.now();
260
+ const cost = computeCost(c.provider === 'anthropic' ? 'anthropic' : 'openai', c.model, {
261
+ inputTokens: c.inputTokens,
262
+ outputTokens: c.outputTokens,
263
+ cacheWriteTokens: c.cacheWriteTokens,
264
+ cacheReadTokens: c.cacheReadTokens,
265
+ });
266
+ if (profile !== undefined)
267
+ profile.pricingMs += performance.now() - pricingStart;
268
+ const row = {
269
+ requestId: c.requestId,
270
+ sessionId: c.sessionId,
271
+ tsEpochMs: c.tsEpochMs,
272
+ provider: c.provider,
273
+ model: c.model,
274
+ project: attribution.project,
275
+ attributionBasis: attribution.basis,
276
+ taskWeight: 1,
277
+ inputTokens: c.inputTokens,
278
+ outputTokens: c.outputTokens,
279
+ cacheWriteTokens: c.cacheWriteTokens,
280
+ cacheReadTokens: c.cacheReadTokens,
281
+ reasoningTokens: c.reasoningTokens,
282
+ costUsd: cost.costUsd,
283
+ economicAmount: cost.exact?.total,
284
+ estimated: cost.estimated,
285
+ pricing: cost.pricing,
286
+ streamed: true,
287
+ statusCode: 200,
288
+ durationMs: null,
289
+ user: null,
290
+ source,
291
+ cwd: c.cwd,
292
+ };
293
+ const persistStart = profile === undefined ? 0 : performance.now();
294
+ store.upsertSession(c.sessionId, attribution.project, source, c.tsEpochMs);
295
+ recordInsert(store, summary, row, cost.estimated);
296
+ if (profile !== undefined)
297
+ profile.persistMs += performance.now() - persistStart;
298
+ }
299
+ const cursorStart = profile === undefined ? 0 : performance.now();
300
+ noteFileImported(store, source, file, stamp, opts, truncationsBefore, summary);
301
+ if (profile !== undefined)
302
+ profile.persistMs += performance.now() - cursorStart;
303
+ }
304
+ if (profile !== undefined) {
305
+ profile.insertedRows = summary.inserted;
306
+ profile.totalMs = performance.now() - started;
307
+ opts.onProfile?.(profile);
308
+ }
309
+ return summary;
310
+ }
@@ -24,10 +24,11 @@ import { createReadStream } from 'node:fs';
24
24
  import { createInterface } from 'node:readline';
25
25
  import { join } from 'node:path';
26
26
  import { homedir } from 'node:os';
27
- import { computeCost } from '../cost/pricing.js';
27
+ import { cacheWriteDuration, computeCost } from '../cost/pricing.js';
28
28
  import { projectKeyWithBasis } from '../value/characterization.js';
29
- import { emptyImportSummary, recordInsert, createRepoResolver, noteRelabel, boundedJsonlFiles, markImportTruncated, fileStampForImport, noteFileImported, noteFileUnchanged, truncationMark, } from './importShared.js';
29
+ import { emptyImportSummary, recordInsert, createRepoResolver, noteRelabel, boundedJsonlFiles, markImportTruncated, fileStampForImport, noteFileImported, noteFileUnchanged, truncationMark, commitObservationsInLine, } from './importShared.js';
30
30
  import { RESOURCE_LIMITS } from '../util/resource-limits.js';
31
+ import { claudeLimitEvent } from '../quota/limits.js';
31
32
  /** Default transcript root: ~/.claude/projects (override for tests / unusual installs). */
32
33
  export function defaultClaudeCodeRoot() {
33
34
  return join(homedir(), '.claude', 'projects');
@@ -73,7 +74,10 @@ export function parseTranscriptLine(line) {
73
74
  outputTokens: u.output_tokens ?? 0,
74
75
  cacheWriteTokens: cacheWrite,
75
76
  cacheReadTokens: u.cache_read_input_tokens ?? 0,
76
- cacheWriteTtl: cacheWrite > 0 ? (ttl1h >= ttl5m ? '1h' : '5m') : undefined,
77
+ ...(cacheWrite > 0 ? (() => {
78
+ const d = cacheWriteDuration(ttl5m, ttl1h);
79
+ return { ...(d.ttl === undefined ? {} : { cacheWriteTtl: d.ttl }), cacheWriteTtlObserved: d.observed };
80
+ })() : {}),
77
81
  };
78
82
  }
79
83
  /**
@@ -106,6 +110,10 @@ async function importClaudeCodeRows(store, opts) {
106
110
  const resolveProject = createRepoResolver();
107
111
  for (const file of files) {
108
112
  const stamp = fileStampForImport(store, source, file, opts);
113
+ if (stamp === 'deferred') {
114
+ summary.filesDeferred = (summary.filesDeferred ?? 0) + 1;
115
+ continue;
116
+ }
109
117
  if (stamp === 'unchanged') {
110
118
  noteFileUnchanged(store, source, file, summary);
111
119
  continue;
@@ -115,6 +123,30 @@ async function importClaudeCodeRows(store, opts) {
115
123
  noteFileImported(store, source, file, stamp, opts, truncationsBefore, summary);
116
124
  }
117
125
  return summary;
126
+ // A tool result that shows git creating a commit names the session that made
127
+ // it. Each transcript line carries its own session id, time and directory.
128
+ function recordCommitsIn(line) {
129
+ const commits = commitObservationsInLine(line);
130
+ if (commits.length === 0)
131
+ return;
132
+ const sessionId = /"sessionId":"([^"]+)"/.exec(line)?.[1];
133
+ const ts = Date.parse(/"timestamp":"([^"]+)"/.exec(line)?.[1] ?? '');
134
+ if (!sessionId || !Number.isFinite(ts) || ts < sinceMs)
135
+ return;
136
+ let cwd = null;
137
+ const rawCwd = /"cwd":"((?:[^"\\]|\\.)*)"/.exec(line)?.[1];
138
+ if (rawCwd !== undefined) {
139
+ try {
140
+ cwd = JSON.parse(`"${rawCwd}"`);
141
+ }
142
+ catch {
143
+ cwd = null;
144
+ }
145
+ }
146
+ for (const c of commits) {
147
+ store.recordObservedCommit({ source, sessionId, shortSha: c.shortSha, branch: c.branch, subject: c.subject, tsEpochMs: ts, cwd });
148
+ }
149
+ }
118
150
  async function importTranscriptFile(file) {
119
151
  // One API request = many transcript lines; first wins. A copy in ANOTHER file
120
152
  // (a resumed session) goes to the store, which recognises the same charge as a
@@ -126,6 +158,13 @@ async function importClaudeCodeRows(store, opts) {
126
158
  markImportTruncated(summary, 'lines');
127
159
  continue;
128
160
  }
161
+ recordCommitsIn(line);
162
+ if (line.includes('"isApiErrorMessage":true')) {
163
+ const ts = Date.parse(/"timestamp":"([^"]+)"/.exec(line)?.[1] ?? '');
164
+ const limit = Number.isFinite(ts) && ts >= sinceMs ? claudeLimitEvent(line, ts) : null;
165
+ if (limit !== null)
166
+ store.recordQuotaEvent(limit);
167
+ }
129
168
  const ev = parseTranscriptLine(line);
130
169
  if (!ev || ev.tsEpochMs < sinceMs)
131
170
  continue;
@@ -158,6 +197,7 @@ async function importClaudeCodeRows(store, opts) {
158
197
  outputTokens: ev.outputTokens,
159
198
  cacheWriteTokens: ev.cacheWriteTokens,
160
199
  cacheReadTokens: ev.cacheReadTokens,
200
+ ...(ev.cacheWriteTtl === undefined || !ev.cacheWriteTtlObserved ? {} : { cacheWriteTtl: ev.cacheWriteTtl }),
161
201
  reasoningTokens: 0,
162
202
  costUsd: cost.costUsd,
163
203
  economicAmount: cost.exact?.total,
@@ -25,8 +25,9 @@ import { join } from 'node:path';
25
25
  import { homedir } from 'node:os';
26
26
  import { computeCost, unpricedPricingEvidence } from '../cost/pricing.js';
27
27
  import { projectKeyWithBasis } from '../value/characterization.js';
28
- import { emptyImportSummary, recordInsert, createRepoResolver, noteRelabel, boundedJsonlFiles, markImportTruncated, fileStampForImport, noteFileImported, noteFileUnchanged, truncationMark, } from './importShared.js';
28
+ import { emptyImportSummary, recordInsert, createRepoResolver, noteRelabel, boundedJsonlFiles, markImportTruncated, fileStampForImport, noteFileImported, noteFileUnchanged, truncationMark, commitObservationsInLine, } from './importShared.js';
29
29
  import { RESOURCE_LIMITS } from '../util/resource-limits.js';
30
+ import { codexRateLimitEvents } from '../quota/limits.js';
30
31
  /** Codex home: ~/.codex (override with CODEX_HOME). null = not installed. */
31
32
  export function defaultCodexRoot() {
32
33
  const home = process.env.CODEX_HOME ?? join(homedir(), '.codex');
@@ -94,6 +95,7 @@ export async function parseCodexRollout(file, options = {}) {
94
95
  let emittedRows = 0;
95
96
  const maxRows = options.maxRows ?? RESOURCE_LIMITS.importRows;
96
97
  const projFromCwd = (cwd) => projectKeyWithBasis(cwd, 'codex');
98
+ const lastMeter = new Map();
97
99
  for await (const line of rl) {
98
100
  if (Buffer.byteLength(line, 'utf8') > RESOURCE_LIMITS.transcriptLineBytes) {
99
101
  options.onTruncatedLine?.();
@@ -107,6 +109,17 @@ export async function parseCodexRollout(file, options = {}) {
107
109
  continue; // torn tail of a live session — next import gets it whole
108
110
  }
109
111
  const p = o.payload ?? {};
112
+ // A fork or subagent file opens with its parent's history replayed; its own
113
+ // turns begin after its first token count. Commits in the replay are the
114
+ // parent's, so they are not credited to this thread.
115
+ if (options.onCommit && o.type !== 'session_meta' && o.type !== 'turn_context' && !(forked && firstCount)) {
116
+ const thread = forkId ?? sessionId;
117
+ const ts = Date.parse(o.timestamp ?? '');
118
+ if (thread && Number.isFinite(ts)) {
119
+ for (const c of commitObservationsInLine(line))
120
+ options.onCommit({ sessionId: thread, ...c, tsEpochMs: ts, cwd });
121
+ }
122
+ }
110
123
  if (o.type === 'session_meta') {
111
124
  // Only the first session_meta names this file's thread; later ones are
112
125
  // the parent's context replayed into a subagent or fork.
@@ -134,6 +147,18 @@ export async function parseCodexRollout(file, options = {}) {
134
147
  }
135
148
  continue;
136
149
  }
150
+ if (o.type === 'event_msg' && p.type === 'token_count' && options.onRateLimit && p.rate_limits !== undefined) {
151
+ const ts = Date.parse(o.timestamp ?? '');
152
+ if (Number.isFinite(ts)) {
153
+ for (const e of codexRateLimitEvents(p.rate_limits, ts)) {
154
+ const key = JSON.stringify([e.usedPercent, e.resetsAtMs]);
155
+ if (lastMeter.get(e.kind) === key)
156
+ continue;
157
+ lastMeter.set(e.kind, key);
158
+ options.onRateLimit(e);
159
+ }
160
+ }
161
+ }
137
162
  if (o.type === 'event_msg' && p.type === 'token_count') {
138
163
  const info = p.info;
139
164
  const tot = totalsFrom(info?.total_token_usage);
@@ -220,6 +245,10 @@ async function importCodexRows(store, opts) {
220
245
  const resolveProject = createRepoResolver();
221
246
  for (const file of files) {
222
247
  const stamp = fileStampForImport(store, source, file, opts);
248
+ if (stamp === 'deferred') {
249
+ summary.filesDeferred = (summary.filesDeferred ?? 0) + 1;
250
+ continue;
251
+ }
223
252
  if (stamp === 'unchanged') {
224
253
  noteFileUnchanged(store, source, file, summary);
225
254
  continue;
@@ -229,6 +258,14 @@ async function importCodexRows(store, opts) {
229
258
  await parseCodexRollout(file, {
230
259
  onTruncatedLine: () => markImportTruncated(summary, 'lines'),
231
260
  onTruncatedRow: () => markImportTruncated(summary, 'rows'),
261
+ onCommit: (c) => {
262
+ if (c.tsEpochMs >= sinceMs)
263
+ store.recordObservedCommit({ source, ...c });
264
+ },
265
+ onRateLimit: (e) => {
266
+ if (e.tsEpochMs >= sinceMs)
267
+ store.recordQuotaEvent(e);
268
+ },
232
269
  onRow: async (ev) => {
233
270
  if (ev.tsEpochMs < sinceMs)
234
271
  return;