mocode-ai 0.1.7 → 0.1.9

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 (48) hide show
  1. package/README.md +22 -28
  2. package/dist/agent/core.js +16 -7
  3. package/dist/commands/config.js +1 -1
  4. package/dist/config/file.js +1 -1
  5. package/dist/config/index.js +48 -7
  6. package/dist/context/classifier.js +2 -1
  7. package/dist/context/encoders/_util.js +40 -0
  8. package/dist/context/encoders/code.js +77 -0
  9. package/dist/context/encoders/doc.js +28 -0
  10. package/dist/context/encoders/graph.js +31 -0
  11. package/dist/context/encoders/index.js +24 -2
  12. package/dist/context/encoders/log.js +52 -0
  13. package/dist/context/encoders/memory.js +63 -0
  14. package/dist/context/encoders/search.js +69 -0
  15. package/dist/context/encoders/summary.js +26 -0
  16. package/dist/context/encoders/table.js +64 -0
  17. package/dist/context/encoders/tree.js +86 -0
  18. package/dist/index.js +2 -1
  19. package/dist/llm/index.js +13 -1
  20. package/dist/repl/index.js +211 -14
  21. package/dist/session/compact.js +49 -18
  22. package/dist/session/drop.js +135 -0
  23. package/dist/session/index.js +1 -0
  24. package/dist/tools/builtins/ask-human.js +4 -4
  25. package/dist/tools/builtins/codegraph.js +3 -3
  26. package/dist/tools/builtins/drop-context.js +68 -0
  27. package/dist/tools/builtins/edit-file.js +1 -1
  28. package/dist/tools/builtins/glob.js +2 -2
  29. package/dist/tools/builtins/grep.js +2 -2
  30. package/dist/tools/builtins/index.js +2 -0
  31. package/dist/tools/builtins/memory-forget.js +1 -1
  32. package/dist/tools/builtins/memory-list.js +1 -1
  33. package/dist/tools/builtins/memory-save.js +1 -1
  34. package/dist/tools/builtins/memory-search.js +1 -1
  35. package/dist/tools/builtins/memory-update.js +1 -1
  36. package/dist/tools/builtins/read-file.js +2 -2
  37. package/dist/tools/builtins/run-command.js +1 -1
  38. package/dist/tools/builtins/switch-mode.js +3 -5
  39. package/dist/tools/builtins/task.js +4 -5
  40. package/dist/tools/builtins/use-skill.js +1 -1
  41. package/dist/tools/builtins/web-fetch.js +1 -1
  42. package/dist/tools/builtins/web-search.js +1 -1
  43. package/dist/tools/builtins/write-file.js +1 -1
  44. package/dist/tools/registry.js +6 -1
  45. package/dist/ui/layout.js +148 -50
  46. package/dist/ui/prompt.js +4 -4
  47. package/dist/ui/render.js +20 -0
  48. package/package.json +1 -1
@@ -0,0 +1,69 @@
1
+ const GREP_RE = /^(.*?):(\d+):(.*)$/;
2
+ export const searchEncoder = {
3
+ kind: 'search',
4
+ encode({ output }) {
5
+ const lines = output.split('\n');
6
+ const matches = [];
7
+ const tail = [];
8
+ let inTail = false;
9
+ for (const l of lines) {
10
+ if (!l)
11
+ continue;
12
+ if (inTail) {
13
+ tail.push(l);
14
+ continue;
15
+ }
16
+ const m = GREP_RE.exec(l);
17
+ if (m) {
18
+ matches.push({ file: m[1], line: m[2], content: m[3] });
19
+ }
20
+ else {
21
+ // 首个非 grep 行起视作 tail(grep 上限标记 / 无匹配串 / web_search 非 grep 结构)
22
+ inTail = true;
23
+ tail.push(l);
24
+ }
25
+ }
26
+ if (matches.length === 0) {
27
+ // 非 grep 格式(web_search 等)→ 不动其已格式化结构
28
+ return {
29
+ text: output,
30
+ meta: {
31
+ kind: 'search',
32
+ originalLen: output.length,
33
+ encodedLen: output.length,
34
+ note: 'no file:line matches → passthrough',
35
+ },
36
+ };
37
+ }
38
+ const groups = new Map();
39
+ const order = [];
40
+ for (const m of matches) {
41
+ if (!groups.has(m.file)) {
42
+ groups.set(m.file, []);
43
+ order.push(m.file);
44
+ }
45
+ groups.get(m.file).push({ line: m.line, content: m.content });
46
+ }
47
+ const out = [
48
+ `# ${matches.length} matches · ${order.length} files · search-encoded`,
49
+ ];
50
+ for (const file of order) {
51
+ out.push(`${file}:`);
52
+ for (const { line, content } of groups.get(file)) {
53
+ out.push(` ${line}:${content}`);
54
+ }
55
+ }
56
+ if (tail.length)
57
+ out.push(...tail);
58
+ const text = out.join('\n');
59
+ return {
60
+ text,
61
+ meta: {
62
+ kind: 'search',
63
+ originalLen: output.length,
64
+ encodedLen: text.length,
65
+ note: `${matches.length} matches / ${order.length} files`,
66
+ },
67
+ };
68
+ },
69
+ };
@@ -0,0 +1,26 @@
1
+ import { collapseBlankRuns } from './_util.js';
2
+ /**
3
+ * Summary Encoder(task):折叠连续空行(≥3 → 1)。
4
+ *
5
+ * 输入:task 工具返回的子 agent 最终摘要文本(已是摘要,可能含多余空行),可能带截断尾标
6
+ * `…(子 agent 摘要已截断 N 字符)`。
7
+ * 输出:空行折叠;摘要文本逐字保留。
8
+ *
9
+ * 不变量:摘要事实文本逐字保留(仅折叠空行);截断尾标保留。子 agent 摘要已是高密度文本,只做轻量去冗余,
10
+ * 不再做进一步压缩(避免丢事实)。
11
+ */
12
+ export const summaryEncoder = {
13
+ kind: 'summary',
14
+ encode({ output }) {
15
+ const text = collapseBlankRuns(output, 3);
16
+ return {
17
+ text,
18
+ meta: {
19
+ kind: 'summary',
20
+ originalLen: output.length,
21
+ encodedLen: text.length,
22
+ note: text !== output ? 'blank runs collapsed' : 'no change',
23
+ },
24
+ };
25
+ },
26
+ };
@@ -0,0 +1,64 @@
1
+ /**
2
+ * Table Encoder(memory_list):去掉默认 `active` 状态标注(冗余信息)。
3
+ *
4
+ * 输入:memory_list 返回的 `- id: name — summary (type, status)` 行(每行一条)。
5
+ * 输出:status 为默认 `active` 时省略 `, active`(→ `(type)`);非默认(archived/superseded)保留。
6
+ * 顶部加 `# N entries · table-encoded` 计数头。
7
+ *
8
+ * 不变量(离线脚本断言):id/name/summary/type 全保留;仅 `active` 状态被省略(默认值,无信息损失)。
9
+ * 正则锚定整行 + 要求 `id: ... — ... (type, active)` 形,不匹配的行原样返回(防误伤 MOCODE.md 等正文)。
10
+ * group1 贪婪捕获到 `(\w+` 为止,故 name/summary 内含 ` — ` 或 ` (` 也不影响(仅丢尾部 `, active)`)。
11
+ */
12
+ const ACTIVE_LINE_RE = /^(- [^:]+: .+ — .+ \(\w+), active\)$/;
13
+ export const tableEncoder = {
14
+ kind: 'table',
15
+ encode({ output }) {
16
+ if (output === '(无记忆条目)') {
17
+ return {
18
+ text: output,
19
+ meta: {
20
+ kind: 'table',
21
+ originalLen: output.length,
22
+ encodedLen: output.length,
23
+ note: 'empty → passthrough',
24
+ },
25
+ };
26
+ }
27
+ const lines = output.split('\n');
28
+ const out = [];
29
+ let changed = 0;
30
+ for (const l of lines) {
31
+ const m = ACTIVE_LINE_RE.exec(l);
32
+ if (m) {
33
+ // m[1] = "- id: name — summary (type";补 ")" 收尾,丢 ", active"
34
+ out.push(`${m[1]})`);
35
+ changed++;
36
+ }
37
+ else {
38
+ out.push(l);
39
+ }
40
+ }
41
+ if (changed === 0) {
42
+ return {
43
+ text: output,
44
+ meta: {
45
+ kind: 'table',
46
+ originalLen: output.length,
47
+ encodedLen: output.length,
48
+ note: 'no active-status lines → passthrough',
49
+ },
50
+ };
51
+ }
52
+ const count = lines.filter((l) => l.startsWith('- ')).length;
53
+ const text = `# ${count} entries · table-encoded (default "active" status omitted)\n${out.join('\n')}`;
54
+ return {
55
+ text,
56
+ meta: {
57
+ kind: 'table',
58
+ originalLen: output.length,
59
+ encodedLen: text.length,
60
+ note: `omitted active from ${changed}/${count} entries`,
61
+ },
62
+ };
63
+ },
64
+ };
@@ -0,0 +1,86 @@
1
+ /**
2
+ * File Tree Encoder(glob):扁平路径列表 → 按目录分组缩进树。
3
+ *
4
+ * 输入:glob 返回的路径(每行一条,\n 拼接),可能带尾部 `... (共 N 个,仅显示前 200)`。
5
+ * 输出:目录头(以 `/` 结尾)+ 其下 2 空格缩进的文件,顶部 `# N files · tree-encoded` 计数。
6
+ *
7
+ * 不变量(离线脚本断言):路径条数与集合保真——每个原始路径可从树还原:
8
+ * 目录头 `dir/` + 其下缩进行 ` file` → `dir/file`;根文件(无缩进、不以 `/` 结尾)→ 自身。
9
+ * 小输入(≤1 路径,含 `无匹配文件` 单行)原样返回:tree 无收益且避免计数头开销。
10
+ * 路径分隔符归一化(`\`→`/`):Windows 下 glob 可能返 `\`,树内统一 `/`。
11
+ */
12
+ function normalizeSep(p) {
13
+ return p.replace(/\\/g, '/');
14
+ }
15
+ export const treeEncoder = {
16
+ kind: 'tree',
17
+ encode({ output }) {
18
+ const lines = output.split('\n');
19
+ // 分离路径与尾部标记(glob 的 `... (共 N 个...)`)。首个非路径行起全部视作 tail 原样保留。
20
+ const paths = [];
21
+ const tail = [];
22
+ let inTail = false;
23
+ for (const l of lines) {
24
+ if (!l)
25
+ continue;
26
+ if (!inTail && (l.startsWith('...') || /^\(共/.test(l))) {
27
+ inTail = true;
28
+ }
29
+ if (inTail)
30
+ tail.push(l);
31
+ else
32
+ paths.push(l);
33
+ }
34
+ if (paths.length <= 1) {
35
+ // ≤1 路径:tree 无收益(含 `无匹配文件`),原样返回。
36
+ return {
37
+ text: output,
38
+ meta: {
39
+ kind: 'tree',
40
+ originalLen: output.length,
41
+ encodedLen: output.length,
42
+ note: '≤1 path → passthrough',
43
+ },
44
+ };
45
+ }
46
+ // 按目录分组(保留首次出现顺序,保还原后顺序与原一致)
47
+ const groups = new Map();
48
+ const order = [];
49
+ for (const p of paths) {
50
+ const norm = normalizeSep(p);
51
+ const idx = norm.lastIndexOf('/');
52
+ const dir = idx >= 0 ? norm.slice(0, idx + 1) : ''; // 含尾斜杠
53
+ const file = idx >= 0 ? norm.slice(idx + 1) : norm;
54
+ if (!groups.has(dir)) {
55
+ groups.set(dir, []);
56
+ order.push(dir);
57
+ }
58
+ groups.get(dir).push(file);
59
+ }
60
+ const out = [`# ${paths.length} files · tree-encoded`];
61
+ for (const dir of order) {
62
+ const files = groups.get(dir);
63
+ if (dir === '') {
64
+ for (const f of files)
65
+ out.push(f);
66
+ }
67
+ else {
68
+ out.push(dir);
69
+ for (const f of files)
70
+ out.push(` ${f}`);
71
+ }
72
+ }
73
+ if (tail.length)
74
+ out.push(...tail);
75
+ const text = out.join('\n');
76
+ return {
77
+ text,
78
+ meta: {
79
+ kind: 'tree',
80
+ originalLen: output.length,
81
+ encodedLen: text.length,
82
+ note: `${paths.length} files / ${order.length} dirs`,
83
+ },
84
+ };
85
+ },
86
+ };
package/dist/index.js CHANGED
@@ -2,7 +2,8 @@ import { exitAltScreen } from './ui/layout.js';
2
2
  import { checkAndMaybeUpdate } from './updater/index.js';
3
3
  // 终端恢复兜底:任一退出 / 中断 / 未捕获异常路径都要恢复 alt screen,避免残留备用屏 + 滚动区域。
4
4
  // exitAltScreen 幂等(未激活时空操作),故全局注册安全——进 alt screen 前的路径(如 --resume 列表、缺环境变量、`mocode config`)调用它无副作用。
5
- // 仅 layout 是叶子(不依赖 config),故静态导入安全;repl / session 依赖 config(requireEnv 缺项即退出),改动态按需加载——`mocode config` 才能在零配置下跑。
5
+ // 仅 layout 是叶子(不依赖 config),故静态导入安全;repl / session 依赖 config(模块加载触发 loadEnvFiles + config 单例初始化),
6
+ // 改动态按需加载——`mocode config` 向导只需读写文件(走 config/file.ts 叶子),不经 config 单例初始化,零配置也能跑。
6
7
  process.on('exit', () => exitAltScreen());
7
8
  process.on('SIGINT', () => {
8
9
  exitAltScreen();
package/dist/llm/index.js CHANGED
@@ -2,10 +2,22 @@ import OpenAI from 'openai';
2
2
  import { config } from '../config/index.js';
3
3
  import { tools } from '../tools/registry.js';
4
4
  import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
5
- const client = new OpenAI({
5
+ let client = new OpenAI({
6
6
  baseURL: config.baseURL,
7
7
  apiKey: config.apiKey,
8
8
  });
9
+ /**
10
+ * 运行时重建 OpenAI 客户端(/model 切换 baseURL/apiKey 后调)。
11
+ * config.model 已在 chat() 每次读取(热切),但 client 的 baseURL/apiKey 是构造时固化的实例字段,
12
+ * 改 config 后必须重建 client 才能让新 baseURL/apiKey 对后续请求生效。
13
+ * 子 agent 复用本模块 chat(),故只此一处重建即全链路生效。
14
+ */
15
+ export function reconfigureClient() {
16
+ client = new OpenAI({
17
+ baseURL: config.baseURL,
18
+ apiKey: config.apiKey,
19
+ });
20
+ }
9
21
  /** 把内部工具定义转成 OpenAI 的 tool 格式 */
10
22
  export const chatTools = tools.map((t) => ({
11
23
  type: 'function',
@@ -1,8 +1,8 @@
1
1
  import readline from 'node:readline/promises';
2
2
  import { emitKeypressEvents } from 'node:readline';
3
3
  import { stdin, stdout } from 'node:process';
4
- import { config, PLAN_MODE_SUFFIX } from '../config/index.js';
5
- import { updateConfigKey } from '../config/file.js';
4
+ import { config, PLAN_MODE_SUFFIX, updateModelConfig, isModelConfigured } from '../config/index.js';
5
+ import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
6
6
  import { runAgent } from '../agent/index.js';
7
7
  import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
8
8
  import { setSandboxRoot } from '../sandbox/root.js';
@@ -13,7 +13,7 @@ import * as mouse from '../ui/mouse.js';
13
13
  import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptThemePicker, promptRevertChoice, } from '../ui/prompt.js';
14
14
  import { promptIntervention } from '../ui/intervention.js';
15
15
  import { tools } from '../tools/registry.js';
16
- import { estimateMessagesTokens, estimateToolSchemaTokens, } from '../llm/index.js';
16
+ import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
17
17
  import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
18
18
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
19
19
  import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
@@ -36,6 +36,7 @@ const SLASH_COMMANDS = [
36
36
  { name: '/reflect', desc: '手动触发后台记忆反思 pass' },
37
37
  { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆' },
38
38
  { name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
39
+ { name: '/model', desc: '配置大模型(baseURL/key/model/窗口)' },
39
40
  { name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
40
41
  { name: '/auto', desc: '切回 auto 模式(全工具执行)' },
41
42
  ];
@@ -47,6 +48,23 @@ const THEME_DESCRIPTIONS = {
47
48
  gruvbox: 'Gruvbox 暖色',
48
49
  nord: 'Nord 冷色',
49
50
  };
51
+ /** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
52
+ const MODEL_PRESETS = [
53
+ { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: 128000 },
54
+ { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: 64000 },
55
+ { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: 128000 },
56
+ { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: 32768 },
57
+ { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: 32768 },
58
+ { label: '自定义 base_url', baseURL: '', model: '', window: 128000 },
59
+ ];
60
+ /** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
61
+ function maskKey(k) {
62
+ if (!k)
63
+ return '(未设置)';
64
+ if (k.length <= 8)
65
+ return '****';
66
+ return `${'='.repeat(Math.min(k.length - 4, 20))}${k.slice(-4)}`;
67
+ }
50
68
  /**
51
69
  * /init 指令:发给 agent 扫描项目并生成 MOCODE.md(对标 Claude Code /init 生成 CLAUDE.md,
52
70
  * 但 mocode 读 MOCODE.md)。已存在则让 agent 读后更新(不丢失事实)。写完供 memory 子系统下轮加载。
@@ -86,11 +104,11 @@ async function askLine(prompt) {
86
104
  rl.close();
87
105
  }
88
106
  }
89
- /** /context 的用量条(详情版,进内容区):优先用上次 chat() 返回的实测 usage,否则用启发式估算。 */
107
+ /** /context 的用量条(详情版,进内容区):只算对话内容(不含 system prompt),方便用户感知自己发了多少、agent 回复了多少。 */
90
108
  function renderContextBar(history) {
91
- const schema = estimateToolSchemaTokens();
92
- const est = contextState.lastUsage?.totalTokens ??
93
- estimateMessagesTokens(history) + schema;
109
+ // 过滤掉 system 消息,只算对话内容
110
+ const dialog = history.filter(m => m.role !== 'system');
111
+ const est = estimateMessagesTokens(dialog);
94
112
  const win = config.contextWindowTokens;
95
113
  const pct = Math.min(1, est / win);
96
114
  const W = 10;
@@ -101,11 +119,12 @@ function renderContextBar(history) {
101
119
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
102
120
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${Math.round(pct * 100)}% ${k(est)}/${k(win)} tokens · ${history.length} 条消息 (${src})${ui.reset}`;
103
121
  }
104
- /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。 */
122
+ /** 状态行用量条(精简版,进底栏):[bar] pct% k/k。
123
+ * 只计算对话内容(不含 system prompt),让用户感知"我发了多少、agent 回复了多少"占用 context。 */
105
124
  function renderContextBarInline(history) {
106
- const schema = estimateToolSchemaTokens();
107
- const est = contextState.lastUsage?.totalTokens ??
108
- estimateMessagesTokens(history) + schema;
125
+ // 过滤掉 system 消息,只算对话内容
126
+ const dialog = history.filter(m => m.role !== 'system');
127
+ const est = estimateMessagesTokens(dialog);
109
128
  const win = config.contextWindowTokens;
110
129
  const pct = Math.min(1, est / win);
111
130
  const W = 10;
@@ -143,6 +162,8 @@ function runningStateFor(cmd) {
143
162
  return { status: '清空', placeholder: '…' };
144
163
  case '/theme':
145
164
  return { status: '切主题', placeholder: '选择主题…' };
165
+ case '/model':
166
+ return { status: '配模型', placeholder: '配置中…' };
146
167
  default:
147
168
  // 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
148
169
  // 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
@@ -189,9 +210,8 @@ function onRunningKey(_str, key) {
189
210
  }
190
211
  // 用户在交互(非滚动键)→ 暂停流式物理写,避免光标去 contentRow 扰动 IME 候选窗(停手后自动 flush)
191
212
  layout.setUserActive();
192
- // 其他键:若处于滚动回看,先回尾再处理(打字即回底)
193
- if (layout.isScrolled())
194
- layout.resetScroll();
213
+ // 滚动回看时打字 / 编辑(typeahead)不回尾——保持历史视图,便于运行中边看历史边预输入;
214
+ // 回尾时机:Enter 在运行态是 no-op,真正回尾发生在 agent 结束后 INPUT 态按 Enter 提交(见 prompt.ts submit 前)
195
215
  // Ctrl+C 4 层语义(RUNNING 态):有 typeahead → 清空(层 1,不中断);空 → abort(层 2,中断 agent)。
196
216
  // 两次 Ctrl+C 才中断(先清 typeahead 再 abort),与 INPUT 态 onCtrlC 的 fish 式一致。
197
217
  // raw 模式下 Ctrl+C 是按键不触发 SIGINT;signal 经 executeTool 串进工具,run_command/web_fetch 即时被杀。
@@ -396,6 +416,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
396
416
  // 自更新提示:开场静态段(进 INPUT 态前),dim 一行,不与流式 / 输入争用。
397
417
  layout.contentWrite(` ${ui.gray}↳ ${updateNotice}${ui.reset}\n`);
398
418
  }
419
+ if (!isModelConfigured()) {
420
+ // 未配置 baseURL/apiKey:醒目提示引导 /model(不退出,REPL 仍可用;发消息会失败但不崩)。
421
+ layout.contentWrite(`${ui.yellow} ⚠ 未配置大模型。输入 ${ui.cyan}/model${ui.yellow} 配置 baseURL / apiKey / model(即时生效),或退出后运行 ${ui.cyan}mocode config${ui.yellow} 走向导。${ui.reset}\n`);
422
+ }
399
423
  layout.contentWrite(`${ui.dim} /plan · /auto · Shift+Tab 切换模式(plan:只读探查 + 产出计划,审批后切 auto 执行)${ui.reset}\n`);
400
424
  /**
401
425
  * 切换 agent 模式(Shift+Tab 触发,经 prompt.ts 的 onCycleMode 回调)。
@@ -772,6 +796,179 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
772
796
  }
773
797
  continue;
774
798
  }
799
+ if (line === '/model' || line.startsWith('/model ')) {
800
+ // /model:运行时配置大模型(baseURL/apiKey/model/contextWindowTokens)。
801
+ // 即时生效(updateModelConfig 改内存 + env,reconfigureClient 重建 OpenAI 实例)+ 持久化(writeConfigKeys 写 ~/.mocode/config)。
802
+ // 仿 /theme:promptIntervention 弹菜单/输入 → 改 config → refreshStatusBase 刷底栏 → clearContent+banner 重显横幅 → dim 警告(shell env 覆盖)。
803
+ const arg = line.startsWith('/model ') ? line.slice('/model '.length).trim() : '';
804
+ // /model list:显示当前四项配置(apiKey 脱敏)。
805
+ if (arg === 'list' || arg === 'show') {
806
+ layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
807
+ layout.contentWrite(` ${ui.cyan}baseURL${ui.reset} ${config.baseURL}\n`);
808
+ layout.contentWrite(` ${ui.cyan}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
809
+ layout.contentWrite(` ${ui.cyan}model ${ui.reset} ${config.model}\n`);
810
+ layout.contentWrite(` ${ui.cyan}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
811
+ layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
812
+ continue;
813
+ }
814
+ // 1) 选 provider 预设(预填 baseURL,后续仍可逐项改)。
815
+ let preset;
816
+ try {
817
+ const res = await promptIntervention({
818
+ type: 'choice',
819
+ title: '选择后端预设(预填 baseURL,后续可改)',
820
+ detail: '选一个会预填 baseURL/model/窗口,之后逐项确认。选「自定义」全部手填。',
821
+ options: MODEL_PRESETS.map((p) => p.label),
822
+ });
823
+ if (res.action === 'cancelled') {
824
+ continue;
825
+ }
826
+ const idx = MODEL_PRESETS.findIndex((p) => p.label === res.value);
827
+ if (idx === -1) {
828
+ continue;
829
+ }
830
+ preset = MODEL_PRESETS[idx];
831
+ }
832
+ catch {
833
+ continue; // Ctrl+C
834
+ }
835
+ // 1.5) 一键应用确认:非「自定义」预设(带预填值)给直接应用入口,免连按 4 次回车。
836
+ // 直接应用 = 用预设 model/baseURL/window + 保留当前 apiKey(等价于下方逐项链连按回车)。
837
+ // 逐项修改 / 自定义输入(promptIntervention choice 自动追加的「其他」项 submitted)→ 回落 4 步链。
838
+ // 「自定义」预设字段空,跳过确认直接进链。
839
+ let quickApply = false;
840
+ if (preset.model || preset.baseURL) {
841
+ try {
842
+ const res = await promptIntervention({
843
+ type: 'choice',
844
+ title: `应用 ${preset.label}?`,
845
+ detail: `model ${preset.model}\nbaseURL ${preset.baseURL}\napiKey ${maskKey(config.apiKey)}(直接应用=保留当前)\n窗口 ${preset.window}`,
846
+ options: ['直接应用', '逐项修改'],
847
+ });
848
+ if (res.action === 'cancelled') {
849
+ continue;
850
+ }
851
+ if (res.action === 'selected' && res.value === '直接应用') {
852
+ quickApply = true;
853
+ }
854
+ // 其余(逐项修改 / 自定义输入 submitted)→ quickApply 保持 false,走下方逐项链
855
+ }
856
+ catch {
857
+ continue; // Ctrl+C
858
+ }
859
+ }
860
+ // 2) 收集 baseURL / apiKey / model / contextWindowTokens。
861
+ // quickApply:直接取预设值 + 当前 apiKey;否则逐项 input(预填 preset 值,回车=采纳;apiKey 不预填明文,回车=保留旧值)。
862
+ let baseURL;
863
+ let apiKey;
864
+ let model;
865
+ let window;
866
+ if (quickApply) {
867
+ baseURL = preset.baseURL;
868
+ apiKey = config.apiKey;
869
+ model = preset.model;
870
+ window = preset.window;
871
+ }
872
+ else {
873
+ // baseURL
874
+ {
875
+ const res = await promptIntervention({
876
+ type: 'input',
877
+ title: 'LLM_BASE_URL',
878
+ detail: 'OpenAI 兼容 API 端点。回车采纳预填值。',
879
+ seed: preset.baseURL || config.baseURL,
880
+ });
881
+ if (res.action === 'cancelled') {
882
+ continue;
883
+ }
884
+ baseURL = (res.value ?? '').trim() || preset.baseURL || config.baseURL;
885
+ }
886
+ if (!baseURL) {
887
+ layout.contentWrite(`${ui.yellow}baseURL 不能为空,已取消。${ui.reset}\n`);
888
+ continue;
889
+ }
890
+ // apiKey(不预填明文:回车=保留旧值,输入新值=覆盖)
891
+ {
892
+ const res = await promptIntervention({
893
+ type: 'input',
894
+ title: 'LLM_API_KEY',
895
+ detail: `回车保留当前 ${maskKey(config.apiKey)};输入新值则覆盖。`,
896
+ seed: '',
897
+ });
898
+ if (res.action === 'cancelled') {
899
+ continue;
900
+ }
901
+ const v = (res.value ?? '').trim();
902
+ apiKey = v || config.apiKey;
903
+ }
904
+ if (!apiKey) {
905
+ layout.contentWrite(`${ui.yellow}apiKey 不能为空,已取消。${ui.reset}\n`);
906
+ continue;
907
+ }
908
+ // model
909
+ {
910
+ const res = await promptIntervention({
911
+ type: 'input',
912
+ title: 'LLM_MODEL',
913
+ detail: '模型名(须支持 function calling)。回车采纳预填值。',
914
+ seed: preset.model || config.model,
915
+ });
916
+ if (res.action === 'cancelled') {
917
+ continue;
918
+ }
919
+ model = (res.value ?? '').trim() || preset.model || config.model;
920
+ }
921
+ if (!model) {
922
+ layout.contentWrite(`${ui.yellow}model 不能为空,已取消。${ui.reset}\n`);
923
+ continue;
924
+ }
925
+ // contextWindowTokens
926
+ {
927
+ const res = await promptIntervention({
928
+ type: 'input',
929
+ title: 'CONTEXT_WINDOW_TOKENS',
930
+ detail: '模型上下文窗口(须对齐真实模型;GLM≈128k,DeepSeek-V3≈64k)。回车采纳预填值。',
931
+ seed: String(preset.window || config.contextWindowTokens),
932
+ });
933
+ if (res.action === 'cancelled') {
934
+ continue;
935
+ }
936
+ const v = (res.value ?? '').trim();
937
+ const n = Number(v);
938
+ if (!v || !Number.isFinite(n) || n <= 0) {
939
+ // 非法输入:保留旧值,不阻断(用 preset.window 或当前值兜底)
940
+ window = preset.window || config.contextWindowTokens;
941
+ }
942
+ else {
943
+ window = Math.floor(n);
944
+ }
945
+ }
946
+ }
947
+ // 3) 应用:内存 config + env(updateModelConfig)→ 持久化(writeConfigKeys)→ 重建 client(reconfigureClient)。
948
+ updateModelConfig({ model, baseURL, apiKey, contextWindowTokens: window });
949
+ writeConfigKeys({
950
+ LLM_BASE_URL: baseURL,
951
+ LLM_API_KEY: apiKey,
952
+ LLM_MODEL: model,
953
+ CONTEXT_WINDOW_TOKENS: String(window),
954
+ });
955
+ reconfigureClient();
956
+ // 4) 刷新 UI:底栏模型名 + 重显横幅(banner() 闭包实时读 config,自动反映新值)。
957
+ refreshStatusBase(history);
958
+ layout.clearContent();
959
+ if (history.some((m) => m.role === 'user')) {
960
+ renderHistory(history);
961
+ }
962
+ else {
963
+ layout.contentWrite(bannerString(banner()));
964
+ }
965
+ layout.contentWrite(`${ui.dim}(已切换模型 → ${model} @ ${baseURL})${ui.reset}\n`);
966
+ // 5) dim 警告:shell export 的 LLM 键下次启动会覆盖文件值。
967
+ if (config.llmKeysFromShell.length > 0) {
968
+ layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖;取消该 shell 设置后生效)${ui.reset}\n`);
969
+ }
970
+ continue;
971
+ }
775
972
  if (line === '/rollback' || line.startsWith('/rollback ')) {
776
973
  // /rollback:打开轮次菜单(↑/↓ 选,Enter 回滚到该轮并预填其输入,再 Enter 重新跑)。
777
974
  // 忽略任何数字参数(原「输数字选回滚」已删,统一走菜单)。无快照的旧轮次(/resume 重建)文件改动不可撤销。