mocode-ai 0.6.0 → 0.6.2

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.
@@ -140,9 +140,6 @@ export async function runAgentCore(opts) {
140
140
  const { history, userInput, signal, onContextUpdate, hooks, skipRollback } = opts;
141
141
  const runtimeContextState = opts.contextState ?? contextState;
142
142
  const maxSteps = opts.maxSteps ?? config.maxSteps;
143
- // 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
144
- // 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
145
- const savedHistory = history.slice();
146
143
  // 中断还原:LLM 中途可能调 switch_mode 切了模式,abort 时连同模式一起还原回轮首。
147
144
  const savedMode = getAgentMode();
148
145
  // 本轮计时:从入口到完毕(正常 return / 达上限),供 finally 打 ✻ Worked for 摘要行。
@@ -174,6 +171,11 @@ export async function runAgentCore(opts) {
174
171
  return thrashHint(name, args, c);
175
172
  };
176
173
  history.push({ role: 'user', content: userInput });
174
+ // 中断回滚快照:push 用户消息后整段浅拷贝。abort 时 length=0;push(...saved) 还原。
175
+ // 这样中断时至少保留用户消息(及之前的历史);每步工具全部执行完毕后刷新快照,
176
+ // 保留已完成的 assistant+tool_calls+tool 结果,只丢弃当前未完成步骤的消息。
177
+ // 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
178
+ let savedHistory = history.slice();
177
179
  // drop_context 工具的上下文剔除回调:闭包捕获 history,原地剔除无关旧 tool 结果。
178
180
  // 保护由 dropContextFromHistory 内部保证:history[0](system)+ 当前轮(最后 user 及其后)永不剔除。
179
181
  // 子 agent 也在自己的 history 上操作(子 agent 独立 history);skipRollback 不影响此行为。
@@ -454,6 +456,9 @@ export async function runAgentCore(opts) {
454
456
  // 工具步末尾补一空行:与下一轮的思考 / 正文分隔(否则 ↳ 后紧接 ▎ 思考,无空行不好看;
455
457
  // 与正文→● 的 1 空行对称)。工具结果已以 \n 收尾,此处再补 \n 恰好 1 空行。
456
458
  hooks.onToolBatchEnd?.();
459
+ // 刷新中断快照:工具全部执行完毕后,history 处于一致状态(assistant+tool_calls+tool 结果完整),
460
+ // 此时中断可安全保留这些已完成的消息,只丢弃下一轮未完成的 chat() 响应。
461
+ savedHistory = history.slice();
457
462
  continue; // 带着工具结果再调一次 LLM
458
463
  }
459
464
  if (mode !== 'idle' && lastChar !== '\n')
@@ -216,7 +216,7 @@ ${PLATFORM_NOTE}
216
216
  - Batch only independent read-only calls. After their results arrive, make the dependent edit in the next turn; then batch independent edits and one final verification when their exact inputs are already known.
217
217
  - Read only what supports the next decision; verify once after a related edit set, not after every edit.
218
218
  - Do not repeat an unchanged failing call; after three unproductive attempts, change tools or ask for the missing decision.
219
- - **Don't re-read a file you already have, unless it may have changed**: if you (or an earlier step in this session) already read a file's relevant content and nothing has touched it since, edit directly from that content instead of calling read_file again "to be safe". This does NOT apply when the file was edited (by you or externally) since your last read, when a prior edit may have shifted line numbers you're about to target, or right after a compact where you're unsure the surviving context is accurate in those cases re-reading is expected and correct, not wasteful.
219
+ - For \`edit_file\`, derive \`old_string\` by copying the exact relevant lines from the latest successful \`read_file\` of that same path; never reconstruct it from memory, a summary, grep output, or a previous diff. That read becomes stale after any edit/write to the path, compaction/resume, or a possible external change. On an \`old_string\` mismatch, re-read the exact region and retry once with the newly returned text; never retry identical arguments.
220
220
 
221
221
  ## Workflow
222
222
  - Understand requirements and current code before acting; do not guess.
@@ -226,7 +226,7 @@ ${PLATFORM_NOTE}
226
226
 
227
227
  ## Tool rules
228
228
  - Precise path/symbol → go directly to \`read_file\` or \`codegraph node\`; use \`glob\`/\`grep\` only for discovery.
229
- - Before editing, confirm the relevant content unless it is already current in this conversation. Use \`edit_file\` for unique local replacements and \`write_file\` for new/full files.
229
+ - Before editing, read the exact target region and use its verbatim text as \`old_string\`. Use \`edit_file\` for unique local replacements and \`write_file\` for new/full files.
230
230
  - Local edits require an exact unique match; use \`write_file\` for new/full files.
231
231
  - Use \`glob\`/\`grep\` for discovery and \`run_command\` for execution or verification, not file existence checks. State intent before side effects.
232
232
  - Call \`ask_human\` only when a real user decision is required; otherwise decide and proceed.
@@ -88,8 +88,9 @@ export function loadSession(id) {
88
88
  }
89
89
  /** 列出最近会话,按 createdAt 降序。损坏文件跳过。
90
90
  * - limit?: 仅返回前 N 条。会话目录名是 YYYYMMDD-HHmmss,字典序=时间序;
91
- * 先按目录名降序取前 N,再解析 session.json,避免
92
- * /resume 在 sessions 目录堆了几百个子目录时 readdirSync + 全量 JSON.parse 慢。
91
+ * 按目录名降序逐个解析,收集到 N 个有效会话就停止,避免 /resume 在
92
+ * sessions 目录堆了几百个子目录时全量 JSON.parse;同时不让只有笔记或
93
+ * 快照、没有 session.json 的目录占掉最近 N 条的名额。
93
94
  * - 不传 limit 时读全部(向后兼容,供裸 --resume 列全表用)。
94
95
  * - 向后兼容:同时扫描旧式 <id>.json 文件(扁平结构),优先读新式目录。
95
96
  */
@@ -108,9 +109,11 @@ export function listSessions(limit) {
108
109
  }
109
110
  }
110
111
  const all = ids.sort().reverse(); // 降序:最新在前
111
- const toRead = typeof limit === 'number' ? all.slice(0, Math.max(0, limit)) : all;
112
+ const maxResults = typeof limit === 'number' ? Math.max(0, limit) : Infinity;
112
113
  const out = [];
113
- for (const id of toRead) {
114
+ for (const id of all) {
115
+ if (out.length >= maxResults)
116
+ break;
114
117
  try {
115
118
  // 优先新式目录,回退旧式文件
116
119
  const newPath = path.join(config.sessionDir, id, 'session.json');
@@ -3,7 +3,7 @@ import { resolve } from 'node:path';
3
3
  // ---------- edit_file ----------
4
4
  export const editFileTool = {
5
5
  name: 'edit_file',
6
- description: 'Replace a string in a file. old_string must occur exactly once and match exactly (including indentation/newlines). Use write_file for new files.',
6
+ description: 'Replace a string in a file. old_string must occur exactly once and match exactly (including indentation/newlines). Copy old_string verbatim from a fresh read_file result for this path; do not reconstruct it from memory or summaries. Use write_file for new files.',
7
7
  risk: 'confirm',
8
8
  parameters: {
9
9
  type: 'object',
@@ -29,7 +29,7 @@ export const editFileTool = {
29
29
  const normNew = newStr.replace(/\r\n/g, '\n').replace(/\r/g, '\n');
30
30
  const count = norm.split(normOld).length - 1;
31
31
  if (count === 0) {
32
- return `错误:在 ${path} 中未找到 old_string。请先 read_file 确认实际内容。`;
32
+ return `错误:在 ${path} 中未找到 old_string。不要重试相同参数;请先 read_file 读取目标区域,再从返回内容逐字复制新的 old_string 后重试。`;
33
33
  }
34
34
  if (count > 1) {
35
35
  return `错误:old_string 在 ${path} 中出现 ${count} 次,不唯一。请加入更多上下文使其唯一。`;
@@ -0,0 +1,80 @@
1
+ import { renderMarkdown } from './markdown.js';
2
+ import { ui } from './theme.js';
3
+ const records = new Map();
4
+ const absLineToId = new Map();
5
+ let liveRaw = '';
6
+ let nextId = 0;
7
+ /** 流式写入推理;首次写入会结束此前的正文 Markdown 段,确保两者能独立折叠。 */
8
+ export function writeThinking(delta, layout) {
9
+ if (!delta)
10
+ return;
11
+ if (!liveRaw)
12
+ layout.beginMarkdownSegment();
13
+ liveRaw += delta;
14
+ layout.contentWriteMd(delta);
15
+ }
16
+ /** 推理流结束:将实时 Markdown 段替换为可点击的一行摘要。 */
17
+ export function finishThinking(layout) {
18
+ if (!liveRaw)
19
+ return;
20
+ const raw = liveRaw;
21
+ liveRaw = '';
22
+ const lineCount = raw.split('\n').length;
23
+ const summary = ` ${ui.bold}${ui.accent}▸${ui.reset} ${ui.dim}Thought · ${lineCount} lines (click to expand)${ui.reset}`;
24
+ const summaryAbsIdx = layout.collapseMarkdownSegment(summary);
25
+ if (summaryAbsIdx == null)
26
+ return;
27
+ const id = `t${++nextId}`;
28
+ records.set(id, { id, summaryAbsIdx, raw, expanded: false, expandedLineCount: 0 });
29
+ absLineToId.set(summaryAbsIdx, id);
30
+ }
31
+ /** 返回指定缓冲绝对行命中的推理记录;非摘要行返回 null。 */
32
+ export function findThinkingByAbsLine(absLine) {
33
+ return absLineToId.get(absLine) ?? null;
34
+ }
35
+ /** 切换摘要行下的完整推理内容。 */
36
+ export function toggleThinking(id, layout) {
37
+ const record = records.get(id);
38
+ if (!record)
39
+ return;
40
+ if (record.expanded) {
41
+ layout.contentDeleteFrom(record.summaryAbsIdx + 1, record.expandedLineCount);
42
+ record.expanded = false;
43
+ record.expandedLineCount = 0;
44
+ return;
45
+ }
46
+ const rendered = renderMarkdown(record.raw, layout.contentCols());
47
+ const maxLines = 200;
48
+ const lines = rendered.slice(0, maxLines);
49
+ if (rendered.length > maxLines) {
50
+ lines.push(`${ui.dim} … (${rendered.length - maxLines} more lines)${ui.reset}`);
51
+ }
52
+ if (lines.length === 0)
53
+ return;
54
+ layout.contentInsertAfter(record.summaryAbsIdx, lines);
55
+ record.expanded = true;
56
+ record.expandedLineCount = lines.length;
57
+ }
58
+ /** 缓冲中段插删后同步摘要绝对行索引。 */
59
+ export function shiftThinkingsAfter(absIdx, delta) {
60
+ if (delta === 0)
61
+ return;
62
+ const next = new Map();
63
+ for (const [idx, id] of absLineToId) {
64
+ const shifted = idx > absIdx ? idx + delta : idx;
65
+ if (shifted >= 0)
66
+ next.set(shifted, id);
67
+ const record = records.get(id);
68
+ if (record && idx > absIdx)
69
+ record.summaryAbsIdx = shifted;
70
+ }
71
+ absLineToId.clear();
72
+ for (const [idx, id] of next)
73
+ absLineToId.set(idx, id);
74
+ }
75
+ /** 内容区整体清空时清掉折叠状态与尚未结束的流。 */
76
+ export function resetThinkings() {
77
+ records.clear();
78
+ absLineToId.clear();
79
+ liveRaw = '';
80
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.6.0",
3
+ "version": "0.6.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {