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
@@ -11,12 +11,58 @@ import { createDashboardServer } from '../dashboard/server.js';
11
11
  import { loadConfig, mutateConfig, dbPath, demoDbPath, isDemo, unlinkDemoDb, segreantHome } from '../config.js';
12
12
  import { packageVersion } from '../version.js';
13
13
  import { seedDemo } from '../demo/seed.js';
14
- import { startOfLocalDay } from '../budget/guard.js';
14
+ import { capBasisSpend, startOfLocalDay } from '../budget/guard.js';
15
+ import { previewReprice, applyReprice } from '../cost/reprice.js';
15
16
  import { pricingCoverage } from '../cost/coverage.js';
16
- import { refreshPricing, pricingStatus, computeCost, DEFAULT_MANIFEST_URL } from '../cost/pricing.js';
17
+ import { refreshPricing, pricingStatus, DEFAULT_MANIFEST_URL } from '../cost/pricing.js';
17
18
  import { refreshBaselineManifest, baselineManifestStatus } from '../value/liftBaseline.js';
19
+ import { createFirstSightWatcher } from '../cost/firstSight.js';
18
20
  import { C, color, usd, num, printJson } from './ui.js';
19
21
  import {} from './flags.js';
22
+ import { capConfigured } from './launchCmd.js';
23
+ /**
24
+ * Fill an empty sample ledger before the dashboard serves it. `start --demo`
25
+ * used to serve whatever demo.db held, so on a fresh install every page said
26
+ * "sample data" over $0 and "no recorded requests". demo.db only ever holds
27
+ * synthetic rows, so seeding it never touches real metering; a ledger that
28
+ * already has sample rows is left exactly as it is.
29
+ */
30
+ export function ensureDemoSeeded(store) {
31
+ if (store.earliestRequestMs() !== null)
32
+ return null;
33
+ return seedDemo(store);
34
+ }
35
+ /**
36
+ * Listen, or say why not. A busy port used to surface as Node's unhandled
37
+ * 'error' event and a stack trace; the usual cause is a Segreant that is
38
+ * already running, and the fix is one flag.
39
+ */
40
+ export function listenOrExplain(server, port, what, flag) {
41
+ return new Promise((resolve) => {
42
+ const onError = (error) => {
43
+ server.off('listening', onListening);
44
+ if (error.code === 'EADDRINUSE') {
45
+ console.error(` Port ${port} (${what}) is already in use, so Segreant did not start.`);
46
+ console.error(` Another Segreant may be running: open http://localhost:${port} to check.`);
47
+ console.error(` Otherwise pick a free port: segreant start ${flag} ${port + 10}`);
48
+ }
49
+ else if (error.code === 'EACCES') {
50
+ console.error(` Port ${port} (${what}) needs permission this account does not have. Pick another: segreant start ${flag} ${port + 10}`);
51
+ }
52
+ else {
53
+ console.error(` The ${what} could not listen on 127.0.0.1:${port}: ${error.message}`);
54
+ }
55
+ resolve(false);
56
+ };
57
+ const onListening = () => {
58
+ server.off('error', onError);
59
+ resolve(true);
60
+ };
61
+ server.once('error', onError);
62
+ server.once('listening', onListening);
63
+ server.listen(port, '127.0.0.1');
64
+ });
65
+ }
20
66
  export async function cmdStart(flags) {
21
67
  const cfg = loadConfig();
22
68
  if (flags.port)
@@ -25,6 +71,11 @@ export async function cmdStart(flags) {
25
71
  cfg.dashboardPort = Number(flags['dashboard-port']);
26
72
  const store = new Store(dbPath());
27
73
  const tty = process.stdout.isTTY ?? false;
74
+ if (isDemo()) {
75
+ const seeded = ensureDemoSeeded(store);
76
+ if (seeded)
77
+ console.log(color(tty, C.gray, ` Sample data generated: ${num(seeded.requests)} requests over ${seeded.days} days (same as segreant demo).`));
78
+ }
28
79
  // The proposal store may contain literal suggested code. Honor its short
29
80
  // retention boundary whenever the local service starts; this does not touch
30
81
  // the longer-lived cost ledger, and an operator can still clear proposals
@@ -44,29 +95,94 @@ export async function cmdStart(flags) {
44
95
  ? color(tty, C.gray, ` ↻ pricing refreshed — ${res.modelCount} models (${cfg.pricing.manifestUrl ?? DEFAULT_MANIFEST_URL})`)
45
96
  : color(tty, C.yellow, ` ↻ pricing auto-refresh skipped: ${res.error ?? 'unreachable'} — keeping current table`));
46
97
  }
47
- const proxy = createProxyServer({
98
+ // With --demo the open store is demo.db. A proxy started here recorded
99
+ // real provider traffic into the sample store while the banner said "Real
100
+ // metering is untouched", so demo mode serves the dashboard only. A tool
101
+ // started with `segreant launch` then finds no proxy, warns that it is
102
+ // unmetered and goes direct, as it does whenever Segreant is not running.
103
+ const demo = isDemo();
104
+ // A model priced by a guess sends one lookup to the public price list (never
105
+ // in the request's path). Rows are not rewritten: the user is told, and
106
+ // `segreant reprice` previews the correction.
107
+ const firstSight = createFirstSightWatcher({
108
+ // Read from the file at each sighting: `segreant pricing --auto off` in
109
+ // another terminal must stop the next lookup without a restart (PR #146
110
+ // review). A config that no longer reads is treated as off.
111
+ enabled: () => {
112
+ try {
113
+ // Only against the built-in public list, which is what the banner and
114
+ // the privacy statement disclose: an operator who points
115
+ // pricing.manifestUrl at their own card refreshes it themselves
116
+ // (PR #146 review).
117
+ const pricing = loadConfig().pricing;
118
+ return pricing.lookupUnknownModels && pricingSourceLabel(pricing.manifestUrl) === 'the public price list';
119
+ }
120
+ catch {
121
+ return false;
122
+ }
123
+ },
124
+ // The source the live config selects, not the one at start: enabled() has
125
+ // just confirmed it is the built-in list, and a source changed since then
126
+ // is never fetched behind the operator's back (PR #146 review).
127
+ refresh: async () => {
128
+ let url;
129
+ try {
130
+ url = loadConfig().pricing.manifestUrl;
131
+ }
132
+ catch {
133
+ return { ok: false, error: 'the configuration could not be read' };
134
+ }
135
+ if (pricingSourceLabel(url) !== 'the public price list')
136
+ return { ok: false, error: 'the price source is no longer the public price list' };
137
+ return refreshPricing(DEFAULT_MANIFEST_URL);
138
+ },
139
+ onLearned: (models) => {
140
+ const names = models.map((m) => m.model).join(', ');
141
+ // refresh() above fetches only the built-in list, so that is the source
142
+ // that learned them, whatever the config said at start (PR #146 review).
143
+ console.log(color(tty, C.green, ` ↻ the public price list now prices ${names}: new requests use that price.`));
144
+ console.log(color(tty, C.gray, ' Requests already recorded keep their estimate until you run: segreant reprice (preview), then segreant reprice --apply'));
145
+ },
146
+ onFailed: (error) => {
147
+ console.log(color(tty, C.yellow, ` ↻ price lookup skipped: ${error}. Estimated prices stand; segreant pricing set <model> gives one yourself.`));
148
+ },
149
+ });
150
+ const proxy = demo ? null : createProxyServer({
48
151
  store,
49
152
  config: cfg,
50
153
  onLog: (row) => {
154
+ if (row.pricing)
155
+ firstSight.note(row.provider, row.model, row.pricing.rateMatchKind);
51
156
  if (row.statusCode === 429) {
52
157
  console.log(color(tty, C.red, ` ⛔ blocked ${row.provider}/${row.model} (budget)`));
53
158
  return;
54
159
  }
55
- const today = store.spendBetween(startOfLocalDay(), Date.now() + 1000);
56
160
  const time = new Date(row.tsEpochMs).toLocaleTimeString('en-US', { hour12: false });
57
- const est = row.estimated ? color(tty, C.yellow, ' ~est') : '';
161
+ // A price you set is not a guess, and not a list price either (PR #146 sweep).
162
+ const est = row.estimated ? color(tty, C.yellow, ' ~est')
163
+ : row.pricing?.costBasis === 'user_declared_price' ? color(tty, C.gray, ' your price') : '';
58
164
  console.log(` ${color(tty, C.gray, time)} ${row.provider}/${row.model} ` +
59
165
  `in ${num(row.inputTokens)} out ${num(row.outputTokens)} ` +
60
- `${color(tty, C.green, usd(row.costUsd))}${est} ${color(tty, C.gray, `today ${usd(today)}`)}`);
166
+ `${color(tty, C.green, usd(row.costUsd))}${est} ${color(tty, C.gray, liveTodayLine(store, cfg.budget))}`);
61
167
  },
62
168
  });
63
169
  const dashboard = createDashboardServer({ store, config: cfg, version: packageVersion() });
64
- await new Promise((resolve) => proxy.listen(cfg.port, '127.0.0.1', resolve));
65
- await new Promise((resolve) => dashboard.listen(cfg.dashboardPort, '127.0.0.1', resolve));
66
- printBanner(cfg, tty);
170
+ const listening = (!proxy || await listenOrExplain(proxy, cfg.port, 'proxy', '--port'))
171
+ && await listenOrExplain(dashboard, cfg.dashboardPort, 'dashboard', '--dashboard-port');
172
+ if (!listening) {
173
+ proxy?.close();
174
+ dashboard.close();
175
+ store.close();
176
+ process.exitCode = 1;
177
+ return;
178
+ }
179
+ if (demo)
180
+ printDemoBanner(cfg, tty);
181
+ else
182
+ printBanner(cfg, tty);
67
183
  const shutdown = () => {
68
184
  console.log('\n Shutting down…');
69
- proxy.close();
185
+ proxy?.close();
70
186
  dashboard.close();
71
187
  store.close();
72
188
  process.exit(0);
@@ -74,6 +190,20 @@ export async function cmdStart(flags) {
74
190
  process.on('SIGINT', shutdown);
75
191
  process.on('SIGTERM', shutdown);
76
192
  }
193
+ function printDemoBanner(cfg, tty) {
194
+ const line = (s) => console.log(' ' + s);
195
+ console.log('');
196
+ line(color(tty, C.bold, 'Segreant · sample-data dashboard'));
197
+ console.log('');
198
+ line(`Dashboard ${color(tty, C.cyan, `http://localhost:${cfg.dashboardPort}`)}`);
199
+ console.log('');
200
+ line(color(tty, C.yellow, '● DEMO DATA — synthetic, isolated in demo.db. No proxy runs in demo mode,'));
201
+ line(color(tty, C.yellow, ' so nothing you send to a provider is recorded here. Real metering is untouched.'));
202
+ line(color(tty, C.gray, ' To meter your own tools, run "segreant start" without --demo.'));
203
+ console.log('');
204
+ line(color(tty, C.gray, 'Press Ctrl+C to stop.'));
205
+ console.log('');
206
+ }
77
207
  function printBanner(cfg, tty) {
78
208
  const line = (s) => console.log(' ' + s);
79
209
  console.log('');
@@ -90,7 +220,9 @@ function printBanner(cfg, tty) {
90
220
  }
91
221
  line(color(tty, C.bold, 'Start your tools through Segreant:'));
92
222
  line(color(tty, C.gray, ' segreant launch -- claude (or codex, aider, opencode, …)'));
93
- line(color(tty, C.gray, ' Metered while this proxy runs; starts unmetered with a warning when it does not.'));
223
+ line(color(tty, C.gray, capConfigured(cfg)
224
+ ? ' Metered while this proxy runs. With a cap set, launch refuses while it is stopped (--allow-unmetered overrides).'
225
+ : ' Metered while this proxy runs; starts unmetered with a warning when it does not.'));
94
226
  line(color(tty, C.bold, 'Or point a tool here yourself (PowerShell):'));
95
227
  line(color(tty, C.gray, ` $env:ANTHROPIC_BASE_URL="http://localhost:${cfg.port}"`));
96
228
  line(color(tty, C.gray, ` $env:OPENAI_BASE_URL="http://localhost:${cfg.port}/v1"`));
@@ -101,11 +233,46 @@ function printBanner(cfg, tty) {
101
233
  else {
102
234
  line(color(tty, C.gray, 'No budget cap set. Add one: segreant budget --daily 25 --soft 18'));
103
235
  }
236
+ // The one request Segreant makes on its own: said where the user starts it.
237
+ if (cfg.pricing.lookupUnknownModels && pricingSourceLabel(cfg.pricing.manifestUrl) === 'the public price list') {
238
+ line(color(tty, C.gray, 'Prices a model missing from the rate card is looked up in the public price list'));
239
+ line(color(tty, C.gray, ' (a plain download; sends nothing about you). Off: segreant pricing --auto off'));
240
+ }
241
+ else if (cfg.pricing.lookupUnknownModels) {
242
+ line(color(tty, C.gray, `Prices ${pricingSourceLabel(cfg.pricing.manifestUrl)} is not looked up automatically;`));
243
+ line(color(tty, C.gray, ' refresh it with: segreant pricing --refresh'));
244
+ }
104
245
  console.log('');
105
246
  line(color(tty, C.gray, 'Press Ctrl+C to stop. A tool pointed at this address by hand cannot connect while'));
106
247
  line(color(tty, C.gray, 'Segreant is stopped; unset the two variables, or use segreant launch, to go direct.'));
107
248
  console.log('');
108
249
  }
250
+ /**
251
+ * The source a price refresh reads, in words. The built-in list is spelled two
252
+ * ways, no URL configured or its own URL; both are the public price list (PR #146 review).
253
+ */
254
+ /**
255
+ * The running total on each live log line. With a daily cap it is the spend the
256
+ * cap counts, beside the cap: the all-sources total read "today $51.29" next to
257
+ * a $25 cap while requests went through, because imported spend does not count
258
+ * toward the cap by default (launch pass, 2026-10-18). Any other spend today is
259
+ * named separately, never added in.
260
+ */
261
+ export function liveTodayLine(store, budget, nowMs = Date.now()) {
262
+ const start = startOfLocalDay(nowMs);
263
+ const all = store.spendBetween(start, nowMs + 1000);
264
+ if (budget.dailyUsd === null)
265
+ return `today ${usd(all)}`;
266
+ const counted = capBasisSpend(store, budget, start, nowMs + 1000);
267
+ const other = all - counted.usd;
268
+ return `today ${usd(counted.usd)} of the ${usd(budget.dailyUsd)} cap`
269
+ + (other >= 0.005 ? ` (+${usd(other)} imported, not counted)` : '');
270
+ }
271
+ export function pricingSourceLabel(manifestUrl) {
272
+ return manifestUrl === null || manifestUrl === undefined || manifestUrl === DEFAULT_MANIFEST_URL
273
+ ? 'the public price list'
274
+ : `your price source (${manifestUrl})`;
275
+ }
109
276
  export async function cmdDemo(flags) {
110
277
  const tty = process.stdout.isTTY ?? false;
111
278
  if (flags.clear) {
@@ -141,6 +308,10 @@ export async function cmdDemo(flags) {
141
308
  }
142
309
  }
143
310
  export async function cmdPricing(flags) {
311
+ const action = flags._[0];
312
+ if (action === 'set' || action === 'unset' || action === 'unknown') {
313
+ return (await import('./pricingDeclareCmd.js')).cmdPricingDeclare(action, flags);
314
+ }
144
315
  const cfg = loadConfig();
145
316
  const on = process.stdout.isTTY ?? false;
146
317
  // Ledger evidence is deliberately a separate read-only surface from the
@@ -200,15 +371,18 @@ export async function cmdPricing(flags) {
200
371
  // file — nothing about the user). `--auto off` turns it back off.
201
372
  if (flags['auto'] !== undefined) {
202
373
  const enable = flags['auto'] !== 'off' && flags['auto'] !== 'false';
203
- const updated = mutateConfig((latest) => ({ ...latest, pricing: { ...latest.pricing, autoRefresh: enable } }));
374
+ // One switch for both: the age-based refresh and the first-sight lookup.
375
+ const updated = mutateConfig((latest) => ({ ...latest, pricing: { ...latest.pricing, autoRefresh: enable, lookupUnknownModels: enable } }));
204
376
  console.log('');
205
377
  if (enable) {
206
- console.log(` ${color(on, C.green, '✓')} Auto-refresh ON — "segreant start" updates the rate card when it is older than ${cfg.pricing.maxAgeDays}d.`);
378
+ console.log(` ${color(on, C.green, '✓')} Auto-refresh ON — "segreant start" updates the rate card when it is older than ${cfg.pricing.maxAgeDays}d,`);
379
+ console.log(' and a running Segreant looks up a model the card does not know the first time it sees it.');
207
380
  console.log(` ${color(on, C.dim, `Source: ${updated.pricing.manifestUrl ?? DEFAULT_MANIFEST_URL}`)}`);
208
381
  console.log(` ${color(on, C.dim, 'The fetch is a GET of a public pricing file — it sends nothing about you. Turn off: segreant pricing --auto off')}`);
209
382
  }
210
383
  else {
211
- console.log(` ${color(on, C.green, '✓')} Auto-refresh OFF — the rate card only changes when you run "segreant pricing --refresh".`);
384
+ console.log(` ${color(on, C.green, '✓')} Auto-refresh OFF — the rate card only changes when you run "segreant pricing --refresh",`);
385
+ console.log(' and a model the card does not know stays at an estimate until you refresh or run segreant pricing set.');
212
386
  }
213
387
  console.log('');
214
388
  return;
@@ -343,46 +517,19 @@ export function cmdReprice(flags) {
343
517
  const store = new Store(dbPath());
344
518
  const tty = process.stdout.isTTY ?? false;
345
519
  try {
346
- const rows = store.estimatedRequestRows();
347
- const updates = [];
348
- const byModel = new Map();
349
- let stillEstimated = 0;
350
- for (const r of rows) {
351
- const c = computeCost(r.provider, r.model, {
352
- inputTokens: r.inputTokens,
353
- outputTokens: r.outputTokens,
354
- cacheWriteTokens: r.cacheWriteTokens,
355
- cacheReadTokens: r.cacheReadTokens,
356
- });
357
- if (c.estimated) {
358
- stillEstimated++;
359
- continue; // still a guess — keep the original guess rather than churn numbers
360
- }
361
- updates.push({ requestId: r.requestId, costUsd: c.costUsd, pricing: c.pricing, ...(c.exact === undefined ? {} : { economicAmount: c.exact.total }) });
362
- // Tuple key, not a delimiter join (D-217/D-241): a model name may carry '/'.
363
- const key = JSON.stringify([r.provider, r.model]);
364
- const agg = byModel.get(key) ?? { n: 0, before: 0, after: 0 };
365
- agg.n++;
366
- agg.before += r.costUsd;
367
- agg.after += c.costUsd;
368
- byModel.set(key, agg);
369
- }
370
- const before = [...byModel.values()].reduce((a, v) => a + v.before, 0);
371
- const after = [...byModel.values()].reduce((a, v) => a + v.after, 0);
520
+ const report = flags.apply ? applyReprice(store) : previewReprice(store);
521
+ const { updates, stillEstimated, totalBeforeUsd: before, totalAfterUsd: after } = report;
522
+ const rows = { length: report.estimatedRows };
523
+ const byModel = new Map(report.byModel.map((v) => [JSON.stringify([v.provider, v.model]), { n: v.rows, before: v.beforeUsd, after: v.afterUsd }]));
372
524
  if (flags.json) {
373
525
  // Write BEFORE serializing, so the payload can report what the reprice did
374
526
  // to the stored realized-value snapshots rather than describing the request
375
527
  // rows alone and leaving the snapshots' state to be discovered elsewhere.
376
- const sync = flags.apply && updates.length ? store.applyRepricedCosts(updates) : null;
528
+ const sync = report.realizationSync;
377
529
  printJson({
378
530
  applied: !!flags.apply, estimatedRows: rows.length, repriceable: updates.length,
379
- stillEstimated, totalBeforeUsd: before, totalAfterUsd: after,
380
- priceEvidence: updates.length ? {
381
- localRateCardOnly: true,
382
- cardSha256: updates[0].pricing.rateCardSha256,
383
- sourceKind: updates[0].pricing.rateCardSourceKind,
384
- note: 'Local list-price estimate only; never provider invoice, discount, tax, credit, or reconciliation.',
385
- } : null,
531
+ stillEstimated, cacheDurationUnknown: report.cacheDurationUnknown, alreadyRepriced: report.alreadyRepriced, totalBeforeUsd: before, totalAfterUsd: after,
532
+ priceEvidence: report.priceEvidence,
386
533
  byModel: [...byModel.entries()].map(([key, v]) => {
387
534
  const [provider, model] = JSON.parse(key);
388
535
  return { provider, model, rows: v.n, beforeUsd: v.before, afterUsd: v.after };
@@ -399,20 +546,25 @@ export function cmdReprice(flags) {
399
546
  return;
400
547
  }
401
548
  console.log(` Estimated rows ${num(rows.length)}`);
402
- console.log(` Now exactly priced ${num(updates.length)} ${color(tty, C.gray, '(current card has an exact rate for these models)')}`);
549
+ console.log(` Now exactly priced ${num(updates.length)} ${color(tty, C.gray, '(the current card, or a price you set, gives these models an exact rate)')}`);
403
550
  if (stillEstimated)
404
551
  console.log(` Still estimates ${num(stillEstimated)} ${color(tty, C.gray, 'left untouched — a new guess is not more honest than the old one')}`);
552
+ if (report.alreadyRepriced)
553
+ console.log(` Already re-priced ${num(report.alreadyRepriced)} ${color(tty, C.gray, 'another reprice wrote these first; left as it wrote them and not counted here')}`);
554
+ if (report.cacheDurationUnknown)
555
+ console.log(` Cache duration ${num(report.cacheDurationUnknown)} ${color(tty, C.gray, 'rows recorded before Segreant kept the cache-write duration; their writes are priced at the 5-minute rate')}`);
405
556
  if (updates.length) {
406
557
  console.log('');
407
558
  for (const [model, v] of [...byModel.entries()].sort((a, b) => b[1].after - a[1].after).slice(0, 10)) {
408
559
  const delta = v.after - v.before;
409
560
  const dc = delta > 0 ? C.red : C.green;
410
- console.log(` ${model.padEnd(40)} ${num(v.n).padStart(6)} rows ${usd(v.before).padStart(10)} → ${usd(v.after).padStart(10)} ${color(tty, dc, (delta >= 0 ? '+' : '') + usd(delta))}`);
561
+ const [provider, name] = JSON.parse(model);
562
+ console.log(` ${`${provider}/${name}`.padEnd(40)} ${num(v.n).padStart(6)} rows ${usd(v.before).padStart(10)} → ${usd(v.after).padStart(10)} ${color(tty, dc, (delta >= 0 ? '+' : '') + usd(delta))}`);
411
563
  }
412
564
  console.log('');
413
565
  console.log(` Total ${usd(before)} → ${usd(after)} ${color(tty, after - before >= 0 ? C.red : C.green, (after - before >= 0 ? '+' : '') + usd(after - before))}`);
414
566
  if (flags.apply) {
415
- const sync = store.applyRepricedCosts(updates);
567
+ const sync = report.realizationSync;
416
568
  console.log(color(tty, C.green, ` ✓ Applied — ${num(updates.length)} rows re-costed, estimated flag cleared.`));
417
569
  if (sync.resynced) {
418
570
  const delta = sync.costUsdAfter - sync.costUsdBefore;
@@ -12,10 +12,13 @@ import { economicRequestsToCsv, economicRequestsToJson } from '../export/economi
12
12
  import { computeAlerts, computeAlertCoverage } from '../alerts/detect.js';
13
13
  import { describeSourceDepth } from '../value/sourceDepth.js';
14
14
  import { isDeclaredAttribution } from '../value/characterization.js';
15
- import { C, color, usd, num, pct, printJson } from './ui.js';
15
+ import { C, color, usd, num, pct, printJson, cliJsonResponse } from './ui.js';
16
16
  import { stringifyJson } from '../util/json.js';
17
17
  import { rangeFor, UserInputError, usdFlag } from './flags.js';
18
18
  import { retentionNotice } from './retention.js';
19
+ import { planComparisons, printPlanLines } from './planCmd.js';
20
+ import { summarizeBasis } from '../cost/basis.js';
21
+ import { basisLines } from './basisLines.js';
19
22
  import { instant } from '../epistemic/time.js';
20
23
  export function cmdShow(window, flags) {
21
24
  const cfg = loadConfig();
@@ -44,7 +47,7 @@ export function cmdShow(window, flags) {
44
47
  }
45
48
  if (flags.json) {
46
49
  printJson({
47
- window, label, demo: isDemo(), retention, summary, byModel, byProject, byUser, bySource,
50
+ window, label, demo: isDemo(), retention, summary, basis: summarizeBasis(store.pricingEvidenceByModel(startMs, endMs)), byModel, byProject, byUser, bySource,
48
51
  ...(alerts === null || coverage === null ? {} : { alerts, alertCoverage: coverage }),
49
52
  });
50
53
  store.close();
@@ -60,19 +63,30 @@ export function cmdShow(window, flags) {
60
63
  const truncation = retentionNotice(retention);
61
64
  if (truncation !== null)
62
65
  console.log(color(tty, C.yellow, ` ● ${truncation}`));
63
- // Every figure here is priced from the rate card, so it is LIST COST, never
64
- // "spend": imported subscription usage is what the work would bill at API
65
- // list price, not what was paid, and even proxied API traffic is metered at
66
- // list price until a bill reconciles it. Say which part is which.
66
+ // The total is never "spend". Its word follows the weakest part of it: list
67
+ // cost only when every dollar was priced at a model's own card rate. A model
68
+ // missing from the card, an amount a tool reported, or an unpriced provider
69
+ // each gets its own line, so no part is presented as another (H003/H007).
70
+ const basis = summarizeBasis(store.pricingEvidenceByModel(startMs, endMs));
67
71
  const liveCostUsd = store.spendBetween(startMs, endMs, true);
68
72
  const importedCostUsd = Math.max(0, summary.costUsd - liveCostUsd);
69
- console.log(` List cost ${color(tty, C.green, usd(summary.costUsd))} ${color(tty, C.gray, `(${num(summary.requests)} requests · priced from the rate card · an estimate)`)}`);
73
+ const single = basis.cohorts.length === 0 || (basis.cohorts.length === 1 && basis.cohorts[0].id === 'list_exact');
74
+ console.log(` ${basis.headlineLabel.padEnd(10)} ${color(tty, C.green, usd(summary.costUsd))} ${color(tty, C.gray, single
75
+ ? basis.cohorts.length === 0 ? `(${num(summary.requests)} requests)` : `(${num(summary.requests)} requests · priced at each model's API list rate · not your bill)`
76
+ : `(${num(summary.requests)} requests · priced in parts:)`)}`);
77
+ if (!single) {
78
+ for (const line of basisLines(basis))
79
+ console.log(color(tty, C.gray, line));
80
+ }
70
81
  if (importedCostUsd >= 0.005) {
71
- console.log(color(tty, C.gray, ` ${usd(importedCostUsd)} read from tool logs: what this use would bill at API list price, not your invoice`));
82
+ console.log(color(tty, C.gray, ` ${usd(importedCostUsd)} read from tool logs: use on your plans and keys, priced as above, not your invoice`));
72
83
  }
73
84
  if (liveCostUsd >= 0.005) {
74
85
  console.log(color(tty, C.gray, ` ${usd(liveCostUsd)} metered through the proxy: list price; your provider's bill may differ`));
75
86
  }
87
+ // A plan price is monthly, so only the 30-day window sets the two side by side.
88
+ if (window === 'month' && !isDemo())
89
+ printPlanLines(tty, planComparisons(store, startMs, endMs));
76
90
  console.log(` Input ${num(summary.inputTokens)} tokens`);
77
91
  console.log(` Output ${num(summary.outputTokens)} tokens`);
78
92
  if (alerts !== null && coverage !== null) {
@@ -261,12 +275,20 @@ export function cmdExport(flags) {
261
275
  }
262
276
  const asJson = flags.json === true || flags.format === 'json';
263
277
  const out = economic
264
- ? (asJson ? economicRequestsToJson(economicRows) : economicRequestsToCsv(economicRows))
265
- : (asJson ? `${stringifyJson(rows)}\n` : requestsToCsv(rows));
278
+ ? (asJson ? `${stringifyJson(cliJsonResponse(JSON.parse(economicRequestsToJson(economicRows))))}\n` : economicRequestsToCsv(economicRows))
279
+ : (asJson ? `${stringifyJson(cliJsonResponse(rows))}\n` : requestsToCsv(rows));
280
+ // The economic export leaves out requests refused before they were sent
281
+ // (they cost nothing), so its count is its own rows, and the gap is named
282
+ // rather than left for the reader to discover (PR #145 review).
283
+ const exportedCount = economicRows ? economicRows.length : rows.length;
284
+ const notSent = economicRows ? rows.length - economicRows.length : 0;
285
+ if (notSent > 0) {
286
+ console.error(color(process.stdout.isTTY ?? false, C.gray, ` ${num(notSent)} request${notSent === 1 ? '' : 's'} refused before sending left out: no cost, no economic record.`));
287
+ }
266
288
  if (typeof flags.out === 'string') {
267
289
  writeFileSync(flags.out, out);
268
290
  const tty = process.stdout.isTTY ?? false;
269
- console.error(color(tty, C.green, ` Exported ${num(rows.length)} requests (${economic ? 'economic-' : ''}${asJson ? 'json' : 'csv'}) → ${flags.out}`));
291
+ console.error(color(tty, C.green, ` Exported ${num(exportedCount)} requests (${economic ? 'economic-' : ''}${asJson ? 'json' : 'csv'}) → ${flags.out}`));
270
292
  }
271
293
  else {
272
294
  process.stdout.write(out);
@@ -313,6 +335,10 @@ export function cmdBudget(flags) {
313
335
  }
314
336
  return updated;
315
337
  });
338
+ if (flags.json) {
339
+ printJson({ updated: changing, budget: next.budget });
340
+ return;
341
+ }
316
342
  console.log('');
317
343
  console.log(changing ? ' Budget updated:' : ' Current caps (change one with, for example, segreant budget --daily 20):');
318
344
  console.log(` Daily hard cap: ${next.budget.dailyUsd === null ? 'off' : usd(next.budget.dailyUsd)}`);
@@ -109,7 +109,7 @@ export async function cmdReceipt(flags) {
109
109
  if (flags.pubkey) {
110
110
  const keys = loadOrCreateKeyPair(join(segreantHome(), 'receipt-key.json'));
111
111
  if (flags.json) {
112
- process.stdout.write(JSON.stringify({ keyId: keys.keyId, publicKey: keys.publicPem }, null, 2) + '\n');
112
+ printJson({ keyId: keys.keyId, publicKey: keys.publicPem });
113
113
  return;
114
114
  }
115
115
  console.log('');
@@ -255,7 +255,7 @@ export async function cmdReceipt(flags) {
255
255
  store.saveReceipt({ unit: r.body.unit, project, tsEpochMs: Date.now(), realized: r.body.realized, receiptJson: JSON.stringify(r) });
256
256
  }
257
257
  if (flags.json) {
258
- process.stdout.write(JSON.stringify(receipts, null, 2) + '\n');
258
+ printJson(receipts);
259
259
  store.close();
260
260
  return;
261
261
  }
@@ -323,7 +323,7 @@ export async function cmdJudge(flags) {
323
323
  if (!picked) {
324
324
  store.close();
325
325
  if (flags.json) {
326
- process.stdout.write(JSON.stringify({ error: 'no-sessions-in-window', project, windowDays, sessions: 0 }, null, 2) + '\n');
326
+ printJson({ error: 'no-sessions-in-window', project, windowDays, sessions: 0 });
327
327
  return;
328
328
  }
329
329
  console.log('');
@@ -540,7 +540,7 @@ export async function cmdTeamPush(flags) {
540
540
  if (flags.pubkey) {
541
541
  const keys = loadOrCreateKeyPair(keyPath);
542
542
  if (flags.json) {
543
- process.stdout.write(JSON.stringify({ keyId: keys.keyId, publicKey: keys.publicPem }, null, 2) + '\n');
543
+ printJson({ keyId: keys.keyId, publicKey: keys.publicPem });
544
544
  return;
545
545
  }
546
546
  console.log('');
@@ -571,7 +571,7 @@ export async function cmdTeamPush(flags) {
571
571
  console.log(JSON.stringify({ ok: false, error: transportError }, null, 2));
572
572
  }
573
573
  else {
574
- console.error(` ${color(tty, C.red, '✗')} ${transportError}`);
574
+ console.error(` ${color(tty, C.red, '✗')} ${transportError}`);
575
575
  }
576
576
  process.exitCode = 1;
577
577
  return;
package/dist/cli/ui.js CHANGED
@@ -23,13 +23,17 @@ export function color(on, code, s) {
23
23
  return on ? `${code}${s}${C.reset}` : s;
24
24
  }
25
25
  export function usd(n) {
26
- if (n === 0)
26
+ // Sign outside the symbol, and the same precision either side of zero: a
27
+ // credit of 5 dollars used to fall through every branch to "$-5.000000".
28
+ const sign = n < 0 ? '-' : '';
29
+ const a = Math.abs(n);
30
+ if (a === 0)
27
31
  return '$0.00';
28
- if (n >= 1)
29
- return `$${n.toFixed(2)}`;
30
- if (n >= 0.01)
31
- return `$${n.toFixed(4)}`;
32
- return `$${n.toFixed(6)}`;
32
+ if (a >= 1)
33
+ return `${sign}$${a.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}`;
34
+ if (a >= 0.01)
35
+ return `${sign}$${a.toFixed(4)}`;
36
+ return `${sign}$${a.toFixed(6)}`;
33
37
  }
34
38
  export function num(n) {
35
39
  return n.toLocaleString('en-US');
@@ -37,9 +41,21 @@ export function num(n) {
37
41
  export function pct(n) {
38
42
  return `${(n * 100).toFixed(0)}%`;
39
43
  }
40
- /** Emit a Segreant-owned JSON response with stable six-decimal USD fields. */
44
+ /** CLI response version, separate from database and exported artifact schemas. */
45
+ export const CLI_JSON_SCHEMA_VERSION = 1;
46
+ export function cliJsonResponse(value) {
47
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
48
+ // A document that already names its own schema version keeps it: that
49
+ // number describes the document, and the CLI must not relabel it.
50
+ if (Object.hasOwn(value, 'schemaVersion'))
51
+ return value;
52
+ return { ...value, schemaVersion: CLI_JSON_SCHEMA_VERSION };
53
+ }
54
+ return { schemaVersion: CLI_JSON_SCHEMA_VERSION, data: value };
55
+ }
56
+ /** Emit one versioned response with stable six-decimal USD fields. */
41
57
  export function printJson(value, space = 2) {
42
- process.stdout.write(stringifyJson(value, space) + '\n');
58
+ process.stdout.write(stringifyJson(cliJsonResponse(value), space) + '\n');
43
59
  }
44
60
  /** Actionable not-a-git-repo message: tell the user what to do, not just what's wrong. */
45
61
  export function printNotAGitRepo(repo) {