mocode-ai 0.7.2 → 1.0.1

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,105 @@
1
+ export const TOOL_RETRY_MAX_ATTEMPTS = 3;
2
+ export const TOOL_RETRY_BASE_MS = 250;
3
+ export const TOOL_RETRY_MAX_MS = 1_000;
4
+ export const TOOL_RETRY_TOTAL_BUDGET_MS = 15_000;
5
+ export const TOOL_RETRY_SAME_ARGS_WINDOW_MS = 60_000;
6
+ export const TOOL_RETRY_SAME_ARGS_BUDGET = 2;
7
+ const NEVER_RETRY_CODES = new Set([
8
+ 'INVALID_JSON',
9
+ 'INVALID_ARGUMENTS',
10
+ 'INVALID_TOOL_SCHEMA',
11
+ 'UNKNOWN_TOOL',
12
+ 'SANDBOX_DENIED',
13
+ 'PERMISSION_DENIED',
14
+ 'TOOL_DISABLED',
15
+ 'MODE_DENIED',
16
+ 'ABORTED',
17
+ 'EDIT_CONFLICT',
18
+ 'POSTCONDITION_FAILED',
19
+ 'PROCESS_FAILED',
20
+ 'MCP_ERROR',
21
+ ]);
22
+ const fingerprintBudgets = new Map();
23
+ const MAX_TRACKED_FINGERPRINTS = 512;
24
+ function reserveFingerprintRetry(fingerprint, now) {
25
+ let budget = fingerprintBudgets.get(fingerprint);
26
+ if (!budget || now - budget.startedAt >= TOOL_RETRY_SAME_ARGS_WINDOW_MS) {
27
+ budget = { startedAt: now, retries: 0 };
28
+ fingerprintBudgets.set(fingerprint, budget);
29
+ }
30
+ if (budget.retries >= TOOL_RETRY_SAME_ARGS_BUDGET)
31
+ return false;
32
+ budget.retries++;
33
+ if (fingerprintBudgets.size > MAX_TRACKED_FINGERPRINTS) {
34
+ const oldest = fingerprintBudgets.keys().next().value;
35
+ if (oldest)
36
+ fingerprintBudgets.delete(oldest);
37
+ }
38
+ return true;
39
+ }
40
+ function shouldRetry(outcome, capabilities) {
41
+ return capabilities.retry !== 'never' &&
42
+ outcome.status === 'error' &&
43
+ outcome.retryable === true &&
44
+ !NEVER_RETRY_CODES.has(outcome.code);
45
+ }
46
+ function backoff(attempt) {
47
+ return Math.min(TOOL_RETRY_MAX_MS, TOOL_RETRY_BASE_MS * 2 ** (attempt - 1));
48
+ }
49
+ function abortError() {
50
+ const error = new Error('Tool retry aborted');
51
+ error.name = 'AbortError';
52
+ return error;
53
+ }
54
+ function sleep(ms, signal) {
55
+ if (signal?.aborted)
56
+ return Promise.reject(abortError());
57
+ return new Promise((resolve, reject) => {
58
+ const timer = setTimeout(() => {
59
+ signal?.removeEventListener('abort', onAbort);
60
+ resolve();
61
+ }, ms);
62
+ const onAbort = () => {
63
+ clearTimeout(timer);
64
+ signal?.removeEventListener('abort', onAbort);
65
+ reject(abortError());
66
+ };
67
+ signal?.addEventListener('abort', onAbort, { once: true });
68
+ });
69
+ }
70
+ /** Retry safe/idempotent transient outcomes; each execute call owns one complete lock attempt. */
71
+ export async function executeWithToolRetry(capabilities, fingerprint, signal, execute, onRetry) {
72
+ const startedAt = Date.now();
73
+ let retryDelayMs = 0;
74
+ for (let attempt = 1; attempt <= TOOL_RETRY_MAX_ATTEMPTS; attempt++) {
75
+ const outcome = await execute(attempt);
76
+ const elapsed = Date.now() - startedAt;
77
+ const waitMs = backoff(attempt);
78
+ const canRetry = attempt < TOOL_RETRY_MAX_ATTEMPTS &&
79
+ shouldRetry(outcome, capabilities) &&
80
+ elapsed + waitMs <= TOOL_RETRY_TOTAL_BUDGET_MS &&
81
+ !signal?.aborted &&
82
+ reserveFingerprintRetry(fingerprint, Date.now());
83
+ if (!canRetry) {
84
+ return {
85
+ ...outcome,
86
+ durationMs: elapsed,
87
+ attempts: attempt,
88
+ retryDelayMs,
89
+ };
90
+ }
91
+ try {
92
+ onRetry?.({ attempt, nextAttempt: attempt + 1, waitMs, code: outcome.code });
93
+ }
94
+ catch {
95
+ // Retry telemetry is best-effort and must never change tool execution.
96
+ }
97
+ await sleep(waitMs, signal);
98
+ retryDelayMs += waitMs;
99
+ }
100
+ throw new Error('unreachable tool retry state');
101
+ }
102
+ /** Test/session reset seam; production never needs to clear the bounded TTL map. */
103
+ export function resetToolRetryBudgets() {
104
+ fingerprintBudgets.clear();
105
+ }
@@ -0,0 +1,80 @@
1
+ import Ajv from 'ajv';
2
+ import Ajv2020 from 'ajv/dist/2020.js';
3
+ const options = {
4
+ allErrors: true,
5
+ strict: false,
6
+ coerceTypes: false,
7
+ useDefaults: false,
8
+ removeAdditional: false,
9
+ validateFormats: false,
10
+ allowUnionTypes: true,
11
+ };
12
+ const draft7 = new Ajv(options);
13
+ const draft2020 = new Ajv2020(options);
14
+ const cache = new WeakMap();
15
+ function compile(schema) {
16
+ const cached = cache.get(schema);
17
+ if (cached)
18
+ return cached;
19
+ const preferred = typeof schema.$schema === 'string' && schema.$schema.includes('2020-12')
20
+ ? [draft2020, draft7]
21
+ : [draft7, draft2020];
22
+ let lastError;
23
+ for (const ajv of preferred) {
24
+ try {
25
+ const result = { valid: true, validate: ajv.compile(schema) };
26
+ cache.set(schema, result);
27
+ return result;
28
+ }
29
+ catch (error) {
30
+ lastError = error;
31
+ }
32
+ }
33
+ const result = {
34
+ valid: false,
35
+ message: lastError instanceof Error ? lastError.message : String(lastError),
36
+ };
37
+ cache.set(schema, result);
38
+ return result;
39
+ }
40
+ function formatErrors(errors) {
41
+ if (!errors?.length)
42
+ return '参数不符合 JSON Schema';
43
+ return errors.slice(0, 5).map((error) => {
44
+ const location = error.instancePath || '/';
45
+ if (error.keyword === 'required') {
46
+ const property = String(error.params.missingProperty ?? '?');
47
+ return `${location} 缺少必填字段 ${JSON.stringify(property)}`;
48
+ }
49
+ if (error.keyword === 'additionalProperties') {
50
+ const property = String(error.params.additionalProperty ?? '?');
51
+ return `${location} 含未知字段 ${JSON.stringify(property)}`;
52
+ }
53
+ return `${location} ${error.message ?? error.keyword}`;
54
+ }).join('; ');
55
+ }
56
+ /** Validate without coercing, defaulting, removing, or otherwise mutating model arguments. */
57
+ export function validateToolArguments(tool, args) {
58
+ if (!args || typeof args !== 'object' || Array.isArray(args)) {
59
+ return {
60
+ valid: false,
61
+ code: 'INVALID_ARGUMENTS',
62
+ message: '参数根节点必须是 JSON object',
63
+ };
64
+ }
65
+ const compiled = compile(tool.parameters);
66
+ if (!compiled.valid) {
67
+ return {
68
+ valid: false,
69
+ code: 'INVALID_TOOL_SCHEMA',
70
+ message: `工具 schema 无法编译: ${compiled.message}`,
71
+ };
72
+ }
73
+ if (compiled.validate(args))
74
+ return { valid: true };
75
+ return {
76
+ valid: false,
77
+ code: 'INVALID_ARGUMENTS',
78
+ message: formatErrors(compiled.validate.errors),
79
+ };
80
+ }
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 symbol = e.failed ? `${ui.red}×${ui.reset}` : `${ui.green}•${ui.reset}`;
155
+ return ` ${ui.dim}${branch}${ui.reset} ${symbol} ${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.2",
3
+ "version": "1.0.1",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {
@@ -27,6 +27,7 @@
27
27
  "prepare": "npm run build"
28
28
  },
29
29
  "dependencies": {
30
+ "ajv": "8.20.0",
30
31
  "cli-highlight": "^2.1.11",
31
32
  "dotenv": "^16.0.0",
32
33
  "fast-glob": "^3.0.0",