mocode-ai 0.4.9 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -18,6 +18,7 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
18
18
  - **Plan / Auto dual mode** — In `plan` mode the agent is read-only (reads code, queries indexes, searches — never writes to disk, runs commands, or spawns sub-agents) and produces a plan; `auto` mode unlocks the full toolset. The agent can switch between the two on its own — scope out an unfamiliar codebase first, then start making changes.
19
19
  - **Automatic context compression** — As the context window fills up, a three-tier compression kicks in (trim individual results → compact older tool results in place → summarize older turns), so long sessions never overflow. `/context` shows live token usage; `/compact` triggers manual compression (optionally with a focus hint to preserve what matters).
20
20
  - **Cross-session long-term memory** — The agent can save project architecture, conventions, and lessons learned as long-term memory, auto-loaded in future sessions. A background process periodically reflects on conversations to mine things worth remembering. Memories can be created, searched, updated, and forgotten, with recall-based decay.
21
+ - **Project context (Snapshot + Skill)** — Two complementary systems help the agent understand your project: **Project Snapshot** automatically scans files and generates LLM-enhanced summaries (project description, tech stack, commands, module responsibilities, directory tree); **Project Skill** is a manually maintained knowledge base capturing design decisions, architectural insights, pitfalls, and conventions. Snapshot provides *what/where* (facts), Skill provides *why/how* (insights) — no duplication, ~46% token savings.
21
22
  - **Working notepad (todolist)** — For complex multi-step tasks (≥3 file changes / ≥5 tool calls), the agent first writes a plan to `.mocode/plans/<id>.md` (file-based, survives context compression), then ticks each step as it goes. A live progress chip in the TUI status bar shows `plan: [title] (3/7) ▸ [current step]`. `finish` auto-archives completed plans to `plans/archive/`, with explicit `list / delete / unarchive` actions.
22
23
  - **Interruptible and reversible** — Ctrl+C interrupts the current turn at any time (kills child processes recursively, rolls history back to before the turn started, leaves no half-finished tool calls). `/rollback` restores file changes from per-turn snapshots, with a per-file keep/undo choice — no git dependency required.
23
24
  - **Sandbox protection** — File reads/writes go through a sandbox that blocks out-of-bounds paths (`../../`, absolute paths outside the root, symlink escapes, etc.), so the agent never touches files outside your working directory.
@@ -174,6 +175,8 @@ The five `memory_*` tools are gated on `MEMORY_ENABLED=true` at startup; toggle
174
175
  | `/pet` | Toggle the optional desktop pet (floating window mirroring agent state) |
175
176
  | `/pet skin` | Pick a pet skin (↑↓ · Enter) |
176
177
  | `/pet quit` | Fully shut down the pet process (not just disconnect) |
178
+ | `/snapshot_refresh` | Refresh project snapshot (re-scan files + regenerate LLM summary) |
179
+ | `/snapshot` | Toggle project snapshot on/off |
177
180
 
178
181
  Type `/` to trigger the dropdown menu, keep typing to filter; Esc to cancel.
179
182
 
@@ -201,6 +204,21 @@ MoCode automatically scans the following directories for skills (each skill is a
201
204
 
202
205
  A skill's `description` is injected into the system prompt (progressive disclosure, tier 1); the model calls `use_skill` to load the full body (tier 2) only when the task is relevant. Use `/skills` to see discovered skills.
203
206
 
207
+ ## Project Context (Snapshot + Skill)
208
+
209
+ MoCode uses two complementary systems to help the agent understand your project:
210
+
211
+ - **Project Snapshot** (automatic + LLM-enhanced): Scans your project files and generates a structured summary including project description, tech stack, key commands, module responsibilities, and directory tree. Built automatically on startup (Phase 1: sync scan ~100ms, Phase 2: async LLM summary ~5s). Stored in `.mocode/snapshot.json`, reused across sessions. Refresh manually with `/snapshot_refresh` after major changes.
212
+ - **Project Skill** (manual + AI-assisted): A knowledge base you maintain capturing insights the agent can't auto-discover — design decisions, architectural patterns, pitfalls, conventions, and development workflow notes. Initialize with `/init` (AI explores your project and drafts the initial content), then edit `.mocode/project-skill.md` to refine. Update via the `project_skill_update` tool during conversations.
213
+
214
+ **Complementary principle**: Snapshot provides *what/where* (facts: files, structure, commands), Skill provides *why/how* (insights: decisions, behaviors, gotchas). No duplication, ~46% token savings compared to the previous approach.
215
+
216
+ Both are enabled by default. Control via environment variables:
217
+ - `MOCODE_PROJECT_SNAPSHOT=false` — disable snapshot
218
+ - `MOCODE_PROJECT_SKILL=false` — disable skill
219
+
220
+ See [docs/USAGE_SNAPSHOT_SKILL.md](./docs/USAGE_SNAPSHOT_SKILL.md) for detailed usage.
221
+
204
222
  ## Project memory (MOCODE.md)
205
223
 
206
224
  MoCode has a **two-tier memory** model distinct from skills:
@@ -74,10 +74,13 @@ function readDiffContext(tc, parsed) {
74
74
  }
75
75
  }
76
76
  if (tc.name === 'edit_file') {
77
- const oldStr = String(parsed.old_string ?? '');
77
+ // 行尾归一化:LLM 生成的 old_string LF(\n),但 Windows 文件可能是 CRLF(\r\n),
78
+ // 不统一则 indexOf 必败、editStartLine 恒为 1。与 edit-file.ts 保持一致归一化为 LF。
79
+ const oldStr = String(parsed.old_string ?? '').replace(/\r\n/g, '\n').replace(/\r/g, '\n');
78
80
  try {
79
81
  // jailResolve:同上,沙箱越界抛错 → catch 兜底,不泄露牢外内容
80
- const data = readFileSync(jailResolve(p), 'utf8');
82
+ const raw = readFileSync(jailResolve(p), 'utf8');
83
+ const data = raw.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
81
84
  const idx = oldStr ? data.indexOf(oldStr) : -1;
82
85
  return {
83
86
  preWriteOld: null,
@@ -190,6 +193,10 @@ export async function runAgentCore(opts) {
190
193
  let mode = 'idle';
191
194
  let gotText = false;
192
195
  let lastChar = '';
196
+ // 早退重探:本 turn 已执行过工具但模型突然返回无工具调用 + 极短/空文本 → 推一条提示让模型继续,
197
+ // 而非直接退出。每 turn 最多触发 1 次,防死循环。弱模型在探索中途偶尔"说完"即此兜底。
198
+ let nudgeCount = 0;
199
+ let hadToolsThisTurn = false;
193
200
  const onText = (s) => {
194
201
  hooks.onText?.(s); // 主 agent:走 markdown 渲染写内容区
195
202
  mode = 'text';
@@ -259,6 +266,7 @@ export async function runAgentCore(opts) {
259
266
  // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
260
267
  onContextUpdate?.();
261
268
  if (result.toolCalls.length > 0) {
269
+ hadToolsThisTurn = true;
262
270
  // 流式正文末尾补换行(若 onToolCall 已补则 lastChar='\n',此处 no-op);防 ● 行黏在正文行尾
263
271
  if (mode !== 'idle' && lastChar !== '\n')
264
272
  hooks.onTextEnd?.();
@@ -435,6 +443,19 @@ export async function runAgentCore(opts) {
435
443
  }
436
444
  if (mode !== 'idle' && lastChar !== '\n')
437
445
  hooks.onTextEnd?.(); // 流式末尾补换行
446
+ // 早退保护:本 turn 已执行过工具调用,但模型突然返回无工具 + 极短/空文本 → 很可能是在探索中途
447
+ // 提前"说完了"。此时推一条 user 提示消息让模型继续探索,而非直接退出。每 turn 最多 1 次,防死循环。
448
+ // 判定标准:无 gotText(完全没输出)或正文极短(< 80 字符,通常是一句"我需要更多信息"级别的截断)。
449
+ const textLen = result.content?.trim().length ?? 0;
450
+ if (hadToolsThisTurn && nudgeCount < 1 && (!gotText || textLen < 80)) {
451
+ nudgeCount++;
452
+ history.push({ role: 'assistant', content: result.content });
453
+ history.push({
454
+ role: 'user',
455
+ content: 'You stopped before completing the task. Please continue investigating — call more tools if needed, or provide a complete answer based on what you have gathered so far.',
456
+ });
457
+ continue; // 带着提示再调一次 LLM
458
+ }
438
459
  // 没有工具调用:流式正文即最终回复(已实时打印)
439
460
  if (!gotText)
440
461
  hooks.onNoReply?.();
@@ -47,7 +47,7 @@ function writeToolResult(tc, output, parsed, preWriteOld, editStartLine) {
47
47
  });
48
48
  }
49
49
  const preview = diff ? '' : summarizeToolResult(tc.name, output);
50
- batch.recordResult(currentBatchId, tc.name, preview, diff);
50
+ batch.recordResult(currentBatchId, tc.name, preview, diff, output);
51
51
  }
52
52
  /**
53
53
  * agent 核心循环(主 agent,TUI 渲染版):
@@ -90,9 +90,8 @@ function buildSnapshotSection() {
90
90
  const snap = loadSnapshot();
91
91
  if (!snap)
92
92
  return '';
93
- const fileList = Object.keys(snap.files).join(', ');
94
- const modules = snap.structure.modules.length ? snap.structure.modules.join(', ') : '(none)';
95
- return `\n## Project Snapshot (cross-session cache)\n- A project snapshot is available with cached static files: [${fileList}]. read_file for these files will hit the snapshot cache (mtime-verified) — no disk read needed.\n- Project structure — top-level modules: [${modules}].\n- You already know the project skeleton; don't read_file these cached files just to "get an overview".\n- Use cached content directly: when a question can be answered from the cached files above (e.g. dependencies → package.json, compiler options → tsconfig.json, env vars → .env.example, project intro → README.md), answer from the snapshot without calling read_file.\n- The module list above is a navigation aid: when locating files, prefer targeted \`glob\` into the relevant module (e.g. \`src/**/*.ts\`) over broad top-level scans.\n`;
93
+ // 快照内容已经是完整的 markdown,直接返回
94
+ return `\n${snap.content}\n`;
96
95
  }
97
96
  catch {
98
97
  return '';
@@ -201,7 +200,7 @@ ${PLATFORM_NOTE}
201
200
  - Use glob to find file paths, grep to search content. **Don't use run_command for file-level checks** (existence / listing / type) — those have no clean cmd.exe equivalent and Windows path escaping fails often. Use \`glob\` to list, and just call \`read_file\` to test existence (returns ENOENT as a clean error string).
202
201
  - run_command has side effects on the host — state intent before invoking (delete, install, push, reset, etc.).
203
202
  - Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
204
- - **Trim context when stale**: when an old tool result is dead weight (sub-goal done, no downstream consumer, or superseded by a later read), call drop_context to stub it; otherwise rely on automatic pruning.
203
+ - **Trim context when stale**: when an old tool result is dead weight (sub-goal done, no downstream consumer, or superseded by a later read), call drop_context to stub it; otherwise rely on automatic pruning. **When your context gets too long, don't hesitate to use drop_context proactively** — it's cheap and designed to be called, not saved for emergencies.
205
204
  - **Batch writes and commands too, not just reads**: the executor runs ALL returned tool_calls (reads, writes, commands) before the next LLM call. Emit independent edit_file / write_file / run_command in one response when the chain is clear — don't serialize them across turns just because they have side effects. (The read-only batching note in Step Economy applies to writes the same way.)
206
205
  - **Chain shell workflows in a single \`run_command\`**: use \`&&\`, \`;\`, \`|\`, \`>\`, heredocs to fold multi-step scripts (\`mkdir -p x && cat > x/file.ts <<'EOF' ... EOF && npm test\`) into one call. Only emit a follow-up turn when the result forces a decision (error, ambiguous output, branching logic).
207
206
 
@@ -222,12 +221,12 @@ ${PLATFORM_NOTE}
222
221
 
223
222
  ${buildSnapshotSection()}${config.projectSkillEnabled ? buildProjectSkillSection() : ''}${memorySection}
224
223
 
225
- ## Working notepad (todolist) — for multi-step tasks
226
- - For tasks spanning **≥2 independent modules** OR when the user asks for stepwise progress ("先计划再执行" / "plan then do" / "按步骤来"), call \`todolist create\` first; update as you go. Skip for single-file edits or quick lookups.
227
- - Plan is file-backed (\`todolist read\` to re-orient). See the tool description for the full action set.
224
+ ## Working notepad (todolist)
225
+ - For genuinely complex tasks only: explore codebase clarify with user create plan execute step by step. See tool description for details.
228
226
 
229
227
  ## Termination & Reporting
230
228
  - Stop immediately when no more tools are needed; give conclusions directly.
229
+ - **Do not stop prematurely during exploration**: if you started investigating but haven't gathered enough information to answer the user's question, keep calling tools. Only stop when you have sufficient evidence or hit a dead end.
231
230
  - **No flattery / no preamble in conclusions**: skip "Sure", "好的", "我已经完成了" and similar no-information prefixes — jump straight to substance.
232
231
  - Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
233
232
  }
@@ -347,3 +346,14 @@ export function updateProjectSkillConfig(enabled) {
347
346
  config.projectSkillEnabled = enabled;
348
347
  process.env.MOCODE_PROJECT_SKILL = enabled ? 'true' : 'false';
349
348
  }
349
+ /**
350
+ * 切换项目快照开关(/snapshot on|off 调)。
351
+ * - 更新 config 单例字段(其它模块下次读 config.projectSnapshotEnabled 即拿新值:
352
+ * buildSnapshotSection 现拼现读、read-file 每次 execute 现读)。
353
+ * - 同步 process.env.MOCODE_PROJECT_SNAPSHOT(下次启动 loadEnvFiles 不会被文件回填)。
354
+ * 持久化(写 ~/.mocode/config 的 MOCODE_PROJECT_SNAPSHOT 键)由调用方走 updateConfigKey。
355
+ */
356
+ export function updateSnapshotConfig(enabled) {
357
+ config.projectSnapshotEnabled = enabled;
358
+ process.env.MOCODE_PROJECT_SNAPSHOT = enabled ? 'true' : 'false';
359
+ }
package/dist/llm/index.js CHANGED
@@ -354,6 +354,24 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
354
354
  buf = buf.slice(i);
355
355
  }
356
356
  if (delta.tool_calls) {
357
+ // 文本→工具转折点:若 buf 还残留普通文本的安全尾(为防跨 chunk 切分留的
358
+ // THINK_OPEN.length - 1 字符),立即 flush 到屏幕。否则这段尾巴会一直搁置到
359
+ // 流结束才由尾部防御输出,而那时 onToolCall 早已触发、TUI 已补换行+生成中
360
+ // spinner,用户看到「话没说完就去调工具」——history 完整但屏幕渲染顺序错位。
361
+ // inThink 段照旧丢弃(思考中模型不会同时吐 tool_call,理论上 buf 不会有思考段);
362
+ // 防御性保留 !inThink 判断。
363
+ if (buf && !inThink) {
364
+ visibleContent += buf;
365
+ // 给 onText 渲染时剥掉尾部 \n:md 渲染器(contentWriteMd)把尾部 \n 当段落分隔 → 产空行;
366
+ // 随后 onToolCall 检测到 lastChar !== '\n' 会经 contentWrite('\n') 补一个原始换行
367
+ // (不走 md,只是普通行分隔,无空行)—— 与改造前 onToolCall 补 \n 的行为一致。
368
+ // visibleContent 保留原 buf(含 \n),history 完整不受影响。
369
+ const tail = buf.replace(/\n+$/, '');
370
+ if (tail)
371
+ handlers.onText?.(tail);
372
+ consumedAny = true;
373
+ buf = '';
374
+ }
357
375
  for (const tc of delta.tool_calls) {
358
376
  const idx = tc.index ?? 0;
359
377
  let entry = toolAcc.get(idx);
@@ -375,11 +393,14 @@ async function chatOnce(messages, handlers, signal, toolsOverride) {
375
393
  }
376
394
  }
377
395
  // 防御:循环内 buf.slice 已把可确认部分消费;此处覆盖流末尾的"安全尾":
378
- // - 普通段(stream 已结束,标签不会再出现):作为可见内容追加到 visibleContent(不再调 onText)
396
+ // - 普通段(stream 已结束,标签不会再出现):作为可见内容追加到 visibleContent + 调 onText
397
+ // (之前注释说"不再调 onText"是 bug——安全尾里的真实文本会被屏幕吞掉,用户看到模型
398
+ // 话没说完就去调工具 / 直接结束;history 有但显示缺。现在补上 onText 让屏幕与 history 一致。)
379
399
  // - 思考段未闭合:丢弃,防 thinking 文本泄漏到 history
380
400
  if (buf) {
381
401
  if (!inThink) {
382
402
  visibleContent += buf;
403
+ handlers.onText?.(buf);
383
404
  consumedAny = true;
384
405
  }
385
406
  buf = '';
@@ -38,8 +38,8 @@ export function readProjectSkill() {
38
38
  return null;
39
39
  }
40
40
  }
41
- /** 内容硬上限(字符数)。超限拒绝写入,防止系统提示词膨胀 */
42
- const MAX_SKILL_CHARS = 6000;
41
+ /** 内容硬上限(字符数)。超限拒绝写入,防止系统提示词膨胀。从 6000 降至 4000,与快照互补后内容更精简。 */
42
+ const MAX_SKILL_CHARS = 4000;
43
43
  /**
44
44
  * 写入/更新项目 skill。先备份旧内容再写新内容。
45
45
  * 返回 { ok, error? }: ok=false 时 error 说明原因(超限/IO 失败)。
@@ -80,9 +80,88 @@ export function appendProjectSkill(addition) {
80
80
  const merged = existing + separator + trimmed;
81
81
  return writeProjectSkill(merged);
82
82
  }
83
+ /**
84
+ * 调用 LLM 压缩内容。超限时自动精简,保留关键信息。
85
+ * 返回压缩后的内容,失败返回 null。
86
+ */
87
+ export async function compressContent(content, signal) {
88
+ try {
89
+ // 动态导入避免循环依赖
90
+ const { chat } = await import('../llm/index.js');
91
+ const messages = [
92
+ {
93
+ role: 'system',
94
+ content: 'You are a technical writer. Compress the following project skill content to fit within ' +
95
+ `${MAX_SKILL_CHARS} characters while preserving the most important information. ` +
96
+ 'Keep concrete examples, paths, and actionable insights. Remove redundancy and verbose explanations. ' +
97
+ 'Output ONLY the compressed content, no explanations.',
98
+ },
99
+ { role: 'user', content },
100
+ ];
101
+ const result = await chat(messages, {}, signal);
102
+ const compressed = result.content?.trim();
103
+ if (!compressed)
104
+ return null;
105
+ return compressed;
106
+ }
107
+ catch {
108
+ return null;
109
+ }
110
+ }
111
+ /**
112
+ * 写入时自动压缩:超限则调用 LLM 压缩,最多尝试 maxAttempts 次。
113
+ * 返回 { ok, error?, compressed? },compressed 标记是否经过压缩。
114
+ */
115
+ export async function writeProjectSkillWithCompression(content, maxAttempts = 3, signal) {
116
+ let current = content;
117
+ let compressed = false;
118
+ for (let attempt = 0; attempt < maxAttempts; attempt++) {
119
+ const result = writeProjectSkill(current);
120
+ if (result.ok) {
121
+ return { ok: true, compressed };
122
+ }
123
+ // 非超限错误直接返回
124
+ if (!result.error?.includes('内容超过上限')) {
125
+ return result;
126
+ }
127
+ // 超限时调用 LLM 压缩
128
+ const compressedContent = await compressContent(current, signal);
129
+ if (!compressedContent) {
130
+ return {
131
+ ok: false,
132
+ error: `压缩失败: ${result.error}`,
133
+ };
134
+ }
135
+ current = compressedContent;
136
+ compressed = true;
137
+ }
138
+ // 多次压缩后仍超限
139
+ const finalCheck = writeProjectSkill(current);
140
+ if (finalCheck.ok) {
141
+ return { ok: true, compressed: true };
142
+ }
143
+ return {
144
+ ok: false,
145
+ error: `经过 ${maxAttempts} 次压缩仍超限: ${finalCheck.error}`,
146
+ };
147
+ }
148
+ /**
149
+ * 追加时自动压缩:合并后超限则调用 LLM 压缩,最多尝试 maxAttempts 次。
150
+ */
151
+ export async function appendProjectSkillWithCompression(addition, maxAttempts = 3, signal) {
152
+ const existing = readProjectSkill() ?? '';
153
+ const trimmed = addition.trim();
154
+ if (!trimmed)
155
+ return { ok: false, error: '追加内容为空' };
156
+ const separator = existing ? '\n\n' : '';
157
+ const merged = existing + separator + trimmed;
158
+ return writeProjectSkillWithCompression(merged, maxAttempts, signal);
159
+ }
83
160
  /**
84
161
  * 生成系统提示词注入段。
85
162
  * 开关关闭或文件不存在 → 空串(零行为变化)。
163
+ *
164
+ * 精简版:只注入 skill 内容本身,维护指南移到 project_skill_update 工具描述中。
86
165
  */
87
166
  export function buildProjectSkillSection() {
88
167
  const content = readProjectSkill();
@@ -97,33 +176,5 @@ export function buildProjectSkillSection() {
97
176
  '<project-skill>',
98
177
  content,
99
178
  '</project-skill>',
100
- '',
101
- '### Project Skill 维护指南',
102
- '你可以(也应该)在开发过程中持续更新这个 skill,让它越来越了解项目:',
103
- '',
104
- '**何时更新**:',
105
- '- 发现项目特有的架构模式、设计决策或数据流',
106
- '- 踩坑后总结出避坑指南(命名冲突、API 行为、构建陷阱等)',
107
- '- 学到新的命名约定、测试规范、代码风格',
108
- '- 完成重要重构或引入新模块后',
109
- '- 用户纠正了你对项目的错误理解',
110
- '',
111
- '**更新什么**:',
112
- '- 项目概述、技术栈、核心模块职责',
113
- '- 常见坑点和解决方案',
114
- '- 开发流程(构建、测试、部署命令)',
115
- '- 关键 API 的使用方式和限制',
116
- '- 设计决策的 why(不只是 what)',
117
- '',
118
- '**如何更新**:',
119
- '- `project_skill_update(action="read")` — 查看当前内容',
120
- '- `project_skill_update(action="update", content="...")` — 全量替换(适合大改)',
121
- '- `project_skill_update(action="append", content="...")` — 追加到末尾(适合加新发现)',
122
- '',
123
- '**注意事项**:',
124
- '- 保持精简,硬上限 6000 字符(约 1500 token)',
125
- '- 写可操作的内容,避免空泛描述',
126
- '- 定期整理,删除过时信息',
127
- '- 更新前建议先 `read` 看一下现有内容,避免重复',
128
179
  ].join('\n');
129
180
  }
@@ -5,64 +5,63 @@ import { spawnAgent } from '../agent/spawn.js';
5
5
  /**
6
6
  * 子 agent 的系统提示后缀:角色与输出格式约束
7
7
  */
8
- const SKILL_INIT_SUFFIX = `You are a project exploration agent. Your task is to deeply understand this project and generate a concise, actionable "Project Skill" document.
9
-
10
- ## Your Mission
11
- Explore the project thoroughly using available tools, then produce a structured skill document that will help future AI agents work efficiently on this project.
8
+ const SKILL_INIT_SUFFIX = `You are a project exploration agent. Your task is to deeply understand this project and generate a concise "Project Skill" document.
9
+
10
+ ## ⚠️ Complementary to Snapshot — CRITICAL
11
+ The following information is ALREADY provided by Project Snapshot **do NOT repeat**:
12
+ - ✗ Project one-liner description (快照已有)
13
+ - ✗ Module names and file locations (快照 srcTree 已有)
14
+ - ✗ Tech stack names and versions (快照 techStack 已有)
15
+ - ✗ Build/test commands (快照 keyCommands 已有)
16
+ - ✗ File lists and directory structure (快照 srcTree 已有)
17
+
18
+ **Your job is to capture INSIGHTS only:**
19
+ - **WHY** — design decisions, trade-offs, reasons for choosing X over Y
20
+ - **HOW** — module behaviors, data flows, call chains, non-obvious interactions
21
+ - **GOTCHAS** — pitfalls, edge cases, non-intuitive behaviors
22
+ - **CONVENTIONS** — naming patterns, code style, unwritten rules
12
23
 
13
24
  ## Exploration Strategy
14
- 1. **Start broad**: Use \`glob\` to understand the project structure
15
- 2. **Read key files**: package.json, README.md, tsconfig.json, any config files
16
- 3. **Understand architecture**: Use \`codegraph\` or \`grep\` to find main entry points, core modules, key interfaces
17
- 4. **Identify patterns**: Look for naming conventions, directory structure patterns, common abstractions
18
- 5. **Find pitfalls**: Check for complex configs, unusual dependencies, build quirks
25
+ 1. Use \`codegraph\` to trace call chains and understand module interactions
26
+ 2. Read core modules to understand **behaviors** (not just names)
27
+ 3. Look for complex logic, error handling patterns, unusual configs
28
+ 4. Identify naming conventions, common abstractions
19
29
 
20
30
  ## Output Format
21
- When you're done exploring, output the skill document between these exact delimiters:
31
+ Output the skill document between these exact delimiters:
22
32
 
23
33
  \`\`\`skill-start
24
- (your skill content here)
25
- \`\`\`skill-end
26
-
27
- The skill content should follow this structure (use Chinese for headings, adapt sections based on what you find):
28
-
29
- ## 项目概述
30
- - 一句话描述项目是什么、做什么
31
- - 核心目标用户/场景
32
-
33
- ## 技术栈
34
- - 主要语言、框架、工具(附版本号)
35
- - 关键技术选型理由(如果能从文档/代码推断)
36
-
37
34
  ## 架构要点
38
- - 核心模块及其职责(用 path/to/module 格式)
39
- - 数据流向或调用链(如果有明显模式)
40
- - 设计模式或架构模式(如果有)
35
+ ### 核心模块及职责
36
+ - **\`path/to/module\`** — 行为描述(做什么 + 怎么做)
37
+ ...
41
38
 
42
- ## 项目结构
43
- - 关键目录及其用途
44
- - 重要文件的位置
45
-
46
- ## 开发流程
47
- - 构建、测试、lint、部署命令
48
- - 开发环境配置要点
39
+ ### 关键设计决策
40
+ - 决策:原因
41
+ ...
49
42
 
50
43
  ## 项目约定
51
- - 命名规范(如果有明显模式)
52
- - 代码风格(如果有 .eslintrc, .prettierrc 等)
53
- - 测试策略(如果有测试)
44
+ - 约定(具体的,有例子的)
45
+ ...
46
+
47
+ ## 开发流程
48
+ - 命令 + 注意事项/坑点(命令本身快照已有,这里只写注意什么)
49
+ ...
54
50
 
55
51
  ## 常见坑点
56
- - 复杂的配置或构建步骤
57
- - 容易踩坑的 API 或行为
58
- - 需要特别注意的依赖版本问题
59
-
60
- ## Guidelines
61
- - 保持精简,总长度控制在 4000-5000 字符(硬上限 6000)
62
- - 写可操作的内容,避免空泛描述(不要写"代码质量高"这种废话)
63
- - 用具体路径和例子(不要写"有多个模块",要写"src/agent 负责 agent 循环")
64
- - 如果某个 section 没有明显内容,可以省略或写"待补充"
65
- - 重点是:让未来的 agent 能立即理解项目并开始有效工作
52
+ - 坑:解法
53
+ ...
54
+ \`\`\`skill-end
55
+
56
+ ## Rules — MUST FOLLOW
57
+ 1. 禁止列出文件名清单或目录结构
58
+ 2. 禁止列出依赖名和版本
59
+ 3. 禁止写项目概述/一句话描述
60
+ 4. 禁止只写命令本身(快照已有),只写注意事项
61
+ 5. 每个模块描述必须包含行为(做什么 + 怎么协作),不只是名称
62
+ 6. **总长度目标 3000-4000 字符,硬上限 4000**
63
+ 7. 写可操作的内容,用具体路径和例子
64
+ 8. 如果某个 section 没有实质内容,省略它
66
65
  `;
67
66
  /**
68
67
  * 生成初始项目 Skill(使用子 agent 深度探索)
@@ -1,8 +1,7 @@
1
1
  import { createHash } from 'node:crypto';
2
- import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, statSync } from 'node:fs';
2
+ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import path from 'node:path';
4
4
  import { getSandboxRoot } from '../sandbox/root.js';
5
- import { scanStaticFiles } from './static-files.js';
6
5
  /** 内存缓存:当前 session 的快照(避免重复 IO) */
7
6
  let currentSnapshot = null;
8
7
  /** 计算 sandboxRoot 的 hash,用作目录名(避免路径特殊字符) */
@@ -16,7 +15,7 @@ function snapshotDir() {
16
15
  }
17
16
  /** 快照文件路径 */
18
17
  function snapshotPath() {
19
- return path.join(snapshotDir(), 'snapshot.json');
18
+ return path.join(snapshotDir(), 'snapshot.md');
20
19
  }
21
20
  /** 从磁盘加载快照(不存在/损坏返 null) */
22
21
  export function loadSnapshot() {
@@ -26,10 +25,17 @@ export function loadSnapshot() {
26
25
  if (!existsSync(p))
27
26
  return null;
28
27
  try {
29
- const raw = readFileSync(p, 'utf8');
30
- const snap = JSON.parse(raw);
31
- if (!snap || snap.version !== 1 || !snap.files)
28
+ const content = readFileSync(p, 'utf8');
29
+ // markdown 文件头部的 YAML front matter 提取元数据
30
+ const match = content.match(/^---\nroot: (.+)\nbuiltAt: (.+)\nversion: (\d+)\n---\n\n([\s\S]+)$/);
31
+ if (!match)
32
32
  return null;
33
+ const snap = {
34
+ version: parseInt(match[3]),
35
+ root: match[1],
36
+ builtAt: match[2],
37
+ content: match[4],
38
+ };
33
39
  currentSnapshot = snap;
34
40
  return snap;
35
41
  }
@@ -37,141 +43,43 @@ export function loadSnapshot() {
37
43
  return null;
38
44
  }
39
45
  }
40
- /** 构建/刷新快照:扫描静态文件 + 提取结构摘要 */
41
- export function buildSnapshot() {
46
+ /** 获取当前快照(内存缓存优先,然后磁盘) */
47
+ export function getSnapshot() {
48
+ return currentSnapshot ?? loadSnapshot();
49
+ }
50
+ export async function buildSnapshot(signal, force = false) {
51
+ // 检查缓存:已有快照且不强制刷新,直接返回
52
+ if (!force) {
53
+ const cached = getSnapshot();
54
+ if (cached)
55
+ return { snapshot: cached };
56
+ }
42
57
  const root = getSandboxRoot() ?? process.cwd();
43
- const files = scanStaticFiles(root);
44
- const structure = extractStructure(root, files);
58
+ // 动态导入避免循环依赖
59
+ const { generateLLMSnapshot } = await import('./llm-snapshot.js');
60
+ const result = await generateLLMSnapshot(root, signal);
61
+ if (!result.ok || !result.content) {
62
+ return {
63
+ snapshot: null,
64
+ error: result.error || 'LLM 未返回有效结果',
65
+ transcript: result.transcript,
66
+ };
67
+ }
45
68
  const snap = {
46
69
  version: 1,
47
70
  root,
48
71
  builtAt: new Date().toISOString(),
49
- files,
50
- structure,
72
+ content: result.content,
51
73
  };
52
- // 落盘
74
+ // 落盘为 markdown 格式,带 YAML front matter
53
75
  const dir = snapshotDir();
54
76
  mkdirSync(dir, { recursive: true });
55
- writeFileSync(snapshotPath(), JSON.stringify(snap, null, 2), 'utf8');
77
+ const mdContent = `---\nroot: ${snap.root}\nbuiltAt: ${snap.builtAt}\nversion: ${snap.version}\n---\n\n${snap.content}`;
78
+ writeFileSync(snapshotPath(), mdContent, 'utf8');
56
79
  currentSnapshot = snap;
57
- return snap;
58
- }
59
- /** 获取当前快照(优先内存 → 磁盘 → 构建) */
60
- export function getSnapshot() {
61
- return loadSnapshot() ?? buildSnapshot();
80
+ return { snapshot: snap };
62
81
  }
63
- /** 清空内存缓存(调试/测试用) */
82
+ /** 清除内存缓存(强制下次重新加载) */
64
83
  export function clearSnapshotCache() {
65
84
  currentSnapshot = null;
66
85
  }
67
- /**
68
- * 从快照中查找文件(带 mtime 校验)。
69
- * 返回 null 表示:文件不在快照中 / mtime 已变 / 快照不存在。
70
- * 调用方应 fallback 到真实 readFile。
71
- */
72
- export function lookupSnapshotFile(absPath) {
73
- const snap = loadSnapshot();
74
- if (!snap)
75
- return null;
76
- // 转成相对路径(快照 key 是相对路径)
77
- const root = snap.root;
78
- if (!absPath.startsWith(root))
79
- return null;
80
- const relPath = path.relative(root, absPath).replace(/\\/g, '/');
81
- const entry = snap.files[relPath];
82
- if (!entry)
83
- return null;
84
- // mtime 校验:磁盘上的 mtime 必须与快照一致
85
- try {
86
- const st = statSync(absPath);
87
- if (st.mtimeMs !== entry.mtime)
88
- return null;
89
- }
90
- catch {
91
- return null;
92
- }
93
- return { content: entry.content, mtime: entry.mtime };
94
- }
95
- /** 提取项目结构摘要(从静态文件 + 目录扫描) */
96
- function extractStructure(root, files) {
97
- const modules = [];
98
- const entries = [];
99
- const configFiles = [];
100
- // 配置文件:直接看 files keys
101
- for (const relPath of Object.keys(files)) {
102
- if (isConfigFile(relPath)) {
103
- configFiles.push(relPath);
104
- }
105
- }
106
- // 入口文件:从 package.json 提取
107
- const pkgEntry = files['package.json'];
108
- if (pkgEntry) {
109
- try {
110
- const pkg = JSON.parse(pkgEntry.content);
111
- if (typeof pkg.main === 'string')
112
- entries.push(pkg.main);
113
- if (typeof pkg.module === 'string')
114
- entries.push(pkg.module);
115
- if (pkg.bin) {
116
- if (typeof pkg.bin === 'string')
117
- entries.push(pkg.bin);
118
- else if (typeof pkg.bin === 'object') {
119
- for (const v of Object.values(pkg.bin)) {
120
- if (typeof v === 'string')
121
- entries.push(v);
122
- }
123
- }
124
- }
125
- }
126
- catch {
127
- // package.json 解析失败,跳过
128
- }
129
- }
130
- // 模块:扫描顶层目录(排除 node_modules, .git 等)
131
- try {
132
- const items = readdirSync(root, { withFileTypes: true });
133
- for (const item of items) {
134
- if (!item.isDirectory())
135
- continue;
136
- if (item.name.startsWith('.'))
137
- continue;
138
- if (item.name === 'node_modules')
139
- continue;
140
- if (item.name === 'dist' || item.name === 'build')
141
- continue;
142
- modules.push(item.name);
143
- }
144
- }
145
- catch {
146
- // 目录扫描失败,留空
147
- }
148
- return { modules, entries, configFiles };
149
- }
150
- /** 判断是否为配置文件 */
151
- function isConfigFile(relPath) {
152
- const configPatterns = [
153
- 'tsconfig.json',
154
- 'jsconfig.json',
155
- '.eslintrc',
156
- '.eslintrc.js',
157
- '.eslintrc.json',
158
- '.eslintrc.yml',
159
- '.prettierrc',
160
- '.prettierrc.js',
161
- '.prettierrc.json',
162
- '.env.example',
163
- 'jest.config.js',
164
- 'jest.config.ts',
165
- 'vitest.config.ts',
166
- 'vite.config.ts',
167
- 'next.config.js',
168
- 'next.config.ts',
169
- 'webpack.config.js',
170
- 'rollup.config.js',
171
- 'pyproject.toml',
172
- 'setup.py',
173
- 'go.mod',
174
- 'Cargo.toml',
175
- ];
176
- return configPatterns.some((p) => relPath === p || relPath.endsWith('/' + p));
177
- }