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,149 @@
1
+ /**
2
+ * The state-file pointer, and the two ways it can reach the model.
3
+ *
4
+ * Compaction is the one moment the agent is deciding what it knows, so it is the
5
+ * one moment worth spending an injection on. But `PostCompact` — the event that
6
+ * knows a compaction happened — cannot inject: Claude Code validates
7
+ * `hookSpecificOutput.hookEventName` against a union that does not contain it, and
8
+ * rejects the whole output. So the pointer is written here as a small pending note
9
+ * and delivered by whichever hook speaks first:
10
+ *
11
+ * - `SessionStart` with `source: "compact"`, which fires in the emptied context
12
+ * and does accept `additionalContext`; or
13
+ * - the next `PostToolUse`, which is guaranteed to run.
14
+ *
15
+ * Whoever delivers it clears it, so it is said exactly once.
16
+ *
17
+ * Keyed by Paseo agent id when there is one, because that survives compaction
18
+ * whatever Claude Code decides to do with the session id.
19
+ */
20
+
21
+ import { existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
22
+ import { homedir } from "node:os";
23
+ import { join } from "node:path";
24
+
25
+ export function pluginDir() {
26
+ const home = process.env.PASEO_HOME ?? join(homedir(), ".paseo");
27
+ return join(home, "plugin-data", "smart-session");
28
+ }
29
+
30
+ /** The same derivation the agent's `checkpoint` tool uses. */
31
+ export function statePath() {
32
+ const override = process.env.SMART_SESSION_STATE_FILE;
33
+ if (override !== undefined && override !== "") return override;
34
+ const agentId = process.env.PASEO_AGENT_ID;
35
+ if (agentId === undefined || agentId === "") return null;
36
+ return join(pluginDir(), "state", `${agentId}.md`);
37
+ }
38
+
39
+ /** Seconds since the state file was last written, or null if there isn't one. */
40
+ export function stateAgeSeconds(path) {
41
+ if (path === null || !existsSync(path)) return null;
42
+ try {
43
+ return Math.round((Date.now() - statSync(path).mtimeMs) / 1000);
44
+ } catch {
45
+ return null;
46
+ }
47
+ }
48
+
49
+ const STALE_AFTER_SECONDS = 30 * 60;
50
+
51
+ /**
52
+ * What the continuation needs to hear, given what is actually on disk.
53
+ *
54
+ * Returns null when there is nowhere to point — no agent id, no override — since
55
+ * naming no file at all would be worse than silence.
56
+ */
57
+ export function pointerText(path = statePath(), age = stateAgeSeconds(path)) {
58
+ if (path === null) return null;
59
+ if (age === null) {
60
+ return (
61
+ `No durable state file exists yet for this session (expected at ${path}). Everything not in the summary above is now gone. ` +
62
+ "Before continuing, write down the goal, the step in progress and its exact next action, decisions made and why, and every approach already tried and rejected — then keep it current, so the next compaction costs nothing."
63
+ );
64
+ }
65
+ const lines = [
66
+ `Durable task state for this session is at ${path}. Re-read it before acting. Where it disagrees with the summary above, the file is correct — it was written deliberately; the summary was compressed automatically.`,
67
+ ];
68
+ // A state file older than the work it is supposed to describe is worse than
69
+ // none, because it reads as authoritative. Say so plainly.
70
+ if (age > STALE_AFTER_SECONDS) {
71
+ lines.push(
72
+ `Note: that file was last written ${Math.round(age / 60)} minutes ago, so it may predate recent work. Reconcile it against the summary and update it before continuing.`,
73
+ );
74
+ }
75
+ return lines.join(" ");
76
+ }
77
+
78
+ function pointerPath(sessionId) {
79
+ const agentId = process.env.PASEO_AGENT_ID;
80
+ const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
81
+ if (typeof key !== "string" || key === "") return null;
82
+ return join(pluginDir(), "pointers", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
83
+ }
84
+
85
+ function readNote(sessionId) {
86
+ const path = pointerPath(sessionId);
87
+ if (path === null) return null;
88
+ try {
89
+ return JSON.parse(readFileSync(path, "utf8"));
90
+ } catch {
91
+ return null;
92
+ }
93
+ }
94
+
95
+ function writeNote(sessionId, note) {
96
+ const path = pointerPath(sessionId);
97
+ if (path === null) return;
98
+ try {
99
+ mkdirSync(join(pluginDir(), "pointers"), { recursive: true });
100
+ writeFileSync(path, JSON.stringify(note), "utf8");
101
+ } catch {
102
+ // A hook that cannot persist its note must still not break the turn.
103
+ }
104
+ }
105
+
106
+ /** Leaves the pointer where either delivery path will find it. */
107
+ export function writePending(sessionId, text) {
108
+ if (text === null) return;
109
+ writeNote(sessionId, { at: new Date().toISOString(), text });
110
+ }
111
+
112
+ /**
113
+ * Records that the pointer has already reached the model for this compaction.
114
+ *
115
+ * Measured on a real compaction: `SessionStart:compact` fires ~55ms *before*
116
+ * `PostCompact`, so without this the later event would queue a note that the next
117
+ * tool call repeats. The marker is left in place rather than deleted — the next
118
+ * compaction overwrites it, and an absent file has to keep meaning "nothing
119
+ * pending".
120
+ */
121
+ export function markDelivered(sessionId) {
122
+ writeNote(sessionId, { at: new Date().toISOString(), delivered: true });
123
+ }
124
+
125
+ /** Whether this compaction's pointer was already said, within `withinMs`. */
126
+ export function deliveredRecently(sessionId, withinMs) {
127
+ const note = readNote(sessionId);
128
+ if (note?.delivered !== true) return false;
129
+ const at = Date.parse(note.at ?? "");
130
+ return Number.isFinite(at) && Date.now() - at < withinMs;
131
+ }
132
+
133
+ /** The pending pointer, without consuming it — nothing is cleared until it is said. */
134
+ export function readPending(sessionId) {
135
+ const note = readNote(sessionId);
136
+ if (note === null || note.delivered === true) return null;
137
+ return typeof note.text === "string" && note.text !== "" ? note.text : null;
138
+ }
139
+
140
+ /** Called only once the pointer has actually been written to stdout. */
141
+ export function clearPending(sessionId) {
142
+ const path = pointerPath(sessionId);
143
+ if (path === null) return;
144
+ try {
145
+ rmSync(path, { force: true });
146
+ } catch {
147
+ // A stale note costs one repeated sentence, never a failed turn.
148
+ }
149
+ }
@@ -0,0 +1,135 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * The compaction hook, registered on two events because neither one can do the
4
+ * whole job.
5
+ *
6
+ * `PostCompact` is the event that knows a compaction happened and carries the
7
+ * summary and its trigger — but Claude Code's hook-output schema has no
8
+ * `PostCompact` variant, so anything it returns in `hookSpecificOutput` is
9
+ * rejected wholesale and injects nothing. It is used here for what it can do:
10
+ * record the compaction, reset the threshold latch so the warnings are available
11
+ * again in the emptied window, and leave the state-file pointer pending.
12
+ *
13
+ * `SessionStart` with `source: "compact"` runs in the fresh context and *does*
14
+ * accept `additionalContext`, so it delivers the pointer. If it never fires, the
15
+ * `PostToolUse` threshold hook delivers the same note on the next tool call.
16
+ * Either way it is said exactly once — see hooks/pointer.mjs.
17
+ */
18
+
19
+ import { existsSync, mkdirSync, unlinkSync, writeFileSync } from "node:fs";
20
+ import { join } from "node:path";
21
+ import {
22
+ clearPending,
23
+ deliveredRecently,
24
+ markDelivered,
25
+ pluginDir,
26
+ pointerText,
27
+ stateAgeSeconds,
28
+ statePath,
29
+ writePending,
30
+ } from "./pointer.mjs";
31
+ import { resetLedger } from "./asks.mjs";
32
+
33
+ /**
34
+ * How recently SessionStart must have spoken for PostCompact to stay quiet.
35
+ *
36
+ * The two events are milliseconds apart for the same compaction; anything on this
37
+ * scale separates "already said" from a genuinely new one.
38
+ */
39
+ const SAME_COMPACTION_MS = 120_000;
40
+
41
+ function resetLatch(sessionId) {
42
+ if (typeof sessionId !== "string") return;
43
+ const path = join(pluginDir(), "thresholds", `${sessionId}.json`);
44
+ try {
45
+ if (existsSync(path)) unlinkSync(path);
46
+ } catch {
47
+ // A stale latch only costs a missed warning; never fail the turn over it.
48
+ }
49
+ }
50
+
51
+ /** Records what compaction cost, so the policy can be graded later. */
52
+ function recordCompaction(event, eventName, age) {
53
+ try {
54
+ const dir = pluginDir();
55
+ mkdirSync(dir, { recursive: true });
56
+ writeFileSync(
57
+ join(dir, "compaction-events.jsonl"),
58
+ `${JSON.stringify({
59
+ at: new Date().toISOString(),
60
+ event: eventName,
61
+ sessionId: event.session_id ?? null,
62
+ agentId: process.env.PASEO_AGENT_ID ?? null,
63
+ trigger: event.trigger ?? event.source ?? null,
64
+ summaryChars: typeof event.compact_summary === "string" ? event.compact_summary.length : null,
65
+ statePath: statePath(),
66
+ stateAgeSeconds: age,
67
+ })}\n`,
68
+ { flag: "a" },
69
+ );
70
+ } catch {
71
+ // Bookkeeping must never break the turn it is describing.
72
+ }
73
+ }
74
+
75
+ /**
76
+ * Which event this invocation is, without trusting a single field.
77
+ *
78
+ * `hook_event_name` is authoritative when present; the payload shape decides
79
+ * otherwise, since only `SessionStart` carries `source`.
80
+ */
81
+ function eventNameOf(event) {
82
+ if (typeof event.hook_event_name === "string") return event.hook_event_name;
83
+ return typeof event.source === "string" ? "SessionStart" : "PostCompact";
84
+ }
85
+
86
+ async function main() {
87
+ const raw = await new Promise((resolve) => {
88
+ let input = "";
89
+ process.stdin.setEncoding("utf8");
90
+ process.stdin.on("data", (chunk) => (input += chunk));
91
+ process.stdin.on("end", () => resolve(input));
92
+ });
93
+
94
+ let event;
95
+ try {
96
+ event = JSON.parse(raw);
97
+ } catch {
98
+ return;
99
+ }
100
+
101
+ const eventName = eventNameOf(event);
102
+ // Every other way a session starts — startup, clear, resume, fork — has its own
103
+ // context and its own reasons; only compaction just destroyed something.
104
+ if (eventName === "SessionStart" && event.source !== "compact") return;
105
+
106
+ const sessionId = event.session_id;
107
+ const path = statePath();
108
+ const age = stateAgeSeconds(path);
109
+ const text = pointerText(path, age);
110
+
111
+ resetLatch(sessionId);
112
+ // A new epoch: the occupancy the last ask was about no longer exists, so a
113
+ // session that fills up again is entitled to be asked again.
114
+ resetLedger(sessionId);
115
+ recordCompaction(event, eventName, age);
116
+
117
+ if (eventName !== "SessionStart") {
118
+ // PostCompact cannot speak. Leave the note for whoever can — unless
119
+ // SessionStart already said it moments ago for this same compaction.
120
+ if (!deliveredRecently(sessionId, SAME_COMPACTION_MS)) writePending(sessionId, text);
121
+ return;
122
+ }
123
+
124
+ if (text === null) return;
125
+ clearPending(sessionId);
126
+ markDelivered(sessionId);
127
+ process.stdout.write(
128
+ `${JSON.stringify({
129
+ hookSpecificOutput: { hookEventName: "SessionStart", additionalContext: text },
130
+ suppressOutput: true,
131
+ })}\n`,
132
+ );
133
+ }
134
+
135
+ void main();
@@ -0,0 +1,42 @@
1
+ import type { PluginClientContext } from "@getpaseo/plugin/client";
2
+
3
+ import { contributeClient, refreshPills } from "./client/pill";
4
+ import { SmartSessionSurface } from "./client/surface";
5
+ import { enrolmentState } from "./shared/governor";
6
+ import { getSettings, setSettings } from "./shared/smart-session";
7
+
8
+ export default function contribute(client: PluginClientContext) {
9
+ client.addSurface("overview", SmartSessionSurface);
10
+ client.addSidebarItem({
11
+ id: "smart-session",
12
+ title: "Smart Session",
13
+ icon: "Gauge",
14
+ surface: "overview",
15
+ });
16
+
17
+ client.addCommandCenterItem({
18
+ id: "smart-session-open",
19
+ title: "Show plan usage and agent context",
20
+ icon: "Gauge",
21
+ keywords: ["usage", "budget", "limit", "context", "tokens", "compact"],
22
+ context: "global",
23
+ onSelect({ openSurface }) {
24
+ openSurface("overview");
25
+ },
26
+ });
27
+
28
+ client.addCommandCenterItem({
29
+ id: "smart-session-pill",
30
+ title: "Show or hide the smart-compact pill",
31
+ icon: "ToggleLeft",
32
+ keywords: ["pill", "compact", "smart", "governor", "enrol"],
33
+ context: "global",
34
+ async onSelect({ rpc }) {
35
+ const { settings } = await rpc(getSettings, {});
36
+ await rpc(setSettings, { showPill: !settings.showPill });
37
+ await refreshPills(() => rpc(enrolmentState, {}));
38
+ },
39
+ });
40
+
41
+ return contributeClient(client);
42
+ }
@@ -0,0 +1,195 @@
1
+ import type { PluginServerContext } from "@getpaseo/plugin/server";
2
+
3
+ import { readAgents, withDaemon } from "./server/daemon.ts";
4
+ import { contextGrowth, projectFull } from "./server/growth.ts";
5
+ import { install } from "./server/install.ts";
6
+ import { summarizeSpend } from "./server/spend.ts";
7
+ import { countEnrolled, listEnrolment, readSettings, setEnrolled, writeSettings } from "./server/settings.ts";
8
+ import { dataDir, newestUsage, readUsage } from "./server/store.ts";
9
+ import {
10
+ budgetStatus,
11
+ contextStatus,
12
+ getSettings,
13
+ installStatus,
14
+ setSettings,
15
+ spendSummary,
16
+ } from "./shared/smart-session.ts";
17
+
18
+ import {
19
+ cancelCompaction,
20
+ enrolmentState,
21
+ listCompactions,
22
+ requestCompaction,
23
+ setEnrolment,
24
+ } from "./shared/governor.ts";
25
+ import { burnRatePctPerHour, projectedExhaustion, segmentBlocks } from "./shared/blocks.ts";
26
+ import { lifecycle } from "./shared/lifecycle.ts";
27
+ import { profileFor } from "./shared/thresholds.ts";
28
+ import { effectiveAt } from "./shared/usage.ts";
29
+
30
+ // Importing the recorder for its side effect: it starts on load and records
31
+ // whether or not anything is looking at it. See server/recorder.ts.
32
+ import "./server/recorder.ts";
33
+ // Same: the governor owns the compaction queue and its delivery timer.
34
+ import { queue } from "./server/governor.ts";
35
+ // Same again: this one registers the plugin's hooks with Claude Code on load. The
36
+ // `Stop` hook is what asks a session to compact itself, so an install without it
37
+ // reports "on" and does nothing at all.
38
+ import "./server/install-on-load.ts";
39
+
40
+ export default function contribute(server: PluginServerContext) {
41
+ server.handle(budgetStatus, async () => {
42
+ try {
43
+ const since = Date.now() - 14 * 24 * 60 * 60 * 1000;
44
+ const samples = await readUsage({ sinceMs: since });
45
+ const newest = await newestUsage();
46
+
47
+ // One row per window, taken from its currently open block.
48
+ const open = new Map<string, ReturnType<typeof segmentBlocks>[number]>();
49
+ for (const block of segmentBlocks(samples)) open.set(block.windowId, block);
50
+
51
+ const windows = [...open.values()]
52
+ .map((block) => ({
53
+ id: block.windowId,
54
+ pct: block.finalPct,
55
+ resetsAt: block.resetsAt,
56
+ burnPctPerHour: burnRatePctPerHour(block),
57
+ projectedFullAt: projectedExhaustion(block),
58
+ }))
59
+ .sort((a, b) => b.pct - a.pct);
60
+
61
+ const newestInstant = newest === null ? null : effectiveAt(newest);
62
+ return {
63
+ windows,
64
+ recorder: {
65
+ newestAt: newestInstant,
66
+ ageSeconds:
67
+ newestInstant === null ? null : Math.round((Date.now() - Date.parse(newestInstant)) / 1000),
68
+ source: newest?.src ?? null,
69
+ samplesHeld: samples.length,
70
+ firstSampleAt: samples.length === 0 ? null : effectiveAt(samples[0]!),
71
+ dataDir: dataDir(),
72
+ },
73
+ error: null,
74
+ };
75
+ } catch (error) {
76
+ return {
77
+ windows: [],
78
+ recorder: {
79
+ newestAt: null,
80
+ ageSeconds: null,
81
+ source: null,
82
+ samplesHeld: 0,
83
+ firstSampleAt: null,
84
+ dataDir: dataDir(),
85
+ },
86
+ error: error instanceof Error ? error.message : String(error),
87
+ };
88
+ }
89
+ });
90
+
91
+ server.handle(contextStatus, async ({ agentId }) => {
92
+ try {
93
+ const rows = await withDaemon((client) => readAgents(client));
94
+ const growth = await contextGrowth();
95
+ const { thresholds } = await readSettings();
96
+ const agents = rows
97
+ .filter((row) => agentId === undefined || row.id === agentId)
98
+ .filter((row) => row.usedTokens !== null && row.maxTokens !== null && row.maxTokens > 0)
99
+ .map((row) => ({
100
+ agentId: row.id,
101
+ title: row.title,
102
+ provider: row.provider,
103
+ model: row.model,
104
+ status: row.status,
105
+ usedTokens: row.usedTokens!,
106
+ maxTokens: row.maxTokens!,
107
+ usedPct: Math.round((row.usedTokens! / row.maxTokens!) * 1000) / 10,
108
+ costUsd: row.costUsd,
109
+ growthTokensPerHour: growth.get(row.id) ?? null,
110
+ projectedFullAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds),
111
+ compactAtPct: profileFor(row.maxTokens!, thresholds).compact,
112
+ }))
113
+ .sort((a, b) => b.usedPct - a.usedPct);
114
+ return { agents, error: null };
115
+ } catch (error) {
116
+ return { agents: [], error: error instanceof Error ? error.message : String(error) };
117
+ }
118
+ });
119
+
120
+ server.handle(spendSummary, async ({ days }) => {
121
+ try {
122
+ return { summary: await summarizeSpend({ days }), error: null };
123
+ } catch (error) {
124
+ return {
125
+ summary: {
126
+ byDay: [], heatmap: [], byProject: [], byModel: [],
127
+ totalTokens: 0, subagentTokens: 0, cacheHitPct: 0,
128
+ firstHour: null, lastHour: null, bucketCount: 0,
129
+ },
130
+ error: error instanceof Error ? error.message : String(error),
131
+ };
132
+ }
133
+ });
134
+
135
+ server.handle(getSettings, async () => ({
136
+ settings: await readSettings(),
137
+ enrolledAgents: await countEnrolled(),
138
+ }));
139
+
140
+ server.handle(setSettings, async (patch) => {
141
+ const settings = await writeSettings(patch);
142
+ // Reconciled here as well as on load, so turning the switch off removes the
143
+ // hooks immediately rather than at the next reload.
144
+ if (patch.installHooks !== undefined) void install().catch(() => undefined);
145
+ return { settings };
146
+ });
147
+
148
+ server.handle(installStatus, async () => ({ report: await install() }));
149
+
150
+ server.handle(
151
+ requestCompaction,
152
+ async ({ agentId, reason, statePath, continueAfterCompaction, continuationMessage }) => ({
153
+ request: await queue.add({
154
+ agentId,
155
+ reason,
156
+ statePath: statePath ?? null,
157
+ continueAfterCompaction: continueAfterCompaction ?? true,
158
+ continuationMessage: continueAfterCompaction === false ? null : (continuationMessage ?? null),
159
+ }),
160
+ }),
161
+ );
162
+
163
+ server.handle(listCompactions, async ({ agentId }) => {
164
+ const items = await queue.list();
165
+ return {
166
+ items: (agentId === undefined ? items : items.filter((item) => item.agentId === agentId)).sort(
167
+ (a, b) => Date.parse(b.createdAt) - Date.parse(a.createdAt),
168
+ ),
169
+ };
170
+ });
171
+
172
+ server.handle(cancelCompaction, async ({ id }) => {
173
+ const updated = await queue.update(id, {
174
+ state: "cancelled",
175
+ settledAt: new Date().toISOString(),
176
+ });
177
+ return { ok: updated !== null };
178
+ });
179
+
180
+ server.handle(enrolmentState, async () => {
181
+ const settings = await readSettings();
182
+ return { showPill: settings.showPill, enabled: settings.enabled, agents: await listEnrolment() };
183
+ });
184
+
185
+ server.handle(setEnrolment, async ({ agentId, enrolled }) => ({
186
+ agent: await setEnrolled(agentId, enrolled),
187
+ }));
188
+
189
+ // The recorder and governor register their timers in this shared lifecycle.
190
+ // Paseo waits for this cleanup before stopping the plugin subprocess.
191
+ return async () => {
192
+ const teardowns = lifecycle.teardowns.splice(0);
193
+ for (const teardown of teardowns) await teardown();
194
+ };
195
+ }