mocode-ai 1.3.10 → 1.4.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.
Files changed (134) hide show
  1. package/README.md +78 -70
  2. package/README.zh-CN.md +81 -73
  3. package/bin/mocode-agent-host.js +1 -1
  4. package/dist/agent/core.js +226 -293
  5. package/dist/agent/index.js +14 -10
  6. package/dist/agent/mode.js +3 -3
  7. package/dist/agent/runtime-context.js +45 -0
  8. package/dist/agent/spawn.js +101 -86
  9. package/dist/agent/tool-helpers.js +134 -0
  10. package/dist/agent/trace-state.js +118 -0
  11. package/dist/agents/coordinator.js +6 -1
  12. package/dist/changeset/index.js +9 -8
  13. package/dist/config/index.js +109 -77
  14. package/dist/config/presets.js +3 -1
  15. package/dist/config/profiles.js +125 -0
  16. package/dist/context/age-aware.js +2 -4
  17. package/dist/context/artifacts.js +6 -13
  18. package/dist/context/budget.js +6 -8
  19. package/dist/context/classifier.js +1 -2
  20. package/dist/context/encoders/code.js +2 -6
  21. package/dist/context/encoders/command.js +1 -3
  22. package/dist/context/encoders/search.js +1 -3
  23. package/dist/context/index.js +1 -1
  24. package/dist/context/lifecycle.js +5 -3
  25. package/dist/context/pipeline.js +1 -1
  26. package/dist/context/relevance.js +2 -4
  27. package/dist/context/token-calibration.js +13 -18
  28. package/dist/host/stdio.js +73 -12
  29. package/dist/i18n/index.js +96 -44
  30. package/dist/index.js +1 -1
  31. package/dist/llm/index.js +39 -23
  32. package/dist/llm/provider.js +23 -0
  33. package/dist/llm/providers/anthropic.js +12 -18
  34. package/dist/llm/tool-schema.js +48 -0
  35. package/dist/mcp/client.js +70 -25
  36. package/dist/mcp/config.js +1 -3
  37. package/dist/mcp/index.js +3 -1
  38. package/dist/memory/reflect.js +3 -6
  39. package/dist/memory/store.js +10 -12
  40. package/dist/permissions/index.js +66 -22
  41. package/dist/pet/bridge.js +2 -2
  42. package/dist/repl/commands/appearance.js +72 -0
  43. package/dist/repl/commands/compact.js +114 -0
  44. package/dist/repl/commands/context.js +17 -0
  45. package/dist/repl/commands/image.js +71 -0
  46. package/dist/repl/commands/memory.js +51 -0
  47. package/dist/repl/commands/mode.js +47 -0
  48. package/dist/repl/commands/model.js +435 -0
  49. package/dist/repl/commands/pet.js +70 -0
  50. package/dist/repl/commands/registry.js +46 -0
  51. package/dist/repl/commands/session.js +102 -0
  52. package/dist/repl/commands/skill.js +95 -0
  53. package/dist/repl/commands/system.js +202 -0
  54. package/dist/repl/commands/tool-group.js +233 -0
  55. package/dist/repl/commands/types.js +4 -0
  56. package/dist/repl/commands.js +324 -0
  57. package/dist/repl/index.js +5 -2581
  58. package/dist/repl/message-format.js +151 -0
  59. package/dist/repl/running-input.js +147 -0
  60. package/dist/repl/runtime.js +816 -0
  61. package/dist/repl/status-bar.js +161 -0
  62. package/dist/rollback/index.js +44 -14
  63. package/dist/runtime/browser-manager.js +1 -1
  64. package/dist/runtime/dev-server-manager.js +1 -1
  65. package/dist/runtime/input-injector.js +313 -0
  66. package/dist/runtime/screen-capture.js +89 -0
  67. package/dist/runtime/screen-pipeline.js +223 -0
  68. package/dist/sandbox/index.js +1 -1
  69. package/dist/sandbox/policy.js +8 -2
  70. package/dist/session/compact.js +22 -34
  71. package/dist/session/index.js +2 -2
  72. package/dist/session/notes.js +39 -5
  73. package/dist/session/persist.js +14 -8
  74. package/dist/session/scheduler.js +3 -4
  75. package/dist/session/trace-metrics.js +6 -3
  76. package/dist/skills/activation.js +10 -5
  77. package/dist/skills/discover.js +5 -17
  78. package/dist/skills/index.js +1 -1
  79. package/dist/skills/runner.js +28 -44
  80. package/dist/skills/toolmap.js +5 -2
  81. package/dist/tools/builtins/browser.js +17 -3
  82. package/dist/tools/builtins/computer.js +322 -0
  83. package/dist/tools/builtins/dev-server.js +1 -3
  84. package/dist/tools/builtins/edit-file.js +21 -7
  85. package/dist/tools/builtins/grep.js +1 -1
  86. package/dist/tools/builtins/index.js +28 -25
  87. package/dist/tools/builtins/memory-forget.js +1 -1
  88. package/dist/tools/builtins/memory-graph.js +4 -7
  89. package/dist/tools/builtins/memory-list.js +2 -6
  90. package/dist/tools/builtins/memory-save.js +8 -2
  91. package/dist/tools/builtins/memory-search.js +4 -4
  92. package/dist/tools/builtins/note-append.js +14 -3
  93. package/dist/tools/builtins/plan-update.js +40 -9
  94. package/dist/tools/builtins/run-command.js +8 -8
  95. package/dist/tools/builtins/screenshot.js +11 -87
  96. package/dist/tools/builtins/task.js +29 -40
  97. package/dist/tools/builtins/use-skill.js +1 -1
  98. package/dist/tools/builtins/view-image.js +5 -3
  99. package/dist/tools/builtins/web-fetch.js +1 -3
  100. package/dist/tools/builtins/web-search.js +3 -7
  101. package/dist/tools/builtins/write-file.js +4 -2
  102. package/dist/tools/constants.js +50 -37
  103. package/dist/tools/policy.js +221 -0
  104. package/dist/tools/registry.js +40 -19
  105. package/dist/tools/resource-lock.js +4 -2
  106. package/dist/tools/router.js +143 -0
  107. package/dist/tools/validation.js +6 -5
  108. package/dist/ui/batch.js +63 -23
  109. package/dist/ui/clipboard.js +17 -5
  110. package/dist/ui/content.js +166 -13
  111. package/dist/ui/diff.js +61 -19
  112. package/dist/ui/fuzzy.js +35 -2
  113. package/dist/ui/intervention.js +2 -4
  114. package/dist/ui/layout-internal/content-write.js +587 -0
  115. package/dist/ui/layout-internal/core.js +590 -0
  116. package/dist/ui/layout-internal/input-paint.js +365 -0
  117. package/dist/ui/layout-internal/screen.js +128 -0
  118. package/dist/ui/layout-internal/scroll.js +152 -0
  119. package/dist/ui/layout-internal/selection.js +229 -0
  120. package/dist/ui/layout-internal/state.js +82 -0
  121. package/dist/ui/layout-internal/statusbar.js +351 -0
  122. package/dist/ui/layout-types.js +1 -0
  123. package/dist/ui/layout.js +3 -2288
  124. package/dist/ui/markdown.js +10 -2
  125. package/dist/ui/prompt-internal/editor.js +945 -0
  126. package/dist/ui/prompt-internal/paste.js +52 -0
  127. package/dist/ui/prompt-internal/pickers.js +519 -0
  128. package/dist/ui/prompt-internal/types.js +1 -0
  129. package/dist/ui/prompt.js +4 -1506
  130. package/dist/ui/render.js +87 -2
  131. package/dist/verification/affected.js +11 -5
  132. package/dist/verification/discovery.js +1 -3
  133. package/dist/verification/profile.js +11 -3
  134. package/package.json +11 -2
@@ -1,2581 +1,5 @@
1
- import readline from 'node:readline/promises';
2
- import { emitKeypressEvents } from 'node:readline';
3
- import { stdin, stdout } from 'node:process';
4
- import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, isMcpEnabled, updateMcpConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, DEFAULT_CONTEXT_WINDOW_TOKENS, pinSessionModel, } from '../config/index.js';
5
- import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
- import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
7
- import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
8
- import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, setActivePresetName, } from '../config/presets.js';
9
- import { runAgent } from '../agent/index.js';
10
- import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
11
- import { togglePet, killPetProcess, listSkins, setSkin, sendState } from '../pet/bridge.js';
12
- import { setSandboxRoot } from '../sandbox/root.js';
13
- import { ui, setTheme, getTheme, listThemes, themeExists } from '../ui/theme.js';
14
- import { bannerLines, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
15
- import * as layout from '../ui/layout.js';
16
- import * as mouse from '../ui/mouse.js';
17
- import * as batch from '../ui/batch.js';
18
- import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
19
- import { promptIntervention } from '../ui/intervention.js';
20
- import { registerToolsExtension } from '../tools/registry.js';
21
- import { initializeAllMcp, getMcpTools, closeAllMcp } from '../mcp/index.js';
22
- import { estimateMessagesTokens, reconfigureClient, refreshChatTools, chatTools, estimatePromptTokens, estimateTokens, classifyChatError, } from '../llm/index.js';
23
- import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
24
- import { modelSupportsVision } from '../llm/capabilities.js';
25
- import { computePruneStats } from '../context/relevance.js';
26
- import { formatArtifactTokenSources } from '../context/artifacts.js';
27
- import { manualCompact, contextState, newSessionId, saveSession, loadSession, listSessions, appendCurrentSessionRuntimeEvent, hashTraceValue, } from '../session/index.js';
28
- import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, getCurrentTurnId, } from '../rollback/index.js';
29
- import { listSkills, effectiveSystemPrompt, findSkill, } from '../skills/index.js';
30
- import { clearSkillActivation } from '../skills/activation.js';
31
- import { runSkill, renderSkillBody } from '../skills/runner.js';
32
- import { isSkillTrusted } from '../skills/trust.js';
33
- import { buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
34
- import fs from 'node:fs';
35
- import path from 'node:path';
36
- import { getSandboxRoot } from '../sandbox/root.js';
37
- import { setCurrentSessionId, getCurrentSessionId } from '../session/state.js';
38
- import { collectQueryHistory } from '../session/query-history.js';
39
- import { checkVersion, fetchLatestVersion, getCurrentVersion, runUpgradeForeground, } from '../commands/upgrade.js';
40
- /**
41
- * readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
42
- * 颜色码会让光标错位、编辑时漂移。颜色只用在直接 stdout.write 的横幅 / 工具行 / 回复。
43
- */
44
- const PROMPT = '❯ ';
45
- /**
46
- * 斜杠命令树(仅用于输入菜单;分发仍走下方 if 链)。
47
- * 分支节点只负责导航,叶子的 value 保持现有命令文本,因此不破坏命令兼容性。
48
- */
49
- function buildSlashCommands() {
50
- const d = (key) => t(key);
51
- return [
52
- { name: '/help', desc: d('commands.help') },
53
- { name: '/exit', desc: d('commands.exit') },
54
- { name: '/clear', desc: d('commands.clear') },
55
- { name: '/context', desc: d('commands.context') },
56
- { name: '/skills', desc: d('commands.skills') },
57
- { name: '/skill', desc: '执行某个 skill(/skill <name> [args-json])' },
58
- { name: '/compact', desc: d('commands.compact') },
59
- { name: '/resume', desc: d('commands.sessionResume') },
60
- { name: '/sessions', desc: d('commands.sessionBrowse') },
61
- { name: '/rollback', desc: d('commands.sessionRollback') },
62
- {
63
- name: '/memory', desc: d('commands.memory'), children: [
64
- { name: 'overview', value: '/memory', desc: d('commands.memoryOverview') },
65
- { name: 'toggle', value: '/memory_switch', desc: d('commands.memoryToggle') },
66
- { name: 'on', value: '/memory_switch on', desc: d('commands.memoryOn') },
67
- { name: 'off', value: '/memory_switch off', desc: d('commands.memoryOff') },
68
- { name: 'status', value: '/memory_status', desc: d('commands.memoryStatus') },
69
- { name: 'reflect', value: '/reflect', desc: d('commands.memoryReflect') },
70
- ],
71
- },
72
- { name: '/init', desc: d('commands.memoryInit') },
73
- {
74
- name: '/subagent', desc: d('commands.subagent'), children: [
75
- { name: 'on', value: '/subagent on', desc: d('commands.subagentOn') },
76
- { name: 'off', value: '/subagent off', desc: d('commands.subagentOff') },
77
- { name: 'status', value: '/subagent status', desc: d('commands.subagentStatus') },
78
- ],
79
- },
80
- {
81
- name: '/fe', desc: d('commands.fe'), children: [
82
- { name: 'on', value: '/fe on', desc: d('commands.feOn') },
83
- { name: 'off', value: '/fe off', desc: d('commands.feOff') },
84
- { name: 'status', value: '/fe status', desc: d('commands.feStatus') },
85
- ],
86
- },
87
- {
88
- name: '/mcp', desc: d('commands.mcp'), children: [
89
- { name: 'on', value: '/mcp on', desc: d('commands.mcpOn') },
90
- { name: 'off', value: '/mcp off', desc: d('commands.mcpOff') },
91
- { name: 'status', value: '/mcp status', desc: d('commands.mcpStatus') },
92
- ],
93
- },
94
- { name: '/theme', desc: d('commands.theme') },
95
- {
96
- name: '/model', desc: d('commands.model'), children: [
97
- { name: 'configure', value: '/model', desc: d('commands.modelConfigure') },
98
- { name: 'switch', value: '/model switch', desc: d('commands.modelSwitch') },
99
- { name: 'list', value: '/model list', desc: d('commands.modelList') },
100
- { name: 'show', value: '/model show', desc: d('commands.modelShow') },
101
- { name: 'use <name>', value: '/model use ', submit: false, desc: d('commands.modelUse') },
102
- { name: 'delete <name>', value: '/model delete ', submit: false, desc: d('commands.modelDelete') },
103
- ],
104
- },
105
- {
106
- name: '/mode', desc: d('commands.mode'), children: [
107
- { name: 'plan', value: '/plan', desc: d('commands.modePlan') },
108
- { name: 'auto', value: '/auto', desc: d('commands.modeAuto') },
109
- ],
110
- },
111
- {
112
- name: '/pet', desc: d('commands.pet'), children: [
113
- { name: 'toggle', value: '/pet', desc: d('commands.petToggle') },
114
- { name: 'skin', value: '/pet skin', desc: d('commands.petSkin') },
115
- { name: 'quit', value: '/pet quit', desc: d('commands.petQuit') },
116
- ],
117
- },
118
- {
119
- name: '/image', desc: d('commands.image'), children: [
120
- { name: 'attach <path>', value: '/image ', submit: false, desc: d('commands.imageAttach') },
121
- { name: 'list', value: '/image list', desc: d('commands.imageList') },
122
- { name: 'clear', value: '/image clear', desc: d('commands.imageClear') },
123
- ],
124
- },
125
- {
126
- name: '/language', desc: d('commands.language'), children: [
127
- { name: 'zh-CN', value: '/language zh-CN', desc: d('commands.languageZh') },
128
- { name: 'en', value: '/language en', desc: d('commands.languageEn') },
129
- ],
130
- },
131
- {
132
- name: '/upgrade', desc: d('commands.upgrade'), children: [
133
- { name: 'now', value: '/upgrade', desc: d('commands.upgradeNow') },
134
- { name: 'check', value: '/upgrade check', desc: d('commands.upgradeCheck') },
135
- { name: 'status', value: '/upgrade status', desc: d('commands.upgradeStatus') },
136
- ],
137
- },
138
- ];
139
- }
140
- /** 从菜单树递归生成 /help 内容;叶子 value 与菜单路径不同则同时展示真实命令。 */
141
- function slashHelpLines(nodes = buildSlashCommands(), parentPath = '', depth = 0) {
142
- const lines = [];
143
- for (const node of nodes) {
144
- const menuPath = parentPath ? `${parentPath} ${node.name}` : node.name;
145
- const isBranch = Boolean(node.children?.length);
146
- const actual = node.value?.trimEnd();
147
- const mapping = actual && actual !== menuPath ? ` ${ui.dim}→ ${actual}${ui.reset}` : '';
148
- const marker = isBranch ? ` ${ui.dim}›${ui.reset}` : '';
149
- lines.push(`${' '.repeat(depth)}${ui.accent}${menuPath}${ui.reset}${marker}${mapping} ${ui.dim}${node.desc}${ui.reset}`);
150
- if (node.children?.length)
151
- lines.push(...slashHelpLines(node.children, menuPath, depth + 1));
152
- }
153
- return lines;
154
- }
155
- /** chat 常见错误类别 → 中文引导文案的 i18n 键(识别不出的错误不走这张表,保留原始诊断)。 */
156
- const LLM_ERROR_HINT_KEYS = {
157
- auth: 'repl.llmAuthError',
158
- quota: 'repl.llmQuotaError',
159
- timeout: 'repl.llmTimeoutError',
160
- network: 'repl.llmNetworkError',
161
- context: 'repl.llmContextError',
162
- };
163
- /** /help 的分组:按使用场景归组,组内保持菜单树顺序。未列入的顶层命令兜底进「其他」。 */
164
- const HELP_GROUPS = [
165
- { key: 'help.groupFrequent', names: ['/help', '/clear', '/context', '/compact', '/resume', '/model'] },
166
- { key: 'help.groupSession', names: ['/sessions', '/rollback', '/memory', '/skills', '/skill', '/init'] },
167
- { key: 'help.groupConfig', names: ['/mode', '/subagent', '/fe', '/mcp', '/theme', '/pet', '/image', '/language', '/upgrade'] },
168
- ];
169
- /**
170
- * 菜单树里全部可用命令名(含分支路径与叶子的真实命令),供未知命令纠错建议用。
171
- * /quit、/frontend 确实可用但没登记进菜单树,单独补上——否则用户照提示打了却仍被判未知。
172
- */
173
- function knownCommandNames() {
174
- const out = [];
175
- const walk = (nodes, parent) => {
176
- for (const node of nodes) {
177
- const path = parent ? `${parent} ${node.name}` : node.name;
178
- out.push(path);
179
- const actual = node.value?.trim();
180
- if (actual)
181
- out.push(actual.split(/\s+/)[0]);
182
- if (node.children?.length)
183
- walk(node.children, path);
184
- }
185
- };
186
- walk(buildSlashCommands(), '');
187
- out.push('/quit', '/frontend');
188
- return [...new Set(out)];
189
- }
190
- /**
191
- * 是否"长得像一条斜杠命令":单个前导 / + 词字符(字母开头,可含数字/下划线/连字符)。
192
- * 用来把 /hepl 这类拼写错误与 /tmp/foo、/src/index.ts:12:5 这类路径区分开——
193
- * 后者在编码 agent 里很常见,同样以 / 开头,绝不能被当成命令拦下。
194
- */
195
- function isCommandShape(cmd) {
196
- return /^\/[A-Za-z][\w-]*$/.test(cmd);
197
- }
198
- /** Levenshtein 距离(标准 DP)。短命令字符串,直接两行数组滚动即可。 */
199
- function editDistance(a, b) {
200
- if (a === b)
201
- return 0;
202
- if (!a.length)
203
- return b.length;
204
- if (!b.length)
205
- return a.length;
206
- let prev = Array.from({ length: b.length + 1 }, (_, i) => i);
207
- for (let i = 1; i <= a.length; i++) {
208
- const cur = [i];
209
- for (let j = 1; j <= b.length; j++) {
210
- cur[j] = Math.min(prev[j] + 1, // 删
211
- cur[j - 1] + 1, // 插
212
- prev[j - 1] + (a[i - 1] === b[j - 1] ? 0 : 1));
213
- }
214
- prev = cur;
215
- }
216
- return prev[b.length];
217
- }
218
- /** 未知命令的纠错建议:返回编辑距离最近且在阈值内的命令名,没有则 null(不要瞎猜)。 */
219
- export function suggestCommand(input) {
220
- // 阈值随长度放宽,但对短命令保持严格:/hepl(5) vs /help 距离 2 → 命中;
221
- // /x(2) 不该被建议成任何东西。
222
- const threshold = Math.max(1, Math.ceil(input.length / 3));
223
- let best = null;
224
- let bestDist = Infinity;
225
- for (const name of knownCommandNames()) {
226
- if (name === input)
227
- continue; // 别建议"你自己"(分支节点如 /mode 也在候选里)
228
- const d = editDistance(input, name);
229
- if (d < bestDist) {
230
- bestDist = d;
231
- best = name;
232
- }
233
- }
234
- return bestDist <= threshold ? best : null;
235
- }
236
- /** 主题名 → 本地化描述。 */
237
- function themeDescription(name) {
238
- const key = `theme.${name}`;
239
- return name in {
240
- default: 1, light: 1, solarized: 1, gruvbox: 1, nord: 1, orange: 1,
241
- rose: 1, emerald: 1, amber: 1, lavender: 1, sunset: 1,
242
- } ? t(key) : '';
243
- }
244
- /** /model 预设后端:同时声明原生协议;旧服务继续走 OpenAI-compatible。 */
245
- const MODEL_PRESETS = [
246
- { label: 'Anthropic Claude', provider: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-5', window: 200000, anthropicPromptCache: true },
247
- { label: 'GLM(智谱)', provider: 'openai', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
248
- { label: 'DeepSeek', provider: 'openai', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
249
- { label: 'Qwen(阿里)', provider: 'openai', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
250
- // MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
251
- // MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
252
- { label: 'MiniMax', provider: 'openai', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
253
- { label: '本地 Ollama', provider: 'openai', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
254
- { label: '本地 vLLM', provider: 'openai', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
255
- { label: '自定义 base_url', provider: 'openai', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
256
- ];
257
- /** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
258
- function maskKey(k) {
259
- if (!k)
260
- return '(未设置)';
261
- if (k.length <= 8)
262
- return '****';
263
- return `${'='.repeat(Math.min(k.length - 4, 20))}${k.slice(-4)}`;
264
- }
265
- /**
266
- * /init 指令:发给 agent 扫描项目并生成 AGENTS.md。已存在则让 agent 读后更新(不丢失事实)。写完供 memory 子系统下轮加载。
267
- *
268
- * 函数化(非 const):.codegraph/ 索引是否存在的探测放在调用瞬间,没索引时不提 codegraph,
269
- * 避免 LLM 调出失败。/init 是冷启动动作,IO 开销可忽略。
270
- */
271
- function buildInitPrompt() {
272
- const cg = hasCodegraphIndex()
273
- ? '- 若有 .codegraph/:用 use_skill 加载 codegraph skill 后用 run_command 调 codegraph explore "<架构或入口符号>" 一次拿相关源码+调用路径,别逐文件读!!!\n'
274
- : '';
275
- return `分析当前项目(process.cwd()),生成 AGENTS.md 项目记忆文件,供 mocode 后续会话自动加载——目标是让后续会话无需重新摸索就能上手。
276
-
277
- 先探查(尽量少调用拿全貌):
278
- ${cg}- read_file package.json(或 Cargo.toml/pyproject.toml/go.mod 等):scripts、依赖、入口、模块类型。
279
- - glob 顶层目录;read_file 入口文件 + 各子系统 index.ts/README。
280
- - 若 AGENTS.md 已存在:read_file 读它,在其基础上更新(补缺、修正过时),不丢已有准确事实。
281
-
282
- AGENTS.md 按以下结构写(每节简短,只写稳定、非显然的事实):
283
- ## 项目
284
- 一两句:是什么、技术栈、运行环境。
285
- ## 命令
286
- install / dev / build / test / typecheck / lint 等——从 package.json scripts 提炼,写原样命令行(如 \`npm run typecheck\`);没有的注明"无测试"/"无 lint"。
287
- ## 目录结构
288
- 顶层各目录与子系统职责,一句话/个;不逐文件列。
289
- ## 约定
290
- 从代码与现有文档提炼的硬约定:模块系统(ESM?)、命名、错误处理、工具/函数契约、易踩坑点。只写非显然、会让人踩坑的;不写"保持简洁"这种正确废话。
291
- ## 扩展点
292
- 加工具/命令/provider/模块的接缝(改哪个文件、加在哪)。
293
-
294
- 硬要求:
295
- - 从实际代码提炼,引用具体文件名/命令/符号;不编造、不泛泛。
296
- - 总长 ≤ 3000 字;只写后续会话有用的稳定事实,不写易变项(当前 bug、临时文件、未决 TODO)。
297
- - 用 write_file 写入项目根 AGENTS.md。
298
- - 写完简述:写了哪几节 + 从代码里发现的 2-3 条非显然关键约定(供用户校验)。`;
299
- }
300
- /** 临时 readline 读一行(cooked,用于子提问;主输入走 promptWithSlashMenu)。 */
301
- async function askLine(prompt) {
302
- const rl = readline.createInterface({ input: stdin, output: stdout });
303
- try {
304
- return await rl.question(prompt);
305
- }
306
- finally {
307
- rl.close();
308
- }
309
- }
310
- /** /context 的用量条(详情版,进内容区):只算对话内容(不含 system prompt),方便用户感知自己发了多少、agent 回复了多少。 */
311
- function renderContextBar(history) {
312
- // 过滤掉 system 消息,只算对话内容
313
- const dialog = history.filter(m => m.role !== 'system');
314
- const est = estimateMessagesTokens(dialog);
315
- const win = config.contextWindowTokens;
316
- const pct = Math.min(1, est / win);
317
- const W = 10;
318
- const filled = Math.round(pct * W);
319
- const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
320
- const src = contextState.lastUsage ? t('status.measured') : t('status.estimated');
321
- const k = (n) => `${Math.round(n / 1000)}k`;
322
- const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
323
- const lifecycle = contextState.lifecycleStats;
324
- const archived = computePruneStats(history);
325
- const artifactStats = contextState.artifactStats;
326
- const artifactLine = artifactStats
327
- ? `\n artifacts · fresh ${artifactStats.fresh} · stale ${artifactStats.stale} · stubbed ${archived.stubbed} · tokens ${formatArtifactTokenSources(artifactStats)}`
328
- : '\n artifacts · no file-backed facts recorded';
329
- const lifecycleLine = lifecycle
330
- ? `\n lifecycle · live ${lifecycle.live} · referenced ${lifecycle.referenced} · digested ${lifecycle.digested} · stubbed ${lifecycle.stubbed}`
331
- : '\n lifecycle · no active snapshot (run a tool-enabled turn first)';
332
- const archiveLine = `\n archived tool results · ${archived.stubbed}`;
333
- return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${t('status.messages', { count: history.length })} (${src})${ui.reset}${artifactLine}${lifecycleLine}${archiveLine}`;
334
- }
335
- /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
336
- * 必须**用全 prompt 估算**(消息 + 工具 schema + 尾部 ephemeral 注入),与压缩触发器
337
- * evaluateBudget 的 system+history+toolOld+toolRecent 总账对齐——任何一段漏算都会让
338
- * bar 与触发器口径不一致、看着没到 80% 实际已经在压。
339
- * 触发器用 `Math.max(rawTotal, total) >= 0.8 * window`,bar 也照搬:校正后和校正前
340
- * 哪个大取哪个,确保不会因 correction<1 而低估。ephemeral 文本由 agent/core 每步写入
341
- * contextState.ephemeralText(避免在 bar 里再读一次 notes.md)。
342
- *
343
- * /context 命令仍是 dialog-only(见 renderContextBar):它的设计意图是"我说了多少"而非
344
- * "还剩多少空间",两条职责分开。 */
345
- function renderContextBarInline(history) {
346
- const baseRaw = estimatePromptTokens(history, chatTools, 1);
347
- const baseAdj = estimatePromptTokens(history, chatTools, contextState.correction);
348
- const ephemeral = contextState.ephemeralText ? estimateTokens(contextState.ephemeralText) : 0;
349
- // 与触发器同样的「取大」语义:correction<1 时 raw 更大,bar 不会假装很安全。
350
- const est = Math.max(baseRaw, baseAdj) + ephemeral;
351
- const win = config.contextWindowTokens;
352
- const pct = Math.min(1, est / win);
353
- const W = 10;
354
- const filled = Math.round(pct * W);
355
- const bar = '█'.repeat(filled) + '░'.repeat(W - filled);
356
- const k = (n) => `${Math.round(n / 1000)}k`;
357
- const pctCol = pct >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio ? ui.yellow : ui.accent;
358
- return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
359
- }
360
- // 宿主侧记录已结束轮次最后看到的 plan。notes.md 仍完整保留,只抑制未变化的旧 plan 状态栏,
361
- // 避免 agent 忘记把 `## Plan:` 改成 `## Done:` 时输入框上方永久悬挂。
362
- let settledPlanFingerprint;
363
- /** 读取 notes.md 中唯一活跃的 `## Plan:` 段。进度只统计该段,避免其他笔记 checkbox 污染计数。 */
364
- function readPlanStatusFromNotes() {
365
- const sessionId = getCurrentSessionId();
366
- if (!sessionId)
367
- return null;
368
- const root = getSandboxRoot() ?? process.cwd();
369
- const p = path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
370
- try {
371
- const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
372
- const lines = normalized.split('\n');
373
- const start = lines.findIndex((line) => /^## Plan:\s*.+$/.test(line));
374
- if (start < 0)
375
- return null;
376
- const endOffset = lines.slice(start + 1).findIndex((line) => /^##\s/.test(line));
377
- const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
378
- const section = lines.slice(start, end).join('\n').trimEnd();
379
- const title = lines[start].match(/^## Plan:\s*(.+)$/)?.[1].trim();
380
- if (!title)
381
- return null;
382
- const total = (section.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
383
- const currentMatch = section.match(/^\s*-\s*\[ \]\s*(\d+)\.\s*(.+)$/m);
384
- const current = currentMatch?.[2].trim();
385
- // 括号进度 = 「当前执行到的步骤序号/总数」(执行第 1 步显示 1/3),与 `▸ 当前步` 后缀自洽;
386
- // 旧语义 done/total 会永远慢一拍(执行第 2 步显示 1/3)。全勾选时显示 total/total(通常已自动结算为 Done,chip 消失)。
387
- const activeNo = currentMatch ? Number(currentMatch[1]) : total;
388
- const summary = `plan: ${title} (${activeNo}/${total})`;
389
- // mtime 让“相同内容被重写为一项新计划”也能重新出现,而不被旧轮次误抑制。
390
- const fingerprint = `${sessionId}\0${fs.statSync(p).mtimeMs}\0${section}`;
391
- return { fingerprint, summary: current ? `${summary} ▸ ${current}` : summary };
392
- }
393
- catch {
394
- return null;
395
- }
396
- }
397
- /** 将当前 plan 标记为已结算。只影响状态栏,不修改 agent 的工作笔记。 */
398
- function settlePlanStatus() {
399
- settledPlanFingerprint = readPlanStatusFromNotes()?.fingerprint;
400
- }
401
- /** 从 notes.md 读取活跃 plan 摘要;已结算且未变化的旧 plan 不再显示。 */
402
- function readPlanFromNotes() {
403
- const plan = readPlanStatusFromNotes();
404
- if (!plan || plan.fingerprint === settledPlanFingerprint)
405
- return '';
406
- return plan.summary;
407
- }
408
- /** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token。repl 在轮次边界、切模式、plan 变更时调。 */
409
- function refreshStatusBase(history, lastTurnUsage) {
410
- layout.setStatusBase({
411
- model: config.model,
412
- contextBar: renderContextBarInline(history),
413
- cwd: process.cwd(),
414
- modeTag: getAgentMode() === 'plan' ? 'Plan' : 'Auto',
415
- planSummary: readPlanFromNotes(),
416
- lastTurnUsage,
417
- });
418
- }
419
- /** 命令 → 运行态状态文字 + 底栏 dim 占位。 */
420
- function runningStateFor(cmd) {
421
- switch (cmd) {
422
- case '/compact':
423
- return { status: t('running.compact'), placeholder: t('running.compacting') };
424
- case '/resume':
425
- return { status: t('running.resume'), placeholder: t('running.chooseSession') };
426
- case '/rollback':
427
- return { status: t('running.rollback'), placeholder: t('running.chooseTurn') };
428
- case '/init':
429
- return { status: t('running.init'), placeholder: t('running.generateMemory') };
430
- case '/plan':
431
- return { status: t('running.plan'), placeholder: '…' };
432
- case '/auto':
433
- return { status: t('running.auto'), placeholder: '…' };
434
- case '/clear':
435
- return { status: t('running.clear'), placeholder: '…' };
436
- case '/theme':
437
- return { status: t('running.theme'), placeholder: t('running.chooseTheme') };
438
- case '/model':
439
- return { status: t('running.model'), placeholder: t('running.configuring') };
440
- case '/pet':
441
- return { status: t('running.pet'), placeholder: t('running.processing') };
442
- case '/memory_switch':
443
- return { status: t('running.memory'), placeholder: t('running.switching') };
444
- case '/memory_status':
445
- return { status: t('running.memoryStatus'), placeholder: '…' };
446
- case '/subagent':
447
- return { status: t('running.subagent'), placeholder: t('running.switching') };
448
- case '/language':
449
- return { status: t('running.language'), placeholder: t('running.chooseLanguage') };
450
- case '/upgrade':
451
- return { status: t('running.upgrade'), placeholder: t('running.upgrading') };
452
- default:
453
- return { status: '', placeholder: '' };
454
- }
455
- }
456
- const emitter = stdin;
457
- // ── 运行态交互(typeahead 输入 + 滚动回看 + Ctrl+C 中断)──
458
- // 只在 await runAgent() 期间挂载;/resume /rollback /compact 等走 askLine(cooked readline)的分支不挂(避免抢 stdin)。
459
- let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填下一轮 INPUT 态)
460
- let runningCursor = 0; // 缓冲内光标字符索引(0..len);运行态支持任意位置编辑,与空闲态一致
461
- let runningPlaceholder = '';
462
- let currentAbort = null;
463
- let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
464
- // ── pending send 撤回窗口(用户按 Enter 后、agent 真发请求前)──
465
- // 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
466
- // 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
467
- // attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
468
- // 短单行:500ms(不拖慢日常)。长文本:MOCODE_RECALL_MS,默认 2000ms —— 人发现"误发了"通常要 1-3 秒,
469
- // 500ms 根本来不及反应,只能事后 /rollback。
470
- const PENDING_RECALL_MS = 500;
471
- const PENDING_RECALL_LONG_MS = 2000;
472
- /** 长输入阈值:与 prompt.ts 的二次确认阈值保持一致(≥2 行 或 ≥120 码点)。 */
473
- const LONG_INPUT_CHARS = 120;
474
- /** 按输入长度决定撤回窗口:长 prompt 给更宽的补救时间,短单行仍走 500ms。 */
475
- function recallWindowMs(input) {
476
- const joined = input.join('\n');
477
- const long = input.length >= 2 || [...joined].length >= LONG_INPUT_CHARS;
478
- if (!long)
479
- return PENDING_RECALL_MS;
480
- const env = Number(process.env.MOCODE_RECALL_MS);
481
- return Number.isFinite(env) && env >= 0 ? env : PENDING_RECALL_LONG_MS;
482
- }
483
- let pendingRecall = null;
484
- let pendingTimer = null;
485
- // agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
486
- /** 多模态 user 输入的附件状态。pending = 本轮尚未提交的待发图片;messageAttachments = 已 push 进 history 的图片元数据
487
- * (供 renderHistory 复显文件名——base64 不可逆地塞进 history 后,只能从侧 channel 拿原文件名)。 */
488
- let pendingAttachments = [];
489
- const messageAttachments = new Map();
490
- /** 运行态按键:滚动优先,再 Ctrl+C 中断,再 typeahead 编辑(单行,Enter=无操作)。 */
491
- function onRunningKey(_str, key) {
492
- if (!key)
493
- return;
494
- // 鼠标 fragment:重组 + 派发给 layout.handleMouseEvent(滚轮/框选/复制)。
495
- if (mouse.swallow(key.sequence ?? ''))
496
- return;
497
- // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,↑/↓ 每次 5 行(键盘)。
498
- // 运行态无输入光标,↑/↓ 无其他用途,直接作滚动。
499
- if (key.name === 'pageup' ||
500
- key.name === 'pagedown' ||
501
- key.name === 'up' ||
502
- key.name === 'down') {
503
- const pageH = layout.getGeo().contentBottom;
504
- if (key.name === 'pageup')
505
- layout.scrollBy(pageH);
506
- else if (key.name === 'pagedown')
507
- layout.scrollBy(-pageH);
508
- else if (key.name === 'up')
509
- layout.scrollBy(5);
510
- else
511
- layout.scrollBy(-5);
512
- return;
513
- }
514
- // 用户在交互(非滚动键)→ 暂停流式物理写,避免光标去 contentRow 扰动 IME 候选窗(停手后自动 flush)
515
- layout.setUserActive();
516
- // 滚动回看时打字 / 编辑(typeahead)不回尾——保持历史视图,便于运行中边看历史边预输入;
517
- // 回尾时机:Enter 在运行态是 no-op,真正回尾发生在 agent 结束后 INPUT 态按 Enter 提交(见 prompt.ts submit 前)。
518
- // Ctrl+C 4 层语义(RUNNING 态):有 typeahead → 清空(层 1,不中断);空 → abort(层 2,中断 agent)。
519
- // 两次 Ctrl+C 才中断(先清 typeahead 再 abort),与 INPUT 态 onCtrlC 的 fish 式一致。
520
- // raw 模式下 Ctrl+C 是按键不触发 SIGINT;signal 经 executeTool 串进工具,run_command/web_fetch 即时被杀。
521
- if (key.ctrl && key.name === 'c') {
522
- if (runningInput.length > 0) {
523
- runningInput = '';
524
- runningCursor = 0;
525
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
526
- }
527
- else if (currentAbort && !currentAbort.signal.aborted) {
528
- appendCurrentSessionRuntimeEvent('abort', { phase: 'requested', source: 'keyboard' });
529
- currentAbort.abort();
530
- }
531
- return;
532
- }
533
- const s = key.sequence ?? '';
534
- // 光标移动(单行 typeahead,光标可任意位置,与空闲态一致)
535
- if (key.name === 'left') {
536
- runningCursor = Math.max(0, runningCursor - 1);
537
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
538
- return;
539
- }
540
- if (key.name === 'right') {
541
- runningCursor = Math.min(runningInput.length, runningCursor + 1);
542
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
543
- return;
544
- }
545
- if (key.name === 'home' || (key.ctrl && key.name === 'a')) {
546
- runningCursor = 0;
547
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
548
- return;
549
- }
550
- if (key.name === 'end' || (key.ctrl && key.name === 'e')) {
551
- runningCursor = runningInput.length;
552
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
553
- return;
554
- }
555
- if (key.name === 'backspace') {
556
- if (runningCursor > 0) {
557
- runningInput = runningInput.slice(0, runningCursor - 1) + runningInput.slice(runningCursor);
558
- runningCursor--;
559
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
560
- }
561
- return;
562
- }
563
- if (key.name === 'delete') {
564
- if (runningCursor < runningInput.length) {
565
- runningInput = runningInput.slice(0, runningCursor) + runningInput.slice(runningCursor + 1);
566
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
567
- }
568
- return;
569
- }
570
- if (key.name === 'escape') {
571
- runningInput = '';
572
- runningCursor = 0;
573
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
574
- return;
575
- }
576
- // Enter / Ctrl+J:运行中 no-op(单行 typeahead;agent 结束后预填,用户在 INPUT 态按 Enter 提交)
577
- if (key.name === 'return' ||
578
- key.name === 'enter' ||
579
- (key.ctrl && key.name === 'j')) {
580
- return;
581
- }
582
- // 可打印字符(>= 空格,非 ctrl/meta)→ 光标处插入 + 非 dim 回显(与空闲态一致)
583
- if (s && s >= ' ' && !key.ctrl && !key.meta) {
584
- runningInput = runningInput.slice(0, runningCursor) + s + runningInput.slice(runningCursor);
585
- runningCursor += s.length;
586
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
587
- }
588
- }
589
- /** 鼠标右键单击输入框(未拖动)时 layout 读剪贴板后回调:在光标处插入 typeahead 缓冲(单行,换行折为空格)。 */
590
- function onRunningMousePaste(text) {
591
- const flat = text.replace(/[\r\n]+/g, ' ');
592
- runningInput = runningInput.slice(0, runningCursor) + flat + runningInput.slice(runningCursor);
593
- runningCursor += flat.length;
594
- layout.paintRunningInput(runningInput, runningCursor, runningPlaceholder);
595
- }
596
- /** 进入运行态:挂 keypress 监听 + raw mode + 新建 abort 控制器,返回其 signal。在 await runAgent 前、enterRunningMode 后调。 */
597
- function startRunningListener(placeholder) {
598
- runningPlaceholder = placeholder;
599
- runningInput = '';
600
- runningCursor = 0;
601
- emitKeypressEvents(stdin); // 幂等:首轮 prompt 已永久挂解析器,这里防御性再调
602
- try {
603
- stdin.setRawMode(true);
604
- }
605
- catch {
606
- // 非 TTY / 不支持 raw:监听器仍挂(按键可能不来,不影响 agent)
607
- }
608
- stdin.resume();
609
- emitter.on('keypress', onRunningKey);
610
- layout.setPasteHandler(onRunningMousePaste); // 鼠标右键单击输入框(未拖动)→ 读剪贴板贴入
611
- const ac = new AbortController();
612
- currentAbort = ac;
613
- return ac.signal;
614
- }
615
- /** 退出运行态:摘监听 + 清 abort。不 pause / 不 setRawMode(false)——紧接着 promptWithSlashMenu 自己接管 raw。 */
616
- function stopRunningListener() {
617
- emitter.off('keypress', onRunningKey);
618
- layout.setPasteHandler(null);
619
- currentAbort = null;
620
- }
621
- /**
622
- * 把用户消息格式化为带满宽背景色的文本(上滑时易辨认用户消息)。
623
- * 每行用 padEndDisplay 填充到终端宽度(含 ❯ / 缩进),背景色 SGR 包裹整行 + 行末 reset。
624
- * 满宽 pad 使终端背景色覆盖整行(含行尾空单元格),上滑滚动时用户消息呈连续色块、与 assistant 正文区分。
625
- * 末尾多留一空行(\n\n 收尾):用户消息与后续(agent 流式输出 / 下条消息)之间空一行。
626
- */
627
- function formatUserMessage(lines, trailingBlank = true) {
628
- const cols = layout.getGeo().cols;
629
- const promptW = displayWidth(PROMPT);
630
- const indent = ' '.repeat(promptW);
631
- const { userBg, reset } = ui;
632
- return (lines
633
- .map((l, i) => {
634
- const prefix = i === 0 ? PROMPT : indent;
635
- const full = prefix + l;
636
- const padded = padEndDisplay(full, cols);
637
- return `${userBg}${padded}${reset}`;
638
- })
639
- .join('\n') + (trailingBlank ? '\n\n' : '\n'));
640
- }
641
- /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
642
- function echoInput(lines, trailingBlank = true) {
643
- if (!layout.isActive())
644
- return;
645
- layout.contentWrite(formatUserMessage(lines, trailingBlank));
646
- // 多模态附件:每张一行 chip 跟在 user bubble 后(原 /rollback /resume 复显一致)
647
- for (const a of pendingAttachments) {
648
- layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
649
- }
650
- // 用户气泡会填满终端整列宽;Windows Terminal 在末列进入 pending-wrap 后,紧随的 LF
651
- // 偶尔只更新内部滚屏状态,导致上一轮耗时行与新气泡暂时黏连。缓冲中的物理行始终正确,
652
- // 提交后立即按缓冲重绘,避免必须等滚动或 Agent 结束时的 repaint 才显示正确边界。
653
- layout.repaintViewport();
654
- }
655
- /**
656
- * 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
657
- * 返 true=应 commit(走原 enterRunningMode + runTurn);false=应 recall(主循环 rewindContent 擦气泡 + prefill 回输入框)。
658
- *
659
- * 监听:
660
- * - Esc / Ctrl+C → recall(shouldCommit=false)
661
- * - Enter / Return → 立即 commit(shouldCommit=true)
662
- * - 其它键忽略(不进 paste 路径、不挂 timer)
663
- * - 500ms 定时器到 → 自动 commit(shouldCommit=true)
664
- *
665
- * 视觉:状态行 spinner 位临时改 '发送中… (Esc / Ctrl+C 撤回)';commit 后
666
- * runAgent.onStepStart 会 setStatus('思考中') 接管,无需手动还原。
667
- *
668
- * 降级:非 TTY(setRawMode 抛错)直接 commit,window=0 —— CI / 管道回放路径不退化。
669
- */
670
- function awaitPendingRecall(input, attachmentsCount, placeholder) {
671
- pendingRecall = { lines: input, attachmentsCount, placeholder };
672
- layout.setStatus(t('agent.sending'), '●');
673
- // 非 TTY:setRawMode 抛错 → window=0 直返 true(向后退化,不走 raw + 不挂监听)。
674
- let ttyReady = false;
675
- try {
676
- stdin.setRawMode(true);
677
- ttyReady = true;
678
- }
679
- catch {
680
- ttyReady = false;
681
- }
682
- if (!ttyReady) {
683
- pendingRecall = null;
684
- return Promise.resolve(true);
685
- }
686
- stdin.resume();
687
- emitKeypressEvents(stdin);
688
- return new Promise((resolve) => {
689
- let done = false;
690
- const finalize = (shouldCommit) => {
691
- if (done)
692
- return;
693
- done = true;
694
- if (pendingTimer) {
695
- clearTimeout(pendingTimer);
696
- pendingTimer = null;
697
- }
698
- emitter.off('keypress', onPendingKey);
699
- pendingRecall = null;
700
- resolve(shouldCommit);
701
- };
702
- const onPendingKey = (_str, key) => {
703
- if (!key || done)
704
- return;
705
- // 鼠标报表:吞(与 prompt.ts / onRunningKey 风格一致)
706
- if (mouse.swallow(key.sequence ?? ''))
707
- return;
708
- // 撤回
709
- if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
710
- finalize(false);
711
- return;
712
- }
713
- // 确认立即发送:Enter / Return
714
- if (key.name === 'enter' || key.name === 'return') {
715
- finalize(true);
716
- return;
717
- }
718
- // 其它任何键 → 撤回。
719
- // 旧实现把非 Enter 键"忽略"掉:误按 Enter 后人的第一反应是狂敲键盘 / 按空格 / 按退格,
720
- // 这一串输入被静默丢弃,白白错过补救窗口。撤回窗口期间输入框尚未接管键盘,
721
- // 这些键本来也不会进输入框,吞掉它们没有任何收益 —— 全部当作撤回信号更有用。
722
- finalize(false);
723
- };
724
- emitter.on('keypress', onPendingKey);
725
- pendingTimer = setTimeout(() => finalize(true), recallWindowMs(input));
726
- pendingTimer.unref?.();
727
- });
728
- }
729
- /** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
730
- function textOf(c) {
731
- if (typeof c === 'string')
732
- return c;
733
- if (c == null)
734
- return '';
735
- if (Array.isArray(c)) {
736
- return c
737
- .map((p) => typeof p === 'string' ? p : p?.text ?? '')
738
- .join('');
739
- }
740
- return String(c);
741
- }
742
- /** 从旧 session 的消息历史回填输入历史;新 session 使用独立 queryHistory,避免混入合成 user 消息。 */
743
- function queryHistoryFromMessages(messages) {
744
- return messages
745
- .filter((message) => message.role === 'user')
746
- .map((message) => textOf(message.content))
747
- .filter((query) => query.trim().length > 0);
748
- }
749
- /**
750
- * 把会话历史渲染成静态文本进内容区(回滚 / 续接 / --resume 后复显上下文):
751
- * user→❯ 回显、assistant→正文(+ tool_calls 折叠成 ● 摘要行)、tool→↳ 结果预览;system 跳过。
752
- * 思考段不持久(history 只存正文),故无思考折叠。渲染后续写位在末尾,紧接 enterInputMode 画输入框。
753
- * 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
754
- * user 多模态:用 textOf 取 text parts;若侧 channel messageAttachments 有原文件名则追加 chip 行
755
- * (避免 base64 解码不可逆,旧 session 没侧 channel 时只显文本,文件名 fallback 到 image/* mime)。
756
- *
757
- * 折叠策略:遇到 assistant + tool_calls 不立即打 ● 行,而是累积到 batchEntries;
758
- * 跟随的连续 tool 消息按 tool_call_id 反查填 resultSummary;遇下一个非 tool 消息(或末尾)时,
759
- * 用 batch.writeSummaryOnly 出单行摘要(与实时 runAgent 同一渲染器,UI 一致)。
760
- * 回放默认全折叠;用户可鼠标点击摘要行展开(由 BatchRenderer 接管,见 ui/batch.ts)。
761
- */
762
- export function renderHistory(history) {
763
- const idToEntry = new Map();
764
- // 普通工具可跨 assistant 步聚合;mutation 各自占一个 group,并切断前后普通工具。
765
- let pendingBatches = [];
766
- let normalBatch = null;
767
- const flushBatch = () => {
768
- for (const entries of pendingBatches)
769
- batch.writeSummaryOnly(entries, layout);
770
- pendingBatches = [];
771
- normalBatch = null;
772
- };
773
- for (let idx = 0; idx < history.length; idx++) {
774
- const m = history[idx];
775
- if (m.role === 'system')
776
- continue;
777
- if (m.role === 'user') {
778
- flushBatch(); // 上一轮 batch(若有)收尾
779
- const lines = textOf(m.content).split('\n');
780
- layout.contentWrite(formatUserMessage(lines));
781
- const atts = messageAttachments.get(idx);
782
- if (atts && atts.length > 0) {
783
- for (const a of atts) {
784
- layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
785
- }
786
- }
787
- else if (Array.isArray(m.content)) {
788
- // 旧 session 没侧 channel:从 data URL 头抽 mime,显一个通用 chip
789
- const c = m.content;
790
- for (const p of c) {
791
- if (p && typeof p === 'object' && p.type === 'image_url') {
792
- const url = p.image_url?.url ?? '';
793
- const mime = url.startsWith('data:') ? url.slice(5, url.indexOf(';')) : 'image';
794
- layout.contentWrite(` ${ui.dim}📷 ${mime}${ui.reset}\n`);
795
- }
796
- }
797
- }
798
- continue;
799
- }
800
- if (m.role === 'assistant') {
801
- const text = textOf(m.content);
802
- if (text) {
803
- flushBatch(); // 文本前若有累积 batch 先收尾(罕见:连续两个 assistant tool_calls 文本间)
804
- layout.contentWriteMdOnce(text);
805
- if (!text.endsWith('\n'))
806
- layout.contentWrite('\n');
807
- }
808
- const tcs = m.tool_calls;
809
- if (Array.isArray(tcs) && tcs.length > 0) {
810
- if (text) {
811
- // contentWriteMdOnce 会裁掉 markdown 尾部空行,而 history 中的原始 text 是否以 \n
812
- // 结尾并不能代表当前物理布局。按缓冲中的视觉行归一化,和实时“正文 → 工具”
813
- // 边界一致地保留一条空行,避免 /resume 回放时工具摘要紧贴正文。
814
- layout.normalizeMutationBoundary();
815
- }
816
- // 累积到 pendingBatch,顺序 = tool_calls 序
817
- for (const tc of tcs) {
818
- const name = tc?.function?.name ?? '';
819
- const args = tc?.function?.arguments ?? '';
820
- const entry = {
821
- name,
822
- callSummary: summarizeToolCall(name, args),
823
- resultSummary: '',
824
- diffBlock: null,
825
- };
826
- if (batch.isMutationToolName(name)) {
827
- normalBatch = null;
828
- pendingBatches.push([entry]);
829
- }
830
- else {
831
- if (!normalBatch) {
832
- normalBatch = [];
833
- pendingBatches.push(normalBatch);
834
- }
835
- normalBatch.push(entry);
836
- }
837
- if (tc?.id)
838
- idToEntry.set(tc.id, entry);
839
- }
840
- continue; // 跳过后续 tool 消息处理循环(由下一分支填 result)
841
- }
842
- // 无 tool_calls:若有 pending batch(文本+无 tool_calls 的 assistant),不常见,先收尾
843
- flushBatch();
844
- continue;
845
- }
846
- if (m.role === 'tool') {
847
- const id = m.tool_call_id ?? '';
848
- const target = idToEntry.get(id);
849
- const name = target?.name ?? '';
850
- const output = textOf(m.content);
851
- const preview = summarizeToolResult(name, output);
852
- if (target) {
853
- target.resultSummary = preview;
854
- target.fullOutput = output;
855
- }
856
- // 不直接写屏——等 flushBatch 时出单行摘要
857
- continue;
858
- }
859
- }
860
- flushBatch(); // 末尾兜底
861
- }
862
- /**
863
- * 交互式 REPL:全屏 TUI(alt screen + 固定底栏)。INPUT 态底栏=状态行+输入框(raw mode 等按键);
864
- * 提交后 enterRunningMode(底栏改 dim 占位、光标回内容续写位),命令分发与 runAgent 的流式输出经
865
- * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
866
- * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
867
- */
868
- export async function startRepl(initialHistory, sessionId, sandboxRootOverride, initialQueryHistory) {
869
- // 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
870
- setAgentMode('auto');
871
- // 钉死本会话模型:之后运行中 agent 一律用此值,其它窗口的 /model switch 不会影响本窗口。
872
- pinSessionModel();
873
- // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
874
- // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
875
- setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
876
- // MCP 在工具表和 LLM schema 创建前连接;失败的单个 server 只给提示,不阻断 REPL。
877
- const mcpReport = await initializeAllMcp();
878
- registerToolsExtension('mcp', getMcpTools());
879
- refreshChatTools();
880
- // --resume:读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
881
- let currentSessionId = sessionId;
882
- if (!currentSessionId)
883
- currentSessionId = newSessionId(); // 新会话立即分配 ID,确保 prompt 里有正确路径
884
- setCurrentSessionId(currentSessionId, process.cwd()); // 必须在 buildBasePrompt() 之前,让 prompt 能读到 sessionId
885
- // 构造系统提示:auto 用 base;plan 在 base 后追加按当前开关现拼的 plan suffix。
886
- // 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
887
- // 活跃 plan 摘要拼在 memory 段后(systemPrompt 的尾段),todo 工具变更后 listener 重写 history[0]。
888
- //
889
- // 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
890
- // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
891
- // ③ AGENTS.md 存在工作区根时由 base 无条件自动导入正文(超长截断,与 memory 开关无关);④ Memory Index 按开关注入。
892
- const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
893
- (planMode ? getPlanModeSuffix() : '') +
894
- buildMemoryIndexSection(isMemoryEnabled()));
895
- // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
896
- // 否则新会话只塞 system 提示(默认 auto)。
897
- const history = initialHistory && initialHistory.length
898
- ? initialHistory
899
- : [{ role: 'system', content: buildSystemMessage(false) }];
900
- // 新 session 使用独立输入历史;旧 session 没有该字段时从 user 消息兼容回填一次。
901
- let queryHistory = initialQueryHistory
902
- ? [...initialQueryHistory]
903
- : queryHistoryFromMessages(history);
904
- if (initialHistory &&
905
- initialHistory.length &&
906
- history[0]?.role === 'system') {
907
- history[0] = { role: 'system', content: buildSystemMessage(false) };
908
- }
909
- if (sessionId && initialHistory && initialHistory.length) {
910
- if (!loadSnapshots(sessionId))
911
- rebuildFromHistory(history);
912
- }
913
- // 反思 cadence 计数:每 reflectEveryN 轮 fire-and-forget 一次后台反思 pass。
914
- let turnCount = 0;
915
- // 本轮 token 累计:runAgent 返回后写入,供底栏模式 chip 右边显示。undefined=无实测
916
- // (后端不开 include_usage / 后端失败时)。
917
- let lastTurnUsage;
918
- const banner = () => ({
919
- model: config.model,
920
- baseURL: config.baseURL,
921
- cwd: process.cwd(),
922
- tools: chatTools.map((tool) => tool.function.name).join(' · '),
923
- memoryEnabled: isMemoryEnabled(),
924
- });
925
- /**
926
- * 欢迎引导块:新会话开场写在内容区(banner 之下),教用户怎么开始 / 能做什么。
927
- * 首次提交任何输入(消息或斜杠命令)前由 layout.dismissWelcomeBlock 整块撤掉——
928
- * 「一打开就能看见,开始干活就消失」。/clear 清空后重新写一次(回到空会话状态)。
929
- */
930
- // 欢迎块整块按终端宽度居中:先按纯文本(displayWidth 不剥 ANSI)算左缩进,再套色;
931
- // 余数列归左,与输入框提示行的居中策略一致;超宽行由 contentWrite 兜底折行。
932
- const welcomeLines = () => {
933
- const cols = layout.getGeo().cols;
934
- const pad = (plain) => ' '.repeat(Math.max(0, Math.floor((cols - displayWidth(plain)) / 2)));
935
- const ideNotice = `⚠ ${t('welcome.ideNotice')}`;
936
- return [
937
- '',
938
- `${pad(ideNotice)}${ui.yellow}${ui.bold}${ideNotice}${ui.reset}`,
939
- '',
940
- `${pad(t('welcome.gettingStarted'))}${ui.accent}${ui.bold}${t('welcome.gettingStarted')}${ui.reset}`,
941
- `${pad(`· ${t('welcome.start1')}`)}${ui.dim}· ${t('welcome.start1')}${ui.reset}`,
942
- `${pad(`· ${t('welcome.start2')}`)}${ui.dim}· ${t('welcome.start2')}${ui.reset}`,
943
- `${pad(`· ${t('welcome.start3')}`)}${ui.dim}· ${t('welcome.start3')}${ui.reset}`,
944
- '',
945
- `${pad(t('welcome.capabilities'))}${ui.accent}${ui.bold}${t('welcome.capabilities')}${ui.reset}`,
946
- `${pad(`· ${t('welcome.cap1')}`)}${ui.dim}· ${t('welcome.cap1')}${ui.reset}`,
947
- `${pad(`· ${t('welcome.cap2')}`)}${ui.dim}· ${t('welcome.cap2')}${ui.reset}`,
948
- `${pad(`· ${t('welcome.cap3')}`)}${ui.dim}· ${t('welcome.cap3')}${ui.reset}`,
949
- '',
950
- `${pad(t('welcome.try'))}${ui.dim}${t('welcome.try')}${ui.reset}`,
951
- '',
952
- ];
953
- };
954
- // 开场:按 config.theme 切主题(横幅 / 状态行 / 后续渲染皆用新色),再进 alt screen + 状态基线 + 清内容区。
955
- // --resume 有历史则渲染对话,否则横幅。
956
- setTheme(config.theme);
957
- layout.enterAltScreen();
958
- refreshStatusBase(history);
959
- layout.clearContent();
960
- layout.contentMode();
961
- if (history.some((m) => m.role === 'user')) {
962
- // --resume 锚点:先给一行会话摘要(id/消息数/当前模型),新手对恢复状态有定位感,
963
- // 再铺历史对话。renderHistory 之后不再追加,锚点恒为恢复内容的第一行。
964
- const anchorCount = history.filter((m) => m.role !== 'system').length;
965
- layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: currentSessionId, count: anchorCount, model: config.model })}${ui.reset}\n`);
966
- renderHistory(history);
967
- // 强制回尾:同 /resume 命令,renderHistory 展开详情会设 scrollOffset>0,需复位避免闪烁。
968
- layout.resetScroll();
969
- layout.repaintViewport();
970
- }
971
- else {
972
- layout.writeBanner(bannerLines(banner()));
973
- }
974
- if (mcpReport.connected.length > 0) {
975
- layout.contentWrite(`${ui.dim} ↳ 已连接 MCP: ${mcpReport.connected.join(', ')} (${getMcpTools().length} 个工具;外部工具每次均需授权)${ui.reset}\n`);
976
- }
977
- for (const warning of mcpReport.warnings) {
978
- layout.contentWrite(`${ui.yellow} ⚠ ${warning}${ui.reset}\n`);
979
- }
980
- if (!isModelConfigured()) {
981
- // 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
982
- layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
983
- }
984
- else {
985
- // 老用户兜底:若 ~/.mocode/models/ 空,自动把 ~/.mocode/config 的当前 LLM 四键迁成 'default' 预设。
986
- // 这样 /model list / switch 立即可见,无需手动 /model 重存一遍。幂等:重启只生效一次。
987
- if (listPresets().length === 0) {
988
- try {
989
- const migrated = migrateCurrentToPreset({
990
- provider: config.provider,
991
- baseURL: config.baseURL,
992
- apiKey: config.apiKey,
993
- model: config.model,
994
- contextWindow: config.contextWindowTokens,
995
- anthropicPromptCache: config.anthropicPromptCache,
996
- });
997
- if (migrated) {
998
- layout.contentWrite(`${ui.dim} ↳ 检测到老配置,已自动迁为预设 “${migrated}”(${ui.cyan}/model list${ui.dim} 查看 · /model switch 切换)${ui.reset}\n`);
999
- }
1000
- }
1001
- catch {
1002
- // 迁移失败不阻塞启动;用户后续 /model 时仍能手动配。
1003
- }
1004
- }
1005
- }
1006
- // 新会话开场:banner 之后写欢迎引导块(--resume 有历史时不写,历史本身就是上下文)。
1007
- if (!history.some((m) => m.role === 'user')) {
1008
- layout.writeWelcomeBlock(welcomeLines());
1009
- }
1010
- /**
1011
- * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
1012
- * cycleMode 翻 agentMode + applyMode 重写 history[0] + refreshStatusBase 设状态行 modeTag;
1013
- * 随后 prompt.ts 自调 redraw() 经 paintInput 把 chip 画出来(光标留输入框,不调 drawStatusBar)。
1014
- */
1015
- const applyMode = (planMode) => {
1016
- history[0] = { role: 'system', content: buildSystemMessage(planMode) };
1017
- };
1018
- const cycleMode = () => {
1019
- const prev = getAgentMode();
1020
- setAgentMode(prev === 'plan' ? 'auto' : 'plan'); // listener 接手 applyMode + refreshStatusBase
1021
- // Shift+Tab 只有 chip 变化,新手可能没感知:内容区补一行确认(与 /plan / /auto 同文案,复用不新造键)。
1022
- layout.contentWrite(`${ui.dim}${t(prev === 'plan' ? 'repl.autoChanged' : 'repl.planChanged')}${ui.reset}\n`);
1023
- };
1024
- // 注册模式变更监听器:switch_mode 工具 / cycleMode / /plan / /auto / runTurn 调 setAgentMode 时同步触发,
1025
- // 重写 history[0] 系统提示(切 plan 追加 PLAN_MODE_SUFFIX)+ 刷状态行 modeTag。
1026
- // 不调 drawStatusBar:INPUT 态靠 prompt.ts redraw 画 chip;RUNNING 态(switch_mode 中途切)靠 200ms turnTimer 兜底。
1027
- onModeChange((m) => {
1028
- applyMode(m === 'plan');
1029
- refreshStatusBase(history);
1030
- });
1031
- /**
1032
- * 回滚子流程(由 /rollback 触发):菜单(↑/↓)选轮次 → 选中第 X 轮 = 删第 X 轮及之后 + 预填第 X 轮 user 输入
1033
- * (Enter 重新跑该轮);被删轮次的文件改动走二选一菜单(promptRevertChoice:
1034
- * 撤销文件 / 只撤销消息)。选轮 + 方式菜单均走 raw mode。预填经 pendingPrefill 注入下轮 INPUT。
1035
- */
1036
- const rollbackFlow = async () => {
1037
- const turnList = listTurns();
1038
- if (turnList.length < 1) {
1039
- layout.contentWrite(`${ui.dim}(没有可回滚的轮次)${ui.reset}\n`);
1040
- return;
1041
- }
1042
- // 各轮 user 全文(预填用;按 user 消息顺序与 turnList 对齐)
1043
- const userTexts = [];
1044
- for (const m of history) {
1045
- if (m.role === 'user')
1046
- userTexts.push(textOf(m.content));
1047
- }
1048
- const items = turnList.map((t) => ({ firstLine: t.firstLine }));
1049
- let picked;
1050
- try {
1051
- picked = await promptTurnPicker(items);
1052
- }
1053
- catch {
1054
- return; // Ctrl+C(SIGINT)→ 取消回滚
1055
- }
1056
- if (picked === null)
1057
- return; // Esc 取消
1058
- // picked(0-based)= 第 (picked+1) 轮:删该轮及之后(planRollback(picked) 保 1..picked),预填该轮 user 输入
1059
- const prefillText = userTexts[picked] ?? '';
1060
- const plan = planRollback(picked, history);
1061
- // 清屏(擦选轮菜单 + /rollback 回显)+ 复位 lastView(dim 空),给文件询问一个干净、resize 安全的画面
1062
- layout.clearContent();
1063
- layout.paintInput({
1064
- prompt: '❯ ',
1065
- lines: [''],
1066
- cursorLine: 0,
1067
- cursorCol: 0,
1068
- menu: null,
1069
- dim: true,
1070
- });
1071
- const revertPaths = new Set();
1072
- // 文件撤销:二选一菜单(1=撤销文件改动 / 2=只撤销消息保留文件)
1073
- // 返回 true=撤销文件(把 changes 里所有可撤销的路径加入 revertPaths);
1074
- // false/null(取消/非 TTY/Ctrl+C)=只撤销消息,保留文件改动。
1075
- const changes = plan.changes;
1076
- const revertable = changes.filter((c) => c.snapshotAvailable);
1077
- let revertFiles = false;
1078
- if (changes.length > 0) {
1079
- try {
1080
- const choice = await promptRevertChoice(revertable.length);
1081
- revertFiles = choice === true;
1082
- }
1083
- catch {
1084
- revertFiles = false; // Ctrl+C(SIGINT)→ 保留文件,只撤销消息
1085
- }
1086
- }
1087
- if (revertFiles) {
1088
- for (const c of revertable)
1089
- revertPaths.add(c.path);
1090
- }
1091
- const rolledBackFromTurnId = getCurrentTurnId();
1092
- const turnCountBeforeRollback = listTurns().length;
1093
- const rollbackResult = applyRollback(plan, history, revertPaths);
1094
- if (!currentSessionId)
1095
- currentSessionId = newSessionId();
1096
- setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
1097
- appendCurrentSessionRuntimeEvent('rollback', {
1098
- status: 'applied',
1099
- rolledBackFromTurnId,
1100
- cutoffTurnId: plan.cutoffTurnId,
1101
- retainedTurns: plan.n,
1102
- rolledBackTurns: Math.max(0, turnCountBeforeRollback - plan.n),
1103
- deletedMessages: rollbackResult.deletedMsgs,
1104
- revertedFiles: rollbackResult.revertedFiles,
1105
- conflictedFiles: rollbackResult.conflictedFiles,
1106
- requestedFileCount: revertPaths.size,
1107
- }, plan.cutoffTurnId);
1108
- try {
1109
- saveSession(history, currentSessionId, queryHistory);
1110
- }
1111
- catch {
1112
- // 落盘失败不阻断
1113
- }
1114
- persistSnapshots(currentSessionId);
1115
- // 复显剩余对话(无提示行),输入框预填该轮 user 输入 → 下轮 Enter 重新跑
1116
- layout.clearContent();
1117
- renderHistory(history);
1118
- if (rollbackResult.conflictedFiles.length > 0) {
1119
- layout.contentWrite(`${ui.yellow} ⚠ rollback conflict: ${rollbackResult.conflictedFiles.join(', ')} 已在 Agent 提交后变化,未覆盖。${ui.reset}\n`);
1120
- }
1121
- // 末尾补空行:与后续用户消息(❯ bubble)之间分隔。runTurn 在每个 agent 轮结束后
1122
- // contentWrite('\n') 做轮次分隔,/resume 后接 \n\n,/theme·/model 后接 \n;
1123
- // rollbackFlow 原本漏了这一行,renderHistory 末尾的 batch 摘要行 / assistant 文本
1124
- // 收口后,续写位未稳到新空行,下一次 echoInput 的 ❯ 气泡会黏在最后一条输出后面。
1125
- layout.contentWrite('\n');
1126
- // 回滚后强制回尾:确保 scrollOffset=0,避免后续 showLiveBatch 在冻结视口下
1127
- // 调用 repaintViewport 导致工具信息闪烁/滚动消失(rollback 后用户继续输入触发新 agent
1128
- // 运行时,若 scrollOffset 意外 >0,contentWrite 只喂缓冲不物理写,showLiveBatch 的
1129
- // repaintViewport 画出不含新摘要的冻结窗口 → 工具信息"闪现后消失")。
1130
- layout.resetScroll();
1131
- pendingPrefill = prefillText.split('\n');
1132
- };
1133
- /**
1134
- * 跑一轮 agent(enterRunningMode 已由调用方完成):startRunningListener → runAgent → autosave / reflect。
1135
- * plan 模式传 planMode=true(runAgent 用 planChatTools 只读子集)。返 ok=正常结束(未中断 / 未抛错),
1136
- * 供调用方决定是否弹审批面板。execute 轮的合成输入也走这里。
1137
- *
1138
- * 多模态:把 pendingAttachments flush 进 userInput:有图时构造 ContentPart[] 数组;
1139
- * 无图时保持 string(向后兼容,且 messageTokens 走 estimateTokens 不走 IMAGE_TOKEN_COST)。
1140
- * side channel 记录本轮 msg 在 history 的 index → attachments,供 renderHistory 复显文件名。
1141
- */
1142
- const runTurn = async (input, planMode, placeholder) => {
1143
- const imgs = pendingAttachments;
1144
- pendingAttachments = []; // 入口即清,即使后续抛错也不留陈旧附件
1145
- const userInput = imgs.length === 0
1146
- ? input
1147
- : [
1148
- { type: 'text', text: input },
1149
- // detail 故意不设(留 undefined,JSON.stringify 时被丢弃):OpenAI 认 'auto'/'low'/'high',
1150
- // 但 MiniMax 只认 'low'/'default'/'high'——'auto' 不是合法枚举值,某些后端会 400。
1151
- // 不传 detail 让各 provider 用自己的默认值(OpenAI 默认视为 auto,MiniMax 默认 default),
1152
- // 是唯一在两边都不出错的写法。
1153
- ...imgs.map((a) => ({
1154
- type: 'image_url',
1155
- image_url: { url: a.dataUrl },
1156
- })),
1157
- ];
1158
- const msgIndex = history.length; // runAgent push 后 = 这个 index
1159
- if (imgs.length)
1160
- messageAttachments.set(msgIndex, imgs);
1161
- let ok = false;
1162
- try {
1163
- const signal = startRunningListener(placeholder);
1164
- // 入口设定本轮初始模式(合成执行轮传 false→auto;用户轮传当前 mode)。
1165
- // setAgentMode 触发 listener 重写 history[0];LLM 可在轮中调 switch_mode 切模式,runAgent 每步读实时值。
1166
- setAgentMode(planMode ? 'plan' : 'auto');
1167
- // 新用户轮开始:清除上一轮 inline skill 的激活态(允许/disallowed 约束一 turn 有效)。
1168
- clearSkillActivation();
1169
- // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
1170
- // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
1171
- const result = await runAgent(history, userInput, signal, () => {
1172
- refreshStatusBase(history);
1173
- layout.drawStatusBar();
1174
- });
1175
- // 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
1176
- // 状态栏统一在 finally 刷新,确保正常、中断、异常都经过同一 plan 收尾路径。
1177
- lastTurnUsage = result.usage;
1178
- ok = !signal.aborted; // 中断(Ctrl+C)→ runAgent 已还原 history,ok=false 不弹审批
1179
- // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
1180
- if (!currentSessionId)
1181
- currentSessionId = newSessionId();
1182
- setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
1183
- try {
1184
- saveSession(history, currentSessionId, queryHistory);
1185
- }
1186
- catch {
1187
- // 落盘失败不阻断 REPL
1188
- }
1189
- persistSnapshots(currentSessionId); // 随会话落盘回滚快照(/resume 后仍可撤销)
1190
- // 后台反思:每 reflectEveryN 轮 fire-and-forget 一次(与下一轮 agent 并发,不阻塞)。
1191
- // 已有在飞任务 / autoReflect 关 → kickoff 内部自守卫。快照同步取(避免下一轮 mutate history 竞态)。
1192
- turnCount++;
1193
- if (turnCount % config.reflectEveryN === 0) {
1194
- kickoffReflection(snapshotTranscript(history, 20));
1195
- }
1196
- }
1197
- catch (e) {
1198
- ok = false;
1199
- // 请求失败也保存已确认提交的 query,确保立即退出后仍可通过 ↑ 或 resume 找回。
1200
- if (!currentSessionId)
1201
- currentSessionId = newSessionId();
1202
- setCurrentSessionId(currentSessionId, process.cwd());
1203
- try {
1204
- saveSession(history, currentSessionId, queryHistory);
1205
- }
1206
- catch {
1207
- // 落盘失败不覆盖原始请求错误
1208
- }
1209
- // 多模态相关错误友好提示:OpenAI/Anthropic 等会报 "does not support image" / "vision" / "multimodal" 等关键词,
1210
- // 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
1211
- const msg = e instanceof Error ? e.message : String(e);
1212
- const lower = msg.toLowerCase();
1213
- // 只有明确的「能力不支持」才显示换模型提示。不能仅因错误里出现 image/vision
1214
- // 就下结论:例如 MiniMax 会因 `image_url.detail=auto` 返 400 invalid params,
1215
- // 模型本身仍支持视觉。参数/格式错误应保留原始 provider 诊断,方便准确修复。
1216
- const isImageParameterError = /\b(?:invalid|unsupported)\s+(?:image\s+)?(?:detail|parameter|param|format|url)\b/i.test(lower);
1217
- const isVisionUnsupportedError = !isImageParameterError && (/\b(?:does not support|doesn't support|not supported|unsupported)\b.{0,48}\b(?:image|vision|multimodal|media)\b/i.test(lower) ||
1218
- /\b(?:image|vision|multimodal|media)\b.{0,48}\b(?:is not supported|not supported|unsupported)\b/i.test(lower) ||
1219
- /(?:不支持|不具备).{0,12}(?:视觉|图片|图像|多模态)|(?:视觉|图片|图像|多模态).{0,12}(?:不支持|不可用)/.test(msg));
1220
- if (isVisionUnsupportedError) {
1221
- layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t('repl.visionUnsupported', { model: `${ui.accent}${config.model}${ui.reset}` })}${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
1222
- layout.contentWrite(`${ui.dim}${t('repl.visionHint')}${ui.reset}\n`);
1223
- }
1224
- else {
1225
- // 常见错误翻译:认证 / 限流 / 超时 / 网络 / 上下文超长 → 中文引导(新手第一堵墙);
1226
- // 认不出的错误保留原始 provider 诊断,不瞎猜。
1227
- const kind = classifyChatError(msg);
1228
- if (kind) {
1229
- const key = LLM_ERROR_HINT_KEYS[kind];
1230
- layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${t(key, { base: config.baseURL, model: config.model })}\n`);
1231
- layout.contentWrite(`${ui.dim}${t('repl.originalError', { message: msg })}${ui.reset}\n`);
1232
- }
1233
- else {
1234
- layout.contentWrite(`${ui.red}${t('repl.errorLabel')}${ui.reset} ${msg}\n`);
1235
- }
1236
- }
1237
- }
1238
- finally {
1239
- stopRunningListener();
1240
- // 纯 plan 轮正常结束后仍需等待审批/细化,继续展示;其余终态统一结算。
1241
- // 结算只隐藏当前 fingerprint,不修改 notes;后续内容或 mtime 变化会自动重新显示。
1242
- const waitingForPlanApproval = ok && planMode && getAgentMode() === 'plan';
1243
- if (!waitingForPlanApproval)
1244
- settlePlanStatus();
1245
- layout.setLiveUsage(undefined); // 轮末清实时 chip,回 INPUT 态不再显示
1246
- refreshStatusBase(history, lastTurnUsage);
1247
- layout.drawStatusBar();
1248
- }
1249
- layout.contentWrite('\n'); // 轮次之间空行
1250
- return ok;
1251
- };
1252
- /** 把 picker 选中的会话加载进 REPL(刷 history + 重建 snapshots + 重画)。/resume / /sessions 共用。 */
1253
- async function resumeFromPick(pick) {
1254
- if (!pick)
1255
- return; // Esc / Ctrl+D 取消
1256
- const loaded = loadSession(pick.id);
1257
- if (!loaded || !loaded.history.length) {
1258
- layout.contentWrite(`${ui.yellow}${t('repl.loadFailed')}${ui.reset}\n`);
1259
- return;
1260
- }
1261
- // Bind before rebuilding the prompt, or it can retain the previous session's notes path.
1262
- currentSessionId = loaded.id;
1263
- setCurrentSessionId(loaded.id, process.cwd());
1264
- if (loaded.history[0]?.role === 'system') {
1265
- loaded.history[0] = { role: 'system', content: buildSystemMessage(false) };
1266
- }
1267
- history.length = 0;
1268
- history.push(...loaded.history);
1269
- queryHistory = loaded.queryHistory
1270
- ? [...loaded.queryHistory]
1271
- : queryHistoryFromMessages(loaded.history);
1272
- setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
1273
- // 读回该会话的轮次/快照;无文件则从 history 重建 turns(无快照→旧轮次文件改动不可撤销)
1274
- if (!loadSnapshots(loaded.id))
1275
- rebuildFromHistory(history);
1276
- contextState.lastUsage = undefined;
1277
- contextState.lifecycleStats = undefined;
1278
- contextState.ephemeralText = undefined;
1279
- lastTurnUsage = undefined; // 续接:旧会话的 token 累计已无意义,清空等下轮覆写
1280
- layout.clearContent();
1281
- // 锚点行:会话 id / 消息数 / 当前模型,给续接的会话一个定位起点(与 --resume 启动路径同文案)。
1282
- const anchorCount = history.filter((m) => m.role !== 'system').length;
1283
- layout.contentWrite(`${ui.dim}${t('repl.resumeAnchor', { id: loaded.id, count: anchorCount, model: config.model })}${ui.reset}\n\n`);
1284
- renderHistory(history);
1285
- // 强制回尾:renderHistory 展开 mutation 工具详情会经 contentInsertAfter 设置 scrollOffset>0;
1286
- // 先把"已续接会话"提示写入缓冲,再统一回尾重画,避免切换后视口停留在历史顶部。
1287
- layout.resetScroll();
1288
- layout.repaintViewport();
1289
- }
1290
- let hasSubmittedInput = false;
1291
- while (true) {
1292
- // INPUT 态:画底栏输入框 + 状态行,光标入输入框
1293
- refreshStatusBase(history);
1294
- // 后台反思若已完成(上轮 fire-and-forget),在安全点 flush 一行 dim 摘要:
1295
- // 不在 RUNNING 态写——防与正在跑的 agent 争屏(contentWrite / 状态行)。
1296
- const reflectRes = getLastReflectResult();
1297
- if (reflectRes) {
1298
- layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
1299
- clearLastReflectResult();
1300
- }
1301
- // 所有斜杠命令和 Agent 轮次共用同一个输出→输入边界,避免某条命令漏写第二个 \n
1302
- // 后下一条 ❯ 气泡紧贴确认文案;已有多余空行也会收敛为恰好一行。
1303
- if (hasSubmittedInput)
1304
- layout.normalizeInputBoundary();
1305
- layout.enterInputMode(t('repl.idle'));
1306
- let input = null;
1307
- try {
1308
- input = await promptWithSlashMenu({
1309
- prompt: PROMPT,
1310
- commands: buildSlashCommands(),
1311
- onCycleMode: cycleMode,
1312
- // Ctrl+R / Ctrl+P 历史搜索候选源:惰性工厂——当前会话内存 queryHistory + 最近落盘会话,
1313
- // 聚合要读盘,只有面板真正打开(Ctrl+R/P)才求值,平常轮次零开销
1314
- history: () => collectQueryHistory(queryHistory).map((e) => e.text),
1315
- // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
1316
- ...(pendingPrefill
1317
- ? { initialLines: pendingPrefill }
1318
- : runningInput
1319
- ? { initialLines: [runningInput] }
1320
- : {}),
1321
- });
1322
- }
1323
- catch {
1324
- break; // Ctrl+C(SIGINT)/ 异常 → 退出
1325
- }
1326
- pendingPrefill = null; // 预填已消费,清空
1327
- runningInput = ''; // 预填已消费,清空(下轮运行态从空开始)
1328
- if (input === null)
1329
- break; // 空 prompt Ctrl+D
1330
- let joined = input.join('\n');
1331
- const line = joined.trim();
1332
- if (!line)
1333
- continue;
1334
- hasSubmittedInput = true;
1335
- if (line === '/exit' || line === '/quit')
1336
- break;
1337
- // 首次提交任何输入(消息或斜杠命令)→ 撤掉欢迎引导块,不让它出现在工作画面里。
1338
- layout.dismissWelcomeBlock();
1339
- // RUNNING 态:回显输入 → 底栏改 dim 占位、光标回内容续写位
1340
- const cmd = line.split(/\s+/)[0];
1341
- // 命令被刻意改写成别的内容后转发给 agent(如 /init)。这类不算"未知命令"。
1342
- let forwardToAgent = false;
1343
- // 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
1344
- echoInput(input, cmd !== '/language');
1345
- const state = runningStateFor(cmd);
1346
- const placeholder = state.placeholder;
1347
- refreshStatusBase(history);
1348
- layout.enterRunningMode(state.status, placeholder);
1349
- if (line === '/help') {
1350
- const nodes = buildSlashCommands();
1351
- layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
1352
- layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
1353
- // 按使用场景分组输出,常用置顶;未归组命令(如 /exit)兜底进「其他」。
1354
- let first = true;
1355
- for (const group of HELP_GROUPS) {
1356
- const picked = nodes.filter((n) => group.names.includes(n.name));
1357
- if (!picked.length)
1358
- continue;
1359
- if (!first)
1360
- layout.contentWrite('\n');
1361
- first = false;
1362
- layout.contentWrite(`${ui.accent}${ui.bold}${t(group.key)}${ui.reset}\n`);
1363
- layout.contentWrite(`${slashHelpLines(picked).join('\n')}\n`);
1364
- }
1365
- const rest = nodes.filter((n) => !HELP_GROUPS.some((g) => g.names.includes(n.name)));
1366
- if (rest.length) {
1367
- if (!first)
1368
- layout.contentWrite('\n');
1369
- layout.contentWrite(`${ui.accent}${ui.bold}${t('help.groupOther')}${ui.reset}\n`);
1370
- layout.contentWrite(`${slashHelpLines(rest).join('\n')}\n`);
1371
- }
1372
- continue;
1373
- }
1374
- if (line === '/language' || line.startsWith('/language ')) {
1375
- const arg = line === '/language' ? '' : line.slice('/language '.length).trim();
1376
- if (!arg) {
1377
- const currentName = getLanguage() === 'zh-CN' ? t('language.zh') : t('language.en');
1378
- layout.contentWrite(`${ui.dim}${t('language.current', { language: currentName })}${ui.reset}\n`);
1379
- layout.contentWrite(`${ui.dim}${t('language.usage')}${ui.reset}\n\n`);
1380
- continue;
1381
- }
1382
- const next = normalizeLanguage(arg);
1383
- if (!next) {
1384
- layout.contentWrite(`${ui.yellow}${t('language.invalid', { value: arg })}${ui.reset}\n\n`);
1385
- continue;
1386
- }
1387
- updateLanguageConfig(next);
1388
- updateConfigKey('MOCODE_LANGUAGE', next);
1389
- history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1390
- refreshStatusBase(history);
1391
- // 横幅是内容缓冲顶部的固定区域;语言切换后等长原地替换,不移动后续对话。
1392
- layout.rewriteBanner(bannerLines(banner()));
1393
- layout.contentWrite(`${ui.cyan}${t('language.changed')}${ui.reset}\n`);
1394
- if (languageFromShell) {
1395
- layout.contentWrite(`${ui.dim}${t('language.shellOverride')}${ui.reset}\n`);
1396
- }
1397
- layout.contentWrite('\n');
1398
- continue;
1399
- }
1400
- if (line === '/init') {
1401
- // /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 AGENTS.md),fall through 走 runAgent
1402
- joined = buildInitPrompt();
1403
- forwardToAgent = true; // 不是"没匹配上",别被下方未知命令兜底拦掉
1404
- }
1405
- if (line === '/upgrade' || line.startsWith('/upgrade ')) {
1406
- const arg = line === '/upgrade' ? '' : line.slice('/upgrade '.length).trim().toLowerCase();
1407
- // /upgrade check — 联网检查当前版本与最新版本差异
1408
- if (arg === 'check') {
1409
- const signal = startRunningListener(t('running.upgrading'));
1410
- try {
1411
- const info = await checkVersion();
1412
- if (!info.latest) {
1413
- layout.contentWrite(`${ui.yellow}⚠ ${t('upgrade.fetchFailed')}${ui.reset}\n`);
1414
- }
1415
- else if (info.hasUpdate) {
1416
- layout.contentWrite(`${ui.cyan}● ${t('upgrade.hasUpdate', { current: info.current, latest: info.latest })}${ui.reset}\n`);
1417
- layout.contentWrite(`${ui.dim}${t('upgrade.checkHint')}${ui.reset}\n`);
1418
- }
1419
- else {
1420
- layout.contentWrite(`${ui.green}✓ ${t('upgrade.noUpdate', { version: info.current })}${ui.reset}\n`);
1421
- }
1422
- }
1423
- catch (e) {
1424
- const msg = e instanceof Error ? e.message : String(e);
1425
- layout.contentWrite(`${ui.red}${t('upgrade.failed', { message: msg })}${ui.reset}\n`);
1426
- }
1427
- finally {
1428
- stopRunningListener();
1429
- }
1430
- continue;
1431
- }
1432
- // /upgrade status — 只显示本地当前版本(不联网)
1433
- if (arg === 'status') {
1434
- const current = getCurrentVersion();
1435
- layout.contentWrite(`${ui.dim}${t('upgrade.currentVersion', { version: `v${current}` })}${ui.reset}\n`);
1436
- layout.contentWrite(`${ui.dim}${t('upgrade.pkgName')}${ui.reset}\n`);
1437
- continue;
1438
- }
1439
- // /upgrade 或 /upgrade now — 前台执行升级,实时显示 npm 输出
1440
- if (arg === '' || arg === 'now') {
1441
- const signal = startRunningListener(t('running.upgrading'));
1442
- try {
1443
- const latest = await fetchLatestVersion();
1444
- const current = getCurrentVersion();
1445
- if (latest && latest === current) {
1446
- layout.contentWrite(`${ui.green}✓ ${t('upgrade.noUpdate', { version: current })}${ui.reset}\n`);
1447
- continue;
1448
- }
1449
- const target = latest ?? 'latest';
1450
- layout.contentWrite(`${ui.cyan}● ${t('upgrade.begin', { version: target })}${ui.reset}\n`);
1451
- const result = await runUpgradeForeground((chunk) => {
1452
- layout.contentWrite(chunk);
1453
- });
1454
- if (result.ok) {
1455
- layout.contentWrite(`\n${ui.green}${ui.bold}✓ ${t('upgrade.completed')}${ui.reset}\n`);
1456
- // 给用户 2.5 秒看清提示,然后自动退出,下次启动即使用新版本。
1457
- await new Promise((resolve) => setTimeout(resolve, 2500));
1458
- break;
1459
- }
1460
- else {
1461
- layout.contentWrite(`${ui.red}✗ ${t('upgrade.failedWithCode', { code: String(result.exitCode ?? 'unknown') })}${ui.reset}\n`);
1462
- if (result.output.includes('ETARGET') || result.output.includes('No matching version found')) {
1463
- layout.contentWrite(`${ui.yellow}${t('upgrade.etargetHint')}${ui.reset}\n`);
1464
- }
1465
- else {
1466
- layout.contentWrite(`${ui.dim}${t('upgrade.manualHint')}${ui.reset}\n`);
1467
- }
1468
- }
1469
- }
1470
- catch (e) {
1471
- const msg = e instanceof Error ? e.message : String(e);
1472
- layout.contentWrite(`${ui.red}${t('upgrade.failed', { message: msg })}${ui.reset}\n`);
1473
- }
1474
- finally {
1475
- stopRunningListener();
1476
- }
1477
- continue;
1478
- }
1479
- // 未知子命令
1480
- layout.contentWrite(`${ui.yellow}${t('upgrade.usage')}${ui.reset}\n`);
1481
- continue;
1482
- }
1483
- if (line === '/plan') {
1484
- // /plan:切到 plan 模式(只读探查 + 产出计划)。
1485
- if (getAgentMode() === 'plan') {
1486
- layout.contentWrite(`${ui.dim}${t('repl.planAlready')}${ui.reset}\n`);
1487
- }
1488
- else {
1489
- setAgentMode('plan');
1490
- layout.contentWrite(`${ui.dim}${t('repl.planChanged')}${ui.reset}\n`);
1491
- }
1492
- continue;
1493
- }
1494
- if (line === '/auto') {
1495
- if (getAgentMode() === 'auto') {
1496
- layout.contentWrite(`${ui.dim}${t('repl.autoAlready')}${ui.reset}\n`);
1497
- }
1498
- else {
1499
- setAgentMode('auto');
1500
- layout.contentWrite(`${ui.dim}${t('repl.autoChanged')}${ui.reset}\n`);
1501
- }
1502
- continue;
1503
- }
1504
- if (line === '/mode') {
1505
- // /mode:菜单里的分支节点(本身不切换,只挂 /plan 与 /auto)。/help 会把它列出来,
1506
- // 所以直接敲它必须给点有用的东西——打印当前模式与两个子命令,而不是报未知命令。
1507
- layout.contentWrite(`${ui.dim}${t('repl.modeCurrent', { mode: getAgentMode() })}${ui.reset}\n`);
1508
- continue;
1509
- }
1510
- if (line === '/pet quit') {
1511
- // /pet quit:完全关闭桌宠进程(区别于 /pet 的仅断开本连接)。方案C的 CLI 侧退出入口,
1512
- // 另一入口是桌宠托盘菜单"退出桌宠"(见 packages/pet-app/src/main.ts)。
1513
- const { ok, reason } = await killPetProcess();
1514
- layout.contentWrite(`${ui.dim}(${ok ? '已关闭桌宠进程' : reason ?? '关闭失败'})${ui.reset}\n`);
1515
- continue;
1516
- }
1517
- if (line === '/pet skin') {
1518
- // /pet skin:菜单选皮(↑↓ 选,Enter 切换,Esc 取消),仿 /theme 的交互。要求桌宠已在运行
1519
- // (未运行则先提示 /pet 打开;不在此处自动 spawn,避免选皮命令产生"顺带开桌宠"的意外副作用)。
1520
- let skinList;
1521
- try {
1522
- skinList = await listSkins();
1523
- }
1524
- catch (e) {
1525
- layout.contentWrite(`${ui.dim}(${e instanceof Error ? e.message : '获取皮肤列表失败'})${ui.reset}\n`);
1526
- continue;
1527
- }
1528
- const items = [
1529
- { id: 'default', title: '默认(mascot)', subtitle: skinList.currentSkinId === 'default' ? '当前' : '' },
1530
- ...skinList.skins.map((s) => ({
1531
- id: s.id,
1532
- title: s.name,
1533
- subtitle: skinList.currentSkinId === s.id ? '当前' : '',
1534
- })),
1535
- ];
1536
- let pick;
1537
- try {
1538
- pick = await promptThemePicker(items);
1539
- }
1540
- catch {
1541
- continue; // Ctrl+C(SIGINT)→ 取消
1542
- }
1543
- if (pick === null)
1544
- continue; // Esc / Ctrl+D 取消
1545
- setSkin(pick.id);
1546
- layout.contentWrite(`${ui.dim}(已切换桌宠皮肤:${pick.title})${ui.reset}\n`);
1547
- continue;
1548
- }
1549
- if (line === '/pet') {
1550
- // /pet:开关桌宠。已连接→断开;未连接→探测端口(已有实例则直连)或 spawn 拉起 + 退避重试连接。
1551
- // togglePet 不抛异常,所有失败路径转为返回值——桌宠是可选增强,任何异常都不能影响 REPL 主流程。
1552
- const { connected, reason } = await togglePet();
1553
- if (connected) {
1554
- layout.contentWrite(`${ui.dim}(桌宠已连接)${ui.reset}\n`);
1555
- }
1556
- else {
1557
- layout.contentWrite(`${ui.dim}(${reason ?? '桌宠已断开'})${ui.reset}\n`);
1558
- }
1559
- continue;
1560
- }
1561
- if (line === '/clear') {
1562
- history.length = 1; // 保留 system 提示
1563
- resetState(); // 同步清空回滚轮次/快照
1564
- // /clear 立刻换新会话 id:之前延后到 turn 收尾(line 1334)分配,但 runAgent 期间模型
1565
- // 调 plan_update / note_append 会命中「no active session」——它们写 notes.md 靠
1566
- // getNotesFilePath(getCurrentSessionId()),而下一个 turn 还没走完。改为与启动对齐
1567
- // 立即分配(对照 line 1019):notes.md 由写入时按需建文件,这里预分配 id 不触盘。
1568
- currentSessionId = newSessionId();
1569
- setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state
1570
- turnCount = 0; // 反思 cadence 重新计数
1571
- contextState.lastUsage = undefined;
1572
- contextState.lifecycleStats = undefined;
1573
- contextState.ephemeralText = undefined;
1574
- lastTurnUsage = undefined; // 清空旧轮的 token 累计
1575
- pendingAttachments = []; // 一并清空待发图片
1576
- layout.clearContent();
1577
- layout.writeBanner(bannerLines(banner()));
1578
- layout.contentWrite(`${ui.dim}${t('repl.historyCleared')}${ui.reset}\n`);
1579
- layout.writeWelcomeBlock(welcomeLines()); // 回到空会话状态,欢迎引导重新出现
1580
- continue;
1581
- }
1582
- // /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
1583
- // dispatch 阶段不调 runTurn:仅 mutate pendingAttachments 状态,提交时(runTurn 入口)才 flush 进 history。
1584
- if (line === '/image' || line === '/image list' || line === '/image clear' || line.startsWith('/image ')) {
1585
- if (line === '/image list' || (line === '/image' && pendingAttachments.length > 0)) {
1586
- // 空 /image 视为 list(无歧义;若用户想加图必须 /image <path>)
1587
- if (pendingAttachments.length === 0) {
1588
- layout.contentWrite(`${ui.dim}(无待发送图片)${ui.reset}\n`);
1589
- }
1590
- else {
1591
- for (const a of pendingAttachments) {
1592
- layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
1593
- }
1594
- }
1595
- continue;
1596
- }
1597
- if (line === '/image clear' || (line === '/image' && pendingAttachments.length === 0)) {
1598
- // /image 单独输入 + 无 pending:也走 list(空集)
1599
- if (line === '/image' && pendingAttachments.length === 0) {
1600
- layout.contentWrite(`${ui.dim}(无待发送图片)${ui.reset}\n`);
1601
- }
1602
- else {
1603
- pendingAttachments = [];
1604
- layout.contentWrite(`${ui.dim}(已清空待发送图片)${ui.reset}\n`);
1605
- }
1606
- continue;
1607
- }
1608
- const arg = line.slice('/image'.length).trim().replace(/^["']|["']$/g, '');
1609
- if (!arg) {
1610
- layout.contentWrite(`${ui.dim}用法: /image <path>${ui.reset}\n`);
1611
- continue;
1612
- }
1613
- const maxBytes = config.maxImageBytes ?? MAX_INLINE_BYTES_DEFAULT;
1614
- const r = await loadImageAttachment(arg, { maxBytes });
1615
- if (!r.ok) {
1616
- layout.contentWrite(`${ui.red}[image] ${r.reason}${ui.reset}\n`);
1617
- continue;
1618
- }
1619
- if (!pendingAttachments.find((a) => a.id === r.att.id)) {
1620
- pendingAttachments.push(r.att);
1621
- }
1622
- layout.contentWrite(` ${ui.dim}${renderChip(r.att)} — will attach to next message${ui.reset}\n`);
1623
- // 提前警告(不阻断附加):当前模型已知不支持视觉(如 MiniMax M2.x / gpt-3.5 等)时,
1624
- // 附加时就提示,而不是等发送后才在 catch 块里翻译 API 报错——减少一轮无意义请求。
1625
- if (!modelSupportsVision(config.model)) {
1626
- layout.contentWrite(` ${ui.yellow}⚠ 当前模型 ${config.model} 已知不支持视觉输入,发送图片可能会失败。可用 /model 切换。${ui.reset}\n`);
1627
- }
1628
- continue;
1629
- }
1630
- if (line === '/context') {
1631
- layout.contentWrite(` ${renderContextBar(history)}\n`);
1632
- continue;
1633
- }
1634
- if (line === '/memory') {
1635
- // 记忆库概览:计数 + 近期 active 索引(详情用 memory_search)。
1636
- const all = loadAll();
1637
- const active = all.filter((e) => e.status === 'active');
1638
- const archived = all.filter((e) => e.status === 'archived').length;
1639
- const byType = {};
1640
- for (const e of active)
1641
- byType[e.type] = (byType[e.type] || 0) + 1;
1642
- layout.contentWrite(`${ui.dim}记忆库:active ${active.length}${archived ? ` · archived ${archived}` : ''}${ui.reset}\n`);
1643
- if (Object.keys(byType).length) {
1644
- layout.contentWrite(`${ui.dim}按类:${Object.entries(byType).map(([t, n]) => `${t} ${n}`).join(' ')}${ui.reset}\n`);
1645
- }
1646
- const recent = [...active]
1647
- .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
1648
- .slice(0, 10);
1649
- for (const e of recent) {
1650
- layout.contentWrite(` ${ui.accent}${e.id}${ui.reset} ${ui.dim}${e.name} — ${e.summary}${ui.reset}\n`);
1651
- }
1652
- if (active.length === 0)
1653
- layout.contentWrite(`${ui.dim}(无 active 记忆;用 memory_save 存,或 /init 生成 AGENTS.md)${ui.reset}\n`);
1654
- layout.contentWrite(`${ui.dim}(详情用 memory_search;启动索引已注入 systemPrompt)${ui.reset}\n`);
1655
- continue;
1656
- }
1657
- if (line === '/reflect') {
1658
- // 记忆子系统总开关关闭时反思无意义(kickoffReflection 内部也会短路),直接提示,不误导用户"已触发"。
1659
- if (!isMemoryEnabled()) {
1660
- layout.contentWrite(`${ui.dim}(记忆子系统已关闭,/reflect 无效。用 /memory_switch 打开后再试)${ui.reset}\n`);
1661
- continue;
1662
- }
1663
- // 手动触发后台反思 pass(不等;完成后下次 INPUT 态显摘要)。
1664
- kickoffReflection(snapshotTranscript(history, 20));
1665
- layout.contentWrite(`${ui.dim}(反思已触发,后台进行;完成后下次输入态显示摘要。日志见 .mocode/memory.log)${ui.reset}\n`);
1666
- continue;
1667
- }
1668
- if (line === '/skills') {
1669
- const skills = listSkills();
1670
- if (skills.length === 0) {
1671
- layout.contentWrite(`${ui.dim}(没有已发现的 skill)${ui.reset}\n`);
1672
- }
1673
- else {
1674
- layout.contentWrite(`${ui.dim}已发现 ${skills.length} 个 skill:${ui.reset}\n`);
1675
- for (const s of skills) {
1676
- const badges = [];
1677
- if (s.context === 'fork')
1678
- badges.push('fork');
1679
- if (!s.modelInvocable)
1680
- badges.push('manual-only');
1681
- badges.push(s.origin);
1682
- if (s.origin === 'project') {
1683
- badges.push(isSkillTrusted(s) ? 'trusted' : 'untrusted');
1684
- }
1685
- const badgeStr = badges.length ? ` ${ui.dim}[${badges.join('|')}]${ui.reset}` : '';
1686
- layout.contentWrite(` ${ui.accent}${s.name}${ui.reset}${badgeStr} ${ui.dim}${s.description}${ui.reset}\n`);
1687
- if (s.allowedTools?.length) {
1688
- layout.contentWrite(` ${ui.dim}allowed: ${s.allowedTools.join(', ')}${ui.reset}\n`);
1689
- }
1690
- if (s.disallowedTools?.length) {
1691
- layout.contentWrite(` ${ui.dim}disallowed: ${s.disallowedTools.join(', ')}${ui.reset}\n`);
1692
- }
1693
- if (s.warnings.length) {
1694
- layout.contentWrite(` ${ui.dim}warnings: ${s.warnings.join('; ')}${ui.reset}\n`);
1695
- }
1696
- }
1697
- layout.contentWrite(`${ui.dim}(用 use_skill 加载指令; fork 类用 run_skill 执行; 也支持 /skill <name> [args-json])${ui.reset}\n`);
1698
- }
1699
- continue;
1700
- }
1701
- // /skill <name> [args-json]:直接执行一个 skill(fork 走 run_skill,inline 打印渲染后正文)。
1702
- if (line === '/skill' || line.startsWith('/skill ')) {
1703
- const rest = line.slice('/skill'.length).trim();
1704
- const sp = rest.indexOf(' ');
1705
- const name = sp === -1 ? rest : rest.slice(0, sp);
1706
- const argStr = sp === -1 ? '' : rest.slice(sp + 1).trim();
1707
- if (!name) {
1708
- layout.contentWrite(`${ui.dim}用法: /skill <name> [args-json]${ui.reset}\n`);
1709
- continue;
1710
- }
1711
- let args;
1712
- if (argStr) {
1713
- try {
1714
- args = JSON.parse(argStr);
1715
- }
1716
- catch {
1717
- layout.contentWrite(`${ui.dim}args 不是合法 JSON,已忽略。${ui.reset}\n`);
1718
- }
1719
- }
1720
- const skill = findSkill(name);
1721
- if (!skill) {
1722
- layout.contentWrite(`错误:未找到 skill "${name}"。\n`);
1723
- continue;
1724
- }
1725
- if (skill.context === 'fork') {
1726
- layout.contentWrite(`${ui.dim}执行 fork skill "${name}"…${ui.reset}\n`);
1727
- const out = await runSkill({ name, args });
1728
- layout.contentWrite((out.status === 'success' ? '' : `[${out.status}] `) + out.output + '\n');
1729
- }
1730
- else {
1731
- const body = await renderSkillBody(skill, args);
1732
- if (body === null)
1733
- layout.contentWrite(`错误:未找到 skill "${name}" 的正文。\n`);
1734
- else {
1735
- // 仅预览:inline 正文未进入模型 history,不激活工具面约束;
1736
- // 要让模型按此 skill 工作,请让它调 use_skill(name) 或在下条消息里提及该 skill。
1737
- layout.contentWrite(`# Skill: ${name}(预览,模型尚未看到此内容)\n\n${body}\n`);
1738
- }
1739
- }
1740
- continue;
1741
- }
1742
- if (line === '/compact' || line.startsWith('/compact ')) {
1743
- // /compact 默认强制压缩(force=true),不受阈值/保护区限制
1744
- // 语法:/compact [focus] 或 /compact --no-force [focus] (显式关闭强制)
1745
- const rest = line.slice('/compact'.length).trim();
1746
- let force = true; // 默认强制
1747
- let focus;
1748
- if (rest === '--no-force')
1749
- force = false;
1750
- else if (rest.startsWith('--no-force ')) {
1751
- force = false;
1752
- focus = rest.slice('--no-force '.length).trim() || undefined;
1753
- }
1754
- else if (rest)
1755
- focus = rest;
1756
- // 走调度器路径:与自动每步压缩完全一致——五区按 ROI 压(cold tools 优先 → history 摘要最后)。
1757
- // focus 透传到 compact_history action 的 LLM 摘要 prompt。
1758
- // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1759
- const log = await (async () => {
1760
- // /compact 的摘要是几十秒的 LLM 调用:包一层运行态监听让 Ctrl+C 能掐断它
1761
- // (信号透传 manualCompact → maybeCompact → compactHistory → chat)。
1762
- const signal = startRunningListener(t('running.compacting'));
1763
- try {
1764
- return await manualCompact(history, focus, { force, signal });
1765
- }
1766
- finally {
1767
- stopRunningListener();
1768
- }
1769
- })().catch((e) => {
1770
- // 中断:history 未被改动(重建在摘要成功之后),直接提示并回到输入态。
1771
- if (e instanceof Error && (e.name === 'AbortError' || e.name === 'APIUserAbortError')) {
1772
- layout.contentWrite(`${ui.dim}(已取消压缩)${ui.reset}\n`);
1773
- return null;
1774
- }
1775
- throw e;
1776
- });
1777
- if (!log)
1778
- continue;
1779
- // 会话状态(plan + 笔记段)不在此处回写 history[0]:agent/core 每步都在 requestHistory
1780
- // 末尾注入最新副本(buildSessionStateReminder),压缩后下一步自然恢复,且系统提示保持
1781
- // 逐字节稳定以命中 prompt 缓存。
1782
- const d = log.compactDetail;
1783
- appendCurrentSessionRuntimeEvent('compact', {
1784
- source: 'manual',
1785
- force,
1786
- called: log.compactHistoryCalled,
1787
- reason: d?.reason ?? 'unknown',
1788
- estimateBefore: d?.estimateBefore,
1789
- estimateAfter: d?.estimateAfter,
1790
- focusHash: focus ? hashTraceValue(focus) : undefined,
1791
- });
1792
- if (!d) {
1793
- // 兜底(旧调用):只显示 old 文案
1794
- if (!log.compactHistoryCalled) {
1795
- layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息)${ui.reset}\n`);
1796
- }
1797
- else if (focus) {
1798
- layout.contentWrite(`${ui.dim}(带焦点压缩:${focus})${ui.reset}\n`);
1799
- }
1800
- continue;
1801
- }
1802
- // 详细文案:按 reason 分类
1803
- const reason = d.reason;
1804
- const before = d.estimateBefore;
1805
- const after = d.estimateAfter;
1806
- const proto = d.protectedRatio !== undefined ? `保护区占比 ${(d.protectedRatio * 100).toFixed(0)}%` : '';
1807
- const oldCt = d.oldGroupCount !== undefined ? `旧区组数 ${d.oldGroupCount}` : '';
1808
- const focusNote = focus ? `焦点:${focus}` : '';
1809
- const stats = [proto, oldCt].filter(Boolean).join(' · ');
1810
- if (reason === 'microcompact') {
1811
- layout.contentWrite(`${ui.cyan}✓ 微压缩:${ui.reset} ${before} → ${after} tokens${stats ? ` (${ui.dim}${stats}${ui.reset})` : ''}\n`);
1812
- }
1813
- else if (reason === 'summarize') {
1814
- layout.contentWrite(`${ui.cyan}✓ LLM 摘要:${ui.reset} ${before} → ${after} tokens${focusNote ? ` (${ui.dim}${focusNote}${ui.reset})` : ''}\n`);
1815
- }
1816
- else if (reason === 'noop-empty') {
1817
- layout.contentWrite(`${ui.dim}(history 太短,只有 system 提示,无可压旧区)${ui.reset}\n`);
1818
- }
1819
- else if (reason === 'noop-protected') {
1820
- layout.contentWrite(`${ui.dim}(无可压旧区:全部在保护区 system + 当前轮)${ui.reset}${stats ? ` ${ui.dim}(${stats})${ui.reset}` : ''}\n`);
1821
- layout.contentWrite(`${ui.dim}提示:/compact --force 强行把早期对话压成摘要${ui.reset}\n`);
1822
- }
1823
- else if (reason === 'noop-ml-only') {
1824
- layout.contentWrite(`${ui.dim}(LLM 摘要失败,且无超大单条可微压;可能是后端不可用)${ui.reset}\n`);
1825
- layout.contentWrite(`${ui.dim}回退:只跑了 keep-current 结构,history 未变${ui.reset}\n`);
1826
- }
1827
- else if (reason === 'noop-shrunk-too-large') {
1828
- layout.contentWrite(`${ui.yellow}● 上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
1829
- if (stats)
1830
- layout.contentWrite(`${ui.dim}(${stats})${ui.reset}\n`);
1831
- }
1832
- else if (reason === 'noop-noold-noop') {
1833
- layout.contentWrite(`${ui.dim}(无需压缩:没有可压缩的旧消息,且不在手动触发)${ui.reset}\n`);
1834
- }
1835
- else {
1836
- layout.contentWrite(`${ui.dim}(reason=${reason},${before} → ${after} tokens)${ui.reset}\n`);
1837
- }
1838
- continue;
1839
- }
1840
- if (line === '/sessions') {
1841
- // /sessions:浏览全部已保存会话(慢路径,readdir+全量 JSON.parse,目录 N 大时会有可感知卡顿)。
1842
- // 默认走 /resume(仅最近 10 条,瞬开);要翻历史续接更早的会话才用这条。
1843
- // picker 走全显(cap=items.length,无 a 展开提示),靠 picker 自身开窗(以选中为中心分屏)。
1844
- const sessions = listSessions(); // 不传 limit = 全量
1845
- if (sessions.length === 0) {
1846
- layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
1847
- continue;
1848
- }
1849
- const items = sessions.map((s) => ({
1850
- id: s.id,
1851
- title: s.firstUser || '(无)',
1852
- subtitle: `${s.id} ${s.model}`,
1853
- }));
1854
- let pick;
1855
- try {
1856
- pick = await promptSessionPicker(items, items.length);
1857
- }
1858
- catch {
1859
- continue; // Ctrl+C(SIGINT)→ 取消
1860
- }
1861
- await resumeFromPick(pick);
1862
- continue;
1863
- }
1864
- if (line === '/resume') {
1865
- // /resume:打开会话菜单(↑/↓ 选,Enter 续接,Esc 取消)。只加载最近 10 条,
1866
- // 避免 sessions 目录堆了几百个会话时 readdir+全量 JSON.parse 卡顿。
1867
- // 仿 /rollback 菜单化(promptSessionPicker);选中项 cyan+bold + ▸ 高亮。
1868
- // 要续接更早的会话请用 /sessions 翻全表,或 CLI `mocode --resume <id>`。
1869
- const sessions = listSessions(10);
1870
- if (sessions.length === 0) {
1871
- layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
1872
- continue;
1873
- }
1874
- const items = sessions.map((s) => ({
1875
- id: s.id,
1876
- title: s.firstUser || '(无)',
1877
- subtitle: `${s.id} ${s.model}`,
1878
- }));
1879
- let pick;
1880
- try {
1881
- pick = await promptSessionPicker(items);
1882
- }
1883
- catch {
1884
- continue; // Ctrl+C(SIGINT)→ 取消
1885
- }
1886
- await resumeFromPick(pick);
1887
- continue;
1888
- }
1889
- if (line === '/theme' || line.startsWith('/theme ')) {
1890
- // /theme:无参开菜单(↑↓ 选,Enter 切换,Esc 取消);/theme <name> 直切;/theme list 或未知名 → 列出。
1891
- const arg = line.startsWith('/theme ') ? line.slice('/theme '.length).trim() : '';
1892
- let name;
1893
- if (arg === '') {
1894
- // 无参:菜单选(仿 /resume /rollback 的 picker)
1895
- const items = listThemes().map((t) => ({
1896
- id: t,
1897
- title: t,
1898
- subtitle: themeDescription(t),
1899
- }));
1900
- let pick;
1901
- try {
1902
- pick = await promptThemePicker(items);
1903
- }
1904
- catch {
1905
- continue; // Ctrl+C(SIGINT)→ 取消
1906
- }
1907
- name = pick?.id ?? null;
1908
- }
1909
- else if (arg === 'list' || !themeExists(arg)) {
1910
- layout.contentWrite(`${ui.dim}${t('repl.themeList')}${ui.reset}\n`);
1911
- for (const theme of listThemes()) {
1912
- layout.contentWrite(` ${ui.accent}${theme}${ui.reset} ${ui.dim}${themeDescription(theme)}${ui.reset}\n`);
1913
- }
1914
- layout.contentWrite(`${ui.dim}${t('repl.themeCurrent', { theme: getTheme() })}${ui.reset}\n`);
1915
- continue;
1916
- }
1917
- else {
1918
- name = arg;
1919
- }
1920
- if (name === null)
1921
- continue; // Esc / Ctrl+D 取消
1922
- // 切:setTheme → 重算状态行(新色)→ 清内容重绘(历史 / 横幅,镜像启动 + /resume)→ 确认 → 持久化。
1923
- // markdown MEMO 按 themeVersion 自动失效,故 renderHistory 取新色;状态栏 / 输入框由 continue 回 INPUT 态时读 getter 刷。
1924
- setTheme(name);
1925
- refreshStatusBase(history);
1926
- layout.clearContent();
1927
- if (history.some((m) => m.role === 'user')) {
1928
- renderHistory(history);
1929
- }
1930
- else {
1931
- layout.writeBanner(bannerLines(banner()));
1932
- }
1933
- layout.contentWrite(`${ui.dim}${t('repl.themeChanged', { theme: name })}${ui.reset}\n`);
1934
- updateConfigKey('MOCODE_THEME', name);
1935
- if (config.themeFromShell) {
1936
- layout.contentWrite(`${ui.dim}(shell 环境变量 MOCODE_THEME 已设,文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
1937
- }
1938
- continue;
1939
- }
1940
- if (line === '/model' || line.startsWith('/model ')) {
1941
- // /model:运行时配置大模型(baseURL/apiKey/model/contextWindowTokens)。
1942
- // 即时生效(updateModelConfig 改内存 + env,reconfigureClient 重建 OpenAI 实例)+ 持久化(writeConfigKeys 写 ~/.mocode/config)。
1943
- // 仿 /theme:promptIntervention 弹菜单/输入 → 改 config → refreshStatusBase 刷底栏 → clearContent+banner 重显横幅 → dim 警告(shell env 覆盖)。
1944
- const arg = line.startsWith('/model ') ? line.slice('/model '.length).trim() : '';
1945
- // ── /model 子命令(use/save/list/delete/rename)优先派发,免得被无参向导路径吞掉。
1946
- // 共用:apply 一个预设到 config + 持久化 + 重建 client + 重显横幅。无参 /model 选菜单和 /model use 都走这里。
1947
- const applyPresetAndPersist = (target) => {
1948
- updateModelConfig({
1949
- provider: target.provider,
1950
- model: target.model,
1951
- baseURL: target.baseURL,
1952
- apiKey: target.apiKey,
1953
- contextWindowTokens: target.contextWindow,
1954
- anthropicPromptCache: target.anthropicPromptCache,
1955
- });
1956
- writeConfigKeys({
1957
- LLM_PROVIDER: target.provider,
1958
- LLM_BASE_URL: target.baseURL,
1959
- LLM_API_KEY: target.apiKey,
1960
- LLM_MODEL: target.model,
1961
- CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
1962
- ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
1963
- });
1964
- reconfigureClient();
1965
- // 记为激活预设:让上下文窗口等配置从此跟随该预设文件(下次启动也用它,不再回退 config 裸键)。
1966
- try {
1967
- setActivePresetName(target.name);
1968
- }
1969
- catch { /* 指针写失败不阻断切换 */ }
1970
- refreshStatusBase(history);
1971
- layout.clearContent();
1972
- if (history.some((m) => m.role === 'user')) {
1973
- renderHistory(history);
1974
- }
1975
- else {
1976
- layout.writeBanner(bannerLines(banner()));
1977
- }
1978
- const cacheLabel = target.provider === 'anthropic'
1979
- ? ` · Prompt Cache ${target.anthropicPromptCache ? 'on' : 'off'}`
1980
- : '';
1981
- layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} · ${target.provider}${cacheLabel} · 窗口 ${target.contextWindow} @ ${target.baseURL})${ui.reset}\n`);
1982
- if (config.llmKeysFromShell.length > 0) {
1983
- layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},优先级最高,下次启动会盖掉预设的对应字段;预设仍记为激活,取消 shell 设置后恢复跟随)${ui.reset}\n`);
1984
- }
1985
- };
1986
- // 决定自动存的预设名:协议、缓存配置和连接四元组都一致时不重复存。
1987
- const uniquePresetName = (desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache) => {
1988
- const existing = listPresets();
1989
- const sameEntry = existing.find((p) => p.provider === provider && p.baseURL === baseURL && p.apiKey === apiKey
1990
- && p.model === model && p.contextWindow === contextWindow
1991
- && p.anthropicPromptCache === anthropicPromptCache);
1992
- if (sameEntry)
1993
- return null;
1994
- const sanitized = desired
1995
- .replace(/[^a-zA-Z0-9_-]+/g, '-')
1996
- .replace(/^-+|-+$/g, '')
1997
- .slice(0, 32) || 'preset';
1998
- const base = isValidPresetName(sanitized) ? sanitized : 'preset';
1999
- if (!existing.some((p) => p.name === base))
2000
- return base;
2001
- for (let i = 2; i < 1000; i++) {
2002
- const candidate = `${base}-${i}`;
2003
- if (candidate.length > 32)
2004
- return `${base.slice(0, 32 - String(i).length - 1)}-${i}`;
2005
- if (!existing.some((p) => p.name === candidate))
2006
- return candidate;
2007
- }
2008
- return `${base}-${Date.now()}`;
2009
- };
2010
- // /model switch:弹 ↑↓·Enter 菜单挑预设切换。无预设时给一行引导。
2011
- if (arg === 'switch') {
2012
- const presets = listPresets();
2013
- if (presets.length === 0) {
2014
- layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
2015
- continue;
2016
- }
2017
- const isCurrent = (p) => p.provider === config.provider &&
2018
- p.baseURL === config.baseURL &&
2019
- p.apiKey === config.apiKey &&
2020
- p.model === config.model &&
2021
- p.contextWindow === config.contextWindowTokens &&
2022
- p.anthropicPromptCache === (config.provider === 'anthropic' && config.anthropicPromptCache);
2023
- const cols = layout.getGeo().cols;
2024
- const labelFor = (p) => {
2025
- const tag = isCurrent(p) ? ' ★current' : '';
2026
- const cache = p.provider === 'anthropic' ? ` · cache ${p.anthropicPromptCache ? 'on' : 'off'}` : '';
2027
- const right = `${p.provider}${cache} · ${p.model} @ ${p.baseURL}`;
2028
- const left = `${p.name}${tag}`;
2029
- const sep = left.length + 1 + right.length;
2030
- if (sep <= cols - 2)
2031
- return `${left} ${ui.dim}${right}${ui.reset}`;
2032
- return left;
2033
- };
2034
- const choice = await promptIntervention({
2035
- type: 'choice',
2036
- title: '切换模型预设',
2037
- detail: `当前: ${config.provider} · ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
2038
- options: presets.map(labelFor),
2039
- allowCustom: false, // 纯切换,不需要「其他」干扰
2040
- });
2041
- if (choice.action === 'selected' && choice.value) {
2042
- // 精确匹配 labelFor 生成的完整选项串,避免 name 前缀误命中
2043
- // (如 'qwen3-8-27b' 是 'qwen3-8-27b-2' 的前缀,排序在前会抢中,应用错 contextWindow)。
2044
- const target = presets.find((p) => labelFor(p) === choice.value);
2045
- if (target)
2046
- applyPresetAndPersist(target);
2047
- }
2048
- continue;
2049
- }
2050
- // /model list:列已配置的预设(★ 标当前);无预设给一行引导。
2051
- // /model presets 是同义别名(老用户习惯)。
2052
- if (arg === 'list' || arg === 'presets') {
2053
- const ps = listPresets();
2054
- if (ps.length === 0) {
2055
- layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
2056
- continue;
2057
- }
2058
- layout.contentWrite(`${ui.dim}已配置 ${ps.length} 个预设:${ui.reset}\n`);
2059
- for (const p of ps) {
2060
- const current = p.provider === config.provider
2061
- && p.baseURL === config.baseURL
2062
- && p.apiKey === config.apiKey
2063
- && p.model === config.model
2064
- && p.contextWindow === config.contextWindowTokens
2065
- && p.anthropicPromptCache === (config.provider === 'anthropic' && config.anthropicPromptCache);
2066
- const star = current ? ' ★' : '';
2067
- const cache = p.provider === 'anthropic' ? ` · cache ${p.anthropicPromptCache ? 'on' : 'off'}` : '';
2068
- layout.contentWrite(` ${ui.accent}${p.name}${ui.reset}${star} ${ui.dim}${p.provider}${cache} · ${p.model} @ ${p.baseURL}${ui.reset}\n`);
2069
- }
2070
- layout.contentWrite(`${ui.dim}(★ = 与当前协议及缓存配置一致;切换用 /model switch)${ui.reset}\n`);
2071
- continue;
2072
- }
2073
- // /model show:显示当前协议、连接与缓存配置(apiKey 脱敏)。
2074
- if (arg === 'show') {
2075
- layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
2076
- layout.contentWrite(` ${ui.accent}provider${ui.reset} ${config.provider}\n`);
2077
- layout.contentWrite(` ${ui.accent}baseURL ${ui.reset} ${config.baseURL}\n`);
2078
- layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
2079
- layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
2080
- layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
2081
- if (config.provider === 'anthropic') {
2082
- layout.contentWrite(` ${ui.accent}缓存 ${ui.reset} Prompt Cache ${config.anthropicPromptCache ? 'on' : 'off'}\n`);
2083
- }
2084
- layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
2085
- continue;
2086
- }
2087
- // /model use <name>:一键把预设应用到 config + 持久化 + 重建 client。
2088
- if (arg.startsWith('use ')) {
2089
- const name = arg.slice(4).trim();
2090
- if (!name) {
2091
- layout.contentWrite(`${ui.yellow}用法: /model use <name>${ui.reset}\n`);
2092
- continue;
2093
- }
2094
- if (!isValidPresetName(name)) {
2095
- layout.contentWrite(`${ui.yellow}非法名字: ${name}(仅允许 [a-zA-Z0-9_-]{1,32})${ui.reset}\n`);
2096
- continue;
2097
- }
2098
- let preset;
2099
- try {
2100
- preset = getPreset(name);
2101
- }
2102
- catch (e) {
2103
- if (e.code === 'ENOENT') {
2104
- layout.contentWrite(`${ui.yellow}没有预设 “${name}”;先 /model save ${name}${ui.reset}\n`);
2105
- }
2106
- else {
2107
- layout.contentWrite(`${ui.red}/model use 失败: ${e.message}${ui.reset}\n`);
2108
- }
2109
- continue;
2110
- }
2111
- applyPresetAndPersist(preset);
2112
- continue;
2113
- }
2114
- // /model delete <name>:删一个预设。无参 / 重名 / 非法名字分别给不同提示。
2115
- if (arg.startsWith('delete ')) {
2116
- const name = arg.slice(7).trim();
2117
- if (!name) {
2118
- layout.contentWrite(`${ui.yellow}用法: /model delete <name>${ui.reset}\n`);
2119
- continue;
2120
- }
2121
- if (!isValidPresetName(name)) {
2122
- layout.contentWrite(`${ui.yellow}非法名字: ${name}(仅允许 [a-zA-Z0-9_-]{1,32})${ui.reset}\n`);
2123
- continue;
2124
- }
2125
- if (deletePreset(name)) {
2126
- layout.contentWrite(`${ui.dim}已删除预设 “${name}”${ui.reset}\n`);
2127
- }
2128
- else {
2129
- layout.contentWrite(`${ui.yellow}没有预设 “${name}”${ui.reset}\n`);
2130
- }
2131
- continue;
2132
- }
2133
- // /model 后跟了未知子命令 → 给一行简短用法提示,免得静默吞用户输入。
2134
- // arg === '' → 直接进向导(下方 4 步链);这里只兜底非法子命令。
2135
- if (arg !== '') {
2136
- layout.contentWrite(`${ui.yellow}未知子命令: ${arg}${ui.reset}\n` +
2137
- `${ui.dim}用法: /model(配置新模型向导) · /model switch · /model list · /model delete <name>${ui.reset}\n`);
2138
- continue;
2139
- }
2140
- // /model 无参 → 直接进入「配置新模型」4 步向导(不弹动作菜单)。
2141
- // 切换 / 查看 / 删除已配置预设改用显式子命令:/model switch · /model list · /model delete <name>。
2142
- // 1) 选 provider 预设(预填 baseURL,后续仍可逐项改)。
2143
- let preset;
2144
- try {
2145
- const res = await promptIntervention({
2146
- type: 'choice',
2147
- title: '选择后端预设(预填 baseURL,后续可改)',
2148
- detail: '选一个会预填 baseURL/model/窗口,之后逐项确认。选「自定义」全部手填。',
2149
- options: MODEL_PRESETS.map((p) => p.label),
2150
- });
2151
- if (res.action === 'cancelled') {
2152
- continue;
2153
- }
2154
- const idx = MODEL_PRESETS.findIndex((p) => p.label === res.value);
2155
- if (idx === -1) {
2156
- continue;
2157
- }
2158
- preset = MODEL_PRESETS[idx];
2159
- }
2160
- catch {
2161
- continue; // Ctrl+C
2162
- }
2163
- // 1.5) 一键应用确认:非「自定义」预设(带预填值)给直接应用入口,免连按 4 次回车。
2164
- // 直接应用 = 用预设 model/baseURL/window + 保留当前 apiKey(等价于下方逐项链连按回车)。
2165
- // 逐项修改 / 自定义输入(promptIntervention choice 自动追加的「其他」项 submitted)→ 回落 4 步链。
2166
- // 「自定义」预设字段空,跳过确认直接进链。
2167
- let quickApply = false;
2168
- if (preset.model || preset.baseURL) {
2169
- try {
2170
- const res = await promptIntervention({
2171
- type: 'choice',
2172
- title: `应用 ${preset.label}?`,
2173
- detail: `model ${preset.model}\nbaseURL ${preset.baseURL}\napiKey ${maskKey(config.apiKey)}(直接应用=保留当前)\n窗口 ${preset.window}`,
2174
- options: ['直接应用', '逐项修改'],
2175
- allowCustom: false,
2176
- });
2177
- if (res.action === 'cancelled') {
2178
- continue;
2179
- }
2180
- if (res.action === 'selected' && res.value === '直接应用') {
2181
- quickApply = true;
2182
- }
2183
- // 其余(逐项修改 / 自定义输入 submitted)→ quickApply 保持 false,走下方逐项链
2184
- }
2185
- catch {
2186
- continue; // Ctrl+C
2187
- }
2188
- }
2189
- // 2) 收集 baseURL / apiKey / model / contextWindowTokens。
2190
- // quickApply:直接取预设值 + 当前 apiKey;否则逐项 input(预填 preset 值,回车=采纳;apiKey 不预填明文,回车=保留旧值)。
2191
- let baseURL;
2192
- let apiKey;
2193
- let model;
2194
- let window;
2195
- if (quickApply) {
2196
- baseURL = preset.baseURL;
2197
- apiKey = config.apiKey;
2198
- model = preset.model;
2199
- window = preset.window;
2200
- }
2201
- else {
2202
- // baseURL
2203
- {
2204
- const res = await promptIntervention({
2205
- type: 'input',
2206
- title: 'LLM_BASE_URL',
2207
- detail: 'OpenAI 兼容 API 端点。回车采纳预填值。',
2208
- seed: preset.baseURL || config.baseURL,
2209
- });
2210
- if (res.action === 'cancelled') {
2211
- continue;
2212
- }
2213
- baseURL = (res.value ?? '').trim() || preset.baseURL || config.baseURL;
2214
- }
2215
- if (!baseURL) {
2216
- layout.contentWrite(`${ui.yellow}baseURL 不能为空,已取消。${ui.reset}\n`);
2217
- continue;
2218
- }
2219
- // apiKey(不预填明文:回车=保留旧值,输入新值=覆盖)
2220
- {
2221
- const res = await promptIntervention({
2222
- type: 'input',
2223
- title: 'LLM_API_KEY',
2224
- detail: `回车保留当前 ${maskKey(config.apiKey)};输入新值则覆盖。`,
2225
- seed: '',
2226
- });
2227
- if (res.action === 'cancelled') {
2228
- continue;
2229
- }
2230
- const v = (res.value ?? '').trim();
2231
- apiKey = v || config.apiKey;
2232
- }
2233
- if (!apiKey) {
2234
- layout.contentWrite(`${ui.yellow}apiKey 不能为空,已取消。${ui.reset}\n`);
2235
- continue;
2236
- }
2237
- // model
2238
- {
2239
- const res = await promptIntervention({
2240
- type: 'input',
2241
- title: 'LLM_MODEL',
2242
- detail: '模型名(须支持 function calling)。回车采纳预填值。',
2243
- seed: preset.model || config.model,
2244
- });
2245
- if (res.action === 'cancelled') {
2246
- continue;
2247
- }
2248
- model = (res.value ?? '').trim() || preset.model || config.model;
2249
- }
2250
- if (!model) {
2251
- layout.contentWrite(`${ui.yellow}model 不能为空,已取消。${ui.reset}\n`);
2252
- continue;
2253
- }
2254
- // contextWindowTokens
2255
- {
2256
- const res = await promptIntervention({
2257
- type: 'input',
2258
- title: 'CONTEXT_WINDOW_TOKENS',
2259
- detail: '模型上下文窗口,全局默认 256k;如需不同窗口可手动覆盖。回车采纳预填值。',
2260
- seed: String(preset.window || config.contextWindowTokens),
2261
- });
2262
- if (res.action === 'cancelled') {
2263
- continue;
2264
- }
2265
- const v = (res.value ?? '').trim();
2266
- const n = Number(v);
2267
- if (!v || !Number.isFinite(n) || n <= 0) {
2268
- // 非法输入:保留旧值,不阻断(用 preset.window 或当前值兜底)
2269
- window = preset.window || config.contextWindowTokens;
2270
- }
2271
- else {
2272
- window = Math.floor(n);
2273
- }
2274
- }
2275
- }
2276
- // 3) 应用协议、连接与缓存配置;Anthropic 原生协议无需重建 OpenAI client,但统一刷新无害。
2277
- const provider = preset.provider;
2278
- const anthropicPromptCache = provider === 'anthropic' && preset.anthropicPromptCache;
2279
- updateModelConfig({
2280
- provider,
2281
- model,
2282
- baseURL,
2283
- apiKey,
2284
- contextWindowTokens: window,
2285
- anthropicPromptCache,
2286
- });
2287
- writeConfigKeys({
2288
- LLM_PROVIDER: provider,
2289
- LLM_BASE_URL: baseURL,
2290
- LLM_API_KEY: apiKey,
2291
- LLM_MODEL: model,
2292
- CONTEXT_WINDOW_TOKENS: String(window),
2293
- ANTHROPIC_PROMPT_CACHE: anthropicPromptCache ? 'true' : 'false',
2294
- });
2295
- reconfigureClient();
2296
- // 3.5) 自动存为命名预设;协议与缓存策略也是去重键的一部分。
2297
- let savedName = null;
2298
- try {
2299
- const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache);
2300
- if (finalName) {
2301
- savePreset({
2302
- name: finalName,
2303
- provider,
2304
- baseURL,
2305
- apiKey,
2306
- model,
2307
- contextWindow: window,
2308
- anthropicPromptCache,
2309
- });
2310
- savedName = finalName;
2311
- }
2312
- // 记为激活预设(新存或复用同名都记),让窗口跟随该预设文件。
2313
- if (savedName) {
2314
- try {
2315
- setActivePresetName(savedName);
2316
- }
2317
- catch { /* 指针写失败不阻断 */ }
2318
- }
2319
- }
2320
- catch (e) {
2321
- layout.contentWrite(`${ui.red}保存预设失败: ${e.message}${ui.reset}\n`);
2322
- }
2323
- // 4) 刷新 UI:底栏模型名 + 重显横幅(banner() 闭包实时读 config,自动反映新值)。
2324
- refreshStatusBase(history);
2325
- layout.clearContent();
2326
- if (history.some((m) => m.role === 'user')) {
2327
- renderHistory(history);
2328
- }
2329
- else {
2330
- layout.writeBanner(bannerLines(banner()));
2331
- }
2332
- const cacheLabel = provider === 'anthropic'
2333
- ? ` · Prompt Cache ${anthropicPromptCache ? 'on' : 'off'}`
2334
- : '';
2335
- layout.contentWrite(`${ui.dim}(已切换模型 → ${model} · ${provider}${cacheLabel} @ ${baseURL})${ui.reset}\n`);
2336
- if (savedName) {
2337
- layout.contentWrite(`${ui.dim}(已保存为预设 “${savedName}”,下次 /model use ${savedName} 一键切回)${ui.reset}\n`);
2338
- }
2339
- // 5) dim 警告:shell export 的 LLM 键下次启动会覆盖文件值。
2340
- if (config.llmKeysFromShell.length > 0) {
2341
- layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
2342
- }
2343
- continue;
2344
- }
2345
- if (line === '/rollback' || line.startsWith('/rollback ')) {
2346
- // /rollback:打开轮次菜单(↑/↓ 选,Enter 回滚到该轮并预填其输入,再 Enter 重新跑)。
2347
- // 忽略任何数字参数(原「输数字选回滚」已删,统一走菜单)。无快照的旧轮次(/resume 重建)文件改动不可撤销。
2348
- await rollbackFlow();
2349
- continue;
2350
- }
2351
- if (line === '/subagent' || line.startsWith('/subagent ')) {
2352
- const arg = line.startsWith('/subagent ')
2353
- ? line.slice('/subagent '.length).trim().toLowerCase()
2354
- : 'status';
2355
- if (arg === '' || arg === 'status') {
2356
- const enabled = isSubAgentEnabled();
2357
- const state = t(enabled ? 'subagent.stateOn' : 'subagent.stateOff');
2358
- layout.contentWrite(`${ui.accent}${t('subagent.status', { state })}${ui.reset}\n` +
2359
- `${ui.dim}MOCODE_SUBAGENT_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
2360
- continue;
2361
- }
2362
- if (arg !== 'on' && arg !== 'off') {
2363
- layout.contentWrite(`${ui.yellow}${t('subagent.usage')}${ui.reset}\n`);
2364
- continue;
2365
- }
2366
- const enabled = arg === 'on';
2367
- if (enabled !== isSubAgentEnabled()) {
2368
- updateSubAgentConfig(enabled);
2369
- updateConfigKey('MOCODE_SUBAGENT_ENABLED', enabled ? 'true' : 'false');
2370
- refreshChatTools();
2371
- history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
2372
- layout.rewriteBanner(bannerLines(banner()));
2373
- }
2374
- layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'subagent.changedOn' : 'subagent.changedOff')}${ui.reset}\n`);
2375
- continue;
2376
- }
2377
- if (line === '/fe' ||
2378
- line.startsWith('/fe ') ||
2379
- line === '/frontend' ||
2380
- line.startsWith('/frontend ')) {
2381
- // /fe — 前端工具簇总开关(browser / dev_server / screenshot / view_image)。
2382
- // 无参切换 on/off;有参 on|off 显式;status 只读。设计同 /subagent:单一来源
2383
- // isFrontendToolsEnabled();关闭时 4 个工具不进模型 schema(refreshChatTools 过滤)、
2384
- // 运行时 getRuntimeDisabledTools 兜底拦截、plan 模式 getPlanDisabledTools 也剔除。默认 false。
2385
- const raw = line.startsWith('/fe ')
2386
- ? line.slice('/fe '.length)
2387
- : line.startsWith('/frontend ')
2388
- ? line.slice('/frontend '.length)
2389
- : line === '/frontend'
2390
- ? ''
2391
- : line.slice('/fe'.length);
2392
- const arg = raw.trim().toLowerCase();
2393
- if (arg === '' || arg === 'status') {
2394
- const enabled = isFrontendToolsEnabled();
2395
- const state = t(enabled ? 'fe.stateOn' : 'fe.stateOff');
2396
- layout.contentWrite(`${ui.accent}${t('fe.status', { state })}${ui.reset}\n` +
2397
- `${ui.dim}MOCODE_FRONTEND_TOOLS_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n`);
2398
- continue;
2399
- }
2400
- if (arg !== 'on' && arg !== 'off') {
2401
- layout.contentWrite(`${ui.yellow}${t('fe.usage')}${ui.reset}\n`);
2402
- continue;
2403
- }
2404
- const enabled = arg === 'on';
2405
- if (enabled !== isFrontendToolsEnabled()) {
2406
- updateFrontendToolsConfig(enabled);
2407
- updateConfigKey('MOCODE_FRONTEND_TOOLS_ENABLED', enabled ? 'true' : 'false');
2408
- refreshChatTools();
2409
- history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
2410
- layout.rewriteBanner(bannerLines(banner()));
2411
- }
2412
- layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'fe.changedOn' : 'fe.changedOff')}${ui.reset}\n`);
2413
- continue;
2414
- }
2415
- if (line === '/mcp' || line.startsWith('/mcp ')) {
2416
- const arg = line.startsWith('/mcp ')
2417
- ? line.slice('/mcp '.length).trim().toLowerCase()
2418
- : 'status';
2419
- if (arg === '' || arg === 'status') {
2420
- const enabled = isMcpEnabled();
2421
- const state = t(enabled ? 'mcp.stateOn' : 'mcp.stateOff');
2422
- layout.contentWrite(`${ui.accent}${t('mcp.status', { state })}${ui.reset}\n` +
2423
- `${ui.dim}MOCODE_MCP_ENABLED=${enabled ? 'true' : 'false'} · ${CONFIG_PATH}${ui.reset}\n` +
2424
- `${ui.dim}${t('mcp.restartHint')}${ui.reset}\n`);
2425
- continue;
2426
- }
2427
- if (arg !== 'on' && arg !== 'off') {
2428
- layout.contentWrite(`${ui.yellow}${t('mcp.usage')}${ui.reset}\n`);
2429
- continue;
2430
- }
2431
- const enabled = arg === 'on';
2432
- if (enabled === isMcpEnabled()) {
2433
- layout.contentWrite(`${ui.dim}${t('mcp.unchanged', { state: t(enabled ? 'mcp.stateOn' : 'mcp.stateOff') })}${ui.reset}\n`);
2434
- continue;
2435
- }
2436
- updateMcpConfig(enabled);
2437
- updateConfigKey('MOCODE_MCP_ENABLED', enabled ? 'true' : 'false');
2438
- if (enabled) {
2439
- await initializeAllMcp();
2440
- registerToolsExtension('mcp', getMcpTools());
2441
- }
2442
- else {
2443
- await closeAllMcp();
2444
- registerToolsExtension('mcp', []);
2445
- }
2446
- refreshChatTools();
2447
- history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
2448
- layout.rewriteBanner(bannerLines(banner()));
2449
- layout.contentWrite(`${enabled ? ui.green : ui.yellow}${t(enabled ? 'mcp.changedOn' : 'mcp.changedOff')}${ui.reset}\n`);
2450
- continue;
2451
- }
2452
- if (line === '/memory_switch' ||
2453
- line.startsWith('/memory_switch ') ||
2454
- line === '/memory_status' ||
2455
- line.startsWith('/memory_status ')) {
2456
- // /memory_switch — 记忆子系统总开关。无参切换 on/off;/memory_switch on 或 /off 显式;
2457
- // /memory_switch true|false|1|0|yes|no 等同义。/memory_status 只读查询(不写盘)。
2458
- //
2459
- // 设计原则:
2460
- // - 单一来源 isMemoryEnabled():工具表(builtins)、系统提示词(Memory Index 段 + 工具使用说明)、
2461
- // plan-mode 提示(tools/constants.ts)三处都从这里查。
2462
- // - 当前会话的 tool list 是模块初始化时的快照(/memory_switch 不重算 builtinTools)——已发出
2463
- // 请求的工具列表不会被回滚。要"完全生效"需要重启 REPL。但 buildSystemMessage 每次 chat 现拼,
2464
- // 所以系统提示词和 plan suffix 会在「下一轮 chat」即时反映新值。
2465
- // - 持久化字段 MEMORY_ENABLED,默认值 false(新用户零侵入)。
2466
- try {
2467
- if (line === '/memory_status' || line.startsWith('/memory_status ')) {
2468
- const on = isMemoryEnabled();
2469
- layout.contentWrite(`${ui.accent}记忆子系统:${ui.reset} ${on ? `${ui.green}开启` : `${ui.yellow}关闭`}${ui.reset}\n`);
2470
- layout.contentWrite(`${ui.dim} 单一来源 isMemoryEnabled()(${config.memoryEnabled});` +
2471
- `持久化 ${ui.accent}MEMORY_ENABLED${ui.dim};` +
2472
- `配置文件 ${CONFIG_PATH}${ui.reset}\n`);
2473
- layout.contentWrite(`${ui.dim} 关闭时:memory_*_save/_search/_list/_update/_forget 五个工具整体不进工具表;` +
2474
- `buildBasePrompt() 不含「## Memory」段;` +
2475
- `plan-mode 提示词里也不出现 memory_* 工具名。${ui.reset}\n`);
2476
- layout.contentWrite(`${ui.dim} 切换后下次新建 system message 即时反映;当前会话工具表需重启 REPL 才完整重算。${ui.reset}\n`);
2477
- continue;
2478
- }
2479
- // /memory_switch(无参=on/off 切换;有参=按值设)
2480
- const arg = line.startsWith('/memory_switch ')
2481
- ? line.slice('/memory_switch '.length).trim().toLowerCase()
2482
- : '';
2483
- let nextEnabled;
2484
- if (arg === '') {
2485
- nextEnabled = !isMemoryEnabled();
2486
- }
2487
- else if (['on', 'true', '1', 'yes', 'y', 'enable', 'enabled'].includes(arg)) {
2488
- nextEnabled = true;
2489
- }
2490
- else if (['off', 'false', '0', 'no', 'n', 'disable', 'disabled'].includes(arg)) {
2491
- nextEnabled = false;
2492
- }
2493
- else {
2494
- layout.contentWrite(`${ui.yellow}/memory_switch 用法:${ui.reset}\n` +
2495
- ` /memory_switch 切换(开↔关)\n` +
2496
- ` /memory_switch on|off 显式设值\n` +
2497
- ` /memory_switch status 等同 /memory_status\n`);
2498
- continue;
2499
- }
2500
- const prev = isMemoryEnabled();
2501
- if (nextEnabled === prev) {
2502
- layout.contentWrite(`${ui.dim}(已是 ${nextEnabled ? '开启' : '关闭'},未变更 — 持久化字段未写入)${ui.reset}\n`);
2503
- continue;
2504
- }
2505
- updateMemoryConfig(nextEnabled);
2506
- // 写盘:mode 文件 values,/~/.mocode/config;writeConfigKeys 不会动其它键(主题 / 模型等)
2507
- updateConfigKey('MEMORY_ENABLED', nextEnabled ? 'true' : 'false');
2508
- const note = nextEnabled
2509
- ? `${ui.green}已开启记忆子系统${ui.reset} — memory_save/search/list/update/forget/graph 进入工具表;` +
2510
- `Memory Index 段会在下次拼 system message 时注入。工具表本身的快照需要重启 REPL 才完整刷新。`
2511
- : `${ui.yellow}已关闭记忆子系统${ui.reset} — 六个 memory_* 工具将在下次拼 system message 时从工具表过滤;` +
2512
- `Memory Index 段不再出现;plan-mode 提示词里的 memory_* 字样消失。重启 REPL 后工具表完全不出现。`;
2513
- layout.contentWrite(`${note}\n`);
2514
- layout.contentWrite(`${ui.dim}(写入 ${CONFIG_PATH}:MEMORY_ENABLED=${nextEnabled ? 'true' : 'false'};${ui.reset}` +
2515
- (process.env.MEMORY_ENABLED
2516
- ? `${ui.dim}同 session shell 未 export,文件写入即时生效)${ui.reset}\n`
2517
- : `${ui.dim}下次启动仍生效)${ui.reset}\n`));
2518
- // 即时刷 banner(原地替换顶部 bannerH 行,不留副本):banner() 闭包实时读
2519
- // isMemoryEnabled(),无需重启 REPL。buffer 中 bannerH 之下的对话历史位置不动。
2520
- layout.rewriteBanner(bannerLines(banner()));
2521
- }
2522
- catch (e) {
2523
- layout.contentWrite(`${ui.red}/memory_switch 失败:${ui.reset} ${e.message}\n`);
2524
- }
2525
- continue;
2526
- }
2527
- // 未知斜杠命令兜底:命令分派是一串独立的 if(…){ …continue },没有 else,
2528
- // 拼错的 /hepl 会一路走到这里被当成普通 prompt 送给模型——白烧一轮还拿不到任何提示。
2529
- // 只拦「长得像命令」的输入:单个前导 / + 词字符。像 /tmp/foo/bar、/src/index.ts:12:5 这类
2530
- // 路径(在编码 agent 里很常见,且同样以 / 开头)必须放行,不能当成拼错的命令。
2531
- if (isCommandShape(cmd) && !forwardToAgent) {
2532
- const suggestion = suggestCommand(cmd);
2533
- layout.contentWrite(`${ui.yellow}${t('repl.unknownCommand', { value: cmd })}${ui.reset}\n`);
2534
- if (suggestion) {
2535
- layout.contentWrite(`${ui.dim}${t('repl.didYouMean', { value: suggestion })}${ui.reset}\n`);
2536
- }
2537
- layout.contentWrite(`${ui.dim}${t('repl.unknownCommandHint')}${ui.reset}\n`);
2538
- continue; // 与其他命令一致:回 INPUT 态由循环顶部的 normalizeInputBoundary + enterInputMode 负责
2539
- }
2540
- const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
2541
- const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
2542
- if (!shouldCommit) {
2543
- // 撤回:气泡从内容区擦掉 + 行放回输入框(下轮 promptWithSlashMenu 经 initialLines 消费)+ 切回 INPUT 视觉。
2544
- // pendingAttachments **保留** —— 撤回的是输入文本不是意图,再发时随消息一起带走
2545
- // (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
2546
- layout.rewindContent(bubbleRows);
2547
- pendingPrefill = input;
2548
- layout.enterInputMode(t('repl.idle'));
2549
- continue;
2550
- }
2551
- // 只记录已过撤回窗口的真实用户 query;slash 命令和合成执行轮不会走到这里。
2552
- queryHistory.push(joined);
2553
- const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
2554
- const ok = await runTurn(joined, initialPlan, placeholder);
2555
- // plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
2556
- // - 仍 plan:模型只产计划就 STOP(模型已无 switch_mode 工具)→ 弹审批面板(原行为)。
2557
- // - 已 auto:本轮被切到 auto 模式(用户用 /auto 触发的合成执行轮)→ 跳过审批,不重复打扰。
2558
- if (initialPlan && ok && getAgentMode() === 'plan') {
2559
- // 桌宠:计划审批面板弹出期间广播 waiting_human(红灯闪烁);面板不在 runAgent/hooks 体系内,
2560
- // 需在此单独广播——用户响应后由下一次 /pet 状态事件(或 idle 兜底)覆盖。
2561
- sendState('waiting_human');
2562
- const executePlanOption = t('plan.execute');
2563
- const res = await promptIntervention({
2564
- type: 'choice',
2565
- title: t('plan.ready'),
2566
- detail: t('plan.approvalDetail'),
2567
- options: [executePlanOption, t('plan.refine')],
2568
- });
2569
- sendState('idle');
2570
- if (res.action === 'selected' && res.value === executePlanOption) {
2571
- // setAgentMode('auto') 由 runTurn 入口做(listener 重写 history[0] 回 auto);这里只切运行态 + 合成执行轮。
2572
- layout.enterRunningMode(t('plan.running'), t('plan.executing'));
2573
- await runTurn(t('plan.executePrompt'), false, t('plan.executing'));
2574
- }
2575
- }
2576
- }
2577
- // 退出前等在飞反思收尾(Ctrl+C 走 SIGINT 直退不等;fire-and-forget 不承诺中断时完成)。
2578
- await drainMemoryBackground();
2579
- await closeAllMcp();
2580
- layout.exitAltScreen();
2581
- }
1
+ // 桶文件:保持对外 import 路径不变(from '../repl/index.js')
2
+ // 实际实现已拆分到同目录下的 runtime.ts / commands.ts / status-bar.ts / message-format.ts / running-input.ts
3
+ export { suggestCommand } from './commands.js';
4
+ export { renderHistory } from './message-format.js';
5
+ export { startRepl } from './runtime.js';