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.
- package/LICENSING.md +5 -3
- package/README.md +57 -19
- package/bin/segreant-statusline.mjs +20 -0
- package/bin/segreant.mjs +19 -8
- package/dist/alerts/detect.js +13 -0
- package/dist/billing/epistemic.js +4 -0
- package/dist/billing/importer.js +29 -10
- package/dist/billing/reconcile.js +122 -32
- package/dist/billing/scope.js +7 -0
- package/dist/budget/guard.js +33 -4
- package/dist/cli/allocCmd.js +73 -4
- package/dist/cli/askModelPrices.js +59 -0
- package/dist/cli/backupCmd.js +2 -1
- package/dist/cli/basisLines.js +45 -0
- package/dist/cli/billingCmd.js +68 -9
- package/dist/cli/capitalCmd.js +2 -1
- package/dist/cli/connectCmd.js +50 -9
- package/dist/cli/egressCmd.js +15 -3
- package/dist/cli/evidenceCmd.js +6 -3
- package/dist/cli/flags.js +68 -0
- package/dist/cli/homeCmd.js +117 -0
- package/dist/cli/importCmd.js +210 -54
- package/dist/cli/launchCmd.js +2 -1
- package/dist/cli/opsCmd.js +49 -2
- package/dist/cli/planCmd.js +181 -0
- package/dist/cli/pricingDeclareCmd.js +178 -0
- package/dist/cli/quotaCmd.js +190 -0
- package/dist/cli/runCmd.js +203 -51
- package/dist/cli/showCmd.js +37 -11
- package/dist/cli/teamCmd.js +5 -5
- package/dist/cli/ui.js +24 -8
- package/dist/cli/valueCmd.js +189 -14
- package/dist/cli.js +102 -21
- package/dist/config.js +95 -6
- package/dist/connect/antigravity.js +310 -0
- package/dist/connect/claudeCode.js +43 -3
- package/dist/connect/codex.js +38 -1
- package/dist/connect/importShared.js +58 -5
- package/dist/connect/opencode.js +37 -9
- package/dist/connect/protobuf.js +126 -0
- package/dist/connect/registry.js +46 -0
- package/dist/connect/repoRoot.js +112 -0
- package/dist/cost/basis.js +168 -0
- package/dist/cost/declarations.js +37 -0
- package/dist/cost/exactPricing.js +4 -1
- package/dist/cost/firstSight.js +98 -0
- package/dist/cost/overrides.js +230 -0
- package/dist/cost/pricing.js +228 -15
- package/dist/cost/reprice.js +130 -0
- package/dist/dashboard/claim-support.js +11 -8
- package/dist/dashboard/contracts.js +23 -3
- package/dist/dashboard/routes.js +980 -112
- package/dist/dashboard/sessions.js +345 -0
- package/dist/dashboard/settings.js +2 -1
- package/dist/dashboard/value-cache.js +105 -0
- package/dist/dashboard/web/app/components/claimInspector.js +6 -5
- package/dist/dashboard/web/app/components/drawer.js +105 -35
- package/dist/dashboard/web/app/components/ruleFields.js +144 -0
- package/dist/dashboard/web/app/components/spine.js +54 -24
- package/dist/dashboard/web/app/components/workflow.js +82 -0
- package/dist/dashboard/web/app/core/actions.js +734 -23
- package/dist/dashboard/web/app/core/api.js +37 -1
- package/dist/dashboard/web/app/core/chain.js +11 -4
- package/dist/dashboard/web/app/core/claimLayers.js +179 -51
- package/dist/dashboard/web/app/core/claimTypes.js +30 -0
- package/dist/dashboard/web/app/core/fmt.js +19 -2
- package/dist/dashboard/web/app/core/generated-contract.js +23 -3
- package/dist/dashboard/web/app/core/generated-payload-contract.js +1944 -465
- package/dist/dashboard/web/app/core/generated-types.js +1 -1
- package/dist/dashboard/web/app/core/griffin.js +10 -0
- package/dist/dashboard/web/app/core/jobs.js +12 -0
- package/dist/dashboard/web/app/core/period.js +35 -0
- package/dist/dashboard/web/app/core/periodWords.js +10 -0
- package/dist/dashboard/web/app/core/periods.js +32 -0
- package/dist/dashboard/web/app/core/pricingActions.js +264 -0
- package/dist/dashboard/web/app/core/privacyText.js +78 -0
- package/dist/dashboard/web/app/core/reconcileWords.js +63 -0
- package/dist/dashboard/web/app/core/registry.js +79 -46
- package/dist/dashboard/web/app/core/slug.js +9 -0
- package/dist/dashboard/web/app/core/theme.js +48 -0
- package/dist/dashboard/web/app/core/view.js +69 -0
- package/dist/dashboard/web/app/main.js +197 -15
- package/dist/dashboard/web/app/views/allocation.js +249 -61
- package/dist/dashboard/web/app/views/control.js +57 -16
- package/dist/dashboard/web/app/views/data.js +4 -4
- package/dist/dashboard/web/app/views/evidence.js +336 -87
- package/dist/dashboard/web/app/views/privacy.js +13 -0
- package/dist/dashboard/web/app/views/spend.js +30 -15
- package/dist/dashboard/web/app/views/statement.js +189 -0
- package/dist/dashboard/web/app/views/system.js +44 -4
- package/dist/dashboard/web/app/views/value.js +105 -59
- package/dist/dashboard/web/app/views/workbench.js +312 -0
- package/dist/dashboard/web/classic.html +16 -4
- package/dist/dashboard/web/index.html +1 -1
- package/dist/dashboard/web/styles/app.css +536 -23
- package/dist/demo/seed.js +153 -14
- package/dist/economics/ledger.js +1 -1
- package/dist/egress/policy.js +129 -5
- package/dist/export/allocationCsv.js +70 -0
- package/dist/export/billingCsv.js +6 -2
- package/dist/export/csv.js +10 -2
- package/dist/export/economic.js +6 -2
- package/dist/export/statementCsv.js +104 -0
- package/dist/git/correlate.js +86 -9
- package/dist/git/quality.js +107 -24
- package/dist/git/repoScope.js +207 -0
- package/dist/git/survivalStore.js +186 -0
- package/dist/guide.js +9 -4
- package/dist/plans/detect.js +147 -0
- package/dist/proxy/server.js +134 -14
- package/dist/proxy/usage.js +9 -4
- package/dist/quota/limits.js +138 -0
- package/dist/quota/statusline.js +68 -0
- package/dist/quota/view.js +122 -0
- package/dist/scan/knownApps.js +6 -2
- package/dist/scan/scan.js +9 -0
- package/dist/store/allocation.js +14 -0
- package/dist/store/db.js +259 -34
- package/dist/store/realization.js +78 -14
- package/dist/store/schema.js +59 -1
- package/dist/store/scopeEvidence.js +67 -0
- package/dist/util/pool.js +19 -0
- package/dist/util/time.js +7 -0
- package/dist/value/realization.js +321 -30
- package/dist/value/report.js +28 -8
- package/dist/value/selfReported.js +10 -9
- package/docs/ARCHITECTURE.md +8 -4
- package/docs/CAPABILITY-EVIDENCE-CONTRACT.md +33 -11
- package/docs/CLI-JSON-CONTRACT.md +38 -0
- package/docs/DATA-BOUNDARIES.md +66 -6
- package/docs/FAQ.md +43 -9
- package/docs/GETTING-STARTED.md +19 -12
- package/docs/GUIDE.md +81 -18
- package/docs/INTEGRATIONS.md +9 -9
- package/docs/METHODOLOGY.md +3 -2
- package/docs/NEUTRALITY.md +1 -1
- package/docs/PRIVACY.md +51 -0
- package/docs/README.md +1 -0
- package/docs/RELEASE-GATE.md +52 -7
- package/docs/THREAT-MODEL.md +2 -1
- package/package.json +3 -2
- 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 {
|
|
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
|
|
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
|
|
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}
|
|
262
|
+
const key = `${cwd}\0${fallback}`;
|
|
211
263
|
const hit = cache.get(key);
|
|
212
264
|
if (hit)
|
|
213
265
|
return hit;
|
|
214
|
-
const top =
|
|
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
|
];
|
package/dist/connect/opencode.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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
|
|
141
|
+
let costUsd;
|
|
136
142
|
let estimated = false;
|
|
137
143
|
let pricing = toolReportedPricingEvidence();
|
|
138
144
|
let economicAmount;
|
|
139
|
-
if (
|
|
140
|
-
|
|
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
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
+
}
|