paseo-smart-session 1.2.1
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/LICENSE +21 -0
- package/README.md +169 -0
- package/client/charts.tsx +185 -0
- package/client/pill.tsx +340 -0
- package/client/surface.tsx +574 -0
- package/docs/screenshots/smart-compact-handoff.png +0 -0
- package/hooks/ask-compact.mjs +186 -0
- package/hooks/asks.mjs +117 -0
- package/hooks/context-threshold.mjs +149 -0
- package/hooks/context.mjs +233 -0
- package/hooks/pointer.mjs +149 -0
- package/hooks/post-compact.mjs +135 -0
- package/index.client.tsx +42 -0
- package/index.server.ts +195 -0
- package/mcp.mjs +602 -0
- package/package.json +46 -0
- package/paseo-plugin.json +6 -0
- package/server/backfill.ts +328 -0
- package/server/daemon-password.d.mts +6 -0
- package/server/daemon-password.mjs +35 -0
- package/server/daemon.ts +198 -0
- package/server/governor.ts +288 -0
- package/server/growth.ts +67 -0
- package/server/install-on-load.ts +34 -0
- package/server/install.ts +318 -0
- package/server/recorder.ts +255 -0
- package/server/settings.ts +225 -0
- package/server/spend.ts +137 -0
- package/server/store.ts +201 -0
- package/server/upstream.ts +117 -0
- package/shared/blocks.ts +208 -0
- package/shared/format.ts +82 -0
- package/shared/governor.ts +178 -0
- package/shared/lifecycle.ts +17 -0
- package/shared/smart-session.ts +155 -0
- package/shared/thresholds.ts +115 -0
- package/shared/usage.ts +400 -0
package/client/pill.tsx
ADDED
|
@@ -0,0 +1,340 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The smart-compact pill, on every agent's composer.
|
|
3
|
+
*
|
|
4
|
+
* Enrolment is a file on disk, which makes it invisible: there was no way to look
|
|
5
|
+
* at a session and tell whether the governor was watching it. The pill is that
|
|
6
|
+
* answer, in the one place the answer matters — next to the box you are about to
|
|
7
|
+
* type into — and pressing it changes it.
|
|
8
|
+
*
|
|
9
|
+
* State lives in this module rather than in each pill, because the pill and the
|
|
10
|
+
* things that change it (its own press, the Command Center item, the surface
|
|
11
|
+
* toggle) are all in this one client bundle. One store, one fetch, and every
|
|
12
|
+
* mounted pill redraws together.
|
|
13
|
+
*
|
|
14
|
+
* Two states, on or off, and pressing it moves between them. There is no third
|
|
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.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { PluginClientContext, PluginHostProps } from "@getpaseo/plugin/client";
|
|
21
|
+
import { Icon } from "@getpaseo/plugin/client/react-native";
|
|
22
|
+
import React, { useCallback, useEffect, useRef, useState, useSyncExternalStore } from "react";
|
|
23
|
+
import { Text, View } from "react-native";
|
|
24
|
+
|
|
25
|
+
import { smartCompactLabel } from "../shared/format";
|
|
26
|
+
import { enrolmentState, setEnrolment, type EnrolmentState } from "../shared/governor";
|
|
27
|
+
|
|
28
|
+
/** How often the client re-reads enrolment, for changes made somewhere else. */
|
|
29
|
+
const REFRESH_MS = 20_000;
|
|
30
|
+
|
|
31
|
+
/** Pointer dwell before the tooltip opens, so passing over the pill is quiet. */
|
|
32
|
+
const HOVER_DELAY_MS = 300;
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* The host's own pill chrome, mirrored from Paseo's `composerPillStyles`:
|
|
36
|
+
* `spacing[3]` and `spacing[1]` of padding inside a 1px border.
|
|
37
|
+
*
|
|
38
|
+
* A plugin renders *inside* that padding, so a hover region left at its natural
|
|
39
|
+
* size answers only over the glyph and stays dead across most of the pill the user
|
|
40
|
+
* is actually pointing at. Cancelling the padding with an equal negative margin
|
|
41
|
+
* grows the region to the pill's edge and leaves the layout exactly where it was —
|
|
42
|
+
* the pill's measured size is unchanged, since the two cancel.
|
|
43
|
+
*/
|
|
44
|
+
const HOST_PILL_INSET = { horizontal: 13, vertical: 5 };
|
|
45
|
+
|
|
46
|
+
interface PillState {
|
|
47
|
+
/** Both switches folded into one answer: is there a pill on the composer at all. */
|
|
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);
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function snapshot(): PillState {
|
|
70
|
+
return state;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function publish(next: PillState): void {
|
|
74
|
+
state = next;
|
|
75
|
+
for (const listener of listeners) listener();
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function enrolmentSet(agents: EnrolmentState["agents"]): ReadonlySet<string> {
|
|
79
|
+
return new Set(agents.filter((agent) => agent.enrolled).map((agent) => agent.agentId));
|
|
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 shape this needs from the agent list, which carries far more. */
|
|
201
|
+
interface AgentListing {
|
|
202
|
+
readonly entries?: readonly { readonly agent?: { id?: string; workspaceId?: string | null } }[];
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* The published 0.8 descriptor API returns this handle. The 0.8.0-beta.1 npm
|
|
207
|
+
* package and app still return the legacy remover function, so registrations
|
|
208
|
+
* carry both input shapes and this adapter accepts either result. Remove these
|
|
209
|
+
* legacy fields once the beta client and SDK catch up with the deployed docs.
|
|
210
|
+
*/
|
|
211
|
+
interface PillRegistration {
|
|
212
|
+
update(patch: { title?: string; label?: string; visible?: boolean; disabled?: boolean }): void;
|
|
213
|
+
remove(): void;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
type PillHandle = (() => void) | PillRegistration;
|
|
217
|
+
|
|
218
|
+
function removePill(handle: PillHandle): void {
|
|
219
|
+
if (typeof handle === "function") handle();
|
|
220
|
+
else handle.remove();
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function updatePill(handle: PillHandle, title: string): void {
|
|
224
|
+
if (typeof handle !== "function") handle.update({ title });
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
export function contributeClient(client: PluginClientContext) {
|
|
228
|
+
/** Every agent this app knows of, and its pill registration while one is up. */
|
|
229
|
+
const tracked = new Map<string, { workspaceId: string; handle: PillHandle | null }>();
|
|
230
|
+
|
|
231
|
+
function register(agentId: string, entry: { workspaceId: string; handle: PillHandle | null }): void {
|
|
232
|
+
const toggle = async () => {
|
|
233
|
+
const { agent } = await client.rpc(setEnrolment, {
|
|
234
|
+
agentId,
|
|
235
|
+
enrolled: !snapshot().enrolled.has(agentId),
|
|
236
|
+
});
|
|
237
|
+
const enrolled = new Set(snapshot().enrolled);
|
|
238
|
+
if (agent.enrolled) enrolled.add(agent.agentId);
|
|
239
|
+
else enrolled.delete(agent.agentId);
|
|
240
|
+
publish({ ...snapshot(), enrolled });
|
|
241
|
+
};
|
|
242
|
+
const title = smartCompactLabel({ enrolled: snapshot().enrolled.has(agentId) });
|
|
243
|
+
const addComposerPill = client.addComposerPill as unknown as (contribution: {
|
|
244
|
+
id: string;
|
|
245
|
+
workspaceId: string;
|
|
246
|
+
agentId: string;
|
|
247
|
+
button: {
|
|
248
|
+
title: string;
|
|
249
|
+
icon: typeof AutoCompactIcon;
|
|
250
|
+
label: string;
|
|
251
|
+
behavior: { kind: "action"; onPress: () => Promise<void> };
|
|
252
|
+
};
|
|
253
|
+
title: string;
|
|
254
|
+
Component: typeof AutoCompactPill;
|
|
255
|
+
onPress: () => Promise<void>;
|
|
256
|
+
}) => PillHandle;
|
|
257
|
+
entry.handle = addComposerPill({
|
|
258
|
+
id: "smart-compact",
|
|
259
|
+
workspaceId: entry.workspaceId,
|
|
260
|
+
agentId,
|
|
261
|
+
button: {
|
|
262
|
+
title,
|
|
263
|
+
icon: AutoCompactIcon,
|
|
264
|
+
label: "Smart Compact",
|
|
265
|
+
behavior: { kind: "action", onPress: toggle },
|
|
266
|
+
},
|
|
267
|
+
// 0.8.0-beta.1 compatibility; ignored by the documented descriptor host.
|
|
268
|
+
title,
|
|
269
|
+
Component: AutoCompactPill,
|
|
270
|
+
onPress: toggle,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
/** Brings the registrations in line with the store, whichever way it moved. */
|
|
275
|
+
function sync(): void {
|
|
276
|
+
for (const [agentId, entry] of tracked) {
|
|
277
|
+
if (state.showPill && entry.handle === null) register(agentId, entry);
|
|
278
|
+
else if (!state.showPill && entry.handle !== null) {
|
|
279
|
+
removePill(entry.handle);
|
|
280
|
+
entry.handle = null;
|
|
281
|
+
} else if (entry.handle !== null) {
|
|
282
|
+
updatePill(entry.handle, smartCompactLabel({ enrolled: state.enrolled.has(agentId) }));
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function track(agentId: string, workspaceId: string): void {
|
|
288
|
+
const existing = tracked.get(agentId);
|
|
289
|
+
if (existing !== undefined && existing.workspaceId === workspaceId) return;
|
|
290
|
+
// A pill is pinned to one workspace, so an agent that moved needs a new one.
|
|
291
|
+
if (existing?.handle !== null && existing?.handle !== undefined) removePill(existing.handle);
|
|
292
|
+
tracked.set(agentId, { workspaceId, handle: null });
|
|
293
|
+
sync();
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
function forget(agentId: string): void {
|
|
297
|
+
const handle = tracked.get(agentId)?.handle;
|
|
298
|
+
if (handle !== null && handle !== undefined) removePill(handle);
|
|
299
|
+
tracked.delete(agentId);
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
const refresh = () => refreshPills(() => client.rpc(enrolmentState, {}));
|
|
303
|
+
|
|
304
|
+
const unsubscribeStore = subscribe(sync);
|
|
305
|
+
|
|
306
|
+
const unsubscribeAgents = client.paseo.agents.subscribe((update) => {
|
|
307
|
+
if (update.kind === "remove") {
|
|
308
|
+
forget(update.agentId);
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
const { id, workspaceId } = update.agent;
|
|
312
|
+
if (typeof workspaceId !== "string" || workspaceId === "") return;
|
|
313
|
+
track(id, workspaceId);
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// The subscription only carries agents that report in from now on, and an idle
|
|
317
|
+
// one may not do that for hours.
|
|
318
|
+
void (async () => {
|
|
319
|
+
try {
|
|
320
|
+
const listing = (await client.paseo.agents.list()) as unknown as AgentListing;
|
|
321
|
+
for (const entry of listing.entries ?? []) {
|
|
322
|
+
const agent = entry.agent;
|
|
323
|
+
if (typeof agent?.id !== "string" || typeof agent.workspaceId !== "string") continue;
|
|
324
|
+
track(agent.id, agent.workspaceId);
|
|
325
|
+
}
|
|
326
|
+
} catch {
|
|
327
|
+
// Not fatal: the subscription still picks up every agent that speaks next.
|
|
328
|
+
}
|
|
329
|
+
})();
|
|
330
|
+
|
|
331
|
+
void refresh().catch(() => undefined);
|
|
332
|
+
const timer = setInterval(() => void refresh().catch(() => undefined), REFRESH_MS);
|
|
333
|
+
|
|
334
|
+
return () => {
|
|
335
|
+
clearInterval(timer);
|
|
336
|
+
unsubscribeAgents();
|
|
337
|
+
unsubscribeStore();
|
|
338
|
+
for (const agentId of [...tracked.keys()]) forget(agentId);
|
|
339
|
+
};
|
|
340
|
+
}
|