mindweave 2.5.0 → 3.0.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 +81 -145
- package/dist/cli/App.js +579 -304
- package/dist/cli/App.js.map +1 -1
- package/dist/cli/analytics.js +27 -18
- package/dist/cli/analytics.js.map +1 -1
- package/dist/cli/attachments.js +34 -0
- package/dist/cli/attachments.js.map +1 -1
- package/dist/cli/bannerFit.js +70 -0
- package/dist/cli/bannerFit.js.map +1 -0
- package/dist/cli/bootstrap.js +41 -3
- package/dist/cli/bootstrap.js.map +1 -1
- package/dist/cli/commands.js +4 -1
- package/dist/cli/commands.js.map +1 -1
- package/dist/cli/components/ApprovalBox.js +3 -2
- package/dist/cli/components/ApprovalBox.js.map +1 -1
- package/dist/cli/components/BlockView.js +27 -6
- package/dist/cli/components/BlockView.js.map +1 -1
- package/dist/cli/components/FirstRunFrame.js +2 -1
- package/dist/cli/components/FirstRunFrame.js.map +1 -1
- package/dist/cli/components/KeyManager.js +5 -4
- package/dist/cli/components/KeyManager.js.map +1 -1
- package/dist/cli/components/KeySetup.js +29 -13
- package/dist/cli/components/KeySetup.js.map +1 -1
- package/dist/cli/components/MarathonBox.js +64 -0
- package/dist/cli/components/MarathonBox.js.map +1 -0
- package/dist/cli/components/McpMinitabs.js +8 -7
- package/dist/cli/components/McpMinitabs.js.map +1 -1
- package/dist/cli/components/Picker.js +4 -3
- package/dist/cli/components/Picker.js.map +1 -1
- package/dist/cli/components/PromptInput.js +47 -8
- package/dist/cli/components/PromptInput.js.map +1 -1
- package/dist/cli/components/PulseDot.js +42 -0
- package/dist/cli/components/PulseDot.js.map +1 -0
- package/dist/cli/components/SubagentView.js +9 -6
- package/dist/cli/components/SubagentView.js.map +1 -1
- package/dist/cli/components/TipLine.js +2 -1
- package/dist/cli/components/TipLine.js.map +1 -1
- package/dist/cli/components/ToolGroup.js +65 -49
- package/dist/cli/components/ToolGroup.js.map +1 -1
- package/dist/cli/components/ToolLine.js +20 -19
- package/dist/cli/components/ToolLine.js.map +1 -1
- package/dist/cli/components/TrustGate.js +2 -1
- package/dist/cli/components/TrustGate.js.map +1 -1
- package/dist/cli/components/minitabs.js +3 -2
- package/dist/cli/components/minitabs.js.map +1 -1
- package/dist/cli/desktopApp.js +45 -0
- package/dist/cli/desktopApp.js.map +1 -0
- package/dist/cli/help.js +1 -0
- package/dist/cli/help.js.map +1 -1
- package/dist/cli/inkOutputPatch.js +285 -0
- package/dist/cli/inkOutputPatch.js.map +1 -0
- package/dist/cli/inkSpeed.js +29 -0
- package/dist/cli/inkSpeed.js.map +1 -0
- package/dist/cli/inkSpeedLoader.js +47 -0
- package/dist/cli/inkSpeedLoader.js.map +1 -0
- package/dist/cli/inputText.js +30 -0
- package/dist/cli/inputText.js.map +1 -0
- package/dist/cli/keyManager.js +2 -1
- package/dist/cli/keyManager.js.map +1 -1
- package/dist/cli/keyPrefs.js +90 -0
- package/dist/cli/keyPrefs.js.map +1 -0
- package/dist/cli/keySetup.js +5 -2
- package/dist/cli/keySetup.js.map +1 -1
- package/dist/cli/marathonUi.js +113 -0
- package/dist/cli/marathonUi.js.map +1 -0
- package/dist/cli/markdown.js +63 -11
- package/dist/cli/markdown.js.map +1 -1
- package/dist/cli/modes.js +3 -3
- package/dist/cli/modes.js.map +1 -1
- package/dist/cli/prodEnv.js +43 -0
- package/dist/cli/prodEnv.js.map +1 -0
- package/dist/cli/revealPace.js +5 -5
- package/dist/cli/revealPace.js.map +1 -1
- package/dist/cli/revealQueue.js +120 -0
- package/dist/cli/revealQueue.js.map +1 -0
- package/dist/cli/theme.js +47 -0
- package/dist/cli/theme.js.map +1 -0
- package/dist/cli/toolDisplay.js +112 -18
- package/dist/cli/toolDisplay.js.map +1 -1
- package/dist/cli/transcript.js +1 -1
- package/dist/cli/transcript.js.map +1 -1
- package/dist/cli/virtualWindow.js +14 -2
- package/dist/cli/virtualWindow.js.map +1 -1
- package/dist/cli/wheelAccel.js +73 -0
- package/dist/cli/wheelAccel.js.map +1 -0
- package/dist/cli/widestLinePatch.js +48 -0
- package/dist/cli/widestLinePatch.js.map +1 -0
- package/dist/cli/workingVerb.js +88 -28
- package/dist/cli/workingVerb.js.map +1 -1
- package/dist/cli/wrap.js +1 -1
- package/dist/cli/wrap.js.map +1 -1
- package/dist/core/contextSettings.js +84 -0
- package/dist/core/contextSettings.js.map +1 -0
- package/dist/core/limitsView.js +40 -0
- package/dist/core/limitsView.js.map +1 -0
- package/dist/core/mcpServers.js +164 -0
- package/dist/core/mcpServers.js.map +1 -0
- package/dist/core/permissions.js +209 -0
- package/dist/core/permissions.js.map +1 -0
- package/dist/core/providerKeys.js +187 -0
- package/dist/core/providerKeys.js.map +1 -0
- package/dist/core/rulesSkills.js +142 -0
- package/dist/core/rulesSkills.js.map +1 -0
- package/dist/core/spendView.js +90 -0
- package/dist/core/spendView.js.map +1 -0
- package/dist/core/turnRunner.js +759 -0
- package/dist/core/turnRunner.js.map +1 -0
- package/dist/drivers/anthropic/client.js +101 -26
- package/dist/drivers/anthropic/client.js.map +1 -1
- package/dist/drivers/anthropic/manifest.js +43 -13
- package/dist/drivers/anthropic/manifest.js.map +1 -1
- package/dist/drivers/cerebras/manifest.js +13 -3
- package/dist/drivers/cerebras/manifest.js.map +1 -1
- package/dist/drivers/deepseek/manifest.js +24 -34
- package/dist/drivers/deepseek/manifest.js.map +1 -1
- package/dist/drivers/glm/manifest.js +21 -6
- package/dist/drivers/glm/manifest.js.map +1 -1
- package/dist/drivers/groq/manifest.js +5 -2
- package/dist/drivers/groq/manifest.js.map +1 -1
- package/dist/drivers/kimi/manifest.js +13 -10
- package/dist/drivers/kimi/manifest.js.map +1 -1
- package/dist/drivers/meta/manifest.js +21 -16
- package/dist/drivers/meta/manifest.js.map +1 -1
- package/dist/drivers/minimax/manifest.js +15 -12
- package/dist/drivers/minimax/manifest.js.map +1 -1
- package/dist/drivers/ollama/catalog.js +71 -0
- package/dist/drivers/ollama/catalog.js.map +1 -0
- package/dist/drivers/ollama/client.js +164 -0
- package/dist/drivers/ollama/client.js.map +1 -0
- package/dist/drivers/ollama/endpoint.js +42 -0
- package/dist/drivers/ollama/endpoint.js.map +1 -0
- package/dist/drivers/ollama/index.js +5 -0
- package/dist/drivers/ollama/index.js.map +1 -0
- package/dist/drivers/ollama/manifest.js +64 -0
- package/dist/drivers/ollama/manifest.js.map +1 -0
- package/dist/drivers/openai/client.js +10 -4
- package/dist/drivers/openai/client.js.map +1 -1
- package/dist/drivers/openai/manifest.js +69 -18
- package/dist/drivers/openai/manifest.js.map +1 -1
- package/dist/drivers/qwen/manifest.js +19 -6
- package/dist/drivers/qwen/manifest.js.map +1 -1
- package/dist/drivers/registry.js +11 -0
- package/dist/drivers/registry.js.map +1 -1
- package/dist/drivers/retryPolicy.js +26 -1
- package/dist/drivers/retryPolicy.js.map +1 -1
- package/dist/drivers/xai/client.js +12 -10
- package/dist/drivers/xai/client.js.map +1 -1
- package/dist/drivers/xai/manifest.js +62 -25
- package/dist/drivers/xai/manifest.js.map +1 -1
- package/dist/dynamo/auxModel.js +54 -0
- package/dist/dynamo/auxModel.js.map +1 -0
- package/dist/dynamo/engine.js +407 -99
- package/dist/dynamo/engine.js.map +1 -1
- package/dist/dynamo/guard.js +10 -0
- package/dist/dynamo/guard.js.map +1 -1
- package/dist/dynamo/liveMeter.js +25 -6
- package/dist/dynamo/liveMeter.js.map +1 -1
- package/dist/dynamo/marathon.js +409 -0
- package/dist/dynamo/marathon.js.map +1 -0
- package/dist/dynamo/marathonPrompt.js +45 -0
- package/dist/dynamo/marathonPrompt.js.map +1 -0
- package/dist/dynamo/model.js +6 -2
- package/dist/dynamo/model.js.map +1 -1
- package/dist/dynamo/toolBatches.js +58 -0
- package/dist/dynamo/toolBatches.js.map +1 -0
- package/dist/dynamo/usageLimits.js +446 -0
- package/dist/dynamo/usageLimits.js.map +1 -0
- package/dist/governor/freshness.js +10 -7
- package/dist/governor/freshness.js.map +1 -1
- package/dist/governor/index.js +76 -9
- package/dist/governor/index.js.map +1 -1
- package/dist/governor/write.js +43 -14
- package/dist/governor/write.js.map +1 -1
- package/dist/index.js +18 -151
- package/dist/index.js.map +1 -1
- package/dist/main.js +162 -0
- package/dist/main.js.map +1 -0
- package/dist/mcp/manager.js +18 -0
- package/dist/mcp/manager.js.map +1 -1
- package/dist/memory/allMetas.js +69 -0
- package/dist/memory/allMetas.js.map +1 -0
- package/dist/memory/compaction.js +70 -15
- package/dist/memory/compaction.js.map +1 -1
- package/dist/memory/earlier.js +15 -0
- package/dist/memory/earlier.js.map +1 -0
- package/dist/memory/profile.js +93 -0
- package/dist/memory/profile.js.map +1 -0
- package/dist/memory/rewind.js +262 -0
- package/dist/memory/rewind.js.map +1 -0
- package/dist/memory/session.js +42 -10
- package/dist/memory/session.js.map +1 -1
- package/dist/memory/sessionMemory.js +48 -13
- package/dist/memory/sessionMemory.js.map +1 -1
- package/dist/memory/sessionMemoryCompact.js +1 -1
- package/dist/memory/sessionMemoryCompact.js.map +1 -1
- package/dist/memory/sessionTitle.js +50 -0
- package/dist/memory/sessionTitle.js.map +1 -0
- package/dist/memory/store.js +42 -1
- package/dist/memory/store.js.map +1 -1
- package/dist/memory/types.js.map +1 -1
- package/dist/tools/askUser.js +14 -0
- package/dist/tools/askUser.js.map +1 -1
- package/dist/tools/backgroundShells.js +208 -5
- package/dist/tools/backgroundShells.js.map +1 -1
- package/dist/tools/browser.js +176 -0
- package/dist/tools/browser.js.map +1 -0
- package/dist/tools/cdp.js +165 -0
- package/dist/tools/cdp.js.map +1 -0
- package/dist/tools/checkpoints.js +208 -5
- package/dist/tools/checkpoints.js.map +1 -1
- package/dist/tools/deferredNative.js +2 -0
- package/dist/tools/deferredNative.js.map +1 -1
- package/dist/tools/detail.js +9 -6
- package/dist/tools/detail.js.map +1 -1
- package/dist/tools/edit.js +2 -1
- package/dist/tools/edit.js.map +1 -1
- package/dist/tools/exitPlan.js +4 -0
- package/dist/tools/exitPlan.js.map +1 -1
- package/dist/tools/hiddenDesktop.js +273 -0
- package/dist/tools/hiddenDesktop.js.map +1 -0
- package/dist/tools/listeningPorts.js +134 -0
- package/dist/tools/listeningPorts.js.map +1 -0
- package/dist/tools/mcpSearch.js +8 -0
- package/dist/tools/mcpSearch.js.map +1 -1
- package/dist/tools/mindweaveStatus.js +1 -1
- package/dist/tools/mindweaveStatus.js.map +1 -1
- package/dist/tools/registry.js +3 -0
- package/dist/tools/registry.js.map +1 -1
- package/dist/tools/replaceSymbol.js +2 -1
- package/dist/tools/replaceSymbol.js.map +1 -1
- package/dist/tools/runCommand.js +113 -9
- package/dist/tools/runCommand.js.map +1 -1
- package/dist/tools/screenshot.js +4 -6
- package/dist/tools/screenshot.js.map +1 -1
- package/dist/tools/screenshotWin.js +26 -9
- package/dist/tools/screenshotWin.js.map +1 -1
- package/dist/tools/stateCheckpoint.js +128 -0
- package/dist/tools/stateCheckpoint.js.map +1 -0
- package/dist/tools/todo.js +6 -0
- package/dist/tools/todo.js.map +1 -1
- package/dist/tools/ui.js +1295 -0
- package/dist/tools/ui.js.map +1 -0
- package/dist/tools/uiHide.js +356 -0
- package/dist/tools/uiHide.js.map +1 -0
- package/dist/tools/uiPage.js +861 -0
- package/dist/tools/uiPage.js.map +1 -0
- package/dist/tools/uiWin.js +506 -0
- package/dist/tools/uiWin.js.map +1 -0
- package/dist/tools/webFetch.js +32 -25
- package/dist/tools/webFetch.js.map +1 -1
- package/dist/tools/webSearch.js +12 -0
- package/dist/tools/webSearch.js.map +1 -1
- package/dist/tools/writeFile.js +2 -1
- package/dist/tools/writeFile.js.map +1 -1
- package/package.json +78 -75
|
@@ -0,0 +1,759 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* turnRunner.ts — drives one Mindweave turn for a caller that is not a terminal.
|
|
3
|
+
*
|
|
4
|
+
* `respond()` in dynamo/engine.ts already speaks nothing but plain JSON: a Session in,
|
|
5
|
+
* a stream of EngineEvents out, no filesystem or terminal coupling of its own (see
|
|
6
|
+
* dynamo/README.md). What the CLI adds on top is the reveal pacer that times each event
|
|
7
|
+
* onto the alt-screen — real UI concerns, of no use to a caller that draws its own
|
|
8
|
+
* chat. This module is the other front door: it maps the same events into a plain
|
|
9
|
+
* TurnEvent stream (the tool display names and colours come from cli/toolDisplay.ts,
|
|
10
|
+
* so a tool call reads the same way in every surface) with no Ink, no pacing, no
|
|
11
|
+
* terminal assumptions. A GUI or a server can drive a session with just this file.
|
|
12
|
+
*/
|
|
13
|
+
import { compactNow, respond } from "../dynamo/engine.js";
|
|
14
|
+
import { startMarathon, resumeMarathon, clearMarathon, describeMarathonEvent, verifyGoal, } from "../dynamo/marathon.js";
|
|
15
|
+
export { clearMarathon, describeMarathonEvent, MAX_MARATHON_VERIFY_FAILS } from "../dynamo/marathon.js";
|
|
16
|
+
/** How full the context is against the auto-compaction bar, for a context meter. */
|
|
17
|
+
export { contextFill } from "../dynamo/engine.js";
|
|
18
|
+
import { createSession, resumeSession, reloadProjectMemory } from "../memory/session.js";
|
|
19
|
+
import { saveSession, listSessions as listSessionsRaw } from "../memory/store.js";
|
|
20
|
+
import { toolDisplay, isGroupable, narrationShown, noteReads } from "../cli/toolDisplay.js";
|
|
21
|
+
import { accessRefusal, providerOutage, statusOf } from "../drivers/providerError.js";
|
|
22
|
+
import { failoverKey, noteKeyWorked } from "./providerKeys.js";
|
|
23
|
+
import { providerOf, modelLabel, refreshModels, DISCOVERY_TTL_MS, withModel, saveModelConfig, thinkLevels, thinkLabel } from "../dynamo/model.js";
|
|
24
|
+
import { allProviders, modelsOf } from "../drivers/registry.js";
|
|
25
|
+
import { errText } from "../tools/editTarget.js";
|
|
26
|
+
import { loadConfig, hasApiKey, saveApiKey, removeApiKey } from "../cli/bootstrap.js";
|
|
27
|
+
import { describeImage, isRejection } from "../memory/images.js";
|
|
28
|
+
import { resolveAttachments, stripAttachments, attachedFiles, hideAttachedNames } from "../cli/attachments.js";
|
|
29
|
+
export { attachedFiles, hideAttachedNames } from "../cli/attachments.js";
|
|
30
|
+
import { isRewindPoint } from "../memory/rewind.js";
|
|
31
|
+
export { rewindPoints, rewindTo, pasteSlot } from "../memory/rewind.js";
|
|
32
|
+
import { wrapPastedText, collapsePastes } from "../memory/pastedText.js";
|
|
33
|
+
import { manifestForModel } from "../drivers/registry.js";
|
|
34
|
+
import { stat, readFile } from "node:fs/promises";
|
|
35
|
+
import { join } from "node:path";
|
|
36
|
+
import { NOTES_FILE } from "../memory/projectNotes.js";
|
|
37
|
+
import { writeFileAtomic } from "../tools/atomicWrite.js";
|
|
38
|
+
import { RECAP_STUB } from "../memory/compaction.js";
|
|
39
|
+
import { stopChassis } from "../alternator/lane.js";
|
|
40
|
+
import { stopUi } from "../tools/ui.js";
|
|
41
|
+
import { runCommand } from "../tools/runCommand.js";
|
|
42
|
+
import { APPROVAL_TEXT, APPROVAL_DISMISSED } from "../tools/approval.js";
|
|
43
|
+
import { MODES, DEFAULT_MODE, modeById, modeFromFlags } from "../cli/modes.js";
|
|
44
|
+
/**
|
|
45
|
+
* The interaction modes (Lightning/Architect/Sentinel) — a client concept, same
|
|
46
|
+
* pure data/functions the CLI's shift-tab indicator uses (cli/modes.ts). The
|
|
47
|
+
* engine never sees a mode name, only the `planMode`/`guarded` flags it implies.
|
|
48
|
+
*/
|
|
49
|
+
export { MODES, DEFAULT_MODE, modeById, modeFromFlags };
|
|
50
|
+
/**
|
|
51
|
+
* `requestApproval`'s resolved string is a plain option label UNLESS the caller
|
|
52
|
+
* offered `freeText`: a typed answer comes back prefixed with `APPROVAL_TEXT`
|
|
53
|
+
* (strip it to get what was typed), and a dialog closed with no choice made should
|
|
54
|
+
* resolve to `APPROVAL_DISMISSED` exactly, never an empty string or a thrown error.
|
|
55
|
+
*/
|
|
56
|
+
export { APPROVAL_TEXT, APPROVAL_DISMISSED };
|
|
57
|
+
/** The typed-answer row exit_plan offers. A front end compares against it to know an
|
|
58
|
+
* approval is a PLAN (shown in full) rather than a question, without matching prose. */
|
|
59
|
+
export { PLAN_FEEDBACK } from "../tools/exitPlan.js";
|
|
60
|
+
/** The user's machine-wide profile (what to call them), shared with the CLI. */
|
|
61
|
+
export { readProfile, saveProfile } from "../memory/profile.js";
|
|
62
|
+
/** Feedback to the maintainer: the CLI's /feedback, same payload, same checks. */
|
|
63
|
+
export { buildFeedback, refuseReason, sendFeedback, ISSUES_URL, MAX_MESSAGE } from "../cli/feedback.js";
|
|
64
|
+
/** The anonymous usage count: the same id, on/off flag and ping the CLI uses. */
|
|
65
|
+
export { analyticsEnabled, setAnalyticsEnabled, sendAnalyticsPing } from "../cli/analytics.js";
|
|
66
|
+
export { appVersion } from "../cli/version.js";
|
|
67
|
+
/** Permissions: protected files, blocked commands and MCP tools, Sentinel allowances,
|
|
68
|
+
* default mode — per project or for every project. */
|
|
69
|
+
export { permissionsView, addPermission, removePermission, movePermission, revokeSessionGrant, setDefaultMode, defaultModeFor, } from "./permissions.js";
|
|
70
|
+
/** The user's own auto-compaction bar: this project's, all-projects', or Mindweave's
|
|
71
|
+
* own model-anchored default (with a one-line reason why it's what it is). */
|
|
72
|
+
export { contextView, contextRecommendationFor, setContextOverride, resetContextOverride, CONTEXT_PRESETS, CONTEXT_MIN_TOKENS, } from "./contextSettings.js";
|
|
73
|
+
/** Tokens spent so far, across every project this machine has state for — by model
|
|
74
|
+
* and by day/week/month. Read-only; tokens only, no dollar figure. */
|
|
75
|
+
export { spendView } from "./spendView.js";
|
|
76
|
+
export { limitsView, saveLimitsFromPanel, analyzeForPanel } from "./limitsView.js";
|
|
77
|
+
/** Rules and skills as files, for this project or all projects. */
|
|
78
|
+
export { listRulesSkills, readRuleSkill, saveRuleSkill, createRuleSkill, deleteRuleSkill, moveRuleSkill, importRuleSkill, } from "./rulesSkills.js";
|
|
79
|
+
/** MCP servers: list, add/edit, remove, enable/disable, reconnect, sign in/out. */
|
|
80
|
+
export { listMcpServers, mcpServerDetail, saveMcpServer, removeMcpServer, setMcpServerDisabled, reconnectMcpServer, signInMcpServer, signOutMcpServer, allowChangedMcpTools, onMcpChange, } from "./mcpServers.js";
|
|
81
|
+
/** Several keys per provider: add, edit, name, switch off, make default, auto-switch. */
|
|
82
|
+
export { providerKeys, addProviderKey, editProviderKey, removeProviderKey, useProviderKey, makeDefaultProviderKey, renameProviderKey, setProviderKeyDisabled, setProviderAutoSwitch, } from "./providerKeys.js";
|
|
83
|
+
/**
|
|
84
|
+
* Load the API key(s) into process.env — the same global `~/.mindweave/.env` /
|
|
85
|
+
* project `.env` / shell-env layering the CLI uses (see cli/bootstrap.ts). Call
|
|
86
|
+
* this once, at startup, before the first `startSession`.
|
|
87
|
+
*/
|
|
88
|
+
export { loadConfig };
|
|
89
|
+
/**
|
|
90
|
+
* One provider's models with what each costs and can do, for a detail screen. Read from
|
|
91
|
+
* the same manifests the engine bills and routes by, so the screen can never show a
|
|
92
|
+
* price the cost math does not use. Null for an unknown provider id.
|
|
93
|
+
*/
|
|
94
|
+
export function providerDetail(id) {
|
|
95
|
+
const p = allProviders().find((m) => m.id === id);
|
|
96
|
+
if (!p)
|
|
97
|
+
return null;
|
|
98
|
+
return {
|
|
99
|
+
id: p.id,
|
|
100
|
+
label: p.label,
|
|
101
|
+
apiKeyEnv: p.apiKeyEnv,
|
|
102
|
+
keysUrl: p.keysUrl,
|
|
103
|
+
connected: hasApiKey(p.apiKeyEnv),
|
|
104
|
+
local: !!p.local,
|
|
105
|
+
models: modelsOf(p).map((m) => {
|
|
106
|
+
const manifest = manifestForModel(m.id);
|
|
107
|
+
const price = manifest.price(m.id);
|
|
108
|
+
return {
|
|
109
|
+
id: m.id,
|
|
110
|
+
label: m.label,
|
|
111
|
+
description: m.description,
|
|
112
|
+
price: {
|
|
113
|
+
input: price.cacheMiss,
|
|
114
|
+
output: price.output,
|
|
115
|
+
cacheRead: price.cacheHit,
|
|
116
|
+
cacheWrite: price.cacheWrite ?? null,
|
|
117
|
+
},
|
|
118
|
+
acceptsImages: manifest.acceptsImages?.(m.id) ?? false,
|
|
119
|
+
thinks: manifest.thinkLevels(m.id).some((l) => l.thinking),
|
|
120
|
+
};
|
|
121
|
+
}),
|
|
122
|
+
};
|
|
123
|
+
}
|
|
124
|
+
/**
|
|
125
|
+
* Every installed provider — the real registry (drivers/registry.ts), not a
|
|
126
|
+
* decorative list — with whether a key is already on file for it and the
|
|
127
|
+
* models it currently offers. Call `refreshDiscoveredModels()` first if the
|
|
128
|
+
* caller wants a discovered provider's (e.g. OpenRouter's) list fully current.
|
|
129
|
+
*/
|
|
130
|
+
export function providerSummaries() {
|
|
131
|
+
return allProviders().map((p) => ({
|
|
132
|
+
id: p.id,
|
|
133
|
+
label: p.label,
|
|
134
|
+
apiKeyEnv: p.apiKeyEnv,
|
|
135
|
+
keysUrl: p.keysUrl,
|
|
136
|
+
connected: hasApiKey(p.apiKeyEnv),
|
|
137
|
+
local: !!p.local,
|
|
138
|
+
models: modelsOf(p).map((m) => ({ id: m.id, label: m.label, description: m.description })),
|
|
139
|
+
}));
|
|
140
|
+
}
|
|
141
|
+
/** Pull fresh model lists for providers that discover theirs live (OpenRouter and
|
|
142
|
+
* any local-runtime provider) — same TTL the CLI's own picker uses. */
|
|
143
|
+
export function refreshDiscoveredModels() {
|
|
144
|
+
return refreshModels({ maxAgeMs: DISCOVERY_TTL_MS });
|
|
145
|
+
}
|
|
146
|
+
/** Save a key for a provider (slot 1 — the primary key). Takes effect immediately;
|
|
147
|
+
* no restart needed, same as the CLI's `/key`. */
|
|
148
|
+
export function setProviderKey(apiKeyEnv, key) {
|
|
149
|
+
saveApiKey(apiKeyEnv, key);
|
|
150
|
+
}
|
|
151
|
+
/** Remove the stored key at slot 1. A provider with no key on file falls back to
|
|
152
|
+
* whatever `requestApproval`-less refusal path its driver already has. */
|
|
153
|
+
export function clearProviderKey(apiKeyEnv) {
|
|
154
|
+
removeApiKey(apiKeyEnv, 1);
|
|
155
|
+
}
|
|
156
|
+
/** The model driving a session's NEXT turn — read after `setSessionModel`, or at
|
|
157
|
+
* session load, to show the composer's picker the model that's actually active. */
|
|
158
|
+
export function sessionModel(session) {
|
|
159
|
+
const id = session.modelConfig.model;
|
|
160
|
+
const manifest = providerOf(id);
|
|
161
|
+
return { id, label: modelLabel(id), providerId: manifest.id, providerLabel: manifest.label };
|
|
162
|
+
}
|
|
163
|
+
/**
|
|
164
|
+
* Switch a live session onto a different model, mid-conversation — the same thing
|
|
165
|
+
* `/model` does in the CLI. Synchronous onto the session (the next `runTurn` picks
|
|
166
|
+
* it up automatically; `ensureDriver` inside `respond()` loads that provider's wire
|
|
167
|
+
* code lazily, same as any other model choice), and persisted so the choice survives
|
|
168
|
+
* a restart. `withModel` also clamps the reasoning level onto whatever ladder the
|
|
169
|
+
* new model actually offers, so a thinking-heavy setting doesn't silently misfire
|
|
170
|
+
* on a model that has no such dial.
|
|
171
|
+
*/
|
|
172
|
+
export async function setSessionModel(session, model) {
|
|
173
|
+
session.modelConfig = withModel(session.modelConfig, model);
|
|
174
|
+
await saveModelConfig(session.cwd, session.modelConfig);
|
|
175
|
+
}
|
|
176
|
+
/**
|
|
177
|
+
* The `/think` ladder for a session's CURRENT model, and which rung it's on now.
|
|
178
|
+
* Every model has its own ladder shape — DeepSeek is Standard/High/Maximum,
|
|
179
|
+
* Gemini is Standard/Thinking/Maximum, some models can't turn thinking off at
|
|
180
|
+
* all — so a fixed three-option Off/Standard/Extended picker is wrong for most
|
|
181
|
+
* of them. This is what the composer's thinking picker should build itself from,
|
|
182
|
+
* fresh, every time the model changes.
|
|
183
|
+
*/
|
|
184
|
+
export function sessionThinkLevels(session) {
|
|
185
|
+
return { levels: thinkLevels(session.modelConfig.model), current: thinkLabel(session.modelConfig) };
|
|
186
|
+
}
|
|
187
|
+
/** Switch a live session onto a different rung of its model's OWN reasoning
|
|
188
|
+
* ladder — the same thing `/think` does in the CLI. Persisted like a model
|
|
189
|
+
* switch; takes effect on the next turn. */
|
|
190
|
+
export async function setSessionThinking(session, level) {
|
|
191
|
+
session.modelConfig = { ...session.modelConfig, thinking: level.thinking, effort: level.effort };
|
|
192
|
+
await saveModelConfig(session.cwd, session.modelConfig);
|
|
193
|
+
}
|
|
194
|
+
/** Start a brand-new session rooted at `cwd` — one per open project/window. */
|
|
195
|
+
export function startSession(cwd) {
|
|
196
|
+
return createSession(cwd);
|
|
197
|
+
}
|
|
198
|
+
/** Reopen a session that was saved earlier, so a chat window can be closed and reopened. */
|
|
199
|
+
export function loadSession(cwd, sessionId) {
|
|
200
|
+
return resumeSession(cwd, sessionId);
|
|
201
|
+
}
|
|
202
|
+
/**
|
|
203
|
+
* Release a session's live resources — background shells, MCP server processes,
|
|
204
|
+
* chassis (LSP) instances — before switching away from it. Same cleanup as the
|
|
205
|
+
* CLI's `stopCurrentLanes`: these are child processes / open handles the session
|
|
206
|
+
* owns, not garbage-collectable state, so leaving them running across a switch
|
|
207
|
+
* leaks a process pool per session swapped away from.
|
|
208
|
+
*/
|
|
209
|
+
export async function disposeSession(session) {
|
|
210
|
+
const ctx = session.toolContext;
|
|
211
|
+
ctx.backgroundShells?.dispose();
|
|
212
|
+
await ctx.mcp?.dispose();
|
|
213
|
+
for (const ch of ctx.chassisByRoot?.values() ?? (ctx.chassis ? [ctx.chassis] : [])) {
|
|
214
|
+
await stopChassis(ch);
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
/** Every saved session for a project, most-recent first — for a session picker. */
|
|
218
|
+
export async function listSessions(cwd) {
|
|
219
|
+
const metas = await listSessionsRaw(cwd);
|
|
220
|
+
return [...metas].sort((a, b) => b.updatedAt - a.updatedAt);
|
|
221
|
+
}
|
|
222
|
+
/** Compaction appends "[<paths> was attached here but is no longer in context …]" to a
|
|
223
|
+
* user message whose image it evicted (see IMAGE_CLEARED_STUB; older sessions carry an
|
|
224
|
+
* earlier wording). That note is for the MODEL. Replay shows the person their own words,
|
|
225
|
+
* plus which attachments have expired. */
|
|
226
|
+
const EXPIRED_IMAGE_NOTE = /\s*\[([^[\]]+?) was attached here but is no longer in context[^\]]*\]/g;
|
|
227
|
+
function splitExpiredImages(content) {
|
|
228
|
+
const expired = [];
|
|
229
|
+
const text = content.replace(EXPIRED_IMAGE_NOTE, (_m, names) => {
|
|
230
|
+
for (const n of names.split(", "))
|
|
231
|
+
expired.push(n.split(/[\\/]/).pop() ?? n);
|
|
232
|
+
return "";
|
|
233
|
+
});
|
|
234
|
+
return expired.length ? { text: text.trimEnd(), expiredImages: expired } : { text: content };
|
|
235
|
+
}
|
|
236
|
+
/**
|
|
237
|
+
* Turn a loaded session's stored transcript back into the same shape the live
|
|
238
|
+
* TurnEvent stream uses, so a session switch in the UI can redraw the whole
|
|
239
|
+
* conversation with the one set of renderers instead of a second code path.
|
|
240
|
+
* A synthetic user entry (an internal nudge, never typed by the person) is
|
|
241
|
+
* skipped — see Entry's `synthetic` field.
|
|
242
|
+
*/
|
|
243
|
+
export function replayHistory(session) {
|
|
244
|
+
const calls = new Map();
|
|
245
|
+
const readThisTurn = new Set(); // for narrationShown, as live
|
|
246
|
+
const out = [];
|
|
247
|
+
// What compaction took out comes first, so the chat shows the whole conversation.
|
|
248
|
+
// None of it can be rewound to: it is no longer part of the conversation.
|
|
249
|
+
const earlier = new Set(session.earlier ?? []);
|
|
250
|
+
for (const entry of [...earlier, ...session.transcript]) {
|
|
251
|
+
if (entry.role === "user") {
|
|
252
|
+
if (!entry.synthetic)
|
|
253
|
+
readThisTurn.clear();
|
|
254
|
+
// Shown as typed: file bodies hidden and pastes collapsed, the same as it looked live.
|
|
255
|
+
if (!entry.synthetic) {
|
|
256
|
+
const paths = (entry.images ?? []).map((i) => i.path);
|
|
257
|
+
const files = attachedFiles(entry.content, session.cwd);
|
|
258
|
+
const shown = splitExpiredImages(displayText(entry.content));
|
|
259
|
+
out.push({
|
|
260
|
+
...shown,
|
|
261
|
+
type: "userMessage",
|
|
262
|
+
text: hideAttachedNames(shown.text, [...paths, ...files]),
|
|
263
|
+
...(paths.length ? { images: paths } : {}),
|
|
264
|
+
...(files.length ? { files } : {}),
|
|
265
|
+
// Typed mid-turn, or from before messages were stamped: nothing to go back to.
|
|
266
|
+
...(isRewindPoint(entry) && !earlier.has(entry) ? {} : { noRewind: true }),
|
|
267
|
+
});
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
else if (entry.role === "assistant") {
|
|
271
|
+
// Words that led only to unseen tools were left out live, and are left out here too.
|
|
272
|
+
const stepCalls = (entry.toolCalls ?? []).map((c) => ({ name: c.name, args: safeParseArgs(c.arguments) }));
|
|
273
|
+
const shown = stepCalls.length === 0 || narrationShown(stepCalls, readThisTurn);
|
|
274
|
+
noteReads(stepCalls, readThisTurn);
|
|
275
|
+
// RECAP_STUB is compaction's note to the MODEL that an old status reply was
|
|
276
|
+
// condensed; it is not something the agent said, so it is never shown.
|
|
277
|
+
if (shown && entry.content.trim() && entry.content !== RECAP_STUB)
|
|
278
|
+
out.push({ type: "assistantMessage", text: entry.content });
|
|
279
|
+
for (const call of entry.toolCalls ?? [])
|
|
280
|
+
calls.set(call.id, call);
|
|
281
|
+
}
|
|
282
|
+
else if (entry.role === "tool") {
|
|
283
|
+
const call = calls.get(entry.toolCallId);
|
|
284
|
+
// Quiet results, and loading a tool even from sessions saved before it was quiet. A search
|
|
285
|
+
// is quiet too, but it is still replayed (marked quiet): a front end may list what was searched.
|
|
286
|
+
if (call?.name === "find_tools")
|
|
287
|
+
continue;
|
|
288
|
+
if (entry.quiet && call?.name !== "search")
|
|
289
|
+
continue;
|
|
290
|
+
// The same display name a live toolStart carries ("Run", "Read"), not the raw
|
|
291
|
+
// tool id — a reopened session was showing `run_command` where the live one said Run.
|
|
292
|
+
let name = entry.displayName ?? call?.name ?? "tool";
|
|
293
|
+
let arg;
|
|
294
|
+
let kind = entry.displayKind ?? "meta";
|
|
295
|
+
const callArgs = call ? safeParseArgs(call.arguments) : undefined;
|
|
296
|
+
if (call) {
|
|
297
|
+
const d = toolDisplay(call.name, callArgs ?? {});
|
|
298
|
+
if (!entry.displayName)
|
|
299
|
+
name = d.name;
|
|
300
|
+
arg = d.arg;
|
|
301
|
+
if (!entry.displayKind)
|
|
302
|
+
kind = d.kind;
|
|
303
|
+
}
|
|
304
|
+
out.push({
|
|
305
|
+
type: "toolReplay",
|
|
306
|
+
name,
|
|
307
|
+
...(call ? { tool: call.name, args: clipArgs(callArgs) } : {}),
|
|
308
|
+
arg,
|
|
309
|
+
kind,
|
|
310
|
+
ok: !entry.isError,
|
|
311
|
+
summary: entry.summary ?? entry.content,
|
|
312
|
+
detail: entry.detail,
|
|
313
|
+
detailKind: entry.detailKind,
|
|
314
|
+
...(entry.imagePaths?.length ? { images: entry.imagePaths } : {}),
|
|
315
|
+
...(entry.web ? { web: entry.web } : {}),
|
|
316
|
+
...(entry.ui ? { ui: entry.ui } : {}),
|
|
317
|
+
...(entry.quiet ? { quiet: true } : {}),
|
|
318
|
+
});
|
|
319
|
+
}
|
|
320
|
+
else if (entry.role === "summary") {
|
|
321
|
+
out.push({ type: "compactionSummary", text: entry.content });
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
return out;
|
|
325
|
+
}
|
|
326
|
+
/** Resolve dropped/attached file paths into ImageRefs, same checks the CLI applies
|
|
327
|
+
* (format, size, dimensions). A rejected file is reported through `onEvent` and
|
|
328
|
+
* otherwise just left out — it never blocks the rest of the message from sending. */
|
|
329
|
+
async function resolveImages(paths, onEvent) {
|
|
330
|
+
const refs = [];
|
|
331
|
+
for (const path of paths) {
|
|
332
|
+
try {
|
|
333
|
+
const st = await stat(path);
|
|
334
|
+
const described = await describeImage(path, st.size);
|
|
335
|
+
if (isRejection(described))
|
|
336
|
+
onEvent({ type: "activity", line: `${path}: ${described.reason}`, error: true });
|
|
337
|
+
else
|
|
338
|
+
refs.push(described);
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
onEvent({ type: "activity", line: `${path}: couldn't read that file`, error: true });
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
return refs;
|
|
345
|
+
}
|
|
346
|
+
function safeParseArgs(raw) {
|
|
347
|
+
try {
|
|
348
|
+
return JSON.parse(raw);
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
return {};
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
/**
|
|
355
|
+
* Wire the client-side channels a session needs beyond `respond()` itself — the
|
|
356
|
+
* same seam App.tsx's `attachApproval` fills for the CLI. Without `requestApproval`
|
|
357
|
+
* every approval call fails CLOSED (see tools/approval.ts): reads and in-workspace
|
|
358
|
+
* writes still work, but a forbidden-path lift, a write outside the workspace, an
|
|
359
|
+
* ask_user question, or reading another agent's data all get refused outright
|
|
360
|
+
* instead of asking. Call this once per session, right after `startSession` /
|
|
361
|
+
* `loadSession`, before the first `runTurn`.
|
|
362
|
+
*/
|
|
363
|
+
export function attachHandlers(session, handlers) {
|
|
364
|
+
if (handlers.requestApproval)
|
|
365
|
+
session.toolContext.requestApproval = handlers.requestApproval;
|
|
366
|
+
if (handlers.interrupt)
|
|
367
|
+
session.toolContext.interrupt = handlers.interrupt;
|
|
368
|
+
if (handlers.onModeChange)
|
|
369
|
+
session.toolContext.onModeChange = handlers.onModeChange;
|
|
370
|
+
if (handlers.onLive)
|
|
371
|
+
session.toolContext.onLive = handlers.onLive;
|
|
372
|
+
}
|
|
373
|
+
/**
|
|
374
|
+
* Esc: stop everything the agent has running. The caller aborts the turn itself; this
|
|
375
|
+
* stops what outlives a turn: every background command (and so any app one launched)
|
|
376
|
+
* and the app being tested with its live view. Background commands are stopped as by
|
|
377
|
+
* the USER, so the model is told they were stopped rather than woken to restart them.
|
|
378
|
+
*/
|
|
379
|
+
export async function stopEverything(session) {
|
|
380
|
+
const mgr = session.toolContext.backgroundShells;
|
|
381
|
+
for (const sh of mgr?.running() ?? [])
|
|
382
|
+
mgr?.kill(sh.id, "user");
|
|
383
|
+
await stopUi(session.toolContext);
|
|
384
|
+
}
|
|
385
|
+
/** The mode a session's CURRENT flags amount to — read after load, or after
|
|
386
|
+
* `onModeChange` fires, to show the UI what's actually active. */
|
|
387
|
+
export function sessionMode(session) {
|
|
388
|
+
return modeFromFlags(session.toolContext);
|
|
389
|
+
}
|
|
390
|
+
/**
|
|
391
|
+
* Switch a session's interaction mode — same thing shift-tab does in the CLI.
|
|
392
|
+
* A client concept: sets the `planMode`/`guarded` flags the engine reads, never
|
|
393
|
+
* anything the engine itself knows the name of. Entering Sentinel clears any
|
|
394
|
+
* earlier per-tool grants, so it starts genuinely vigilant rather than carrying
|
|
395
|
+
* over permissions given under a looser mode.
|
|
396
|
+
*/
|
|
397
|
+
export function applySessionMode(session, id) {
|
|
398
|
+
const mode = modeById(id);
|
|
399
|
+
session.toolContext.planMode = mode.readOnly;
|
|
400
|
+
session.toolContext.guarded = mode.guarded;
|
|
401
|
+
if (mode.guarded)
|
|
402
|
+
session.toolContext.guardAllowed = undefined;
|
|
403
|
+
}
|
|
404
|
+
/**
|
|
405
|
+
* Run one turn: append the user's message, stream the reply, persist after every step.
|
|
406
|
+
* Mirrors App.tsx's streamRespond — same event mapping, without the reveal pacer.
|
|
407
|
+
*/
|
|
408
|
+
export async function runTurn(session, message, handlers) {
|
|
409
|
+
await reloadProjectMemory(session).catch(() => { });
|
|
410
|
+
const images = [...(message.images ?? []), ...(await resolveImages(message.imagePaths ?? [], handlers.onEvent))];
|
|
411
|
+
await recordUserMessage(session, message.content, images, message.arrival);
|
|
412
|
+
await streamTurn(session, handlers);
|
|
413
|
+
}
|
|
414
|
+
/**
|
|
415
|
+
* Adds the user's message to the conversation and writes it to disk at once, before any model
|
|
416
|
+
* call. The first save of a turn used to come after the model's first step, so a kill or a crash
|
|
417
|
+
* during a slow first answer lost the message the user had just sent.
|
|
418
|
+
*/
|
|
419
|
+
export async function recordUserMessage(session, content, images = [], arrival) {
|
|
420
|
+
session.transcript.push({
|
|
421
|
+
role: "user",
|
|
422
|
+
content,
|
|
423
|
+
// Sent by stopping the turn that was running (the app's "Send now"): the model is
|
|
424
|
+
// told it was stopped on purpose, as the CLI tells it after Esc.
|
|
425
|
+
...(arrival ? { arrival } : {}),
|
|
426
|
+
...(images.length > 0 ? { images } : {}),
|
|
427
|
+
});
|
|
428
|
+
await saveSession(session);
|
|
429
|
+
}
|
|
430
|
+
/**
|
|
431
|
+
* A reply that was being written when the app died. A front end that kept the streamed words
|
|
432
|
+
* hands them here on the next start; they join the conversation, marked as cut off, so the
|
|
433
|
+
* user (and the model) can see what had been said. Nothing is added if a saved reply already
|
|
434
|
+
* holds those words: the kill may have come just after the step was saved.
|
|
435
|
+
*/
|
|
436
|
+
export async function appendInterruptedReply(session, text) {
|
|
437
|
+
const words = text.trim();
|
|
438
|
+
if (!words)
|
|
439
|
+
return false;
|
|
440
|
+
const recent = session.transcript.slice(-4).filter((e) => e.role === "assistant" && typeof e.content === "string").map((e) => e.content.trim());
|
|
441
|
+
if (recent.some((c) => c && (c.includes(words.slice(0, 160)) || words.startsWith(c))))
|
|
442
|
+
return false;
|
|
443
|
+
session.transcript.push({ role: "assistant", content: `${words}\n\n(interrupted)` });
|
|
444
|
+
await saveSession(session);
|
|
445
|
+
return true;
|
|
446
|
+
}
|
|
447
|
+
/**
|
|
448
|
+
* What was typed, plus files added through the UI and long pastes, turned into what the
|
|
449
|
+
* model gets and what the chat shows. Shared by a fresh send and a message queued into a
|
|
450
|
+
* running turn, so the two can't resolve the same message differently.
|
|
451
|
+
*
|
|
452
|
+
* Files go through the CLI's own `resolveAttachments`, exactly as if they had been
|
|
453
|
+
* dropped into the prompt as quoted paths: images take the vision path when the running
|
|
454
|
+
* model can see them, text files arrive as `<attached_file>` blocks, binaries are skipped
|
|
455
|
+
* with a note. Pastes are wrapped with the CLI's `wrapPastedText`. So a message reads the
|
|
456
|
+
* same to the model whichever front end it was typed in.
|
|
457
|
+
*/
|
|
458
|
+
export async function prepareMessage(session, input, onEvent) {
|
|
459
|
+
const paths = input.filePaths ?? [];
|
|
460
|
+
const withPaths = paths.length
|
|
461
|
+
? `${input.text}${input.text ? "\n" : ""}${paths.map((p) => `"${p}"`).join(" ")}`
|
|
462
|
+
: input.text;
|
|
463
|
+
const model = session.modelConfig.model;
|
|
464
|
+
const canSee = manifestForModel(model).acceptsImages?.(model) ?? false;
|
|
465
|
+
const resolved = await resolveAttachments(withPaths, session.cwd, canSee);
|
|
466
|
+
// Only what went wrong is news; "attached a.ts (+40 lines)" is already on screen as a chip.
|
|
467
|
+
for (const note of resolved.notes) {
|
|
468
|
+
if (/^skipped |can't see images/.test(note))
|
|
469
|
+
onEvent({ type: "activity", line: note, error: true });
|
|
470
|
+
}
|
|
471
|
+
const pastes = (input.pastes ?? []).filter((p) => p.trim().length > 0);
|
|
472
|
+
const content = pastes.length
|
|
473
|
+
? `${resolved.modelText}${resolved.modelText ? "\n\n" : ""}${pastes.map(wrapPastedText).join("\n\n")}`
|
|
474
|
+
: resolved.modelText;
|
|
475
|
+
const imagePaths = resolved.images.map((i) => i.path);
|
|
476
|
+
const files = attachedFiles(content, session.cwd);
|
|
477
|
+
return { content, images: resolved.images, imagePaths, files, displayText: hideAttachedNames(displayText(content), [...imagePaths, ...files]) };
|
|
478
|
+
}
|
|
479
|
+
/** A user message as the chat shows it: attached file bodies hidden, pastes as their chip. */
|
|
480
|
+
function displayText(content) {
|
|
481
|
+
return collapsePastes(stripAttachments(content));
|
|
482
|
+
}
|
|
483
|
+
/**
|
|
484
|
+
* Continue a session with NO new message — the engine notices what changed on its
|
|
485
|
+
* own (a background shell finished) from state already on the session. Mirrors the
|
|
486
|
+
* CLI's `reactToBackground`: it calls the exact same `streamRespond` a typed message
|
|
487
|
+
* would, just without anything new having been said.
|
|
488
|
+
*/
|
|
489
|
+
/**
|
|
490
|
+
* Compact the conversation now, whatever its size: the app's button, the CLI's /compact.
|
|
491
|
+
* It sends the same compaction events an automatic one does inside a turn, then saves.
|
|
492
|
+
*/
|
|
493
|
+
export async function compactSession(session, onEvent) {
|
|
494
|
+
await compactNow(session, {
|
|
495
|
+
onActivity: (line, opts) => onEvent({ type: "activity", line, error: opts?.error }),
|
|
496
|
+
onCompaction: (report) => onEvent({ type: "compaction", before: report.before, after: report.after, window: report.window }),
|
|
497
|
+
onCompactionStart: () => onEvent({ type: "compactionStart" }),
|
|
498
|
+
onCompactionEnd: () => onEvent({ type: "compactionEnd" }),
|
|
499
|
+
});
|
|
500
|
+
await saveSession(session);
|
|
501
|
+
}
|
|
502
|
+
export function continueTurn(session, handlers) {
|
|
503
|
+
return streamTurn(session, handlers);
|
|
504
|
+
}
|
|
505
|
+
/** Re-export of the CLI's own wake decision (cli/backgroundWake.ts) — plain state in,
|
|
506
|
+
* boolean out, no Ink involved — so a caller can reuse the exact same rule for when a
|
|
507
|
+
* finished background command is worth waking the model for. */
|
|
508
|
+
export { shouldReactToBackground } from "../cli/backgroundWake.js";
|
|
509
|
+
/** A tool call's arguments for display: long strings (a whole file being written) are cut, since a front end
|
|
510
|
+
* that wants the file reads it from disk. */
|
|
511
|
+
const ARG_TEXT_MAX = 2000;
|
|
512
|
+
export function clipArgs(args) {
|
|
513
|
+
const clip = (v) => typeof v === "string" ? (v.length > ARG_TEXT_MAX ? v.slice(0, ARG_TEXT_MAX) : v)
|
|
514
|
+
: Array.isArray(v) ? v.map(clip)
|
|
515
|
+
: v && typeof v === "object" ? Object.fromEntries(Object.entries(v).map(([k, x]) => [k, clip(x)]))
|
|
516
|
+
: v;
|
|
517
|
+
return clip(args ?? {});
|
|
518
|
+
}
|
|
519
|
+
/**
|
|
520
|
+
* One engine event, as the plain TurnEvent a front end draws. Shared by an ordinary turn
|
|
521
|
+
* and a Marathon run so a tool call, a task list or a token reads identically in both.
|
|
522
|
+
*/
|
|
523
|
+
export function forwardEngineEvent(e, onEvent) {
|
|
524
|
+
if (e.type === "text") {
|
|
525
|
+
onEvent({ type: "text", delta: e.delta });
|
|
526
|
+
}
|
|
527
|
+
else if (e.type === "reasoning") {
|
|
528
|
+
onEvent({ type: "reasoning", delta: e.delta });
|
|
529
|
+
}
|
|
530
|
+
else if (e.type === "todos") {
|
|
531
|
+
onEvent({ type: "todos", items: e.items });
|
|
532
|
+
}
|
|
533
|
+
else if (e.type === "narration") {
|
|
534
|
+
onEvent({ type: "narration", shown: e.shown });
|
|
535
|
+
}
|
|
536
|
+
else if (e.type === "replyReset") {
|
|
537
|
+
onEvent({ type: "replyReset" });
|
|
538
|
+
}
|
|
539
|
+
else if (e.type === "tool" && e.phase === "start") {
|
|
540
|
+
// The spawn is rendered by the subagentStart block; loading a tool is never shown.
|
|
541
|
+
if (e.name === "spawn_subagent" || e.name === "find_tools")
|
|
542
|
+
return;
|
|
543
|
+
const d = toolDisplay(e.name, e.args);
|
|
544
|
+
if (e.agent) {
|
|
545
|
+
onEvent({ type: "subToolStart", agentId: e.agent, toolId: e.id, name: d.name, arg: d.arg, kind: d.kind });
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
onEvent({ type: "toolStart", id: e.id, name: d.name, arg: d.arg, kind: d.kind, tool: e.name, group: isGroupable(e.name), args: clipArgs(e.args), ...(d.covers ? { covers: d.covers } : {}) });
|
|
549
|
+
}
|
|
550
|
+
}
|
|
551
|
+
else if (e.type === "tool" && e.phase === "end") {
|
|
552
|
+
if (e.name === "spawn_subagent" || e.name === "find_tools")
|
|
553
|
+
return;
|
|
554
|
+
if (e.agent) {
|
|
555
|
+
onEvent({ type: "subToolEnd", agentId: e.agent, toolId: e.id, ok: !e.error, summary: e.summary });
|
|
556
|
+
}
|
|
557
|
+
else {
|
|
558
|
+
onEvent({ type: "toolEnd", id: e.id, ok: !e.error, summary: e.summary, detail: e.detail, detailFull: e.detailFull, detailKind: e.detailKind, quiet: e.quiet, ...(e.images?.length ? { images: e.images } : {}), ...(e.web ? { web: e.web } : {}), ...(e.ui ? { ui: e.ui } : {}) });
|
|
559
|
+
}
|
|
560
|
+
}
|
|
561
|
+
else if (e.type === "tool" && e.phase === "progress") {
|
|
562
|
+
// What a call that takes a while is doing meanwhile (the ui tool waiting for an
|
|
563
|
+
// app to finish building). Latest-wins text, not a log.
|
|
564
|
+
if (!e.agent)
|
|
565
|
+
onEvent({ type: "toolProgress", id: e.id, text: e.text });
|
|
566
|
+
}
|
|
567
|
+
else if (e.type === "subagent" && e.phase === "start") {
|
|
568
|
+
onEvent({ type: "subagentStart", id: e.id, task: e.task, readOnly: e.readOnly });
|
|
569
|
+
}
|
|
570
|
+
else if (e.type === "subagent" && e.phase === "end") {
|
|
571
|
+
onEvent({ type: "subagentEnd", id: e.id, ok: !e.error, summary: e.summary });
|
|
572
|
+
}
|
|
573
|
+
else if (e.type === "usage") {
|
|
574
|
+
onEvent({ type: "usage", promptTokens: e.promptTokens, completionTokens: e.completionTokens, totalTokens: e.totalTokens });
|
|
575
|
+
}
|
|
576
|
+
}
|
|
577
|
+
/**
|
|
578
|
+
* A key refused for credit, a rate limit or rejection: when the user allowed it, move to
|
|
579
|
+
* their next key so the caller can carry on from where the turn stopped. A refusal happens
|
|
580
|
+
* on the status line, before any reply arrived, so nothing is half-written to resume over.
|
|
581
|
+
* True when it switched; false leaves the error for the caller to report.
|
|
582
|
+
*/
|
|
583
|
+
function tryFailover(session, error, signal, triedKeys, onEvent) {
|
|
584
|
+
const provider = providerOf(session.modelConfig.model);
|
|
585
|
+
const status = statusOf(error);
|
|
586
|
+
if (status === null || signal?.aborted || !accessRefusal(error, provider.label, false))
|
|
587
|
+
return false;
|
|
588
|
+
const moved = failoverKey(manifestForModel(session.modelConfig.model).apiKeyEnv, status, triedKeys);
|
|
589
|
+
if (!moved)
|
|
590
|
+
return false;
|
|
591
|
+
const why = moved.reason === "no-credit" ? "is out of credit" : moved.reason === "rate-limited" ? "hit its rate limit" : "was rejected";
|
|
592
|
+
onEvent({ type: "activity", line: `${provider.label} key ${moved.from} ${why}, switched to ${moved.to}` });
|
|
593
|
+
return true;
|
|
594
|
+
}
|
|
595
|
+
async function streamTurn(session, handlers) {
|
|
596
|
+
const { onEvent, steer, signal } = handlers;
|
|
597
|
+
// Keys already refused in THIS turn, so switching can't go round in a circle.
|
|
598
|
+
const triedKeys = new Set();
|
|
599
|
+
for (;;) {
|
|
600
|
+
try {
|
|
601
|
+
await respond(session, {
|
|
602
|
+
steer,
|
|
603
|
+
signal,
|
|
604
|
+
onActivity: (line, opts) => onEvent({ type: "activity", line, error: opts?.error }),
|
|
605
|
+
onCompaction: (report) => onEvent({ type: "compaction", before: report.before, after: report.after, window: report.window }),
|
|
606
|
+
onCompactionStart: () => onEvent({ type: "compactionStart" }),
|
|
607
|
+
onCompactionEnd: () => onEvent({ type: "compactionEnd" }),
|
|
608
|
+
onEvent: (e) => forwardEngineEvent(e, onEvent),
|
|
609
|
+
persist: () => saveSession(session),
|
|
610
|
+
});
|
|
611
|
+
noteKeyWorked(manifestForModel(session.modelConfig.model).apiKeyEnv);
|
|
612
|
+
onEvent({ type: "done" });
|
|
613
|
+
await saveSession(session);
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
catch (error) {
|
|
617
|
+
if (tryFailover(session, error, signal, triedKeys, onEvent))
|
|
618
|
+
continue;
|
|
619
|
+
reportFailure(session, error, onEvent);
|
|
620
|
+
onEvent({ type: "done" });
|
|
621
|
+
await saveSession(session);
|
|
622
|
+
return;
|
|
623
|
+
}
|
|
624
|
+
}
|
|
625
|
+
}
|
|
626
|
+
/** A turn that ended in an error, as a notice (a refusal or an outage the user can act on)
|
|
627
|
+
* or a plain error line. */
|
|
628
|
+
function reportFailure(session, error, onEvent) {
|
|
629
|
+
const refusal = accessRefusal(error, providerOf(session.modelConfig.model).label, false) ??
|
|
630
|
+
providerOutage(error, providerOf(session.modelConfig.model).label, modelLabel(session.modelConfig.model));
|
|
631
|
+
if (refusal)
|
|
632
|
+
onEvent({ type: "notice", title: refusal.title, body: refusal.body });
|
|
633
|
+
else
|
|
634
|
+
onEvent({ type: "error", text: errText(error) });
|
|
635
|
+
}
|
|
636
|
+
/**
|
|
637
|
+
* Run a Marathon for a front end: the same events an ordinary turn streams, plus the
|
|
638
|
+
* run's own progress and the live task list, with the same key failover and error
|
|
639
|
+
* reporting. `run` is the start/resume call; `override` swaps the engine calls for a fake
|
|
640
|
+
* in a test.
|
|
641
|
+
*/
|
|
642
|
+
async function driveMarathon(session, handlers, run, override = {}) {
|
|
643
|
+
const { onEvent, steer, signal } = handlers;
|
|
644
|
+
const triedKeys = new Set();
|
|
645
|
+
const deps = {
|
|
646
|
+
// Every turn of the run gets the failover an ordinary turn gets.
|
|
647
|
+
respond: async (s, options) => {
|
|
648
|
+
for (;;) {
|
|
649
|
+
try {
|
|
650
|
+
return await respond(s, options);
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
if (tryFailover(s, error, signal, triedKeys, onEvent))
|
|
654
|
+
continue;
|
|
655
|
+
throw error;
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
},
|
|
659
|
+
verify: verifyGoal,
|
|
660
|
+
sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)),
|
|
661
|
+
...override,
|
|
662
|
+
};
|
|
663
|
+
let state = null;
|
|
664
|
+
try {
|
|
665
|
+
state = await run({
|
|
666
|
+
signal,
|
|
667
|
+
...(steer ? { steer } : {}),
|
|
668
|
+
...(handlers.limits ? { limits: handlers.limits } : {}),
|
|
669
|
+
onActivity: (line, opts) => onEvent({ type: "activity", line, error: opts?.error }),
|
|
670
|
+
onCompaction: (report) => onEvent({ type: "compaction", before: report.before, after: report.after, window: report.window }),
|
|
671
|
+
onCompactionStart: () => onEvent({ type: "compactionStart" }),
|
|
672
|
+
onCompactionEnd: () => onEvent({ type: "compactionEnd" }),
|
|
673
|
+
onEvent: (e) => forwardEngineEvent(e, onEvent),
|
|
674
|
+
onMarathon: (event) => onEvent({ type: "marathon", event, text: describeMarathonEvent(event) }),
|
|
675
|
+
persist: () => saveSession(session),
|
|
676
|
+
}, deps);
|
|
677
|
+
noteKeyWorked(manifestForModel(session.modelConfig.model).apiKeyEnv);
|
|
678
|
+
}
|
|
679
|
+
catch (error) {
|
|
680
|
+
// The run stays "running" on the session, so it can be resumed once the cause is fixed.
|
|
681
|
+
reportFailure(session, error, onEvent);
|
|
682
|
+
}
|
|
683
|
+
onEvent({ type: "done" });
|
|
684
|
+
await saveSession(session);
|
|
685
|
+
return state;
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Start a Marathon on `goal`: the user armed it and sent their goal. Replaces any
|
|
689
|
+
* Marathon the session already holds. Resolves when the run reaches an outcome or is
|
|
690
|
+
* stopped (the same signal that stops a turn); a stopped run stays resumable.
|
|
691
|
+
*/
|
|
692
|
+
export async function startMarathonRun(session, goal, handlers, override) {
|
|
693
|
+
// Same preparation as the first message of any turn: the project's notes re-read, and
|
|
694
|
+
// attached images resolved (and rejected loudly) the way `runTurn` does it.
|
|
695
|
+
const message = typeof goal === "string" ? { content: goal } : goal;
|
|
696
|
+
await reloadProjectMemory(session).catch(() => { });
|
|
697
|
+
const goalImages = [
|
|
698
|
+
...(("images" in message && message.images) || []),
|
|
699
|
+
...(await resolveImages(("imagePaths" in message && message.imagePaths) || [], handlers.onEvent)),
|
|
700
|
+
];
|
|
701
|
+
return driveMarathon(session, handlers, (options, deps) => startMarathon(session, message.content, { ...options, ...(goalImages.length ? { goalImages } : {}) }, deps), override);
|
|
702
|
+
}
|
|
703
|
+
/** Carry on a Marathon that was stopped, or that a restart interrupted. Resolves null
|
|
704
|
+
* when there is nothing to resume. */
|
|
705
|
+
export function resumeMarathonRun(session, handlers, override) {
|
|
706
|
+
return driveMarathon(session, handlers, (options, deps) => resumeMarathon(session, options, deps), override);
|
|
707
|
+
}
|
|
708
|
+
/** Close a Marathon for good: dropped from the session AND from its saved file, so
|
|
709
|
+
* reopening the session later does not bring a dismissed run back. */
|
|
710
|
+
export async function dismissMarathon(session) {
|
|
711
|
+
clearMarathon(session);
|
|
712
|
+
await saveSession(session);
|
|
713
|
+
}
|
|
714
|
+
/** The session's Marathon, if it has one — running, stopped, or finished. */
|
|
715
|
+
export function marathonOf(session) {
|
|
716
|
+
return session.marathon ?? null;
|
|
717
|
+
}
|
|
718
|
+
/**
|
|
719
|
+
* Start a command the USER asked for (the app's Run button) as a background shell, through
|
|
720
|
+
* the same run_command tool the agent uses, so it lands in the same shell list, the agent
|
|
721
|
+
* can read its output, and stopping it is the same kill. The click is the approval, so no
|
|
722
|
+
* gate is asked. `notify: "never"` because the user starting their own app is not an event
|
|
723
|
+
* to wake the model for. Returns the shell id, or an error message.
|
|
724
|
+
*/
|
|
725
|
+
export async function startUserCommand(session, command, cwd) {
|
|
726
|
+
// A fresh context: the session's may still carry the last turn's (aborted) signal.
|
|
727
|
+
// `cwd` is for an app that lives in a subfolder of the project.
|
|
728
|
+
const ctx = { ...session.toolContext, abortSignal: undefined, cwd: cwd ?? session.toolContext.cwd };
|
|
729
|
+
// hidden: false because the user pressed Run to see their app, whatever its script says.
|
|
730
|
+
const result = await runCommand.execute({ command, run_in_background: true, notify: "never", hidden: false }, ctx);
|
|
731
|
+
const id = /shell #(\d+)/.exec(`${result.summary ?? ""} ${result.output}`)?.[1];
|
|
732
|
+
if (result.isError || !id)
|
|
733
|
+
return { error: result.output || "Could not start the command." };
|
|
734
|
+
return { id: Number(id) };
|
|
735
|
+
}
|
|
736
|
+
/** The project's MINDWEAVE.md (in the project root, the notebook the agent keeps) and
|
|
737
|
+
* what it says. A missing file is empty, not an error. */
|
|
738
|
+
export async function readProjectNotes(cwd) {
|
|
739
|
+
const path = join(cwd, NOTES_FILE);
|
|
740
|
+
try {
|
|
741
|
+
const [text, info] = await Promise.all([readFile(path, "utf8"), stat(path)]);
|
|
742
|
+
return { path, text, exists: true, modifiedAt: info.mtimeMs };
|
|
743
|
+
}
|
|
744
|
+
catch {
|
|
745
|
+
return { path, text: "", exists: false, modifiedAt: null };
|
|
746
|
+
}
|
|
747
|
+
}
|
|
748
|
+
/**
|
|
749
|
+
* Replace the project's MINDWEAVE.md, then re-read it into the live session so the NEXT
|
|
750
|
+
* message already follows it. That costs one prompt-cache rebuild, which is what an edit
|
|
751
|
+
* the user made on purpose is worth; the model's own mid-session edits keep the cheaper
|
|
752
|
+
* deferred path.
|
|
753
|
+
*/
|
|
754
|
+
export async function saveProjectNotes(cwd, text, session) {
|
|
755
|
+
await writeFileAtomic(join(cwd, NOTES_FILE), text);
|
|
756
|
+
if (session)
|
|
757
|
+
await reloadProjectMemory(session, { force: true });
|
|
758
|
+
}
|
|
759
|
+
//# sourceMappingURL=turnRunner.js.map
|