@prestyj/cli 4.10.3 → 4.12.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 +4 -4
- package/dist/app-sidecar.d.ts +2 -0
- package/dist/app-sidecar.d.ts.map +1 -0
- package/dist/app-sidecar.js +1720 -0
- package/dist/app-sidecar.js.map +1 -0
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +10 -25
- package/dist/cli.js.map +1 -1
- package/dist/config.d.ts +5 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +3 -0
- package/dist/config.js.map +1 -1
- package/dist/core/agent-session.d.ts +162 -0
- package/dist/core/agent-session.d.ts.map +1 -1
- package/dist/core/agent-session.js +544 -23
- package/dist/core/agent-session.js.map +1 -1
- package/dist/core/api-benchmark.d.ts +64 -0
- package/dist/core/api-benchmark.d.ts.map +1 -0
- package/dist/core/api-benchmark.js +381 -0
- package/dist/core/api-benchmark.js.map +1 -0
- package/dist/core/auth-providers.d.ts +18 -0
- package/dist/core/auth-providers.d.ts.map +1 -0
- package/dist/core/auth-providers.js +71 -0
- package/dist/core/auth-providers.js.map +1 -0
- package/dist/core/event-bus.d.ts +6 -0
- package/dist/core/event-bus.d.ts.map +1 -1
- package/dist/core/event-bus.js +1 -0
- package/dist/core/event-bus.js.map +1 -1
- package/dist/core/mcp/client.d.ts +21 -0
- package/dist/core/mcp/client.d.ts.map +1 -1
- package/dist/core/mcp/client.js +167 -5
- package/dist/core/mcp/client.js.map +1 -1
- package/dist/core/mcp/index.d.ts +3 -1
- package/dist/core/mcp/index.d.ts.map +1 -1
- package/dist/core/mcp/index.js +2 -0
- package/dist/core/mcp/index.js.map +1 -1
- package/dist/core/mcp/oauth-provider.d.ts +51 -0
- package/dist/core/mcp/oauth-provider.d.ts.map +1 -0
- package/dist/core/mcp/oauth-provider.js +95 -0
- package/dist/core/mcp/oauth-provider.js.map +1 -0
- package/dist/core/mcp/oauth-store.d.ts +39 -0
- package/dist/core/mcp/oauth-store.d.ts.map +1 -0
- package/dist/core/mcp/oauth-store.js +63 -0
- package/dist/core/mcp/oauth-store.js.map +1 -0
- package/dist/core/mcp/oauth-store.test.d.ts +2 -0
- package/dist/core/mcp/oauth-store.test.d.ts.map +1 -0
- package/dist/core/mcp/oauth-store.test.js +94 -0
- package/dist/core/mcp/oauth-store.test.js.map +1 -0
- package/dist/core/process-manager.d.ts.map +1 -1
- package/dist/core/process-manager.js +5 -1
- package/dist/core/process-manager.js.map +1 -1
- package/dist/core/project-discovery.d.ts +41 -0
- package/dist/core/project-discovery.d.ts.map +1 -0
- package/dist/core/project-discovery.js +441 -0
- package/dist/core/project-discovery.js.map +1 -0
- package/dist/core/prompt-commands.d.ts.map +1 -1
- package/dist/core/prompt-commands.js +203 -0
- package/dist/core/prompt-commands.js.map +1 -1
- package/dist/core/prompt-commands.test.js +39 -0
- package/dist/core/prompt-commands.test.js.map +1 -1
- package/dist/core/radio.d.ts +44 -0
- package/dist/core/radio.d.ts.map +1 -0
- package/dist/core/radio.js +250 -0
- package/dist/core/radio.js.map +1 -0
- package/dist/core/resolve-start.d.ts +29 -0
- package/dist/core/resolve-start.d.ts.map +1 -0
- package/dist/core/resolve-start.js +35 -0
- package/dist/core/resolve-start.js.map +1 -0
- package/dist/core/resolve-start.test.d.ts +2 -0
- package/dist/core/resolve-start.test.d.ts.map +1 -0
- package/dist/core/resolve-start.test.js +66 -0
- package/dist/core/resolve-start.test.js.map +1 -0
- package/dist/core/settings-manager.d.ts +5 -1
- package/dist/core/settings-manager.d.ts.map +1 -1
- package/dist/core/settings-manager.js +5 -0
- package/dist/core/settings-manager.js.map +1 -1
- package/dist/core/shell-path.d.ts +12 -0
- package/dist/core/shell-path.d.ts.map +1 -0
- package/dist/core/shell-path.js +166 -0
- package/dist/core/shell-path.js.map +1 -0
- package/dist/core/shell.d.ts +51 -0
- package/dist/core/shell.d.ts.map +1 -0
- package/dist/core/shell.js +82 -0
- package/dist/core/shell.js.map +1 -0
- package/dist/core/shell.test.d.ts +2 -0
- package/dist/core/shell.test.d.ts.map +1 -0
- package/dist/core/shell.test.js +87 -0
- package/dist/core/shell.test.js.map +1 -0
- package/dist/core/slash-commands.d.ts.map +1 -1
- package/dist/core/slash-commands.js +10 -0
- package/dist/core/slash-commands.js.map +1 -1
- package/dist/core/speed-benchmark.d.ts +133 -0
- package/dist/core/speed-benchmark.d.ts.map +1 -0
- package/dist/core/speed-benchmark.js +410 -0
- package/dist/core/speed-benchmark.js.map +1 -0
- package/dist/core/speed-benchmark.test.d.ts +2 -0
- package/dist/core/speed-benchmark.test.d.ts.map +1 -0
- package/dist/core/speed-benchmark.test.js +97 -0
- package/dist/core/speed-benchmark.test.js.map +1 -0
- package/dist/core/telegram-config.d.ts +21 -0
- package/dist/core/telegram-config.d.ts.map +1 -0
- package/dist/core/telegram-config.js +52 -0
- package/dist/core/telegram-config.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/interactive.d.ts.map +1 -1
- package/dist/interactive.js +4 -3
- package/dist/interactive.js.map +1 -1
- package/dist/modes/serve-mode.d.ts +24 -0
- package/dist/modes/serve-mode.d.ts.map +1 -1
- package/dist/modes/serve-mode.js +54 -19
- package/dist/modes/serve-mode.js.map +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +17 -1
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/generate-image.d.ts +39 -0
- package/dist/tools/generate-image.d.ts.map +1 -0
- package/dist/tools/generate-image.js +301 -0
- package/dist/tools/generate-image.js.map +1 -0
- package/dist/tools/generate-image.test.d.ts +2 -0
- package/dist/tools/generate-image.test.d.ts.map +1 -0
- package/dist/tools/generate-image.test.js +223 -0
- package/dist/tools/generate-image.test.js.map +1 -0
- package/dist/tools/goal-mode.test.js +1 -1
- package/dist/tools/goal-mode.test.js.map +1 -1
- package/dist/tools/goals.d.ts +7 -7
- package/dist/tools/index.d.ts +15 -1
- package/dist/tools/index.d.ts.map +1 -1
- package/dist/tools/index.js +17 -2
- package/dist/tools/index.js.map +1 -1
- package/dist/tools/plan-mode.test.js +5 -5
- package/dist/tools/plan-mode.test.js.map +1 -1
- package/dist/tools/prompt-hints.d.ts.map +1 -1
- package/dist/tools/prompt-hints.js +2 -0
- package/dist/tools/prompt-hints.js.map +1 -1
- package/dist/tools/safe-env.d.ts.map +1 -1
- package/dist/tools/safe-env.js +27 -0
- package/dist/tools/safe-env.js.map +1 -1
- package/dist/tools/subagent.d.ts +2 -4
- package/dist/tools/subagent.d.ts.map +1 -1
- package/dist/tools/subagent.js +24 -23
- package/dist/tools/subagent.js.map +1 -1
- package/dist/tools/tasks.d.ts +1 -7
- package/dist/tools/tasks.d.ts.map +1 -1
- package/dist/tools/tasks.js +4 -31
- package/dist/tools/tasks.js.map +1 -1
- package/dist/ui/App.d.ts +1 -1
- package/dist/ui/App.d.ts.map +1 -1
- package/dist/ui/hooks/usePixelFixFlow.d.ts +1 -1
- package/dist/ui/hooks/usePixelFixFlow.d.ts.map +1 -1
- package/dist/ui/hooks/usePixelFixFlow.js +1 -1
- package/dist/ui/hooks/usePixelFixFlow.js.map +1 -1
- package/dist/ui/hooks/useTranscriptHistory.d.ts +2 -0
- package/dist/ui/hooks/useTranscriptHistory.d.ts.map +1 -1
- package/dist/ui/hooks/useTranscriptHistory.js +3 -2
- package/dist/ui/hooks/useTranscriptHistory.js.map +1 -1
- package/dist/ui/long-prompt-regression-harness.test.js +8 -8
- package/dist/ui/render.d.ts +1 -1
- package/dist/ui/render.d.ts.map +1 -1
- package/dist/ui/render.js +60 -8
- package/dist/ui/render.js.map +1 -1
- package/dist/ui/startup-banner-order.test.js +1 -1
- package/dist/ui/startup-banner-order.test.js.map +1 -1
- package/dist/ui/terminal-history-repaint.test.d.ts +2 -0
- package/dist/ui/terminal-history-repaint.test.d.ts.map +1 -0
- package/dist/ui/terminal-history-repaint.test.js +73 -0
- package/dist/ui/terminal-history-repaint.test.js.map +1 -0
- package/dist/ui/terminal-history.d.ts +1 -0
- package/dist/ui/terminal-history.d.ts.map +1 -1
- package/dist/ui/terminal-history.js +31 -2
- package/dist/ui/terminal-history.js.map +1 -1
- package/dist/utils/git.d.ts +6 -0
- package/dist/utils/git.d.ts.map +1 -1
- package/dist/utils/git.js +12 -0
- package/dist/utils/git.js.map +1 -1
- package/package.json +6 -6
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { agentLoop, isAbortError } from "@prestyj/agent";
|
|
2
|
-
import { ProviderError } from "@prestyj/ai";
|
|
2
|
+
import { ProviderError, prewarmAnthropicCache, } from "@prestyj/ai";
|
|
3
3
|
import { EventBus } from "./event-bus.js";
|
|
4
4
|
import { SlashCommandRegistry, createBuiltinCommands, } from "./slash-commands.js";
|
|
5
5
|
import { PROMPT_COMMANDS, getPromptCommand } from "./prompt-commands.js";
|
|
@@ -16,10 +16,14 @@ import { discoverSkills } from "./skills.js";
|
|
|
16
16
|
import { ensureAppDirs } from "../config.js";
|
|
17
17
|
import { buildSystemPrompt } from "../system-prompt.js";
|
|
18
18
|
import { createTools, createWebSearchTool, } from "../tools/index.js";
|
|
19
|
-
import { MCPClientManager,
|
|
19
|
+
import { MCPClientManager, getAllMcpServers } from "./mcp/index.js";
|
|
20
20
|
import { log } from "./logger.js";
|
|
21
21
|
import { setEstimatorModel } from "./compaction/token-estimator.js";
|
|
22
22
|
import { discoverAgents } from "./agents.js";
|
|
23
|
+
import { generateSessionTitle } from "../utils/session-title.js";
|
|
24
|
+
import { evaluateIdealReview, buildIdealReviewMessage, } from "./ideal-review.js";
|
|
25
|
+
import { evaluateLoopBreak, buildLoopBreakMessage, toolCallSignature, detectTextRepetition, } from "./loop-breaker.js";
|
|
26
|
+
import { buildRegroundingMessage } from "./regrounding.js";
|
|
23
27
|
import crypto from "node:crypto";
|
|
24
28
|
import fs from "node:fs/promises";
|
|
25
29
|
import path from "node:path";
|
|
@@ -33,8 +37,44 @@ export class AgentSession {
|
|
|
33
37
|
extensionLoader = new ExtensionLoader();
|
|
34
38
|
messages = [];
|
|
35
39
|
tools = [];
|
|
40
|
+
/** Rebuilds the read tool for a new model (video byte cap is baked in at
|
|
41
|
+
* creation). Called from switchModel so video-capable models get the
|
|
42
|
+
* read-tool's native-video path after a mid-session model change. */
|
|
43
|
+
rebuildReadTool;
|
|
36
44
|
skills = [];
|
|
37
45
|
cacheKeyLogged = false;
|
|
46
|
+
// ── Self-correction hook state (mirrors the TUI's useAgentLoop refs) ──
|
|
47
|
+
// Reset at the start of every run; observed from the event stream; read by
|
|
48
|
+
// the loop-break (mid-loop) and ideal-review (pre-stop) callbacks.
|
|
49
|
+
hookStats = {
|
|
50
|
+
changedLines: 0,
|
|
51
|
+
toolCalls: 0,
|
|
52
|
+
toolFailures: 0,
|
|
53
|
+
turns: 0,
|
|
54
|
+
writeCalls: 0,
|
|
55
|
+
editCalls: 0,
|
|
56
|
+
bashCalls: 0,
|
|
57
|
+
};
|
|
58
|
+
hookText = "";
|
|
59
|
+
hookConsecutiveFailures = 0;
|
|
60
|
+
hookMaxSignatureRepeats = 0;
|
|
61
|
+
hookMaxSameFileEdits = 0;
|
|
62
|
+
hookSignatureCounts = new Map();
|
|
63
|
+
hookFileEditCounts = new Map();
|
|
64
|
+
hookToolCalls = new Map();
|
|
65
|
+
idealReviewInjected = false;
|
|
66
|
+
loopBreakInjected = false;
|
|
67
|
+
regroundingInjected = false;
|
|
68
|
+
compactionOccurred = false;
|
|
69
|
+
originalRequest = "";
|
|
70
|
+
/** True after the cache has been pre-warmed for this session. Ensures we only
|
|
71
|
+
* fire the warm-up call once (before the first real turn). */
|
|
72
|
+
cachePrewarmed = false;
|
|
73
|
+
// Messages queued by the user while a run is in flight. Drained at the
|
|
74
|
+
// mid-loop steering boundary (user steering wins over the hooks), mirroring
|
|
75
|
+
// the TUI's getSteeringMessages. Each entry carries its own attachments so a
|
|
76
|
+
// user can queue media (images/video/files) mid-run, not just plain text.
|
|
77
|
+
userQueue = [];
|
|
38
78
|
processManager;
|
|
39
79
|
lspManager;
|
|
40
80
|
mcpManager;
|
|
@@ -45,6 +85,12 @@ export class AgentSession {
|
|
|
45
85
|
maxTokens;
|
|
46
86
|
thinkingLevel;
|
|
47
87
|
customSystemPrompt;
|
|
88
|
+
/** Shared with the tool layer so plan-mode restrictions read live state. */
|
|
89
|
+
planModeRef = { current: false };
|
|
90
|
+
/** Path of the approved plan currently being implemented, or undefined. When
|
|
91
|
+
* set, the system prompt carries the `[DONE:n]` progress contract so the
|
|
92
|
+
* model emits step-completion markers the UI's plan-progress widget reads. */
|
|
93
|
+
approvedPlanPath;
|
|
48
94
|
sessionId = "";
|
|
49
95
|
sessionPath = "";
|
|
50
96
|
lastPersistedIndex = 0;
|
|
@@ -57,10 +103,27 @@ export class AgentSession {
|
|
|
57
103
|
this.model = options.model;
|
|
58
104
|
this.cwd = options.cwd;
|
|
59
105
|
this.baseUrl = options.baseUrl;
|
|
60
|
-
this.maxTokens =
|
|
106
|
+
this.maxTokens = this.resolveMaxTokens(options.model);
|
|
61
107
|
this.thinkingLevel = options.thinkingLevel;
|
|
62
108
|
this.customSystemPrompt = options.systemPrompt;
|
|
63
109
|
}
|
|
110
|
+
/**
|
|
111
|
+
* Derive the output-token cap for a model. Follows the active model's
|
|
112
|
+
* `maxOutputTokens` so a session booted on a large-output model (e.g. Kimi's
|
|
113
|
+
* 256K) doesn't carry that cap to a smaller one (e.g. Opus's 128K) after a
|
|
114
|
+
* model switch — that mismatch surfaces from the provider as
|
|
115
|
+
* `max_tokens: 262144 > 128000, which is the maximum allowed …`. An explicit
|
|
116
|
+
* `maxTokens` override is honored but clamped to the model's ceiling.
|
|
117
|
+
*/
|
|
118
|
+
resolveMaxTokens(modelId) {
|
|
119
|
+
const modelInfo = getModel(modelId);
|
|
120
|
+
if (this.opts.maxTokens) {
|
|
121
|
+
return modelInfo
|
|
122
|
+
? Math.min(this.opts.maxTokens, modelInfo.maxOutputTokens)
|
|
123
|
+
: this.opts.maxTokens;
|
|
124
|
+
}
|
|
125
|
+
return modelInfo?.maxOutputTokens ?? 16384;
|
|
126
|
+
}
|
|
64
127
|
async initialize() {
|
|
65
128
|
// Set model for accurate token estimation
|
|
66
129
|
setEstimatorModel(this.model);
|
|
@@ -87,17 +150,30 @@ export class AgentSession {
|
|
|
87
150
|
globalAgentsDir: paths.agentsDir,
|
|
88
151
|
projectDir: this.cwd,
|
|
89
152
|
});
|
|
90
|
-
const { tools, processManager, lspManager } = createTools(this.cwd, {
|
|
153
|
+
const { tools, processManager, rebuildReadTool, lspManager } = await createTools(this.cwd, {
|
|
91
154
|
agents,
|
|
92
155
|
skills: this.skills,
|
|
93
156
|
provider: this.provider,
|
|
94
157
|
model: this.model,
|
|
95
158
|
lspDiagnostics: this.settingsManager.get("lspDiagnostics"),
|
|
96
|
-
|
|
97
|
-
//
|
|
159
|
+
authStorage: this.authStorage,
|
|
160
|
+
// Lazy — sessionId/model/provider can change after createTools() runs, so
|
|
161
|
+
// sub-agent spawns read the current parent state at execution time.
|
|
162
|
+
getProvider: () => this.provider,
|
|
163
|
+
getModel: () => this.model,
|
|
98
164
|
getCacheKey: () => this.getPromptCacheKey(),
|
|
165
|
+
// Plan mode: only wired when the host supplies callbacks. The ref is
|
|
166
|
+
// shared so bash/edit/write enforce read-only restrictions live.
|
|
167
|
+
...(this.opts.onEnterPlan || this.opts.onExitPlan
|
|
168
|
+
? {
|
|
169
|
+
planModeRef: this.planModeRef,
|
|
170
|
+
onEnterPlan: this.opts.onEnterPlan,
|
|
171
|
+
onExitPlan: this.opts.onExitPlan,
|
|
172
|
+
}
|
|
173
|
+
: {}),
|
|
99
174
|
});
|
|
100
175
|
this.tools = tools;
|
|
176
|
+
this.rebuildReadTool = rebuildReadTool;
|
|
101
177
|
this.processManager = processManager;
|
|
102
178
|
this.lspManager = lspManager;
|
|
103
179
|
// Connect MCP servers (non-blocking — failures are logged and skipped).
|
|
@@ -108,23 +184,20 @@ export class AgentSession {
|
|
|
108
184
|
// skip user servers: a single `/expand` fans out 5 parallel subagents, and
|
|
109
185
|
// cold-spawning every user MCP server (e.g. npx-based remotes) per
|
|
110
186
|
// short-lived child would be slow and could hammer rate-limited endpoints.
|
|
187
|
+
//
|
|
188
|
+
// The connect attempt itself can block for up to the per-server connect
|
|
189
|
+
// timeout (~30s) — a slow stdio server such as a first-run
|
|
190
|
+
// `npx -y @playwright/mcp` download stalls here. When the host gates its own
|
|
191
|
+
// readiness on initialize() (the ezcoder-app sidecar can't emit its
|
|
192
|
+
// listening handshake until this resolves), `backgroundMcpConnect` moves the
|
|
193
|
+
// connect off the critical path so the session becomes usable immediately
|
|
194
|
+
// and tools are appended whenever the servers come up.
|
|
111
195
|
this.mcpManager = new MCPClientManager();
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
if (this.provider === "glm") {
|
|
115
|
-
try {
|
|
116
|
-
const glmCreds = await this.authStorage.resolveCredentials("glm");
|
|
117
|
-
apiKey = glmCreds.accessToken;
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
// GLM not configured — skip Z.AI MCP servers
|
|
121
|
-
}
|
|
122
|
-
}
|
|
123
|
-
const mcpTools = await this.mcpManager.connectAll(getMCPServers(this.provider, apiKey));
|
|
124
|
-
this.tools.push(...mcpTools);
|
|
196
|
+
if (this.opts.backgroundMcpConnect) {
|
|
197
|
+
void this.connectMcpServers();
|
|
125
198
|
}
|
|
126
|
-
|
|
127
|
-
|
|
199
|
+
else {
|
|
200
|
+
await this.connectMcpServers();
|
|
128
201
|
}
|
|
129
202
|
const basePrompt = this.customSystemPrompt ??
|
|
130
203
|
(await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider));
|
|
@@ -194,6 +267,47 @@ export class AgentSession {
|
|
|
194
267
|
await this.extensionLoader.loadAll(paths.extensionsDir, extContext);
|
|
195
268
|
this.eventBus.emit("session_start", { sessionId: this.sessionId });
|
|
196
269
|
}
|
|
270
|
+
/**
|
|
271
|
+
* Connect all configured MCP servers and append their tools to `this.tools`.
|
|
272
|
+
* Resolves the GLM api key first (Z.AI's bundled servers need it). Never
|
|
273
|
+
* throws — a failed connect is logged and skipped — so it is safe to either
|
|
274
|
+
* `await` (CLI: tools ready before the first turn) or fire-and-forget
|
|
275
|
+
* (sidecar: `backgroundMcpConnect`, so a slow stdio server can't stall
|
|
276
|
+
* startup). Tools are pushed onto the live array the agent loop reads each
|
|
277
|
+
* turn, so background-connected servers become available on the next prompt.
|
|
278
|
+
*/
|
|
279
|
+
async connectMcpServers() {
|
|
280
|
+
if (!this.mcpManager)
|
|
281
|
+
return;
|
|
282
|
+
try {
|
|
283
|
+
let apiKey;
|
|
284
|
+
if (this.provider === "glm") {
|
|
285
|
+
try {
|
|
286
|
+
const glmCreds = await this.authStorage.resolveCredentials("glm");
|
|
287
|
+
apiKey = glmCreds.accessToken;
|
|
288
|
+
}
|
|
289
|
+
catch {
|
|
290
|
+
// GLM not configured — skip Z.AI MCP servers
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
const mcpTools = await this.mcpManager.connectAll(await getAllMcpServers(this.provider, apiKey, this.cwd));
|
|
294
|
+
this.tools.push(...mcpTools);
|
|
295
|
+
// Background connect resolves AFTER initialize() has already built the
|
|
296
|
+
// system prompt (the default path awaits this before buildSystemPrompt,
|
|
297
|
+
// so its prompt already lists the tools). Refresh messages[0] so the
|
|
298
|
+
// model is also told about the MCP tools by name on its next turn —
|
|
299
|
+
// mirrors the TUI's replaceSystemPrompt after connectInitialMcpTools.
|
|
300
|
+
// Safe ordering: this method's first await yields before initialize()
|
|
301
|
+
// sets `messages`, and connectAll (process spawn / network) always
|
|
302
|
+
// resolves long after the local-only remainder of init has finished.
|
|
303
|
+
if (this.opts.backgroundMcpConnect && mcpTools.length > 0) {
|
|
304
|
+
await this.rebuildSystemPromptInPlace();
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
catch (err) {
|
|
308
|
+
log("WARN", "mcp", `MCP initialization failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
309
|
+
}
|
|
310
|
+
}
|
|
197
311
|
/**
|
|
198
312
|
* Process user input. Handles slash commands or runs agent loop.
|
|
199
313
|
*/
|
|
@@ -235,6 +349,225 @@ export class AgentSession {
|
|
|
235
349
|
this.lastPersistedIndex = this.messages.length;
|
|
236
350
|
await this.runLoop();
|
|
237
351
|
}
|
|
352
|
+
/**
|
|
353
|
+
* Prompt with multimodal attachments (images / videos) alongside optional
|
|
354
|
+
* text. Images and videos become native content blocks the model can see;
|
|
355
|
+
* non-media files are surfaced as a text note with their saved path so the
|
|
356
|
+
* agent can open them with its tools. Slash-command parsing is skipped —
|
|
357
|
+
* attachments are always a direct conversational turn.
|
|
358
|
+
*/
|
|
359
|
+
async promptWithAttachments(text, attachments) {
|
|
360
|
+
const parts = this.buildAttachmentParts(text, attachments);
|
|
361
|
+
if (parts.length === 0)
|
|
362
|
+
return;
|
|
363
|
+
const userMessage = { role: "user", content: parts };
|
|
364
|
+
this.messages.push(userMessage);
|
|
365
|
+
await this.persistMessage(userMessage);
|
|
366
|
+
this.lastPersistedIndex = this.messages.length;
|
|
367
|
+
await this.runLoop();
|
|
368
|
+
}
|
|
369
|
+
/**
|
|
370
|
+
* Build the native content blocks (text + image/video notes + file notes) for
|
|
371
|
+
* a user message with attachments. Shared by {@link promptWithAttachments} and
|
|
372
|
+
* the mid-run steering drain so queued media is delivered identically.
|
|
373
|
+
*/
|
|
374
|
+
buildAttachmentParts(text, attachments) {
|
|
375
|
+
const parts = [];
|
|
376
|
+
const fileNotes = [];
|
|
377
|
+
const modelSupportsVideo = getModel(this.model)?.supportsVideo ?? false;
|
|
378
|
+
for (const a of attachments) {
|
|
379
|
+
if (a.kind === "image") {
|
|
380
|
+
parts.push({ type: "image", mediaType: a.mediaType, data: a.data });
|
|
381
|
+
if (a.path) {
|
|
382
|
+
parts.push({ type: "text", text: `[Image saved at ${a.path}]` });
|
|
383
|
+
}
|
|
384
|
+
}
|
|
385
|
+
else if (a.kind === "video") {
|
|
386
|
+
// Mirror the CLI's buildUserContentWithAttachments: never send inline
|
|
387
|
+
// VideoContent in the user message. Video-capable models (Kimi/Gemini/
|
|
388
|
+
// MiniMax) watch video via the read tool, which auto-compresses to the
|
|
389
|
+
// model's byte cap and delivers it in the provider's required shape.
|
|
390
|
+
// Non-video models get a plain note so they know to use ffmpeg. The file
|
|
391
|
+
// was already saved to disk by prepareAttachments in the sidecar.
|
|
392
|
+
if (modelSupportsVideo && a.path) {
|
|
393
|
+
parts.push({
|
|
394
|
+
type: "text",
|
|
395
|
+
text: `The user attached a video at ${a.path}. You CAN watch it: call the read tool ` +
|
|
396
|
+
`on this exact path now, then answer based on what you see. Do not say you ` +
|
|
397
|
+
`cannot watch video — reading the file lets you analyze it.`,
|
|
398
|
+
});
|
|
399
|
+
}
|
|
400
|
+
else if (a.path) {
|
|
401
|
+
parts.push({
|
|
402
|
+
type: "text",
|
|
403
|
+
text: `[User attached a video file at ${a.path}. You cannot watch video directly; ` +
|
|
404
|
+
`if needed, use ffmpeg to extract frames or audio.]`,
|
|
405
|
+
});
|
|
406
|
+
}
|
|
407
|
+
else {
|
|
408
|
+
parts.push({
|
|
409
|
+
type: "text",
|
|
410
|
+
text: `[User attached a video file but it could not be saved for analysis.]`,
|
|
411
|
+
});
|
|
412
|
+
}
|
|
413
|
+
}
|
|
414
|
+
else if (a.path) {
|
|
415
|
+
fileNotes.push(`- ${a.name} (saved at ${a.path})`);
|
|
416
|
+
}
|
|
417
|
+
}
|
|
418
|
+
const textParts = [];
|
|
419
|
+
if (text.trim())
|
|
420
|
+
textParts.push(text.trim());
|
|
421
|
+
if (fileNotes.length > 0) {
|
|
422
|
+
textParts.push(`Attached files (inspect with your tools):\n${fileNotes.join("\n")}`);
|
|
423
|
+
}
|
|
424
|
+
if (textParts.length > 0)
|
|
425
|
+
parts.unshift({ type: "text", text: textParts.join("\n\n") });
|
|
426
|
+
return parts;
|
|
427
|
+
}
|
|
428
|
+
/**
|
|
429
|
+
* Reset per-run self-correction hook state. Mirrors the TUI's run_start
|
|
430
|
+
* resets so each run evaluates the hooks from a clean slate. `originalRequest`
|
|
431
|
+
* is the verbatim user ask, pinned for post-compaction re-grounding.
|
|
432
|
+
*/
|
|
433
|
+
resetHookState(originalRequest) {
|
|
434
|
+
this.hookStats = {
|
|
435
|
+
changedLines: 0,
|
|
436
|
+
toolCalls: 0,
|
|
437
|
+
toolFailures: 0,
|
|
438
|
+
turns: 0,
|
|
439
|
+
writeCalls: 0,
|
|
440
|
+
editCalls: 0,
|
|
441
|
+
bashCalls: 0,
|
|
442
|
+
};
|
|
443
|
+
this.hookText = "";
|
|
444
|
+
this.hookConsecutiveFailures = 0;
|
|
445
|
+
this.hookMaxSignatureRepeats = 0;
|
|
446
|
+
this.hookMaxSameFileEdits = 0;
|
|
447
|
+
this.hookSignatureCounts.clear();
|
|
448
|
+
this.hookFileEditCounts.clear();
|
|
449
|
+
this.hookToolCalls.clear();
|
|
450
|
+
this.idealReviewInjected = false;
|
|
451
|
+
this.loopBreakInjected = false;
|
|
452
|
+
this.regroundingInjected = false;
|
|
453
|
+
this.compactionOccurred = false;
|
|
454
|
+
this.originalRequest = originalRequest;
|
|
455
|
+
}
|
|
456
|
+
/**
|
|
457
|
+
* Fold one agent event into the hook stat accumulators. Pure bookkeeping —
|
|
458
|
+
* the same signals the TUI's useAgentLoop collects, so the loop-break and
|
|
459
|
+
* ideal-review decisions match across the CLI and the app.
|
|
460
|
+
*/
|
|
461
|
+
trackHookEvent(event) {
|
|
462
|
+
switch (event.type) {
|
|
463
|
+
case "text_delta":
|
|
464
|
+
this.hookText += event.text;
|
|
465
|
+
break;
|
|
466
|
+
case "tool_call_start":
|
|
467
|
+
this.hookToolCalls.set(event.toolCallId, { name: event.name, args: event.args ?? {} });
|
|
468
|
+
break;
|
|
469
|
+
case "tool_call_end": {
|
|
470
|
+
const call = this.hookToolCalls.get(event.toolCallId);
|
|
471
|
+
const name = call?.name ?? "";
|
|
472
|
+
const args = call?.args;
|
|
473
|
+
this.hookStats.toolCalls += 1;
|
|
474
|
+
if (event.isError)
|
|
475
|
+
this.hookStats.toolFailures += 1;
|
|
476
|
+
if (name === "write")
|
|
477
|
+
this.hookStats.writeCalls += 1;
|
|
478
|
+
if (name === "edit")
|
|
479
|
+
this.hookStats.editCalls += 1;
|
|
480
|
+
if (name === "bash")
|
|
481
|
+
this.hookStats.bashCalls += 1;
|
|
482
|
+
this.hookConsecutiveFailures = event.isError ? this.hookConsecutiveFailures + 1 : 0;
|
|
483
|
+
const sig = toolCallSignature(name, args);
|
|
484
|
+
const sigNext = (this.hookSignatureCounts.get(sig) ?? 0) + 1;
|
|
485
|
+
this.hookSignatureCounts.set(sig, sigNext);
|
|
486
|
+
if (sigNext > this.hookMaxSignatureRepeats)
|
|
487
|
+
this.hookMaxSignatureRepeats = sigNext;
|
|
488
|
+
if ((name === "edit" || name === "write") && args) {
|
|
489
|
+
const filePath = args.file_path;
|
|
490
|
+
if (typeof filePath === "string") {
|
|
491
|
+
const fileNext = (this.hookFileEditCounts.get(filePath) ?? 0) + 1;
|
|
492
|
+
this.hookFileEditCounts.set(filePath, fileNext);
|
|
493
|
+
if (fileNext > this.hookMaxSameFileEdits)
|
|
494
|
+
this.hookMaxSameFileEdits = fileNext;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
if (name === "edit" && !event.isError) {
|
|
498
|
+
const diff = event.details?.diff ?? event.result;
|
|
499
|
+
const added = (diff.match(/^\+[^+]/gm) ?? []).length;
|
|
500
|
+
const removed = (diff.match(/^-[^-]/gm) ?? []).length;
|
|
501
|
+
this.hookStats.changedLines += added + removed;
|
|
502
|
+
}
|
|
503
|
+
break;
|
|
504
|
+
}
|
|
505
|
+
case "turn_end":
|
|
506
|
+
this.hookStats.turns = event.turn;
|
|
507
|
+
break;
|
|
508
|
+
}
|
|
509
|
+
}
|
|
510
|
+
/**
|
|
511
|
+
* Mid-loop steering hook: fires the loop-breaker when the agent looks stuck,
|
|
512
|
+
* then post-compaction re-grounding. At most one of each per run. Mirrors the
|
|
513
|
+
* TUI's getSteeringMessages ordering (minus user steering, which the app
|
|
514
|
+
* delivers as normal prompts).
|
|
515
|
+
*/
|
|
516
|
+
getHookSteeringMessages() {
|
|
517
|
+
// User steering wins: drain any messages queued during this run first so the
|
|
518
|
+
// agent sees them mid-loop instead of after it stops.
|
|
519
|
+
if (this.userQueue.length > 0) {
|
|
520
|
+
const queued = this.userQueue.splice(0);
|
|
521
|
+
// Plain-text-only queue: keep the simple merged-string message.
|
|
522
|
+
if (queued.every((m) => m.attachments.length === 0)) {
|
|
523
|
+
const merged = queued.map((m) => m.text).join("\n\n");
|
|
524
|
+
return [{ role: "user", content: merged }];
|
|
525
|
+
}
|
|
526
|
+
// Any queued attachments → deliver one user message with text + media
|
|
527
|
+
// blocks built the same way as a non-queued attachment prompt.
|
|
528
|
+
const parts = [];
|
|
529
|
+
for (const m of queued)
|
|
530
|
+
parts.push(...this.buildAttachmentParts(m.text, m.attachments));
|
|
531
|
+
return [{ role: "user", content: parts }];
|
|
532
|
+
}
|
|
533
|
+
if (!this.settingsManager.get("idealReviewEnabled"))
|
|
534
|
+
return null;
|
|
535
|
+
if (!this.loopBreakInjected) {
|
|
536
|
+
const decision = evaluateLoopBreak({
|
|
537
|
+
consecutiveFailures: this.hookConsecutiveFailures,
|
|
538
|
+
maxSignatureRepeats: this.hookMaxSignatureRepeats,
|
|
539
|
+
maxSameFileEdits: this.hookMaxSameFileEdits,
|
|
540
|
+
textRepetitionDetected: detectTextRepetition(this.hookText),
|
|
541
|
+
});
|
|
542
|
+
if (decision.shouldBreak) {
|
|
543
|
+
this.loopBreakInjected = true;
|
|
544
|
+
this.eventBus.emit("hook", { kind: "loop_break" });
|
|
545
|
+
return [buildLoopBreakMessage(decision.reasons)];
|
|
546
|
+
}
|
|
547
|
+
}
|
|
548
|
+
if (!this.regroundingInjected && this.compactionOccurred) {
|
|
549
|
+
this.regroundingInjected = true;
|
|
550
|
+
this.eventBus.emit("hook", { kind: "regrounding" });
|
|
551
|
+
return [buildRegroundingMessage(this.originalRequest)];
|
|
552
|
+
}
|
|
553
|
+
return null;
|
|
554
|
+
}
|
|
555
|
+
/**
|
|
556
|
+
* Pre-stop follow-up hook: runs the ideal review once, when the agent would
|
|
557
|
+
* otherwise finish and the change set is substantial enough to warrant it.
|
|
558
|
+
*/
|
|
559
|
+
getHookFollowUpMessages() {
|
|
560
|
+
if (!this.settingsManager.get("idealReviewEnabled"))
|
|
561
|
+
return null;
|
|
562
|
+
if (this.idealReviewInjected)
|
|
563
|
+
return null;
|
|
564
|
+
const decision = evaluateIdealReview(this.hookStats);
|
|
565
|
+
if (!decision.shouldReview)
|
|
566
|
+
return null;
|
|
567
|
+
this.idealReviewInjected = true;
|
|
568
|
+
this.eventBus.emit("hook", { kind: "ideal" });
|
|
569
|
+
return [buildIdealReviewMessage(decision.reasons)];
|
|
570
|
+
}
|
|
238
571
|
/** Auto-compact if needed, run agent loop with auth retry, and persist messages. */
|
|
239
572
|
async runLoop() {
|
|
240
573
|
// One-shot cache-key marker per session so turn_end cacheRead numbers
|
|
@@ -249,6 +582,11 @@ export class AgentSession {
|
|
|
249
582
|
transient: String(!!this.opts.transient),
|
|
250
583
|
});
|
|
251
584
|
}
|
|
585
|
+
// Reset self-correction hook state for this run; pin the latest user message
|
|
586
|
+
// as the verbatim original request for post-compaction re-grounding.
|
|
587
|
+
const lastUser = [...this.messages].reverse().find((m) => m.role === "user");
|
|
588
|
+
const originalRequest = typeof lastUser?.content === "string" ? lastUser.content : "";
|
|
589
|
+
this.resetHookState(originalRequest);
|
|
252
590
|
// Resolve OAuth credentials and run agent loop.
|
|
253
591
|
// On 401, force-refresh the token and retry once — the provider may have
|
|
254
592
|
// revoked the token server-side before the stored expiry (e.g. after a restart).
|
|
@@ -264,6 +602,8 @@ export class AgentSession {
|
|
|
264
602
|
const threshold = this.settingsManager.get("compactThreshold");
|
|
265
603
|
if (shouldCompact(this.messages, contextWindow, threshold)) {
|
|
266
604
|
await this.compact(creds);
|
|
605
|
+
// Re-grounding hook keys off this — the context was just summarized.
|
|
606
|
+
this.compactionOccurred = true;
|
|
267
607
|
}
|
|
268
608
|
}
|
|
269
609
|
const userAgent = this.provider === "anthropic" ? await getClaudeCliUserAgent() : undefined;
|
|
@@ -288,7 +628,9 @@ export class AgentSession {
|
|
|
288
628
|
defaultHeaders: this.provider === "moonshot" && isKimiCodingEndpoint(effectiveBaseUrl)
|
|
289
629
|
? kimiCodingHeaders()
|
|
290
630
|
: undefined,
|
|
291
|
-
|
|
631
|
+
// speedProfile "optimized": 1-h cache TTL (survives turns >5 min apart)
|
|
632
|
+
// + pre-warm before the first turn. "baseline": current 5-min default.
|
|
633
|
+
cacheRetention: this.isSpeedOptimized() ? "long" : "short",
|
|
292
634
|
promptCacheKey: this.getPromptCacheKey(),
|
|
293
635
|
supportsImages: modelInfo?.supportsImages,
|
|
294
636
|
supportsVideo: modelInfo?.supportsVideo,
|
|
@@ -296,12 +638,22 @@ export class AgentSession {
|
|
|
296
638
|
// clearToolUses disabled — causes model to output unsolicited context summaries
|
|
297
639
|
// Single tool result shouldn't exceed 30% of context window (in chars)
|
|
298
640
|
maxToolResultChars: Math.floor(getContextWindow(this.model, { provider: this.provider, accountId }) * 3.5 * 0.3),
|
|
641
|
+
// Self-correction hooks (same as the TUI): loop-break + re-grounding are
|
|
642
|
+
// polled mid-loop; the ideal review is polled when the agent would stop.
|
|
643
|
+
getSteeringMessages: () => this.getHookSteeringMessages(),
|
|
644
|
+
getFollowUpMessages: () => this.getHookFollowUpMessages(),
|
|
299
645
|
});
|
|
300
646
|
for await (const event of generator) {
|
|
647
|
+
this.trackHookEvent(event);
|
|
301
648
|
this.eventBus.forwardAgentEvent(event);
|
|
302
649
|
}
|
|
303
650
|
};
|
|
304
651
|
try {
|
|
652
|
+
// Fire cache pre-warm before the first turn (Anthropic + speedProfile optimized).
|
|
653
|
+
// Runs concurrently with nothing — it must complete before runAgentLoop so
|
|
654
|
+
// the cache is warm when the real request arrives. Best-effort: swallowed
|
|
655
|
+
// inside maybePrewarmCache/prewarmAnthropicCache.
|
|
656
|
+
await this.maybePrewarmCache(creds);
|
|
305
657
|
await runAgentLoop(creds.accessToken, creds.accountId, creds.projectId);
|
|
306
658
|
}
|
|
307
659
|
catch (err) {
|
|
@@ -341,7 +693,25 @@ export class AgentSession {
|
|
|
341
693
|
this.provider = provider;
|
|
342
694
|
this.model = model;
|
|
343
695
|
setEstimatorModel(model);
|
|
344
|
-
|
|
696
|
+
// maxTokens must follow the active model — it was frozen at the boot
|
|
697
|
+
// model's `maxOutputTokens` in the constructor, so without this a session
|
|
698
|
+
// booted on e.g. Kimi (256K) keeps sending that cap after switching to a
|
|
699
|
+
// smaller model (Opus 128K), which the provider rejects.
|
|
700
|
+
this.maxTokens = this.resolveMaxTokens(model);
|
|
701
|
+
this.eventBus.emit("model_change", {
|
|
702
|
+
provider: this.provider,
|
|
703
|
+
model: this.model,
|
|
704
|
+
supportsVideo: getModel(this.model)?.supportsVideo ?? false,
|
|
705
|
+
});
|
|
706
|
+
// Rebuild the read tool for the new model's video byte cap. The tool's
|
|
707
|
+
// video capability (description + native-video execute path) is baked in
|
|
708
|
+
// at creation from the model's maxVideoBytes, so switching to/from a
|
|
709
|
+
// video-capable model mid-session needs a fresh tool object — mirrors
|
|
710
|
+
// the TUI's rebuildReadTool call on model switch.
|
|
711
|
+
if (this.rebuildReadTool) {
|
|
712
|
+
const newReadTool = this.rebuildReadTool(model);
|
|
713
|
+
this.tools = this.tools.map((t) => (t.name === "read" ? newReadTool : t));
|
|
714
|
+
}
|
|
345
715
|
// Update provider-specific tools when provider changes
|
|
346
716
|
if (provider && provider !== prevProvider) {
|
|
347
717
|
// Add/remove client-side web_search tool based on provider.
|
|
@@ -425,6 +795,9 @@ export class AgentSession {
|
|
|
425
795
|
});
|
|
426
796
|
}
|
|
427
797
|
async newSession() {
|
|
798
|
+
// A fresh session drops any in-flight plan state so its prompt is clean.
|
|
799
|
+
this.planModeRef.current = false;
|
|
800
|
+
this.approvedPlanPath = undefined;
|
|
428
801
|
const basePrompt = this.customSystemPrompt ??
|
|
429
802
|
(await buildSystemPrompt(this.cwd, this.skills, false, undefined, this.tools.map((tool) => tool.name), undefined, this.provider));
|
|
430
803
|
this.messages = [{ role: "system", content: basePrompt }];
|
|
@@ -485,15 +858,163 @@ export class AgentSession {
|
|
|
485
858
|
sessionId: this.sessionId,
|
|
486
859
|
sessionPath: this.sessionPath,
|
|
487
860
|
messageCount: this.messages.length,
|
|
861
|
+
planMode: this.planModeRef.current,
|
|
488
862
|
};
|
|
489
863
|
}
|
|
864
|
+
getPlanMode() {
|
|
865
|
+
return this.planModeRef.current;
|
|
866
|
+
}
|
|
867
|
+
/** Queue a user message (optionally with attachments) to be injected mid-run
|
|
868
|
+
* as steering. Returns the new queue length. No-op semantics are the caller's
|
|
869
|
+
* concern. */
|
|
870
|
+
queueMessage(text, attachments = []) {
|
|
871
|
+
this.userQueue.push({ text, attachments });
|
|
872
|
+
return this.userQueue.length;
|
|
873
|
+
}
|
|
874
|
+
/** Number of messages currently queued. */
|
|
875
|
+
getQueuedCount() {
|
|
876
|
+
return this.userQueue.length;
|
|
877
|
+
}
|
|
878
|
+
/** Clear the queue, returning the combined text (to restore to the composer).
|
|
879
|
+
* Queued attachments are dropped on cancel — the composer only restores text. */
|
|
880
|
+
drainQueue() {
|
|
881
|
+
return this.userQueue
|
|
882
|
+
.splice(0)
|
|
883
|
+
.map((m) => m.text)
|
|
884
|
+
.join("\n\n");
|
|
885
|
+
}
|
|
886
|
+
/** Snapshot of background processes (bash run_in_background), newest-state. */
|
|
887
|
+
listBackgroundProcesses() {
|
|
888
|
+
return this.processManager?.list() ?? [];
|
|
889
|
+
}
|
|
890
|
+
/** Stop a background process by id. Returns a human-readable status string. */
|
|
891
|
+
async killBackgroundProcess(id) {
|
|
892
|
+
if (!this.processManager)
|
|
893
|
+
return `No background process with id "${id}"`;
|
|
894
|
+
return this.processManager.stop(id);
|
|
895
|
+
}
|
|
896
|
+
/**
|
|
897
|
+
* Toggle plan mode: flips the shared ref (so tools enforce read-only
|
|
898
|
+
* restrictions) and rebuilds the system prompt in place so the model is told
|
|
899
|
+
* about the mode change on its next turn. No-op when a custom system prompt
|
|
900
|
+
* is in force (the host owns the prompt then).
|
|
901
|
+
*/
|
|
902
|
+
async setPlanMode(active) {
|
|
903
|
+
this.planModeRef.current = active;
|
|
904
|
+
// Entering plan mode discards any prior approved-plan contract (a new plan
|
|
905
|
+
// is about to be drafted); exiting keeps it (set explicitly via accept).
|
|
906
|
+
if (active)
|
|
907
|
+
this.approvedPlanPath = undefined;
|
|
908
|
+
await this.rebuildSystemPromptInPlace();
|
|
909
|
+
}
|
|
910
|
+
/**
|
|
911
|
+
* Bake an approved plan into the system prompt so the model is told to emit
|
|
912
|
+
* `[DONE:n]` markers as it completes each step (the contract the UI's
|
|
913
|
+
* plan-progress widget reads). Pass `undefined` to clear it. No-op when a
|
|
914
|
+
* custom system prompt is in force (the host owns the prompt then).
|
|
915
|
+
*/
|
|
916
|
+
async setApprovedPlan(approvedPlanPath) {
|
|
917
|
+
this.approvedPlanPath = approvedPlanPath;
|
|
918
|
+
await this.rebuildSystemPromptInPlace();
|
|
919
|
+
}
|
|
920
|
+
/** Rebuild messages[0] from current plan-mode + approved-plan state. */
|
|
921
|
+
async rebuildSystemPromptInPlace() {
|
|
922
|
+
if (this.customSystemPrompt)
|
|
923
|
+
return;
|
|
924
|
+
const rebuilt = await buildSystemPrompt(this.cwd, this.skills, this.planModeRef.current, this.approvedPlanPath, this.tools.map((tool) => tool.name), undefined, this.provider);
|
|
925
|
+
if (this.messages[0]?.role === "system") {
|
|
926
|
+
this.messages[0] = { role: "system", content: rebuilt };
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
this.messages.unshift({ role: "system", content: rebuilt });
|
|
930
|
+
}
|
|
931
|
+
}
|
|
490
932
|
getMessages() {
|
|
491
933
|
return this.messages;
|
|
492
934
|
}
|
|
935
|
+
/**
|
|
936
|
+
* Generate a short LLM session title from the conversation so far (first user
|
|
937
|
+
* message + first assistant reply). Best-effort; returns null on failure or
|
|
938
|
+
* when there's no user message yet. Uses the cheapest model for the provider.
|
|
939
|
+
*/
|
|
940
|
+
async generateTitle() {
|
|
941
|
+
const extractText = (content) => typeof content === "string"
|
|
942
|
+
? content
|
|
943
|
+
: content
|
|
944
|
+
.map((c) => c.type === "text" && "text" in c && typeof c.text === "string" ? c.text : "")
|
|
945
|
+
.join(" ");
|
|
946
|
+
const userMsg = this.messages.find((m) => m.role === "user");
|
|
947
|
+
const assistantMsg = this.messages.find((m) => m.role === "assistant");
|
|
948
|
+
const userText = userMsg ? extractText(userMsg.content) : "";
|
|
949
|
+
if (!userText.trim())
|
|
950
|
+
return null;
|
|
951
|
+
try {
|
|
952
|
+
const creds = await this.authStorage.resolveCredentials(this.provider);
|
|
953
|
+
const title = await generateSessionTitle({
|
|
954
|
+
provider: this.provider,
|
|
955
|
+
userMessage: userText,
|
|
956
|
+
assistantPreview: assistantMsg ? extractText(assistantMsg.content).slice(0, 200) : "",
|
|
957
|
+
apiKey: creds.accessToken,
|
|
958
|
+
baseUrl: this.baseUrl ?? creds.baseUrl,
|
|
959
|
+
accountId: creds.accountId,
|
|
960
|
+
});
|
|
961
|
+
return title || null;
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
return null;
|
|
965
|
+
}
|
|
966
|
+
}
|
|
967
|
+
/** Current reasoning/thinking level, or undefined when thinking is off. */
|
|
968
|
+
getThinkingLevel() {
|
|
969
|
+
return this.thinkingLevel;
|
|
970
|
+
}
|
|
971
|
+
/** Set the reasoning/thinking level (undefined turns thinking off). Takes
|
|
972
|
+
* effect on the next prompt, since the in-flight loop reads it at start. */
|
|
973
|
+
setThinkingLevel(level) {
|
|
974
|
+
this.thinkingLevel = level;
|
|
975
|
+
}
|
|
493
976
|
/** Replace the abort signal (e.g. after cancellation). */
|
|
494
977
|
setSignal(signal) {
|
|
495
978
|
this.opts = { ...this.opts, signal };
|
|
496
979
|
}
|
|
980
|
+
/** True when speedProfile is "optimized" (1-h cache TTL + pre-warm). */
|
|
981
|
+
isSpeedOptimized() {
|
|
982
|
+
return this.settingsManager?.get("speedProfile") === "optimized";
|
|
983
|
+
}
|
|
984
|
+
/** Fire a cache pre-warm request for Anthropic so the first real turn is a
|
|
985
|
+
* cache read instead of a cold write. No-op for other providers and when
|
|
986
|
+
* speedProfile is not "optimized". Entirely best-effort — any failure is
|
|
987
|
+
* swallowed so prewarm never blocks or aborts the real prompt. */
|
|
988
|
+
async maybePrewarmCache(creds) {
|
|
989
|
+
if (this.cachePrewarmed || !this.isSpeedOptimized() || this.provider !== "anthropic") {
|
|
990
|
+
return;
|
|
991
|
+
}
|
|
992
|
+
this.cachePrewarmed = true;
|
|
993
|
+
try {
|
|
994
|
+
const userAgent = await getClaudeCliUserAgent();
|
|
995
|
+
const systemText = typeof this.messages[0]?.content === "string" ? this.messages[0].content : "";
|
|
996
|
+
if (!systemText)
|
|
997
|
+
return;
|
|
998
|
+
await prewarmAnthropicCache({
|
|
999
|
+
apiKey: creds.accessToken,
|
|
1000
|
+
model: this.model,
|
|
1001
|
+
system: systemText,
|
|
1002
|
+
tools: this.tools.map((t) => ({
|
|
1003
|
+
name: t.name,
|
|
1004
|
+
description: t.description,
|
|
1005
|
+
parameters: t.parameters,
|
|
1006
|
+
...(t.rawInputSchema ? { rawInputSchema: t.rawInputSchema } : {}),
|
|
1007
|
+
})),
|
|
1008
|
+
baseUrl: this.baseUrl ?? creds.baseUrl,
|
|
1009
|
+
userAgent,
|
|
1010
|
+
cacheRetention: "long",
|
|
1011
|
+
signal: this.opts.signal,
|
|
1012
|
+
});
|
|
1013
|
+
}
|
|
1014
|
+
catch {
|
|
1015
|
+
// Best-effort — prewarm failure must never block the session.
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
497
1018
|
getPromptCacheKey() {
|
|
498
1019
|
if (this.opts.promptCacheKey)
|
|
499
1020
|
return this.opts.promptCacheKey;
|