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,243 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { execSync } from "node:child_process";
4
+ import {
5
+ windowBounds,
6
+ loadConfig,
7
+ loadMeter,
8
+ loadQuota,
9
+ loadTheme,
10
+ renderHud,
11
+ sampleFiles,
12
+ saveQuota,
13
+ schedule,
14
+ upsertClaimant,
15
+ viewFor,
16
+ } from "./kernel.mjs";
17
+
18
+ const ADAPTER = "claude-code";
19
+ const METER_THROTTLE_MS = 10 * 1000;
20
+ const LIVENESS_THROTTLE_MS = 15 * 1000;
21
+ const HISTORY_LIMIT = 96;
22
+ const STALE_READING_MS = 10 * 60 * 1000;
23
+ const MAX_SUBAGENT_DEPTH = 4;
24
+ const WRAP_TIMEOUT_MS = 2000;
25
+
26
+ function readInput() {
27
+ try {
28
+ const raw = fs.readFileSync(0, "utf8");
29
+ return raw ? { payload: JSON.parse(raw), raw } : null;
30
+ } catch {
31
+ return null;
32
+ }
33
+ }
34
+
35
+ function nested(dir, depth, out) {
36
+ if (depth > MAX_SUBAGENT_DEPTH) return;
37
+ let entries;
38
+ try {
39
+ entries = fs.readdirSync(dir, { withFileTypes: true });
40
+ } catch {
41
+ return;
42
+ }
43
+ for (const entry of entries) {
44
+ const full = path.join(dir, entry.name);
45
+ if (entry.isDirectory()) nested(full, depth + 1, out);
46
+ else if (entry.name.endsWith(".jsonl")) out.push(full);
47
+ }
48
+ }
49
+
50
+ function transcriptFiles(payload) {
51
+ const transcript = payload.transcript_path;
52
+ if (typeof transcript !== "string" || !transcript) return [];
53
+ const files = [transcript];
54
+ nested(path.join(path.dirname(transcript), String(payload.session_id ?? "")), 1, files);
55
+ return files;
56
+ }
57
+
58
+ function normalizeWindows(rateLimits) {
59
+ const windows = {};
60
+ for (const key of ["five_hour", "seven_day", "spend_limit"]) {
61
+ const window = rateLimits?.[key];
62
+ if (!window || typeof window.used_percentage !== "number" || !Number.isFinite(window.used_percentage)) continue;
63
+ windows[key] = {
64
+ usedPercent: Math.max(0, Math.min(100, window.used_percentage)),
65
+ resetsAt: window.resets_at,
66
+ };
67
+ }
68
+ return windows;
69
+ }
70
+
71
+ const WINDOW_SPAN_MS = { five_hour: 5 * 3600_000, seven_day: 7 * 24 * 3600_000, spend_limit: 31 * 24 * 3600_000 };
72
+ const SLACK_MS = 6 * 3600_000;
73
+
74
+ function plausibleReset(key, resetsAt, now) {
75
+ if (typeof resetsAt !== "number" || !Number.isFinite(resetsAt) || resetsAt <= 0) return null;
76
+ const ms = resetsAt * 1000;
77
+ if (ms <= now) return null;
78
+ if (ms - now > (WINDOW_SPAN_MS[key] ?? WINDOW_SPAN_MS.seven_day) + SLACK_MS) return null;
79
+ return resetsAt;
80
+ }
81
+
82
+ function mergeWindows(stored, incoming, now) {
83
+ const merged = {};
84
+ for (const [key, window] of Object.entries(stored ?? {})) {
85
+ if (typeof window?.usedPercent !== "number") continue;
86
+ if (window.resetsAt !== undefined && plausibleReset(key, window.resetsAt, now) === null) continue;
87
+ merged[key] = window;
88
+ }
89
+ for (const [key, window] of Object.entries(incoming)) {
90
+ if (window.resetsAt !== undefined && plausibleReset(key, window.resetsAt, now) === null) continue;
91
+ const previous = merged[key];
92
+ if (!previous) {
93
+ merged[key] = { ...window, at: now };
94
+ continue;
95
+ }
96
+ const older = (window.resetsAt ?? 0) < (previous.resetsAt ?? 0);
97
+ if (older) continue;
98
+ const sameWindow = (window.resetsAt ?? 0) === (previous.resetsAt ?? 0);
99
+ merged[key] = {
100
+ ...window,
101
+ usedPercent: sameWindow ? Math.max(previous.usedPercent, window.usedPercent) : window.usedPercent,
102
+ at: now,
103
+ };
104
+ }
105
+ return merged;
106
+ }
107
+
108
+ function buildReading(payload, now) {
109
+ const incoming = normalizeWindows(payload.rate_limits);
110
+ const stored = loadQuota(ADAPTER);
111
+ const windows = mergeWindows(stored?.windows, incoming, now);
112
+ if (Object.keys(windows).length === 0) return null;
113
+ if (Object.keys(incoming).length === 0 && stored) return { ...stored, windows };
114
+ return {
115
+ at: now,
116
+ source: "statusline",
117
+ sessionId: String(payload.session_id ?? ""),
118
+ windows,
119
+ history: Array.isArray(stored?.history) ? stored.history : [],
120
+ };
121
+ }
122
+
123
+ function persistReading(reading, metered, turnAt) {
124
+ const previous = loadQuota(ADAPTER);
125
+ const history = Array.isArray(previous?.history) ? previous.history : [];
126
+ const last = history[history.length - 1];
127
+ const point = {
128
+ at: reading.at,
129
+ metered,
130
+ turnAt,
131
+ five_hour: reading.windows.five_hour?.usedPercent ?? null,
132
+ seven_day: reading.windows.seven_day?.usedPercent ?? null,
133
+ };
134
+ const changed =
135
+ !last ||
136
+ last.five_hour !== point.five_hour ||
137
+ last.seven_day !== point.seven_day ||
138
+ last.turnAt !== point.turnAt ||
139
+ reading.at - last.at > 10 * 60 * 1000;
140
+ saveQuota(ADAPTER, {
141
+ ...reading,
142
+ meteredTokens: metered,
143
+ history: changed ? [...history, point].slice(-HISTORY_LIMIT) : history,
144
+ });
145
+ }
146
+
147
+ function meterSession(payload, now) {
148
+ const id = String(payload.session_id ?? "");
149
+ if (!id) return;
150
+ const record = loadMeter(ADAPTER, id);
151
+ if (now - (record.meteredAt ?? 0) < METER_THROTTLE_MS) return;
152
+ const files = transcriptFiles(payload);
153
+ if (files.length === 0) return;
154
+ sampleFiles(ADAPTER, id, files, now);
155
+ }
156
+
157
+ function keepAlive(payload, now) {
158
+ const id = String(payload.session_id ?? "");
159
+ if (!id) return;
160
+ const project = payload.cwd || payload.workspace?.current_dir || "";
161
+ const label = project ? path.basename(project) : String(payload.session_name || "session");
162
+ upsertClaimant(ADAPTER, id, { project, label, heartbeat: now });
163
+ }
164
+
165
+ function wrappedOutput(config, raw) {
166
+ const command = config.wrappedStatusLine;
167
+ if (!command) return "";
168
+ try {
169
+ return String(
170
+ execSync(command, { input: raw, encoding: "utf8", timeout: WRAP_TIMEOUT_MS, stdio: ["pipe", "pipe", "ignore"] }),
171
+ ).trim();
172
+ } catch {
173
+ return "";
174
+ }
175
+ }
176
+
177
+ function run() {
178
+ const input = readInput();
179
+ if (!input) return;
180
+ const { payload, raw } = input;
181
+ const now = Date.now();
182
+ const config = loadConfig();
183
+
184
+ keepAlive(payload, now);
185
+ meterSession(payload, now);
186
+
187
+ const reading = buildReading(payload, now);
188
+ const fresh = Object.keys(normalizeWindows(payload.rate_limits)).length > 0;
189
+ const plan = schedule(ADAPTER, now, "five_hour", reading);
190
+ if (reading && fresh) {
191
+ let turnAt = 0;
192
+ for (const claimant of plan.claimants) turnAt = Math.max(turnAt, loadMeter(ADAPTER, claimant.claimant.id).lastAt ?? 0);
193
+ persistReading(reading, plan.totalWeighted, turnAt);
194
+ }
195
+
196
+ const view = viewFor(plan, String(payload.session_id ?? ""));
197
+ const theme = loadTheme(config.theme.hud);
198
+ const quota = {};
199
+ for (const key of ["five_hour", "seven_day", "spend_limit"]) {
200
+ const window = plan.quota?.windows?.[key];
201
+ if (window && (window.resetsAt === undefined || plausibleReset(key, window.resetsAt, now) !== null)) quota[key] = window;
202
+ }
203
+
204
+ const bounds = windowBounds(plan.quota, "five_hour", now);
205
+ const history = (plan.quota?.history ?? [])
206
+ .filter((point) => typeof point.five_hour === "number" && point.at >= bounds.from)
207
+ .map((point) => point.five_hour);
208
+ const rate = (() => {
209
+ const points = (plan.quota?.history ?? []).filter((point) => typeof point.five_hour === "number" && point.at >= now - 45 * 60 * 1000);
210
+ const first = points[0];
211
+ const last = points[points.length - 1];
212
+ if (!first || !last || last.at <= first.at) return null;
213
+ return ((last.five_hour - first.five_hour) / (last.at - first.at)) * 3600000;
214
+ })();
215
+
216
+ const line = renderHud(
217
+ config.hud?.segments ?? config.layout.hud,
218
+ {
219
+ label: view?.claimant.label || (payload.cwd ? path.basename(payload.cwd) : "session"),
220
+ target: view?.allocation.target ?? 1,
221
+ observed: view?.observed ?? 0,
222
+ used: view?.attributedPercent ?? null,
223
+ pressure: view?.pressure.value ?? 0,
224
+ priority: view?.claimant.priority ?? "normal",
225
+ quota,
226
+ history,
227
+ rate,
228
+ from: bounds.from,
229
+ to: bounds.to,
230
+ stale: !fresh && Object.keys(quota).length > 0 && now - (plan.quota?.at ?? 0) > STALE_READING_MS,
231
+ now,
232
+ },
233
+ theme,
234
+ );
235
+
236
+ const prefix = wrappedOutput(config, raw);
237
+ process.stdout.write(prefix ? `${prefix} ${line}\n` : `${line}\n`);
238
+ }
239
+
240
+ try {
241
+ run();
242
+ } catch {}
243
+ process.exit(0);
@@ -0,0 +1,112 @@
1
+ const ESC = "\u001b";
2
+ const FINAL = /[@-~]/;
3
+ export function splitKeys(chunk) {
4
+ const keys = [];
5
+ let index = 0;
6
+ while (index < chunk.length) {
7
+ const char = chunk[index] ?? "";
8
+ if (char !== ESC) {
9
+ keys.push(char);
10
+ index++;
11
+ continue;
12
+ }
13
+ const next = chunk[index + 1];
14
+ if (next !== "[" && next !== "O") {
15
+ keys.push(ESC);
16
+ index++;
17
+ continue;
18
+ }
19
+ let end = index + 2;
20
+ while (end < chunk.length && !FINAL.test(chunk[end] ?? ""))
21
+ end++;
22
+ keys.push(chunk.slice(index, end + 1));
23
+ index = end + 1;
24
+ }
25
+ return keys;
26
+ }
27
+ export function actionFor(key, mode, step) {
28
+ if (key === "\u0003")
29
+ return { kind: "quit" };
30
+ if (mode === "prefs") {
31
+ if (key === "\r" || key === "\n")
32
+ return { kind: "save" };
33
+ if (key === ESC || key === "q")
34
+ return { kind: "skip" };
35
+ if (key === `${ESC}[A` || key === "k")
36
+ return { kind: "up" };
37
+ if (key === `${ESC}[B` || key === "j")
38
+ return { kind: "down" };
39
+ if (key === `${ESC}[C` || key === "l")
40
+ return { kind: "share", delta: step };
41
+ if (key === `${ESC}[D` || key === "h")
42
+ return { kind: "share", delta: -step };
43
+ if (key === " ")
44
+ return { kind: "toggleCurrent" };
45
+ if (key === "e")
46
+ return { kind: "edit" };
47
+ if (key === "s")
48
+ return { kind: "save" };
49
+ const index = Number(key) - 1;
50
+ if (Number.isInteger(index) && index >= 0 && index <= 8)
51
+ return { kind: "toggle", index };
52
+ return { kind: "none" };
53
+ }
54
+ switch (key) {
55
+ case "q":
56
+ return { kind: "quit" };
57
+ case ESC:
58
+ return { kind: "back" };
59
+ case `${ESC}[A`:
60
+ case "k":
61
+ return { kind: "up" };
62
+ case `${ESC}[B`:
63
+ case "j":
64
+ return { kind: "down" };
65
+ case `${ESC}[C`:
66
+ case "l":
67
+ return { kind: "share", delta: step };
68
+ case `${ESC}[D`:
69
+ case "h":
70
+ return { kind: "share", delta: -step };
71
+ case "p":
72
+ return { kind: "priority" };
73
+ case "e":
74
+ return { kind: "equalize" };
75
+ case "u":
76
+ return { kind: "unpin" };
77
+ case "d":
78
+ return { kind: "state", state: "done" };
79
+ case "b":
80
+ return { kind: "state", state: "blocked" };
81
+ case "a":
82
+ return { kind: "add" };
83
+ case "n":
84
+ return { kind: "state", state: "needs-more" };
85
+ case "r":
86
+ return { kind: "refresh" };
87
+ case "s":
88
+ case "P":
89
+ case ",":
90
+ return { kind: "preferences" };
91
+ case "?":
92
+ return { kind: "help" };
93
+ case "f":
94
+ return { kind: "pin" };
95
+ case " ":
96
+ return { kind: "toggleMember" };
97
+ case "x":
98
+ return { kind: "park" };
99
+ case "m":
100
+ return { kind: "expand" };
101
+ case "\r":
102
+ case "\n":
103
+ return { kind: "resume" };
104
+ default:
105
+ return { kind: "none" };
106
+ }
107
+ }
108
+ export function keyActions(chunk, mode, step) {
109
+ return splitKeys(chunk)
110
+ .map((key) => actionFor(key, mode, step))
111
+ .filter((action) => action.kind !== "none");
112
+ }
@@ -0,0 +1,287 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { withMoved, withToggled } from "../report/settings.js";
4
+ import { claudeCodeProvider } from "../adapters/claude-code/provider.js";
5
+ import { codexProvider } from "../adapters/codex/provider.js";
6
+ import { DEFAULT_CONFIG, HOOKS_DIR, FIVE_HOUR_MS, WINDOW_MS, clearDeferred, deferredProjects, loadClaimants, loadConfig, loadProjects, presetMatching, presetSegments, upsertProject, loadQuota, policyNames, saveConfig, schedule, upsertClaimant, windowBounds, } from "../runtime/kernel.mjs";
7
+ export const providers = [claudeCodeProvider, codexProvider];
8
+ export function providerFor(id) {
9
+ return providers.find((provider) => provider.id === id) ?? claudeCodeProvider;
10
+ }
11
+ export function detectedProviders() {
12
+ return providers.filter((provider) => provider.detect());
13
+ }
14
+ const QUIET_MS = 5 * 60 * 1000;
15
+ function unattributedPercent(plan) {
16
+ const history = plan.quota?.history;
17
+ if (!Array.isArray(history) || history.length < 2)
18
+ return null;
19
+ let drift = 0;
20
+ let anchor = null;
21
+ for (const point of history) {
22
+ if (point.at < plan.bounds.from)
23
+ continue;
24
+ if (typeof point.five_hour !== "number" || typeof point.metered !== "number")
25
+ continue;
26
+ const here = { at: point.at, metered: point.metered, five_hour: point.five_hour };
27
+ if (!anchor) {
28
+ anchor = here;
29
+ continue;
30
+ }
31
+ if (here.metered > anchor.metered) {
32
+ anchor = here;
33
+ continue;
34
+ }
35
+ if (here.at - anchor.at < QUIET_MS)
36
+ continue;
37
+ const moved = here.five_hour - anchor.five_hour;
38
+ if (moved > 0)
39
+ drift += moved;
40
+ anchor = here;
41
+ }
42
+ return drift > 0 ? drift : null;
43
+ }
44
+ export function buildPlan(now = Date.now(), withSweep = true, window = "five_hour", adapter = "claude-code") {
45
+ const provider = providerFor(adapter);
46
+ if (withSweep) {
47
+ const bounds = windowBounds(loadQuota(provider.id), window, now);
48
+ const span = WINDOW_MS[window] ?? FIVE_HOUR_MS;
49
+ provider.sweep(Math.min(bounds.from, now - span), now);
50
+ }
51
+ const plan = schedule(provider.id, now, window, null, provider.dataDir ?? null);
52
+ const others = detectedProviders()
53
+ .filter((other) => other.id !== provider.id)
54
+ .map((other) => ({ id: other.id, label: other.label, resources: other.resources(now) }));
55
+ return {
56
+ provider,
57
+ schedule: plan,
58
+ resources: provider.resources(now),
59
+ config: loadConfig(),
60
+ enforcement: provider.enforcer.supports,
61
+ unattributed: unattributedPercent(plan),
62
+ deferred: deferredProjects(provider.id, now),
63
+ others,
64
+ installed: fs.existsSync(path.join(HOOKS_DIR, "statusline.mjs")),
65
+ };
66
+ }
67
+ const CANDIDATE_LIMIT = 12;
68
+ export function inPlan(view) {
69
+ if (view.bucket === "active")
70
+ return true;
71
+ if (view.settings.kept === true)
72
+ return true;
73
+ if (view.settings.kept === false)
74
+ return false;
75
+ return view.settings.pinned || (view.settings.share ?? 0) > 0;
76
+ }
77
+ function byInterest(a, b) {
78
+ const live = Number(b.bucket === "active") - Number(a.bucket === "active");
79
+ if (live !== 0)
80
+ return live;
81
+ return (Number(b.settings.pinned) - Number(a.settings.pinned) ||
82
+ (b.allocation.target || b.settings.share || 0) - (a.allocation.target || a.settings.share || 0) ||
83
+ b.lastSeen - a.lastSeen);
84
+ }
85
+ export function workingSet(plan, full = false) {
86
+ const members = plan.projects.filter(inPlan).sort(byInterest);
87
+ const rest = plan.projects.filter((view) => !inPlan(view));
88
+ const open = rest.filter((view) => !view.settings.parked).sort(byInterest);
89
+ const buried = rest.filter((view) => view.settings.parked).sort(byInterest);
90
+ const candidates = full ? [...open, ...buried] : open.slice(0, CANDIDATE_LIMIT);
91
+ return { members, candidates, hidden: rest.length - candidates.length };
92
+ }
93
+ export function visibleRows(plan, full = false) {
94
+ const set = workingSet(plan, full);
95
+ return [...set.members, ...set.candidates];
96
+ }
97
+ export function joinPlan(project, adapter = "claude-code") {
98
+ upsertProject(adapter, project, { kept: true });
99
+ }
100
+ export function leavePlan(project, adapter = "claude-code") {
101
+ upsertProject(adapter, project, { kept: false });
102
+ }
103
+ export function demote(view, adapter = "claude-code") {
104
+ if (inPlan(view))
105
+ upsertProject(adapter, view.project, { kept: false });
106
+ else
107
+ upsertProject(adapter, view.project, { parked: true, kept: false });
108
+ }
109
+ export function promote(view, adapter = "claude-code") {
110
+ if (view.settings.parked)
111
+ upsertProject(adapter, view.project, { parked: false });
112
+ else
113
+ upsertProject(adapter, view.project, { kept: true, parked: false });
114
+ }
115
+ export function activeViews(plan) {
116
+ return workingSet(plan).members.filter((view) => view.bucket === "active");
117
+ }
118
+ export function selectionIndex(ids, selectedId, previousIndex) {
119
+ if (ids.length === 0)
120
+ return 0;
121
+ if (selectedId) {
122
+ const found = ids.indexOf(selectedId);
123
+ if (found !== -1)
124
+ return found;
125
+ }
126
+ return Math.max(0, Math.min(previousIndex, ids.length - 1));
127
+ }
128
+ export function cleanShare(share) {
129
+ if (share === null || !Number.isFinite(share))
130
+ return null;
131
+ const clamped = Math.max(0, Math.min(1, share));
132
+ const rounded = Math.round(clamped * 200) / 200;
133
+ return rounded < 0.005 ? 0 : rounded;
134
+ }
135
+ export function nextShare(view, delta) {
136
+ const from = view.settings.share ?? view.allocation.target;
137
+ return Math.max(0, Math.min(1, from + delta));
138
+ }
139
+ export function setShare(project, share, adapter = "claude-code") {
140
+ upsertProject(adapter, project, { share: cleanShare(share) });
141
+ }
142
+ export function setPriority(project, priority, adapter = "claude-code") {
143
+ upsertProject(adapter, project, { priority });
144
+ }
145
+ export function setState(project, state, adapter = "claude-code") {
146
+ for (const claimant of loadClaimants(adapter)) {
147
+ if ((claimant.project || claimant.label) !== project)
148
+ continue;
149
+ upsertClaimant(adapter, claimant.id, { state, endedAt: state === "done" ? Date.now() : null });
150
+ }
151
+ }
152
+ export function equalize(adapter = "claude-code") {
153
+ for (const project of loadProjects(adapter)) {
154
+ if (project.share === null)
155
+ continue;
156
+ upsertProject(adapter, project.project, { share: null });
157
+ }
158
+ }
159
+ export function cyclePriority(current) {
160
+ if (current === "high")
161
+ return "normal";
162
+ if (current === "normal")
163
+ return "low";
164
+ return "high";
165
+ }
166
+ export function savePreference(project, kinds) {
167
+ const config = loadConfig();
168
+ config.preserveFor[project || "default"] = kinds;
169
+ config.preferencesSetAt = Date.now();
170
+ saveConfig(config);
171
+ }
172
+ export function resetPreferences() {
173
+ const config = loadConfig();
174
+ const fresh = JSON.parse(JSON.stringify(DEFAULT_CONFIG));
175
+ saveConfig({
176
+ ...fresh,
177
+ version: config.version,
178
+ createdAt: config.createdAt,
179
+ preferencesSetAt: Date.now(),
180
+ offeredInstallAt: config.offeredInstallAt,
181
+ wrappedStatusLine: config.wrappedStatusLine,
182
+ });
183
+ }
184
+ export function toggleColumn(id) {
185
+ const config = loadConfig();
186
+ const columns = withToggled(config.columns, id);
187
+ config.columns = columns.length > 0 ? columns : [id];
188
+ saveConfig(config);
189
+ }
190
+ export function cyclePreset(delta, names) {
191
+ if (names.length === 0)
192
+ return;
193
+ const config = loadConfig();
194
+ const current = presetMatching(config.hud.segments);
195
+ const at = current ? names.indexOf(current) : -1;
196
+ const next = names[(at + delta + names.length * 2) % names.length] ?? names[0];
197
+ const segments = next ? presetSegments(next) : null;
198
+ if (segments) {
199
+ config.hud.segments = [...segments];
200
+ saveConfig(config);
201
+ }
202
+ }
203
+ export function toggleSegment(id) {
204
+ const config = loadConfig();
205
+ config.hud.segments = withToggled(config.hud.segments, id);
206
+ saveConfig(config);
207
+ }
208
+ export function moveSegment(id, delta) {
209
+ const config = loadConfig();
210
+ config.hud.segments = withMoved(config.hud.segments, id, delta);
211
+ saveConfig(config);
212
+ }
213
+ export function cycleTheme(surface, delta, names) {
214
+ if (names.length === 0)
215
+ return;
216
+ const config = loadConfig();
217
+ const at = names.indexOf(config.theme[surface]);
218
+ const next = names[(at + delta + names.length) % names.length] ?? names[0];
219
+ if (next)
220
+ config.theme[surface] = next;
221
+ saveConfig(config);
222
+ }
223
+ export function cyclePolicy(delta) {
224
+ const names = policyNames();
225
+ const config = loadConfig();
226
+ const at = names.indexOf(config.policy);
227
+ const next = names[(at + delta + names.length) % names.length] ?? names[0];
228
+ if (next)
229
+ config.policy = next;
230
+ saveConfig(config);
231
+ }
232
+ export function togglePreserve(kind) {
233
+ const config = loadConfig();
234
+ const current = config.preserveFor.default ?? [];
235
+ const next = current.includes(kind) ? current.filter((value) => value !== kind) : [...current, kind];
236
+ config.preserveFor.default = next;
237
+ config.preferencesSetAt = Date.now();
238
+ saveConfig(config);
239
+ }
240
+ export function saveCustomAdvice(project, text) {
241
+ const config = loadConfig();
242
+ const key = project || "default";
243
+ const value = text.trim();
244
+ if (value)
245
+ config.customAdvice[key] = value;
246
+ else
247
+ delete config.customAdvice[key];
248
+ saveConfig(config);
249
+ }
250
+ export function setPinned(project, pinned, adapter = "claude-code") {
251
+ upsertProject(adapter, project, { pinned });
252
+ }
253
+ export function setParked(project, parked, adapter = "claude-code") {
254
+ upsertProject(adapter, project, { parked });
255
+ if (parked)
256
+ setState(project, "done", adapter);
257
+ }
258
+ export function setPolicy(name, project) {
259
+ if (!policyNames().includes(name))
260
+ return false;
261
+ const config = loadConfig();
262
+ if (project)
263
+ config.policyFor[project] = name;
264
+ else
265
+ config.policy = name;
266
+ saveConfig(config);
267
+ return true;
268
+ }
269
+ export function forgetDeferred(project, adapter = "claude-code") {
270
+ clearDeferred(adapter, project);
271
+ }
272
+ export function resolveClaimant(plan, term) {
273
+ const needle = term.trim().toLowerCase();
274
+ if (!needle)
275
+ return null;
276
+ const pool = plan.projects;
277
+ const exact = pool.filter((view) => view.project === term || view.label.toLowerCase() === needle);
278
+ const partial = pool.filter((view) => view.label.toLowerCase().includes(needle) || view.project.toLowerCase().includes(needle));
279
+ const candidates = exact.length > 0 ? exact : partial;
280
+ if (candidates.length === 0)
281
+ return null;
282
+ const ranked = [...candidates].sort((a, b) => Number(b.bucket === "active") - Number(a.bucket === "active") || b.observed - a.observed);
283
+ const view = ranked[0];
284
+ if (!view)
285
+ return null;
286
+ return { view, matches: candidates.length };
287
+ }
@@ -0,0 +1,38 @@
1
+ import fs from "node:fs";
2
+ import path from "node:path";
3
+ import { EVIDENCE_SCHEMA } from "../core/types.js";
4
+ import { CACHE_DIR, readJson, writeJson } from "./paths.js";
5
+ const RETENTION_MS = 120 * 24 * 60 * 60 * 1000;
6
+ export class EvidenceCache {
7
+ file;
8
+ data;
9
+ dirty = false;
10
+ constructor(adapter) {
11
+ this.file = path.join(CACHE_DIR, `${adapter}.json`);
12
+ const loaded = readJson(this.file, { schema: EVIDENCE_SCHEMA, entries: {} });
13
+ this.data = loaded.schema === EVIDENCE_SCHEMA && loaded.entries ? loaded : { schema: EVIDENCE_SCHEMA, entries: {} };
14
+ }
15
+ get(file, size, mtimeMs) {
16
+ const hit = this.data.entries[file];
17
+ if (!hit)
18
+ return null;
19
+ if (hit.sourceSize !== size || Math.abs(hit.sourceMtimeMs - mtimeMs) > 1)
20
+ return null;
21
+ return hit;
22
+ }
23
+ set(evidence) {
24
+ this.data.entries[evidence.sourceFile] = evidence;
25
+ this.dirty = true;
26
+ }
27
+ flush() {
28
+ if (!this.dirty)
29
+ return;
30
+ const cutoff = Date.now() - RETENTION_MS;
31
+ for (const [file, evidence] of Object.entries(this.data.entries)) {
32
+ if (evidence.sourceMtimeMs < cutoff || !fs.existsSync(file))
33
+ delete this.data.entries[file];
34
+ }
35
+ writeJson(this.file, this.data);
36
+ this.dirty = false;
37
+ }
38
+ }