mocode-ai 1.2.5 → 1.2.7

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.
@@ -1,7 +1,7 @@
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, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, reinjectActivePlanIntoSystem, DEFAULT_CONTEXT_WINDOW_TOKENS, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isSubAgentEnabled, updateSubAgentConfig, isFrontendToolsEnabled, updateFrontendToolsConfig, updateLanguageConfig, languageFromShell, buildBasePrompt, getPlanModeSuffix, hasCodegraphIndex, DEFAULT_CONTEXT_WINDOW_TOKENS, pinSessionModel, } from '../config/index.js';
5
5
  import { getLanguage, normalizeLanguage, t, } from '../i18n/index.js';
6
6
  import { DEFAULT_BUDGET_POLICY } from '../context/budget.js';
7
7
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
@@ -66,9 +66,9 @@ function buildSlashCommands() {
66
66
  { name: 'off', value: '/memory_switch off', desc: d('commands.memoryOff') },
67
67
  { name: 'status', value: '/memory_status', desc: d('commands.memoryStatus') },
68
68
  { name: 'reflect', value: '/reflect', desc: d('commands.memoryReflect') },
69
- { name: 'init', value: '/init', desc: d('commands.memoryInit') },
70
69
  ],
71
70
  },
71
+ { name: '/init', desc: d('commands.memoryInit') },
72
72
  {
73
73
  name: '/subagent', desc: d('commands.subagent'), children: [
74
74
  { name: 'on', value: '/subagent on', desc: d('commands.subagentOn') },
@@ -152,17 +152,18 @@ function themeDescription(name) {
152
152
  rose: 1, emerald: 1, amber: 1, lavender: 1, sunset: 1,
153
153
  } ? t(key) : '';
154
154
  }
155
- /** /model 预设后端:选一个预填 baseURL,仍可逐项改。base_url 取自 README 常见表。 */
155
+ /** /model 预设后端:同时声明原生协议;旧服务继续走 OpenAI-compatible。 */
156
156
  const MODEL_PRESETS = [
157
- { label: 'GLM(智谱)', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
158
- { label: 'DeepSeek', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
159
- { label: 'Qwen(阿里)', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
157
+ { label: 'Anthropic Claude', provider: 'anthropic', baseURL: 'https://api.anthropic.com', model: 'claude-sonnet-4-5', window: 200000, anthropicPromptCache: true },
158
+ { label: 'GLM(智谱)', provider: 'openai', baseURL: 'https://open.bigmodel.cn/api/v3', model: 'glm-4.6', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
159
+ { label: 'DeepSeek', provider: 'openai', baseURL: 'https://api.deepseek.com', model: 'deepseek-chat', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
160
+ { label: 'Qwen(阿里)', provider: 'openai', baseURL: 'https://dashscope.aliyuncs.com/compatible-mode/v1', model: 'qwen-plus', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
160
161
  // MiniMax OpenAI 兼容端点(https://platform.minimax.io/docs/api-reference/text-openai-api)。
161
162
  // MiniMax-M3 为唯一支持图片/视频输入的模型;M2 系列纯文本(见 llm/capabilities.ts KNOWN_TEXT_ONLY_PREFIXES)。
162
- { label: 'MiniMax', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
163
- { label: '本地 Ollama', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
164
- { label: '本地 vLLM', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
165
- { label: '自定义 base_url', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS },
163
+ { label: 'MiniMax', provider: 'openai', baseURL: 'https://api.minimax.io/v1', model: 'MiniMax-M3', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
164
+ { label: '本地 Ollama', provider: 'openai', baseURL: 'http://localhost:11434/v1', model: 'qwen2.5:7b', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
165
+ { label: '本地 vLLM', provider: 'openai', baseURL: 'http://localhost:8000/v1', model: 'default', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
166
+ { label: '自定义 base_url', provider: 'openai', baseURL: '', model: '', window: DEFAULT_CONTEXT_WINDOW_TOKENS, anthropicPromptCache: false },
166
167
  ];
167
168
  /** apiKey 脱敏:只露末 4 位,前面打星号(显示用,绝不把明文 key 写进内容区)。 */
168
169
  function maskKey(k) {
@@ -173,7 +174,7 @@ function maskKey(k) {
173
174
  return `${'='.repeat(Math.min(k.length - 4, 20))}${k.slice(-4)}`;
174
175
  }
175
176
  /**
176
- * /init 指令:发给 agent 扫描项目并生成 MOCODE.md。已存在则让 agent 读后更新(不丢失事实)。写完供 memory 子系统下轮加载。
177
+ * /init 指令:发给 agent 扫描项目并生成 AGENTS.md。已存在则让 agent 读后更新(不丢失事实)。写完供 memory 子系统下轮加载。
177
178
  *
178
179
  * 函数化(非 const):.codegraph/ 索引是否存在的探测放在调用瞬间,没索引时不提 codegraph,
179
180
  * 避免 LLM 调出失败。/init 是冷启动动作,IO 开销可忽略。
@@ -182,14 +183,14 @@ function buildInitPrompt() {
182
183
  const cg = hasCodegraphIndex()
183
184
  ? '- 若有 .codegraph/:用 use_skill 加载 codegraph skill 后用 run_command 调 codegraph explore "<架构或入口符号>" 一次拿相关源码+调用路径,别逐文件读!!!\n'
184
185
  : '';
185
- return `分析当前项目(process.cwd()),生成 MOCODE.md 项目记忆文件,供 mocode 后续会话自动加载——目标是让后续会话无需重新摸索就能上手。
186
+ return `分析当前项目(process.cwd()),生成 AGENTS.md 项目记忆文件,供 mocode 后续会话自动加载——目标是让后续会话无需重新摸索就能上手。
186
187
 
187
188
  先探查(尽量少调用拿全貌):
188
189
  ${cg}- read_file package.json(或 Cargo.toml/pyproject.toml/go.mod 等):scripts、依赖、入口、模块类型。
189
190
  - glob 顶层目录;read_file 入口文件 + 各子系统 index.ts/README。
190
- - 若 MOCODE.md 已存在:read_file 读它,在其基础上更新(补缺、修正过时),不丢已有准确事实。
191
+ - 若 AGENTS.md 已存在:read_file 读它,在其基础上更新(补缺、修正过时),不丢已有准确事实。
191
192
 
192
- MOCODE.md 按以下结构写(每节简短,只写稳定、非显然的事实):
193
+ AGENTS.md 按以下结构写(每节简短,只写稳定、非显然的事实):
193
194
  ## 项目
194
195
  一两句:是什么、技术栈、运行环境。
195
196
  ## 命令
@@ -204,7 +205,7 @@ install / dev / build / test / typecheck / lint 等——从 package.json script
204
205
  硬要求:
205
206
  - 从实际代码提炼,引用具体文件名/命令/符号;不编造、不泛泛。
206
207
  - 总长 ≤ 3000 字;只写后续会话有用的稳定事实,不写易变项(当前 bug、临时文件、未决 TODO)。
207
- - 用 write_file 写入项目根 MOCODE.md。
208
+ - 用 write_file 写入项目根 AGENTS.md。
208
209
  - 写完简述:写了哪几节 + 从代码里发现的 2-3 条非显然关键约定(供用户校验)。`;
209
210
  }
210
211
  /** 临时 readline 读一行(cooked,用于子提问;主输入走 promptWithSlashMenu)。 */
@@ -750,6 +751,8 @@ export function renderHistory(history) {
750
751
  export async function startRepl(initialHistory, sessionId, sandboxRootOverride, initialQueryHistory) {
751
752
  // 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
752
753
  setAgentMode('auto');
754
+ // 钉死本会话模型:之后运行中 agent 一律用此值,其它窗口的 /model switch 不会影响本窗口。
755
+ pinSessionModel();
753
756
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
754
757
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
755
758
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
@@ -768,7 +771,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
768
771
  //
769
772
  // 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
770
773
  // 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
771
- // ③ MOCODE.md 只在 base 中提示按需 read_file,不注入正文;④ Memory Index 按开关注入。
774
+ // ③ AGENTS.md 存在工作区根时由 base 无条件自动导入正文(超长截断,与 memory 开关无关);④ Memory Index 按开关注入。
772
775
  const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt(currentSessionId) +
773
776
  (planMode ? getPlanModeSuffix() : '') +
774
777
  buildMemoryIndexSection(isMemoryEnabled()));
@@ -834,10 +837,12 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
834
837
  if (listPresets().length === 0) {
835
838
  try {
836
839
  const migrated = migrateCurrentToPreset({
840
+ provider: config.provider,
837
841
  baseURL: config.baseURL,
838
842
  apiKey: config.apiKey,
839
843
  model: config.model,
840
844
  contextWindow: config.contextWindowTokens,
845
+ anthropicPromptCache: config.anthropicPromptCache,
841
846
  });
842
847
  if (migrated) {
843
848
  layout.contentWrite(`${ui.dim} ↳ 检测到老配置,已自动迁为预设 “${migrated}”(${ui.cyan}/model list${ui.dim} 查看 · /model switch 切换)${ui.reset}\n`);
@@ -1199,7 +1204,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1199
1204
  continue;
1200
1205
  }
1201
1206
  if (line === '/init') {
1202
- // /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 MOCODE.md),fall through 走 runAgent
1207
+ // /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 AGENTS.md),fall through 走 runAgent
1203
1208
  joined = buildInitPrompt();
1204
1209
  }
1205
1210
  if (line === '/upgrade' || line.startsWith('/upgrade ')) {
@@ -1438,7 +1443,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1438
1443
  layout.contentWrite(` ${ui.accent}${e.id}${ui.reset} ${ui.dim}${e.name} — ${e.summary}${ui.reset}\n`);
1439
1444
  }
1440
1445
  if (active.length === 0)
1441
- layout.contentWrite(`${ui.dim}(无 active 记忆;用 memory_save 存,或 /init 生成 MOCODE.md)${ui.reset}\n`);
1446
+ layout.contentWrite(`${ui.dim}(无 active 记忆;用 memory_save 存,或 /init 生成 AGENTS.md)${ui.reset}\n`);
1442
1447
  layout.contentWrite(`${ui.dim}(详情用 memory_search;启动索引已注入 systemPrompt)${ui.reset}\n`);
1443
1448
  continue;
1444
1449
  }
@@ -1545,9 +1550,9 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1545
1550
  // focus 透传到 compact_history action 的 LLM 摘要 prompt。
1546
1551
  // 返回 SchedulerRunLog 给 UI 显示决策;退化路径(开关关时)在 manualCompact 内部走 compactHistory。
1547
1552
  const log = await manualCompact(history, focus, { force });
1548
- // compact 后把活跃 plan 重注入系统提示(history[0]),避免 agent 因上下文压缩丢失执行计划。
1549
- if (log.compactHistoryCalled)
1550
- reinjectActivePlanIntoSystem(history);
1553
+ // 会话状态(plan + 笔记段)不在此处回写 history[0]:agent/core 每步都在 requestHistory
1554
+ // 末尾注入最新副本(buildSessionStateReminder),压缩后下一步自然恢复,且系统提示保持
1555
+ // 逐字节稳定以命中 prompt 缓存。
1551
1556
  const d = log.compactDetail;
1552
1557
  appendCurrentSessionRuntimeEvent('compact', {
1553
1558
  source: 'manual',
@@ -1716,16 +1721,20 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1716
1721
  // 共用:apply 一个预设到 config + 持久化 + 重建 client + 重显横幅。无参 /model 选菜单和 /model use 都走这里。
1717
1722
  const applyPresetAndPersist = (target) => {
1718
1723
  updateModelConfig({
1724
+ provider: target.provider,
1719
1725
  model: target.model,
1720
1726
  baseURL: target.baseURL,
1721
1727
  apiKey: target.apiKey,
1722
1728
  contextWindowTokens: target.contextWindow,
1729
+ anthropicPromptCache: target.anthropicPromptCache,
1723
1730
  });
1724
1731
  writeConfigKeys({
1732
+ LLM_PROVIDER: target.provider,
1725
1733
  LLM_BASE_URL: target.baseURL,
1726
1734
  LLM_API_KEY: target.apiKey,
1727
1735
  LLM_MODEL: target.model,
1728
1736
  CONTEXT_WINDOW_TOKENS: String(target.contextWindow),
1737
+ ANTHROPIC_PROMPT_CACHE: target.anthropicPromptCache ? 'true' : 'false',
1729
1738
  });
1730
1739
  reconfigureClient();
1731
1740
  refreshStatusBase(history);
@@ -1736,20 +1745,22 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1736
1745
  else {
1737
1746
  layout.writeBanner(bannerLines(banner()));
1738
1747
  }
1739
- layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” ${target.model} @ ${target.baseURL})${ui.reset}\n`);
1748
+ const cacheLabel = target.provider === 'anthropic'
1749
+ ? ` · Prompt Cache ${target.anthropicPromptCache ? 'on' : 'off'}`
1750
+ : '';
1751
+ layout.contentWrite(`${ui.dim}(已切换到预设 “${target.name}” → ${target.model} · ${target.provider}${cacheLabel} @ ${target.baseURL})${ui.reset}\n`);
1740
1752
  if (config.llmKeysFromShell.length > 0) {
1741
1753
  layout.contentWrite(`${ui.dim}(shell 环境变量已设 ${config.llmKeysFromShell.join(' / ')},文件写入下次启动被其覆盖)${ui.reset}\n`);
1742
1754
  }
1743
1755
  };
1744
- // 决定自动存的预设名:用 desired(model 字段),若与已有预设四元组完全相同则不重复存(返 null);
1745
- // 否则若 desired 已存在则追加 -2/-3/...。desired 含非法字符(如 glm-4.6 '.')时先 sanitize(. → -),
1746
- // sanitize 后仍空才退化到 'preset'。
1747
- const uniquePresetName = (desired, baseURL, apiKey, model, contextWindow) => {
1756
+ // 决定自动存的预设名:协议、缓存配置和连接四元组都一致时不重复存。
1757
+ const uniquePresetName = (desired, provider, baseURL, apiKey, model, contextWindow, anthropicPromptCache) => {
1748
1758
  const existing = listPresets();
1749
- const sameEntry = existing.find((p) => p.baseURL === baseURL && p.apiKey === apiKey && p.model === model && p.contextWindow === contextWindow);
1759
+ const sameEntry = existing.find((p) => p.provider === provider && p.baseURL === baseURL && p.apiKey === apiKey
1760
+ && p.model === model && p.contextWindow === contextWindow
1761
+ && p.anthropicPromptCache === anthropicPromptCache);
1750
1762
  if (sameEntry)
1751
- return null; // 完全相同,不重复存
1752
- // sanitize:把非 [a-zA-Z0-9_-] 字符(如 glm-4.6 的 '.')替换为 -,压缩两端 -,裁 1-32。
1763
+ return null;
1753
1764
  const sanitized = desired
1754
1765
  .replace(/[^a-zA-Z0-9_-]+/g, '-')
1755
1766
  .replace(/^-+|-+$/g, '')
@@ -1773,14 +1784,17 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1773
1784
  layout.contentWrite(`${ui.dim}(还没有预设;先跑 /model 添加一个)${ui.reset}\n`);
1774
1785
  continue;
1775
1786
  }
1776
- const isCurrent = (p) => p.baseURL === config.baseURL &&
1787
+ const isCurrent = (p) => p.provider === config.provider &&
1788
+ p.baseURL === config.baseURL &&
1777
1789
  p.apiKey === config.apiKey &&
1778
1790
  p.model === config.model &&
1779
- p.contextWindow === config.contextWindowTokens;
1791
+ p.contextWindow === config.contextWindowTokens &&
1792
+ p.anthropicPromptCache === (config.provider === 'anthropic' && config.anthropicPromptCache);
1780
1793
  const cols = layout.getGeo().cols;
1781
1794
  const labelFor = (p) => {
1782
1795
  const tag = isCurrent(p) ? ' ★current' : '';
1783
- const right = `${p.model} @ ${p.baseURL}`;
1796
+ const cache = p.provider === 'anthropic' ? ` · cache ${p.anthropicPromptCache ? 'on' : 'off'}` : '';
1797
+ const right = `${p.provider}${cache} · ${p.model} @ ${p.baseURL}`;
1784
1798
  const left = `${p.name}${tag}`;
1785
1799
  const sep = left.length + 1 + right.length;
1786
1800
  if (sep <= cols - 2)
@@ -1790,7 +1804,7 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1790
1804
  const choice = await promptIntervention({
1791
1805
  type: 'choice',
1792
1806
  title: '切换模型预设',
1793
- detail: `当前: ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
1807
+ detail: `当前: ${config.provider} · ${config.model} @ ${config.baseURL}(★ = 已匹配)`,
1794
1808
  options: presets.map(labelFor),
1795
1809
  allowCustom: false, // 纯切换,不需要「其他」干扰
1796
1810
  });
@@ -1812,19 +1826,30 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
1812
1826
  }
1813
1827
  layout.contentWrite(`${ui.dim}已配置 ${ps.length} 个预设:${ui.reset}\n`);
1814
1828
  for (const p of ps) {
1815
- const star = p.baseURL === config.baseURL && p.apiKey === config.apiKey && p.model === config.model ? ' ★' : '';
1816
- layout.contentWrite(` ${ui.accent}${p.name}${ui.reset}${star} ${ui.dim}${p.model} @ ${p.baseURL}${ui.reset}\n`);
1829
+ const current = p.provider === config.provider
1830
+ && p.baseURL === config.baseURL
1831
+ && p.apiKey === config.apiKey
1832
+ && p.model === config.model
1833
+ && p.contextWindow === config.contextWindowTokens
1834
+ && p.anthropicPromptCache === (config.provider === 'anthropic' && config.anthropicPromptCache);
1835
+ const star = current ? ' ★' : '';
1836
+ const cache = p.provider === 'anthropic' ? ` · cache ${p.anthropicPromptCache ? 'on' : 'off'}` : '';
1837
+ layout.contentWrite(` ${ui.accent}${p.name}${ui.reset}${star} ${ui.dim}${p.provider}${cache} · ${p.model} @ ${p.baseURL}${ui.reset}\n`);
1817
1838
  }
1818
- layout.contentWrite(`${ui.dim}(★ = 与当前一致;切换用 /model switch)${ui.reset}\n`);
1839
+ layout.contentWrite(`${ui.dim}(★ = 与当前协议及缓存配置一致;切换用 /model switch)${ui.reset}\n`);
1819
1840
  continue;
1820
1841
  }
1821
- // /model show:显示当前四项配置(apiKey 脱敏)。
1842
+ // /model show:显示当前协议、连接与缓存配置(apiKey 脱敏)。
1822
1843
  if (arg === 'show') {
1823
1844
  layout.contentWrite(`${ui.dim}当前模型配置:${ui.reset}\n`);
1824
- layout.contentWrite(` ${ui.accent}baseURL${ui.reset} ${config.baseURL}\n`);
1825
- layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
1826
- layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
1827
- layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
1845
+ layout.contentWrite(` ${ui.accent}provider${ui.reset} ${config.provider}\n`);
1846
+ layout.contentWrite(` ${ui.accent}baseURL ${ui.reset} ${config.baseURL}\n`);
1847
+ layout.contentWrite(` ${ui.accent}apiKey ${ui.reset} ${maskKey(config.apiKey)}\n`);
1848
+ layout.contentWrite(` ${ui.accent}model ${ui.reset} ${config.model}\n`);
1849
+ layout.contentWrite(` ${ui.accent}窗口 ${ui.reset} ${config.contextWindowTokens} tokens\n`);
1850
+ if (config.provider === 'anthropic') {
1851
+ layout.contentWrite(` ${ui.accent}缓存 ${ui.reset} Prompt Cache ${config.anthropicPromptCache ? 'on' : 'off'}\n`);
1852
+ }
1828
1853
  layout.contentWrite(`${ui.dim}(配置文件: ${CONFIG_PATH})${ui.reset}\n`);
1829
1854
  continue;
1830
1855
  }
@@ -2017,25 +2042,42 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2017
2042
  }
2018
2043
  }
2019
2044
  }
2020
- // 3) 应用:内存 config + env(updateModelConfig)→ 持久化(writeConfigKeys)→ 重建 client(reconfigureClient)。
2021
- updateModelConfig({ model, baseURL, apiKey, contextWindowTokens: window });
2045
+ // 3) 应用协议、连接与缓存配置;Anthropic 原生协议无需重建 OpenAI client,但统一刷新无害。
2046
+ const provider = preset.provider;
2047
+ const anthropicPromptCache = provider === 'anthropic' && preset.anthropicPromptCache;
2048
+ updateModelConfig({
2049
+ provider,
2050
+ model,
2051
+ baseURL,
2052
+ apiKey,
2053
+ contextWindowTokens: window,
2054
+ anthropicPromptCache,
2055
+ });
2022
2056
  writeConfigKeys({
2057
+ LLM_PROVIDER: provider,
2023
2058
  LLM_BASE_URL: baseURL,
2024
2059
  LLM_API_KEY: apiKey,
2025
2060
  LLM_MODEL: model,
2026
2061
  CONTEXT_WINDOW_TOKENS: String(window),
2062
+ ANTHROPIC_PROMPT_CACHE: anthropicPromptCache ? 'true' : 'false',
2027
2063
  });
2028
2064
  reconfigureClient();
2029
- // 3.5) 自动存为命名预设:用 model 字段,重名追加 -2/-3,完全相同的四元组不重复存。
2030
- // 让 /model 跑一次就多一份可切换的预设,/model switch 切回去。
2065
+ // 3.5) 自动存为命名预设;协议与缓存策略也是去重键的一部分。
2031
2066
  let savedName = null;
2032
2067
  try {
2033
- const finalName = uniquePresetName(model, baseURL, apiKey, model, window);
2068
+ const finalName = uniquePresetName(model, provider, baseURL, apiKey, model, window, anthropicPromptCache);
2034
2069
  if (finalName) {
2035
- savePreset({ name: finalName, baseURL, apiKey, model, contextWindow: window });
2070
+ savePreset({
2071
+ name: finalName,
2072
+ provider,
2073
+ baseURL,
2074
+ apiKey,
2075
+ model,
2076
+ contextWindow: window,
2077
+ anthropicPromptCache,
2078
+ });
2036
2079
  savedName = finalName;
2037
2080
  }
2038
- // finalName === null 表示与某个已存在预设完全一致,不再重复保存。
2039
2081
  }
2040
2082
  catch (e) {
2041
2083
  layout.contentWrite(`${ui.red}保存预设失败: ${e.message}${ui.reset}\n`);
@@ -2049,7 +2091,10 @@ export async function startRepl(initialHistory, sessionId, sandboxRootOverride,
2049
2091
  else {
2050
2092
  layout.writeBanner(bannerLines(banner()));
2051
2093
  }
2052
- layout.contentWrite(`${ui.dim}(已切换模型 ${model} @ ${baseURL})${ui.reset}\n`);
2094
+ const cacheLabel = provider === 'anthropic'
2095
+ ? ` · Prompt Cache ${anthropicPromptCache ? 'on' : 'off'}`
2096
+ : '';
2097
+ layout.contentWrite(`${ui.dim}(已切换模型 → ${model} · ${provider}${cacheLabel} @ ${baseURL})${ui.reset}\n`);
2053
2098
  if (savedName) {
2054
2099
  layout.contentWrite(`${ui.dim}(已保存为预设 “${savedName}”,下次 /model use ${savedName} 一键切回)${ui.reset}\n`);
2055
2100
  }
@@ -292,10 +292,22 @@ export async function compactHistory(history, opts) {
292
292
  let oldGroups = groups.slice(0, groups.length - kept.length);
293
293
  // force(硬闸/手动强压):保护区不豁免——常规切分无旧区时只保最后一组,
294
294
  // 其余全部进可压区(首轮/当前轮也一样)。仍按 group 边界切,不破坏 tool_call 配对。
295
+ // **必须保留最早 user 所在 group**:LLM API(OpenAI / Anthropic)要求 messages 至少
296
+ // 含一条非空 user 消息,否则 400。force 旧实现把所有 user 丢进摘要 → 重建后 history
297
+ // 无 user → 下一轮 chat() 被后端拒绝。保最早 user(而非最后一个)因为它是最原始的
298
+ // 请求上下文,摘要器已覆盖后续交互。
295
299
  if (oldGroups.length === 0 && opts.force && groups.length >= 2) {
296
300
  kept.length = 0;
297
- kept.push(groups[groups.length - 1]);
298
- oldGroups = groups.slice(0, groups.length - 1);
301
+ const lastIdx = groups.length - 1;
302
+ const firstUserIdx = groups.findIndex((g) => g.assistant?.role === 'user');
303
+ if (firstUserIdx >= 0 && firstUserIdx !== lastIdx) {
304
+ kept.push(groups[firstUserIdx], groups[lastIdx]);
305
+ oldGroups = groups.filter((_, i) => i !== firstUserIdx && i !== lastIdx);
306
+ }
307
+ else {
308
+ kept.push(groups[lastIdx]);
309
+ oldGroups = groups.slice(0, groups.length - 1);
310
+ }
299
311
  }
300
312
  const noop = {
301
313
  compacted: false,
@@ -319,6 +331,8 @@ export async function compactHistory(history, opts) {
319
331
  const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
320
332
  state.lastEstimate = estimateAfter;
321
333
  state.lastUsage = undefined;
334
+ if (!layout.isLastContentRowBlank())
335
+ layout.contentWrite('\n');
322
336
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制微压缩(单组)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
323
337
  return {
324
338
  compacted: true,
@@ -339,6 +353,8 @@ export async function compactHistory(history, opts) {
339
353
  }
340
354
  // history 有内容但全在保护区(系统 + 当前轮)
341
355
  if (estimateBefore >= opts.threshold * opts.window) {
356
+ if (!layout.isLastContentRowBlank())
357
+ layout.contentWrite('\n');
342
358
  layout.contentWrite(` ${ui.yellow}●${ui.reset} ${ui.yellow}上下文已超阈但无可压缩项(全在保护区),建议 /clear 或缩短输入。${ui.reset}\n`);
343
359
  return { ...noop, reason: 'noop-shrunk-too-large', protectedRatio };
344
360
  }
@@ -383,6 +399,10 @@ export async function compactHistory(history, opts) {
383
399
  const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
384
400
  state.lastEstimate = estimateAfter;
385
401
  state.lastUsage = undefined; // 压缩后旧 usage 失效,/context 改用校正估算
402
+ // 压缩行与上一个工具批次摘要行之间补空行分隔(compact 在 core step 循环顶部触发,
403
+ // 上一步的 batch 可能尚未 flush,缓冲末行仍是 ● 工具摘要行 → 两行黏在一起)。
404
+ if (!layout.isLastContentRowBlank())
405
+ layout.contentWrite('\n');
386
406
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}压缩上下文${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
387
407
  // 抖动保护:压缩后仍超阈 → 提示 /clear,不死循环
388
408
  if (estimateAfter >= opts.threshold * opts.window) {
@@ -402,6 +422,8 @@ export async function compactHistory(history, opts) {
402
422
  state.lastEstimate = estimateAfter;
403
423
  state.lastUsage = undefined; // token 数已变,旧 usage 失效
404
424
  if (microcompactDone) {
425
+ if (!layout.isLastContentRowBlank())
426
+ layout.contentWrite('\n');
405
427
  layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}微压缩旧工具结果${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
406
428
  return {
407
429
  compacted: true,
@@ -105,3 +105,236 @@ export function writePlanToNotes(plan, sessionId = getCurrentSessionId()) {
105
105
  }
106
106
  return { path: p, settled: plan.steps.length > 0 && plan.steps.every((s) => s.status === 'completed') };
107
107
  }
108
+ // ── Session notes(单会话永久记忆):note_append 写入,reinject 常驻 system ──────
109
+ // 设计:notes.md 不止放 Plan。note_append 往预设笔记段追加一条 finding/decision/
110
+ // open_question/risk;extractActiveNotesSections 读出活跃笔记段正文(排除 Plan/Done),
111
+ // 按 5k token 预算裁剪后由 reinjectSessionStateIntoSystem 注入 system prompt——compact
112
+ // 后仍能恢复,让 agent 始终记得本会话做过什么、发现过什么(单会话永久记忆)。
113
+ /** note_append 接受的预设段 key → 渲染标题。 */
114
+ const NOTE_SECTION_TITLES = {
115
+ findings: 'Findings',
116
+ decisions: 'Decisions',
117
+ open_questions: 'Open Questions',
118
+ risks: 'Risks',
119
+ };
120
+ /** 预设段 key 列表(供工具 schema enum 与校验用)。 */
121
+ export const NOTE_SECTION_KEYS = Object.keys(NOTE_SECTION_TITLES);
122
+ /** 段注入优先级:数值越大越先占预算、越后丢弃正文。Risks 最重要。 */
123
+ const SECTION_PRIORITY = {
124
+ risks: 4, findings: 3, decisions: 2, open_questions: 1,
125
+ };
126
+ /** 常驻笔记正文总预算(token)。5k:占百万级 context 的 0.5%,可常驻相当量笔记。 */
127
+ const NOTES_INJECT_BUDGET_TOKENS = 5000;
128
+ /** 单段正文上限(token):防单段独占预算。 */
129
+ const NOTES_PER_SECTION_TOKENS = 2000;
130
+ /** 单条笔记上限(token):防一条过长吃掉整段预算。 */
131
+ const NOTES_PER_ENTRY_TOKENS = 800;
132
+ /**
133
+ * 轻量 token 估算(启发式,不依赖 tokenizer):CJK ≈ 0.6 token/字,
134
+ * ASCII ≈ 0.25 token/字。对 GLM/DeepSeek/Qwen 等中文偏多的后端略偏保守
135
+ * (估算略高于实际 → 注入实际 token 略低于预算 → 安全侧)。仅供笔记 cap 用,
136
+ * 不替换 llm/estimatePromptTokens 的主口径。
137
+ */
138
+ function estimateTokens(text) {
139
+ let cjk = 0;
140
+ let other = 0;
141
+ for (let i = 0; i < text.length; i++) {
142
+ const c = text.charCodeAt(i);
143
+ if ((c >= 0x3000 && c <= 0x9fff) || (c >= 0xff00 && c <= 0xffef))
144
+ cjk++;
145
+ else
146
+ other++;
147
+ }
148
+ return Math.ceil(cjk * 0.6 + other * 0.25);
149
+ }
150
+ /** 把标题映射回预设 key(非预设段返 '',优先级 0,最后注入)。 */
151
+ function matchSectionKey(title) {
152
+ const t = title.trim().toLowerCase();
153
+ for (const [k, v] of Object.entries(NOTE_SECTION_TITLES)) {
154
+ if (v.toLowerCase() === t)
155
+ return k;
156
+ }
157
+ return '';
158
+ }
159
+ /**
160
+ * 往 notes.md 的指定笔记段追加一条。段存在则在其末尾追加(保留其它段不动);
161
+ * 段不存在则在文件末新建。返回 { path } 或 { error }。
162
+ */
163
+ export function appendNoteToSection(section, entry, tag, sessionId = getCurrentSessionId()) {
164
+ const title = NOTE_SECTION_TITLES[section];
165
+ if (!title)
166
+ return { error: `unknown note section "${section}"` };
167
+ const p = getNotesFilePath(sessionId);
168
+ if (!p)
169
+ return { error: 'no active session' };
170
+ const line = tag ? `- **[${tag}]** ${entry}` : `- ${entry}`;
171
+ let existing = '';
172
+ try {
173
+ existing = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
174
+ }
175
+ catch {
176
+ existing = '';
177
+ }
178
+ const header = `## ${title}`;
179
+ const lines = existing.split('\n');
180
+ const start = lines.findIndex((l) => l.trim() === header);
181
+ let next;
182
+ if (start >= 0) {
183
+ // 段末 = 下一个 ## 或文件末
184
+ let end = lines.length;
185
+ for (let k = start + 1; k < lines.length; k++) {
186
+ if (/^##\s/.test(lines[k])) {
187
+ end = k;
188
+ break;
189
+ }
190
+ }
191
+ const before = lines.slice(0, start).join('\n').replace(/\s+$/, '');
192
+ const sectionLines = lines.slice(start, end);
193
+ // 去段尾空行后追加新条目
194
+ while (sectionLines.length && sectionLines[sectionLines.length - 1].trim() === '')
195
+ sectionLines.pop();
196
+ sectionLines.push(line);
197
+ const section = sectionLines.join('\n');
198
+ const after = lines.slice(end).join('\n').replace(/^\s+/, '');
199
+ next = [before, section, after].filter((s) => s.length > 0).join('\n\n') + '\n';
200
+ }
201
+ else {
202
+ // 新建段:放文件末,与已有内容以空行分隔
203
+ const rest = existing.trim();
204
+ const newSection = `${header}\n${line}`;
205
+ next = rest ? `${rest}\n\n${newSection}\n` : `${newSection}\n`;
206
+ }
207
+ try {
208
+ fs.mkdirSync(path.dirname(p), { recursive: true });
209
+ fs.writeFileSync(p, next, 'utf8');
210
+ }
211
+ catch (e) {
212
+ return { error: e instanceof Error ? e.message : String(e) };
213
+ }
214
+ return { path: p };
215
+ }
216
+ /** 按字符二分截断条目到 token 上限,加省略标记。条目不长,线性二分足够。 */
217
+ function truncateEntry(entry, maxTokens) {
218
+ if (estimateTokens(entry) <= maxTokens)
219
+ return entry;
220
+ let lo = 0;
221
+ let hi = entry.length;
222
+ while (lo < hi) {
223
+ const mid = Math.ceil((lo + hi) / 2);
224
+ if (estimateTokens(entry.slice(0, mid)) <= maxTokens)
225
+ lo = mid;
226
+ else
227
+ hi = mid - 1;
228
+ }
229
+ return entry.slice(0, lo).replace(/\s+$/, '') + ' …[truncated]';
230
+ }
231
+ /** 把单段正文按条目分割,从末尾(最近)保留,丢最旧,裁到 token 预算内。 */
232
+ function trimSectionToBudget(body, budgetTokens) {
233
+ if (budgetTokens <= 0)
234
+ return null;
235
+ const bodyLines = body.split('\n');
236
+ const header = bodyLines[0] ?? '';
237
+ const rest = bodyLines.slice(1);
238
+ // 分条目:以 "- " 开头为一条起始,后续非 "- " 行归入该条
239
+ const entries = [];
240
+ let cur = [];
241
+ for (const ln of rest) {
242
+ if (/^-\s+/.test(ln)) {
243
+ if (cur.length)
244
+ entries.push(cur.join('\n'));
245
+ cur = [ln];
246
+ }
247
+ else {
248
+ cur.push(ln);
249
+ }
250
+ }
251
+ if (cur.length)
252
+ entries.push(cur.join('\n'));
253
+ const kept = [];
254
+ let used = estimateTokens(header);
255
+ for (let k = entries.length - 1; k >= 0; k--) {
256
+ let e = entries[k];
257
+ if (estimateTokens(e) > NOTES_PER_ENTRY_TOKENS) {
258
+ e = truncateEntry(e, NOTES_PER_ENTRY_TOKENS);
259
+ }
260
+ const t = estimateTokens(e);
261
+ if (used + t > budgetTokens)
262
+ break;
263
+ kept.unshift(e);
264
+ used += t;
265
+ }
266
+ if (kept.length === 0)
267
+ return null;
268
+ return `${header}\n${kept.join('\n')}`;
269
+ }
270
+ /**
271
+ * 读 notes.md,提取所有活跃笔记段正文(排除 `## Plan:` 与 `## Done:`),
272
+ * 按 NOTES_INJECT_BUDGET_TOKENS 裁剪后返回——供 reinject 注入 system prompt。
273
+ * 裁剪策略:段按优先级排序(Risks>Findings>Decisions>Open Questions>自定义),
274
+ * 逐段注入累计 token;单段超 per-section 则段内从最近条目保留丢最旧;
275
+ * 总预算用尽则后续段不注入正文(其标题仍由 buildNotepadSection 索引常驻,
276
+ * agent 可 read_file 取细节)。这样 5k 预算内"写了就常驻",超出降级为索引+按需 read。
277
+ */
278
+ export function extractActiveNotesSections(budget = NOTES_INJECT_BUDGET_TOKENS, sessionId = getCurrentSessionId()) {
279
+ const p = getNotesFilePath(sessionId);
280
+ if (!p)
281
+ return '';
282
+ let content = '';
283
+ try {
284
+ content = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
285
+ }
286
+ catch {
287
+ return '';
288
+ }
289
+ const lines = content.split('\n');
290
+ const sections = [];
291
+ let i = 0;
292
+ while (i < lines.length) {
293
+ const m = lines[i].match(/^##\s+(.+?)\s*$/);
294
+ if (!m) {
295
+ i++;
296
+ continue;
297
+ }
298
+ const title = m[1];
299
+ // 跳过 Plan/Done 段(Plan 有专属 ACTIVE_PLAN_MARKER 重注入;Done 是归档不常驻)
300
+ if (/^Plan:/.test(title) || /^Done:/.test(title)) {
301
+ i++;
302
+ while (i < lines.length && !/^##\s/.test(lines[i]))
303
+ i++;
304
+ continue;
305
+ }
306
+ const start = i;
307
+ i++;
308
+ while (i < lines.length && !/^##\s/.test(lines[i]))
309
+ i++;
310
+ const body = lines.slice(start, i).join('\n').trim();
311
+ if (body)
312
+ sections.push({ key: matchSectionKey(title), body });
313
+ }
314
+ // 按优先级降序(优先级高的先占预算)
315
+ sections.sort((a, b) => (SECTION_PRIORITY[b.key] ?? 0) - (SECTION_PRIORITY[a.key] ?? 0));
316
+ let used = 0;
317
+ const out = [];
318
+ for (const s of sections) {
319
+ const remaining = budget - used;
320
+ if (remaining <= 0)
321
+ break;
322
+ const bodyTokens = estimateTokens(s.body);
323
+ if (bodyTokens <= Math.min(remaining, NOTES_PER_SECTION_TOKENS)) {
324
+ out.push(s.body);
325
+ used += bodyTokens;
326
+ continue;
327
+ }
328
+ // 段超预算:段内裁条目(从最近保留)
329
+ const cap = Math.min(remaining, NOTES_PER_SECTION_TOKENS);
330
+ const trimmed = trimSectionToBudget(s.body, cap);
331
+ if (trimmed) {
332
+ out.push(trimmed);
333
+ used += estimateTokens(trimmed);
334
+ }
335
+ // 预算用尽则后续段不再注入正文(降级为索引)
336
+ if (used >= budget)
337
+ break;
338
+ }
339
+ return out.join('\n\n');
340
+ }
@@ -1,6 +1,6 @@
1
1
  import { existsSync, mkdirSync, readFileSync, readdirSync, unlinkSync, writeFileSync, } from 'node:fs';
2
2
  import path from 'node:path';
3
- import { config } from '../config/index.js';
3
+ import { config, getActiveModel } from '../config/index.js';
4
4
  import { truncateDisplay } from '../ui/render.js';
5
5
  /** 会话目录(确保存在)。 */
6
6
  export function sessionDir() {
@@ -49,7 +49,7 @@ export function saveSession(history, id, queryHistory = []) {
49
49
  const meta = {
50
50
  id,
51
51
  createdAt: idToIso(id),
52
- model: config.model,
52
+ model: getActiveModel(),
53
53
  firstUser: history.length > 1
54
54
  ? firstUserOf(history)
55
55
  : truncateDisplay((queryHistory[0] ?? '').replace(/\n/g, ' ').trim(), 40),