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
@@ -21,13 +21,17 @@
21
21
  * history refuses the outbound request before DNS/dial.
22
22
  */
23
23
  import http from 'node:http';
24
+ import { statSync } from 'node:fs';
24
25
  import { randomUUID } from 'node:crypto';
26
+ import { configPath, loadConfig } from '../config.js';
25
27
  import { BudgetGuard, unverifiedBasis } from '../budget/guard.js';
26
- import { computeCost, unpricedPricingEvidence } from '../cost/pricing.js';
28
+ import { computeCost, withDeclarations, unpricedPricingEvidence, notSentPricingEvidence } from '../cost/pricing.js';
29
+ import { activePriceOverrides, PriceOverridesError } from '../cost/overrides.js';
27
30
  import { StreamUsageAccumulator, normalizeAnthropicUsage, normalizeOpenAIUsage, emptyUsage, } from './usage.js';
28
31
  import { extractProposalsWithCoverage } from '../value/proposals.js';
29
32
  import { StreamProposalAccumulator } from './stream-proposals.js';
30
33
  import { EgressError, egressFetchWithConfig } from '../egress/transport.js';
34
+ import { providerRuleCommand } from '../egress/policy.js';
31
35
  import { readBoundedResponseText, RESOURCE_LIMITS, ResourceLimitError } from '../util/resource-limits.js';
32
36
  const HOP_BY_HOP = new Set([
33
37
  'connection',
@@ -231,10 +235,21 @@ function providerBudgetUnavailableBody(provider, message) {
231
235
  }
232
236
  return JSON.stringify({ error: { message, type: 'segreant_budget_unavailable', code: 'budget_enforcement_unavailable' } });
233
237
  }
234
- function providerEgressRefusalBody(provider, error) {
238
+ export function providerEgressRefusalBody(provider, error, targetUrl, method, egressMode) {
239
+ // Only the providers' official APIs are built in; a gateway or a self-hosted
240
+ // model is allowed by name, with the one command the refusal prints. A
241
+ // malformed egress section refuses everything, built-ins included, and its
242
+ // repair is the file, not a rule: printing a rule there called
243
+ // api.openai.com "not a built-in provider API" (independent review, 2026-10-18).
244
+ const invalid = egressMode === 'invalid';
245
+ const allow = error.code === 'policy_denied' && !invalid ? providerRuleCommand(targetUrl, method) : null;
235
246
  const repair = error.code === 'receipt_integrity_failed' || error.code === 'receipt_persistence_failed'
236
247
  ? ' Repair or restore the local receipt history before retrying.'
237
- : '';
248
+ : invalid
249
+ ? ' The egress section of config.json is malformed, so every cloud request is refused. Fix it by hand, or reset it with: segreant egress apply --apply --mode local_locked'
250
+ : allow
251
+ ? ` ${new URL(targetUrl).origin} is not one of the built-in provider APIs. To send your requests there, run: ${allow}`
252
+ : '';
238
253
  const message = `Segreant refused this outbound request at its egress boundary: ${error.message}.${repair}`;
239
254
  if (provider === 'anthropic') {
240
255
  return JSON.stringify({ type: 'error', error: { type: 'segreant_egress_refusal', code: 'egress_refused', subcode: error.code, message } });
@@ -266,12 +281,94 @@ function waitForDrainOrClose(res) {
266
281
  res.once('close', onClose);
267
282
  });
268
283
  }
284
+ /**
285
+ * The budget the running proxy enforces, kept in step with config.json.
286
+ *
287
+ * Dashboard Settings mutates the shared config object in place, but
288
+ * `segreant budget --daily N` and a hand edit write the FILE from another
289
+ * process. Before this, a cap set from the terminal was printed as "Budget
290
+ * updated" and not enforced until a restart (verified 2026-10-17: two requests
291
+ * over a $0.05 cap went through), and an invalid budget written while running
292
+ * kept forwarding. The file is checked at most every `intervalMs`; a changed
293
+ * file is re-read and validated, and only its budget is taken (ports and
294
+ * upstreams still need a restart). An unreadable or invalid file THROWS, which
295
+ * the pre-flight check turns into a fail-closed 503, and is retried at the next
296
+ * check rather than cached, so a half-written file does not stick. A missing
297
+ * file keeps the budget in force: deleting config.json never lifts a cap.
298
+ */
299
+ /**
300
+ * The persisted budget could not be read or validated. Requests fail closed
301
+ * while it lasts, but unlike a ledger failure it is not latched: repairing
302
+ * config.json resumes enforcement without a restart.
303
+ */
304
+ export class BudgetConfigUnavailable extends Error {
305
+ constructor(cause) {
306
+ super(`budget configuration unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
307
+ this.name = 'BudgetConfigUnavailable';
308
+ }
309
+ }
310
+ export function liveBudget(config, opts = {}) {
311
+ const intervalMs = opts.intervalMs ?? 500;
312
+ const path = opts.path ?? configPath;
313
+ const load = opts.load ?? loadConfig;
314
+ let checkedAt = -Infinity;
315
+ // No mtime is recorded up front: the file may have changed between the
316
+ // caller's loadConfig() and this point (start awaits a pricing refresh), so
317
+ // the first check always re-reads what is on disk now (PR #133 review).
318
+ let seenMtimeMs = null;
319
+ let failure = null;
320
+ return () => {
321
+ const now = Date.now();
322
+ if (now - checkedAt >= intervalMs) {
323
+ checkedAt = now;
324
+ let mtimeMs = null;
325
+ try {
326
+ mtimeMs = statSync(path()).mtimeMs;
327
+ }
328
+ catch (error) {
329
+ // Only a missing file is a deletion, which keeps the cap in force.
330
+ // Any other stat failure (permissions, I/O) means the persisted
331
+ // budget cannot be verified, and that fails closed.
332
+ if (error?.code !== 'ENOENT')
333
+ failure = new BudgetConfigUnavailable(error);
334
+ else {
335
+ // Deleting the file is the documented way back to defaults, so the
336
+ // egress policy returns to its default too: a gateway allowed by a
337
+ // rule in the deleted file is refused on the next request, not at
338
+ // the next restart (PR #148 review). The built-in provider routes
339
+ // hold in the default. A file written again is re-read in full.
340
+ config.egress = { mode: 'local_locked', rules: [] };
341
+ seenMtimeMs = null;
342
+ }
343
+ }
344
+ if (mtimeMs !== null && (mtimeMs !== seenMtimeMs || failure !== null)) {
345
+ try {
346
+ const fresh = load();
347
+ config.budget = fresh.budget;
348
+ // The egress rules follow the file the same way: a rule added with
349
+ // `segreant egress apply` (the command a refusal prints) governs the
350
+ // next request, with no restart, and so does a tightening (PR #148
351
+ // review). An invalid file throws above and fails closed.
352
+ config.egress = fresh.egress;
353
+ seenMtimeMs = mtimeMs;
354
+ failure = null;
355
+ }
356
+ catch (error) {
357
+ failure = new BudgetConfigUnavailable(error);
358
+ }
359
+ }
360
+ }
361
+ if (failure !== null)
362
+ throw failure;
363
+ return config.budget;
364
+ };
365
+ }
269
366
  export function createProxyServer(deps) {
270
367
  const { store, config } = deps;
271
- // Dashboard Settings mutates the shared config object after persisting it.
272
368
  // Resolve budget at each pre-flight check so a newly chosen cap actually
273
- // governs the already-running proxy, rather than merely looking saved in UI.
274
- const guard = new BudgetGuard(store, () => config.budget);
369
+ // governs the already-running proxy, whether it was saved from Settings (the
370
+ // shared object) or from the CLI or an editor (the file; see liveBudget).
371
+ const guard = new BudgetGuard(store, deps.budget ?? liveBudget(config));
275
372
  const state = { accountingFailure: false };
276
373
  const server = http.createServer((req, res) => {
277
374
  handle(req, res, deps, guard, state).catch((err) => {
@@ -396,12 +493,25 @@ async function handle(req, res, deps, guard, state) {
396
493
  // --- Budget pre-flight ---
397
494
  let decision;
398
495
  let budgetFailure = false;
496
+ let declarations = Object.create(null);
399
497
  try {
498
+ // Declared prices are read before anything is sent: a hand-edited
499
+ // overrides.json that no longer parses would otherwise throw only when the
500
+ // answer is priced, after the provider had charged for it, leaving the
501
+ // request out of the ledger and the cap (PR #146 review). Like a broken
502
+ // config.json it does not latch: repairing the file resumes forwarding.
503
+ // The request is later priced from exactly these declarations, so an edit
504
+ // that breaks the file while it is in flight cannot stop its accounting
505
+ // after the provider has answered (PR #146 review).
506
+ declarations = activePriceOverrides();
400
507
  decision = guard.evaluate({ sessionId });
401
508
  }
402
- catch {
509
+ catch (error) {
403
510
  budgetFailure = true;
404
- state.accountingFailure = true;
511
+ // A ledger or accounting failure latches; an unreadable or invalid
512
+ // config.json or price-overrides file does not, so repairing it resumes enforcement.
513
+ if (!(error instanceof BudgetConfigUnavailable) && !(error instanceof PriceOverridesError))
514
+ state.accountingFailure = true;
405
515
  decision = {
406
516
  action: 'block',
407
517
  reason: 'budget_enforcement_unavailable',
@@ -431,7 +541,8 @@ async function handle(req, res, deps, guard, state) {
431
541
  ? 'Segreant cannot verify local budget/accounting state; the request was not sent. Repair the local ledger or configuration, then restart Segreant.'
432
542
  : decision.reason ?? 'Budget limit reached.';
433
543
  res.end(unavailable ? providerBudgetUnavailableBody(provider, message) : providerErrorBody(provider, message));
434
- // Log the blocked attempt at zero cost for the audit trail.
544
+ // Log the blocked attempt at zero cost for the audit trail. Nothing was
545
+ // sent, so the cost is known to be zero, not unknown.
435
546
  safeLog(deps, {
436
547
  requestId: randomUUID(),
437
548
  sessionId,
@@ -451,7 +562,7 @@ async function handle(req, res, deps, guard, state) {
451
562
  reasoningTokens: 0,
452
563
  costUsd: 0,
453
564
  estimated: false,
454
- pricing: unpricedPricingEvidence(),
565
+ pricing: notSentPricingEvidence(),
455
566
  streamed: parsed.stream,
456
567
  statusCode: unavailable ? 503 : 429,
457
568
  durationMs: 0,
@@ -502,7 +613,7 @@ async function handle(req, res, deps, guard, state) {
502
613
  headers['x-segreant-egress-refusal'] = egressRefusal.code;
503
614
  res.writeHead(status, headers);
504
615
  res.end(egressRefusal
505
- ? providerEgressRefusalBody(provider, egressRefusal)
616
+ ? providerEgressRefusalBody(provider, egressRefusal, targetUrl, req.method ?? 'POST', deps.config.egress.mode)
506
617
  : providerUpstreamErrorBody(provider, detail, timedOut ? 'upstream_timeout' : 'upstream_unreachable'));
507
618
  safeLog(deps, {
508
619
  requestId,
@@ -523,7 +634,13 @@ async function handle(req, res, deps, guard, state) {
523
634
  reasoningTokens: 0,
524
635
  costUsd: 0,
525
636
  estimated: false,
526
- pricing: unpricedPricingEvidence(),
637
+ // Only a policy or DNS refusal is certainly decided before the dial. A
638
+ // receipt failure can come after the provider answered (the response
639
+ // receipt), and a timeout or dropped connection may have reached it, so
640
+ // those keep an unknown cost (PR #143 review).
641
+ pricing: egressRefusal !== null && (egressRefusal.code === 'policy_denied' || egressRefusal.code === 'dns_denied')
642
+ ? notSentPricingEvidence()
643
+ : unpricedPricingEvidence(),
527
644
  streamed: parsed.stream,
528
645
  statusCode: status,
529
646
  durationMs: Date.now() - startedAt,
@@ -661,7 +778,7 @@ async function handle(req, res, deps, guard, state) {
661
778
  catch {
662
779
  /* non-JSON (e.g. error HTML) — usage stays empty */
663
780
  }
664
- const cost = computeCost(provider, resolvedModel, usage);
781
+ const cost = withDeclarations(declarations, () => computeCost(provider, resolvedModel, usage));
665
782
  downHeaders['x-segreant-cost-usd'] = cost.costUsd.toFixed(6);
666
783
  if (decision.sessionSpendUsd !== null && config.budget.sessionUsd !== null) {
667
784
  const remaining = Math.max(0, config.budget.sessionUsd - decision.sessionSpendUsd - cost.costUsd);
@@ -671,7 +788,7 @@ async function handle(req, res, deps, guard, state) {
671
788
  res.end(text);
672
789
  }
673
790
  // --- Cost + log (after the client has its bytes) ---
674
- const cost = computeCost(provider, resolvedModel, usage);
791
+ const cost = withDeclarations(declarations, () => computeCost(provider, resolvedModel, usage));
675
792
  safeLog(deps, {
676
793
  requestId,
677
794
  sessionId,
@@ -687,6 +804,9 @@ async function handle(req, res, deps, guard, state) {
687
804
  inputTokens: usage.inputTokens,
688
805
  outputTokens: usage.outputTokens,
689
806
  cacheWriteTokens: usage.cacheWriteTokens,
807
+ // Kept so a later reprice prices the writes at the same duration (PR #146
808
+ // review), and only when it was observed rather than chosen.
809
+ ...(usage.cacheWriteTtl === undefined || !usage.cacheWriteTtlObserved ? {} : { cacheWriteTtl: usage.cacheWriteTtl }),
690
810
  cacheReadTokens: usage.cacheReadTokens,
691
811
  reasoningTokens: usage.reasoningTokens ?? 0,
692
812
  costUsd: cost.costUsd,
@@ -27,19 +27,20 @@
27
27
  * is nested under a `response` key (e.g. the `response.completed` event has
28
28
  * `usage` at `event.response.usage`, not `event.usage`). See consumeOpenAI.
29
29
  */
30
+ import { cacheWriteDuration } from '../cost/pricing.js';
30
31
  import { RESOURCE_LIMITS } from '../util/resource-limits.js';
31
32
  export function normalizeAnthropicUsage(u) {
32
33
  const ephem1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0;
33
34
  const ephem5m = u.cache_creation?.ephemeral_5m_input_tokens ?? 0;
34
35
  const cacheWrite = u.cache_creation_input_tokens ?? ephem5m + ephem1h;
35
- // Prefer the longer-TTL flag when the breakdown says most writes were 1h.
36
- const ttl = ephem1h > ephem5m ? '1h' : '5m';
36
+ const duration = cacheWriteDuration(ephem5m, ephem1h);
37
37
  return {
38
38
  inputTokens: u.input_tokens ?? 0,
39
39
  outputTokens: u.output_tokens ?? 0,
40
40
  cacheWriteTokens: cacheWrite,
41
41
  cacheReadTokens: u.cache_read_input_tokens ?? 0,
42
- cacheWriteTtl: ttl,
42
+ ...(duration.ttl === undefined ? {} : { cacheWriteTtl: duration.ttl }),
43
+ cacheWriteTtlObserved: duration.observed,
43
44
  };
44
45
  }
45
46
  export function normalizeOpenAIUsage(u) {
@@ -176,7 +177,11 @@ export class StreamUsageAccumulator {
176
177
  this.partial.inputTokens = n.inputTokens;
177
178
  this.partial.cacheWriteTokens = n.cacheWriteTokens;
178
179
  this.partial.cacheReadTokens = n.cacheReadTokens;
179
- this.partial.cacheWriteTtl = n.cacheWriteTtl;
180
+ if (n.cacheWriteTtl === undefined)
181
+ delete this.partial.cacheWriteTtl;
182
+ else
183
+ this.partial.cacheWriteTtl = n.cacheWriteTtl;
184
+ this.partial.cacheWriteTtlObserved = n.cacheWriteTtlObserved;
180
185
  this.maxOutput = Math.max(this.maxOutput, n.outputTokens);
181
186
  }
182
187
  }
@@ -0,0 +1,138 @@
1
+ /**
2
+ * The vendors' own usage meters, as their tools write them to local logs.
3
+ *
4
+ * Codex writes `rate_limits` on every token count: the percentage used of a
5
+ * 5-hour and a weekly window, with each window's reset time. Claude Code writes
6
+ * no percentage; when a limit is reached it writes the vendor's message
7
+ * ("You've hit your session limit · resets 6:40pm (Africa/Cairo)") as an API
8
+ * error line. Both are the VENDOR's meter. Segreant's own count is the ledger;
9
+ * the quota view sets the two side by side and never treats either as the
10
+ * other.
11
+ *
12
+ * Pure: parsing only, no I/O.
13
+ */
14
+ /** Codex snapshots from one token_count payload's `rate_limits`. */
15
+ export function codexRateLimitEvents(rateLimits, tsEpochMs) {
16
+ if (rateLimits === null || typeof rateLimits !== 'object')
17
+ return [];
18
+ const out = [];
19
+ for (const [key, kind] of [['primary', 'codex_primary'], ['secondary', 'codex_secondary']]) {
20
+ const w = rateLimits[key];
21
+ if (w === null || w === undefined || typeof w !== 'object')
22
+ continue;
23
+ const used = typeof w.used_percent === 'number' && Number.isFinite(w.used_percent) ? w.used_percent : null;
24
+ if (used === null)
25
+ continue;
26
+ const minutes = typeof w.window_minutes === 'number' && w.window_minutes > 0 ? w.window_minutes : null;
27
+ const resets = typeof w.resets_at === 'number' && w.resets_at > 0 ? w.resets_at * 1000 : null;
28
+ out.push({ source: 'codex', kind, tsEpochMs, usedPercent: used, windowMinutes: minutes, resetsAtMs: resets, detail: null });
29
+ }
30
+ return out;
31
+ }
32
+ /**
33
+ * The `rate_limits` object Claude Code passes to a status line script
34
+ * (code.claude.com/docs/en/statusline): `five_hour`, `seven_day` and
35
+ * `spend_limit`, each with `used_percentage` (0-100) and `resets_at` (epoch
36
+ * seconds). Present for Pro and Max subscribers after a session's first
37
+ * response; each window may be absent.
38
+ */
39
+ export function claudeStatuslineEvents(input, tsEpochMs) {
40
+ if (input === null || typeof input !== 'object')
41
+ return [];
42
+ const limits = input.rate_limits;
43
+ if (limits === null || typeof limits !== 'object')
44
+ return [];
45
+ const out = [];
46
+ for (const [key, kind, minutes] of [
47
+ ['five_hour', 'claude_five_hour', 300],
48
+ ['seven_day', 'claude_seven_day', 10_080],
49
+ ['spend_limit', 'claude_spend_window', null],
50
+ ]) {
51
+ const w = limits[key];
52
+ if (w === null || w === undefined || typeof w !== 'object')
53
+ continue;
54
+ const used = typeof w.used_percentage === 'number' && Number.isFinite(w.used_percentage) ? w.used_percentage : null;
55
+ if (used === null)
56
+ continue;
57
+ const resets = typeof w.resets_at === 'number' && w.resets_at > 0 ? w.resets_at * 1000 : null;
58
+ out.push({ source: 'claude-code', kind, tsEpochMs, usedPercent: used, windowMinutes: minutes, resetsAtMs: resets, detail: null });
59
+ }
60
+ return out;
61
+ }
62
+ const CLAUDE_LIMIT = /You've hit your ([a-z ]{3,30}?) limit(?: · resets ([0-9]{1,2}(?::[0-9]{2})?\s?[ap]m)(?: \(([A-Za-z_]+(?:\/[A-Za-z_+-]+){0,2})\))?)?/;
63
+ /**
64
+ * A Claude Code limit message, only from the vendor's own error line
65
+ * (`"isApiErrorMessage":true`). The same words quoted in a prompt or a tool
66
+ * result are not a limit event.
67
+ */
68
+ export function claudeLimitEvent(line, tsEpochMs) {
69
+ if (!line.includes('"isApiErrorMessage":true'))
70
+ return null;
71
+ const m = CLAUDE_LIMIT.exec(line);
72
+ if (m === null)
73
+ return null;
74
+ const what = m[1].trim();
75
+ const kind = what === 'session' ? 'claude_session'
76
+ : what === 'weekly' ? 'claude_weekly'
77
+ : what.includes('spend') ? 'claude_spend'
78
+ : 'claude_other';
79
+ const resetsAtMs = m[2] !== undefined ? nextLocalTime(m[2], m[3] ?? null, tsEpochMs) : null;
80
+ return {
81
+ source: 'claude-code',
82
+ kind,
83
+ tsEpochMs,
84
+ usedPercent: null,
85
+ windowMinutes: kind === 'claude_session' ? 300 : kind === 'claude_weekly' ? 10_080 : null,
86
+ resetsAtMs,
87
+ detail: m[0].slice(0, 120),
88
+ };
89
+ }
90
+ /** Minutes a zone is ahead of UTC at an instant (Cairo in summer: +180). */
91
+ export function zoneOffsetMinutes(timeZone, atMs) {
92
+ let parts;
93
+ try {
94
+ parts = new Intl.DateTimeFormat('en-US', {
95
+ timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
96
+ }).formatToParts(new Date(atMs));
97
+ }
98
+ catch {
99
+ return null; // an unknown zone name
100
+ }
101
+ const get = (t) => Number(parts.find((p) => p.type === t)?.value);
102
+ const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
103
+ return Math.round((asUtc - Math.floor(atMs / 1000) * 1000) / 60_000);
104
+ }
105
+ /**
106
+ * The first instant after `afterMs` at which the clock in `timeZone` reads
107
+ * `clock` ("6:40pm", "4am"). With no zone, the machine's own zone is assumed,
108
+ * which is the zone Claude Code itself printed in.
109
+ */
110
+ export function nextLocalTime(clock, timeZone, afterMs) {
111
+ const m = /^([0-9]{1,2})(?::([0-9]{2}))?\s?([ap]m)$/.exec(clock.trim());
112
+ if (m === null)
113
+ return null;
114
+ const clockHour = Number(m[1]);
115
+ if (clockHour < 1 || clockHour > 12)
116
+ return null; // a 12-hour clock reads 1 to 12
117
+ let hour = clockHour % 12;
118
+ if (m[3] === 'pm')
119
+ hour += 12;
120
+ const minute = m[2] === undefined ? 0 : Number(m[2]);
121
+ if (hour > 23 || minute > 59)
122
+ return null;
123
+ const offsetAt = (ms) => timeZone === null ? -new Date(ms).getTimezoneOffset() : zoneOffsetMinutes(timeZone, ms);
124
+ const offset = offsetAt(afterMs);
125
+ if (offset === null)
126
+ return null;
127
+ // The local calendar day of `afterMs`, then that day's clock time, in UTC.
128
+ const local = new Date(afterMs + offset * 60_000);
129
+ for (let day = 0; day <= 1; day++) {
130
+ const wallAsUtc = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate() + day, hour, minute);
131
+ const guess = wallAsUtc - offset * 60_000;
132
+ // Re-read the offset at the guess so a DST change between the two instants is honoured.
133
+ const exact = wallAsUtc - (offsetAt(guess) ?? offset) * 60_000;
134
+ if (exact > afterMs)
135
+ return exact;
136
+ }
137
+ return null;
138
+ }
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Claude Code's status line, the lean path.
3
+ *
4
+ * Claude Code runs the status line command on every refresh and pipes session
5
+ * JSON on stdin; for Pro and Max subscribers that JSON carries the live usage
6
+ * meter (`rate_limits`). Going through the full CLI took 1.6 s a call, almost
7
+ * all of it opening the Store with its schema and integrity checks, which is
8
+ * far too slow for a bar that refreshes on every message. This path opens the
9
+ * ledger file directly, writes at most one row per meter (only when the
10
+ * reading changed), sums today's list cost and prints one line.
11
+ *
12
+ * It must never break the person's status bar: any failure prints a minimal
13
+ * line. It never creates the ledger or its tables; a fresh machine gets them
14
+ * from the first ordinary `segreant` command, and until then the line still
15
+ * prints.
16
+ */
17
+ import { DatabaseSync } from 'node:sqlite';
18
+ import { existsSync } from 'node:fs';
19
+ import { claudeStatuslineEvents } from './limits.js';
20
+ export function statuslineLine(dbFile, rawInput, nowMs) {
21
+ let input = null;
22
+ try {
23
+ input = rawInput.trim() === '' ? null : JSON.parse(rawInput);
24
+ }
25
+ catch {
26
+ input = null;
27
+ }
28
+ const events = claudeStatuslineEvents(input, nowMs);
29
+ const parts = events
30
+ .filter((e) => e.kind !== 'claude_spend_window')
31
+ .map((e) => `${e.kind === 'claude_five_hour' ? '5h' : 'week'} ${Math.round(e.usedPercent ?? 0)}%`);
32
+ if (!existsSync(dbFile))
33
+ return parts.length > 0 ? parts.join(' · ') : 'segreant';
34
+ let db = null;
35
+ try {
36
+ db = new DatabaseSync(dbFile);
37
+ // A running import holds the write lock in short batches; wait briefly, never long.
38
+ db.exec('PRAGMA busy_timeout = 200');
39
+ const hasTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'quota_events'").get() !== undefined;
40
+ if (hasTable) {
41
+ const last = db.prepare('SELECT used_percent AS used, resets_at_ms AS resets FROM quota_events WHERE source = ? AND kind = ? ORDER BY ts_epoch_ms DESC LIMIT 1');
42
+ const insert = db.prepare(`INSERT INTO quota_events (source, kind, ts_epoch_ms, used_percent, window_minutes, resets_at_ms, detail)
43
+ VALUES (?,?,?,?,?,?,NULL) ON CONFLICT(source, kind, ts_epoch_ms) DO NOTHING`);
44
+ for (const e of events) {
45
+ const prev = last.get(e.source, e.kind);
46
+ const resets = e.resetsAtMs === null ? null : Math.trunc(e.resetsAtMs);
47
+ if (prev !== undefined && prev.used === e.usedPercent && prev.resets === resets)
48
+ continue;
49
+ insert.run(e.source, e.kind, Math.trunc(e.tsEpochMs), e.usedPercent, e.windowMinutes, resets);
50
+ }
51
+ }
52
+ const dayStart = new Date(nowMs);
53
+ dayStart.setHours(0, 0, 0, 0);
54
+ const today = db.prepare('SELECT COALESCE(SUM(cost_usd), 0) AS c FROM requests WHERE ts_epoch_ms >= ?').get(dayStart.getTime());
55
+ if (today !== undefined)
56
+ parts.push(`today $${today.c.toFixed(2)} list`);
57
+ }
58
+ catch {
59
+ // A locked, missing or older ledger: show what the input alone gives.
60
+ }
61
+ finally {
62
+ try {
63
+ db?.close();
64
+ }
65
+ catch { /* already closed */ }
66
+ }
67
+ return parts.length > 0 ? parts.join(' · ') : 'segreant';
68
+ }
@@ -0,0 +1,122 @@
1
+ const AGREE_BAND = 1.5; // within 1.5x either way of the earlier median
2
+ const MIN_PERCENT_FOR_RATIO = 5; // a 1-2% reading is too coarse to divide by
3
+ export function windowView(e, nowMs) {
4
+ const used = e.usedPercent ?? 0;
5
+ const reset = e.resetsAtMs !== null && e.resetsAtMs <= nowMs;
6
+ let projected = null;
7
+ let limitAt = null;
8
+ if (!reset && e.resetsAtMs !== null && e.windowMinutes !== null) {
9
+ const opened = e.resetsAtMs - e.windowMinutes * 60_000;
10
+ const elapsed = e.tsEpochMs - opened;
11
+ if (elapsed > 0) {
12
+ const perMs = used / elapsed;
13
+ projected = used + perMs * (e.resetsAtMs - e.tsEpochMs);
14
+ if (used >= 100)
15
+ limitAt = e.tsEpochMs;
16
+ else if (perMs > 0) {
17
+ const at = e.tsEpochMs + (100 - used) / perMs;
18
+ if (at < e.resetsAtMs)
19
+ limitAt = at;
20
+ }
21
+ }
22
+ }
23
+ return {
24
+ kind: e.kind, usedPercent: used, windowMinutes: e.windowMinutes, resetsAtMs: e.resetsAtMs,
25
+ asOfMs: e.tsEpochMs, reset, projectedAtResetPercent: projected, limitAtMs: limitAt,
26
+ };
27
+ }
28
+ function median(xs) {
29
+ if (xs.length === 0)
30
+ return null;
31
+ const s = [...xs].sort((a, b) => a - b);
32
+ const mid = s.length >> 1;
33
+ return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
34
+ }
35
+ /** For the longer Codex window: list cost per 1% of the vendor's meter, this window and earlier ones. */
36
+ export function meterCheck(events, spend) {
37
+ const byWindow = new Map();
38
+ for (const e of events) {
39
+ if (e.kind !== 'codex_secondary' || e.resetsAtMs === null || e.windowMinutes === null)
40
+ continue;
41
+ const prev = byWindow.get(e.resetsAtMs);
42
+ if (prev === undefined || e.tsEpochMs > prev.tsEpochMs)
43
+ byWindow.set(e.resetsAtMs, e);
44
+ }
45
+ const ratios = [...byWindow.values()]
46
+ .sort((a, b) => b.resetsAtMs - a.resetsAtMs)
47
+ .map((e) => {
48
+ if ((e.usedPercent ?? 0) < MIN_PERCENT_FOR_RATIO)
49
+ return null;
50
+ const opened = e.resetsAtMs - e.windowMinutes * 60_000;
51
+ return spend('codex', opened, e.tsEpochMs + 1) / e.usedPercent;
52
+ });
53
+ const current = ratios[0] ?? null;
54
+ const earlier = ratios.slice(1, 9).filter((r) => r !== null);
55
+ const m = median(earlier);
56
+ return {
57
+ currentUsdPerPercent: current,
58
+ earlierUsdPerPercent: earlier,
59
+ agrees: current === null || m === null || earlier.length < 2 || m === 0
60
+ ? null
61
+ : current / m <= AGREE_BAND && m / current <= AGREE_BAND,
62
+ };
63
+ }
64
+ export function claudeLimitView(events, spend, nowMs) {
65
+ // Parallel agents hit the same wall together: one hit per window (its reset).
66
+ const hits = new Map();
67
+ for (const e of events) {
68
+ if (e.source !== 'claude-code')
69
+ continue;
70
+ const key = JSON.stringify([e.kind, e.resetsAtMs ?? Math.floor(e.tsEpochMs / 3_600_000)]);
71
+ const prev = hits.get(key);
72
+ if (prev === undefined || e.tsEpochMs < prev.tsEpochMs)
73
+ hits.set(key, e);
74
+ }
75
+ const list = [...hits.values()].sort((a, b) => a.tsEpochMs - b.tsEpochMs);
76
+ const fiveHours = 5 * 60 * 60_000;
77
+ const usdBeforeSessionHit = list
78
+ .filter((e) => e.kind === 'claude_session')
79
+ .map((e) => spend('claude-code', (e.resetsAtMs ?? e.tsEpochMs) - fiveHours, e.tsEpochMs + 1));
80
+ const lastFiveHoursUsd = spend('claude-code', nowMs - fiveHours, nowMs + 1);
81
+ if (list.length === 0 && lastFiveHoursUsd === 0)
82
+ return null;
83
+ return {
84
+ sessionHits: list.filter((e) => e.kind === 'claude_session').length,
85
+ weeklyHits: list.filter((e) => e.kind === 'claude_weekly').length,
86
+ spendHits: list.filter((e) => e.kind === 'claude_spend').length,
87
+ lastHit: list[list.length - 1] ?? null,
88
+ usdBeforeSessionHit,
89
+ lastFiveHoursUsd,
90
+ };
91
+ }
92
+ export function quotaView(events, spend, nowMs) {
93
+ const codexEvents = events.filter((e) => e.source === 'codex');
94
+ const latest = new Map();
95
+ for (const e of codexEvents) {
96
+ const prev = latest.get(e.kind);
97
+ if (prev === undefined || e.tsEpochMs >= prev.tsEpochMs)
98
+ latest.set(e.kind, e);
99
+ }
100
+ const windows = ['codex_primary', 'codex_secondary']
101
+ .map((k) => latest.get(k))
102
+ .filter((e) => e !== undefined)
103
+ .map((e) => windowView(e, nowMs));
104
+ const claudeLatest = new Map();
105
+ for (const e of events) {
106
+ if (e.kind !== 'claude_five_hour' && e.kind !== 'claude_seven_day' && e.kind !== 'claude_spend_window')
107
+ continue;
108
+ const prev = claudeLatest.get(e.kind);
109
+ if (prev === undefined || e.tsEpochMs >= prev.tsEpochMs)
110
+ claudeLatest.set(e.kind, e);
111
+ }
112
+ const claudeWindows = ['claude_five_hour', 'claude_seven_day', 'claude_spend_window']
113
+ .map((k) => claudeLatest.get(k))
114
+ .filter((e) => e !== undefined)
115
+ .map((e) => windowView(e, nowMs));
116
+ return {
117
+ codex: windows.length === 0 ? null : { windows, weekly: meterCheck(codexEvents, spend) },
118
+ claude: claudeLimitView(events.filter((e) => !e.kind.startsWith('claude_') || e.usedPercent === null), spend, nowMs),
119
+ claudeWindows,
120
+ };
121
+ }
122
+ export { median };
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Known-app inventory — the wider, honest half of `scan`.
3
3
  *
4
- * `detectTools()` (scan.ts) answers "which of our 3 NATIVELY SUPPORTED tools are
4
+ * `detectTools()` (scan.ts) answers "which of our NATIVELY SUPPORTED tools are
5
5
  * here" — present implies Segreant can read its usage data. This module answers
6
6
  * a different, narrower question: "which OTHER AI coding tools do we merely SEE
7
7
  * evidence of on this machine". Presence here implies nothing about import
@@ -61,6 +61,7 @@ const KNOWN_APPS = [
61
61
  id: 'cursor',
62
62
  label: 'Cursor',
63
63
  blurb: 'AI-first code editor.',
64
+ notReadBecause: 'its local token counts are best-effort and often 0 (Cursor staff, forum.cursor.com/t/155984); a team Admin API connector is planned',
64
65
  detect: (env) => firstExisting(env, [join(env.home, '.cursor')]),
65
66
  },
66
67
  {
@@ -104,6 +105,9 @@ export function detectKnownApps(opts = {}) {
104
105
  };
105
106
  return KNOWN_APPS.map((sig) => {
106
107
  const evidence = sig.detect(env);
107
- return { id: sig.id, label: sig.label, blurb: sig.blurb, present: evidence !== null, evidence };
108
+ return {
109
+ id: sig.id, label: sig.label, blurb: sig.blurb, present: evidence !== null, evidence,
110
+ ...(sig.notReadBecause === undefined ? {} : { notReadBecause: sig.notReadBecause }),
111
+ };
108
112
  });
109
113
  }
package/dist/scan/scan.js CHANGED
@@ -21,6 +21,7 @@ import { projectKey } from '../value/characterization.js';
21
21
  import { defaultClaudeCodeRoot } from '../connect/claudeCode.js';
22
22
  import { defaultOpencodeDbPath } from '../connect/opencode.js';
23
23
  import { defaultCodexRoot } from '../connect/codex.js';
24
+ import { defaultAntigravityRoot } from '../connect/antigravity.js';
24
25
  import { detectKnownApps } from './knownApps.js';
25
26
  /**
26
27
  * Detect which supported tools have local data here, reusing each importer's own
@@ -32,6 +33,7 @@ export function detectTools() {
32
33
  const claudePresent = existsSync(claudeRoot);
33
34
  const opencodeDb = defaultOpencodeDbPath(); // already null when absent
34
35
  const codexRoot = defaultCodexRoot(); // already null when absent
36
+ const antigravityRoot = defaultAntigravityRoot(); // already null when absent
35
37
  return [
36
38
  {
37
39
  id: 'claude-code',
@@ -54,6 +56,13 @@ export function detectTools() {
54
56
  dataPath: codexRoot,
55
57
  blurb: 'Per-turn token usage from ~/.codex rollout session logs.',
56
58
  },
59
+ {
60
+ id: 'antigravity',
61
+ label: 'Antigravity',
62
+ present: antigravityRoot !== null,
63
+ dataPath: antigravityRoot,
64
+ blurb: 'Per-call token usage from the IDE and agy conversation databases under ~/.gemini.',
65
+ },
57
66
  ];
58
67
  }
59
68
  /** Directory names never worth descending into on a repo hunt (heavy or system). */