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 +6 -4
- package/client/charts.tsx +7 -6
- package/client/overview.tsx +13 -8
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +10 -11
- package/client/settings.tsx +39 -17
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +193 -0
- package/hooks/asks.mjs +53 -6
- package/hooks/context-threshold.mjs +31 -10
- package/hooks/context.mjs +18 -4
- package/index.server.ts +2 -0
- package/mcp.mjs +11 -1
- package/package.json +1 -1
- package/server/growth.ts +2 -1
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/smart-session.ts +6 -0
- package/shared/thresholds.ts +41 -6
package/README.md
CHANGED
|
@@ -31,7 +31,7 @@ On first load it registers its Claude Code hooks, touching only entries that poi
|
|
|
31
31
|
- **Smart Compact and Pause at the limit.** One switch each, what they will do next, and the sessions you changed from your default.
|
|
32
32
|
- **Tokens, last 30 days.** Billable tokens, the cache hit rate and the subagent share, per day, by hour of the week, by workspace and by model, reconstructed from Claude Code's transcripts.
|
|
33
33
|
|
|
34
|
-
Each session has one pill above its message box: a ring of its context, with a tick at its compact
|
|
34
|
+
Each session has one pill above its message box: a ring of its context, with a tick at each of its two compact lines. Press it to see the detail, when it last saved its task state and compacted, and to switch Smart Compact and Pause at the limit for that session. When the session is paused at the limit the same pill turns amber and says when it resumes, and it stays up even if you hide the pill.
|
|
35
35
|
|
|
36
36
|

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