mocode-ai 0.6.10 → 0.7.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.
@@ -262,11 +262,20 @@ This is your private working surface — write intermediate findings, decisions,
262
262
  and anything you might need to recall later. The file survives context compaction.
263
263
 
264
264
  ### WHEN TO WRITE
265
+ The notepad is opt-in for complex work, not a routine task log. Use it only when the task has at least 3 meaningful steps, spans multiple investigation/implementation phases, or contains details that are genuinely at risk of being lost to context compaction.
266
+
267
+ Do NOT create, read, or update the notepad for simple tasks, including:
268
+ - Questions that can be answered directly
269
+ - One-step commands or lookups
270
+ - Small, localized edits that can be completed without intermediate notes
271
+ - Work that only needs a few tool calls and fits comfortably in the current context
272
+
273
+ For qualifying complex work:
265
274
  - After exploring code and discovering key constraints → add a section
266
- - Before making a design decision → record reasoning and alternatives considered
267
- - When accumulating data across tool calls → store intermediates
268
- - When you realize something you might forget after compaction → write it down
269
- - After completing a phase → summarize what you learned
275
+ - Before making a consequential design decision → record reasoning and alternatives considered
276
+ - When accumulating data across many tool calls → store concise intermediates
277
+ - When you realize important information may be lost after compaction → write it down
278
+ - After completing a substantial phase → summarize what you learned
270
279
 
271
280
  ### FORMAT (markdown, section-based)
272
281
  Use \`## <topic>\` headers to organize. Each section is self-contained.
@@ -313,7 +322,8 @@ Write the plan as a top-level \`## Plan:\` section. The system extracts this for
313
322
  Rules:
314
323
  - Only ONE active \`## Plan:\` section at a time.
315
324
  - Mark steps \`[x]\` as you complete them; append a line to \`### Progress\` after each phase.
316
- - When the plan is done, either delete the section or rename it to \`## Done: <title>\` so the status bar chip clears automatically.
325
+ - Before your final response, reconcile every step with the work actually completed, then delete the plan section or rename it to \`## Done: <title>\`.
326
+ - The host hides an unchanged active plan when an agent turn ends as a safety fallback; this does not edit the notepad. Keep updating the plan during execution so live progress remains accurate.
317
327
 
318
328
  ## Termination & Reporting
319
329
  - Stop immediately when no more tools are needed; give conclusions directly.
package/dist/index.js CHANGED
@@ -81,7 +81,7 @@ async function main() {
81
81
  }
82
82
  const updateNotice = checkAndMaybeUpdate();
83
83
  const { startRepl } = await import('./repl/index.js');
84
- await startRepl(loaded.history, loaded.id, updateNotice, sandboxRootOverride);
84
+ await startRepl(loaded.history, loaded.id, updateNotice, sandboxRootOverride, loaded.queryHistory);
85
85
  }
86
86
  else {
87
87
  const updateNotice = checkAndMaybeUpdate();
@@ -237,28 +237,51 @@ function renderContextBarInline(history) {
237
237
  const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
238
238
  return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
239
239
  }
240
- /** .mocode/sessions/<sessionId>/notes.md 读取活跃 plan 摘要(## Plan: 段)。每次状态栏刷新时同步读,文件小开销可忽略。 */
241
- function readPlanFromNotes() {
240
+ // 宿主侧记录已结束轮次最后看到的 plan。notes.md 仍完整保留,只抑制未变化的旧 plan 状态栏,
241
+ // 避免 agent 忘记把 `## Plan:` 改成 `## Done:` 时输入框上方永久悬挂。
242
+ let settledPlanFingerprint;
243
+ /** 读取 notes.md 中唯一活跃的 `## Plan:` 段。进度只统计该段,避免其他笔记 checkbox 污染计数。 */
244
+ function readPlanStatusFromNotes() {
242
245
  const sessionId = getCurrentSessionId();
243
246
  if (!sessionId)
244
- return '';
247
+ return null;
245
248
  const root = getSandboxRoot() ?? process.cwd();
246
249
  const p = path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
247
250
  try {
248
- const c = fs.readFileSync(p, 'utf8');
249
- const title = c.match(/^## Plan:\s*(.+)$/m)?.[1].trim();
251
+ const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
252
+ const lines = normalized.split('\n');
253
+ const start = lines.findIndex((line) => /^## Plan:\s*.+$/.test(line));
254
+ if (start < 0)
255
+ return null;
256
+ const endOffset = lines.slice(start + 1).findIndex((line) => /^##\s/.test(line));
257
+ const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
258
+ const section = lines.slice(start, end).join('\n').trimEnd();
259
+ const title = lines[start].match(/^## Plan:\s*(.+)$/)?.[1].trim();
250
260
  if (!title)
251
- return '';
252
- const total = (c.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
253
- const done = (c.match(/^\s*-\s*\[[xX]\]\s*\d+\./gm) || []).length;
254
- const current = c.match(/^\s*-\s*\[ \]\s*\d+\.\s*(.+)$/m)?.[1].trim();
261
+ return null;
262
+ const total = (section.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
263
+ const done = (section.match(/^\s*-\s*\[[xX]\]\s*\d+\./gm) || []).length;
264
+ const current = section.match(/^\s*-\s*\[ \]\s*\d+\.\s*(.+)$/m)?.[1].trim();
255
265
  const summary = `plan: ${title} (${done}/${total})`;
256
- return current ? `${summary} ▸ ${current}` : summary;
266
+ // mtime 让“相同内容被重写为一项新计划”也能重新出现,而不被旧轮次误抑制。
267
+ const fingerprint = `${sessionId}\0${fs.statSync(p).mtimeMs}\0${section}`;
268
+ return { fingerprint, summary: current ? `${summary} ▸ ${current}` : summary };
257
269
  }
258
270
  catch {
259
- return '';
271
+ return null;
260
272
  }
261
273
  }
274
+ /** 将当前 plan 标记为已结算。只影响状态栏,不修改 agent 的工作笔记。 */
275
+ function settlePlanStatus() {
276
+ settledPlanFingerprint = readPlanStatusFromNotes()?.fingerprint;
277
+ }
278
+ /** 从 notes.md 读取活跃 plan 摘要;已结算且未变化的旧 plan 不再显示。 */
279
+ function readPlanFromNotes() {
280
+ const plan = readPlanStatusFromNotes();
281
+ if (!plan || plan.fingerprint === settledPlanFingerprint)
282
+ return '';
283
+ return plan.summary;
284
+ }
262
285
  /** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token。repl 在轮次边界、切模式、plan 变更时调。 */
263
286
  function refreshStatusBase(history, lastTurnUsage) {
264
287
  layout.setStatusBase({
@@ -536,6 +559,13 @@ function textOf(c) {
536
559
  }
537
560
  return String(c);
538
561
  }
562
+ /** 从旧 session 的消息历史回填输入历史;新 session 使用独立 queryHistory,避免混入合成 user 消息。 */
563
+ function queryHistoryFromMessages(messages) {
564
+ return messages
565
+ .filter((message) => message.role === 'user')
566
+ .map((message) => textOf(message.content))
567
+ .filter((query) => query.trim().length > 0);
568
+ }
539
569
  /**
540
570
  * 把会话历史渲染成静态文本进内容区(回滚 / 续接 / --resume 后复显上下文):
541
571
  * user→❯ 回显、assistant→正文(+ tool_calls 折叠成 ● 摘要行)、tool→↳ 结果预览;system 跳过。
@@ -649,7 +679,7 @@ export function renderHistory(history) {
649
679
  * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
650
680
  * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
651
681
  */
652
- export async function startRepl(initialHistory, sessionId, updateNotice = null, sandboxRootOverride) {
682
+ export async function startRepl(initialHistory, sessionId, updateNotice = null, sandboxRootOverride, initialQueryHistory) {
653
683
  // 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
654
684
  setAgentMode('auto');
655
685
  // 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
@@ -686,6 +716,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
686
716
  const history = initialHistory && initialHistory.length
687
717
  ? initialHistory
688
718
  : [{ role: 'system', content: buildSystemMessage(false) }];
719
+ // 新 session 使用独立输入历史;旧 session 没有该字段时从 user 消息兼容回填一次。
720
+ let queryHistory = initialQueryHistory
721
+ ? [...initialQueryHistory]
722
+ : queryHistoryFromMessages(history);
689
723
  if (initialHistory &&
690
724
  initialHistory.length &&
691
725
  history[0]?.role === 'system') {
@@ -839,7 +873,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
839
873
  currentSessionId = newSessionId();
840
874
  setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
841
875
  try {
842
- saveSession(history, currentSessionId);
876
+ saveSession(history, currentSessionId, queryHistory);
843
877
  }
844
878
  catch {
845
879
  // 落盘失败不阻断
@@ -896,16 +930,15 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
896
930
  layout.drawStatusBar();
897
931
  });
898
932
  // 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
933
+ // 状态栏统一在 finally 刷新,确保正常、中断、异常都经过同一 plan 收尾路径。
899
934
  lastTurnUsage = result.usage;
900
- refreshStatusBase(history, lastTurnUsage); // 即时刷状态行显示本轮 token chip
901
- layout.drawStatusBar();
902
935
  ok = !signal.aborted; // 中断(Ctrl+C)→ runAgent 已还原 history,ok=false 不弹审批
903
936
  // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
904
937
  if (!currentSessionId)
905
938
  currentSessionId = newSessionId();
906
939
  setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
907
940
  try {
908
- saveSession(history, currentSessionId);
941
+ saveSession(history, currentSessionId, queryHistory);
909
942
  }
910
943
  catch {
911
944
  // 落盘失败不阻断 REPL
@@ -920,6 +953,16 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
920
953
  }
921
954
  catch (e) {
922
955
  ok = false;
956
+ // 请求失败也保存已确认提交的 query,确保立即退出后仍可通过 ↑ 或 resume 找回。
957
+ if (!currentSessionId)
958
+ currentSessionId = newSessionId();
959
+ setCurrentSessionId(currentSessionId, process.cwd());
960
+ try {
961
+ saveSession(history, currentSessionId, queryHistory);
962
+ }
963
+ catch {
964
+ // 落盘失败不覆盖原始请求错误
965
+ }
923
966
  // 多模态相关错误友好提示:OpenAI/Anthropic 等会报 "does not support image" / "vision" / "multimodal" 等关键词,
924
967
  // 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
925
968
  const msg = e instanceof Error ? e.message : String(e);
@@ -937,6 +980,13 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
937
980
  }
938
981
  finally {
939
982
  stopRunningListener();
983
+ // 纯 plan 轮正常结束后仍需等待审批/细化,继续展示;其余终态统一结算。
984
+ // 结算只隐藏当前 fingerprint,不修改 notes;后续内容或 mtime 变化会自动重新显示。
985
+ const waitingForPlanApproval = ok && planMode && getAgentMode() === 'plan';
986
+ if (!waitingForPlanApproval)
987
+ settlePlanStatus();
988
+ refreshStatusBase(history, lastTurnUsage);
989
+ layout.drawStatusBar();
940
990
  }
941
991
  layout.contentWrite('\n'); // 轮次之间空行
942
992
  return ok;
@@ -955,6 +1005,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
955
1005
  }
956
1006
  history.length = 0;
957
1007
  history.push(...loaded.history);
1008
+ queryHistory = loaded.queryHistory
1009
+ ? [...loaded.queryHistory]
1010
+ : queryHistoryFromMessages(loaded.history);
958
1011
  setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
959
1012
  currentSessionId = loaded.id;
960
1013
  setCurrentSessionId(loaded.id, process.cwd()); // 切换会话:确保该会话的 notes.md 存在
@@ -985,6 +1038,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
985
1038
  input = await promptWithSlashMenu({
986
1039
  prompt: PROMPT,
987
1040
  commands: buildSlashCommands(),
1041
+ queryHistory,
988
1042
  onCycleMode: cycleMode,
989
1043
  // /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
990
1044
  ...(pendingPrefill
@@ -2085,6 +2139,8 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
2085
2139
  layout.enterInputMode(t('repl.idle'));
2086
2140
  continue;
2087
2141
  }
2142
+ // 只记录已过撤回窗口的真实用户 query;slash 命令和合成执行轮不会走到这里。
2143
+ queryHistory.push(joined);
2088
2144
  const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
2089
2145
  const ok = await runTurn(joined, initialPlan, placeholder);
2090
2146
  // plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
@@ -44,22 +44,27 @@ function firstUserOf(history) {
44
44
  function sessionPath(id) {
45
45
  return path.join(config.sessionDir, id, 'session.json');
46
46
  }
47
- /** 保存会话到磁盘。全新且只有 system 的会话不创建文件;已有会话即使回滚为空也必须覆盖旧记录。 */
48
- export function saveSession(history, id) {
47
+ /** 保存会话到磁盘。全新且没有 query 的会话不创建文件;已有会话即使回滚为空也必须覆盖旧记录。 */
48
+ export function saveSession(history, id, queryHistory = []) {
49
49
  const meta = {
50
50
  id,
51
51
  createdAt: idToIso(id),
52
52
  model: config.model,
53
- firstUser: history.length > 1 ? firstUserOf(history) : '',
53
+ firstUser: history.length > 1
54
+ ? firstUserOf(history)
55
+ : truncateDisplay((queryHistory[0] ?? '').replace(/\n/g, ' ').trim(), 40),
54
56
  };
55
57
  const currentPath = sessionPath(id);
56
58
  const legacyPath = path.join(config.sessionDir, `${id}.json`);
57
- if (history.length <= 1 && !existsSync(currentPath) && !existsSync(legacyPath)) {
59
+ if (history.length <= 1 &&
60
+ queryHistory.length === 0 &&
61
+ !existsSync(currentPath) &&
62
+ !existsSync(legacyPath)) {
58
63
  return meta;
59
64
  }
60
65
  const dir = path.join(config.sessionDir, id);
61
66
  mkdirSync(dir, { recursive: true });
62
- const record = { ...meta, history };
67
+ const record = { ...meta, history, queryHistory: [...queryHistory] };
63
68
  writeFileSync(currentPath, JSON.stringify(record), 'utf8');
64
69
  // 一旦写入新式目录,删除旧式扁平副本,避免已回滚消息仍残留在磁盘。
65
70
  if (existsSync(legacyPath))
@@ -86,6 +91,9 @@ export function loadSession(id) {
86
91
  model: rec.model ?? '',
87
92
  firstUser: rec.firstUser ?? '',
88
93
  history: rec.history,
94
+ queryHistory: Array.isArray(rec.queryHistory)
95
+ ? rec.queryHistory.filter((query) => typeof query === 'string')
96
+ : undefined,
89
97
  };
90
98
  }
91
99
  catch {
package/dist/ui/prompt.js CHANGED
@@ -90,6 +90,59 @@ export async function promptWithSlashMenu(opts) {
90
90
  let resolved = false;
91
91
  let resolve;
92
92
  let reject;
93
+ const queryHistory = opts.queryHistory ?? [];
94
+ let historyIndex = queryHistory.length; // length = 草稿哨兵;0..length-1 = 历史项
95
+ let historyDraft = null;
96
+ /** 用历史 query 替换编辑缓冲;历史内容恢复为普通可编辑文本。 */
97
+ function loadHistoryEntry(value) {
98
+ lines = value.split('\n');
99
+ cl = lines.length - 1;
100
+ cc = lines[cl].length;
101
+ chip = null;
102
+ chipPre = '';
103
+ selected = 0;
104
+ menuTop = 0;
105
+ justSawCR = false;
106
+ computeFiltered();
107
+ redraw();
108
+ }
109
+ /** 在首次离开最新位置时保存完整草稿,然后向更旧的 query 移动。 */
110
+ function recallPrevious() {
111
+ if (queryHistory.length === 0 || historyIndex <= 0)
112
+ return;
113
+ if (historyIndex === queryHistory.length) {
114
+ historyDraft = {
115
+ lines: [...lines],
116
+ cl,
117
+ cc,
118
+ chip,
119
+ chipPre,
120
+ };
121
+ }
122
+ historyIndex--;
123
+ loadHistoryEntry(queryHistory[historyIndex]);
124
+ }
125
+ /** 向更新的 query 移动;越过最新历史时恢复进入历史前的草稿。 */
126
+ function recallNext() {
127
+ if (historyIndex >= queryHistory.length)
128
+ return;
129
+ historyIndex++;
130
+ if (historyIndex < queryHistory.length) {
131
+ loadHistoryEntry(queryHistory[historyIndex]);
132
+ return;
133
+ }
134
+ const draft = historyDraft;
135
+ lines = draft ? [...draft.lines] : [''];
136
+ cl = draft?.cl ?? 0;
137
+ cc = draft?.cc ?? 0;
138
+ chip = draft?.chip ?? null;
139
+ chipPre = draft?.chipPre ?? '';
140
+ selected = 0;
141
+ menuTop = 0;
142
+ justSawCR = false;
143
+ computeFiltered();
144
+ redraw();
145
+ }
93
146
  /** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
94
147
  function menuLines() {
95
148
  if (!menuOpen || filtered.length === 0)
@@ -456,6 +509,8 @@ export async function promptWithSlashMenu(opts) {
456
509
  filtered = [];
457
510
  selected = 0;
458
511
  menuTop = 0;
512
+ historyIndex = queryHistory.length;
513
+ historyDraft = null;
459
514
  if (pasteTimer) {
460
515
  clearTimeout(pasteTimer);
461
516
  pasteTimer = null;
@@ -518,19 +573,11 @@ export async function promptWithSlashMenu(opts) {
518
573
  pasteParts.push(s);
519
574
  return;
520
575
  }
521
- // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 每次 5 行。
522
- // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
523
- // 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5。
524
- const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
525
- !key.ctrl &&
526
- !key.meta &&
527
- !key.shift &&
528
- lines.length <= 1 &&
529
- !(menuOpen && filtered.length > 0);
576
+ // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
577
+ // ↑/↓ 留给斜杠菜单、多行光标和 query 历史导航;鼠标滚轮由 SGR mouse 事件处理。
530
578
  if (key.name === 'pageup' ||
531
579
  key.name === 'pagedown' ||
532
- (key.ctrl && (key.name === 'up' || key.name === 'down')) ||
533
- plainArrowScroll) {
580
+ (key.ctrl && (key.name === 'up' || key.name === 'down'))) {
534
581
  const pageH = layout.getGeo().contentBottom;
535
582
  if (key.name === 'pageup')
536
583
  layout.scrollBy(pageH);
@@ -622,6 +669,9 @@ export async function promptWithSlashMenu(opts) {
622
669
  cc = Math.min(cc, lines[cl].length);
623
670
  redraw();
624
671
  }
672
+ else {
673
+ recallPrevious();
674
+ }
625
675
  return;
626
676
  case 'down':
627
677
  if (menuOpen && filtered.length) {
@@ -633,6 +683,9 @@ export async function promptWithSlashMenu(opts) {
633
683
  cc = Math.min(cc, lines[cl].length);
634
684
  redraw();
635
685
  }
686
+ else {
687
+ recallNext();
688
+ }
636
689
  return;
637
690
  case 'tab':
638
691
  if (menuOpen && filtered[selected])
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.6.10",
3
+ "version": "0.7.0",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 16 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {