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
|
@@ -0,0 +1,345 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Workbench read behind the Spend page: the range's metered requests,
|
|
3
|
+
* grouped by agent session, by time bucket and model, and joined to the
|
|
4
|
+
* commits each session reported making.
|
|
5
|
+
*
|
|
6
|
+
* Everything here is metered list cost. A session's link to a commit is the
|
|
7
|
+
* session's own log line (`observed_commits`), never a time-window guess, and a
|
|
8
|
+
* commit's outcome is the last value read filed by `keptBucketOf`, the rule the
|
|
9
|
+
* Statement uses. A session that made several commits splits its cost evenly
|
|
10
|
+
* across them, and the basis sentence says so.
|
|
11
|
+
*/
|
|
12
|
+
import { realizationFromStore, keptBucketOf } from '../value/realization.js';
|
|
13
|
+
import { isDemo } from '../config.js';
|
|
14
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
15
|
+
const HOUR_MS = 60 * 60 * 1000;
|
|
16
|
+
/** The most session rows one read returns; the rest are counted, not listed. */
|
|
17
|
+
export const SESSION_LIMIT = 200;
|
|
18
|
+
/** The most points one session's context profile carries. */
|
|
19
|
+
export const PROFILE_POINTS = 40;
|
|
20
|
+
/** The most named models in the chart; the rest are `other`. */
|
|
21
|
+
export const NAMED_MODELS = 5;
|
|
22
|
+
/** Past this many days the chart steps by week, so a year stays readable. */
|
|
23
|
+
const MAX_DAY_BUCKETS = 92;
|
|
24
|
+
/** Context sent with one request: uncached input plus cache reads and writes. */
|
|
25
|
+
export function contextTokens(r) {
|
|
26
|
+
return r.inputTokens + r.cacheReadTokens + r.cacheWriteTokens;
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* What a status code shows, and no more. 429 is a refusal by a cap, Segreant's
|
|
30
|
+
* own or the provider's (the alert counts call every 429 blocked); 5xx is a
|
|
31
|
+
* failure. The code alone does not say which side refused, so neither is named.
|
|
32
|
+
*/
|
|
33
|
+
export function replyKind(statusCode) {
|
|
34
|
+
if (statusCode === 429)
|
|
35
|
+
return 'refused';
|
|
36
|
+
if (statusCode !== null && statusCode >= 500)
|
|
37
|
+
return 'failed';
|
|
38
|
+
return null;
|
|
39
|
+
}
|
|
40
|
+
/** A request that reported tokens: the only kind that says how much context was sent. */
|
|
41
|
+
function measured(r) {
|
|
42
|
+
return contextTokens(r) + r.outputTokens > 0;
|
|
43
|
+
}
|
|
44
|
+
/**
|
|
45
|
+
* A reply that went through yet reported no tokens: its usage is unknown. This
|
|
46
|
+
* is not `estimated`, which says only that the PRICE came from a near match;
|
|
47
|
+
* such a request may carry complete token counts (Codex review 2026-10-16).
|
|
48
|
+
* A refused or failed reply with no tokens sent nothing measurable.
|
|
49
|
+
*/
|
|
50
|
+
function usageUnknown(r) {
|
|
51
|
+
return !measured(r) && (r.statusCode === null || r.statusCode < 400);
|
|
52
|
+
}
|
|
53
|
+
function localDay(ts) {
|
|
54
|
+
const d = new Date(ts);
|
|
55
|
+
d.setHours(0, 0, 0, 0);
|
|
56
|
+
return d.getTime();
|
|
57
|
+
}
|
|
58
|
+
function localHour(ts) {
|
|
59
|
+
const d = new Date(ts);
|
|
60
|
+
d.setMinutes(0, 0, 0);
|
|
61
|
+
return d.getTime();
|
|
62
|
+
}
|
|
63
|
+
function localWeek(ts) {
|
|
64
|
+
const d = new Date(localDay(ts));
|
|
65
|
+
d.setDate(d.getDate() - ((d.getDay() + 6) % 7)); // weeks start on Monday
|
|
66
|
+
return d.getTime();
|
|
67
|
+
}
|
|
68
|
+
function nextBucket(start, step) {
|
|
69
|
+
if (step === 'hour') {
|
|
70
|
+
const next = localHour(start + HOUR_MS);
|
|
71
|
+
return next > start ? next : start + HOUR_MS;
|
|
72
|
+
}
|
|
73
|
+
// From noon of the bucket's date, so a midnight that a DST change moved to
|
|
74
|
+
// 01:00 cannot carry 01:00 into every later bucket; each boundary is then
|
|
75
|
+
// taken afresh as that date's own local midnight (Codex review 2026-10-16).
|
|
76
|
+
const d = new Date(start);
|
|
77
|
+
const noon = new Date(d.getFullYear(), d.getMonth(), d.getDate() + (step === 'week' ? 7 : 1), 12).getTime();
|
|
78
|
+
return localDay(noon);
|
|
79
|
+
}
|
|
80
|
+
/** Fold a sequence to at most `points` values, each the largest in its slice. */
|
|
81
|
+
export function foldProfile(values, points = PROFILE_POINTS) {
|
|
82
|
+
if (values.length <= points)
|
|
83
|
+
return { profile: [...values], sliceOf: (i) => i };
|
|
84
|
+
const sliceOf = (i) => Math.min(points - 1, Math.floor((i * points) / values.length));
|
|
85
|
+
const profile = new Array(points).fill(0);
|
|
86
|
+
values.forEach((v, i) => { const s = sliceOf(i); if (v > profile[s])
|
|
87
|
+
profile[s] = v; });
|
|
88
|
+
return { profile, sliceOf };
|
|
89
|
+
}
|
|
90
|
+
function topBy(counts) {
|
|
91
|
+
let best;
|
|
92
|
+
let bestValue = -Infinity;
|
|
93
|
+
for (const [k, v] of counts)
|
|
94
|
+
if (v > bestValue) {
|
|
95
|
+
best = k;
|
|
96
|
+
bestValue = v;
|
|
97
|
+
}
|
|
98
|
+
return best;
|
|
99
|
+
}
|
|
100
|
+
function add(m, k, v) {
|
|
101
|
+
m.set(k, (m.get(k) ?? 0) + v);
|
|
102
|
+
}
|
|
103
|
+
export function buildSessions(store, range, window, opts = {}) {
|
|
104
|
+
const now = opts.now ?? Date.now();
|
|
105
|
+
const sessionLimit = opts.sessionLimit ?? SESSION_LIMIT;
|
|
106
|
+
const rows = store.requestsInRange(window.startMs, window.endMs);
|
|
107
|
+
// Models: the five that cost most in the range keep their names.
|
|
108
|
+
const modelCost = new Map();
|
|
109
|
+
for (const r of rows)
|
|
110
|
+
add(modelCost, r.model, r.costUsd);
|
|
111
|
+
const models = [...modelCost.entries()].sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0])).slice(0, NAMED_MODELS).map(([m]) => m);
|
|
112
|
+
const named = new Set(models);
|
|
113
|
+
// Buckets: hours for today, days otherwise, weeks for a long all-time range.
|
|
114
|
+
// A bounded range is drawn whole, empty stretches included; all-time starts at the first request.
|
|
115
|
+
const from = window.startMs > 0 ? window.startMs : Math.min(rows[0]?.tsEpochMs ?? now, now);
|
|
116
|
+
const step = window.bucketMs <= HOUR_MS
|
|
117
|
+
? 'hour'
|
|
118
|
+
: (now - from) / DAY_MS > MAX_DAY_BUCKETS ? 'week' : 'day';
|
|
119
|
+
const keyOf = step === 'hour' ? localHour : step === 'week' ? localWeek : localDay;
|
|
120
|
+
const buckets = [];
|
|
121
|
+
const bucketIndex = new Map();
|
|
122
|
+
for (let b = keyOf(from); b < window.endMs && b <= now; b = nextBucket(b, step)) {
|
|
123
|
+
bucketIndex.set(b, buckets.length);
|
|
124
|
+
buckets.push({ startMs: b, costUsd: 0, requests: 0, byModel: [], otherUsd: 0 });
|
|
125
|
+
}
|
|
126
|
+
const bucketModel = buckets.map(() => new Map());
|
|
127
|
+
const sessions = new Map();
|
|
128
|
+
const totals = {
|
|
129
|
+
requests: 0, costUsd: 0, inputTokens: 0, outputTokens: 0, cacheReadTokens: 0, cacheWriteTokens: 0,
|
|
130
|
+
sessions: 0, tools: 0, refusedReplies: 0, failedReplies: 0, unsessionedRequests: 0, unsessionedCostUsd: 0,
|
|
131
|
+
};
|
|
132
|
+
const tools = new Set();
|
|
133
|
+
// Spend is added once, as whole nanodollars, and every total below is read
|
|
134
|
+
// from these integers. Summing floats in different groupings let the
|
|
135
|
+
// outcome breakdown round to 1,440 µ$ against a 1,439 µ$ total (PR #135 review).
|
|
136
|
+
const nano = (usd) => Math.round(usd * 1e9);
|
|
137
|
+
let totalNano = 0;
|
|
138
|
+
let unsessionedNano = 0;
|
|
139
|
+
for (const r of rows) {
|
|
140
|
+
totals.requests += 1;
|
|
141
|
+
totalNano += nano(r.costUsd);
|
|
142
|
+
totals.inputTokens += r.inputTokens;
|
|
143
|
+
totals.outputTokens += r.outputTokens;
|
|
144
|
+
totals.cacheReadTokens += r.cacheReadTokens;
|
|
145
|
+
totals.cacheWriteTokens += r.cacheWriteTokens;
|
|
146
|
+
const kind = replyKind(r.statusCode);
|
|
147
|
+
if (kind === 'refused')
|
|
148
|
+
totals.refusedReplies += 1;
|
|
149
|
+
if (kind === 'failed')
|
|
150
|
+
totals.failedReplies += 1;
|
|
151
|
+
if (r.source)
|
|
152
|
+
tools.add(r.source);
|
|
153
|
+
const bi = bucketIndex.get(keyOf(r.tsEpochMs));
|
|
154
|
+
if (bi !== undefined) {
|
|
155
|
+
buckets[bi].costUsd += r.costUsd;
|
|
156
|
+
buckets[bi].requests += 1;
|
|
157
|
+
if (named.has(r.model))
|
|
158
|
+
add(bucketModel[bi], r.model, r.costUsd);
|
|
159
|
+
else
|
|
160
|
+
buckets[bi].otherUsd += r.costUsd;
|
|
161
|
+
}
|
|
162
|
+
if (!r.sessionId) {
|
|
163
|
+
totals.unsessionedRequests += 1;
|
|
164
|
+
unsessionedNano += nano(r.costUsd);
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
let acc = sessions.get(r.sessionId);
|
|
168
|
+
if (!acc) {
|
|
169
|
+
acc = { rows: [], source: new Map(), project: new Map(), model: new Map() };
|
|
170
|
+
sessions.set(r.sessionId, acc);
|
|
171
|
+
}
|
|
172
|
+
acc.rows.push(r);
|
|
173
|
+
add(acc.source, r.source ?? null, 1);
|
|
174
|
+
add(acc.project, r.projectCanonical ?? r.project, r.costUsd + 1e-12);
|
|
175
|
+
add(acc.model, r.model, r.costUsd + 1e-12);
|
|
176
|
+
}
|
|
177
|
+
totals.costUsd = totalNano / 1e9;
|
|
178
|
+
totals.unsessionedCostUsd = unsessionedNano / 1e9;
|
|
179
|
+
totals.sessions = sessions.size;
|
|
180
|
+
totals.tools = tools.size;
|
|
181
|
+
buckets.forEach((b, i) => {
|
|
182
|
+
b.byModel = models.filter((m) => bucketModel[i].has(m)).map((m) => ({ model: m, costUsd: bucketModel[i].get(m) }));
|
|
183
|
+
});
|
|
184
|
+
// Commits: each session's own log lines, joined to the last value read.
|
|
185
|
+
const report = realizationFromStore(store);
|
|
186
|
+
const units = report.units;
|
|
187
|
+
const observed = new Map();
|
|
188
|
+
const seenSha = new Map();
|
|
189
|
+
for (const o of store.observedCommits()) {
|
|
190
|
+
if (!sessions.has(o.sessionId))
|
|
191
|
+
continue;
|
|
192
|
+
const seen = seenSha.get(o.sessionId) ?? new Set();
|
|
193
|
+
if (seen.has(o.shortSha))
|
|
194
|
+
continue;
|
|
195
|
+
seen.add(o.shortSha);
|
|
196
|
+
seenSha.set(o.sessionId, seen);
|
|
197
|
+
const list = observed.get(o.sessionId) ?? [];
|
|
198
|
+
list.push(o);
|
|
199
|
+
observed.set(o.sessionId, list);
|
|
200
|
+
}
|
|
201
|
+
// Indexed once: scanning every unit for every commit stalled the process
|
|
202
|
+
// that also serves the proxy (Codex review 2026-10-16: 30 s at 10,000).
|
|
203
|
+
const byHash = new Map();
|
|
204
|
+
const byPrefix = new Map();
|
|
205
|
+
for (const u of units) {
|
|
206
|
+
const h = u.hash.toLowerCase();
|
|
207
|
+
byHash.set(h, u);
|
|
208
|
+
if (h.length >= 7) {
|
|
209
|
+
const list = byPrefix.get(h.slice(0, 7)) ?? [];
|
|
210
|
+
list.push(u);
|
|
211
|
+
byPrefix.set(h.slice(0, 7), list);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
const commitOf = (o) => {
|
|
215
|
+
// A log records an abbreviated sha. Only a hex abbreviation of seven or more
|
|
216
|
+
// characters may stand for a longer hash, and only when exactly one
|
|
217
|
+
// measured unit has it: with two, picking either would hand this session
|
|
218
|
+
// another repository's outcome, so the commit stays unmatched.
|
|
219
|
+
const sha = o.shortSha.toLowerCase();
|
|
220
|
+
const abbreviated = sha.length >= 7 && /^[0-9a-f]+$/.test(sha);
|
|
221
|
+
const exact = byHash.get(sha);
|
|
222
|
+
const candidates = exact || !abbreviated ? [] : (byPrefix.get(sha.slice(0, 7)) ?? []).filter((u) => u.hash.toLowerCase().startsWith(sha));
|
|
223
|
+
const unit = exact ?? (candidates.length === 1 ? candidates[0] : undefined);
|
|
224
|
+
const outcome = unit ? keptBucketOf(unit) : 'unmeasured';
|
|
225
|
+
const checkMs = unit && outcome === 'maturing' ? unit.tsEpochMs + (unit.checkWindowDays ?? report.windowDays) * DAY_MS : null;
|
|
226
|
+
return { sha: o.shortSha, subject: o.subject, tsEpochMs: o.tsEpochMs, outcome, checkMs };
|
|
227
|
+
};
|
|
228
|
+
const share = () => ({ costUsd: 0, commits: 0 });
|
|
229
|
+
const outcomes = {
|
|
230
|
+
kept: share(), notKept: share(), contested: share(), unknown: share(), maturing: share(), unmeasured: share(),
|
|
231
|
+
noCommit: { costUsd: 0, sessions: 0 },
|
|
232
|
+
basis: '',
|
|
233
|
+
measuredAt: units.length > 0 ? report.generatedAt : null,
|
|
234
|
+
};
|
|
235
|
+
const outcomeMicros = { kept: 0, notKept: 0, contested: 0, unknown: 0, maturing: 0, unmeasured: 0 };
|
|
236
|
+
let noCommitMicros = 0;
|
|
237
|
+
const built = [];
|
|
238
|
+
for (const [sessionId, acc] of sessions) {
|
|
239
|
+
const context = [];
|
|
240
|
+
const rawMarks = [];
|
|
241
|
+
for (const r of acc.rows) {
|
|
242
|
+
if (measured(r))
|
|
243
|
+
context.push(contextTokens(r));
|
|
244
|
+
const kind = replyKind(r.statusCode);
|
|
245
|
+
if (kind)
|
|
246
|
+
rawMarks.push({ at: Math.max(0, context.length - 1), kind });
|
|
247
|
+
}
|
|
248
|
+
const { profile, sliceOf } = foldProfile(context);
|
|
249
|
+
const seen = new Set();
|
|
250
|
+
const marks = rawMarks
|
|
251
|
+
.map((m) => ({ at: sliceOf(m.at), kind: m.kind }))
|
|
252
|
+
.filter((m) => { const k = `${m.at}:${m.kind}`; if (seen.has(k))
|
|
253
|
+
return false; seen.add(k); return true; });
|
|
254
|
+
const commits = (observed.get(sessionId) ?? []).sort((a, b) => a.tsEpochMs - b.tsEpochMs).map(commitOf);
|
|
255
|
+
const costNano = acc.rows.reduce((s, r) => s + nano(r.costUsd), 0);
|
|
256
|
+
const cost = costNano / 1e9;
|
|
257
|
+
// Exact (fractional) microdollars here; each category is rounded once, at
|
|
258
|
+
// the end, against the rounded total. Rounding every session first let a
|
|
259
|
+
// thousand $0.0014008 sessions add up to $1.401 of outcomes for $1.4008 of
|
|
260
|
+
// spend (review 4, F1).
|
|
261
|
+
if (commits.length === 0) {
|
|
262
|
+
noCommitMicros += costNano / 1000;
|
|
263
|
+
outcomes.noCommit.sessions += 1;
|
|
264
|
+
}
|
|
265
|
+
else {
|
|
266
|
+
const share = costNano / 1000 / commits.length;
|
|
267
|
+
for (const c of commits) {
|
|
268
|
+
outcomeMicros[c.outcome] += share;
|
|
269
|
+
outcomes[c.outcome].commits += 1;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
built.push({
|
|
273
|
+
sessionId,
|
|
274
|
+
startMs: acc.rows[0].tsEpochMs,
|
|
275
|
+
endMs: acc.rows[acc.rows.length - 1].tsEpochMs,
|
|
276
|
+
source: topBy(acc.source) ?? null,
|
|
277
|
+
project: topBy(acc.project) ?? '',
|
|
278
|
+
model: topBy(acc.model) ?? '',
|
|
279
|
+
models: acc.model.size,
|
|
280
|
+
requests: acc.rows.length,
|
|
281
|
+
costUsd: cost,
|
|
282
|
+
inputTokens: acc.rows.reduce((s, r) => s + r.inputTokens, 0),
|
|
283
|
+
outputTokens: acc.rows.reduce((s, r) => s + r.outputTokens, 0),
|
|
284
|
+
cacheReadTokens: acc.rows.reduce((s, r) => s + r.cacheReadTokens, 0),
|
|
285
|
+
cacheWriteTokens: acc.rows.reduce((s, r) => s + r.cacheWriteTokens, 0),
|
|
286
|
+
contextStartTokens: context[0] ?? 0,
|
|
287
|
+
contextEndTokens: context[context.length - 1] ?? 0,
|
|
288
|
+
contextPeakTokens: context.reduce((m, v) => Math.max(m, v), 0),
|
|
289
|
+
refusedReplies: acc.rows.filter((r) => replyKind(r.statusCode) === 'refused').length,
|
|
290
|
+
failedReplies: acc.rows.filter((r) => replyKind(r.statusCode) === 'failed').length,
|
|
291
|
+
usageUnknownRequests: acc.rows.filter(usageUnknown).length,
|
|
292
|
+
profile: context.length > 0 ? profile : [],
|
|
293
|
+
marks,
|
|
294
|
+
commits,
|
|
295
|
+
});
|
|
296
|
+
}
|
|
297
|
+
built.sort((a, b) => b.startMs - a.startMs || a.sessionId.localeCompare(b.sessionId));
|
|
298
|
+
// Largest remainder: whole microdollars per category that add up to the
|
|
299
|
+
// rounded sum of the exact parts, so the breakdown partitions the spend.
|
|
300
|
+
const parts = [
|
|
301
|
+
...Object.keys(outcomeMicros).map((k) => [k, outcomeMicros[k]]),
|
|
302
|
+
['noCommit', noCommitMicros],
|
|
303
|
+
];
|
|
304
|
+
const whole = parts.map(([k, v]) => ({ k, floor: Math.floor(v), rem: v - Math.floor(v) }));
|
|
305
|
+
// The target is the sessioned spend, rounded once from the exact integer sum.
|
|
306
|
+
let left = Math.round((totalNano - unsessionedNano) / 1000) - whole.reduce((s, w) => s + w.floor, 0);
|
|
307
|
+
for (const w of [...whole].sort((a, b) => b.rem - a.rem)) {
|
|
308
|
+
if (left <= 0)
|
|
309
|
+
break;
|
|
310
|
+
w.floor += 1;
|
|
311
|
+
left -= 1;
|
|
312
|
+
}
|
|
313
|
+
// Float error in the fractional parts can leave the floors a microdollar
|
|
314
|
+
// over the target; take it back from the smallest remainders.
|
|
315
|
+
for (const w of [...whole].sort((a, b) => a.rem - b.rem)) {
|
|
316
|
+
if (left >= 0)
|
|
317
|
+
break;
|
|
318
|
+
if (w.floor > 0) {
|
|
319
|
+
w.floor -= 1;
|
|
320
|
+
left += 1;
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
for (const w of whole) {
|
|
324
|
+
if (w.k === 'noCommit')
|
|
325
|
+
outcomes.noCommit.costUsd = w.floor / 1e6;
|
|
326
|
+
else
|
|
327
|
+
outcomes[w.k].costUsd = w.floor / 1e6;
|
|
328
|
+
}
|
|
329
|
+
outcomes.basis = 'Metered list cost of the sessions in this range. A session is linked to a commit only when its own log recorded making it;'
|
|
330
|
+
+ ' a session that made several commits splits its cost evenly across them. Each commit is filed by the last value read'
|
|
331
|
+
+ (outcomes.measuredAt === null ? ', and none has run on this machine yet, so every linked commit is unmeasured.' : ', by the same rule as the Statement.');
|
|
332
|
+
return {
|
|
333
|
+
demo: isDemo(),
|
|
334
|
+
generatedAt: new Date(now).toISOString(),
|
|
335
|
+
range,
|
|
336
|
+
window: { startMs: window.startMs, endMs: window.endMs, bucketMs: step === 'hour' ? HOUR_MS : step === 'week' ? 7 * DAY_MS : DAY_MS },
|
|
337
|
+
totals,
|
|
338
|
+
models,
|
|
339
|
+
buckets,
|
|
340
|
+
sessions: built.slice(0, sessionLimit),
|
|
341
|
+
sessionLimit,
|
|
342
|
+
sessionsOmitted: Math.max(0, built.length - sessionLimit),
|
|
343
|
+
outcomes,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* Kept separate from server.ts (already large) and framework-free so it's unit-testable
|
|
5
5
|
* without spinning up an HTTP server.
|
|
6
6
|
*/
|
|
7
|
-
import { segreantHome, configPath, dbPath, validateBudgetConfig, FEATURE_DEFAULTS } from '../config.js';
|
|
7
|
+
import { segreantHome, configPath, dbPath, validateBudgetConfig, FEATURE_DEFAULTS, isDemo } from '../config.js';
|
|
8
8
|
import { describeBudgetEnforcement } from '../budget/enforceability.js';
|
|
9
9
|
import { egressReceiptPath, verifyEgressReceipts } from '../egress/receipts.js';
|
|
10
10
|
export function buildSettingsSnapshot(store, config, version, windowDays = 14) {
|
|
@@ -19,6 +19,7 @@ export function buildSettingsSnapshot(store, config, version, windowDays = 14) {
|
|
|
19
19
|
retentionDays: config.retentionDays,
|
|
20
20
|
proposalRetentionDays: config.proposalRetentionDays,
|
|
21
21
|
metadataOnly: config.metadataOnly,
|
|
22
|
+
demo: isDemo(),
|
|
22
23
|
features: { ...config.features },
|
|
23
24
|
budget: config.budget,
|
|
24
25
|
enforcement: describeBudgetEnforcement(config.budget),
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* In-memory cache for the `/api/value` payload.
|
|
3
|
+
*
|
|
4
|
+
* Why it exists: with no stored realization (the user has not run `scan`), the
|
|
5
|
+
* value report re-runs git realization for the start folder on every load —
|
|
6
|
+
* 24.7 s, then 13.2 s again, with nothing changed. This cache answers repeat
|
|
7
|
+
* loads from memory. It never writes the realization to the ledger: dashboard
|
|
8
|
+
* reads are read-only by default, and persisting is an explicit `--apply`.
|
|
9
|
+
*
|
|
10
|
+
* Correctness rules:
|
|
11
|
+
* - The key covers every input the report reads: the query string, the repo
|
|
12
|
+
* path and its HEAD commit, a hash of the whole config, and change marks on
|
|
13
|
+
* each ledger table the report depends on (max rowid plus exact row count).
|
|
14
|
+
* An append, a price change, a gate signal, a stored realization or a config
|
|
15
|
+
* edit therefore misses the cache.
|
|
16
|
+
* - Entries live at most `ttlMs` from the start of their computation, whatever
|
|
17
|
+
* the key, because realization units mature with wall-clock time.
|
|
18
|
+
* - The payload is cached whole, so its own `generatedAt` keeps the ORIGINAL
|
|
19
|
+
* compute time; nothing rewrites it on a hit.
|
|
20
|
+
* - Concurrent identical requests share one in-flight computation.
|
|
21
|
+
* - Bounded: at most `maxEntries` payloads, least recently used evicted first.
|
|
22
|
+
*
|
|
23
|
+
* A cache is owned per Store (see `valueReadCacheFor` in routes.ts), so a fresh
|
|
24
|
+
* Store, as in each test, starts with an empty cache.
|
|
25
|
+
*/
|
|
26
|
+
import { createHash } from 'node:crypto';
|
|
27
|
+
export const VALUE_CACHE_MAX_ENTRIES = 8;
|
|
28
|
+
export const VALUE_CACHE_TTL_MS = 10 * 60_000;
|
|
29
|
+
export class ValueReadCache {
|
|
30
|
+
stats = { computes: 0, hits: 0, shared: 0 };
|
|
31
|
+
entries = new Map();
|
|
32
|
+
inflight = new Map();
|
|
33
|
+
maxEntries;
|
|
34
|
+
ttlMs;
|
|
35
|
+
now;
|
|
36
|
+
constructor(opts = {}) {
|
|
37
|
+
this.maxEntries = Math.max(1, Math.floor(opts.maxEntries ?? VALUE_CACHE_MAX_ENTRIES));
|
|
38
|
+
this.ttlMs = opts.ttlMs ?? VALUE_CACHE_TTL_MS;
|
|
39
|
+
this.now = opts.now ?? Date.now;
|
|
40
|
+
}
|
|
41
|
+
/** Return the stored value for `key`, or run `compute` once and store its result. */
|
|
42
|
+
get(key, compute) {
|
|
43
|
+
const nowMs = this.now();
|
|
44
|
+
const stored = this.entries.get(key);
|
|
45
|
+
if (stored !== undefined) {
|
|
46
|
+
const age = nowMs - stored.startedAtMs;
|
|
47
|
+
if (age >= 0 && age < this.ttlMs) {
|
|
48
|
+
// Re-insert to mark it most recently used.
|
|
49
|
+
this.entries.delete(key);
|
|
50
|
+
this.entries.set(key, stored);
|
|
51
|
+
this.stats.hits += 1;
|
|
52
|
+
return Promise.resolve(stored.value);
|
|
53
|
+
}
|
|
54
|
+
this.entries.delete(key);
|
|
55
|
+
}
|
|
56
|
+
const running = this.inflight.get(key);
|
|
57
|
+
if (running !== undefined) {
|
|
58
|
+
this.stats.shared += 1;
|
|
59
|
+
return running;
|
|
60
|
+
}
|
|
61
|
+
this.stats.computes += 1;
|
|
62
|
+
const started = Promise.resolve()
|
|
63
|
+
.then(compute)
|
|
64
|
+
.then((value) => {
|
|
65
|
+
this.inflight.delete(key);
|
|
66
|
+
this.store(key, value, nowMs);
|
|
67
|
+
return value;
|
|
68
|
+
}, (err) => {
|
|
69
|
+
this.inflight.delete(key);
|
|
70
|
+
throw err;
|
|
71
|
+
});
|
|
72
|
+
this.inflight.set(key, started);
|
|
73
|
+
return started;
|
|
74
|
+
}
|
|
75
|
+
store(key, value, startedAtMs) {
|
|
76
|
+
this.entries.delete(key);
|
|
77
|
+
this.entries.set(key, { value, startedAtMs });
|
|
78
|
+
while (this.entries.size > this.maxEntries) {
|
|
79
|
+
const oldest = this.entries.keys().next().value;
|
|
80
|
+
if (oldest === undefined)
|
|
81
|
+
break;
|
|
82
|
+
this.entries.delete(oldest);
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
/** Number of stored entries (expired ones included until next touched). */
|
|
86
|
+
get size() {
|
|
87
|
+
return this.entries.size;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
export function valueCacheKey(input) {
|
|
91
|
+
const params = [...input.params.entries()].sort(([ak, av], [bk, bv]) => {
|
|
92
|
+
if (ak !== bk)
|
|
93
|
+
return ak < bk ? -1 : 1;
|
|
94
|
+
if (av === bv)
|
|
95
|
+
return 0;
|
|
96
|
+
return av < bv ? -1 : 1;
|
|
97
|
+
});
|
|
98
|
+
return JSON.stringify({
|
|
99
|
+
params,
|
|
100
|
+
repo: input.repo,
|
|
101
|
+
head: input.head ?? 'no-git',
|
|
102
|
+
config: createHash('sha256').update(JSON.stringify(input.config) ?? 'null').digest('hex'),
|
|
103
|
+
marks: input.marks,
|
|
104
|
+
});
|
|
105
|
+
}
|
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
import { h, render, captureFocus, restoreFocus, trapFocus } from '../core/dom.js';
|
|
25
25
|
import { signal, effect, onCleanup } from '../core/signal.js';
|
|
26
26
|
import { usd, isPrecise } from '../core/fmt.js';
|
|
27
|
-
import { claimIsSupported, claimProfileRows, claimShowsFigure, } from '../core/claimTypes.js';
|
|
27
|
+
import { claimIsSupported, claimProfileRows, claimShowsFigure, claimCompletionLine, claimStrongerClaimLine, } from '../core/claimTypes.js';
|
|
28
28
|
const active = signal(null);
|
|
29
29
|
let opener = null;
|
|
30
30
|
export function openClaimInspector(layer) {
|
|
@@ -92,6 +92,7 @@ function figureText(layer) {
|
|
|
92
92
|
function panel(layer) {
|
|
93
93
|
const i = layer.inspection;
|
|
94
94
|
const scrim = h('div', { class: 'claim-scrim', onclick: () => closeClaimInspector() });
|
|
95
|
+
const stronger = claimStrongerClaimLine(layer);
|
|
95
96
|
const body = h('aside', {
|
|
96
97
|
class: `claim-panel${claimIsSupported(layer) ? '' : ' claim-panel-open'}`,
|
|
97
98
|
role: 'dialog',
|
|
@@ -107,10 +108,10 @@ function panel(layer) {
|
|
|
107
108
|
text: figureText(layer) }), h('p', { class: 'claim-basis', text: layer.basis }), profileCard(layer), h('dl', { class: 'claim-rows' }, row(isPrecise() ? 'Provenance' : 'Where it came from', i.provenance), row(isPrecise() ? 'Scope' : 'What it covers', i.scope), row(isPrecise() ? 'Freshness' : 'When it was computed', i.freshness), row(isPrecise() ? 'Coverage' : 'How much it reaches', i.coverage), row(isPrecise() ? 'Enforceability' : 'What it can and cannot do', i.enforceability), row(isPrecise() ? 'Evidence source' : 'Where to check it', i.evidenceSource)), i.assumptions.length
|
|
108
109
|
? list(isPrecise() ? 'Assumptions and conditions' : 'Taken on trust', i.assumptions, 'claim-assumed')
|
|
109
110
|
: null, i.missingEvidence.length
|
|
110
|
-
? list(isPrecise() ? 'Missing evidence' : '
|
|
111
|
-
: h('p', { class: 'claim-complete', text: isPrecise()
|
|
112
|
-
|
|
113
|
-
|
|
111
|
+
? list(isPrecise() ? 'Missing evidence' : "What's missing", i.missingEvidence, 'claim-missing')
|
|
112
|
+
: h('p', { class: 'claim-complete', text: claimCompletionLine(layer, isPrecise()) }), stronger
|
|
113
|
+
? h('p', { class: 'claim-basis', text: stronger })
|
|
114
|
+
: null, layer.nextStep
|
|
114
115
|
? h('p', { class: 'claim-next', text: `Next action: ${layer.nextStep}` })
|
|
115
116
|
: null);
|
|
116
117
|
const release = trapFocus(body);
|