savemytokens 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.
Files changed (55) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +164 -0
  3. package/dist/adapters/claude-code/index.js +130 -0
  4. package/dist/adapters/claude-code/merge.js +90 -0
  5. package/dist/adapters/claude-code/parse.js +642 -0
  6. package/dist/adapters/claude-code/provider.js +105 -0
  7. package/dist/adapters/codex/index.js +74 -0
  8. package/dist/adapters/codex/parse.js +389 -0
  9. package/dist/adapters/codex/provider.js +171 -0
  10. package/dist/adapters/index.js +10 -0
  11. package/dist/adapters/pending.js +29 -0
  12. package/dist/adapters/types.js +1 -0
  13. package/dist/analyze/aggregate.js +180 -0
  14. package/dist/analyze/combine.js +11 -0
  15. package/dist/analyze/detectors.js +244 -0
  16. package/dist/analyze/index.js +29 -0
  17. package/dist/analyze/score.js +20 -0
  18. package/dist/cli-options.js +149 -0
  19. package/dist/cli.js +141 -0
  20. package/dist/collect.js +62 -0
  21. package/dist/commands/audit.js +74 -0
  22. package/dist/commands/control.js +654 -0
  23. package/dist/commands/hud.js +71 -0
  24. package/dist/commands/install.js +369 -0
  25. package/dist/commands/policy.js +93 -0
  26. package/dist/commands/privacy.js +28 -0
  27. package/dist/commands/set.js +83 -0
  28. package/dist/commands/theme.js +136 -0
  29. package/dist/commands/watch.js +135 -0
  30. package/dist/core/cost.js +24 -0
  31. package/dist/core/hash.js +0 -0
  32. package/dist/core/pricing.js +63 -0
  33. package/dist/core/resource.js +1 -0
  34. package/dist/core/tokens.js +32 -0
  35. package/dist/core/types.js +1 -0
  36. package/dist/hooks/nudge.js +111 -0
  37. package/dist/hooks/rules.js +14 -0
  38. package/dist/privacy/payload.js +22 -0
  39. package/dist/report/graph.js +162 -0
  40. package/dist/report/graphs.js +61 -0
  41. package/dist/report/render.js +183 -0
  42. package/dist/report/schedule.js +143 -0
  43. package/dist/report/settings.js +237 -0
  44. package/dist/report/views.js +418 -0
  45. package/dist/runtime/hook.mjs +234 -0
  46. package/dist/runtime/kernel.mjs +1472 -0
  47. package/dist/runtime/statusline.mjs +243 -0
  48. package/dist/scheduler/keys.js +112 -0
  49. package/dist/scheduler/plan.js +287 -0
  50. package/dist/storage/cache.js +38 -0
  51. package/dist/storage/paths.js +29 -0
  52. package/dist/storage/store.js +48 -0
  53. package/dist/util/ansi.js +35 -0
  54. package/dist/util/fmt.js +76 -0
  55. package/package.json +51 -0
@@ -0,0 +1,1472 @@
1
+ import fs from "node:fs";
2
+ import os from "node:os";
3
+ import path from "node:path";
4
+
5
+ export const HOME = process.env.SAVEMYTOKENS_HOME || path.join(os.homedir(), ".savemytokens");
6
+ export const CLAIMANT_DIR = path.join(HOME, "claimants");
7
+ export const METER_DIR = path.join(HOME, "meter");
8
+ export const QUOTA_DIR = path.join(HOME, "quota");
9
+ export const THEME_DIR = path.join(HOME, "themes");
10
+ export const HOOKS_DIR = path.join(HOME, "hooks");
11
+ export const DEFER_DIR = path.join(HOME, "deferred");
12
+ export const PROJECT_DIR = path.join(HOME, "projects");
13
+ export const CONFIG_FILE = path.join(HOME, "config.json");
14
+
15
+ export const FIVE_HOUR_MS = 5 * 60 * 60 * 1000;
16
+ export const SEVEN_DAY_MS = 7 * 24 * 60 * 60 * 1000;
17
+ export const WINDOW_MS = { five_hour: FIVE_HOUR_MS, seven_day: SEVEN_DAY_MS, spend_limit: SEVEN_DAY_MS };
18
+ export const WINDOW_LABEL = { five_hour: "5h", seven_day: "7d", spend_limit: "spend" };
19
+
20
+ const BUCKET_MS = 5 * 60 * 1000;
21
+ const RETENTION_MS = 9 * 24 * 60 * 60 * 1000;
22
+ const CLAIMANT_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
23
+ const SEEN_LIMIT = 400;
24
+ const LOCKOUT_GAP_MS = 5 * 60 * 1000;
25
+ const STALE_MS = 45 * 60 * 1000;
26
+ const HEARTBEAT_MS = 60 * 1000;
27
+ const RECENT_MS = 24 * 60 * 60 * 1000;
28
+ const DEFER_LIMIT = 12;
29
+ const DEFER_RETENTION_MS = 14 * 24 * 60 * 60 * 1000;
30
+ const CHUNK = 1 << 20;
31
+ const MAX_BACKFILL = 32 * 1024 * 1024;
32
+ const WEIGHTS = { input: 1, output: 5, cacheWrite: 1.25, cacheRead: 0.1 };
33
+ const PRIORITY_RANK = { high: 0, normal: 1, low: 2 };
34
+ const EPSILON = 1e-9;
35
+
36
+ export function readJson(file, fallback) {
37
+ try {
38
+ return JSON.parse(fs.readFileSync(file, "utf8"));
39
+ } catch {
40
+ return fallback;
41
+ }
42
+ }
43
+
44
+ export function writeJson(file, value) {
45
+ try {
46
+ fs.mkdirSync(path.dirname(file), { recursive: true });
47
+ const tmp = `${file}.${process.pid}.tmp`;
48
+ fs.writeFileSync(tmp, JSON.stringify(value));
49
+ fs.renameSync(tmp, file);
50
+ return true;
51
+ } catch {
52
+ return false;
53
+ }
54
+ }
55
+
56
+ function listJson(dir) {
57
+ try {
58
+ return fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
59
+ } catch {
60
+ return [];
61
+ }
62
+ }
63
+
64
+ export const DEFAULT_CONFIG = {
65
+ version: 1,
66
+ createdAt: 0,
67
+ preferencesSetAt: 0,
68
+ offeredInstallAt: 0,
69
+ primerSeenAt: 0,
70
+ theme: { tui: "default", hud: "default" },
71
+ layout: { hud: "allocation" },
72
+ policy: "finish",
73
+ policyFor: {},
74
+ columns: ["allocation", "used", "priority", "last prompt"],
75
+ hud: { segments: ["project", "pair", "5h", "reset"] },
76
+ preserveFor: {},
77
+ customAdvice: {},
78
+ wrappedStatusLine: null,
79
+ };
80
+
81
+ const COLUMN_RENAMES = { target: "allocation", "of target": "used", used: null, share: "share" };
82
+
83
+ function migrateColumns(stored) {
84
+ if (!Array.isArray(stored) || stored.length === 0) return DEFAULT_CONFIG.columns;
85
+ if (stored.every((name) => COLUMNS.includes(name))) return stored;
86
+ const out = [];
87
+ for (const name of stored) {
88
+ const mapped = name in COLUMN_RENAMES ? COLUMN_RENAMES[name] : name;
89
+ if (mapped && COLUMNS.includes(mapped) && !out.includes(mapped)) out.push(mapped);
90
+ }
91
+ return out.length > 0 ? out : DEFAULT_CONFIG.columns;
92
+ }
93
+
94
+ export function loadConfig() {
95
+ const stored = readJson(CONFIG_FILE, null);
96
+ if (!stored || typeof stored !== "object") return { ...DEFAULT_CONFIG, createdAt: Date.now() };
97
+ return {
98
+ ...DEFAULT_CONFIG,
99
+ ...stored,
100
+ theme: {
101
+ ...DEFAULT_CONFIG.theme,
102
+ ...(stored.theme || {}),
103
+ ...(stored.theme?.tui && THEME_RENAMES[stored.theme.tui] ? { tui: THEME_RENAMES[stored.theme.tui] } : {}),
104
+ ...(stored.theme?.hud && THEME_RENAMES[stored.theme.hud] ? { hud: THEME_RENAMES[stored.theme.hud] } : {}),
105
+ },
106
+ layout: { ...DEFAULT_CONFIG.layout, ...(stored.layout || {}) },
107
+ columns: migrateColumns(stored.columns),
108
+ hud: {
109
+ segments:
110
+ Array.isArray(stored.hud?.segments) && stored.hud.segments.length > 0
111
+ ? stored.hud.segments
112
+ : presetSegments(stored.layout?.hud) ?? DEFAULT_CONFIG.hud.segments,
113
+ },
114
+ policyFor: { ...(stored.policyFor || {}) },
115
+ preserveFor: { ...(stored.preserveFor || {}) },
116
+ customAdvice: { ...(stored.customAdvice || {}) },
117
+ };
118
+ }
119
+
120
+ export function saveConfig(config) {
121
+ return writeJson(CONFIG_FILE, config);
122
+ }
123
+
124
+ export function claimantFile(adapter, id) {
125
+ return path.join(CLAIMANT_DIR, adapter, `${id}.json`);
126
+ }
127
+
128
+ export function meterFile(adapter, id) {
129
+ return path.join(METER_DIR, adapter, `${id}.json`);
130
+ }
131
+
132
+ export function quotaFile(adapter) {
133
+ return path.join(QUOTA_DIR, `${adapter}.json`);
134
+ }
135
+
136
+ function blankClaimant(adapter, id, now) {
137
+ return {
138
+ schema: 1,
139
+ adapter,
140
+ id,
141
+ resourceId: `${adapter}:five_hour`,
142
+ label: "",
143
+ project: "",
144
+ share: null,
145
+ priority: "normal",
146
+ cap: null,
147
+ state: "active",
148
+ startedAt: now,
149
+ lastSeen: now,
150
+ endedAt: null,
151
+ heartbeat: 0,
152
+ pinned: false,
153
+ parked: false,
154
+ prompt: "",
155
+ signal: null,
156
+ advice: { stage: 0, at: 0, window: 0 },
157
+ };
158
+ }
159
+
160
+ export function loadClaimant(adapter, id) {
161
+ return readJson(claimantFile(adapter, id), null);
162
+ }
163
+
164
+ export function upsertClaimant(adapter, id, patch = {}) {
165
+ const now = Date.now();
166
+ const current = loadClaimant(adapter, id) || blankClaimant(adapter, id, now);
167
+ const next = {
168
+ ...blankClaimant(adapter, id, now),
169
+ ...current,
170
+ ...patch,
171
+ adapter,
172
+ id,
173
+ lastSeen: typeof patch.lastSeen === "number" ? patch.lastSeen : now,
174
+ };
175
+ writeJson(claimantFile(adapter, id), next);
176
+ return next;
177
+ }
178
+
179
+ const SEEN_MS = 30 * 24 * 60 * 60 * 1000;
180
+
181
+ function lastPromptIn(file) {
182
+ try {
183
+ const size = fs.statSync(file).size;
184
+ const start = Math.max(0, size - 96 * 1024);
185
+ const handle = fs.openSync(file, "r");
186
+ const buffer = Buffer.alloc(size - start);
187
+ fs.readSync(handle, buffer, 0, buffer.length, start);
188
+ fs.closeSync(handle);
189
+ const lines = buffer.toString("utf8").split("\n");
190
+ for (let at = lines.length - 1; at >= 0; at--) {
191
+ const line = lines[at];
192
+ if (!line || line[0] !== "{") continue;
193
+ let row;
194
+ try {
195
+ row = JSON.parse(line);
196
+ } catch {
197
+ continue;
198
+ }
199
+ if (row.type !== "user" || row.isMeta) continue;
200
+ const content = row.message?.content;
201
+ const text = typeof content === "string" ? content : content?.find?.((part) => part.type === "text")?.text;
202
+ if (typeof text === "string" && text.trim() && !text.startsWith("<")) return text.trim().replace(/\s+/g, " ").slice(0, 200);
203
+ }
204
+ } catch {}
205
+ return "";
206
+ }
207
+
208
+ function cwdOf(file) {
209
+ try {
210
+ const handle = fs.openSync(file, "r");
211
+ const buffer = Buffer.alloc(8192);
212
+ const read = fs.readSync(handle, buffer, 0, buffer.length, 0);
213
+ fs.closeSync(handle);
214
+ for (const line of buffer.toString("utf8", 0, read).split("\n")) {
215
+ if (!line || line[0] !== "{") continue;
216
+ try {
217
+ const row = JSON.parse(line);
218
+ if (typeof row.cwd === "string" && row.cwd) return row.cwd;
219
+ } catch {}
220
+ }
221
+ } catch {}
222
+ return "";
223
+ }
224
+
225
+ export function seenProjects(root, now = Date.now(), limit = 60) {
226
+ const out = [];
227
+ let dirs;
228
+ try {
229
+ dirs = fs.readdirSync(root, { withFileTypes: true }).filter((entry) => entry.isDirectory());
230
+ } catch {
231
+ return out;
232
+ }
233
+ for (const dir of dirs) {
234
+ const full = path.join(root, dir.name);
235
+ let newest = null;
236
+ try {
237
+ for (const name of fs.readdirSync(full)) {
238
+ if (!name.endsWith(".jsonl")) continue;
239
+ const file = path.join(full, name);
240
+ const at = fs.statSync(file).mtimeMs;
241
+ if (!newest || at > newest.at) newest = { file, at };
242
+ }
243
+ } catch {
244
+ continue;
245
+ }
246
+ if (!newest || now - newest.at > SEEN_MS) continue;
247
+ out.push({ dir: full, file: newest.file, at: newest.at });
248
+ }
249
+ out.sort((a, b) => b.at - a.at);
250
+ return out.slice(0, limit).map((entry) => {
251
+ const cwd = cwdOf(entry.file);
252
+ return {
253
+ project: cwd || entry.dir,
254
+ label: cwd ? "" : (path.basename(entry.dir).split("-").filter(Boolean).pop() ?? ""),
255
+ lastSeen: entry.at,
256
+ prompt: lastPromptIn(entry.file),
257
+ };
258
+ });
259
+ }
260
+
261
+ export function loadClaimants(adapter) {
262
+ const dir = path.join(CLAIMANT_DIR, adapter);
263
+ const cutoff = Date.now() - CLAIMANT_RETENTION_MS;
264
+ const out = [];
265
+ for (const name of listJson(dir)) {
266
+ const record = readJson(path.join(dir, name), null);
267
+ if (!record || typeof record !== "object") continue;
268
+ if ((record.lastSeen ?? 0) < cutoff && !record.pinned) {
269
+ try {
270
+ fs.rmSync(path.join(dir, name));
271
+ } catch {}
272
+ continue;
273
+ }
274
+ out.push({ ...blankClaimant(adapter, record.id ?? name.slice(0, -5), record.startedAt ?? 0), ...record });
275
+ }
276
+ return out.sort((a, b) => a.startedAt - b.startedAt);
277
+ }
278
+
279
+ export function heartbeatsLive(claimants, now = Date.now()) {
280
+ let latest = 0;
281
+ for (const claimant of claimants) latest = Math.max(latest, claimant.heartbeat ?? 0);
282
+ return latest > 0 && now - latest <= HEARTBEAT_MS;
283
+ }
284
+
285
+ export function isStale(claimant, now = Date.now(), strict = false) {
286
+ const beat = claimant.heartbeat ?? 0;
287
+ if (now - (claimant.lastSeen ?? 0) <= HEARTBEAT_MS) return false;
288
+ if (beat > 0 || strict) return now - beat > HEARTBEAT_MS;
289
+ return now - (claimant.lastSeen ?? 0) > STALE_MS;
290
+ }
291
+
292
+ export function bucketFor(claimant, now = Date.now(), strict = false) {
293
+ if (claimant.parked) return "parked";
294
+ const state = effectiveState(claimant, now, strict);
295
+ if (state === "active" || state === "needs-more") return "active";
296
+ if (now - (claimant.lastSeen ?? 0) <= RECENT_MS) return "recent";
297
+ return "parked";
298
+ }
299
+
300
+ export function effectiveState(claimant, now = Date.now(), strict = false) {
301
+ if (claimant.state === "done" || claimant.state === "blocked") return claimant.state;
302
+ if (claimant.endedAt) return "done";
303
+ if (isStale(claimant, now, strict)) return "done";
304
+ return claimant.state;
305
+ }
306
+
307
+ export function projectLabel(project) {
308
+ const value = String(project || "");
309
+ const parts = value.split(/[\\/]+/).filter(Boolean);
310
+ return parts[parts.length - 1] || "unknown";
311
+ }
312
+
313
+ export function projectKey(project) {
314
+ return String(project || "unknown").replace(/[^a-zA-Z0-9]/g, "-").slice(-90);
315
+ }
316
+
317
+ function blankProject(project) {
318
+ return { schema: 1, project, label: project ? projectLabel(project) : "unknown", share: null, priority: "normal", cap: null, pinned: false, parked: false, kept: null };
319
+ }
320
+
321
+ export function loadProject(adapter, project) {
322
+ const stored = readJson(path.join(PROJECT_DIR, adapter, `${projectKey(project)}.json`), null);
323
+ const record = { ...blankProject(project), ...(stored && typeof stored === "object" ? stored : {}) };
324
+ if (typeof record.share === "number") {
325
+ const rounded = Math.round(Math.max(0, Math.min(1, record.share)) * 200) / 200;
326
+ record.share = rounded < 0.005 ? 0 : rounded;
327
+ }
328
+ return record;
329
+ }
330
+
331
+ export function upsertProject(adapter, project, patch = {}) {
332
+ const next = { ...loadProject(adapter, project), ...patch, project };
333
+ writeJson(path.join(PROJECT_DIR, adapter, `${projectKey(project)}.json`), next);
334
+ return next;
335
+ }
336
+
337
+ export function loadProjects(adapter) {
338
+ const dir = path.join(PROJECT_DIR, adapter);
339
+ const out = [];
340
+ for (const name of listJson(dir)) {
341
+ const stored = readJson(path.join(dir, name), null);
342
+ if (stored && typeof stored === "object" && stored.project) out.push({ ...blankProject(stored.project), ...stored });
343
+ }
344
+ return out;
345
+ }
346
+
347
+ export function saveQuota(adapter, reading) {
348
+ return writeJson(quotaFile(adapter), reading);
349
+ }
350
+
351
+ export function loadQuota(adapter) {
352
+ const reading = readJson(quotaFile(adapter), null);
353
+ if (!reading || typeof reading !== "object" || !reading.windows) return null;
354
+ return reading;
355
+ }
356
+
357
+ export function liveWindow(reading, key, now = Date.now()) {
358
+ if (!reading) return null;
359
+ const window = reading.windows?.[key];
360
+ if (!window || typeof window.usedPercent !== "number") return null;
361
+ if (typeof window.resetsAt === "number" && window.resetsAt * 1000 <= now) return null;
362
+ return window;
363
+ }
364
+
365
+ export function windowBounds(reading, key, now = Date.now()) {
366
+ const span = WINDOW_MS[key] ?? FIVE_HOUR_MS;
367
+ const window = liveWindow(reading, key, now);
368
+ if (window && typeof window.resetsAt === "number") {
369
+ const to = window.resetsAt * 1000;
370
+ return { from: to - span, to, anchored: true };
371
+ }
372
+ return { from: now - span, to: now, anchored: false };
373
+ }
374
+
375
+ function newMeter(adapter, id) {
376
+ return {
377
+ schema: 1,
378
+ adapter,
379
+ id,
380
+ files: {},
381
+ buckets: [],
382
+ seen: [],
383
+ lockouts: [],
384
+ lastAt: 0,
385
+ meteredAt: 0,
386
+ project: "",
387
+ prompt: "",
388
+ prompts: [],
389
+ signal: null,
390
+ defers: [],
391
+ };
392
+ }
393
+
394
+ export function loadMeter(adapter, id) {
395
+ const record = readJson(meterFile(adapter, id), null);
396
+ if (!record || typeof record !== "object" || !Array.isArray(record.buckets)) return newMeter(adapter, id);
397
+ return { ...newMeter(adapter, id), ...record };
398
+ }
399
+
400
+ function bucketStart(at) {
401
+ return Math.floor(at / BUCKET_MS) * BUCKET_MS;
402
+ }
403
+
404
+ function addUsage(map, at, usage) {
405
+ const key = bucketStart(at);
406
+ const row = map.get(key) || [key, 0, 0, 0, 0, 0];
407
+ row[1] += usage.input;
408
+ row[2] += usage.output;
409
+ row[3] += usage.cacheWrite;
410
+ row[4] += usage.cacheRead;
411
+ row[5] += 1;
412
+ map.set(key, row);
413
+ }
414
+
415
+ function scanLines(file, from, to, onLine, skipFirst = false) {
416
+ let fd;
417
+ try {
418
+ fd = fs.openSync(file, "r");
419
+ } catch {
420
+ return from;
421
+ }
422
+ let position = from;
423
+ let skip = skipFirst;
424
+ let carry = Buffer.alloc(0);
425
+ const buffer = Buffer.allocUnsafe(CHUNK);
426
+ try {
427
+ while (position < to) {
428
+ const want = Math.min(buffer.length, to - position);
429
+ const read = fs.readSync(fd, buffer, 0, want, position);
430
+ if (read <= 0) break;
431
+ position += read;
432
+ let chunk = carry.length > 0 ? Buffer.concat([carry, buffer.subarray(0, read)]) : Buffer.from(buffer.subarray(0, read));
433
+ let start = 0;
434
+ for (;;) {
435
+ const index = chunk.indexOf(0x0a, start);
436
+ if (index === -1) break;
437
+ if (skip) skip = false;
438
+ else onLine(chunk.toString("utf8", start, index));
439
+ start = index + 1;
440
+ }
441
+ carry = chunk.subarray(start);
442
+ }
443
+ } catch {
444
+ } finally {
445
+ try {
446
+ fs.closeSync(fd);
447
+ } catch {}
448
+ }
449
+ return position - carry.length;
450
+ }
451
+
452
+ export function openBuckets(record) {
453
+ return new Map(record.buckets.map((row) => [row[0], [...row]]));
454
+ }
455
+
456
+ export function addSample(buckets, at, usage) {
457
+ addUsage(buckets, at, usage);
458
+ }
459
+
460
+ export function scanNew(record, files, onLine) {
461
+ for (const file of files) {
462
+ let size = 0;
463
+ try {
464
+ size = fs.statSync(file).size;
465
+ } catch {
466
+ continue;
467
+ }
468
+ const previous = record.files[file] ?? 0;
469
+ let from = size < previous ? 0 : previous;
470
+ const truncated = from === 0 && size > MAX_BACKFILL;
471
+ if (truncated) from = size - MAX_BACKFILL;
472
+ if (size <= from) continue;
473
+ record.files[file] = scanLines(file, from, size, onLine, truncated);
474
+ }
475
+ return record;
476
+ }
477
+
478
+ export function commitMeter(adapter, id, record, buckets, fresh, now = Date.now()) {
479
+ const cutoff = now - RETENTION_MS;
480
+ record.buckets = [...buckets.values()].filter((row) => row[0] >= cutoff).sort((a, b) => a[0] - b[0]);
481
+ record.lockouts = record.lockouts.filter((at) => at >= cutoff).slice(-50);
482
+ record.seen = [...record.seen, ...fresh].slice(-SEEN_LIMIT);
483
+ record.meteredAt = now;
484
+ writeJson(meterFile(adapter, id), record);
485
+ return record;
486
+ }
487
+
488
+ function truncatedFile(record, files) {
489
+ for (const file of files) {
490
+ const previous = record.files[file];
491
+ if (previous === undefined) continue;
492
+ try {
493
+ if (fs.statSync(file).size < previous) return true;
494
+ } catch {
495
+ return true;
496
+ }
497
+ }
498
+ return false;
499
+ }
500
+
501
+ export function sampleFiles(adapter, id, files, now = Date.now()) {
502
+ let record = loadMeter(adapter, id);
503
+ if (truncatedFile(record, files)) {
504
+ record = { ...newMeter(adapter, id), project: record.project, prompts: record.prompts ?? [] };
505
+ }
506
+ const seen = new Set(record.seen);
507
+ const buckets = openBuckets(record);
508
+ let lastLockout = record.lockouts.length > 0 ? record.lockouts[record.lockouts.length - 1] : 0;
509
+ const fresh = [];
510
+
511
+ scanNew(record, files, (line) => {
512
+ if (line.length < 2 || line.charCodeAt(0) !== 123) return;
513
+ const hasUsage = line.includes('"usage"');
514
+ const hasPrompt = line.includes('"promptSource"');
515
+ const hasError = line.includes('"isApiErrorMessage":true');
516
+ const hasSignal = line.includes("SMT:");
517
+ if (!hasUsage && !hasPrompt && !hasError && !hasSignal) return;
518
+ let entry;
519
+ try {
520
+ entry = JSON.parse(line);
521
+ } catch {
522
+ return;
523
+ }
524
+ const at = typeof entry.timestamp === "string" ? Date.parse(entry.timestamp) : NaN;
525
+ const stamp = Number.isFinite(at) ? at : now;
526
+
527
+ if (entry.type === "assistant" && entry.message) {
528
+ const messageId = entry.message.id;
529
+ const usage = entry.message.usage;
530
+ if (usage && messageId && !seen.has(messageId)) {
531
+ seen.add(messageId);
532
+ fresh.push(messageId);
533
+ addUsage(buckets, stamp, {
534
+ input: usage.input_tokens || 0,
535
+ output: usage.output_tokens || 0,
536
+ cacheWrite: usage.cache_creation_input_tokens || 0,
537
+ cacheRead: usage.cache_read_input_tokens || 0,
538
+ });
539
+ if (stamp > record.lastAt) record.lastAt = stamp;
540
+ }
541
+ if (entry.isApiErrorMessage && (entry.quotaLimits || /\blimit\b/i.test(JSON.stringify(entry.message.content ?? "")))) {
542
+ if (stamp - lastLockout > LOCKOUT_GAP_MS) {
543
+ record.lockouts.push(stamp);
544
+ lastLockout = stamp;
545
+ }
546
+ }
547
+ const text = signalIn(entry.message.content);
548
+ if (text) record.signal = text;
549
+ for (const deferred of defersIn(entry.message.content)) {
550
+ if (!record.defers.includes(deferred)) record.defers.push(deferred);
551
+ }
552
+ } else if (entry.type === "user" && typeof entry.message?.content === "string") {
553
+ if (entry.promptSource === "typed" || entry.origin?.kind === "human") {
554
+ const text = entry.message.content.replace(/\s+/g, " ").trim().slice(0, 120);
555
+ record.prompt = text;
556
+ if (!Array.isArray(record.prompts)) record.prompts = [];
557
+ if (text && record.prompts[record.prompts.length - 1] !== text) {
558
+ record.prompts = [...record.prompts, text].slice(-5);
559
+ }
560
+ }
561
+ }
562
+ if (!record.project && typeof entry.cwd === "string") record.project = entry.cwd;
563
+ });
564
+
565
+ record.defers = record.defers.slice(-DEFER_LIMIT);
566
+ return commitMeter(adapter, id, record, buckets, fresh, now);
567
+ }
568
+
569
+ export function consumeSignal(adapter, id) {
570
+ const record = loadMeter(adapter, id);
571
+ const signal = record.signal;
572
+ const defers = record.defers ?? [];
573
+ if (signal || defers.length > 0) writeJson(meterFile(adapter, id), { ...record, signal: null, defers: [] });
574
+ return { signal, defers };
575
+ }
576
+
577
+ function blockText(content) {
578
+ if (typeof content === "string") return content;
579
+ if (!Array.isArray(content)) return "";
580
+ const parts = [];
581
+ for (const block of content) if (typeof block?.text === "string") parts.push(block.text);
582
+ return parts.join("\n");
583
+ }
584
+
585
+ export function trailingSignals(content) {
586
+ const lines = blockText(content)
587
+ .split("\n")
588
+ .map((line) => line.trim())
589
+ .filter(Boolean);
590
+ const block = [];
591
+ for (let index = lines.length - 1; index >= 0; index--) {
592
+ const line = lines[index] ?? "";
593
+ if (!/^SMT:\s*(DONE|NEEDS_MORE|BLOCKED|DEFER\b)/.test(line)) break;
594
+ block.unshift(line);
595
+ }
596
+ let signal = null;
597
+ const defers = [];
598
+ for (const line of block) {
599
+ const state = /^SMT:\s*(DONE|NEEDS_MORE|BLOCKED)$/.exec(line);
600
+ if (state) {
601
+ signal = state[1];
602
+ continue;
603
+ }
604
+ const defer = /^SMT:\s*DEFER\s+(.+)$/.exec(line);
605
+ if (defer) {
606
+ const text = String(defer[1]).replace(/\s+/g, " ").trim().slice(0, 140);
607
+ if (text) defers.push(text);
608
+ }
609
+ }
610
+ return { signal, defers };
611
+ }
612
+
613
+ export function signalIn(content) {
614
+ return trailingSignals(content).signal;
615
+ }
616
+
617
+ export function defersIn(content) {
618
+ return trailingSignals(content).defers;
619
+ }
620
+
621
+ export function deferFile(adapter, project) {
622
+ const key = (project || "default").replace(/[^a-zA-Z0-9]/g, "-").slice(-90);
623
+ return path.join(DEFER_DIR, adapter, `${key}.json`);
624
+ }
625
+
626
+ export function loadDeferred(adapter, project, now = Date.now()) {
627
+ const stored = readJson(deferFile(adapter, project), null);
628
+ const items = Array.isArray(stored?.items) ? stored.items : [];
629
+ return items.filter((item) => now - (item.at ?? 0) < DEFER_RETENTION_MS);
630
+ }
631
+
632
+ export function addDeferred(adapter, project, texts, sessionId, now = Date.now()) {
633
+ if (texts.length === 0) return [];
634
+ const current = loadDeferred(adapter, project, now);
635
+ const known = new Set(current.map((item) => item.text));
636
+ for (const text of texts) {
637
+ if (known.has(text)) continue;
638
+ known.add(text);
639
+ current.push({ at: now, text, session: sessionId, project });
640
+ }
641
+ const items = current.slice(-DEFER_LIMIT);
642
+ writeJson(deferFile(adapter, project), { schema: 1, project, items });
643
+ return items;
644
+ }
645
+
646
+ export function clearDeferred(adapter, project) {
647
+ writeJson(deferFile(adapter, project), { schema: 1, project, items: [] });
648
+ }
649
+
650
+ export function deferredProjects(adapter, now = Date.now()) {
651
+ const dir = path.join(DEFER_DIR, adapter);
652
+ const out = [];
653
+ for (const name of listJson(dir)) {
654
+ const stored = readJson(path.join(dir, name), null);
655
+ const items = Array.isArray(stored?.items) ? stored.items : [];
656
+ const live = items.filter((item) => now - (item.at ?? 0) < DEFER_RETENTION_MS);
657
+ if (live.length > 0) out.push({ project: stored.project ?? name.slice(0, -5), items: live });
658
+ }
659
+ return out.sort((a, b) => b.items.length - a.items.length);
660
+ }
661
+
662
+ export function usageInWindow(record, from, to) {
663
+ const total = { input: 0, output: 0, cacheWrite: 0, cacheRead: 0, requests: 0 };
664
+ for (const row of record.buckets) {
665
+ if (row[0] < from || row[0] > to) continue;
666
+ total.input += row[1];
667
+ total.output += row[2];
668
+ total.cacheWrite += row[3];
669
+ total.cacheRead += row[4];
670
+ total.requests += row[5];
671
+ }
672
+ const tokens = total.input + total.output + total.cacheWrite + total.cacheRead;
673
+ const weighted =
674
+ total.input * WEIGHTS.input +
675
+ total.output * WEIGHTS.output +
676
+ total.cacheWrite * WEIGHTS.cacheWrite +
677
+ total.cacheRead * WEIGHTS.cacheRead;
678
+ return { ...total, tokens, weighted };
679
+ }
680
+
681
+ export function allocate(entries) {
682
+ const targets = new Map();
683
+ const eligible = [];
684
+ let reserved = 0;
685
+ let released = 0;
686
+
687
+ for (const entry of entries) {
688
+ const state = entry.state;
689
+ if (state === "active" || state === "needs-more") {
690
+ eligible.push(entry);
691
+ continue;
692
+ }
693
+ const keep = Math.max(0, Math.min(1, entry.consumed || 0));
694
+ reserved += keep;
695
+ if (typeof entry.share === "number" && entry.share > keep) released += entry.share - keep;
696
+ targets.set(entry.id, { claimantId: entry.id, target: keep, pinned: false, pool: 0, released: true });
697
+ }
698
+
699
+ if (reserved > 1) {
700
+ const shrink = 1 / reserved;
701
+ for (const allocation of targets.values()) allocation.target *= shrink;
702
+ released *= shrink;
703
+ reserved = 1;
704
+ }
705
+
706
+ let budget = Math.max(0, 1 - Math.min(1, reserved));
707
+ if (eligible.length === 0) return { targets, unusedPool: budget };
708
+
709
+ const pinned = eligible.filter((entry) => typeof entry.share === "number" && entry.share >= 0);
710
+ const free = eligible.filter((entry) => !(typeof entry.share === "number" && entry.share >= 0));
711
+ const pinnedSum = pinned.reduce((sum, entry) => sum + entry.share, 0);
712
+ const scale = pinnedSum > budget && pinnedSum > 0 ? budget / pinnedSum : 1;
713
+
714
+ for (const entry of pinned) {
715
+ targets.set(entry.id, { claimantId: entry.id, target: entry.share * scale, pinned: true, pool: 0, released: false });
716
+ }
717
+ const spent = Math.min(pinnedSum, budget);
718
+ const spare = Math.max(0, budget - spent);
719
+ const handedBack = Math.min(spare, released);
720
+ const base = Math.max(0, spare - handedBack);
721
+ const even = free.length > 0 ? base / free.length : 0;
722
+ for (const entry of free) {
723
+ targets.set(entry.id, { claimantId: entry.id, target: even, pinned: false, pool: 0, released: false });
724
+ }
725
+
726
+ let pool = handedBack;
727
+ const idle = free.length > 0 ? 0 : spare - handedBack;
728
+ for (const entry of eligible) {
729
+ const allocation = targets.get(entry.id);
730
+ const cap = typeof entry.cap === "number" ? entry.cap : 1;
731
+ if (allocation.target > cap) {
732
+ pool += allocation.target - cap;
733
+ allocation.target = cap;
734
+ }
735
+ }
736
+
737
+ const tiers = new Map();
738
+ for (const entry of eligible) {
739
+ const rank = PRIORITY_RANK[entry.priority] ?? 1;
740
+ if (!tiers.has(rank)) tiers.set(rank, []);
741
+ tiers.get(rank).push(entry);
742
+ }
743
+
744
+ for (const rank of [...tiers.keys()].sort((a, b) => a - b)) {
745
+ if (pool <= EPSILON) break;
746
+ let takers = tiers.get(rank);
747
+ while (pool > EPSILON && takers.length > 0) {
748
+ const slice = pool / takers.length;
749
+ const next = [];
750
+ let moved = 0;
751
+ for (const entry of takers) {
752
+ const allocation = targets.get(entry.id);
753
+ const cap = typeof entry.cap === "number" ? entry.cap : 1;
754
+ const room = cap - allocation.target;
755
+ if (room <= EPSILON) continue;
756
+ const give = Math.min(slice, room);
757
+ allocation.target += give;
758
+ allocation.pool += give;
759
+ moved += give;
760
+ if (room - give > EPSILON) next.push(entry);
761
+ }
762
+ pool -= moved;
763
+ if (moved <= EPSILON) break;
764
+ takers = next;
765
+ }
766
+ }
767
+
768
+ for (const allocation of targets.values()) {
769
+ if (allocation.target < EPSILON * 1000) allocation.target = 0;
770
+ if (allocation.pool < EPSILON * 1000) allocation.pool = 0;
771
+ }
772
+ return { targets, unusedPool: Math.max(0, pool + idle) };
773
+ }
774
+
775
+ export function schedule(adapter, now = Date.now(), key = "five_hour", quotaOverride = null, transcriptRoot = null) {
776
+ const quota = quotaOverride ?? loadQuota(adapter);
777
+ const bounds = windowBounds(quota, key, now);
778
+ const claimants = loadClaimants(adapter);
779
+ const usage = new Map();
780
+ const lockouts = [];
781
+ let total = 0;
782
+
783
+ for (const claimant of claimants) {
784
+ const record = loadMeter(adapter, claimant.id);
785
+ const window = usageInWindow(record, bounds.from, bounds.to);
786
+ usage.set(claimant.id, window);
787
+ total += window.weighted;
788
+ for (const at of record.lockouts) if (at >= bounds.from && at <= bounds.to) lockouts.push(at);
789
+ }
790
+
791
+ const live = liveWindow(quota, key, now);
792
+ const strict = heartbeatsLive(claimants, now);
793
+ const groups = new Map();
794
+
795
+ for (const claimant of claimants) {
796
+ const project = claimant.project || claimant.label || claimant.id;
797
+ let group = groups.get(project);
798
+ if (!group) {
799
+ group = { project, settings: loadProject(adapter, project), sessions: [], observed: 0, weighted: 0, tokens: 0, requests: 0, lastSeen: 0 };
800
+ groups.set(project, group);
801
+ }
802
+ const window = usage.get(claimant.id);
803
+ const observed = total > 0 ? window.weighted / total : 0;
804
+ const state = effectiveState(claimant, now, strict);
805
+ group.sessions.push({ claimant, window, observed, state, bucket: bucketFor(claimant, now, strict) });
806
+ group.observed += observed;
807
+ group.weighted += window.weighted;
808
+ group.tokens += window.tokens;
809
+ group.requests += window.requests;
810
+ group.lastSeen = Math.max(group.lastSeen, claimant.lastSeen ?? 0);
811
+ }
812
+
813
+ const entries = [...groups.values()].map((group) => {
814
+ const running = group.sessions.some((session) => session.bucket === "active");
815
+ return {
816
+ id: group.project,
817
+ share: group.settings.share,
818
+ priority: group.settings.priority,
819
+ state: running ? "active" : "done",
820
+ consumed: live ? (live.usedPercent / 100) * group.observed : 0,
821
+ cap: group.settings.cap,
822
+ };
823
+ });
824
+
825
+ const eligible = entries.filter((entry) => entry.state === "active").length;
826
+ const { targets, unusedPool } = allocate(entries);
827
+
828
+ const projects = [...groups.values()].map((group) => {
829
+ const allocation = targets.get(group.project) ?? {
830
+ claimantId: group.project,
831
+ target: 0,
832
+ pinned: false,
833
+ pool: 0,
834
+ released: true,
835
+ };
836
+ const running = group.sessions.filter((session) => session.bucket === "active");
837
+ const liveWeight = running.reduce((sum, session) => sum + session.window.weighted, 0);
838
+ const sessions = group.sessions
839
+ .map((session) => {
840
+ const alive = session.bucket === "active";
841
+ const slice = alive
842
+ ? liveWeight > 0
843
+ ? session.window.weighted / liveWeight
844
+ : 1 / Math.max(1, running.length)
845
+ : 0;
846
+ const target = allocation.target * slice;
847
+ const pressure =
848
+ live || eligible > 1
849
+ ? pressureFor(session.observed, target, live ? live.usedPercent : null)
850
+ : { value: 0, basis: "share" };
851
+ return {
852
+ claimant: session.claimant,
853
+ allocation: { claimantId: session.claimant.id, target, pinned: allocation.pinned, pool: 0, released: !alive },
854
+ usage: session.window,
855
+ observed: session.observed,
856
+ state: session.state,
857
+ bucket: session.bucket,
858
+ stale: isStale(session.claimant, now, strict),
859
+ pressure,
860
+ attributedPercent: live ? live.usedPercent * session.observed : null,
861
+ project: group.project,
862
+ };
863
+ })
864
+ .sort((a, b) => b.observed - a.observed);
865
+
866
+ const bucket = sessions.some((session) => session.bucket === "active")
867
+ ? "active"
868
+ : group.settings.parked
869
+ ? "parked"
870
+ : sessions.some((session) => session.bucket === "recent")
871
+ ? "recent"
872
+ : "parked";
873
+
874
+ return {
875
+ project: group.project,
876
+ label: group.settings.label || projectLabel(group.project),
877
+ settings: group.settings,
878
+ sessions,
879
+ allocation,
880
+ observed: group.observed,
881
+ usage: { tokens: group.tokens, weighted: group.weighted, requests: group.requests },
882
+ lastSeen: group.lastSeen,
883
+ bucket,
884
+ attributedPercent: live ? live.usedPercent * group.observed : null,
885
+ pressure:
886
+ live || eligible > 1
887
+ ? pressureFor(group.observed, allocation.target, live ? live.usedPercent : null)
888
+ : { value: 0, basis: "share" },
889
+ prompt: sessions[0]?.claimant.prompt ?? "",
890
+ liveSessions: sessions.filter((session) => session.bucket === "active").length,
891
+ };
892
+ });
893
+
894
+ const known = new Set(projects.map((view) => view.project));
895
+ for (const seen of transcriptRoot ? seenProjects(transcriptRoot, now) : []) {
896
+ if (!seen.project || known.has(seen.project)) continue;
897
+ const settings = loadProject(adapter, seen.project);
898
+ projects.push({
899
+ project: seen.project,
900
+ label: settings.label || seen.label || projectLabel(seen.project),
901
+ settings,
902
+ sessions: [],
903
+ allocation: { claimantId: seen.project, target: 0, pinned: false, pool: 0, released: true },
904
+ observed: 0,
905
+ usage: { tokens: 0, weighted: 0, requests: 0 },
906
+ lastSeen: seen.lastSeen,
907
+ bucket: "recent",
908
+ attributedPercent: null,
909
+ pressure: { value: 0, basis: "share" },
910
+ prompt: seen.prompt,
911
+ liveSessions: 0,
912
+ });
913
+ }
914
+
915
+ const span = WINDOW_MS[key] ?? FIVE_HOUR_MS;
916
+ return {
917
+ adapter,
918
+ key,
919
+ now,
920
+ quota,
921
+ live,
922
+ bounds,
923
+ windowId: bounds.anchored ? bounds.to : Math.floor(now / span) * span,
924
+ projects,
925
+ claimants: projects.flatMap((project) => project.sessions),
926
+ unusedPool,
927
+ totalWeighted: total,
928
+ lockouts: lockouts.sort((a, b) => a - b),
929
+ };
930
+ }
931
+
932
+ export function viewFor(plan, id) {
933
+ return plan.claimants.find((view) => view.claimant.id === id) ?? null;
934
+ }
935
+
936
+ const MIN_TARGET = 0.001;
937
+ const MAX_PRESSURE = 9.99;
938
+
939
+ export function pressureFor(consumedShare, target, quotaUsedPercent) {
940
+ if (!(target > MIN_TARGET)) return { value: consumedShare > 0 ? MAX_PRESSURE : 0, basis: "share" };
941
+ if (typeof quotaUsedPercent === "number" && quotaUsedPercent >= 0) {
942
+ return { value: Math.min(MAX_PRESSURE, (quotaUsedPercent / 100) * (consumedShare / target)), basis: "budget" };
943
+ }
944
+ return { value: Math.min(MAX_PRESSURE, consumedShare / target), basis: "share" };
945
+ }
946
+
947
+ export const POLICIES = {
948
+ finish: {
949
+ label: "finish and defer",
950
+ summary: "narrow the scope as the window fills, and push what is dropped to the next session",
951
+ stages: [
952
+ { at: 50, actions: ["focus"] },
953
+ { at: 80, actions: ["narrow", "defer"] },
954
+ { at: 90, actions: ["verify", "defer", "handoff"] },
955
+ ],
956
+ },
957
+ strict: {
958
+ label: "protect the window",
959
+ summary: "the same moves, much earlier, for when running out is expensive",
960
+ stages: [
961
+ { at: 35, actions: ["focus"] },
962
+ { at: 60, actions: ["narrow", "defer"] },
963
+ { at: 80, actions: ["verify", "defer", "handoff"] },
964
+ ],
965
+ },
966
+ relaxed: {
967
+ label: "warn late",
968
+ summary: "stay quiet until the target share is nearly gone",
969
+ stages: [
970
+ { at: 80, actions: ["focus"] },
971
+ { at: 95, actions: ["verify", "handoff"] },
972
+ ],
973
+ },
974
+ off: { label: "no advice", summary: "measure and allocate, but never inject anything", stages: [] },
975
+ };
976
+
977
+ export const DEFAULT_POLICY = "finish";
978
+ export const STAGES = POLICIES.finish.stages.map((stage) => stage.at).reverse();
979
+
980
+ export function policyNames() {
981
+ return Object.keys(POLICIES);
982
+ }
983
+
984
+ export function policyFor(config, project) {
985
+ const name = config?.policyFor?.[project] ?? config?.policy ?? DEFAULT_POLICY;
986
+ return POLICIES[name] ? { name, ...POLICIES[name] } : { name: DEFAULT_POLICY, ...POLICIES[DEFAULT_POLICY] };
987
+ }
988
+
989
+ export function stageFor(pressure, policy = POLICIES[DEFAULT_POLICY]) {
990
+ const stages = policy?.stages ?? [];
991
+ let hit = 0;
992
+ for (const stage of stages) if (pressure >= stage.at / 100) hit = Math.max(hit, stage.at);
993
+ return hit;
994
+ }
995
+
996
+ export function actionsFor(stage, policy = POLICIES[DEFAULT_POLICY]) {
997
+ const found = (policy?.stages ?? []).find((entry) => entry.at === stage);
998
+ return found ? found.actions : [];
999
+ }
1000
+
1001
+ export function preserveText(preserve) {
1002
+ const list = Array.isArray(preserve) ? preserve.filter(Boolean) : [];
1003
+ if (list.length === 0) return "testing and finalisation";
1004
+ if (list.length === 1) return list[0];
1005
+ return `${list.slice(0, -1).join(", ")} and ${list[list.length - 1]}`;
1006
+ }
1007
+
1008
+ const ACTION_TEXT = {
1009
+ focus: () =>
1010
+ "Stay on completion of what was asked: no side quests, no wide reading, and batch your tool calls instead of one round trip per step.",
1011
+ narrow: (view) =>
1012
+ `Narrow the scope to the smallest version that is genuinely done. Cut optional work, stop comparing alternatives, start nothing new, and keep enough capacity for ${preserveText(view.preserve)}.`,
1013
+ defer: () =>
1014
+ "Whatever you drop, write on its own line as `SMT: DEFER <one line>`. It comes back at the start of the next session in this project, so dropping it now costs nothing.",
1015
+ verify: () => "Verification and finalisation only: finish what is already open, run the tests, and leave the tree clean.",
1016
+ handoff: () =>
1017
+ "End with one line on where you stopped, then report SMT: DONE, SMT: NEEDS_MORE or SMT: BLOCKED on its own line.",
1018
+ };
1019
+
1020
+ export function openingAdvice(view) {
1021
+ const target = Math.round(view.target * 100);
1022
+ const policy = view.policy ?? POLICIES[DEFAULT_POLICY];
1023
+ const first = policy.stages?.[0];
1024
+ const plan = first
1025
+ ? ` Past ${first.at}% of it, tighten up rather than pushing on: ${policy.summary}.`
1026
+ : "";
1027
+ return `[savemytokens] This session's target share of the current Claude window is ${target}%. Work inside it: prioritise completion, and preserve enough capacity for ${preserveText(view.preserve)}.${plan} When you stop, report one of SMT: DONE, SMT: NEEDS_MORE or SMT: BLOCKED on its own line.`;
1028
+ }
1029
+
1030
+ export function stageText(stage, view) {
1031
+ const policy = view.policy ?? POLICIES[DEFAULT_POLICY];
1032
+ return actionsFor(stage, policy)
1033
+ .map((action) => (ACTION_TEXT[action] ?? (() => ""))(view))
1034
+ .filter(Boolean)
1035
+ .join(" ");
1036
+ }
1037
+
1038
+ export function adviceFor(stage, view) {
1039
+ const policy = view.policy ?? POLICIES[DEFAULT_POLICY];
1040
+ const target = Math.round(view.target * 100);
1041
+ const spent = Math.round(view.pressure * 100);
1042
+ const basis =
1043
+ view.basis === "budget"
1044
+ ? `${spent}% of your ${target}% target share of this Claude window is spent`
1045
+ : `you are at ${Math.round(view.observed * 100)}% of measured usage against a ${target}% target`;
1046
+ const body = actionsFor(stage, policy).map((action) => (ACTION_TEXT[action] ?? (() => ""))(view));
1047
+ const custom = typeof view.custom === "string" && view.custom.trim() ? ` ${view.custom.trim()}` : "";
1048
+ return `[savemytokens] ${basis}. ${body.join(" ")}${custom}`.trim();
1049
+ }
1050
+
1051
+ export function deferredAdvice(items) {
1052
+ const lines = items.slice(-5).map((item) => ` · ${item.text}`);
1053
+ return `[savemytokens] Deferred earlier in this project:\n${lines.join("\n")}\nPick these up only if they fit inside your target share. Clear them with: npx savemytokens defer clear`;
1054
+ }
1055
+
1056
+ const BUILTIN_THEMES = {
1057
+ default: {
1058
+ name: "default",
1059
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2588", empty: "\u2591", over: "\u25b6", meter: "\u2588", track: "\u2591" },
1060
+ colors: { fg: "#cdd6f4", dim: "#9399b2", accent: "#89b4fa", ok: "#a6e3a1", warn: "#f9e2af", danger: "#f38ba8", track: "#585b70", fill: "#89b4fa" },
1061
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1062
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1063
+ },
1064
+
1065
+ tokyonight: {
1066
+ name: "tokyonight",
1067
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u2022", done: "\u2713", blocked: "!", idle: "\u00b7", open: "[", close: "]", fill: "|", empty: ".", over: "\u00bb", meter: "\u2588", track: "\u2591" },
1068
+ colors: { fg: "#e6e6e6", dim: "#8a8a8a", accent: "#7aa2f7", ok: "#9ece6a", warn: "#e0af68", danger: "#f7768e", track: "#3b3b3b", fill: "#7aa2f7" },
1069
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1070
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1071
+ },
1072
+ minimal: {
1073
+ name: "minimal",
1074
+ tui: { cursor: ">", pin: "*", active: "o", done: "x", blocked: "!", idle: ".", open: "[", close: "]", fill: "#", empty: ".", over: ">", meter: "#", track: "." },
1075
+ colors: { fg: "#ffffff", dim: "#8c8c8c", accent: "#ffffff", ok: "#ffffff", warn: "#ffffff", danger: "#ffffff", track: "#4f4f4f", fill: "#ffffff" },
1076
+ glyphs: { full: "#", empty: ".", sep: "|", arrow: ">", tag: "smt" },
1077
+ border: { h: "-", v: "|", tl: "+", tr: "+", bl: "+", br: "+" },
1078
+ },
1079
+ nord: {
1080
+ name: "nord",
1081
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u25b0", empty: "\u25b1", over: "\u25b6", meter: "\u25b0", track: "\u25b1" },
1082
+ colors: { fg: "#eceff4", dim: "#7b88a1", accent: "#88c0d0", ok: "#a3be8c", warn: "#ebcb8b", danger: "#e0707c", track: "#434c5e", fill: "#88c0d0" },
1083
+ glyphs: { full: "\u25b0", empty: "\u25b1", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1084
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1085
+ },
1086
+ violet: {
1087
+ name: "violet",
1088
+ tui: { cursor: "\u25b8", pin: "\u2605", active: "\u25c6", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2588", empty: "\u2591", over: "\u25b6", meter: "\u2588", track: "\u2591" },
1089
+ colors: { fg: "#f8f8f2", dim: "#6272a4", accent: "#bd93f9", ok: "#50fa7b", warn: "#f1fa8c", danger: "#ff5555", track: "#44475a", fill: "#bd93f9" },
1090
+ glyphs: { full: "\u2588", empty: "\u2592", sep: "\u2022", arrow: "\u2192", tag: "SMT" },
1091
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1092
+ },
1093
+ matrix: {
1094
+ name: "matrix",
1095
+ tui: { cursor: "\u00bb", pin: "*", active: "\u2593", done: "\u2713", blocked: "!", idle: "\u00b7", open: "<", close: ">", fill: "\u2593", empty: "\u00b7", over: "\u00bb", meter: "\u2593", track: "\u00b7" },
1096
+ colors: { fg: "#8fff8f", dim: "#3f9f3f", accent: "#00ff41", ok: "#00ff41", warn: "#b6ff00", danger: "#ff6b5e", track: "#1e421e", fill: "#00ff41" },
1097
+ glyphs: { full: "\u2593", empty: "\u00b7", sep: "::", arrow: ">>", tag: "SMT" },
1098
+ border: { h: "\u2550", v: "\u2551", tl: "\u2554", tr: "\u2557", bl: "\u255a", br: "\u255d" },
1099
+ },
1100
+ solarized: {
1101
+ name: "solarized",
1102
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2501", empty: "\u2500", over: "\u25b6", meter: "\u2501", track: "\u2500" },
1103
+ colors: { fg: "#b4c2c2", dim: "#7d9092", accent: "#4fa3e0", ok: "#9db81b", warn: "#d2a106", danger: "#f0645f", track: "#0b4a5a", fill: "#4fa3e0" },
1104
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1105
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1106
+ },
1107
+ gruvbox: {
1108
+ name: "gruvbox",
1109
+ tui: { cursor: "\u27a4", pin: "\u2605", active: "\u25a0", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u25a0", empty: "\u25a1", over: "\u25b6", meter: "\u25a0", track: "\u25a1" },
1110
+ colors: { fg: "#ebdbb2", dim: "#a89984", accent: "#8ec07c", ok: "#b8bb26", warn: "#fabd2f", danger: "#fb6a58", track: "#504945", fill: "#8ec07c" },
1111
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1112
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1113
+ },
1114
+ rose: {
1115
+ name: "rose",
1116
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25c6", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u25c6", empty: "\u25c7", over: "\u25b6", meter: "\u25ac", track: "\u25ad" },
1117
+ colors: { fg: "#e0def4", dim: "#8d88a8", accent: "#c4a7e7", ok: "#9ccfd8", warn: "#f6c177", danger: "#eb6f92", track: "#3a3552", fill: "#c4a7e7" },
1118
+ glyphs: { full: "\u25ac", empty: "\u25ad", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1119
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1120
+ },
1121
+ paper: {
1122
+ name: "paper",
1123
+ tui: { cursor: "\u203a", pin: "\u2605", active: "\u25aa", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u25ae", empty: "\u25af", over: "\u25b8", meter: "\u25ae", track: "\u25af" },
1124
+ colors: { fg: "#24292f", dim: "#6e7781", accent: "#0969da", ok: "#1a7f37", warn: "#9a6700", danger: "#cf222e", track: "#d0d7de", fill: "#0969da" },
1125
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1126
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1127
+ },
1128
+ neon: {
1129
+ name: "neon",
1130
+ tui: { cursor: "\u25b8", pin: "\u2605", active: "\u25c9", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2589", empty: "\u2595", over: "\u25b6", meter: "\u2589", track: "\u2595" },
1131
+ colors: { fg: "#f0f0ff", dim: "#8f7fc0", accent: "#ff5cc8", ok: "#00f5d4", warn: "#fee440", danger: "#ff5c8a", track: "#3a3a52", fill: "#ff5cc8" },
1132
+ glyphs: { full: "\u2589", empty: "\u2595", sep: "\u2502", arrow: "\u25b8", tag: "SMT" },
1133
+ border: { h: "\u2501", v: "\u2503", tl: "\u250f", tr: "\u2513", bl: "\u2517", br: "\u251b" },
1134
+ },
1135
+ onedark: {
1136
+ name: "onedark",
1137
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2588", empty: "\u2591", over: "\u25b6", meter: "\u2588", track: "\u2591" },
1138
+ colors: { fg: "#abb2bf", dim: "#8b93a1", accent: "#61afef", ok: "#98c379", warn: "#e5c07b", danger: "#e88b93", track: "#4b515d", fill: "#61afef" },
1139
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1140
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1141
+ },
1142
+ monokai: {
1143
+ name: "monokai",
1144
+ tui: { cursor: "\u25b8", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2588", empty: "\u2591", over: "\u25b6", meter: "\u2588", track: "\u2591" },
1145
+ colors: { fg: "#f8f8f2", dim: "#a6a48f", accent: "#66d9ef", ok: "#a6e22e", warn: "#e6db74", danger: "#ff6188", track: "#5a594e", fill: "#66d9ef" },
1146
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1147
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1148
+ },
1149
+ everforest: {
1150
+ name: "everforest",
1151
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25c6", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u25ac", empty: "\u25ad", over: "\u25b6", meter: "\u25ac", track: "\u25ad" },
1152
+ colors: { fg: "#d3c6aa", dim: "#9da9a0", accent: "#a7c080", ok: "#83c092", warn: "#dbbc7f", danger: "#e67e80", track: "#4f585e", fill: "#a7c080" },
1153
+ glyphs: { full: "\u25ac", empty: "\u25ad", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1154
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1155
+ },
1156
+ kanagawa: {
1157
+ name: "kanagawa",
1158
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2588", empty: "\u2591", over: "\u25b6", meter: "\u2588", track: "\u2591" },
1159
+ colors: { fg: "#dcd7ba", dim: "#a09a84", accent: "#7e9cd8", ok: "#98bb6c", warn: "#e6c384", danger: "#e46876", track: "#54546d", fill: "#7e9cd8" },
1160
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1161
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1162
+ },
1163
+ terracotta: {
1164
+ name: "terracotta",
1165
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25c6", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u25ac", empty: "\u25ad", over: "\u25b6", meter: "\u25ac", track: "\u25ad" },
1166
+ colors: { fg: "#f0e2d8", dim: "#a68a7b", accent: "#e2836b", ok: "#b6c99b", warn: "#f0c05a", danger: "#f2807a", track: "#4a3630", fill: "#e2836b" },
1167
+ glyphs: { full: "\u25ac", empty: "\u25ad", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1168
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1169
+ },
1170
+ orange: {
1171
+ name: "orange",
1172
+ tui: { cursor: "\u25b8", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "", close: "", fill: "\u2588", empty: "\u2591", over: "\u25b6", meter: "\u2588", track: "\u2591" },
1173
+ colors: { fg: "#f7ede2", dim: "#a08b78", accent: "#ff9f45", ok: "#9ccf7f", warn: "#ffd166", danger: "#ff7a6b", track: "#43342a", fill: "#ff9f45" },
1174
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1175
+ border: { h: "\u2500", v: "\u2502", tl: "\u250c", tr: "\u2510", bl: "\u2514", br: "\u2518" },
1176
+ },
1177
+ ember: {
1178
+ name: "ember",
1179
+ tui: { cursor: "\u276f", pin: "\u2605", active: "\u25cf", done: "\u2713", blocked: "!", idle: "\u00b7", open: "(", close: ")", fill: "\u25cf", empty: "\u25cb", over: "\u00bb", meter: "\u2588", track: "\u2591" },
1180
+ colors: { fg: "#f5e0dc", dim: "#9a7b76", accent: "#fab387", ok: "#a6e3a1", warn: "#f9e2af", danger: "#f38ba8", track: "#45475a", fill: "#fab387" },
1181
+ glyphs: { full: "\u2588", empty: "\u2591", sep: "\u00b7", arrow: "\u2192", tag: "SMT" },
1182
+ border: { h: "\u2500", v: "\u2502", tl: "\u256d", tr: "\u256e", bl: "\u2570", br: "\u256f" },
1183
+ },
1184
+ };
1185
+
1186
+ const THEME_RENAMES = {
1187
+ catppuccin: "default",
1188
+ dracula: "violet",
1189
+ "tokyo-night": "tokyonight",
1190
+ "catppuccin-mocha": "default",
1191
+ "catppuccin-macchiato": "default",
1192
+ "rose-pine": "rose",
1193
+ "one-dark": "onedark",
1194
+ };
1195
+
1196
+ export function builtinThemes() {
1197
+ return Object.keys(BUILTIN_THEMES);
1198
+ }
1199
+
1200
+ export function userThemes() {
1201
+ return listJson(THEME_DIR).map((name) => name.slice(0, -5));
1202
+ }
1203
+
1204
+ export function loadTheme(name) {
1205
+ const wanted = THEME_RENAMES[name] ?? name ?? "default";
1206
+ const user = readJson(path.join(THEME_DIR, `${wanted}.json`), null);
1207
+ const base = BUILTIN_THEMES[wanted] || BUILTIN_THEMES.default;
1208
+ const fallback = BUILTIN_THEMES.default;
1209
+ if (!user || typeof user !== "object") return { ...base, tui: { ...fallback.tui, ...base.tui } };
1210
+ return {
1211
+ ...base,
1212
+ ...user,
1213
+ name: wanted,
1214
+ colors: { ...base.colors, ...(user.colors || {}) },
1215
+ glyphs: { ...base.glyphs, ...(user.glyphs || {}) },
1216
+ border: { ...base.border, ...(user.border || {}) },
1217
+ tui: { ...fallback.tui, ...base.tui, ...(user.tui || {}) },
1218
+ };
1219
+ }
1220
+
1221
+ function rgb(hex) {
1222
+ const value = String(hex || "").replace("#", "");
1223
+ if (value.length !== 6) return null;
1224
+ const number = Number.parseInt(value, 16);
1225
+ if (!Number.isFinite(number)) return null;
1226
+ return [(number >> 16) & 255, (number >> 8) & 255, number & 255];
1227
+ }
1228
+
1229
+ export function truecolor() {
1230
+ const declared = String(process.env.COLORTERM || "").toLowerCase();
1231
+ if (declared.includes("truecolor") || declared.includes("24bit")) return true;
1232
+ const term = String(process.env.TERM || "").toLowerCase();
1233
+ return term.includes("direct") || term.includes("truecolor") || term.includes("kitty");
1234
+ }
1235
+
1236
+ function xterm256(parts) {
1237
+ const [red, green, blue] = parts;
1238
+ if (Math.abs(red - green) < 10 && Math.abs(green - blue) < 10) {
1239
+ const level = Math.round(((red + green + blue) / 3 - 8) / 10);
1240
+ if (level <= 0) return 16;
1241
+ if (level >= 24) return 231;
1242
+ return 232 + level;
1243
+ }
1244
+ const step = (value) => Math.round(Math.max(0, value - 55) / 40);
1245
+ return 16 + 36 * step(red) + 6 * step(green) + step(blue);
1246
+ }
1247
+
1248
+ function sgr(parts, bold) {
1249
+ const weight = bold ? "1;" : "";
1250
+ return truecolor()
1251
+ ? `\u001b[${weight}38;2;${parts[0]};${parts[1]};${parts[2]}m`
1252
+ : `\u001b[${weight}38;5;${xterm256(parts)}m`;
1253
+ }
1254
+
1255
+ export function paint(theme, role, text, enabled = true) {
1256
+ if (!enabled || process.env.NO_COLOR !== undefined) return text;
1257
+ const parts = rgb(theme.colors?.[role]);
1258
+ if (!parts) return text;
1259
+ return `${sgr(parts, false)}${text}\u001b[0m`;
1260
+ }
1261
+
1262
+ export function paintHead(theme, text, enabled = true) {
1263
+ if (!enabled || process.env.NO_COLOR !== undefined) return text;
1264
+ const parts = rgb(theme.colors?.head ?? theme.colors?.fg);
1265
+ if (!parts) return text;
1266
+ return `${sgr(parts, true)}${text}\u001b[0m`;
1267
+ }
1268
+
1269
+ export function meterBar(theme, ratio, width, role = "fill", enabled = true) {
1270
+ const clamped = Math.max(0, Math.min(1, ratio));
1271
+ const filled = Math.round(clamped * width);
1272
+ const full = (theme.glyphs?.full ?? "█").repeat(filled);
1273
+ const empty = (theme.glyphs?.empty ?? "░").repeat(Math.max(0, width - filled));
1274
+ return paint(theme, role, full, enabled) + paint(theme, "track", empty, enabled);
1275
+ }
1276
+
1277
+ export function pressureRole(pressure) {
1278
+ if (pressure >= 0.9) return "danger";
1279
+ if (pressure >= 0.8) return "warn";
1280
+ return "ok";
1281
+ }
1282
+
1283
+ export function formatCountdown(resetsAt, now = Date.now()) {
1284
+ if (typeof resetsAt !== "number") return "";
1285
+ const diff = resetsAt * 1000 - now;
1286
+ if (diff <= 0) return "due";
1287
+ const minutes = Math.round(diff / 60000);
1288
+ if (minutes < 60) return `${minutes}m`;
1289
+ const hours = Math.floor(minutes / 60);
1290
+ const rest = minutes % 60;
1291
+ if (hours < 24) return rest === 0 ? `${hours}h` : `${hours}h${String(rest).padStart(2, "0")}`;
1292
+ const days = Math.floor(hours / 24);
1293
+ return `${days}d${hours % 24 > 0 ? `${hours % 24}h` : ""}`;
1294
+ }
1295
+
1296
+ export function formatReset(resetsAt, now = Date.now()) {
1297
+ if (typeof resetsAt !== "number") return "";
1298
+ const at = new Date(resetsAt * 1000);
1299
+ const diff = resetsAt * 1000 - now;
1300
+ if (diff <= 0) return "due";
1301
+ if (diff < 12 * 60 * 60 * 1000) {
1302
+ return `${String(at.getHours()).padStart(2, "0")}:${String(at.getMinutes()).padStart(2, "0")}`;
1303
+ }
1304
+ return ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"][at.getDay()] ?? "";
1305
+ }
1306
+
1307
+ function percentText(value) {
1308
+ return `${Math.round(value)}%`;
1309
+ }
1310
+
1311
+ export const HUD_SEGMENTS = [
1312
+ "tag",
1313
+ "project",
1314
+ "target",
1315
+ "used",
1316
+ "share",
1317
+ "pair",
1318
+ "bar",
1319
+ "priority",
1320
+ "5h",
1321
+ "7d",
1322
+ "spend",
1323
+ "reset",
1324
+ "meter5h",
1325
+ "spark",
1326
+ "pace",
1327
+ "empty",
1328
+ ];
1329
+
1330
+ export const HUD_PRESETS = {
1331
+ default: ["bar", "pair", "5h", "reset"],
1332
+ minimal: ["bar", "pair"],
1333
+ window: ["5h", "reset", "7d"],
1334
+ pacing: ["bar", "pace", "5h", "reset"],
1335
+ everything: ["project", "target", "used", "priority", "meter5h", "5h", "7d", "reset"],
1336
+ };
1337
+
1338
+ export const HUD_PRESET_ABOUT = {
1339
+ default: "a bar for your share, then the numbers",
1340
+ minimal: "the bar and your share, nothing else",
1341
+ window: "only Anthropic's numbers, no per-project detail",
1342
+ pacing: "whether you are ahead of or behind the clock",
1343
+ everything: "every number there is",
1344
+ };
1345
+
1346
+ const HUD_PRESET_ALIASES = {
1347
+ balanced: "default",
1348
+ allocation: "default",
1349
+ compact: "minimal",
1350
+ global: "window",
1351
+ bar: "default",
1352
+ blocks: "default",
1353
+ dots: "default",
1354
+ pace: "pacing",
1355
+ runway: "pacing",
1356
+ spark: "default",
1357
+ };
1358
+
1359
+ export const HUD_LAYOUTS = Object.keys(HUD_PRESETS);
1360
+ export const DEFAULT_HUD_SEGMENTS = HUD_PRESETS.default;
1361
+
1362
+ export function presetSegments(name) {
1363
+ return HUD_PRESETS[name] ?? HUD_PRESETS[HUD_PRESET_ALIASES[name]] ?? null;
1364
+ }
1365
+
1366
+ export function presetMatching(segments) {
1367
+ const key = (list) => list.join(">");
1368
+ for (const [name, list] of Object.entries(HUD_PRESETS)) {
1369
+ if (key(list) === key(segments)) return name;
1370
+ }
1371
+ return null;
1372
+ }
1373
+
1374
+ export const COLUMNS = ["allocation", "used", "share", "tokens", "priority", "last prompt"];
1375
+ export const DEFAULT_COLUMNS = ["allocation", "used", "priority", "last prompt"];
1376
+
1377
+ function hudMeter(theme, ratio, width, role, enabled, filled = "\u2588", empty = "\u2591") {
1378
+ const cells = Math.max(4, width);
1379
+ const on = Math.max(0, Math.min(cells, Math.round(Math.max(0, Math.min(1, ratio)) * cells)));
1380
+ return paint(theme, role, filled.repeat(on), enabled) + paint(theme, "track", empty.repeat(cells - on), enabled);
1381
+ }
1382
+
1383
+ function windowOf(view, key) {
1384
+ const window = view.quota?.[key];
1385
+ return window && typeof window.usedPercent === "number" ? window : null;
1386
+ }
1387
+
1388
+ const SEGMENTS = {
1389
+ tag: (view, theme, on) => paint(theme, "accent", theme.glyphs?.tag ?? "SMT", on),
1390
+ project: (view) => view.label || "session",
1391
+ target: (view, theme, on) => `${paint(theme, "dim", "target", on)} ${percentText((view.target ?? 0) * 100)}`,
1392
+ used: (view, theme, on) => {
1393
+ const value = typeof view.used === "number" ? view.used : (view.observed ?? 0) * 100;
1394
+ return `${paint(theme, "dim", typeof view.used === "number" ? "used" : "share", on)} ${paint(theme, pressureRole(view.pressure ?? 0), percentText(value), on)}`;
1395
+ },
1396
+ share: (view, theme, on) => `${paint(theme, "dim", "share", on)} ${percentText((view.observed ?? 0) * 100)}`,
1397
+ pair: (view, theme, on) => {
1398
+ const value = typeof view.used === "number" ? view.used : (view.observed ?? 0) * 100;
1399
+ return `${paint(theme, pressureRole(view.pressure ?? 0), percentText(value), on)}${paint(theme, "dim", `/${percentText((view.target ?? 0) * 100)}`, on)}`;
1400
+ },
1401
+ bar: (view, theme, on) =>
1402
+ hudMeter(theme, view.pressure ?? 0, 8, pressureRole(view.pressure ?? 0), on, theme.glyphs?.hudFull ?? "\u28ff", theme.glyphs?.hudEmpty ?? "\u28c0"),
1403
+ priority: (view, theme, on) => paint(theme, "dim", String(view.priority ?? "normal").toUpperCase(), on),
1404
+ "5h": (view, theme, on) => {
1405
+ const window = windowOf(view, "five_hour");
1406
+ return window ? `5h ${paint(theme, pressureRole(window.usedPercent / 100), percentText(window.usedPercent), on)}` : "";
1407
+ },
1408
+ "7d": (view, theme, on) => {
1409
+ const window = windowOf(view, "seven_day");
1410
+ return window ? `7d ${paint(theme, pressureRole(window.usedPercent / 100), percentText(window.usedPercent), on)}` : "";
1411
+ },
1412
+ spend: (view, theme, on) => {
1413
+ const window = windowOf(view, "spend_limit");
1414
+ return window ? `spend ${paint(theme, pressureRole(window.usedPercent / 100), percentText(window.usedPercent), on)}` : "";
1415
+ },
1416
+ reset: (view, theme, on) => {
1417
+ const window = windowOf(view, "five_hour");
1418
+ if (!window || typeof window.resetsAt !== "number") return "";
1419
+ const left = formatCountdown(window.resetsAt, view.now);
1420
+ return left ? paint(theme, "dim", `in ${left}`, on) : "";
1421
+ },
1422
+ meter5h: (view, theme, on) => {
1423
+ const window = windowOf(view, "five_hour");
1424
+ return window ? hudMeter(theme, window.usedPercent / 100, 8, pressureRole(window.usedPercent / 100), on) : "";
1425
+ },
1426
+ spark: (view, theme, on) => {
1427
+ const points = Array.isArray(view.history) ? view.history.slice(-12) : [];
1428
+ if (points.length === 0) return "";
1429
+ const glyphs = "\u2581\u2582\u2583\u2584\u2585\u2586\u2587\u2588".split("");
1430
+ return points
1431
+ .map((value) => paint(theme, pressureRole(value / 100), glyphs[Math.max(0, Math.min(7, Math.round((value / 100) * 7)))] ?? "\u2581", on))
1432
+ .join("");
1433
+ },
1434
+ pace: (view, theme, on) => {
1435
+ const window = windowOf(view, "five_hour");
1436
+ if (!window || typeof view.from !== "number" || typeof view.to !== "number") return "";
1437
+ const elapsed = Math.max(0, Math.min(1, (view.now - view.from) / Math.max(1, view.to - view.from))) * 100;
1438
+ const ahead = window.usedPercent - elapsed;
1439
+ return paint(theme, ahead > 5 ? "warn" : "ok", `${ahead >= 0 ? "+" : ""}${Math.round(ahead)} vs pace`, on);
1440
+ },
1441
+ empty: (view, theme, on) => {
1442
+ const window = windowOf(view, "five_hour");
1443
+ if (!window || !(typeof view.rate === "number" && view.rate > 0)) return "";
1444
+ const at = view.now + ((100 - window.usedPercent) / view.rate) * 3600000;
1445
+ const resetsAt = typeof window.resetsAt === "number" ? window.resetsAt * 1000 : null;
1446
+ if (resetsAt !== null && at >= resetsAt) return paint(theme, "ok", "lasts the window", on);
1447
+ return paint(theme, "danger", `empty ${formatReset(Math.floor(at / 1000), view.now)}`, on);
1448
+ },
1449
+ };
1450
+
1451
+ const BARE_SEGMENTS = new Set(["bar", "meter5h", "spark"]);
1452
+
1453
+ export function renderSegments(segments, view, theme, enabled = true) {
1454
+ const sep = ` ${theme.glyphs?.sep ?? "\u00b7"} `;
1455
+ let line = "";
1456
+ let bare = false;
1457
+ for (const name of segments) {
1458
+ const render = SEGMENTS[name];
1459
+ if (!render) continue;
1460
+ const text = render(view, theme, enabled);
1461
+ if (!text) continue;
1462
+ if (!line) line = text;
1463
+ else line += (bare ? " " : sep) + text;
1464
+ bare = BARE_SEGMENTS.has(name);
1465
+ }
1466
+ return line + (view.stale ? paint(theme, "dim", " stale", enabled) : "");
1467
+ }
1468
+
1469
+ export function renderHud(layout, view, theme, enabled = true) {
1470
+ const segments = Array.isArray(layout) ? layout : presetSegments(layout) ?? DEFAULT_HUD_SEGMENTS;
1471
+ return renderSegments(segments, view, theme, enabled);
1472
+ }