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
package/mcp.mjs
ADDED
|
@@ -0,0 +1,602 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* A stdio MCP server that lets an agent see its own context and ask to be compacted.
|
|
4
|
+
*
|
|
5
|
+
* This is the half of Smart Session that the *model* talks to, and it is where the
|
|
6
|
+
* decision to compact is actually taken. The plugin records and delivers; nothing
|
|
7
|
+
* in it ever decides that a session should be compacted. These tools are how the
|
|
8
|
+
* session decides for itself: how full am I, how much plan budget is left, write
|
|
9
|
+
* down what I know, compact me, not yet.
|
|
10
|
+
*
|
|
11
|
+
* Identity comes from `PASEO_AGENT_ID`, which Paseo sets in every agent process, so
|
|
12
|
+
* "my context" needs no argument and cannot be pointed at the wrong session.
|
|
13
|
+
*
|
|
14
|
+
* No dependencies beyond Paseo's own client: MCP over stdio is newline-delimited
|
|
15
|
+
* JSON-RPC, which is small enough to speak directly.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { existsSync, mkdirSync, readFileSync, realpathSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { createRequire } from "node:module";
|
|
20
|
+
import { homedir } from "node:os";
|
|
21
|
+
import { delimiter, dirname, join } from "node:path";
|
|
22
|
+
import { pathToFileURL } from "node:url";
|
|
23
|
+
import { randomUUID } from "node:crypto";
|
|
24
|
+
|
|
25
|
+
import { readSettings } from "./hooks/context.mjs";
|
|
26
|
+
import { defer, readLedger } from "./hooks/asks.mjs";
|
|
27
|
+
import { resolveDaemonPassword } from "./server/daemon-password.mjs";
|
|
28
|
+
|
|
29
|
+
const PLUGIN_ID = "smart-session";
|
|
30
|
+
const AGENT_ID = process.env.PASEO_AGENT_ID ?? null;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Which tools this session gets.
|
|
34
|
+
*
|
|
35
|
+
* All but one are about *this* session — its context, its state file, compacting
|
|
36
|
+
* it, putting that off — and they need the `PASEO_AGENT_ID` that only Paseo sets.
|
|
37
|
+
* Outside Paseo they cannot work, so they are not offered: a tool that exists and
|
|
38
|
+
* always fails is worse than one that is absent.
|
|
39
|
+
*
|
|
40
|
+
* `budget_status` needs no session identity and is useful anywhere, so it stays.
|
|
41
|
+
* Set SMART_SESSION_MCP_SCOPE=paseo to offer nothing at all outside Paseo.
|
|
42
|
+
*
|
|
43
|
+
* This is the cheap half of scoping. Claude Code 2.1.261 defers MCP tool loading —
|
|
44
|
+
* tools appear as bare names until something asks for their schema — so an empty
|
|
45
|
+
* or short list costs no tokens, only the node process. For true Paseo-only
|
|
46
|
+
* registration, see the wrapper described in the README.
|
|
47
|
+
*/
|
|
48
|
+
const AGENT_SCOPED = new Set(["context_status", "checkpoint", "request_compaction", "defer_compaction"]);
|
|
49
|
+
|
|
50
|
+
function availableTools() {
|
|
51
|
+
if (AGENT_ID !== null) return Object.keys(TOOLS);
|
|
52
|
+
if (process.env.SMART_SESSION_MCP_SCOPE === "paseo") return [];
|
|
53
|
+
return Object.keys(TOOLS).filter((name) => !AGENT_SCOPED.has(name));
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/** stdout carries protocol only; anything else corrupts the stream. */
|
|
57
|
+
const log = (...args) => console.error("[smart-session mcp]", ...args);
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Paseo's daemon client, loaded only when something actually needs it.
|
|
61
|
+
*
|
|
62
|
+
* A top-level import would make this whole server fail to start wherever
|
|
63
|
+
* `@getpaseo/client` cannot be resolved — which is every Claude Code session that
|
|
64
|
+
* is not a Paseo agent, since the plugin ships with no installed dependencies. The
|
|
65
|
+
* tools that need it are already hidden outside Paseo by `availableTools()`; this
|
|
66
|
+
* makes the ones that do not need it work there too.
|
|
67
|
+
*
|
|
68
|
+
* This process is spawned directly by Claude Code from the plugin's own checkout,
|
|
69
|
+
* not by Paseo, so unlike `server/daemon.ts` it has no module graph to borrow from
|
|
70
|
+
* and a bare specifier resolves only in a dev checkout with `node_modules`
|
|
71
|
+
* installed. On a managed Git install the fallback instead finds the `paseo` CLI
|
|
72
|
+
* on `PATH` and resolves the client from its own installed dependencies — the
|
|
73
|
+
* same package the running daemon was built with.
|
|
74
|
+
*/
|
|
75
|
+
let daemonClientModule = null;
|
|
76
|
+
|
|
77
|
+
const CLIENT_SPECIFIER = ["@getpaseo", "client", "internal", "daemon-client"].join("/");
|
|
78
|
+
|
|
79
|
+
async function loadDaemonClient() {
|
|
80
|
+
if (daemonClientModule !== null) return daemonClientModule;
|
|
81
|
+
try {
|
|
82
|
+
daemonClientModule = await import(CLIENT_SPECIFIER);
|
|
83
|
+
return daemonClientModule;
|
|
84
|
+
} catch (directError) {
|
|
85
|
+
const cliPath = findPaseoCli();
|
|
86
|
+
if (cliPath === null) throw directError;
|
|
87
|
+
try {
|
|
88
|
+
const resolved = createRequire(cliPath).resolve(CLIENT_SPECIFIER);
|
|
89
|
+
daemonClientModule = await import(pathToFileURL(resolved).href);
|
|
90
|
+
return daemonClientModule;
|
|
91
|
+
} catch (viaCliError) {
|
|
92
|
+
throw new Error(
|
|
93
|
+
`Cannot resolve ${CLIENT_SPECIFIER} directly (${directError.message}) or via the paseo CLI at ${cliPath} (${viaCliError.message})`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Finds `paseo` on PATH and resolves symlinks, the way a shell's `which` would. */
|
|
100
|
+
function findPaseoCli() {
|
|
101
|
+
const name = process.platform === "win32" ? "paseo.cmd" : "paseo";
|
|
102
|
+
for (const dir of (process.env.PATH ?? "").split(delimiter)) {
|
|
103
|
+
if (dir === "") continue;
|
|
104
|
+
const candidate = join(dir, name);
|
|
105
|
+
try {
|
|
106
|
+
statSync(candidate);
|
|
107
|
+
return realpathSync(candidate);
|
|
108
|
+
} catch {
|
|
109
|
+
// Not here; keep looking.
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
async function callPlugin(method, input) {
|
|
116
|
+
const { DaemonClient } = await loadDaemonClient();
|
|
117
|
+
const password = await resolveDaemonPassword();
|
|
118
|
+
const client = new DaemonClient({
|
|
119
|
+
url: process.env.PASEO_DAEMON_URL ?? "ws://127.0.0.1:6767/ws",
|
|
120
|
+
clientId: "smart-session-mcp",
|
|
121
|
+
clientType: "cli",
|
|
122
|
+
reconnect: { enabled: false },
|
|
123
|
+
connectTimeoutMs: 10_000,
|
|
124
|
+
suppressSendErrors: true,
|
|
125
|
+
...(password === undefined ? {} : { password }),
|
|
126
|
+
});
|
|
127
|
+
await client.connect();
|
|
128
|
+
try {
|
|
129
|
+
return await client.invokePluginRpc(PLUGIN_ID, method, input);
|
|
130
|
+
} finally {
|
|
131
|
+
try {
|
|
132
|
+
await client.close();
|
|
133
|
+
} catch {
|
|
134
|
+
/* Teardown must not mask the result. */
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function requireAgent() {
|
|
140
|
+
if (AGENT_ID === null) {
|
|
141
|
+
throw new Error(
|
|
142
|
+
"PASEO_AGENT_ID is not set, so this tool cannot tell which session it belongs to. It only works inside a Paseo agent.",
|
|
143
|
+
);
|
|
144
|
+
}
|
|
145
|
+
return AGENT_ID;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
const HOURS = (ms) => ms / 3_600_000;
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Where this session's durable task state lives.
|
|
152
|
+
*
|
|
153
|
+
* Outside the working tree on purpose: a state file inside the repo gets committed
|
|
154
|
+
* by accident, or vanishes with the worktree it was describing. Keyed by agent id,
|
|
155
|
+
* so it survives compaction, `/clear`, and the model forgetting the path.
|
|
156
|
+
*/
|
|
157
|
+
function statePath() {
|
|
158
|
+
const override = process.env.SMART_SESSION_STATE_FILE;
|
|
159
|
+
if (override !== undefined && override !== "") return override;
|
|
160
|
+
const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
161
|
+
return join(home, "plugin-data", "smart-session", "state", `${requireAgent()}.md`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/** A deferral is an answer with a deadline, not an escape hatch; hence the cap. */
|
|
165
|
+
const DEFAULT_DEFER_MINUTES = 20;
|
|
166
|
+
const MAX_DEFER_MINUTES = 60;
|
|
167
|
+
|
|
168
|
+
const SECTIONS = ["Goal", "Plan", "Current step", "Decisions", "Dead ends", "Key facts"];
|
|
169
|
+
|
|
170
|
+
/** Sections that accumulate rather than being replaced. */
|
|
171
|
+
const APPEND_ONLY = new Set(["Decisions", "Dead ends", "Key facts"]);
|
|
172
|
+
|
|
173
|
+
function parseState(text) {
|
|
174
|
+
const sections = new Map(SECTIONS.map((name) => [name, []]));
|
|
175
|
+
let current = null;
|
|
176
|
+
for (const line of text.split("\n")) {
|
|
177
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
178
|
+
if (heading !== null) {
|
|
179
|
+
current = sections.has(heading[1]) ? heading[1] : null;
|
|
180
|
+
continue;
|
|
181
|
+
}
|
|
182
|
+
if (current !== null) sections.get(current).push(line);
|
|
183
|
+
}
|
|
184
|
+
for (const [name, lines] of sections) {
|
|
185
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
186
|
+
while (lines.length > 0 && lines[0].trim() === "") lines.shift();
|
|
187
|
+
sections.set(name, lines);
|
|
188
|
+
}
|
|
189
|
+
return sections;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
function renderState(sections) {
|
|
193
|
+
const parts = [`# Task state`, `_Updated ${new Date().toISOString()}_`, ""];
|
|
194
|
+
for (const name of SECTIONS) {
|
|
195
|
+
const lines = sections.get(name) ?? [];
|
|
196
|
+
parts.push(`## ${name}`, lines.length === 0 ? "_(empty)_" : lines.join("\n"), "");
|
|
197
|
+
}
|
|
198
|
+
return parts.join("\n");
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function writeAtomic(path, text) {
|
|
202
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
203
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
204
|
+
writeFileSync(temp, text, "utf8");
|
|
205
|
+
renameSync(temp, path);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
function stateAgeSeconds(path) {
|
|
209
|
+
try {
|
|
210
|
+
return Math.round((Date.now() - statSync(path).mtimeMs) / 1000);
|
|
211
|
+
} catch {
|
|
212
|
+
return null;
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function describeRelative(iso) {
|
|
217
|
+
if (iso === null || iso === undefined) return "unknown";
|
|
218
|
+
const ms = Date.parse(iso) - Date.now();
|
|
219
|
+
if (Number.isNaN(ms)) return "unknown";
|
|
220
|
+
const minutes = Math.round(Math.abs(ms) / 60_000);
|
|
221
|
+
const text = minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
222
|
+
return ms >= 0 ? `in ${text}` : `${text} ago`;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const TOOLS = {
|
|
226
|
+
context_status: {
|
|
227
|
+
description:
|
|
228
|
+
"How full this session's context window is right now, how fast it is filling, and when it will reach the point where quality starts to suffer. Call this when you are deep into a long task, before starting a large new step, or whenever you are deciding whether to keep going or checkpoint.",
|
|
229
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
230
|
+
async run() {
|
|
231
|
+
const agentId = requireAgent();
|
|
232
|
+
const result = await callPlugin("smart-session.context", { agentId });
|
|
233
|
+
const mine = result.agents?.[0];
|
|
234
|
+
if (mine === undefined) {
|
|
235
|
+
return "No context reading for this session yet — it reports one after its first completed turn.";
|
|
236
|
+
}
|
|
237
|
+
const lines = [
|
|
238
|
+
`Context holds ${mine.usedTokens.toLocaleString()} tokens (${mine.usedPct}% of a ${mine.maxTokens.toLocaleString()}-token window). Worth compacting from ${mine.compactAtPct}% (${Math.round(
|
|
239
|
+
(mine.compactAtPct / 100) * mine.maxTokens,
|
|
240
|
+
).toLocaleString()} tokens).`,
|
|
241
|
+
];
|
|
242
|
+
if (mine.growthTokensPerHour !== null) {
|
|
243
|
+
lines.push(`Filling at roughly ${Math.round(mine.growthTokensPerHour).toLocaleString()} tokens/hour.`);
|
|
244
|
+
}
|
|
245
|
+
if (mine.usedPct >= mine.compactAtPct) {
|
|
246
|
+
// Already past it: a projection of when it will arrive reads as nonsense.
|
|
247
|
+
lines.push(
|
|
248
|
+
"You are already past that point. Check your task state is on disk and current, then compact at the next clean boundary — call request_compaction when the step you are on is finished.",
|
|
249
|
+
);
|
|
250
|
+
} else if (mine.projectedFullAt !== null) {
|
|
251
|
+
lines.push(
|
|
252
|
+
`At that rate you reach it ${describeRelative(mine.projectedFullAt)}. On a large window that arrives long before the ceiling does: what costs you is the prefix re-read on every turn, not running out of room.`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
if (mine.costUsd !== null) lines.push(`Session cost so far: $${mine.costUsd.toFixed(2)}.`);
|
|
256
|
+
|
|
257
|
+
const path = statePath();
|
|
258
|
+
const age = stateAgeSeconds(path);
|
|
259
|
+
if (age === null) {
|
|
260
|
+
lines.push(
|
|
261
|
+
`No durable task state on disk yet. Call checkpoint before you need it — compacting without it is what loses work.`,
|
|
262
|
+
);
|
|
263
|
+
} else {
|
|
264
|
+
lines.push(`Task state last written ${Math.round(age / 60)}m ago (${path}).`);
|
|
265
|
+
}
|
|
266
|
+
return lines.join("\n");
|
|
267
|
+
},
|
|
268
|
+
},
|
|
269
|
+
|
|
270
|
+
budget_status: {
|
|
271
|
+
description:
|
|
272
|
+
"How much of the Claude plan's rolling 5-hour and weekly limits is already consumed, how fast, and when each window resets. Call this before spawning several subagents, before a long autonomous run, or when deciding whether to use a more expensive model.",
|
|
273
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
274
|
+
async run() {
|
|
275
|
+
const result = await callPlugin("smart-session.budget", {});
|
|
276
|
+
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
277
|
+
const interesting = (result.windows ?? []).filter(
|
|
278
|
+
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
279
|
+
);
|
|
280
|
+
if (interesting.length === 0) return "No plan-usage reading recorded yet.";
|
|
281
|
+
|
|
282
|
+
const lines = interesting.map((window) => {
|
|
283
|
+
const name = window.id === "five_hour" ? "Session (5h)" : window.id === "seven_day" ? "Week" : window.id;
|
|
284
|
+
const parts = [`${name}: ${Math.round(window.pct)}% used`];
|
|
285
|
+
if (window.resetsAt !== null) parts.push(`resets ${describeRelative(window.resetsAt)}`);
|
|
286
|
+
if (window.burnPctPerHour !== null && window.burnPctPerHour > 0) {
|
|
287
|
+
parts.push(`burning ${window.burnPctPerHour.toFixed(1)}%/h`);
|
|
288
|
+
}
|
|
289
|
+
if (window.projectedFullAt !== null) parts.push(`would hit 100% ${describeRelative(window.projectedFullAt)}`);
|
|
290
|
+
return `- ${parts.join(", ")}`;
|
|
291
|
+
});
|
|
292
|
+
|
|
293
|
+
const recorder = result.recorder;
|
|
294
|
+
if (recorder?.ageSeconds !== null && recorder?.ageSeconds !== undefined && recorder.ageSeconds > 900) {
|
|
295
|
+
lines.push(
|
|
296
|
+
`(Newest reading is ${Math.round(HOURS(recorder.ageSeconds * 1000) * 10) / 10}h old — treat these as a floor, not a current number.)`,
|
|
297
|
+
);
|
|
298
|
+
}
|
|
299
|
+
return lines.join("\n");
|
|
300
|
+
},
|
|
301
|
+
},
|
|
302
|
+
|
|
303
|
+
checkpoint: {
|
|
304
|
+
description:
|
|
305
|
+
"Write this session's durable task state to disk, so that compaction — or a crash, or a handoff — costs nothing. Call it after finishing a plan step, after a subagent returns, and whenever you learn something you would hate to rediscover. Record POINTERS, not payloads: 'src/foo.ts:120 is where the retry lives', never the file's contents. Re-reading a file after compaction is cheap; re-deriving a conclusion is not. Decisions, dead ends and key facts accumulate; goal, plan and current step are replaced.",
|
|
306
|
+
inputSchema: {
|
|
307
|
+
type: "object",
|
|
308
|
+
properties: {
|
|
309
|
+
goal: { type: "string", description: "The task's objective. Set once; only change it if the task itself changed." },
|
|
310
|
+
plan: { type: "string", description: "The steps, with their status. Replaces the previous plan." },
|
|
311
|
+
current_step: {
|
|
312
|
+
type: "string",
|
|
313
|
+
description: "What you are doing right now AND the exact next action, so a fresh reader could continue without guessing.",
|
|
314
|
+
},
|
|
315
|
+
decisions: {
|
|
316
|
+
type: "array",
|
|
317
|
+
items: { type: "string" },
|
|
318
|
+
description: "Decisions made and WHY. Appended, dated. The reason matters more than the decision.",
|
|
319
|
+
},
|
|
320
|
+
dead_ends: {
|
|
321
|
+
type: "array",
|
|
322
|
+
items: { type: "string" },
|
|
323
|
+
description:
|
|
324
|
+
"Approaches tried that did not work, and why. Appended, dated. This is the highest-value section: without it a compacted agent confidently redoes failed work.",
|
|
325
|
+
},
|
|
326
|
+
key_facts: {
|
|
327
|
+
type: "array",
|
|
328
|
+
items: { type: "string" },
|
|
329
|
+
description: "Pointers worth keeping: file:line locations, commands, gotchas. Appended. Pointers, not contents.",
|
|
330
|
+
},
|
|
331
|
+
},
|
|
332
|
+
additionalProperties: false,
|
|
333
|
+
},
|
|
334
|
+
async run(args) {
|
|
335
|
+
const path = statePath();
|
|
336
|
+
const sections = parseState(existsSync(path) ? readFileSync(path, "utf8") : "");
|
|
337
|
+
const stamp = new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
338
|
+
|
|
339
|
+
const replacements = { Goal: args.goal, Plan: args.plan, "Current step": args.current_step };
|
|
340
|
+
for (const [name, value] of Object.entries(replacements)) {
|
|
341
|
+
if (typeof value === "string" && value.trim() !== "") sections.set(name, [value.trim()]);
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
const additions = { Decisions: args.decisions, "Dead ends": args.dead_ends, "Key facts": args.key_facts };
|
|
345
|
+
let added = 0;
|
|
346
|
+
for (const [name, values] of Object.entries(additions)) {
|
|
347
|
+
if (!Array.isArray(values)) continue;
|
|
348
|
+
const lines = sections.get(name) ?? [];
|
|
349
|
+
for (const value of values) {
|
|
350
|
+
const text = String(value).trim();
|
|
351
|
+
if (text === "") continue;
|
|
352
|
+
// Key facts are pointers and repeat naturally; a duplicate is noise.
|
|
353
|
+
if (name === "Key facts" && lines.some((line) => line.endsWith(text))) continue;
|
|
354
|
+
lines.push(APPEND_ONLY.has(name) && name !== "Key facts" ? `- [${stamp}] ${text}` : `- ${text}`);
|
|
355
|
+
added += 1;
|
|
356
|
+
}
|
|
357
|
+
sections.set(name, lines);
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
writeAtomic(path, renderState(sections));
|
|
361
|
+
|
|
362
|
+
const missing = ["Goal", "Current step"].filter((name) => (sections.get(name) ?? []).length === 0);
|
|
363
|
+
const lines = [`Task state written to ${path} (${added} new entr${added === 1 ? "y" : "ies"}).`];
|
|
364
|
+
if (missing.length > 0) {
|
|
365
|
+
lines.push(
|
|
366
|
+
`Still empty: ${missing.join(" and ")}. A state file without those cannot be continued from — fill them on your next checkpoint.`,
|
|
367
|
+
);
|
|
368
|
+
}
|
|
369
|
+
return lines.join(" ");
|
|
370
|
+
},
|
|
371
|
+
},
|
|
372
|
+
|
|
373
|
+
request_compaction: {
|
|
374
|
+
description:
|
|
375
|
+
"Ask for this session to be compacted. Nothing compacts a session that has not asked, so this is the only way it happens. The request is delivered as a real /compact once this session goes idle — never mid-turn — with instructions steering what the summary must keep. Choose whether Paseo should start another turn afterwards: continue when work remains, or do not continue when the task is finished. Write your durable task state with checkpoint FIRST: this refuses to queue anything while the state on disk is missing or older than the work it describes.",
|
|
376
|
+
inputSchema: {
|
|
377
|
+
type: "object",
|
|
378
|
+
properties: {
|
|
379
|
+
reason: {
|
|
380
|
+
type: "string",
|
|
381
|
+
description: "Why compacting now is the right call, in one sentence. This is quoted into the instructions.",
|
|
382
|
+
},
|
|
383
|
+
state_path: {
|
|
384
|
+
type: "string",
|
|
385
|
+
description:
|
|
386
|
+
"Absolute path to the file holding this task's durable state (goal, current step, decisions, dead ends). Strongly recommended.",
|
|
387
|
+
},
|
|
388
|
+
continue_after_compaction: {
|
|
389
|
+
type: "boolean",
|
|
390
|
+
description:
|
|
391
|
+
"Whether Paseo should send a follow-up that starts another model turn after compaction. Defaults to true for compatibility. Set false when the task is finished.",
|
|
392
|
+
},
|
|
393
|
+
continue_message: {
|
|
394
|
+
type: "string",
|
|
395
|
+
description:
|
|
396
|
+
"Exact follow-up message to send after compaction when continuing. Omit to use the default instruction to re-read the state file and carry on.",
|
|
397
|
+
},
|
|
398
|
+
},
|
|
399
|
+
required: ["reason"],
|
|
400
|
+
additionalProperties: false,
|
|
401
|
+
},
|
|
402
|
+
async run(args) {
|
|
403
|
+
const agentId = requireAgent();
|
|
404
|
+
const settings = readSettings();
|
|
405
|
+
if (!settings.enabled) {
|
|
406
|
+
return "Smart Compact is switched off for this Paseo install, so nothing would deliver this request. Compact by hand if you need to, or ask the user to turn Smart Compact on in the Smart Session surface.";
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
// Default to this session's own state file: the whole point is that the
|
|
410
|
+
// continuation has something authoritative to read, and the agent should not
|
|
411
|
+
// have to remember a path to get that.
|
|
412
|
+
const path = args.state_path === undefined ? statePath() : String(args.state_path);
|
|
413
|
+
|
|
414
|
+
// Persist, then compact. The other order is how you lose the work — so this
|
|
415
|
+
// refuses rather than queueing, because refusing costs one tool call and the
|
|
416
|
+
// agent can fix it in the same turn, where the old design spent a whole extra
|
|
417
|
+
// turn on the plugin asking for the same thing.
|
|
418
|
+
const age = stateAgeSeconds(path);
|
|
419
|
+
if (age === null) {
|
|
420
|
+
return [
|
|
421
|
+
`Not queued: there is no task state at ${path}, and compacting without it is exactly how a task gets lost.`,
|
|
422
|
+
"Call checkpoint first — goal, the step in progress and its exact next action, decisions and why, and every approach already tried and rejected — then call request_compaction again.",
|
|
423
|
+
].join(" ");
|
|
424
|
+
}
|
|
425
|
+
if (age > settings.freshStateMinutes * 60) {
|
|
426
|
+
return [
|
|
427
|
+
`Not queued: ${path} was last written ${Math.round(age / 60)} minutes ago, so it probably predates the work you are about to discard.`,
|
|
428
|
+
"Call checkpoint to bring it up to date, then call request_compaction again.",
|
|
429
|
+
].join(" ");
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
const continueAfterCompaction = args.continue_after_compaction !== false;
|
|
433
|
+
const continueMessage = typeof args.continue_message === "string" ? args.continue_message : "";
|
|
434
|
+
const result = await callPlugin("smart-session.compact.request", {
|
|
435
|
+
agentId,
|
|
436
|
+
reason: String(args.reason ?? "no reason given"),
|
|
437
|
+
statePath: path,
|
|
438
|
+
continueAfterCompaction,
|
|
439
|
+
continuationMessage: continueAfterCompaction && continueMessage.trim() !== "" ? continueMessage : null,
|
|
440
|
+
});
|
|
441
|
+
const queued = result.request;
|
|
442
|
+
const lines = [
|
|
443
|
+
`Compaction queued (${queued.id.slice(0, 8)}). It will be delivered as soon as this session is idle — so finish the turn you are in and stop.`,
|
|
444
|
+
];
|
|
445
|
+
if (!queued.continueAfterCompaction) {
|
|
446
|
+
lines.push("No follow-up will be sent after the compaction lands.");
|
|
447
|
+
} else if (queued.continuationMessage !== null) {
|
|
448
|
+
lines.push("After it lands, the continuation message you supplied will start the next turn.");
|
|
449
|
+
} else {
|
|
450
|
+
lines.push(
|
|
451
|
+
`The continuation will be told to re-read ${queued.statePath} and to trust it over the summary, and to carry on from its "Current step" section.`,
|
|
452
|
+
);
|
|
453
|
+
}
|
|
454
|
+
return lines.join("\n");
|
|
455
|
+
},
|
|
456
|
+
},
|
|
457
|
+
|
|
458
|
+
defer_compaction: {
|
|
459
|
+
description:
|
|
460
|
+
"Decline to compact for now, and say why. Use this when Smart Compact has asked and this is the wrong moment — a refactor half applied, a tool sequence not finished, an answer the user is waiting on. You will not be asked again until the deferral runs out. This is a real answer, not a way of ignoring the question: the context keeps growing while it is in force.",
|
|
461
|
+
inputSchema: {
|
|
462
|
+
type: "object",
|
|
463
|
+
properties: {
|
|
464
|
+
reason: {
|
|
465
|
+
type: "string",
|
|
466
|
+
description: "Why now is the wrong moment, in one sentence. Recorded as your answer.",
|
|
467
|
+
},
|
|
468
|
+
minutes: {
|
|
469
|
+
type: "number",
|
|
470
|
+
description: `How long to hold off, in minutes. Capped at ${MAX_DEFER_MINUTES}; defaults to ${DEFAULT_DEFER_MINUTES}.`,
|
|
471
|
+
},
|
|
472
|
+
},
|
|
473
|
+
required: ["reason"],
|
|
474
|
+
additionalProperties: false,
|
|
475
|
+
},
|
|
476
|
+
async run(args) {
|
|
477
|
+
requireAgent();
|
|
478
|
+
const asked = Number(args.minutes);
|
|
479
|
+
const minutes = Math.min(
|
|
480
|
+
MAX_DEFER_MINUTES,
|
|
481
|
+
Math.max(1, Number.isFinite(asked) && asked > 0 ? asked : DEFAULT_DEFER_MINUTES),
|
|
482
|
+
);
|
|
483
|
+
const reason = String(args.reason ?? "no reason given");
|
|
484
|
+
const until = defer(null, minutes, reason);
|
|
485
|
+
const ledger = readLedger(null);
|
|
486
|
+
return [
|
|
487
|
+
`Deferred for ${minutes} minutes (until ${until}). You will not be asked again before then.`,
|
|
488
|
+
ledger.askCount >= 2
|
|
489
|
+
? "This is not the first time this session has put it off, and the window has kept filling the whole while. If the moment does not arrive soon, checkpoint and compact anyway — the alternative is Claude Code's own auto-compact firing with no instructions and no pointer to your state file."
|
|
490
|
+
: "When the step you are on is finished, call checkpoint and then request_compaction.",
|
|
491
|
+
].join("\n");
|
|
492
|
+
},
|
|
493
|
+
},
|
|
494
|
+
};
|
|
495
|
+
|
|
496
|
+
function send(message) {
|
|
497
|
+
process.stdout.write(`${JSON.stringify(message)}\n`);
|
|
498
|
+
}
|
|
499
|
+
|
|
500
|
+
function respond(id, result) {
|
|
501
|
+
send({ jsonrpc: "2.0", id, result });
|
|
502
|
+
}
|
|
503
|
+
|
|
504
|
+
function fail(id, message) {
|
|
505
|
+
send({ jsonrpc: "2.0", id, error: { code: -32000, message } });
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
async function handle(request) {
|
|
509
|
+
const { id, method, params } = request;
|
|
510
|
+
// Notifications carry no id and must not be answered.
|
|
511
|
+
if (id === undefined || id === null) return;
|
|
512
|
+
|
|
513
|
+
if (method === "initialize") {
|
|
514
|
+
respond(id, {
|
|
515
|
+
protocolVersion: params?.protocolVersion ?? "2025-06-18",
|
|
516
|
+
capabilities: { tools: {} },
|
|
517
|
+
serverInfo: { name: "smart-session", version: "0.3.0" },
|
|
518
|
+
});
|
|
519
|
+
return;
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
if (method === "tools/list") {
|
|
523
|
+
respond(id, {
|
|
524
|
+
tools: availableTools().map((name) => ({
|
|
525
|
+
name,
|
|
526
|
+
description: TOOLS[name].description,
|
|
527
|
+
inputSchema: TOOLS[name].inputSchema,
|
|
528
|
+
})),
|
|
529
|
+
});
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
if (method === "tools/call") {
|
|
534
|
+
const tool = TOOLS[params?.name];
|
|
535
|
+
if (tool === undefined || !availableTools().includes(params.name)) {
|
|
536
|
+
fail(id, `Unknown tool: ${params?.name}`);
|
|
537
|
+
return;
|
|
538
|
+
}
|
|
539
|
+
try {
|
|
540
|
+
const text = await tool.run(params.arguments ?? {});
|
|
541
|
+
respond(id, { content: [{ type: "text", text }] });
|
|
542
|
+
} catch (error) {
|
|
543
|
+
// Reported as a tool result, not a protocol error, so the model can read it
|
|
544
|
+
// and decide what to do rather than seeing the call vanish.
|
|
545
|
+
respond(id, {
|
|
546
|
+
content: [{ type: "text", text: `Failed: ${error instanceof Error ? error.message : String(error)}` }],
|
|
547
|
+
isError: true,
|
|
548
|
+
});
|
|
549
|
+
}
|
|
550
|
+
return;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
if (method === "ping") {
|
|
554
|
+
respond(id, {});
|
|
555
|
+
return;
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
fail(id, `Unsupported method: ${method}`);
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* In-flight calls, so a closed stdin does not kill work already started.
|
|
563
|
+
*
|
|
564
|
+
* A client that writes its requests and closes the pipe is a normal shape (and how
|
|
565
|
+
* this server is tested); exiting on "end" would answer none of them.
|
|
566
|
+
*/
|
|
567
|
+
let pending = 0;
|
|
568
|
+
let inputEnded = false;
|
|
569
|
+
|
|
570
|
+
function exitWhenDrained() {
|
|
571
|
+
if (inputEnded && pending === 0) process.exit(0);
|
|
572
|
+
}
|
|
573
|
+
|
|
574
|
+
let buffer = "";
|
|
575
|
+
process.stdin.setEncoding("utf8");
|
|
576
|
+
process.stdin.on("data", (chunk) => {
|
|
577
|
+
buffer += chunk;
|
|
578
|
+
let newline = buffer.indexOf("\n");
|
|
579
|
+
while (newline !== -1) {
|
|
580
|
+
const line = buffer.slice(0, newline).trim();
|
|
581
|
+
buffer = buffer.slice(newline + 1);
|
|
582
|
+
if (line !== "") {
|
|
583
|
+
try {
|
|
584
|
+
const message = JSON.parse(line);
|
|
585
|
+
pending += 1;
|
|
586
|
+
void handle(message).finally(() => {
|
|
587
|
+
pending -= 1;
|
|
588
|
+
exitWhenDrained();
|
|
589
|
+
});
|
|
590
|
+
} catch (error) {
|
|
591
|
+
log("could not parse a message", String(error));
|
|
592
|
+
}
|
|
593
|
+
}
|
|
594
|
+
newline = buffer.indexOf("\n");
|
|
595
|
+
}
|
|
596
|
+
});
|
|
597
|
+
process.stdin.on("end", () => {
|
|
598
|
+
inputEnded = true;
|
|
599
|
+
exitWhenDrained();
|
|
600
|
+
});
|
|
601
|
+
|
|
602
|
+
log(`ready${AGENT_ID === null ? " (no PASEO_AGENT_ID; context tools will not work)" : ` for agent ${AGENT_ID.slice(0, 8)}`}`);
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "paseo-smart-session",
|
|
3
|
+
"type": "module",
|
|
4
|
+
"version": "1.2.1",
|
|
5
|
+
"license": "MIT",
|
|
6
|
+
"description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
|
|
7
|
+
"repository": {
|
|
8
|
+
"type": "git",
|
|
9
|
+
"url": "git+https://github.com/tomgrin10/paseo-smart-session.git"
|
|
10
|
+
},
|
|
11
|
+
"homepage": "https://github.com/tomgrin10/paseo-smart-session#readme",
|
|
12
|
+
"bugs": "https://github.com/tomgrin10/paseo-smart-session/issues",
|
|
13
|
+
"author": "Tom Gringauz",
|
|
14
|
+
"keywords": ["paseo", "paseo-plugin", "context", "monitoring", "productivity"],
|
|
15
|
+
"files": [
|
|
16
|
+
"paseo-plugin.json",
|
|
17
|
+
"index.client.tsx",
|
|
18
|
+
"index.server.ts",
|
|
19
|
+
"client/",
|
|
20
|
+
"server/",
|
|
21
|
+
"shared/",
|
|
22
|
+
"hooks/",
|
|
23
|
+
"mcp.mjs",
|
|
24
|
+
"docs/"
|
|
25
|
+
],
|
|
26
|
+
"scripts": {
|
|
27
|
+
"typecheck": "tsc --noEmit",
|
|
28
|
+
"test": "node --test --experimental-strip-types *.test.ts",
|
|
29
|
+
"check": "node check-bundles.mjs && node check-gitinstall.mjs && node check-teardown.mjs && node check-mcp-resolve.mjs",
|
|
30
|
+
"verify": "npm run typecheck && npm run test && npm run check"
|
|
31
|
+
},
|
|
32
|
+
"devDependencies": {
|
|
33
|
+
"@babel/parser": "^8.0.4",
|
|
34
|
+
"@getpaseo/client": "0.8.0-beta.1",
|
|
35
|
+
"@getpaseo/plugin": "0.8.0-beta.1",
|
|
36
|
+
"@getpaseo/protocol": "0.8.0-beta.1",
|
|
37
|
+
"@tanstack/react-query": "^5.90.11",
|
|
38
|
+
"@types/node": "^22.10.2",
|
|
39
|
+
"@types/react": "~19.2.0",
|
|
40
|
+
"esbuild": "^0.28.2",
|
|
41
|
+
"react": "19.1.0",
|
|
42
|
+
"react-native": "0.81.5",
|
|
43
|
+
"typescript": "^5.9.3",
|
|
44
|
+
"zod": "^4.4.3"
|
|
45
|
+
}
|
|
46
|
+
}
|