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
@@ -3,6 +3,7 @@
3
3
  * Extracted verbatim from cli.ts in the per-command-module split;
4
4
  * gatherGuideFacts is exported for the CLI/dashboard guidance contract tests.
5
5
  */
6
+ import { capBasisSpend } from '../budget/guard.js';
6
7
  import { Store } from '../store/db.js';
7
8
  import { loadConfig, mutateConfig, dbPath, configPath, isDemo } from '../config.js';
8
9
  import { attributeCommits, isGitRepo } from '../git/correlate.js';
@@ -11,6 +12,7 @@ import { buildGuide } from '../guide.js';
11
12
  import { computeAlertCoverage, computeAlerts } from '../alerts/detect.js';
12
13
  import { notifyWebhook } from '../alerts/notify.js';
13
14
  import { pricingStatus } from '../cost/pricing.js';
15
+ import { summarizeBasis } from '../cost/basis.js';
14
16
  import { baselineManifestStatus } from '../value/liftBaseline.js';
15
17
  import { probeProxyState } from '../egress/proxyHealth.js';
16
18
  import { C, color, usd, num, printNotAGitRepo, printJson } from './ui.js';
@@ -108,7 +110,30 @@ export async function cmdAlerts(flags) {
108
110
  process.exitCode = 1;
109
111
  store.close();
110
112
  }
111
- export async function cmdDoctor() {
113
+ /**
114
+ * How the last 30 days were priced, in one line. A price you set is not a list
115
+ * price and was never checked against one, so it is named on its own rather
116
+ * than folded into "each model's own list rate" (PR #146 review).
117
+ */
118
+ export function doctorPricingLine(basis) {
119
+ if (basis.cohorts.length === 0)
120
+ return 'no priced requests in 30d';
121
+ const weak = basis.cohorts.filter((c) => (c.id === 'fallback' || c.id === 'tool_reported' || c.id === 'unrecorded') && c.costUsd >= 0.005);
122
+ const declared = basis.cohorts.find((c) => c.id === 'user_declared');
123
+ const parts = [];
124
+ if (weak.length > 0) {
125
+ parts.push(`${usd(weak.reduce((s, c) => s + c.costUsd, 0))} of 30d spend priced without the model's own rate (${weak.map((c) => c.label).join(', ')}) — see "segreant month"`);
126
+ }
127
+ if (declared)
128
+ parts.push(`${usd(declared.costUsd)} at prices you set (not checked against a price list)`);
129
+ const free = basis.cohorts.find((c) => c.id === 'user_free');
130
+ if (free && !basis.cohorts.some((c) => c.id === 'list_exact' || c.id === 'list_family')) {
131
+ // Only free models: no rate card priced any of it (PR #146 review).
132
+ parts.push(`${num(free.requests)} requests on models you marked free (no charge)`);
133
+ }
134
+ return parts.length > 0 ? parts.join('; ') : "30d spend priced at each model's own list rate (an estimate, not a bill)";
135
+ }
136
+ export async function cmdDoctor(flags = { _: [] }) {
112
137
  const tty = process.stdout.isTTY ?? false;
113
138
  const cfg = loadConfig();
114
139
  const store = new Store(dbPath());
@@ -121,6 +146,25 @@ export async function cmdDoctor() {
121
146
  const criticals = alerts.filter((a) => a.severity === 'critical').length;
122
147
  const proxyStatus = await probeProxyState(cfg);
123
148
  const proxyUp = proxyStatus.kind === 'up';
149
+ const basis = summarizeBasis(store.pricingEvidenceByModel(now - 30 * day, now + 1000));
150
+ if (flags.json) {
151
+ // The same checks as the text below, as data. `doctor --json` used to print
152
+ // the text (H013).
153
+ const price = pricingStatus(cfg.pricing.maxAgeDays);
154
+ const base = baselineManifestStatus();
155
+ const coverage = computeAlertCoverage(store, cfg);
156
+ printJson({
157
+ config: { path: configPath() },
158
+ database: { path: dbPath(), last30Days: { requests: sum30.requests, costUsd: sum30.costUsd } },
159
+ proxy: { port: cfg.port, state: proxyStatus.kind },
160
+ budget: { dailyUsd: cfg.budget.dailyUsd },
161
+ pricing: { basis, rateCard: price },
162
+ baseline: base,
163
+ alerts: { active: alerts.length, critical: criticals, coverage },
164
+ });
165
+ store.close();
166
+ return;
167
+ }
124
168
  const mark = (good) => (good ? color(tty, C.green, '✓') : color(tty, C.yellow, '!'));
125
169
  console.log('');
126
170
  console.log(color(tty, C.bold, ' Segreant — doctor'));
@@ -134,7 +178,7 @@ export async function cmdDoctor() {
134
178
  : color(tty, C.yellow, `not reachable on :${cfg.port} — start with "segreant start"`);
135
179
  console.log(` ${mark(proxyUp)} Proxy ${proxyMessage}`);
136
180
  console.log(` ${mark(cfg.budget.dailyUsd !== null)} Daily cap ${cfg.budget.dailyUsd !== null ? usd(cfg.budget.dailyUsd) : color(tty, C.yellow, 'none — metering only (set with "segreant budget --daily N")')}`);
137
- console.log(` ${mark(estShare <= 0.2)} Pricing ${estShare > 0 ? `${Math.round(estShare * 100)}% of 30d spend used estimated rates` : 'all spend priced from the rate card'}`);
181
+ console.log(` ${mark(estShare <= 0.2)} Pricing ${doctorPricingLine(basis)}`);
138
182
  const price = pricingStatus(cfg.pricing.maxAgeDays);
139
183
  const priceAge = price.ageDays === null ? '' : ` · ${price.ageDays}d old`;
140
184
  const priceEvidence = price.source === 'cache'
@@ -201,6 +245,7 @@ export async function gatherGuideFacts() {
201
245
  const all = store.summary(0, now + 1000);
202
246
  const sum30 = store.summary(now - 30 * day, now + 1000);
203
247
  const outcomeSignals = store.countSignals();
248
+ const capBasis = capBasisSpend(store, cfg.budget, now - 30 * day, now + 1000);
204
249
  const realizationUnits = store.countRealizationUnits();
205
250
  // Read before the store closes: without it `requestsAllTime` is a count of
206
251
  // survivors presented as a count of everything (D-170).
@@ -216,6 +261,8 @@ export async function gatherGuideFacts() {
216
261
  requestsAllTime: all.requests,
217
262
  requestsRetention,
218
263
  spend30dUsd: sum30.costUsd,
264
+ capBasisSpend30dUsd: capBasis.usd,
265
+ capBasis: capBasis.basis,
219
266
  dailyCapUsd: cfg.budget.dailyUsd,
220
267
  outcomeSignals,
221
268
  realizationUnits,
@@ -0,0 +1,181 @@
1
+ /**
2
+ * `segreant plan`: which plan each tool runs on, what the person pays for it,
3
+ * and how much list-price work the plan did. Two bases, always named: the plan
4
+ * price is what the person entered; the work is list cost from the rate card.
5
+ */
6
+ import { computeCost, withoutDeclaredPrices } from '../cost/pricing.js';
7
+ import { Store } from '../store/db.js';
8
+ import { dbPath, loadConfig, mutateConfig, PLAN_SOURCES } from '../config.js';
9
+ import { detectPlans, planName, suggestedPrice, PUBLIC_PLAN_PRICES_CHECKED, } from '../plans/detect.js';
10
+ import { C, color, usd, printJson, num } from './ui.js';
11
+ import { rangeFor, UserInputError } from './flags.js';
12
+ const TOOL_LABEL = { 'claude-code': 'Claude Code', codex: 'Codex' };
13
+ /** List cost of a tool's imported (subscription) use in a window. */
14
+ export function importedListCost(store, source, startMs, endMs) {
15
+ return importedListWork(store, source, startMs, endMs).listCostUsd;
16
+ }
17
+ /**
18
+ * The list-price work a tool's imported use did, and how many requests on a
19
+ * price you set were left out because the card has no exact price for their
20
+ * model. "List-price work" means list prices: a declared row is priced from
21
+ * the card here, so a declaration never inflates, shrinks or hides the plan
22
+ * line, and a private model the card only estimates (family or fallback) is
23
+ * withheld rather than shown as a published price (PR #146 review). Other
24
+ * rows keep the price they were recorded at.
25
+ */
26
+ export function importedListWork(store, source, startMs, endMs) {
27
+ let listCostUsd = 0;
28
+ let withheldRequests = 0;
29
+ for (const r of store.requestsInRange(startMs, endMs)) {
30
+ if (r.source !== source || r.via !== 'import')
31
+ continue;
32
+ if (r.pricing?.costBasis !== 'user_declared_price') {
33
+ listCostUsd += r.costUsd;
34
+ continue;
35
+ }
36
+ const card = withoutDeclaredPrices(() => computeCost(r.provider, r.model, {
37
+ inputTokens: r.inputTokens, outputTokens: r.outputTokens,
38
+ cacheWriteTokens: r.cacheWriteTokens, cacheReadTokens: r.cacheReadTokens,
39
+ ...(r.cacheWriteTtl === undefined ? {} : { cacheWriteTtl: r.cacheWriteTtl }),
40
+ }));
41
+ if (card.estimated) {
42
+ withheldRequests++;
43
+ continue;
44
+ }
45
+ listCostUsd += card.costUsd;
46
+ }
47
+ return { listCostUsd, withheldRequests };
48
+ }
49
+ export function planComparisons(store, startMs, endMs) {
50
+ const cfg = loadConfig();
51
+ return detectPlans().map((detected) => ({
52
+ source: detected.source,
53
+ detected,
54
+ price: cfg.plans[detected.source] ?? null,
55
+ ...importedListWork(store, detected.source, startMs, endMs),
56
+ }));
57
+ }
58
+ function withheldNote(r) {
59
+ return r.withheldRequests > 0
60
+ ? ` · ${num(r.withheldRequests)} request${r.withheldRequests === 1 ? '' : 's'} on models with no published price left out`
61
+ : '';
62
+ }
63
+ /**
64
+ * The month's plan lines, printed under the list cost by `month` and `scan`.
65
+ * A price the person set: "Your $20 Claude Pro plan did $1,830 of list-price
66
+ * work". A plan with no price: the plan, and the command that sets it.
67
+ */
68
+ export function printPlanLines(tty, rows) {
69
+ for (const r of rows) {
70
+ if (r.listCostUsd < 0.005)
71
+ continue;
72
+ const name = planName(r.detected) ?? `${TOOL_LABEL[r.source]} (plan not detected)`;
73
+ if (r.price !== null) {
74
+ const times = r.price.monthlyUsd > 0 ? `, ${(r.listCostUsd / r.price.monthlyUsd).toFixed(1)}× its price` : '';
75
+ console.log(` ${color(tty, C.bold, `Your ${usd(r.price.monthlyUsd)}/month ${name} plan`)} did ${color(tty, C.green, usd(r.listCostUsd))} of list-price work in the last 30 days${color(tty, C.gray, `${times}`)}`);
76
+ console.log(color(tty, C.gray, ` plan price: what you set${r.price.plan !== null && r.price.plan !== r.detected.plan ? ` (for ${r.price.plan}; the tool now reports ${r.detected.plan ?? 'no plan'})` : ''} · work: priced from the rate card${withheldNote(r)}`));
77
+ }
78
+ else if (r.detected.plan !== null) {
79
+ const hint = suggestedPrice(r.detected);
80
+ console.log(color(tty, C.gray, ` ${name} plan: ${usd(r.listCostUsd)} of list-price work${withheldNote(r)}. Compare it with what you pay: segreant plan set ${r.source} <dollars per month>${hint !== null ? ` (public price ${usd(hint)})` : ''}`));
81
+ }
82
+ }
83
+ }
84
+ /**
85
+ * Ask once, at the end of `scan --setup`, what the person pays for each plan a
86
+ * tool reports and no price is set for. Interactive terminals only: a script,
87
+ * a pipe or --json never waits. Enter skips; an answer is saved through the
88
+ * same validated path as `segreant plan set`. Returns how many were saved.
89
+ */
90
+ export async function askPlanPrices(tty, io = {
91
+ input: process.stdin, output: process.stdout, interactive: process.stdin.isTTY === true,
92
+ }) {
93
+ if (!io.interactive)
94
+ return 0;
95
+ const cfg = loadConfig();
96
+ const pending = detectPlans().filter((p) => p.plan !== null && cfg.plans[p.source] === undefined);
97
+ if (pending.length === 0)
98
+ return 0;
99
+ const { createInterface } = await import('node:readline/promises');
100
+ const rl = createInterface({ input: io.input, output: io.output, terminal: false });
101
+ let saved = 0;
102
+ try {
103
+ io.output.write(color(tty, C.bold, ' What do you pay for your plans?') + color(tty, C.gray, ' so Segreant can set the plan price beside the list-price work (Enter skips)') + '\n');
104
+ for (const p of pending) {
105
+ const hint = suggestedPrice(p);
106
+ const answer = (await rl.question(` ${planName(p)}, dollars per month${hint !== null ? ` (public price ${hint})` : ''}: `)).trim().replace(/^\$/, '');
107
+ if (answer === '')
108
+ continue;
109
+ const monthlyUsd = Number(answer);
110
+ if (!Number.isFinite(monthlyUsd) || monthlyUsd < 0 || monthlyUsd > 100_000) {
111
+ io.output.write(color(tty, C.yellow, ` Not a dollar amount; skipped. Set it later: segreant plan set ${p.source} <dollars>`) + '\n');
112
+ continue;
113
+ }
114
+ mutateConfig((c) => ({ ...c, plans: { ...c.plans, [p.source]: { monthlyUsd, plan: p.plan, setAt: new Date().toISOString() } } }));
115
+ saved += 1;
116
+ }
117
+ }
118
+ finally {
119
+ rl.close();
120
+ }
121
+ return saved;
122
+ }
123
+ export function cmdPlan(flags) {
124
+ const [action, tool, amount] = flags._.map(String);
125
+ if (action === 'set' || action === 'clear') {
126
+ if (tool === undefined || !PLAN_SOURCES.includes(tool)) {
127
+ throw new UserInputError(`Name the tool: segreant plan ${action} ${PLAN_SOURCES.join('|')}${action === 'set' ? ' <dollars per month>' : ''}`);
128
+ }
129
+ const source = tool;
130
+ const detected = detectPlans().find((p) => p.source === source);
131
+ if (action === 'set') {
132
+ if (amount === undefined)
133
+ throw new UserInputError(`Give the monthly price: segreant plan set ${source} 20`);
134
+ const monthlyUsd = Number(amount.replace(/^\$/, ''));
135
+ if (!Number.isFinite(monthlyUsd) || monthlyUsd < 0 || monthlyUsd > 100_000) {
136
+ throw new UserInputError('The price must be a dollar amount per month, for example 20.');
137
+ }
138
+ mutateConfig((cfg) => ({
139
+ ...cfg,
140
+ plans: { ...cfg.plans, [source]: { monthlyUsd, plan: detected.plan, setAt: new Date().toISOString() } },
141
+ }));
142
+ console.log(` Saved: ${planName(detected) ?? TOOL_LABEL[source]} costs you ${usd(monthlyUsd)} a month.`);
143
+ }
144
+ else {
145
+ mutateConfig((cfg) => {
146
+ const plans = { ...cfg.plans };
147
+ delete plans[source];
148
+ return { ...cfg, plans };
149
+ });
150
+ console.log(` Cleared the plan price for ${TOOL_LABEL[source]}.`);
151
+ }
152
+ return;
153
+ }
154
+ if (action !== undefined)
155
+ throw new UserInputError('Usage: segreant plan [set <tool> <dollars> | clear <tool>]');
156
+ const store = new Store(dbPath());
157
+ const { startMs, endMs } = rangeFor('month');
158
+ const rows = planComparisons(store, startMs, endMs);
159
+ store.close();
160
+ if (flags.json) {
161
+ printJson({ windowDays: 30, plans: rows, publicPricesChecked: PUBLIC_PLAN_PRICES_CHECKED });
162
+ return;
163
+ }
164
+ const tty = process.stdout.isTTY ?? false;
165
+ console.log('');
166
+ console.log(color(tty, C.bold, ' Plans') + color(tty, C.gray, ' read from each tool\'s own files · the price is only what you set'));
167
+ for (const r of rows) {
168
+ const name = planName(r.detected);
169
+ const price = r.price === null ? color(tty, C.gray, 'price not set') : `${usd(r.price.monthlyUsd)}/month (you set this ${r.price.setAt.slice(0, 10)})`;
170
+ console.log(` ${TOOL_LABEL[r.source].padEnd(12)} ${(name ?? 'not detected').padEnd(18)} ${price}`);
171
+ console.log(color(tty, C.gray, ` ${r.detected.evidence}${r.detected.billing ? ` · billed via ${r.detected.billing.replace(/_/g, ' ')}` : ''}`));
172
+ }
173
+ console.log('');
174
+ printPlanLines(tty, rows);
175
+ const unset = rows.filter((r) => r.price === null && r.detected.plan !== null);
176
+ if (unset.length > 0) {
177
+ console.log('');
178
+ console.log(color(tty, C.gray, ` Public prices (checked ${PUBLIC_PLAN_PRICES_CHECKED}) are only a hint: App Store billing, tax and annual plans change what you pay.`));
179
+ }
180
+ console.log('');
181
+ }
@@ -0,0 +1,178 @@
1
+ /** User-declared model prices; historical request amounts remain unchanged. */
2
+ import { existsSync } from 'node:fs';
3
+ import { dbPath, isDemo } from '../config.js';
4
+ import { modelNameError, PriceOverridesError, removePriceOverride, } from '../cost/overrides.js';
5
+ import { declarePrice as declare, unknownModels } from '../cost/declarations.js';
6
+ import { Store } from '../store/db.js';
7
+ import { printJson, usd } from './ui.js';
8
+ const NEXT_STEP = 'segreant reprice previews re-pricing recorded requests; nothing changes without --apply.';
9
+ const SET_WAYS = 'Set a price: segreant pricing set <model> --input <usd> --output <usd> (USD per 1M tokens), --like <known-model>, or --free.';
10
+ const PRICE_OPTIONS = ['input', 'output', 'cache-read', 'cache-write', 'cache-write-1h', 'like', 'free'];
11
+ function priceInput(flags) {
12
+ // Read every form before choosing one, including cache-only mixtures.
13
+ const input = flags.input;
14
+ const output = flags.output;
15
+ const cacheRead = flags['cache-read'];
16
+ const cacheWrite = flags['cache-write'];
17
+ const cacheWrite1h = flags['cache-write-1h'];
18
+ const like = flags.like;
19
+ const free = flags.free;
20
+ const rate = [input, output, cacheRead, cacheWrite, cacheWrite1h].some((v) => v !== undefined);
21
+ if (Number(rate) + Number(like !== undefined) + Number(free !== undefined) !== 1) {
22
+ throw new PriceOverridesError('Choose exactly one price form: --input with --output, --like, or --free; do not mix them.');
23
+ }
24
+ if (free !== undefined) {
25
+ if (free !== true)
26
+ throw new PriceOverridesError('--free is a switch; give it no value.');
27
+ return { kind: 'free' };
28
+ }
29
+ if (like !== undefined) {
30
+ if (typeof like !== 'string')
31
+ throw new PriceOverridesError('--like needs an exact model name from the rate card.');
32
+ return { kind: 'like', like };
33
+ }
34
+ if (typeof input !== 'string' || typeof output !== 'string') {
35
+ throw new PriceOverridesError('A rate needs both --input <usd> and --output <usd> (USD per 1M tokens).');
36
+ }
37
+ if (cacheRead !== undefined && typeof cacheRead !== 'string')
38
+ throw new PriceOverridesError('--cache-read needs a decimal USD price per 1M tokens.');
39
+ if (cacheWrite !== undefined && typeof cacheWrite !== 'string')
40
+ throw new PriceOverridesError('--cache-write needs a decimal USD price per 1M tokens.');
41
+ if (cacheWrite1h !== undefined && typeof cacheWrite1h !== 'string')
42
+ throw new PriceOverridesError('--cache-write-1h needs a decimal USD price per 1M tokens.');
43
+ return {
44
+ kind: 'rate', input, output,
45
+ ...(cacheRead === undefined ? {} : { cacheRead }),
46
+ ...(cacheWrite === undefined ? {} : { cacheWrite }),
47
+ ...(cacheWrite1h === undefined ? {} : { cacheWrite1h }),
48
+ };
49
+ }
50
+ function storedLine(model, price) {
51
+ if (price.kind === 'free')
52
+ return `Stored user-declared price for ${model}: free ($0 per request).`;
53
+ if (price.kind === 'like')
54
+ return `Stored user-declared price for ${model}: like ${price.like} (exact rate-card model).`;
55
+ const cache = [
56
+ ...(price.cacheRead === undefined ? [] : [`cache-read $${price.cacheRead}`]),
57
+ ...(price.cacheWrite === undefined ? [] : [`cache-write $${price.cacheWrite}`]),
58
+ ...(price.cacheWrite1h === undefined ? [] : [`cache-write-1h $${price.cacheWrite1h}`]),
59
+ ];
60
+ // A cache price left out is not yours: it is the usual multiple of your
61
+ // input price. Say so where the price is stored (independent review,
62
+ // 2026-10-18), naming only the parts that were left out.
63
+ // --cache-write also prices 1-hour writes unless --cache-write-1h is given
64
+ // (declaredRate), so 2× is derived only when neither was (PR #150 review).
65
+ const derived = [
66
+ ...(price.cacheWrite === undefined ? ['5-minute cache writes at 1.25×'] : []),
67
+ ...(price.cacheWrite1h === undefined && price.cacheWrite === undefined ? ['1-hour cache writes at 2×'] : []),
68
+ ...(price.cacheRead === undefined ? ['cache reads at 0.1×'] : []),
69
+ ];
70
+ const note = derived.length
71
+ ? ` Not set, so priced at the usual multiple of your input price: ${derived.join(', ')} (set --cache-write, --cache-write-1h or --cache-read to give your own).`
72
+ : '';
73
+ return `Stored user-declared price for ${model}: input $${price.input}, output $${price.output}${cache.length ? `, ${cache.join(', ')}` : ''} (USD per 1M tokens).${note}`;
74
+ }
75
+ /** No ledger is created just to inspect historical usage. */
76
+ function readLedger(empty, read) {
77
+ const path = dbPath();
78
+ if (!existsSync(path))
79
+ return empty;
80
+ // An existing ledger opens as every command opens it; a missing one is not created.
81
+ const store = new Store(path);
82
+ try {
83
+ return read(store);
84
+ }
85
+ finally {
86
+ store.close();
87
+ }
88
+ }
89
+ /** `flags._` includes the action. Input refusals are one line and exit 2. */
90
+ export function cmdPricingDeclare(action, flags) {
91
+ try {
92
+ const allowed = new Set(['_', 'debug', 'help', 'demo', ...(action === 'set' ? PRICE_OPTIONS : action === 'unknown' ? ['json'] : [])]);
93
+ const unsupported = Object.keys(flags).find((k) => !allowed.has(k));
94
+ if (unsupported)
95
+ throw new PriceOverridesError(`--${unsupported} does nothing for segreant pricing ${action}.`);
96
+ if (action === 'unknown') {
97
+ if (flags._.length !== 1)
98
+ throw new PriceOverridesError('Usage: segreant pricing unknown [--json]');
99
+ const models = readLedger([], unknownModels);
100
+ if (flags.json) {
101
+ printJson({ models, costBasis: 'recorded cost (estimated)', nextStep: SET_WAYS, demo: isDemo() });
102
+ }
103
+ else {
104
+ if (isDemo())
105
+ console.log(' DEMO DATA — synthetic requests.');
106
+ if (models.length === 0)
107
+ console.log(' No unresolved models with recorded guessed prices.');
108
+ for (const r of models) {
109
+ console.log(` ${r.provider} ${r.model} ${r.requests} recorded requests recorded cost (estimated): ${usd(r.costUsd)} current match: ${r.rateMatchKind}`);
110
+ }
111
+ console.log(` ${SET_WAYS}`);
112
+ }
113
+ return;
114
+ }
115
+ const model = flags._[1];
116
+ if (flags._.length !== 2 || model === undefined)
117
+ throw new PriceOverridesError(`Usage: segreant pricing ${action} <model>${action === 'set' ? ' --input <usd> --output <usd> | --like <known-model> | --free' : ''}`);
118
+ const error = modelNameError(model);
119
+ if (error)
120
+ throw new PriceOverridesError(error);
121
+ if (isDemo())
122
+ throw new PriceOverridesError('Demo mode only shows sample data; run without --demo to change a declared price.');
123
+ if (action === 'unset') {
124
+ console.log(removePriceOverride(model) ? ` Removed declared price for ${model}.` : ` No declared price for ${model}.`);
125
+ console.log(` ${NEXT_STEP}`);
126
+ return;
127
+ }
128
+ const input = priceInput(flags);
129
+ const recorded = readLedger({ requests: 0, costUsd: 0 }, (store) => {
130
+ return store.byModel(Number.MIN_SAFE_INTEGER, Number.MAX_SAFE_INTEGER)
131
+ .filter((r) => r.label === model)
132
+ .reduce((total, r) => ({ requests: total.requests + r.requests, costUsd: total.costUsd + r.costUsd }), { requests: 0, costUsd: 0 });
133
+ });
134
+ const price = declare(model, input);
135
+ console.log(` ${storedLine(model, price)}`);
136
+ console.log(` ${recorded.requests} recorded requests use ${model}; recorded cost (estimated): ${usd(recorded.costUsd)}.`);
137
+ console.log(` ${NEXT_STEP}`);
138
+ }
139
+ catch (error) {
140
+ if (!(error instanceof PriceOverridesError))
141
+ throw error;
142
+ console.error(' ' + error.message.replace(/[\r\n]+/g, ' '));
143
+ process.exitCode = 2;
144
+ }
145
+ }
146
+ /** Ask once per model name, with at most one retry; callers own terminal IO. */
147
+ export async function promptForUnknownModels(models, io) {
148
+ const made = [];
149
+ for (const model of new Set(models.map((m) => m.model))) {
150
+ for (let attempt = 0; attempt < 2; attempt++) {
151
+ const answer = (await io.ask(`price for ${model}? [Enter] skip l <model> like another p <in> <out> price per 1M f free`)).trim();
152
+ if (answer === '' || answer === 's')
153
+ break;
154
+ const words = answer.split(/\s+/);
155
+ let input;
156
+ if (words[0] === 'f' && words.length === 1)
157
+ input = { kind: 'free' };
158
+ if (words[0] === 'l' && words.length === 2)
159
+ input = { kind: 'like', like: words[1] };
160
+ if (words[0] === 'p' && words.length === 3)
161
+ input = { kind: 'rate', input: words[1], output: words[2] };
162
+ try {
163
+ if (!input)
164
+ throw new PriceOverridesError('Use Enter or s to skip, l <model>, p <in> <out>, or f.');
165
+ const price = declare(model, input);
166
+ made.push({ model, ...price });
167
+ io.print(` ${storedLine(model, price)}`);
168
+ break;
169
+ }
170
+ catch (error) {
171
+ if (!(error instanceof PriceOverridesError))
172
+ throw error;
173
+ io.print(` ${error.message.replace(/[\r\n]+/g, ' ')} ${attempt === 0 ? 'Try once more (Enter skips).' : 'Skipped.'}`);
174
+ }
175
+ }
176
+ }
177
+ return made;
178
+ }
@@ -0,0 +1,190 @@
1
+ /**
2
+ * `segreant quota`: each vendor's own usage meter, read from its tool's logs,
3
+ * the pace to the next reset, and whether the meter moves in step with what
4
+ * Segreant counted. It reads what the last import or status-line reading
5
+ * stored; `segreant import all` refreshes it.
6
+ */
7
+ import { Store } from '../store/db.js';
8
+ import { dbPath } from '../config.js';
9
+ import { quotaView, median } from '../quota/view.js';
10
+ import { statuslineLine } from '../quota/statusline.js';
11
+ import { readFileSync } from 'node:fs';
12
+ import { dirname, join } from 'node:path';
13
+ import { detectPlans, planName } from '../plans/detect.js';
14
+ import { C, color, usd, printJson } from './ui.js';
15
+ const DAY = 86_400_000;
16
+ function when(ms, nowMs) {
17
+ const d = new Date(ms);
18
+ const sameDay = new Date(nowMs).toDateString() === d.toDateString();
19
+ const time = d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
20
+ return sameDay ? `today ${time}` : `${d.toLocaleDateString([], { weekday: 'short' })} ${time}`;
21
+ }
22
+ function inFor(ms, nowMs) {
23
+ const mins = Math.max(0, Math.round((ms - nowMs) / 60_000));
24
+ if (mins < 60)
25
+ return `${mins}m`;
26
+ const h = Math.floor(mins / 60);
27
+ return h < 48 ? `${h}h ${mins % 60}m` : `${Math.round(h / 24)}d`;
28
+ }
29
+ function windowLabel(w) {
30
+ if (w.windowMinutes === 300)
31
+ return '5-hour';
32
+ if (w.windowMinutes === 10_080)
33
+ return 'weekly';
34
+ return w.windowMinutes === null ? 'window' : `${Math.round(w.windowMinutes / 60)}-hour`;
35
+ }
36
+ const STATUSLINE_ABOUT = ` Claude Code then hands Segreant its live usage meter (5-hour and weekly percentages)
37
+ each time the status line refreshes. Segreant records a reading when it changes and
38
+ prints one short line. Nothing leaves your machine; segreant quota shows the history.`;
39
+ /**
40
+ * The setup text for THIS install. It used to name the bare
41
+ * segreant-statusline command, which exists only after a global install;
42
+ * after a local install or npx, Claude Code ran a command that was not there
43
+ * (H012). It now names the installed file by its absolute path, run with node,
44
+ * so nothing has to be on PATH. Under npx the package lives in a cache that
45
+ * can be cleared, so it asks for a lasting install first.
46
+ *
47
+ * `launcher` is bin/segreant.mjs as started: process.argv[1] in the runtime
48
+ * child, which the launcher spawns with its own real path, never the snapshot.
49
+ */
50
+ export function statuslineSetupText(launcher) {
51
+ const bin = launcher ? join(dirname(launcher), 'segreant-statusline.mjs').replace(/\\/g, '/') : null;
52
+ if (bin === null || /[\\/]_npx[\\/]/.test(launcher)) {
53
+ return ` Segreant is running from npx's temporary cache, which can be cleared at any time.
54
+ Install it so the status line has a file that stays put:
55
+
56
+ npm install -g segreant
57
+
58
+ then run segreant statusline --setup again for the exact line to add.`;
59
+ }
60
+ const command = `node "${bin}"`;
61
+ return ` Add this to ~/.claude/settings.json (or merge it into an existing statusLine):
62
+
63
+ "statusLine": ${JSON.stringify({ type: 'command', command })}
64
+
65
+ It runs the installed file directly, so nothing has to be on PATH.
66
+ ${STATUSLINE_ABOUT}`;
67
+ }
68
+ /**
69
+ * `segreant statusline`: the same lean path as the `segreant-statusline` bin
70
+ * (src/quota/statusline.ts), reachable through the full CLI for convenience.
71
+ * The bin is what the setup snippet names, because it starts far faster.
72
+ */
73
+ export function cmdStatusline(flags) {
74
+ if (flags.setup) {
75
+ console.log(statuslineSetupText(process.argv[1]));
76
+ return;
77
+ }
78
+ let raw = '';
79
+ try {
80
+ raw = readFileSync(0, 'utf8').slice(0, 1_000_000);
81
+ }
82
+ catch {
83
+ raw = '';
84
+ }
85
+ process.stdout.write(statuslineLine(dbPath(), raw, Date.now()) + '\n');
86
+ }
87
+ export function cmdQuota(flags) {
88
+ const store = new Store(dbPath());
89
+ const now = Date.now();
90
+ const events = store.quotaEvents(now - 35 * DAY);
91
+ const view = quotaView(events, (source, a, b) => store.sourceCostBetween(source, a, b), now);
92
+ store.close();
93
+ if (flags.json) {
94
+ printJson({ generatedAtMs: now, ...view });
95
+ return;
96
+ }
97
+ const tty = process.stdout.isTTY ?? false;
98
+ const plans = new Map(detectPlans().map((p) => [p.source, planName(p)]));
99
+ console.log('');
100
+ console.log(color(tty, C.bold, ' Quota') + color(tty, C.gray, " the vendors' own meters, read from their tools' logs · refresh with segreant import all"));
101
+ console.log('');
102
+ console.log(color(tty, C.bold, ` Codex${plans.get('codex') ? ` (${plans.get('codex')})` : ''}`));
103
+ if (view.codex === null) {
104
+ console.log(color(tty, C.gray, ' No rate-limit readings in the Codex logs yet.'));
105
+ }
106
+ else {
107
+ for (const w of view.codex.windows) {
108
+ const label = windowLabel(w).padEnd(7);
109
+ if (w.reset) {
110
+ console.log(` ${label} ${color(tty, C.gray, `reset at ${when(w.resetsAtMs, now)}; no use logged since, so no current reading`)}`);
111
+ continue;
112
+ }
113
+ const resets = w.resetsAtMs === null ? '' : ` · resets ${when(w.resetsAtMs, now)} (in ${inFor(w.resetsAtMs, now)})`;
114
+ const tone = w.usedPercent >= 90 ? C.red : w.usedPercent >= 70 ? C.yellow : C.green;
115
+ console.log(` ${label} ${color(tty, tone, `${Math.round(w.usedPercent)}% used`)}${resets}${color(tty, C.gray, ` · read ${when(w.asOfMs, now)}`)}`);
116
+ if (w.limitAtMs !== null && w.limitAtMs <= now) {
117
+ console.log(color(tty, C.yellow, ` at the pace of that reading the limit was due ${when(w.limitAtMs, now)}; nothing has been logged since, so whether it was reached is not known yet`));
118
+ }
119
+ else if (w.limitAtMs !== null) {
120
+ console.log(color(tty, C.yellow, ` at this pace you reach the limit ${when(w.limitAtMs, now)} (estimate: the pace since the window opened)`));
121
+ }
122
+ else if (w.projectedAtResetPercent !== null) {
123
+ console.log(color(tty, C.gray, ` at this pace: about ${Math.round(w.projectedAtResetPercent)}% by the reset (estimate)`));
124
+ }
125
+ }
126
+ const m = view.codex.weekly;
127
+ if (m.currentUsdPerPercent !== null) {
128
+ const earlier = median(m.earlierUsdPerPercent);
129
+ const line = ` Segreant counted ${usd(m.currentUsdPerPercent)} of list-price work per 1% of the weekly meter this week`
130
+ + (earlier === null ? '' : `; ${usd(earlier)} per 1% in earlier weeks (median of ${m.earlierUsdPerPercent.length})`);
131
+ console.log(color(tty, C.gray, line));
132
+ if (m.agrees === false) {
133
+ console.log(color(tty, C.yellow, ' The meter and the count disagree beyond 1.5x this week. Both figures are above; the vendor may weight models or caching differently from list price.'));
134
+ }
135
+ else if (m.agrees === true) {
136
+ console.log(color(tty, C.gray, ' The meter moves in step with what Segreant counted.'));
137
+ }
138
+ }
139
+ }
140
+ console.log('');
141
+ console.log(color(tty, C.bold, ` Claude Code${plans.get('claude-code') ? ` (${plans.get('claude-code')})` : ''}`));
142
+ const c = view.claude;
143
+ if (c === null) {
144
+ console.log(color(tty, C.gray, ' No Claude Code use or limit messages in the last 35 days.'));
145
+ }
146
+ else {
147
+ if (view.claudeWindows.length > 0) {
148
+ for (const w of view.claudeWindows) {
149
+ const label = (w.kind === 'claude_spend_window' ? 'spend' : windowLabel(w)).padEnd(7);
150
+ if (w.reset) {
151
+ console.log(` ${label} ${color(tty, C.gray, `reset at ${when(w.resetsAtMs, now)}; no reading since`)}`);
152
+ continue;
153
+ }
154
+ const resets = w.resetsAtMs === null ? '' : ` · resets ${when(w.resetsAtMs, now)} (in ${inFor(w.resetsAtMs, now)})`;
155
+ const tone = w.usedPercent >= 90 ? C.red : w.usedPercent >= 70 ? C.yellow : C.green;
156
+ console.log(` ${label} ${color(tty, tone, `${Math.round(w.usedPercent)}% used`)}${resets}${color(tty, C.gray, ` · read ${when(w.asOfMs, now)} from the status line`)}`);
157
+ if (w.limitAtMs !== null && w.limitAtMs > now) {
158
+ console.log(color(tty, C.yellow, ` at this pace you reach the limit ${when(w.limitAtMs, now)} (estimate: the pace since the window opened)`));
159
+ }
160
+ }
161
+ }
162
+ else {
163
+ console.log(color(tty, C.gray, ' Claude Code logs no percentage; it logs a message when a limit is reached. For the live percentage, set segreant as'));
164
+ console.log(color(tty, C.gray, ' your Claude Code status line (segreant statusline --setup shows how), or see claude.ai/settings/usage.'));
165
+ }
166
+ const parts = [
167
+ `the session limit ${c.sessionHits} time${c.sessionHits === 1 ? '' : 's'}`,
168
+ `the weekly limit ${c.weeklyHits} time${c.weeklyHits === 1 ? '' : 's'}`,
169
+ ...(c.spendHits > 0 ? [`the monthly spend limit ${c.spendHits} time${c.spendHits === 1 ? '' : 's'}`] : []),
170
+ ];
171
+ console.log(` Last 35 days: you reached ${parts.join(', ')}.`);
172
+ if (c.lastHit !== null) {
173
+ const reset = c.lastHit.resetsAtMs === null ? '' : `, reset ${when(c.lastHit.resetsAtMs, now)}`;
174
+ console.log(color(tty, C.gray, ` Last: ${when(c.lastHit.tsEpochMs, now)}${reset} ("${c.lastHit.detail ?? ''}")`));
175
+ }
176
+ const before = c.usdBeforeSessionHit.filter((x) => x > 0);
177
+ const med = median(before);
178
+ if (med !== null) {
179
+ const lo = Math.min(...before);
180
+ const hi = Math.max(...before);
181
+ console.log(` Before a session-limit message, the 5 hours held ${usd(lo)}–${usd(hi)} of list-price work (median ${usd(med)}).`);
182
+ const share = Math.round((c.lastFiveHoursUsd / med) * 100);
183
+ console.log(color(tty, C.gray, ` The last 5 hours: ${usd(c.lastFiveHoursUsd)}, about ${share}% of that median (estimate: the window's real start is not logged).`));
184
+ }
185
+ else {
186
+ console.log(color(tty, C.gray, ` The last 5 hours: ${usd(c.lastFiveHoursUsd)} of list-price work.`));
187
+ }
188
+ }
189
+ console.log('');
190
+ }