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.
@@ -1,26 +1,63 @@
1
- import type { PluginServerContext } from "@getpaseo/plugin/server";
1
+ import type { PluginServerContext, PluginSessionOpenRequest } from "@getpaseo/plugin/server";
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdirSync, renameSync, writeFileSync } from "node:fs";
4
+ import { readFile, writeFile } from "node:fs/promises";
2
5
  import { homedir } from "node:os";
3
6
  import { join } from "node:path";
4
7
  import { mcpDefinition, resolvePluginDir } from "./install.ts";
8
+ import { type Invoke, nativeTools, nativeToolsAvailable } from "./native-tools.ts";
5
9
  import { readSettings } from "./settings.ts";
6
10
 
7
- /** MCP is a launch contribution, so it never writes global provider settings. */
8
- export function contributeSessionMcp(server: PluginServerContext) {
11
+ const paseoHome = () => process.env.PASEO_HOME ?? join(homedir(), ".paseo");
12
+
13
+ async function paseoConfig(): Promise<Record<string, any> | null> {
14
+ try { return JSON.parse(await readFile(join(paseoHome(), "config.json"), "utf8")); } catch { return null; }
15
+ }
16
+
17
+ /** Providers whose sessions get the tools. A test harness may widen it to its fixture provider. */
18
+ const providers = () => (process.env.SMART_SESSION_TOOL_PROVIDERS ?? "claude,codex").split(",").map((name) => name.trim());
19
+
20
+ /**
21
+ * The agent tools are a launch contribution, so nothing global is written. On Paseo 0.15+ they go
22
+ * on `request.tools` and the daemon serves them through the agent's own Paseo MCP; otherwise this
23
+ * launch alone gets `mcp.mjs` as a stdio server. `SMART_SESSION_TOOLS` tells the hooks which names
24
+ * the agent has (`smart_checkpoint` or `checkpoint`).
25
+ */
26
+ export function contributeSessionMcp(server: PluginServerContext, invoke: Invoke) {
27
+ // A reload or update detaches native tools from open sessions until each reopens. This worker's instance, and the
28
+ // instance that attached each session, let the hooks tell a detached session how to reach the tools meanwhile
29
+ // (hooks/context.mjs toolsMode). Written synchronously, before any session can open.
30
+ const instance = randomUUID();
31
+ const data = join(paseoHome(), "plugin-data", "smart-session");
32
+ try {
33
+ mkdirSync(join(data, "tools"), { recursive: true });
34
+ writeFileSync(join(data, `tools-instance.json.${instance}`), JSON.stringify({ instance }));
35
+ renameSync(join(data, `tools-instance.json.${instance}`), join(data, "tools-instance.json"));
36
+ } catch { /* Without it the hooks assume the tools are attached. */ }
9
37
  return server.before("agent.session_open", async ({ request }) => {
10
- if (request.purpose !== "interactive" || !["claude", "codex"].includes(request.provider)
38
+ if (request.purpose !== "interactive" || !providers().includes(request.provider)
11
39
  || request.labels["plugin-tools/enabled"] === "false" || !(await readSettings()).enabled) return;
12
- const directory = await resolvePluginDir();
13
- if (!directory) return;
14
- const definition = mcpDefinition(directory);
15
- return {
16
- ...request,
17
- mcpServers: { ...request.mcpServers, "smart-session": { ...definition, env: {
18
- ...definition.env, PASEO_AGENT_ID: request.agentId,
19
- PASEO_HOME: process.env.PASEO_HOME ?? join(homedir(), ".paseo"), SMART_SESSION_MCP_SCOPE: "paseo",
20
- ...(process.env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: process.env.PASEO_DAEMON_URL } : {}),
21
- ...(process.env.PASEO_PASSWORD ? { PASEO_PASSWORD: process.env.PASEO_PASSWORD } : {}),
22
- ...(process.env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: process.env.PASEO_PASSWORD_FILE } : {}),
23
- } } },
24
- };
40
+ if (process.env.SMART_SESSION_FORCE_STDIO !== "1" && nativeToolsAvailable(request, await paseoConfig())) {
41
+ await writeFile(join(data, "tools", `native-${request.agentId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`), JSON.stringify({ instance })).catch(() => undefined);
42
+ return { ...request, env: { ...request.env, SMART_SESSION_TOOLS: "native" }, tools: [...request.tools, ...nativeTools(invoke)] };
43
+ }
44
+ return withStdio(request);
25
45
  });
26
46
  }
47
+
48
+ async function withStdio(request: PluginSessionOpenRequest): Promise<PluginSessionOpenRequest | void> {
49
+ const directory = await resolvePluginDir();
50
+ if (!directory) return;
51
+ const definition = mcpDefinition(directory);
52
+ return {
53
+ ...request,
54
+ env: { ...request.env, SMART_SESSION_TOOLS: "stdio" },
55
+ mcpServers: { ...request.mcpServers, "smart-session": { ...definition, env: {
56
+ ...definition.env, PASEO_AGENT_ID: request.agentId,
57
+ PASEO_HOME: paseoHome(), SMART_SESSION_MCP_SCOPE: "paseo",
58
+ ...(process.env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: process.env.PASEO_DAEMON_URL } : {}),
59
+ ...(process.env.PASEO_PASSWORD ? { PASEO_PASSWORD: process.env.PASEO_PASSWORD } : {}),
60
+ ...(process.env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: process.env.PASEO_PASSWORD_FILE } : {}),
61
+ } } },
62
+ };
63
+ }
package/shared/chat.ts CHANGED
@@ -31,9 +31,13 @@ export const PromptCardSchema = z.object({
31
31
  });
32
32
  export type PromptCard = z.infer<typeof PromptCardSchema>;
33
33
 
34
- /** Claude records mcp__smart-session__context_status; Codex records smart-session.context_status. */
34
+ /**
35
+ * Claude records mcp__smart-session__context_status and Codex smart-session.context_status from the
36
+ * stdio server; served natively through Paseo's MCP they are mcp__paseo__smart_context_status and
37
+ * paseo.smart_context_status.
38
+ */
35
39
  export function smartSessionTool(name: string): SmartSessionTool | null {
36
- const match = /^(?:mcp__)?smart-session(?:__|\.|\/|:)([a-z_]+)$/.exec(name);
40
+ const match = /^(?:mcp__)?(?:smart-session(?:__|\.|\/|:)|paseo(?:__|\.|\/|:)smart_)([a-z_]+)$/.exec(name);
37
41
  return match && (SMART_SESSION_TOOLS as readonly string[]).includes(match[1]!) ? (match[1] as SmartSessionTool) : null;
38
42
  }
39
43
 
@@ -0,0 +1,60 @@
1
+ /**
2
+ * What the views say about Smart Compact's two lines, as plain functions.
3
+ *
4
+ * The lower line is the **end-of-turn line**: past it, the `Stop` hook asks the
5
+ * agent at the end of a turn whether to compact. The upper line is the
6
+ * **mid-turn line**: past it, the `PostToolUse` hook asks mid-turn, and the agent
7
+ * is told to finish only the step it is on. The pill, its popover and the overview
8
+ * all read these, so they cannot describe the same session two ways.
9
+ */
10
+
11
+ import { formatCompactTokens, formatDuration } from "./format.ts";
12
+ import type { ContextOutlook, Tone } from "./forecast.ts";
13
+
14
+ /** "asks at 30%, mid-turn at 40%", or the lower line alone when there is no upper one. */
15
+ export function linesSummary(lower: number, upper: number | null | undefined): string {
16
+ return typeof upper === "number" ? `asks at ${lower}%, mid-turn at ${upper}%` : `asks at ${lower}%`;
17
+ }
18
+
19
+ /** The popover's one sentence about where this session stands. */
20
+ export function outlookSentence(
21
+ outlook: ContextOutlook,
22
+ { enrolled, compactOn, growthPerHour }: { enrolled: boolean; compactOn: boolean; growthPerHour: number | null },
23
+ ): { line: string; tone: Tone } {
24
+ const pace = growthPerHour !== null && growthPerHour > 0 ? ` at +${formatCompactTokens(growthPerHour)}/h` : "";
25
+ if (outlook.past && !enrolled) {
26
+ return {
27
+ line: compactOn ? "Past its compact line, but not enrolled, so nothing will ask it." : "Past its compact line. Smart Compact is off, so nothing will ask it.",
28
+ tone: outlook.tone,
29
+ };
30
+ }
31
+ if (outlook.pastNow) {
32
+ return { line: "Past its mid-turn line. Smart Compact is asking it to finish the step it is on and compact.", tone: outlook.tone };
33
+ }
34
+ if (outlook.past) {
35
+ const next = outlook.untilNowMs !== null
36
+ ? ` Reaches its mid-turn line in about ${formatDuration(outlook.untilNowMs)}${pace}.`
37
+ : outlook.nowAt !== null ? ` From ${outlook.nowAt}% it is asked mid-turn.` : "";
38
+ return { line: `Past its end-of-turn line, so it is asked at the end of each turn whether to compact.${next}`, tone: outlook.tone };
39
+ }
40
+ if (outlook.untilCompactMs !== null) {
41
+ const upper = outlook.untilNowMs !== null ? ` and its mid-turn line in about ${formatDuration(outlook.untilNowMs)}` : "";
42
+ return { line: `Reaches its end-of-turn line in about ${formatDuration(outlook.untilCompactMs)}${upper}${pace}.`, tone: "default" };
43
+ }
44
+ return { line: outlook.nowAt !== null ? "Below both compact lines and not growing right now." : "Below its compact line and not growing right now.", tone: "default" };
45
+ }
46
+
47
+ /** The overview row's short line, or null when there is nothing to add. */
48
+ export function outlookShort(outlook: ContextOutlook, enrolled: boolean): string | null {
49
+ if (outlook.past && !enrolled) return "Past its compact line · not enrolled";
50
+ if (outlook.pastNow) return "Past its mid-turn line · asked now";
51
+ if (outlook.past) {
52
+ return outlook.untilNowMs !== null
53
+ ? `Past its end-of-turn line · mid-turn in ~${formatDuration(outlook.untilNowMs)}`
54
+ : "Past its end-of-turn line · asked at the end of a turn";
55
+ }
56
+ if (outlook.untilCompactMs === null) return null;
57
+ return outlook.untilNowMs !== null && outlook.nowAt !== null
58
+ ? `${outlook.compactAt}% in ~${formatDuration(outlook.untilCompactMs)} · ${outlook.nowAt}% in ~${formatDuration(outlook.untilNowMs)}`
59
+ : `Reaches it in ~${formatDuration(outlook.untilCompactMs)}`;
60
+ }
@@ -181,18 +181,29 @@ export function leadWindow(windows: readonly WindowForecast[]): WindowForecast |
181
181
  export interface ContextReading {
182
182
  readonly usedTokens: number;
183
183
  readonly maxTokens: number;
184
+ /** The lower line: Smart Compact asks at the end of a turn. */
184
185
  readonly compactAtPct: number;
186
+ /** The upper line: Smart Compact asks mid-turn. Absent from older daemons. */
187
+ readonly compactNowAtPct?: number | null;
185
188
  readonly growthTokensPerHour?: number | null;
186
189
  }
187
190
 
188
191
  export interface ContextOutlook {
189
192
  readonly pct: number;
190
193
  readonly compactAt: number;
191
- /** Tokens at the compact line. */
194
+ /** Tokens at the lower line. */
192
195
  readonly compactTokens: number;
196
+ /** Past the lower line. */
193
197
  readonly past: boolean;
194
- /** Time until the compact line at the measured growth; null when not growing or already past. */
198
+ /** Time until the lower line at the measured growth; null when not growing or already past. */
195
199
  readonly untilCompactMs: number | null;
200
+ /** The upper line, or null when the reading has none. */
201
+ readonly nowAt: number | null;
202
+ readonly nowTokens: number | null;
203
+ /** Past the upper line, where the agent is asked mid-turn. */
204
+ readonly pastNow: boolean;
205
+ /** Time until the upper line at the measured growth; null when not growing, already past or unknown. */
206
+ readonly untilNowMs: number | null;
196
207
  readonly tone: Tone;
197
208
  }
198
209
 
@@ -201,17 +212,24 @@ export function contextOutlook(reading: ContextReading): ContextOutlook {
201
212
  const compactTokens = Math.round((reading.compactAtPct / 100) * reading.maxTokens);
202
213
  const past = pct >= reading.compactAtPct;
203
214
  const growth = reading.growthTokensPerHour ?? null;
204
- const untilCompactMs = past || growth === null || growth <= 0 ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
205
- return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, tone: pct >= 90 ? "danger" : past ? "warning" : "default" };
215
+ const growing = growth !== null && growth > 0;
216
+ const untilCompactMs = past || !growing ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
217
+ const nowAt = typeof reading.compactNowAtPct === "number" ? reading.compactNowAtPct : null;
218
+ const nowTokens = nowAt === null ? null : Math.round((nowAt / 100) * reading.maxTokens);
219
+ const pastNow = nowAt !== null && pct >= nowAt;
220
+ const untilNowMs = nowTokens === null || pastNow || !growing ? null : ((nowTokens - reading.usedTokens) / growth) * HOUR;
221
+ // Amber past the lower line, red past the upper one (or a nearly full window).
222
+ const tone = pct >= 90 || pastNow ? "danger" : past ? "warning" : "default";
223
+ return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, nowAt, nowTokens, pastNow, untilNowMs, tone };
206
224
  }
207
225
 
208
226
  /**
209
227
  * The context ring: a faint full track, an arc for the share of the window used, and a
210
- * tick across the ring at the compact line. Inline SVG in the surface's own colour, so
228
+ * tick at each compact line: inward at the end-of-turn line, outward at the mid-turn line. Inline SVG in the surface's own colour, so
211
229
  * the host `Icon` can draw it anywhere a Lucide glyph fits. Quantized to 1/48 so a
212
230
  * ticking view rarely redraws.
213
231
  */
214
- export function contextRing(fraction: number, mark: number | null, track = 0.28): string {
232
+ export function contextRing(fraction: number, mark: number | null, track = 0.28, upper: number | null = null): string {
215
233
  const steps = 48;
216
234
  const f = Math.round(Math.min(1, Math.max(0, fraction)) * steps) / steps;
217
235
  const r = 8.5;
@@ -223,11 +241,19 @@ export function contextRing(fraction: number, mark: number | null, track = 0.28)
223
241
  const [x, y] = point(f, r);
224
242
  arc = `<path d="M${c} ${c - r}A${r} ${r} 0 ${f > 0.5 ? 1 : 0} 1 ${x} ${y}"/>`;
225
243
  }
226
- let tick = "";
227
- if (mark !== null && mark > 0 && mark < 1) {
228
- const [x1, y1] = point(mark, r - 3.6);
229
- const [x2, y2] = point(mark, r + 3.2);
230
- tick = `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
231
- }
244
+ // The end-of-turn tick points inward from the ring and the mid-turn tick outward, so the pair sits
245
+ // on opposite sides of the stroke and stays apart at pill size, where two ticks 36° apart that
246
+ // both crossed the ring merged with the arc's end into one hooked smudge.
247
+ const tickAt = (at: number | null, from: number, to: number) => {
248
+ if (at === null || !(at > 0 && at < 1)) return "";
249
+ const [x1, y1] = point(at, from);
250
+ const [x2, y2] = point(at, to);
251
+ return `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
252
+ };
253
+ // A muted ring (no lower line) has no upper one either.
254
+ const inward = tickAt(mark, r - 3.8, r);
255
+ // r + 2.6 plus the 0.9 round cap ends exactly at the 24-unit box edge, so the stub is never clipped.
256
+ const outward = upper !== null && upper !== mark ? tickAt(upper, r, r + 2.6) : "";
257
+ const tick = mark === null ? "" : inward + outward;
232
258
  return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><circle cx="${c}" cy="${c}" r="${r}" stroke-opacity="${track}"/>${arc}${tick}</svg>`;
233
259
  }
@@ -155,6 +155,10 @@ export const ContextStatusSchema = z.object({
155
155
  projectedFullAt: z.string().nullable(),
156
156
  /** The percentage at which this window is considered ready to compact. */
157
157
  compactAtPct: z.number(),
158
+ /** The upper line: past it, Smart Compact asks mid-turn. Optional for older daemons. */
159
+ compactNowAtPct: z.number().optional(),
160
+ /** When this agent would reach the upper line, at that rate. */
161
+ projectedNowAt: z.string().nullable().optional(),
158
162
  /** When this agent's task state file was last written; null when it has none. */
159
163
  stateWrittenAt: z.string().nullable().optional(),
160
164
  });
@@ -174,6 +178,8 @@ const ThresholdProfileSchema = z.object({
174
178
  notice: z.number().finite().min(1).max(99),
175
179
  closing: z.number().finite().min(1).max(99),
176
180
  compact: z.number().finite().min(1).max(99),
181
+ /** The upper line, where Smart Compact asks mid-turn. */
182
+ now: z.number().finite().min(1).max(99),
177
183
  });
178
184
 
179
185
  export const ThresholdsSchema = z.object({
@@ -19,8 +19,16 @@ export interface ThresholdProfile {
19
19
  readonly notice: number;
20
20
  /** Percentage at which to stop opening new work. */
21
21
  readonly closing: number;
22
- /** Percentage at which to recommend compacting. */
22
+ /**
23
+ * The lower line, "when convenient": the `Stop` hook asks at the end of a turn.
24
+ * Kept under its old name so saved settings and older daemons still read it.
25
+ */
23
26
  readonly compact: number;
27
+ /**
28
+ * The upper line, "mid-turn": the `PostToolUse` hook asks mid-turn, and the
29
+ * agent is told to finish only the step it is on. Always at or above `compact`.
30
+ */
31
+ readonly now: number;
24
32
  }
25
33
 
26
34
  export interface Thresholds {
@@ -42,10 +50,21 @@ export interface Thresholds {
42
50
  */
43
51
  export const DEFAULT_THRESHOLDS: Thresholds = {
44
52
  largeWindowFrom: 400_000,
45
- large: { notice: 15, closing: 22, compact: 30 },
46
- small: { notice: 60, closing: 75, compact: 85 },
53
+ large: { notice: 15, closing: 22, compact: 30, now: 40 },
54
+ small: { notice: 60, closing: 75, compact: 85, now: 90 },
47
55
  };
48
56
 
57
+ /**
58
+ * Where the upper line goes when a saved profile predates it.
59
+ *
60
+ * The saved lower line is kept as it is, and the upper line keeps the default's
61
+ * distance above it, so a person who moved their compact line to 50% does not get
62
+ * an upper line at 40% — below it — the moment they upgrade.
63
+ */
64
+ export function defaultNowFor(compact: number, fallback: ThresholdProfile): number {
65
+ return Math.min(99, Math.max(fallback.now, compact + (fallback.now - fallback.compact)));
66
+ }
67
+
49
68
  export function profileFor(maxTokens: number, thresholds: Thresholds = DEFAULT_THRESHOLDS): ThresholdProfile {
50
69
  return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
51
70
  }
@@ -77,7 +96,17 @@ export function normalizeProfile(raw: Partial<ThresholdProfile> | undefined, fal
77
96
  const compact = clamp(raw?.compact, fallback.compact);
78
97
  const closing = Math.min(compact, clamp(raw?.closing, fallback.closing));
79
98
  const notice = Math.min(closing, clamp(raw?.notice, fallback.notice));
80
- return { notice, closing, compact };
99
+ // The upper line must stay above the lower one; a saved file from before it
100
+ // existed gets the default distance above whatever lower line it kept.
101
+ const now = Math.max(Math.min(99, compact + 1), clamp(raw?.now, defaultNowFor(compact, fallback)));
102
+ return { notice, closing, compact, now: Math.min(99, now) };
103
+ }
104
+
105
+ /** Why a pair of lines cannot be saved, or null when it can. */
106
+ export function linesProblem(lower: number | null, upper: number | null): string | null {
107
+ if (lower === null || upper === null) return "Enter a whole number from 1 to 99.";
108
+ if (upper <= lower) return "The mid-turn line has to be above the end-of-turn line.";
109
+ return null;
81
110
  }
82
111
 
83
112
  /**
@@ -87,9 +116,14 @@ export function normalizeProfile(raw: Partial<ThresholdProfile> | undefined, fal
87
116
  * compact all fire together. Scale the earlier bands with it when necessary;
88
117
  * raising it leaves the existing advance notice intact.
89
118
  */
90
- export function withCompactThreshold(profile: ThresholdProfile, value: number): ThresholdProfile {
119
+ export function withCompactThreshold(profile: ThresholdProfile, value: number, nowValue?: number): ThresholdProfile {
91
120
  const compact = Number.isFinite(value) ? Math.round(Math.min(99, Math.max(1, value))) : profile.compact;
92
- if (compact >= profile.closing) return { ...profile, compact };
121
+ // An explicit upper line wins; otherwise it keeps its distance above the lower one.
122
+ const now =
123
+ nowValue !== undefined && Number.isFinite(nowValue)
124
+ ? Math.round(Math.min(99, Math.max(1, nowValue)))
125
+ : Math.min(99, Math.max(profile.now, compact + (profile.now - profile.compact)));
126
+ if (compact >= profile.closing) return normalizeProfile({ ...profile, compact, now }, profile);
93
127
 
94
128
  const scale = compact / profile.compact;
95
129
  return normalizeProfile(
@@ -97,6 +131,7 @@ export function withCompactThreshold(profile: ThresholdProfile, value: number):
97
131
  notice: Math.max(1, Math.round(profile.notice * scale)),
98
132
  closing: Math.max(1, Math.round(profile.closing * scale)),
99
133
  compact,
134
+ now,
100
135
  },
101
136
  profile,
102
137
  );