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.
@@ -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
+ }
@@ -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
+ });