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/hooks/ask.mjs ADDED
@@ -0,0 +1,198 @@
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 { 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");
24
+ import { pluginDir, stateAgeSeconds, statePath } from "./pointer.mjs";
25
+ import { readLedger } from "./asks.mjs";
26
+
27
+ /**
28
+ * A compaction that had to be repeated within minutes is not solving the problem —
29
+ * something in the loop is refilling the window as fast as it is emptied, and
30
+ * another compaction just burns tokens on summarizing. Claude Code detects the same
31
+ * pathology and calls it thrashing.
32
+ */
33
+ export const THRASH_WINDOW_MS = 15 * 60_000;
34
+
35
+ /** The governor's queue, read directly: a hook must not open a daemon connection. */
36
+ export function compactions() {
37
+ try {
38
+ const parsed = JSON.parse(readFileSync(join(pluginDir(), "compactions.json"), "utf8"));
39
+ return Array.isArray(parsed?.items) ? parsed.items : [];
40
+ } catch {
41
+ return [];
42
+ }
43
+ }
44
+
45
+ /** Already asked and waiting to be delivered: there is nothing to add. */
46
+ export function alreadyQueued(items, agentId) {
47
+ return items.some(
48
+ (item) => item.agentId === agentId && (item.state === "pending" || item.state === "sending"),
49
+ );
50
+ }
51
+
52
+ export function thrashing(items, agentId, now = Date.now()) {
53
+ return items.some(
54
+ (item) =>
55
+ item.agentId === agentId &&
56
+ item.state === "sent" &&
57
+ item.settledAt !== null &&
58
+ now - Date.parse(item.settledAt) < THRASH_WINDOW_MS,
59
+ );
60
+ }
61
+
62
+ function headline({ used, max, pct }) {
63
+ return `this session holds ${used.toLocaleString()} tokens (${pct}% of a ${max.toLocaleString()}-token window)`;
64
+ }
65
+
66
+ function why(max) {
67
+ return max >= 400_000
68
+ ? "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."
69
+ : "This is the point where recall degrades noticeably and every turn re-reads an expensive prefix.";
70
+ }
71
+
72
+ /** The checkpoint half of either ask, which depends on how old the state file is. */
73
+ function checkpointStep(path, age, freshMinutes, then) {
74
+ if (age === null) {
75
+ 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.`;
76
+ }
77
+ if (age > freshMinutes * 60) {
78
+ 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}`;
79
+ }
80
+ return null;
81
+ }
82
+
83
+ /**
84
+ * The end-of-turn question, at the lower line.
85
+ *
86
+ * On a large window the headline is the token count, not the percentage: "30% full"
87
+ * sounds like there is plenty of room, and 300,000 tokens does not.
88
+ *
89
+ * It has to be answerable in one turn, so it names the tools and says what to do
90
+ * with each, and it has to be refusable, or it is not a question — hence the
91
+ * deferral, which the agent is told about in the same breath.
92
+ */
93
+ export function askAtTurnEnd(reading, path, age, freshMinutes) {
94
+ const lines = [
95
+ `Smart Compact: ${headline(reading)}.`,
96
+ why(reading.max),
97
+ "Compacting is your call, not this plugin's, and nothing will happen unless you ask. Do one of these before you stop:",
98
+ ];
99
+ const stale = checkpointStep(path, age, freshMinutes, "call request_compaction.");
100
+ lines.push(
101
+ stale !== null
102
+ ? `1. ${stale}`
103
+ : `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.`,
104
+ );
105
+ lines.push(
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.",
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.",
108
+ );
109
+ return forTools(lines.join("\n"), BRIDGE);
110
+ }
111
+
112
+ /**
113
+ * The mid-turn question, at the upper line.
114
+ *
115
+ * It arrives after a tool call, in the middle of the agent's work, so it says what
116
+ * to finish (only the step already under way) and what not to start. The compaction
117
+ * still lands only once the session is idle, which is why it ends with "then end
118
+ * your turn": without that the agent would queue it and carry on for an hour.
119
+ */
120
+ export function askRightAway(reading, upper, path, age, freshMinutes) {
121
+ const lines = [
122
+ `Smart Compact: ${headline(reading)}, past its upper line of ${upper}%.`,
123
+ `This is the line where you should not push on. ${why(reading.max)}`,
124
+ "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:",
125
+ ];
126
+ const stale = checkpointStep(path, age, freshMinutes, "go on to step 2.");
127
+ lines.push(stale !== null ? `1. ${stale}` : `1. Your task state at ${path} is current, so go straight to step 2.`);
128
+ lines.push(
129
+ "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.",
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.",
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.",
132
+ );
133
+ return forTools(lines.join("\n"), BRIDGE);
134
+ }
135
+
136
+ /**
137
+ * What to say instead, when compacting is not the answer.
138
+ *
139
+ * Said once, as an ask, so it counts against the same latch: repeating it every
140
+ * turn would be the nagging these hooks exist to avoid.
141
+ */
142
+ export function thrashAdvice({ used, pct }) {
143
+ return forTools([
144
+ `Smart Compact: this session refilled to ${used.toLocaleString()} tokens (${pct}%) within minutes of its last compaction.`,
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.",
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.",
147
+ ].join("\n"), BRIDGE);
148
+ }
149
+
150
+ /**
151
+ * Whether there is anyone to ask, and how full they are.
152
+ *
153
+ * The guards both hooks share: a subagent is never asked (its window is its own),
154
+ * Smart Compact must be on, the session must be enrolled, and the transcript must
155
+ * have a reading. Returns null whenever the answer is "say nothing".
156
+ */
157
+ export function askable(event) {
158
+ if (event.agent_id !== undefined && event.agent_id !== null) return null;
159
+ const transcriptPath = event.transcript_path;
160
+ const sessionId = event.session_id;
161
+ if (typeof transcriptPath !== "string" || typeof sessionId !== "string") return null;
162
+
163
+ const settings = readSettings();
164
+ if (!settings.enabled) return null;
165
+
166
+ const agentId = process.env.PASEO_AGENT_ID ?? null;
167
+ if (!isEnrolled(agentId, settings)) return null;
168
+
169
+ const reading = occupancy(transcriptPath, settings);
170
+ if (reading === null) return null;
171
+ return { sessionId, agentId, settings, reading };
172
+ }
173
+
174
+ /**
175
+ * The text to put to the agent at one of the two lines, or null for silence.
176
+ *
177
+ * `line` is "compact" (end of turn) or "now" (right away). `allowed` is that
178
+ * line's own back-off rule over the ledger. The queue is read last because it is
179
+ * the most expensive check and the others usually decide first.
180
+ */
181
+ export function compose(context, line, allowed) {
182
+ const { sessionId, agentId, settings, reading } = context;
183
+ if (reading.pct < reading.profile[line]) return null;
184
+
185
+ const ledger = readLedger(sessionId);
186
+ if (!allowed(ledger, reading.pct)) return null;
187
+
188
+ const queue = compactions();
189
+ if (alreadyQueued(queue, agentId)) return null;
190
+
191
+ const path = statePath();
192
+ const age = stateAgeSeconds(path);
193
+ let text;
194
+ if (thrashing(queue, agentId)) text = thrashAdvice(reading);
195
+ else if (line === "now") text = askRightAway(reading, reading.profile.now, path, age, settings.freshStateMinutes);
196
+ else text = askAtTurnEnd(reading, path, age, settings.freshStateMinutes);
197
+ return { text, ledger };
198
+ }
package/hooks/asks.mjs CHANGED
@@ -1,117 +1,2 @@
1
- /**
2
- * The record of what this session has already been asked, and what it answered.
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
- const EMPTY = { askCount: 0, lastAskedAt: null, askedAtPct: null, deferredUntil: null, deferReason: null };
32
-
33
- /**
34
- * Keyed by Paseo agent id where there is one, because that survives compaction
35
- * whatever Claude Code decides to do with the session id.
36
- */
37
- export function ledgerPath(sessionId) {
38
- const agentId = process.env.PASEO_AGENT_ID;
39
- const key = agentId !== undefined && agentId !== "" ? agentId : sessionId;
40
- if (typeof key !== "string" || key === "") return null;
41
- return join(pluginDir(), "asks", `${key.replace(/[^A-Za-z0-9_-]/g, "_")}.json`);
42
- }
43
-
44
- export function readLedger(sessionId) {
45
- const path = ledgerPath(sessionId);
46
- if (path === null) return { ...EMPTY };
47
- try {
48
- return { ...EMPTY, ...JSON.parse(readFileSync(path, "utf8")) };
49
- } catch {
50
- return { ...EMPTY };
51
- }
52
- }
53
-
54
- export function writeLedger(sessionId, ledger) {
55
- const path = ledgerPath(sessionId);
56
- if (path === null) return;
57
- try {
58
- mkdirSync(join(pluginDir(), "asks"), { recursive: true });
59
- const temp = `${path}.${randomUUID()}.tmp`;
60
- writeFileSync(temp, JSON.stringify(ledger), "utf8");
61
- renameSync(temp, path);
62
- } catch {
63
- // A hook that cannot persist its latch must still not break the turn.
64
- }
65
- }
66
-
67
- /**
68
- * Forgets everything, because the window just emptied.
69
- *
70
- * A compaction is a new epoch: the occupancy the last ask was about is gone, and a
71
- * session that fills up again deserves to be asked again.
72
- */
73
- export function resetLedger(sessionId) {
74
- const path = ledgerPath(sessionId);
75
- if (path === null) return;
76
- try {
77
- rmSync(path, { force: true });
78
- } catch {
79
- // A stale ledger costs one missed ask, never a failed turn.
80
- }
81
- }
82
-
83
- /** The agent's own answer: not now, and here is for how long. */
84
- export function defer(sessionId, minutes, reason) {
85
- const ledger = readLedger(sessionId);
86
- const until = new Date(Date.now() + minutes * 60_000).toISOString();
87
- writeLedger(sessionId, { ...ledger, deferredUntil: until, deferReason: reason });
88
- return until;
89
- }
90
-
91
- /**
92
- * Whether to ask, given what has already been said and how much has changed.
93
- *
94
- * `pct` is the occupancy now; `askedAtPct` the occupancy the last ask was about.
95
- */
96
- export function shouldAsk(ledger, pct, now = Date.now()) {
97
- if (ledger.deferredUntil !== null) {
98
- const until = Date.parse(ledger.deferredUntil);
99
- if (Number.isFinite(until) && now < until) return false;
100
- }
101
- if (ledger.askCount >= MAX_ASKS) return false;
102
- if (ledger.askedAtPct === null) return true;
103
- return pct >= ledger.askedAtPct + RE_ASK_GROWTH_PCT;
104
- }
105
-
106
- /** Records that the question was actually put to the model. */
107
- export function recordAsk(sessionId, ledger, pct) {
108
- writeLedger(sessionId, {
109
- ...ledger,
110
- askCount: ledger.askCount + 1,
111
- lastAskedAt: new Date().toISOString(),
112
- askedAtPct: pct,
113
- // An answered ask clears the deferral it was waiting out.
114
- deferredUntil: null,
115
- deferReason: null,
116
- });
117
- }
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";
@@ -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
- * It describes; it never asks for anything. Deciding that a session should compact
12
- * belongs at a turn boundary, and that is `ask-compact.mjs` on `Stop`.
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,7 +21,10 @@
18
21
  import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
19
22
  import { dirname, join } from "node:path";
20
23
 
21
- import { BANDS, DEFAULT_THRESHOLDS, occupancy, readSettings } from "./context.mjs";
24
+ import { askable, compose } from "./ask.mjs";
25
+ import { recordNowAsk, shouldAskNow } from "./asks.mjs";
26
+ import { BANDS, DEFAULT_THRESHOLDS, detachedNotice, occupancy, readSettings, toolsMode } from "./context.mjs";
27
+ import { BRIDGE } from "./ask.mjs";
22
28
  import { clearPending, pluginDir, readPending, statePath } from "./pointer.mjs";
23
29
 
24
30
  function latchPathFor(sessionId) {
@@ -102,6 +108,8 @@ async function main() {
102
108
  const parts = [];
103
109
  const pending = readPending(sessionId);
104
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));
105
113
 
106
114
  const now = occupancy(transcriptPath, readSettings());
107
115
  if (now === null) return emit(sessionId, parts, pending);
@@ -117,20 +125,36 @@ async function main() {
117
125
 
118
126
  const reached = BANDS.filter((band) => now.pct >= now.profile[band]);
119
127
  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
128
  latch.fired = [...new Set([...latch.fired, ...crossed])];
128
129
  writeLatch(latchPath, latch);
129
130
 
130
- parts.push(advice(band, now.pct, now.used, now.max, statePath()));
131
+ // Past the upper line, the question replaces the description: it says everything
132
+ // the band line would, and two messages about the same moment read as two.
133
+ const asked = now.pct >= now.profile.now ? askNow(event) : null;
134
+ if (asked !== null) {
135
+ parts.push(asked.text);
136
+ emit(sessionId, parts, pending);
137
+ // Recorded only once it has reached stdout, like the `Stop` hook's ask.
138
+ recordNowAsk(sessionId, asked.ledger);
139
+ return;
140
+ }
141
+
142
+ // A session that jumped two bands in one turn hears the more urgent one.
143
+ if (crossed.length > 0) parts.push(advice(crossed[crossed.length - 1], now.pct, now.used, now.max, statePath()));
131
144
  return emit(sessionId, parts, pending);
132
145
  }
133
146
 
147
+ /** The upper line's question, or null when one of the shared guards says no. */
148
+ function askNow(event) {
149
+ try {
150
+ const context = askable(event);
151
+ if (context === null) return null;
152
+ return compose(context, "now", (ledger) => shouldAskNow(ledger));
153
+ } catch {
154
+ return null; // The ask is never worth failing the tool call it rides on.
155
+ }
156
+ }
157
+
134
158
  /**
135
159
  * Writes whatever there is to say, and only then forgets the pending pointer:
136
160
  * clearing it before it reaches stdout would lose it on any later failure.
package/hooks/context.mjs CHANGED
@@ -1,233 +1,2 @@
1
- /**
2
- * What every hook needs to know about the session it is running in.
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 },
33
- small: { notice: 60, closing: 75, compact: 85 },
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: { ...DEFAULT_THRESHOLDS.large, ...(merged.large ?? {}) },
64
- small: { ...DEFAULT_THRESHOLDS.small, ...(merged.small ?? {}) },
65
- },
66
- };
67
- } catch {
68
- return DEFAULT_SETTINGS;
69
- }
70
- }
71
-
72
- export function profileFor(maxTokens, thresholds) {
73
- return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
74
- }
75
-
76
- /**
77
- * Whether this agent is one the governor may speak to.
78
- *
79
- * The same rule settings.server.ts resolves, duplicated here because a hook on the
80
- * hot path must not open a daemon connection to answer it: an explicit answer in
81
- * `enrolment.json` outranks the state-file inference in both directions, and the
82
- * inference itself only counts while `autoEnrol` is on.
83
- */
84
- export function isEnrolled(agentId, settings = readSettings()) {
85
- if (typeof agentId !== "string" || agentId === "") return false;
86
- try {
87
- const overrides = JSON.parse(readFileSync(join(pluginDir(), "enrolment.json"), "utf8"));
88
- if (typeof overrides?.[agentId] === "boolean") return overrides[agentId];
89
- } catch {
90
- // No file, or one we cannot read: nobody has overridden anything.
91
- }
92
- if (!settings.autoEnrol) return false;
93
- try {
94
- return readdirSync(join(pluginDir(), "state")).includes(`${agentId}.md`);
95
- } catch {
96
- return false;
97
- }
98
- }
99
-
100
- /** The tail of a file as text, or null if it cannot be read. */
101
- function tail(path, bytes) {
102
- let fd;
103
- try {
104
- fd = openSync(path, "r");
105
- } catch {
106
- return null;
107
- }
108
- try {
109
- const size = fstatSync(fd).size;
110
- const length = Math.min(size, bytes);
111
- const buffer = Buffer.allocUnsafe(length);
112
- readSync(fd, buffer, 0, length, size - length);
113
- return buffer.toString("utf8");
114
- } catch {
115
- return null;
116
- } finally {
117
- closeSync(fd);
118
- }
119
- }
120
-
121
- /**
122
- * Context occupancy from the last assistant message's usage.
123
- *
124
- * input + cache_read + cache_creation + output is the same arithmetic Claude Code
125
- * itself documents for a resumed session's `context_tokens`, so it is the
126
- * sanctioned way to ask "how much of the window is currently occupied".
127
- */
128
- export function readContextTokens(transcriptPath) {
129
- const text = tail(transcriptPath, TAIL_BYTES);
130
- if (text === null) return null;
131
- const lines = text.split("\n");
132
-
133
- // Backwards: the newest assistant message wins, and a partial first line from
134
- // slicing mid-file simply fails to parse and is skipped.
135
- for (let index = lines.length - 1; index >= 0; index -= 1) {
136
- const line = lines[index];
137
- if (line === undefined || line === "" || !line.includes('"usage"')) continue;
138
- let entry;
139
- try {
140
- entry = JSON.parse(line);
141
- } catch {
142
- continue;
143
- }
144
- if (entry?.type !== "assistant") continue;
145
- // A subagent's usage is its own window, not the main thread's.
146
- if (entry.isSidechain === true) continue;
147
- const usage = entry.message?.usage;
148
- if (usage === undefined) continue;
149
- const used =
150
- (usage.input_tokens ?? 0) +
151
- (usage.cache_read_input_tokens ?? 0) +
152
- (usage.cache_creation_input_tokens ?? 0) +
153
- (usage.output_tokens ?? 0);
154
- return { used, model: entry.message?.model ?? null };
155
- }
156
- return null;
157
- }
158
-
159
- /**
160
- * Models whose context window is not the current default of 1M.
161
- *
162
- * The transcript records the resolved model id (`claude-opus-5`), never the config
163
- * alias (`opus[1m]`), so the suffix is not a signal. Every current Opus, Sonnet and
164
- * Fable model has a 1M window; Haiku and the older generations do not.
165
- */
166
- const SMALL_WINDOW = /haiku|-3-|sonnet-3|opus-3|sonnet-4-5|opus-4-5|sonnet-4-20|opus-4-20/;
167
-
168
- /**
169
- * The window size Paseo reported for this agent, from the recorder's own log.
170
- *
171
- * Read from the tail, like the transcript: this is the hot path, and the newest row
172
- * is the only one that matters.
173
- */
174
- function recordedWindow() {
175
- const agentId = process.env.PASEO_AGENT_ID;
176
- if (agentId === undefined || agentId === "") return null;
177
- const month = new Date().toISOString().slice(0, 7);
178
- const text = tail(join(pluginDir(), `context-${month}.jsonl`), 64 * 1024);
179
- if (text === null) return null;
180
-
181
- const lines = text.split("\n");
182
- for (let index = lines.length - 1; index >= 0; index -= 1) {
183
- const line = lines[index];
184
- if (line === undefined || !line.includes(agentId)) continue;
185
- try {
186
- const row = JSON.parse(line);
187
- if (row.agentId === agentId && typeof row.maxTokens === "number" && row.maxTokens > 0) {
188
- return row.maxTokens;
189
- }
190
- } catch {
191
- continue;
192
- }
193
- }
194
- return null;
195
- }
196
-
197
- /**
198
- * The window size for this session.
199
- *
200
- * Getting this wrong is worse than not warning at all: assuming 200k on a 1M
201
- * session warns at a fifth of the real occupancy and reports negative headroom.
202
- * So the first choice is what Paseo actually measured for this agent — recorded by
203
- * the plugin, read from the tail of its log — and the model table is the fallback.
204
- */
205
- export function windowFor(model) {
206
- const override = Number(process.env.SMART_SESSION_CONTEXT_WINDOW ?? "");
207
- if (Number.isFinite(override) && override > 0) return override;
208
-
209
- const recorded = recordedWindow();
210
- if (recorded !== null) return recorded;
211
-
212
- if (typeof model === "string" && SMALL_WINDOW.test(model)) return 200_000;
213
- return 1_000_000;
214
- }
215
-
216
- /**
217
- * Occupancy, the window it is measured against, and the bands that apply.
218
- *
219
- * Returns null when the transcript says nothing yet — a session with no completed
220
- * assistant turn has no usage to read, and guessing would be worse than silence.
221
- */
222
- export function occupancy(transcriptPath, settings = readSettings()) {
223
- const reading = readContextTokens(transcriptPath);
224
- if (reading === null) return null;
225
- const max = windowFor(reading.model);
226
- return {
227
- used: reading.used,
228
- max,
229
- model: reading.model,
230
- pct: Math.floor((reading.used / max) * 100),
231
- profile: profileFor(max, settings.thresholds),
232
- };
233
- }
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";