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
package/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Screenshots use synthetic usage history and sessions.
|
|
|
20
20
|
paseo plugin install paseo-smart-session
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
On first load it registers its Claude Code hooks, touching only entries that point at its own scripts.
|
|
23
|
+
On first load it registers its Claude Code hooks, touching only entries that point at its own scripts. Its agent tools are attached when an interactive session opens: on Paseo 0.15+ they are served through the agent's own Paseo MCP (`smart_checkpoint`, `smart_request_compaction`, …) with no process per agent, and on 0.14 each session gets its own `mcp.mjs` stdio server. Nothing is registered globally; an older version's global `smart-session` entry is removed on load, with a backup beside the file.
|
|
24
24
|
|
|
25
25
|
## Use
|
|
26
26
|
|
|
@@ -31,7 +31,7 @@ On first load it registers its Claude Code hooks, touching only entries that poi
|
|
|
31
31
|
- **Smart Compact and Pause at the limit.** One switch each, what they will do next, and the sessions you changed from your default.
|
|
32
32
|
- **Tokens, last 30 days.** Billable tokens, the cache hit rate and the subagent share, per day, by hour of the week, by workspace and by model, reconstructed from Claude Code's transcripts.
|
|
33
33
|
|
|
34
|
-
Each session has one pill above its message box: a ring of its context, with a tick at its compact
|
|
34
|
+
Each session has one pill above its message box: a ring of its context, with a tick at each of its two compact lines. Press it to see the detail, when it last saved its task state and compacted, and to switch Smart Compact and Pause at the limit for that session. When the session is paused at the limit the same pill turns amber and says when it resumes, and it stays up even if you hide the pill.
|
|
35
35
|
|
|
36
36
|

|
|
37
37
|
|
|
@@ -53,7 +53,7 @@ The usual flow is `context_status → checkpoint → request_compaction`, then s
|
|
|
53
53
|
|
|
54
54
|
## How Smart Compact works
|
|
55
55
|
|
|
56
|
-
As a session fills, hooks send context notices.
|
|
56
|
+
As a session fills, hooks send context notices. Each window size has two lines. Past the first (the end-of-turn line), the agent is asked at the end of its turn whether it wants to compact. Past the second (the mid-turn line), it is asked straight away, to finish only the step it is on, checkpoint, request the compaction and end its turn so the compaction can land. Nothing is forced: the agent can compact, defer with a reason, or keep working. A request is delivered only when the agent is idle and its state file is current; `request_compaction` refuses a missing or stale one.
|
|
57
57
|
|
|
58
58
|
After compaction, the agent chooses to stop (`continue_after_compaction: false`), continue with the default prompt to re-read its state, or continue with its own `continue_message`. A `/compact` typed by a person never gets a plugin continuation.
|
|
59
59
|
|
|
@@ -97,8 +97,10 @@ Everything is on one screen, **Settings → Plugins → Smart Session**, in the
|
|
|
97
97
|
| --- | --- | --- | --- |
|
|
98
98
|
| Smart Compact | Smart Compact | On | Context notices and agent-requested compaction |
|
|
99
99
|
| | Enrol sessions automatically | On | Enrol a session once it writes task state |
|
|
100
|
-
| When to ask |
|
|
101
|
-
| |
|
|
100
|
+
| When to ask | 400k+ windows · end of turn | 30% | Where large windows are asked when a turn ends |
|
|
101
|
+
| | 400k+ windows · mid-turn | 40% | Where large windows are asked mid-turn; must be above the end-of-turn line |
|
|
102
|
+
| | Smaller windows · end of turn | 85% | Where smaller windows are asked when a turn ends |
|
|
103
|
+
| | Smaller windows · mid-turn | 90% | Where smaller windows are asked mid-turn |
|
|
102
104
|
| Pause at the limit (Session Guard) | Pause at the limit (Session Guard) | Off | Pause sessions at the session limit |
|
|
103
105
|
| | Sessions | Every session | Or only the ones you turn on from their pill |
|
|
104
106
|
| | Continue after the reset | On | Off, you continue each one by hand |
|
package/client/charts.tsx
CHANGED
|
@@ -36,11 +36,11 @@ function hover(set: (value: number | null) => void, index: number) {
|
|
|
36
36
|
|
|
37
37
|
/**
|
|
38
38
|
* A meter: the used share, an optional faint projection of where it lands at the
|
|
39
|
-
* reset, an optional muted pace tick (where an even spend would be) and
|
|
40
|
-
*
|
|
39
|
+
* reset, an optional muted pace tick (where an even spend would be) and optional
|
|
40
|
+
* marks (the two compact lines on a context bar).
|
|
41
41
|
*/
|
|
42
|
-
export function Meter({ theme, pct, tone = "default", projected, pace, mark, height = 8, label }: {
|
|
43
|
-
theme: Theme; pct: number; tone?: Tone; projected?: number | null; pace?: number | null; mark?: number | null; height?: number; label: string;
|
|
42
|
+
export function Meter({ theme, pct, tone = "default", projected, pace, mark, upper, height = 8, label }: {
|
|
43
|
+
theme: Theme; pct: number; tone?: Tone; projected?: number | null; pace?: number | null; mark?: number | null; upper?: number | null; height?: number; label: string;
|
|
44
44
|
}) {
|
|
45
45
|
const c = theme.colors;
|
|
46
46
|
const used = Math.max(0, Math.min(100, pct));
|
|
@@ -57,13 +57,14 @@ export function Meter({ theme, pct, tone = "default", projected, pace, mark, hei
|
|
|
57
57
|
</View>
|
|
58
58
|
{pace !== null && pace !== undefined ? tick(pace, c.foregroundMuted, "pace") : null}
|
|
59
59
|
{mark !== null && mark !== undefined ? tick(mark, c.foreground, "mark") : null}
|
|
60
|
+
{upper !== null && upper !== undefined && upper !== mark ? tick(upper, c.foreground, "upper") : null}
|
|
60
61
|
</View>;
|
|
61
62
|
}
|
|
62
63
|
|
|
63
64
|
/** The context ring glyph, the same object as the composer pill's icon. */
|
|
64
|
-
export function Ring({ size, color, fraction, mark }: { size: number; color: string; fraction: number; mark: number | null }) {
|
|
65
|
+
export function Ring({ size, color, fraction, mark, upper = null }: { size: number; color: string; fraction: number; mark: number | null; upper?: number | null }) {
|
|
65
66
|
// A stronger track at pill size, so the ring reads as a gauge and not as the host's spinner.
|
|
66
|
-
return <Icon icon={{ svg: contextRing(fraction, mark, size <= 18 ? 0.5 : 0.28) }} size={size} color={color} />;
|
|
67
|
+
return <Icon icon={{ svg: contextRing(fraction, mark, size <= 18 ? 0.5 : 0.28, mark === null ? null : upper) }} size={size} color={color} />;
|
|
67
68
|
}
|
|
68
69
|
|
|
69
70
|
/** The percentage held at `t`: readings only climb within a window, so the last one before it stands. */
|
package/client/overview.tsx
CHANGED
|
@@ -4,6 +4,7 @@ import { useQuery, useQueryClient } from "@tanstack/react-query";
|
|
|
4
4
|
import { useEffect, useRef, useSyncExternalStore, type ReactNode } from "react";
|
|
5
5
|
import { Platform, ScrollView, Text, View, type ScrollView as ScrollViewType } from "react-native";
|
|
6
6
|
|
|
7
|
+
import { outlookShort } from "../shared/compact-lines";
|
|
7
8
|
import { formatCompactTokens, formatDay, formatDuration, formatRelative, isInteresting } from "../shared/format";
|
|
8
9
|
import {
|
|
9
10
|
budgetLine, contextOutlook, forecastWindow, formatMoment, headline, leadWindow, paceLine, resetLine,
|
|
@@ -215,11 +216,12 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
215
216
|
: null;
|
|
216
217
|
const notice = guard === undefined ? null : governorNotice(guard);
|
|
217
218
|
// One line each, paired with its moment where there is one.
|
|
218
|
-
const pastLine = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).past).length;
|
|
219
|
+
const pastLine = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).past && !contextOutlook(agent).pastNow).length;
|
|
220
|
+
const pastUpper = agents.filter((agent) => enrolled.has(agent.agentId) && contextOutlook(agent).pastNow).length;
|
|
219
221
|
const compactLine: ReactNode = settings === undefined ? "…" : !settings.enabled ? "Off · nothing is asked or compacted"
|
|
220
222
|
// Only the clause that is state wears the state colour.
|
|
221
|
-
: pastLine > 0 ? <>{`${enrolled.size} enrolled
|
|
222
|
-
: `${enrolled.size} enrolled · asked
|
|
223
|
+
: pastUpper + pastLine > 0 ? <>{`${enrolled.size} enrolled`}{pastUpper > 0 ? <Text style={{ color: c.statusDanger }}>{` · ${pastUpper} asked mid-turn`}</Text> : null}{pastLine > 0 ? <Text style={{ color: c.statusWarning }}>{` · ${pastLine} asked at the end of a turn`}</Text> : null}</>
|
|
224
|
+
: `${enrolled.size} enrolled · asked at ${settings.thresholds.large.compact}% / ${settings.thresholds.large.now}% of a ${formatCompactTokens(settings.thresholds.largeWindowFrom)}+ window, ${settings.thresholds.small.compact}% / ${settings.thresholds.small.now}% of a smaller one`;
|
|
223
225
|
const guardState: { line: string; tone: Tone } = (() => {
|
|
224
226
|
if (guardOn === null) return { line: "…", tone: "default" };
|
|
225
227
|
if (!guardOn) return { line: paused.length > 0 ? `Off · ${paused.length} paused still ${paused.length === 1 ? "waits" : "wait"} for the reset` : "Off · sessions run into the limit", tone: "default" };
|
|
@@ -308,10 +310,13 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
308
310
|
: <Card theme={theme}>{agents.map((agent, i) => {
|
|
309
311
|
const outlook = contextOutlook(agent);
|
|
310
312
|
const on = enrolled.has(agent.agentId);
|
|
311
|
-
const
|
|
313
|
+
const upper = agent.compactNowAtPct ?? null;
|
|
314
|
+
// A session nobody enrolled is never asked, so it gets no lines and no ticks.
|
|
315
|
+
const parts = [`${formatCompactTokens(agent.usedTokens)} of ${formatCompactTokens(agent.maxTokens)}`];
|
|
316
|
+
if (!on) { if (compact) parts.push("not enrolled"); }
|
|
317
|
+
else parts.push(upper === null ? `compacts at ${agent.compactAtPct}%` : `asks at ${agent.compactAtPct}%, mid-turn at ${upper}%`);
|
|
312
318
|
if (agent.growthTokensPerHour !== null && agent.growthTokensPerHour > 0) parts.push(`+${formatCompactTokens(agent.growthTokensPerHour)}/h`);
|
|
313
|
-
const outlookLine = outlook
|
|
314
|
-
: outlook.untilCompactMs !== null ? `Reaches it in ~${formatDuration(outlook.untilCompactMs)}` : null;
|
|
319
|
+
const outlookLine = outlookShort(outlook, on);
|
|
315
320
|
return <CardRow key={agent.agentId} theme={theme} first={i === 0} style={{ gap: 6 }}
|
|
316
321
|
onPress={navigation ? () => navigation.openAgent({ agentId: agent.agentId }) : undefined} accessibilityLabel={`Open ${agent.title ?? "session"}`}>
|
|
317
322
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
@@ -320,10 +325,10 @@ export function makeOverview(client: PluginClientContext, live: Live) {
|
|
|
320
325
|
<View style={{ flex: 1 }} />
|
|
321
326
|
{governed.some((p) => p.agentId === agent.agentId) ? <Chip theme={theme} icon="Pause" label="Paused" color={c.statusWarning} /> : null}
|
|
322
327
|
{on ? <Chip theme={theme} label="Smart Compact" /> : compact ? null : <Text style={t.small}>Not enrolled</Text>}
|
|
323
|
-
<Ring size={16} color={outlook.tone !== "default" ? toneFill(c, outlook.tone) : on ? c.foreground : c.foregroundMuted} fraction={outlook.pct / 100} mark={on ? agent.compactAtPct / 100 : null} />
|
|
328
|
+
<Ring size={16} color={outlook.tone !== "default" ? toneFill(c, outlook.tone) : on ? c.foreground : c.foregroundMuted} fraction={outlook.pct / 100} mark={on ? agent.compactAtPct / 100 : null} upper={upper === null ? null : upper / 100} />
|
|
324
329
|
<Text style={{ color: c.foreground, fontSize: 15, lineHeight: 20, fontWeight: "600", minWidth: 36, textAlign: "right", ...tabular }}>{`${Math.round(outlook.pct)}%`}</Text>
|
|
325
330
|
</View>
|
|
326
|
-
<Meter theme={theme} pct={outlook.pct} tone={outlook.tone} mark={agent.compactAtPct} height={6} label={`${agent.title ?? "Session"}: ${Math.round(outlook.pct)}% of its context
|
|
331
|
+
<Meter theme={theme} pct={outlook.pct} tone={outlook.tone} mark={on ? agent.compactAtPct : null} upper={on ? upper : null} height={6} label={`${agent.title ?? "Session"}: ${Math.round(outlook.pct)}% of its context${on ? `, asked at ${agent.compactAtPct}%${upper === null ? "" : ` and mid-turn at ${upper}%`}` : ", not enrolled"}`} />
|
|
327
332
|
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 10, rowGap: 2 }}>
|
|
328
333
|
<Text style={t.meta}>{parts.join(" · ")}</Text>
|
|
329
334
|
{outlookLine ? <Text style={{ ...t.meta, color: outlook.past ? toneColor(c, outlook.tone) : c.foregroundMuted }}>{outlookLine}</Text> : null}
|
package/client/pill-view.ts
CHANGED
|
@@ -18,16 +18,17 @@ export function pillPresentation(agent: Pick<DirectoryAgent, "usedTokens" | "max
|
|
|
18
18
|
return {
|
|
19
19
|
label: compactOn ? "Smart Compact" : "Context",
|
|
20
20
|
title: `${compactWords ?? "Smart Compact on"} · no context reading yet`,
|
|
21
|
-
fraction: 0, mark: null as number | null, tone: "default" as const, pct: null as number | null,
|
|
21
|
+
fraction: 0, mark: null as number | null, upper: null as number | null, tone: "default" as const, pct: null as number | null,
|
|
22
22
|
};
|
|
23
23
|
}
|
|
24
|
-
const
|
|
25
|
-
const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct });
|
|
24
|
+
const profile = profileFor(agent.maxTokens!, thresholds ?? undefined);
|
|
25
|
+
const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct: profile.compact, compactNowAtPct: profile.now });
|
|
26
26
|
const pct = Math.round(outlook.pct);
|
|
27
|
+
// Short on purpose: the phone sheet uses the title as its one-line header.
|
|
27
28
|
return {
|
|
28
29
|
label: `${pct}%`,
|
|
29
|
-
title: `Context ${pct}% full · ${compactWords ?? `
|
|
30
|
-
fraction: outlook.pct / 100, mark:
|
|
30
|
+
title: `Context ${pct}% full · ${compactWords ?? `asks at ${profile.compact}%, mid-turn at ${profile.now}%`}`,
|
|
31
|
+
fraction: outlook.pct / 100, mark: profile.compact / 100, upper: profile.now / 100 as number | null, tone: outlook.tone, pct,
|
|
31
32
|
};
|
|
32
33
|
}
|
|
33
34
|
|
package/client/pill.tsx
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
*
|
|
5
5
|
* Normally it answers how full this session's context is and whether Smart Compact
|
|
6
6
|
* will ask it to compact itself: the icon is a ring that fills with the context and
|
|
7
|
-
* carries a tick at
|
|
7
|
+
* carries a tick at each compact line (end of turn, then mid-turn), the label is the percentage. Protection adds
|
|
8
8
|
* nothing while it simply protects; the quiet case stays quiet. When this session is
|
|
9
9
|
* paused at the session limit the pill turns into the pause itself, an amber pause
|
|
10
10
|
* glyph and the moment it resumes, and it stays on the composer even when the pill
|
|
@@ -56,7 +56,7 @@ function makeIcon(agents: AgentDirectory) {
|
|
|
56
56
|
const enrolled = current.compactOn && current.enrolled.has(agentId);
|
|
57
57
|
const view = pillPresentation(agents.current().get(agentId), enrolled, current.thresholds, current.compactOn);
|
|
58
58
|
const color = view.tone !== "default" ? toneFill(c, view.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
59
|
-
return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} />;
|
|
59
|
+
return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} upper={view.upper} />;
|
|
60
60
|
};
|
|
61
61
|
}
|
|
62
62
|
|
package/client/popover.tsx
CHANGED
|
@@ -5,6 +5,7 @@ import { useRpc, type PluginButtonContentProps, type PluginClientContext } from
|
|
|
5
5
|
import { useQuery } from "@tanstack/react-query";
|
|
6
6
|
|
|
7
7
|
import { formatClock, formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
|
|
8
|
+
import { linesSummary, outlookSentence } from "../shared/compact-lines";
|
|
8
9
|
import { contextOutlook } from "../shared/forecast";
|
|
9
10
|
import { listCompactions, setEnrolment, type CompactionRequest } from "../shared/governor";
|
|
10
11
|
import { cancelResumeRpc, dismissPauseRpc, resumeNowRpc, setProtectionRpc, statusRpc } from "../shared/guard/contracts";
|
|
@@ -99,16 +100,14 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
99
100
|
|
|
100
101
|
const head = (() => {
|
|
101
102
|
if (used === null || max === null) return null;
|
|
102
|
-
const
|
|
103
|
-
|
|
103
|
+
const profile = profileFor(max, pills.thresholds ?? undefined);
|
|
104
|
+
const compactAtPct = row?.compactAtPct ?? profile.compact;
|
|
105
|
+
const nowAtPct = row?.compactNowAtPct ?? profile.now;
|
|
106
|
+
return { compactAtPct, nowAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, compactNowAtPct: nowAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
|
|
104
107
|
})();
|
|
105
108
|
const sentence = head === null
|
|
106
109
|
? { line: "No context reading yet. A session reports one after its first turn.", tone: "default" as const }
|
|
107
|
-
: head.outlook
|
|
108
|
-
? { line: enrolled ? "Past its compact line. Smart Compact asks it at the end of a turn whether to compact." : 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.", tone: head.outlook.tone }
|
|
109
|
-
: head.outlook.untilCompactMs !== null
|
|
110
|
-
? { line: `Reaches its compact line in about ${formatDuration(head.outlook.untilCompactMs)} at +${formatCompactTokens(row!.growthTokensPerHour!)}/h.`, tone: "default" as const }
|
|
111
|
-
: { line: "Below its compact line and not growing right now.", tone: "default" as const };
|
|
110
|
+
: outlookSentence(head.outlook, { enrolled, compactOn, growthPerHour: row?.growthTokensPerHour ?? null });
|
|
112
111
|
const latest = compactions.data?.items[0];
|
|
113
112
|
const state = row?.stateWrittenAt ?? null;
|
|
114
113
|
const facts: [string, string][] = [
|
|
@@ -205,16 +204,16 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
205
204
|
{pausePart}
|
|
206
205
|
<View style={[{ gap: 12 }, pausePart === null ? null : part]}>
|
|
207
206
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
208
|
-
<Ring size={pausePart === null ? 40 : 28} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null || !enrolled ? null : head.compactAtPct / 100} />
|
|
207
|
+
<Ring size={pausePart === null ? 40 : 28} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null || !enrolled ? null : head.compactAtPct / 100} upper={head === null ? null : head.nowAtPct / 100} />
|
|
209
208
|
<View style={{ flex: 1, gap: 1 }}>
|
|
210
209
|
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
|
211
210
|
<Text style={{ color: c.foreground, fontSize: pausePart === null ? 22 : 15, lineHeight: pausePart === null ? 28 : 20, fontWeight: "600", ...tabular }}>{head === null ? "—" : `${Math.round(head.outlook.pct)}%`}</Text>
|
|
212
211
|
<Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
|
|
213
212
|
</View>
|
|
214
|
-
<Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens ·
|
|
213
|
+
<Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · ${linesSummary(head.compactAtPct, head.nowAtPct)}`}</Text>
|
|
215
214
|
</View>
|
|
216
215
|
</View>
|
|
217
|
-
{head === null || pausePart !== null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full
|
|
216
|
+
{head === null || pausePart !== null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} upper={head.nowAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full. Smart Compact ${linesSummary(head.compactAtPct, head.nowAtPct)}`} />}
|
|
218
217
|
{pausePart !== null ? null : <Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>}
|
|
219
218
|
</View>
|
|
220
219
|
{pausePart !== null ? null : <View style={part}>
|
|
@@ -228,7 +227,7 @@ export function makeContextPopover(client: PluginClientContext, agents: AgentDir
|
|
|
228
227
|
{compactOn ? <View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
229
228
|
<View style={{ flex: 1, gap: 2 }}>
|
|
230
229
|
<Text style={t.label}>Smart Compact</Text>
|
|
231
|
-
<Text style={t.meta}>{enrolled ?
|
|
230
|
+
<Text style={t.meta}>{enrolled ? "Asked at the end of a turn, then mid-turn. Never forced." : "Never asked to compact. The agent can still request it."}</Text>
|
|
232
231
|
</View>
|
|
233
232
|
<Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving !== null} compact={compact} onChange={(next) => void toggleCompact(next)} />
|
|
234
233
|
</View> : <View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
package/client/settings.tsx
CHANGED
|
@@ -12,13 +12,20 @@ import { isPending, type Policy } from "../shared/guard/policy";
|
|
|
12
12
|
import { checkTiming, timingOf, type Timing } from "../shared/guard/timing";
|
|
13
13
|
import { governorNotice } from "../shared/guard/presentation";
|
|
14
14
|
import { getSettings, installStatus, setSettings } from "../shared/smart-session";
|
|
15
|
-
import { withCompactThreshold } from "../shared/thresholds";
|
|
15
|
+
import { DEFAULT_THRESHOLDS, linesProblem, withCompactThreshold } from "../shared/thresholds";
|
|
16
16
|
import { broadcastDisplay } from "./display";
|
|
17
17
|
import { guardSnapshot, refreshGuard, subscribeGuard } from "./guard/status";
|
|
18
18
|
import { Button, errorText } from "./kit";
|
|
19
19
|
import { refreshPills } from "./pill-store";
|
|
20
20
|
|
|
21
21
|
/** A whole number from 1 to 99, or null. */
|
|
22
|
+
/** The four editable lines: the end-of-turn and mid-turn line for each window size. */
|
|
23
|
+
interface Lines<T> { large: T; largeNow: T; small: T; smallNow: T }
|
|
24
|
+
|
|
25
|
+
function stringLines(lines: Lines<number>): Lines<string> {
|
|
26
|
+
return { large: String(lines.large), largeNow: String(lines.largeNow), small: String(lines.small), smallNow: String(lines.smallNow) };
|
|
27
|
+
}
|
|
28
|
+
|
|
22
29
|
function percent(value: string): number | null {
|
|
23
30
|
const parsed = Number(value.trim());
|
|
24
31
|
return Number.isInteger(parsed) && parsed >= 1 && parsed <= 99 ? parsed : null;
|
|
@@ -47,12 +54,12 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
47
54
|
const display = useSettings(displayPreferences);
|
|
48
55
|
const status = useSyncExternalStore(subscribeGuard, guardSnapshot, guardSnapshot);
|
|
49
56
|
const [saving, setSaving] = useState(false);
|
|
50
|
-
const [draft, setDraft] = useState<
|
|
57
|
+
const [draft, setDraft] = useState<Lines<string> | null>(null);
|
|
51
58
|
const [inputKey, setInputKey] = useState(0);
|
|
52
59
|
const [timing, setTiming] = useState<{ draft: Timing; revision: string } | null>(null);
|
|
53
60
|
const [timingTried, setTimingTried] = useState(false);
|
|
54
61
|
const settings = query.data?.settings;
|
|
55
|
-
useEffect(() => { setDraft(null); }, [settings?.thresholds.large.compact, settings?.thresholds.small.compact]);
|
|
62
|
+
useEffect(() => { setDraft(null); }, [settings?.thresholds.large.compact, settings?.thresholds.small.compact, settings?.thresholds.large.now, settings?.thresholds.small.now]);
|
|
56
63
|
|
|
57
64
|
// The pill and sidebar follow these documents; `useSettings` hears every client's change.
|
|
58
65
|
const guardValues = guard.status === "ready" ? guard.values : undefined;
|
|
@@ -93,9 +100,22 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
93
100
|
else toast.show(next ? "Pill shown on every session" : "Pill hidden");
|
|
94
101
|
}
|
|
95
102
|
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
103
|
+
const savedLines: Lines<number> = {
|
|
104
|
+
large: settings.thresholds.large.compact, largeNow: settings.thresholds.large.now,
|
|
105
|
+
small: settings.thresholds.small.compact, smallNow: settings.thresholds.small.now,
|
|
106
|
+
};
|
|
107
|
+
const lines: Lines<number | null> = draft === null ? savedLines
|
|
108
|
+
: { large: percent(draft.large), largeNow: percent(draft.largeNow), small: percent(draft.small), smallNow: percent(draft.smallNow) };
|
|
109
|
+
const { large, largeNow, small, smallNow } = lines;
|
|
110
|
+
// A blank or fractional field is its own error; an upper line not above its lower one is the pair's.
|
|
111
|
+
const fieldProblem = (value: number | null) => (draft !== null && value === null ? "Enter a whole number from 1 to 99." : null);
|
|
112
|
+
const pairProblem = (lower: number | null, upper: number | null) => (draft !== null && lower !== null && upper !== null ? linesProblem(lower, upper) : null);
|
|
113
|
+
const largePair = pairProblem(large, largeNow);
|
|
114
|
+
const smallPair = pairProblem(small, smallNow);
|
|
115
|
+
const valid = large !== null && largeNow !== null && small !== null && smallNow !== null && largePair === null && smallPair === null;
|
|
116
|
+
const changed = draft !== null && valid && (Object.keys(savedLines) as (keyof Lines<number>)[]).some((key) => lines[key] !== savedLines[key]);
|
|
117
|
+
const edit = (key: keyof Lines<string>) => (value: string) =>
|
|
118
|
+
setDraft((previous) => ({ ...(previous ?? stringLines(savedLines)), [key]: value }));
|
|
99
119
|
const off = !settings.enabled || saving;
|
|
100
120
|
const report = install.data?.report;
|
|
101
121
|
const hooksHint = report === undefined ? "Keeps this plugin's context and compaction hooks in ~/.claude/settings.json."
|
|
@@ -127,26 +147,28 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
127
147
|
const pillOn = settings.showPill && (display.status !== "ready" || display.values.enable_composer_button);
|
|
128
148
|
|
|
129
149
|
return <ScrollView style={{ flex: 1 }} contentContainerStyle={{ paddingVertical: 8, gap: 24 }}>
|
|
130
|
-
<SettingsSection title="Smart Compact" info="A session is never compacted unless it agrees.
|
|
150
|
+
<SettingsSection title="Smart Compact" info="A session is never compacted unless it agrees. Past its end-of-turn line it is asked when its turn ends; past its mid-turn line it is asked straight away and told not to start anything new. Either way it can compact, put it off with a reason, or keep working.">
|
|
131
151
|
<SettingsCard>
|
|
132
|
-
<SettingsSwitch label="Smart Compact" hint={settings.enabled ? "On. Context notices, and the question at the
|
|
152
|
+
<SettingsSwitch label="Smart Compact" hint={settings.enabled ? "On. Context notices, and the question at the end-of-turn and mid-turn lines." : "Off. Nothing is asked and nothing is compacted."}
|
|
133
153
|
value={settings.enabled} disabled={saving} onValueChange={(enabled) => void save({ enabled }, enabled ? "Smart Compact on" : "Smart Compact off")} />
|
|
134
154
|
<SettingsSwitch label="Enrol sessions automatically" hint="A session that saves its task state with the checkpoint tool is enrolled by that alone. Off, enrol each one from its pill."
|
|
135
155
|
value={settings.autoEnrol} disabled={off} onValueChange={(autoEnrol) => void save({ autoEnrol }, autoEnrol ? "Sessions enrol themselves" : "Sessions are enrolled one at a time")} />
|
|
136
156
|
</SettingsCard>
|
|
137
157
|
</SettingsSection>
|
|
138
158
|
|
|
139
|
-
<SettingsSection title="When to ask" info="What wears a session down is its absolute size, not its share of the window, so large windows are asked much earlier.
|
|
159
|
+
<SettingsSection title="When to ask" info="Two lines per window size: the end-of-turn line, where a session is asked when its turn ends, and the mid-turn line above it, where it is asked straight away. What wears a session down is its absolute size, not its share of the window, so large windows are asked much earlier.">
|
|
140
160
|
<SettingsCard>
|
|
141
|
-
<SettingsInput key={`large-${inputKey}`} label={
|
|
142
|
-
initialValue={String(
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
161
|
+
<SettingsInput key={`large-${inputKey}`} label={`${from}+ windows · end of turn (%)`} hint={`At ${large ?? "—"}% (about ${large === null ? "—" : formatCompactTokens((large / 100) * 1_000_000)} tokens of a 1M window) a session is asked whether to compact when its turn ends. Default ${DEFAULT_THRESHOLDS.large.compact}.`}
|
|
162
|
+
initialValue={String(savedLines.large)} disabled={off} error={fieldProblem(large)} onChangeText={edit("large")} />
|
|
163
|
+
<SettingsInput key={`largeNow-${inputKey}`} label={`${from}+ windows · mid-turn (%)`} hint={`At ${largeNow ?? "—"}% (about ${largeNow === null ? "—" : formatCompactTokens((largeNow / 100) * 1_000_000)} tokens) it is asked straight away to finish its step and compact. Default ${DEFAULT_THRESHOLDS.large.now}.`}
|
|
164
|
+
initialValue={String(savedLines.largeNow)} disabled={off} error={fieldProblem(largeNow) ?? largePair} onChangeText={edit("largeNow")} />
|
|
165
|
+
<SettingsInput key={`small-${inputKey}`} label="Smaller windows · end of turn (%)" hint={`At ${small ?? "—"}% (about ${small === null ? "—" : formatCompactTokens((small / 100) * 200_000)} tokens of a 200k window) a session is asked when its turn ends. Default ${DEFAULT_THRESHOLDS.small.compact}.`}
|
|
166
|
+
initialValue={String(savedLines.small)} disabled={off} error={fieldProblem(small)} onChangeText={edit("small")} />
|
|
167
|
+
<SettingsInput key={`smallNow-${inputKey}`} label="Smaller windows · mid-turn (%)" hint={`At ${smallNow ?? "—"}% (about ${smallNow === null ? "—" : formatCompactTokens((smallNow / 100) * 200_000)} tokens) it is asked straight away to finish its step and compact. Default ${DEFAULT_THRESHOLDS.small.now}.`}
|
|
168
|
+
initialValue={String(savedLines.smallNow)} disabled={off} error={fieldProblem(smallNow) ?? smallPair} onChangeText={edit("smallNow")} />
|
|
147
169
|
{draft !== null ? <>
|
|
148
|
-
<SettingsAction label="Apply the new lines" hint={changed ? `Large ${large}% · small ${small}%` : "Nothing to apply yet"} actionLabel="Save" disabled={!changed || off}
|
|
149
|
-
onPress={() => void save({ thresholds: { ...settings.thresholds, large: withCompactThreshold(settings.thresholds.large, large!), small: withCompactThreshold(settings.thresholds.small, small!) } }, `Smart Compact asks at ${large}% and ${small}%`)} />
|
|
170
|
+
<SettingsAction label="Apply the new lines" hint={changed ? `Large ${large}% then ${largeNow}% · small ${small}% then ${smallNow}%` : valid ? "Nothing to apply yet" : "Fix the lines above first"} actionLabel="Save" disabled={!changed || off}
|
|
171
|
+
onPress={() => void save({ thresholds: { ...settings.thresholds, large: withCompactThreshold(settings.thresholds.large, large!, largeNow!), small: withCompactThreshold(settings.thresholds.small, small!, smallNow!) } }, `Smart Compact asks at ${large}% / ${largeNow}% and ${small}% / ${smallNow}%`)} />
|
|
150
172
|
<SettingsAction label="Keep the saved lines" actionLabel="Discard" disabled={saving} onPress={() => { setDraft(null); setInputKey((k) => k + 1); }} />
|
|
151
173
|
</> : null}
|
|
152
174
|
</SettingsCard>
|
package/hooks/ask-compact.mjs
CHANGED
|
@@ -19,102 +19,13 @@
|
|
|
19
19
|
* session is done" from "this session is waiting on work still in flight".
|
|
20
20
|
*
|
|
21
21
|
* It stays completely silent unless there is something worth stopping for.
|
|
22
|
-
*/
|
|
23
|
-
|
|
24
|
-
import { readFileSync } from "node:fs";
|
|
25
|
-
import { join } from "node:path";
|
|
26
|
-
|
|
27
|
-
import { isEnrolled, occupancy, readSettings } from "./context.mjs";
|
|
28
|
-
import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
|
|
29
|
-
import { readLedger, recordAsk, shouldAsk } from "./asks.mjs";
|
|
30
|
-
|
|
31
|
-
/**
|
|
32
|
-
* A compaction that had to be repeated within minutes is not solving the problem —
|
|
33
|
-
* something in the loop is refilling the window as fast as it is emptied, and
|
|
34
|
-
* another compaction just burns tokens on summarizing. Claude Code detects the same
|
|
35
|
-
* pathology and calls it thrashing.
|
|
36
|
-
*/
|
|
37
|
-
const THRASH_WINDOW_MS = 15 * 60_000;
|
|
38
|
-
|
|
39
|
-
/** The governor's queue, read directly: a hook must not open a daemon connection. */
|
|
40
|
-
function compactions() {
|
|
41
|
-
try {
|
|
42
|
-
const parsed = JSON.parse(readFileSync(join(pluginDir(), "compactions.json"), "utf8"));
|
|
43
|
-
return Array.isArray(parsed?.items) ? parsed.items : [];
|
|
44
|
-
} catch {
|
|
45
|
-
return [];
|
|
46
|
-
}
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
/** Already asked and waiting to be delivered: there is nothing to add. */
|
|
50
|
-
function alreadyQueued(items, agentId) {
|
|
51
|
-
return items.some(
|
|
52
|
-
(item) => item.agentId === agentId && (item.state === "pending" || item.state === "sending"),
|
|
53
|
-
);
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
function thrashing(items, agentId, now = Date.now()) {
|
|
57
|
-
return items.some(
|
|
58
|
-
(item) =>
|
|
59
|
-
item.agentId === agentId &&
|
|
60
|
-
item.state === "sent" &&
|
|
61
|
-
item.settledAt !== null &&
|
|
62
|
-
now - Date.parse(item.settledAt) < THRASH_WINDOW_MS,
|
|
63
|
-
);
|
|
64
|
-
}
|
|
65
|
-
|
|
66
|
-
/**
|
|
67
|
-
* The question.
|
|
68
|
-
*
|
|
69
|
-
* On a large window the headline is the token count, not the percentage: "30% full"
|
|
70
|
-
* sounds like there is plenty of room, and 300,000 tokens does not.
|
|
71
22
|
*
|
|
72
|
-
*
|
|
73
|
-
*
|
|
74
|
-
* deferral, which the agent is told about in the same breath.
|
|
23
|
+
* This is the lower of Smart Compact's two lines. The upper one asks mid-turn from
|
|
24
|
+
* `context-threshold.mjs`; the text and the guards both use are in `ask.mjs`.
|
|
75
25
|
*/
|
|
76
|
-
function ask({ used, max, pct }, path, age, freshMinutes) {
|
|
77
|
-
const stale = age === null || age > freshMinutes * 60;
|
|
78
|
-
const lines = [
|
|
79
|
-
`Smart Compact: this session holds ${used.toLocaleString()} tokens (${pct}% of a ${max.toLocaleString()}-token window).`,
|
|
80
|
-
max >= 400_000
|
|
81
|
-
? "That is worth compacting now even though the window is nowhere near full — what costs you is the prefix re-read on every turn, not the ceiling."
|
|
82
|
-
: "This is the point where recall degrades noticeably and every turn re-reads an expensive prefix.",
|
|
83
|
-
"Compacting is your call, not this plugin's, and nothing will happen unless you ask. Do one of these before you stop:",
|
|
84
|
-
];
|
|
85
26
|
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
age === null
|
|
89
|
-
? `1. There is no task state on disk yet. Call checkpoint — goal, the step in progress and its exact next action, decisions and why, and every approach already tried and rejected — then call request_compaction. Anything you do not write down is gone after the compaction.`
|
|
90
|
-
: `1. Your task state at ${path} was last written ${Math.round(age / 60)} minutes ago, so it may predate recent work. Call checkpoint to bring it up to date, then call request_compaction.`,
|
|
91
|
-
);
|
|
92
|
-
} else {
|
|
93
|
-
lines.push(
|
|
94
|
-
`1. Your task state at ${path} is current, so this is cheap: call request_compaction with a one-line reason. If work remains, ask it to continue (and optionally choose the follow-up message); if the task is finished, ask it not to continue.`,
|
|
95
|
-
);
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
lines.push(
|
|
99
|
-
"2. Or, if this is the wrong moment — mid-refactor, a tool sequence half finished, an answer the user is waiting on — call defer_compaction with a reason and you will not be asked again for a while.",
|
|
100
|
-
"Either way, do it now and then stop. A queued compaction is delivered as a real /compact once this session goes idle; a follow-up turn is sent only if you requested one.",
|
|
101
|
-
);
|
|
102
|
-
return lines.join("\n");
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
/**
|
|
106
|
-
* What to say instead, when compacting is not the answer.
|
|
107
|
-
*
|
|
108
|
-
* Said once, as an ask, so it counts against the same latch: repeating it every
|
|
109
|
-
* turn would be the nagging this hook exists to avoid.
|
|
110
|
-
*/
|
|
111
|
-
function thrashAdvice({ used, pct }) {
|
|
112
|
-
return [
|
|
113
|
-
`Smart Compact: this session refilled to ${used.toLocaleString()} tokens (${pct}%) within minutes of its last compaction.`,
|
|
114
|
-
"Compacting again would not help — something in this loop is reading far more than it keeps, so the window would refill just as fast and you would pay for another summary.",
|
|
115
|
-
"Write what you have learned to your state file with checkpoint, then either change the approach so it stops re-reading the same material, or finish and let a fresh session take the rest.",
|
|
116
|
-
].join("\n");
|
|
117
|
-
}
|
|
27
|
+
import { askable, compose } from "./ask.mjs";
|
|
28
|
+
import { recordAsk, shouldAsk } from "./asks.mjs";
|
|
118
29
|
|
|
119
30
|
function emit(text) {
|
|
120
31
|
process.stdout.write(
|
|
@@ -152,35 +63,18 @@ async function main() {
|
|
|
152
63
|
// Compacting through the second one discards the context that work reports into.
|
|
153
64
|
if (Array.isArray(event.background_tasks) && event.background_tasks.length > 0) return;
|
|
154
65
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
const agentId = process.env.PASEO_AGENT_ID ?? null;
|
|
163
|
-
if (!isEnrolled(agentId, settings)) return;
|
|
164
|
-
|
|
165
|
-
const now = occupancy(transcriptPath, settings);
|
|
166
|
-
if (now === null || now.pct < now.profile.compact) return;
|
|
167
|
-
|
|
168
|
-
const ledger = readLedger(sessionId);
|
|
169
|
-
if (!shouldAsk(ledger, now.pct)) return;
|
|
170
|
-
|
|
171
|
-
const queue = compactions();
|
|
172
|
-
if (alreadyQueued(queue, agentId)) return;
|
|
173
|
-
|
|
174
|
-
const path = statePath();
|
|
175
|
-
const age = stateAgeSeconds(path);
|
|
176
|
-
const text = thrashing(queue, agentId)
|
|
177
|
-
? thrashAdvice(now)
|
|
178
|
-
: ask(now, path, age, settings.freshStateMinutes);
|
|
66
|
+
// The lower line, "when convenient". Past the upper one the agent was already
|
|
67
|
+
// asked mid-turn by `context-threshold.mjs`; this is the turn-end follow-up, under
|
|
68
|
+
// this line's own back-off, unless that ask was answered.
|
|
69
|
+
const context = askable(event);
|
|
70
|
+
if (context === null) return;
|
|
71
|
+
const decision = compose(context, "compact", shouldAsk);
|
|
72
|
+
if (decision === null) return;
|
|
179
73
|
|
|
180
74
|
// Recorded only after it has reached stdout: a latch set on an ask that was never
|
|
181
75
|
// delivered would silence the next one for nothing.
|
|
182
|
-
emit(text);
|
|
183
|
-
recordAsk(sessionId, ledger,
|
|
76
|
+
emit(decision.text);
|
|
77
|
+
recordAsk(context.sessionId, decision.ledger, context.reading.pct);
|
|
184
78
|
}
|
|
185
79
|
|
|
186
80
|
void main();
|