mocode-ai 0.1.2 → 0.1.4

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.
@@ -4,14 +4,15 @@ import { stdin, stdout } from 'node:process';
4
4
  import { config } from '../config/index.js';
5
5
  import { runAgent } from '../agent/index.js';
6
6
  import { ui } from '../ui/theme.js';
7
- import { bannerString, displayWidth, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
7
+ import { bannerString, displayWidth, padEndDisplay, summarizeToolCall, summarizeToolResult } from '../ui/render.js';
8
8
  import * as layout from '../ui/layout.js';
9
- import { promptWithSlashMenu, promptTurnPicker } from '../ui/prompt.js';
9
+ import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, } from '../ui/prompt.js';
10
10
  import { tools } from '../tools/registry.js';
11
11
  import { estimateMessagesTokens, estimateToolSchemaTokens, } from '../llm/index.js';
12
12
  import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
13
13
  import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
14
14
  import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
15
+ import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
15
16
  /**
16
17
  * readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
17
18
  * 颜色码会让光标错位、编辑时漂移。颜色只用在直接 stdout.write 的横幅 / 工具行 / 回复。
@@ -27,7 +28,39 @@ const SLASH_COMMANDS = [
27
28
  { name: '/resume', desc: '续接已保存的会话' },
28
29
  { name: '/think', desc: '展开折叠思考段(/think N)' },
29
30
  { name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
31
+ { name: '/memory', desc: '记忆库:条目计数与近期索引' },
32
+ { name: '/reflect', desc: '手动触发后台记忆反思 pass' },
33
+ { name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆' },
30
34
  ];
35
+ /**
36
+ * /init 指令:发给 agent 扫描项目并生成 MOCODE.md(对标 Claude Code /init 生成 CLAUDE.md,
37
+ * 但 mocode 读 MOCODE.md)。已存在则让 agent 读后更新(不丢失事实)。写完供 memory 子系统下轮加载。
38
+ */
39
+ const INIT_PROMPT = `分析当前项目(process.cwd()),生成 MOCODE.md 项目记忆文件,供 mocode 后续会话自动加载——目标是让后续会话无需重新摸索就能上手。
40
+
41
+ 先探查(尽量少调用拿全貌):
42
+ - read_file package.json(或 Cargo.toml/pyproject.toml/go.mod 等):scripts、依赖、入口、模块类型。
43
+ - glob 顶层目录;read_file 入口文件 + 各子系统 index.ts/README。
44
+ - 若有 .codegraph/:run_command codegraph explore "<架构或入口符号>" 一次拿相关源码+调用路径,别逐文件读。
45
+ - 若 MOCODE.md 已存在:read_file 读它,在其基础上更新(补缺、修正过时),不丢已有准确事实。
46
+
47
+ MOCODE.md 按以下结构写(每节简短,只写稳定、非显然的事实):
48
+ ## 项目
49
+ 一两句:是什么、技术栈、运行环境。
50
+ ## 命令
51
+ install / dev / build / test / typecheck / lint 等——从 package.json scripts 提炼,写原样命令行(如 \`npm run typecheck\`);没有的注明"无测试"/"无 lint"。
52
+ ## 目录结构
53
+ 顶层各目录与子系统职责,一句话/个;不逐文件列。
54
+ ## 约定
55
+ 从代码与现有文档提炼的硬约定:模块系统(ESM?)、命名、错误处理、工具/函数契约、易踩坑点。只写非显然、会让人踩坑的;不写"保持简洁"这种正确废话。
56
+ ## 扩展点
57
+ 加工具/命令/provider/模块的接缝(改哪个文件、加在哪)。
58
+
59
+ 硬要求:
60
+ - 从实际代码提炼,引用具体文件名/命令/符号;不编造、不泛泛。
61
+ - 总长 ≤ 1500 字;只写后续会话有用的稳定事实,不写易变项(当前 bug、临时文件、未决 TODO)。
62
+ - 用 write_file 写入项目根 MOCODE.md。
63
+ - 写完简述:写了哪几节 + 从代码里发现的 2-3 条非显然关键约定(供用户校验)。`;
31
64
  /** 临时 readline 读一行(cooked,用于子提问;主输入走 promptWithSlashMenu)。 */
32
65
  async function askLine(prompt) {
33
66
  const rl = readline.createInterface({ input: stdin, output: stdout });
@@ -84,10 +117,14 @@ function runningStateFor(cmd) {
84
117
  return { status: '续接', placeholder: '选择会话…' };
85
118
  case '/rollback':
86
119
  return { status: '回滚', placeholder: '选择轮次…' };
120
+ case '/init':
121
+ return { status: '初始化', placeholder: '生成 MOCODE.md…' };
87
122
  case '/clear':
88
123
  return { status: '清空', placeholder: '…' };
89
124
  default:
90
- return { status: '处理', placeholder: '思考中… Ctrl+C 中断' };
125
+ // 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由状态行 spinner 承载,
126
+ // 不再把「思考中… Ctrl+C 中断」塞进输入框占位——避免看起来像已有输入、妨碍正常输入。
127
+ return { status: '处理', placeholder: '' };
91
128
  }
92
129
  }
93
130
  const emitter = stdin;
@@ -173,14 +210,30 @@ function stopRunningListener() {
173
210
  emitter.off('keypress', onRunningKey);
174
211
  currentAbort = null;
175
212
  }
213
+ /**
214
+ * 把用户消息格式化为带满宽背景色的文本(上滑时易辨认用户消息)。
215
+ * 每行用 padEndDisplay 填充到终端宽度(含 ❯ / 缩进),背景色 SGR 包裹整行 + 行末 reset。
216
+ * 满宽 pad 使终端背景色覆盖整行(含行尾空单元格),上滑滚动时用户消息呈连续色块、与 assistant 正文区分。
217
+ */
218
+ function formatUserMessage(lines) {
219
+ const cols = layout.getGeo().cols;
220
+ const promptW = displayWidth(PROMPT);
221
+ const indent = ' '.repeat(promptW);
222
+ const { userBg, reset } = ui;
223
+ return (lines
224
+ .map((l, i) => {
225
+ const prefix = i === 0 ? PROMPT : indent;
226
+ const full = prefix + l;
227
+ const padded = padEndDisplay(full, cols);
228
+ return `${userBg}${padded}${reset}`;
229
+ })
230
+ .join('\n') + '\n');
231
+ }
176
232
  /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
177
233
  function echoInput(lines) {
178
234
  if (!layout.isActive())
179
235
  return;
180
- const indent = ' '.repeat(displayWidth(PROMPT));
181
- const echo = lines.map((l, i) => (i === 0 ? `${PROMPT}${l}` : `${indent}${l}`)).join('\n') +
182
- '\n';
183
- layout.contentWrite(echo);
236
+ layout.contentWrite(formatUserMessage(lines));
184
237
  }
185
238
  /** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
186
239
  function textOf(c) {
@@ -202,16 +255,13 @@ function textOf(c) {
202
255
  * 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
203
256
  */
204
257
  export function renderHistory(history) {
205
- const indent = ' '.repeat(displayWidth(PROMPT));
206
258
  const idToName = new Map();
207
259
  for (const m of history) {
208
260
  if (m.role === 'system')
209
261
  continue;
210
262
  if (m.role === 'user') {
211
263
  const lines = textOf(m.content).split('\n');
212
- layout.contentWrite(lines
213
- .map((l, i) => (i === 0 ? `${PROMPT}${l}` : `${indent}${l}`))
214
- .join('\n') + '\n');
264
+ layout.contentWrite(formatUserMessage(lines));
215
265
  continue;
216
266
  }
217
267
  if (m.role === 'assistant') {
@@ -252,7 +302,7 @@ export function renderHistory(history) {
252
302
  export async function startRepl(initialHistory, sessionId, updateNotice = null) {
253
303
  // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
254
304
  // 否则新会话只塞 system 提示。
255
- const systemPrompt = effectiveSystemPrompt(config.systemPrompt);
305
+ const systemPrompt = effectiveSystemPrompt(config.systemPrompt + buildMemorySection() + buildMemoryIndexSection());
256
306
  const history = initialHistory && initialHistory.length
257
307
  ? initialHistory
258
308
  : [{ role: 'system', content: systemPrompt }];
@@ -267,6 +317,8 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
267
317
  rebuildFromHistory(history);
268
318
  }
269
319
  let currentSessionId = sessionId;
320
+ // 反思 cadence 计数:每 reflectEveryN 轮 fire-and-forget 一次后台反思 pass。
321
+ let turnCount = 0;
270
322
  // 本会话累积的折叠思考段,供 /think N 重打原文。
271
323
  const collapsedThinkings = [];
272
324
  const toolsLine = tools.map((t) => t.name).join(' · ');
@@ -368,6 +420,13 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
368
420
  while (true) {
369
421
  // INPUT 态:画底栏输入框 + 状态行,光标入输入框
370
422
  refreshStatusBase(history);
423
+ // 后台反思若已完成(上轮 fire-and-forget),在安全点 flush 一行 dim 摘要:
424
+ // 不在 RUNNING 态写——防与正在跑的 agent 争屏(contentWrite / 状态行)。
425
+ const reflectRes = getLastReflectResult();
426
+ if (reflectRes) {
427
+ layout.contentWrite(` ${ui.gray}↳ ${formatReflectResult(reflectRes)}${ui.reset}\n`);
428
+ clearLastReflectResult();
429
+ }
371
430
  layout.enterInputMode('空闲');
372
431
  let input = null;
373
432
  try {
@@ -389,7 +448,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
389
448
  runningInput = ''; // 预填已消费,清空(下轮运行态从空开始)
390
449
  if (input === null)
391
450
  break; // 空 prompt Ctrl+D
392
- const joined = input.join('\n');
451
+ let joined = input.join('\n');
393
452
  const line = joined.trim();
394
453
  if (!line)
395
454
  continue;
@@ -401,11 +460,16 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
401
460
  const { status, placeholder } = runningStateFor(cmd);
402
461
  refreshStatusBase(history);
403
462
  layout.enterRunningMode(status, placeholder);
463
+ if (line === '/init') {
464
+ // /init:把 init 指令当 user 输入发给 agent(扫描项目 + 生成 MOCODE.md),fall through 走 runAgent
465
+ joined = INIT_PROMPT;
466
+ }
404
467
  if (line === '/clear') {
405
468
  history.length = 1; // 保留 system 提示
406
469
  collapsedThinkings.length = 0; // 同步清空折叠的思考段
407
470
  resetState(); // 同步清空回滚轮次/快照
408
471
  currentSessionId = undefined; // 下轮起新会话文件
472
+ turnCount = 0; // 反思 cadence 重新计数
409
473
  contextState.lastUsage = undefined;
410
474
  layout.clearContent();
411
475
  layout.contentWrite(bannerString(banner()));
@@ -416,6 +480,35 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
416
480
  layout.contentWrite(` ${renderContextBar(history)}\n`);
417
481
  continue;
418
482
  }
483
+ if (line === '/memory') {
484
+ // 记忆库概览:计数 + 近期 active 索引(详情用 memory_search)。
485
+ const all = loadAll();
486
+ const active = all.filter((e) => e.status === 'active');
487
+ const archived = all.filter((e) => e.status === 'archived').length;
488
+ const byType = {};
489
+ for (const e of active)
490
+ byType[e.type] = (byType[e.type] || 0) + 1;
491
+ layout.contentWrite(`${ui.dim}记忆库:active ${active.length}${archived ? ` · archived ${archived}` : ''}${ui.reset}\n`);
492
+ if (Object.keys(byType).length) {
493
+ layout.contentWrite(`${ui.dim}按类:${Object.entries(byType).map(([t, n]) => `${t} ${n}`).join(' ')}${ui.reset}\n`);
494
+ }
495
+ const recent = [...active]
496
+ .sort((a, b) => (b.updatedAt || '').localeCompare(a.updatedAt || ''))
497
+ .slice(0, 10);
498
+ for (const e of recent) {
499
+ layout.contentWrite(` ${ui.cyan}${e.id}${ui.reset} ${ui.dim}${e.name} — ${e.summary}${ui.reset}\n`);
500
+ }
501
+ if (active.length === 0)
502
+ layout.contentWrite(`${ui.dim}(无 active 记忆;用 memory_save 存,或 /init 生成 MOCODE.md)${ui.reset}\n`);
503
+ layout.contentWrite(`${ui.dim}(详情用 memory_search;启动索引已注入 systemPrompt)${ui.reset}\n`);
504
+ continue;
505
+ }
506
+ if (line === '/reflect') {
507
+ // 手动触发后台反思 pass(不等;完成后下次 INPUT 态显摘要)。
508
+ kickoffReflection(snapshotTranscript(history, 20));
509
+ layout.contentWrite(`${ui.dim}(反思已触发,后台进行;完成后下次输入态显示摘要。日志见 .mocode/memory.log)${ui.reset}\n`);
510
+ continue;
511
+ }
419
512
  if (line === '/skills') {
420
513
  const skills = listSkills();
421
514
  if (skills.length === 0) {
@@ -445,25 +538,28 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
445
538
  continue;
446
539
  }
447
540
  if (line === '/resume') {
541
+ // /resume:打开会话菜单(↑/↓ 选,Enter 续接,Esc 取消)。默认仅最近 10 条,按 a 展开全部。
542
+ // 仿 /rollback 菜单化(promptSessionPicker);选中项 cyan+bold + ▸ 高亮。
448
543
  const sessions = listSessions();
449
544
  if (sessions.length === 0) {
450
545
  layout.contentWrite(`${ui.dim}(没有已保存的会话)${ui.reset}\n`);
451
546
  continue;
452
547
  }
453
- sessions.forEach((s, i) => {
454
- layout.contentWrite(` ${ui.dim}${i + 1}${ui.reset} ${s.id} ${ui.cyan}${s.firstUser || '(无)'}${ui.reset} ${ui.dim}${s.model}${ui.reset}\n`);
455
- });
456
- let pick = '';
548
+ const items = sessions.map((s) => ({
549
+ id: s.id,
550
+ title: s.firstUser || '(无)',
551
+ subtitle: `${s.id} ${s.model}`,
552
+ }));
553
+ let pick;
457
554
  try {
458
- pick = (await askLine('序号(回车取消): ')).trim();
555
+ pick = await promptSessionPicker(items);
459
556
  }
460
557
  catch {
461
- continue;
558
+ continue; // Ctrl+C(SIGINT)→ 取消
462
559
  }
463
- const idx = Number(pick);
464
- if (!pick || !Number.isInteger(idx) || idx < 1 || idx > sessions.length)
465
- continue;
466
- const loaded = loadSession(sessions[idx - 1].id);
560
+ if (!pick)
561
+ continue; // Esc / Ctrl+D 取消
562
+ const loaded = loadSession(pick.id);
467
563
  if (!loaded || !loaded.history.length) {
468
564
  layout.contentWrite(`${ui.yellow}(加载失败)${ui.reset}\n`);
469
565
  continue;
@@ -510,7 +606,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
510
606
  }
511
607
  try {
512
608
  const signal = startRunningListener(placeholder);
513
- await runAgent(history, joined, collapsedThinkings, signal);
609
+ // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
610
+ // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
611
+ await runAgent(history, joined, collapsedThinkings, signal, () => {
612
+ refreshStatusBase(history);
613
+ layout.drawStatusBar();
614
+ });
514
615
  // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
515
616
  if (!currentSessionId)
516
617
  currentSessionId = newSessionId();
@@ -521,6 +622,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
521
622
  // 落盘失败不阻断 REPL
522
623
  }
523
624
  persistSnapshots(currentSessionId); // 随会话落盘回滚快照(/resume 后仍可撤销)
625
+ // 后台反思:每 reflectEveryN 轮 fire-and-forget 一次(与下一轮 agent 并发,不阻塞)。
626
+ // 已有在飞任务 / autoReflect 关 → kickoff 内部自守卫。快照同步取(避免下一轮 mutate history 竞态)。
627
+ turnCount++;
628
+ if (turnCount % config.reflectEveryN === 0) {
629
+ kickoffReflection(snapshotTranscript(history, 20));
630
+ }
524
631
  }
525
632
  catch (e) {
526
633
  layout.contentWrite(`${ui.red}[错误]${ui.reset} ${e instanceof Error ? e.message : String(e)}\n`);
@@ -530,5 +637,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null)
530
637
  }
531
638
  layout.contentWrite('\n'); // 轮次之间空行
532
639
  }
640
+ // 退出前等在飞反思收尾(Ctrl+C 走 SIGINT 直退不等;fire-and-forget 不承诺中断时完成)。
641
+ await drainMemoryBackground();
533
642
  layout.exitAltScreen();
534
643
  }
@@ -1,6 +1,6 @@
1
1
  import { chat, estimateMessagesTokens, estimateToolSchemaTokens, estimateTokens, } from '../llm/index.js';
2
2
  import { config } from '../config/index.js';
3
- import { MAX_HISTORY_RESULT, MAX_OLD_TOOL_STUB, MAX_SKILL_RESULT } from '../tools/constants.js';
3
+ import { MAX_HISTORY_RESULT, MAX_MEMORY_RESULT, MAX_OLD_TOOL_STUB, MAX_SKILL_RESULT } from '../tools/constants.js';
4
4
  import { ui } from '../ui/theme.js';
5
5
  import { Spinner } from '../ui/spinner.js';
6
6
  import * as layout from '../ui/layout.js';
@@ -40,6 +40,17 @@ export function capToolResultForHistory(name, output) {
40
40
  return marker.slice(0, MAX_SKILL_RESULT);
41
41
  return output.slice(0, remain) + marker;
42
42
  }
43
+ if (name === 'memory_search') {
44
+ // 召回的记忆正文是高价值事实/指令,中截会劈断语义;同 use_skill 走尾截(保头部、弃尾部)。
45
+ if (output.length <= MAX_MEMORY_RESULT)
46
+ return output;
47
+ const removed = output.length - MAX_MEMORY_RESULT;
48
+ const marker = `…[记忆结果过长,已截断尾部 ${removed} 字符]…`;
49
+ const remain = MAX_MEMORY_RESULT - marker.length;
50
+ if (remain <= 0)
51
+ return marker.slice(0, MAX_MEMORY_RESULT);
52
+ return output.slice(0, remain) + marker;
53
+ }
43
54
  if (output.length <= MAX_HISTORY_RESULT)
44
55
  return output;
45
56
  return truncateMid(output, MAX_HISTORY_RESULT);
@@ -194,7 +205,12 @@ export async function compactHistory(history, opts) {
194
205
  }
195
206
  return noop;
196
207
  }
197
- // 第一层:微压缩——旧区 tool 结果原地截短(保 tool_call_id,无 LLM 调用)
208
+ // 第一层:微压缩——旧区原地截短(保 tool_call_id,无 LLM 调用)
209
+ // 覆盖三类大字段,均只裁模型/工具产物,不动 user 原话与 system(摘要):
210
+ // ① tool 结果 content;
211
+ // ② 旧 assistant 的 tool_calls.arguments —— 保 JSON 合法:整体超长才进,
212
+ // 逐字段值裁中截后重新 stringify(不裸中截会劈断 JSON、严格后端 400);parse 失败跳过;
213
+ // ③ 旧 assistant 正文 content(模型长解释,回看价值低)。
198
214
  let microcompactDone = false;
199
215
  for (const g of oldGroups) {
200
216
  for (const t of g.tools) {
@@ -204,6 +220,38 @@ export async function compactHistory(history, opts) {
204
220
  microcompactDone = true;
205
221
  }
206
222
  }
223
+ const as = g.assistant;
224
+ if (as && as.role === 'assistant') {
225
+ // ③ 旧正文 content
226
+ if (typeof as.content === 'string' && as.content.length > MAX_OLD_TOOL_STUB) {
227
+ as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
228
+ microcompactDone = true;
229
+ }
230
+ // ② tool_calls 参数:整体超长才进,逐字段裁值,保 JSON 合法
231
+ if (Array.isArray(as.tool_calls)) {
232
+ for (const tc of as.tool_calls) {
233
+ const args = tc?.function?.arguments;
234
+ if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
235
+ continue;
236
+ try {
237
+ const parsed = JSON.parse(args);
238
+ if (parsed && typeof parsed === 'object') {
239
+ for (const k of Object.keys(parsed)) {
240
+ const v = parsed[k];
241
+ if (typeof v === 'string' && v.length > MAX_OLD_TOOL_STUB) {
242
+ parsed[k] = truncateMid(v, MAX_OLD_TOOL_STUB);
243
+ }
244
+ }
245
+ tc.function.arguments = JSON.stringify(parsed);
246
+ microcompactDone = true;
247
+ }
248
+ }
249
+ catch {
250
+ // arguments 非合法 JSON(模型偶发):不裁,沿用「调度器永不抛错」
251
+ }
252
+ }
253
+ }
254
+ }
207
255
  }
208
256
  // 第二层:摘要——把旧区(微压缩后)压成一条 system 摘要
209
257
  const older = flattenGroups(oldGroups);
@@ -0,0 +1,50 @@
1
+ import { promptIntervention } from '../../ui/intervention.js';
2
+ // ---------- ask_human ----------
3
+ export const askHumanTool = {
4
+ name: 'ask_human',
5
+ description: [
6
+ '当你在执行任务中遇到需要人类做决策的岔路时调用此工具,在终端弹出问题面板让用户选择。',
7
+ '适用:多种实现方案需要用户拍板、不确定用户意图需要澄清、需要用户提供额外信息才能继续。',
8
+ '调用后阻塞等待用户响应;用户可挑预设选项,也可选"自定义输入"自由作答,结果作为工具返回值返回。',
9
+ '不要在任务明确、可自行决定时频繁调用——会打断用户。options 省略或为空时改为自由文本输入。',
10
+ ].join(''),
11
+ parameters: {
12
+ type: 'object',
13
+ properties: {
14
+ question: {
15
+ type: 'string',
16
+ description: '要问用户的问题,简明扼要(显示为面板标题)',
17
+ },
18
+ options: {
19
+ type: 'array',
20
+ items: { type: 'string' },
21
+ description: '供用户选择的选项(2~6 个)。可省略——省略或为空时改为自由文本输入',
22
+ },
23
+ context: {
24
+ type: 'string',
25
+ description: '问题的背景说明(可选,帮助用户理解为何需要他决策;显示在标题下,可多行)',
26
+ },
27
+ },
28
+ required: ['question'],
29
+ },
30
+ async execute(args) {
31
+ const question = String(args.question ?? '');
32
+ const options = Array.isArray(args.options)
33
+ ? args.options.map((o) => String(o))
34
+ : [];
35
+ const context = args.context ? String(args.context) : undefined;
36
+ const result = await promptIntervention({
37
+ type: options.length > 0 ? 'choice' : 'input',
38
+ title: question,
39
+ options: options.length > 0 ? options : undefined,
40
+ detail: context,
41
+ });
42
+ if (result.action === 'cancelled') {
43
+ return '用户取消了选择。请考虑是否有不依赖用户输入的替代方案,或换个角度重新提问。';
44
+ }
45
+ if (result.action === 'submitted') {
46
+ return `用户回答:${result.value ?? ''}`;
47
+ }
48
+ return `用户选择:${result.value ?? ''}`;
49
+ },
50
+ };
@@ -7,6 +7,12 @@ import { grepTool } from './grep.js';
7
7
  import { webSearchTool } from './web-search.js';
8
8
  import { webFetchTool } from './web-fetch.js';
9
9
  import { useSkillTool } from './use-skill.js';
10
+ import { askHumanTool } from './ask-human.js';
11
+ import { memorySaveTool } from './memory-save.js';
12
+ import { memorySearchTool } from './memory-search.js';
13
+ import { memoryListTool } from './memory-list.js';
14
+ import { memoryUpdateTool } from './memory-update.js';
15
+ import { memoryForgetTool } from './memory-forget.js';
10
16
  /**
11
17
  * 所有内置工具,按注册顺序排列。
12
18
  * 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。
@@ -21,4 +27,10 @@ export const builtinTools = [
21
27
  webSearchTool,
22
28
  webFetchTool,
23
29
  useSkillTool,
30
+ askHumanTool,
31
+ memorySaveTool,
32
+ memorySearchTool,
33
+ memoryListTool,
34
+ memoryUpdateTool,
35
+ memoryForgetTool,
24
36
  ];
@@ -0,0 +1,32 @@
1
+ import { forgetEntry } from '../../memory/store.js';
2
+ // ---------- memory_forget ----------
3
+ // 遗忘:默认归档(archived,从索引/默认搜索隐藏,可复活);mode=delete 硬删。pinned 拒删。
4
+ export const memoryForgetTool = {
5
+ name: 'memory_forget',
6
+ description: '遗忘一条记忆:默认归档(archived,从索引/默认搜索隐藏,可经 memory_update 复活);mode=delete 硬删。pinned 条目拒删(先 memory_update pinned=false 解钉)。',
7
+ parameters: {
8
+ type: 'object',
9
+ properties: {
10
+ id: { type: 'string' },
11
+ mode: {
12
+ type: 'string',
13
+ enum: ['archive', 'delete'],
14
+ description: '默认 archive',
15
+ },
16
+ },
17
+ required: ['id'],
18
+ },
19
+ async execute(args) {
20
+ const id = String(args.id ?? '').trim();
21
+ if (!id)
22
+ return '错误:缺少 id。';
23
+ const mode = args.mode === 'delete' ? 'delete' : 'archive';
24
+ const r = forgetEntry(id, mode);
25
+ if (r.ok)
26
+ return `已${mode === 'delete' ? '硬删' : '归档'}记忆 [${id}]。`;
27
+ if ('notFound' in r)
28
+ return `错误:找不到记忆 id="${id}"。`;
29
+ // pinned
30
+ return `错误:[${id}] 已钉住,拒绝遗忘。先 memory_update(id="${id}", pinned=false) 解钉。`;
31
+ },
32
+ };
@@ -0,0 +1,34 @@
1
+ import { listEntries } from '../../memory/store.js';
2
+ // ---------- memory_list ----------
3
+ // 列索引(id/name/summary,无正文、不 bump recall)。用于浏览有哪些、拿 id 再 memory_search 取正文。
4
+ export const memoryListTool = {
5
+ name: 'memory_list',
6
+ description: '列出记忆索引(id/name/summary,不含正文)。默认列 active。拿 id 再 memory_search 取正文。',
7
+ parameters: {
8
+ type: 'object',
9
+ properties: {
10
+ type: {
11
+ type: 'string',
12
+ enum: ['decision', 'fact', 'pitfall', 'reference', 'feedback'],
13
+ },
14
+ status: {
15
+ type: 'string',
16
+ enum: ['active', 'superseded', 'archived', 'any'],
17
+ description: '默认 active',
18
+ },
19
+ },
20
+ },
21
+ async execute(args) {
22
+ const items = listEntries({
23
+ type: typeof args.type === 'string' ? args.type : undefined,
24
+ status: typeof args.status === 'string'
25
+ ? args.status
26
+ : undefined,
27
+ });
28
+ if (items.length === 0)
29
+ return '(无记忆条目)';
30
+ return items
31
+ .map((i) => `- ${i.id}: ${i.name} — ${i.summary} (${i.type}, ${i.status})`)
32
+ .join('\n');
33
+ },
34
+ };
@@ -0,0 +1,51 @@
1
+ import { saveEntry } from '../../memory/store.js';
2
+ // ---------- memory_save ----------
3
+ // 存一条长期记忆(跨会话)。启动只把标题/摘要注入索引(几百 token);详情按需 memory_search 取。
4
+ // 撞库(name→id 已存在)拒绝,引导用 memory_update。
5
+ export const memorySaveTool = {
6
+ name: 'memory_save',
7
+ description: '保存一条长期记忆(跨会话)。只存非显然、长期有用的事实/决策/坑。启动时标题进索引,详情按需 memory_search 取。',
8
+ parameters: {
9
+ type: 'object',
10
+ properties: {
11
+ name: { type: 'string', description: '简短唯一标题(转成 id,撞库会拒绝)' },
12
+ summary: { type: 'string', description: '一句话摘要(进索引,尽量短)' },
13
+ body: { type: 'string', description: '完整内容(细节/上下文/证据)' },
14
+ type: {
15
+ type: 'string',
16
+ enum: ['decision', 'fact', 'pitfall', 'reference', 'feedback'],
17
+ description: '分类,默认 fact',
18
+ },
19
+ pinned: { type: 'boolean', description: '钉住(豁免自动遗忘衰减),默认 false' },
20
+ scope: {
21
+ type: 'string',
22
+ enum: ['project', 'global'],
23
+ description: '存到项目级(<cwd>/.mocode/)还是全局(~/.mocode/),默认 project',
24
+ },
25
+ },
26
+ required: ['name', 'summary', 'body'],
27
+ },
28
+ async execute(args) {
29
+ const name = String(args.name ?? '').trim();
30
+ const summary = String(args.summary ?? '').trim();
31
+ const body = String(args.body ?? '').trim();
32
+ if (!name)
33
+ return '错误:缺少 name。';
34
+ if (!summary)
35
+ return '错误:缺少 summary。';
36
+ if (!body)
37
+ return '错误:缺少 body。';
38
+ const type = typeof args.type === 'string' ? args.type : undefined;
39
+ const r = saveEntry({
40
+ name,
41
+ summary,
42
+ body,
43
+ type,
44
+ pinned: args.pinned === true,
45
+ scope: args.scope === 'global' ? 'global' : 'project',
46
+ });
47
+ if (r.ok)
48
+ return `已保存记忆 [${r.id}] "${name}"(下次启动进索引)。`;
49
+ return `已存在同名记忆 [${r.exists}]。改用 memory_update(id="${r.exists}", …) 更新,或换一个 name。`;
50
+ },
51
+ };
@@ -0,0 +1,42 @@
1
+ import { searchEntries } from '../../memory/store.js';
2
+ // ---------- memory_search ----------
3
+ // 关键词搜记忆正文(多词子串匹配,name 权重最高)。命中即 bump recallCount(遗忘衰减依据)。
4
+ // 结果走 capToolResultForHistory 的放宽上限(同 use_skill,保正文完整)。
5
+ export const memorySearchTool = {
6
+ name: 'memory_search',
7
+ description: '按关键词搜索记忆正文(多词子串匹配,按相关度排),返回匹配条目全文。命中记一次召回(影响遗忘衰减)。',
8
+ parameters: {
9
+ type: 'object',
10
+ properties: {
11
+ query: { type: 'string', description: '关键词(id 或 name 里的词效果最好)' },
12
+ type: {
13
+ type: 'string',
14
+ enum: ['decision', 'fact', 'pitfall', 'reference', 'feedback'],
15
+ },
16
+ status: {
17
+ type: 'string',
18
+ enum: ['active', 'superseded', 'archived', 'any'],
19
+ description: '默认 active(只搜活的)',
20
+ },
21
+ limit: { type: 'integer', description: '返回条数,默认 5,上限 20' },
22
+ },
23
+ required: ['query'],
24
+ },
25
+ async execute(args) {
26
+ const query = String(args.query ?? '').trim();
27
+ if (!query)
28
+ return '错误:缺少 query。';
29
+ const r = searchEntries(query, {
30
+ type: typeof args.type === 'string' ? args.type : undefined,
31
+ status: typeof args.status === 'string'
32
+ ? args.status
33
+ : undefined,
34
+ limit: typeof args.limit === 'number' ? args.limit : undefined,
35
+ });
36
+ if (r.length === 0)
37
+ return `(无匹配记忆:query="${query}")`;
38
+ return r
39
+ .map((e) => `# [${e.id}] ${e.name} (${e.type}, recalled ${e.recallCount})\nsummary: ${e.summary}\n\n${e.body}`)
40
+ .join('\n\n---\n\n');
41
+ },
42
+ };
@@ -0,0 +1,41 @@
1
+ import { updateEntry } from '../../memory/store.js';
2
+ // ---------- memory_update ----------
3
+ // 原地改一条记忆(id 不变)。反思的弱意义:干活时发现事实变了/过时即纠正。
4
+ export const memoryUpdateTool = {
5
+ name: 'memory_update',
6
+ description: '更新一条记忆(id 不变)。用于事实变了/纠正过时/补 summary 或 body/切换 pinned。id 从 memory_list 或 memory_search 拿。',
7
+ parameters: {
8
+ type: 'object',
9
+ properties: {
10
+ id: { type: 'string' },
11
+ summary: { type: 'string', description: '新摘要(只传要改的字段)' },
12
+ body: { type: 'string', description: '新正文(只传要改的字段)' },
13
+ name: { type: 'string', description: '新标题(id 不随 name 变)' },
14
+ pinned: { type: 'boolean', description: '切换钉住状态(true 钉住豁免衰减 / false 解钉)' },
15
+ reason: { type: 'string', description: '更新理由(记入 lastUpdateReason,便于追溯)' },
16
+ },
17
+ required: ['id'],
18
+ },
19
+ async execute(args) {
20
+ const id = String(args.id ?? '').trim();
21
+ if (!id)
22
+ return '错误:缺少 id。';
23
+ const patch = {};
24
+ if (typeof args.summary === 'string')
25
+ patch.summary = args.summary;
26
+ if (typeof args.body === 'string')
27
+ patch.body = args.body;
28
+ if (typeof args.name === 'string')
29
+ patch.name = args.name;
30
+ if (typeof args.reason === 'string')
31
+ patch.reason = args.reason;
32
+ if (typeof args.pinned === 'boolean')
33
+ patch.pinned = args.pinned;
34
+ if (Object.keys(patch).length === 0)
35
+ return '错误:至少传一个要改的字段(summary/body/name/pinned/reason)。';
36
+ const r = updateEntry(id, patch);
37
+ if (r.ok)
38
+ return `已更新记忆 [${id}]。`;
39
+ return `错误:找不到记忆 id="${id}"。用 memory_list 查可用 id。`;
40
+ },
41
+ };