@vimoxshah/tokenflow 1.1.1 → 1.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/CHANGELOG.md +228 -0
- package/Dockerfile.team +20 -0
- package/README.md +30 -11
- package/bin/tokenflow.js +147 -12
- package/design/tokens.yaml +330 -0
- package/docs/architecture.md +5 -4
- package/docs/cli.md +204 -0
- package/docs/configuration.md +117 -2
- package/docs/design-system.md +187 -0
- package/docs/exports-and-budgets.md +85 -0
- package/docs/guard-codex.md +132 -0
- package/docs/ledger.md +144 -0
- package/docs/live-mode.md +40 -0
- package/docs/media/overview-aurora-dark.png +0 -0
- package/docs/media/receipts-aurora-dark.png +0 -0
- package/docs/providers-otel.md +179 -0
- package/docs/providers.md +54 -1
- package/docs/receipt-schema.md +74 -0
- package/docs/roadmap.md +182 -0
- package/docs/team-server.md +170 -0
- package/docs/ui-views.md +322 -0
- package/package.json +7 -2
- package/schemas/receipt.v0.json +160 -0
- package/scripts/build-dmg.sh +11 -2
- package/scripts/build-menubar-app.sh +58 -7
- package/scripts/design-build.js +475 -0
- package/src/analytics/anatomy.js +467 -0
- package/src/analytics/branch-compare.js +159 -0
- package/src/analytics/cache-health.js +141 -0
- package/src/analytics/live-view.js +266 -0
- package/src/analytics/receipt-schema.js +214 -0
- package/src/analytics/receipt.js +709 -0
- package/src/analytics/rhythm.js +184 -0
- package/src/analytics/whatif.js +263 -0
- package/src/commands/budget-scopes.js +133 -0
- package/src/commands/doctor-checks.js +400 -0
- package/src/commands/guard.js +531 -0
- package/src/commands/hooks.js +238 -0
- package/src/commands/pricing-diff.js +316 -0
- package/src/commands/receipt.js +226 -0
- package/src/commands/team-serve.js +407 -0
- package/src/commands/week.js +86 -0
- package/src/core/annotations.js +97 -0
- package/src/core/budget.js +33 -0
- package/src/core/bundle.js +45 -2
- package/src/core/ingest.js +33 -0
- package/src/core/live-status.js +227 -2
- package/src/core/policy.js +103 -0
- package/src/core/receipt-note.js +123 -0
- package/src/core/repo.js +64 -0
- package/src/core/sync.js +163 -26
- package/src/core/team.js +0 -0
- package/src/export/html-snapshot.js +28 -1
- package/src/export/menubar.js +21 -0
- package/src/export/receipt-card.js +210 -0
- package/src/export/week-card.js +185 -0
- package/src/providers/mock/index.js +383 -52
- package/src/providers/openai/index.js +31 -1
- package/src/providers/otel/index.js +656 -0
- package/src/server/routes/annotations.js +42 -0
- package/src/server/routes/cache-health.js +95 -0
- package/src/server/routes/index.js +54 -0
- package/src/server/routes/session.js +157 -0
- package/src/server/server.js +47 -1
- package/src/ui/app.js +541 -308
- package/src/ui/charts.js +95 -0
- package/src/ui/first-run.js +144 -0
- package/src/ui/index.html +4 -1
- package/src/ui/palette.js +335 -0
- package/src/ui/styles/anatomy.css +117 -0
- package/src/ui/styles/annotations.css +40 -0
- package/src/ui/styles/branches.css +99 -0
- package/src/ui/styles/cache.css +6 -0
- package/src/ui/styles/first-run.css +31 -0
- package/src/ui/styles/live.css +100 -0
- package/src/ui/styles/palette.css +85 -0
- package/src/ui/styles/rhythm.css +8 -0
- package/src/ui/styles/whatif.css +55 -0
- package/src/ui/styles.css +303 -196
- package/src/ui/views/anatomy.js +567 -0
- package/src/ui/views/annotations.js +121 -0
- package/src/ui/views/branches.js +304 -0
- package/src/ui/views/cache.js +232 -0
- package/src/ui/views/index.js +85 -0
- package/src/ui/views/live.js +683 -0
- package/src/ui/views/rhythm.js +206 -0
- package/src/ui/views/whatif.js +196 -0
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `tokenflow week` -- "Your AI week": spend this calendar week vs last week,
|
|
3
|
+
* computed from the same store every other surface reads.
|
|
4
|
+
*
|
|
5
|
+
* tokenflow week text summary
|
|
6
|
+
* tokenflow week --svg week.svg a shareable card
|
|
7
|
+
* tokenflow week --svg week.svg --png week.png card + a local screenshot
|
|
8
|
+
* tokenflow week --json everything, machine-readable
|
|
9
|
+
*/
|
|
10
|
+
import fs from 'node:fs';
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import os from 'node:os';
|
|
13
|
+
import { loadConfig, paths } from '../core/config.js';
|
|
14
|
+
import { readJson } from '../core/store.js';
|
|
15
|
+
import { buildPriceBook } from '../core/pricing.js';
|
|
16
|
+
import { makeRepoResolver } from '../core/repo.js';
|
|
17
|
+
import { weekStart, addDays } from '../analytics/aggregate.js';
|
|
18
|
+
import { loadPrimaryRecords } from './receipt.js';
|
|
19
|
+
import { computeWeek, renderWeekCardSvg } from '../export/week-card.js';
|
|
20
|
+
import { renderSvgToPng } from '../export/receipt-card.js';
|
|
21
|
+
import { usd, pct } from '../core/units.js';
|
|
22
|
+
|
|
23
|
+
function expandHome(p) {
|
|
24
|
+
return p && p.startsWith('~') ? path.join(os.homedir(), p.slice(1)) : p;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function renderWeekText(week) {
|
|
28
|
+
const L = [];
|
|
29
|
+
L.push(`Your AI week: ${week.thisWeek.from} to ${week.thisWeek.to}`);
|
|
30
|
+
L.push(` Spend: ${usd(week.thisWeek.cost, 'n/a')} (last week ${usd(week.lastWeek.cost, 'n/a')})`);
|
|
31
|
+
if (week.thisWeek.contextShare !== null) L.push(` Context share: ${pct(week.thisWeek.contextShare, 0, 'n/a')}`);
|
|
32
|
+
L.push(` Turns: ${week.thisWeek.turns} Sessions: ${week.thisWeek.sessions}`);
|
|
33
|
+
if (week.topBranches.length) {
|
|
34
|
+
L.push(' Top branches:');
|
|
35
|
+
for (const b of week.topBranches) L.push(` ${b.repo}/${b.branch} ${usd(b.cost)}`);
|
|
36
|
+
}
|
|
37
|
+
if (week.maxSession) L.push(` Most expensive session: ${usd(week.maxSession.cost)}`);
|
|
38
|
+
L.push(` ${week.insight}`);
|
|
39
|
+
return L.join('\n');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @param {object} flags parsed CLI flags
|
|
44
|
+
* @returns {{text:string, json:object}}
|
|
45
|
+
*/
|
|
46
|
+
export function run(flags = {}) {
|
|
47
|
+
const cfg = loadConfig();
|
|
48
|
+
const pricing = readJson(paths().pricing, {});
|
|
49
|
+
const book = buildPriceBook(pricing);
|
|
50
|
+
const now = new Date();
|
|
51
|
+
|
|
52
|
+
// A single scan covers this week and last week even across a month
|
|
53
|
+
// boundary; loadPrimaryRecords already prunes shards outside the window.
|
|
54
|
+
const today = now.toISOString().slice(0, 10);
|
|
55
|
+
const thisFrom = weekStart(today);
|
|
56
|
+
const from = addDays(thisFrom, -7);
|
|
57
|
+
const records = loadPrimaryRecords({ from, to: today });
|
|
58
|
+
|
|
59
|
+
const week = computeWeek({ records, now, book, repoOf: makeRepoResolver() });
|
|
60
|
+
|
|
61
|
+
const skin = cfg.ui?.skin || 'aurora';
|
|
62
|
+
const mode = cfg.ui?.mode || cfg.ui?.theme || 'dark';
|
|
63
|
+
let text = renderWeekText(week);
|
|
64
|
+
|
|
65
|
+
if (typeof flags.svg === 'string') {
|
|
66
|
+
const svgPath = expandHome(flags.svg);
|
|
67
|
+
fs.writeFileSync(svgPath, renderWeekCardSvg(week, { skin, mode }));
|
|
68
|
+
text += `\n\nWrote SVG week card to ${svgPath}`;
|
|
69
|
+
if (typeof flags.png === 'string') {
|
|
70
|
+
const pngPath = expandHome(flags.png);
|
|
71
|
+
// Matches renderWeekCardSvg's own default aspect ratio (640x400).
|
|
72
|
+
const res = renderSvgToPng(svgPath, pngPath, { width: 640, height: 400 });
|
|
73
|
+
text += res.ok ? `\nWrote PNG week card to ${pngPath}` : `\n${res.message}`;
|
|
74
|
+
}
|
|
75
|
+
} else if (typeof flags.png === 'string') {
|
|
76
|
+
// --png with no --svg: the SVG is written alongside it, and named, so
|
|
77
|
+
// there is always a deliverable even without a local Chromium.
|
|
78
|
+
const pngPath = expandHome(flags.png);
|
|
79
|
+
const svgPath = `${pngPath}.svg`;
|
|
80
|
+
fs.writeFileSync(svgPath, renderWeekCardSvg(week, { skin, mode }));
|
|
81
|
+
const res = renderSvgToPng(svgPath, pngPath, { width: 640, height: 400 });
|
|
82
|
+
text += res.ok ? `\nWrote PNG week card to ${pngPath} (SVG at ${svgPath})` : `\n${res.message}`;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return { text, json: week };
|
|
86
|
+
}
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Day annotations: a small, user-declared list of dated notes ("switched to
|
|
3
|
+
* Opus 5", "started using subagents") that every daily chart in the dashboard
|
|
4
|
+
* draws as a marker.
|
|
5
|
+
*
|
|
6
|
+
* One flat file, `<paths().root>/annotations.json`, sitting next to
|
|
7
|
+
* config.yaml rather than under data/ — data/ holds ingested and derived
|
|
8
|
+
* state that a refresh rebuilds from source records, while annotations are
|
|
9
|
+
* hand-authored and must survive a `tokenflow refresh --full` or a store
|
|
10
|
+
* rebuild exactly the way config.yaml does. test/demo-isolation.test.js does
|
|
11
|
+
* not assert the store's file set (it only checks that nothing lands under
|
|
12
|
+
* the default home, and that the sandboxed store has a config.yaml), so
|
|
13
|
+
* nothing there constrains this choice either way.
|
|
14
|
+
*/
|
|
15
|
+
import path from 'node:path';
|
|
16
|
+
import { randomUUID } from 'node:crypto';
|
|
17
|
+
import { paths } from './config.js';
|
|
18
|
+
import { readJson, writeJson } from './store.js';
|
|
19
|
+
|
|
20
|
+
export const ANNOTATIONS_SCHEMA = 1;
|
|
21
|
+
|
|
22
|
+
/** Sanitized text longer than this is truncated, never rejected. */
|
|
23
|
+
const MAX_TEXT_LENGTH = 140;
|
|
24
|
+
|
|
25
|
+
const DATE_RE = /^\d{4}-\d{2}-\d{2}$/;
|
|
26
|
+
|
|
27
|
+
function annotationsFile() {
|
|
28
|
+
return path.join(paths().root, 'annotations.json');
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** True when `date` is a real calendar day in YYYY-MM-DD form (rejects e.g. 2024-02-30). */
|
|
32
|
+
function isValidDate(date) {
|
|
33
|
+
if (typeof date !== 'string' || !DATE_RE.test(date)) return false;
|
|
34
|
+
const [y, m, d] = date.split('-').map(Number);
|
|
35
|
+
const dt = new Date(Date.UTC(y, m - 1, d));
|
|
36
|
+
return dt.getUTCFullYear() === y && dt.getUTCMonth() === m - 1 && dt.getUTCDate() === d;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Strip ASCII control characters (including DEL) before trimming, so a
|
|
41
|
+
* control character sitting outside the visible text does not leave stray
|
|
42
|
+
* whitespace behind once it's gone. Never HTML-escaped: every render path
|
|
43
|
+
* (the annotations list, the chart label's title) sets textContent, not
|
|
44
|
+
* innerHTML, so raw text is safe to store as-is.
|
|
45
|
+
*/
|
|
46
|
+
function sanitizeText(text) {
|
|
47
|
+
return String(text ?? '').replace(/[\x00-\x1F\x7F]/g, '').trim();
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Read the annotations file. A missing or corrupt file yields an empty list
|
|
52
|
+
* rather than throwing — annotations are optional, and a bad file must never
|
|
53
|
+
* take the dashboard down.
|
|
54
|
+
* @returns {{schema:number, items:{id:string,date:string,text:string}[]}}
|
|
55
|
+
*/
|
|
56
|
+
export function readAnnotations() {
|
|
57
|
+
const raw = readJson(annotationsFile(), null);
|
|
58
|
+
if (!raw || !Array.isArray(raw.items)) return { schema: ANNOTATIONS_SCHEMA, items: [] };
|
|
59
|
+
return { schema: ANNOTATIONS_SCHEMA, items: raw.items };
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function saveAnnotations(items) {
|
|
63
|
+
writeJson(annotationsFile(), { schema: ANNOTATIONS_SCHEMA, items });
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/**
|
|
67
|
+
* Add one annotation. Throws a plain `Error` with a short, user-safe message
|
|
68
|
+
* on invalid input; the caller decides how to surface it (the route answers
|
|
69
|
+
* 400 with it).
|
|
70
|
+
* @param {{date?:string, text?:string}} input
|
|
71
|
+
* @returns {{id:string,date:string,text:string}} the stored item
|
|
72
|
+
*/
|
|
73
|
+
export function addAnnotation({ date, text } = {}) {
|
|
74
|
+
if (!isValidDate(date)) throw new Error(`invalid date "${date}" — expected YYYY-MM-DD`);
|
|
75
|
+
const clean = sanitizeText(text).slice(0, MAX_TEXT_LENGTH);
|
|
76
|
+
if (!clean) throw new Error('text is required');
|
|
77
|
+
const item = { id: randomUUID(), date, text: clean };
|
|
78
|
+
const { items } = readAnnotations();
|
|
79
|
+
items.push(item);
|
|
80
|
+
saveAnnotations(items);
|
|
81
|
+
return item;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Remove one annotation by id. Idempotent: removing an id that is already
|
|
86
|
+
* gone is not an error, it just reports nothing was removed.
|
|
87
|
+
* @param {string} id
|
|
88
|
+
* @returns {boolean} whether an item was actually removed
|
|
89
|
+
*/
|
|
90
|
+
export function removeAnnotation(id) {
|
|
91
|
+
if (!id || typeof id !== 'string') throw new Error('id is required');
|
|
92
|
+
const { items } = readAnnotations();
|
|
93
|
+
const next = items.filter((it) => it.id !== id);
|
|
94
|
+
const removed = next.length !== items.length;
|
|
95
|
+
if (removed) saveAnnotations(next);
|
|
96
|
+
return removed;
|
|
97
|
+
}
|
package/src/core/budget.js
CHANGED
|
@@ -140,3 +140,36 @@ function persist(s) {
|
|
|
140
140
|
|
|
141
141
|
/** Reset state (new month detected by callers, or user command). */
|
|
142
142
|
export function resetBudgetState() { try { fs.unlinkSync(STATE_FILE()); } catch { /* absent */ } }
|
|
143
|
+
|
|
144
|
+
// ------------------------------------------------------- scoped budgets ---
|
|
145
|
+
//
|
|
146
|
+
// Budgets per repo and per team, alongside the single monthly budget above.
|
|
147
|
+
// Config:
|
|
148
|
+
//
|
|
149
|
+
// budgets:
|
|
150
|
+
// - id: api-monthly
|
|
151
|
+
// scope: repo # total | repo | team
|
|
152
|
+
// repo: api # required when scope is repo
|
|
153
|
+
// monthlyUsd: 150
|
|
154
|
+
// warnAt: 0.8 # optional, default 0.8 — a fraction, same
|
|
155
|
+
// # meaning as `limits[].warnAt`
|
|
156
|
+
//
|
|
157
|
+
// This function is the pure evaluator only: given what was already spent in
|
|
158
|
+
// a scope and its declared cap, decide ok / warn / over. Finding "what was
|
|
159
|
+
// spent" for a repo or a team needs I/O (the store, the sync folder) and
|
|
160
|
+
// lives in src/commands/budget-scopes.js so this module stays dependency-free
|
|
161
|
+
// and unit-testable without a filesystem.
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* @param {{spentUsd?:number, monthlyUsd?:number, warnAt?:number}} [input]
|
|
165
|
+
* @returns {{spentUsd:number, monthlyUsd:number, warnAt:number, share:number, state:'ok'|'warn'|'over'}|null}
|
|
166
|
+
* null when no positive monthly cap was declared for this scope (including
|
|
167
|
+
* when called with nothing at all — the same "no cap" answer).
|
|
168
|
+
*/
|
|
169
|
+
export function scopedBudgetState({ spentUsd, monthlyUsd, warnAt = 0.8 } = {}) {
|
|
170
|
+
if (!(monthlyUsd > 0)) return null;
|
|
171
|
+
const spent = spentUsd > 0 ? spentUsd : 0;
|
|
172
|
+
const share = spent / monthlyUsd;
|
|
173
|
+
const state = share >= 1 ? 'over' : share >= warnAt ? 'warn' : 'ok';
|
|
174
|
+
return { spentUsd: spent, monthlyUsd, warnAt, share, state };
|
|
175
|
+
}
|
package/src/core/bundle.js
CHANGED
|
@@ -13,9 +13,48 @@ import { Store, readJson } from './store.js';
|
|
|
13
13
|
import { paths, loadConfig } from './config.js';
|
|
14
14
|
import { summarizeQuality } from './validate.js';
|
|
15
15
|
import { tzOffsetMinutes } from './schema.js';
|
|
16
|
-
import { PRICING_TABLE_VERSION, PRICING_SOURCES, TIER_MULTIPLIERS } from './pricing.js';
|
|
16
|
+
import { PRICING_TABLE_VERSION, PRICING_SOURCES, TIER_MULTIPLIERS, buildPriceBook } from './pricing.js';
|
|
17
|
+
import { decodeRecord } from './store.js';
|
|
18
|
+
import { MEASUREMENT } from './schema.js';
|
|
19
|
+
import { createReceiptBuilder } from '../analytics/receipt.js';
|
|
20
|
+
import { makeRepoResolver } from './repo.js';
|
|
21
|
+
import { readAnnotations } from './annotations.js';
|
|
17
22
|
|
|
18
|
-
|
|
23
|
+
/**
|
|
24
|
+
* Receipts for the whole store, attributed per repository and branch. A full
|
|
25
|
+
* record scan costs seconds on a large store, so the result is cached against
|
|
26
|
+
* the store's refresh stamp and streamed through the builder rather than
|
|
27
|
+
* materialized. Shipped in the bundle so the offline snapshot has them too.
|
|
28
|
+
*/
|
|
29
|
+
let receiptsCache = { key: null, value: null };
|
|
30
|
+
export function buildReceiptsForStore(store, pricing) {
|
|
31
|
+
const key = `${store.state.lastRefresh || ''}|${store.state.records || ''}|${JSON.stringify(pricing || {})}`;
|
|
32
|
+
if (receiptsCache.key === key) return receiptsCache.value;
|
|
33
|
+
const t0 = Date.now();
|
|
34
|
+
const builder = createReceiptBuilder({ book: buildPriceBook(pricing || {}), repoOf: makeRepoResolver() });
|
|
35
|
+
store.scanRecords((o) => {
|
|
36
|
+
if (o.ms !== MEASUREMENT.PRIMARY) return;
|
|
37
|
+
builder.add(decodeRecord(o));
|
|
38
|
+
});
|
|
39
|
+
const r = builder.finish();
|
|
40
|
+
const value = {
|
|
41
|
+
...r,
|
|
42
|
+
computedAt: new Date().toISOString(),
|
|
43
|
+
computeMs: Date.now() - t0,
|
|
44
|
+
// The dashboard shows the whole store; the PR join stays a CLI concern
|
|
45
|
+
// (`tokenflow receipt --gh`) until cached PR lists exist.
|
|
46
|
+
scope: 'store',
|
|
47
|
+
};
|
|
48
|
+
receiptsCache = { key, value };
|
|
49
|
+
return value;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* @param {{config?:object, receipts?:boolean}} [opt] `receipts: false` skips the
|
|
54
|
+
* whole-store receipt scan; the watcher's status snapshot and one-line CLI
|
|
55
|
+
* summaries never read them, so they should not pay seconds for them.
|
|
56
|
+
*/
|
|
57
|
+
export function buildBundle({ config = loadConfig(), receipts = true } = {}) {
|
|
19
58
|
const store = new Store();
|
|
20
59
|
const p = paths();
|
|
21
60
|
const cube = store.cube();
|
|
@@ -85,6 +124,10 @@ export function buildBundle({ config = loadConfig() } = {}) {
|
|
|
85
124
|
// against the same cube every other surface reads.
|
|
86
125
|
limits: Array.isArray(config.limits) ? config.limits : [],
|
|
87
126
|
health,
|
|
127
|
+
receipts: receipts ? buildReceiptsForStore(store, pricing) : null,
|
|
128
|
+
// User-marked calendar days ("switched to Opus 5"), drawn on every daily
|
|
129
|
+
// chart. A missing annotations.json yields an empty list, never a throw.
|
|
130
|
+
annotations: readAnnotations().items,
|
|
88
131
|
};
|
|
89
132
|
}
|
|
90
133
|
|
package/src/core/ingest.js
CHANGED
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
*/
|
|
14
14
|
import fs from 'node:fs';
|
|
15
15
|
import os from 'node:os';
|
|
16
|
+
import path from 'node:path';
|
|
16
17
|
import { Store, encodeRecord, decodeRecord, fileId, compactShards } from './store.js';
|
|
17
18
|
import { createRecord, dateParts, hashId, MEASUREMENT, INTERFACE } from './schema.js';
|
|
18
19
|
import { classifyModel, BUILTIN_MODEL_RULES } from './model-map.js';
|
|
@@ -21,6 +22,7 @@ import { buildPriceBook, estimateCost } from './pricing.js';
|
|
|
21
22
|
import { validateUsage } from './validate.js';
|
|
22
23
|
import { loadConfig, paths } from './config.js';
|
|
23
24
|
import { readJson, writeJson, truncateFile } from './store.js';
|
|
25
|
+
import { repoRootOf } from './repo.js';
|
|
24
26
|
|
|
25
27
|
/**
|
|
26
28
|
* @param {object} opt
|
|
@@ -360,6 +362,37 @@ export function enrich(partial, { ctx, provider, seq = 0, fileRef = null }) {
|
|
|
360
362
|
metadata: partial.metadata || {},
|
|
361
363
|
};
|
|
362
364
|
|
|
365
|
+
// Repository identity from the recorded cwd: a session in
|
|
366
|
+
// `<repo>/.worktrees/<x>` (or any dir whose `.git` points back into a main
|
|
367
|
+
// checkout's `.git/worktrees/`) is otherwise filed under `x`, splitting one
|
|
368
|
+
// repository's spend across every worktree it ever had. `repoRootOf` walks
|
|
369
|
+
// up to the main checkout; `project` always moves to its basename.
|
|
370
|
+
// `repository` follows only when the adapter derived it the same way the
|
|
371
|
+
// broken `project` was derived — null, or equal to the adapter's own
|
|
372
|
+
// (pre-correction) `project` value, which is how anthropic and git-less
|
|
373
|
+
// Codex sessions set it (basename of cwd). A `repository` that DIFFERS from
|
|
374
|
+
// that — e.g. openai's `session_meta.git`-derived name — came from real
|
|
375
|
+
// evidence (a git remote), not a guess from the directory name, and is left
|
|
376
|
+
// alone even though the checkout dir the session ran in has another name.
|
|
377
|
+
// When no repository root is found (cwd outside any git checkout, or
|
|
378
|
+
// missing), the adapter's own values are kept as-is, and `repoResolved`
|
|
379
|
+
// records which case this was so the doctor can find sessions that were
|
|
380
|
+
// never matched to a repo.
|
|
381
|
+
const cwd = base.metadata && base.metadata.cwd;
|
|
382
|
+
if (cwd) {
|
|
383
|
+
ctx.repoCache = ctx.repoCache || new Map();
|
|
384
|
+
const root = repoRootOf(cwd, ctx.repoCache);
|
|
385
|
+
if (root) {
|
|
386
|
+
const name = path.basename(root);
|
|
387
|
+
const derivedFromDir = base.repository === null || base.repository === undefined || base.repository === base.project;
|
|
388
|
+
base.project = name;
|
|
389
|
+
if (derivedFromDir) base.repository = name;
|
|
390
|
+
base.metadata = { ...base.metadata, repoResolved: true };
|
|
391
|
+
} else {
|
|
392
|
+
base.metadata = { ...base.metadata, repoResolved: false };
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
|
|
363
396
|
// Cost: a measured cost from the source always wins; otherwise estimate,
|
|
364
397
|
// and leave null when the model has no configured price.
|
|
365
398
|
if (partial.measured_cost !== undefined && partial.measured_cost !== null) {
|
package/src/core/live-status.js
CHANGED
|
@@ -19,10 +19,13 @@ import { buildBundle } from './bundle.js';
|
|
|
19
19
|
import { computeView } from '../analytics/index.js';
|
|
20
20
|
import { filterCube, filterSessions, indexCube, rank, finalize, sumRows, weekStart, addDays } from '../analytics/aggregate.js';
|
|
21
21
|
import { loadConfig, paths, ensureDirs } from './config.js';
|
|
22
|
-
import { readJson } from './store.js';
|
|
22
|
+
import { readJson, Store, decodeRecord } from './store.js';
|
|
23
23
|
import { compact, usd, countdown } from './units.js';
|
|
24
24
|
import { detectMilestones } from '../analytics/milestones.js';
|
|
25
25
|
import { lockIsLive, readLock } from './watch-lock.js';
|
|
26
|
+
import { evaluateGuard, createReceiptBuilder } from '../analytics/receipt.js';
|
|
27
|
+
import { buildPriceBook } from './pricing.js';
|
|
28
|
+
import { MEASUREMENT } from './schema.js';
|
|
26
29
|
|
|
27
30
|
// Formatting adapters over the shared units.js formatters (which the browser
|
|
28
31
|
// bundle also uses): null means "nothing to show", never "—", never 0.
|
|
@@ -57,7 +60,7 @@ function usageSlice(m, extra = {}) {
|
|
|
57
60
|
*/
|
|
58
61
|
export function buildLiveStatus(opt = {}) {
|
|
59
62
|
const config = opt.config || loadConfig();
|
|
60
|
-
const b = opt.bundle || buildBundle({ config });
|
|
63
|
+
const b = opt.bundle || buildBundle({ config, receipts: false });
|
|
61
64
|
const nowMs = opt.nowMs ?? Date.now();
|
|
62
65
|
const v = computeView(b, {});
|
|
63
66
|
const ix = indexCube(b.cube);
|
|
@@ -208,6 +211,19 @@ export function buildLiveStatus(opt = {}) {
|
|
|
208
211
|
const ageMs = lastRefresh ? Math.max(0, nowMs - new Date(lastRefresh).getTime()) : null;
|
|
209
212
|
const staleAfterMs = (config.watch?.staleAfterSeconds ?? 600) * 1000;
|
|
210
213
|
|
|
214
|
+
// A second, record-level pass: the cube above is pre-aggregated and carries
|
|
215
|
+
// no session id, branch or per-turn guard verdict, so live sessions,
|
|
216
|
+
// today's receipts, guard state and sparklines are derived straight from
|
|
217
|
+
// the store's shard files rather than from `b.cube`.
|
|
218
|
+
const recent = buildRecentActivity({
|
|
219
|
+
pricing: b.pricing,
|
|
220
|
+
guardPolicy: config.guard || {},
|
|
221
|
+
referenceMs: lastRefresh ? new Date(lastRefresh).getTime() : nowMs,
|
|
222
|
+
lastRefresh,
|
|
223
|
+
today,
|
|
224
|
+
tzOffsetMinutes,
|
|
225
|
+
});
|
|
226
|
+
|
|
211
227
|
return {
|
|
212
228
|
schema: STATUS_SCHEMA,
|
|
213
229
|
generatedAt: new Date(nowMs).toISOString(),
|
|
@@ -253,6 +269,10 @@ export function buildLiveStatus(opt = {}) {
|
|
|
253
269
|
})),
|
|
254
270
|
firstSeen: v.firstSeen,
|
|
255
271
|
insights: v.insights.slice(0, 3).map((i) => ({ icon: i.icon, text: i.text })),
|
|
272
|
+
liveSessions: recent.liveSessions,
|
|
273
|
+
receiptsToday: recent.receiptsToday,
|
|
274
|
+
guard: recent.guard,
|
|
275
|
+
sparklines: recent.sparklines,
|
|
256
276
|
};
|
|
257
277
|
}
|
|
258
278
|
|
|
@@ -279,6 +299,211 @@ function trimLimitState(s) {
|
|
|
279
299
|
};
|
|
280
300
|
}
|
|
281
301
|
|
|
302
|
+
// ------------------------------------------------------- recent activity ----
|
|
303
|
+
|
|
304
|
+
/** Sessions "live" within this many minutes of `asOf` show up in `liveSessions`. */
|
|
305
|
+
const LIVE_WINDOW_MINUTES = 10;
|
|
306
|
+
/** Hourly sparkline depth. */
|
|
307
|
+
const SPARK_HOURS = 24;
|
|
308
|
+
/** `liveSessions.sessions` is capped here — a menu bar row, not a table. */
|
|
309
|
+
const MAX_LIVE_SESSIONS = 8;
|
|
310
|
+
|
|
311
|
+
/** `YYYY-MM` shard key a UTC instant falls into, in a timezone `offsetMinutes` east of UTC. */
|
|
312
|
+
function monthKeyOf(ms, offsetMs) {
|
|
313
|
+
return new Date(ms + offsetMs).toISOString().slice(0, 7);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/**
|
|
317
|
+
* Start-of-local-hour instant (as a UTC epoch ms) containing `ms`, in a
|
|
318
|
+
* timezone `offsetMs` (== tzOffsetMinutes*60000) east of UTC. Like the
|
|
319
|
+
* hour-granular windows above, this is a fixed-offset approximation — a
|
|
320
|
+
* timezone whose offset changes (DST) mid-window is not modelled.
|
|
321
|
+
*/
|
|
322
|
+
function hourStartMs(ms, offsetMs) {
|
|
323
|
+
return Math.floor((ms + offsetMs) / 3600000) * 3600000 - offsetMs;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
const GUARD_LEVEL_RANK = { ok: 0, warn: 1, block: 2 };
|
|
327
|
+
|
|
328
|
+
/**
|
|
329
|
+
* Most recently modified guard-cache file's verdict, if the cache stores one.
|
|
330
|
+
*
|
|
331
|
+
* As of this writing `tokenflow guard`'s cache (`$TOKENFLOW_HOME/guard/*.json`,
|
|
332
|
+
* see `src/commands/guard.js`) persists `{offset, state, records, updated}` —
|
|
333
|
+
* no verdict — so this always falls through to `null` today; the read stays
|
|
334
|
+
* here so a future cache format that adds one is picked up without a change
|
|
335
|
+
* here, and `lastVerdict.source` tells a reader which path produced it.
|
|
336
|
+
* @returns {{level:string, sessionId:string|null, at:string|null, reasons:string[], source:'cache'}|null}
|
|
337
|
+
*/
|
|
338
|
+
function readGuardCacheVerdict() {
|
|
339
|
+
const dir = path.join(paths().root, 'guard');
|
|
340
|
+
let files;
|
|
341
|
+
try { files = fs.readdirSync(dir).filter((f) => f.endsWith('.json')); } catch { return null; }
|
|
342
|
+
let latest = null;
|
|
343
|
+
let latestMtime = -1;
|
|
344
|
+
for (const f of files) {
|
|
345
|
+
let st;
|
|
346
|
+
try { st = fs.statSync(path.join(dir, f)); } catch { continue; }
|
|
347
|
+
if (st.mtimeMs > latestMtime) { latestMtime = st.mtimeMs; latest = f; }
|
|
348
|
+
}
|
|
349
|
+
if (!latest) return null;
|
|
350
|
+
const data = readJson(path.join(dir, latest), null);
|
|
351
|
+
if (!data || !data.verdict) return null;
|
|
352
|
+
const v = data.verdict;
|
|
353
|
+
return {
|
|
354
|
+
level: v.level ?? 'ok',
|
|
355
|
+
sessionId: v.sessionId ?? data.records?.[0]?.session_id ?? null,
|
|
356
|
+
at: data.updated ?? null,
|
|
357
|
+
reasons: Array.isArray(v.reasons) ? v.reasons : [],
|
|
358
|
+
source: 'cache',
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
/**
|
|
363
|
+
* Live sessions, today's receipts, guard state and hourly sparklines.
|
|
364
|
+
*
|
|
365
|
+
* A second, record-level scan of the store (the cube `buildLiveStatus` reads
|
|
366
|
+
* above is pre-aggregated and has no session id, branch or per-turn guard
|
|
367
|
+
* verdict). Restricted to the shards the window can touch — current +
|
|
368
|
+
* previous month by `referenceMs`, plus `today`'s month if a stale
|
|
369
|
+
* `lastRefresh` has drifted away from it — and within those shards, to
|
|
370
|
+
* records timestamped in the last `SPARK_HOURS` hours (sessions + sparklines)
|
|
371
|
+
* or dated `today` in the dataset timezone (receipts).
|
|
372
|
+
*
|
|
373
|
+
* "Live" and every `asOf` here anchor on `referenceMs` (the store's last
|
|
374
|
+
* refresh, falling back to the current clock only when it has never
|
|
375
|
+
* refreshed) rather than the real clock: a reader of this file is only ever
|
|
376
|
+
* as current as the last completed refresh, and the field name says so
|
|
377
|
+
* rather than quietly assuming "now".
|
|
378
|
+
*
|
|
379
|
+
* @param {{pricing:object, guardPolicy:object, referenceMs:number,
|
|
380
|
+
* lastRefresh:string|null, today:string, tzOffsetMinutes:number}} opt
|
|
381
|
+
*/
|
|
382
|
+
export function buildRecentActivity(opt) {
|
|
383
|
+
const { pricing, guardPolicy, referenceMs, lastRefresh, today, tzOffsetMinutes: tzOff } = opt;
|
|
384
|
+
const offsetMs = (tzOff || 0) * 60000;
|
|
385
|
+
const book = buildPriceBook(pricing || {});
|
|
386
|
+
const store = new Store();
|
|
387
|
+
|
|
388
|
+
const firstBucket = hourStartMs(referenceMs, offsetMs) - (SPARK_HOURS - 1) * 3600000;
|
|
389
|
+
const liveCutoffMs = referenceMs - LIVE_WINDOW_MINUTES * 60000;
|
|
390
|
+
const months = [...new Set([
|
|
391
|
+
monthKeyOf(firstBucket, offsetMs),
|
|
392
|
+
monthKeyOf(referenceMs, offsetMs),
|
|
393
|
+
today.slice(0, 7),
|
|
394
|
+
])];
|
|
395
|
+
|
|
396
|
+
/** @type {Map<string, {tokens:number[], cost:number[]}>} */
|
|
397
|
+
const perSource = new Map();
|
|
398
|
+
const sessions = new Map();
|
|
399
|
+
const receiptBuilder = createReceiptBuilder({
|
|
400
|
+
book,
|
|
401
|
+
repoOf: (rec) => {
|
|
402
|
+
const raw = rec.repository || rec.project;
|
|
403
|
+
return raw ? path.basename(raw) : null;
|
|
404
|
+
},
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
store.scanRecords((o) => {
|
|
408
|
+
if (o.ms !== MEASUREMENT.PRIMARY) return;
|
|
409
|
+
const ts = Date.parse(o.ts);
|
|
410
|
+
if (!Number.isFinite(ts)) return;
|
|
411
|
+
if (o.d === today) receiptBuilder.add(decodeRecord(o));
|
|
412
|
+
|
|
413
|
+
if (ts < firstBucket || ts > referenceMs) return;
|
|
414
|
+
const src = o.so || 'unknown';
|
|
415
|
+
let sp = perSource.get(src);
|
|
416
|
+
if (!sp) { sp = { tokens: new Array(SPARK_HOURS).fill(0), cost: new Array(SPARK_HOURS).fill(0) }; perSource.set(src, sp); }
|
|
417
|
+
const idx = Math.round((hourStartMs(ts, offsetMs) - firstBucket) / 3600000);
|
|
418
|
+
if (idx >= 0 && idx < SPARK_HOURS) {
|
|
419
|
+
sp.tokens[idx] += (o.in || 0) + (o.ou || 0) + (o.cr || 0) + (o.cw || 0);
|
|
420
|
+
if (o.co !== null && o.co !== undefined && o.cb !== 'measured') sp.cost[idx] += o.co;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
if (!o.s) return; // no session id: still in the sparklines/receipts above, never a "live session"
|
|
424
|
+
let e = sessions.get(o.s);
|
|
425
|
+
if (!e) { e = { records: [], maxTs: -Infinity }; sessions.set(o.s, e); }
|
|
426
|
+
e.records.push(decodeRecord(o));
|
|
427
|
+
if (ts > e.maxTs) e.maxTs = ts;
|
|
428
|
+
}, { months });
|
|
429
|
+
|
|
430
|
+
// ---- live sessions ----------------------------------------------------
|
|
431
|
+
const liveEntries = [...sessions.entries()]
|
|
432
|
+
.filter(([, e]) => e.maxTs >= liveCutoffMs)
|
|
433
|
+
.sort((a, b) => b[1].maxTs - a[1].maxTs)
|
|
434
|
+
.slice(0, MAX_LIVE_SESSIONS);
|
|
435
|
+
|
|
436
|
+
const liveSessionsList = liveEntries.map(([sid, e]) => {
|
|
437
|
+
const recs = e.records.slice().sort((a, b) => (a.timestamp < b.timestamp ? -1 : a.timestamp > b.timestamp ? 1 : 0));
|
|
438
|
+
const last = recs[recs.length - 1];
|
|
439
|
+
const v = evaluateGuard(recs, guardPolicy || {}, book);
|
|
440
|
+
return {
|
|
441
|
+
sessionId: sid,
|
|
442
|
+
source: last.source ?? null,
|
|
443
|
+
provider: last.provider ?? null,
|
|
444
|
+
model: last.model ?? null,
|
|
445
|
+
project: last.project ?? null,
|
|
446
|
+
repository: last.repository ? path.basename(last.repository) : null,
|
|
447
|
+
branch: last.git_branch ?? null,
|
|
448
|
+
startedAt: v.first,
|
|
449
|
+
lastActivityAt: v.last,
|
|
450
|
+
turns: v.turns,
|
|
451
|
+
subagentTurns: v.subagentTurns,
|
|
452
|
+
costUsd: v.cost,
|
|
453
|
+
coverage: v.coverage,
|
|
454
|
+
contextTokens: v.contextTokens,
|
|
455
|
+
contextShare: v.contextShare,
|
|
456
|
+
guard: { level: v.level, reasons: v.reasons, declared: v.declared },
|
|
457
|
+
};
|
|
458
|
+
});
|
|
459
|
+
|
|
460
|
+
// ---- today's receipts ---------------------------------------------------
|
|
461
|
+
const receipts = receiptBuilder.finish();
|
|
462
|
+
const items = [];
|
|
463
|
+
for (const R of receipts.repos) {
|
|
464
|
+
for (const br of R.branches) items.push({ repo: R.repo, branch: br.key, costUsd: br.cost, turns: br.turns, sessions: br.sessions });
|
|
465
|
+
if (R.unattributed.turns > 0) {
|
|
466
|
+
items.push({ repo: R.repo, branch: null, costUsd: R.unattributed.cost, turns: R.unattributed.turns, sessions: R.unattributed.sessions });
|
|
467
|
+
}
|
|
468
|
+
}
|
|
469
|
+
items.sort((a, b) => (b.costUsd ?? -1) - (a.costUsd ?? -1));
|
|
470
|
+
|
|
471
|
+
// ---- guard ----------------------------------------------------------------
|
|
472
|
+
const numOrNull = (v) => (typeof v === 'number' && Number.isFinite(v) && v > 0 ? v : null);
|
|
473
|
+
const gp = guardPolicy || {};
|
|
474
|
+
const policy = {
|
|
475
|
+
warnCostUsd: numOrNull(gp.warnCostUsd),
|
|
476
|
+
maxCostUsd: numOrNull(gp.maxCostUsd),
|
|
477
|
+
warnContextTokens: numOrNull(gp.warnContextTokens),
|
|
478
|
+
maxContextTokens: numOrNull(gp.maxContextTokens),
|
|
479
|
+
warnMarginalUsd: numOrNull(gp.warnMarginalUsd),
|
|
480
|
+
};
|
|
481
|
+
const declared = Object.values(policy).some((val) => val !== null);
|
|
482
|
+
|
|
483
|
+
/** @type {{level:string, sessionId:string|null, at:string|null, reasons:string[], source:'cache'|'derived'}|null} */
|
|
484
|
+
let lastVerdict = readGuardCacheVerdict();
|
|
485
|
+
if (!lastVerdict) {
|
|
486
|
+
let worst = null;
|
|
487
|
+
for (const s of liveSessionsList) {
|
|
488
|
+
if (!worst || GUARD_LEVEL_RANK[s.guard.level] > GUARD_LEVEL_RANK[worst.guard.level]) worst = s;
|
|
489
|
+
}
|
|
490
|
+
lastVerdict = worst
|
|
491
|
+
? { level: worst.guard.level, sessionId: worst.sessionId, at: worst.lastActivityAt, reasons: worst.guard.reasons, source: 'derived' }
|
|
492
|
+
: null;
|
|
493
|
+
}
|
|
494
|
+
|
|
495
|
+
return {
|
|
496
|
+
liveSessions: { asOf: lastRefresh, windowMinutes: LIVE_WINDOW_MINUTES, sessions: liveSessionsList },
|
|
497
|
+
receiptsToday: { asOf: lastRefresh, totalCostUsd: receipts.totals.cost, items: items.slice(0, 3) },
|
|
498
|
+
guard: { policy, declared, lastVerdict },
|
|
499
|
+
sparklines: {
|
|
500
|
+
hours: Array.from({ length: SPARK_HOURS }, (_, i) => new Date(firstBucket + i * 3600000).toISOString()),
|
|
501
|
+
bySource: Object.fromEntries([...perSource].map(([k, v]) => [k, v.tokens])),
|
|
502
|
+
costBySource: Object.fromEntries([...perSource].map(([k, v]) => [k, v.cost.map((n) => Math.round(n * 100) / 100)])),
|
|
503
|
+
},
|
|
504
|
+
};
|
|
505
|
+
}
|
|
506
|
+
|
|
282
507
|
/** Atomic write: tmp file + rename, so readers never see a half-file. */
|
|
283
508
|
export function writeLiveStatus(status) {
|
|
284
509
|
const p = paths();
|