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,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();
|
package/server/growth.ts
ADDED
|
@@ -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)));
|