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,288 @@
1
+ /**
2
+ * Delivery for compaction requests.
3
+ *
4
+ * The queue lives on the daemon so a request survives a plugin reload, a Paseo
5
+ * restart, and the client that made it going away. Delivery waits for the target
6
+ * agent to be idle, then sends `/compact <instructions>` — which Paseo routes as a
7
+ * real command rather than as text the model reads.
8
+ *
9
+ * This file decides nothing. Every request in the queue was put there by the agent
10
+ * it belongs to, or by a person; the governor's whole job is to carry it out at a
11
+ * moment when doing so is safe, and then, when requested, to hand the emptied
12
+ * session back its state file. Those are the only things this plugin ever says to
13
+ * an agent.
14
+ */
15
+
16
+ import { randomUUID } from "node:crypto";
17
+ import { mkdir, readFile, rename, writeFile } from "node:fs/promises";
18
+ import { join } from "node:path";
19
+
20
+ import { readAgents, sendToAgent, withDaemon, type AgentRow } from "./daemon.ts";
21
+ import { compactionInstructions, continuationInstructions, type CompactionRequest } from "../shared/governor.ts";
22
+ import { lifecycle } from "../shared/lifecycle.ts";
23
+ import { readSettings } from "./settings.ts";
24
+ import { dataDir } from "./store.ts";
25
+
26
+ const TICK_MS = 15_000;
27
+
28
+ /**
29
+ * How long to keep watching an agent after a compaction, to record what it
30
+ * achieved and to hand it back its state file. Compaction is a summarization turn;
31
+ * a few minutes of ticks is plenty.
32
+ */
33
+ const GRADE_WINDOW_MS = 5 * 60_000;
34
+
35
+ const queuePath = () => join(dataDir(), "compactions.json");
36
+
37
+ let tail: Promise<unknown> = Promise.resolve();
38
+
39
+ function serialize<T>(work: () => Promise<T>): Promise<T> {
40
+ const next = tail.then(work, work);
41
+ tail = next.then(
42
+ () => undefined,
43
+ () => undefined,
44
+ );
45
+ return next;
46
+ }
47
+
48
+ /**
49
+ * Fills in fields added after a row was written.
50
+ *
51
+ * The queue is validated against `CompactionRequestSchema` on its way out over the
52
+ * RPC boundary, so a row from an older version missing a key would fail there
53
+ * rather than here. Defaulting on read is the migration.
54
+ */
55
+ function normalize(item: CompactionRequest): CompactionRequest {
56
+ return {
57
+ ...item,
58
+ continueAfterCompaction: item.continueAfterCompaction ?? true,
59
+ continuationMessage: item.continuationMessage ?? null,
60
+ resumedAt: item.resumedAt ?? null,
61
+ };
62
+ }
63
+
64
+ async function readAll(): Promise<CompactionRequest[]> {
65
+ try {
66
+ const raw = await readFile(queuePath(), "utf8");
67
+ const parsed = JSON.parse(raw) as { version?: number; items?: CompactionRequest[] };
68
+ return (parsed.items ?? []).map(normalize);
69
+ } catch (error) {
70
+ const code = (error as NodeJS.ErrnoException).code;
71
+ if (code !== "ENOENT") console.error("[smart-session] could not read the compaction queue", String(error));
72
+ return [];
73
+ }
74
+ }
75
+
76
+ async function writeAll(items: CompactionRequest[]): Promise<void> {
77
+ await mkdir(dataDir(), { recursive: true });
78
+ const target = queuePath();
79
+ const temp = `${target}.${randomUUID()}.tmp`;
80
+ await writeFile(temp, JSON.stringify({ version: 1, items }, null, 2), "utf8");
81
+ await rename(temp, target);
82
+ }
83
+
84
+ export const queue = {
85
+ list: (): Promise<CompactionRequest[]> => serialize(readAll),
86
+
87
+ add: (input: {
88
+ agentId: string;
89
+ reason: string;
90
+ statePath: string | null;
91
+ continueAfterCompaction: boolean;
92
+ continuationMessage: string | null;
93
+ }): Promise<CompactionRequest> =>
94
+ serialize(async () => {
95
+ const items = await readAll();
96
+ const request: CompactionRequest = {
97
+ id: randomUUID(),
98
+ agentId: input.agentId,
99
+ reason: input.reason,
100
+ statePath: input.statePath,
101
+ continueAfterCompaction: input.continueAfterCompaction,
102
+ continuationMessage: input.continuationMessage,
103
+ createdAt: new Date().toISOString(),
104
+ state: "pending",
105
+ settledAt: null,
106
+ error: null,
107
+ preTokens: null,
108
+ postTokens: null,
109
+ resumedAt: null,
110
+ };
111
+ items.push(request);
112
+ await writeAll(items);
113
+ return request;
114
+ }),
115
+
116
+ update: (id: string, patch: Partial<CompactionRequest>): Promise<CompactionRequest | null> =>
117
+ serialize(async () => {
118
+ const items = await readAll();
119
+ const found = items.find((item) => item.id === id);
120
+ if (found === undefined) return null;
121
+ Object.assign(found, patch);
122
+ await writeAll(items);
123
+ return found;
124
+ }),
125
+
126
+ /**
127
+ * A request interrupted mid-send is failed, never retried.
128
+ *
129
+ * `/compact` is destructive and not idempotent: sending a second one after a
130
+ * restart could discard a context the first one already summarized. Failing
131
+ * closed costs one compaction; retrying could cost the task.
132
+ */
133
+ recoverInterrupted: (): Promise<number> =>
134
+ serialize(async () => {
135
+ const items = await readAll();
136
+ let count = 0;
137
+ for (const item of items) {
138
+ if (item.state !== "sending") continue;
139
+ item.state = "failed";
140
+ item.error = "The plugin stopped while sending; not retried, because a second /compact is destructive.";
141
+ item.settledAt = new Date().toISOString();
142
+ count += 1;
143
+ }
144
+ if (count > 0) await writeAll(items);
145
+ return count;
146
+ }),
147
+ };
148
+
149
+ /**
150
+ * Agents whose compaction we are still waiting to land.
151
+ *
152
+ * Two things happen when it does: the post-compaction size is recorded, so the
153
+ * policy can be graded, and, if the agent requested it, the emptied session is
154
+ * sent its chosen continuation. Nothing else will start that turn — see
155
+ * `continuationInstructions`.
156
+ */
157
+ const grading = new Map<
158
+ string,
159
+ {
160
+ requestId: string;
161
+ preTokens: number | null;
162
+ statePath: string | null;
163
+ continueAfterCompaction: boolean;
164
+ continuationMessage: string | null;
165
+ until: number;
166
+ }
167
+ >();
168
+
169
+ async function flush(): Promise<void> {
170
+ const settings = await readSettings();
171
+ // The master switch. Off means a queued request waits rather than failing: the
172
+ // agent asked for it, and turning the feature back on should honour that.
173
+ if (!settings.enabled) return;
174
+
175
+ const pending = (await queue.list()).filter((item) => item.state === "pending");
176
+ // No queued work and nothing to land means no reason to open a connection.
177
+ if (pending.length === 0 && grading.size === 0) return;
178
+
179
+ await withDaemon(async (client) => {
180
+ const agents = new Map<string, AgentRow>();
181
+ for (const agent of await readAgents(client)) agents.set(agent.id, agent);
182
+
183
+ // Land compactions that have already happened.
184
+ for (const [agentId, watch] of [...grading]) {
185
+ const agent = agents.get(agentId);
186
+ if (Date.now() > watch.until || agent === undefined) {
187
+ grading.delete(agentId);
188
+ continue;
189
+ }
190
+ if (agent.status !== "idle" || agent.usedTokens === null) continue;
191
+ if (watch.preTokens !== null && agent.usedTokens >= watch.preTokens) continue; // Not shrunk yet.
192
+ await queue.update(watch.requestId, { postTokens: agent.usedTokens });
193
+ grading.delete(agentId);
194
+
195
+ const continuation = continuationInstructions(watch);
196
+ if (continuation === null) {
197
+ console.log(`[smart-session] compacted ${agentId.slice(0, 8)} without a continuation, as requested`);
198
+ continue;
199
+ }
200
+
201
+ // A manual /compact leaves the session idle with the task abandoned: no hook
202
+ // fires afterwards that can start a turn. Send one only when the requesting
203
+ // agent explicitly or implicitly chose to continue.
204
+ try {
205
+ await sendToAgent(client, agentId, continuation);
206
+ await queue.update(watch.requestId, { resumedAt: new Date().toISOString() });
207
+ console.log(`[smart-session] sent ${agentId.slice(0, 8)} its continuation after compacting`);
208
+ } catch (error) {
209
+ // The compaction itself succeeded; failing to restart the task is worth a
210
+ // line in the log, not a failed request.
211
+ console.error(`[smart-session] could not continue ${agentId.slice(0, 8)}`, String(error));
212
+ }
213
+ }
214
+
215
+ for (const item of pending) {
216
+ const agent = agents.get(item.agentId);
217
+ if (agent === undefined || agent.status === "closed") {
218
+ await queue.update(item.id, {
219
+ state: "failed",
220
+ error: "The target session is gone.",
221
+ settledAt: new Date().toISOString(),
222
+ });
223
+ continue;
224
+ }
225
+ // Sending into a live turn steers it instead of arriving as its own
226
+ // instruction, and compacting mid-turn discards exactly the working state the
227
+ // agent has not written down yet. Wait for the next tick instead.
228
+ if (agent.status !== "idle") continue;
229
+
230
+ // Claim before sending, so a crash cannot produce a second /compact.
231
+ const claimed = await queue.update(item.id, { state: "sending", preTokens: agent.usedTokens });
232
+ try {
233
+ await sendToAgent(client, item.agentId, compactionInstructions(claimed ?? item));
234
+ await queue.update(item.id, { state: "sent", settledAt: new Date().toISOString() });
235
+ grading.set(item.agentId, {
236
+ requestId: item.id,
237
+ preTokens: agent.usedTokens,
238
+ statePath: item.statePath,
239
+ continueAfterCompaction: item.continueAfterCompaction,
240
+ continuationMessage: item.continuationMessage,
241
+ until: Date.now() + GRADE_WINDOW_MS,
242
+ });
243
+ console.log(`[smart-session] compacted ${item.agentId.slice(0, 8)} (${item.reason})`);
244
+ } catch (error) {
245
+ await queue.update(item.id, {
246
+ state: "failed",
247
+ error: error instanceof Error ? error.message : String(error),
248
+ settledAt: new Date().toISOString(),
249
+ });
250
+ }
251
+ }
252
+ });
253
+ }
254
+
255
+ function startGovernor(): void {
256
+ let running = false;
257
+ let stopped = false;
258
+
259
+ const tick = async () => {
260
+ if (running || stopped) return;
261
+ running = true;
262
+ try {
263
+ await flush();
264
+ } catch (error) {
265
+ console.error("[smart-session] governor tick failed", String(error));
266
+ } finally {
267
+ running = false;
268
+ }
269
+ };
270
+
271
+ void queue
272
+ .recoverInterrupted()
273
+ .then((count) => {
274
+ if (count > 0) console.error(`[smart-session] failed ${count} compaction(s) interrupted by a restart`);
275
+ })
276
+ .catch((error: unknown) => console.error("[smart-session] recovery failed", String(error)))
277
+ .then(tick);
278
+
279
+ const timer = setInterval(() => void tick(), TICK_MS);
280
+
281
+ lifecycle.teardowns.push(() => {
282
+ stopped = true;
283
+ clearInterval(timer);
284
+ grading.clear();
285
+ });
286
+ }
287
+
288
+ startGovernor();
@@ -0,0 +1,67 @@
1
+ /**
2
+ * Context growth, measured from recorded history.
3
+ *
4
+ * Lives in the server runtime rather than in the entry so its history and
5
+ * filesystem dependencies can never cross into the client bundle.
6
+ */
7
+
8
+ import { readContext } from "./store.ts";
9
+ import { profileFor, type Thresholds } from "../shared/thresholds.ts";
10
+
11
+ /**
12
+ * How fast each agent's context is filling, in tokens per hour.
13
+ *
14
+ * Measured from what the recorder has actually seen rather than from the agent's
15
+ * own bookkeeping: a rate is only meaningful over a span, and the span has to come
16
+ * from somewhere that survives the turn.
17
+ */
18
+ export async function contextGrowth(): Promise<Map<string, number>> {
19
+ const MEASURE_MS = 2 * 60 * 60 * 1000;
20
+ const MIN_SPAN_MS = 5 * 60 * 1000;
21
+ const rows = await readContext({ sinceMs: Date.now() - MEASURE_MS });
22
+
23
+ const byAgent = new Map<string, { firstAt: number; firstTokens: number; lastAt: number; lastTokens: number }>();
24
+ for (const row of rows) {
25
+ const at = Date.parse(row.at);
26
+ const seen = byAgent.get(row.agentId);
27
+ if (seen === undefined) {
28
+ byAgent.set(row.agentId, { firstAt: at, firstTokens: row.usedTokens, lastAt: at, lastTokens: row.usedTokens });
29
+ continue;
30
+ }
31
+ // A compaction resets the baseline: growth measured across one would be
32
+ // negative and meaningless, so start again from the drop.
33
+ if (row.usedTokens < seen.lastTokens) {
34
+ byAgent.set(row.agentId, { firstAt: at, firstTokens: row.usedTokens, lastAt: at, lastTokens: row.usedTokens });
35
+ continue;
36
+ }
37
+ seen.lastAt = at;
38
+ seen.lastTokens = row.usedTokens;
39
+ }
40
+
41
+ const rates = new Map<string, number>();
42
+ for (const [agentId, span] of byAgent) {
43
+ const ms = span.lastAt - span.firstAt;
44
+ if (ms < MIN_SPAN_MS) continue;
45
+ rates.set(agentId, ((span.lastTokens - span.firstTokens) / ms) * 3_600_000);
46
+ }
47
+ return rates;
48
+ }
49
+
50
+ /**
51
+ * When an agent's context would reach the point where compacting is the right call.
52
+ *
53
+ * Aimed at the window's own compact band, not at its ceiling: on a large window the
54
+ * ceiling is never what bites first, so projecting to 100% would forecast a moment
55
+ * nobody should ever reach.
56
+ */
57
+ export function projectFull(
58
+ usedTokens: number,
59
+ maxTokens: number,
60
+ ratePerHour: number | null,
61
+ thresholds?: Thresholds,
62
+ ): string | null {
63
+ if (ratePerHour === null || ratePerHour <= 0) return null;
64
+ const room = maxTokens * (profileFor(maxTokens, thresholds).compact / 100) - usedTokens;
65
+ if (room <= 0) return new Date().toISOString();
66
+ return new Date(Date.now() + (room / ratePerHour) * 3_600_000).toISOString();
67
+ }
@@ -0,0 +1,34 @@
1
+ /**
2
+ * Reconciling Claude Code's configuration, once, when the plugin loads.
3
+ *
4
+ * A separate module from `install.server.ts` for two reasons, and both of them
5
+ * matter more than the four lines saved by merging them.
6
+ *
7
+ * Paseo compiles the server entry independently, so the load-time side effect
8
+ * belongs here and is imported only by `index.server.ts`. `check-bundles.mjs`
9
+ * guards that runtime boundary.
10
+ *
11
+ * And it keeps `install.server.ts` free of side effects, so a test can import the
12
+ * reconciler without a module-level call racing it — or worse, writing to the
13
+ * machine's real Claude Code settings just because something imported a function.
14
+ */
15
+
16
+ import { install } from "./install.ts";
17
+
18
+ void install()
19
+ .then((report) => {
20
+ if (report.error !== null) {
21
+ console.error(`[smart-session] ${report.error}`);
22
+ return;
23
+ }
24
+ if (report.changed) {
25
+ console.log(`[smart-session] registered ${report.hooks.join(", ")} in ${report.settingsPath}`);
26
+ }
27
+ if (report.mcp === "added") console.log("[smart-session] registered the agent-facing MCP server");
28
+ if (report.mcp === "unavailable") {
29
+ console.error(
30
+ "[smart-session] could not reach the `claude` CLI to register the MCP server; run `claude mcp add-json --scope user smart-session` by hand if the agent tools are missing",
31
+ );
32
+ }
33
+ })
34
+ .catch((error: unknown) => console.error("[smart-session] hook install failed", String(error)));