paseo-smart-session 3.3.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 +29 -11
- package/client/charts.tsx +7 -6
- package/client/display.tsx +2 -2
- package/client/kit.tsx +2 -2
- package/client/live.ts +8 -1
- package/client/overview.tsx +72 -26
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +35 -17
- package/client/settings.tsx +42 -20
- package/client/subscriptions-view.tsx +201 -0
- package/client/subscriptions.ts +68 -0
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/media/smart-session-subscriptions-light.png +0 -0
- package/docs/media/smart-session-subscriptions.png +0 -0
- package/docs/session-guard.md +2 -2
- 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.client.tsx +4 -0
- package/index.server.ts +15 -0
- package/mcp.mjs +33 -1
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/growth.ts +2 -1
- package/server/recorder.ts +12 -0
- package/server/store.ts +24 -1
- package/server/subscriptions.ts +208 -0
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/guard/zerosub.ts +18 -3
- package/shared/smart-session.ts +72 -0
- package/shared/subscriptions.ts +540 -0
- package/shared/thresholds.ts +41 -6
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,32 +147,34 @@ 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>
|
|
153
175
|
</SettingsSection>
|
|
154
176
|
|
|
155
|
-
<SettingsSection title="Pause at the limit" info="When the provider's session window is spent, a running session is paused instead of failing, then continued in the same conversation once the window resets.
|
|
177
|
+
<SettingsSection title="Pause at the limit (Session Guard)" info="When the provider's session window is spent, a running session is paused instead of failing, then continued in the same conversation once the window resets. Choices and waiting pauses from the standalone Session Guard plugin carry over.">
|
|
156
178
|
{guard.status === "loading" ? <ActivityIndicator accessibilityLabel="Loading Pause at the limit settings" color={c.foregroundMuted} style={{ padding: 16 }} />
|
|
157
179
|
: guard.status !== "ready" ? <View style={{ gap: 8 }}>
|
|
158
180
|
<Text accessibilityRole="alert" style={{ color: c.statusDanger }}>{guard.error}</Text>
|
|
@@ -163,7 +185,7 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
163
185
|
</View>
|
|
164
186
|
: <SettingsCard>
|
|
165
187
|
{elsewhere ? <SettingsRow label={elsewhere.title} hint={elsewhere.hint} /> : null}
|
|
166
|
-
<SettingsSwitch label="Pause at the limit" hint={guard.values.enabled ? `On. Sessions pause at ${guard.values.thresholdPct}% of the session window. Works while the app is closed.` : "Off until you turn it on. Turning it off lets frozen workers go; waiting continuations hold until you turn it back on or cancel them."}
|
|
188
|
+
<SettingsSwitch label="Pause at the limit (Session Guard)" hint={guard.values.enabled ? `On. Sessions pause at ${guard.values.thresholdPct}% of the session window. Works while the app is closed.` : "Off until you turn it on. Turning it off lets frozen workers go; waiting continuations hold until you turn it back on or cancel them."}
|
|
167
189
|
value={guard.values.enabled} disabled={guardBusy} onValueChange={(enabled) => changeGuard({ enabled }, enabled ? "Sessions pause at the limit" : "Pause at the limit off")} />
|
|
168
190
|
<SettingsSelect label="Sessions" hint="Change any one session from its pill."
|
|
169
191
|
value={guard.values.protectByDefault ? "all" : "opt-in"} options={[{ label: "Every session", value: "all" }, { label: "Only ones I turn on", value: "opt-in" }]}
|
|
@@ -188,7 +210,7 @@ export function makeSettings(client: PluginClientContext) {
|
|
|
188
210
|
</SettingsCard>
|
|
189
211
|
</View>
|
|
190
212
|
: <SettingsCard>
|
|
191
|
-
<SettingsSwitch label="Sidebar row" hint="Smart Session in the sidebar, with the session window's use, or how many sessions are paused."
|
|
213
|
+
<SettingsSwitch label="Sidebar row" hint="Smart Session in the sidebar, with the session window's use (with ZeroSub, how your subscriptions last), or how many sessions are paused."
|
|
192
214
|
value={display.values.showSidebar} disabled={display.saving} onValueChange={(showSidebar) => void display.save({ ...display.values, showSidebar }, display.revision)} />
|
|
193
215
|
<SettingsSwitch label="Pill on every session" hint="The context ring above each message box. A paused session keeps its pill either way."
|
|
194
216
|
value={pillOn} disabled={saving || display.saving} onValueChange={(next) => void setPill(next)} />
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
import { Icon } from "@getpaseo/plugin/client/ui";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
|
|
4
|
+
import { formatDuration } from "../shared/format";
|
|
5
|
+
import { forecastWindow, formatMoment, paceLine } from "../shared/forecast";
|
|
6
|
+
import {
|
|
7
|
+
accountOutlook, familyName, limitedWindow, poolHeadline, sessionWindow, subscriptionState, weeklyWindow,
|
|
8
|
+
type LaneKind, type PoolForecast, type Subscription, type SubscriptionWindow,
|
|
9
|
+
} from "../shared/subscriptions";
|
|
10
|
+
import { Meter, ink } from "./charts";
|
|
11
|
+
import { Card, CardRow, Chip, SectionHead, text } from "./kit";
|
|
12
|
+
import { tabular, toneColor, withAlpha, type Theme } from "./theme";
|
|
13
|
+
|
|
14
|
+
const HOUR = 3_600_000;
|
|
15
|
+
|
|
16
|
+
/** How a lane run is drawn: magnitude is ink, the limit is red, out is a hairline. */
|
|
17
|
+
function laneFill(theme: Theme, kind: LaneKind): string {
|
|
18
|
+
const c = theme.colors;
|
|
19
|
+
if (kind === "active") return ink(c, 0.82);
|
|
20
|
+
if (kind === "room") return ink(c, 0.16);
|
|
21
|
+
if (kind === "limit") return withAlpha(c.statusDanger, 0.42);
|
|
22
|
+
return "transparent";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const LANE_WORDS: Record<LaneKind, string> = { active: "In use", room: "Room", limit: "At the limit", out: "Set aside or signed out" };
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* The runway: one lane per account across the next hours, as the pooled forecast
|
|
29
|
+
* runs the work through them: in use, waiting with room, at the limit. Reading down
|
|
30
|
+
* a column says who carries the work then; reading along a lane says when an
|
|
31
|
+
* account fills and when it is back.
|
|
32
|
+
*/
|
|
33
|
+
function Runway({ theme, pool, accounts, now, compact }: { theme: Theme; pool: PoolForecast; accounts: readonly Subscription[]; now: number; compact: boolean }) {
|
|
34
|
+
const c = theme.colors;
|
|
35
|
+
const t = text(c);
|
|
36
|
+
const span = (compact ? 8 : 12) * HOUR;
|
|
37
|
+
const end = now + span;
|
|
38
|
+
const at = (ms: number) => `${((ms - now) / span) * 100}%`;
|
|
39
|
+
const width = (from: number, to: number) => `${((Math.min(to, end) - Math.max(from, now)) / span) * 100}%`;
|
|
40
|
+
// Hour ticks on the clock, labelled every few hours.
|
|
41
|
+
const first = Math.ceil(now / HOUR) * HOUR;
|
|
42
|
+
const ticks: number[] = [];
|
|
43
|
+
for (let h = first; h < end; h += HOUR) ticks.push(h);
|
|
44
|
+
const every = 2;
|
|
45
|
+
const kinds = new Set<LaneKind>();
|
|
46
|
+
for (const account of accounts) for (const run of pool.lanes[account.id] ?? []) if (run.from < end) kinds.add(run.kind);
|
|
47
|
+
const nameWidth = compact ? 0 : 112;
|
|
48
|
+
const summary = poolHeadline(pool, accounts, now);
|
|
49
|
+
const dry = pool.runsOutAt !== null && pool.runsOutAt > now && pool.runsOutAt < end ? pool.runsOutAt : null;
|
|
50
|
+
const near = (ms: number, width: number) => dry !== null && Math.abs(ms - dry) / span < width;
|
|
51
|
+
|
|
52
|
+
return <View accessible accessibilityRole="image" accessibilityLabel={`${summary.title}. ${summary.detail}`} style={{ gap: 6 }}>
|
|
53
|
+
{accounts.map((account) => <View key={account.id} style={{ flexDirection: compact ? "column" : "row", alignItems: compact ? "stretch" : "center", gap: compact ? 3 : 10 }}>
|
|
54
|
+
<Text numberOfLines={1} style={{ ...t.small, width: compact ? undefined : nameWidth, color: c.foreground }}>{account.name}</Text>
|
|
55
|
+
<View style={{ flex: compact ? undefined : 1, height: 10, borderRadius: 3, overflow: "hidden", backgroundColor: ink(c, 0.04) }}>
|
|
56
|
+
{(pool.lanes[account.id] ?? []).filter((run) => run.from < end).map((run, i) => <View key={i} style={{
|
|
57
|
+
position: "absolute", left: at(Math.max(run.from, now)) as `${number}%`, width: width(run.from, run.to) as `${number}%`,
|
|
58
|
+
...(run.kind === "out" ? { top: 4.5, bottom: 4.5, backgroundColor: c.border } : { top: 0, bottom: 0, backgroundColor: laneFill(theme, run.kind) }),
|
|
59
|
+
}} />)}
|
|
60
|
+
{/* The moment the pool runs dry, across every lane. */}
|
|
61
|
+
{dry !== null ? <View style={{ position: "absolute", top: 0, bottom: 0, left: at(dry) as `${number}%`, width: 1, backgroundColor: c.foreground }} /> : null}
|
|
62
|
+
</View>
|
|
63
|
+
</View>)}
|
|
64
|
+
{/* The clock under the lanes, aligned with them. */}
|
|
65
|
+
<View style={{ flexDirection: "row", gap: compact ? 0 : 10 }}>
|
|
66
|
+
{compact ? null : <View style={{ width: nameWidth }} />}
|
|
67
|
+
<View style={{ flex: 1, height: 14 }}>
|
|
68
|
+
<Text style={{ ...t.small, fontSize: 10, lineHeight: 14, position: "absolute", left: 0 }}>Now</Text>
|
|
69
|
+
{ticks.map((h, i) => (Math.round(h / HOUR) % every === 0 && (h - now) / span > (compact ? 0.1 : 0.08) && (end - h) / span > (compact ? 0.22 : 0.09) && !near(h, compact ? 0.18 : 0.1))
|
|
70
|
+
? <Text key={i} style={{ ...t.small, fontSize: 10, lineHeight: 14, position: "absolute", left: at(h) as `${number}%`, transform: [{ translateX: -30 }], width: 60, textAlign: "center" }}>{hourLabel(h, now)}</Text>
|
|
71
|
+
: null)}
|
|
72
|
+
{dry !== null ? <Text style={{ ...t.small, fontSize: 10, lineHeight: 14, fontWeight: "600", color: c.foreground, position: "absolute", left: at(dry) as `${number}%`, transform: [{ translateX: -40 }], width: 80, textAlign: "center" }}>{`out ${formatMoment(new Date(dry).toISOString(), now)}`}</Text> : null}
|
|
73
|
+
<Text style={{ ...t.small, fontSize: 10, lineHeight: 14, position: "absolute", right: 0 }}>{near(end, compact ? 0.2 : 0.12) ? "" : formatMoment(new Date(end).toISOString(), now)}</Text>
|
|
74
|
+
</View>
|
|
75
|
+
</View>
|
|
76
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", columnGap: 14, rowGap: 4, paddingLeft: compact ? 0 : nameWidth + 10 }}>
|
|
77
|
+
{(["active", "room", "limit", "out"] as LaneKind[]).filter((k) => kinds.has(k)).map((k) => <View key={k} style={{ flexDirection: "row", alignItems: "center", gap: 6 }}>
|
|
78
|
+
<View style={{ width: 14, height: k === "out" ? 1 : 6, borderRadius: 2, backgroundColor: k === "out" ? c.border : laneFill(theme, k) }} />
|
|
79
|
+
<Text style={t.small}>{LANE_WORDS[k]}</Text>
|
|
80
|
+
</View>)}
|
|
81
|
+
</View>
|
|
82
|
+
</View>;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** "8 AM" or "20:00": an hour mark on the runway's clock, in the device's convention. */
|
|
86
|
+
function hourLabel(ms: number, now: number): string {
|
|
87
|
+
const clock = formatMoment(new Date(ms).toISOString(), now);
|
|
88
|
+
return clock.replace(/:00(?=\s|$)/, "");
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** One window of one account: "5h 42%", a thin meter, and its reset. */
|
|
92
|
+
function MiniWindow({ theme, label, w, now, stale, full = false }: { theme: Theme; label: string; w: SubscriptionWindow | null; now: number; stale: boolean; full?: boolean }) {
|
|
93
|
+
const c = theme.colors;
|
|
94
|
+
const t = text(c);
|
|
95
|
+
if (w === null) return <View style={{ flex: 1, minWidth: 130, gap: 2 }}><Text style={t.meta}>{`${label} · not reported`}</Text></View>;
|
|
96
|
+
const reset = w.resetsAt === null ? null : Date.parse(w.resetsAt);
|
|
97
|
+
const rolled = reset !== null && reset <= now;
|
|
98
|
+
// A limit the CLI reported is the freshest word on its window: full until it resets.
|
|
99
|
+
const f = forecastWindow({ ...w, pct: rolled ? 0 : full ? Math.max(100, w.pct) : w.pct, burnPctPerHour: stale ? null : w.burnPctPerHour }, now);
|
|
100
|
+
const pct = rolled ? null : Math.round(full ? Math.max(100, w.pct) : w.pct);
|
|
101
|
+
return <View style={{ flex: 1, minWidth: 130, gap: 1 }}>
|
|
102
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
|
103
|
+
<Text style={{ ...t.meta, color: c.foreground }}>{label}</Text>
|
|
104
|
+
<Text style={{ ...t.label, ...tabular, color: f.tone === "default" ? c.foreground : toneColor(c, f.tone) }}>{pct === null ? "reset" : `${pct}%`}</Text>
|
|
105
|
+
<View style={{ flex: 1 }} />
|
|
106
|
+
<Text numberOfLines={1} style={t.small}>{reset === null ? "" : rolled ? `was ${formatMoment(w.resetsAt!, now)}` : `resets ${formatMoment(w.resetsAt!, now)}`}</Text>
|
|
107
|
+
</View>
|
|
108
|
+
<Meter theme={theme} pct={pct ?? 0} tone={f.tone} projected={f.atResetPct} height={5} label={`${label}: ${pct === null ? "reset since the last reading" : `${pct}% used`}`} />
|
|
109
|
+
</View>;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function AccountRow({ theme, account, pool, now, compact, first, alone }: { theme: Theme; account: Subscription; pool: PoolForecast; now: number; compact: boolean; first: boolean; alone: boolean }) {
|
|
113
|
+
const c = theme.colors;
|
|
114
|
+
const t = text(c);
|
|
115
|
+
const state = subscriptionState(account, now);
|
|
116
|
+
const stale = state.staleMs !== null;
|
|
117
|
+
const s5 = sessionWindow(account);
|
|
118
|
+
const week = weeklyWindow(account);
|
|
119
|
+
const muted = state.kind === "aside" || state.kind === "signed-out";
|
|
120
|
+
// When it runs out comes from the pool's simulation, so the row, the lead and the
|
|
121
|
+
// popover say the same time; "on pace for" is its own pace when nothing moves onto it.
|
|
122
|
+
const outlook = state.kind === "room" ? accountOutlook(pool, account, now) : null;
|
|
123
|
+
const f = s5 && !stale && !(s5.resetsAt && Date.parse(s5.resetsAt) <= now) ? forecastWindow(s5, now) : null;
|
|
124
|
+
const pace: { text: string; tone: "default" | "warning" | "danger" } | null = outlook?.kind === "runs-out"
|
|
125
|
+
? { text: `Runs out ~${formatMoment(new Date(outlook.at).toISOString(), now)}`, tone: outlook.at - now <= 30 * 60_000 ? "danger" : "warning" }
|
|
126
|
+
: outlook?.kind === "lasts" && f && f.state === "ok" ? { text: paceLine(f, now), tone: "default" } : null;
|
|
127
|
+
const facts = [
|
|
128
|
+
account.agents > 0 ? `${account.agents} ${account.agents === 1 ? "session" : "sessions"} on it` : null,
|
|
129
|
+
stale && !muted ? `Reading ${formatDuration(state.staleMs!)} old` : null,
|
|
130
|
+
account.credits ? (account.credits.unlimited ? "Unlimited credits" : account.credits.balance !== null ? `${Math.round(account.credits.balance).toLocaleString()} credits` : null) : null,
|
|
131
|
+
].filter(Boolean);
|
|
132
|
+
const limitedId = state.kind === "limit" ? limitedWindow(account, now)?.id ?? null : null;
|
|
133
|
+
|
|
134
|
+
return <CardRow theme={theme} first={first} style={{ gap: 8, opacity: muted ? 0.62 : 1 }}>
|
|
135
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
136
|
+
<Icon name={account.kind === "main" ? "Terminal" : "UserRound"} size={14} color={c.foregroundMuted} />
|
|
137
|
+
<Text numberOfLines={1} style={{ ...t.label, flexShrink: 1 }}>{account.name}</Text>
|
|
138
|
+
{account.isDefault && !alone ? <Chip theme={theme} label="Default" /> : null}
|
|
139
|
+
<View style={{ flex: 1 }} />
|
|
140
|
+
{state.kind === "room" ? null : <Chip theme={theme} icon={state.kind === "limit" ? "OctagonAlert" : undefined} label={state.label} color={toneColor(c, state.tone)} />}
|
|
141
|
+
</View>
|
|
142
|
+
{muted ? null : <View style={{ flexDirection: compact ? "column" : "row", gap: compact ? 8 : 20, paddingLeft: 22 }}>
|
|
143
|
+
<MiniWindow theme={theme} label="5h" w={s5} now={now} stale={stale} full={limitedId !== null && limitedId === s5?.id} />
|
|
144
|
+
<MiniWindow theme={theme} label="Week" w={week} now={now} stale={stale} full={limitedId !== null && limitedId === week?.id} />
|
|
145
|
+
</View>}
|
|
146
|
+
{facts.length > 0 || pace ? <Text style={{ ...t.meta, paddingLeft: 22 }}>
|
|
147
|
+
{facts.join(" · ")}{facts.length > 0 && pace ? " · " : ""}
|
|
148
|
+
{/* Only the clause that is state wears the state colour. */}
|
|
149
|
+
{pace ? <Text style={{ color: pace.tone === "default" ? c.foregroundMuted : toneColor(c, pace.tone), fontWeight: pace.tone === "default" ? "400" : "600" }}>{pace.text}</Text> : null}
|
|
150
|
+
</Text> : null}
|
|
151
|
+
</CardRow>;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/**
|
|
155
|
+
* Every ZeroSub subscription, grouped by provider: the pool's runway and headline,
|
|
156
|
+
* then one row per account. ZeroSub manages the accounts; this says when.
|
|
157
|
+
*/
|
|
158
|
+
export function SubscriptionsSection({ theme, compact, accounts, pools, now, autoSwitch, leadFamily }: {
|
|
159
|
+
theme: Theme; compact: boolean; accounts: readonly Subscription[]; pools: readonly PoolForecast[]; now: number; autoSwitch: boolean;
|
|
160
|
+
/** The family the page's headline already speaks for; its card does not repeat it. */
|
|
161
|
+
leadFamily: string | null;
|
|
162
|
+
}) {
|
|
163
|
+
const c = theme.colors;
|
|
164
|
+
const t = text(c);
|
|
165
|
+
return <View style={{ gap: 8 }}>
|
|
166
|
+
<SectionHead theme={theme} title="Subscriptions" count={accounts.length}
|
|
167
|
+
trailing={autoSwitch ? null : <Text style={t.small}>Automatic switching is off in ZeroSub</Text>} />
|
|
168
|
+
{pools.map((pool) => {
|
|
169
|
+
const mine = [...accounts.filter((a) => a.family === pool.family)].sort((a, b) =>
|
|
170
|
+
Number(b.agents > 0) - Number(a.agents > 0) || Number(b.isDefault) - Number(a.isDefault) || Number(b.kind === "main") - Number(a.kind === "main"));
|
|
171
|
+
const head = poolHeadline(pool, mine, now);
|
|
172
|
+
return <Card key={pool.family} theme={theme}>
|
|
173
|
+
<CardRow theme={theme} first style={{ gap: 12 }}>
|
|
174
|
+
<View style={{ gap: 2 }}>
|
|
175
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
176
|
+
<Text style={{ ...t.title }}>{familyName(pool.family)}</Text>
|
|
177
|
+
<Text style={t.meta}>{`${pool.withRoom} of ${pool.total} with room`}</Text>
|
|
178
|
+
</View>
|
|
179
|
+
{mine.length > 1 && pool.family !== leadFamily ? <Text style={{ ...t.meta, color: head.tone === "default" ? c.foregroundMuted : toneColor(c, head.tone) }}>{headLine(head, pool)}</Text> : null}
|
|
180
|
+
</View>
|
|
181
|
+
{mine.length > 1 ? <Runway theme={theme} pool={pool} accounts={mine} now={now} compact={compact} /> : null}
|
|
182
|
+
</CardRow>
|
|
183
|
+
{mine.map((account) => <AccountRow key={account.id} theme={theme} account={account} pool={pool} now={now} compact={compact} first={false} alone={mine.length === 1} />)}
|
|
184
|
+
</Card>;
|
|
185
|
+
})}
|
|
186
|
+
{/* What the rows' meters mean, once. */}
|
|
187
|
+
<View style={{ flexDirection: "row", flexWrap: "wrap", alignItems: "center", columnGap: 14, rowGap: 4, paddingHorizontal: 2 }}>
|
|
188
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}><View style={{ width: 14, height: 5, borderRadius: 3, backgroundColor: ink(c, 0.82) }} /><Text style={t.small}>Used</Text></View>
|
|
189
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 6 }}><View style={{ width: 14, height: 5, borderRadius: 3, backgroundColor: ink(c, 0.16) }} /><Text style={t.small}>By the reset at its own pace</Text></View>
|
|
190
|
+
</View>
|
|
191
|
+
</View>;
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** The family's one line in its card: the headline's facts, which the title already frames. */
|
|
195
|
+
function headLine(head: { title: string; detail: string }, pool: PoolForecast): string {
|
|
196
|
+
// "Claude · 2 subscriptions last until about 9:40 PM" → "Last until about 9:40 PM".
|
|
197
|
+
const lead = head.title.replace(/^(Claude|Codex) · \d+ subscriptions? /, "").replace(/^(Claude|Codex) /, "");
|
|
198
|
+
const sentence = lead.charAt(0).toUpperCase() + lead.slice(1);
|
|
199
|
+
if (pool.pace === null && pool.runsOutAt === null) return head.detail;
|
|
200
|
+
return [sentence, head.detail].filter(Boolean).join(" · ");
|
|
201
|
+
}
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ZeroSub subscriptions for this client bundle: one poll of
|
|
3
|
+
* `smart-session.subscriptions` that the overview, the sidebar row and the pill's
|
|
4
|
+
* popover all read. Without ZeroSub it answers `zerosub: false`, is asked rarely, and
|
|
5
|
+
* every view looks exactly as it does without this file.
|
|
6
|
+
*/
|
|
7
|
+
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
8
|
+
import type { z } from "zod";
|
|
9
|
+
|
|
10
|
+
import { subscriptionsStatus } from "../shared/smart-session.ts";
|
|
11
|
+
import { poolForecast, type Family, type PoolForecast } from "../shared/subscriptions.ts";
|
|
12
|
+
|
|
13
|
+
export type Subscriptions = z.infer<(typeof subscriptionsStatus)["output"]>;
|
|
14
|
+
|
|
15
|
+
/** ZeroSub refreshes an account in use every few minutes; half a minute keeps up. */
|
|
16
|
+
const ZEROSUB_POLL_MS = 30_000;
|
|
17
|
+
/** Without ZeroSub: only to notice it being turned on. */
|
|
18
|
+
const IDLE_POLL_MS = 5 * 60_000;
|
|
19
|
+
|
|
20
|
+
let current: Subscriptions | null = null;
|
|
21
|
+
const listeners = new Set<() => void>();
|
|
22
|
+
|
|
23
|
+
export function subscriptionsSnapshot(): Subscriptions | null {
|
|
24
|
+
return current;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function subscribeSubscriptions(listener: () => void): () => void {
|
|
28
|
+
listeners.add(listener);
|
|
29
|
+
return () => { listeners.delete(listener); };
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function publishSubscriptions(next: Subscriptions | null): void {
|
|
33
|
+
current = next;
|
|
34
|
+
for (const listener of [...listeners]) listener();
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** The families worth a pooled view: ZeroSub on, and at least one account in them. */
|
|
38
|
+
export function pools(subs: Subscriptions | null, now: number): PoolForecast[] {
|
|
39
|
+
if (!subs?.zerosub) return [];
|
|
40
|
+
const families = (["claude", "codex"] as Family[]).filter((family) => subs.accounts.some((a) => a.family === family));
|
|
41
|
+
return families.map((family) => poolForecast(subs.accounts, family, now, { autoSwitch: subs.autoSwitch }));
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** Whether the views speak about subscriptions at all: ZeroSub with more than one login. */
|
|
45
|
+
export function hasSubscriptions(subs: Subscriptions | null): subs is Subscriptions {
|
|
46
|
+
return subs?.zerosub === true && subs.accounts.length > 1;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/** Starts the one poll for this bundle. */
|
|
50
|
+
export function watchSubscriptions(client: PluginClientContext, onChange?: (next: Subscriptions) => void): () => void {
|
|
51
|
+
let stopped = false;
|
|
52
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
53
|
+
const refresh = async () => {
|
|
54
|
+
try {
|
|
55
|
+
const next = await client.rpc(subscriptionsStatus, {});
|
|
56
|
+
if (!stopped) { publishSubscriptions(next); onChange?.(next); }
|
|
57
|
+
} catch {
|
|
58
|
+
// Keep the last reading across a brief disconnect.
|
|
59
|
+
} finally {
|
|
60
|
+
if (!stopped) timer = setTimeout(() => void refresh(), current?.zerosub ? ZEROSUB_POLL_MS : IDLE_POLL_MS);
|
|
61
|
+
}
|
|
62
|
+
};
|
|
63
|
+
void refresh();
|
|
64
|
+
return () => {
|
|
65
|
+
stopped = true;
|
|
66
|
+
if (timer !== null) clearTimeout(timer);
|
|
67
|
+
};
|
|
68
|
+
}
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
package/docs/session-guard.md
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
|
-
# Pause at the limit
|
|
1
|
+
# Pause at the limit (Session Guard)
|
|
2
2
|
|
|
3
|
-
Pause at the limit (
|
|
3
|
+
Pause at the limit (Session Guard) (the standalone Session Guard plugin, now part of Smart Session) pauses agents at their provider's session limit and continues the same conversations after the window resets, even while the Paseo app is closed. It began as the standalone [paseo-session-guard](https://github.com/tomgrin10/paseo-session-guard) plugin and now ships inside Smart Session. See the [README](../README.md) for installation.
|
|
4
4
|
|
|
5
5
|
## Turning it on
|
|
6
6
|
|
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();
|