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
package/dist/proxy/server.js
CHANGED
|
@@ -21,13 +21,17 @@
|
|
|
21
21
|
* history refuses the outbound request before DNS/dial.
|
|
22
22
|
*/
|
|
23
23
|
import http from 'node:http';
|
|
24
|
+
import { statSync } from 'node:fs';
|
|
24
25
|
import { randomUUID } from 'node:crypto';
|
|
26
|
+
import { configPath, loadConfig } from '../config.js';
|
|
25
27
|
import { BudgetGuard, unverifiedBasis } from '../budget/guard.js';
|
|
26
|
-
import { computeCost, unpricedPricingEvidence } from '../cost/pricing.js';
|
|
28
|
+
import { computeCost, withDeclarations, unpricedPricingEvidence, notSentPricingEvidence } from '../cost/pricing.js';
|
|
29
|
+
import { activePriceOverrides, PriceOverridesError } from '../cost/overrides.js';
|
|
27
30
|
import { StreamUsageAccumulator, normalizeAnthropicUsage, normalizeOpenAIUsage, emptyUsage, } from './usage.js';
|
|
28
31
|
import { extractProposalsWithCoverage } from '../value/proposals.js';
|
|
29
32
|
import { StreamProposalAccumulator } from './stream-proposals.js';
|
|
30
33
|
import { EgressError, egressFetchWithConfig } from '../egress/transport.js';
|
|
34
|
+
import { providerRuleCommand } from '../egress/policy.js';
|
|
31
35
|
import { readBoundedResponseText, RESOURCE_LIMITS, ResourceLimitError } from '../util/resource-limits.js';
|
|
32
36
|
const HOP_BY_HOP = new Set([
|
|
33
37
|
'connection',
|
|
@@ -231,10 +235,21 @@ function providerBudgetUnavailableBody(provider, message) {
|
|
|
231
235
|
}
|
|
232
236
|
return JSON.stringify({ error: { message, type: 'segreant_budget_unavailable', code: 'budget_enforcement_unavailable' } });
|
|
233
237
|
}
|
|
234
|
-
function providerEgressRefusalBody(provider, error) {
|
|
238
|
+
export function providerEgressRefusalBody(provider, error, targetUrl, method, egressMode) {
|
|
239
|
+
// Only the providers' official APIs are built in; a gateway or a self-hosted
|
|
240
|
+
// model is allowed by name, with the one command the refusal prints. A
|
|
241
|
+
// malformed egress section refuses everything, built-ins included, and its
|
|
242
|
+
// repair is the file, not a rule: printing a rule there called
|
|
243
|
+
// api.openai.com "not a built-in provider API" (independent review, 2026-10-18).
|
|
244
|
+
const invalid = egressMode === 'invalid';
|
|
245
|
+
const allow = error.code === 'policy_denied' && !invalid ? providerRuleCommand(targetUrl, method) : null;
|
|
235
246
|
const repair = error.code === 'receipt_integrity_failed' || error.code === 'receipt_persistence_failed'
|
|
236
247
|
? ' Repair or restore the local receipt history before retrying.'
|
|
237
|
-
:
|
|
248
|
+
: invalid
|
|
249
|
+
? ' The egress section of config.json is malformed, so every cloud request is refused. Fix it by hand, or reset it with: segreant egress apply --apply --mode local_locked'
|
|
250
|
+
: allow
|
|
251
|
+
? ` ${new URL(targetUrl).origin} is not one of the built-in provider APIs. To send your requests there, run: ${allow}`
|
|
252
|
+
: '';
|
|
238
253
|
const message = `Segreant refused this outbound request at its egress boundary: ${error.message}.${repair}`;
|
|
239
254
|
if (provider === 'anthropic') {
|
|
240
255
|
return JSON.stringify({ type: 'error', error: { type: 'segreant_egress_refusal', code: 'egress_refused', subcode: error.code, message } });
|
|
@@ -266,12 +281,94 @@ function waitForDrainOrClose(res) {
|
|
|
266
281
|
res.once('close', onClose);
|
|
267
282
|
});
|
|
268
283
|
}
|
|
284
|
+
/**
|
|
285
|
+
* The budget the running proxy enforces, kept in step with config.json.
|
|
286
|
+
*
|
|
287
|
+
* Dashboard Settings mutates the shared config object in place, but
|
|
288
|
+
* `segreant budget --daily N` and a hand edit write the FILE from another
|
|
289
|
+
* process. Before this, a cap set from the terminal was printed as "Budget
|
|
290
|
+
* updated" and not enforced until a restart (verified 2026-10-17: two requests
|
|
291
|
+
* over a $0.05 cap went through), and an invalid budget written while running
|
|
292
|
+
* kept forwarding. The file is checked at most every `intervalMs`; a changed
|
|
293
|
+
* file is re-read and validated, and only its budget is taken (ports and
|
|
294
|
+
* upstreams still need a restart). An unreadable or invalid file THROWS, which
|
|
295
|
+
* the pre-flight check turns into a fail-closed 503, and is retried at the next
|
|
296
|
+
* check rather than cached, so a half-written file does not stick. A missing
|
|
297
|
+
* file keeps the budget in force: deleting config.json never lifts a cap.
|
|
298
|
+
*/
|
|
299
|
+
/**
|
|
300
|
+
* The persisted budget could not be read or validated. Requests fail closed
|
|
301
|
+
* while it lasts, but unlike a ledger failure it is not latched: repairing
|
|
302
|
+
* config.json resumes enforcement without a restart.
|
|
303
|
+
*/
|
|
304
|
+
export class BudgetConfigUnavailable extends Error {
|
|
305
|
+
constructor(cause) {
|
|
306
|
+
super(`budget configuration unavailable: ${cause instanceof Error ? cause.message : String(cause)}`, { cause });
|
|
307
|
+
this.name = 'BudgetConfigUnavailable';
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
export function liveBudget(config, opts = {}) {
|
|
311
|
+
const intervalMs = opts.intervalMs ?? 500;
|
|
312
|
+
const path = opts.path ?? configPath;
|
|
313
|
+
const load = opts.load ?? loadConfig;
|
|
314
|
+
let checkedAt = -Infinity;
|
|
315
|
+
// No mtime is recorded up front: the file may have changed between the
|
|
316
|
+
// caller's loadConfig() and this point (start awaits a pricing refresh), so
|
|
317
|
+
// the first check always re-reads what is on disk now (PR #133 review).
|
|
318
|
+
let seenMtimeMs = null;
|
|
319
|
+
let failure = null;
|
|
320
|
+
return () => {
|
|
321
|
+
const now = Date.now();
|
|
322
|
+
if (now - checkedAt >= intervalMs) {
|
|
323
|
+
checkedAt = now;
|
|
324
|
+
let mtimeMs = null;
|
|
325
|
+
try {
|
|
326
|
+
mtimeMs = statSync(path()).mtimeMs;
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
// Only a missing file is a deletion, which keeps the cap in force.
|
|
330
|
+
// Any other stat failure (permissions, I/O) means the persisted
|
|
331
|
+
// budget cannot be verified, and that fails closed.
|
|
332
|
+
if (error?.code !== 'ENOENT')
|
|
333
|
+
failure = new BudgetConfigUnavailable(error);
|
|
334
|
+
else {
|
|
335
|
+
// Deleting the file is the documented way back to defaults, so the
|
|
336
|
+
// egress policy returns to its default too: a gateway allowed by a
|
|
337
|
+
// rule in the deleted file is refused on the next request, not at
|
|
338
|
+
// the next restart (PR #148 review). The built-in provider routes
|
|
339
|
+
// hold in the default. A file written again is re-read in full.
|
|
340
|
+
config.egress = { mode: 'local_locked', rules: [] };
|
|
341
|
+
seenMtimeMs = null;
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
if (mtimeMs !== null && (mtimeMs !== seenMtimeMs || failure !== null)) {
|
|
345
|
+
try {
|
|
346
|
+
const fresh = load();
|
|
347
|
+
config.budget = fresh.budget;
|
|
348
|
+
// The egress rules follow the file the same way: a rule added with
|
|
349
|
+
// `segreant egress apply` (the command a refusal prints) governs the
|
|
350
|
+
// next request, with no restart, and so does a tightening (PR #148
|
|
351
|
+
// review). An invalid file throws above and fails closed.
|
|
352
|
+
config.egress = fresh.egress;
|
|
353
|
+
seenMtimeMs = mtimeMs;
|
|
354
|
+
failure = null;
|
|
355
|
+
}
|
|
356
|
+
catch (error) {
|
|
357
|
+
failure = new BudgetConfigUnavailable(error);
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
if (failure !== null)
|
|
362
|
+
throw failure;
|
|
363
|
+
return config.budget;
|
|
364
|
+
};
|
|
365
|
+
}
|
|
269
366
|
export function createProxyServer(deps) {
|
|
270
367
|
const { store, config } = deps;
|
|
271
|
-
// Dashboard Settings mutates the shared config object after persisting it.
|
|
272
368
|
// Resolve budget at each pre-flight check so a newly chosen cap actually
|
|
273
|
-
// governs the already-running proxy,
|
|
274
|
-
|
|
369
|
+
// governs the already-running proxy, whether it was saved from Settings (the
|
|
370
|
+
// shared object) or from the CLI or an editor (the file; see liveBudget).
|
|
371
|
+
const guard = new BudgetGuard(store, deps.budget ?? liveBudget(config));
|
|
275
372
|
const state = { accountingFailure: false };
|
|
276
373
|
const server = http.createServer((req, res) => {
|
|
277
374
|
handle(req, res, deps, guard, state).catch((err) => {
|
|
@@ -396,12 +493,25 @@ async function handle(req, res, deps, guard, state) {
|
|
|
396
493
|
// --- Budget pre-flight ---
|
|
397
494
|
let decision;
|
|
398
495
|
let budgetFailure = false;
|
|
496
|
+
let declarations = Object.create(null);
|
|
399
497
|
try {
|
|
498
|
+
// Declared prices are read before anything is sent: a hand-edited
|
|
499
|
+
// overrides.json that no longer parses would otherwise throw only when the
|
|
500
|
+
// answer is priced, after the provider had charged for it, leaving the
|
|
501
|
+
// request out of the ledger and the cap (PR #146 review). Like a broken
|
|
502
|
+
// config.json it does not latch: repairing the file resumes forwarding.
|
|
503
|
+
// The request is later priced from exactly these declarations, so an edit
|
|
504
|
+
// that breaks the file while it is in flight cannot stop its accounting
|
|
505
|
+
// after the provider has answered (PR #146 review).
|
|
506
|
+
declarations = activePriceOverrides();
|
|
400
507
|
decision = guard.evaluate({ sessionId });
|
|
401
508
|
}
|
|
402
|
-
catch {
|
|
509
|
+
catch (error) {
|
|
403
510
|
budgetFailure = true;
|
|
404
|
-
|
|
511
|
+
// A ledger or accounting failure latches; an unreadable or invalid
|
|
512
|
+
// config.json or price-overrides file does not, so repairing it resumes enforcement.
|
|
513
|
+
if (!(error instanceof BudgetConfigUnavailable) && !(error instanceof PriceOverridesError))
|
|
514
|
+
state.accountingFailure = true;
|
|
405
515
|
decision = {
|
|
406
516
|
action: 'block',
|
|
407
517
|
reason: 'budget_enforcement_unavailable',
|
|
@@ -431,7 +541,8 @@ async function handle(req, res, deps, guard, state) {
|
|
|
431
541
|
? 'Segreant cannot verify local budget/accounting state; the request was not sent. Repair the local ledger or configuration, then restart Segreant.'
|
|
432
542
|
: decision.reason ?? 'Budget limit reached.';
|
|
433
543
|
res.end(unavailable ? providerBudgetUnavailableBody(provider, message) : providerErrorBody(provider, message));
|
|
434
|
-
// Log the blocked attempt at zero cost for the audit trail.
|
|
544
|
+
// Log the blocked attempt at zero cost for the audit trail. Nothing was
|
|
545
|
+
// sent, so the cost is known to be zero, not unknown.
|
|
435
546
|
safeLog(deps, {
|
|
436
547
|
requestId: randomUUID(),
|
|
437
548
|
sessionId,
|
|
@@ -451,7 +562,7 @@ async function handle(req, res, deps, guard, state) {
|
|
|
451
562
|
reasoningTokens: 0,
|
|
452
563
|
costUsd: 0,
|
|
453
564
|
estimated: false,
|
|
454
|
-
pricing:
|
|
565
|
+
pricing: notSentPricingEvidence(),
|
|
455
566
|
streamed: parsed.stream,
|
|
456
567
|
statusCode: unavailable ? 503 : 429,
|
|
457
568
|
durationMs: 0,
|
|
@@ -502,7 +613,7 @@ async function handle(req, res, deps, guard, state) {
|
|
|
502
613
|
headers['x-segreant-egress-refusal'] = egressRefusal.code;
|
|
503
614
|
res.writeHead(status, headers);
|
|
504
615
|
res.end(egressRefusal
|
|
505
|
-
? providerEgressRefusalBody(provider, egressRefusal)
|
|
616
|
+
? providerEgressRefusalBody(provider, egressRefusal, targetUrl, req.method ?? 'POST', deps.config.egress.mode)
|
|
506
617
|
: providerUpstreamErrorBody(provider, detail, timedOut ? 'upstream_timeout' : 'upstream_unreachable'));
|
|
507
618
|
safeLog(deps, {
|
|
508
619
|
requestId,
|
|
@@ -523,7 +634,13 @@ async function handle(req, res, deps, guard, state) {
|
|
|
523
634
|
reasoningTokens: 0,
|
|
524
635
|
costUsd: 0,
|
|
525
636
|
estimated: false,
|
|
526
|
-
|
|
637
|
+
// Only a policy or DNS refusal is certainly decided before the dial. A
|
|
638
|
+
// receipt failure can come after the provider answered (the response
|
|
639
|
+
// receipt), and a timeout or dropped connection may have reached it, so
|
|
640
|
+
// those keep an unknown cost (PR #143 review).
|
|
641
|
+
pricing: egressRefusal !== null && (egressRefusal.code === 'policy_denied' || egressRefusal.code === 'dns_denied')
|
|
642
|
+
? notSentPricingEvidence()
|
|
643
|
+
: unpricedPricingEvidence(),
|
|
527
644
|
streamed: parsed.stream,
|
|
528
645
|
statusCode: status,
|
|
529
646
|
durationMs: Date.now() - startedAt,
|
|
@@ -661,7 +778,7 @@ async function handle(req, res, deps, guard, state) {
|
|
|
661
778
|
catch {
|
|
662
779
|
/* non-JSON (e.g. error HTML) — usage stays empty */
|
|
663
780
|
}
|
|
664
|
-
const cost = computeCost(provider, resolvedModel, usage);
|
|
781
|
+
const cost = withDeclarations(declarations, () => computeCost(provider, resolvedModel, usage));
|
|
665
782
|
downHeaders['x-segreant-cost-usd'] = cost.costUsd.toFixed(6);
|
|
666
783
|
if (decision.sessionSpendUsd !== null && config.budget.sessionUsd !== null) {
|
|
667
784
|
const remaining = Math.max(0, config.budget.sessionUsd - decision.sessionSpendUsd - cost.costUsd);
|
|
@@ -671,7 +788,7 @@ async function handle(req, res, deps, guard, state) {
|
|
|
671
788
|
res.end(text);
|
|
672
789
|
}
|
|
673
790
|
// --- Cost + log (after the client has its bytes) ---
|
|
674
|
-
const cost = computeCost(provider, resolvedModel, usage);
|
|
791
|
+
const cost = withDeclarations(declarations, () => computeCost(provider, resolvedModel, usage));
|
|
675
792
|
safeLog(deps, {
|
|
676
793
|
requestId,
|
|
677
794
|
sessionId,
|
|
@@ -687,6 +804,9 @@ async function handle(req, res, deps, guard, state) {
|
|
|
687
804
|
inputTokens: usage.inputTokens,
|
|
688
805
|
outputTokens: usage.outputTokens,
|
|
689
806
|
cacheWriteTokens: usage.cacheWriteTokens,
|
|
807
|
+
// Kept so a later reprice prices the writes at the same duration (PR #146
|
|
808
|
+
// review), and only when it was observed rather than chosen.
|
|
809
|
+
...(usage.cacheWriteTtl === undefined || !usage.cacheWriteTtlObserved ? {} : { cacheWriteTtl: usage.cacheWriteTtl }),
|
|
690
810
|
cacheReadTokens: usage.cacheReadTokens,
|
|
691
811
|
reasoningTokens: usage.reasoningTokens ?? 0,
|
|
692
812
|
costUsd: cost.costUsd,
|
package/dist/proxy/usage.js
CHANGED
|
@@ -27,19 +27,20 @@
|
|
|
27
27
|
* is nested under a `response` key (e.g. the `response.completed` event has
|
|
28
28
|
* `usage` at `event.response.usage`, not `event.usage`). See consumeOpenAI.
|
|
29
29
|
*/
|
|
30
|
+
import { cacheWriteDuration } from '../cost/pricing.js';
|
|
30
31
|
import { RESOURCE_LIMITS } from '../util/resource-limits.js';
|
|
31
32
|
export function normalizeAnthropicUsage(u) {
|
|
32
33
|
const ephem1h = u.cache_creation?.ephemeral_1h_input_tokens ?? 0;
|
|
33
34
|
const ephem5m = u.cache_creation?.ephemeral_5m_input_tokens ?? 0;
|
|
34
35
|
const cacheWrite = u.cache_creation_input_tokens ?? ephem5m + ephem1h;
|
|
35
|
-
|
|
36
|
-
const ttl = ephem1h > ephem5m ? '1h' : '5m';
|
|
36
|
+
const duration = cacheWriteDuration(ephem5m, ephem1h);
|
|
37
37
|
return {
|
|
38
38
|
inputTokens: u.input_tokens ?? 0,
|
|
39
39
|
outputTokens: u.output_tokens ?? 0,
|
|
40
40
|
cacheWriteTokens: cacheWrite,
|
|
41
41
|
cacheReadTokens: u.cache_read_input_tokens ?? 0,
|
|
42
|
-
cacheWriteTtl: ttl,
|
|
42
|
+
...(duration.ttl === undefined ? {} : { cacheWriteTtl: duration.ttl }),
|
|
43
|
+
cacheWriteTtlObserved: duration.observed,
|
|
43
44
|
};
|
|
44
45
|
}
|
|
45
46
|
export function normalizeOpenAIUsage(u) {
|
|
@@ -176,7 +177,11 @@ export class StreamUsageAccumulator {
|
|
|
176
177
|
this.partial.inputTokens = n.inputTokens;
|
|
177
178
|
this.partial.cacheWriteTokens = n.cacheWriteTokens;
|
|
178
179
|
this.partial.cacheReadTokens = n.cacheReadTokens;
|
|
179
|
-
|
|
180
|
+
if (n.cacheWriteTtl === undefined)
|
|
181
|
+
delete this.partial.cacheWriteTtl;
|
|
182
|
+
else
|
|
183
|
+
this.partial.cacheWriteTtl = n.cacheWriteTtl;
|
|
184
|
+
this.partial.cacheWriteTtlObserved = n.cacheWriteTtlObserved;
|
|
180
185
|
this.maxOutput = Math.max(this.maxOutput, n.outputTokens);
|
|
181
186
|
}
|
|
182
187
|
}
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The vendors' own usage meters, as their tools write them to local logs.
|
|
3
|
+
*
|
|
4
|
+
* Codex writes `rate_limits` on every token count: the percentage used of a
|
|
5
|
+
* 5-hour and a weekly window, with each window's reset time. Claude Code writes
|
|
6
|
+
* no percentage; when a limit is reached it writes the vendor's message
|
|
7
|
+
* ("You've hit your session limit · resets 6:40pm (Africa/Cairo)") as an API
|
|
8
|
+
* error line. Both are the VENDOR's meter. Segreant's own count is the ledger;
|
|
9
|
+
* the quota view sets the two side by side and never treats either as the
|
|
10
|
+
* other.
|
|
11
|
+
*
|
|
12
|
+
* Pure: parsing only, no I/O.
|
|
13
|
+
*/
|
|
14
|
+
/** Codex snapshots from one token_count payload's `rate_limits`. */
|
|
15
|
+
export function codexRateLimitEvents(rateLimits, tsEpochMs) {
|
|
16
|
+
if (rateLimits === null || typeof rateLimits !== 'object')
|
|
17
|
+
return [];
|
|
18
|
+
const out = [];
|
|
19
|
+
for (const [key, kind] of [['primary', 'codex_primary'], ['secondary', 'codex_secondary']]) {
|
|
20
|
+
const w = rateLimits[key];
|
|
21
|
+
if (w === null || w === undefined || typeof w !== 'object')
|
|
22
|
+
continue;
|
|
23
|
+
const used = typeof w.used_percent === 'number' && Number.isFinite(w.used_percent) ? w.used_percent : null;
|
|
24
|
+
if (used === null)
|
|
25
|
+
continue;
|
|
26
|
+
const minutes = typeof w.window_minutes === 'number' && w.window_minutes > 0 ? w.window_minutes : null;
|
|
27
|
+
const resets = typeof w.resets_at === 'number' && w.resets_at > 0 ? w.resets_at * 1000 : null;
|
|
28
|
+
out.push({ source: 'codex', kind, tsEpochMs, usedPercent: used, windowMinutes: minutes, resetsAtMs: resets, detail: null });
|
|
29
|
+
}
|
|
30
|
+
return out;
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* The `rate_limits` object Claude Code passes to a status line script
|
|
34
|
+
* (code.claude.com/docs/en/statusline): `five_hour`, `seven_day` and
|
|
35
|
+
* `spend_limit`, each with `used_percentage` (0-100) and `resets_at` (epoch
|
|
36
|
+
* seconds). Present for Pro and Max subscribers after a session's first
|
|
37
|
+
* response; each window may be absent.
|
|
38
|
+
*/
|
|
39
|
+
export function claudeStatuslineEvents(input, tsEpochMs) {
|
|
40
|
+
if (input === null || typeof input !== 'object')
|
|
41
|
+
return [];
|
|
42
|
+
const limits = input.rate_limits;
|
|
43
|
+
if (limits === null || typeof limits !== 'object')
|
|
44
|
+
return [];
|
|
45
|
+
const out = [];
|
|
46
|
+
for (const [key, kind, minutes] of [
|
|
47
|
+
['five_hour', 'claude_five_hour', 300],
|
|
48
|
+
['seven_day', 'claude_seven_day', 10_080],
|
|
49
|
+
['spend_limit', 'claude_spend_window', null],
|
|
50
|
+
]) {
|
|
51
|
+
const w = limits[key];
|
|
52
|
+
if (w === null || w === undefined || typeof w !== 'object')
|
|
53
|
+
continue;
|
|
54
|
+
const used = typeof w.used_percentage === 'number' && Number.isFinite(w.used_percentage) ? w.used_percentage : null;
|
|
55
|
+
if (used === null)
|
|
56
|
+
continue;
|
|
57
|
+
const resets = typeof w.resets_at === 'number' && w.resets_at > 0 ? w.resets_at * 1000 : null;
|
|
58
|
+
out.push({ source: 'claude-code', kind, tsEpochMs, usedPercent: used, windowMinutes: minutes, resetsAtMs: resets, detail: null });
|
|
59
|
+
}
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const CLAUDE_LIMIT = /You've hit your ([a-z ]{3,30}?) limit(?: · resets ([0-9]{1,2}(?::[0-9]{2})?\s?[ap]m)(?: \(([A-Za-z_]+(?:\/[A-Za-z_+-]+){0,2})\))?)?/;
|
|
63
|
+
/**
|
|
64
|
+
* A Claude Code limit message, only from the vendor's own error line
|
|
65
|
+
* (`"isApiErrorMessage":true`). The same words quoted in a prompt or a tool
|
|
66
|
+
* result are not a limit event.
|
|
67
|
+
*/
|
|
68
|
+
export function claudeLimitEvent(line, tsEpochMs) {
|
|
69
|
+
if (!line.includes('"isApiErrorMessage":true'))
|
|
70
|
+
return null;
|
|
71
|
+
const m = CLAUDE_LIMIT.exec(line);
|
|
72
|
+
if (m === null)
|
|
73
|
+
return null;
|
|
74
|
+
const what = m[1].trim();
|
|
75
|
+
const kind = what === 'session' ? 'claude_session'
|
|
76
|
+
: what === 'weekly' ? 'claude_weekly'
|
|
77
|
+
: what.includes('spend') ? 'claude_spend'
|
|
78
|
+
: 'claude_other';
|
|
79
|
+
const resetsAtMs = m[2] !== undefined ? nextLocalTime(m[2], m[3] ?? null, tsEpochMs) : null;
|
|
80
|
+
return {
|
|
81
|
+
source: 'claude-code',
|
|
82
|
+
kind,
|
|
83
|
+
tsEpochMs,
|
|
84
|
+
usedPercent: null,
|
|
85
|
+
windowMinutes: kind === 'claude_session' ? 300 : kind === 'claude_weekly' ? 10_080 : null,
|
|
86
|
+
resetsAtMs,
|
|
87
|
+
detail: m[0].slice(0, 120),
|
|
88
|
+
};
|
|
89
|
+
}
|
|
90
|
+
/** Minutes a zone is ahead of UTC at an instant (Cairo in summer: +180). */
|
|
91
|
+
export function zoneOffsetMinutes(timeZone, atMs) {
|
|
92
|
+
let parts;
|
|
93
|
+
try {
|
|
94
|
+
parts = new Intl.DateTimeFormat('en-US', {
|
|
95
|
+
timeZone, hourCycle: 'h23', year: 'numeric', month: '2-digit', day: '2-digit', hour: '2-digit', minute: '2-digit', second: '2-digit',
|
|
96
|
+
}).formatToParts(new Date(atMs));
|
|
97
|
+
}
|
|
98
|
+
catch {
|
|
99
|
+
return null; // an unknown zone name
|
|
100
|
+
}
|
|
101
|
+
const get = (t) => Number(parts.find((p) => p.type === t)?.value);
|
|
102
|
+
const asUtc = Date.UTC(get('year'), get('month') - 1, get('day'), get('hour'), get('minute'), get('second'));
|
|
103
|
+
return Math.round((asUtc - Math.floor(atMs / 1000) * 1000) / 60_000);
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The first instant after `afterMs` at which the clock in `timeZone` reads
|
|
107
|
+
* `clock` ("6:40pm", "4am"). With no zone, the machine's own zone is assumed,
|
|
108
|
+
* which is the zone Claude Code itself printed in.
|
|
109
|
+
*/
|
|
110
|
+
export function nextLocalTime(clock, timeZone, afterMs) {
|
|
111
|
+
const m = /^([0-9]{1,2})(?::([0-9]{2}))?\s?([ap]m)$/.exec(clock.trim());
|
|
112
|
+
if (m === null)
|
|
113
|
+
return null;
|
|
114
|
+
const clockHour = Number(m[1]);
|
|
115
|
+
if (clockHour < 1 || clockHour > 12)
|
|
116
|
+
return null; // a 12-hour clock reads 1 to 12
|
|
117
|
+
let hour = clockHour % 12;
|
|
118
|
+
if (m[3] === 'pm')
|
|
119
|
+
hour += 12;
|
|
120
|
+
const minute = m[2] === undefined ? 0 : Number(m[2]);
|
|
121
|
+
if (hour > 23 || minute > 59)
|
|
122
|
+
return null;
|
|
123
|
+
const offsetAt = (ms) => timeZone === null ? -new Date(ms).getTimezoneOffset() : zoneOffsetMinutes(timeZone, ms);
|
|
124
|
+
const offset = offsetAt(afterMs);
|
|
125
|
+
if (offset === null)
|
|
126
|
+
return null;
|
|
127
|
+
// The local calendar day of `afterMs`, then that day's clock time, in UTC.
|
|
128
|
+
const local = new Date(afterMs + offset * 60_000);
|
|
129
|
+
for (let day = 0; day <= 1; day++) {
|
|
130
|
+
const wallAsUtc = Date.UTC(local.getUTCFullYear(), local.getUTCMonth(), local.getUTCDate() + day, hour, minute);
|
|
131
|
+
const guess = wallAsUtc - offset * 60_000;
|
|
132
|
+
// Re-read the offset at the guess so a DST change between the two instants is honoured.
|
|
133
|
+
const exact = wallAsUtc - (offsetAt(guess) ?? offset) * 60_000;
|
|
134
|
+
if (exact > afterMs)
|
|
135
|
+
return exact;
|
|
136
|
+
}
|
|
137
|
+
return null;
|
|
138
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Claude Code's status line, the lean path.
|
|
3
|
+
*
|
|
4
|
+
* Claude Code runs the status line command on every refresh and pipes session
|
|
5
|
+
* JSON on stdin; for Pro and Max subscribers that JSON carries the live usage
|
|
6
|
+
* meter (`rate_limits`). Going through the full CLI took 1.6 s a call, almost
|
|
7
|
+
* all of it opening the Store with its schema and integrity checks, which is
|
|
8
|
+
* far too slow for a bar that refreshes on every message. This path opens the
|
|
9
|
+
* ledger file directly, writes at most one row per meter (only when the
|
|
10
|
+
* reading changed), sums today's list cost and prints one line.
|
|
11
|
+
*
|
|
12
|
+
* It must never break the person's status bar: any failure prints a minimal
|
|
13
|
+
* line. It never creates the ledger or its tables; a fresh machine gets them
|
|
14
|
+
* from the first ordinary `segreant` command, and until then the line still
|
|
15
|
+
* prints.
|
|
16
|
+
*/
|
|
17
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
18
|
+
import { existsSync } from 'node:fs';
|
|
19
|
+
import { claudeStatuslineEvents } from './limits.js';
|
|
20
|
+
export function statuslineLine(dbFile, rawInput, nowMs) {
|
|
21
|
+
let input = null;
|
|
22
|
+
try {
|
|
23
|
+
input = rawInput.trim() === '' ? null : JSON.parse(rawInput);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
input = null;
|
|
27
|
+
}
|
|
28
|
+
const events = claudeStatuslineEvents(input, nowMs);
|
|
29
|
+
const parts = events
|
|
30
|
+
.filter((e) => e.kind !== 'claude_spend_window')
|
|
31
|
+
.map((e) => `${e.kind === 'claude_five_hour' ? '5h' : 'week'} ${Math.round(e.usedPercent ?? 0)}%`);
|
|
32
|
+
if (!existsSync(dbFile))
|
|
33
|
+
return parts.length > 0 ? parts.join(' · ') : 'segreant';
|
|
34
|
+
let db = null;
|
|
35
|
+
try {
|
|
36
|
+
db = new DatabaseSync(dbFile);
|
|
37
|
+
// A running import holds the write lock in short batches; wait briefly, never long.
|
|
38
|
+
db.exec('PRAGMA busy_timeout = 200');
|
|
39
|
+
const hasTable = db.prepare("SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'quota_events'").get() !== undefined;
|
|
40
|
+
if (hasTable) {
|
|
41
|
+
const last = db.prepare('SELECT used_percent AS used, resets_at_ms AS resets FROM quota_events WHERE source = ? AND kind = ? ORDER BY ts_epoch_ms DESC LIMIT 1');
|
|
42
|
+
const insert = db.prepare(`INSERT INTO quota_events (source, kind, ts_epoch_ms, used_percent, window_minutes, resets_at_ms, detail)
|
|
43
|
+
VALUES (?,?,?,?,?,?,NULL) ON CONFLICT(source, kind, ts_epoch_ms) DO NOTHING`);
|
|
44
|
+
for (const e of events) {
|
|
45
|
+
const prev = last.get(e.source, e.kind);
|
|
46
|
+
const resets = e.resetsAtMs === null ? null : Math.trunc(e.resetsAtMs);
|
|
47
|
+
if (prev !== undefined && prev.used === e.usedPercent && prev.resets === resets)
|
|
48
|
+
continue;
|
|
49
|
+
insert.run(e.source, e.kind, Math.trunc(e.tsEpochMs), e.usedPercent, e.windowMinutes, resets);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
const dayStart = new Date(nowMs);
|
|
53
|
+
dayStart.setHours(0, 0, 0, 0);
|
|
54
|
+
const today = db.prepare('SELECT COALESCE(SUM(cost_usd), 0) AS c FROM requests WHERE ts_epoch_ms >= ?').get(dayStart.getTime());
|
|
55
|
+
if (today !== undefined)
|
|
56
|
+
parts.push(`today $${today.c.toFixed(2)} list`);
|
|
57
|
+
}
|
|
58
|
+
catch {
|
|
59
|
+
// A locked, missing or older ledger: show what the input alone gives.
|
|
60
|
+
}
|
|
61
|
+
finally {
|
|
62
|
+
try {
|
|
63
|
+
db?.close();
|
|
64
|
+
}
|
|
65
|
+
catch { /* already closed */ }
|
|
66
|
+
}
|
|
67
|
+
return parts.length > 0 ? parts.join(' · ') : 'segreant';
|
|
68
|
+
}
|
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
const AGREE_BAND = 1.5; // within 1.5x either way of the earlier median
|
|
2
|
+
const MIN_PERCENT_FOR_RATIO = 5; // a 1-2% reading is too coarse to divide by
|
|
3
|
+
export function windowView(e, nowMs) {
|
|
4
|
+
const used = e.usedPercent ?? 0;
|
|
5
|
+
const reset = e.resetsAtMs !== null && e.resetsAtMs <= nowMs;
|
|
6
|
+
let projected = null;
|
|
7
|
+
let limitAt = null;
|
|
8
|
+
if (!reset && e.resetsAtMs !== null && e.windowMinutes !== null) {
|
|
9
|
+
const opened = e.resetsAtMs - e.windowMinutes * 60_000;
|
|
10
|
+
const elapsed = e.tsEpochMs - opened;
|
|
11
|
+
if (elapsed > 0) {
|
|
12
|
+
const perMs = used / elapsed;
|
|
13
|
+
projected = used + perMs * (e.resetsAtMs - e.tsEpochMs);
|
|
14
|
+
if (used >= 100)
|
|
15
|
+
limitAt = e.tsEpochMs;
|
|
16
|
+
else if (perMs > 0) {
|
|
17
|
+
const at = e.tsEpochMs + (100 - used) / perMs;
|
|
18
|
+
if (at < e.resetsAtMs)
|
|
19
|
+
limitAt = at;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
return {
|
|
24
|
+
kind: e.kind, usedPercent: used, windowMinutes: e.windowMinutes, resetsAtMs: e.resetsAtMs,
|
|
25
|
+
asOfMs: e.tsEpochMs, reset, projectedAtResetPercent: projected, limitAtMs: limitAt,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
function median(xs) {
|
|
29
|
+
if (xs.length === 0)
|
|
30
|
+
return null;
|
|
31
|
+
const s = [...xs].sort((a, b) => a - b);
|
|
32
|
+
const mid = s.length >> 1;
|
|
33
|
+
return s.length % 2 === 1 ? s[mid] : (s[mid - 1] + s[mid]) / 2;
|
|
34
|
+
}
|
|
35
|
+
/** For the longer Codex window: list cost per 1% of the vendor's meter, this window and earlier ones. */
|
|
36
|
+
export function meterCheck(events, spend) {
|
|
37
|
+
const byWindow = new Map();
|
|
38
|
+
for (const e of events) {
|
|
39
|
+
if (e.kind !== 'codex_secondary' || e.resetsAtMs === null || e.windowMinutes === null)
|
|
40
|
+
continue;
|
|
41
|
+
const prev = byWindow.get(e.resetsAtMs);
|
|
42
|
+
if (prev === undefined || e.tsEpochMs > prev.tsEpochMs)
|
|
43
|
+
byWindow.set(e.resetsAtMs, e);
|
|
44
|
+
}
|
|
45
|
+
const ratios = [...byWindow.values()]
|
|
46
|
+
.sort((a, b) => b.resetsAtMs - a.resetsAtMs)
|
|
47
|
+
.map((e) => {
|
|
48
|
+
if ((e.usedPercent ?? 0) < MIN_PERCENT_FOR_RATIO)
|
|
49
|
+
return null;
|
|
50
|
+
const opened = e.resetsAtMs - e.windowMinutes * 60_000;
|
|
51
|
+
return spend('codex', opened, e.tsEpochMs + 1) / e.usedPercent;
|
|
52
|
+
});
|
|
53
|
+
const current = ratios[0] ?? null;
|
|
54
|
+
const earlier = ratios.slice(1, 9).filter((r) => r !== null);
|
|
55
|
+
const m = median(earlier);
|
|
56
|
+
return {
|
|
57
|
+
currentUsdPerPercent: current,
|
|
58
|
+
earlierUsdPerPercent: earlier,
|
|
59
|
+
agrees: current === null || m === null || earlier.length < 2 || m === 0
|
|
60
|
+
? null
|
|
61
|
+
: current / m <= AGREE_BAND && m / current <= AGREE_BAND,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
export function claudeLimitView(events, spend, nowMs) {
|
|
65
|
+
// Parallel agents hit the same wall together: one hit per window (its reset).
|
|
66
|
+
const hits = new Map();
|
|
67
|
+
for (const e of events) {
|
|
68
|
+
if (e.source !== 'claude-code')
|
|
69
|
+
continue;
|
|
70
|
+
const key = JSON.stringify([e.kind, e.resetsAtMs ?? Math.floor(e.tsEpochMs / 3_600_000)]);
|
|
71
|
+
const prev = hits.get(key);
|
|
72
|
+
if (prev === undefined || e.tsEpochMs < prev.tsEpochMs)
|
|
73
|
+
hits.set(key, e);
|
|
74
|
+
}
|
|
75
|
+
const list = [...hits.values()].sort((a, b) => a.tsEpochMs - b.tsEpochMs);
|
|
76
|
+
const fiveHours = 5 * 60 * 60_000;
|
|
77
|
+
const usdBeforeSessionHit = list
|
|
78
|
+
.filter((e) => e.kind === 'claude_session')
|
|
79
|
+
.map((e) => spend('claude-code', (e.resetsAtMs ?? e.tsEpochMs) - fiveHours, e.tsEpochMs + 1));
|
|
80
|
+
const lastFiveHoursUsd = spend('claude-code', nowMs - fiveHours, nowMs + 1);
|
|
81
|
+
if (list.length === 0 && lastFiveHoursUsd === 0)
|
|
82
|
+
return null;
|
|
83
|
+
return {
|
|
84
|
+
sessionHits: list.filter((e) => e.kind === 'claude_session').length,
|
|
85
|
+
weeklyHits: list.filter((e) => e.kind === 'claude_weekly').length,
|
|
86
|
+
spendHits: list.filter((e) => e.kind === 'claude_spend').length,
|
|
87
|
+
lastHit: list[list.length - 1] ?? null,
|
|
88
|
+
usdBeforeSessionHit,
|
|
89
|
+
lastFiveHoursUsd,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
export function quotaView(events, spend, nowMs) {
|
|
93
|
+
const codexEvents = events.filter((e) => e.source === 'codex');
|
|
94
|
+
const latest = new Map();
|
|
95
|
+
for (const e of codexEvents) {
|
|
96
|
+
const prev = latest.get(e.kind);
|
|
97
|
+
if (prev === undefined || e.tsEpochMs >= prev.tsEpochMs)
|
|
98
|
+
latest.set(e.kind, e);
|
|
99
|
+
}
|
|
100
|
+
const windows = ['codex_primary', 'codex_secondary']
|
|
101
|
+
.map((k) => latest.get(k))
|
|
102
|
+
.filter((e) => e !== undefined)
|
|
103
|
+
.map((e) => windowView(e, nowMs));
|
|
104
|
+
const claudeLatest = new Map();
|
|
105
|
+
for (const e of events) {
|
|
106
|
+
if (e.kind !== 'claude_five_hour' && e.kind !== 'claude_seven_day' && e.kind !== 'claude_spend_window')
|
|
107
|
+
continue;
|
|
108
|
+
const prev = claudeLatest.get(e.kind);
|
|
109
|
+
if (prev === undefined || e.tsEpochMs >= prev.tsEpochMs)
|
|
110
|
+
claudeLatest.set(e.kind, e);
|
|
111
|
+
}
|
|
112
|
+
const claudeWindows = ['claude_five_hour', 'claude_seven_day', 'claude_spend_window']
|
|
113
|
+
.map((k) => claudeLatest.get(k))
|
|
114
|
+
.filter((e) => e !== undefined)
|
|
115
|
+
.map((e) => windowView(e, nowMs));
|
|
116
|
+
return {
|
|
117
|
+
codex: windows.length === 0 ? null : { windows, weekly: meterCheck(codexEvents, spend) },
|
|
118
|
+
claude: claudeLimitView(events.filter((e) => !e.kind.startsWith('claude_') || e.usedPercent === null), spend, nowMs),
|
|
119
|
+
claudeWindows,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
export { median };
|
package/dist/scan/knownApps.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Known-app inventory — the wider, honest half of `scan`.
|
|
3
3
|
*
|
|
4
|
-
* `detectTools()` (scan.ts) answers "which of our
|
|
4
|
+
* `detectTools()` (scan.ts) answers "which of our NATIVELY SUPPORTED tools are
|
|
5
5
|
* here" — present implies Segreant can read its usage data. This module answers
|
|
6
6
|
* a different, narrower question: "which OTHER AI coding tools do we merely SEE
|
|
7
7
|
* evidence of on this machine". Presence here implies nothing about import
|
|
@@ -61,6 +61,7 @@ const KNOWN_APPS = [
|
|
|
61
61
|
id: 'cursor',
|
|
62
62
|
label: 'Cursor',
|
|
63
63
|
blurb: 'AI-first code editor.',
|
|
64
|
+
notReadBecause: 'its local token counts are best-effort and often 0 (Cursor staff, forum.cursor.com/t/155984); a team Admin API connector is planned',
|
|
64
65
|
detect: (env) => firstExisting(env, [join(env.home, '.cursor')]),
|
|
65
66
|
},
|
|
66
67
|
{
|
|
@@ -104,6 +105,9 @@ export function detectKnownApps(opts = {}) {
|
|
|
104
105
|
};
|
|
105
106
|
return KNOWN_APPS.map((sig) => {
|
|
106
107
|
const evidence = sig.detect(env);
|
|
107
|
-
return {
|
|
108
|
+
return {
|
|
109
|
+
id: sig.id, label: sig.label, blurb: sig.blurb, present: evidence !== null, evidence,
|
|
110
|
+
...(sig.notReadBecause === undefined ? {} : { notReadBecause: sig.notReadBecause }),
|
|
111
|
+
};
|
|
108
112
|
});
|
|
109
113
|
}
|
package/dist/scan/scan.js
CHANGED
|
@@ -21,6 +21,7 @@ import { projectKey } from '../value/characterization.js';
|
|
|
21
21
|
import { defaultClaudeCodeRoot } from '../connect/claudeCode.js';
|
|
22
22
|
import { defaultOpencodeDbPath } from '../connect/opencode.js';
|
|
23
23
|
import { defaultCodexRoot } from '../connect/codex.js';
|
|
24
|
+
import { defaultAntigravityRoot } from '../connect/antigravity.js';
|
|
24
25
|
import { detectKnownApps } from './knownApps.js';
|
|
25
26
|
/**
|
|
26
27
|
* Detect which supported tools have local data here, reusing each importer's own
|
|
@@ -32,6 +33,7 @@ export function detectTools() {
|
|
|
32
33
|
const claudePresent = existsSync(claudeRoot);
|
|
33
34
|
const opencodeDb = defaultOpencodeDbPath(); // already null when absent
|
|
34
35
|
const codexRoot = defaultCodexRoot(); // already null when absent
|
|
36
|
+
const antigravityRoot = defaultAntigravityRoot(); // already null when absent
|
|
35
37
|
return [
|
|
36
38
|
{
|
|
37
39
|
id: 'claude-code',
|
|
@@ -54,6 +56,13 @@ export function detectTools() {
|
|
|
54
56
|
dataPath: codexRoot,
|
|
55
57
|
blurb: 'Per-turn token usage from ~/.codex rollout session logs.',
|
|
56
58
|
},
|
|
59
|
+
{
|
|
60
|
+
id: 'antigravity',
|
|
61
|
+
label: 'Antigravity',
|
|
62
|
+
present: antigravityRoot !== null,
|
|
63
|
+
dataPath: antigravityRoot,
|
|
64
|
+
blurb: 'Per-call token usage from the IDE and agy conversation databases under ~/.gemini.',
|
|
65
|
+
},
|
|
57
66
|
];
|
|
58
67
|
}
|
|
59
68
|
/** Directory names never worth descending into on a repo hunt (heavy or system). */
|