paseo-smart-session 3.5.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 +1 -1
- package/hooks/ask.mjs +10 -5
- package/hooks/asks.mjs +2 -164
- package/hooks/context-threshold.mjs +4 -1
- package/hooks/context.mjs +2 -247
- package/hooks/pointer.mjs +2 -149
- package/index.server.ts +30 -3
- package/mcp.mjs +49 -468
- package/package.json +3 -2
- package/server/agent-tools.d.mts +15 -0
- package/server/agent-tools.mjs +472 -0
- 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/README.md
CHANGED
|
@@ -20,7 +20,7 @@ Screenshots use synthetic usage history and sessions.
|
|
|
20
20
|
paseo plugin install paseo-smart-session
|
|
21
21
|
```
|
|
22
22
|
|
|
23
|
-
On first load it registers its Claude Code hooks, touching only entries that point at its own scripts.
|
|
23
|
+
On first load it registers its Claude Code hooks, touching only entries that point at its own scripts. Its agent tools are attached when an interactive session opens: on Paseo 0.15+ they are served through the agent's own Paseo MCP (`smart_checkpoint`, `smart_request_compaction`, …) with no process per agent, and on 0.14 each session gets its own `mcp.mjs` stdio server. Nothing is registered globally; an older version's global `smart-session` entry is removed on load, with a backup beside the file.
|
|
24
24
|
|
|
25
25
|
## Use
|
|
26
26
|
|
package/hooks/ask.mjs
CHANGED
|
@@ -15,7 +15,12 @@
|
|
|
15
15
|
import { readFileSync } from "node:fs";
|
|
16
16
|
import { join } from "node:path";
|
|
17
17
|
|
|
18
|
-
import { isEnrolled, occupancy, readSettings } from "./context.mjs";
|
|
18
|
+
import { forTools, isEnrolled, occupancy, readSettings } from "./context.mjs";
|
|
19
|
+
import { fileURLToPath } from "node:url";
|
|
20
|
+
import { dirname as parentOf } from "node:path";
|
|
21
|
+
|
|
22
|
+
/** The one-call bridge a detached session is pointed at (beside this plugin's hooks). */
|
|
23
|
+
export const BRIDGE = join(parentOf(fileURLToPath(import.meta.url)), "..", "mcp.mjs");
|
|
19
24
|
import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
|
|
20
25
|
import { readLedger } from "./asks.mjs";
|
|
21
26
|
|
|
@@ -101,7 +106,7 @@ export function askAtTurnEnd(reading, path, age, freshMinutes) {
|
|
|
101
106
|
"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
107
|
"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
108
|
);
|
|
104
|
-
return lines.join("\n");
|
|
109
|
+
return forTools(lines.join("\n"), BRIDGE);
|
|
105
110
|
}
|
|
106
111
|
|
|
107
112
|
/**
|
|
@@ -125,7 +130,7 @@ export function askRightAway(reading, upper, path, age, freshMinutes) {
|
|
|
125
130
|
"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
131
|
"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
132
|
);
|
|
128
|
-
return lines.join("\n");
|
|
133
|
+
return forTools(lines.join("\n"), BRIDGE);
|
|
129
134
|
}
|
|
130
135
|
|
|
131
136
|
/**
|
|
@@ -135,11 +140,11 @@ export function askRightAway(reading, upper, path, age, freshMinutes) {
|
|
|
135
140
|
* turn would be the nagging these hooks exist to avoid.
|
|
136
141
|
*/
|
|
137
142
|
export function thrashAdvice({ used, pct }) {
|
|
138
|
-
return [
|
|
143
|
+
return forTools([
|
|
139
144
|
`Smart Compact: this session refilled to ${used.toLocaleString()} tokens (${pct}%) within minutes of its last compaction.`,
|
|
140
145
|
"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
146
|
"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");
|
|
147
|
+
].join("\n"), BRIDGE);
|
|
143
148
|
}
|
|
144
149
|
|
|
145
150
|
/**
|
package/hooks/asks.mjs
CHANGED
|
@@ -1,164 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Asking once and never again lets a session drift; asking every turn is nagging.
|
|
5
|
-
* So the ask is latched, re-armed by growth rather than by a clock alone, and can
|
|
6
|
-
* be switched off for a while by the agent itself — that last part is what makes it
|
|
7
|
-
* a question instead of an announcement.
|
|
8
|
-
*
|
|
9
|
-
* One file per agent rather than one shared file. `state/<agentId>.md` already
|
|
10
|
-
* works that way, and it means the `Stop` hook and the `defer_compaction` tool —
|
|
11
|
-
* two separate processes — can never be writing the same file at the same moment.
|
|
12
|
-
*/
|
|
13
|
-
|
|
14
|
-
import { mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
|
|
15
|
-
import { randomUUID } from "node:crypto";
|
|
16
|
-
import { join } from "node:path";
|
|
17
|
-
|
|
18
|
-
import { pluginDir } from "./pointer.mjs";
|
|
19
|
-
|
|
20
|
-
/** Asks per compaction epoch, after which the session is left to its own judgement. */
|
|
21
|
-
export const MAX_ASKS = 3;
|
|
22
|
-
|
|
23
|
-
/**
|
|
24
|
-
* Further occupancy, in percentage points of the window, that re-arms the ask.
|
|
25
|
-
*
|
|
26
|
-
* Time alone is the wrong trigger: a session parked at 31% for an hour has not
|
|
27
|
-
* changed its situation, and one that went 31% to 55% in ten minutes plainly has.
|
|
28
|
-
*/
|
|
29
|
-
export const RE_ASK_GROWTH_PCT = 10;
|
|
30
|
-
|
|
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
|
-
};
|
|
47
|
-
|
|
48
|
-
/**
|
|
49
|
-
* Keyed by Paseo agent id where there is one, because that survives compaction
|
|
50
|
-
* whatever Claude Code decides to do with the session id.
|
|
51
|
-
*/
|
|
52
|
-
export function ledgerPath(sessionId) {
|
|
53
|
-
const agentId = process.env.PASEO_AGENT_ID;
|
|
54
|
-
const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
|
|
55
|
-
if (typeof key !== "string" || key === "") return null;
|
|
56
|
-
return join(pluginDir(), "asks", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
export function readLedger(sessionId) {
|
|
60
|
-
const path = ledgerPath(sessionId);
|
|
61
|
-
if (path === null) return { ...EMPTY };
|
|
62
|
-
try {
|
|
63
|
-
return { ...EMPTY, ...JSON.parse(readFileSync(path, "utf8")) };
|
|
64
|
-
} catch {
|
|
65
|
-
return { ...EMPTY };
|
|
66
|
-
}
|
|
67
|
-
}
|
|
68
|
-
|
|
69
|
-
export function writeLedger(sessionId, ledger) {
|
|
70
|
-
const path = ledgerPath(sessionId);
|
|
71
|
-
if (path === null) return;
|
|
72
|
-
try {
|
|
73
|
-
mkdirSync(join(pluginDir(), "asks"), { recursive: true });
|
|
74
|
-
const temp = `${path}.${randomUUID()}.tmp`;
|
|
75
|
-
writeFileSync(temp, JSON.stringify(ledger), "utf8");
|
|
76
|
-
renameSync(temp, path);
|
|
77
|
-
} catch {
|
|
78
|
-
// A hook that cannot persist its latch must still not break the turn.
|
|
79
|
-
}
|
|
80
|
-
}
|
|
81
|
-
|
|
82
|
-
/**
|
|
83
|
-
* Forgets everything, because the window just emptied.
|
|
84
|
-
*
|
|
85
|
-
* A compaction is a new epoch: the occupancy the last ask was about is gone, and a
|
|
86
|
-
* session that fills up again deserves to be asked again.
|
|
87
|
-
*/
|
|
88
|
-
export function resetLedger(sessionId) {
|
|
89
|
-
const path = ledgerPath(sessionId);
|
|
90
|
-
if (path === null) return;
|
|
91
|
-
try {
|
|
92
|
-
rmSync(path, { force: true });
|
|
93
|
-
} catch {
|
|
94
|
-
// A stale ledger costs one missed ask, never a failed turn.
|
|
95
|
-
}
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
/** The agent's own answer: not now, and here is for how long. */
|
|
99
|
-
export function defer(sessionId, minutes, reason) {
|
|
100
|
-
const ledger = readLedger(sessionId);
|
|
101
|
-
const until = new Date(Date.now() + minutes * 60_000).toISOString();
|
|
102
|
-
writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason, deferredAt: new Date().toISOString() });
|
|
103
|
-
return until;
|
|
104
|
-
}
|
|
105
|
-
|
|
106
|
-
/**
|
|
107
|
-
* Whether to ask, given what has already been said and how much has changed.
|
|
108
|
-
*
|
|
109
|
-
* `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
|
|
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
|
-
|
|
117
|
-
export function shouldAsk(ledger, pct, now = Date.now()) {
|
|
118
|
-
if (deferred(ledger, now)) return false;
|
|
119
|
-
if (ledger.askCount >= MAX_ASKS) return false;
|
|
120
|
-
if (ledger.askedAtPct === null) return true;
|
|
121
|
-
return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
|
|
122
|
-
}
|
|
123
|
-
|
|
124
|
-
/** Records that the question was actually put to the model. */
|
|
125
|
-
export function recordAsk(sessionId, ledger, pct) {
|
|
126
|
-
writeLedger(sessionId, {
|
|
127
|
-
...ledger,
|
|
128
|
-
askCount: ledger.askCount + 1,
|
|
129
|
-
lastAskedAt: new Date().toISOString(),
|
|
130
|
-
askedAtPct: pct,
|
|
131
|
-
// An answered ask clears the deferral it was waiting out.
|
|
132
|
-
deferredUntil: null,
|
|
133
|
-
deferReason: null,
|
|
134
|
-
});
|
|
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
|
-
}
|
|
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/asks.mjs";
|
|
@@ -23,7 +23,8 @@ import { dirname, join } from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { askable, compose } from "./ask.mjs";
|
|
25
25
|
import { recordNowAsk, shouldAskNow } from "./asks.mjs";
|
|
26
|
-
import { BANDS, DEFAULT_THRESHOLDS, occupancy, readSettings } from "./context.mjs";
|
|
26
|
+
import { BANDS, DEFAULT_THRESHOLDS, detachedNotice, occupancy, readSettings, toolsMode } from "./context.mjs";
|
|
27
|
+
import { BRIDGE } from "./ask.mjs";
|
|
27
28
|
import { clearPending, pluginDir, readPending, statePath } from "./pointer.mjs";
|
|
28
29
|
|
|
29
30
|
function latchPathFor(sessionId) {
|
|
@@ -107,6 +108,8 @@ async function main() {
|
|
|
107
108
|
const parts = [];
|
|
108
109
|
const pending = readPending(sessionId);
|
|
109
110
|
if (pending !== null) parts.push(pending);
|
|
111
|
+
// A smart_* call that just failed because a plugin update detached the tools: say how to reach them meanwhile.
|
|
112
|
+
if (/^mcp__paseo__smart_/.test(String(event.tool_name ?? "")) && toolsMode() === "detached") parts.push(detachedNotice(BRIDGE));
|
|
110
113
|
|
|
111
114
|
const now = occupancy(transcriptPath, readSettings());
|
|
112
115
|
if (now === null) return emit(sessionId, parts, pending);
|
package/hooks/context.mjs
CHANGED
|
@@ -1,247 +1,2 @@
|
|
|
1
|
-
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
* Two hooks now answer "how full is this context" — `context-threshold.mjs` on
|
|
5
|
-
* `PostToolUse` and `ask-compact.mjs` on `Stop` — and they must not disagree. One
|
|
6
|
-
* reading them differently from the other would mean the agent hears the number and
|
|
7
|
-
* the advice from two different sessions.
|
|
8
|
-
*
|
|
9
|
-
* Everything here reads the tail of a file rather than the whole of it: these run
|
|
10
|
-
* on the hot path, and the newest row is the only one that matters.
|
|
11
|
-
*/
|
|
12
|
-
|
|
13
|
-
import { closeSync, fstatSync, openSync, readdirSync, readFileSync, readSync } from "node:fs";
|
|
14
|
-
import { join } from "node:path";
|
|
15
|
-
|
|
16
|
-
import { pluginDir } from "./pointer.mjs";
|
|
17
|
-
|
|
18
|
-
/** Enough tail to contain the last assistant message in any realistic transcript. */
|
|
19
|
-
const TAIL_BYTES = 256 * 1024;
|
|
20
|
-
|
|
21
|
-
/**
|
|
22
|
-
* Defaults mirroring thresholds.shared.ts, used when the plugin has not written a
|
|
23
|
-
* settings file yet. Kept in sync by a test that runs the hooks and checks where
|
|
24
|
-
* they fire against the shared module's numbers.
|
|
25
|
-
*
|
|
26
|
-
* Two profiles because a percentage of the window is the wrong unit on its own:
|
|
27
|
-
* what tires a context is the absolute prefix re-read every turn, so 30% of a
|
|
28
|
-
* million tokens is a heavier context than 85% of two hundred thousand.
|
|
29
|
-
*/
|
|
30
|
-
export const DEFAULT_THRESHOLDS = {
|
|
31
|
-
largeWindowFrom: 400_000,
|
|
32
|
-
large: { notice: 15, closing: 22, compact: 30, now: 40 },
|
|
33
|
-
small: { notice: 60, closing: 75, compact: 85, now: 90 },
|
|
34
|
-
};
|
|
35
|
-
|
|
36
|
-
export const BANDS = ["notice", "closing", "compact"];
|
|
37
|
-
|
|
38
|
-
export const DEFAULT_SETTINGS = {
|
|
39
|
-
enabled: true,
|
|
40
|
-
showPill: true,
|
|
41
|
-
autoEnrol: true,
|
|
42
|
-
freshStateMinutes: 30,
|
|
43
|
-
thresholds: DEFAULT_THRESHOLDS,
|
|
44
|
-
};
|
|
45
|
-
|
|
46
|
-
/** The plugin's settings are the single source of truth when they exist. */
|
|
47
|
-
export function readSettings() {
|
|
48
|
-
try {
|
|
49
|
-
const raw = JSON.parse(readFileSync(join(pluginDir(), "settings.json"), "utf8"));
|
|
50
|
-
const merged = { ...DEFAULT_THRESHOLDS, ...(raw.thresholds ?? {}) };
|
|
51
|
-
return {
|
|
52
|
-
// Absent means an install that predates the switch, which was always meant
|
|
53
|
-
// to be on once it could only ask rather than act.
|
|
54
|
-
enabled: raw.enabled !== false,
|
|
55
|
-
showPill: raw.showPill !== false,
|
|
56
|
-
autoEnrol: raw.autoEnrol !== false,
|
|
57
|
-
freshStateMinutes:
|
|
58
|
-
typeof raw.freshStateMinutes === "number" && raw.freshStateMinutes > 0
|
|
59
|
-
? raw.freshStateMinutes
|
|
60
|
-
: DEFAULT_SETTINGS.freshStateMinutes,
|
|
61
|
-
thresholds: {
|
|
62
|
-
largeWindowFrom: merged.largeWindowFrom ?? DEFAULT_THRESHOLDS.largeWindowFrom,
|
|
63
|
-
large: withUpperLine(merged.large, DEFAULT_THRESHOLDS.large),
|
|
64
|
-
small: withUpperLine(merged.small, DEFAULT_THRESHOLDS.small),
|
|
65
|
-
},
|
|
66
|
-
};
|
|
67
|
-
} catch {
|
|
68
|
-
return DEFAULT_SETTINGS;
|
|
69
|
-
}
|
|
70
|
-
}
|
|
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
|
-
|
|
86
|
-
export function profileFor(maxTokens, thresholds) {
|
|
87
|
-
return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
/**
|
|
91
|
-
* Whether this agent is one the governor may speak to.
|
|
92
|
-
*
|
|
93
|
-
* The same rule settings.server.ts resolves, duplicated here because a hook on the
|
|
94
|
-
* hot path must not open a daemon connection to answer it: an explicit answer in
|
|
95
|
-
* `enrolment.json` outranks the state-file inference in both directions, and the
|
|
96
|
-
* inference itself only counts while `autoEnrol` is on.
|
|
97
|
-
*/
|
|
98
|
-
export function isEnrolled(agentId, settings = readSettings()) {
|
|
99
|
-
if (typeof agentId !== "string" || agentId === "") return false;
|
|
100
|
-
try {
|
|
101
|
-
const overrides = JSON.parse(readFileSync(join(pluginDir(), "enrolment.json"), "utf8"));
|
|
102
|
-
if (typeof overrides?.[agentId] === "boolean") return overrides[agentId];
|
|
103
|
-
} catch {
|
|
104
|
-
// No file, or one we cannot read: nobody has overridden anything.
|
|
105
|
-
}
|
|
106
|
-
if (!settings.autoEnrol) return false;
|
|
107
|
-
try {
|
|
108
|
-
return readdirSync(join(pluginDir(), "state")).includes(`${agentId}.md`);
|
|
109
|
-
} catch {
|
|
110
|
-
return false;
|
|
111
|
-
}
|
|
112
|
-
}
|
|
113
|
-
|
|
114
|
-
/** The tail of a file as text, or null if it cannot be read. */
|
|
115
|
-
function tail(path, bytes) {
|
|
116
|
-
let fd;
|
|
117
|
-
try {
|
|
118
|
-
fd = openSync(path, "r");
|
|
119
|
-
} catch {
|
|
120
|
-
return null;
|
|
121
|
-
}
|
|
122
|
-
try {
|
|
123
|
-
const size = fstatSync(fd).size;
|
|
124
|
-
const length = Math.min(size, bytes);
|
|
125
|
-
const buffer = Buffer.allocUnsafe(length);
|
|
126
|
-
readSync(fd, buffer, 0, length, size - length);
|
|
127
|
-
return buffer.toString("utf8");
|
|
128
|
-
} catch {
|
|
129
|
-
return null;
|
|
130
|
-
} finally {
|
|
131
|
-
closeSync(fd);
|
|
132
|
-
}
|
|
133
|
-
}
|
|
134
|
-
|
|
135
|
-
/**
|
|
136
|
-
* Context occupancy from the last assistant message's usage.
|
|
137
|
-
*
|
|
138
|
-
* input + cache_read + cache_creation + output is the same arithmetic Claude Code
|
|
139
|
-
* itself documents for a resumed session's `context_tokens`, so it is the
|
|
140
|
-
* sanctioned way to ask "how much of the window is currently occupied".
|
|
141
|
-
*/
|
|
142
|
-
export function readContextTokens(transcriptPath) {
|
|
143
|
-
const text = tail(transcriptPath, TAIL_BYTES);
|
|
144
|
-
if (text === null) return null;
|
|
145
|
-
const lines = text.split("\n");
|
|
146
|
-
|
|
147
|
-
// Backwards: the newest assistant message wins, and a partial first line from
|
|
148
|
-
// slicing mid-file simply fails to parse and is skipped.
|
|
149
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
150
|
-
const line = lines[index];
|
|
151
|
-
if (line === undefined || line === "" || !line.includes('"usage"')) continue;
|
|
152
|
-
let entry;
|
|
153
|
-
try {
|
|
154
|
-
entry = JSON.parse(line);
|
|
155
|
-
} catch {
|
|
156
|
-
continue;
|
|
157
|
-
}
|
|
158
|
-
if (entry?.type !== "assistant") continue;
|
|
159
|
-
// A subagent's usage is its own window, not the main thread's.
|
|
160
|
-
if (entry.isSidechain === true) continue;
|
|
161
|
-
const usage = entry.message?.usage;
|
|
162
|
-
if (usage === undefined) continue;
|
|
163
|
-
const used =
|
|
164
|
-
(usage.input_tokens ?? 0) +
|
|
165
|
-
(usage.cache_read_input_tokens ?? 0) +
|
|
166
|
-
(usage.cache_creation_input_tokens ?? 0) +
|
|
167
|
-
(usage.output_tokens ?? 0);
|
|
168
|
-
return { used, model: entry.message?.model ?? null };
|
|
169
|
-
}
|
|
170
|
-
return null;
|
|
171
|
-
}
|
|
172
|
-
|
|
173
|
-
/**
|
|
174
|
-
* Models whose context window is not the current default of 1M.
|
|
175
|
-
*
|
|
176
|
-
* The transcript records the resolved model id (`claude-opus-5`), never the config
|
|
177
|
-
* alias (`opus[1m]`), so the suffix is not a signal. Every current Opus, Sonnet and
|
|
178
|
-
* Fable model has a 1M window; Haiku and the older generations do not.
|
|
179
|
-
*/
|
|
180
|
-
const SMALL_WINDOW = /haiku|-3-|sonnet-3|opus-3|sonnet-4-5|opus-4-5|sonnet-4-20|opus-4-20/;
|
|
181
|
-
|
|
182
|
-
/**
|
|
183
|
-
* The window size Paseo reported for this agent, from the recorder's own log.
|
|
184
|
-
*
|
|
185
|
-
* Read from the tail, like the transcript: this is the hot path, and the newest row
|
|
186
|
-
* is the only one that matters.
|
|
187
|
-
*/
|
|
188
|
-
function recordedWindow() {
|
|
189
|
-
const agentId = process.env.PASEO_AGENT_ID;
|
|
190
|
-
if (agentId === undefined || agentId === "") return null;
|
|
191
|
-
const month = new Date().toISOString().slice(0, 7);
|
|
192
|
-
const text = tail(join(pluginDir(), `context-${month}.jsonl`), 64 * 1024);
|
|
193
|
-
if (text === null) return null;
|
|
194
|
-
|
|
195
|
-
const lines = text.split("\n");
|
|
196
|
-
for (let index = lines.length - 1; index >= 0; index -= 1) {
|
|
197
|
-
const line = lines[index];
|
|
198
|
-
if (line === undefined || !line.includes(agentId)) continue;
|
|
199
|
-
try {
|
|
200
|
-
const row = JSON.parse(line);
|
|
201
|
-
if (row.agentId === agentId && typeof row.maxTokens === "number" && row.maxTokens > 0) {
|
|
202
|
-
return row.maxTokens;
|
|
203
|
-
}
|
|
204
|
-
} catch {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
}
|
|
208
|
-
return null;
|
|
209
|
-
}
|
|
210
|
-
|
|
211
|
-
/**
|
|
212
|
-
* The window size for this session.
|
|
213
|
-
*
|
|
214
|
-
* Getting this wrong is worse than not warning at all: assuming 200k on a 1M
|
|
215
|
-
* session warns at a fifth of the real occupancy and reports negative headroom.
|
|
216
|
-
* So the first choice is what Paseo actually measured for this agent — recorded by
|
|
217
|
-
* the plugin, read from the tail of its log — and the model table is the fallback.
|
|
218
|
-
*/
|
|
219
|
-
export function windowFor(model) {
|
|
220
|
-
const override = Number(process.env.SMART_SESSION_CONTEXT_WINDOW ?? "");
|
|
221
|
-
if (Number.isFinite(override) && override > 0) return override;
|
|
222
|
-
|
|
223
|
-
const recorded = recordedWindow();
|
|
224
|
-
if (recorded !== null) return recorded;
|
|
225
|
-
|
|
226
|
-
if (typeof model === "string" && SMALL_WINDOW.test(model)) return 200_000;
|
|
227
|
-
return 1_000_000;
|
|
228
|
-
}
|
|
229
|
-
|
|
230
|
-
/**
|
|
231
|
-
* Occupancy, the window it is measured against, and the bands that apply.
|
|
232
|
-
*
|
|
233
|
-
* Returns null when the transcript says nothing yet — a session with no completed
|
|
234
|
-
* assistant turn has no usage to read, and guessing would be worse than silence.
|
|
235
|
-
*/
|
|
236
|
-
export function occupancy(transcriptPath, settings = readSettings()) {
|
|
237
|
-
const reading = readContextTokens(transcriptPath);
|
|
238
|
-
if (reading === null) return null;
|
|
239
|
-
const max = windowFor(reading.model);
|
|
240
|
-
return {
|
|
241
|
-
used: reading.used,
|
|
242
|
-
max,
|
|
243
|
-
model: reading.model,
|
|
244
|
-
pct: Math.floor((reading.used / max) * 100),
|
|
245
|
-
profile: profileFor(max, settings.thresholds),
|
|
246
|
-
};
|
|
247
|
-
}
|
|
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/context.mjs";
|
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";
|