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/server/session-mcp.ts
CHANGED
|
@@ -1,26 +1,63 @@
|
|
|
1
|
-
import type { PluginServerContext } from "@getpaseo/plugin/server";
|
|
1
|
+
import type { PluginServerContext, PluginSessionOpenRequest } from "@getpaseo/plugin/server";
|
|
2
|
+
import { randomUUID } from "node:crypto";
|
|
3
|
+
import { mkdirSync, renameSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { readFile, writeFile } from "node:fs/promises";
|
|
2
5
|
import { homedir } from "node:os";
|
|
3
6
|
import { join } from "node:path";
|
|
4
7
|
import { mcpDefinition, resolvePluginDir } from "./install.ts";
|
|
8
|
+
import { type Invoke, nativeTools, nativeToolsAvailable } from "./native-tools.ts";
|
|
5
9
|
import { readSettings } from "./settings.ts";
|
|
6
10
|
|
|
7
|
-
|
|
8
|
-
|
|
11
|
+
const paseoHome = () => process.env.PASEO_HOME ?? join(homedir(), ".paseo");
|
|
12
|
+
|
|
13
|
+
async function paseoConfig(): Promise<Record<string, any> | null> {
|
|
14
|
+
try { return JSON.parse(await readFile(join(paseoHome(), "config.json"), "utf8")); } catch { return null; }
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** Providers whose sessions get the tools. A test harness may widen it to its fixture provider. */
|
|
18
|
+
const providers = () => (process.env.SMART_SESSION_TOOL_PROVIDERS ?? "claude,codex").split(",").map((name) => name.trim());
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* The agent tools are a launch contribution, so nothing global is written. On Paseo 0.15+ they go
|
|
22
|
+
* on `request.tools` and the daemon serves them through the agent's own Paseo MCP; otherwise this
|
|
23
|
+
* launch alone gets `mcp.mjs` as a stdio server. `SMART_SESSION_TOOLS` tells the hooks which names
|
|
24
|
+
* the agent has (`smart_checkpoint` or `checkpoint`).
|
|
25
|
+
*/
|
|
26
|
+
export function contributeSessionMcp(server: PluginServerContext, invoke: Invoke) {
|
|
27
|
+
// A reload or update detaches native tools from open sessions until each reopens. This worker's instance, and the
|
|
28
|
+
// instance that attached each session, let the hooks tell a detached session how to reach the tools meanwhile
|
|
29
|
+
// (hooks/context.mjs toolsMode). Written synchronously, before any session can open.
|
|
30
|
+
const instance = randomUUID();
|
|
31
|
+
const data = join(paseoHome(), "plugin-data", "smart-session");
|
|
32
|
+
try {
|
|
33
|
+
mkdirSync(join(data, "tools"), { recursive: true });
|
|
34
|
+
writeFileSync(join(data, `tools-instance.json.${instance}`), JSON.stringify({ instance }));
|
|
35
|
+
renameSync(join(data, `tools-instance.json.${instance}`), join(data, "tools-instance.json"));
|
|
36
|
+
} catch { /* Without it the hooks assume the tools are attached. */ }
|
|
9
37
|
return server.before("agent.session_open", async ({ request }) => {
|
|
10
|
-
if (request.purpose !== "interactive" || !
|
|
38
|
+
if (request.purpose !== "interactive" || !providers().includes(request.provider)
|
|
11
39
|
|| request.labels["plugin-tools/enabled"] === "false" || !(await readSettings()).enabled) return;
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
mcpServers: { ...request.mcpServers, "smart-session": { ...definition, env: {
|
|
18
|
-
...definition.env, PASEO_AGENT_ID: request.agentId,
|
|
19
|
-
PASEO_HOME: process.env.PASEO_HOME ?? join(homedir(), ".paseo"), SMART_SESSION_MCP_SCOPE: "paseo",
|
|
20
|
-
...(process.env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: process.env.PASEO_DAEMON_URL } : {}),
|
|
21
|
-
...(process.env.PASEO_PASSWORD ? { PASEO_PASSWORD: process.env.PASEO_PASSWORD } : {}),
|
|
22
|
-
...(process.env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: process.env.PASEO_PASSWORD_FILE } : {}),
|
|
23
|
-
} } },
|
|
24
|
-
};
|
|
40
|
+
if (process.env.SMART_SESSION_FORCE_STDIO !== "1" && nativeToolsAvailable(request, await paseoConfig())) {
|
|
41
|
+
await writeFile(join(data, "tools", `native-${request.agentId.replace(/[^A-Za-z0-9_-]/g, "_")}.json`), JSON.stringify({ instance })).catch(() => undefined);
|
|
42
|
+
return { ...request, env: { ...request.env, SMART_SESSION_TOOLS: "native" }, tools: [...request.tools, ...nativeTools(invoke)] };
|
|
43
|
+
}
|
|
44
|
+
return withStdio(request);
|
|
25
45
|
});
|
|
26
46
|
}
|
|
47
|
+
|
|
48
|
+
async function withStdio(request: PluginSessionOpenRequest): Promise<PluginSessionOpenRequest | void> {
|
|
49
|
+
const directory = await resolvePluginDir();
|
|
50
|
+
if (!directory) return;
|
|
51
|
+
const definition = mcpDefinition(directory);
|
|
52
|
+
return {
|
|
53
|
+
...request,
|
|
54
|
+
env: { ...request.env, SMART_SESSION_TOOLS: "stdio" },
|
|
55
|
+
mcpServers: { ...request.mcpServers, "smart-session": { ...definition, env: {
|
|
56
|
+
...definition.env, PASEO_AGENT_ID: request.agentId,
|
|
57
|
+
PASEO_HOME: paseoHome(), SMART_SESSION_MCP_SCOPE: "paseo",
|
|
58
|
+
...(process.env.PASEO_DAEMON_URL ? { PASEO_DAEMON_URL: process.env.PASEO_DAEMON_URL } : {}),
|
|
59
|
+
...(process.env.PASEO_PASSWORD ? { PASEO_PASSWORD: process.env.PASEO_PASSWORD } : {}),
|
|
60
|
+
...(process.env.PASEO_PASSWORD_FILE ? { PASEO_PASSWORD_FILE: process.env.PASEO_PASSWORD_FILE } : {}),
|
|
61
|
+
} } },
|
|
62
|
+
};
|
|
63
|
+
}
|
package/shared/chat.ts
CHANGED
|
@@ -31,9 +31,13 @@ export const PromptCardSchema = z.object({
|
|
|
31
31
|
});
|
|
32
32
|
export type PromptCard = z.infer<typeof PromptCardSchema>;
|
|
33
33
|
|
|
34
|
-
/**
|
|
34
|
+
/**
|
|
35
|
+
* Claude records mcp__smart-session__context_status and Codex smart-session.context_status from the
|
|
36
|
+
* stdio server; served natively through Paseo's MCP they are mcp__paseo__smart_context_status and
|
|
37
|
+
* paseo.smart_context_status.
|
|
38
|
+
*/
|
|
35
39
|
export function smartSessionTool(name: string): SmartSessionTool | null {
|
|
36
|
-
const match = /^(?:mcp__)?smart-session(?:__|\.|\/|:)([a-z_]+)$/.exec(name);
|
|
40
|
+
const match = /^(?:mcp__)?(?:smart-session(?:__|\.|\/|:)|paseo(?:__|\.|\/|:)smart_)([a-z_]+)$/.exec(name);
|
|
37
41
|
return match && (SMART_SESSION_TOOLS as readonly string[]).includes(match[1]!) ? (match[1] as SmartSessionTool) : null;
|
|
38
42
|
}
|
|
39
43
|
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What the views say about Smart Compact's two lines, as plain functions.
|
|
3
|
+
*
|
|
4
|
+
* The lower line is the **end-of-turn line**: past it, the `Stop` hook asks the
|
|
5
|
+
* agent at the end of a turn whether to compact. The upper line is the
|
|
6
|
+
* **mid-turn line**: past it, the `PostToolUse` hook asks mid-turn, and the agent
|
|
7
|
+
* is told to finish only the step it is on. The pill, its popover and the overview
|
|
8
|
+
* all read these, so they cannot describe the same session two ways.
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { formatCompactTokens, formatDuration } from "./format.ts";
|
|
12
|
+
import type { ContextOutlook, Tone } from "./forecast.ts";
|
|
13
|
+
|
|
14
|
+
/** "asks at 30%, mid-turn at 40%", or the lower line alone when there is no upper one. */
|
|
15
|
+
export function linesSummary(lower: number, upper: number | null | undefined): string {
|
|
16
|
+
return typeof upper === "number" ? `asks at ${lower}%, mid-turn at ${upper}%` : `asks at ${lower}%`;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/** The popover's one sentence about where this session stands. */
|
|
20
|
+
export function outlookSentence(
|
|
21
|
+
outlook: ContextOutlook,
|
|
22
|
+
{ enrolled, compactOn, growthPerHour }: { enrolled: boolean; compactOn: boolean; growthPerHour: number | null },
|
|
23
|
+
): { line: string; tone: Tone } {
|
|
24
|
+
const pace = growthPerHour !== null && growthPerHour > 0 ? ` at +${formatCompactTokens(growthPerHour)}/h` : "";
|
|
25
|
+
if (outlook.past && !enrolled) {
|
|
26
|
+
return {
|
|
27
|
+
line: compactOn ? "Past its compact line, but not enrolled, so nothing will ask it." : "Past its compact line. Smart Compact is off, so nothing will ask it.",
|
|
28
|
+
tone: outlook.tone,
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
if (outlook.pastNow) {
|
|
32
|
+
return { line: "Past its mid-turn line. Smart Compact is asking it to finish the step it is on and compact.", tone: outlook.tone };
|
|
33
|
+
}
|
|
34
|
+
if (outlook.past) {
|
|
35
|
+
const next = outlook.untilNowMs !== null
|
|
36
|
+
? ` Reaches its mid-turn line in about ${formatDuration(outlook.untilNowMs)}${pace}.`
|
|
37
|
+
: outlook.nowAt !== null ? ` From ${outlook.nowAt}% it is asked mid-turn.` : "";
|
|
38
|
+
return { line: `Past its end-of-turn line, so it is asked at the end of each turn whether to compact.${next}`, tone: outlook.tone };
|
|
39
|
+
}
|
|
40
|
+
if (outlook.untilCompactMs !== null) {
|
|
41
|
+
const upper = outlook.untilNowMs !== null ? ` and its mid-turn line in about ${formatDuration(outlook.untilNowMs)}` : "";
|
|
42
|
+
return { line: `Reaches its end-of-turn line in about ${formatDuration(outlook.untilCompactMs)}${upper}${pace}.`, tone: "default" };
|
|
43
|
+
}
|
|
44
|
+
return { line: outlook.nowAt !== null ? "Below both compact lines and not growing right now." : "Below its compact line and not growing right now.", tone: "default" };
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** The overview row's short line, or null when there is nothing to add. */
|
|
48
|
+
export function outlookShort(outlook: ContextOutlook, enrolled: boolean): string | null {
|
|
49
|
+
if (outlook.past && !enrolled) return "Past its compact line · not enrolled";
|
|
50
|
+
if (outlook.pastNow) return "Past its mid-turn line · asked now";
|
|
51
|
+
if (outlook.past) {
|
|
52
|
+
return outlook.untilNowMs !== null
|
|
53
|
+
? `Past its end-of-turn line · mid-turn in ~${formatDuration(outlook.untilNowMs)}`
|
|
54
|
+
: "Past its end-of-turn line · asked at the end of a turn";
|
|
55
|
+
}
|
|
56
|
+
if (outlook.untilCompactMs === null) return null;
|
|
57
|
+
return outlook.untilNowMs !== null && outlook.nowAt !== null
|
|
58
|
+
? `${outlook.compactAt}% in ~${formatDuration(outlook.untilCompactMs)} · ${outlook.nowAt}% in ~${formatDuration(outlook.untilNowMs)}`
|
|
59
|
+
: `Reaches it in ~${formatDuration(outlook.untilCompactMs)}`;
|
|
60
|
+
}
|
package/shared/forecast.ts
CHANGED
|
@@ -181,18 +181,29 @@ export function leadWindow(windows: readonly WindowForecast[]): WindowForecast |
|
|
|
181
181
|
export interface ContextReading {
|
|
182
182
|
readonly usedTokens: number;
|
|
183
183
|
readonly maxTokens: number;
|
|
184
|
+
/** The lower line: Smart Compact asks at the end of a turn. */
|
|
184
185
|
readonly compactAtPct: number;
|
|
186
|
+
/** The upper line: Smart Compact asks mid-turn. Absent from older daemons. */
|
|
187
|
+
readonly compactNowAtPct?: number | null;
|
|
185
188
|
readonly growthTokensPerHour?: number | null;
|
|
186
189
|
}
|
|
187
190
|
|
|
188
191
|
export interface ContextOutlook {
|
|
189
192
|
readonly pct: number;
|
|
190
193
|
readonly compactAt: number;
|
|
191
|
-
/** Tokens at the
|
|
194
|
+
/** Tokens at the lower line. */
|
|
192
195
|
readonly compactTokens: number;
|
|
196
|
+
/** Past the lower line. */
|
|
193
197
|
readonly past: boolean;
|
|
194
|
-
/** Time until the
|
|
198
|
+
/** Time until the lower line at the measured growth; null when not growing or already past. */
|
|
195
199
|
readonly untilCompactMs: number | null;
|
|
200
|
+
/** The upper line, or null when the reading has none. */
|
|
201
|
+
readonly nowAt: number | null;
|
|
202
|
+
readonly nowTokens: number | null;
|
|
203
|
+
/** Past the upper line, where the agent is asked mid-turn. */
|
|
204
|
+
readonly pastNow: boolean;
|
|
205
|
+
/** Time until the upper line at the measured growth; null when not growing, already past or unknown. */
|
|
206
|
+
readonly untilNowMs: number | null;
|
|
196
207
|
readonly tone: Tone;
|
|
197
208
|
}
|
|
198
209
|
|
|
@@ -201,17 +212,24 @@ export function contextOutlook(reading: ContextReading): ContextOutlook {
|
|
|
201
212
|
const compactTokens = Math.round((reading.compactAtPct / 100) * reading.maxTokens);
|
|
202
213
|
const past = pct >= reading.compactAtPct;
|
|
203
214
|
const growth = reading.growthTokensPerHour ?? null;
|
|
204
|
-
const
|
|
205
|
-
|
|
215
|
+
const growing = growth !== null && growth > 0;
|
|
216
|
+
const untilCompactMs = past || !growing ? null : ((compactTokens - reading.usedTokens) / growth) * HOUR;
|
|
217
|
+
const nowAt = typeof reading.compactNowAtPct === "number" ? reading.compactNowAtPct : null;
|
|
218
|
+
const nowTokens = nowAt === null ? null : Math.round((nowAt / 100) * reading.maxTokens);
|
|
219
|
+
const pastNow = nowAt !== null && pct >= nowAt;
|
|
220
|
+
const untilNowMs = nowTokens === null || pastNow || !growing ? null : ((nowTokens - reading.usedTokens) / growth) * HOUR;
|
|
221
|
+
// Amber past the lower line, red past the upper one (or a nearly full window).
|
|
222
|
+
const tone = pct >= 90 || pastNow ? "danger" : past ? "warning" : "default";
|
|
223
|
+
return { pct, compactAt: reading.compactAtPct, compactTokens, past, untilCompactMs, nowAt, nowTokens, pastNow, untilNowMs, tone };
|
|
206
224
|
}
|
|
207
225
|
|
|
208
226
|
/**
|
|
209
227
|
* The context ring: a faint full track, an arc for the share of the window used, and a
|
|
210
|
-
* tick
|
|
228
|
+
* tick at each compact line: inward at the end-of-turn line, outward at the mid-turn line. Inline SVG in the surface's own colour, so
|
|
211
229
|
* the host `Icon` can draw it anywhere a Lucide glyph fits. Quantized to 1/48 so a
|
|
212
230
|
* ticking view rarely redraws.
|
|
213
231
|
*/
|
|
214
|
-
export function contextRing(fraction: number, mark: number | null, track = 0.28): string {
|
|
232
|
+
export function contextRing(fraction: number, mark: number | null, track = 0.28, upper: number | null = null): string {
|
|
215
233
|
const steps = 48;
|
|
216
234
|
const f = Math.round(Math.min(1, Math.max(0, fraction)) * steps) / steps;
|
|
217
235
|
const r = 8.5;
|
|
@@ -223,11 +241,19 @@ export function contextRing(fraction: number, mark: number | null, track = 0.28)
|
|
|
223
241
|
const [x, y] = point(f, r);
|
|
224
242
|
arc = `<path d="M${c} ${c - r}A${r} ${r} 0 ${f > 0.5 ? 1 : 0} 1 ${x} ${y}"/>`;
|
|
225
243
|
}
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
244
|
+
// The end-of-turn tick points inward from the ring and the mid-turn tick outward, so the pair sits
|
|
245
|
+
// on opposite sides of the stroke and stays apart at pill size, where two ticks 36° apart that
|
|
246
|
+
// both crossed the ring merged with the arc's end into one hooked smudge.
|
|
247
|
+
const tickAt = (at: number | null, from: number, to: number) => {
|
|
248
|
+
if (at === null || !(at > 0 && at < 1)) return "";
|
|
249
|
+
const [x1, y1] = point(at, from);
|
|
250
|
+
const [x2, y2] = point(at, to);
|
|
251
|
+
return `<path d="M${x1} ${y1}L${x2} ${y2}" stroke-width="1.8"/>`;
|
|
252
|
+
};
|
|
253
|
+
// A muted ring (no lower line) has no upper one either.
|
|
254
|
+
const inward = tickAt(mark, r - 3.8, r);
|
|
255
|
+
// r + 2.6 plus the 0.9 round cap ends exactly at the 24-unit box edge, so the stub is never clipped.
|
|
256
|
+
const outward = upper !== null && upper !== mark ? tickAt(upper, r, r + 2.6) : "";
|
|
257
|
+
const tick = mark === null ? "" : inward + outward;
|
|
232
258
|
return `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round"><circle cx="${c}" cy="${c}" r="${r}" stroke-opacity="${track}"/>${arc}${tick}</svg>`;
|
|
233
259
|
}
|
package/shared/smart-session.ts
CHANGED
|
@@ -155,6 +155,10 @@ export const ContextStatusSchema = z.object({
|
|
|
155
155
|
projectedFullAt: z.string().nullable(),
|
|
156
156
|
/** The percentage at which this window is considered ready to compact. */
|
|
157
157
|
compactAtPct: z.number(),
|
|
158
|
+
/** The upper line: past it, Smart Compact asks mid-turn. Optional for older daemons. */
|
|
159
|
+
compactNowAtPct: z.number().optional(),
|
|
160
|
+
/** When this agent would reach the upper line, at that rate. */
|
|
161
|
+
projectedNowAt: z.string().nullable().optional(),
|
|
158
162
|
/** When this agent's task state file was last written; null when it has none. */
|
|
159
163
|
stateWrittenAt: z.string().nullable().optional(),
|
|
160
164
|
});
|
|
@@ -174,6 +178,8 @@ const ThresholdProfileSchema = z.object({
|
|
|
174
178
|
notice: z.number().finite().min(1).max(99),
|
|
175
179
|
closing: z.number().finite().min(1).max(99),
|
|
176
180
|
compact: z.number().finite().min(1).max(99),
|
|
181
|
+
/** The upper line, where Smart Compact asks mid-turn. */
|
|
182
|
+
now: z.number().finite().min(1).max(99),
|
|
177
183
|
});
|
|
178
184
|
|
|
179
185
|
export const ThresholdsSchema = z.object({
|
package/shared/thresholds.ts
CHANGED
|
@@ -19,8 +19,16 @@ export interface ThresholdProfile {
|
|
|
19
19
|
readonly notice: number;
|
|
20
20
|
/** Percentage at which to stop opening new work. */
|
|
21
21
|
readonly closing: number;
|
|
22
|
-
/**
|
|
22
|
+
/**
|
|
23
|
+
* The lower line, "when convenient": the `Stop` hook asks at the end of a turn.
|
|
24
|
+
* Kept under its old name so saved settings and older daemons still read it.
|
|
25
|
+
*/
|
|
23
26
|
readonly compact: number;
|
|
27
|
+
/**
|
|
28
|
+
* The upper line, "mid-turn": the `PostToolUse` hook asks mid-turn, and the
|
|
29
|
+
* agent is told to finish only the step it is on. Always at or above `compact`.
|
|
30
|
+
*/
|
|
31
|
+
readonly now: number;
|
|
24
32
|
}
|
|
25
33
|
|
|
26
34
|
export interface Thresholds {
|
|
@@ -42,10 +50,21 @@ export interface Thresholds {
|
|
|
42
50
|
*/
|
|
43
51
|
export const DEFAULT_THRESHOLDS: Thresholds = {
|
|
44
52
|
largeWindowFrom: 400_000,
|
|
45
|
-
large: { notice: 15, closing: 22, compact: 30 },
|
|
46
|
-
small: { notice: 60, closing: 75, compact: 85 },
|
|
53
|
+
large: { notice: 15, closing: 22, compact: 30, now: 40 },
|
|
54
|
+
small: { notice: 60, closing: 75, compact: 85, now: 90 },
|
|
47
55
|
};
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Where the upper line goes when a saved profile predates it.
|
|
59
|
+
*
|
|
60
|
+
* The saved lower line is kept as it is, and the upper line keeps the default's
|
|
61
|
+
* distance above it, so a person who moved their compact line to 50% does not get
|
|
62
|
+
* an upper line at 40% — below it — the moment they upgrade.
|
|
63
|
+
*/
|
|
64
|
+
export function defaultNowFor(compact: number, fallback: ThresholdProfile): number {
|
|
65
|
+
return Math.min(99, Math.max(fallback.now, compact + (fallback.now - fallback.compact)));
|
|
66
|
+
}
|
|
67
|
+
|
|
49
68
|
export function profileFor(maxTokens: number, thresholds: Thresholds = DEFAULT_THRESHOLDS): ThresholdProfile {
|
|
50
69
|
return maxTokens >= thresholds.largeWindowFrom ? thresholds.large : thresholds.small;
|
|
51
70
|
}
|
|
@@ -77,7 +96,17 @@ export function normalizeProfile(raw: Partial<ThresholdProfile> | undefined, fal
|
|
|
77
96
|
const compact = clamp(raw?.compact, fallback.compact);
|
|
78
97
|
const closing = Math.min(compact, clamp(raw?.closing, fallback.closing));
|
|
79
98
|
const notice = Math.min(closing, clamp(raw?.notice, fallback.notice));
|
|
80
|
-
|
|
99
|
+
// The upper line must stay above the lower one; a saved file from before it
|
|
100
|
+
// existed gets the default distance above whatever lower line it kept.
|
|
101
|
+
const now = Math.max(Math.min(99, compact + 1), clamp(raw?.now, defaultNowFor(compact, fallback)));
|
|
102
|
+
return { notice, closing, compact, now: Math.min(99, now) };
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** Why a pair of lines cannot be saved, or null when it can. */
|
|
106
|
+
export function linesProblem(lower: number | null, upper: number | null): string | null {
|
|
107
|
+
if (lower === null || upper === null) return "Enter a whole number from 1 to 99.";
|
|
108
|
+
if (upper <= lower) return "The mid-turn line has to be above the end-of-turn line.";
|
|
109
|
+
return null;
|
|
81
110
|
}
|
|
82
111
|
|
|
83
112
|
/**
|
|
@@ -87,9 +116,14 @@ export function normalizeProfile(raw: Partial<ThresholdProfile> | undefined, fal
|
|
|
87
116
|
* compact all fire together. Scale the earlier bands with it when necessary;
|
|
88
117
|
* raising it leaves the existing advance notice intact.
|
|
89
118
|
*/
|
|
90
|
-
export function withCompactThreshold(profile: ThresholdProfile, value: number): ThresholdProfile {
|
|
119
|
+
export function withCompactThreshold(profile: ThresholdProfile, value: number, nowValue?: number): ThresholdProfile {
|
|
91
120
|
const compact = Number.isFinite(value) ? Math.round(Math.min(99, Math.max(1, value))) : profile.compact;
|
|
92
|
-
|
|
121
|
+
// An explicit upper line wins; otherwise it keeps its distance above the lower one.
|
|
122
|
+
const now =
|
|
123
|
+
nowValue !== undefined && Number.isFinite(nowValue)
|
|
124
|
+
? Math.round(Math.min(99, Math.max(1, nowValue)))
|
|
125
|
+
: Math.min(99, Math.max(profile.now, compact + (profile.now - profile.compact)));
|
|
126
|
+
if (compact >= profile.closing) return normalizeProfile({ ...profile, compact, now }, profile);
|
|
93
127
|
|
|
94
128
|
const scale = compact / profile.compact;
|
|
95
129
|
return normalizeProfile(
|
|
@@ -97,6 +131,7 @@ export function withCompactThreshold(profile: ThresholdProfile, value: number):
|
|
|
97
131
|
notice: Math.max(1, Math.round(profile.notice * scale)),
|
|
98
132
|
closing: Math.max(1, Math.round(profile.closing * scale)),
|
|
99
133
|
compact,
|
|
134
|
+
now,
|
|
100
135
|
},
|
|
101
136
|
profile,
|
|
102
137
|
);
|