mocode-ai 0.4.9 → 0.4.10

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.
@@ -0,0 +1,127 @@
1
+ import { spawnAgent } from '../agent/spawn.js';
2
+ /**
3
+ * 子 agent 系统提示:让 agent 自主探索项目并生成 markdown 快照
4
+ */
5
+ const SNAPSHOT_LLM_SUFFIX = `You are a project analysis agent. Your task is to explore this project and generate a concise markdown snapshot for an AI coding assistant.
6
+
7
+ ## Your Mission
8
+ Explore the project using available tools (read_file, glob, grep) and generate a structured markdown snapshot that captures:
9
+ - WHAT exists (project description, tech stack, commands, modules, directory structure)
10
+ - WHERE it is (paths, locations)
11
+
12
+ Do NOT explain WHY or HOW (that's Project Skill's job).
13
+
14
+ ## Exploration Strategy
15
+ 1. Read package.json, README.md, tsconfig.json (or equivalent for other languages)
16
+ 2. List top-level directories to identify modules
17
+ 3. Scan src/ directory structure (depth ≤3, directories only)
18
+ 4. Extract key commands from package.json scripts
19
+ 5. Identify tech stack from dependencies
20
+
21
+ ## Output Format
22
+ Output a markdown document between these exact delimiters:
23
+
24
+ \`\`\`snapshot-md
25
+ # Project Snapshot
26
+
27
+ ## Description
28
+ (一句话描述项目,≤80字,中文)
29
+
30
+ ## Tech Stack
31
+ (关键技术栈,逗号分隔,含版本,≤15项)
32
+
33
+ ## Commands
34
+ | Command | Description |
35
+ |---------|-------------|
36
+ | \`npm run build\` | 构建项目 |
37
+ | \`npm test\` | 运行测试 |
38
+ | ... | ... |
39
+
40
+ ## Modules
41
+ - **module-name** — 一句话职责描述(≤20字)
42
+ - **module-name** — 一句话职责描述
43
+ - ...
44
+
45
+ ## Source Tree
46
+ \`\`\`
47
+ src/
48
+ agent/
49
+ tools/
50
+ ui/
51
+ \`\`\`
52
+ \`\`\`snapshot-md
53
+
54
+ ## Rules
55
+ 1. **Description**: ≤80字,中文,从 README 和 package.json description 提炼。不要"一个..."开头。
56
+ 2. **Tech Stack**: ≤15项,逗号分隔,含版本。格式如 "TypeScript 5.x, Node.js ≥18, openai@^4"。
57
+ 3. **Commands**: 5-8个最重要的命令,从 package.json scripts 提取。Description ≤15字。用表格格式。
58
+ 4. **Modules**: 每个顶层模块目录一条。职责≤20字,只写 WHAT(做什么)。
59
+ 5. **Source Tree**: 只列目录(不列文件),深度≤3,用缩进表示层级,放在代码块中。
60
+ 6. 不要包含:设计决策、注意事项、坑点、约定 → 这些是 Skill 的职责。
61
+ 7. 总 markdown ≤ 2500 字符。
62
+ 8. 使用中文。
63
+ `;
64
+ /**
65
+ * 生成 LLM 快照(markdown 格式)
66
+ * @param root 项目根目录
67
+ * @param signal AbortSignal
68
+ */
69
+ export async function generateLLMSnapshot(root, signal) {
70
+ const prompt = `请探索项目 ${root},生成项目快照(markdown 格式)。
71
+
72
+ ## 探索步骤
73
+ 1. 读取 package.json(或等效配置文件)
74
+ 2. 读取 README.md
75
+ 3. 列出顶层目录,识别模块
76
+ 4. 扫描 src/ 目录结构(深度≤3,只列目录)
77
+ 5. 从 package.json scripts 提取关键命令
78
+ 6. 从 dependencies 识别技术栈
79
+
80
+ ## 输出
81
+ 严格按照系统提示的 markdown 格式输出,不要添加额外解释。`;
82
+ try {
83
+ const result = await spawnAgent({
84
+ prompt,
85
+ systemPromptSuffix: SNAPSHOT_LLM_SUFFIX,
86
+ tools: ['read_file', 'glob', 'grep'], // 允许探索工具
87
+ maxSteps: 15, // 给足够的探索步数
88
+ signal,
89
+ });
90
+ if (!result.completed) {
91
+ return {
92
+ ok: false,
93
+ error: '子 agent 未完成(可能中断或超时)',
94
+ transcript: result.transcript,
95
+ };
96
+ }
97
+ if (!result.summary) {
98
+ return {
99
+ ok: false,
100
+ error: '子 agent 未返回内容',
101
+ transcript: result.transcript,
102
+ };
103
+ }
104
+ // 从 summary 中提取 markdown
105
+ const mdMatch = result.summary.match(/```snapshot-md\n([\s\S]*?)\n```snapshot-md/);
106
+ if (!mdMatch || !mdMatch[1]) {
107
+ // fallback: 尝试普通 markdown 代码块
108
+ const fallbackMatch = result.summary.match(/```markdown\n([\s\S]*?)\n```/);
109
+ if (!fallbackMatch || !fallbackMatch[1]) {
110
+ return {
111
+ ok: false,
112
+ error: '无法从子 agent 输出中提取 markdown',
113
+ transcript: result.transcript,
114
+ };
115
+ }
116
+ return { ok: true, content: fallbackMatch[1].trim(), transcript: result.transcript };
117
+ }
118
+ return { ok: true, content: mdMatch[1].trim(), transcript: result.transcript };
119
+ }
120
+ catch (e) {
121
+ return {
122
+ ok: false,
123
+ error: e instanceof Error ? e.message : '未知错误',
124
+ transcript: '',
125
+ };
126
+ }
127
+ }
@@ -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, isProjectSkillEnabled, updateProjectSkillConfig, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
4
+ import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, isProjectSkillEnabled, isProjectSnapshotEnabled, updateProjectSkillConfig, updateSnapshotConfig, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
5
5
  import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
6
6
  import { deletePreset, getPreset, isValidPresetName, listPresets, migrateCurrentToPreset, savePreset, } from '../config/presets.js';
7
7
  import { runAgent } from '../agent/index.js';
@@ -24,7 +24,7 @@ import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots
24
24
  import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
25
25
  import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
26
26
  import { buildActivePlanSection, onActivePlanChange, hasActivePlan, getActivePlanSummary, } from '../plan/index.js';
27
- import { buildSnapshot as buildProjectSnapshot } from '../project-snapshot/index.js';
27
+ import { buildSnapshot, clearSnapshotCache } from '../project-snapshot/index.js';
28
28
  /**
29
29
  * readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
30
30
  * 颜色码会让光标错位、编辑时漂移。颜色只用在直接 stdout.write 的横幅 / 工具行 / 回复。
@@ -43,6 +43,7 @@ const SLASH_COMMANDS = [
43
43
  { name: '/memory', desc: '记忆库:条目计数与近期索引(关闭时提示先开 /memory_switch)' },
44
44
  { name: '/memory_switch', desc: '切换记忆子系统开关(无参=切换;/on 或 /off 显式;持久化 MEMORY_ENABLED)' },
45
45
  { name: '/project_skill', desc: '项目专属 Skill 开关 + 查看/初始化(无参=切换;/on·/off·/view·/init)' },
46
+ { name: '/snapshot', desc: '切换项目快照开关(无参=切换;/on·/off·/status;持久化 MOCODE_PROJECT_SNAPSHOT)' },
46
47
  { name: '/memory_status', desc: '查看记忆子系统当前开关与原理' },
47
48
  { name: '/reflect', desc: '手动触发后台记忆反思 pass(需先开启记忆)' },
48
49
  { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆(需先开启记忆)' },
@@ -51,7 +52,7 @@ const SLASH_COMMANDS = [
51
52
  { name: '/model switch', desc: '↑↓·Enter 在已配置预设间切换' },
52
53
  { name: '/model list', desc: '列出已经配置的模型' },
53
54
  { name: '/model delete <name>', desc: '删除已配置的模型' },
54
- { name: '/refresh_snapshot', desc: '刷新项目快照(重新扫描静态文件,更新缓存)' },
55
+ { name: '/snapshot_refresh', desc: '刷新项目快照(重新扫描静态文件,重新生成 LLM 摘要)' },
55
56
  { name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
56
57
  { name: '/auto', desc: '切回 auto 模式(全工具执行)' },
57
58
  { name: '/pet', desc: '开关桌宠(独立悬浮窗,展示 agent 状态动画)' },
@@ -178,7 +179,7 @@ function runningStateFor(cmd) {
178
179
  return { status: '回滚', placeholder: '选择轮次…' };
179
180
  case '/init':
180
181
  return { status: '初始化', placeholder: '生成 MOCODE.md…' };
181
- case '/refresh_snapshot':
182
+ case '/snapshot_refresh':
182
183
  return { status: '刷新快照', placeholder: '扫描项目文件中…' };
183
184
  case '/plan':
184
185
  return { status: '切 plan', placeholder: '…' };
@@ -198,6 +199,8 @@ function runningStateFor(cmd) {
198
199
  return { status: '查记忆状态', placeholder: '…' };
199
200
  case '/project_skill':
200
201
  return { status: '项目 Skill', placeholder: '处理中…' };
202
+ case '/snapshot':
203
+ return { status: '切快照开关', placeholder: '切换中…' };
201
204
  default:
202
205
  // 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
203
206
  // 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
@@ -525,8 +528,10 @@ export function renderHistory(history) {
525
528
  break;
526
529
  }
527
530
  }
528
- if (target)
531
+ if (target) {
529
532
  target.resultSummary = preview;
533
+ target.fullOutput = output;
534
+ }
530
535
  // 不直接写屏——等 flushBatch 时出单行摘要
531
536
  continue;
532
537
  }
@@ -545,13 +550,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
545
550
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
546
551
  // 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
547
552
  setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
548
- // 项目快照:sandboxRoot 设定后立即构建/加载(同步,小文件几十 ms)。
549
- // 构建失败不阻断 REPL:snapshot 内部 catch 所有异常,此处再兜一层。
553
+ // 项目快照:sandboxRoot 设定后异步构建(完全由 LLM 生成)。
554
+ // 构建失败不阻断 REPL:snapshot 内部 catch 所有异常。
550
555
  if (config.projectSnapshotEnabled) {
551
- try {
552
- buildProjectSnapshot();
553
- }
554
- catch { /* ignore */ }
556
+ // 异步触发 LLM 快照生成(不阻塞 REPL 启动)
557
+ buildSnapshot().catch(() => { });
555
558
  }
556
559
  // 构造系统提示:auto 用 base;plan 在 base 后追加按当前开关现拼的 plan suffix。
557
560
  // 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
@@ -1150,24 +1153,33 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1150
1153
  }
1151
1154
  continue;
1152
1155
  }
1153
- if (line === '/refresh_snapshot') {
1156
+ if (line === '/snapshot_refresh') {
1154
1157
  if (!config.projectSnapshotEnabled) {
1155
1158
  layout.contentWrite(`${ui.dim}(项目快照功能已关闭,MOCODE_PROJECT_SNAPSHOT=false)${ui.reset}\n`);
1156
1159
  continue;
1157
1160
  }
1158
- // buildProjectSnapshot 是同步操作(扫顶层小文件,几十 ms),不需要 spinner
1161
+ // buildSnapshot 是异步操作(完全由 LLM 生成),显示进度提示
1162
+ layout.contentWrite(`${ui.dim}正在重新生成项目快照 (LLM 分析中)...${ui.reset}\n`);
1159
1163
  try {
1160
- const snap = buildProjectSnapshot();
1161
- const fileCount = Object.keys(snap.files).length;
1162
- const moduleCount = snap.structure.modules.length;
1163
- const configCount = snap.structure.configFiles.length;
1164
- layout.contentWrite(`${ui.cyan}✓ 项目快照已刷新${ui.reset}: ${fileCount} 个静态文件 · ${moduleCount} 个模块 · ${configCount} 个配置文件\n`);
1165
- layout.contentWrite(`${ui.dim}文件: ${Object.keys(snap.files).join(', ')}${ui.reset}\n`);
1166
- layout.contentWrite(`${ui.dim}模块: ${snap.structure.modules.join(', ')}${ui.reset}\n`);
1167
- // 刷新 history[0]:buildSnapshotSection 内部 loadSnapshot() 会拿到新快照,
1168
- // buildSystemMessage 重新拼 system prompt,快照段落就更新了。
1169
- history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1170
- layout.contentWrite(`${ui.dim}(system prompt 已同步刷新)${ui.reset}\n`);
1164
+ // 清除缓存,强制重新生成
1165
+ clearSnapshotCache();
1166
+ const result = await buildSnapshot(undefined, true);
1167
+ if (result.snapshot) {
1168
+ layout.contentWrite(`${ui.cyan}✓ 项目快照已刷新${ui.reset} (${result.snapshot.builtAt})\n`);
1169
+ layout.contentWrite(`${ui.dim}已生成 markdown 快照,注入到系统提示词中${ui.reset}\n`);
1170
+ // 刷新 history[0]:buildSnapshotSection 内部 loadSnapshot() 会拿到新快照,
1171
+ // buildSystemMessage 重新拼 system prompt,快照段落就更新了。
1172
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1173
+ layout.contentWrite(`${ui.dim}(system prompt 已同步刷新)${ui.reset}\n`);
1174
+ }
1175
+ else {
1176
+ layout.contentWrite(`${ui.red}✗ 快照生成失败${ui.reset}: ${result.error || '未知错误'}\n`);
1177
+ if (result.transcript) {
1178
+ layout.contentWrite(`${ui.dim}--- 子 agent 日志 ---${ui.reset}\n`);
1179
+ layout.contentWrite(`${ui.dim}${result.transcript}${ui.reset}\n`);
1180
+ layout.contentWrite(`${ui.dim}--- 日志结束 ---${ui.reset}\n`);
1181
+ }
1182
+ }
1171
1183
  }
1172
1184
  catch (e) {
1173
1185
  const msg = e instanceof Error ? e.message : String(e);
@@ -1823,6 +1835,102 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
1823
1835
  }
1824
1836
  continue;
1825
1837
  }
1838
+ if (line === '/snapshot' ||
1839
+ line.startsWith('/snapshot ')) {
1840
+ // /snapshot — 项目快照总开关。无参切换 on/off;/on 或 /off 显式;/status 只读。
1841
+ //
1842
+ // 设计原则(与 /project_skill / /memory_switch 对齐):
1843
+ // - 单一来源 config.projectSnapshotEnabled(被 buildSnapshotSection() 现拼 +
1844
+ // read_file 工具每次 execute 现读);改完下一轮 chat 的 system prompt 即时反映。
1845
+ // - 持久化字段 MOCODE_PROJECT_SNAPSHOT,默认值 true(默认开启,启动 lazy build)。
1846
+ // - 关闭时:system prompt 不注入快照段,read_file 不查 cache 直接走真实 readFile。
1847
+ // - 开启时:若还没有 snapshot.json,主动 buildProjectSnapshot() 一次(仿 startRepl
1848
+ // 655-658 的 lazy build 模式),否则 buildSnapshotSection() 会因 loadSnapshot=null
1849
+ // 返空串,用户感知不到自己刚打开的开关已经生效。
1850
+ try {
1851
+ const arg = line.startsWith('/snapshot ')
1852
+ ? line.slice('/snapshot '.length).trim().toLowerCase()
1853
+ : '';
1854
+ // /snapshot status — 只读查询
1855
+ if (arg === 'status') {
1856
+ const on = isProjectSnapshotEnabled();
1857
+ layout.contentWrite(`${ui.cyan}项目快照:${ui.reset} ${on ? `${ui.green}开启` : `${ui.yellow}关闭`}${ui.reset}\n`);
1858
+ layout.contentWrite(`${ui.dim} 单一来源 config.projectSnapshotEnabled(${on ? 'true' : 'false'});` +
1859
+ `持久化 ${ui.cyan}MOCODE_PROJECT_SNAPSHOT${ui.dim};` +
1860
+ `配置文件 ${CONFIG_PATH}${ui.reset}\n`);
1861
+ layout.contentWrite(`${ui.dim} 关闭时:buildBasePrompt() 不含「## Project Snapshot」段;` +
1862
+ `read_file 工具直接走真实 readFile,不走 snapshot cache。${ui.reset}\n`);
1863
+ layout.contentWrite(`${ui.dim} 切换后下次 chat 即时反映(本轮已发出的请求不会回滚)。${ui.reset}\n`);
1864
+ continue;
1865
+ }
1866
+ // /snapshot(无参=切换;有参=按值设)
1867
+ let nextEnabled;
1868
+ if (arg === '') {
1869
+ nextEnabled = !isProjectSnapshotEnabled();
1870
+ }
1871
+ else if (['on', 'true', '1', 'yes', 'y', 'enable', 'enabled'].includes(arg)) {
1872
+ nextEnabled = true;
1873
+ }
1874
+ else if (['off', 'false', '0', 'no', 'n', 'disable', 'disabled'].includes(arg)) {
1875
+ nextEnabled = false;
1876
+ }
1877
+ else {
1878
+ layout.contentWrite(`${ui.yellow}/snapshot 用法:${ui.reset}\n` +
1879
+ ` /snapshot 切换(开↔关)\n` +
1880
+ ` /snapshot on|off 显式设值\n` +
1881
+ ` /snapshot status 查看当前状态\n`);
1882
+ continue;
1883
+ }
1884
+ const prev = isProjectSnapshotEnabled();
1885
+ if (nextEnabled === prev) {
1886
+ layout.contentWrite(`${ui.dim}(已是 ${nextEnabled ? '开启' : '关闭'},未变更 — 持久化字段未写入)${ui.reset}\n`);
1887
+ continue;
1888
+ }
1889
+ updateSnapshotConfig(nextEnabled);
1890
+ updateConfigKey('MOCODE_PROJECT_SNAPSHOT', nextEnabled ? 'true' : 'false');
1891
+ // 开启时异步 build 一次,避免 buildSnapshotSection 因 loadSnapshot=null 返空串。
1892
+ let built = false;
1893
+ if (nextEnabled) {
1894
+ try {
1895
+ clearSnapshotCache();
1896
+ const result = await buildSnapshot();
1897
+ if (result.snapshot) {
1898
+ built = true;
1899
+ }
1900
+ else {
1901
+ layout.contentWrite(`${ui.yellow}⚠ 快照构建失败:${ui.reset} ${result.error || '未知错误'} ` +
1902
+ `${ui.dim}(开关已切换,系统提示词中的快照段将为空,稍后可用 /snapshot_refresh 重试)${ui.reset}\n`);
1903
+ }
1904
+ }
1905
+ catch (e) {
1906
+ layout.contentWrite(`${ui.yellow}⚠ 快照构建失败:${ui.reset} ${e.message} ` +
1907
+ `${ui.dim}(开关已切换,系统提示词中的快照段将为空,稍后可用 /snapshot_refresh 重试)${ui.reset}\n`);
1908
+ }
1909
+ }
1910
+ // 刷新 history[0] 使 system prompt 立即反映新值(仿 /snapshot_refresh)。
1911
+ history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
1912
+ const note = nextEnabled
1913
+ ? `${ui.green}已开启项目快照${ui.reset} — ` +
1914
+ `system prompt 将在下次 chat 注入「## Project Snapshot」段;` +
1915
+ `read_file 工具优先走 snapshot cache(mtime 校验失败时回退真实 readFile)。`
1916
+ : `${ui.yellow}已关闭项目快照${ui.reset} — ` +
1917
+ `system prompt 不再注入「## Project Snapshot」段;` +
1918
+ `read_file 工具直接走真实 readFile,不再查 cache。`;
1919
+ let extra = '';
1920
+ if (nextEnabled && built) {
1921
+ extra = `${ui.dim}(已构建快照;如需刷新用 /snapshot_refresh)${ui.reset}\n`;
1922
+ }
1923
+ layout.contentWrite(`${note}\n${extra}`);
1924
+ layout.contentWrite(`${ui.dim}(写入 ${CONFIG_PATH}:MOCODE_PROJECT_SNAPSHOT=${nextEnabled ? 'true' : 'false'};` +
1925
+ (process.env.MOCODE_PROJECT_SNAPSHOT
1926
+ ? `${ui.dim}同 session shell 已 export MOCODE_PROJECT_SNAPSHOT,文件写入下次启动仍以 shell 值为准;取消 shell 设置后生效)${ui.reset}\n`
1927
+ : `${ui.dim}下次启动仍生效)${ui.reset}\n`));
1928
+ }
1929
+ catch (e) {
1930
+ layout.contentWrite(`${ui.red}/snapshot 失败:${ui.reset} ${e.message}\n`);
1931
+ }
1932
+ continue;
1933
+ }
1826
1934
  const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
1827
1935
  const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
1828
1936
  if (!shouldCommit) {
@@ -1,4 +1,4 @@
1
- import { readProjectSkill, writeProjectSkill, appendProjectSkill, } from '../../project-skill/index.js';
1
+ import { readProjectSkill, writeProjectSkillWithCompression, appendProjectSkillWithCompression, } from '../../project-skill/index.js';
2
2
  /**
3
3
  * 项目专属 Skill 更新工具。
4
4
  * 支持三种操作:
@@ -18,7 +18,33 @@ export const projectSkillUpdateTool = {
18
18
  'Use this to record architectural patterns, naming conventions, common pitfalls, ' +
19
19
  'and key decisions specific to this project. The skill persists across sessions and ' +
20
20
  'is injected into the system prompt when MOCODE_PROJECT_SKILL=true. ' +
21
- 'Actions: "read" (view current content), "write" (replace all), "append" (add to end).',
21
+ 'Actions: "read" (view current content), "write" (replace all), "append" (add to end). ' +
22
+ '\n\n' +
23
+ '## Skill 维护指南\n' +
24
+ 'Skill 专注 Snapshot 无法自动提供的洞察知识(why/how/gotchas)。Snapshot 已提供文件结构和静态文件内容——Skill 不要重复这些信息。\n' +
25
+ '\n' +
26
+ '**Skill 该写什么(Snapshot 不能替代的)**:\n' +
27
+ '- 设计决策的 why(为什么这样设计、取舍是什么)\n' +
28
+ '- 模块行为和职责描述(怎么工作、数据流、调用链)\n' +
29
+ '- 常见坑点和解决方案(踩过的坑、非直觉行为、边界条件)\n' +
30
+ '- 项目约定(命名规范、代码风格、测试策略)\n' +
31
+ '- 开发流程(构建/测试/部署命令及注意事项)\n' +
32
+ '- 关键 API 的使用限制和特殊行为\n' +
33
+ '\n' +
34
+ '**Skill 不该写什么(交给 Snapshot)**:\n' +
35
+ '- 文件列表和目录结构(Snapshot 自动扫描 src 树)\n' +
36
+ '- 静态文件内容摘要(依赖、编译器选项等 → package.json / tsconfig.json)\n' +
37
+ '\n' +
38
+ '**何时更新**:发现新架构模式、踩坑后总结、学到新约定、完成重要重构、用户纠正理解时。\n' +
39
+ '\n' +
40
+ '**注意事项**:\n' +
41
+ '- 保持精简,硬上限 4000 字符(约 1000 token)\n' +
42
+ '- 写可操作的内容,避免空泛描述\n' +
43
+ '- 用具体路径和例子(不要写"有多个模块",要写"src/agent 负责 agent 循环")\n' +
44
+ '- 定期整理,删除过时信息\n' +
45
+ '- 更新前建议先 `read` 看一下现有内容,避免重复\n' +
46
+ '\n' +
47
+ '**自动压缩**:内容超限时会自动调用 LLM 压缩(最多 3 次),无需手动精简。',
22
48
  risk: 'safe',
23
49
  parameters: {
24
50
  type: 'object',
@@ -35,9 +61,10 @@ export const projectSkillUpdateTool = {
35
61
  },
36
62
  required: ['action'],
37
63
  },
38
- execute: async (args) => {
64
+ execute: async (args, ctx) => {
39
65
  const action = String(args.action ?? '').trim();
40
66
  const content = String(args.content ?? '');
67
+ const signal = ctx?.signal;
41
68
  switch (action) {
42
69
  case 'read': {
43
70
  const current = readProjectSkill();
@@ -50,21 +77,25 @@ export const projectSkillUpdateTool = {
50
77
  if (!content.trim()) {
51
78
  return 'Error: "write" action requires non-empty content parameter.';
52
79
  }
53
- const result = writeProjectSkill(content);
80
+ const result = await writeProjectSkillWithCompression(content, 3, signal);
54
81
  if (!result.ok) {
55
82
  return `Error: ${result.error}`;
56
83
  }
57
- return 'Project skill updated successfully (full replacement).';
84
+ return result.compressed
85
+ ? 'Project skill updated successfully (full replacement, auto-compressed to fit limit).'
86
+ : 'Project skill updated successfully (full replacement).';
58
87
  }
59
88
  case 'append': {
60
89
  if (!content.trim()) {
61
90
  return 'Error: "append" action requires non-empty content parameter.';
62
91
  }
63
- const result = appendProjectSkill(content);
92
+ const result = await appendProjectSkillWithCompression(content, 3, signal);
64
93
  if (!result.ok) {
65
94
  return `Error: ${result.error}`;
66
95
  }
67
- return 'Project skill updated successfully (appended).';
96
+ return result.compressed
97
+ ? 'Project skill updated successfully (appended, auto-compressed to fit limit).'
98
+ : 'Project skill updated successfully (appended).';
68
99
  }
69
100
  default:
70
101
  return `Error: Unknown action "${action}". Use "read", "write", or "append".`;
@@ -1,8 +1,6 @@
1
1
  import { readFile } from 'node:fs/promises';
2
2
  import { resolve } from 'node:path';
3
3
  import { MAX_FILE_LINES } from '../constants.js';
4
- import { lookupSnapshotFile } from '../../project-snapshot/index.js';
5
- import { config } from '../../config/index.js';
6
4
  /** 默认单次 read_file 拉取的行数。刻意压低,逼 LLM 分块读大文件,
7
5
  * 配合 description 中的 PAGINATION IS MANDATORY 引导。
8
6
  * 300 行 ≈ 一个屏幕的源码量,够定位一段逻辑而不至于吃光上下文。 */
@@ -31,21 +29,7 @@ export const readFileTool = {
31
29
  const offset = Number(args.offset ?? 1);
32
30
  // 无论 LLM 传多大,单次硬钳到 MAX_FILE_LINES,杜绝「绕过分页引导一把全拿」。
33
31
  const limit = Math.min(Number(args.limit ?? DEFAULT_READ_LIMIT), MAX_FILE_LINES);
34
- // 项目快照 cache hit:mtime 校验通过则直接返回,省磁盘读
35
- let data;
36
- if (config.projectSnapshotEnabled) {
37
- const absPath = resolve(path);
38
- const cached = lookupSnapshotFile(absPath);
39
- if (cached) {
40
- data = cached.content;
41
- }
42
- else {
43
- data = await readFile(absPath, 'utf8');
44
- }
45
- }
46
- else {
47
- data = await readFile(resolve(path), 'utf8');
48
- }
32
+ const data = await readFile(resolve(path), 'utf8');
49
33
  const lines = data.split(/\r?\n/);
50
34
  const start = Math.max(0, offset - 1);
51
35
  const end = Math.min(lines.length, start + limit);
@@ -20,18 +20,38 @@ export const todolistTool = {
20
20
  name: 'todolist',
21
21
  description: [
22
22
  'Maintain a working "notepad" plan in .mocode/plans/<id>.md (file-based, survives context compression).',
23
- 'For complex multi-step tasks (≥3 file changes or ≥5 tool calls expected, OR user says "先计划再执行" / "plan then do"), CALL THIS FIRST to write the plan, then update each step as you go.',
24
- 'For simple single-step tasks, skip it and just execute.',
23
+ '',
24
+ '## PREREQUISITES (before create)',
25
+ 'Research first: explore codebase (read-only tools) → clarify with user (ask_human) → have concrete steps.',
26
+ 'Do NOT create prematurely — if you haven\'t done research and confirmation, stop and do that first.',
27
+ '',
28
+ '## WHEN TO USE / NOT USE',
29
+ 'ONLY for genuinely complex tasks: ≥3 file changes, ≥5 tool calls across phases, multi-phase features,',
30
+ 'or user explicitly requests ("先计划再执行" / "plan then do").',
31
+ 'Skip for: single-file edits, bug fixes, quick lookups, ≤3 focused tool calls. If in doubt, just execute.',
32
+ '',
33
+ '## STEP GRANULARITY (4-5 steps max)',
34
+ 'Each step = one meaningful execution unit (a phase, not a single tool call).',
35
+ 'GOOD: "调研现有架构 → 设计新接口 → 实现核心逻辑 → 编写测试 → 集成验证"',
36
+ 'BAD: "打开文件A → 修改函数X → 保存文件A → 运行测试" (too fine-grained, just do it)',
37
+ '',
38
+ '## UPDATE WORKFLOW',
39
+ 'Update in real-time after completing each step (one step = one update, or batch_update for 2-3 at once).',
40
+ 'Do NOT batch all updates at the end — update as you go so the chip reflects real progress.',
41
+ 'All steps done/skipped → plan auto-finishes, archives, and chip disappears.',
42
+ '',
43
+ '## LIFECYCLE',
25
44
  'Single plan per session: create refuses if an in-progress plan already exists — finish or abandon it first.',
26
- 'Lifecycle: finish AUTO-ARCHIVES the plan to .mocode/plans/archive/ (history preserved, active dir stays clean). To revisit, call list with scope=archived or unarchive. Use delete to permanently remove a plan (any location).',
27
- ].join(''),
45
+ 'finish(plan_status="finished") auto-archives to .mocode/plans/archive/; use plan_status="abandoned" to abandon.',
46
+ 'To revisit: list with scope=archived, or unarchive to bring back to active. delete permanently removes.',
47
+ ].join('\n'),
28
48
  parameters: {
29
49
  type: 'object',
30
50
  properties: {
31
51
  action: {
32
52
  type: 'string',
33
- enum: ['create', 'read', 'update', 'add_step', 'finish', 'list', 'delete', 'unarchive'],
34
- description: 'create=新计划;read=读当前活跃;update=改步骤状态;add_step=追加步骤;finish=收尾(自动归档);list=列计划;delete=永久删除;unarchive=从归档还原到活跃',
53
+ enum: ['create', 'read', 'update', 'batch_update', 'add_step', 'finish', 'list', 'delete', 'unarchive'],
54
+ description: 'create=新计划;read=读当前活跃;update=改步骤状态;batch_update=批量改多个步骤状态;add_step=追加步骤;finish=收尾(自动归档);list=列计划;delete=永久删除;unarchive=从归档还原到活跃',
35
55
  },
36
56
  title: { type: 'string', description: 'create 必填:计划标题' },
37
57
  goal: { type: 'string', description: 'create 可选:目标描述(写进「目标」段)' },
@@ -49,9 +69,21 @@ export const todolistTool = {
49
69
  enum: ['pending', 'in_progress', 'done', 'skipped', 'failed'],
50
70
  description: 'update 必填:目标状态',
51
71
  },
72
+ updates: {
73
+ type: 'array',
74
+ items: {
75
+ type: 'object',
76
+ properties: {
77
+ step_id: { type: 'number', description: '步骤编号' },
78
+ status: { type: 'string', enum: ['pending', 'in_progress', 'done', 'skipped', 'failed'], description: '目标状态' },
79
+ },
80
+ required: ['step_id', 'status'],
81
+ },
82
+ description: 'batch_update 必填:批量更新数组,每项含 step_id 和 status',
83
+ },
52
84
  note: {
53
85
  type: 'string',
54
- description: 'update / finish 可选:追加到进度日志的一行说明(可空)',
86
+ description: 'update / batch_update / finish 可选:追加到进度日志的一行说明(可空)',
55
87
  },
56
88
  plan_status: {
57
89
  type: 'string',
@@ -77,13 +109,14 @@ export const todolistTool = {
77
109
  case 'create': return doCreate(args);
78
110
  case 'read': return doRead();
79
111
  case 'update': return doUpdate(args);
112
+ case 'batch_update': return doBatchUpdate(args);
80
113
  case 'add_step': return doAddStep(args);
81
114
  case 'finish': return doFinish(args);
82
115
  case 'list': return doList(args);
83
116
  case 'delete': return doDelete(args);
84
117
  case 'unarchive': return doUnarchive(args);
85
118
  default:
86
- return `错误:未知 action「${action}」,合法值:create / read / update / add_step / finish / list / delete / unarchive。`;
119
+ return `错误:未知 action「${action}」,合法值:create / read / update / batch_update / add_step / finish / list / delete / unarchive。`;
87
120
  }
88
121
  }
89
122
  catch (e) {
@@ -175,9 +208,92 @@ function doUpdate(args) {
175
208
  if (!updated.steps.some((s) => s.id === stepId)) {
176
209
  return `错误:找不到 step_id=${stepId}(plan 共 ${updated.steps.length} 步)。`;
177
210
  }
211
+ // 自动完成:所有步骤都 done/skipped 时自动 finish + 归档 + 清 chip(LLM 常漏调 finish)
212
+ const allDone = updated.steps.length > 0
213
+ && updated.steps.every((s) => s.status === 'done' || s.status === 'skipped');
214
+ if (allDone && updated.status === 'in_progress') {
215
+ updated.status = 'finished';
216
+ updated.log.push({ at: localIsoTimestamp(), text: '自动完成:所有步骤已完成' });
217
+ if (!writePlan(updated)) {
218
+ setActivePlan(updated);
219
+ return renderSuccess('update', updated) + '\n⚠ 自动 finish 写盘失败';
220
+ }
221
+ const archived = archivePlan(updated.id);
222
+ clearActivePlan();
223
+ const archivedNote = archived
224
+ ? '(已自动归档到 plans/archive/)'
225
+ : '(⚠ 归档失败,plan 仍留在 plans/)';
226
+ return renderSuccess('update', updated) + `\n✓ 自动完成:所有步骤已完成 ${archivedNote}`;
227
+ }
178
228
  setActivePlan(updated);
179
229
  return renderSuccess('update', updated);
180
230
  }
231
+ function doBatchUpdate(args) {
232
+ const cur = getActivePlan();
233
+ if (!cur)
234
+ return '错误:无活跃 plan 可 batch_update。先 create。';
235
+ const updates = args.updates;
236
+ if (!Array.isArray(updates) || updates.length === 0) {
237
+ return '错误:batch_update 必填 updates(非空数组,每项含 step_id 和 status)。';
238
+ }
239
+ // 验证所有更新项
240
+ for (const u of updates) {
241
+ const sid = Number(u.step_id);
242
+ const st = String(u.status ?? '');
243
+ if (!Number.isFinite(sid) || sid < 1) {
244
+ return `错误:updates 中某项 step_id 非法「${u.step_id}」,需 >=1 整数。`;
245
+ }
246
+ if (!VALID_STATUS.has(st)) {
247
+ return `错误:updates 中某项 status 非法「${st}」,合法:pending / in_progress / done / skipped / failed。`;
248
+ }
249
+ }
250
+ const note = String(args.note ?? '').trim();
251
+ const updated = updatePlan(cur.id, (p) => {
252
+ for (const u of updates) {
253
+ const sid = Number(u.step_id);
254
+ const st = String(u.status);
255
+ const step = p.steps.find((s) => s.id === sid);
256
+ if (step) {
257
+ step.status = st;
258
+ p.log.push({ at: new Date().toISOString(), text: `step ${sid} → ${st}` });
259
+ }
260
+ }
261
+ if (note)
262
+ p.log.push({ at: new Date().toISOString(), text: note });
263
+ return p;
264
+ });
265
+ if (!updated)
266
+ return '错误:batch_update 写盘失败。';
267
+ // 检查所有 step_id 是否存在
268
+ const missingIds = [];
269
+ for (const u of updates) {
270
+ const sid = Number(u.step_id);
271
+ if (!updated.steps.some((s) => s.id === sid))
272
+ missingIds.push(sid);
273
+ }
274
+ if (missingIds.length > 0) {
275
+ return `错误:batch_update 找不到 step_id=${missingIds.join(', ')}(plan 共 ${updated.steps.length} 步)。`;
276
+ }
277
+ // 自动完成:所有步骤都 done/skipped 时自动 finish + 归档 + 清 chip(LLM 常漏调 finish)
278
+ const allDone = updated.steps.length > 0
279
+ && updated.steps.every((s) => s.status === 'done' || s.status === 'skipped');
280
+ if (allDone && updated.status === 'in_progress') {
281
+ updated.status = 'finished';
282
+ updated.log.push({ at: localIsoTimestamp(), text: '自动完成:所有步骤已完成' });
283
+ if (!writePlan(updated)) {
284
+ setActivePlan(updated);
285
+ return renderSuccess('batch_update', updated) + '\n⚠ 自动 finish 写盘失败';
286
+ }
287
+ const archived = archivePlan(updated.id);
288
+ clearActivePlan();
289
+ const archivedNote = archived
290
+ ? '(已自动归档到 plans/archive/)'
291
+ : '(⚠ 归档失败,plan 仍留在 plans/)';
292
+ return renderSuccess('batch_update', updated) + `\n✓ 自动完成:所有步骤已完成 ${archivedNote}`;
293
+ }
294
+ setActivePlan(updated);
295
+ return renderSuccess('batch_update', updated);
296
+ }
181
297
  function doAddStep(args) {
182
298
  const cur = getActivePlan();
183
299
  if (!cur)