@vimoxshah/tokenflow 1.1.2 → 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.
Files changed (86) hide show
  1. package/CHANGELOG.md +180 -0
  2. package/Dockerfile.team +20 -0
  3. package/README.md +30 -11
  4. package/bin/tokenflow.js +147 -12
  5. package/design/tokens.yaml +330 -0
  6. package/docs/architecture.md +5 -4
  7. package/docs/cli.md +204 -0
  8. package/docs/configuration.md +117 -2
  9. package/docs/design-system.md +187 -0
  10. package/docs/exports-and-budgets.md +85 -0
  11. package/docs/guard-codex.md +132 -0
  12. package/docs/ledger.md +144 -0
  13. package/docs/live-mode.md +40 -0
  14. package/docs/media/overview-aurora-dark.png +0 -0
  15. package/docs/media/receipts-aurora-dark.png +0 -0
  16. package/docs/providers-otel.md +179 -0
  17. package/docs/providers.md +54 -1
  18. package/docs/receipt-schema.md +74 -0
  19. package/docs/roadmap.md +182 -0
  20. package/docs/team-server.md +170 -0
  21. package/docs/ui-views.md +322 -0
  22. package/package.json +7 -2
  23. package/schemas/receipt.v0.json +160 -0
  24. package/scripts/build-menubar-app.sh +3 -1
  25. package/scripts/design-build.js +475 -0
  26. package/src/analytics/anatomy.js +467 -0
  27. package/src/analytics/branch-compare.js +159 -0
  28. package/src/analytics/cache-health.js +141 -0
  29. package/src/analytics/live-view.js +266 -0
  30. package/src/analytics/receipt-schema.js +214 -0
  31. package/src/analytics/receipt.js +709 -0
  32. package/src/analytics/rhythm.js +184 -0
  33. package/src/analytics/whatif.js +263 -0
  34. package/src/commands/budget-scopes.js +133 -0
  35. package/src/commands/doctor-checks.js +400 -0
  36. package/src/commands/guard.js +531 -0
  37. package/src/commands/hooks.js +238 -0
  38. package/src/commands/pricing-diff.js +316 -0
  39. package/src/commands/receipt.js +226 -0
  40. package/src/commands/team-serve.js +407 -0
  41. package/src/commands/week.js +86 -0
  42. package/src/core/annotations.js +97 -0
  43. package/src/core/budget.js +33 -0
  44. package/src/core/bundle.js +45 -2
  45. package/src/core/ingest.js +33 -0
  46. package/src/core/live-status.js +227 -2
  47. package/src/core/policy.js +103 -0
  48. package/src/core/receipt-note.js +123 -0
  49. package/src/core/repo.js +64 -0
  50. package/src/core/sync.js +163 -26
  51. package/src/core/team.js +0 -0
  52. package/src/export/html-snapshot.js +28 -1
  53. package/src/export/menubar.js +21 -0
  54. package/src/export/receipt-card.js +210 -0
  55. package/src/export/week-card.js +185 -0
  56. package/src/providers/mock/index.js +383 -52
  57. package/src/providers/openai/index.js +31 -1
  58. package/src/providers/otel/index.js +656 -0
  59. package/src/server/routes/annotations.js +42 -0
  60. package/src/server/routes/cache-health.js +95 -0
  61. package/src/server/routes/index.js +54 -0
  62. package/src/server/routes/session.js +157 -0
  63. package/src/server/server.js +47 -1
  64. package/src/ui/app.js +541 -308
  65. package/src/ui/charts.js +95 -0
  66. package/src/ui/first-run.js +144 -0
  67. package/src/ui/index.html +4 -1
  68. package/src/ui/palette.js +335 -0
  69. package/src/ui/styles/anatomy.css +117 -0
  70. package/src/ui/styles/annotations.css +40 -0
  71. package/src/ui/styles/branches.css +99 -0
  72. package/src/ui/styles/cache.css +6 -0
  73. package/src/ui/styles/first-run.css +31 -0
  74. package/src/ui/styles/live.css +100 -0
  75. package/src/ui/styles/palette.css +85 -0
  76. package/src/ui/styles/rhythm.css +8 -0
  77. package/src/ui/styles/whatif.css +55 -0
  78. package/src/ui/styles.css +303 -196
  79. package/src/ui/views/anatomy.js +567 -0
  80. package/src/ui/views/annotations.js +121 -0
  81. package/src/ui/views/branches.js +304 -0
  82. package/src/ui/views/cache.js +232 -0
  83. package/src/ui/views/index.js +85 -0
  84. package/src/ui/views/live.js +683 -0
  85. package/src/ui/views/rhythm.js +206 -0
  86. 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
+ }
@@ -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
+ }
@@ -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
- export function buildBundle({ config = loadConfig() } = {}) {
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
 
@@ -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) {
@@ -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();