paseo-smart-session 2.1.0 → 3.0.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 +40 -11
- package/client/agents.ts +26 -4
- package/client/charts.tsx +277 -152
- package/client/chat.tsx +122 -0
- package/client/display.tsx +55 -33
- package/client/guard/index.tsx +14 -16
- package/client/guard/paused.tsx +125 -38
- package/client/guard/pill-icons.tsx +1 -1
- package/client/guard/pills.ts +16 -6
- package/client/guard/surfaces.ts +3 -2
- package/client/guard/timeline.tsx +116 -56
- 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 +83 -260
- 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 +3 -3
- package/index.client.tsx +47 -35
- package/index.server.ts +14 -5
- package/mcp.mjs +1 -1
- package/package.json +1 -1
- package/paseo-plugin.json +10 -0
- package/server/settings.ts +10 -0
- 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/smart-session.ts +16 -0
- package/client/navigation.tsx +0 -14
- package/client/surface.tsx +0 -659
- package/docs/screenshots/smart-compact-handoff.png +0 -0
package/client/theme.ts
ADDED
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
import type { PluginHostProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import type { ViewStyle } from "react-native";
|
|
3
|
+
import type { Tone } from "../shared/forecast";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Theme-aware depth and tint, shared with the rest of the plugin family
|
|
7
|
+
* (Follow-up's client/theme.ts). Only host tokens are used: the helpers derive
|
|
8
|
+
* alphas from them rather than introducing colours of their own.
|
|
9
|
+
*/
|
|
10
|
+
export type Theme = PluginHostProps["theme"];
|
|
11
|
+
export type Colors = Theme["colors"];
|
|
12
|
+
|
|
13
|
+
function channels(color: string | undefined): number[] | null {
|
|
14
|
+
if (typeof color !== "string") return null;
|
|
15
|
+
const hex = /^#([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(color.trim());
|
|
16
|
+
if (hex) return (hex[1].length === 3 ? [...hex[1]].map((d) => d + d) : hex[1].match(/../g)!).map((d) => parseInt(d, 16));
|
|
17
|
+
const fn = /^rgba?\(\s*([\d.]+)[\s,]+([\d.]+)[\s,]+([\d.]+)/i.exec(color);
|
|
18
|
+
return fn ? fn.slice(1, 4).map(Number) : null;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** Light when the page ground is bright; unknown colours count as dark, Paseo's default. */
|
|
22
|
+
export function isLight(colors: Colors): boolean {
|
|
23
|
+
const rgb = channels(colors.surface0);
|
|
24
|
+
if (!rgb) return false;
|
|
25
|
+
const [r, g, b] = rgb.map((v) => { const c = v / 255; return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4; });
|
|
26
|
+
return 0.2126 * r + 0.7152 * g + 0.0722 * b > 0.4;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function withAlpha(color: string, alpha: number): string {
|
|
30
|
+
const rgb = channels(color);
|
|
31
|
+
return rgb ? `rgba(${rgb.join(", ")}, ${alpha})` : color;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/** A tag fill in the same hue as its text: 12% on light grounds, 16% on dark ones. */
|
|
35
|
+
export function tint(color: string, colors: Colors): string {
|
|
36
|
+
return withAlpha(color, isLight(colors) ? 0.12 : 0.16);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Card depth: a hairline ring plus a soft lift in light themes, a single faint ring in dark ones. */
|
|
40
|
+
export function raised(colors: Colors): ViewStyle {
|
|
41
|
+
return isLight(colors)
|
|
42
|
+
? ({ boxShadow: "0px 0px 0px 1px rgba(0, 0, 0, 0.07), 0px 1px 2px -1px rgba(0, 0, 0, 0.06), 0px 2px 6px 0px rgba(0, 0, 0, 0.04)" } as ViewStyle)
|
|
43
|
+
: ({ boxShadow: "0px 0px 0px 1px rgba(255, 255, 255, 0.08)" } as ViewStyle);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** State colours: amber when a limit is near, red when it bites, green only for a resumed/finished outcome. */
|
|
47
|
+
export function toneColor(colors: Colors, tone: Tone | "success" | "muted"): string {
|
|
48
|
+
switch (tone) {
|
|
49
|
+
case "success": return colors.statusSuccess;
|
|
50
|
+
case "danger": return colors.statusDanger;
|
|
51
|
+
case "warning": return colors.statusWarning;
|
|
52
|
+
default: return colors.foregroundMuted;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export const tabular = { fontVariant: ["tabular-nums" as const] };
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
# Navigation verification
|
|
2
2
|
|
|
3
|
-
Verified
|
|
3
|
+
Verified in an isolated Paseo 0.15 daemon with synthetic workspaces, usage history and sessions, using the app's web runtime at 1440×900 and 390×844, light and dark.
|
|
4
4
|
|
|
5
|
-
-
|
|
6
|
-
-
|
|
7
|
-
-
|
|
8
|
-
-
|
|
9
|
-
-
|
|
5
|
+
- The sidebar row **Smart Session** opens the overview screen; its header reads **Smart Session**, not a raw id. The row trails the session window's use, or how many sessions are paused.
|
|
6
|
+
- **Paused sessions** is its own screen with its own title, opened from the overview, the Session Guard pill's menu, a pause card or Command Center.
|
|
7
|
+
- Smart Compact, Session Guard and Display settings live in **Settings → Plugins → Smart Session** and open from the overview's gear, the feature cards and Command Center.
|
|
8
|
+
- **Display** hides the sidebar row or the composer pills; hiding either never turns a feature off, and Command Center still opens the overview.
|
|
9
|
+
- Compact settings have no horizontal overflow and keep accessible native switches.
|
|
10
10
|
|
|
11
11
|
Screenshots: [desktop](screenshots/display-settings-desktop.png), [compact](screenshots/display-settings-mobile.png). Screenshots contain synthetic data. No main daemon restart or real-agent messages were used.
|
|
Binary file
|
|
Binary file
|
package/docs/session-guard.md
CHANGED
|
@@ -27,9 +27,9 @@ Every live session with a workspace gets a **Session Guard** composer pill while
|
|
|
27
27
|
- **Session Guard settings**: configure the host policy and see paused sessions.
|
|
28
28
|
- **All paused sessions**: open the host-wide overview.
|
|
29
29
|
|
|
30
|
-
The **Paused sessions**
|
|
30
|
+
The **Paused sessions** screen gathers every Session Guard pause on the selected host, each led by the time its window resets and a countdown, with the session title, provider and whether native subagents are frozen or the turn was interrupted. Uncertain stops are grouped under **Needs attention**. Open a session or manage its pause directly; search appears when the list is busy. Open it from the Smart Session overview (the **2 paused** line under the headline, or the Session Guard card), the pill's menu, Command Center, settings, or a pause card. While anything is paused, the Smart Session sidebar row shows the count. A waiting session's composer pill begins **Paused**. Its workspace also gets an amber **Paused** label in the left sidebar. The label persists through restarts and stays until the last paused session in that workspace is released. Turn it off with **Label paused workspaces** in settings; other workspace labels stay intact.
|
|
31
31
|
|
|
32
|
-
Confirmed pauses write a **Paused for session limit** timeline entry
|
|
32
|
+
Confirmed pauses write a **Paused for session limit** timeline entry: the reset time with a live countdown, the provider and how much of the window was used, and whether native subagents are covered. When the pause ends, the entry folds into one line ("Paused at 7:57 PM · resumed 9:41 PM"), so the chat keeps the history without a stale card. Resume, release, and uncertain-stop entries make the recovery history clear. Existing confirmed waits are backfilled when upgrading. Timeline events use the host-owned plugin IPC identity, stay outside model messages, and never start a new turn. Delivery is journaled and retried; a lost acknowledgement is checked against timeline IDs before another append. Timeline delivery failures do not undo pauses and are shown in the overview/settings.
|
|
33
33
|
|
|
34
34
|
Choose **Dismiss pause** on an active pause or restart-recovery timeline item to release it **without sending a message**. This cancels its scheduled continuation, turns protection off for that session, clears its active pause from the overview and workspace label when applicable, and collapses the original card to a short acknowledgement. Existing suspended workers are released and may continue their current work. The dismissal is saved before release so it survives restarts; partial release retries without sending. Historical cards cannot dismiss a newer pause. **Resume now** remains available when you want Guard to continue interrupted work immediately.
|
|
35
35
|
|
|
@@ -57,7 +57,7 @@ Native coverage currently requires **Linux**, access to `/proc`, and a uniquely
|
|
|
57
57
|
|
|
58
58
|
Suspension does not undo an API request or external action already sent. Network calls can time out while paused, and tools can retain locks. Resumption preserves local process state, but a provider's ordinary network/tool error still requires normal recovery; Session Guard automatically recovers only matching quota failures. Outstanding permission prompts remain for the person to answer.
|
|
59
59
|
|
|
60
|
-
|
|
60
|
+
The pill's icon carries its state: a shield when protected, a muted crossed-out shield when off, an amber pause while paused, and a red warning when the session needs a look. A confirmed pause timeline entry offers **Resume now** while that exact pause is still waiting. This resumes owned native workers in place, or sends one manual continuation for an interrupted turn, and opts that session out so a still-full quota cannot immediately pause it again. Restore protection from the pill when ready. Resuming does not restore provider capacity: requests can still be rejected before reset. Historical entries cannot resume a newer pause, and unconfirmed message delivery is left for inspection without a blind retry.
|
|
61
61
|
|
|
62
62
|
A disposable, empty session labelled `session_guard_demo=true` can exercise the real native pause/release path through the `smart-session.guard.demo-pause` RPC (`node probe.mjs rpc smart-session.guard.demo-pause '{"agentId":"…"}'`). It must have no user messages, active turn, or pending permissions. Its timeline explicitly says **Session Guard demo paused**, makes no quota claim, and waits for your action even when capacity resets. The demo never sends a task or changes provider quota reports.
|
|
63
63
|
|
package/index.client.tsx
CHANGED
|
@@ -1,53 +1,65 @@
|
|
|
1
1
|
import { withButtonVisibility } from "./client/button-visibility";
|
|
2
2
|
import { contributeDisplay } from "./client/display";
|
|
3
|
-
import { withNavigation } from "./client/navigation";
|
|
4
3
|
import type { PluginClientContext } from "@getpaseo/plugin/client";
|
|
5
4
|
|
|
6
5
|
import { observeAgents } from "./client/agents";
|
|
6
|
+
import { contributeChat } from "./client/chat";
|
|
7
7
|
import { contributeGuardClient } from "./client/guard/index";
|
|
8
|
+
import { createLive } from "./client/live";
|
|
9
|
+
import { OVERVIEW_SCREEN, SMART_COMPACT_SETTINGS, makeOverview } from "./client/overview";
|
|
8
10
|
import { contributeClient, refreshPills } from "./client/pill";
|
|
9
|
-
import {
|
|
11
|
+
import { SmartCompactSettings } from "./client/settings";
|
|
10
12
|
import { enrolmentState } from "./shared/governor";
|
|
11
|
-
import { getSettings, setSettings } from "./shared/smart-session";
|
|
13
|
+
import { budgetStatus, getSettings, setSettings } from "./shared/smart-session";
|
|
12
14
|
|
|
13
15
|
export default function contribute(host: PluginClientContext) {
|
|
14
16
|
const visibility = withButtonVisibility(host);
|
|
15
17
|
const client = visibility.client;
|
|
16
|
-
|
|
17
|
-
const
|
|
18
|
-
id: "
|
|
19
|
-
title: "Smart
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
18
|
+
const live = createLive();
|
|
19
|
+
const removers = [
|
|
20
|
+
client.addScreen({ id: OVERVIEW_SCREEN, title: "Smart Session", Component: makeOverview(client, live) }),
|
|
21
|
+
client.addSettingsScreen({ id: SMART_COMPACT_SETTINGS, title: "Smart Compact", icon: "FoldVertical", Component: SmartCompactSettings }),
|
|
22
|
+
contributeDisplay(client, live),
|
|
23
|
+
client.addCommandCenterItem({
|
|
24
|
+
id: "smart-session-open",
|
|
25
|
+
title: "Smart Session: plan usage and context",
|
|
26
|
+
icon: "Gauge",
|
|
27
|
+
keywords: ["usage", "budget", "limit", "forecast", "context", "tokens", "compact", "smart session"],
|
|
28
|
+
context: "global",
|
|
29
|
+
onSelect({ openScreen }) {
|
|
30
|
+
openScreen({ screenId: OVERVIEW_SCREEN });
|
|
31
|
+
},
|
|
32
|
+
}),
|
|
33
|
+
client.addCommandCenterItem({
|
|
34
|
+
id: "smart-session-pill",
|
|
35
|
+
title: "Show or hide the context pill",
|
|
36
|
+
icon: "ToggleLeft",
|
|
37
|
+
keywords: ["pill", "compact", "smart", "context", "ring", "enrol"],
|
|
38
|
+
context: "global",
|
|
39
|
+
async onSelect({ rpc }) {
|
|
40
|
+
const { settings } = await rpc(getSettings, {});
|
|
41
|
+
await rpc(setSettings, { showPill: !settings.showPill });
|
|
42
|
+
await refreshPills(() => rpc(enrolmentState, {}));
|
|
43
|
+
},
|
|
44
|
+
}),
|
|
45
|
+
contributeChat(client),
|
|
46
|
+
];
|
|
23
47
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
keywords: ["usage", "budget", "limit", "context", "tokens", "compact"],
|
|
29
|
-
context: "global",
|
|
30
|
-
onSelect({ openSurface }) {
|
|
31
|
-
openSurface("overview");
|
|
32
|
-
},
|
|
33
|
-
});
|
|
34
|
-
|
|
35
|
-
client.addCommandCenterItem({
|
|
36
|
-
id: "smart-session-pill",
|
|
37
|
-
title: "Show or hide the smart-compact pill",
|
|
38
|
-
icon: "ToggleLeft",
|
|
39
|
-
keywords: ["pill", "compact", "smart", "governor", "enrol"],
|
|
40
|
-
context: "global",
|
|
41
|
-
async onSelect({ rpc }) {
|
|
42
|
-
const { settings } = await rpc(getSettings, {});
|
|
43
|
-
await rpc(setSettings, { showPill: !settings.showPill });
|
|
44
|
-
await refreshPills(() => rpc(enrolmentState, {}));
|
|
45
|
-
},
|
|
46
|
-
});
|
|
48
|
+
// The sidebar row shows the session window between overview visits.
|
|
49
|
+
const readBudget = () => client.rpc(budgetStatus, {}).then((budget) => live.setBudget(budget)).catch(() => undefined);
|
|
50
|
+
void readBudget();
|
|
51
|
+
const budgetTimer = setInterval(() => void readBudget(), 2 * 60_000);
|
|
47
52
|
|
|
48
53
|
// One agent observation for every pill in this bundle.
|
|
49
54
|
const agents = observeAgents(client);
|
|
50
55
|
const removeClient = contributeClient(client, agents);
|
|
51
|
-
const removeGuard = contributeGuardClient(client, agents,
|
|
52
|
-
return () => {
|
|
56
|
+
const removeGuard = contributeGuardClient(client, agents, live);
|
|
57
|
+
return () => {
|
|
58
|
+
clearInterval(budgetTimer);
|
|
59
|
+
removeGuard();
|
|
60
|
+
removeClient();
|
|
61
|
+
for (const remove of removers.splice(0)) void remove();
|
|
62
|
+
visibility.dispose();
|
|
63
|
+
return agents.dispose();
|
|
64
|
+
};
|
|
53
65
|
}
|
package/index.server.ts
CHANGED
|
@@ -7,7 +7,7 @@ import { readAgents, withDaemon } from "./server/daemon.ts";
|
|
|
7
7
|
import { contextGrowth, projectFull } from "./server/growth.ts";
|
|
8
8
|
import { install } from "./server/install.ts";
|
|
9
9
|
import { summarizeSpend } from "./server/spend.ts";
|
|
10
|
-
import { countEnrolled, listEnrolment, readSettings, setEnrolled, writeSettings } from "./server/settings.ts";
|
|
10
|
+
import { countEnrolled, listEnrolment, readSettings, setEnrolled, stateWrittenAt, writeSettings } from "./server/settings.ts";
|
|
11
11
|
import { dataDir, newestUsage, readUsage } from "./server/store.ts";
|
|
12
12
|
import {
|
|
13
13
|
budgetStatus,
|
|
@@ -25,7 +25,7 @@ import {
|
|
|
25
25
|
requestCompaction,
|
|
26
26
|
setEnrolment,
|
|
27
27
|
} from "./shared/governor.ts";
|
|
28
|
-
import { burnRatePctPerHour, projectedExhaustion, segmentBlocks } from "./shared/blocks.ts";
|
|
28
|
+
import { burnRatePctPerHour, projectedExhaustion, segmentBlocks, thinPoints } from "./shared/blocks.ts";
|
|
29
29
|
import { lifecycle, runTeardowns } from "./shared/lifecycle.ts";
|
|
30
30
|
import { profileFor } from "./shared/thresholds.ts";
|
|
31
31
|
import { effectiveAt } from "./shared/usage.ts";
|
|
@@ -52,8 +52,9 @@ export default function contribute(server: PluginServerContext) {
|
|
|
52
52
|
const newest = await newestUsage();
|
|
53
53
|
|
|
54
54
|
// One row per window, taken from its currently open block.
|
|
55
|
-
const
|
|
56
|
-
|
|
55
|
+
const blocks = segmentBlocks(samples);
|
|
56
|
+
const open = new Map<string, (typeof blocks)[number]>();
|
|
57
|
+
for (const block of blocks) open.set(block.windowId, block);
|
|
57
58
|
|
|
58
59
|
const windows = [...open.values()]
|
|
59
60
|
.map((block) => ({
|
|
@@ -62,12 +63,19 @@ export default function contribute(server: PluginServerContext) {
|
|
|
62
63
|
resetsAt: block.resetsAt,
|
|
63
64
|
burnPctPerHour: burnRatePctPerHour(block),
|
|
64
65
|
projectedFullAt: projectedExhaustion(block),
|
|
66
|
+
startAt: block.startAt,
|
|
67
|
+
history: thinPoints(block.points, 96),
|
|
65
68
|
}))
|
|
66
69
|
.sort((a, b) => b.pct - a.pct);
|
|
70
|
+
const current = open.get("five_hour");
|
|
71
|
+
const past = blocks
|
|
72
|
+
.filter((block) => block.windowId === "five_hour" && block !== current)
|
|
73
|
+
.map((block) => ({ startAt: block.startAt, resetsAt: block.resetsAt, firstSeenAt: block.firstSeenAt, peakPct: block.peakPct }));
|
|
67
74
|
|
|
68
75
|
const newestInstant = newest === null ? null : effectiveAt(newest);
|
|
69
76
|
return {
|
|
70
77
|
windows,
|
|
78
|
+
past,
|
|
71
79
|
recorder: {
|
|
72
80
|
newestAt: newestInstant,
|
|
73
81
|
ageSeconds:
|
|
@@ -116,6 +124,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
116
124
|
growthTokensPerHour: growth.get(row.id) ?? null,
|
|
117
125
|
projectedFullAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds),
|
|
118
126
|
compactAtPct: profileFor(row.maxTokens!, thresholds).compact,
|
|
127
|
+
stateWrittenAt: stateWrittenAt(row.id),
|
|
119
128
|
}))
|
|
120
129
|
.sort((a, b) => b.usedPct - a.usedPct);
|
|
121
130
|
return { agents, error: null };
|
|
@@ -186,7 +195,7 @@ export default function contribute(server: PluginServerContext) {
|
|
|
186
195
|
|
|
187
196
|
server.handle(enrolmentState, async () => {
|
|
188
197
|
const settings = await readSettings();
|
|
189
|
-
return { showPill: settings.showPill, enabled: settings.enabled, agents: await listEnrolment() };
|
|
198
|
+
return { showPill: settings.showPill, enabled: settings.enabled, agents: await listEnrolment(), thresholds: settings.thresholds };
|
|
190
199
|
});
|
|
191
200
|
|
|
192
201
|
server.handle(setEnrolment, async ({ agentId, enrolled }) => ({
|
package/mcp.mjs
CHANGED
|
@@ -454,7 +454,7 @@ const TOOLS = {
|
|
|
454
454
|
const agentId = requireAgent();
|
|
455
455
|
const settings = readSettings();
|
|
456
456
|
if (!settings.enabled) {
|
|
457
|
-
return "Smart Compact is switched off for this Paseo install, so nothing would deliver this request. Compact by hand if you need to, or ask the user to turn Smart Compact on in the Smart Session
|
|
457
|
+
return "Smart Compact is switched off for this Paseo install, so nothing would deliver this request. Compact by hand if you need to, or ask the user to turn Smart Compact on in the Smart Session screen or Settings → Plugins → Smart Session.";
|
|
458
458
|
}
|
|
459
459
|
|
|
460
460
|
// Default to this session's own state file: the whole point is that the
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED
|
@@ -1,6 +1,16 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "smart-session",
|
|
3
|
+
"name": "Smart Session",
|
|
3
4
|
"icon": "assets/icon.png",
|
|
5
|
+
"description": "See when your plan runs out at this pace, how full each session's context is, and let agents checkpoint and compact themselves. Session Guard pauses work at the session limit and continues it after the reset.",
|
|
6
|
+
"media": [
|
|
7
|
+
"docs/media/smart-session-hero.png",
|
|
8
|
+
"docs/media/smart-session-flow.gif",
|
|
9
|
+
"docs/media/smart-session-pill.png",
|
|
10
|
+
"docs/media/smart-session-chat.png",
|
|
11
|
+
"docs/media/smart-session-guard.png",
|
|
12
|
+
"docs/media/smart-session-mobile.png"
|
|
13
|
+
],
|
|
4
14
|
"requirements": {
|
|
5
15
|
"paseo": ">=0.14.0"
|
|
6
16
|
}
|
package/server/settings.ts
CHANGED
|
@@ -7,6 +7,7 @@
|
|
|
7
7
|
|
|
8
8
|
import { randomUUID } from "node:crypto";
|
|
9
9
|
import { mkdir, readFile, readdir, rename, writeFile } from "node:fs/promises";
|
|
10
|
+
import { statSync } from "node:fs";
|
|
10
11
|
import { join } from "node:path";
|
|
11
12
|
|
|
12
13
|
import { dataDir } from "./store.ts";
|
|
@@ -130,6 +131,15 @@ export function statePathFor(agentId: string): string {
|
|
|
130
131
|
return join(dataDir(), "state", `${agentId}.md`);
|
|
131
132
|
}
|
|
132
133
|
|
|
134
|
+
/** When an agent last wrote its task state, or null when it never has. */
|
|
135
|
+
export function stateWrittenAt(agentId: string): string | null {
|
|
136
|
+
try {
|
|
137
|
+
return new Date(statSync(statePathFor(agentId)).mtimeMs).toISOString();
|
|
138
|
+
} catch {
|
|
139
|
+
return null;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
133
143
|
|
|
134
144
|
/**
|
|
135
145
|
* Whether the governor may speak to one agent, and who decided.
|
package/shared/blocks.ts
CHANGED
|
@@ -206,3 +206,18 @@ export function projectedExhaustion(block: Block, now = new Date()): string | nu
|
|
|
206
206
|
if (block.resetsAt !== null && at > Date.parse(block.resetsAt)) return null;
|
|
207
207
|
return new Date(at).toISOString();
|
|
208
208
|
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* At most `max` points from a block, for a chart.
|
|
212
|
+
*
|
|
213
|
+
* Keeps the first and last points and every turn of the curve a chart can show; drops
|
|
214
|
+
* the rest evenly. The percentage only climbs within one window, so an even stride
|
|
215
|
+
* loses nothing a 96-column chart could draw.
|
|
216
|
+
*/
|
|
217
|
+
export function thinPoints(points: readonly BlockPoint[], max: number): BlockPoint[] {
|
|
218
|
+
if (points.length <= max || max < 2) return [...points];
|
|
219
|
+
const stride = (points.length - 1) / (max - 1);
|
|
220
|
+
const out: BlockPoint[] = [];
|
|
221
|
+
for (let i = 0; i < max; i++) out.push(points[Math.round(i * stride)]!);
|
|
222
|
+
return out;
|
|
223
|
+
}
|
package/shared/chat.ts
ADDED
|
@@ -0,0 +1,174 @@
|
|
|
1
|
+
import { z } from "zod";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Smart Session's agent tools and plugin-sent messages as they appear in a chat,
|
|
5
|
+
* reduced to what a one-line row or a small card needs. Pure, so the transformer and
|
|
6
|
+
* the tests read the same rules.
|
|
7
|
+
*/
|
|
8
|
+
export const TOOL_ROW_KIND = "smart-session-tool";
|
|
9
|
+
export const PROMPT_CARD_KIND = "smart-session-prompt";
|
|
10
|
+
|
|
11
|
+
export const SMART_SESSION_TOOLS = ["context_status", "checkpoint", "request_compaction", "defer_compaction", "budget_status"] as const;
|
|
12
|
+
export type SmartSessionTool = (typeof SMART_SESSION_TOOLS)[number];
|
|
13
|
+
|
|
14
|
+
export const ToolRowSchema = z.object({
|
|
15
|
+
tool: z.enum(SMART_SESSION_TOOLS),
|
|
16
|
+
state: z.enum(["running", "done", "refused", "failed"]),
|
|
17
|
+
/** The one line, e.g. "Checked its context · 32% of 1M · compacts at 30%". */
|
|
18
|
+
line: z.string(),
|
|
19
|
+
/** What opens under it: the call's own words, trimmed. Empty when there is nothing more. */
|
|
20
|
+
detail: z.string(),
|
|
21
|
+
});
|
|
22
|
+
export type ToolRow = z.infer<typeof ToolRowSchema>;
|
|
23
|
+
|
|
24
|
+
export const PromptCardSchema = z.object({
|
|
25
|
+
kind: z.enum(["compact", "continue", "guard-continue", "guard-resume"]),
|
|
26
|
+
/** compact: the reason the agent gave. */
|
|
27
|
+
reason: z.string(),
|
|
28
|
+
/** compact / continue: the state file it re-reads, when named. */
|
|
29
|
+
statePath: z.string(),
|
|
30
|
+
text: z.string(),
|
|
31
|
+
});
|
|
32
|
+
export type PromptCard = z.infer<typeof PromptCardSchema>;
|
|
33
|
+
|
|
34
|
+
/** Claude records mcp__smart-session__context_status; Codex records smart-session.context_status. */
|
|
35
|
+
export function smartSessionTool(name: string): SmartSessionTool | null {
|
|
36
|
+
const match = /^(?:mcp__)?smart-session(?:__|\.|\/|:)([a-z_]+)$/.exec(name);
|
|
37
|
+
return match && (SMART_SESSION_TOOLS as readonly string[]).includes(match[1]!) ? (match[1] as SmartSessionTool) : null;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function record(value: unknown): value is Record<string, unknown> {
|
|
41
|
+
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/** The text a tool result carries, however the provider wrapped it. */
|
|
45
|
+
export function readText(value: unknown): string | undefined {
|
|
46
|
+
if (value === null || value === undefined) return "";
|
|
47
|
+
if (typeof value === "string") return value;
|
|
48
|
+
if (Array.isArray(value)) {
|
|
49
|
+
const parts = value.map((part) => (typeof part === "string" ? part : record(part) && typeof part.text === "string" ? part.text : undefined));
|
|
50
|
+
return parts.every((part) => part !== undefined) ? parts.join("\n") : undefined;
|
|
51
|
+
}
|
|
52
|
+
if (!record(value)) return undefined;
|
|
53
|
+
const key = ["content", "output", "text", "message", "result"].find((name) => name in value);
|
|
54
|
+
return key ? readText(value[key]) : undefined;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
const str = (value: unknown) => (typeof value === "string" ? value.trim() : "");
|
|
58
|
+
const clip = (value: string, max: number) => (value.length > max ? `${value.slice(0, max - 1).trimEnd()}…` : value);
|
|
59
|
+
const oneLine = (value: string) => value.replace(/\s+/g, " ").trim();
|
|
60
|
+
|
|
61
|
+
/** "1,000,000" → "1M", "200,000" → "200k". */
|
|
62
|
+
function size(raw: string): string {
|
|
63
|
+
const n = Number(raw.replace(/,/g, ""));
|
|
64
|
+
if (!Number.isFinite(n)) return raw;
|
|
65
|
+
return n >= 1_000_000 ? `${Number((n / 1_000_000).toFixed(1))}M` : n >= 1000 ? `${Math.round(n / 1000)}k` : String(n);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function contextLine(output: string): string {
|
|
69
|
+
if (/^No context reading/i.test(output)) return "Checked its context · no reading yet";
|
|
70
|
+
const m = /\(([\d.]+)% of a ([\d,]+)-token window\)\. Worth compacting from (\d+)%/.exec(output);
|
|
71
|
+
if (!m) return "Checked its context";
|
|
72
|
+
const past = /already past that point/i.test(output);
|
|
73
|
+
return `Checked its context · ${Math.round(Number(m[1]))}% of ${size(m[2]!)} · ${past ? `past its ${m[3]}% line` : `compacts at ${m[3]}%`}`;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function budgetLine(output: string): string {
|
|
77
|
+
if (/^Could not read plan usage/i.test(output)) return "Couldn't read plan usage";
|
|
78
|
+
const parts: string[] = [];
|
|
79
|
+
for (const m of output.matchAll(/^- (Session \(5h\)|Week|[^:]+): (\d+)% used/gm)) {
|
|
80
|
+
const name = m[1] === "Session (5h)" ? "session" : m[1] === "Week" ? "week" : m[1]!;
|
|
81
|
+
parts.push(`${name} ${m[2]}%`);
|
|
82
|
+
}
|
|
83
|
+
return parts.length === 0 ? "Checked plan usage" : `Checked plan usage · ${parts.slice(0, 2).join(" · ")}`;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function checkpointDetail(input: Record<string, unknown>): string {
|
|
87
|
+
const lines: string[] = [];
|
|
88
|
+
const add = (label: string, value: unknown) => {
|
|
89
|
+
if (typeof value === "string" && value.trim()) lines.push(`${label}: ${value.trim()}`);
|
|
90
|
+
else if (Array.isArray(value)) for (const item of value) if (typeof item === "string" && item.trim()) lines.push(`${label}: ${item.trim()}`);
|
|
91
|
+
};
|
|
92
|
+
add("Goal", input.goal); add("Plan", input.plan); add("Current step", input.current_step);
|
|
93
|
+
add("Decision", input.decisions); add("Dead end", input.dead_ends); add("Key fact", input.key_facts);
|
|
94
|
+
return clip(lines.join("\n"), 4000);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/** Builds a row from a tool call, or null when the call is not one of Smart Session's. */
|
|
98
|
+
export function toolRow(name: string, status: string, detail: unknown, error: unknown): ToolRow | null {
|
|
99
|
+
const tool = smartSessionTool(name);
|
|
100
|
+
if (tool === null) return null;
|
|
101
|
+
const input = record(detail) && record(detail.input) ? detail.input : {};
|
|
102
|
+
const output = record(detail) && "output" in detail ? (readText(detail.output) ?? "") : "";
|
|
103
|
+
const failedOutput = record(detail) && record(detail.output) && detail.output.isError === true;
|
|
104
|
+
const failed = status === "failed" || failedOutput || /^Failed: /.test(output);
|
|
105
|
+
const running = !failed && status !== "completed";
|
|
106
|
+
if (failed) {
|
|
107
|
+
const why = clip(oneLine(readText(error) || output.replace(/^Failed: /, "") || ""), 500);
|
|
108
|
+
return { tool, state: "failed", line: `Smart Session couldn't ${tool === "checkpoint" ? "save task state" : tool === "request_compaction" ? "queue the compaction" : tool === "defer_compaction" ? "put off compacting" : tool === "budget_status" ? "read plan usage" : "read the context"}`, detail: why };
|
|
109
|
+
}
|
|
110
|
+
switch (tool) {
|
|
111
|
+
case "context_status":
|
|
112
|
+
return { tool, state: running ? "running" : "done", line: running ? "Checking its context…" : contextLine(output), detail: clip(output, 2000) };
|
|
113
|
+
case "budget_status":
|
|
114
|
+
return { tool, state: running ? "running" : "done", line: running ? "Checking plan usage…" : budgetLine(output), detail: clip(output, 2000) };
|
|
115
|
+
case "checkpoint": {
|
|
116
|
+
const step = str(input.current_step);
|
|
117
|
+
const added = /\((\d+) new entr/.exec(output)?.[1];
|
|
118
|
+
const line = running ? "Saving task state…" : `Saved task state${step ? ` · ${clip(oneLine(step), 90)}` : added ? ` · ${added} new ${added === "1" ? "entry" : "entries"}` : ""}`;
|
|
119
|
+
return { tool, state: running ? "running" : "done", line, detail: checkpointDetail(input) };
|
|
120
|
+
}
|
|
121
|
+
case "request_compaction": {
|
|
122
|
+
const reason = clip(oneLine(str(input.reason)), 110);
|
|
123
|
+
const continues = input.continue_after_compaction !== false;
|
|
124
|
+
const message = str(input.continue_message);
|
|
125
|
+
const detailText = [
|
|
126
|
+
reason ? `Reason: ${str(input.reason)}` : "",
|
|
127
|
+
continues ? (message ? `Then: ${message}` : "Then: re-read the task state and carry on") : "Then: stop, the task is done",
|
|
128
|
+
].filter(Boolean).join("\n");
|
|
129
|
+
if (running) return { tool, state: "running", line: "Asking to compact…", detail: detailText };
|
|
130
|
+
if (/^Not queued|switched off/i.test(output)) {
|
|
131
|
+
const why = /switched off/i.test(output) ? "Smart Compact is off" : /no task state/i.test(output) ? "no task state saved yet" : /last written/i.test(output) ? "task state is out of date" : "refused";
|
|
132
|
+
return { tool, state: "refused", line: `Compaction not queued · ${why}`, detail: clip(output, 1500) };
|
|
133
|
+
}
|
|
134
|
+
return { tool, state: "done", line: `Asked to compact${continues ? "" : " and stop"}${reason ? ` · ${reason}` : ""}`, detail: detailText };
|
|
135
|
+
}
|
|
136
|
+
case "defer_compaction": {
|
|
137
|
+
const reason = clip(oneLine(str(input.reason)), 110);
|
|
138
|
+
const minutes = /Deferred for (\d+) minutes/.exec(output)?.[1] ?? (typeof input.minutes === "number" ? String(input.minutes) : null);
|
|
139
|
+
return { tool, state: running ? "running" : "done", line: running ? "Putting off compacting…" : `Put off compacting${minutes ? ` for ${minutes}m` : ""}${reason ? ` · ${reason}` : ""}`, detail: clip(output, 1500) };
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
const COMPACT_PREFIX = "/compact Preserve: the current goal";
|
|
145
|
+
const CONTINUE_PREFIX = "That compaction was the one you asked for.";
|
|
146
|
+
const GUARD_DEFAULT_PREFIX = "Your previous turn was paused because the provider's session usage limit was reached.";
|
|
147
|
+
const GUARD_RESUME_PREFIX = "You explicitly chose Resume now while Session Guard was waiting";
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* Recognises the messages Smart Session itself sends into a chat: the /compact an
|
|
151
|
+
* agent asked for, the continuation after it, and Session Guard's continuation and
|
|
152
|
+
* Resume now. Anything else, including a person's own /compact, is left alone.
|
|
153
|
+
*/
|
|
154
|
+
export function promptCard(text: string): PromptCard | null {
|
|
155
|
+
const body = text.trim();
|
|
156
|
+
if (body.startsWith(COMPACT_PREFIX)) {
|
|
157
|
+
const reason = /Reason for compacting now: ([\s\S]*?)(?: Authoritative state lives at |$)/.exec(body)?.[1]?.trim() ?? "";
|
|
158
|
+
const statePath = /Authoritative state lives at (.+?)\. Re-read that file/.exec(body)?.[1]?.trim() ?? "";
|
|
159
|
+
return { kind: "compact", reason, statePath, text: body };
|
|
160
|
+
}
|
|
161
|
+
if (body.startsWith(CONTINUE_PREFIX)) {
|
|
162
|
+
const statePath = /^That compaction was the one you asked for\. Re-read (.+?) and continue from/.exec(body)?.[1]?.trim() ?? "";
|
|
163
|
+
return { kind: "continue", reason: "", statePath, text: body };
|
|
164
|
+
}
|
|
165
|
+
if (body.startsWith(GUARD_DEFAULT_PREFIX)) return { kind: "guard-continue", reason: "", statePath: "", text: body };
|
|
166
|
+
if (body.startsWith(GUARD_RESUME_PREFIX)) return { kind: "guard-resume", reason: "", statePath: "", text: body };
|
|
167
|
+
return null;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** The last path segment, for a state file named in a card. */
|
|
171
|
+
export function fileName(path: string): string {
|
|
172
|
+
const parts = path.split(/[\\/]/).filter(Boolean);
|
|
173
|
+
return parts[parts.length - 1] ?? path;
|
|
174
|
+
}
|