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 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. Paseo attaches its MCP server when an interactive session opens.
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 line. 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.
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
  ![Pressing the context ring above the message box, then following it to the Smart Session screen and sweeping the window chart: recorded usage up to now, the projection at this pace, and the stretch after it runs out](docs/media/smart-session-flow.gif)
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. At the compact line, the agent is asked at the end of its turn whether it wants to compact. 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.
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 | Windows of 400k or more | 30% | Where large windows are asked to compact |
101
- | | Smaller windows | 85% | Where smaller windows are asked to compact |
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 an optional
40
- * mark (the compact line on a context bar).
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. */
@@ -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 · `}<Text style={{ color: c.statusWarning }}>{`${pastLine} past ${pastLine === 1 ? "its" : "their"} compact line, asked at the end of a turn`}</Text></>
222
- : `${enrolled.size} enrolled · asked past ${settings.thresholds.large.compact}% of a ${formatCompactTokens(settings.thresholds.largeWindowFrom)}+ window, ${settings.thresholds.small.compact}% of a smaller one`;
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 parts = [`${formatCompactTokens(agent.usedTokens)} of ${formatCompactTokens(agent.maxTokens)}`, `compacts at ${agent.compactAtPct}%`];
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.past ? (on ? "Past its compact line · asked at the end of a turn" : "Past its compact line · not enrolled")
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, compacts at ${agent.compactAtPct}%`} />
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}
@@ -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 compactAtPct = profileFor(agent.maxTokens!, thresholds ?? undefined).compact;
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 ?? `Smart Compact asks at ${compactAtPct}%`}`,
30
- fraction: outlook.pct / 100, mark: compactAtPct / 100, tone: outlook.tone, pct,
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 the compact line, the label is the percentage. Protection adds
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
 
@@ -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 compactAtPct = row?.compactAtPct ?? profileFor(max, pills.thresholds ?? undefined).compact;
103
- return { compactAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
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.past
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 · compacts at ${head.compactAtPct}% (${formatCompactTokens(head.outlook.compactTokens)})`}</Text>
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, compacts at ${head.compactAtPct}%`} />}
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 ? `Asked once past ${head?.compactAtPct ?? "its"}%${head === null ? " line" : ""}. Never forced.` : "Never asked to compact. The agent can still request it."}</Text>
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 }}>
@@ -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<{ large: string; small: string } | null>(null);
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 large = draft === null ? settings.thresholds.large.compact : percent(draft.large);
97
- const small = draft === null ? settings.thresholds.small.compact : percent(draft.small);
98
- const changed = draft !== null && large !== null && small !== null && (large !== settings.thresholds.large.compact || small !== settings.thresholds.small.compact);
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. At the end of a turn past its line, it is asked whether to compact; it can compact, put it off with a reason, or keep working.">
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 compact line." : "Off. Nothing is asked and nothing is compacted."}
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. Earlier context notices move down with the line.">
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={`Windows of ${from} or more (%)`} hint={`Asked at ${large ?? "—"}%, about ${large === null ? "—" : formatCompactTokens((large / 100) * 1_000_000)} tokens of a 1M window. Default 30.`}
142
- initialValue={String(settings.thresholds.large.compact)} disabled={off} error={draft !== null && large === null ? "Enter a whole number from 1 to 99." : null}
143
- onChangeText={(value) => setDraft((previous) => ({ large: value, small: previous?.small ?? String(settings.thresholds.small.compact) }))} />
144
- <SettingsInput key={`small-${inputKey}`} label="Smaller windows (%)" hint={`Asked at ${small ?? "—"}% of a window under ${from}. Default 85.`}
145
- initialValue={String(settings.thresholds.small.compact)} disabled={off} error={draft !== null && small === null ? "Enter a whole number from 1 to 99." : null}
146
- onChangeText={(value) => setDraft((previous) => ({ small: value, large: previous?.large ?? String(settings.thresholds.large.compact) }))} />
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>
@@ -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
- * It has to be answerable in one turn, so it names the tools and says what to do
73
- * with each, and it has to be refusable, or it is not a question — hence the
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
- if (stale) {
87
- lines.push(
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
- const transcriptPath = event.transcript_path;
156
- const sessionId = event.session_id;
157
- if (typeof transcriptPath !== "string" || typeof sessionId !== "string") return;
158
-
159
- const settings = readSettings();
160
- if (!settings.enabled) return;
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, now.pct);
76
+ emit(decision.text);
77
+ recordAsk(context.sessionId, decision.ledger, context.reading.pct);
184
78
  }
185
79
 
186
80
  void main();