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
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import { mkdir, readFile, rename, open, unlink, stat } from "node:fs/promises";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { randomUUID } from "node:crypto";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { StateSchema, type State } from "../../shared/guard/policy.ts";
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Where Session Guard keeps its pauses, session overrides and governor lock.
|
|
9
|
+
*
|
|
10
|
+
* Deliberately the standalone `paseo-session-guard` plugin's directory, not
|
|
11
|
+
* `plugin-data/smart-session/`. Both plugins may be installed on one host while people
|
|
12
|
+
* move over, and both would otherwise pause the same agent. Sharing this directory
|
|
13
|
+
* means they share `governor.lock`, so exactly one of them governs; and when the
|
|
14
|
+
* standalone plugin is removed, its per-session choices and confirmed waits are picked
|
|
15
|
+
* up here instead of being lost. The state shape is therefore frozen: the standalone
|
|
16
|
+
* plugin reads and rewrites the same file, and its schema would strip anything new.
|
|
17
|
+
*/
|
|
18
|
+
export function guardDataDir(): string {
|
|
19
|
+
return join(process.env.PASEO_HOME ?? join(homedir(), ".paseo"), "plugin-data", "session-guard");
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/** A live process already governs this host. Not a failure: the caller defers to it. */
|
|
23
|
+
export class LockHeldError extends Error {}
|
|
24
|
+
|
|
25
|
+
export class Store {
|
|
26
|
+
readonly directory: string;
|
|
27
|
+
constructor(directory: string) { this.directory = directory; }
|
|
28
|
+
async read(): Promise<State> {
|
|
29
|
+
try {
|
|
30
|
+
return StateSchema.parse(JSON.parse(await readFile(join(this.directory, "state.json"), "utf8")));
|
|
31
|
+
} catch (error) {
|
|
32
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { version: 1, overrides: {}, pauses: [] };
|
|
33
|
+
throw new Error("Session Guard state could not be read. Restore state.json before continuing.");
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
async write(state: State): Promise<void> {
|
|
37
|
+
const parsed = StateSchema.parse(state);
|
|
38
|
+
// Keep pending work and a bounded history; never write transcripts or prompts.
|
|
39
|
+
const pending = parsed.pauses.filter(p => p.settledAt === null);
|
|
40
|
+
const history = parsed.pauses.filter(p => p.settledAt !== null).slice(-200);
|
|
41
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
42
|
+
const temporary = join(this.directory, `state-${randomUUID()}.tmp`);
|
|
43
|
+
try {
|
|
44
|
+
const file = await open(temporary, "wx", 0o600);
|
|
45
|
+
try {
|
|
46
|
+
await file.writeFile(JSON.stringify({ ...parsed, pauses: [...history, ...pending] }, null, 2));
|
|
47
|
+
await file.sync(); // Confirmed schedules must reach disk before signals.
|
|
48
|
+
} finally { await file.close(); }
|
|
49
|
+
await rename(temporary, join(this.directory, "state.json"));
|
|
50
|
+
if (process.platform === "linux") {
|
|
51
|
+
const directory = await open(this.directory, "r");
|
|
52
|
+
try { await directory.sync(); } finally { await directory.close(); }
|
|
53
|
+
}
|
|
54
|
+
} finally { await unlink(temporary).catch(() => undefined); }
|
|
55
|
+
}
|
|
56
|
+
/** One governor per host, including installs under a second plugin ID. */
|
|
57
|
+
async lock(): Promise<() => Promise<void>> {
|
|
58
|
+
await mkdir(this.directory, { recursive: true, mode: 0o700 });
|
|
59
|
+
const path = join(this.directory, "governor.lock");
|
|
60
|
+
const identity = async (pid: number) => {
|
|
61
|
+
if (process.platform !== "linux") return null;
|
|
62
|
+
const boot = (await readFile("/proc/sys/kernel/random/boot_id", "utf8")).trim();
|
|
63
|
+
const raw = await readFile(`/proc/${pid}/stat`, "utf8");
|
|
64
|
+
const birth = raw.slice(raw.lastIndexOf(")") + 2).trim().split(/\s+/)[19];
|
|
65
|
+
return `${boot}:${birth}`;
|
|
66
|
+
};
|
|
67
|
+
const token = `${process.pid}:${randomUUID()}:${await identity(process.pid) ?? "portable"}`;
|
|
68
|
+
const alive = async (value: string) => {
|
|
69
|
+
const [pidText, , ...saved] = value.split(":"); const pid = Number(pidText);
|
|
70
|
+
if (!Number.isInteger(pid) || pid <= 0) throw new Error("Governor lock has an invalid owner.");
|
|
71
|
+
try {
|
|
72
|
+
process.kill(pid, 0);
|
|
73
|
+
return saved.length < 2 || process.platform !== "linux" || await identity(pid) === saved.join(":");
|
|
74
|
+
} catch (error) {
|
|
75
|
+
if (["ESRCH", "ENOENT"].includes((error as NodeJS.ErrnoException).code ?? "")) return false;
|
|
76
|
+
throw error;
|
|
77
|
+
}
|
|
78
|
+
};
|
|
79
|
+
const claim = async () => {
|
|
80
|
+
const handle = await open(path, "wx", 0o600);
|
|
81
|
+
try { await handle.writeFile(token); } finally { await handle.close(); }
|
|
82
|
+
};
|
|
83
|
+
try { await claim(); } catch (error) {
|
|
84
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
85
|
+
const previous = await readFile(path, "utf8");
|
|
86
|
+
if (await alive(previous)) throw new LockHeldError("Another Session Guard instance is already running.");
|
|
87
|
+
// Stale-lock recovery must itself be exclusive across simultaneous loads.
|
|
88
|
+
const recoveryPath = `${path}.recovery`;
|
|
89
|
+
const abandoned = await readFile(recoveryPath, "utf8").catch(error => {
|
|
90
|
+
if (error.code === "ENOENT") return null;
|
|
91
|
+
throw error;
|
|
92
|
+
});
|
|
93
|
+
if (abandoned !== null) {
|
|
94
|
+
// Older releases left an empty recovery file. Allow its writer time to
|
|
95
|
+
// finish; a nonempty owner is checked by boot and process birth identity.
|
|
96
|
+
const expired = abandoned === "" ? Date.now() - (await stat(recoveryPath)).mtimeMs > 30_000 : !await alive(abandoned);
|
|
97
|
+
if (!expired) throw new LockHeldError("Governor recovery is already running.");
|
|
98
|
+
if (await readFile(recoveryPath, "utf8") !== abandoned) throw new Error("Governor recovery changed.");
|
|
99
|
+
await unlink(recoveryPath);
|
|
100
|
+
}
|
|
101
|
+
const recovery = await open(recoveryPath, "wx", 0o600);
|
|
102
|
+
await recovery.writeFile(token);
|
|
103
|
+
try {
|
|
104
|
+
if (await readFile(path, "utf8") !== previous) throw new Error("Governor lock changed; reload Session Guard.");
|
|
105
|
+
await unlink(path);
|
|
106
|
+
await claim();
|
|
107
|
+
} finally { await recovery.close(); await unlink(`${path}.recovery`).catch(() => undefined); }
|
|
108
|
+
}
|
|
109
|
+
return async () => {
|
|
110
|
+
if (await readFile(path, "utf8").catch(() => null) === token) await unlink(path);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { PluginHandlerContext } from "@getpaseo/plugin/server";
|
|
2
|
+
import type { Notification } from "../../shared/guard/timeline.ts";
|
|
3
|
+
|
|
4
|
+
export type PublishTimeline = (agentId: string, notification: Notification, reconcile: boolean) => Promise<void>;
|
|
5
|
+
export function timelinePublisher(api: () => Promise<PluginHandlerContext["paseo"]>): PublishTimeline {
|
|
6
|
+
return async (agentId, notification, reconcile) => {
|
|
7
|
+
const timeline = (await api()).agents.ref(agentId).timeline;
|
|
8
|
+
if (reconcile) {
|
|
9
|
+
// Lost acknowledgements are checked against durable history before retry.
|
|
10
|
+
// Only IDs/timestamps are inspected; content is never logged or retained.
|
|
11
|
+
let cursor: NonNullable<Parameters<typeof timeline.refetch>[0]>["cursor"];
|
|
12
|
+
for (let page = 0; page < 32; page++) {
|
|
13
|
+
const result = await timeline.refetch({ direction: cursor ? "before" : "tail", ...(cursor ? { cursor } : {}), limit: 100, projection: "canonical" });
|
|
14
|
+
if (result.error || result.staleCursor) throw new Error("Timeline receipt could not be checked.");
|
|
15
|
+
if (result.entries.some(entry => entry.item.type === "plugin" && entry.item.id === notification.id)) return;
|
|
16
|
+
if (!result.hasOlder || result.entries.some(entry => Date.parse(entry.timestamp) < Date.parse(notification.data.at))) break;
|
|
17
|
+
if (!result.startCursor) throw new Error("Timeline receipt cursor is unavailable.");
|
|
18
|
+
cursor = result.startCursor;
|
|
19
|
+
if (page === 31) throw new Error("Timeline receipt check exceeded its page budget.");
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
await timeline.append({ type: "plugin", id: notification.id, kind: "quota-pause", version: 1, data: notification.data });
|
|
23
|
+
};
|
|
24
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Session Guard's usage reading, sharpened with Smart Session's recorder.
|
|
3
|
+
*
|
|
4
|
+
* The guard needs every provider's session window, which only Paseo's
|
|
5
|
+
* `provider.usage.list` reports. For Claude, though, Paseo serves a cache up to five
|
|
6
|
+
* minutes old and cannot be made to refresh (RESEARCH.md §7a), while the recorder in
|
|
7
|
+
* this same process asks Anthropic directly every three minutes. Whichever reading is
|
|
8
|
+
* newer wins, so a pause and — more usefully — its release follow the fresher number
|
|
9
|
+
* instead of waiting out Paseo's cache.
|
|
10
|
+
*
|
|
11
|
+
* Only a reading that says when the provider produced it is used: `resetReady` trusts
|
|
12
|
+
* `fetchedAt` to prove a report is newer than the pause, and an observation time is not
|
|
13
|
+
* that.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
import { canonicalWindowId, effectiveAt, type UsageSample } from "../../shared/usage.ts";
|
|
17
|
+
import type { ProviderUsage, Usage, UsageWindow } from "../../shared/guard/policy.ts";
|
|
18
|
+
|
|
19
|
+
/** The provider the recorder measures. It records nothing for any other. */
|
|
20
|
+
const RECORDED_PROVIDER = "claude";
|
|
21
|
+
|
|
22
|
+
export function withRecordedUsage(paseo: Usage, recorded: UsageSample | null): Usage {
|
|
23
|
+
if (recorded === null || recorded.fetchedAt === null) return paseo;
|
|
24
|
+
const recordedMs = Date.parse(effectiveAt(recorded));
|
|
25
|
+
if (!Number.isFinite(recordedMs)) return paseo;
|
|
26
|
+
|
|
27
|
+
const index = paseo.providers.findIndex((provider) => provider.providerId === RECORDED_PROVIDER);
|
|
28
|
+
const current = index === -1 ? undefined : paseo.providers[index];
|
|
29
|
+
if (current?.status === "available") {
|
|
30
|
+
const paseoMs = Date.parse(current.fetchedAt ?? paseo.fetchedAt);
|
|
31
|
+
if (Number.isFinite(paseoMs) && paseoMs >= recordedMs) return paseo;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Keep Paseo's window ids, which are what the policy's `windowIds` name, and fill
|
|
35
|
+
// them from the recorder; the recorder's own canonical ids cover any it lacks.
|
|
36
|
+
const used = new Set<string>();
|
|
37
|
+
const windows: UsageWindow[] = [];
|
|
38
|
+
for (const window of current?.status === "available" ? current.windows : []) {
|
|
39
|
+
const id = canonicalWindowId(window.id);
|
|
40
|
+
const reading = recorded.windows[id];
|
|
41
|
+
if (reading === undefined) {
|
|
42
|
+
windows.push(window);
|
|
43
|
+
continue;
|
|
44
|
+
}
|
|
45
|
+
used.add(id);
|
|
46
|
+
windows.push({ id: window.id, usedPct: reading.pct, remainingPct: null, resetsAt: reading.resetsAt ?? window.resetsAt ?? null });
|
|
47
|
+
}
|
|
48
|
+
for (const [id, reading] of Object.entries(recorded.windows)) {
|
|
49
|
+
if (!used.has(id)) windows.push({ id, usedPct: reading.pct, remainingPct: null, resetsAt: reading.resetsAt });
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const merged: ProviderUsage = { providerId: RECORDED_PROVIDER, status: "available", fetchedAt: new Date(recordedMs).toISOString(), windows };
|
|
53
|
+
const providers = [...paseo.providers];
|
|
54
|
+
if (index === -1) providers.push(merged);
|
|
55
|
+
else providers[index] = merged;
|
|
56
|
+
return { ...paseo, providers };
|
|
57
|
+
}
|
package/server/recorder.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
appendUsage,
|
|
23
23
|
clearCaches,
|
|
24
24
|
newestUsage,
|
|
25
|
+
noteAcceptedReading,
|
|
25
26
|
noteNewestUsage,
|
|
26
27
|
type ContextSample,
|
|
27
28
|
} from "./store.ts";
|
|
@@ -86,6 +87,7 @@ async function record(sample: UsageSample): Promise<boolean> {
|
|
|
86
87
|
const newest = await newestUsage();
|
|
87
88
|
const verdict = acceptReading(newest, sample);
|
|
88
89
|
if (!verdict.ok) return false;
|
|
90
|
+
noteAcceptedReading(sample);
|
|
89
91
|
if (!isWorthRecording(newest, sample)) return false;
|
|
90
92
|
await appendUsage(sample);
|
|
91
93
|
noteNewestUsage(sample);
|
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/server/store.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { appendFile, mkdir, readFile, readdir } from "node:fs/promises";
|
|
|
12
12
|
import { homedir } from "node:os";
|
|
13
13
|
import { join } from "node:path";
|
|
14
14
|
|
|
15
|
-
import type
|
|
15
|
+
import { effectiveAt, type UsageSample } from "../shared/usage.ts";
|
|
16
16
|
|
|
17
17
|
const DATA_DIRECTORY = "smart-session";
|
|
18
18
|
const LEGACY_DATA_DIRECTORY = "super-session";
|
|
@@ -195,7 +195,31 @@ export function noteNewestUsage(sample: UsageSample): void {
|
|
|
195
195
|
newestCache = { value: sample };
|
|
196
196
|
}
|
|
197
197
|
|
|
198
|
+
/**
|
|
199
|
+
* The newest reading that passed the acceptance gate, whether or not it was written.
|
|
200
|
+
*
|
|
201
|
+
* Most readings are refused by `isWorthRecording` only because nothing moved, so the
|
|
202
|
+
* newest *line* can be minutes older than the newest *fact*. Session Guard wants the
|
|
203
|
+
* fact — a fresh report below its threshold is what confirms a reset.
|
|
204
|
+
*/
|
|
205
|
+
let latestAccepted: UsageSample | null = null;
|
|
206
|
+
|
|
207
|
+
export function noteAcceptedReading(sample: UsageSample): void {
|
|
208
|
+
if (latestAccepted === null || Date.parse(effectiveAt(sample)) >= Date.parse(effectiveAt(latestAccepted))) {
|
|
209
|
+
latestAccepted = sample;
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
/** The freshest Claude plan-usage reading this process knows of, recorded or not. */
|
|
214
|
+
export async function freshestUsage(): Promise<UsageSample | null> {
|
|
215
|
+
const newest = await newestUsage();
|
|
216
|
+
if (latestAccepted === null) return newest;
|
|
217
|
+
if (newest === null) return latestAccepted;
|
|
218
|
+
return Date.parse(effectiveAt(latestAccepted)) >= Date.parse(effectiveAt(newest)) ? latestAccepted : newest;
|
|
219
|
+
}
|
|
220
|
+
|
|
198
221
|
/** Drops in-memory state so a reload starts clean. */
|
|
199
222
|
export function clearCaches(): void {
|
|
200
223
|
newestCache = null;
|
|
224
|
+
latestAccepted = null;
|
|
201
225
|
}
|
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
|
+
}
|