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/shared/blocks.ts
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Turning a stream of samples into the thing you actually want to look at:
|
|
3
|
+
* one record per limit window occurrence — a "block".
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { effectiveAt, type UsageSample, type WindowSample } from "./usage.ts";
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* How far two reported resets may differ and still be the same window.
|
|
10
|
+
*
|
|
11
|
+
* The provider re-derives the reset instant on every read, so the same window
|
|
12
|
+
* comes back as "…:59.873850" then "…:00.309167". Compared exactly, every refresh
|
|
13
|
+
* looks like a rollover. (Learned the hard way in paseo-defer; see its
|
|
14
|
+
* `paseo-defer/server/engine.ts`.)
|
|
15
|
+
*/
|
|
16
|
+
const SAME_WINDOW_TOLERANCE_MS = 120_000;
|
|
17
|
+
|
|
18
|
+
/** Nominal lengths, used only to place a block's start on a timeline. */
|
|
19
|
+
const WINDOW_DURATION_MS: Readonly<Record<string, number>> = {
|
|
20
|
+
five_hour: 5 * 60 * 60 * 1000,
|
|
21
|
+
seven_day: 7 * 24 * 60 * 60 * 1000,
|
|
22
|
+
};
|
|
23
|
+
|
|
24
|
+
/** A stretch with no samples, long enough that a chart must not draw across it. */
|
|
25
|
+
export interface Gap {
|
|
26
|
+
readonly from: string;
|
|
27
|
+
readonly to: string;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface BlockPoint {
|
|
31
|
+
readonly t: string;
|
|
32
|
+
readonly pct: number;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface Block {
|
|
36
|
+
readonly windowId: string;
|
|
37
|
+
/** The window's rollover instant, as last reported. */
|
|
38
|
+
readonly resetsAt: string | null;
|
|
39
|
+
/** resetsAt minus the window's nominal length; null when the length is unknown. */
|
|
40
|
+
readonly startAt: string | null;
|
|
41
|
+
readonly firstSeenAt: string;
|
|
42
|
+
readonly lastSeenAt: string;
|
|
43
|
+
readonly peakPct: number;
|
|
44
|
+
readonly finalPct: number;
|
|
45
|
+
readonly points: readonly BlockPoint[];
|
|
46
|
+
readonly gaps: readonly Gap[];
|
|
47
|
+
readonly scope?: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** One instant format, whatever spelling the source used. */
|
|
51
|
+
function normalize(value: string | null): string | null {
|
|
52
|
+
if (value === null) return null;
|
|
53
|
+
const ms = Date.parse(value);
|
|
54
|
+
return Number.isNaN(ms) ? null : new Date(ms).toISOString();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function sameWindow(a: string | null, b: string | null): boolean {
|
|
58
|
+
if (a === null || b === null) return a === b;
|
|
59
|
+
return Math.abs(Date.parse(a) - Date.parse(b)) <= SAME_WINDOW_TOLERANCE_MS;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Whether `next` is a genuinely later window than `current`.
|
|
64
|
+
*
|
|
65
|
+
* A new window always ends later than the one it replaced. An *earlier* reported
|
|
66
|
+
* reset is the provider revising the window we are already in, never a rollover —
|
|
67
|
+
* treating it as one attributes the rest of the block to the wrong occurrence.
|
|
68
|
+
*/
|
|
69
|
+
function isRollover(current: string | null, next: string | null): boolean {
|
|
70
|
+
if (next === null) return false;
|
|
71
|
+
if (current === null) return true;
|
|
72
|
+
return Date.parse(next) - Date.parse(current) > SAME_WINDOW_TOLERANCE_MS;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
interface OpenBlock {
|
|
76
|
+
windowId: string;
|
|
77
|
+
resetsAt: string | null;
|
|
78
|
+
scope?: string;
|
|
79
|
+
points: BlockPoint[];
|
|
80
|
+
gaps: Gap[];
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
function seal(open: OpenBlock): Block {
|
|
84
|
+
const points = open.points;
|
|
85
|
+
const first = points[0]!;
|
|
86
|
+
const last = points[points.length - 1]!;
|
|
87
|
+
const duration = WINDOW_DURATION_MS[open.windowId.split(":")[0]!];
|
|
88
|
+
return {
|
|
89
|
+
windowId: open.windowId,
|
|
90
|
+
resetsAt: open.resetsAt,
|
|
91
|
+
startAt:
|
|
92
|
+
open.resetsAt !== null && duration !== undefined
|
|
93
|
+
? new Date(Date.parse(open.resetsAt) - duration).toISOString()
|
|
94
|
+
: null,
|
|
95
|
+
firstSeenAt: first.t,
|
|
96
|
+
lastSeenAt: last.t,
|
|
97
|
+
peakPct: points.reduce((peak, point) => Math.max(peak, point.pct), 0),
|
|
98
|
+
finalPct: last.pct,
|
|
99
|
+
points,
|
|
100
|
+
gaps: open.gaps,
|
|
101
|
+
...(open.scope !== undefined ? { scope: open.scope } : {}),
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Segment samples into blocks, newest last.
|
|
107
|
+
*
|
|
108
|
+
* `gapMs` should exceed the recorder's heartbeat: anything longer than that means
|
|
109
|
+
* nobody was watching, which is a fact about the recorder and must stay visible in
|
|
110
|
+
* the output rather than being smoothed over.
|
|
111
|
+
*/
|
|
112
|
+
export function segmentBlocks(
|
|
113
|
+
samples: readonly UsageSample[],
|
|
114
|
+
options: { readonly gapMs?: number } = {},
|
|
115
|
+
): Block[] {
|
|
116
|
+
const gapMs = options.gapMs ?? 15 * 60 * 1000;
|
|
117
|
+
const ordered = [...samples].sort((a, b) => Date.parse(effectiveAt(a)) - Date.parse(effectiveAt(b)));
|
|
118
|
+
|
|
119
|
+
const open = new Map<string, OpenBlock>();
|
|
120
|
+
const sealed: Block[] = [];
|
|
121
|
+
|
|
122
|
+
for (const sample of ordered) {
|
|
123
|
+
// One instant format in, whatever the source spelled it as.
|
|
124
|
+
const t = new Date(Date.parse(effectiveAt(sample))).toISOString();
|
|
125
|
+
for (const [windowId, window] of Object.entries(sample.windows)) {
|
|
126
|
+
const current = open.get(windowId);
|
|
127
|
+
if (current === undefined) {
|
|
128
|
+
open.set(windowId, startBlock(windowId, window, t));
|
|
129
|
+
continue;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
if (isRollover(current.resetsAt, window.resetsAt)) {
|
|
133
|
+
sealed.push(seal(current));
|
|
134
|
+
open.set(windowId, startBlock(windowId, window, t));
|
|
135
|
+
continue;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// Not a rollover: adopt a reset instant we did not have, keep the one we did.
|
|
139
|
+
if (current.resetsAt === null && window.resetsAt !== null) current.resetsAt = normalize(window.resetsAt);
|
|
140
|
+
else if (!sameWindow(current.resetsAt, window.resetsAt) && window.resetsAt !== null) {
|
|
141
|
+
// Reported earlier than what we hold — a revision. Keep the block, take the value.
|
|
142
|
+
current.resetsAt = normalize(window.resetsAt);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
const previous = current.points[current.points.length - 1]!;
|
|
146
|
+
if (Date.parse(t) - Date.parse(previous.t) > gapMs) {
|
|
147
|
+
current.gaps.push({ from: previous.t, to: t });
|
|
148
|
+
}
|
|
149
|
+
current.points.push({ t, pct: window.pct });
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
for (const current of open.values()) sealed.push(seal(current));
|
|
154
|
+
return sealed.sort((a, b) => Date.parse(a.firstSeenAt) - Date.parse(b.firstSeenAt));
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function startBlock(windowId: string, window: WindowSample, t: string): OpenBlock {
|
|
158
|
+
return {
|
|
159
|
+
windowId,
|
|
160
|
+
resetsAt: normalize(window.resetsAt),
|
|
161
|
+
...(window.scope !== undefined ? { scope: window.scope } : {}),
|
|
162
|
+
points: [{ t, pct: window.pct }],
|
|
163
|
+
gaps: [],
|
|
164
|
+
};
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Burn rate in percentage points per hour over *at least* the block's most recent
|
|
169
|
+
* `spanMs`.
|
|
170
|
+
*
|
|
171
|
+
* The anchor is the last point at or before the cutoff, not the first one after
|
|
172
|
+
* it. Samples are only written when something changes, so a quiet stretch may hold
|
|
173
|
+
* a single point inside any given span — anchoring inside the window would make
|
|
174
|
+
* the span zero and the rate unreportable exactly when the answer is "flat".
|
|
175
|
+
*/
|
|
176
|
+
export function burnRatePctPerHour(block: Block, spanMs = 30 * 60 * 1000): number | null {
|
|
177
|
+
const points = block.points;
|
|
178
|
+
if (points.length < 2) return null;
|
|
179
|
+
const last = points[points.length - 1]!;
|
|
180
|
+
const cutoff = Date.parse(last.t) - spanMs;
|
|
181
|
+
|
|
182
|
+
let index = points.findIndex((point) => Date.parse(point.t) >= cutoff);
|
|
183
|
+
if (index === -1) index = points.length - 1;
|
|
184
|
+
if (index > 0) index -= 1;
|
|
185
|
+
|
|
186
|
+
const first = points[index]!;
|
|
187
|
+
const hours = (Date.parse(last.t) - Date.parse(first.t)) / 3_600_000;
|
|
188
|
+
if (hours <= 0) return null;
|
|
189
|
+
return (last.pct - first.pct) / hours;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
/**
|
|
193
|
+
* When this block reaches 100% at the current burn rate.
|
|
194
|
+
*
|
|
195
|
+
* Null when it is not burning, or when it would exhaust after the window rolls
|
|
196
|
+
* over anyway — in which case there is nothing to warn about.
|
|
197
|
+
*/
|
|
198
|
+
export function projectedExhaustion(block: Block, now = new Date()): string | null {
|
|
199
|
+
const rate = burnRatePctPerHour(block);
|
|
200
|
+
if (rate === null || rate <= 0) return null;
|
|
201
|
+
const last = block.points[block.points.length - 1]!;
|
|
202
|
+
const hoursLeft = (100 - last.pct) / rate;
|
|
203
|
+
if (!Number.isFinite(hoursLeft) || hoursLeft < 0) return null;
|
|
204
|
+
const at = Date.parse(last.t) + hoursLeft * 3_600_000;
|
|
205
|
+
if (at < now.getTime()) return null;
|
|
206
|
+
if (block.resetsAt !== null && at > Date.parse(block.resetsAt)) return null;
|
|
207
|
+
return new Date(at).toISOString();
|
|
208
|
+
}
|
package/shared/format.ts
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/** Small formatters shared by the surface and any future CLI. */
|
|
2
|
+
|
|
3
|
+
/** "2h 14m", "4m", "now" — a duration a person reads without decoding. */
|
|
4
|
+
export function formatDuration(ms: number): string {
|
|
5
|
+
if (!Number.isFinite(ms) || ms <= 0) return "now";
|
|
6
|
+
const minutes = Math.round(ms / 60_000);
|
|
7
|
+
if (minutes < 1) return "under a minute";
|
|
8
|
+
if (minutes < 60) return `${minutes}m`;
|
|
9
|
+
const hours = Math.floor(minutes / 60);
|
|
10
|
+
const rest = minutes % 60;
|
|
11
|
+
if (hours < 24) return rest === 0 ? `${hours}h` : `${hours}h ${rest}m`;
|
|
12
|
+
const days = Math.floor(hours / 24);
|
|
13
|
+
return `${days}d ${hours % 24}h`;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
/** "in 2h 14m" / "14m ago", relative to now. */
|
|
17
|
+
export function formatRelative(iso: string | null, now = Date.now()): string {
|
|
18
|
+
if (iso === null) return "—";
|
|
19
|
+
const ms = Date.parse(iso);
|
|
20
|
+
if (Number.isNaN(ms)) return "—";
|
|
21
|
+
return ms >= now ? `in ${formatDuration(ms - now)}` : `${formatDuration(now - ms)} ago`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** "261.7k" — token counts, which are always large and never need units. */
|
|
25
|
+
export function formatTokens(count: number): string {
|
|
26
|
+
if (count < 1000) return String(count);
|
|
27
|
+
if (count < 1_000_000) return `${(count / 1000).toFixed(count < 10_000 ? 1 : 0)}k`;
|
|
28
|
+
return `${(count / 1_000_000).toFixed(2)}M`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/** A window id turned into something worth putting on a row. */
|
|
32
|
+
export function windowLabel(id: string): string {
|
|
33
|
+
if (id === "five_hour") return "Session (5h)";
|
|
34
|
+
if (id === "seven_day") return "Week";
|
|
35
|
+
const scoped = /^weekly_(model|surface):(.+)$/.exec(id);
|
|
36
|
+
if (scoped !== null) {
|
|
37
|
+
const name = scoped[2]!;
|
|
38
|
+
return `Week · ${name.charAt(0).toUpperCase()}${name.slice(1)}`;
|
|
39
|
+
}
|
|
40
|
+
return id;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Which windows are worth a row.
|
|
45
|
+
*
|
|
46
|
+
* The provider reports a long tail of caps that are inactive, unnamed, or at zero
|
|
47
|
+
* for this account — internal codenames included. Showing them all buries the two
|
|
48
|
+
* that matter.
|
|
49
|
+
*/
|
|
50
|
+
export function isInteresting(window: { id: string; pct: number; resetsAt: string | null }): boolean {
|
|
51
|
+
if (window.id === "five_hour" || window.id === "seven_day") return true;
|
|
52
|
+
return window.pct > 0 || window.resetsAt !== null;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Which step of a sequential ramp a value falls on.
|
|
58
|
+
*
|
|
59
|
+
* Magnitude is encoded with one hue at stepped intensities, so this returns an
|
|
60
|
+
* index into that ramp — or -1 for "no data", which must render as the surface
|
|
61
|
+
* rather than as the faintest step: absence and "a little" must not look alike.
|
|
62
|
+
*/
|
|
63
|
+
export function rampStep(value: number, max: number, steps: number): number {
|
|
64
|
+
if (steps <= 0 || max <= 0 || value <= 0) return -1;
|
|
65
|
+
const index = Math.ceil((value / max) * steps) - 1;
|
|
66
|
+
return Math.min(steps - 1, Math.max(0, index));
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* What the composer pill says about one agent.
|
|
71
|
+
*
|
|
72
|
+
* "Smart Compact" rather than "auto-compact": Claude Code already ships a feature
|
|
73
|
+
* by the latter name that fires on its own thresholds, and a pill that borrowed the
|
|
74
|
+
* name would read as a switch for that.
|
|
75
|
+
*
|
|
76
|
+
* Two states, because there are only two. Whether this session is governed is the
|
|
77
|
+
* only question a pill can answer; whether the feature exists at all is answered by
|
|
78
|
+
* the pill being drawn, since it is hidden entirely when Smart Compact is off.
|
|
79
|
+
*/
|
|
80
|
+
export function smartCompactLabel(input: { enrolled: boolean }): string {
|
|
81
|
+
return input.enrolled ? "Smart Compact on" : "Smart Compact off";
|
|
82
|
+
}
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The Governor: letting an agent compact itself.
|
|
3
|
+
*
|
|
4
|
+
* An agent cannot run `/compact` — it is a client command, not something the model
|
|
5
|
+
* can invoke. But Paseo parses slash commands out of any message sent to an agent
|
|
6
|
+
* and honours the root-only set, `compact` included, so a request routed through
|
|
7
|
+
* the daemon becomes a real compaction with real custom instructions. Verified end
|
|
8
|
+
* to end: 39,325 -> 6,160 tokens with instructions honoured (RESEARCH.md §7.1).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
12
|
+
import { z } from "zod";
|
|
13
|
+
|
|
14
|
+
export const CompactionStateSchema = z.enum(["pending", "sending", "sent", "failed", "cancelled"]);
|
|
15
|
+
|
|
16
|
+
export const CompactionRequestSchema = z.object({
|
|
17
|
+
id: z.string(),
|
|
18
|
+
agentId: z.string(),
|
|
19
|
+
/** Why the agent asked, in its own words. Goes into the compaction instructions. */
|
|
20
|
+
reason: z.string(),
|
|
21
|
+
/** A file the agent considers authoritative, re-read after compaction. */
|
|
22
|
+
statePath: z.string().nullable(),
|
|
23
|
+
/** Whether to start another model turn after the compaction lands. */
|
|
24
|
+
continueAfterCompaction: z.boolean(),
|
|
25
|
+
/** Exact follow-up chosen by the agent, or null for the state-aware default. */
|
|
26
|
+
continuationMessage: z.string().nullable(),
|
|
27
|
+
createdAt: z.string(),
|
|
28
|
+
state: CompactionStateSchema,
|
|
29
|
+
settledAt: z.string().nullable(),
|
|
30
|
+
error: z.string().nullable(),
|
|
31
|
+
/** Context occupancy either side of the compaction, for grading the policy. */
|
|
32
|
+
preTokens: z.number().nullable(),
|
|
33
|
+
postTokens: z.number().nullable(),
|
|
34
|
+
/**
|
|
35
|
+
* When the requested continuation message was sent.
|
|
36
|
+
*
|
|
37
|
+
* Nothing in Claude Code restarts a task after a manual `/compact` — verified
|
|
38
|
+
* across 190 real compactions, 163 of which stopped dead waiting for a person
|
|
39
|
+
* (`RESEARCH.md` §3.4). When the agent wants to keep working, the governor says
|
|
40
|
+
* the default or agent-supplied follow-up once and records that it did.
|
|
41
|
+
*/
|
|
42
|
+
resumedAt: z.string().nullable(),
|
|
43
|
+
});
|
|
44
|
+
export type CompactionRequest = z.infer<typeof CompactionRequestSchema>;
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Queues a compaction for an agent, delivered once that agent is idle.
|
|
48
|
+
*
|
|
49
|
+
* Never delivered mid-turn: a message arriving during a turn steers it instead of
|
|
50
|
+
* arriving as its own instruction, and a compaction that interrupts a half-finished
|
|
51
|
+
* tool sequence loses exactly the state the agent had not written down yet.
|
|
52
|
+
*/
|
|
53
|
+
export const requestCompaction = defineRpc({
|
|
54
|
+
name: "smart-session.compact.request",
|
|
55
|
+
input: z.object({
|
|
56
|
+
agentId: z.string(),
|
|
57
|
+
reason: z.string(),
|
|
58
|
+
statePath: z.string().nullable().optional(),
|
|
59
|
+
continueAfterCompaction: z.boolean().optional(),
|
|
60
|
+
continuationMessage: z.string().min(1).nullable().optional(),
|
|
61
|
+
}),
|
|
62
|
+
output: z.object({ request: CompactionRequestSchema }),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
export const listCompactions = defineRpc({
|
|
66
|
+
name: "smart-session.compact.list",
|
|
67
|
+
input: z.object({ agentId: z.string().optional() }),
|
|
68
|
+
output: z.object({ items: z.array(CompactionRequestSchema) }),
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
export const cancelCompaction = defineRpc({
|
|
72
|
+
name: "smart-session.compact.cancel",
|
|
73
|
+
input: z.object({ id: z.string() }),
|
|
74
|
+
output: z.object({ ok: z.boolean() }),
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
export const AgentEnrolmentSchema = z.object({
|
|
78
|
+
agentId: z.string(),
|
|
79
|
+
enrolled: z.boolean(),
|
|
80
|
+
/** True when a person said so, false when it was inferred from a state file. */
|
|
81
|
+
explicit: z.boolean(),
|
|
82
|
+
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* Everything the composer pill draws itself from, in one round trip.
|
|
86
|
+
*
|
|
87
|
+
* `enabled` rides along because it decides whether there is a pill at all: with the
|
|
88
|
+
* feature switched off there is nothing for one to say, and a pill that read
|
|
89
|
+
* "paused" would just be a second way of spelling off.
|
|
90
|
+
*/
|
|
91
|
+
export const EnrolmentStateSchema = z.object({
|
|
92
|
+
showPill: z.boolean(),
|
|
93
|
+
enabled: z.boolean(),
|
|
94
|
+
/** Only agents with a state file or an explicit answer. Anyone absent is off. */
|
|
95
|
+
agents: z.array(AgentEnrolmentSchema),
|
|
96
|
+
});
|
|
97
|
+
export type EnrolmentState = z.infer<typeof EnrolmentStateSchema>;
|
|
98
|
+
|
|
99
|
+
export const enrolmentState = defineRpc({
|
|
100
|
+
name: "smart-session.enrolment.state",
|
|
101
|
+
input: z.object({}),
|
|
102
|
+
output: EnrolmentStateSchema,
|
|
103
|
+
});
|
|
104
|
+
|
|
105
|
+
export const setEnrolment = defineRpc({
|
|
106
|
+
name: "smart-session.enrolment.set",
|
|
107
|
+
input: z.object({ agentId: z.string(), enrolled: z.boolean() }),
|
|
108
|
+
output: z.object({ agent: AgentEnrolmentSchema }),
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* The instructions sent with `/compact`.
|
|
113
|
+
*
|
|
114
|
+
* Two jobs. It tells the summarizer what this particular task cannot afford to
|
|
115
|
+
* lose, and — when the agent named a state file — it points the continuation at the
|
|
116
|
+
* one thing on disk that is authoritative, so the quality of the summary stops
|
|
117
|
+
* being load-bearing.
|
|
118
|
+
*/
|
|
119
|
+
export function compactionInstructions(request: {
|
|
120
|
+
reason: string;
|
|
121
|
+
statePath: string | null;
|
|
122
|
+
}): string {
|
|
123
|
+
const parts = [
|
|
124
|
+
"/compact",
|
|
125
|
+
`Preserve: the current goal, the step in progress and its exact next action, decisions already made and why, and every approach already tried and rejected.`,
|
|
126
|
+
`Discard: file contents already read, superseded plans, and tool output that has been acted on.`,
|
|
127
|
+
`Reason for compacting now: ${request.reason}`,
|
|
128
|
+
];
|
|
129
|
+
if (request.statePath !== null && request.statePath !== "") {
|
|
130
|
+
parts.push(
|
|
131
|
+
`Authoritative state lives at ${request.statePath}. Re-read that file before acting; where it disagrees with this summary, the file wins.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
return parts.join(" ");
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* What the continuation is told, once the compaction has landed.
|
|
139
|
+
*
|
|
140
|
+
* This is the second and last message Paseo sends, and it exists because no hook
|
|
141
|
+
* can do it. `PostCompact` cannot inject at all; `SessionStart:compact` injects
|
|
142
|
+
* context but starts no turn — its `initialUserMessage` field is only ever read at
|
|
143
|
+
* process startup; and `Stop` does not fire for a `/compact`, because a command
|
|
144
|
+
* runs no model turn. All three were tested against 2.1.263 (`RESEARCH.md` §3.4).
|
|
145
|
+
*
|
|
146
|
+
* Deliberately short. The state file holds the task and the summary holds the
|
|
147
|
+
* conversation, so restating either here would only give the continuation a third
|
|
148
|
+
* account to reconcile.
|
|
149
|
+
*/
|
|
150
|
+
export function resumeInstructions(request: { statePath: string | null }): string {
|
|
151
|
+
if (request.statePath === null || request.statePath === "") {
|
|
152
|
+
return (
|
|
153
|
+
"That compaction was the one you asked for. Pick the task back up from the summary above and carry on from the next action it describes — " +
|
|
154
|
+
"and write your task state to disk with the checkpoint tool now, so the next one costs nothing."
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
return (
|
|
158
|
+
`That compaction was the one you asked for. Re-read ${request.statePath} and continue from its "Current step" section. ` +
|
|
159
|
+
"Where the file disagrees with the summary above, the file is correct — it was written deliberately; the summary was compressed automatically. " +
|
|
160
|
+
"Carry on from there without asking what to do next."
|
|
161
|
+
);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Resolves the optional second message after a queued compaction lands.
|
|
166
|
+
*
|
|
167
|
+
* Continuing remains the default for requests made by older plugin versions, but
|
|
168
|
+
* the requesting agent can suppress the turn when its task is already complete or
|
|
169
|
+
* replace the state-aware default with the exact next instruction it needs.
|
|
170
|
+
*/
|
|
171
|
+
export function continuationInstructions(request: {
|
|
172
|
+
continueAfterCompaction: boolean;
|
|
173
|
+
continuationMessage: string | null;
|
|
174
|
+
statePath: string | null;
|
|
175
|
+
}): string | null {
|
|
176
|
+
if (!request.continueAfterCompaction) return null;
|
|
177
|
+
return request.continuationMessage ?? resumeInstructions({ statePath: request.statePath });
|
|
178
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lets the entry point release daemon-side resources without naming them.
|
|
3
|
+
*
|
|
4
|
+
* Paseo deletes `*.server` imports from the client bundle but keeps the surrounding
|
|
5
|
+
* statements, so a server identifier in `contribute()`'s shared body becomes a
|
|
6
|
+
* ReferenceError that aborts every registration. A shared module is safe in both
|
|
7
|
+
* bundles: `recorder.server` fills this in on the daemon, and it stays null in the
|
|
8
|
+
* client, where there is nothing to release.
|
|
9
|
+
*/
|
|
10
|
+
export type Teardown = () => void | Promise<void>;
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* A list rather than a single slot: the recorder and the governor each start on
|
|
14
|
+
* import and each own a timer, and both must be released or the subprocess never
|
|
15
|
+
* exits and the plugin wedges at "Stopping" for the life of the daemon.
|
|
16
|
+
*/
|
|
17
|
+
export const lifecycle: { teardowns: Teardown[] } = { teardowns: [] };
|
|
@@ -0,0 +1,155 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* RPC contracts, shared by both bundles.
|
|
3
|
+
*
|
|
4
|
+
* Zod schemas here are the wire contract: the daemon validates input and output
|
|
5
|
+
* against them, so a shape change that a client has not seen fails loudly at the
|
|
6
|
+
* boundary rather than quietly downstream.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import { defineRpc } from "@getpaseo/plugin";
|
|
10
|
+
import { z } from "zod";
|
|
11
|
+
|
|
12
|
+
export const WindowStatusSchema = z.object({
|
|
13
|
+
id: z.string(),
|
|
14
|
+
pct: z.number(),
|
|
15
|
+
resetsAt: z.string().nullable(),
|
|
16
|
+
/** Percentage points per hour over the recent past; null when it cannot be measured. */
|
|
17
|
+
burnPctPerHour: z.number().nullable(),
|
|
18
|
+
/** When this window hits 100% at that rate, if that happens before it resets. */
|
|
19
|
+
projectedFullAt: z.string().nullable(),
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
export const RecorderStatusSchema = z.object({
|
|
23
|
+
/** The reading the store considers current, as an instant the provider vouched for. */
|
|
24
|
+
newestAt: z.string().nullable(),
|
|
25
|
+
/** How stale that reading is, in seconds. */
|
|
26
|
+
ageSeconds: z.number().nullable(),
|
|
27
|
+
source: z.string().nullable(),
|
|
28
|
+
samplesHeld: z.number(),
|
|
29
|
+
firstSampleAt: z.string().nullable(),
|
|
30
|
+
dataDir: z.string(),
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Everything an agent or a panel needs to answer "how much budget is left, and how
|
|
35
|
+
* fast am I spending it".
|
|
36
|
+
*/
|
|
37
|
+
export const budgetStatus = defineRpc({
|
|
38
|
+
name: "smart-session.budget",
|
|
39
|
+
input: z.object({}),
|
|
40
|
+
output: z.object({
|
|
41
|
+
windows: z.array(WindowStatusSchema),
|
|
42
|
+
recorder: RecorderStatusSchema,
|
|
43
|
+
error: z.string().nullable(),
|
|
44
|
+
}),
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
export const ContextStatusSchema = z.object({
|
|
48
|
+
agentId: z.string(),
|
|
49
|
+
title: z.string().nullable(),
|
|
50
|
+
provider: z.string(),
|
|
51
|
+
model: z.string().nullable(),
|
|
52
|
+
status: z.string(),
|
|
53
|
+
usedTokens: z.number(),
|
|
54
|
+
maxTokens: z.number(),
|
|
55
|
+
usedPct: z.number(),
|
|
56
|
+
costUsd: z.number().nullable(),
|
|
57
|
+
/** Recent context growth, from the recorded history. Null until there is a span to measure. */
|
|
58
|
+
growthTokensPerHour: z.number().nullable(),
|
|
59
|
+
/** When this agent would reach the compact band, at that rate. */
|
|
60
|
+
projectedFullAt: z.string().nullable(),
|
|
61
|
+
/** The percentage at which this window is considered ready to compact. */
|
|
62
|
+
compactAtPct: z.number(),
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
/** Context occupancy for one agent, or all of them. */
|
|
66
|
+
export const contextStatus = defineRpc({
|
|
67
|
+
name: "smart-session.context",
|
|
68
|
+
input: z.object({ agentId: z.string().optional() }),
|
|
69
|
+
output: z.object({
|
|
70
|
+
agents: z.array(ContextStatusSchema),
|
|
71
|
+
error: z.string().nullable(),
|
|
72
|
+
}),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
const ThresholdProfileSchema = z.object({
|
|
77
|
+
notice: z.number().finite().min(1).max(99),
|
|
78
|
+
closing: z.number().finite().min(1).max(99),
|
|
79
|
+
compact: z.number().finite().min(1).max(99),
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
export const ThresholdsSchema = z.object({
|
|
83
|
+
largeWindowFrom: z.number().finite().positive(),
|
|
84
|
+
large: ThresholdProfileSchema,
|
|
85
|
+
small: ThresholdProfileSchema,
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
export const SettingsSchema = z.object({
|
|
89
|
+
/** The master switch: with this off, Smart Compact does nothing at all. */
|
|
90
|
+
enabled: z.boolean(),
|
|
91
|
+
thresholds: ThresholdsSchema,
|
|
92
|
+
freshStateMinutes: z.number(),
|
|
93
|
+
/** Whether every agent's composer carries the smart-compact pill. */
|
|
94
|
+
showPill: z.boolean(),
|
|
95
|
+
/** Whether checkpointing enrols a session, or enrolment is per-session opt-in. */
|
|
96
|
+
autoEnrol: z.boolean(),
|
|
97
|
+
/** Whether the plugin keeps its own hooks registered in Claude Code. */
|
|
98
|
+
installHooks: z.boolean(),
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
/**
|
|
102
|
+
* What the plugin did to Claude Code's configuration, and whether it worked.
|
|
103
|
+
*
|
|
104
|
+
* Worth surfacing rather than only logging: an install that silently found no
|
|
105
|
+
* hooks directory looks exactly like a working one from the outside.
|
|
106
|
+
*/
|
|
107
|
+
export const InstallReportSchema = z.object({
|
|
108
|
+
pluginDir: z.string().nullable(),
|
|
109
|
+
settingsPath: z.string(),
|
|
110
|
+
changed: z.boolean(),
|
|
111
|
+
hooks: z.array(z.string()),
|
|
112
|
+
mcp: z.enum(["present", "added", "unavailable", "skipped"]),
|
|
113
|
+
error: z.string().nullable(),
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
/** The state of the Claude Code integration, re-reconciled on demand. */
|
|
117
|
+
export const installStatus = defineRpc({
|
|
118
|
+
name: "smart-session.install.status",
|
|
119
|
+
input: z.object({}),
|
|
120
|
+
output: z.object({ report: InstallReportSchema }),
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
/** Read the governor's settings, and the count of agents currently enrolled. */
|
|
124
|
+
export const getSettings = defineRpc({
|
|
125
|
+
name: "smart-session.settings.get",
|
|
126
|
+
input: z.object({}),
|
|
127
|
+
output: z.object({ settings: SettingsSchema, enrolledAgents: z.number() }),
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
export const setSettings = defineRpc({
|
|
131
|
+
name: "smart-session.settings.set",
|
|
132
|
+
input: SettingsSchema.partial(),
|
|
133
|
+
output: z.object({ settings: SettingsSchema }),
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
export const SpendSummarySchema = z.object({
|
|
138
|
+
byDay: z.array(z.object({ day: z.string(), tokens: z.number(), messages: z.number() })),
|
|
139
|
+
heatmap: z.array(z.object({ weekday: z.number(), hour: z.number(), tokens: z.number() })),
|
|
140
|
+
byProject: z.array(z.object({ name: z.string(), tokens: z.number() })),
|
|
141
|
+
byModel: z.array(z.object({ name: z.string(), tokens: z.number() })),
|
|
142
|
+
totalTokens: z.number(),
|
|
143
|
+
subagentTokens: z.number(),
|
|
144
|
+
cacheHitPct: z.number(),
|
|
145
|
+
firstHour: z.string().nullable(),
|
|
146
|
+
lastHour: z.string().nullable(),
|
|
147
|
+
bucketCount: z.number(),
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
/** Token spend reconstructed from transcripts — the history from before the recorder. */
|
|
151
|
+
export const spendSummary = defineRpc({
|
|
152
|
+
name: "smart-session.spend",
|
|
153
|
+
input: z.object({ days: z.number().optional() }),
|
|
154
|
+
output: z.object({ summary: SpendSummarySchema, error: z.string().nullable() }),
|
|
155
|
+
});
|