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
@@ -12,7 +12,7 @@
12
12
  import { readdirSync, statSync } from 'node:fs';
13
13
  import { join } from 'node:path';
14
14
  import { RequestObservationConflictError } from '../store/db.js';
15
- import { repoToplevel } from '../git/correlate.js';
15
+ import { repoToplevelOnDisk } from './repoRoot.js';
16
16
  import { projectKey, projectKeyWithBasis } from '../value/characterization.js';
17
17
  import { RESOURCE_LIMITS } from '../util/resource-limits.js';
18
18
  export function emptyImportSummary(files = 0) {
@@ -76,6 +76,49 @@ export function boundedJsonlFiles(root) {
76
76
  }
77
77
  return { files, truncated };
78
78
  }
79
+ /**
80
+ * The version of what the importers read from a log file. A file read in full
81
+ * by an older reader is read once more: version 2 added commit observations;
82
+ * version 4 finds them only where git's own output starts (see COMMIT_START).
83
+ */
84
+ export const IMPORT_READER_VERSION = 4;
85
+ // git prints "[main 3f60dcf] subject" when it creates a commit, "[main
86
+ // (root-commit) 3f60dcf] subject" for the first one, and "[detached HEAD
87
+ // 3f60dcf] subject" off a branch. In a JSON log line the subject ends at an
88
+ // escaped newline or the closing quote of the string.
89
+ const COMMIT_LINE = /\[((?:detached HEAD)|[A-Za-z0-9._\/-]{1,120})(?: \(root-commit\))? ([0-9a-f]{7,40})\] ((?:[^"\\]|\\[^n]){1,200})/g;
90
+ /**
91
+ * Where git's own output can begin in a raw log line: the start of a JSON
92
+ * string value (`"content":"`, `"stdout":"`, `"output":"`, at any escaping
93
+ * depth) or an escaped newline inside one. A commit line anywhere else is
94
+ * TEXT ABOUT a commit: a test fixture `content: '[main abc1234] ...'` that an
95
+ * agent read or wrote, or a comment explaining the format. On the owner's real
96
+ * logs (2026-10-16) the rule kept all 127 real matches and rejected all 26 of
97
+ * those, which had been recorded as commits the sessions made.
98
+ */
99
+ const COMMIT_START = /(?:(?:^|[:\[,]\s*)\\*"|\\+n)$/;
100
+ /** Every commit-creation line in one raw (JSON-encoded) log line. */
101
+ export function commitObservationsInLine(line) {
102
+ if (!line.includes('] '))
103
+ return [];
104
+ const found = [];
105
+ for (const m of line.matchAll(COMMIT_LINE)) {
106
+ if (!COMMIT_START.test(line.slice(Math.max(0, m.index - 16), m.index)))
107
+ continue;
108
+ let subject = m[3];
109
+ try {
110
+ subject = JSON.parse(`"${subject}"`);
111
+ }
112
+ catch { /* keep the raw text */ }
113
+ subject = subject.trim();
114
+ if (subject.length === 0)
115
+ continue;
116
+ if (found.some((f) => f.shortSha === m[2]))
117
+ continue;
118
+ found.push({ branch: m[1], shortSha: m[2], subject });
119
+ }
120
+ return found;
121
+ }
79
122
  /**
80
123
  * Decide whether a tool log file needs reading. Returns 'unchanged' when its
81
124
  * size and mtime match the last COMPLETE import of it into this ledger, its
@@ -91,10 +134,14 @@ export function fileStampForImport(store, source, file, opts) {
91
134
  catch {
92
135
  return null;
93
136
  }
137
+ if (opts.modifiedSinceMs !== undefined && stamp.mtimeMs < opts.modifiedSinceMs)
138
+ return 'deferred';
94
139
  if (opts.rescan)
95
140
  return stamp;
96
141
  const last = store.importFileCursor(source, file);
97
- return last !== null && last.size === stamp.size && last.mtimeMs === stamp.mtimeMs ? 'unchanged' : stamp;
142
+ return last !== null && last.readerVersion === IMPORT_READER_VERSION && last.size === stamp.size && last.mtimeMs === stamp.mtimeMs
143
+ ? 'unchanged'
144
+ : stamp;
98
145
  }
99
146
  /**
100
147
  * Account for a skipped file: count it, and replay the truncation its last full
@@ -126,6 +173,7 @@ export function noteFileImported(store, source, file, stamp, opts, before, summa
126
173
  mtimeMs: stamp.mtimeMs,
127
174
  truncatedLines: now.lines - before.lines,
128
175
  truncatedRows: now.rows - before.rows,
176
+ readerVersion: IMPORT_READER_VERSION,
129
177
  });
130
178
  }
131
179
  /** Mark a source import as incomplete without changing its accounting rows. */
@@ -197,9 +245,13 @@ export function recordInsert(store, summary, row, estimated) {
197
245
  * of nearly lining up.
198
246
  *
199
247
  * When the path is not inside a repository (a scratch folder, a deleted
200
- * directory, no git on PATH), this degrades to exactly the previous behaviour
248
+ * directory), this degrades to exactly the previous behaviour
201
249
  * and says so through the basis. Results are cached per directory: a transcript
202
250
  * corpus has thousands of lines and a handful of distinct working directories.
251
+ *
252
+ * The root is found on disk (`repoToplevelOnDisk`), the way git discovers it,
253
+ * rather than by running git: one spawn per directory cost about 45 ms on
254
+ * Windows, in every importer, on every refresh.
203
255
  */
204
256
  export function createRepoResolver() {
205
257
  const cache = new Map();
@@ -207,11 +259,11 @@ export function createRepoResolver() {
207
259
  const inferred = projectKeyWithBasis(cwd, fallback);
208
260
  if (!cwd || inferred.basis === 'tool_log_fallback')
209
261
  return { ...inferred, supersedes: null };
210
- const key = `${cwd}${fallback}`;
262
+ const key = `${cwd}\0${fallback}`;
211
263
  const hit = cache.get(key);
212
264
  if (hit)
213
265
  return hit;
214
- const top = await repoToplevel(cwd);
266
+ const top = repoToplevelOnDisk(cwd);
215
267
  const resolved = top === null
216
268
  ? { ...inferred, supersedes: null }
217
269
  : {
@@ -227,4 +279,5 @@ export const IMPORTERS = [
227
279
  { id: 'claude-code', label: 'Claude Code', blurb: 'Exact per-request usage from ~/.claude transcripts — works on Pro/Max subscriptions.' },
228
280
  { id: 'opencode', label: 'opencode', blurb: "Token usage from opencode's local session database (all providers it ran)." },
229
281
  { id: 'codex', label: 'Codex CLI', blurb: 'Per-turn token usage from ~/.codex rollout session logs.' },
282
+ { id: 'antigravity', label: 'Antigravity', blurb: 'Per-call token usage from the conversation databases Antigravity and the agy CLI keep under ~/.gemini.' },
230
283
  ];
@@ -62,8 +62,14 @@ export function parseOpencodeMessage(id, dataJson, fallbackTsMs) {
62
62
  if (input === 0 && output === 0 && reasoning === 0 && cacheRead === 0 && cacheWrite === 0)
63
63
  return null;
64
64
  const ts = d.time?.created ?? fallbackTsMs;
65
- if (typeof ts !== 'number' || !Number.isFinite(ts))
65
+ // Missing dates cannot be placed in a reporting period. Zero is OpenCode's
66
+ // unknown sentinel, not evidence that the request happened in January 1970.
67
+ if (typeof ts !== 'number' || !Number.isFinite(ts) || ts <= 0 || ts > 8.64e15)
66
68
  return null;
69
+ // An explicit zero is a valid free-tier report. An absent or malformed price
70
+ // is not a reported zero: it stays unknown here, and the importer prices the
71
+ // tokens with a stated basis or leaves the row out.
72
+ const reportedCostUsd = typeof d.cost === 'number' && Number.isFinite(d.cost) && d.cost >= 0 ? d.cost : null;
67
73
  const cwd = d.path?.cwd ?? '';
68
74
  const { project, basis: attributionBasis } = projectKeyWithBasis(cwd, 'opencode');
69
75
  return {
@@ -80,7 +86,7 @@ export function parseOpencodeMessage(id, dataJson, fallbackTsMs) {
80
86
  reasoningTokens: reasoning,
81
87
  cacheReadTokens: cacheRead,
82
88
  cacheWriteTokens: cacheWrite,
83
- reportedCostUsd: typeof d.cost === 'number' && d.cost >= 0 ? d.cost : 0,
89
+ reportedCostUsd,
84
90
  };
85
91
  }
86
92
  /** opencode provider ids that our own rate card can price exactly. */
@@ -132,21 +138,43 @@ function importOpencodeRows(store, opts) {
132
138
  // OpenCode supplies this number; Segreant records it as tool-reported rather
133
139
  // than calling it an invoice. Only a zero amount with an exact local match
134
140
  // is replaced by a local list-price calculation.
135
- let costUsd = ev.reportedCostUsd;
141
+ let costUsd;
136
142
  let estimated = false;
137
143
  let pricing = toolReportedPricingEvidence();
138
144
  let economicAmount;
139
- if (costUsd === 0 && REPRICEABLE[ev.provider]) {
140
- const c = computeCost(REPRICEABLE[ev.provider], ev.model, {
145
+ if (ev.reportedCostUsd === null) {
146
+ // No reported cost. The tokens are still real usage, so price them the
147
+ // way the proxy would: the list rate when the model is listed, otherwise
148
+ // a fallback marked estimated. A provider with no list price at all has
149
+ // nothing honest to price with, and the row stays out of the totals.
150
+ const provider = REPRICEABLE[ev.provider];
151
+ if (!provider)
152
+ continue;
153
+ const c = computeCost(provider, ev.model, {
141
154
  inputTokens: ev.inputTokens,
142
155
  outputTokens: ev.outputTokens,
143
156
  cacheWriteTokens: ev.cacheWriteTokens,
144
157
  cacheReadTokens: ev.cacheReadTokens,
145
158
  });
146
- if (!c.estimated) {
147
- costUsd = c.costUsd; // only adopt an EXACT match; never the fallback rate
148
- pricing = c.pricing;
149
- economicAmount = c.exact?.total;
159
+ costUsd = c.costUsd;
160
+ estimated = c.estimated;
161
+ pricing = c.pricing;
162
+ economicAmount = c.exact?.total;
163
+ }
164
+ else {
165
+ costUsd = ev.reportedCostUsd;
166
+ if (costUsd === 0 && REPRICEABLE[ev.provider]) {
167
+ const c = computeCost(REPRICEABLE[ev.provider], ev.model, {
168
+ inputTokens: ev.inputTokens,
169
+ outputTokens: ev.outputTokens,
170
+ cacheWriteTokens: ev.cacheWriteTokens,
171
+ cacheReadTokens: ev.cacheReadTokens,
172
+ });
173
+ if (!c.estimated) {
174
+ costUsd = c.costUsd; // only adopt an EXACT match; never the fallback rate
175
+ pricing = c.pricing;
176
+ economicAmount = c.exact?.total;
177
+ }
150
178
  }
151
179
  }
152
180
  const row = {
@@ -0,0 +1,126 @@
1
+ /**
2
+ * A minimal protobuf wire-format reader: enough to pull known fields out of a
3
+ * message whose schema is published, without a runtime dependency. It reads
4
+ * varints, length-delimited fields and fixed 32/64-bit fields, and stops at the
5
+ * first malformed byte rather than guessing.
6
+ */
7
+ /** All fields of one message, by field number, in wire order. Malformed input yields what parsed cleanly. */
8
+ export function pbFields(buf) {
9
+ const out = new Map();
10
+ let pos = 0;
11
+ const varint = () => {
12
+ let result = 0n;
13
+ let shift = 0n;
14
+ while (pos < buf.length) {
15
+ const b = buf[pos++];
16
+ result |= BigInt(b & 0x7f) << shift;
17
+ if ((b & 0x80) === 0)
18
+ return result;
19
+ shift += 7n;
20
+ if (shift > 70n)
21
+ return null;
22
+ }
23
+ return null;
24
+ };
25
+ while (pos < buf.length) {
26
+ const key = varint();
27
+ if (key === null)
28
+ break;
29
+ const field = Number(key >> 3n);
30
+ const wire = Number(key & 7n);
31
+ if (field <= 0)
32
+ break;
33
+ let v;
34
+ if (wire === 0) {
35
+ const n = varint();
36
+ if (n === null)
37
+ break;
38
+ v = { wire: 0, value: n };
39
+ }
40
+ else if (wire === 2) {
41
+ const len = varint();
42
+ if (len === null || pos + Number(len) > buf.length)
43
+ break;
44
+ v = { wire: 2, bytes: buf.subarray(pos, pos + Number(len)) };
45
+ pos += Number(len);
46
+ }
47
+ else if (wire === 1 || wire === 5) {
48
+ const size = wire === 1 ? 8 : 4;
49
+ if (pos + size > buf.length)
50
+ break;
51
+ v = { wire, bytes: buf.subarray(pos, pos + size) };
52
+ pos += size;
53
+ }
54
+ else {
55
+ break; // groups (3/4) are not used by the schemas read here
56
+ }
57
+ const list = out.get(field) ?? [];
58
+ list.push(v);
59
+ out.set(field, list);
60
+ }
61
+ return out;
62
+ }
63
+ /** The first varint of a field as a safe number (0 when absent or too large). */
64
+ export function pbUint(fields, field) {
65
+ const v = fields.get(field)?.find((x) => x.wire === 0);
66
+ if (v === undefined || v.wire !== 0)
67
+ return 0;
68
+ return v.value <= BigInt(Number.MAX_SAFE_INTEGER) ? Number(v.value) : 0;
69
+ }
70
+ /** The first length-delimited field's bytes, or null. */
71
+ export function pbBytes(fields, field) {
72
+ const v = fields.get(field)?.find((x) => x.wire === 2);
73
+ return v !== undefined && v.wire === 2 ? v.bytes : null;
74
+ }
75
+ /** The first length-delimited field as UTF-8 text, or null. */
76
+ export function pbString(fields, field) {
77
+ const b = pbBytes(fields, field);
78
+ return b === null ? null : Buffer.from(b).toString('utf8');
79
+ }
80
+ /** A nested message's fields, or null. */
81
+ export function pbMessage(fields, field) {
82
+ const b = pbBytes(fields, field);
83
+ return b === null ? null : pbFields(b);
84
+ }
85
+ /** Every varint of a field, including a packed repeated field. */
86
+ export function pbUints(fields, field) {
87
+ const out = [];
88
+ for (const v of fields.get(field) ?? []) {
89
+ if (v.wire === 0)
90
+ out.push(Number(v.value));
91
+ else if (v.wire === 2)
92
+ out.push(...packedVarints(v.bytes)); // packed: varints with no keys
93
+ }
94
+ return out;
95
+ }
96
+ function packedVarints(bytes) {
97
+ const out = [];
98
+ let pos = 0;
99
+ while (pos < bytes.length) {
100
+ let result = 0n;
101
+ let shift = 0n;
102
+ let done = false;
103
+ while (pos < bytes.length) {
104
+ const b = bytes[pos++];
105
+ result |= BigInt(b & 0x7f) << shift;
106
+ if ((b & 0x80) === 0) {
107
+ done = true;
108
+ break;
109
+ }
110
+ shift += 7n;
111
+ }
112
+ if (!done)
113
+ break;
114
+ out.push(Number(result));
115
+ }
116
+ return out;
117
+ }
118
+ /** A google.protobuf.Timestamp message as epoch milliseconds, or null. */
119
+ export function pbTimestampMs(ts) {
120
+ if (ts === null)
121
+ return null;
122
+ const seconds = pbUint(ts, 1);
123
+ if (seconds === 0)
124
+ return null;
125
+ return seconds * 1000 + Math.floor(pbUint(ts, 2) / 1_000_000);
126
+ }
@@ -0,0 +1,46 @@
1
+ /**
2
+ * The one list of native importers.
3
+ *
4
+ * The CLI and the dashboard each kept their own list. The CLI's gained
5
+ * Antigravity; the dashboard's did not, so "import all" from the dashboard
6
+ * silently skipped a tool the CLI read and `scan` reported (H008). Every
7
+ * surface now builds from this list: adding a tool is adding one entry here.
8
+ */
9
+ import { existsSync } from 'node:fs';
10
+ import { IMPORTERS } from './importShared.js';
11
+ import { importClaudeCode, defaultClaudeCodeRoot } from './claudeCode.js';
12
+ import { importOpencode, defaultOpencodeDbPath } from './opencode.js';
13
+ import { importCodex, defaultCodexRoot } from './codex.js';
14
+ import { importAntigravity, defaultAntigravityRoot } from './antigravity.js';
15
+ const info = (id) => {
16
+ const found = IMPORTERS.find((i) => i.id === id);
17
+ if (!found)
18
+ throw new Error(`importer ${id} has no description in importShared IMPORTERS`);
19
+ return { label: found.label, blurb: found.blurb };
20
+ };
21
+ export const IMPORT_REGISTRY = [
22
+ {
23
+ id: 'claude-code', ...info('claude-code'), aliases: ['claude-code', 'claudecode', 'claude'],
24
+ locate: () => (existsSync(defaultClaudeCodeRoot()) ? defaultClaudeCodeRoot() : null),
25
+ run: (store, opts) => importClaudeCode(store, opts), recentFirst: true,
26
+ },
27
+ {
28
+ id: 'opencode', ...info('opencode'), aliases: ['opencode'],
29
+ locate: () => defaultOpencodeDbPath(),
30
+ run: (store, opts) => importOpencode(store, opts), recentFirst: false,
31
+ },
32
+ {
33
+ id: 'codex', ...info('codex'), aliases: ['codex', 'codex-cli'],
34
+ locate: () => defaultCodexRoot(),
35
+ run: (store, opts) => importCodex(store, opts), recentFirst: true,
36
+ },
37
+ {
38
+ id: 'antigravity', ...info('antigravity'), aliases: ['antigravity', 'agy', 'gemini'],
39
+ locate: () => defaultAntigravityRoot(),
40
+ run: (store, opts) => importAntigravity(store, opts), recentFirst: true,
41
+ },
42
+ ];
43
+ export function importerFor(what) {
44
+ const w = what.toLowerCase();
45
+ return IMPORT_REGISTRY.find((e) => e.aliases.includes(w)) ?? null;
46
+ }
@@ -0,0 +1,112 @@
1
+ /**
2
+ * The git working tree a directory belongs to, found on disk the way git's own
3
+ * discovery finds it, without starting a process.
4
+ *
5
+ * Import attribution used to ask `git rev-parse --show-toplevel` once per
6
+ * distinct working directory. On Windows each spawn costs about 45 ms, so a
7
+ * corpus that touched 54 directories spent 2.6 s of a 5.3 s import waiting on
8
+ * git, in every importer, on every refresh. A CPU profile shows that wait as
9
+ * idle time, which is why it never stood out.
10
+ *
11
+ * Git walks up from the directory and stops at the first ancestor that holds
12
+ * either a `.git` directory it recognises as a repository or a `.git` file
13
+ * whose `gitdir:` line names an existing directory (a linked worktree or a
14
+ * submodule). This does the same, from the physical path, and returns the
15
+ * result in git's format: forward slashes on Windows, symlinks resolved, the
16
+ * on-disk letter case.
17
+ *
18
+ * Where this can differ from git, and why that is acceptable for a label:
19
+ * - git refuses a repository owned by another user (`safe.directory`); this
20
+ * still finds its root. The label is the repository's name either way.
21
+ * - `GIT_DIR`, `GIT_WORK_TREE`, `GIT_CEILING_DIRECTORIES` and `core.worktree`
22
+ * are not consulted. A tool's session directory does not set them.
23
+ * - a bare repository, or the inside of a `.git` folder, has no work tree:
24
+ * git and this both return null there.
25
+ * The basis recorded with the label stays an inference (see CONTEXT.md).
26
+ */
27
+ import { closeSync, openSync, readSync, realpathSync, statSync } from 'node:fs';
28
+ import { dirname, isAbsolute, join, resolve } from 'node:path';
29
+ /** The first line of a small pointer file (`.git`, `commondir`), or null. */
30
+ function firstLine(file) {
31
+ let fd = null;
32
+ try {
33
+ fd = openSync(file, 'r');
34
+ const buf = Buffer.alloc(4096);
35
+ const n = readSync(fd, buf, 0, buf.length, 0);
36
+ return (buf.toString('utf8', 0, n).split(/\r?\n/, 1)[0] ?? '').trim();
37
+ }
38
+ catch {
39
+ return null;
40
+ }
41
+ finally {
42
+ if (fd !== null)
43
+ closeSync(fd);
44
+ }
45
+ }
46
+ /** A path written inside a git pointer file is relative to that file's folder. */
47
+ function fromPointer(file, target) {
48
+ return isAbsolute(target) ? target : resolve(dirname(file), target);
49
+ }
50
+ /**
51
+ * git's own minimum for a directory to count as a repository: a HEAD, plus
52
+ * objects and refs. A linked worktree's git directory keeps those two in the
53
+ * main repository and names it in a `commondir` file.
54
+ */
55
+ function isGitDirectory(dir) {
56
+ try {
57
+ if (!statSync(join(dir, 'HEAD')).isFile())
58
+ return false;
59
+ const common = firstLine(join(dir, 'commondir'));
60
+ const shared = common ? fromPointer(join(dir, 'commondir'), common) : dir;
61
+ return statSync(join(shared, 'objects')).isDirectory() && statSync(join(shared, 'refs')).isDirectory();
62
+ }
63
+ catch {
64
+ return false;
65
+ }
66
+ }
67
+ /** A `.git` file names the real git directory: "gitdir: <path>". */
68
+ function gitFileTarget(file) {
69
+ const m = /^gitdir: (.+)$/.exec(firstLine(file) ?? '');
70
+ return m ? fromPointer(file, m[1].trim()) : null;
71
+ }
72
+ function gitFormat(path) {
73
+ return process.platform === 'win32' ? path.replace(/\\/g, '/') : path;
74
+ }
75
+ export function repoToplevelOnDisk(dir) {
76
+ let current;
77
+ try {
78
+ // .native keeps the on-disk case on Windows and macOS, as git reports it.
79
+ current = realpathSync.native(dir);
80
+ if (!statSync(current).isDirectory())
81
+ return null;
82
+ }
83
+ catch {
84
+ return null; // a deleted or unreadable directory: git fails here too
85
+ }
86
+ for (;;) {
87
+ const marker = join(current, '.git');
88
+ let kind = null;
89
+ try {
90
+ const st = statSync(marker);
91
+ kind = st.isDirectory() ? 'dir' : st.isFile() ? 'file' : null;
92
+ }
93
+ catch {
94
+ kind = null;
95
+ }
96
+ if (kind === 'dir' && isGitDirectory(marker))
97
+ return gitFormat(current);
98
+ if (kind === 'file') {
99
+ // A broken pointer is fatal to git; it does not keep looking further up.
100
+ const target = gitFileTarget(marker);
101
+ return target !== null && isGitDirectory(target) ? gitFormat(current) : null;
102
+ }
103
+ // Inside a git directory itself (a bare repository, or a `.git` folder):
104
+ // git finds the repository but there is no work tree to name.
105
+ if (isGitDirectory(current))
106
+ return null;
107
+ const parent = dirname(current);
108
+ if (parent === current)
109
+ return null;
110
+ current = parent;
111
+ }
112
+ }
@@ -0,0 +1,168 @@
1
+ /**
2
+ * How a total was priced, in parts — the one basis read model for every surface.
3
+ *
4
+ * The ledger records, per request, what kind of number its amount is (cost
5
+ * basis) and how its model was matched against the rate card. Totals used to
6
+ * throw that away: `today`, `week` and `month` called every dollar "priced from
7
+ * the rate card", so an amount opencode reported for a model the card does not
8
+ * know was printed as API list price, and a model missing from the card (priced
9
+ * at the generic fallback rate) sat inside a headline with nothing to say so.
10
+ *
11
+ * `summarizeBasis` keeps the parts. Every surface prints a total together with
12
+ * the cohorts that make it up, and the headline word follows the weakest part:
13
+ * a total is "list cost" only when every dollar in it was priced from a model's
14
+ * own rate on the card.
15
+ *
16
+ * Pure: it takes rows a store read already produced. No surface computes this
17
+ * itself, so the CLI and the dashboard cannot describe one total two ways.
18
+ */
19
+ /**
20
+ * The parts a total can be made of, strongest first. Exact and family matches
21
+ * share a cost basis in the ledger (`local_list_price`), but a family match
22
+ * prices one model at another's rate, so the two are kept apart here.
23
+ */
24
+ export const BASIS_COHORTS = [
25
+ 'list_exact',
26
+ 'list_family',
27
+ 'user_declared',
28
+ 'user_free',
29
+ 'fallback',
30
+ 'tool_reported',
31
+ 'demo',
32
+ 'unpriced',
33
+ 'not_sent',
34
+ 'unrecorded',
35
+ ];
36
+ export const BASIS_BOUNDARY = 'None of these amounts is a provider bill. List price is what the use would cost at the provider\'s published API rates; a plan, discount, credit or tax is not in it.';
37
+ const TEXT = {
38
+ list_exact: {
39
+ label: 'list price',
40
+ meaning: "priced at the model's own published API rate on the rate card",
41
+ },
42
+ list_family: {
43
+ label: 'list price, nearest model',
44
+ meaning: 'the model is not on the rate card by name; priced at the rate of the closest model that is',
45
+ },
46
+ user_declared: {
47
+ label: 'your price',
48
+ meaning: 'a price you set with segreant pricing set; Segreant has not checked it against a price list',
49
+ },
50
+ user_free: {
51
+ label: 'free, your setting',
52
+ meaning: 'a model you marked free with segreant pricing set --free (a local model); every request costs nothing',
53
+ },
54
+ fallback: {
55
+ label: 'fallback rate',
56
+ meaning: 'the model is not on the rate card; priced at a generic mid-tier rate, a rough estimate',
57
+ },
58
+ tool_reported: {
59
+ label: 'tool-reported',
60
+ meaning: 'the amount the tool itself recorded; Segreant has not verified it against any rate card or bill',
61
+ },
62
+ demo: {
63
+ label: 'sample data',
64
+ meaning: 'synthetic demo data, not measured on this machine',
65
+ },
66
+ unpriced: {
67
+ label: 'not priced',
68
+ meaning: 'no rate applies (for example a custom provider); counted in requests, not in dollars',
69
+ },
70
+ not_sent: {
71
+ label: 'refused, not sent',
72
+ meaning: 'stopped by Segreant before it reached the provider (a cap or an egress rule); nothing was charged',
73
+ },
74
+ unrecorded: {
75
+ label: 'basis not recorded',
76
+ meaning: 'recorded before Segreant kept how each amount was priced',
77
+ },
78
+ };
79
+ export function cohortOf(row) {
80
+ switch (row.costBasis) {
81
+ case 'local_list_price':
82
+ return row.rateMatchKind === 'exact_provider' || row.rateMatchKind === 'exact_cross_provider'
83
+ ? 'list_exact'
84
+ : 'list_family';
85
+ case 'fallback_estimate': return 'fallback';
86
+ case 'user_declared_price': return row.rateMatchKind === 'user_declared_free' ? 'user_free' : 'user_declared';
87
+ case 'tool_reported_unverified': return 'tool_reported';
88
+ case 'synthetic_demo': return 'demo';
89
+ case 'unpriced': return 'unpriced';
90
+ case 'not_sent': return 'not_sent';
91
+ default: return 'unrecorded';
92
+ }
93
+ }
94
+ export function summarizeBasis(rows) {
95
+ const by = new Map();
96
+ let totalUsd = 0;
97
+ let requests = 0;
98
+ for (const row of rows) {
99
+ if (row.requests <= 0)
100
+ continue;
101
+ const id = cohortOf(row);
102
+ let c = by.get(id);
103
+ if (!c) {
104
+ c = { id, costUsd: 0, requests: 0, ...TEXT[id], models: [] };
105
+ by.set(id, c);
106
+ }
107
+ c.costUsd += row.costUsd;
108
+ c.requests += row.requests;
109
+ const m = c.models.find((x) => x.provider === row.provider && x.model === row.model);
110
+ if (m) {
111
+ m.costUsd += row.costUsd;
112
+ m.requests += row.requests;
113
+ }
114
+ else {
115
+ c.models.push({ provider: row.provider, model: row.model, costUsd: row.costUsd, requests: row.requests });
116
+ }
117
+ totalUsd += row.costUsd;
118
+ requests += row.requests;
119
+ }
120
+ const cohorts = BASIS_COHORTS.flatMap((id) => {
121
+ const c = by.get(id);
122
+ if (!c)
123
+ return [];
124
+ c.models.sort((a, b) => b.costUsd - a.costUsd || b.requests - a.requests);
125
+ c.models = c.models.slice(0, 5);
126
+ return [c];
127
+ });
128
+ const has = (id) => by.has(id);
129
+ // Refused requests carry no money, so they never decide what the total is called.
130
+ const priced = cohorts.filter((c) => c.id !== 'not_sent');
131
+ const only = (...ids) => priced.length > 0 && priced.every((c) => ids.includes(c.id));
132
+ const headline = priced.length === 0
133
+ ? 'no_cost'
134
+ : only('demo')
135
+ ? 'sample_cost'
136
+ : only('tool_reported', 'unpriced') && has('tool_reported')
137
+ ? 'tool_reported_cost'
138
+ // A model marked free does not turn list prices into an estimate; any
139
+ // paid declaration does, whatever its amount, because the declaration
140
+ // itself is retained, not inferred from a zero (PR #146 review).
141
+ : only('list_exact', 'list_family', 'unpriced', 'user_free') && (has('list_exact') || has('list_family'))
142
+ ? 'list_cost'
143
+ : 'estimated_cost';
144
+ const exact = by.get('list_exact')?.costUsd ?? 0;
145
+ return {
146
+ totalUsd,
147
+ requests,
148
+ headline,
149
+ headlineLabel: HEADLINE_LABEL[headline],
150
+ cohorts,
151
+ exactShare: totalUsd > 0 ? exact / totalUsd : null,
152
+ boundary: BASIS_BOUNDARY,
153
+ };
154
+ }
155
+ const HEADLINE_LABEL = {
156
+ list_cost: 'List cost',
157
+ estimated_cost: 'Estimated cost',
158
+ sample_cost: 'Sample cost',
159
+ tool_reported_cost: 'Tool-reported cost',
160
+ no_cost: 'Cost',
161
+ };
162
+ /** "gpt-6.1-sol, codex-auto-review and 2 more" — the models a cohort holds, for one line of text. */
163
+ export function cohortModels(c, shown = 2) {
164
+ const names = c.models.map((m) => m.model);
165
+ if (names.length <= shown)
166
+ return names.join(', ');
167
+ return `${names.slice(0, shown).join(', ')} and ${names.length - shown} more`;
168
+ }