mocode-ai 0.7.3 → 1.0.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.
@@ -1,36 +1,71 @@
1
- import { writeFile, mkdir } from 'node:fs/promises';
2
- import { resolve, dirname } from 'node:path';
3
- import { verifyWrittenFile } from '../../verification/postconditions.js';
4
- // ---------- write_file ----------
1
+ import { commitChangeSet, createChangeSet, normalizeContentHash, summarizeChangeSet, } from '../../changeset/index.js';
2
+ function conflict(path, details) {
3
+ return {
4
+ status: 'error',
5
+ code: 'CHANGE_CONFLICT',
6
+ retryable: false,
7
+ changedFiles: [],
8
+ staleFiles: [path],
9
+ output: `CHANGE_CONFLICT: ${path} was not changed. ${details} Do not retry these arguments. Call read_file on this exact path, then use the returned hash; use expected_hash=null only if read_file reports that the path is missing.`,
10
+ };
11
+ }
5
12
  export const writeFileTool = {
6
13
  name: 'write_file',
7
- description: 'Create or overwrite a file; parent dirs created.',
14
+ description: 'Create or replace one file transactionally. expected_hash may be omitted (or null) only for create-only writes to a path that must not exist; overwriting requires the hash from a fresh read_file artifact header.',
8
15
  risk: 'confirm',
9
16
  parameters: {
10
17
  type: 'object',
11
18
  properties: {
12
19
  path: { type: 'string', description: 'File path' },
13
20
  content: { type: 'string', description: 'Full file content' },
21
+ expected_hash: {
22
+ type: ['string', 'null'],
23
+ description: 'Optional sha256 hash from read_file. Omit or pass null only when the path must not exist.',
24
+ },
14
25
  },
15
26
  required: ['path', 'content'],
16
27
  },
17
- async execute(args) {
18
- const path = String(args.path);
28
+ async execute(args, ctx) {
29
+ const file = String(args.path);
19
30
  const content = String(args.content);
20
- const full = resolve(path);
21
- await mkdir(dirname(full), { recursive: true });
22
- await writeFile(full, content, 'utf8');
23
- const postcondition = await verifyWrittenFile(full, content);
24
- if (postcondition.status === 'failed') {
31
+ let expectedHash = null;
32
+ // Missing and explicit null are both safe create-only requests. They never
33
+ // overwrite: ChangeSet compares expectedHash=null against the current path.
34
+ if (args.expected_hash != null) {
35
+ expectedHash = normalizeContentHash(String(args.expected_hash));
36
+ if (!expectedHash)
37
+ return conflict(file, 'expected_hash 必须是 null 或 sha256:<64 hex>。');
38
+ }
39
+ const operation = expectedHash === null ? 'create' : 'update';
40
+ const result = await commitChangeSet(createChangeSet([{
41
+ path: file,
42
+ operation,
43
+ expectedHash,
44
+ replacement: content,
45
+ }]), ctx?.signal);
46
+ if (result.status === 'conflict') {
47
+ const item = result.conflicts[0];
48
+ return conflict(file, `expected=${item?.expectedHash ?? 'missing'}, actual=${item?.actualHash ?? 'missing'}。请重新读取后再写入。`);
49
+ }
50
+ if (result.status === 'failed') {
25
51
  return {
26
52
  status: 'error',
27
- code: 'POSTCONDITION_FAILED',
53
+ code: 'EXECUTION_ERROR',
28
54
  retryable: false,
29
- output: postcondition.diagnostics
30
- .map((item) => `[${item.code ?? 'V0_FAILED'}] ${item.file ?? path}: ${item.message}`)
31
- .join('\n'),
55
+ changedFiles: [],
56
+ output: `错误:ChangeSet 提交失败并已执行恢复: ${result.error}`,
32
57
  };
33
58
  }
34
- return `已写入 ${path} (${content.length} 字符, sha256=${postcondition.actualHash})`;
59
+ const summary = summarizeChangeSet(result.changeSet);
60
+ return {
61
+ status: 'success',
62
+ code: 'OK',
63
+ retryable: false,
64
+ changedFiles: result.changedFiles,
65
+ changeSet: summary,
66
+ output: result.changedFiles.length === 0
67
+ ? `文件 ${file} 内容未变化 (ChangeSet ${summary.id})。`
68
+ : `已事务化写入 ${file} (${content.length} 字符, ChangeSet ${summary.id}, sha256=${summary.changes[0]?.afterHash})。`,
69
+ };
35
70
  },
36
71
  };
@@ -26,7 +26,7 @@ export const IGNORE = ['**/node_modules/**', '**/.git/**'];
26
26
  // ── plan 模式(只读规划,不执行)──────────────────────────────────────────────
27
27
  /**
28
28
  * plan 模式下从工具 schema 里剔除的工具(模型根本看不到 → 调不到):
29
- * 写盘 / 命令 / 记忆写入类 + task(派生子 agent,plan 模式只读不可有副作用)。单一事实源,
29
+ * 写盘 / 命令 / 记忆写入类 + sub-agent(派生子 agentplan 模式只读不可有副作用)。单一事实源,
30
30
  * 被 llm(planChatTools)与 agent(防御 backstop)共用。
31
31
  * 只读工具(read_file/glob/grep/codegraph/web_search/web_fetch/use_skill/ask_human/memory_search/memory_list)保留。
32
32
  */
@@ -37,7 +37,7 @@ export const PLAN_DISABLED_TOOLS = new Set([
37
37
  'memory_save',
38
38
  'memory_update',
39
39
  'memory_forget',
40
- 'task',
40
+ 'sub-agent',
41
41
  ]);
42
42
  /**
43
43
  * 按当前 isMemoryEnabled() 现算 plan 模式应屏蔽的工具。
@@ -56,5 +56,5 @@ export function getPlanDisabledTools() {
56
56
  }
57
57
  /** auto/plan 共用的运行时功能开关防线;关闭时即使模型幻觉调用也不得执行。 */
58
58
  export function getRuntimeDisabledTools() {
59
- return isSubAgentEnabled() ? new Set() : new Set(['task']);
59
+ return isSubAgentEnabled() ? new Set() : new Set(['sub-agent']);
60
60
  }
@@ -145,7 +145,9 @@ async function executeToolAttempt(tool, args, signal, opts, notifyLockAcquired)
145
145
  opts?.onLockAcquired?.(args);
146
146
  const mutationBefore = getCurrentTurnMutationState();
147
147
  mutationVersionBefore = mutationBefore.version;
148
- const pathCapture = isFileMutationTool(tool.name) && typeof args.path === 'string' && args.path
148
+ // Transactional tools own their full write-set capture inside ChangeSet commit.
149
+ const pathCapture = !capabilities.delegatesResourceLocks &&
150
+ isFileMutationTool(tool.name) && typeof args.path === 'string' && args.path
149
151
  ? beginPathMutation(args.path)
150
152
  : null;
151
153
  capturedPath = pathCapture?.path;
@@ -169,7 +171,8 @@ async function executeToolAttempt(tool, args, signal, opts, notifyLockAcquired)
169
171
  : mutationAfter.changedFiles.map((item) => item.path)
170
172
  : [];
171
173
  if (signal?.aborted) {
172
- return terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
174
+ const aborted = terminalOutcome('aborted', 'ABORTED', String(isStructuredOutcome(raw) ? raw.output : raw), startedAt, changedFiles);
175
+ return isStructuredOutcome(raw) ? { ...aborted, usage: raw.usage } : aborted;
173
176
  }
174
177
  return normalizeOutcome(raw, Date.now() - startedAt, changedFiles);
175
178
  });
package/dist/ui/batch.js CHANGED
@@ -12,6 +12,7 @@
12
12
  * 状态全模块级(单实例)——一次 REPL 内所有 batch 共享,清内容区 / 退 alt 屏时统一重置。
13
13
  */
14
14
  import { ui } from './theme.js';
15
+ import { t } from '../i18n/index.js';
15
16
  const batches = new Map();
16
17
  /** 绝对行索引 → 所属 batch id(仅记录 summary 行;用于鼠标点击反查)。
17
18
  * buffer 行数变化时本表可能漂移——但只在 insertAfter/deleteFrom 后由本模块同步更新,
@@ -36,7 +37,7 @@ export function reset() {
36
37
  /** 新建一个 batch(在 agent 拿到第一条 onToolHeader 时调)。返回 id。 */
37
38
  export function beginBatch() {
38
39
  const id = `b${++_idCounter}`;
39
- batches.set(id, { id, summaryAbsIdx: -1, entries: [], expandedEntries: new Set() });
40
+ batches.set(id, { id, summaryAbsIdx: -1, entries: [], expandedEntries: new Set(), startedAt: Date.now() });
40
41
  return id;
41
42
  }
42
43
  /** 记一条工具调用(在 onToolHeader 时调,与 setEntryResult 配对;entries 顺序 = agent 调用顺序)。 */
@@ -44,11 +45,13 @@ export function recordCall(id, name, callSummary) {
44
45
  const b = batches.get(id);
45
46
  if (!b)
46
47
  return;
48
+ // 已完成的累计探索后又追加工具:恢复进行中,待新结果返回再完成。
49
+ b.finishedAt = undefined;
47
50
  b.entries.push({ name, callSummary, resultSummary: '', diffBlock: null });
48
51
  }
49
52
  /** 记一条工具结果(diff 块或单行 preview);agent 在 onToolResult 时调,匹配最后一条未填的 entry。
50
53
  * fullOutput:工具原始完整输出(纯文本),展开时显示;mutation 工具的 diff 块已自含无需传。 */
51
- export function recordResult(id, name, resultSummary, diffBlock, fullOutput) {
54
+ export function recordResult(id, name, resultSummary, diffBlock, fullOutput, failed = false) {
52
55
  const b = batches.get(id);
53
56
  if (!b || b.entries.length === 0)
54
57
  return;
@@ -58,6 +61,9 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput) {
58
61
  b.entries[i].resultSummary = resultSummary;
59
62
  b.entries[i].diffBlock = diffBlock;
60
63
  b.entries[i].fullOutput = fullOutput;
64
+ b.entries[i].failed = failed;
65
+ if (b.entries.every((e) => e.resultSummary || e.diffBlock || e.failed))
66
+ b.finishedAt = Date.now();
61
67
  return;
62
68
  }
63
69
  }
@@ -67,19 +73,17 @@ export function recordResult(id, name, resultSummary, diffBlock, fullOutput) {
67
73
  last.resultSummary = resultSummary;
68
74
  last.diffBlock = diffBlock;
69
75
  last.fullOutput = fullOutput;
76
+ last.failed = failed;
77
+ if (b.entries.every((e) => e.resultSummary || e.diffBlock || e.failed))
78
+ b.finishedAt = Date.now();
70
79
  }
71
80
  }
72
81
  // ── 摘要行文本生成 ──
73
82
  /** 把 entry 列表压缩成一行摘要。 */
74
- function buildSummaryLine(entries) {
83
+ function buildSummaryLine(record, live = false) {
84
+ const entries = record.entries;
75
85
  if (entries.length === 0) {
76
- return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}No tools${ui.reset}`;
77
- }
78
- if (entries.length === 1) {
79
- const e = entries[0];
80
- // 实时摘要必须稳定保持单行;完整参数放在第一层工具概要中,避免长 JSON 自动折行后
81
- // 原地刷新只能覆盖最后一条物理行、残留旧摘要前半段。
82
- return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran 1 tool · ${e.name} 1${ui.reset}`;
86
+ return ` ${ui.dim}│${ui.reset} ${ui.bold}${ui.accent}◇${ui.reset} ${ui.dim}No tools${ui.reset}`;
83
87
  }
84
88
  // N>1:同类合并 "read_file 3, glob 1, grep 1"
85
89
  const counts = new Map();
@@ -88,7 +92,23 @@ function buildSummaryLine(entries) {
88
92
  const parts = [];
89
93
  for (const [n, c] of counts)
90
94
  parts.push(`${n} ${c}`);
91
- return ` ${ui.bold}${ui.accent}●${ui.reset} ${ui.dim}Ran ${entries.length} tools · ${parts.join(', ')}${ui.reset}`;
95
+ const completed = entries.filter((e) => e.resultSummary || e.diffBlock || e.failed).length;
96
+ const failed = entries.some((e) => e.failed);
97
+ // 工具本身完成就立即显示完成态,不等待整轮正文流完/onDone。
98
+ const finished = completed >= entries.length;
99
+ const symbol = failed ? '×' : finished ? '◆' : '◇';
100
+ const color = failed ? ui.red : finished ? ui.green : ui.accent;
101
+ const label = failed
102
+ ? t('agent.toolsFailed')
103
+ : finished
104
+ ? t('agent.toolsComplete')
105
+ : t('agent.toolsRunning');
106
+ const progress = live && !finished ? ` ${completed}/${entries.length}` : ` ${entries.length}`;
107
+ const elapsedMs = record.finishedAt ? record.finishedAt - record.startedAt : 0;
108
+ const elapsed = record.finishedAt
109
+ ? ` ${elapsedMs < 100 ? '<0.1s' : `${(elapsedMs / 1000).toFixed(1)}s`}`
110
+ : '';
111
+ return ` ${ui.bold}${color}${symbol}${ui.reset} ${label}${progress}${elapsed} ${ui.dim}${parts.join(' ')}${ui.reset}`;
92
112
  }
93
113
  // ── 展开/折叠 ──
94
114
  /** 把 batch 的详情行展开成自洽行数组(供 layout.contentInsertAfter 走 mid-buffer 插入)。
@@ -105,7 +125,8 @@ function buildEntryDetailLines(e, indent = ' ') {
105
125
  continue; // 折叠连续空行
106
126
  if (line === '' && lines.length > 0)
107
127
  continue; // 跳过首尾空行(diff 头/尾换行)
108
- lines.push(line.endsWith('\x1B[0m') ? line : line + '\x1B[0m');
128
+ const prefixed = `${ui.dim}${indent}${ui.reset}${line}`;
129
+ lines.push(prefixed.endsWith('\x1B[0m') ? prefixed : prefixed + '\x1B[0m');
109
130
  }
110
131
  }
111
132
  else if (e.fullOutput) {
@@ -126,24 +147,30 @@ function buildEntryDetailLines(e, indent = ' ') {
126
147
  return lines;
127
148
  }
128
149
  /** 第一层只展示有哪些调用及其简短结果,不展开完整输出。 */
129
- function buildExpandedLines(entries, indent = ' ') {
130
- return entries.map((e) => {
150
+ function buildExpandedLines(entries) {
151
+ return entries.map((e, index) => {
131
152
  const result = e.resultSummary ? ` ${ui.gray}↳ ${e.resultSummary}${ui.reset}` : '';
132
- return `${indent}${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}\x1B[0m`;
153
+ const branch = index === entries.length - 1 ? '└─' : '├─';
154
+ const failure = e.failed ? `${ui.red}×${ui.reset} ` : '';
155
+ return ` ${ui.dim}${branch}${ui.reset} ${failure}${ui.accent}${e.name}${ui.reset} ${ui.dim}${e.callSummary}${ui.reset}${result}\x1B[0m`;
133
156
  });
134
157
  }
158
+ function entryDetailIndent(entries, index) {
159
+ return index < entries.length - 1 ? ' │ ' : ' ';
160
+ }
135
161
  /** 在 batch 收尾时(onToolBatchEnd):写摘要行 + 登记 summaryAbsIdx;若已展开(回放场景)立即插详情。 */
136
162
  export function endBatch(id, layout) {
137
163
  const b = batches.get(id);
138
164
  if (!b)
139
165
  return;
166
+ b.finishedAt ??= Date.now();
140
167
  if (b.summaryAbsIdx >= 0) {
141
- layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b.entries));
168
+ layout.contentReplaceLine?.(b.summaryAbsIdx, buildSummaryLine(b));
142
169
  // 执行阶段只展示实时摘要;到 endBatch 才开放点击,避免未完成 batch 的第一层列表失步。
143
170
  absLineToBatchId.set(b.summaryAbsIdx, b.id);
144
171
  return;
145
172
  }
146
- const summary = buildSummaryLine(b.entries);
173
+ const summary = buildSummaryLine(b);
147
174
  // 写摘要行(以 \n 收尾;contentWrite 会 breakRow 让其成为完整物理行)
148
175
  layout.contentWrite(summary + '\n');
149
176
  // 摘要行绝对索引 = totalRows - 2(hasCurrent 那行是新空行)
@@ -157,7 +184,7 @@ export function showLiveBatch(id, layout) {
157
184
  const b = batches.get(id);
158
185
  if (!b)
159
186
  return;
160
- const summary = buildSummaryLine(b.entries);
187
+ const summary = buildSummaryLine(b, true);
161
188
  if (b.summaryAbsIdx < 0) {
162
189
  layout.contentWrite(summary + '\n');
163
190
  b.summaryAbsIdx = Math.max(0, layout.totalRows() - 2);
@@ -211,7 +238,7 @@ export function expandSingleEntryFully(id, layout) {
211
238
  return;
212
239
  const lines = [
213
240
  ...buildExpandedLines(b.entries),
214
- ...buildEntryDetailLines(b.entries[0]),
241
+ ...buildEntryDetailLines(b.entries[0], entryDetailIndent(b.entries, 0)),
215
242
  ];
216
243
  layout.contentInsertAfter(b.summaryAbsIdx, lines);
217
244
  expandedBatches.add(id);
@@ -220,8 +247,9 @@ export function expandSingleEntryFully(id, layout) {
220
247
  }
221
248
  function collapse(b, layout) {
222
249
  let lineCount = b.entries.length;
223
- for (const i of b.expandedEntries)
224
- lineCount += buildEntryDetailLines(b.entries[i]).length;
250
+ for (const i of b.expandedEntries) {
251
+ lineCount += buildEntryDetailLines(b.entries[i], entryDetailIndent(b.entries, i)).length;
252
+ }
225
253
  layout.contentDeleteFrom(b.summaryAbsIdx + 1, lineCount);
226
254
  expandedBatches.delete(b.id);
227
255
  b.expandedEntries.clear();
@@ -246,7 +274,7 @@ export function toggleEntry(batchId, entryIndex, layout) {
246
274
  }
247
275
  if (headerIdx < 0)
248
276
  return;
249
- const details = buildEntryDetailLines(b.entries[entryIndex]);
277
+ const details = buildEntryDetailLines(b.entries[entryIndex], entryDetailIndent(b.entries, entryIndex));
250
278
  if (details.length === 0)
251
279
  return;
252
280
  if (b.expandedEntries.has(entryIndex)) {
@@ -303,8 +331,8 @@ export function shiftBatchesAfter(absIdx, delta) {
303
331
  }
304
332
  }
305
333
  // ── history 回放支持 ──
306
- /** 把已构造好的 BatchEntry[] 直接落成摘要行(用于 renderHistory 回放;不记录 id 也不需可切换)。
307
- * 含 mutation(write_file/edit_file)时整批展开——与实时 endBatch 行为一致。 */
334
+ /** 把已构造好的 BatchEntry[] 落成可切换摘要行(用于 renderHistory 回放)。
335
+ * 含 mutation(write_file/edit_file)时整批展开;普通批次保留与实时 flushToolBatch 相同的空行边界。 */
308
336
  export function writeSummaryOnly(entries, layout) {
309
337
  const id = beginBatch();
310
338
  const b = batches.get(id);
@@ -315,5 +343,9 @@ export function writeSummaryOnly(entries, layout) {
315
343
  if (entries.length === 1 && isMutationToolName(entries[0].name)) {
316
344
  expandSingleEntryFully(id, layout);
317
345
  }
346
+ else {
347
+ // endBatch 已用一个换行结束摘要;再提交当前空行,避免下一段 assistant 正文紧贴工具结果。
348
+ layout.contentWrite('\n');
349
+ }
318
350
  }
319
351
  let _idCounter = 0;
package/dist/ui/layout.js CHANGED
@@ -3,7 +3,7 @@ import { charWidth, displayWidth, truncateDisplay, truncateDisplayHead, truncate
3
3
  import { ui, applyTerminalBackground, resetTerminalBackground } from './theme.js';
4
4
  import * as content from './content.js';
5
5
  import * as mouse from './mouse.js';
6
- import { shiftBatchesAfter } from './batch.js';
6
+ import { reset as resetBatches, shiftBatchesAfter } from './batch.js';
7
7
  import { copyToClipboard, readClipboard } from './clipboard.js';
8
8
  import { renderMarkdown } from './markdown.js';
9
9
  import { t } from '../i18n/index.js';
@@ -604,6 +604,10 @@ export function normalizeMutationBoundary() {
604
604
  if (scrollOffset === 0)
605
605
  repaintViewport();
606
606
  }
607
+ /** 命令/Agent 输出→下一条输入气泡前,统一保留恰好一条视觉空行。 */
608
+ export function normalizeInputBoundary() {
609
+ normalizeMutationBoundary();
610
+ }
607
611
  /** 原地刷新一条内容行(行数不变),用于运行中的工具 batch 更新计数。 */
608
612
  export function contentReplaceLine(absIdx, line) {
609
613
  if (!active)
@@ -616,8 +620,9 @@ export function contentReplaceLine(absIdx, line) {
616
620
  }
617
621
  /** 清空内容区时通知 batch 渲染器重置(摘要行映射与展开态)。 */
618
622
  export function notifyContentReset() {
619
- // 动态 import 避免循环;模块级 reset() 只清映射,不动 batch 内部数据(id 与 entries 仍可重用)
620
- void import('./batch.js').then((m) => m.reset());
623
+ // 必须同步清理:clearContent() 后调用方会立即 renderHistory() 重建 batch
624
+ // 若异步 reset,旧清理会在回放完成后反过来抹掉新摘要的点击映射。
625
+ resetBatches();
621
626
  }
622
627
  // ── viewport 滚动回看(Phase 2)──
623
628
  /** 是否处于滚动回看态(offset>0,内容区显历史)。prompt 据此在非滚动键时回尾。 */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.7.3",
3
+ "version": "1.0.2",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {