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/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Tom Gringauz
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,169 @@
|
|
|
1
|
+
# paseo-smart-session
|
|
2
|
+
|
|
3
|
+
[](https://paseo.sh)
|
|
4
|
+
[](https://www.npmjs.com/package/paseo-smart-session)
|
|
5
|
+
[](https://github.com/tomgrin10/paseo-smart-session/releases/latest)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
Give Paseo agents a safe way to manage long-running work.
|
|
9
|
+
|
|
10
|
+
Smart Session lets an agent see how full its context is, save durable task state, and compact itself when it is ready. It also records session and plan-usage statistics, so you can understand where tokens are going and when limits reset.
|
|
11
|
+
|
|
12
|
+

|
|
13
|
+
|
|
14
|
+
## What it does
|
|
15
|
+
|
|
16
|
+
### Lets agents compact themselves
|
|
17
|
+
|
|
18
|
+
An agent can check how much context it has used, checkpoint the work that must survive a reset, and request compaction when it reaches a safe stopping point. The agent also decides whether another turn should start afterwards and can write the exact follow-up message. A completed task can compact and stop; work in progress can resume from its saved state.
|
|
19
|
+
|
|
20
|
+
The saved state captures the goal, the current step, decisions already made, and approaches that have already failed. That prevents a resumed agent from redoing work or asking a person how to continue.
|
|
21
|
+
|
|
22
|
+
Smart Session never chooses to compact an agent by itself. It can let an agent know that context is filling, but the agent decides whether to checkpoint, compact, or defer.
|
|
23
|
+
|
|
24
|
+
### Shows session and plan usage
|
|
25
|
+
|
|
26
|
+
Smart Session keeps a local history of:
|
|
27
|
+
|
|
28
|
+
- Context-window usage for each agent session
|
|
29
|
+
- Claude plan usage, burn rate, and reset times
|
|
30
|
+
- Token spend by model, workspace, and main agent versus subagent
|
|
31
|
+
- Compaction requests and their before/after context sizes
|
|
32
|
+
|
|
33
|
+
That history remains useful after an individual session ends or a provider cache changes.
|
|
34
|
+
|
|
35
|
+
## Install
|
|
36
|
+
|
|
37
|
+
Install from npm on Paseo 0.9.0 or newer:
|
|
38
|
+
|
|
39
|
+
```sh
|
|
40
|
+
paseo plugin install npm:paseo-smart-session@1.2.1
|
|
41
|
+
```
|
|
42
|
+
|
|
43
|
+
Paseo 0.8 can install the same plugin from Git:
|
|
44
|
+
|
|
45
|
+
```sh
|
|
46
|
+
paseo plugin add tomgrin10/paseo-smart-session --ref v1.2.1
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
```sh
|
|
50
|
+
paseo plugin ls # confirm it is running
|
|
51
|
+
paseo plugin update smart-session
|
|
52
|
+
paseo plugin remove smart-session
|
|
53
|
+
```
|
|
54
|
+
|
|
55
|
+
On first load, Smart Session registers its Claude Code hooks and MCP server. It only manages entries that point at its own scripts; other Claude Code configuration is left alone.
|
|
56
|
+
|
|
57
|
+
## How an agent uses it
|
|
58
|
+
|
|
59
|
+
Smart Session exposes these MCP tools to Paseo agents:
|
|
60
|
+
|
|
61
|
+
| Tool | What it does |
|
|
62
|
+
| --- | --- |
|
|
63
|
+
| `context_status` | Shows current context usage, filling rate, and relevant thresholds. |
|
|
64
|
+
| `checkpoint` | Writes durable task state for the current session. |
|
|
65
|
+
| `request_compaction` | Queues compaction and chooses whether and how to continue afterwards. |
|
|
66
|
+
| `defer_compaction` | Defers a compaction request and records why. |
|
|
67
|
+
| `budget_status` | Shows plan usage, burn rate, and reset times. |
|
|
68
|
+
|
|
69
|
+
The usual flow is:
|
|
70
|
+
|
|
71
|
+
```text
|
|
72
|
+
context_status → checkpoint → request_compaction → stop, or re-read state and continue
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
`request_compaction` refuses when the state file is missing or stale. The agent can checkpoint and request again in the same turn.
|
|
76
|
+
|
|
77
|
+
## Smart Compact
|
|
78
|
+
|
|
79
|
+
Smart Compact is the session-control feature. As a session fills, hooks can provide context notices at useful thresholds. When the compact threshold is reached at the end of a turn, the agent is asked whether it wants to compact.
|
|
80
|
+
|
|
81
|
+
There is no forced compaction. An agent can compact immediately, defer with a reason, or keep working. A request is delivered only when the agent is idle and its state file is current.
|
|
82
|
+
|
|
83
|
+
Every request sends the `/compact` command. The agent can also set:
|
|
84
|
+
|
|
85
|
+
- `continue_after_compaction: false` to finish without starting another turn.
|
|
86
|
+
- `continue_after_compaction: true` to start another turn after compaction.
|
|
87
|
+
- `continue_message` to choose the exact follow-up; when omitted, the default tells the agent to re-read the state file and continue from its **Current step**.
|
|
88
|
+
|
|
89
|
+
Omitting the new options preserves the earlier behavior: Smart Session continues with the default state-aware prompt. The durable state wins if it disagrees with the automatic summary. A `/compact` entered by a person never receives a plugin continuation.
|
|
90
|
+
|
|
91
|
+
### The composer pill
|
|
92
|
+
|
|
93
|
+
The composer pill shows whether the current session is enrolled:
|
|
94
|
+
|
|
95
|
+
| State | Meaning |
|
|
96
|
+
| --- | --- |
|
|
97
|
+
| **Smart Compact on** | The session can receive context notices and request compaction. |
|
|
98
|
+
| **Smart Compact off** | The session is not enrolled. |
|
|
99
|
+
|
|
100
|
+
If Smart Compact is disabled globally, the pill is not shown.
|
|
101
|
+
|
|
102
|
+
## Settings
|
|
103
|
+
|
|
104
|
+
| Setting | Default | Description |
|
|
105
|
+
| --- | --- | --- |
|
|
106
|
+
| **Smart Compact** | On | Enables context notices and agent-requested compaction. |
|
|
107
|
+
| **Large-window compact threshold** | 30% | Sets when Smart Compact asks on context windows of 400k tokens or more. |
|
|
108
|
+
| **Small-window compact threshold** | 85% | Sets when Smart Compact asks on smaller context windows. |
|
|
109
|
+
| **Enrol sessions automatically** | On | Enrols a session after it writes task state. |
|
|
110
|
+
| **Show the pill on every agent** | On | Shows the Smart Compact control in the composer. |
|
|
111
|
+
| **Register the hooks with Claude Code** | On | Keeps the context and compaction hooks installed. |
|
|
112
|
+
|
|
113
|
+
The remaining settings apply only while Smart Compact is enabled. Thresholds accept whole percentages from 1% to 99%; lowering one also moves its earlier notice bands down when needed.
|
|
114
|
+
|
|
115
|
+
## Usage history and state
|
|
116
|
+
|
|
117
|
+
Smart Session stores its data under:
|
|
118
|
+
|
|
119
|
+
```text
|
|
120
|
+
$PASEO_HOME/plugin-data/smart-session/
|
|
121
|
+
```
|
|
122
|
+
|
|
123
|
+
| File | Contains |
|
|
124
|
+
| --- | --- |
|
|
125
|
+
| `usage-YYYY-MM.jsonl` | Plan-usage observations and reset times. |
|
|
126
|
+
| `context-YYYY-MM.jsonl` | Context-window usage changes. |
|
|
127
|
+
| `compactions.json` | Compaction requests, continuation choices, and context sizes. |
|
|
128
|
+
| `spend-index.json` | Hourly token spend by model, workspace, and agent type. |
|
|
129
|
+
| `state/<agentId>.md` | Durable task state for one agent. |
|
|
130
|
+
| `settings.json`, `enrolment.json` | Plugin settings and per-session enrolment. |
|
|
131
|
+
|
|
132
|
+
Usage and context observations are append-only. A damaged line is skipped rather than putting the rest of the history at risk. Existing compaction rows are read as “continue with the default prompt,” so upgrading requires no data migration.
|
|
133
|
+
|
|
134
|
+
## Password-protected daemons
|
|
135
|
+
|
|
136
|
+
When the Paseo daemon requires a password, Smart Session resolves it in this order:
|
|
137
|
+
|
|
138
|
+
1. `PASEO_PASSWORD`
|
|
139
|
+
2. The file named by `PASEO_PASSWORD_FILE`
|
|
140
|
+
3. `~/paseo-hub/secrets/daemon-password`
|
|
141
|
+
|
|
142
|
+
The secret is used only to connect to the daemon. It is never written or logged.
|
|
143
|
+
|
|
144
|
+
## Develop locally
|
|
145
|
+
|
|
146
|
+
```sh
|
|
147
|
+
git clone https://github.com/tomgrin10/paseo-smart-session.git
|
|
148
|
+
cd paseo-smart-session
|
|
149
|
+
npm ci
|
|
150
|
+
npm run verify
|
|
151
|
+
paseo plugin install "$PWD"
|
|
152
|
+
```
|
|
153
|
+
|
|
154
|
+
After editing:
|
|
155
|
+
|
|
156
|
+
```sh
|
|
157
|
+
npm run verify
|
|
158
|
+
paseo plugin reload smart-session
|
|
159
|
+
```
|
|
160
|
+
|
|
161
|
+
Reloading the plugin is enough. Do not restart the Paseo daemon: that interrupts every running agent.
|
|
162
|
+
|
|
163
|
+
## Data and privacy
|
|
164
|
+
|
|
165
|
+
Smart Session reads local Claude Code usage information and communicates with the local Paseo daemon. Its usage history, settings, and task state stay under `$PASEO_HOME`. Apart from narrowly maintaining its own Claude Code hook and MCP entries, it does not modify user configuration.
|
|
166
|
+
|
|
167
|
+
## License
|
|
168
|
+
|
|
169
|
+
[MIT](LICENSE) © 2026 Tom Gringauz.
|
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
import type { PluginSurfaceProps } from "@getpaseo/plugin/client";
|
|
2
|
+
import React from "react";
|
|
3
|
+
import { Text, View } from "react-native";
|
|
4
|
+
|
|
5
|
+
import { formatTokens, rampStep } from "../shared/format";
|
|
6
|
+
|
|
7
|
+
type Theme = PluginSurfaceProps["theme"];
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* A sequential ramp: one hue, light to dark.
|
|
11
|
+
*
|
|
12
|
+
* Magnitude gets a single hue at stepped intensities — never a rainbow, and never
|
|
13
|
+
* the status colours, which are reserved for state. The host gives one accent hue
|
|
14
|
+
* and no scale, so the steps come from opacity over the chart surface, which is the
|
|
15
|
+
* same construction as a lightness ramp for a single hue.
|
|
16
|
+
*/
|
|
17
|
+
const STEPS = [0.12, 0.3, 0.5, 0.72, 1] as const;
|
|
18
|
+
|
|
19
|
+
const WEEKDAYS = ["Sun", "Mon", "Tue", "Wed", "Thu", "Fri", "Sat"];
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Tokens by weekday and hour of day, in local time.
|
|
23
|
+
*
|
|
24
|
+
* The question this answers is "when am I actually working the model hardest", so
|
|
25
|
+
* the cell is the unit and the ramp carries the magnitude. Hour labels are shown
|
|
26
|
+
* every six hours: a label on all 24 would be unreadable at this width.
|
|
27
|
+
*/
|
|
28
|
+
export function HourHeatmap({
|
|
29
|
+
theme,
|
|
30
|
+
cells,
|
|
31
|
+
compact,
|
|
32
|
+
}: {
|
|
33
|
+
theme: Theme;
|
|
34
|
+
cells: readonly { weekday: number; hour: number; tokens: number }[];
|
|
35
|
+
compact: boolean;
|
|
36
|
+
}) {
|
|
37
|
+
const grid = new Map<string, number>();
|
|
38
|
+
let max = 0;
|
|
39
|
+
for (const cell of cells) {
|
|
40
|
+
grid.set(`${cell.weekday}|${cell.hour}`, cell.tokens);
|
|
41
|
+
if (cell.tokens > max) max = cell.tokens;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const size = compact ? 9 : 12;
|
|
45
|
+
const gap = 2;
|
|
46
|
+
|
|
47
|
+
return (
|
|
48
|
+
<View style={{ gap: 6 }}>
|
|
49
|
+
<View style={{ gap }}>
|
|
50
|
+
{WEEKDAYS.map((label, weekday) => (
|
|
51
|
+
<View key={label} style={{ flexDirection: "row", alignItems: "center", gap }}>
|
|
52
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10, width: 28 }}>{label}</Text>
|
|
53
|
+
{Array.from({ length: 24 }, (unused, hour) => {
|
|
54
|
+
const tokens = grid.get(`${weekday}|${hour}`) ?? 0;
|
|
55
|
+
const step = rampStep(tokens, max, STEPS.length);
|
|
56
|
+
return (
|
|
57
|
+
<View
|
|
58
|
+
key={hour}
|
|
59
|
+
style={{
|
|
60
|
+
width: size,
|
|
61
|
+
height: size,
|
|
62
|
+
borderRadius: 2,
|
|
63
|
+
// An empty hour is the surface, not a faint accent: absence and
|
|
64
|
+
// "a little" must not look alike.
|
|
65
|
+
backgroundColor: step === -1 ? theme.colors.surface2 : theme.colors.accent,
|
|
66
|
+
opacity: step === -1 ? 0.35 : STEPS[step],
|
|
67
|
+
}}
|
|
68
|
+
/>
|
|
69
|
+
);
|
|
70
|
+
})}
|
|
71
|
+
</View>
|
|
72
|
+
))}
|
|
73
|
+
</View>
|
|
74
|
+
<View style={{ flexDirection: "row", gap, marginLeft: 28 + gap }}>
|
|
75
|
+
{Array.from({ length: 24 }, (unused, hour) => (
|
|
76
|
+
<View key={hour} style={{ width: size, alignItems: "center" }}>
|
|
77
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 9 }}>
|
|
78
|
+
{hour % 6 === 0 ? String(hour) : ""}
|
|
79
|
+
</Text>
|
|
80
|
+
</View>
|
|
81
|
+
))}
|
|
82
|
+
</View>
|
|
83
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 11 }}>
|
|
84
|
+
Local time. Darkest cell is {formatTokens(max)} tokens.
|
|
85
|
+
</Text>
|
|
86
|
+
</View>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
/**
|
|
91
|
+
* Tokens per day.
|
|
92
|
+
*
|
|
93
|
+
* One series, so no legend — the heading names it — and one label, on the peak,
|
|
94
|
+
* rather than a number over every bar.
|
|
95
|
+
*/
|
|
96
|
+
export function DailyBars({
|
|
97
|
+
theme,
|
|
98
|
+
days,
|
|
99
|
+
compact,
|
|
100
|
+
}: {
|
|
101
|
+
theme: Theme;
|
|
102
|
+
days: readonly { day: string; tokens: number }[];
|
|
103
|
+
compact: boolean;
|
|
104
|
+
}) {
|
|
105
|
+
if (days.length === 0) return null;
|
|
106
|
+
const max = days.reduce((peak, point) => Math.max(peak, point.tokens), 0);
|
|
107
|
+
const height = compact ? 48 : 64;
|
|
108
|
+
const peakDay = days.reduce((best, point) => (point.tokens > best.tokens ? point : best), days[0]!);
|
|
109
|
+
|
|
110
|
+
return (
|
|
111
|
+
<View style={{ gap: 6 }}>
|
|
112
|
+
<View style={{ flexDirection: "row", alignItems: "flex-end", gap: 2, height }}>
|
|
113
|
+
{days.map((point) => (
|
|
114
|
+
<View
|
|
115
|
+
key={point.day}
|
|
116
|
+
style={{
|
|
117
|
+
flex: 1,
|
|
118
|
+
height: max === 0 ? 1 : Math.max(2, (point.tokens / max) * height),
|
|
119
|
+
backgroundColor: theme.colors.accent,
|
|
120
|
+
opacity: point.day === peakDay.day ? 1 : 0.65,
|
|
121
|
+
// Rounded at the data end only; the baseline end stays square so the
|
|
122
|
+
// bar reads as anchored rather than floating.
|
|
123
|
+
borderTopLeftRadius: 3,
|
|
124
|
+
borderTopRightRadius: 3,
|
|
125
|
+
}}
|
|
126
|
+
/>
|
|
127
|
+
))}
|
|
128
|
+
</View>
|
|
129
|
+
<View style={{ flexDirection: "row", justifyContent: "space-between" }}>
|
|
130
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>{days[0]!.day.slice(5)}</Text>
|
|
131
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
|
|
132
|
+
peak {peakDay.day.slice(5)} · {formatTokens(peakDay.tokens)}
|
|
133
|
+
</Text>
|
|
134
|
+
<Text style={{ color: theme.colors.foregroundMuted, fontSize: 10 }}>
|
|
135
|
+
{days[days.length - 1]!.day.slice(5)}
|
|
136
|
+
</Text>
|
|
137
|
+
</View>
|
|
138
|
+
</View>
|
|
139
|
+
);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** A ranked list — a bar chart of eight rows would say the same thing with more ink. */
|
|
143
|
+
export function RankedTotals({
|
|
144
|
+
theme,
|
|
145
|
+
rows,
|
|
146
|
+
total,
|
|
147
|
+
}: {
|
|
148
|
+
theme: Theme;
|
|
149
|
+
rows: readonly { name: string; tokens: number }[];
|
|
150
|
+
total: number;
|
|
151
|
+
}) {
|
|
152
|
+
return (
|
|
153
|
+
<View style={{ gap: 4 }}>
|
|
154
|
+
{rows.map((row) => (
|
|
155
|
+
<View key={row.name} style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|
156
|
+
<View
|
|
157
|
+
style={{
|
|
158
|
+
flexGrow: 1,
|
|
159
|
+
height: 4,
|
|
160
|
+
borderRadius: 2,
|
|
161
|
+
backgroundColor: theme.colors.surface2,
|
|
162
|
+
overflow: "hidden",
|
|
163
|
+
}}
|
|
164
|
+
>
|
|
165
|
+
<View
|
|
166
|
+
style={{
|
|
167
|
+
width: `${total === 0 ? 0 : (row.tokens / total) * 100}%`,
|
|
168
|
+
height: 4,
|
|
169
|
+
backgroundColor: theme.colors.accent,
|
|
170
|
+
}}
|
|
171
|
+
/>
|
|
172
|
+
</View>
|
|
173
|
+
<Text style={{ color: theme.colors.foreground, fontSize: 12, width: 130 }} numberOfLines={1}>
|
|
174
|
+
{row.name}
|
|
175
|
+
</Text>
|
|
176
|
+
<Text
|
|
177
|
+
style={{ color: theme.colors.foregroundMuted, fontSize: 12, fontVariant: ["tabular-nums"], width: 52, textAlign: "right" }}
|
|
178
|
+
>
|
|
179
|
+
{formatTokens(row.tokens)}
|
|
180
|
+
</Text>
|
|
181
|
+
</View>
|
|
182
|
+
))}
|
|
183
|
+
</View>
|
|
184
|
+
);
|
|
185
|
+
}
|