paseo-smart-session 3.3.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +29 -11
- package/client/charts.tsx +7 -6
- package/client/display.tsx +2 -2
- package/client/kit.tsx +2 -2
- package/client/live.ts +8 -1
- package/client/overview.tsx +72 -26
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +35 -17
- package/client/settings.tsx +42 -20
- package/client/subscriptions-view.tsx +201 -0
- package/client/subscriptions.ts +68 -0
- package/docs/media/before-after.png +0 -0
- package/docs/media/smart-session-mobile.png +0 -0
- package/docs/media/smart-session-pill.png +0 -0
- package/docs/media/smart-session-settings.png +0 -0
- package/docs/media/smart-session-subscriptions-light.png +0 -0
- package/docs/media/smart-session-subscriptions.png +0 -0
- package/docs/session-guard.md +2 -2
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +193 -0
- package/hooks/asks.mjs +53 -6
- package/hooks/context-threshold.mjs +31 -10
- package/hooks/context.mjs +18 -4
- package/index.client.tsx +4 -0
- package/index.server.ts +15 -0
- package/mcp.mjs +33 -1
- package/package.json +1 -1
- package/paseo-plugin.json +1 -0
- package/server/growth.ts +2 -1
- package/server/recorder.ts +12 -0
- package/server/store.ts +24 -1
- package/server/subscriptions.ts +208 -0
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/guard/zerosub.ts +18 -3
- package/shared/smart-session.ts +72 -0
- package/shared/subscriptions.ts +540 -0
- package/shared/thresholds.ts +41 -6
package/hooks/ask.mjs
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smart Compact's question, shared by the two hooks that put it.
|
|
3
|
+
*
|
|
4
|
+
* There are two lines per window. Past the lower one, `ask-compact.mjs` on `Stop`
|
|
5
|
+
* asks at the end of a turn, when it is convenient. Past the upper one,
|
|
6
|
+
* `context-threshold.mjs` on `PostToolUse` asks right away, mid-turn, because a
|
|
7
|
+
* session that far along should not start anything new before compacting. Both
|
|
8
|
+
* read the same settings, the same enrolment, the same queue and the same ledger,
|
|
9
|
+
* and the guards live here so the two can never disagree about whether to speak.
|
|
10
|
+
*
|
|
11
|
+
* Either way the plugin only asks. The agent answers with its own tool call, and
|
|
12
|
+
* a queued compaction is still delivered once the session goes idle.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
import { readFileSync } from "node:fs";
|
|
16
|
+
import { join } from "node:path";
|
|
17
|
+
|
|
18
|
+
import { isEnrolled, occupancy, readSettings } from "./context.mjs";
|
|
19
|
+
import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
|
|
20
|
+
import { readLedger } from "./asks.mjs";
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* A compaction that had to be repeated within minutes is not solving the problem —
|
|
24
|
+
* something in the loop is refilling the window as fast as it is emptied, and
|
|
25
|
+
* another compaction just burns tokens on summarizing. Claude Code detects the same
|
|
26
|
+
* pathology and calls it thrashing.
|
|
27
|
+
*/
|
|
28
|
+
export const THRASH_WINDOW_MS = 15 * 60_000;
|
|
29
|
+
|
|
30
|
+
/** The governor's queue, read directly: a hook must not open a daemon connection. */
|
|
31
|
+
export function compactions() {
|
|
32
|
+
try {
|
|
33
|
+
const parsed = JSON.parse(readFileSync(join(pluginDir(), "compactions.json"), "utf8"));
|
|
34
|
+
return Array.isArray(parsed?.items) ? parsed.items : [];
|
|
35
|
+
} catch {
|
|
36
|
+
return [];
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Already asked and waiting to be delivered: there is nothing to add. */
|
|
41
|
+
export function alreadyQueued(items, agentId) {
|
|
42
|
+
return items.some(
|
|
43
|
+
(item) => item.agentId === agentId && (item.state === "pending" || item.state === "sending"),
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
export function thrashing(items, agentId, now = Date.now()) {
|
|
48
|
+
return items.some(
|
|
49
|
+
(item) =>
|
|
50
|
+
item.agentId === agentId &&
|
|
51
|
+
item.state === "sent" &&
|
|
52
|
+
item.settledAt !== null &&
|
|
53
|
+
now - Date.parse(item.settledAt) < THRASH_WINDOW_MS,
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function headline({ used, max, pct }) {
|
|
58
|
+
return `this session holds ${used.toLocaleString()} tokens (${pct}% of a ${max.toLocaleString()}-token window)`;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
function why(max) {
|
|
62
|
+
return max >= 400_000
|
|
63
|
+
? "That is worth compacting now even though the window is nowhere near full — what costs you is the prefix re-read on every turn, not the ceiling."
|
|
64
|
+
: "This is the point where recall degrades noticeably and every turn re-reads an expensive prefix.";
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The checkpoint half of either ask, which depends on how old the state file is. */
|
|
68
|
+
function checkpointStep(path, age, freshMinutes, then) {
|
|
69
|
+
if (age === null) {
|
|
70
|
+
return `There is no task state on disk yet. Call checkpoint — goal, the step in progress and its exact next action, decisions and why, and every approach already tried and rejected — then ${then} Anything you do not write down is gone after the compaction.`;
|
|
71
|
+
}
|
|
72
|
+
if (age > freshMinutes * 60) {
|
|
73
|
+
return `Your task state at ${path} was last written ${Math.round(age / 60)} minutes ago, so it may predate recent work. Call checkpoint to bring it up to date, then ${then}`;
|
|
74
|
+
}
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* The end-of-turn question, at the lower line.
|
|
80
|
+
*
|
|
81
|
+
* On a large window the headline is the token count, not the percentage: "30% full"
|
|
82
|
+
* sounds like there is plenty of room, and 300,000 tokens does not.
|
|
83
|
+
*
|
|
84
|
+
* It has to be answerable in one turn, so it names the tools and says what to do
|
|
85
|
+
* with each, and it has to be refusable, or it is not a question — hence the
|
|
86
|
+
* deferral, which the agent is told about in the same breath.
|
|
87
|
+
*/
|
|
88
|
+
export function askAtTurnEnd(reading, path, age, freshMinutes) {
|
|
89
|
+
const lines = [
|
|
90
|
+
`Smart Compact: ${headline(reading)}.`,
|
|
91
|
+
why(reading.max),
|
|
92
|
+
"Compacting is your call, not this plugin's, and nothing will happen unless you ask. Do one of these before you stop:",
|
|
93
|
+
];
|
|
94
|
+
const stale = checkpointStep(path, age, freshMinutes, "call request_compaction.");
|
|
95
|
+
lines.push(
|
|
96
|
+
stale !== null
|
|
97
|
+
? `1. ${stale}`
|
|
98
|
+
: `1. Your task state at ${path} is current, so this is cheap: call request_compaction with a one-line reason. If work remains, ask it to continue (and optionally choose the follow-up message); if the task is finished, ask it not to continue.`,
|
|
99
|
+
);
|
|
100
|
+
lines.push(
|
|
101
|
+
"2. Or, if this is the wrong moment — mid-refactor, a tool sequence half finished, an answer the user is waiting on — call defer_compaction with a reason and you will not be asked again for a while.",
|
|
102
|
+
"Either way, do it now and then stop. A queued compaction is delivered as a real /compact once this session goes idle; a follow-up turn is sent only if you requested one.",
|
|
103
|
+
);
|
|
104
|
+
return lines.join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* The mid-turn question, at the upper line.
|
|
109
|
+
*
|
|
110
|
+
* It arrives after a tool call, in the middle of the agent's work, so it says what
|
|
111
|
+
* to finish (only the step already under way) and what not to start. The compaction
|
|
112
|
+
* still lands only once the session is idle, which is why it ends with "then end
|
|
113
|
+
* your turn": without that the agent would queue it and carry on for an hour.
|
|
114
|
+
*/
|
|
115
|
+
export function askRightAway(reading, upper, path, age, freshMinutes) {
|
|
116
|
+
const lines = [
|
|
117
|
+
`Smart Compact: ${headline(reading)}, past its upper line of ${upper}%.`,
|
|
118
|
+
`This is the line where you should not push on. ${why(reading.max)}`,
|
|
119
|
+
"Finish only the atomic step you are on — the edit half applied, the command already running — and do not start a new step, read a large file or begin another tool sequence. Then:",
|
|
120
|
+
];
|
|
121
|
+
const stale = checkpointStep(path, age, freshMinutes, "go on to step 2.");
|
|
122
|
+
lines.push(stale !== null ? `1. ${stale}` : `1. Your task state at ${path} is current, so go straight to step 2.`);
|
|
123
|
+
lines.push(
|
|
124
|
+
"2. Call request_compaction with continue_after_compaction set to true and a one-line reason. If you know the next action, put it in continue_message so the follow-up names it.",
|
|
125
|
+
"3. End your turn. The compaction is delivered as a real /compact once this session goes idle, so it cannot land while you keep working.",
|
|
126
|
+
"If stopping now would leave something broken that the next turn could not pick up, call defer_compaction with that reason instead — you will be asked again when it runs out. Do not defer just to keep going.",
|
|
127
|
+
);
|
|
128
|
+
return lines.join("\n");
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* What to say instead, when compacting is not the answer.
|
|
133
|
+
*
|
|
134
|
+
* Said once, as an ask, so it counts against the same latch: repeating it every
|
|
135
|
+
* turn would be the nagging these hooks exist to avoid.
|
|
136
|
+
*/
|
|
137
|
+
export function thrashAdvice({ used, pct }) {
|
|
138
|
+
return [
|
|
139
|
+
`Smart Compact: this session refilled to ${used.toLocaleString()} tokens (${pct}%) within minutes of its last compaction.`,
|
|
140
|
+
"Compacting again would not help — something in this loop is reading far more than it keeps, so the window would refill just as fast and you would pay for another summary.",
|
|
141
|
+
"Write what you have learned to your state file with checkpoint, then either change the approach so it stops re-reading the same material, or finish and let a fresh session take the rest.",
|
|
142
|
+
].join("\n");
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/**
|
|
146
|
+
* Whether there is anyone to ask, and how full they are.
|
|
147
|
+
*
|
|
148
|
+
* The guards both hooks share: a subagent is never asked (its window is its own),
|
|
149
|
+
* Smart Compact must be on, the session must be enrolled, and the transcript must
|
|
150
|
+
* have a reading. Returns null whenever the answer is "say nothing".
|
|
151
|
+
*/
|
|
152
|
+
export function askable(event) {
|
|
153
|
+
if (event.agent_id !== undefined && event.agent_id !== null) return null;
|
|
154
|
+
const transcriptPath = event.transcript_path;
|
|
155
|
+
const sessionId = event.session_id;
|
|
156
|
+
if (typeof transcriptPath !== "string" || typeof sessionId !== "string") return null;
|
|
157
|
+
|
|
158
|
+
const settings = readSettings();
|
|
159
|
+
if (!settings.enabled) return null;
|
|
160
|
+
|
|
161
|
+
const agentId = process.env.PASEO_AGENT_ID ?? null;
|
|
162
|
+
if (!isEnrolled(agentId, settings)) return null;
|
|
163
|
+
|
|
164
|
+
const reading = occupancy(transcriptPath, settings);
|
|
165
|
+
if (reading === null) return null;
|
|
166
|
+
return { sessionId, agentId, settings, reading };
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* The text to put to the agent at one of the two lines, or null for silence.
|
|
171
|
+
*
|
|
172
|
+
* `line` is "compact" (end of turn) or "now" (right away). `allowed` is that
|
|
173
|
+
* line's own back-off rule over the ledger. The queue is read last because it is
|
|
174
|
+
* the most expensive check and the others usually decide first.
|
|
175
|
+
*/
|
|
176
|
+
export function compose(context, line, allowed) {
|
|
177
|
+
const { sessionId, agentId, settings, reading } = context;
|
|
178
|
+
if (reading.pct < reading.profile[line]) return null;
|
|
179
|
+
|
|
180
|
+
const ledger = readLedger(sessionId);
|
|
181
|
+
if (!allowed(ledger, reading.pct)) return null;
|
|
182
|
+
|
|
183
|
+
const queue = compactions();
|
|
184
|
+
if (alreadyQueued(queue, agentId)) return null;
|
|
185
|
+
|
|
186
|
+
const path = statePath();
|
|
187
|
+
const age = stateAgeSeconds(path);
|
|
188
|
+
let text;
|
|
189
|
+
if (thrashing(queue, agentId)) text = thrashAdvice(reading);
|
|
190
|
+
else if (line === "now") text = askRightAway(reading, reading.profile.now, path, age, settings.freshStateMinutes);
|
|
191
|
+
else text = askAtTurnEnd(reading, path, age, settings.freshStateMinutes);
|
|
192
|
+
return { text, ledger };
|
|
193
|
+
}
|
package/hooks/asks.mjs
CHANGED
|
@@ -28,7 +28,22 @@ export const MAX_ASKS = 3;
|
|
|
28
28
|
*/
|
|
29
29
|
export const RE_ASK_GROWTH_PCT = 10;
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
/**
|
|
32
|
+
* `now*` is the upper line's own record. It is kept apart from the end-of-turn ask
|
|
33
|
+
* so that having been asked politely at the lower line never silences the one ask
|
|
34
|
+
* that says "stop pushing on", and `deferredAt` survives an answered ask so the
|
|
35
|
+
* upper line can tell "deferred after I asked" from "never answered".
|
|
36
|
+
*/
|
|
37
|
+
const EMPTY = {
|
|
38
|
+
askCount: 0,
|
|
39
|
+
lastAskedAt: null,
|
|
40
|
+
askedAtPct: null,
|
|
41
|
+
deferredUntil: null,
|
|
42
|
+
deferReason: null,
|
|
43
|
+
deferredAt: null,
|
|
44
|
+
nowAskCount: 0,
|
|
45
|
+
nowAskedAt: null,
|
|
46
|
+
};
|
|
32
47
|
|
|
33
48
|
/**
|
|
34
49
|
* Keyed by Paseo agent id where there is one, because that survives compaction
|
|
@@ -84,7 +99,7 @@ export function resetLedger(sessionId) {
|
|
|
84
99
|
export function defer(sessionId, minutes, reason) {
|
|
85
100
|
const ledger = readLedger(sessionId);
|
|
86
101
|
const until = new Date(Date.now() + minutes * 60_000).toISOString();
|
|
87
|
-
writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason });
|
|
102
|
+
writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason, deferredAt: new Date().toISOString() });
|
|
88
103
|
return until;
|
|
89
104
|
}
|
|
90
105
|
|
|
@@ -93,11 +108,14 @@ export function defer(sessionId, minutes, reason) {
|
|
|
93
108
|
*
|
|
94
109
|
* `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
|
|
95
110
|
*/
|
|
111
|
+
function deferred(ledger, now) {
|
|
112
|
+
if (ledger.deferredUntil === null) return false;
|
|
113
|
+
const until = Date.parse(ledger.deferredUntil);
|
|
114
|
+
return Number.isFinite(until) && now < until;
|
|
115
|
+
}
|
|
116
|
+
|
|
96
117
|
export function shouldAsk(ledger, pct, now = Date.now()) {
|
|
97
|
-
if (ledger
|
|
98
|
-
const until = Date.parse(ledger.deferredUntil);
|
|
99
|
-
if (Number.isFinite(until) && now < until) return false;
|
|
100
|
-
}
|
|
118
|
+
if (deferred(ledger, now)) return false;
|
|
101
119
|
if (ledger.askCount >= MAX_ASKS) return false;
|
|
102
120
|
if (ledger.askedAtPct === null) return true;
|
|
103
121
|
return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
|
|
@@ -115,3 +133,32 @@ export function recordAsk(sessionId, ledger, pct) {
|
|
|
115
133
|
deferReason: null,
|
|
116
134
|
});
|
|
117
135
|
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* Whether to ask right away, mid-turn, at the upper line.
|
|
139
|
+
*
|
|
140
|
+
* Once per compaction epoch. It comes back only if the agent answered the last one
|
|
141
|
+
* with a deferral and that deferral has run out — ignoring it earns no repeat, so a
|
|
142
|
+
* long turn hears it once rather than after every tool call — and never more than
|
|
143
|
+
* `MAX_ASKS` times in all. Any live deferral, from either ask, holds it off: the
|
|
144
|
+
* agent's "not now" is one answer, not one per line.
|
|
145
|
+
*/
|
|
146
|
+
export function shouldAskNow(ledger, now = Date.now()) {
|
|
147
|
+
if (deferred(ledger, now)) return false;
|
|
148
|
+
if (ledger.nowAskCount === 0 || ledger.nowAskedAt === null) return true;
|
|
149
|
+
if (ledger.nowAskCount >= MAX_ASKS) return false;
|
|
150
|
+
const asked = Date.parse(ledger.nowAskedAt);
|
|
151
|
+
const answered = ledger.deferredAt === null ? Number.NaN : Date.parse(ledger.deferredAt);
|
|
152
|
+
return Number.isFinite(asked) && Number.isFinite(answered) && answered >= asked;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Records that the upper line's question was put to the model. */
|
|
156
|
+
export function recordNowAsk(sessionId, ledger) {
|
|
157
|
+
writeLedger(sessionId, {
|
|
158
|
+
...ledger,
|
|
159
|
+
nowAskCount: ledger.nowAskCount + 1,
|
|
160
|
+
nowAskedAt: new Date().toISOString(),
|
|
161
|
+
deferredUntil: null,
|
|
162
|
+
deferReason: null,
|
|
163
|
+
});
|
|
164
|
+
}
|
|
@@ -8,8 +8,11 @@
|
|
|
8
8
|
* short line the moment context crosses each band. Each level fires once per
|
|
9
9
|
* compaction epoch, so a long session gets three sentences, not three hundred.
|
|
10
10
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
11
|
+
* Below Smart Compact's upper line it only describes; the end-of-turn question at
|
|
12
|
+
* the lower line is `ask-compact.mjs` on `Stop`. Past the upper line it asks right
|
|
13
|
+
* away, because that is the line where the agent should not start anything new —
|
|
14
|
+
* once per compaction epoch, again only after a deferral runs out, and through the
|
|
15
|
+
* same guards as the `Stop` hook (`ask.mjs`).
|
|
13
16
|
*
|
|
14
17
|
* It reads the transcript rather than asking Paseo, so it works in any Claude Code
|
|
15
18
|
* session, and it reads only the tail of it, because this runs on the hot path.
|
|
@@ -18,6 +21,8 @@
|
|
|
18
21
|
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
19
22
|
import { dirname, join } from "node:path";
|
|
20
23
|
|
|
24
|
+
import { askable, compose } from "./ask.mjs";
|
|
25
|
+
import { recordNowAsk, shouldAskNow } from "./asks.mjs";
|
|
21
26
|
import { BANDS, DEFAULT_THRESHOLDS, occupancy, readSettings } from "./context.mjs";
|
|
22
27
|
import { clearPending, pluginDir, readPending, statePath } from "./pointer.mjs";
|
|
23
28
|
|
|
@@ -117,20 +122,36 @@ async function main() {
|
|
|
117
122
|
|
|
118
123
|
const reached = BANDS.filter((band) => now.pct >= now.profile[band]);
|
|
119
124
|
const crossed = reached.filter((band) => !latch.fired.includes(band));
|
|
120
|
-
if (crossed.length === 0) {
|
|
121
|
-
writeLatch(latchPath, latch);
|
|
122
|
-
return emit(sessionId, parts, pending);
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
// A session that jumped two bands in one turn hears the more urgent one.
|
|
126
|
-
const band = crossed[crossed.length - 1];
|
|
127
125
|
latch.fired = [...new Set([...latch.fired, ...crossed])];
|
|
128
126
|
writeLatch(latchPath, latch);
|
|
129
127
|
|
|
130
|
-
|
|
128
|
+
// Past the upper line, the question replaces the description: it says everything
|
|
129
|
+
// the band line would, and two messages about the same moment read as two.
|
|
130
|
+
const asked = now.pct >= now.profile.now ? askNow(event) : null;
|
|
131
|
+
if (asked !== null) {
|
|
132
|
+
parts.push(asked.text);
|
|
133
|
+
emit(sessionId, parts, pending);
|
|
134
|
+
// Recorded only once it has reached stdout, like the `Stop` hook's ask.
|
|
135
|
+
recordNowAsk(sessionId, asked.ledger);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// A session that jumped two bands in one turn hears the more urgent one.
|
|
140
|
+
if (crossed.length > 0) parts.push(advice(crossed[crossed.length - 1], now.pct, now.used, now.max, statePath()));
|
|
131
141
|
return emit(sessionId, parts, pending);
|
|
132
142
|
}
|
|
133
143
|
|
|
144
|
+
/** The upper line's question, or null when one of the shared guards says no. */
|
|
145
|
+
function askNow(event) {
|
|
146
|
+
try {
|
|
147
|
+
const context = askable(event);
|
|
148
|
+
if (context === null) return null;
|
|
149
|
+
return compose(context, "now", (ledger) => shouldAskNow(ledger));
|
|
150
|
+
} catch {
|
|
151
|
+
return null; // The ask is never worth failing the tool call it rides on.
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
134
155
|
/**
|
|
135
156
|
* Writes whatever there is to say, and only then forgets the pending pointer:
|
|
136
157
|
* clearing it before it reaches stdout would lose it on any later failure.
|
package/hooks/context.mjs
CHANGED
|
@@ -29,8 +29,8 @@ const TAIL_BYTES = 256 * 1024;
|
|
|
29
29
|
*/
|
|
30
30
|
export const DEFAULT_THRESHOLDS = {
|
|
31
31
|
largeWindowFrom: 400_000,
|
|
32
|
-
large: { notice: 15, closing: 22, compact: 30 },
|
|
33
|
-
small: { notice: 60, closing: 75, compact: 85 },
|
|
32
|
+
large: { notice: 15, closing: 22, compact: 30, now: 40 },
|
|
33
|
+
small: { notice: 60, closing: 75, compact: 85, now: 90 },
|
|
34
34
|
};
|
|
35
35
|
|
|
36
36
|
export const BANDS = ["notice", "closing", "compact"];
|
|
@@ -60,8 +60,8 @@ export function readSettings() {
|
|
|
60
60
|
: DEFAULT_SETTINGS.freshStateMinutes,
|
|
61
61
|
thresholds: {
|
|
62
62
|
largeWindowFrom: merged.largeWindowFrom ?? DEFAULT_THRESHOLDS.largeWindowFrom,
|
|
63
|
-
large:
|
|
64
|
-
small:
|
|
63
|
+
large: withUpperLine(merged.large, DEFAULT_THRESHOLDS.large),
|
|
64
|
+
small: withUpperLine(merged.small, DEFAULT_THRESHOLDS.small),
|
|
65
65
|
},
|
|
66
66
|
};
|
|
67
67
|
} catch {
|
|
@@ -69,6 +69,20 @@ export function readSettings() {
|
|
|
69
69
|
}
|
|
70
70
|
}
|
|
71
71
|
|
|
72
|
+
/**
|
|
73
|
+
* A saved profile, with the upper line filled in when it predates one.
|
|
74
|
+
*
|
|
75
|
+
* Mirrors `normalizeProfile` in thresholds.ts: the saved lower line is kept, and an
|
|
76
|
+
* absent upper line keeps the default's distance above it, never falling below it.
|
|
77
|
+
*/
|
|
78
|
+
export function withUpperLine(saved, fallback) {
|
|
79
|
+
const profile = { ...fallback, ...(saved ?? {}) };
|
|
80
|
+
const compact = profile.compact;
|
|
81
|
+
const migrated = Math.min(99, Math.max(fallback.now, compact + (fallback.now - fallback.compact)));
|
|
82
|
+
const raw = typeof saved?.now === "number" && Number.isFinite(saved.now) ? saved.now : migrated;
|
|
83
|
+
return { ...profile, now: Math.min(99, Math.max(Math.min(99, compact + 1), raw)) };
|
|
84
|
+
}
|
|
85
|
+
|
|
72
86
|
export function profileFor(maxTokens, thresholds) {
|
|
73
87
|
return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
|
|
74
88
|
}
|
package/index.client.tsx
CHANGED
|
@@ -11,6 +11,7 @@ import { createLive } from "./client/live";
|
|
|
11
11
|
import { OVERVIEW_SCREEN, PAUSED_SCREEN, SMART_COMPACT_SETTINGS, makeOverview } from "./client/overview";
|
|
12
12
|
import { contributeClient } from "./client/pill";
|
|
13
13
|
import { makeSettings } from "./client/settings";
|
|
14
|
+
import { watchSubscriptions } from "./client/subscriptions";
|
|
14
15
|
import { budgetStatus } from "./shared/smart-session";
|
|
15
16
|
|
|
16
17
|
export default function contribute(host: PluginClientContext) {
|
|
@@ -37,11 +38,14 @@ export default function contribute(host: PluginClientContext) {
|
|
|
37
38
|
// One agent observation and one protection poll for the one pill.
|
|
38
39
|
const agents = observeAgents(client);
|
|
39
40
|
const stopGuard = watchGuard(client, live);
|
|
41
|
+
// ZeroSub's subscriptions, for the overview, the sidebar row and the popover; rare without ZeroSub.
|
|
42
|
+
const stopSubscriptions = watchSubscriptions(client, (next) => live.setSubscriptions(next));
|
|
40
43
|
// The pill registers on the host's own client: a paused session keeps it even when pills are hidden.
|
|
41
44
|
const removeClient = contributeClient(host, agents);
|
|
42
45
|
return () => {
|
|
43
46
|
clearInterval(budgetTimer);
|
|
44
47
|
stopGuard();
|
|
48
|
+
stopSubscriptions();
|
|
45
49
|
removeClient();
|
|
46
50
|
for (const remove of removers.splice(0)) void remove();
|
|
47
51
|
visibility.dispose();
|
package/index.server.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { contributeGuard } from "./server/guard/index.ts";
|
|
2
2
|
import { zerosubBudgetAccount } from "./server/guard/zerosub.ts";
|
|
3
3
|
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
4
|
+
import { budgetPool, subscriptionsView } from "./server/subscriptions.ts";
|
|
4
5
|
import { displayPreferences } from "./shared/display";
|
|
5
6
|
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
6
7
|
|
|
@@ -17,6 +18,7 @@ import {
|
|
|
17
18
|
installStatus,
|
|
18
19
|
setSettings,
|
|
19
20
|
spendSummary,
|
|
21
|
+
subscriptionsStatus,
|
|
20
22
|
} from "./shared/smart-session.ts";
|
|
21
23
|
|
|
22
24
|
import {
|
|
@@ -76,9 +78,12 @@ export default function contribute(server: PluginServerContext) {
|
|
|
76
78
|
const newestInstant = newest === null ? null : effectiveAt(newest);
|
|
77
79
|
// Under ZeroSub the agent may run on another account than the one recorded.
|
|
78
80
|
const account = agentId === undefined ? null : await zerosubBudgetAccount(agentId).catch(() => null);
|
|
81
|
+
// …and with several subscriptions, how long they last together.
|
|
82
|
+
const pool = agentId === undefined ? null : await budgetPool(agentId).catch(() => null);
|
|
79
83
|
return {
|
|
80
84
|
windows: account?.windows ?? windows,
|
|
81
85
|
...(account ? { account: account.account } : {}),
|
|
86
|
+
...(pool ? { pool } : {}),
|
|
82
87
|
past,
|
|
83
88
|
recorder: {
|
|
84
89
|
newestAt: newestInstant,
|
|
@@ -107,6 +112,14 @@ export default function contribute(server: PluginServerContext) {
|
|
|
107
112
|
}
|
|
108
113
|
});
|
|
109
114
|
|
|
115
|
+
server.handle(subscriptionsStatus, async () => {
|
|
116
|
+
try {
|
|
117
|
+
return { ...(await subscriptionsView()), error: null };
|
|
118
|
+
} catch (error) {
|
|
119
|
+
return { zerosub: false, autoSwitch: false, accounts: [], agents: {}, error: error instanceof Error ? error.message : String(error) };
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
|
|
110
123
|
server.handle(contextStatus, async ({ agentId }) => {
|
|
111
124
|
try {
|
|
112
125
|
const rows = await withDaemon((client) => readAgents(client));
|
|
@@ -128,6 +141,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
128
141
|
growthTokensPerHour: growth.get(row.id) ?? null,
|
|
129
142
|
projectedFullAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds),
|
|
130
143
|
compactAtPct: profileFor(row.maxTokens!, thresholds).compact,
|
|
144
|
+
compactNowAtPct: profileFor(row.maxTokens!, thresholds).now,
|
|
145
|
+
projectedNowAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds, "now"),
|
|
131
146
|
stateWrittenAt: stateWrittenAt(row.id),
|
|
132
147
|
}))
|
|
133
148
|
.sort((a, b) => b.usedPct - a.usedPct);
|
package/mcp.mjs
CHANGED
|
@@ -280,6 +280,27 @@ function accountLines(account) {
|
|
|
280
280
|
return { head, tail, fromRecorder: account.kind === "main" };
|
|
281
281
|
}
|
|
282
282
|
|
|
283
|
+
/**
|
|
284
|
+
* All of this provider's ZeroSub subscriptions together, when there are several:
|
|
285
|
+
* how long they last at this pace and which takes over next. Names are the person's
|
|
286
|
+
* own or neutral ("CLI login", "Account 2"); the RPC never carries an email.
|
|
287
|
+
*/
|
|
288
|
+
function poolLines(pool) {
|
|
289
|
+
if (pool === undefined || pool === null) return [];
|
|
290
|
+
const fam = pool.family === "codex" ? "Codex" : "Claude";
|
|
291
|
+
const stale = pool.staleReadings > 0 ? ` (${pool.staleReadings} reading${pool.staleReadings === 1 ? " is" : "s are"} over 30 min old, so this is rough)` : "";
|
|
292
|
+
const head = `All ${pool.accounts} ${fam} subscriptions: ${pool.withRoom} with room now`;
|
|
293
|
+
if (pool.runsOutAt !== null && Date.parse(pool.runsOutAt) <= Date.now()) {
|
|
294
|
+
return [`${head}. Every one is at a limit${pool.back ? `; ${pool.back.name} is back ${describeRelative(pool.back.at)}` : ""}${stale}.`];
|
|
295
|
+
}
|
|
296
|
+
const next = pool.next ? ` ${pool.next.name} takes over ${describeRelative(pool.next.at)}.` : "";
|
|
297
|
+
if (pool.runsOutAt !== null) {
|
|
298
|
+
return [`${head}. At the current pace (${pool.pacePctPerHour}%/h of a session window) they run out ${describeRelative(pool.runsOutAt)}${pool.back ? `, and ${pool.back.name} is back ${describeRelative(pool.back.at)}` : ""}.${next}${stale}`];
|
|
299
|
+
}
|
|
300
|
+
if (pool.pacePctPerHour !== null) return [`${head}. At the current pace they last the next 24 hours.${next}${stale}`];
|
|
301
|
+
return [`${head}${stale}.`];
|
|
302
|
+
}
|
|
303
|
+
|
|
283
304
|
/**
|
|
284
305
|
* Session Guard's word on this session, for `budget_status`.
|
|
285
306
|
*
|
|
@@ -317,7 +338,17 @@ const TOOLS = {
|
|
|
317
338
|
if (mine.growthTokensPerHour !== null) {
|
|
318
339
|
lines.push(`Filling at roughly ${Math.round(mine.growthTokensPerHour).toLocaleString()} tokens/hour.`);
|
|
319
340
|
}
|
|
320
|
-
|
|
341
|
+
const upper = typeof mine.compactNowAtPct === "number" ? mine.compactNowAtPct : null;
|
|
342
|
+
if (upper !== null) {
|
|
343
|
+
lines.push(
|
|
344
|
+
`Its upper line is ${upper}% (${Math.round((upper / 100) * mine.maxTokens).toLocaleString()} tokens): past that, Smart Compact asks right away, mid-turn, and you should not start new work before compacting.`,
|
|
345
|
+
);
|
|
346
|
+
}
|
|
347
|
+
if (upper !== null && mine.usedPct >= upper) {
|
|
348
|
+
lines.push(
|
|
349
|
+
"You are past the upper line. Finish only the step you are on, call checkpoint, call request_compaction with continue_after_compaction true, and end your turn.",
|
|
350
|
+
);
|
|
351
|
+
} else if (mine.usedPct >= mine.compactAtPct) {
|
|
321
352
|
// Already past it: a projection of when it will arrive reads as nonsense.
|
|
322
353
|
lines.push(
|
|
323
354
|
"You are already past that point. Check your task state is on disk and current, then compact at the next clean boundary — call request_compaction when the step you are on is finished.",
|
|
@@ -351,6 +382,7 @@ const TOOLS = {
|
|
|
351
382
|
if (result.error) return `Could not read plan usage: ${result.error}`;
|
|
352
383
|
const guard = await guardSummary();
|
|
353
384
|
const account = accountLines(result.account);
|
|
385
|
+
account.tail.push(...poolLines(result.pool));
|
|
354
386
|
const interesting = (result.windows ?? []).filter(
|
|
355
387
|
(window) => window.id === "five_hour" || window.id === "seven_day" || window.pct > 0,
|
|
356
388
|
);
|
package/package.json
CHANGED
package/paseo-plugin.json
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
"docs/media/smart-session-flow.gif",
|
|
9
9
|
"docs/media/smart-session-pill.png",
|
|
10
10
|
"docs/media/smart-session-chat.png",
|
|
11
|
+
"docs/media/smart-session-subscriptions.png",
|
|
11
12
|
"docs/media/smart-session-guard.png",
|
|
12
13
|
"docs/media/smart-session-settings.png",
|
|
13
14
|
"docs/media/smart-session-mobile.png"
|
package/server/growth.ts
CHANGED
|
@@ -59,9 +59,10 @@ export function projectFull(
|
|
|
59
59
|
maxTokens: number,
|
|
60
60
|
ratePerHour: number | null,
|
|
61
61
|
thresholds?: Thresholds,
|
|
62
|
+
line: "compact" | "now" = "compact",
|
|
62
63
|
): string | null {
|
|
63
64
|
if (ratePerHour === null || ratePerHour <= 0) return null;
|
|
64
|
-
const room = maxTokens * (profileFor(maxTokens, thresholds)
|
|
65
|
+
const room = maxTokens * (profileFor(maxTokens, thresholds)[line] / 100) - usedTokens;
|
|
65
66
|
if (room <= 0) return new Date().toISOString();
|
|
66
67
|
return new Date(Date.now() + (room / ratePerHour) * 3_600_000).toISOString();
|
|
67
68
|
}
|
package/server/recorder.ts
CHANGED
|
@@ -26,6 +26,7 @@ import {
|
|
|
26
26
|
noteNewestUsage,
|
|
27
27
|
type ContextSample,
|
|
28
28
|
} from "./store.ts";
|
|
29
|
+
import { forgetAccounts, recordAccounts } from "./subscriptions.ts";
|
|
29
30
|
import { fetchUpstreamUsage } from "./upstream.ts";
|
|
30
31
|
import {
|
|
31
32
|
acceptReading,
|
|
@@ -182,6 +183,15 @@ async function pollUpstream(): Promise<void> {
|
|
|
182
183
|
if (sample !== null) await record(sample);
|
|
183
184
|
}
|
|
184
185
|
|
|
186
|
+
/** ZeroSub's other accounts, kept as ZeroSub reads them; nothing happens without ZeroSub. */
|
|
187
|
+
async function pollAccounts(): Promise<void> {
|
|
188
|
+
try {
|
|
189
|
+
await recordAccounts();
|
|
190
|
+
} catch (error) {
|
|
191
|
+
console.error("[smart-session] could not record ZeroSub's account readings", String(error));
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
|
|
185
195
|
async function pollDaemon(): Promise<void> {
|
|
186
196
|
await withDaemon(async (client) => {
|
|
187
197
|
const at = new Date().toISOString();
|
|
@@ -232,6 +242,7 @@ function startRecorder(): void {
|
|
|
232
242
|
await pollUpstream();
|
|
233
243
|
await pollDaemon();
|
|
234
244
|
await pollClaudeJson();
|
|
245
|
+
await pollAccounts();
|
|
235
246
|
await pollSpend();
|
|
236
247
|
} catch (error) {
|
|
237
248
|
// A daemon restart, a lost socket, a machine asleep — none of these should
|
|
@@ -251,6 +262,7 @@ function startRecorder(): void {
|
|
|
251
262
|
clearInterval(timer);
|
|
252
263
|
lastContext.clear();
|
|
253
264
|
clearCaches();
|
|
265
|
+
forgetAccounts();
|
|
254
266
|
});
|
|
255
267
|
}
|
|
256
268
|
|
package/server/store.ts
CHANGED
|
@@ -73,7 +73,18 @@ export interface ContextSample {
|
|
|
73
73
|
readonly title: string | null;
|
|
74
74
|
}
|
|
75
75
|
|
|
76
|
-
|
|
76
|
+
/**
|
|
77
|
+
* One ZeroSub account's reading, as ZeroSub took it (`usage.json`), kept so each
|
|
78
|
+
* account gets a pace and a forecast. Shaped like a usage sample, so the same block
|
|
79
|
+
* logic reads it, plus the account it belongs to: ZeroSub's id, never an email.
|
|
80
|
+
*/
|
|
81
|
+
export interface AccountSample extends Omit<UsageSample, "src"> {
|
|
82
|
+
readonly src: "zerosub";
|
|
83
|
+
readonly accountId: string;
|
|
84
|
+
readonly family: "claude" | "codex";
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
type LogKind = "usage" | "context" | "accounts";
|
|
77
88
|
|
|
78
89
|
/** Monthly files: small enough to read whole, coarse enough to stay few. */
|
|
79
90
|
function logPath(kind: LogKind, at: string): string {
|
|
@@ -113,6 +124,10 @@ export async function appendContext(sample: ContextSample): Promise<void> {
|
|
|
113
124
|
await append("context", sample.at, sample);
|
|
114
125
|
}
|
|
115
126
|
|
|
127
|
+
export async function appendAccount(sample: AccountSample): Promise<void> {
|
|
128
|
+
await append("accounts", sample.at, sample);
|
|
129
|
+
}
|
|
130
|
+
|
|
116
131
|
/**
|
|
117
132
|
* Reads a log back.
|
|
118
133
|
*
|
|
@@ -169,6 +184,14 @@ export async function readUsage(options: { sinceMs?: number } = {}): Promise<Usa
|
|
|
169
184
|
return since === null ? rows : rows.filter((row) => Date.parse(row.at) >= since);
|
|
170
185
|
}
|
|
171
186
|
|
|
187
|
+
export async function readAccounts(options: { sinceMs?: number } = {}): Promise<AccountSample[]> {
|
|
188
|
+
const since = options.sinceMs ?? null;
|
|
189
|
+
const rows = await readLog<AccountSample>("accounts", since);
|
|
190
|
+
// Defensive: a row from a future shape, or a hand-edited one, is skipped, not thrown on.
|
|
191
|
+
return rows.filter((row) => typeof row?.accountId === "string" && typeof row.at === "string" && typeof row.windows === "object" && row.windows !== null
|
|
192
|
+
&& (since === null || Date.parse(row.at) >= since));
|
|
193
|
+
}
|
|
194
|
+
|
|
172
195
|
export async function readContext(options: { sinceMs?: number } = {}): Promise<ContextSample[]> {
|
|
173
196
|
const since = options.sinceMs ?? null;
|
|
174
197
|
const rows = await readLog<ContextSample>("context", since);
|