paseo-smart-session 2.1.0 → 2.2.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.
Files changed (46) hide show
  1. package/README.md +40 -11
  2. package/client/agents.ts +26 -4
  3. package/client/charts.tsx +277 -152
  4. package/client/chat.tsx +122 -0
  5. package/client/display.tsx +55 -33
  6. package/client/guard/index.tsx +14 -16
  7. package/client/guard/paused.tsx +125 -38
  8. package/client/guard/pill-icons.tsx +1 -1
  9. package/client/guard/pills.ts +16 -6
  10. package/client/guard/surfaces.ts +3 -2
  11. package/client/guard/timeline.tsx +116 -56
  12. package/client/kit.tsx +144 -0
  13. package/client/live.ts +49 -0
  14. package/client/motion.tsx +94 -0
  15. package/client/overview.tsx +373 -0
  16. package/client/pill-store.ts +77 -0
  17. package/client/pill.tsx +83 -260
  18. package/client/popover.tsx +113 -0
  19. package/client/settings.tsx +105 -0
  20. package/client/theme.ts +56 -0
  21. package/docs/media/smart-session-chat.png +0 -0
  22. package/docs/media/smart-session-flow.gif +0 -0
  23. package/docs/media/smart-session-guard.png +0 -0
  24. package/docs/media/smart-session-hero-light.png +0 -0
  25. package/docs/media/smart-session-hero.png +0 -0
  26. package/docs/media/smart-session-mobile.png +0 -0
  27. package/docs/media/smart-session-pill.png +0 -0
  28. package/docs/navigation-verification.md +6 -6
  29. package/docs/screenshots/display-settings-desktop.png +0 -0
  30. package/docs/screenshots/display-settings-mobile.png +0 -0
  31. package/docs/session-guard.md +3 -3
  32. package/index.client.tsx +47 -35
  33. package/index.server.ts +14 -5
  34. package/mcp.mjs +1 -1
  35. package/package.json +1 -1
  36. package/paseo-plugin.json +10 -0
  37. package/server/settings.ts +10 -0
  38. package/shared/blocks.ts +15 -0
  39. package/shared/chat.ts +174 -0
  40. package/shared/forecast.ts +233 -0
  41. package/shared/format.ts +47 -1
  42. package/shared/governor.ts +3 -0
  43. package/shared/smart-session.ts +16 -0
  44. package/client/navigation.tsx +0 -14
  45. package/client/surface.tsx +0 -659
  46. package/docs/screenshots/smart-compact-handoff.png +0 -0
@@ -0,0 +1,233 @@
1
+ /**
2
+ * Turning a usage reading into the moment it matters.
3
+ *
4
+ * Plain functions over the budget and context RPC outputs, shared by the overview,
5
+ * the pill and the checks, so every view says the same thing about one window: when
6
+ * it resets, whether it runs out first at this pace, and how much is left per hour.
7
+ */
8
+
9
+ import { formatClock, formatDuration, windowLabel } from "./format.ts";
10
+
11
+ export type Tone = "default" | "warning" | "danger";
12
+
13
+ const HOUR = 3_600_000;
14
+ const FIVE_HOURS = 5 * HOUR;
15
+ const WEEK = 7 * 24 * HOUR;
16
+
17
+ /** A window's nominal length, or null for a cap whose length is not known. */
18
+ export function windowLength(id: string): number | null {
19
+ if (id === "five_hour") return FIVE_HOURS;
20
+ if (id === "seven_day" || id.startsWith("weekly")) return WEEK;
21
+ return null;
22
+ }
23
+
24
+ export interface WindowReading {
25
+ readonly id: string;
26
+ readonly pct: number;
27
+ readonly resetsAt: string | null;
28
+ /** Percentage points per hour over the recent past; null when it cannot be measured. */
29
+ readonly burnPctPerHour: number | null;
30
+ }
31
+
32
+ export interface WindowForecast {
33
+ readonly id: string;
34
+ readonly label: string;
35
+ readonly pct: number;
36
+ readonly resetsAt: string | null;
37
+ /** The window's start, when its length is known. */
38
+ readonly startAt: string | null;
39
+ /** How far through the window we are, 0–1: where an even pace would put the meter. */
40
+ readonly elapsed: number | null;
41
+ /** The rate the forecast uses: the last half hour for the session, the window's average for a week. */
42
+ readonly ratePerHour: number | null;
43
+ /** Where the window ends up at the reset at that rate, uncapped. */
44
+ readonly atResetPct: number | null;
45
+ /** When it reaches 100% at that rate, if that comes before the reset. */
46
+ readonly runsOutAt: string | null;
47
+ /** How long before the reset it runs out. */
48
+ readonly shortMs: number | null;
49
+ /** Percentage points per hour that would last exactly until the reset. */
50
+ readonly budgetPerHour: number | null;
51
+ readonly state: "full" | "short" | "ok" | "idle" | "unknown";
52
+ readonly tone: Tone;
53
+ }
54
+
55
+ /** Rates below this are a window nobody is using right now. */
56
+ const IDLE_RATE = 0.2;
57
+
58
+ export function forecastWindow(window: WindowReading, now = Date.now()): WindowForecast {
59
+ const length = windowLength(window.id);
60
+ const reset = window.resetsAt === null ? NaN : Date.parse(window.resetsAt);
61
+ const known = Number.isFinite(reset) && reset > now;
62
+ const start = known && length !== null ? reset - length : null;
63
+ const elapsed = start === null || length === null ? null : Math.min(1, Math.max(0, (now - start) / length));
64
+ const hoursLeft = known ? (reset - now) / HOUR : null;
65
+ const base = {
66
+ id: window.id,
67
+ label: windowLabel(window.id),
68
+ pct: window.pct,
69
+ resetsAt: known ? new Date(reset).toISOString() : window.resetsAt,
70
+ startAt: start === null ? null : new Date(start).toISOString(),
71
+ elapsed,
72
+ };
73
+ const budgetPerHour = hoursLeft === null || hoursLeft <= 0 ? null : Math.max(0, 100 - window.pct) / hoursLeft;
74
+ const none = { ratePerHour: null, atResetPct: null, runsOutAt: null, shortMs: null, budgetPerHour };
75
+
76
+ if (window.pct >= 99.5) return { ...base, ...none, state: "full", tone: "danger" };
77
+ if (hoursLeft === null) return { ...base, ...none, state: "unknown", tone: window.pct >= 90 ? "warning" : "default" };
78
+
79
+ // A week is bursty: half an hour of work extrapolated over four days says nothing.
80
+ // Its pace is the window's own average so far.
81
+ const elapsedHours = elapsed === null || length === null ? null : (elapsed * length) / HOUR;
82
+ const rate = length !== null && length > FIVE_HOURS
83
+ ? (elapsedHours !== null && elapsedHours >= 1 ? window.pct / elapsedHours : null)
84
+ : window.burnPctPerHour;
85
+ if (rate === null || !Number.isFinite(rate) || rate < IDLE_RATE) {
86
+ return { ...base, ...none, ratePerHour: rate === null ? null : Math.max(0, rate), state: "idle", tone: window.pct >= 90 ? "warning" : "default" };
87
+ }
88
+ const atResetPct = window.pct + rate * hoursLeft;
89
+ if (atResetPct < 100) {
90
+ return { ...base, ratePerHour: rate, atResetPct, runsOutAt: null, shortMs: null, budgetPerHour, state: "ok", tone: window.pct >= 90 ? "warning" : "default" };
91
+ }
92
+ const runsOut = now + ((100 - window.pct) / rate) * HOUR;
93
+ const shortMs = reset - runsOut;
94
+ const soon = runsOut - now <= 30 * 60_000 || window.pct >= 95;
95
+ return {
96
+ ...base, ratePerHour: rate, atResetPct, runsOutAt: new Date(runsOut).toISOString(), shortMs, budgetPerHour,
97
+ state: "short", tone: soon ? "danger" : "warning",
98
+ };
99
+ }
100
+
101
+ const rounded = (value: number) => (value >= 10 ? Math.round(value) : Math.round(value * 10) / 10);
102
+ const until = (iso: string, now: number) => formatDuration(Date.parse(iso) - now);
103
+
104
+ /** A clock within the next day, else a weekday and clock: "9:21 PM", "Sat 11:00 PM". */
105
+ export function formatMoment(iso: string, now = Date.now(), hour12?: boolean): string {
106
+ const at = Date.parse(iso);
107
+ const clock = formatClock(iso, hour12);
108
+ const sameDay = new Date(at).toDateString() === new Date(now).toDateString();
109
+ if (sameDay || (at > now && at - now < 12 * HOUR)) return clock;
110
+ try {
111
+ return `${new Intl.DateTimeFormat(undefined, { weekday: "short" }).format(new Date(at))} ${clock}`;
112
+ } catch {
113
+ return clock;
114
+ }
115
+ }
116
+
117
+ /** "Resets 9:21 PM · in 1h 57m". */
118
+ export function resetLine(f: WindowForecast, now = Date.now(), hour12?: boolean): string {
119
+ if (f.resetsAt === null) return "Reset time not reported";
120
+ const at = Date.parse(f.resetsAt);
121
+ if (!Number.isFinite(at)) return "Reset time not reported";
122
+ // Past its reset but no newer reading yet: name the moment rather than "due".
123
+ if (at <= now) return `Reset was ${formatMoment(f.resetsAt, now, hour12)}`;
124
+ return `Resets ${formatMoment(f.resetsAt, now, hour12)} · in ${until(f.resetsAt, now)}`;
125
+ }
126
+
127
+ /** The short pace phrase on a meter row: "runs out ~8:47 PM", "on pace for ~88%". */
128
+ export function paceLine(f: WindowForecast, now = Date.now(), hour12?: boolean): string {
129
+ switch (f.state) {
130
+ case "full": return "Limit reached";
131
+ case "short": return `Runs out ~${formatMoment(f.runsOutAt!, now, hour12)} at ${rounded(f.ratePerHour!)}%/h`;
132
+ case "ok": return `On pace for ~${Math.round(f.atResetPct!)}% by the reset`;
133
+ case "idle": return f.ratePerHour === null ? "No pace yet" : "Not in use right now";
134
+ default: return "";
135
+ }
136
+ }
137
+
138
+ /** When a window runs short: the pace that would still last until the reset. */
139
+ export function budgetLine(f: WindowForecast): string | null {
140
+ if (f.budgetPerHour === null || f.state !== "short") return null;
141
+ return `${rounded(f.budgetPerHour)}%/h would last until the reset`;
142
+ }
143
+
144
+ /** The overview's status headline and the sentence under it. */
145
+ export function headline(f: WindowForecast | null, now = Date.now(), hour12?: boolean): { title: string; detail: string; tone: Tone } {
146
+ if (f === null) return { title: "No plan usage recorded yet", detail: "The recorder samples every minute while Paseo runs.", tone: "default" };
147
+ const name = f.id === "five_hour" ? "Session" : f.label;
148
+ const pct = `${Math.round(f.pct)}% used`;
149
+ switch (f.state) {
150
+ case "full": {
151
+ const at = f.resetsAt === null ? NaN : Date.parse(f.resetsAt);
152
+ const detail = !Number.isFinite(at) ? "Waiting for the provider to report the reset"
153
+ : at <= now ? `${resetLine(f, now, hour12)} · waiting for the provider to report the new window`
154
+ : `Back at the ${formatMoment(f.resetsAt!, now, hour12)} reset · in ${until(f.resetsAt!, now)}`;
155
+ return { title: `${name} limit reached`, detail, tone: "danger" };
156
+ }
157
+ case "short":
158
+ return {
159
+ title: `${name} runs out around ${formatMoment(f.runsOutAt!, now, hour12)}`,
160
+ detail: `${formatDuration(f.shortMs!)} before the ${formatMoment(f.resetsAt!, now, hour12)} reset · ${pct} · ${rounded(f.ratePerHour!)}%/h`,
161
+ tone: f.tone,
162
+ };
163
+ case "ok":
164
+ return { title: `${name} lasts until the ${formatMoment(f.resetsAt!, now, hour12)} reset`, detail: `${pct} · on pace for ~${Math.round(f.atResetPct!)}%`, tone: f.tone };
165
+ case "idle":
166
+ return {
167
+ title: `${name} at ${Math.round(f.pct)}%`,
168
+ detail: f.resetsAt === null ? "Nothing used in the last half hour" : `Nothing used in the last half hour · ${resetLine(f, now, hour12).replace(/^R/, "r")}`,
169
+ tone: f.tone,
170
+ };
171
+ default:
172
+ return { title: `${name} at ${Math.round(f.pct)}%`, detail: "The provider has not reported a reset time", tone: f.tone };
173
+ }
174
+ }
175
+
176
+ /** The window the overview leads with: the session, else whichever is fullest. */
177
+ export function leadWindow(windows: readonly WindowForecast[]): WindowForecast | null {
178
+ return windows.find((window) => window.id === "five_hour") ?? [...windows].sort((a, b) => b.pct - a.pct)[0] ?? null;
179
+ }
180
+
181
+ export interface ContextReading {
182
+ readonly usedTokens: number;
183
+ readonly maxTokens: number;
184
+ readonly compactAtPct: number;
185
+ readonly growthTokensPerHour?: number | null;
186
+ }
187
+
188
+ export interface ContextOutlook {
189
+ readonly pct: number;
190
+ readonly compactAt: number;
191
+ /** Tokens at the compact line. */
192
+ readonly compactTokens: number;
193
+ readonly past: boolean;
194
+ /** Time until the compact line at the measured growth; null when not growing or already past. */
195
+ readonly untilCompactMs: number | null;
196
+ readonly tone: Tone;
197
+ }
198
+
199
+ export function contextOutlook(reading: ContextReading): ContextOutlook {
200
+ const pct = reading.maxTokens > 0 ? (reading.usedTokens / reading.maxTokens) * 100 : 0;
201
+ const compactTokens = Math.round((reading.compactAtPct / 100) * reading.maxTokens);
202
+ const past = pct >= reading.compactAtPct;
203
+ const growth = reading.growthTokensPerHour ?? null;
204
+ const untilCompactMs = past || growth === null || growth <= 0 ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
205
+ return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, tone: pct >= 90 ? "danger" : past ? "warning" : "default" };
206
+ }
207
+
208
+ /**
209
+ * The context ring: a faint full track, an arc for the share of the window used, and a
210
+ * tick across the ring at the compact line. Inline SVG in the surface's own colour, so
211
+ * the host `Icon` can draw it anywhere a Lucide glyph fits. Quantized to 1/48 so a
212
+ * ticking view rarely redraws.
213
+ */
214
+ export function contextRing(fraction: number, mark: number | null, track = 0.28): string {
215
+ const steps = 48;
216
+ const f = Math.round(Math.min(1, Math.max(0, fraction)) * steps) / steps;
217
+ const r = 8.5;
218
+ const c = 12;
219
+ const point = (t: number, radius: number) => [(c + radius * Math.sin(t * 2 * Math.PI)).toFixed(2), (c - radius * Math.cos(t * 2 * Math.PI)).toFixed(2)];
220
+ let arc = "";
221
+ if (f >= 1) arc = `<circle cx="${c}" cy="${c}" r="${r}"/>`;
222
+ else if (f > 0) {
223
+ const [x, y] = point(f, r);
224
+ arc = `<path d="M${c} ${c - r}A${r} ${r} 0 ${f > 0.5 ? 1 : 0} 1 ${x} ${y}"/>`;
225
+ }
226
+ let tick = "";
227
+ if (mark !== null && mark > 0 && mark < 1) {
228
+ const [x1, y1] = point(mark, r - 3.6);
229
+ const [x2, y2] = point(mark, r + 3.2);
230
+ tick = `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
231
+ }
232
+ 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
+ }
package/shared/format.ts CHANGED
@@ -21,6 +21,42 @@ export function formatRelative(iso: string | null, now = Date.now()): string {
21
21
  return ms >= now ? `in ${formatDuration(ms - now)}` : `${formatDuration(now - ms)} ago`;
22
22
  }
23
23
 
24
+ let cachedHour12: boolean | null = null;
25
+
26
+ /** Whether this device writes times as "8:50 PM" rather than "20:50". */
27
+ export function uses12HourClock(): boolean {
28
+ if (cachedHour12 !== null) return cachedHour12;
29
+ try {
30
+ const parts = new Intl.DateTimeFormat(undefined, { hour: "numeric" }).formatToParts(new Date(2020, 0, 1, 13, 0, 0));
31
+ cachedHour12 = parts.some((part) => part.type === "dayPeriod");
32
+ } catch {
33
+ cachedHour12 = false;
34
+ }
35
+ return cachedHour12;
36
+ }
37
+
38
+ /** Local wall-clock time in the device's own convention: "20:50" or "8:50 PM". */
39
+ export function formatClock(iso: string | null, hour12: boolean = uses12HourClock()): string {
40
+ if (iso === null) return "—";
41
+ const date = new Date(iso);
42
+ if (Number.isNaN(date.getTime())) return "—";
43
+ const hours = date.getHours();
44
+ const minutes = String(date.getMinutes()).padStart(2, "0");
45
+ if (!hour12) return `${String(hours).padStart(2, "0")}:${minutes}`;
46
+ // A non-breaking space, so "AM" never wraps onto a line of its own.
47
+ return `${hours % 12 === 0 ? 12 : hours % 12}:${minutes}\u00a0${hours < 12 ? "AM" : "PM"}`;
48
+ }
49
+
50
+ /** "Oct 22" — a short local date. */
51
+ export function formatDay(iso: string): string {
52
+ const date = new Date(iso);
53
+ try {
54
+ return new Intl.DateTimeFormat(undefined, { month: "short", day: "numeric" }).format(date);
55
+ } catch {
56
+ return iso.slice(5, 10);
57
+ }
58
+ }
59
+
24
60
  /** "261.7k" — token counts, which are always large and never need units. */
25
61
  export function formatTokens(count: number): string {
26
62
  if (count < 1000) return String(count);
@@ -28,9 +64,19 @@ export function formatTokens(count: number): string {
28
64
  return `${(count / 1_000_000).toFixed(2)}M`;
29
65
  }
30
66
 
67
+ /** "1M", "200k", "88.6M" — a window size or a total, without trailing zeros. */
68
+ export function formatCompactTokens(count: number): string {
69
+ if (count >= 1_000_000) {
70
+ const value = count / 1_000_000;
71
+ return `${value >= 10 ? Math.round(value) : Number(value.toFixed(1))}M`;
72
+ }
73
+ if (count >= 1000) return `${Math.round(count / 1000)}k`;
74
+ return String(Math.round(count));
75
+ }
76
+
31
77
  /** A window id turned into something worth putting on a row. */
32
78
  export function windowLabel(id: string): string {
33
- if (id === "five_hour") return "Session (5h)";
79
+ if (id === "five_hour") return "Session · 5h";
34
80
  if (id === "seven_day") return "Week";
35
81
  const scoped = /^weekly_(model|surface):(.+)$/.exec(id);
36
82
  if (scoped !== null) {
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { defineRpc } from "@getpaseo/plugin";
12
12
  import { z } from "zod";
13
+ import { ThresholdsSchema } from "./smart-session.ts";
13
14
 
14
15
  export const CompactionStateSchema = z.enum(["pending", "sending", "sent", "failed", "cancelled"]);
15
16
 
@@ -93,6 +94,8 @@ export const EnrolmentStateSchema = z.object({
93
94
  enabled: z.boolean(),
94
95
  /** Only agents with a state file or an explicit answer. Anyone absent is off. */
95
96
  agents: z.array(AgentEnrolmentSchema),
97
+ /** The compact bands, so a pill can mark where its session will be asked. Optional for older daemons. */
98
+ thresholds: ThresholdsSchema.optional(),
96
99
  });
97
100
  export type EnrolmentState = z.infer<typeof EnrolmentStateSchema>;
98
101
 
@@ -17,6 +17,18 @@ export const WindowStatusSchema = z.object({
17
17
  burnPctPerHour: z.number().nullable(),
18
18
  /** When this window hits 100% at that rate, if that happens before it resets. */
19
19
  projectedFullAt: z.string().nullable(),
20
+ /** The window's start, when its length is known. Optional: older daemons omit it. */
21
+ startAt: z.string().nullable().optional(),
22
+ /** The open window's recorded readings, oldest first, thinned to a chart's worth. */
23
+ history: z.array(z.object({ t: z.string(), pct: z.number() })).optional(),
24
+ });
25
+
26
+ /** A finished 5-hour window: how close it came to the limit. */
27
+ export const PastWindowSchema = z.object({
28
+ startAt: z.string().nullable(),
29
+ resetsAt: z.string().nullable(),
30
+ firstSeenAt: z.string(),
31
+ peakPct: z.number(),
20
32
  });
21
33
 
22
34
  export const RecorderStatusSchema = z.object({
@@ -39,6 +51,8 @@ export const budgetStatus = defineRpc({
39
51
  input: z.object({}),
40
52
  output: z.object({
41
53
  windows: z.array(WindowStatusSchema),
54
+ /** Finished session windows from the last 14 days, oldest first. */
55
+ past: z.array(PastWindowSchema).optional(),
42
56
  recorder: RecorderStatusSchema,
43
57
  error: z.string().nullable(),
44
58
  }),
@@ -60,6 +74,8 @@ export const ContextStatusSchema = z.object({
60
74
  projectedFullAt: z.string().nullable(),
61
75
  /** The percentage at which this window is considered ready to compact. */
62
76
  compactAtPct: z.number(),
77
+ /** When this agent's task state file was last written; null when it has none. */
78
+ stateWrittenAt: z.string().nullable().optional(),
63
79
  });
64
80
 
65
81
  /** Context occupancy for one agent, or all of them. */
@@ -1,14 +0,0 @@
1
- import type { ComponentType } from "react";
2
- import type { PluginClientContext, PluginSurfaceProps } from "@getpaseo/plugin/client";
3
- import { Pressable, Text, View } from "react-native";
4
-
5
- export function withNavigation<P extends PluginSurfaceProps>(Component: ComponentType<P>, client: PluginClientContext, home: string, active = "overview") {
6
- return function PluginView(props: P) {
7
- return <View style={{ flex: 1, backgroundColor: props.theme.colors.surface0 }}>
8
- <View accessibilityRole="toolbar" style={{ flexDirection: "row", flexWrap: "wrap", gap: 4, padding: 8, borderBottomWidth: 1, borderColor: props.theme.colors.border }}>
9
- {[{ id: "overview", title: "Overview", target: home }, { id: "session-guard", title: "Session Guard", target: "session-guard" }, { id: "display", title: "Display settings", target: "display-settings" }].map(item => <Pressable key={item.id} accessibilityRole="button" accessibilityState={{ selected: active === item.id }} onPress={() => client.openSurface(item.target)} style={({ pressed }) => ({ minHeight: 44, paddingHorizontal: 12, justifyContent: "center", borderRadius: 6, backgroundColor: pressed || active === item.id ? props.theme.colors.surface2 : "transparent" })}><Text style={{ color: active === item.id ? props.theme.colors.foreground : props.theme.colors.foregroundMuted, fontSize: 13, fontWeight: active === item.id ? "600" : "400" }}>{item.title}</Text></Pressable>)}
10
- </View>
11
- <Component {...props} />
12
- </View>;
13
- };
14
- }