mocode-ai 0.1.1 → 0.1.3

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.
@@ -31,7 +31,9 @@ function parseArgs(raw) {
31
31
  * 思考段用 layout.beginSegment / eraseSegmentBack 精确按物理行折叠(修预存按 \n 数行漏擦 bug),
32
32
  * 原文存入 collapsedThinkings 供 /think N 重打。
33
33
  */
34
- export async function runAgent(history, userInput, collapsedThinkings = [], signal) {
34
+ export async function runAgent(history, userInput, collapsedThinkings = [], signal,
35
+ /** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条(运行中实时刷新,不冻结在轮首)。 */
36
+ onContextUpdate) {
35
37
  // 中断回滚快照:入口(本 turn push 任何消息前)整段浅拷贝。abort 时 length=0;push(...saved) 还原。
36
38
  // 用 slice() 而非 length:maybeCompact 会原地重建(length=0;push(...rebuilt)),savedLen 会失效。
37
39
  const savedHistory = history.slice();
@@ -138,6 +140,8 @@ export async function runAgent(history, userInput, collapsedThinkings = [], sign
138
140
  }
139
141
  contextState.lastUsage = result.usage; // 供 /context 与状态行显示实测 token
140
142
  spinner.stop();
143
+ // lastUsage 已更新:触发状态行 context 用量条重算+重画,运行中不再冻结在轮首。
144
+ onContextUpdate?.();
141
145
  if (result.toolCalls.length > 0) {
142
146
  // 思考后直接进入 tool_call(无 text):先把可见的思考段折叠
143
147
  flushThinkCollapsed();
package/dist/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { exitAltScreen } from './ui/layout.js';
2
+ import { checkAndMaybeUpdate } from './updater/index.js';
2
3
  // 终端恢复兜底:任一退出 / 中断 / 未捕获异常路径都要恢复 alt screen,避免残留备用屏 + 滚动区域。
3
4
  // exitAltScreen 幂等(未激活时空操作),故全局注册安全——进 alt screen 前的路径(如 --resume 列表、缺环境变量、`mocode config`)调用它无副作用。
4
5
  // 仅 layout 是叶子(不依赖 config),故静态导入安全;repl / session 依赖 config(requireEnv 缺项即退出),改动态按需加载——`mocode config` 才能在零配置下跑。
@@ -63,12 +64,14 @@ async function main() {
63
64
  console.error(`[session] 找不到会话 ${id}(用 --resume 查看列表)`);
64
65
  process.exit(1);
65
66
  }
67
+ const updateNotice = checkAndMaybeUpdate();
66
68
  const { startRepl } = await import('./repl/index.js');
67
- await startRepl(loaded.history, loaded.id);
69
+ await startRepl(loaded.history, loaded.id, updateNotice);
68
70
  }
69
71
  else {
72
+ const updateNotice = checkAndMaybeUpdate();
70
73
  const { startRepl } = await import('./repl/index.js');
71
- await startRepl();
74
+ await startRepl(undefined, undefined, updateNotice);
72
75
  }
73
76
  process.exit(0);
74
77
  }
@@ -4,7 +4,7 @@ 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
9
  import { promptWithSlashMenu, promptTurnPicker } from '../ui/prompt.js';
10
10
  import { tools } from '../tools/registry.js';
@@ -173,14 +173,30 @@ function stopRunningListener() {
173
173
  emitter.off('keypress', onRunningKey);
174
174
  currentAbort = null;
175
175
  }
176
+ /**
177
+ * 把用户消息格式化为带满宽背景色的文本(上滑时易辨认用户消息)。
178
+ * 每行用 padEndDisplay 填充到终端宽度(含 ❯ / 缩进),背景色 SGR 包裹整行 + 行末 reset。
179
+ * 满宽 pad 使终端背景色覆盖整行(含行尾空单元格),上滑滚动时用户消息呈连续色块、与 assistant 正文区分。
180
+ */
181
+ function formatUserMessage(lines) {
182
+ const cols = layout.getGeo().cols;
183
+ const promptW = displayWidth(PROMPT);
184
+ const indent = ' '.repeat(promptW);
185
+ const { userBg, reset } = ui;
186
+ return (lines
187
+ .map((l, i) => {
188
+ const prefix = i === 0 ? PROMPT : indent;
189
+ const full = prefix + l;
190
+ const padded = padEndDisplay(full, cols);
191
+ return `${userBg}${padded}${reset}`;
192
+ })
193
+ .join('\n') + '\n');
194
+ }
176
195
  /** 把多行提交输入回显进内容区(❯ 首行,续行按 prompt 宽度缩进)。仅 TUI 态回显(非 TTY 由 readline 自带回显)。 */
177
196
  function echoInput(lines) {
178
197
  if (!layout.isActive())
179
198
  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);
199
+ layout.contentWrite(formatUserMessage(lines));
184
200
  }
185
201
  /** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
186
202
  function textOf(c) {
@@ -202,16 +218,13 @@ function textOf(c) {
202
218
  * 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
203
219
  */
204
220
  export function renderHistory(history) {
205
- const indent = ' '.repeat(displayWidth(PROMPT));
206
221
  const idToName = new Map();
207
222
  for (const m of history) {
208
223
  if (m.role === 'system')
209
224
  continue;
210
225
  if (m.role === 'user') {
211
226
  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');
227
+ layout.contentWrite(formatUserMessage(lines));
215
228
  continue;
216
229
  }
217
230
  if (m.role === 'assistant') {
@@ -249,7 +262,7 @@ export function renderHistory(history) {
249
262
  * contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
250
263
  * agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
251
264
  */
252
- export async function startRepl(initialHistory, sessionId) {
265
+ export async function startRepl(initialHistory, sessionId, updateNotice = null) {
253
266
  // 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
254
267
  // 否则新会话只塞 system 提示。
255
268
  const systemPrompt = effectiveSystemPrompt(config.systemPrompt);
@@ -287,6 +300,10 @@ export async function startRepl(initialHistory, sessionId) {
287
300
  else {
288
301
  layout.contentWrite(bannerString(banner()));
289
302
  }
303
+ if (updateNotice) {
304
+ // 自更新提示:开场静态段(进 INPUT 态前),dim 一行,不与流式 / 输入争用。
305
+ layout.contentWrite(` ${ui.gray}↳ ${updateNotice}${ui.reset}\n`);
306
+ }
290
307
  /**
291
308
  * 回滚子流程(由 /rollback 触发):菜单(↑/↓)选轮次 → 选中第 X 轮 = 删第 X 轮及之后 + 预填第 X 轮 user 输入
292
309
  * (仿 Claude Code rewind,Enter 重新跑该轮);被删轮次的文件改动仍逐个「保留/撤销」询问(cooked readline)。
@@ -506,7 +523,12 @@ export async function startRepl(initialHistory, sessionId) {
506
523
  }
507
524
  try {
508
525
  const signal = startRunningListener(placeholder);
509
- await runAgent(history, joined, collapsedThinkings, signal);
526
+ // 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
527
+ // 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
528
+ await runAgent(history, joined, collapsedThinkings, signal, () => {
529
+ refreshStatusBase(history);
530
+ layout.drawStatusBar();
531
+ });
510
532
  // 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
511
533
  if (!currentSessionId)
512
534
  currentSessionId = newSessionId();
package/dist/ui/prompt.js CHANGED
@@ -3,6 +3,40 @@ import { stdin, stdout } from 'node:process';
3
3
  import { ui } from './theme.js';
4
4
  import { displayWidth, padEndDisplay, truncateDisplay } from './render.js';
5
5
  import * as layout from './layout.js';
6
+ // ── 粘贴检测(块级 + 时间窗)──
7
+ // 块级:多字节大块(len>8)或含 CR/LF 的小块 = 粘贴(键盘单键 1 字节,Enter=\r 单字节)。
8
+ // 时间窗:每块重置 50ms 计时器,静默 50ms 即"粘贴结束"→ onPasteEnd。跨多块的大粘贴(块间 <50ms)累积进
9
+ // 同一个 pasteParts、末尾一次性落 chip——避免"首块成 chip、后续块泄成文本"。粘贴中 onKey 把键累积进
10
+ // pasteParts(不编辑 lines)。不启用 bracketed paste——emitKeypressEvents 会把 \x1B[200~ 标记当按键砸进输入框。
11
+ let pasting = false;
12
+ let pasteParts = [];
13
+ let pasteTimer = null;
14
+ let onPasteEnd = null; // 粘贴结束回调(prompt 注入:落 chip 或保留为文本)
15
+ let pasteDetectorInstalled = false;
16
+ function ensurePasteDetector() {
17
+ if (pasteDetectorInstalled)
18
+ return;
19
+ pasteDetectorInstalled = true;
20
+ stdin.on('data', (chunk) => {
21
+ const len = chunk.length;
22
+ const hasNL = typeof chunk === 'string'
23
+ ? chunk.indexOf('\r') >= 0 || chunk.indexOf('\n') >= 0
24
+ : chunk.indexOf(0x0d) >= 0 || chunk.indexOf(0x0a) >= 0;
25
+ // 大块(>8 字节)或含换行的多字节块 = 粘贴;键盘单键(1 字节,含 Enter=\r)不触发
26
+ if (!(len > 8 || (len > 1 && hasNL)))
27
+ return;
28
+ pasting = true;
29
+ if (pasteTimer)
30
+ clearTimeout(pasteTimer);
31
+ const t = setTimeout(() => {
32
+ pasteTimer = null;
33
+ pasting = false;
34
+ onPasteEnd?.();
35
+ }, 50);
36
+ t.unref();
37
+ pasteTimer = t;
38
+ });
39
+ }
6
40
  /** 非 TTY / 未进 alt screen 时退化为普通 readline 行输入(无菜单、单行)。 */
7
41
  function questionFallback(prompt) {
8
42
  return new Promise((res, rej) => {
@@ -38,6 +72,8 @@ export async function promptWithSlashMenu(opts) {
38
72
  : [''];
39
73
  let cl = lines.length - 1; // 光标行(0-based)= 末行
40
74
  let cc = lines[cl].length; // 光标在该行的字符索引 = 末行末尾
75
+ let justSawCR = false; // \r\n 合一:粘贴的 \r 折行后,紧跟的 \n 吞掉(避免折两行)
76
+ let chip = null; // 原子粘贴块:整段封进 [预览…],不可编辑;提交时拼回全文
41
77
  let menuOpen = false;
42
78
  let selected = 0;
43
79
  let filtered = [];
@@ -62,6 +98,58 @@ export async function promptWithSlashMenu(opts) {
62
98
  function cursorCol() {
63
99
  return displayWidth(lines[cl].slice(0, cc));
64
100
  }
101
+ /** chip 预览前缀:整段扁平化(行界→空格,避免框内折行)取前 ~20 列,超长 truncateDisplay 自带 …;末尾空格与 suffix 分隔。 */
102
+ function chipPrefix() {
103
+ return chip ? `[${truncateDisplay(chip.split('\n').join(' '), 20)}] ` : '';
104
+ }
105
+ /** 供 layout 画的行:chip 存在时把前缀拼到第 0 行前(chip 原子显示,不可编辑;光标活在 suffix)。 */
106
+ function dispLines() {
107
+ if (!chip)
108
+ return lines;
109
+ const pre = chipPrefix();
110
+ return lines.length > 0 ? [pre + lines[0], ...lines.slice(1)] : [pre];
111
+ }
112
+ /** 供 layout 定位光标列:chip 在第 0 行时偏移 chipPrefix 宽度(suffix 光标始终在 chip 之后)。 */
113
+ function dispCursorCol() {
114
+ return chip && cl === 0 ? displayWidth(chipPrefix()) + cursorCol() : cursorCol();
115
+ }
116
+ /** 在光标处插入文本(含换行则拆行)。供短粘贴 finalize 落为可编辑文本。 */
117
+ function insertText(text) {
118
+ const parts = text.split('\n');
119
+ const before = lines[cl].slice(0, cc);
120
+ const after = lines[cl].slice(cc);
121
+ const newLines = [before + parts[0]];
122
+ for (let i = 1; i < parts.length; i++)
123
+ newLines.push(parts[i]);
124
+ newLines[newLines.length - 1] += after;
125
+ lines.splice(cl, 1, ...newLines);
126
+ cl = cl + parts.length - 1;
127
+ cc = parts[parts.length - 1].length;
128
+ }
129
+ /** 粘贴结束:长粘贴(>8 行或 >400 字符)落/并进 chip(原子,整段封预览),短粘贴落为可编辑文本。 */
130
+ function finalizePaste() {
131
+ if (resolved) {
132
+ pasteParts = [];
133
+ return;
134
+ }
135
+ const buf = pasteParts.join('');
136
+ pasteParts = [];
137
+ justSawCR = false;
138
+ if (!buf)
139
+ return;
140
+ const isLong = buf.split('\n').length > 8 || buf.length > 400;
141
+ if (isLong) {
142
+ chip = chip == null ? buf : chip + '\n' + buf; // 多块粘贴:并进同一 chip
143
+ lines = [''];
144
+ cl = 0;
145
+ cc = 0;
146
+ }
147
+ else {
148
+ insertText(buf);
149
+ }
150
+ computeFiltered();
151
+ redraw();
152
+ }
65
153
  function computeFiltered() {
66
154
  if (cl === 0 && lines[0].startsWith('/')) {
67
155
  filtered = opts.commands.filter((c) => c.name.startsWith(lines[0]));
@@ -77,9 +165,9 @@ export async function promptWithSlashMenu(opts) {
77
165
  function redraw() {
78
166
  layout.paintInput({
79
167
  prompt: opts.prompt,
80
- lines,
168
+ lines: dispLines(),
81
169
  cursorLine: cl,
82
- cursorCol: cursorCol(),
170
+ cursorCol: dispCursorCol(),
83
171
  menu: menuLines().length ? { lines: menuLines() } : null,
84
172
  });
85
173
  }
@@ -91,6 +179,13 @@ export async function promptWithSlashMenu(opts) {
91
179
  // 忽略
92
180
  }
93
181
  emitter.removeListener('keypress', onKey);
182
+ if (pasteTimer) {
183
+ clearTimeout(pasteTimer);
184
+ pasteTimer = null;
185
+ }
186
+ pasting = false;
187
+ pasteParts = [];
188
+ onPasteEnd = null;
94
189
  stdin.pause();
95
190
  }
96
191
  function finish(value) {
@@ -100,14 +195,16 @@ export async function promptWithSlashMenu(opts) {
100
195
  cleanup();
101
196
  resolve(value);
102
197
  }
103
- /** 提交:菜单打开时先补全选中项到第 0 行。 */
198
+ /** 提交:菜单打开时先补全选中项到第 0 行。chip 与 suffix 拼回全文(chip 在前,换行接 suffix)。 */
104
199
  function submit() {
105
200
  if (menuOpen && filtered[selected]) {
106
201
  lines = [filtered[selected].name];
107
202
  cl = 0;
108
203
  cc = lines[0].length;
109
204
  }
110
- finish(lines);
205
+ const suffix = lines.join('\n');
206
+ const content = chip ? (suffix.length > 0 ? chip + '\n' + suffix : chip) : suffix;
207
+ finish(content === '' ? [''] : content.split('\n'));
111
208
  }
112
209
  /** 插换行:在光标处断行。 */
113
210
  function insertNewline() {
@@ -122,6 +219,29 @@ export async function promptWithSlashMenu(opts) {
122
219
  function onKey(_str, key) {
123
220
  if (resolved || !key)
124
221
  return;
222
+ // 粘贴中:把键累积进 pasteParts(换行 \r\n 合一),不编辑 lines;末尾 finalizePaste 统一落 chip/文本
223
+ if (pasting) {
224
+ if (key.ctrl && key.name === 'c') {
225
+ cleanup();
226
+ reject(new Error('SIGINT'));
227
+ return;
228
+ }
229
+ const s = key.sequence ?? '';
230
+ const isReturn = key.name === 'return' || key.name === 'enter';
231
+ if (s === '\n' && justSawCR) {
232
+ justSawCR = false; // \r\n 的 \n:已随 \r 折行,吞掉
233
+ return;
234
+ }
235
+ if (isReturn || s === '\r' || s === '\n') {
236
+ justSawCR = s === '\r'; // \r 标记,待可能的尾随 \n
237
+ pasteParts.push('\n');
238
+ return;
239
+ }
240
+ justSawCR = false;
241
+ if (s && s >= ' ' && !key.ctrl && !key.meta)
242
+ pasteParts.push(s);
243
+ return;
244
+ }
125
245
  // 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑↓ 与 plain ↑/↓ 单行。
126
246
  // plain ↑/↓ 仅在单行输入且菜单关闭时作滚动(多行编辑留给光标移动,菜单打开留给选项);
127
247
  // 兼鼠标滚轮——WT alt 屏(经 \x1B[?1007h)滚轮转发 ↑/↓。
@@ -171,17 +291,17 @@ export async function promptWithSlashMenu(opts) {
171
291
  return;
172
292
  }
173
293
  const isReturn = key.name === 'return' || key.name === 'enter';
174
- // 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ 粘贴的 LF
294
+ // 换行:Ctrl+J / Alt+Enter(meta)/ Shift+Enter(终端区分时)/ lone LF
295
+ // (粘贴的 CR/LF 已在上方 pasting 分支累积进 pasteParts,不会到此)
175
296
  const wantNewline = (key.ctrl && key.name === 'j') ||
176
297
  (key.meta && isReturn) ||
177
298
  (key.shift && isReturn) ||
178
299
  (key.sequence === '\n' && !key.ctrl);
179
- // 换行(Ctrl+J / Alt+Enter / Shift+Enter / 粘贴 LF)
180
300
  if (wantNewline) {
181
301
  insertNewline();
182
302
  return;
183
303
  }
184
- // 提交(plain Enter)
304
+ // 提交(键盘 plain Enter)
185
305
  if (isReturn && !key.shift && !key.meta && !key.ctrl) {
186
306
  submit();
187
307
  return;
@@ -204,6 +324,12 @@ export async function promptWithSlashMenu(opts) {
204
324
  computeFiltered();
205
325
  redraw();
206
326
  }
327
+ else if (chip) {
328
+ // 光标在 suffix 开头(紧贴 ] 后):退格删整个 chip(原子)
329
+ chip = null;
330
+ computeFiltered();
331
+ redraw();
332
+ }
207
333
  return;
208
334
  case 'up':
209
335
  if (menuOpen && filtered.length) {
@@ -284,6 +410,8 @@ export async function promptWithSlashMenu(opts) {
284
410
  return new Promise((res, rej) => {
285
411
  resolve = res;
286
412
  reject = rej;
413
+ ensurePasteDetector(); // 首次调用在 emitKeypressEvents 之前装 data 监听器(保序:mine 先于 解析器)
414
+ onPasteEnd = finalizePaste; // 粘贴结束回调:落 chip 或保留文本
287
415
  readline.emitKeypressEvents(stdin);
288
416
  let rawOk = true;
289
417
  try {
@@ -395,6 +523,7 @@ export async function promptTurnPicker(items) {
395
523
  return new Promise((res, rej) => {
396
524
  resolve = res;
397
525
  reject = rej;
526
+ ensurePasteDetector();
398
527
  readline.emitKeypressEvents(stdin);
399
528
  let rawOk = true;
400
529
  try {
package/dist/ui/render.js CHANGED
@@ -160,7 +160,7 @@ const LOGO_GAP = 4; // logo 与信息区之间的间隔
160
160
  const LOGO_PAD = ' '.repeat(LOGO_W + LOGO_GAP); // 无 logo 行的缩进(对齐信息区)
161
161
  // 坐着的小人:[●ᴗ●] 头 / | | 身 / ╲| |╱ 双手外撑 / | | 身 / OO 脚
162
162
  const LOGO_LINES = [
163
- ' [●ᴗ●] ',
163
+ ' [◕ᴗ◕] ',
164
164
  ' ●| |● ',
165
165
  ' OO ',
166
166
  ];
package/dist/ui/theme.js CHANGED
@@ -19,4 +19,6 @@ export const ui = {
19
19
  magenta: wrap('\x1B[35m'),
20
20
  brightCyan: wrap('\x1B[96m'),
21
21
  brightMagenta: wrap('\x1B[95m'),
22
+ /** 用户消息满宽背景色(上滑时易辨认);bright black bg = 深灰,深色终端上微妙可辨。 */
23
+ userBg: wrap('\x1B[100m'),
22
24
  };
@@ -0,0 +1,161 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { spawn } from 'node:child_process';
5
+ import { fileURLToPath } from 'node:url';
6
+ /**
7
+ * 启动时自动检测并后台自更新(仿 update-notifier,但**真更新**而非仅提示)。
8
+ *
9
+ * 设计要点:
10
+ * - **零启动延迟**:`checkAndMaybeUpdate()` 同步返回提示串或 null;联网刷新与 `npm i -g` 都是
11
+ * fire-and-forget 后台操作(不 await),不卡 REPL 启动。
12
+ * - **下次启动生效**:当前进程已把 dist/*.js 读入内存,运行中不持有文件句柄;后台 `npm i -g`
13
+ * 覆写磁盘包(含 mocode.cmd shim),当前进程不受影响,**下次** `mocode` 即新版本。
14
+ * - **节流**:检查 24h、spawn 6h(防每跑必联网 / 重复 spawn)。更新成功后下次重读 package.json
15
+ * 得 current=latest → 不再 spawn、不再提示。
16
+ * - **不依赖 config**:只用 stdlib + 全局 fetch,缺 LLM 配置也能跑(在 config 校验前调)。
17
+ * - **dev 跳过**:tsx 运行 `.ts`(`npm start`)不自更新;编译态 `.js`(`mocode` 全局命令)才更新。
18
+ * - **失败静默**:断网 / 无 npm / 权限不足都不阻断启动。
19
+ */
20
+ const PKG_NAME = 'mocode-ai';
21
+ /** 包根 package.json:从 dist/updater/ 或 src/updater/ 均 `../../package.json`(npm 必带 package.json)。 */
22
+ const PKG_PATH = fileURLToPath(new URL('../../package.json', import.meta.url));
23
+ const CACHE_PATH = path.join(os.homedir(), '.mocode', 'update-check.json');
24
+ const CHECK_INTERVAL_MS = 24 * 60 * 60 * 1000; // 24h:多久重新拉一次 registry
25
+ const SPAWN_INTERVAL_MS = 6 * 60 * 60 * 1000; // 6h:同一待更新版本多久内不重复 spawn
26
+ const FETCH_TIMEOUT_MS = 2500;
27
+ /** 缓存路径:MOCODE_UPDATE_CACHE 可重定向(测试用临时文件,避免污染真实 ~/.mocode 缓存)。 */
28
+ function cachePath() {
29
+ return process.env.MOCODE_UPDATE_CACHE || CACHE_PATH;
30
+ }
31
+ /** tsx 开发态(import.meta.url 以 .ts 结尾)跳过自更新;编译态 .js 才更新。 */
32
+ function isDevRun() {
33
+ try {
34
+ return fileURLToPath(import.meta.url).endsWith('.ts');
35
+ }
36
+ catch {
37
+ return false;
38
+ }
39
+ }
40
+ /** 读当前安装版本(package.json 的 version)。读不到返 ''。 */
41
+ function readCurrentVersion() {
42
+ try {
43
+ const pkg = JSON.parse(fs.readFileSync(PKG_PATH, 'utf8'));
44
+ return pkg.version ?? '';
45
+ }
46
+ catch {
47
+ return '';
48
+ }
49
+ }
50
+ function readCache() {
51
+ try {
52
+ return JSON.parse(fs.readFileSync(cachePath(), 'utf8'));
53
+ }
54
+ catch {
55
+ return {};
56
+ }
57
+ }
58
+ /** 读-合并-写(各写者独立 merge,降低后台 fetch 与同步 spawn 写的竞态丢字段风险)。 */
59
+ function writeCache(patch) {
60
+ try {
61
+ const merged = { ...readCache(), ...patch };
62
+ fs.mkdirSync(path.dirname(cachePath()), { recursive: true });
63
+ fs.writeFileSync(cachePath(), JSON.stringify(merged), 'utf8');
64
+ }
65
+ catch {
66
+ // 写缓存失败不阻断
67
+ }
68
+ }
69
+ /** registry 取自 ~/.npmrc(国内镜像用户与 `npm i -g` 装的源一致);无则用官方端点。保证尾 `/`。 */
70
+ function readRegistry() {
71
+ let reg = 'https://registry.npmjs.org/';
72
+ try {
73
+ const npmrc = fs.readFileSync(path.join(os.homedir(), '.npmrc'), 'utf8');
74
+ const m = /^registry\s*=\s*["']?([^"'\s]+)["']?\s*$/m.exec(npmrc);
75
+ if (m && m[1])
76
+ reg = m[1];
77
+ }
78
+ catch {
79
+ // 无 ~/.npmrc:用默认
80
+ }
81
+ return reg.endsWith('/') ? reg : reg + '/';
82
+ }
83
+ /** 后台拉 registry 最新版(不 await):成功写 latest+lastCheck,失败只写 lastCheck(防离线每跑必联网)。 */
84
+ async function fetchLatestInBackground() {
85
+ try {
86
+ const ctrl = new AbortController();
87
+ const t = setTimeout(() => ctrl.abort(), FETCH_TIMEOUT_MS);
88
+ const res = await fetch(`${readRegistry()}${PKG_NAME}/latest`, { signal: ctrl.signal });
89
+ clearTimeout(t);
90
+ if (!res.ok)
91
+ throw new Error(`HTTP ${res.status}`);
92
+ const data = (await res.json());
93
+ writeCache(data.version ? { latest: data.version, lastCheck: Date.now() } : { lastCheck: Date.now() });
94
+ }
95
+ catch {
96
+ writeCache({ lastCheck: Date.now() });
97
+ }
98
+ }
99
+ /** 后台 spawn `npm i -g <pkg>@latest`(Win:shell 解析 npm.cmd + windowsHide 防控制台闪;detached+unref 跨进程存活)。 */
100
+ function spawnUpdate() {
101
+ if (process.env.MOCODE_NO_SPAWN)
102
+ return; // 测试门控:禁真跑 npm
103
+ try {
104
+ const child = spawn('npm', ['install', '-g', `${PKG_NAME}@latest`], {
105
+ shell: true,
106
+ detached: true,
107
+ stdio: 'ignore',
108
+ windowsHide: true,
109
+ });
110
+ child.unref();
111
+ child.on('error', () => { });
112
+ }
113
+ catch {
114
+ // 忽略
115
+ }
116
+ }
117
+ /** 简易 semver 比较(只管 X.Y.Z 数字,无 pre-release):a>b 返正,a<b 返负,等返 0。 */
118
+ export function compareSemver(a, b) {
119
+ const pa = a.split('.').map((x) => Number(x) || 0);
120
+ const pb = b.split('.').map((x) => Number(x) || 0);
121
+ for (let i = 0; i < Math.max(pa.length, pb.length); i++) {
122
+ const da = pa[i] ?? 0;
123
+ const db = pb[i] ?? 0;
124
+ if (da !== db)
125
+ return da - db;
126
+ }
127
+ return 0;
128
+ }
129
+ /** 纯决策:据当前版本 / 缓存 / 时间,算是否提示 / spawn / 后台刷新。无副作用,可离线测。 */
130
+ export function evaluateUpdate(current, cache, now) {
131
+ const refresh = !cache.latest || now - (cache.lastCheck ?? 0) > CHECK_INTERVAL_MS;
132
+ let notice = null;
133
+ let spawn = false;
134
+ if (cache.latest && compareSemver(cache.latest, current) > 0) {
135
+ notice = `检测到新版本 ${cache.latest},后台更新中,下次启动生效。`;
136
+ spawn = now - (cache.lastSpawn ?? 0) > SPAWN_INTERVAL_MS;
137
+ }
138
+ return { notice, spawn, refresh };
139
+ }
140
+ /**
141
+ * 启动时调(同步,零延迟):返一行纯文本更新提示或 null。
142
+ * 流程:dev 跳过 → 读当前版本 → 读缓存 → evaluateUpdate 决策 → 按需后台刷新 / spawn → 返提示。
143
+ * 提示由 repl 上色写入内容区开场段(进 INPUT 态前)。
144
+ */
145
+ export function checkAndMaybeUpdate() {
146
+ if (isDevRun())
147
+ return null;
148
+ const current = readCurrentVersion();
149
+ if (!current)
150
+ return null;
151
+ const cache = readCache();
152
+ const now = Date.now();
153
+ const d = evaluateUpdate(current, cache, now);
154
+ if (d.refresh)
155
+ void fetchLatestInBackground(); // fire-and-forget:刷新缓存供下次启动用
156
+ if (d.spawn) {
157
+ spawnUpdate();
158
+ writeCache({ lastSpawn: now });
159
+ }
160
+ return d.notice;
161
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mocode-ai",
3
- "version": "0.1.1",
3
+ "version": "0.1.3",
4
4
  "description": "终端编码 agent:LLM + tool-call 循环 + 流式输出(含思考)+ 9 个工具,接任意 OpenAI 兼容后端。",
5
5
  "type": "module",
6
6
  "bin": {