paseo-smart-session 2.0.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.
- package/README.md +55 -130
- package/assets/LICENSE.lucide +43 -0
- package/assets/icon.png +0 -0
- package/assets/icon.svg +1 -0
- package/client/agents.ts +200 -0
- package/client/charts.tsx +277 -152
- package/client/chat.tsx +122 -0
- package/client/display.tsx +55 -33
- package/client/guard/index.tsx +48 -0
- package/client/guard/paused.tsx +164 -0
- package/client/guard/pill-icons.tsx +26 -0
- package/client/guard/pills.ts +132 -0
- package/client/guard/settings.tsx +114 -0
- package/client/guard/surfaces.ts +3 -0
- package/client/guard/timeline.tsx +154 -0
- package/client/kit.tsx +144 -0
- package/client/live.ts +49 -0
- package/client/motion.tsx +94 -0
- package/client/overview.tsx +373 -0
- package/client/pill-store.ts +77 -0
- package/client/pill.tsx +91 -380
- package/client/popover.tsx +113 -0
- package/client/settings.tsx +105 -0
- package/client/theme.ts +56 -0
- package/docs/media/smart-session-chat.png +0 -0
- package/docs/media/smart-session-flow.gif +0 -0
- package/docs/media/smart-session-guard.png +0 -0
- package/docs/media/smart-session-hero-light.png +0 -0
- package/docs/media/smart-session-hero.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/navigation-verification.md +6 -6
- package/docs/screenshots/display-settings-desktop.png +0 -0
- package/docs/screenshots/display-settings-mobile.png +0 -0
- package/docs/session-guard.md +88 -0
- package/index.client.tsx +52 -35
- package/index.server.ts +19 -11
- package/mcp.mjs +22 -3
- package/package.json +2 -1
- package/paseo-plugin.json +11 -0
- package/server/guard/client.ts +27 -0
- package/server/guard/engine.ts +554 -0
- package/server/guard/index.ts +395 -0
- package/server/guard/labels.ts +77 -0
- package/server/guard/processes.ts +147 -0
- package/server/guard/standalone.ts +61 -0
- package/server/guard/store.ts +113 -0
- package/server/guard/timeline.ts +24 -0
- package/server/guard/usage.ts +57 -0
- package/server/recorder.ts +2 -0
- package/server/settings.ts +10 -0
- package/server/store.ts +25 -1
- package/shared/blocks.ts +15 -0
- package/shared/chat.ts +174 -0
- package/shared/forecast.ts +233 -0
- package/shared/format.ts +47 -1
- package/shared/governor.ts +3 -0
- package/shared/guard/contracts.ts +65 -0
- package/shared/guard/policy.ts +128 -0
- package/shared/guard/presentation.ts +85 -0
- package/shared/guard/timeline.ts +34 -0
- package/shared/lifecycle.ts +18 -0
- package/shared/smart-session.ts +16 -0
- package/client/navigation.tsx +0 -14
- package/client/surface.tsx +0 -574
- package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/pill.tsx
CHANGED
|
@@ -1,434 +1,145 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The
|
|
2
|
+
* The Smart Compact pill, on every agent's composer.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* It answers two questions where they matter, next to the box you are about to type
|
|
5
|
+
* into: how full is this session's context, and will Smart Compact ask it to compact
|
|
6
|
+
* itself. The icon is a ring that fills with the context and carries a tick at the
|
|
7
|
+
* compact line; the label is the percentage. Pressing it opens a popover with the
|
|
8
|
+
* detail and the enrolment switch.
|
|
8
9
|
*
|
|
9
10
|
* State lives in this module rather than in each pill, because the pill and the
|
|
10
|
-
* things that change it (its own
|
|
11
|
-
*
|
|
12
|
-
* mounted pill redraws together.
|
|
11
|
+
* things that change it (its own popover, the Command Center item, the overview and
|
|
12
|
+
* settings) are all in this one client bundle. One store, one fetch, and every
|
|
13
|
+
* mounted pill redraws together. Context occupancy comes from the shared agent
|
|
14
|
+
* directory, which already carries each agent's last usage, so the ring moves with
|
|
15
|
+
* every turn without a poll of its own.
|
|
13
16
|
*
|
|
14
|
-
*
|
|
15
|
-
* state for "on but nothing will happen", because there is no longer a global
|
|
16
|
-
* switch that can make an enrolled session inert — the master switch hides the pill
|
|
17
|
-
* outright instead.
|
|
17
|
+
* With Smart Compact off there is no pill at all: there is nothing for one to say.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
-
import type { PluginClientContext,
|
|
21
|
-
import {
|
|
22
|
-
import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
23
|
-
import { Text, View } from "react-native";
|
|
20
|
+
import type { PluginButtonIconProps, PluginClientContext, PluginButtonRegistration } from "@getpaseo/plugin/client";
|
|
21
|
+
import { useSyncExternalStore } from "react";
|
|
24
22
|
|
|
25
|
-
import {
|
|
26
|
-
import { enrolmentState
|
|
23
|
+
import { contextOutlook } from "../shared/forecast";
|
|
24
|
+
import { enrolmentState } from "../shared/governor";
|
|
25
|
+
import { pillSnapshot, refreshPills, state as currentState, subscribePills } from "./pill-store";
|
|
26
|
+
|
|
27
|
+
export { refreshPills } from "./pill-store";
|
|
28
|
+
import { profileFor } from "../shared/thresholds";
|
|
29
|
+
import type { Thresholds } from "../shared/thresholds";
|
|
30
|
+
import type { AgentDirectory, DirectoryAgent } from "./agents";
|
|
31
|
+
import { Ring, toneFill } from "./charts";
|
|
32
|
+
import { makeContextPopover } from "./popover";
|
|
27
33
|
|
|
28
34
|
/** How often the client re-reads enrolment, for changes made somewhere else. */
|
|
29
35
|
const REFRESH_MS = 20_000;
|
|
30
36
|
|
|
31
|
-
/**
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
readonly showPill: boolean;
|
|
49
|
-
readonly enrolled: ReadonlySet<string>;
|
|
50
|
-
}
|
|
51
|
-
|
|
52
|
-
/**
|
|
53
|
-
* Hidden until the daemon says otherwise.
|
|
54
|
-
*
|
|
55
|
-
* The alternative — assume shown, then retract — flashes a pill at everyone who
|
|
56
|
-
* turned it off, every time the app loads.
|
|
57
|
-
*/
|
|
58
|
-
let state: PillState = { showPill: false, enrolled: new Set() };
|
|
59
|
-
|
|
60
|
-
const listeners = new Set<() => void>();
|
|
61
|
-
|
|
62
|
-
function subscribe(listener: () => void): () => void {
|
|
63
|
-
listeners.add(listener);
|
|
64
|
-
return () => {
|
|
65
|
-
listeners.delete(listener);
|
|
37
|
+
/** Everything a pill shows about one agent, in one place for the icon, label and title. */
|
|
38
|
+
export function pillPresentation(agent: Pick<DirectoryAgent, "usedTokens" | "maxTokens"> | undefined, enrolled: boolean, thresholds: Thresholds | null) {
|
|
39
|
+
const known = agent !== undefined && agent.usedTokens !== null && agent.maxTokens !== null;
|
|
40
|
+
if (!known) {
|
|
41
|
+
return {
|
|
42
|
+
label: "Smart Compact",
|
|
43
|
+
title: enrolled ? "Smart Compact on · no context reading yet" : "Smart Compact off · no context reading yet",
|
|
44
|
+
fraction: 0, mark: null as number | null, tone: "default" as const, pct: null as number | null,
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const compactAtPct = profileFor(agent.maxTokens!, thresholds ?? undefined).compact;
|
|
48
|
+
const outlook = contextOutlook({ usedTokens: agent.usedTokens!, maxTokens: agent.maxTokens!, compactAtPct });
|
|
49
|
+
const pct = Math.round(outlook.pct);
|
|
50
|
+
return {
|
|
51
|
+
label: `${pct}%`,
|
|
52
|
+
title: `Context ${pct}% full · ${enrolled ? `Smart Compact asks at ${compactAtPct}%` : "Smart Compact off for this session"}`,
|
|
53
|
+
fraction: outlook.pct / 100, mark: compactAtPct / 100, tone: outlook.tone, pct,
|
|
66
54
|
};
|
|
67
55
|
}
|
|
68
56
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Re-reads enrolment and redraws every pill.
|
|
84
|
-
*
|
|
85
|
-
* Takes the call rather than making it: the same fetch is reachable from the client
|
|
86
|
-
* entry, a Command Center item and the surface, and each of those holds a different
|
|
87
|
-
* flavour of RPC caller.
|
|
88
|
-
*/
|
|
89
|
-
export async function refreshPills(fetch: () => Promise<EnrolmentState>): Promise<void> {
|
|
90
|
-
const next = await fetch();
|
|
91
|
-
publish({
|
|
92
|
-
// With the feature off there is nothing for a pill to say. Drawing a third,
|
|
93
|
-
// "paused" state would just be a second way of spelling off.
|
|
94
|
-
showPill: next.enabled && next.showPill,
|
|
95
|
-
enrolled: enrolmentSet(next.agents),
|
|
96
|
-
});
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
/**
|
|
100
|
-
* One icon, and the words only when you ask for them.
|
|
101
|
-
*
|
|
102
|
-
* The composer track is one line shared with Paseo's own pills, and this one has
|
|
103
|
-
* nothing to say that changes minute to minute — so it states itself in colour and
|
|
104
|
-
* spells it out on hover. Pointer platforms get the tooltip; touch hosts never fire
|
|
105
|
-
* these events and read the colour, or press.
|
|
106
|
-
*
|
|
107
|
-
* Paseo owns the pressable, the border and the spinner; this owns what is in it.
|
|
108
|
-
*/
|
|
109
|
-
type LegacyPillProps = PluginHostProps & { workspaceId: string; agentId: string };
|
|
110
|
-
|
|
111
|
-
export function AutoCompactPill({ theme, agentId }: LegacyPillProps) {
|
|
112
|
-
const current = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
113
|
-
const [tooltip, setTooltip] = useState(false);
|
|
114
|
-
const timer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
115
|
-
|
|
116
|
-
const clearHoverTimer = useCallback(() => {
|
|
117
|
-
if (timer.current === null) return;
|
|
118
|
-
clearTimeout(timer.current);
|
|
119
|
-
timer.current = null;
|
|
120
|
-
}, []);
|
|
121
|
-
useEffect(() => clearHoverTimer, [clearHoverTimer]);
|
|
122
|
-
|
|
123
|
-
const onPointerEnter = useCallback(() => {
|
|
124
|
-
if (timer.current !== null) return;
|
|
125
|
-
timer.current = setTimeout(() => {
|
|
126
|
-
timer.current = null;
|
|
127
|
-
setTooltip(true);
|
|
128
|
-
}, HOVER_DELAY_MS);
|
|
129
|
-
}, []);
|
|
130
|
-
|
|
131
|
-
const onPointerLeave = useCallback(() => {
|
|
132
|
-
clearHoverTimer();
|
|
133
|
-
setTooltip(false);
|
|
134
|
-
}, [clearHoverTimer]);
|
|
135
|
-
|
|
136
|
-
const enrolled = current.enrolled.has(agentId);
|
|
137
|
-
const color = enrolled ? theme.colors.accent : theme.colors.foregroundMuted;
|
|
138
|
-
return (
|
|
139
|
-
<View
|
|
140
|
-
style={{
|
|
141
|
-
flexDirection: "row",
|
|
142
|
-
alignItems: "center",
|
|
143
|
-
justifyContent: "center",
|
|
144
|
-
// Stretch plus the cancelled padding is what makes the whole pill hoverable,
|
|
145
|
-
// vertically as well: Paseo's pill is 32px high and this glyph is 14.
|
|
146
|
-
alignSelf: "stretch",
|
|
147
|
-
marginVertical: -HOST_PILL_INSET.vertical,
|
|
148
|
-
marginHorizontal: -HOST_PILL_INSET.horizontal,
|
|
149
|
-
paddingVertical: HOST_PILL_INSET.vertical,
|
|
150
|
-
paddingHorizontal: HOST_PILL_INSET.horizontal,
|
|
151
|
-
}}
|
|
152
|
-
onPointerEnter={onPointerEnter}
|
|
153
|
-
onPointerLeave={onPointerLeave}
|
|
154
|
-
>
|
|
155
|
-
<Icon name="FoldVertical" size={14} color={color} />
|
|
156
|
-
{tooltip ? (
|
|
157
|
-
// Drawn above the pill rather than beside it: the track bar is one line
|
|
158
|
-
// high, and widening the pill on hover would shove its neighbours along.
|
|
159
|
-
<View
|
|
160
|
-
style={{
|
|
161
|
-
position: "absolute",
|
|
162
|
-
bottom: "100%",
|
|
163
|
-
left: 0,
|
|
164
|
-
marginBottom: 8,
|
|
165
|
-
gap: 2,
|
|
166
|
-
paddingVertical: 6,
|
|
167
|
-
paddingHorizontal: 10,
|
|
168
|
-
borderRadius: 8,
|
|
169
|
-
borderWidth: 1,
|
|
170
|
-
borderColor: theme.colors.border,
|
|
171
|
-
backgroundColor: theme.colors.surface1,
|
|
172
|
-
}}
|
|
173
|
-
>
|
|
174
|
-
<Text numberOfLines={1} style={{ color: theme.colors.foreground, fontSize: 13 }}>
|
|
175
|
-
{smartCompactLabel({ enrolled })}
|
|
176
|
-
</Text>
|
|
177
|
-
<Text numberOfLines={1} style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
|
|
178
|
-
{enrolled ? "Press to take this session out" : "Press to enrol this session"}
|
|
179
|
-
</Text>
|
|
180
|
-
</View>
|
|
181
|
-
) : null}
|
|
182
|
-
</View>
|
|
183
|
-
);
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
/**
|
|
187
|
-
* The v0.8 button-descriptor icon. The host owns its tooltip and interaction;
|
|
188
|
-
* this component only paints the current state inside the icon slot.
|
|
189
|
-
*/
|
|
190
|
-
function AutoCompactIcon({
|
|
191
|
-
theme,
|
|
192
|
-
agentId,
|
|
193
|
-
size,
|
|
194
|
-
}: PluginHostProps & { agentId: string; size: number; color: string }) {
|
|
195
|
-
const current = useSyncExternalStore(subscribe, snapshot, snapshot);
|
|
196
|
-
const color = current.enrolled.has(agentId) ? theme.colors.accent : theme.colors.foregroundMuted;
|
|
197
|
-
return <Icon name="FoldVertical" size={size} color={color} />;
|
|
198
|
-
}
|
|
199
|
-
|
|
200
|
-
/** The lean shapes this needs from the agent directory, which carries far more. */
|
|
201
|
-
interface ListedAgent {
|
|
202
|
-
readonly id?: string;
|
|
203
|
-
readonly workspaceId?: string | null;
|
|
204
|
-
}
|
|
205
|
-
|
|
206
|
-
interface AgentUpdate {
|
|
207
|
-
readonly kind: "remove" | "upsert";
|
|
208
|
-
readonly agentId?: string;
|
|
209
|
-
readonly agent?: ListedAgent;
|
|
210
|
-
}
|
|
211
|
-
|
|
212
|
-
interface AgentListing {
|
|
213
|
-
readonly entries?: readonly { readonly agent?: ListedAgent }[];
|
|
214
|
-
readonly subscription?: {
|
|
215
|
-
subscribe(observer: {
|
|
216
|
-
snapshot(listing: AgentListing): void;
|
|
217
|
-
update(message: { readonly type?: string; readonly payload?: AgentUpdate }): void;
|
|
218
|
-
}): () => void;
|
|
219
|
-
release(): Promise<void>;
|
|
57
|
+
/** One live icon per pill: the ring, muted while the session is not enrolled. */
|
|
58
|
+
function makeIcon(agents: AgentDirectory) {
|
|
59
|
+
return function ContextRingIcon(props: PluginButtonIconProps) {
|
|
60
|
+
const current = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
|
|
61
|
+
useSyncExternalStore(agents.subscribe, agents.version, agents.version);
|
|
62
|
+
const agentId = props.context === "agent" ? props.agentId : "";
|
|
63
|
+
const enrolled = current.enrolled.has(agentId);
|
|
64
|
+
const view = pillPresentation(agents.current().get(agentId), enrolled, current.thresholds);
|
|
65
|
+
const c = props.theme.colors;
|
|
66
|
+
const color = view.tone !== "default" ? toneFill(c, view.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
67
|
+
return <Ring size={props.size} color={color} fraction={view.fraction} mark={enrolled ? view.mark : null} />;
|
|
220
68
|
};
|
|
221
69
|
}
|
|
222
70
|
|
|
223
|
-
|
|
224
|
-
* The published 0.8 descriptor API returns this handle. The 0.8.0-beta.1 npm
|
|
225
|
-
* package and app still return the legacy remover function, so registrations
|
|
226
|
-
* carry both input shapes and this adapter accepts either result. Remove these
|
|
227
|
-
* legacy fields once the beta client and SDK catch up with the deployed docs.
|
|
228
|
-
*/
|
|
229
|
-
interface PillRegistration {
|
|
230
|
-
update(patch: { title?: string; label?: string; visible?: boolean; disabled?: boolean }): void;
|
|
231
|
-
remove(): void;
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
type PillHandle = (() => void) | PillRegistration;
|
|
235
|
-
|
|
236
|
-
function removePill(handle: PillHandle): void {
|
|
237
|
-
if (typeof handle === "function") handle();
|
|
238
|
-
else handle.remove();
|
|
239
|
-
}
|
|
240
|
-
|
|
241
|
-
function updatePill(handle: PillHandle, title: string): void {
|
|
242
|
-
if (typeof handle !== "function") handle.update({ title });
|
|
243
|
-
}
|
|
244
|
-
|
|
245
|
-
export function contributeClient(client: PluginClientContext) {
|
|
71
|
+
export function contributeClient(client: PluginClientContext, agents: AgentDirectory) {
|
|
246
72
|
/** Every agent this app knows of, and its pill registration while one is up. */
|
|
247
|
-
const tracked = new Map<string, { workspaceId: string; handle:
|
|
248
|
-
const
|
|
249
|
-
|
|
250
|
-
let unsubscribeOwnedObservation: (() => void) | null = null;
|
|
251
|
-
let releaseAgentObservation: (() => Promise<void>) | null = null;
|
|
73
|
+
const tracked = new Map<string, { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }>();
|
|
74
|
+
const Icon = makeIcon(agents);
|
|
75
|
+
const Content = makeContextPopover(client, agents);
|
|
252
76
|
|
|
253
|
-
function
|
|
254
|
-
const
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
publish({ ...snapshot(), enrolled });
|
|
263
|
-
};
|
|
264
|
-
const title = smartCompactLabel({ enrolled: snapshot().enrolled.has(agentId) });
|
|
265
|
-
const addComposerPill = client.addComposerPill as unknown as (contribution: {
|
|
266
|
-
id: string;
|
|
267
|
-
workspaceId: string;
|
|
268
|
-
agentId: string;
|
|
269
|
-
button: {
|
|
270
|
-
title: string;
|
|
271
|
-
icon: typeof AutoCompactIcon;
|
|
272
|
-
label: string;
|
|
273
|
-
behavior: { kind: "action"; onPress: () => Promise<void> };
|
|
274
|
-
};
|
|
275
|
-
title: string;
|
|
276
|
-
Component: typeof AutoCompactPill;
|
|
277
|
-
onPress: () => Promise<void>;
|
|
278
|
-
}) => PillHandle;
|
|
279
|
-
entry.handle = addComposerPill({
|
|
77
|
+
function describe(agentId: string) {
|
|
78
|
+
const view = pillPresentation(agents.current().get(agentId), currentState().enrolled.has(agentId), currentState().thresholds);
|
|
79
|
+
return { label: view.label, title: view.title };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function register(agentId: string, entry: { workspaceId: string; handle: PluginButtonRegistration | null; signature: string }): void {
|
|
83
|
+
const { label, title } = describe(agentId);
|
|
84
|
+
entry.signature = `${label}|${title}`;
|
|
85
|
+
entry.handle = client.addComposerPill({
|
|
280
86
|
id: "smart-compact",
|
|
281
87
|
workspaceId: entry.workspaceId,
|
|
282
88
|
agentId,
|
|
283
|
-
button: {
|
|
284
|
-
title,
|
|
285
|
-
icon: AutoCompactIcon,
|
|
286
|
-
label: "Smart Compact",
|
|
287
|
-
behavior: { kind: "action", onPress: toggle },
|
|
288
|
-
},
|
|
289
|
-
// 0.8.0-beta.1 compatibility; ignored by the documented descriptor host.
|
|
290
|
-
title,
|
|
291
|
-
Component: AutoCompactPill,
|
|
292
|
-
onPress: toggle,
|
|
89
|
+
button: { title, label, icon: Icon, behavior: { kind: "popover", Content } },
|
|
293
90
|
});
|
|
294
91
|
}
|
|
295
92
|
|
|
296
93
|
/** Brings the registrations in line with the store, whichever way it moved. */
|
|
297
94
|
function sync(): void {
|
|
298
95
|
for (const [agentId, entry] of tracked) {
|
|
299
|
-
if (
|
|
300
|
-
else if (!
|
|
301
|
-
|
|
96
|
+
if (currentState().showPill && entry.handle === null) register(agentId, entry);
|
|
97
|
+
else if (!currentState().showPill && entry.handle !== null) {
|
|
98
|
+
entry.handle.remove();
|
|
302
99
|
entry.handle = null;
|
|
303
100
|
} else if (entry.handle !== null) {
|
|
304
|
-
|
|
101
|
+
const { label, title } = describe(agentId);
|
|
102
|
+
const signature = `${label}|${title}`;
|
|
103
|
+
// Only a changed label is sent: a fresh behaviour would remount an open popover.
|
|
104
|
+
if (signature !== entry.signature) {
|
|
105
|
+
entry.signature = signature;
|
|
106
|
+
entry.handle.update({ label, title });
|
|
107
|
+
}
|
|
305
108
|
}
|
|
306
109
|
}
|
|
307
110
|
}
|
|
308
111
|
|
|
309
|
-
function track(agentId: string, workspaceId: string): void {
|
|
310
|
-
const existing = tracked.get(agentId);
|
|
311
|
-
if (existing !== undefined && existing.workspaceId === workspaceId) return;
|
|
312
|
-
// A pill is pinned to one workspace, so an agent that moved needs a new one.
|
|
313
|
-
if (existing?.handle !== null && existing?.handle !== undefined) removePill(existing.handle);
|
|
314
|
-
tracked.set(agentId, { workspaceId, handle: null });
|
|
315
|
-
sync();
|
|
316
|
-
}
|
|
317
|
-
|
|
318
112
|
function forget(agentId: string): void {
|
|
319
|
-
|
|
320
|
-
if (handle !== null && handle !== undefined) removePill(handle);
|
|
113
|
+
tracked.get(agentId)?.handle?.remove();
|
|
321
114
|
tracked.delete(agentId);
|
|
322
115
|
}
|
|
323
116
|
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
const agent = update.agent;
|
|
330
|
-
if (
|
|
331
|
-
typeof agent?.id !== "string" ||
|
|
332
|
-
typeof agent.workspaceId !== "string" ||
|
|
333
|
-
agent.workspaceId === ""
|
|
334
|
-
) return;
|
|
335
|
-
track(agent.id, agent.workspaceId);
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
function trackAgents(entries: AgentListing["entries"]): Set<string> {
|
|
339
|
-
const present = new Set<string>();
|
|
340
|
-
for (const entry of entries ?? []) {
|
|
341
|
-
const agent = entry.agent;
|
|
342
|
-
if (
|
|
343
|
-
typeof agent?.id !== "string" ||
|
|
344
|
-
typeof agent.workspaceId !== "string" ||
|
|
345
|
-
agent.workspaceId === ""
|
|
346
|
-
) continue;
|
|
347
|
-
present.add(agent.id);
|
|
348
|
-
track(agent.id, agent.workspaceId);
|
|
117
|
+
/** Follows the shared directory: new agents gain a pill, moved or removed ones lose theirs. */
|
|
118
|
+
function followDirectory(): void {
|
|
119
|
+
const current = agents.current();
|
|
120
|
+
for (const [agentId, entry] of [...tracked]) {
|
|
121
|
+
if (current.get(agentId)?.workspaceId !== entry.workspaceId) forget(agentId);
|
|
349
122
|
}
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
/** A restored observation replaces its directory snapshot, including removals. */
|
|
354
|
-
function replaceAgents(entries: AgentListing["entries"]): void {
|
|
355
|
-
const present = trackAgents(entries);
|
|
356
|
-
for (const agentId of [...tracked.keys()]) {
|
|
357
|
-
if (!present.has(agentId)) forget(agentId);
|
|
123
|
+
for (const [agentId, agent] of current) {
|
|
124
|
+
// A pill is pinned to one workspace, so an agent that moved needs a new one.
|
|
125
|
+
if (!tracked.has(agentId)) tracked.set(agentId, { workspaceId: agent.workspaceId, handle: null, signature: "" });
|
|
358
126
|
}
|
|
127
|
+
sync();
|
|
359
128
|
}
|
|
360
129
|
|
|
361
130
|
const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
|
|
362
131
|
|
|
363
|
-
const unsubscribeStore =
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
// local listener. Keep it attached until a newer runtime gives us the owned
|
|
367
|
-
// observation whose snapshots can reconcile the whole directory on reconnect.
|
|
368
|
-
let unsubscribeLegacyAgents: (() => void) | null = client.paseo.agents.subscribe((update) => {
|
|
369
|
-
applyAgentUpdate(update as AgentUpdate);
|
|
370
|
-
});
|
|
371
|
-
|
|
372
|
-
// The subscription only carries agents that report in from now on, and an idle
|
|
373
|
-
// one may not do that for hours.
|
|
374
|
-
void (async () => {
|
|
375
|
-
try {
|
|
376
|
-
// `agents.subscribe()` listens only to observations already owned by
|
|
377
|
-
// this API instance. A plain list stopped receiving newly created agents
|
|
378
|
-
// in Paseo 0.9, which made both plugin pills disappear on new sessions.
|
|
379
|
-
const listAgents = client.paseo.agents.list as unknown as (options: {
|
|
380
|
-
subscribe: Record<string, never>;
|
|
381
|
-
signal: AbortSignal;
|
|
382
|
-
}) => Promise<AgentListing>;
|
|
383
|
-
const listing = await listAgents({ subscribe: {}, signal: lifetime.signal });
|
|
384
|
-
const release = listing.subscription
|
|
385
|
-
? () => listing.subscription!.release()
|
|
386
|
-
: null;
|
|
387
|
-
if (stopped) {
|
|
388
|
-
await release?.();
|
|
389
|
-
return;
|
|
390
|
-
}
|
|
391
|
-
releaseAgentObservation = release;
|
|
392
|
-
// The legacy listener was already live while this snapshot was in flight,
|
|
393
|
-
// so bootstrap must be additive or it can erase a just-created agent.
|
|
394
|
-
trackAgents(listing.entries);
|
|
395
|
-
if (listing.subscription) {
|
|
396
|
-
unsubscribeOwnedObservation = listing.subscription.subscribe({
|
|
397
|
-
snapshot(snapshot) {
|
|
398
|
-
if (!stopped) replaceAgents(snapshot.entries);
|
|
399
|
-
},
|
|
400
|
-
update(message) {
|
|
401
|
-
if (!stopped && message.type === "agent_update" && message.payload) {
|
|
402
|
-
applyAgentUpdate(message.payload);
|
|
403
|
-
}
|
|
404
|
-
},
|
|
405
|
-
});
|
|
406
|
-
unsubscribeLegacyAgents?.();
|
|
407
|
-
unsubscribeLegacyAgents = null;
|
|
408
|
-
}
|
|
409
|
-
} catch {
|
|
410
|
-
const release = releaseAgentObservation;
|
|
411
|
-
releaseAgentObservation = null;
|
|
412
|
-
await release?.().catch(() => undefined);
|
|
413
|
-
// Not fatal to the rest of the client contribution; a reload can retry.
|
|
414
|
-
}
|
|
415
|
-
})();
|
|
132
|
+
const unsubscribeStore = subscribePills(sync);
|
|
133
|
+
const unsubscribeAgents = agents.subscribe(followDirectory);
|
|
134
|
+
followDirectory();
|
|
416
135
|
|
|
417
136
|
void refresh().catch(() => undefined);
|
|
418
137
|
const timer = setInterval(() => void refresh().catch(() => undefined), REFRESH_MS);
|
|
419
138
|
|
|
420
|
-
return
|
|
421
|
-
stopped = true;
|
|
422
|
-
lifetime.abort();
|
|
139
|
+
return () => {
|
|
423
140
|
clearInterval(timer);
|
|
424
|
-
|
|
425
|
-
unsubscribeLegacyAgents = null;
|
|
426
|
-
unsubscribeOwnedObservation?.();
|
|
427
|
-
unsubscribeOwnedObservation = null;
|
|
141
|
+
unsubscribeAgents();
|
|
428
142
|
unsubscribeStore();
|
|
429
143
|
for (const agentId of [...tracked.keys()]) forget(agentId);
|
|
430
|
-
const release = releaseAgentObservation;
|
|
431
|
-
releaseAgentObservation = null;
|
|
432
|
-
await release?.().catch(() => undefined);
|
|
433
144
|
};
|
|
434
145
|
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { useState, useSyncExternalStore } from "react";
|
|
2
|
+
import { Text, View } from "react-native";
|
|
3
|
+
import { useRpc, type PluginButtonContentProps, type PluginClientContext } from "@getpaseo/plugin/client";
|
|
4
|
+
import { useQuery } from "@tanstack/react-query";
|
|
5
|
+
|
|
6
|
+
import { formatCompactTokens, formatDuration, formatRelative } from "../shared/format";
|
|
7
|
+
import { contextOutlook } from "../shared/forecast";
|
|
8
|
+
import { listCompactions, setEnrolment, type CompactionRequest } from "../shared/governor";
|
|
9
|
+
import { contextStatus } from "../shared/smart-session";
|
|
10
|
+
import { profileFor } from "../shared/thresholds";
|
|
11
|
+
import type { AgentDirectory } from "./agents";
|
|
12
|
+
import { Meter, Ring, toneFill } from "./charts";
|
|
13
|
+
import { Switch, TextLink, errorText, text, useNow } from "./kit";
|
|
14
|
+
import { noteEnrolment, pillSnapshot, subscribePills } from "./pill-store";
|
|
15
|
+
import { tabular, toneColor } from "./theme";
|
|
16
|
+
|
|
17
|
+
/** What the last compaction did, in one line. */
|
|
18
|
+
function compactionLine(item: CompactionRequest | undefined, now: number): string {
|
|
19
|
+
if (item === undefined) return "None yet";
|
|
20
|
+
if (item.state === "pending" || item.state === "sending") return "Queued · sent when this turn ends";
|
|
21
|
+
if (item.state === "failed") return `Failed ${formatRelative(item.settledAt ?? item.createdAt, now)}`;
|
|
22
|
+
if (item.state === "cancelled") return `Cancelled ${formatRelative(item.settledAt ?? item.createdAt, now)}`;
|
|
23
|
+
const when = formatRelative(item.settledAt ?? item.createdAt, now);
|
|
24
|
+
return item.preTokens !== null && item.postTokens !== null ? `${when} · ${formatCompactTokens(item.preTokens)} → ${formatCompactTokens(item.postTokens)}` : when;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
/** The pill's popover: this session's context, its compact line, task state and enrolment. */
|
|
28
|
+
export function makeContextPopover(client: PluginClientContext, agents: AgentDirectory) {
|
|
29
|
+
return function ContextPopover({ theme, layout, close, ...rest }: PluginButtonContentProps) {
|
|
30
|
+
const agentId = rest.context === "agent" ? rest.agentId : "";
|
|
31
|
+
const c = theme.colors;
|
|
32
|
+
const t = text(c);
|
|
33
|
+
const now = useNow(30_000);
|
|
34
|
+
const pills = useSyncExternalStore(subscribePills, pillSnapshot, pillSnapshot);
|
|
35
|
+
useSyncExternalStore(agents.subscribe, agents.version, agents.version);
|
|
36
|
+
const readContext = useRpc(contextStatus);
|
|
37
|
+
const readCompactions = useRpc(listCompactions);
|
|
38
|
+
const writeEnrolment = useRpc(setEnrolment);
|
|
39
|
+
const [saving, setSaving] = useState(false);
|
|
40
|
+
const [problem, setProblem] = useState<string | null>(null);
|
|
41
|
+
const detail = useQuery({ queryKey: ["smart-session", "context", agentId], queryFn: () => readContext({ agentId }), refetchInterval: 30_000 });
|
|
42
|
+
const compactions = useQuery({ queryKey: ["smart-session", "compactions", agentId], queryFn: () => readCompactions({ agentId }), refetchInterval: 30_000 });
|
|
43
|
+
|
|
44
|
+
const agent = agents.current().get(agentId);
|
|
45
|
+
const row = detail.data?.agents[0];
|
|
46
|
+
const used = agent?.usedTokens ?? row?.usedTokens ?? null;
|
|
47
|
+
const max = agent?.maxTokens ?? row?.maxTokens ?? null;
|
|
48
|
+
const enrolled = pills.enrolled.has(agentId);
|
|
49
|
+
const compact = layout.compact;
|
|
50
|
+
|
|
51
|
+
async function toggle(next: boolean) {
|
|
52
|
+
setSaving(true); setProblem(null);
|
|
53
|
+
try {
|
|
54
|
+
const { agent: saved } = await writeEnrolment({ agentId, enrolled: next });
|
|
55
|
+
noteEnrolment(saved.agentId, saved.enrolled);
|
|
56
|
+
} catch (error) {
|
|
57
|
+
setProblem(errorText(error));
|
|
58
|
+
} finally {
|
|
59
|
+
setSaving(false);
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const head = (() => {
|
|
64
|
+
if (used === null || max === null) return null;
|
|
65
|
+
const compactAtPct = row?.compactAtPct ?? profileFor(max, pills.thresholds ?? undefined).compact;
|
|
66
|
+
return { compactAtPct, outlook: contextOutlook({ usedTokens: used, maxTokens: max, compactAtPct, growthTokensPerHour: row?.growthTokensPerHour ?? null }) };
|
|
67
|
+
})();
|
|
68
|
+
const sentence = head === null
|
|
69
|
+
? { line: "No context reading yet. A session reports one after its first turn.", tone: "default" as const }
|
|
70
|
+
: head.outlook.past
|
|
71
|
+
? { line: enrolled ? "Past its compact line. Smart Compact asks it at the end of a turn whether to compact." : "Past its compact line, but not enrolled, so nothing will ask it.", tone: head.outlook.tone }
|
|
72
|
+
: head.outlook.untilCompactMs !== null
|
|
73
|
+
? { line: `Reaches its compact line in about ${formatDuration(head.outlook.untilCompactMs)} at +${formatCompactTokens(row!.growthTokensPerHour!)}/h.`, tone: "default" as const }
|
|
74
|
+
: { line: "Below its compact line and not growing right now.", tone: "default" as const };
|
|
75
|
+
const latest = compactions.data?.items[0];
|
|
76
|
+
const state = row?.stateWrittenAt ?? null;
|
|
77
|
+
const facts: [string, string][] = [
|
|
78
|
+
["Task state", state === null ? (row === undefined ? "…" : "Not written yet") : `Saved ${formatRelative(state, now)}`],
|
|
79
|
+
["Last compaction", compactions.data === undefined ? "…" : compactionLine(latest, now)],
|
|
80
|
+
];
|
|
81
|
+
const ringColor = head === null ? c.foregroundMuted : head.outlook.tone !== "default" ? toneFill(c, head.outlook.tone) : enrolled ? c.foreground : c.foregroundMuted;
|
|
82
|
+
|
|
83
|
+
return <View style={{ width: compact ? "100%" : 340, padding: compact ? 16 : 14, gap: 12 }}>
|
|
84
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|
85
|
+
<Ring size={40} color={ringColor} fraction={head === null ? 0 : head.outlook.pct / 100} mark={head === null ? null : head.compactAtPct / 100} />
|
|
86
|
+
<View style={{ flex: 1, gap: 1 }}>
|
|
87
|
+
<View style={{ flexDirection: "row", alignItems: "baseline", gap: 6 }}>
|
|
88
|
+
<Text style={{ color: c.foreground, fontSize: 22, lineHeight: 28, fontWeight: "600" }}>{head === null ? "—" : `${Math.round(head.outlook.pct)}%`}</Text>
|
|
89
|
+
<Text style={t.meta}>{max === null ? "context" : `of ${formatCompactTokens(max)} context`}</Text>
|
|
90
|
+
</View>
|
|
91
|
+
<Text style={t.meta}>{head === null || used === null ? "Waiting for a reading" : `${formatCompactTokens(used)} tokens · compacts at ${head.compactAtPct}% (${formatCompactTokens(head.outlook.compactTokens)})`}</Text>
|
|
92
|
+
</View>
|
|
93
|
+
</View>
|
|
94
|
+
{head === null ? null : <Meter theme={theme} pct={head.outlook.pct} tone={head.outlook.tone} mark={head.compactAtPct} height={6} label={`Context ${Math.round(head.outlook.pct)}% full, compacts at ${head.compactAtPct}%`} />}
|
|
95
|
+
<Text style={{ ...t.body, color: sentence.tone === "default" ? c.foreground : toneColor(c, sentence.tone) }}>{sentence.line}</Text>
|
|
96
|
+
<View style={{ gap: 6, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
|
|
97
|
+
{facts.map(([label, value]) => <View key={label} style={{ flexDirection: "row", gap: 12 }}>
|
|
98
|
+
<Text style={{ ...t.meta, width: 112 }}>{label}</Text>
|
|
99
|
+
<Text style={{ ...t.meta, flex: 1, color: c.foreground, ...tabular }}>{value}</Text>
|
|
100
|
+
</View>)}
|
|
101
|
+
</View>
|
|
102
|
+
<View style={{ flexDirection: "row", alignItems: "center", gap: 12, paddingTop: 10, borderTopWidth: 1, borderTopColor: c.border }}>
|
|
103
|
+
<View style={{ flex: 1, gap: 2 }}>
|
|
104
|
+
<Text style={t.label}>Smart Compact for this session</Text>
|
|
105
|
+
<Text style={t.meta}>{enrolled ? `Asked to compact itself once past ${head?.compactAtPct ?? "its"}%${head === null ? " line" : ""}. Never forced.` : "Never asked to compact. The agent can still request it."}</Text>
|
|
106
|
+
</View>
|
|
107
|
+
<Switch theme={theme} label="Smart Compact for this session" hideLabel value={enrolled} disabled={saving} compact={compact} onChange={(next) => void toggle(next)} />
|
|
108
|
+
</View>
|
|
109
|
+
{problem ? <Text accessibilityRole="alert" style={{ ...t.meta, color: c.statusDanger }}>{problem}</Text> : null}
|
|
110
|
+
<TextLink theme={theme} compact={compact} label="Plan usage and every session" onPress={() => { close(); client.openScreen({ screenId: "overview" }); }} />
|
|
111
|
+
</View>;
|
|
112
|
+
};
|
|
113
|
+
}
|