paseo-smart-session 3.4.0 → 3.6.0
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/README.md +7 -5
- package/client/charts.tsx +7 -6
- package/client/overview.tsx +13 -8
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +10 -11
- package/client/settings.tsx +39 -17
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +198 -0
- package/hooks/asks.mjs +2 -117
- package/hooks/context-threshold.mjs +35 -11
- package/hooks/context.mjs +2 -233
- package/hooks/pointer.mjs +2 -149
- package/index.server.ts +32 -3
- package/mcp.mjs +49 -458
- package/package.json +3 -2
- package/server/agent-tools.d.mts +15 -0
- package/server/agent-tools.mjs +472 -0
- package/server/growth.ts +2 -1
- package/server/hooklib/asks.mjs +163 -0
- package/server/hooklib/context.mjs +284 -0
- package/server/hooklib/pointer.mjs +149 -0
- package/server/install-on-load.ts +15 -1
- package/server/mcp-registrations.ts +126 -0
- package/server/native-tools.ts +60 -0
- package/server/session-mcp.ts +54 -17
- package/shared/chat.ts +6 -2
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/smart-session.ts +6 -0
- package/shared/thresholds.ts +41 -6
|
@@ -0,0 +1,472 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Session's agent tools, independent of how they reach the agent.
|
|
3
|
+
*
|
|
4
|
+
* Two transports share this: Paseo 0.15+ serves them natively through each agent's own Paseo
|
|
5
|
+
* MCP (server/native-tools.ts, calls run in the plugin process and the caller's id comes from the
|
|
6
|
+
* daemon), and `mcp.mjs` is the per-launch stdio fallback (identity from `PASEO_AGENT_ID`).
|
|
7
|
+
* `context.call` reaches the plugin's RPCs: in-process for the first, over the daemon for the second.
|
|
8
|
+
*
|
|
9
|
+
* Dependency-free, like the hooks: `mcp.mjs` runs it from a checkout with no node_modules.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, renameSync, statSync, writeFileSync } from "node:fs";
|
|
13
|
+
import { homedir } from "node:os";
|
|
14
|
+
import { dirname, join } from "node:path";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
import { named, readSettings } from "./hooklib/context.mjs";
|
|
18
|
+
export { named };
|
|
19
|
+
import { defer, readLedger } from "./hooklib/asks.mjs";
|
|
20
|
+
|
|
21
|
+
/** The tools that act on the calling session and need its agent id. */
|
|
22
|
+
export const AGENT_SCOPED = new Set(["context_status", "checkpoint", "request_compaction", "defer_compaction"]);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* @param {{ agentId: string | null, call: (method: string, input: unknown) => Promise<any>, home?: string, stateFile?: string }} context
|
|
26
|
+
*/
|
|
27
|
+
export function agentTools(context) {
|
|
28
|
+
const AGENT_ID = context.agentId;
|
|
29
|
+
const callPlugin = context.call;
|
|
30
|
+
function requireAgent() {
|
|
31
|
+
if (AGENT_ID === null) {
|
|
32
|
+
throw new Error(
|
|
33
|
+
"PASEO_AGENT_ID is not set, so this tool cannot tell which session it belongs to. It only works inside a Paseo agent.",
|
|
34
|
+
);
|
|
35
|
+
}
|
|
36
|
+
return AGENT_ID;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const HOURS = (ms) => ms / 3_600_000;
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Where this session's durable task state lives.
|
|
43
|
+
*
|
|
44
|
+
* Outside the working tree on purpose: a state file inside the repo gets committed
|
|
45
|
+
* by accident, or vanishes with the worktree it was describing. Keyed by agent id,
|
|
46
|
+
* so it survives compaction, `/clear`, and the model forgetting the path.
|
|
47
|
+
*/
|
|
48
|
+
function statePath() {
|
|
49
|
+
const override = context.stateFile ?? process.env.SMART_SESSION_STATE_FILE;
|
|
50
|
+
if (override !== undefined && override !== "") return override;
|
|
51
|
+
const home = context.home ?? process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
52
|
+
return join(home, "plugin-data", "smart-session", "state", `${requireAgent()}.md`);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/** A deferral is an answer with a deadline, not an escape hatch; hence the cap. */
|
|
56
|
+
const DEFAULT_DEFER_MINUTES = 20;
|
|
57
|
+
const MAX_DEFER_MINUTES = 60;
|
|
58
|
+
|
|
59
|
+
const SECTIONS = ["Goal", "Plan", "Current step", "Decisions", "Dead ends", "Key facts"];
|
|
60
|
+
|
|
61
|
+
/** Sections that accumulate rather than being replaced. */
|
|
62
|
+
const APPEND_ONLY = new Set(["Decisions", "Dead ends", "Key facts"]);
|
|
63
|
+
|
|
64
|
+
function parseState(text) {
|
|
65
|
+
const sections = new Map(SECTIONS.map((name) => [name, []]));
|
|
66
|
+
let current = null;
|
|
67
|
+
for (const line of text.split("\n")) {
|
|
68
|
+
const heading = /^##\s+(.+?)\s*$/.exec(line);
|
|
69
|
+
if (heading !== null) {
|
|
70
|
+
current = sections.has(heading[1]) ? heading[1] : null;
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (current !== null) sections.get(current).push(line);
|
|
74
|
+
}
|
|
75
|
+
for (const [name, lines] of sections) {
|
|
76
|
+
while (lines.length > 0 && lines[lines.length - 1].trim() === "") lines.pop();
|
|
77
|
+
while (lines.length > 0 && lines[0].trim() === "") lines.shift();
|
|
78
|
+
sections.set(name, lines);
|
|
79
|
+
}
|
|
80
|
+
return sections;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function renderState(sections) {
|
|
84
|
+
const parts = [`# Task state`, `_Updated ${new Date().toISOString()}_`, ""];
|
|
85
|
+
for (const name of SECTIONS) {
|
|
86
|
+
const lines = sections.get(name) ?? [];
|
|
87
|
+
parts.push(`## ${name}`, lines.length === 0 ? "_(empty)_" : lines.join("\n"), "");
|
|
88
|
+
}
|
|
89
|
+
return parts.join("\n");
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function writeAtomic(path, text) {
|
|
93
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
94
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
95
|
+
writeFileSync(temp, text, "utf8");
|
|
96
|
+
renameSync(temp, path);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function stateAgeSeconds(path) {
|
|
100
|
+
try {
|
|
101
|
+
return Math.round((Date.now() - statSync(path).mtimeMs) / 1000);
|
|
102
|
+
} catch {
|
|
103
|
+
return null;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function describeRelative(iso) {
|
|
108
|
+
if (iso === null || iso === undefined) return "unknown";
|
|
109
|
+
const ms = Date.parse(iso) - Date.now();
|
|
110
|
+
if (Number.isNaN(ms)) return "unknown";
|
|
111
|
+
const minutes = Math.round(Math.abs(ms) / 60_000);
|
|
112
|
+
const text = minutes < 60 ? `${minutes}m` : `${Math.floor(minutes / 60)}h ${minutes % 60}m`;
|
|
113
|
+
return ms >= 0 ? `in ${text}` : `${text} ago`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/**
|
|
117
|
+
* Which account `budget_status` describes, when ZeroSub runs agents on several.
|
|
118
|
+
*
|
|
119
|
+
* Without ZeroSub the RPC sends no `account`, and nothing changes. A label is shown
|
|
120
|
+
* only when the person chose it; ZeroSub's generated labels come from the email.
|
|
121
|
+
*/
|
|
122
|
+
function accountLines(account) {
|
|
123
|
+
if (account === undefined || account === null) return { head: [], tail: [], fromRecorder: true };
|
|
124
|
+
const name = account.label === null ? "" : ` "${account.label}"`;
|
|
125
|
+
const head = [
|
|
126
|
+
account.kind === "main"
|
|
127
|
+
? `Account: the CLI's own Claude login${name} (ZeroSub routes this session there).`
|
|
128
|
+
: `Account: a Claude account ZeroSub added${name}, not the CLI login. These numbers are ZeroSub's reading of it${
|
|
129
|
+
account.readAt === null ? " (none yet)" : `, taken ${describeRelative(account.readAt)}`
|
|
130
|
+
}; there is no pace history for it.`,
|
|
131
|
+
];
|
|
132
|
+
const tail = [];
|
|
133
|
+
const others = account.otherAccountsWithRoom;
|
|
134
|
+
if (account.autoSwitch && others > 0) {
|
|
135
|
+
tail.push(
|
|
136
|
+
`ZeroSub has ${others} other Claude account${others === 1 ? "" : "s"} with room and moves this session to one when this account hits a limit, so a full window here is not a reason to stop early.`,
|
|
137
|
+
);
|
|
138
|
+
}
|
|
139
|
+
return { head, tail, fromRecorder: account.kind === "main" };
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* All of this provider's ZeroSub subscriptions together, when there are several:
|
|
144
|
+
* how long they last at this pace and which takes over next. Names are the person's
|
|
145
|
+
* own or neutral ("CLI login", "Account 2"); the RPC never carries an email.
|
|
146
|
+
*/
|
|
147
|
+
function poolLines(pool) {
|
|
148
|
+
if (pool === undefined || pool === null) return [];
|
|
149
|
+
const fam = pool.family === "codex" ? "Codex" : "Claude";
|
|
150
|
+
const stale = pool.staleReadings > 0 ? ` (${pool.staleReadings} reading${pool.staleReadings === 1 ? " is" : "s are"} over 30 min old, so this is rough)` : "";
|
|
151
|
+
const head = `All ${pool.accounts} ${fam} subscriptions: ${pool.withRoom} with room now`;
|
|
152
|
+
if (pool.runsOutAt !== null && Date.parse(pool.runsOutAt) <= Date.now()) {
|
|
153
|
+
return [`${head}. Every one is at a limit${pool.back ? `; ${pool.back.name} is back ${describeRelative(pool.back.at)}` : ""}${stale}.`];
|
|
154
|
+
}
|
|
155
|
+
const next = pool.next ? ` ${pool.next.name} takes over ${describeRelative(pool.next.at)}.` : "";
|
|
156
|
+
if (pool.runsOutAt !== null) {
|
|
157
|
+
return [`${head}. At the current pace (${pool.pacePctPerHour}%/h of a session window) they run out ${describeRelative(pool.runsOutAt)}${pool.back ? `, and ${pool.back.name} is back ${describeRelative(pool.back.at)}` : ""}.${next}${stale}`];
|
|
158
|
+
}
|
|
159
|
+
if (pool.pacePctPerHour !== null) return [`${head}. At the current pace they last the next 24 hours.${next}${stale}`];
|
|
160
|
+
return [`${head}${stale}.`];
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
/**
|
|
164
|
+
* Session Guard's word on this session, for `budget_status`.
|
|
165
|
+
*
|
|
166
|
+
* Read-only, and only for the session asking: the plugin resolves it from
|
|
167
|
+
* `PASEO_AGENT_ID`, never from anything the model passes. A failure here costs the
|
|
168
|
+
* extra line, not the budget answer.
|
|
169
|
+
*/
|
|
170
|
+
async function guardSummary() {
|
|
171
|
+
if (AGENT_ID === null) return [];
|
|
172
|
+
try {
|
|
173
|
+
const result = await callPlugin("smart-session.guard.agent", { agentId: AGENT_ID });
|
|
174
|
+
return Array.isArray(result?.summary) ? result.summary.filter((line) => typeof line === "string") : [];
|
|
175
|
+
} catch {
|
|
176
|
+
return [];
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const TOOLS = {
|
|
181
|
+
context_status: {
|
|
182
|
+
description:
|
|
183
|
+
"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.",
|
|
184
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
185
|
+
async run() {
|
|
186
|
+
const agentId = requireAgent();
|
|
187
|
+
const result = await callPlugin("smart-session.context", { agentId });
|
|
188
|
+
const mine = result.agents?.[0];
|
|
189
|
+
if (mine === undefined) {
|
|
190
|
+
return "No context reading for this session yet — it reports one after its first completed turn.";
|
|
191
|
+
}
|
|
192
|
+
const lines = [
|
|
193
|
+
`Context holds ${mine.usedTokens.toLocaleString()} tokens (${mine.usedPct}% of a ${mine.maxTokens.toLocaleString()}-token window). Worth compacting from ${mine.compactAtPct}% (${Math.round(
|
|
194
|
+
(mine.compactAtPct / 100) * mine.maxTokens,
|
|
195
|
+
).toLocaleString()} tokens).`,
|
|
196
|
+
];
|
|
197
|
+
if (mine.growthTokensPerHour !== null) {
|
|
198
|
+
lines.push(`Filling at roughly ${Math.round(mine.growthTokensPerHour).toLocaleString()} tokens/hour.`);
|
|
199
|
+
}
|
|
200
|
+
const upper = typeof mine.compactNowAtPct === "number" ? mine.compactNowAtPct : null;
|
|
201
|
+
if (upper !== null) {
|
|
202
|
+
lines.push(
|
|
203
|
+
`Its upper line is ${upper}% (${Math.round((upper / 100) * mine.maxTokens).toLocaleString()} tokens): past that, Smart Compact asks right away, mid-turn, and you should not start new work before compacting.`,
|
|
204
|
+
);
|
|
205
|
+
}
|
|
206
|
+
if (upper !== null && mine.usedPct >= upper) {
|
|
207
|
+
lines.push(
|
|
208
|
+
"You are past the upper line. Finish only the step you are on, call checkpoint, call request_compaction with continue_after_compaction true, and end your turn.",
|
|
209
|
+
);
|
|
210
|
+
} else if (mine.usedPct >= mine.compactAtPct) {
|
|
211
|
+
// Already past it: a projection of when it will arrive reads as nonsense.
|
|
212
|
+
lines.push(
|
|
213
|
+
"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.",
|
|
214
|
+
);
|
|
215
|
+
} else if (mine.projectedFullAt !== null) {
|
|
216
|
+
lines.push(
|
|
217
|
+
`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.`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
if (mine.costUsd !== null) lines.push(`Session cost so far: $${mine.costUsd.toFixed(2)}.`);
|
|
221
|
+
|
|
222
|
+
const path = statePath();
|
|
223
|
+
const age = stateAgeSeconds(path);
|
|
224
|
+
if (age === null) {
|
|
225
|
+
lines.push(
|
|
226
|
+
`No durable task state on disk yet. Call checkpoint before you need it — compacting without it is what loses work.`,
|
|
227
|
+
);
|
|
228
|
+
} else {
|
|
229
|
+
lines.push(`Task state last written ${Math.round(age / 60)}m ago (${path}).`);
|
|
230
|
+
}
|
|
231
|
+
return lines.join("\n");
|
|
232
|
+
},
|
|
233
|
+
},
|
|
234
|
+
|
|
235
|
+
budget_status: {
|
|
236
|
+
description:
|
|
237
|
+
"How much of the Claude plan's rolling 5-hour and weekly limits is already consumed, how fast, and when each window resets — and, inside Paseo, whether Session Guard will pause this session at the session limit and continue it after the reset. Call this before spawning several subagents, before a long autonomous run, or when deciding whether to use a more expensive model.",
|
|
238
|
+
inputSchema: { type: "object", properties: {}, additionalProperties: false },
|
|
239
|
+
async run() {
|
|
240
|
+
const result = await callPlugin("smart-session.budget", AGENT_ID === null ? {} : { agentId: AGENT_ID });
|
|
241
|
+
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
242
|
+
const guard = await guardSummary();
|
|
243
|
+
const account = accountLines(result.account);
|
|
244
|
+
account.tail.push(...poolLines(result.pool));
|
|
245
|
+
const interesting = (result.windows ?? []).filter(
|
|
246
|
+
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
247
|
+
);
|
|
248
|
+
if (interesting.length === 0) return [...account.head, "No plan-usage reading recorded yet.", ...account.tail, ...guard].join("\n");
|
|
249
|
+
|
|
250
|
+
const lines = interesting.map((window) => {
|
|
251
|
+
const name = window.id === "five_hour" ? "Session (5h)" : window.id === "seven_day" ? "Week" : window.id;
|
|
252
|
+
const parts = [`${name}: ${Math.round(window.pct)}% used`];
|
|
253
|
+
if (window.resetsAt !== null) parts.push(`resets ${describeRelative(window.resetsAt)}`);
|
|
254
|
+
if (window.burnPctPerHour !== null && window.burnPctPerHour > 0) {
|
|
255
|
+
parts.push(`burning ${window.burnPctPerHour.toFixed(1)}%/h`);
|
|
256
|
+
}
|
|
257
|
+
if (window.projectedFullAt !== null) parts.push(`would hit 100% ${describeRelative(window.projectedFullAt)}`);
|
|
258
|
+
return `- ${parts.join(", ")}`;
|
|
259
|
+
});
|
|
260
|
+
|
|
261
|
+
lines.unshift(...account.head);
|
|
262
|
+
lines.push(...account.tail);
|
|
263
|
+
const recorder = result.recorder;
|
|
264
|
+
if (account.fromRecorder && recorder?.ageSeconds !== null && recorder?.ageSeconds !== undefined && recorder.ageSeconds > 900) {
|
|
265
|
+
lines.push(
|
|
266
|
+
`(Newest reading is ${Math.round(HOURS(recorder.ageSeconds * 1000) * 10) / 10}h old — treat these as a floor, not a current number.)`,
|
|
267
|
+
);
|
|
268
|
+
}
|
|
269
|
+
lines.push(...guard);
|
|
270
|
+
return lines.join("\n");
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
|
|
274
|
+
checkpoint: {
|
|
275
|
+
description:
|
|
276
|
+
"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.",
|
|
277
|
+
inputSchema: {
|
|
278
|
+
type: "object",
|
|
279
|
+
properties: {
|
|
280
|
+
goal: { type: "string", description: "The task's objective. Set once; only change it if the task itself changed." },
|
|
281
|
+
plan: { type: "string", description: "The steps, with their status. Replaces the previous plan." },
|
|
282
|
+
current_step: {
|
|
283
|
+
type: "string",
|
|
284
|
+
description: "What you are doing right now AND the exact next action, so a fresh reader could continue without guessing.",
|
|
285
|
+
},
|
|
286
|
+
decisions: {
|
|
287
|
+
type: "array",
|
|
288
|
+
items: { type: "string" },
|
|
289
|
+
description: "Decisions made and WHY. Appended, dated. The reason matters more than the decision.",
|
|
290
|
+
},
|
|
291
|
+
dead_ends: {
|
|
292
|
+
type: "array",
|
|
293
|
+
items: { type: "string" },
|
|
294
|
+
description:
|
|
295
|
+
"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.",
|
|
296
|
+
},
|
|
297
|
+
key_facts: {
|
|
298
|
+
type: "array",
|
|
299
|
+
items: { type: "string" },
|
|
300
|
+
description: "Pointers worth keeping: file:line locations, commands, gotchas. Appended. Pointers, not contents.",
|
|
301
|
+
},
|
|
302
|
+
},
|
|
303
|
+
additionalProperties: false,
|
|
304
|
+
},
|
|
305
|
+
async run(args) {
|
|
306
|
+
const path = statePath();
|
|
307
|
+
const sections = parseState(existsSync(path) ? readFileSync(path, "utf8") : "");
|
|
308
|
+
const stamp = new Date().toISOString().slice(0, 16).replace("T", " ");
|
|
309
|
+
|
|
310
|
+
const replacements = { Goal: args.goal, Plan: args.plan, "Current step": args.current_step };
|
|
311
|
+
for (const [name, value] of Object.entries(replacements)) {
|
|
312
|
+
if (typeof value === "string" && value.trim() !== "") sections.set(name, [value.trim()]);
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const additions = { Decisions: args.decisions, "Dead ends": args.dead_ends, "Key facts": args.key_facts };
|
|
316
|
+
let added = 0;
|
|
317
|
+
for (const [name, values] of Object.entries(additions)) {
|
|
318
|
+
if (!Array.isArray(values)) continue;
|
|
319
|
+
const lines = sections.get(name) ?? [];
|
|
320
|
+
for (const value of values) {
|
|
321
|
+
const text = String(value).trim();
|
|
322
|
+
if (text === "") continue;
|
|
323
|
+
// Key facts are pointers and repeat naturally; a duplicate is noise.
|
|
324
|
+
if (name === "Key facts" && lines.some((line) => line.endsWith(text))) continue;
|
|
325
|
+
lines.push(APPEND_ONLY.has(name) && name !== "Key facts" ? `- [${stamp}] ${text}` : `- ${text}`);
|
|
326
|
+
added += 1;
|
|
327
|
+
}
|
|
328
|
+
sections.set(name, lines);
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
writeAtomic(path, renderState(sections));
|
|
332
|
+
|
|
333
|
+
const missing = ["Goal", "Current step"].filter((name) => (sections.get(name) ?? []).length === 0);
|
|
334
|
+
const lines = [`Task state written to ${path} (${added} new entr${added === 1 ? "y" : "ies"}).`];
|
|
335
|
+
if (missing.length > 0) {
|
|
336
|
+
lines.push(
|
|
337
|
+
`Still empty: ${missing.join(" and ")}. A state file without those cannot be continued from — fill them on your next checkpoint.`,
|
|
338
|
+
);
|
|
339
|
+
}
|
|
340
|
+
return lines.join(" ");
|
|
341
|
+
},
|
|
342
|
+
},
|
|
343
|
+
|
|
344
|
+
request_compaction: {
|
|
345
|
+
description:
|
|
346
|
+
"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.",
|
|
347
|
+
inputSchema: {
|
|
348
|
+
type: "object",
|
|
349
|
+
properties: {
|
|
350
|
+
reason: {
|
|
351
|
+
type: "string",
|
|
352
|
+
description: "Why compacting now is the right call, in one sentence. This is quoted into the instructions.",
|
|
353
|
+
},
|
|
354
|
+
state_path: {
|
|
355
|
+
type: "string",
|
|
356
|
+
description:
|
|
357
|
+
"Absolute path to the file holding this task's durable state (goal, current step, decisions, dead ends). Strongly recommended.",
|
|
358
|
+
},
|
|
359
|
+
continue_after_compaction: {
|
|
360
|
+
type: "boolean",
|
|
361
|
+
description:
|
|
362
|
+
"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.",
|
|
363
|
+
},
|
|
364
|
+
continue_message: {
|
|
365
|
+
type: "string",
|
|
366
|
+
description:
|
|
367
|
+
"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.",
|
|
368
|
+
},
|
|
369
|
+
},
|
|
370
|
+
required: ["reason"],
|
|
371
|
+
additionalProperties: false,
|
|
372
|
+
},
|
|
373
|
+
async run(args) {
|
|
374
|
+
const agentId = requireAgent();
|
|
375
|
+
const settings = readSettings();
|
|
376
|
+
if (!settings.enabled) {
|
|
377
|
+
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 screen or Settings → Plugins → Smart Session.";
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
// Default to this session's own state file: the whole point is that the
|
|
381
|
+
// continuation has something authoritative to read, and the agent should not
|
|
382
|
+
// have to remember a path to get that.
|
|
383
|
+
const path = args.state_path === undefined ? statePath() : String(args.state_path);
|
|
384
|
+
|
|
385
|
+
// Persist, then compact. The other order is how you lose the work — so this
|
|
386
|
+
// refuses rather than queueing, because refusing costs one tool call and the
|
|
387
|
+
// agent can fix it in the same turn, where the old design spent a whole extra
|
|
388
|
+
// turn on the plugin asking for the same thing.
|
|
389
|
+
const age = stateAgeSeconds(path);
|
|
390
|
+
if (age === null) {
|
|
391
|
+
return [
|
|
392
|
+
`Not queued: there is no task state at ${path}, and compacting without it is exactly how a task gets lost.`,
|
|
393
|
+
"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.",
|
|
394
|
+
].join(" ");
|
|
395
|
+
}
|
|
396
|
+
if (age > settings.freshStateMinutes * 60) {
|
|
397
|
+
return [
|
|
398
|
+
`Not queued: ${path} was last written ${Math.round(age / 60)} minutes ago, so it probably predates the work you are about to discard.`,
|
|
399
|
+
"Call checkpoint to bring it up to date, then call request_compaction again.",
|
|
400
|
+
].join(" ");
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
const continueAfterCompaction = args.continue_after_compaction !== false;
|
|
404
|
+
const continueMessage = typeof args.continue_message === "string" ? args.continue_message : "";
|
|
405
|
+
const result = await callPlugin("smart-session.compact.request", {
|
|
406
|
+
agentId,
|
|
407
|
+
reason: String(args.reason ?? "no reason given"),
|
|
408
|
+
statePath: path,
|
|
409
|
+
continueAfterCompaction,
|
|
410
|
+
continuationMessage: continueAfterCompaction && continueMessage.trim() !== "" ? continueMessage : null,
|
|
411
|
+
});
|
|
412
|
+
const queued = result.request;
|
|
413
|
+
const lines = [
|
|
414
|
+
`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.`,
|
|
415
|
+
];
|
|
416
|
+
if (!queued.continueAfterCompaction) {
|
|
417
|
+
lines.push("No follow-up will be sent after the compaction lands.");
|
|
418
|
+
} else if (queued.continuationMessage !== null) {
|
|
419
|
+
lines.push("After it lands, the continuation message you supplied will start the next turn.");
|
|
420
|
+
} else {
|
|
421
|
+
lines.push(
|
|
422
|
+
`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.`,
|
|
423
|
+
);
|
|
424
|
+
}
|
|
425
|
+
return lines.join("\n");
|
|
426
|
+
},
|
|
427
|
+
},
|
|
428
|
+
|
|
429
|
+
defer_compaction: {
|
|
430
|
+
description:
|
|
431
|
+
"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.",
|
|
432
|
+
inputSchema: {
|
|
433
|
+
type: "object",
|
|
434
|
+
properties: {
|
|
435
|
+
reason: {
|
|
436
|
+
type: "string",
|
|
437
|
+
description: "Why now is the wrong moment, in one sentence. Recorded as your answer.",
|
|
438
|
+
},
|
|
439
|
+
minutes: {
|
|
440
|
+
type: "number",
|
|
441
|
+
description: `How long to hold off, in minutes. Capped at ${MAX_DEFER_MINUTES}; defaults to ${DEFAULT_DEFER_MINUTES}.`,
|
|
442
|
+
},
|
|
443
|
+
},
|
|
444
|
+
required: ["reason"],
|
|
445
|
+
additionalProperties: false,
|
|
446
|
+
},
|
|
447
|
+
async run(args) {
|
|
448
|
+
requireAgent();
|
|
449
|
+
const asked = Number(args.minutes);
|
|
450
|
+
const minutes = Math.min(
|
|
451
|
+
MAX_DEFER_MINUTES,
|
|
452
|
+
Math.max(1, Number.isFinite(asked) && asked > 0 ? asked : DEFAULT_DEFER_MINUTES),
|
|
453
|
+
);
|
|
454
|
+
const reason = String(args.reason ?? "no reason given");
|
|
455
|
+
const until = defer(null, minutes, reason, AGENT_ID);
|
|
456
|
+
const ledger = readLedger(null, AGENT_ID);
|
|
457
|
+
return [
|
|
458
|
+
`Deferred for ${minutes} minutes (until ${until}). You will not be asked again before then.`,
|
|
459
|
+
ledger.askCount >= 2
|
|
460
|
+
? "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."
|
|
461
|
+
: "When the step you are on is finished, call checkpoint and then request_compaction.",
|
|
462
|
+
].join("\n");
|
|
463
|
+
},
|
|
464
|
+
},
|
|
465
|
+
};
|
|
466
|
+
|
|
467
|
+
return TOOLS;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
/** Name, description and JSON Schema of each tool, for listing. */
|
|
471
|
+
export const TOOL_SHAPES = Object.entries(agentTools({ agentId: null, call: async () => ({}) }))
|
|
472
|
+
.map(([name, tool]) => ({ name, description: tool.description, inputSchema: tool.inputSchema }));
|
package/server/growth.ts
CHANGED
|
@@ -59,9 +59,10 @@ export function projectFull(
|
|
|
59
59
|
maxTokens: number,
|
|
60
60
|
ratePerHour: number | null,
|
|
61
61
|
thresholds?: Thresholds,
|
|
62
|
+
line: "compact" | "now" = "compact",
|
|
62
63
|
): string | null {
|
|
63
64
|
if (ratePerHour === null || ratePerHour <= 0) return null;
|
|
64
|
-
const room = maxTokens * (profileFor(maxTokens, thresholds)
|
|
65
|
+
const room = maxTokens * (profileFor(maxTokens, thresholds)[line] / 100) - usedTokens;
|
|
65
66
|
if (room <= 0) return new Date().toISOString();
|
|
66
67
|
return new Date(Date.now() + (room / ratePerHour) * 3_600_000).toISOString();
|
|
67
68
|
}
|
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The record of what this session has already been asked, and what it answered.
|
|
3
|
+
*
|
|
4
|
+
* Asking once and never again lets a session drift; asking every turn is nagging.
|
|
5
|
+
* So the ask is latched, re-armed by growth rather than by a clock alone, and can
|
|
6
|
+
* be switched off for a while by the agent itself — that last part is what makes it
|
|
7
|
+
* a question instead of an announcement.
|
|
8
|
+
*
|
|
9
|
+
* One file per agent rather than one shared file. `state/<agentId>.md` already
|
|
10
|
+
* works that way, and it means the `Stop` hook and the `defer_compaction` tool —
|
|
11
|
+
* two separate processes — can never be writing the same file at the same moment.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
+
import { randomUUID } from "node:crypto";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { pluginDir } from "./pointer.mjs";
|
|
19
|
+
|
|
20
|
+
/** Asks per compaction epoch, after which the session is left to its own judgement. */
|
|
21
|
+
export const MAX_ASKS = 3;
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Further occupancy, in percentage points of the window, that re-arms the ask.
|
|
25
|
+
*
|
|
26
|
+
* Time alone is the wrong trigger: a session parked at 31% for an hour has not
|
|
27
|
+
* changed its situation, and one that went 31% to 55% in ten minutes plainly has.
|
|
28
|
+
*/
|
|
29
|
+
export const RE_ASK_GROWTH_PCT = 10;
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* `now*` is the upper line's own record. It is kept apart from the end-of-turn ask
|
|
33
|
+
* so that having been asked politely at the lower line never silences the one ask
|
|
34
|
+
* that says "stop pushing on", and `deferredAt` survives an answered ask so the
|
|
35
|
+
* upper line can tell "deferred after I asked" from "never answered".
|
|
36
|
+
*/
|
|
37
|
+
const EMPTY = {
|
|
38
|
+
askCount: 0,
|
|
39
|
+
lastAskedAt: null,
|
|
40
|
+
askedAtPct: null,
|
|
41
|
+
deferredUntil: null,
|
|
42
|
+
deferReason: null,
|
|
43
|
+
deferredAt: null,
|
|
44
|
+
nowAskCount: 0,
|
|
45
|
+
nowAskedAt: null,
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Keyed by Paseo agent id where there is one, because that survives compaction
|
|
50
|
+
* whatever Claude Code decides to do with the session id.
|
|
51
|
+
*/
|
|
52
|
+
export function ledgerPath(sessionId, agentId = process.env.PASEO_AGENT_ID) {
|
|
53
|
+
const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
|
|
54
|
+
if (typeof key !== "string" || key === "") return null;
|
|
55
|
+
return join(pluginDir(), "asks", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function readLedger(sessionId, agentId) {
|
|
59
|
+
const path = ledgerPath(sessionId, agentId ?? undefined);
|
|
60
|
+
if (path === null) return { ...EMPTY };
|
|
61
|
+
try {
|
|
62
|
+
return { ...EMPTY, ...JSON.parse(readFileSync(path, "utf8")) };
|
|
63
|
+
} catch {
|
|
64
|
+
return { ...EMPTY };
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export function writeLedger(sessionId, ledger, agentId) {
|
|
69
|
+
const path = ledgerPath(sessionId, agentId ?? undefined);
|
|
70
|
+
if (path === null) return;
|
|
71
|
+
try {
|
|
72
|
+
mkdirSync(join(pluginDir(), "asks"), { recursive: true });
|
|
73
|
+
const temp = `${path}.${randomUUID()}.tmp`;
|
|
74
|
+
writeFileSync(temp, JSON.stringify(ledger), "utf8");
|
|
75
|
+
renameSync(temp, path);
|
|
76
|
+
} catch {
|
|
77
|
+
// A hook that cannot persist its latch must still not break the turn.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Forgets everything, because the window just emptied.
|
|
83
|
+
*
|
|
84
|
+
* A compaction is a new epoch: the occupancy the last ask was about is gone, and a
|
|
85
|
+
* session that fills up again deserves to be asked again.
|
|
86
|
+
*/
|
|
87
|
+
export function resetLedger(sessionId) {
|
|
88
|
+
const path = ledgerPath(sessionId);
|
|
89
|
+
if (path === null) return;
|
|
90
|
+
try {
|
|
91
|
+
rmSync(path, { force: true });
|
|
92
|
+
} catch {
|
|
93
|
+
// A stale ledger costs one missed ask, never a failed turn.
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** The agent's own answer: not now, and here is for how long. */
|
|
98
|
+
export function defer(sessionId, minutes, reason, agentId) {
|
|
99
|
+
const ledger = readLedger(sessionId, agentId);
|
|
100
|
+
const until = new Date(Date.now() + minutes * 60_000).toISOString();
|
|
101
|
+
writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason, deferredAt: new Date().toISOString() }, agentId);
|
|
102
|
+
return until;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Whether to ask, given what has already been said and how much has changed.
|
|
107
|
+
*
|
|
108
|
+
* `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
|
|
109
|
+
*/
|
|
110
|
+
function deferred(ledger, now) {
|
|
111
|
+
if (ledger.deferredUntil === null) return false;
|
|
112
|
+
const until = Date.parse(ledger.deferredUntil);
|
|
113
|
+
return Number.isFinite(until) && now < until;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function shouldAsk(ledger, pct, now = Date.now()) {
|
|
117
|
+
if (deferred(ledger, now)) return false;
|
|
118
|
+
if (ledger.askCount >= MAX_ASKS) return false;
|
|
119
|
+
if (ledger.askedAtPct === null) return true;
|
|
120
|
+
return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Records that the question was actually put to the model. */
|
|
124
|
+
export function recordAsk(sessionId, ledger, pct) {
|
|
125
|
+
writeLedger(sessionId, {
|
|
126
|
+
...ledger,
|
|
127
|
+
askCount: ledger.askCount + 1,
|
|
128
|
+
lastAskedAt: new Date().toISOString(),
|
|
129
|
+
askedAtPct: pct,
|
|
130
|
+
// An answered ask clears the deferral it was waiting out.
|
|
131
|
+
deferredUntil: null,
|
|
132
|
+
deferReason: null,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* Whether to ask right away, mid-turn, at the upper line.
|
|
138
|
+
*
|
|
139
|
+
* Once per compaction epoch. It comes back only if the agent answered the last one
|
|
140
|
+
* with a deferral and that deferral has run out — ignoring it earns no repeat, so a
|
|
141
|
+
* long turn hears it once rather than after every tool call — and never more than
|
|
142
|
+
* `MAX_ASKS` times in all. Any live deferral, from either ask, holds it off: the
|
|
143
|
+
* agent's "not now" is one answer, not one per line.
|
|
144
|
+
*/
|
|
145
|
+
export function shouldAskNow(ledger, now = Date.now()) {
|
|
146
|
+
if (deferred(ledger, now)) return false;
|
|
147
|
+
if (ledger.nowAskCount === 0 || ledger.nowAskedAt === null) return true;
|
|
148
|
+
if (ledger.nowAskCount >= MAX_ASKS) return false;
|
|
149
|
+
const asked = Date.parse(ledger.nowAskedAt);
|
|
150
|
+
const answered = ledger.deferredAt === null ? Number.NaN : Date.parse(ledger.deferredAt);
|
|
151
|
+
return Number.isFinite(asked) && Number.isFinite(answered) && answered >= asked;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** Records that the upper line's question was put to the model. */
|
|
155
|
+
export function recordNowAsk(sessionId, ledger) {
|
|
156
|
+
writeLedger(sessionId, {
|
|
157
|
+
...ledger,
|
|
158
|
+
nowAskCount: ledger.nowAskCount + 1,
|
|
159
|
+
nowAskedAt: new Date().toISOString(),
|
|
160
|
+
deferredUntil: null,
|
|
161
|
+
deferReason: null,
|
|
162
|
+
});
|
|
163
|
+
}
|