paseo-smart-session 3.4.0 → 3.6.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 +7 -5
- package/client/charts.tsx +7 -6
- package/client/overview.tsx +13 -8
- package/client/pill-view.ts +6 -5
- package/client/pill.tsx +2 -2
- package/client/popover.tsx +10 -11
- package/client/settings.tsx +39 -17
- package/hooks/ask-compact.mjs +13 -119
- package/hooks/ask.mjs +198 -0
- package/hooks/asks.mjs +2 -117
- package/hooks/context-threshold.mjs +35 -11
- package/hooks/context.mjs +2 -233
- package/hooks/pointer.mjs +2 -149
- package/index.server.ts +32 -3
- package/mcp.mjs +49 -458
- package/package.json +3 -2
- package/server/agent-tools.d.mts +15 -0
- package/server/agent-tools.mjs +472 -0
- package/server/growth.ts +2 -1
- package/server/hooklib/asks.mjs +163 -0
- package/server/hooklib/context.mjs +284 -0
- package/server/hooklib/pointer.mjs +149 -0
- package/server/install-on-load.ts +15 -1
- package/server/mcp-registrations.ts +126 -0
- package/server/native-tools.ts +60 -0
- package/server/session-mcp.ts +54 -17
- package/shared/chat.ts +6 -2
- package/shared/compact-lines.ts +60 -0
- package/shared/forecast.ts +38 -12
- package/shared/smart-session.ts +6 -0
- package/shared/thresholds.ts +41 -6
package/hooks/pointer.mjs
CHANGED
|
@@ -1,149 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
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
|
-
}
|
|
1
|
+
// Moved to server/hooklib so the server bundle (native agent tools) can share it; hooks keep importing it from here.
|
|
2
|
+
export * from "../server/hooklib/pointer.mjs";
|
package/index.server.ts
CHANGED
|
@@ -3,7 +3,8 @@ import { zerosubBudgetAccount } from "./server/guard/zerosub.ts";
|
|
|
3
3
|
import { contributeSessionMcp } from "./server/session-mcp.ts";
|
|
4
4
|
import { budgetPool, subscriptionsView } from "./server/subscriptions.ts";
|
|
5
5
|
import { displayPreferences } from "./shared/display";
|
|
6
|
-
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
6
|
+
import type { PluginHandlerContext, PluginServerContext } from "@getpaseo/plugin/server";
|
|
7
|
+
import type { Invoke } from "./server/native-tools.ts";
|
|
7
8
|
|
|
8
9
|
import { readAgents, withDaemon } from "./server/daemon.ts";
|
|
9
10
|
import { contextGrowth, projectFull } from "./server/growth.ts";
|
|
@@ -43,8 +44,34 @@ import { queue } from "./server/governor.ts";
|
|
|
43
44
|
// reports "on" and does nothing at all.
|
|
44
45
|
import "./server/install-on-load.ts";
|
|
45
46
|
|
|
46
|
-
|
|
47
|
-
|
|
47
|
+
/**
|
|
48
|
+
* The host's server context, with every RPC handler also recorded by name, so the native agent
|
|
49
|
+
* tools call the same handlers in-process that `mcp.mjs` reaches over the daemon.
|
|
50
|
+
*/
|
|
51
|
+
function recording(host: PluginServerContext): { server: PluginServerContext; invoke: Invoke } {
|
|
52
|
+
const handlers = new Map<string, (input: unknown, context: PluginHandlerContext) => unknown>();
|
|
53
|
+
const handle: PluginServerContext["handle"] = (contract, handler) => {
|
|
54
|
+
handlers.set(contract.name, (input, context) => handler(contract.input.parse(input), context));
|
|
55
|
+
host.handle(contract, handler);
|
|
56
|
+
};
|
|
57
|
+
const server = new Proxy(host, {
|
|
58
|
+
get(target, key) {
|
|
59
|
+
if (key === "handle") return handle;
|
|
60
|
+
const value = Reflect.get(target, key);
|
|
61
|
+
return typeof value === "function" ? value.bind(target) : value;
|
|
62
|
+
},
|
|
63
|
+
});
|
|
64
|
+
const invoke: Invoke = async (method, input, context) => {
|
|
65
|
+
const handler = handlers.get(method);
|
|
66
|
+
if (!handler) throw new Error(`No handler for ${method}`);
|
|
67
|
+
return handler(input, { paseo: context.paseo });
|
|
68
|
+
};
|
|
69
|
+
return { server, invoke };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
export default function contribute(host: PluginServerContext) {
|
|
73
|
+
const { server, invoke } = recording(host);
|
|
74
|
+
const removeSessionMcp = contributeSessionMcp(server, invoke);
|
|
48
75
|
server.registerSettings(displayPreferences);
|
|
49
76
|
// Pauses work at a provider's session limit; off until someone turns it on.
|
|
50
77
|
const removeGuard = contributeGuard(server);
|
|
@@ -141,6 +168,8 @@ export default function contribute(server: PluginServerContext) {
|
|
|
141
168
|
growthTokensPerHour: growth.get(row.id) ?? null,
|
|
142
169
|
projectedFullAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds),
|
|
143
170
|
compactAtPct: profileFor(row.maxTokens!, thresholds).compact,
|
|
171
|
+
compactNowAtPct: profileFor(row.maxTokens!, thresholds).now,
|
|
172
|
+
projectedNowAt: projectFull(row.usedTokens!, row.maxTokens!, growth.get(row.id) ?? null, thresholds, "now"),
|
|
144
173
|
stateWrittenAt: stateWrittenAt(row.id),
|
|
145
174
|
}))
|
|
146
175
|
.sort((a, b) => b.usedPct - a.usedPct);
|