paseo-smart-session 1.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +169 -0
- package/client/charts.tsx +185 -0
- package/client/pill.tsx +340 -0
- package/client/surface.tsx +574 -0
- package/docs/screenshots/smart-compact-handoff.png +0 -0
- package/hooks/ask-compact.mjs +186 -0
- package/hooks/asks.mjs +117 -0
- package/hooks/context-threshold.mjs +149 -0
- package/hooks/context.mjs +233 -0
- package/hooks/pointer.mjs +149 -0
- package/hooks/post-compact.mjs +135 -0
- package/index.client.tsx +42 -0
- package/index.server.ts +195 -0
- package/mcp.mjs +602 -0
- package/package.json +46 -0
- package/paseo-plugin.json +6 -0
- package/server/backfill.ts +328 -0
- package/server/daemon-password.d.mts +6 -0
- package/server/daemon-password.mjs +35 -0
- package/server/daemon.ts +198 -0
- package/server/governor.ts +288 -0
- package/server/growth.ts +67 -0
- package/server/install-on-load.ts +34 -0
- package/server/install.ts +318 -0
- package/server/recorder.ts +255 -0
- package/server/settings.ts +225 -0
- package/server/spend.ts +137 -0
- package/server/store.ts +201 -0
- package/server/upstream.ts +117 -0
- package/shared/blocks.ts +208 -0
- package/shared/format.ts +82 -0
- package/shared/governor.ts +178 -0
- package/shared/lifecycle.ts +17 -0
- package/shared/smart-session.ts +155 -0
- package/shared/thresholds.ts +115 -0
- package/shared/usage.ts +400 -0
|
@@ -0,0 +1,318 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Installing the agent-facing half of the plugin into Claude Code.
|
|
3
|
+
*
|
|
4
|
+
* The `Stop` hook is not a nice-to-have: it is the only thing that ever asks a
|
|
5
|
+
* session whether to compact itself, so a Smart Session install without it does
|
|
6
|
+
* nothing at all except record. Leaving that to a paragraph in a README meant the
|
|
7
|
+
* feature was off for anyone who skimmed, and off in a way that looked like it was
|
|
8
|
+
* working — the surface said "on", and nothing ever happened.
|
|
9
|
+
*
|
|
10
|
+
* Paseo cannot do this for us. Its plugin API contributes UI, commands, timeline
|
|
11
|
+
* renderers and RPCs, and explicitly nothing about agent configuration — there is
|
|
12
|
+
* no hook contribution and no way to set an agent's environment. So the plugin
|
|
13
|
+
* reconciles the entries itself, in `~/.claude/settings.json`.
|
|
14
|
+
*
|
|
15
|
+
* The rules that make that acceptable:
|
|
16
|
+
*
|
|
17
|
+
* - **Own only our own.** An entry is ours when its command points at a
|
|
18
|
+
* `hooks/*.mjs` in this repository. Nothing else in the file is read for
|
|
19
|
+
* meaning, reordered or rewritten.
|
|
20
|
+
* - **Never write a path that does not exist.** The plugin directory comes from
|
|
21
|
+
* Paseo's own `config.json` and is then checked for the scripts themselves. A
|
|
22
|
+
* hook command pointing at a missing file fails on every turn of every session.
|
|
23
|
+
* - **Write only on a real change.** Reconciling to identical JSON writes nothing,
|
|
24
|
+
* so the common case — every reload after the first — touches no files.
|
|
25
|
+
* - **Be reversible.** One backup, kept from the first modification, and turning
|
|
26
|
+
* `installHooks` off removes every entry we added.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { randomUUID } from "node:crypto";
|
|
30
|
+
import { execFile } from "node:child_process";
|
|
31
|
+
import { access, copyFile, mkdir, readFile, rename, writeFile } from "node:fs/promises";
|
|
32
|
+
import { homedir } from "node:os";
|
|
33
|
+
import { dirname, join } from "node:path";
|
|
34
|
+
import { promisify } from "node:util";
|
|
35
|
+
|
|
36
|
+
import { readSettings } from "./settings.ts";
|
|
37
|
+
|
|
38
|
+
const execFileAsync = promisify(execFile);
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The hooks this plugin owns, and the events they belong on.
|
|
42
|
+
*
|
|
43
|
+
* `PostCompact` appears alongside `SessionStart` deliberately: the same script is
|
|
44
|
+
* registered on both because neither event can do the whole job — `PostCompact`
|
|
45
|
+
* knows a compaction happened but cannot inject, and `SessionStart:compact` can
|
|
46
|
+
* inject but does not know. See `hooks/pointer.mjs`.
|
|
47
|
+
*/
|
|
48
|
+
const MANAGED = [
|
|
49
|
+
{ event: "PostToolUse", matcher: "", script: "context-threshold.mjs" },
|
|
50
|
+
{ event: "Stop", matcher: "", script: "ask-compact.mjs" },
|
|
51
|
+
{ event: "PostCompact", matcher: "", script: "post-compact.mjs" },
|
|
52
|
+
{ event: "SessionStart", matcher: "compact", script: "post-compact.mjs" },
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/** Every script we might have written, including into an older install's path. */
|
|
56
|
+
const OUR_SCRIPTS = ["context-threshold.mjs", "ask-compact.mjs", "post-compact.mjs"];
|
|
57
|
+
|
|
58
|
+
const HOOK_TIMEOUT_SECONDS = 10;
|
|
59
|
+
|
|
60
|
+
interface HookEntry {
|
|
61
|
+
type?: string;
|
|
62
|
+
command?: string;
|
|
63
|
+
timeout?: number;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
interface MatcherGroup {
|
|
67
|
+
matcher?: string;
|
|
68
|
+
hooks?: HookEntry[];
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
type HookMap = Record<string, MatcherGroup[]>;
|
|
72
|
+
|
|
73
|
+
export interface InstallReport {
|
|
74
|
+
/** Where the hook scripts were found, or null when they could not be. */
|
|
75
|
+
readonly pluginDir: string | null;
|
|
76
|
+
readonly settingsPath: string;
|
|
77
|
+
/** True when this run actually modified the file. */
|
|
78
|
+
readonly changed: boolean;
|
|
79
|
+
/** One line per managed event, for the surface to show. */
|
|
80
|
+
readonly hooks: string[];
|
|
81
|
+
readonly mcp: "present" | "added" | "unavailable" | "skipped";
|
|
82
|
+
readonly error: string | null;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
const claudeDir = (): string => process.env.CLAUDE_CONFIG_DIR ?? join(homedir(), ".claude");
|
|
86
|
+
const settingsPath = (): string => join(claudeDir(), "settings.json");
|
|
87
|
+
|
|
88
|
+
async function exists(path: string): Promise<boolean> {
|
|
89
|
+
try {
|
|
90
|
+
await access(path);
|
|
91
|
+
return true;
|
|
92
|
+
} catch {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Where this plugin's files live, according to Paseo.
|
|
99
|
+
*
|
|
100
|
+
* Read from the host's own `config.json` rather than derived from the module's
|
|
101
|
+
* location: Paseo compiles `index.server.ts` into a bundle whose path is an
|
|
102
|
+
* implementation detail, and `import.meta` is not safe to rely on across that
|
|
103
|
+
* compilation. Every configured plugin is checked rather than just `smart-session`,
|
|
104
|
+
* because a second install under `--id something-else` is a supported thing to do.
|
|
105
|
+
*/
|
|
106
|
+
export async function resolvePluginDir(): Promise<string | null> {
|
|
107
|
+
const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
108
|
+
let configured: string[] = [];
|
|
109
|
+
try {
|
|
110
|
+
const raw = JSON.parse(await readFile(join(home, "config.json"), "utf8")) as {
|
|
111
|
+
plugins?: Record<string, { path?: unknown }>;
|
|
112
|
+
};
|
|
113
|
+
configured = Object.values(raw.plugins ?? {})
|
|
114
|
+
.map((entry) => entry.path)
|
|
115
|
+
.filter((path): path is string => typeof path === "string" && path !== "");
|
|
116
|
+
} catch {
|
|
117
|
+
// No config, or one we cannot read. Fall through to the environment.
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const fromEnv = process.env.SMART_SESSION_PLUGIN_DIR;
|
|
121
|
+
const candidates = fromEnv !== undefined && fromEnv !== "" ? [fromEnv, ...configured] : configured;
|
|
122
|
+
|
|
123
|
+
for (const candidate of candidates) {
|
|
124
|
+
// The scripts themselves are the proof. A directory that merely has the right
|
|
125
|
+
// name would still produce hook commands that fail on every turn.
|
|
126
|
+
const complete = await Promise.all(
|
|
127
|
+
OUR_SCRIPTS.map((script) => exists(join(candidate, "hooks", script))),
|
|
128
|
+
);
|
|
129
|
+
if (complete.every(Boolean)) return candidate;
|
|
130
|
+
}
|
|
131
|
+
return null;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** Whether a hook command is one of ours, wherever it was installed from. */
|
|
135
|
+
function isOurs(command: unknown): boolean {
|
|
136
|
+
if (typeof command !== "string") return false;
|
|
137
|
+
return OUR_SCRIPTS.some((script) => command.includes(`hooks/${script}`));
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function commandFor(pluginDir: string, script: string): string {
|
|
141
|
+
return `node ${join(pluginDir, "hooks", script)}`;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
/**
|
|
145
|
+
* The hook map this plugin wants, given the one already on disk.
|
|
146
|
+
*
|
|
147
|
+
* Every entry of ours is stripped first and then re-added, so a plugin that moved
|
|
148
|
+
* directory or renamed a script leaves nothing stale behind — and so the manual
|
|
149
|
+
* entries an early adopter added by hand are adopted rather than duplicated.
|
|
150
|
+
*/
|
|
151
|
+
export function reconcile(existing: HookMap, pluginDir: string | null): HookMap {
|
|
152
|
+
const next: HookMap = {};
|
|
153
|
+
|
|
154
|
+
for (const [event, groups] of Object.entries(existing)) {
|
|
155
|
+
const kept = (Array.isArray(groups) ? groups : [])
|
|
156
|
+
.map((group) => ({
|
|
157
|
+
...group,
|
|
158
|
+
hooks: (group.hooks ?? []).filter((hook) => !isOurs(hook.command)),
|
|
159
|
+
}))
|
|
160
|
+
// A matcher group we emptied was only ever there for us.
|
|
161
|
+
.filter((group) => (group.hooks ?? []).length > 0);
|
|
162
|
+
if (kept.length > 0) next[event] = kept;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
if (pluginDir === null) return next;
|
|
166
|
+
|
|
167
|
+
for (const { event, matcher, script } of MANAGED) {
|
|
168
|
+
const groups = next[event] ?? [];
|
|
169
|
+
const entry: HookEntry = {
|
|
170
|
+
type: "command",
|
|
171
|
+
command: commandFor(pluginDir, script),
|
|
172
|
+
timeout: HOOK_TIMEOUT_SECONDS,
|
|
173
|
+
};
|
|
174
|
+
const group = groups.find((candidate) => (candidate.matcher ?? "") === matcher);
|
|
175
|
+
if (group === undefined) groups.push({ matcher, hooks: [entry] });
|
|
176
|
+
else group.hooks = [...(group.hooks ?? []), entry];
|
|
177
|
+
next[event] = groups;
|
|
178
|
+
}
|
|
179
|
+
return next;
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** One backup, from the first time we ever touched the file. */
|
|
183
|
+
async function backupOnce(path: string): Promise<void> {
|
|
184
|
+
const backup = `${path}.smart-session-backup`;
|
|
185
|
+
if (await exists(backup)) return;
|
|
186
|
+
try {
|
|
187
|
+
await copyFile(path, backup);
|
|
188
|
+
} catch {
|
|
189
|
+
// A missing backup is not worth refusing the install over; the reconciler is
|
|
190
|
+
// additive and reversible on its own terms.
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
async function writeAtomic(path: string, text: string): Promise<void> {
|
|
195
|
+
await mkdir(dirname(path), { recursive: true });
|
|
196
|
+
const temp = join(dirname(path), `.smart-session-${randomUUID()}.tmp`);
|
|
197
|
+
await writeFile(temp, text, "utf8");
|
|
198
|
+
await rename(temp, path);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Registers the agent-facing MCP server, through Claude Code's own CLI.
|
|
203
|
+
*
|
|
204
|
+
* `~/.claude.json` is where the MCP list lives, and it is also where credentials
|
|
205
|
+
* live, so this plugin does not write it: `claude mcp add-json` does, which keeps
|
|
206
|
+
* us out of a file we have no business editing. Only ever called when the server is
|
|
207
|
+
* absent, because shelling out on every load would be absurd.
|
|
208
|
+
*/
|
|
209
|
+
async function ensureMcp(pluginDir: string): Promise<InstallReport["mcp"]> {
|
|
210
|
+
const run = (args: string[]): Promise<{ stdout: string }> =>
|
|
211
|
+
execFileAsync("claude", args, { timeout: 20_000, maxBuffer: 1024 * 1024 });
|
|
212
|
+
|
|
213
|
+
try {
|
|
214
|
+
await run(["mcp", "get", "smart-session"]);
|
|
215
|
+
return "present";
|
|
216
|
+
} catch {
|
|
217
|
+
// Absent, or no `claude` on PATH. The add below tells the two apart.
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
const definition = JSON.stringify({
|
|
221
|
+
type: "stdio",
|
|
222
|
+
command: "node",
|
|
223
|
+
args: [join(pluginDir, "mcp.mjs")],
|
|
224
|
+
});
|
|
225
|
+
try {
|
|
226
|
+
await run(["mcp", "add-json", "--scope", "user", "smart-session", definition]);
|
|
227
|
+
return "added";
|
|
228
|
+
} catch {
|
|
229
|
+
// No `claude` binary on the daemon's PATH is the usual reason, and it is not
|
|
230
|
+
// an error worth failing a plugin load over.
|
|
231
|
+
return "unavailable";
|
|
232
|
+
}
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
let tail: Promise<unknown> = Promise.resolve();
|
|
236
|
+
|
|
237
|
+
/** One writer at a time: this is read-modify-write over a file we do not own. */
|
|
238
|
+
function serialize<T>(work: () => Promise<T>): Promise<T> {
|
|
239
|
+
const next = tail.then(work, work);
|
|
240
|
+
tail = next.then(
|
|
241
|
+
() => undefined,
|
|
242
|
+
() => undefined,
|
|
243
|
+
);
|
|
244
|
+
return next;
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
/**
|
|
248
|
+
* Brings Claude Code's configuration in line with this plugin's settings.
|
|
249
|
+
*
|
|
250
|
+
* Safe to call on every load and after every settings change: with nothing to do it
|
|
251
|
+
* reads two files and writes none.
|
|
252
|
+
*/
|
|
253
|
+
export function install(options: { registerMcp?: boolean } = {}): Promise<InstallReport> {
|
|
254
|
+
const registerMcp = options.registerMcp !== false;
|
|
255
|
+
return serialize(async () => {
|
|
256
|
+
const path = settingsPath();
|
|
257
|
+
const settings = await readSettings();
|
|
258
|
+
const pluginDir = settings.installHooks ? await resolvePluginDir() : null;
|
|
259
|
+
|
|
260
|
+
if (settings.installHooks && pluginDir === null) {
|
|
261
|
+
return {
|
|
262
|
+
pluginDir: null,
|
|
263
|
+
settingsPath: path,
|
|
264
|
+
changed: false,
|
|
265
|
+
hooks: [],
|
|
266
|
+
mcp: "skipped",
|
|
267
|
+
error:
|
|
268
|
+
"Could not find this plugin's hooks/ directory from Paseo's config.json, so no hooks were installed. Set SMART_SESSION_PLUGIN_DIR to the checkout if the plugin runs from somewhere unusual.",
|
|
269
|
+
};
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
let raw = "";
|
|
273
|
+
let parsed: Record<string, unknown> = {};
|
|
274
|
+
try {
|
|
275
|
+
raw = await readFile(path, "utf8");
|
|
276
|
+
parsed = JSON.parse(raw) as Record<string, unknown>;
|
|
277
|
+
} catch (error) {
|
|
278
|
+
const code = (error as NodeJS.ErrnoException).code;
|
|
279
|
+
if (code !== "ENOENT") {
|
|
280
|
+
// Malformed settings are not ours to repair, and rewriting them from a
|
|
281
|
+
// partial parse would lose whatever the user has in there.
|
|
282
|
+
return {
|
|
283
|
+
pluginDir,
|
|
284
|
+
settingsPath: path,
|
|
285
|
+
changed: false,
|
|
286
|
+
hooks: [],
|
|
287
|
+
mcp: "skipped",
|
|
288
|
+
error: `${path} is not valid JSON, so it was left alone. Fix it and reload the plugin.`,
|
|
289
|
+
};
|
|
290
|
+
}
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
const before = (parsed.hooks ?? {}) as HookMap;
|
|
294
|
+
const after = reconcile(before, pluginDir);
|
|
295
|
+
const merged = { ...parsed, hooks: after };
|
|
296
|
+
if (Object.keys(after).length === 0) delete (merged as { hooks?: unknown }).hooks;
|
|
297
|
+
|
|
298
|
+
const text = `${JSON.stringify(merged, null, 2)}\n`;
|
|
299
|
+
const changed = text !== raw;
|
|
300
|
+
if (changed) {
|
|
301
|
+
if (raw !== "") await backupOnce(path);
|
|
302
|
+
await writeAtomic(path, text);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
const lines = pluginDir === null
|
|
306
|
+
? []
|
|
307
|
+
: MANAGED.map(({ event, matcher }) => `${event}${matcher === "" ? "" : `:${matcher}`}`);
|
|
308
|
+
|
|
309
|
+
return {
|
|
310
|
+
pluginDir,
|
|
311
|
+
settingsPath: path,
|
|
312
|
+
changed,
|
|
313
|
+
hooks: lines,
|
|
314
|
+
mcp: pluginDir === null || !registerMcp ? "skipped" : await ensureMcp(pluginDir),
|
|
315
|
+
error: null,
|
|
316
|
+
};
|
|
317
|
+
});
|
|
318
|
+
}
|
|
@@ -0,0 +1,255 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The recorder: the one piece that has to be running before anything else is worth
|
|
3
|
+
* building.
|
|
4
|
+
*
|
|
5
|
+
* Plan-utilization history is not kept anywhere — `/usage` is a snapshot, Paseo's
|
|
6
|
+
* quota fetcher has no store, and Claude Code's on-disk copy is overwritten in
|
|
7
|
+
* place. Tokens can be reconstructed from transcripts after the fact; percentages
|
|
8
|
+
* of a plan limit cannot. Every hour this is not running is an hour of history that
|
|
9
|
+
* cannot be recovered later, which is why it starts on import and does its own
|
|
10
|
+
* thing whether or not any UI is open.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { readFile, stat } from "node:fs/promises";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
|
|
17
|
+
import { scanSpend } from "./backfill.ts";
|
|
18
|
+
import { readAgents, readProviderUsage, withDaemon } from "./daemon.ts";
|
|
19
|
+
import { lifecycle } from "../shared/lifecycle.ts";
|
|
20
|
+
import {
|
|
21
|
+
appendContext,
|
|
22
|
+
appendUsage,
|
|
23
|
+
clearCaches,
|
|
24
|
+
newestUsage,
|
|
25
|
+
noteNewestUsage,
|
|
26
|
+
type ContextSample,
|
|
27
|
+
} from "./store.ts";
|
|
28
|
+
import { fetchUpstreamUsage } from "./upstream.ts";
|
|
29
|
+
import {
|
|
30
|
+
acceptReading,
|
|
31
|
+
effectiveAt,
|
|
32
|
+
fromClaudeJson,
|
|
33
|
+
fromPaseoProviderUsage,
|
|
34
|
+
fromUpstream,
|
|
35
|
+
isWorthRecording,
|
|
36
|
+
type UsageSample,
|
|
37
|
+
} from "../shared/usage.ts";
|
|
38
|
+
|
|
39
|
+
const TICK_MS = 60_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* How often to ask Anthropic directly.
|
|
43
|
+
*
|
|
44
|
+
* Paseo's reading is free but bounded at five minutes old, and there is no way to
|
|
45
|
+
* force it to refresh — `forceRefresh` exists in the daemon but is not in the wire
|
|
46
|
+
* schema, so a client cannot ask for it. Fetching upstream ourselves is what makes
|
|
47
|
+
* the history actually true rather than approximately true. Three minutes keeps the
|
|
48
|
+
* worst-case error under half of Paseo's, without asking more often than a Claude
|
|
49
|
+
* Code session would on its own.
|
|
50
|
+
*/
|
|
51
|
+
const UPSTREAM_TICK_MS = 3 * 60_000;
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* How stale things must get before Claude Code's on-disk cache is worth reading.
|
|
55
|
+
*
|
|
56
|
+
* It only refreshes when a session refreshes it — in practice when someone runs
|
|
57
|
+
* `/usage` — and has been measured 14 hours behind, understating a weekly window by
|
|
58
|
+
* 7 points. It is a fallback for when both live sources fail, nothing more.
|
|
59
|
+
*/
|
|
60
|
+
const CACHE_FALLBACK_AFTER_MS = 15 * 60_000;
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* How often to fold new transcript lines into the spend index.
|
|
64
|
+
*
|
|
65
|
+
* The scan is incremental, so this is cheap after the first pass; ten minutes keeps
|
|
66
|
+
* the attribution current without re-reading the world.
|
|
67
|
+
*/
|
|
68
|
+
const SPEND_SCAN_MS = 10 * 60_000;
|
|
69
|
+
|
|
70
|
+
/** Where Claude Code keeps the usage payload it last fetched. */
|
|
71
|
+
function claudeJsonPath(): string {
|
|
72
|
+
const configDir = process.env.CLAUDE_CONFIG_DIR;
|
|
73
|
+
return configDir !== undefined && configDir !== ""
|
|
74
|
+
? join(configDir, ".claude.json")
|
|
75
|
+
: join(homedir(), ".claude.json");
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* Records a reading if it is both new and credible.
|
|
80
|
+
*
|
|
81
|
+
* Two of the three sources are caches that can hand back something hours old, so
|
|
82
|
+
* "did this actually happen after what I already have" is asked before "is this
|
|
83
|
+
* different from what I already have".
|
|
84
|
+
*/
|
|
85
|
+
async function record(sample: UsageSample): Promise<boolean> {
|
|
86
|
+
const newest = await newestUsage();
|
|
87
|
+
const verdict = acceptReading(newest, sample);
|
|
88
|
+
if (!verdict.ok) return false;
|
|
89
|
+
if (!isWorthRecording(newest, sample)) return false;
|
|
90
|
+
await appendUsage(sample);
|
|
91
|
+
noteNewestUsage(sample);
|
|
92
|
+
return true;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Claude Code's cache, read only when it has actually changed.
|
|
97
|
+
*
|
|
98
|
+
* It refreshes only when a Claude Code session refreshes it — in practice when
|
|
99
|
+
* someone runs `/usage` — so most ticks find it untouched and skip the read
|
|
100
|
+
* entirely. When it does move it is free extra resolution between daemon polls,
|
|
101
|
+
* never a substitute for them: `acceptReading` throws it out if it turns out to be
|
|
102
|
+
* older than what the daemon already told us.
|
|
103
|
+
*/
|
|
104
|
+
let lastClaudeJsonMtimeMs = 0;
|
|
105
|
+
|
|
106
|
+
async function pollClaudeJson(): Promise<void> {
|
|
107
|
+
// Only when nothing live has answered for a while: a stale reading that gets
|
|
108
|
+
// rejected still costs a file read, and one that gets accepted is only better
|
|
109
|
+
// than nothing.
|
|
110
|
+
const newest = await newestUsage();
|
|
111
|
+
if (newest !== null && Date.now() - Date.parse(effectiveAt(newest)) < CACHE_FALLBACK_AFTER_MS) return;
|
|
112
|
+
|
|
113
|
+
const path = claudeJsonPath();
|
|
114
|
+
let mtimeMs: number;
|
|
115
|
+
try {
|
|
116
|
+
mtimeMs = (await stat(path)).mtimeMs;
|
|
117
|
+
} catch {
|
|
118
|
+
return; // No Claude Code on this machine, or no config yet.
|
|
119
|
+
}
|
|
120
|
+
if (mtimeMs === lastClaudeJsonMtimeMs) return;
|
|
121
|
+
lastClaudeJsonMtimeMs = mtimeMs;
|
|
122
|
+
|
|
123
|
+
try {
|
|
124
|
+
const parsed = JSON.parse(await readFile(path, "utf8")) as { cachedUsageUtilization?: unknown };
|
|
125
|
+
const sample = fromClaudeJson(parsed.cachedUsageUtilization);
|
|
126
|
+
if (sample !== null) await record(sample);
|
|
127
|
+
} catch (error) {
|
|
128
|
+
console.error("[smart-session] could not read Claude's usage cache", String(error));
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/** Last context reading per agent, so only movement is written. */
|
|
133
|
+
const lastContext = new Map<string, number>();
|
|
134
|
+
|
|
135
|
+
let lastUpstreamMs = 0;
|
|
136
|
+
let lastSpendScanMs = 0;
|
|
137
|
+
let authWarned = false;
|
|
138
|
+
|
|
139
|
+
async function pollSpend(): Promise<void> {
|
|
140
|
+
if (Date.now() - lastSpendScanMs < SPEND_SCAN_MS) return;
|
|
141
|
+
lastSpendScanMs = Date.now();
|
|
142
|
+
try {
|
|
143
|
+
const result = await scanSpend();
|
|
144
|
+
if (result.messagesCounted > 0) {
|
|
145
|
+
console.log(`[smart-session] folded ${result.messagesCounted} message(s) into the spend index`);
|
|
146
|
+
}
|
|
147
|
+
} catch (error) {
|
|
148
|
+
console.error("[smart-session] spend scan failed", String(error));
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Asks Anthropic directly, on its own slower cadence.
|
|
154
|
+
*
|
|
155
|
+
* Failure here is not an error worth stopping for: the daemon poll still runs, and
|
|
156
|
+
* an expired credential is something only the user can fix, so it is said once
|
|
157
|
+
* rather than every three minutes.
|
|
158
|
+
*/
|
|
159
|
+
async function pollUpstream(): Promise<void> {
|
|
160
|
+
if (Date.now() - lastUpstreamMs < UPSTREAM_TICK_MS) return;
|
|
161
|
+
lastUpstreamMs = Date.now();
|
|
162
|
+
|
|
163
|
+
const result = await fetchUpstreamUsage();
|
|
164
|
+
if (result.kind === "needs-auth") {
|
|
165
|
+
if (!authWarned) {
|
|
166
|
+
authWarned = true;
|
|
167
|
+
console.error(
|
|
168
|
+
"[smart-session] Claude credentials are expired or unreadable; falling back to Paseo's cached usage, which can be up to five minutes old. Log in with the Claude CLI to restore direct readings.",
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
if (result.kind === "unavailable") {
|
|
174
|
+
console.error("[smart-session] upstream usage unavailable", result.reason);
|
|
175
|
+
return;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
authWarned = false;
|
|
179
|
+
const sample = fromUpstream(result.body);
|
|
180
|
+
if (sample !== null) await record(sample);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
async function pollDaemon(): Promise<void> {
|
|
184
|
+
await withDaemon(async (client) => {
|
|
185
|
+
const at = new Date().toISOString();
|
|
186
|
+
|
|
187
|
+
const usage = fromPaseoProviderUsage(await readProviderUsage(client));
|
|
188
|
+
if (usage !== null) await record(usage);
|
|
189
|
+
|
|
190
|
+
for (const agent of await readAgents(client)) {
|
|
191
|
+
if (agent.usedTokens === null || agent.maxTokens === null) continue;
|
|
192
|
+
if (lastContext.get(agent.id) === agent.usedTokens) continue;
|
|
193
|
+
lastContext.set(agent.id, agent.usedTokens);
|
|
194
|
+
const row: ContextSample = {
|
|
195
|
+
at,
|
|
196
|
+
agentId: agent.id,
|
|
197
|
+
usedTokens: agent.usedTokens,
|
|
198
|
+
maxTokens: agent.maxTokens,
|
|
199
|
+
costUsd: agent.costUsd,
|
|
200
|
+
provider: agent.provider,
|
|
201
|
+
model: agent.model,
|
|
202
|
+
status: agent.status,
|
|
203
|
+
title: agent.title,
|
|
204
|
+
};
|
|
205
|
+
await appendContext(row);
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* Starts the recorder as an import side effect and registers its teardown.
|
|
212
|
+
*
|
|
213
|
+
* `contribute()` cannot call this: Paseo strips `*.server` imports from the client
|
|
214
|
+
* bundle while keeping the surrounding statements, so a server identifier in that
|
|
215
|
+
* shared body becomes a ReferenceError that aborts every registration. Teardown
|
|
216
|
+
* still has to run from `contribute()`'s cleanup, or the interval keeps the
|
|
217
|
+
* subprocess alive and Paseo's stop step hangs, which wedges reload. The shared
|
|
218
|
+
* `lifecycle` object bridges the two safely.
|
|
219
|
+
*/
|
|
220
|
+
function startRecorder(): void {
|
|
221
|
+
let running = false;
|
|
222
|
+
let stopped = false;
|
|
223
|
+
|
|
224
|
+
const tick = async () => {
|
|
225
|
+
if (running || stopped) return; // One tick at a time; a slow daemon must not pile up.
|
|
226
|
+
running = true;
|
|
227
|
+
try {
|
|
228
|
+
// Order matters: the freshest source first, so the acceptance gate has the
|
|
229
|
+
// best reading to compare the others against.
|
|
230
|
+
await pollUpstream();
|
|
231
|
+
await pollDaemon();
|
|
232
|
+
await pollClaudeJson();
|
|
233
|
+
await pollSpend();
|
|
234
|
+
} catch (error) {
|
|
235
|
+
// A daemon restart, a lost socket, a machine asleep — none of these should
|
|
236
|
+
// end the recorder. The gap is visible in the data, which is the honest
|
|
237
|
+
// outcome; a dead timer would not be.
|
|
238
|
+
console.error("[smart-session] tick failed", String(error));
|
|
239
|
+
} finally {
|
|
240
|
+
running = false;
|
|
241
|
+
}
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
const timer = setInterval(() => void tick(), TICK_MS);
|
|
245
|
+
void tick();
|
|
246
|
+
|
|
247
|
+
lifecycle.teardowns.push(() => {
|
|
248
|
+
stopped = true;
|
|
249
|
+
clearInterval(timer);
|
|
250
|
+
lastContext.clear();
|
|
251
|
+
clearCaches();
|
|
252
|
+
});
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
startRecorder();
|