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.
@@ -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();