paseo-smart-session 3.4.0 → 3.5.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
@@ -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();
package/hooks/ask.mjs ADDED
@@ -0,0 +1,193 @@
1
+ /**
2
+ * Smart Compact's question, shared by the two hooks that put it.
3
+ *
4
+ * There are two lines per window. Past the lower one, `ask-compact.mjs` on `Stop`
5
+ * asks at the end of a turn, when it is convenient. Past the upper one,
6
+ * `context-threshold.mjs` on `PostToolUse` asks right away, mid-turn, because a
7
+ * session that far along should not start anything new before compacting. Both
8
+ * read the same settings, the same enrolment, the same queue and the same ledger,
9
+ * and the guards live here so the two can never disagree about whether to speak.
10
+ *
11
+ * Either way the plugin only asks. The agent answers with its own tool call, and
12
+ * a queued compaction is still delivered once the session goes idle.
13
+ */
14
+
15
+ import { readFileSync } from "node:fs";
16
+ import { join } from "node:path";
17
+
18
+ import { isEnrolled, occupancy, readSettings } from "./context.mjs";
19
+ import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
20
+ import { readLedger } from "./asks.mjs";
21
+
22
+ /**
23
+ * A compaction that had to be repeated within minutes is not solving the problem —
24
+ * something in the loop is refilling the window as fast as it is emptied, and
25
+ * another compaction just burns tokens on summarizing. Claude Code detects the same
26
+ * pathology and calls it thrashing.
27
+ */
28
+ export const THRASH_WINDOW_MS = 15 * 60_000;
29
+
30
+ /** The governor's queue, read directly: a hook must not open a daemon connection. */
31
+ export function compactions() {
32
+ try {
33
+ const parsed = JSON.parse(readFileSync(join(pluginDir(), "compactions.json"), "utf8"));
34
+ return Array.isArray(parsed?.items) ? parsed.items : [];
35
+ } catch {
36
+ return [];
37
+ }
38
+ }
39
+
40
+ /** Already asked and waiting to be delivered: there is nothing to add. */
41
+ export function alreadyQueued(items, agentId) {
42
+ return items.some(
43
+ (item) => item.agentId === agentId && (item.state === "pending" || item.state === "sending"),
44
+ );
45
+ }
46
+
47
+ export function thrashing(items, agentId, now = Date.now()) {
48
+ return items.some(
49
+ (item) =>
50
+ item.agentId === agentId &&
51
+ item.state === "sent" &&
52
+ item.settledAt !== null &&
53
+ now - Date.parse(item.settledAt) < THRASH_WINDOW_MS,
54
+ );
55
+ }
56
+
57
+ function headline({ used, max, pct }) {
58
+ return `this session holds ${used.toLocaleString()} tokens (${pct}% of a ${max.toLocaleString()}-token window)`;
59
+ }
60
+
61
+ function why(max) {
62
+ return max >= 400_000
63
+ ? "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."
64
+ : "This is the point where recall degrades noticeably and every turn re-reads an expensive prefix.";
65
+ }
66
+
67
+ /** The checkpoint half of either ask, which depends on how old the state file is. */
68
+ function checkpointStep(path, age, freshMinutes, then) {
69
+ if (age === null) {
70
+ return `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 ${then} Anything you do not write down is gone after the compaction.`;
71
+ }
72
+ if (age > freshMinutes * 60) {
73
+ return `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 ${then}`;
74
+ }
75
+ return null;
76
+ }
77
+
78
+ /**
79
+ * The end-of-turn question, at the lower line.
80
+ *
81
+ * On a large window the headline is the token count, not the percentage: "30% full"
82
+ * sounds like there is plenty of room, and 300,000 tokens does not.
83
+ *
84
+ * It has to be answerable in one turn, so it names the tools and says what to do
85
+ * with each, and it has to be refusable, or it is not a question — hence the
86
+ * deferral, which the agent is told about in the same breath.
87
+ */
88
+ export function askAtTurnEnd(reading, path, age, freshMinutes) {
89
+ const lines = [
90
+ `Smart Compact: ${headline(reading)}.`,
91
+ why(reading.max),
92
+ "Compacting is your call, not this plugin's, and nothing will happen unless you ask. Do one of these before you stop:",
93
+ ];
94
+ const stale = checkpointStep(path, age, freshMinutes, "call request_compaction.");
95
+ lines.push(
96
+ stale !== null
97
+ ? `1. ${stale}`
98
+ : `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.`,
99
+ );
100
+ lines.push(
101
+ "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.",
102
+ "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.",
103
+ );
104
+ return lines.join("\n");
105
+ }
106
+
107
+ /**
108
+ * The mid-turn question, at the upper line.
109
+ *
110
+ * It arrives after a tool call, in the middle of the agent's work, so it says what
111
+ * to finish (only the step already under way) and what not to start. The compaction
112
+ * still lands only once the session is idle, which is why it ends with "then end
113
+ * your turn": without that the agent would queue it and carry on for an hour.
114
+ */
115
+ export function askRightAway(reading, upper, path, age, freshMinutes) {
116
+ const lines = [
117
+ `Smart Compact: ${headline(reading)}, past its upper line of ${upper}%.`,
118
+ `This is the line where you should not push on. ${why(reading.max)}`,
119
+ "Finish only the atomic step you are on — the edit half applied, the command already running — and do not start a new step, read a large file or begin another tool sequence. Then:",
120
+ ];
121
+ const stale = checkpointStep(path, age, freshMinutes, "go on to step 2.");
122
+ lines.push(stale !== null ? `1. ${stale}` : `1. Your task state at ${path} is current, so go straight to step 2.`);
123
+ lines.push(
124
+ "2. Call request_compaction with continue_after_compaction set to true and a one-line reason. If you know the next action, put it in continue_message so the follow-up names it.",
125
+ "3. End your turn. The compaction is delivered as a real /compact once this session goes idle, so it cannot land while you keep working.",
126
+ "If stopping now would leave something broken that the next turn could not pick up, call defer_compaction with that reason instead — you will be asked again when it runs out. Do not defer just to keep going.",
127
+ );
128
+ return lines.join("\n");
129
+ }
130
+
131
+ /**
132
+ * What to say instead, when compacting is not the answer.
133
+ *
134
+ * Said once, as an ask, so it counts against the same latch: repeating it every
135
+ * turn would be the nagging these hooks exist to avoid.
136
+ */
137
+ export function thrashAdvice({ used, pct }) {
138
+ return [
139
+ `Smart Compact: this session refilled to ${used.toLocaleString()} tokens (${pct}%) within minutes of its last compaction.`,
140
+ "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.",
141
+ "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.",
142
+ ].join("\n");
143
+ }
144
+
145
+ /**
146
+ * Whether there is anyone to ask, and how full they are.
147
+ *
148
+ * The guards both hooks share: a subagent is never asked (its window is its own),
149
+ * Smart Compact must be on, the session must be enrolled, and the transcript must
150
+ * have a reading. Returns null whenever the answer is "say nothing".
151
+ */
152
+ export function askable(event) {
153
+ if (event.agent_id !== undefined && event.agent_id !== null) return null;
154
+ const transcriptPath = event.transcript_path;
155
+ const sessionId = event.session_id;
156
+ if (typeof transcriptPath !== "string" || typeof sessionId !== "string") return null;
157
+
158
+ const settings = readSettings();
159
+ if (!settings.enabled) return null;
160
+
161
+ const agentId = process.env.PASEO_AGENT_ID ?? null;
162
+ if (!isEnrolled(agentId, settings)) return null;
163
+
164
+ const reading = occupancy(transcriptPath, settings);
165
+ if (reading === null) return null;
166
+ return { sessionId, agentId, settings, reading };
167
+ }
168
+
169
+ /**
170
+ * The text to put to the agent at one of the two lines, or null for silence.
171
+ *
172
+ * `line` is "compact" (end of turn) or "now" (right away). `allowed` is that
173
+ * line's own back-off rule over the ledger. The queue is read last because it is
174
+ * the most expensive check and the others usually decide first.
175
+ */
176
+ export function compose(context, line, allowed) {
177
+ const { sessionId, agentId, settings, reading } = context;
178
+ if (reading.pct < reading.profile[line]) return null;
179
+
180
+ const ledger = readLedger(sessionId);
181
+ if (!allowed(ledger, reading.pct)) return null;
182
+
183
+ const queue = compactions();
184
+ if (alreadyQueued(queue, agentId)) return null;
185
+
186
+ const path = statePath();
187
+ const age = stateAgeSeconds(path);
188
+ let text;
189
+ if (thrashing(queue, agentId)) text = thrashAdvice(reading);
190
+ else if (line === "now") text = askRightAway(reading, reading.profile.now, path, age, settings.freshStateMinutes);
191
+ else text = askAtTurnEnd(reading, path, age, settings.freshStateMinutes);
192
+ return { text, ledger };
193
+ }
package/hooks/asks.mjs CHANGED
@@ -28,7 +28,22 @@ export const MAX_ASKS = 3;
28
28
  */
29
29
  export const RE_ASK_GROWTH_PCT = 10;
30
30
 
31
- const EMPTY = { askCount: 0, lastAskedAt: null, askedAtPct: null, deferredUntil: null, deferReason: null };
31
+ /**
32
+ * `now*` is the upper line's own record. It is kept apart from the end-of-turn ask
33
+ * so that having been asked politely at the lower line never silences the one ask
34
+ * that says "stop pushing on", and `deferredAt` survives an answered ask so the
35
+ * upper line can tell "deferred after I asked" from "never answered".
36
+ */
37
+ const EMPTY = {
38
+ askCount: 0,
39
+ lastAskedAt: null,
40
+ askedAtPct: null,
41
+ deferredUntil: null,
42
+ deferReason: null,
43
+ deferredAt: null,
44
+ nowAskCount: 0,
45
+ nowAskedAt: null,
46
+ };
32
47
 
33
48
  /**
34
49
  * Keyed by Paseo agent id where there is one, because that survives compaction
@@ -84,7 +99,7 @@ export function resetLedger(sessionId) {
84
99
  export function defer(sessionId, minutes, reason) {
85
100
  const ledger = readLedger(sessionId);
86
101
  const until = new Date(Date.now() + minutes * 60_000).toISOString();
87
- writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason });
102
+ writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason, deferredAt: new Date().toISOString() });
88
103
  return until;
89
104
  }
90
105
 
@@ -93,11 +108,14 @@ export function defer(sessionId, minutes, reason) {
93
108
  *
94
109
  * `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
95
110
  */
111
+ function deferred(ledger, now) {
112
+ if (ledger.deferredUntil === null) return false;
113
+ const until = Date.parse(ledger.deferredUntil);
114
+ return Number.isFinite(until) && now < until;
115
+ }
116
+
96
117
  export function shouldAsk(ledger, pct, now = Date.now()) {
97
- if (ledger.deferredUntil !== null) {
98
- const until = Date.parse(ledger.deferredUntil);
99
- if (Number.isFinite(until) && now < until) return false;
100
- }
118
+ if (deferred(ledger, now)) return false;
101
119
  if (ledger.askCount >= MAX_ASKS) return false;
102
120
  if (ledger.askedAtPct === null) return true;
103
121
  return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
@@ -115,3 +133,32 @@ export function recordAsk(sessionId, ledger, pct) {
115
133
  deferReason: null,
116
134
  });
117
135
  }
136
+
137
+ /**
138
+ * Whether to ask right away, mid-turn, at the upper line.
139
+ *
140
+ * Once per compaction epoch. It comes back only if the agent answered the last one
141
+ * with a deferral and that deferral has run out — ignoring it earns no repeat, so a
142
+ * long turn hears it once rather than after every tool call — and never more than
143
+ * `MAX_ASKS` times in all. Any live deferral, from either ask, holds it off: the
144
+ * agent's "not now" is one answer, not one per line.
145
+ */
146
+ export function shouldAskNow(ledger, now = Date.now()) {
147
+ if (deferred(ledger, now)) return false;
148
+ if (ledger.nowAskCount === 0 || ledger.nowAskedAt === null) return true;
149
+ if (ledger.nowAskCount >= MAX_ASKS) return false;
150
+ const asked = Date.parse(ledger.nowAskedAt);
151
+ const answered = ledger.deferredAt === null ? Number.NaN : Date.parse(ledger.deferredAt);
152
+ return Number.isFinite(asked) && Number.isFinite(answered) && answered >= asked;
153
+ }
154
+
155
+ /** Records that the upper line's question was put to the model. */
156
+ export function recordNowAsk(sessionId, ledger) {
157
+ writeLedger(sessionId, {
158
+ ...ledger,
159
+ nowAskCount: ledger.nowAskCount + 1,
160
+ nowAskedAt: new Date().toISOString(),
161
+ deferredUntil: null,
162
+ deferReason: null,
163
+ });
164
+ }
@@ -8,8 +8,11 @@
8
8
  * short line the moment context crosses each band. Each level fires once per
9
9
  * compaction epoch, so a long session gets three sentences, not three hundred.
10
10
  *
11
- * It describes; it never asks for anything. Deciding that a session should compact
12
- * belongs at a turn boundary, and that is `ask-compact.mjs` on `Stop`.
11
+ * Below Smart Compact's upper line it only describes; the end-of-turn question at
12
+ * the lower line is `ask-compact.mjs` on `Stop`. Past the upper line it asks right
13
+ * away, because that is the line where the agent should not start anything new —
14
+ * once per compaction epoch, again only after a deferral runs out, and through the
15
+ * same guards as the `Stop` hook (`ask.mjs`).
13
16
  *
14
17
  * It reads the transcript rather than asking Paseo, so it works in any Claude Code
15
18
  * session, and it reads only the tail of it, because this runs on the hot path.
@@ -18,6 +21,8 @@
18
21
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
22
  import { dirname, join } from "node:path";
20
23
 
24
+ import { askable, compose } from "./ask.mjs";
25
+ import { recordNowAsk, shouldAskNow } from "./asks.mjs";
21
26
  import { BANDS, DEFAULT_THRESHOLDS, occupancy, readSettings } from "./context.mjs";
22
27
  import { clearPending, pluginDir, readPending, statePath } from "./pointer.mjs";
23
28
 
@@ -117,20 +122,36 @@ async function main() {
117
122
 
118
123
  const reached = BANDS.filter((band) => now.pct >= now.profile[band]);
119
124
  const crossed = reached.filter((band) => !latch.fired.includes(band));
120
- if (crossed.length === 0) {
121
- writeLatch(latchPath, latch);
122
- return emit(sessionId, parts, pending);
123
- }
124
-
125
- // A session that jumped two bands in one turn hears the more urgent one.
126
- const band = crossed[crossed.length - 1];
127
125
  latch.fired = [...new Set([...latch.fired, ...crossed])];
128
126
  writeLatch(latchPath, latch);
129
127
 
130
- parts.push(advice(band, now.pct, now.used, now.max, statePath()));
128
+ // Past the upper line, the question replaces the description: it says everything
129
+ // the band line would, and two messages about the same moment read as two.
130
+ const asked = now.pct >= now.profile.now ? askNow(event) : null;
131
+ if (asked !== null) {
132
+ parts.push(asked.text);
133
+ emit(sessionId, parts, pending);
134
+ // Recorded only once it has reached stdout, like the `Stop` hook's ask.
135
+ recordNowAsk(sessionId, asked.ledger);
136
+ return;
137
+ }
138
+
139
+ // A session that jumped two bands in one turn hears the more urgent one.
140
+ if (crossed.length > 0) parts.push(advice(crossed[crossed.length - 1], now.pct, now.used, now.max, statePath()));
131
141
  return emit(sessionId, parts, pending);
132
142
  }
133
143
 
144
+ /** The upper line's question, or null when one of the shared guards says no. */
145
+ function askNow(event) {
146
+ try {
147
+ const context = askable(event);
148
+ if (context === null) return null;
149
+ return compose(context, "now", (ledger) => shouldAskNow(ledger));
150
+ } catch {
151
+ return null; // The ask is never worth failing the tool call it rides on.
152
+ }
153
+ }
154
+
134
155
  /**
135
156
  * Writes whatever there is to say, and only then forgets the pending pointer:
136
157
  * clearing it before it reaches stdout would lose it on any later failure.
package/hooks/context.mjs CHANGED
@@ -29,8 +29,8 @@ const TAIL_BYTES = 256 * 1024;
29
29
  */
30
30
  export const DEFAULT_THRESHOLDS = {
31
31
  largeWindowFrom: 400_000,
32
- large: { notice: 15, closing: 22, compact: 30 },
33
- small: { notice: 60, closing: 75, compact: 85 },
32
+ large: { notice: 15, closing: 22, compact: 30, now: 40 },
33
+ small: { notice: 60, closing: 75, compact: 85, now: 90 },
34
34
  };
35
35
 
36
36
  export const BANDS = ["notice", "closing", "compact"];
@@ -60,8 +60,8 @@ export function readSettings() {
60
60
  : DEFAULT_SETTINGS.freshStateMinutes,
61
61
  thresholds: {
62
62
  largeWindowFrom: merged.largeWindowFrom ?? DEFAULT_THRESHOLDS.largeWindowFrom,
63
- large: { ...DEFAULT_THRESHOLDS.large, ...(merged.large ?? {}) },
64
- small: { ...DEFAULT_THRESHOLDS.small, ...(merged.small ?? {}) },
63
+ large: withUpperLine(merged.large, DEFAULT_THRESHOLDS.large),
64
+ small: withUpperLine(merged.small, DEFAULT_THRESHOLDS.small),
65
65
  },
66
66
  };
67
67
  } catch {
@@ -69,6 +69,20 @@ export function readSettings() {
69
69
  }
70
70
  }
71
71
 
72
+ /**
73
+ * A saved profile, with the upper line filled in when it predates one.
74
+ *
75
+ * Mirrors `normalizeProfile` in thresholds.ts: the saved lower line is kept, and an
76
+ * absent upper line keeps the default's distance above it, never falling below it.
77
+ */
78
+ export function withUpperLine(saved, fallback) {
79
+ const profile = { ...fallback, ...(saved ?? {}) };
80
+ const compact = profile.compact;
81
+ const migrated = Math.min(99, Math.max(fallback.now, compact + (fallback.now - fallback.compact)));
82
+ const raw = typeof saved?.now === "number" && Number.isFinite(saved.now) ? saved.now : migrated;
83
+ return { ...profile, now: Math.min(99, Math.max(Math.min(99, compact + 1), raw)) };
84
+ }
85
+
72
86
  export function profileFor(maxTokens, thresholds) {
73
87
  return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
74
88
  }
package/index.server.ts CHANGED
@@ -141,6 +141,8 @@ export default function contribute(server: PluginServerContext) {
141
141
  growthTokensPerHour: growth.get(row.id) ?? null,
142
142
  projectedFullAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds),
143
143
  compactAtPct: profileFor(row.maxTokens!, thresholds).compact,
144
+ compactNowAtPct: profileFor(row.maxTokens!, thresholds).now,
145
+ projectedNowAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds, "now"),
144
146
  stateWrittenAt: stateWrittenAt(row.id),
145
147
  }))
146
148
  .sort((a, b) => b.usedPct - a.usedPct);
package/mcp.mjs CHANGED
@@ -338,7 +338,17 @@ const TOOLS = {
338
338
  if (mine.growthTokensPerHour !== null) {
339
339
  lines.push(`Filling at roughly ${Math.round(mine.growthTokensPerHour).toLocaleString()} tokens/hour.`);
340
340
  }
341
- if (mine.usedPct >= mine.compactAtPct) {
341
+ const upper = typeof mine.compactNowAtPct === "number" ? mine.compactNowAtPct : null;
342
+ if (upper !== null) {
343
+ lines.push(
344
+ `Its upper line is ${upper}% (${Math.round((upper / 100) * mine.maxTokens).toLocaleString()} tokens): past that, Smart Compact asks right away, mid-turn, and you should not start new work before compacting.`,
345
+ );
346
+ }
347
+ if (upper !== null && mine.usedPct >= upper) {
348
+ lines.push(
349
+ "You are past the upper line. Finish only the step you are on, call checkpoint, call request_compaction with continue_after_compaction true, and end your turn.",
350
+ );
351
+ } else if (mine.usedPct >= mine.compactAtPct) {
342
352
  // Already past it: a projection of when it will arrive reads as nonsense.
343
353
  lines.push(
344
354
  "You are already past that point. Check your task state is on disk and current, then compact at the next clean boundary — call request_compaction when the step you are on is finished.",
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "paseo-smart-session",
3
3
  "type": "module",
4
- "version": "3.4.0",
4
+ "version": "3.5.0",
5
5
  "license": "MIT",
6
6
  "description": "Records Claude plan-usage history and lets a Paseo agent manage its own context.",
7
7
  "repository": {
package/server/growth.ts CHANGED
@@ -59,9 +59,10 @@ export function projectFull(
59
59
  maxTokens: number,
60
60
  ratePerHour: number | null,
61
61
  thresholds?: Thresholds,
62
+ line: "compact" | "now" = "compact",
62
63
  ): string | null {
63
64
  if (ratePerHour === null || ratePerHour <= 0) return null;
64
- const room = maxTokens * (profileFor(maxTokens, thresholds).compact / 100) - usedTokens;
65
+ const room = maxTokens * (profileFor(maxTokens, thresholds)[line] / 100) - usedTokens;
65
66
  if (room <= 0) return new Date().toISOString();
66
67
  return new Date(Date.now() + (room / ratePerHour) * 3_600_000).toISOString();
67
68
  }
@@ -0,0 +1,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
  );