mocode-ai 0.4.1 → 0.4.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.
- package/dist/agent/core.js +64 -10
- package/dist/agent/index.js +40 -2
- package/dist/agent/spawn.js +18 -9
- package/dist/config/index.js +116 -29
- package/dist/llm/index.js +71 -3
- package/dist/memory/index.js +8 -1
- package/dist/memory/store.js +51 -9
- package/dist/repl/index.js +191 -11
- package/dist/tools/builtins/ask-human.js +40 -3
- package/dist/tools/builtins/index.js +20 -5
- package/dist/tools/constants.js +16 -0
- package/dist/ui/content.js +27 -0
- package/dist/ui/layout.js +77 -18
- package/dist/ui/prompt.js +15 -5
- package/package.json +1 -1
package/dist/memory/store.js
CHANGED
|
@@ -315,24 +315,66 @@ export function gcMemories() {
|
|
|
315
315
|
/**
|
|
316
316
|
* active 条目按 updatedAt 降序,封顶 MAX_INDEX_ENTRIES,只注 id/name/summary/type。
|
|
317
317
|
* 无 active 返空串(零行为变化)。body 不注入——按需 memory_search 取。
|
|
318
|
+
*
|
|
319
|
+
* 索引策略(省 token):不全量塞进每轮 systemPrompt。
|
|
320
|
+
* - pinned 永远包含(pinned = 用户明确想长期保留)
|
|
321
|
+
* - recallCount ≥ 1 包含(被引用过,价值已验证)
|
|
322
|
+
* - 否则仅当 (lastRecalledAt|createdAt) 近 RECENT_MS(=DECAY_DAYS×2) 内
|
|
323
|
+
* 排序:pinned 先 → recallCount 降 → updatedAt 降。
|
|
324
|
+
* 封顶 MAX_INDEX_ENTRIES;尾部标 hidden 数量,引导用 memory_list/memory_search 兜底。
|
|
325
|
+
* 真正「陈旧」被滤掉时也明示(让 LLM 知道有内容存在但被策略隐藏,而不是误以为空)。
|
|
326
|
+
*
|
|
327
|
+
* memoryEnabled=false 时(记忆子系统总开关关闭)直接返空串:Memory Index 段
|
|
328
|
+
* 不进系统提示,LLM 看不到工具使用提示;配合 tools/builtins 屏蔽 memory_* 工具,
|
|
329
|
+
* 实现「关闭时零侵入」(默认行为)。传参由 repl 的 buildSystemMessage 在拼装前调
|
|
330
|
+
* isMemoryEnabled() 注入(本文件是叶子,避免直接引 config 起环)。
|
|
318
331
|
*/
|
|
319
|
-
export function buildMemoryIndexSection() {
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
332
|
+
export function buildMemoryIndexSection(memoryEnabled = true) {
|
|
333
|
+
if (!memoryEnabled)
|
|
334
|
+
return '';
|
|
335
|
+
const all = loadAll();
|
|
336
|
+
const active = all.filter((e) => e.status === 'active');
|
|
323
337
|
if (active.length === 0)
|
|
324
338
|
return '';
|
|
325
|
-
const
|
|
339
|
+
const now = Date.now();
|
|
340
|
+
// DECAY_DAYS×2 = 60 天(常量在下方定义,同模块作用域内可见)
|
|
341
|
+
const recentCutoff = now - DECAY_DAYS * 2 * DAY_MS;
|
|
342
|
+
const refTs = (e) => {
|
|
343
|
+
const r = e.lastRecalledAt ? Date.parse(e.lastRecalledAt) : Date.parse(e.createdAt);
|
|
344
|
+
return Number.isFinite(r) ? r : now;
|
|
345
|
+
};
|
|
346
|
+
// 过滤:只保留"近 60 天有动静 / 有 recall / pinned"的三类。其它 active 视为陈旧。
|
|
347
|
+
const eligible = active.filter((e) => {
|
|
348
|
+
if (e.pinned)
|
|
349
|
+
return true;
|
|
350
|
+
if (e.recallCount >= 1)
|
|
351
|
+
return true;
|
|
352
|
+
return refTs(e) >= recentCutoff;
|
|
353
|
+
});
|
|
354
|
+
eligible.sort((a, b) => {
|
|
355
|
+
if (a.pinned !== b.pinned)
|
|
356
|
+
return a.pinned ? -1 : 1;
|
|
357
|
+
if (a.recallCount !== b.recallCount)
|
|
358
|
+
return b.recallCount - a.recallCount;
|
|
359
|
+
return (b.updatedAt || '').localeCompare(a.updatedAt || '');
|
|
360
|
+
});
|
|
361
|
+
const staleCount = active.length - eligible.length;
|
|
362
|
+
const shown = eligible.slice(0, MAX_INDEX_ENTRIES);
|
|
326
363
|
const lines = shown.map((e) => `- ${e.id}: ${e.name} — ${e.summary} (${e.type})`);
|
|
327
|
-
const
|
|
328
|
-
|
|
329
|
-
|
|
364
|
+
const capOmitted = Math.max(0, eligible.length - shown.length);
|
|
365
|
+
const tailParts = [];
|
|
366
|
+
if (capOmitted > 0)
|
|
367
|
+
tailParts.push(`${capOmitted} additional active entries omitted by cap (${shown.length}/${eligible.length} shown)`);
|
|
368
|
+
if (staleCount > 0)
|
|
369
|
+
tailParts.push(`${staleCount} stale active entries hidden by index policy (no recall + older than ${DECAY_DAYS * 2}d; use memory_list to see all)`);
|
|
370
|
+
const tail = tailParts.length > 0 ? `\n\n…(${tailParts.join('; ')})` : '';
|
|
330
371
|
return [
|
|
331
372
|
'',
|
|
332
373
|
'',
|
|
333
374
|
'## Memory Index (retrieve full body via memory_search)',
|
|
334
375
|
'The following are saved memory entries (title/summary only). Retrieve full body via memory_search (pass id or keyword); use memory_list to see all,'
|
|
335
|
-
+ ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.'
|
|
376
|
+
+ ' memory_update to modify, memory_forget to archive. This list is a startup snapshot; entries added during the session are not listed here — use memory_list/memory_search to find them.'
|
|
377
|
+
+ ' Index policy: pinned + recently-recalled always shown; long-untouched active entries are hidden to keep this section lean.',
|
|
336
378
|
...lines,
|
|
337
379
|
tail,
|
|
338
380
|
].join('\n');
|
package/dist/repl/index.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import readline from 'node:readline/promises';
|
|
2
2
|
import { emitKeypressEvents } from 'node:readline';
|
|
3
3
|
import { stdin, stdout } from 'node:process';
|
|
4
|
-
import { config,
|
|
4
|
+
import { config, updateModelConfig, isModelConfigured, updateMemoryConfig, isMemoryEnabled, buildBasePrompt, getPlanModeSuffix, } from '../config/index.js';
|
|
5
5
|
import { updateConfigKey, writeConfigKeys, CONFIG_PATH } from '../config/file.js';
|
|
6
6
|
import { runAgent } from '../agent/index.js';
|
|
7
7
|
import { getAgentMode, setAgentMode, onModeChange } from '../agent/mode.js';
|
|
@@ -35,9 +35,11 @@ const SLASH_COMMANDS = [
|
|
|
35
35
|
{ name: '/compact', desc: '压缩历史(可带焦点 /compact …)' },
|
|
36
36
|
{ name: '/resume', desc: '续接已保存的会话' },
|
|
37
37
|
{ name: '/rollback', desc: '菜单选轮次回滚(↑↓·Enter)' },
|
|
38
|
-
{ name: '/memory', desc: '记忆库:条目计数与近期索引' },
|
|
39
|
-
{ name: '/
|
|
40
|
-
{ name: '/
|
|
38
|
+
{ name: '/memory', desc: '记忆库:条目计数与近期索引(关闭时提示先开 /memory_switch)' },
|
|
39
|
+
{ name: '/memory_switch', desc: '切换记忆子系统开关(无参=切换;/on 或 /off 显式;持久化 MEMORY_ENABLED)' },
|
|
40
|
+
{ name: '/memory_status', desc: '查看记忆子系统当前开关与原理' },
|
|
41
|
+
{ name: '/reflect', desc: '手动触发后台记忆反思 pass(需先开启记忆)' },
|
|
42
|
+
{ name: '/init', desc: '扫描项目生成 MOCODE.md 项目记忆(需先开启记忆)' },
|
|
41
43
|
{ name: '/theme', desc: '切换颜色主题(↑↓·Enter)' },
|
|
42
44
|
{ name: '/model', desc: '配置大模型(baseURL/key/model/窗口)' },
|
|
43
45
|
{ name: '/plan', desc: '切到 plan 模式(只读探查+产出计划)' },
|
|
@@ -141,14 +143,15 @@ function renderContextBarInline(history) {
|
|
|
141
143
|
const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
|
|
142
144
|
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
|
|
143
145
|
}
|
|
144
|
-
/** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip。repl 在轮次边界、切模式、plan 变更时调。 */
|
|
145
|
-
function refreshStatusBase(history) {
|
|
146
|
+
/** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token。repl 在轮次边界、切模式、plan 变更时调。 */
|
|
147
|
+
function refreshStatusBase(history, lastTurnUsage) {
|
|
146
148
|
layout.setStatusBase({
|
|
147
149
|
model: config.model,
|
|
148
150
|
contextBar: renderContextBarInline(history),
|
|
149
151
|
cwd: process.cwd(),
|
|
150
152
|
modeTag: getAgentMode() === 'plan' ? 'plan' : 'auto',
|
|
151
153
|
planSummary: hasActivePlan() ? getActivePlanSummary(process.stdout.columns ?? 80) : '',
|
|
154
|
+
lastTurnUsage,
|
|
152
155
|
});
|
|
153
156
|
}
|
|
154
157
|
/** 命令 → 运行态状态文字 + 底栏 dim 占位。 */
|
|
@@ -174,6 +177,10 @@ function runningStateFor(cmd) {
|
|
|
174
177
|
return { status: '配模型', placeholder: '配置中…' };
|
|
175
178
|
case '/pet':
|
|
176
179
|
return { status: '桌宠', placeholder: '处理中…' };
|
|
180
|
+
case '/memory_switch':
|
|
181
|
+
return { status: '切记忆开关', placeholder: '切换中…' };
|
|
182
|
+
case '/memory_status':
|
|
183
|
+
return { status: '查记忆状态', placeholder: '…' };
|
|
177
184
|
default:
|
|
178
185
|
// 输入框留空(运行中可 typeahead 打字,dim 回显);运行状态由内联 spinner 承载(思考中/执行…),
|
|
179
186
|
// 状态行只显走时——故常态 status 留空,不塞「处理」这种与内联重复的泛标签。
|
|
@@ -188,6 +195,13 @@ let runningInput = ''; // 运行中已打字缓冲(单行;agent 结束后预填
|
|
|
188
195
|
let runningPlaceholder = '';
|
|
189
196
|
let currentAbort = null;
|
|
190
197
|
let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
|
|
198
|
+
// ── pending send 撤回窗口(用户按 Enter 后、agent 真发请求前)──
|
|
199
|
+
// 500ms 内 Ctrl+C / Esc → 整条用户气泡从内容区擦掉 + 原行 prefilled 回输入框(可改可再发);
|
|
200
|
+
// 期间再按 Enter 立即推进 / 时间到自然推进 → 走原流程 enterRunningMode + runTurn。
|
|
201
|
+
// attachmentsCount 记 pendingAttachments 当时长度——撤回时 attachments 保留(用户意图未变,只是改字)。
|
|
202
|
+
const PENDING_RECALL_MS = 500;
|
|
203
|
+
let pendingRecall = null;
|
|
204
|
+
let pendingTimer = null;
|
|
191
205
|
// agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
|
|
192
206
|
/** 多模态 user 输入的附件状态。pending = 本轮尚未提交的待发图片;messageAttachments = 已 push 进 history 的图片元数据
|
|
193
207
|
* (供 renderHistory 复显文件名——base64 不可逆地塞进 history 后,只能从侧 channel 拿原文件名)。 */
|
|
@@ -318,6 +332,76 @@ function echoInput(lines) {
|
|
|
318
332
|
layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
|
|
319
333
|
}
|
|
320
334
|
}
|
|
335
|
+
/**
|
|
336
|
+
* 等待 pending 撤回窗口(用户 Enter 后、agent 真发请求前的 500ms 兜底)。
|
|
337
|
+
* 返 true=应 commit(走原 enterRunningMode + runTurn);false=应 recall(主循环 rewindContent 擦气泡 + prefill 回输入框)。
|
|
338
|
+
*
|
|
339
|
+
* 监听:
|
|
340
|
+
* - Esc / Ctrl+C → recall(shouldCommit=false)
|
|
341
|
+
* - Enter / Return → 立即 commit(shouldCommit=true)
|
|
342
|
+
* - 其它键忽略(不进 paste 路径、不挂 timer)
|
|
343
|
+
* - 500ms 定时器到 → 自动 commit(shouldCommit=true)
|
|
344
|
+
*
|
|
345
|
+
* 视觉:状态行 spinner 位临时改 '发送中… (Esc / Ctrl+C 撤回)';commit 后
|
|
346
|
+
* runAgent.onStepStart 会 setStatus('思考中') 接管,无需手动还原。
|
|
347
|
+
*
|
|
348
|
+
* 降级:非 TTY(setRawMode 抛错)直接 commit,window=0 —— CI / 管道回放路径不退化。
|
|
349
|
+
*/
|
|
350
|
+
function awaitPendingRecall(input, attachmentsCount, placeholder) {
|
|
351
|
+
pendingRecall = { lines: input, attachmentsCount, placeholder };
|
|
352
|
+
layout.setStatus('发送中… (Esc / Ctrl+C 撤回)', '●');
|
|
353
|
+
// 非 TTY:setRawMode 抛错 → window=0 直返 true(向后退化,不走 raw + 不挂监听)。
|
|
354
|
+
let ttyReady = false;
|
|
355
|
+
try {
|
|
356
|
+
stdin.setRawMode(true);
|
|
357
|
+
ttyReady = true;
|
|
358
|
+
}
|
|
359
|
+
catch {
|
|
360
|
+
ttyReady = false;
|
|
361
|
+
}
|
|
362
|
+
if (!ttyReady) {
|
|
363
|
+
pendingRecall = null;
|
|
364
|
+
return Promise.resolve(true);
|
|
365
|
+
}
|
|
366
|
+
stdin.resume();
|
|
367
|
+
emitKeypressEvents(stdin);
|
|
368
|
+
return new Promise((resolve) => {
|
|
369
|
+
let done = false;
|
|
370
|
+
const finalize = (shouldCommit) => {
|
|
371
|
+
if (done)
|
|
372
|
+
return;
|
|
373
|
+
done = true;
|
|
374
|
+
if (pendingTimer) {
|
|
375
|
+
clearTimeout(pendingTimer);
|
|
376
|
+
pendingTimer = null;
|
|
377
|
+
}
|
|
378
|
+
emitter.off('keypress', onPendingKey);
|
|
379
|
+
pendingRecall = null;
|
|
380
|
+
resolve(shouldCommit);
|
|
381
|
+
};
|
|
382
|
+
const onPendingKey = (_str, key) => {
|
|
383
|
+
if (!key || done)
|
|
384
|
+
return;
|
|
385
|
+
// 鼠标报表:吞(与 prompt.ts / onRunningKey 风格一致)
|
|
386
|
+
if (mouse.swallow(key.sequence ?? ''))
|
|
387
|
+
return;
|
|
388
|
+
// 撤回
|
|
389
|
+
if (key.name === 'escape' || (key.ctrl && key.name === 'c')) {
|
|
390
|
+
finalize(false);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
// 立即 commit
|
|
394
|
+
if (key.name === 'enter' || key.name === 'return') {
|
|
395
|
+
finalize(true);
|
|
396
|
+
return;
|
|
397
|
+
}
|
|
398
|
+
// 其他键忽略
|
|
399
|
+
};
|
|
400
|
+
emitter.on('keypress', onPendingKey);
|
|
401
|
+
pendingTimer = setTimeout(() => finalize(true), PENDING_RECALL_MS);
|
|
402
|
+
pendingTimer.unref?.();
|
|
403
|
+
});
|
|
404
|
+
}
|
|
321
405
|
/** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
|
|
322
406
|
function textOf(c) {
|
|
323
407
|
if (typeof c === 'string')
|
|
@@ -408,13 +492,17 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
408
492
|
// 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
|
|
409
493
|
// 纯边界记录(不 chdir),jail.ts 内部 resolve。子 agent 同进程继承全局 root。
|
|
410
494
|
setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
|
|
411
|
-
// 构造系统提示:auto 用 base;plan 在
|
|
495
|
+
// 构造系统提示:auto 用 base;plan 在 base 后追加按当前开关现拼的 plan suffix。
|
|
412
496
|
// 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
|
|
413
497
|
// 活跃 plan 摘要拼在 memory 段后(systemPrompt 的尾段),todo 工具变更后 listener 重写 history[0]。
|
|
414
|
-
|
|
415
|
-
|
|
498
|
+
//
|
|
499
|
+
// 与开关联动:① base 用 buildBasePrompt() 取代 config.systemPrompt(后者是启动时一次性
|
|
500
|
+
// 求值的常量,运行时 /memory_switch 不会刷新);② plan suffix 走 getPlanModeSuffix() 现拼;
|
|
501
|
+
// ③ buildMemorySection 内已自决 ;④ buildMemoryIndexSection 显式传 isMemoryEnabled() 关闭段。
|
|
502
|
+
const buildSystemMessage = (planMode) => effectiveSystemPrompt(buildBasePrompt() +
|
|
503
|
+
(planMode ? getPlanModeSuffix() : '') +
|
|
416
504
|
buildMemorySection() +
|
|
417
|
-
buildMemoryIndexSection() +
|
|
505
|
+
buildMemoryIndexSection(isMemoryEnabled()) +
|
|
418
506
|
buildActivePlanSection());
|
|
419
507
|
// 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
|
|
420
508
|
// 否则新会话只塞 system 提示(默认 auto)。
|
|
@@ -434,6 +522,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
434
522
|
let currentSessionId = sessionId;
|
|
435
523
|
// 反思 cadence 计数:每 reflectEveryN 轮 fire-and-forget 一次后台反思 pass。
|
|
436
524
|
let turnCount = 0;
|
|
525
|
+
// 本轮 token 累计:runAgent 返回后写入,供底栏模式 chip 右边显示。undefined=无实测
|
|
526
|
+
// (后端不开 include_usage / 后端失败时)。
|
|
527
|
+
let lastTurnUsage;
|
|
437
528
|
const toolsLine = tools.map((t) => t.name).join(' · ');
|
|
438
529
|
const banner = () => ({
|
|
439
530
|
model: config.model,
|
|
@@ -598,10 +689,14 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
598
689
|
setAgentMode(planMode ? 'plan' : 'auto');
|
|
599
690
|
// 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
|
|
600
691
|
// 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
|
|
601
|
-
await runAgent(history, userInput, signal, () => {
|
|
692
|
+
const result = await runAgent(history, userInput, signal, () => {
|
|
602
693
|
refreshStatusBase(history);
|
|
603
694
|
layout.drawStatusBar();
|
|
604
695
|
});
|
|
696
|
+
// 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
|
|
697
|
+
lastTurnUsage = result.usage;
|
|
698
|
+
refreshStatusBase(history, lastTurnUsage); // 即时刷状态行显示本轮 token chip
|
|
699
|
+
layout.drawStatusBar();
|
|
605
700
|
ok = !signal.aborted; // 中断(Ctrl+C)→ runAgent 已还原 history,ok=false 不弹审批
|
|
606
701
|
// 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
|
|
607
702
|
if (!currentSessionId)
|
|
@@ -770,6 +865,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
770
865
|
currentSessionId = undefined; // 下轮起新会话文件
|
|
771
866
|
turnCount = 0; // 反思 cadence 重新计数
|
|
772
867
|
contextState.lastUsage = undefined;
|
|
868
|
+
lastTurnUsage = undefined; // 清空旧轮的 token 累计
|
|
773
869
|
pendingAttachments = []; // 一并清空待发图片
|
|
774
870
|
layout.clearContent();
|
|
775
871
|
layout.contentWrite(bannerString(banner()));
|
|
@@ -971,6 +1067,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
971
1067
|
if (!loadSnapshots(loaded.id))
|
|
972
1068
|
rebuildFromHistory(history);
|
|
973
1069
|
contextState.lastUsage = undefined;
|
|
1070
|
+
lastTurnUsage = undefined; // /resume:旧会话的 token 累计已无意义,清空等下轮覆写
|
|
974
1071
|
layout.clearContent();
|
|
975
1072
|
renderHistory(history);
|
|
976
1073
|
layout.contentWrite(`${ui.dim}(已续接会话 ${loaded.id})${ui.reset}\n`);
|
|
@@ -1206,6 +1303,89 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1206
1303
|
await rollbackFlow();
|
|
1207
1304
|
continue;
|
|
1208
1305
|
}
|
|
1306
|
+
if (line === '/memory_switch' ||
|
|
1307
|
+
line.startsWith('/memory_switch ') ||
|
|
1308
|
+
line === '/memory_status' ||
|
|
1309
|
+
line.startsWith('/memory_status ')) {
|
|
1310
|
+
// /memory_switch — 记忆子系统总开关。无参切换 on/off;/memory_switch on 或 /off 显式;
|
|
1311
|
+
// /memory_switch true|false|1|0|yes|no 等同义。/memory_status 只读查询(不写盘)。
|
|
1312
|
+
//
|
|
1313
|
+
// 设计原则:
|
|
1314
|
+
// - 单一来源 isMemoryEnabled():工具表(builtins)、系统提示词(Memory Index 段 + 工具使用说明)、
|
|
1315
|
+
// plan-mode 提示(tools/constants.ts)三处都从这里查。
|
|
1316
|
+
// - 当前会话的 tool list 是模块初始化时的快照(/memory_switch 不重算 builtinTools)——已发出
|
|
1317
|
+
// 请求的工具列表不会被回滚。要"完全生效"需要重启 REPL。但 buildSystemMessage 每次 chat 现拼,
|
|
1318
|
+
// 所以系统提示词和 plan suffix 会在「下一轮 chat」即时反映新值。
|
|
1319
|
+
// - 持久化字段 MEMORY_ENABLED,默认值 false(新用户零侵入)。
|
|
1320
|
+
try {
|
|
1321
|
+
if (line === '/memory_status' || line.startsWith('/memory_status ')) {
|
|
1322
|
+
const on = isMemoryEnabled();
|
|
1323
|
+
layout.contentWrite(`${ui.cyan}记忆子系统:${ui.reset} ${on ? `${ui.green}开启` : `${ui.yellow}关闭`}${ui.reset}\n`);
|
|
1324
|
+
layout.contentWrite(`${ui.dim} 单一来源 isMemoryEnabled()(${config.memoryEnabled});` +
|
|
1325
|
+
`持久化 ${ui.cyan}MEMORY_ENABLED${ui.dim};` +
|
|
1326
|
+
`配置文件 ${CONFIG_PATH}${ui.reset}\n`);
|
|
1327
|
+
layout.contentWrite(`${ui.dim} 关闭时:memory_*_save/_search/_list/_update/_forget 五个工具整体不进工具表;` +
|
|
1328
|
+
`buildBasePrompt() 不含「## Memory」段;` +
|
|
1329
|
+
`plan-mode 提示词里也不出现 memory_* 工具名。${ui.reset}\n`);
|
|
1330
|
+
layout.contentWrite(`${ui.dim} 切换后下次新建 system message 即时反映;当前会话工具表需重启 REPL 才完整重算。${ui.reset}\n`);
|
|
1331
|
+
continue;
|
|
1332
|
+
}
|
|
1333
|
+
// /memory_switch(无参=on/off 切换;有参=按值设)
|
|
1334
|
+
const arg = line.startsWith('/memory_switch ')
|
|
1335
|
+
? line.slice('/memory_switch '.length).trim().toLowerCase()
|
|
1336
|
+
: '';
|
|
1337
|
+
let nextEnabled;
|
|
1338
|
+
if (arg === '') {
|
|
1339
|
+
nextEnabled = !isMemoryEnabled();
|
|
1340
|
+
}
|
|
1341
|
+
else if (['on', 'true', '1', 'yes', 'y', 'enable', 'enabled'].includes(arg)) {
|
|
1342
|
+
nextEnabled = true;
|
|
1343
|
+
}
|
|
1344
|
+
else if (['off', 'false', '0', 'no', 'n', 'disable', 'disabled'].includes(arg)) {
|
|
1345
|
+
nextEnabled = false;
|
|
1346
|
+
}
|
|
1347
|
+
else {
|
|
1348
|
+
layout.contentWrite(`${ui.yellow}/memory_switch 用法:${ui.reset}\n` +
|
|
1349
|
+
` /memory_switch 切换(开↔关)\n` +
|
|
1350
|
+
` /memory_switch on|off 显式设值\n` +
|
|
1351
|
+
` /memory_switch status 等同 /memory_status\n`);
|
|
1352
|
+
continue;
|
|
1353
|
+
}
|
|
1354
|
+
const prev = isMemoryEnabled();
|
|
1355
|
+
if (nextEnabled === prev) {
|
|
1356
|
+
layout.contentWrite(`${ui.dim}(已是 ${nextEnabled ? '开启' : '关闭'},未变更 — 持久化字段未写入)${ui.reset}\n`);
|
|
1357
|
+
continue;
|
|
1358
|
+
}
|
|
1359
|
+
updateMemoryConfig(nextEnabled);
|
|
1360
|
+
// 写盘:mode 文件 values,/~/.mocode/config;writeConfigKeys 不会动其它键(主题 / 模型等)
|
|
1361
|
+
updateConfigKey('MEMORY_ENABLED', nextEnabled ? 'true' : 'false');
|
|
1362
|
+
const note = nextEnabled
|
|
1363
|
+
? `${ui.green}已开启记忆子系统${ui.reset} — memory_save/search/list/update/forget 进入工具表;` +
|
|
1364
|
+
`Memory Index 段会在下次拼 system message 时注入。工具表本身的快照需要重启 REPL 才完整刷新。`
|
|
1365
|
+
: `${ui.yellow}已关闭记忆子系统${ui.reset} — 五个 memory_* 工具将在下次拼 system message 时从工具表过滤;` +
|
|
1366
|
+
`Memory Index 段不再出现;plan-mode 提示词里的 memory_* 字样消失。重启 REPL 后工具表完全不出现。`;
|
|
1367
|
+
layout.contentWrite(`${note}\n`);
|
|
1368
|
+
layout.contentWrite(`${ui.dim}(写入 ${CONFIG_PATH}:MEMORY_ENABLED=${nextEnabled ? 'true' : 'false'};${ui.reset}` +
|
|
1369
|
+
(process.env.MEMORY_ENABLED
|
|
1370
|
+
? `${ui.dim}同 session shell 未 export,文件写入即时生效)${ui.reset}\n`
|
|
1371
|
+
: `${ui.dim}下次启动仍生效)${ui.reset}\n`));
|
|
1372
|
+
}
|
|
1373
|
+
catch (e) {
|
|
1374
|
+
layout.contentWrite(`${ui.red}/memory_switch 失败:${ui.reset} ${e.message}\n`);
|
|
1375
|
+
}
|
|
1376
|
+
continue;
|
|
1377
|
+
}
|
|
1378
|
+
const bubbleRows = input.length + 2 + pendingAttachments.length; // N 行 message + 2 行尾随空(含 \n\n 留下的 open current 行) + 每附件 1 行
|
|
1379
|
+
const shouldCommit = await awaitPendingRecall(input, pendingAttachments.length, placeholder);
|
|
1380
|
+
if (!shouldCommit) {
|
|
1381
|
+
// 撤回:气泡从内容区擦掉 + 行放回输入框(下轮 promptWithSlashMenu 经 initialLines 消费)+ 切回 INPUT 视觉。
|
|
1382
|
+
// pendingAttachments **保留** —— 撤回的是输入文本不是意图,再发时随消息一起带走
|
|
1383
|
+
// (runTurn 入口的 pendingAttachments.flush 仍按现有逻辑把附件塞进 userInput)。
|
|
1384
|
+
layout.rewindContent(bubbleRows);
|
|
1385
|
+
pendingPrefill = input;
|
|
1386
|
+
layout.enterInputMode('空闲');
|
|
1387
|
+
continue;
|
|
1388
|
+
}
|
|
1209
1389
|
const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
|
|
1210
1390
|
const ok = await runTurn(joined, initialPlan, placeholder);
|
|
1211
1391
|
// plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
|
|
@@ -1,5 +1,42 @@
|
|
|
1
1
|
import { promptIntervention } from '../../ui/intervention.js';
|
|
2
2
|
import { sendState } from '../../pet/bridge.js';
|
|
3
|
+
/** 将单个选项元素安全地转为可读字符串。
|
|
4
|
+
* LLM 有时会传对象(如 {name/label/title:"xxx", desc/description:"yyy"})而不是纯字符串,
|
|
5
|
+
* 直接 String(obj) 会变成 "[object Object]"——这里智能提取可读字段。 */
|
|
6
|
+
function optionToString(o) {
|
|
7
|
+
if (o === null || o === undefined)
|
|
8
|
+
return '';
|
|
9
|
+
if (typeof o === 'string')
|
|
10
|
+
return o;
|
|
11
|
+
if (typeof o === 'number' || typeof o === 'boolean')
|
|
12
|
+
return String(o);
|
|
13
|
+
if (typeof o === 'object') {
|
|
14
|
+
const obj = o;
|
|
15
|
+
// 优先取常见的标签字段
|
|
16
|
+
const labelKeys = ['label', 'name', 'title', 'text', 'option', 'choice', 'value', 'key'];
|
|
17
|
+
for (const k of labelKeys) {
|
|
18
|
+
const v = obj[k];
|
|
19
|
+
if (typeof v === 'string' && v.trim())
|
|
20
|
+
return v;
|
|
21
|
+
}
|
|
22
|
+
// 其次尝试 "label + description" 组合
|
|
23
|
+
const label = obj.label ?? obj.name ?? obj.title;
|
|
24
|
+
const desc = obj.description ?? obj.desc ?? obj.detail;
|
|
25
|
+
if (typeof label === 'string' && typeof desc === 'string') {
|
|
26
|
+
return `${label}: ${desc}`;
|
|
27
|
+
}
|
|
28
|
+
// 兜底:JSON 序列化(去掉大括号让它看起来不像代码)
|
|
29
|
+
try {
|
|
30
|
+
const s = JSON.stringify(obj);
|
|
31
|
+
// 如果是简单对象尝试美化
|
|
32
|
+
return s;
|
|
33
|
+
}
|
|
34
|
+
catch {
|
|
35
|
+
return String(o);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
return String(o);
|
|
39
|
+
}
|
|
3
40
|
/** 公开以便 check-ask-human-options.ts 单元测试。 */
|
|
4
41
|
export function coerceOptions(raw) {
|
|
5
42
|
// 路径 1:本身就是数组,map 成字符串。
|
|
@@ -11,14 +48,14 @@ export function coerceOptions(raw) {
|
|
|
11
48
|
try {
|
|
12
49
|
const parsed = JSON.parse(t);
|
|
13
50
|
if (Array.isArray(parsed))
|
|
14
|
-
return parsed.map(
|
|
51
|
+
return parsed.map(optionToString);
|
|
15
52
|
}
|
|
16
53
|
catch {
|
|
17
54
|
// 不是合法 JSON 数组,降级原值
|
|
18
55
|
}
|
|
19
56
|
}
|
|
20
57
|
}
|
|
21
|
-
return raw.map(
|
|
58
|
+
return raw.map(optionToString);
|
|
22
59
|
}
|
|
23
60
|
// 路径 2:LLM 直接把整个数组 stringify 成单字符串塞 options 字段(JSON.parse 出来是字符串)
|
|
24
61
|
// 例如 GLM 系经常这么做,arg h['options']='["A","B"]' → args.options='["A","B"]'
|
|
@@ -29,7 +66,7 @@ export function coerceOptions(raw) {
|
|
|
29
66
|
try {
|
|
30
67
|
const parsed = JSON.parse(t);
|
|
31
68
|
if (Array.isArray(parsed))
|
|
32
|
-
return parsed.map(
|
|
69
|
+
return parsed.map(optionToString);
|
|
33
70
|
}
|
|
34
71
|
catch {
|
|
35
72
|
// 不是合法 JSON,保留为单元素数组(对应 input 模式)
|
|
@@ -21,7 +21,26 @@ import { todolistTool } from './todolist.js';
|
|
|
21
21
|
/**
|
|
22
22
|
* 所有内置工具,按注册顺序排列。
|
|
23
23
|
* 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。无需改 agent / llm。
|
|
24
|
+
*
|
|
25
|
+
* 记忆子系统总开关(MEMORY_ENABLED !== 'true'):5 个 memory_* 工具整体不进 builtinTools,
|
|
26
|
+
* 进而不进 LLM 的工具表(模型根本看不到、也不会想着去调)。运行时通过 /memory_switch 切;
|
|
27
|
+
* 切换对当前会话的 tool list 不重算(取的是模块初始化时的快照),所以需要重启 REPL 才生效
|
|
28
|
+
* —— 这是有意为之,避免切开关瞬间把已发出请求的工具列表打乱。
|
|
29
|
+
*
|
|
30
|
+
* 注:这里直接读 env(MEMORY_ENABLED)而不是调 config.isMemoryEnabled(),因为本模块可能在
|
|
31
|
+
* config 单例尚未初始化时被其它模块拉起(import 链路:tools/registry → builtinTools,
|
|
32
|
+
* config 单例字段 getter 在 getPlanDisabledTools 等调用链路上 lazy 求值)。
|
|
24
33
|
*/
|
|
34
|
+
const _memoryEnabledAtBoot = process.env.MEMORY_ENABLED === 'true';
|
|
35
|
+
const _memoryTools = _memoryEnabledAtBoot
|
|
36
|
+
? [
|
|
37
|
+
memorySaveTool,
|
|
38
|
+
memorySearchTool,
|
|
39
|
+
memoryListTool,
|
|
40
|
+
memoryUpdateTool,
|
|
41
|
+
memoryForgetTool,
|
|
42
|
+
]
|
|
43
|
+
: [];
|
|
25
44
|
export const builtinTools = [
|
|
26
45
|
readFileTool,
|
|
27
46
|
writeFileTool,
|
|
@@ -36,11 +55,7 @@ export const builtinTools = [
|
|
|
36
55
|
askHumanTool,
|
|
37
56
|
switchModeTool, // plan↔auto 自切(两模式都可见,不进 PLAN_DISABLED_TOOLS;副作用控制工具→串行分支)
|
|
38
57
|
dropContextTool, // 运行中剔除无关 tool 结果(上下文管理,无副作用;两模式都可见,串行分支)
|
|
39
|
-
|
|
40
|
-
memorySearchTool,
|
|
41
|
-
memoryListTool,
|
|
42
|
-
memoryUpdateTool,
|
|
43
|
-
memoryForgetTool,
|
|
58
|
+
..._memoryTools,
|
|
44
59
|
taskTool, // 派生子 agent(独立 history + 可受限工具集);plan 模式禁用(见 PLAN_DISABLED_TOOLS)
|
|
45
60
|
todolistTool, // 工作记事本(plan 文件:复杂任务 checklist,落盘抗压缩);plan 模式可用(便于「先 plan 再 auto」时落地执行清单)
|
|
46
61
|
];
|
package/dist/tools/constants.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
/** 工具共享的截断 / 上限 / 忽略规则。 */
|
|
2
|
+
import { isMemoryEnabled } from '../config/index.js';
|
|
2
3
|
export const MAX_FILE_LINES = 2000;
|
|
3
4
|
export const MAX_OUTPUT = 20000;
|
|
4
5
|
export const MAX_RESULTS = 100;
|
|
@@ -55,3 +56,18 @@ export const PLAN_DISABLED_TOOLS = new Set([
|
|
|
55
56
|
'memory_forget',
|
|
56
57
|
'task',
|
|
57
58
|
]);
|
|
59
|
+
/**
|
|
60
|
+
* 按当前 isMemoryEnabled() 现算 plan 模式应屏蔽的工具。
|
|
61
|
+
* memoryEnabled=false 时记忆工具整体不在 builtinTools 里,plan 屏蔽集里也无须再列 ——
|
|
62
|
+
* 反之留着只是死名字。统一过滤,避免 Set 里残留与已下架工具不一致的概念性冗余。
|
|
63
|
+
* 调用方(agent/core 串行分支、llm/planChatTools)每次 chat 时调本函数拿当前值。
|
|
64
|
+
*/
|
|
65
|
+
export function getPlanDisabledTools() {
|
|
66
|
+
if (isMemoryEnabled())
|
|
67
|
+
return PLAN_DISABLED_TOOLS;
|
|
68
|
+
const next = new Set(PLAN_DISABLED_TOOLS);
|
|
69
|
+
next.delete('memory_save');
|
|
70
|
+
next.delete('memory_update');
|
|
71
|
+
next.delete('memory_forget');
|
|
72
|
+
return next;
|
|
73
|
+
}
|
package/dist/ui/content.js
CHANGED
|
@@ -55,6 +55,33 @@ export function breakRow() {
|
|
|
55
55
|
rowStartSgr = curSgr; // 下行继承本行末状态
|
|
56
56
|
hasCurrent = true; // 新空行即当前行
|
|
57
57
|
}
|
|
58
|
+
/**
|
|
59
|
+
* 弹出 buffer 末尾 n 物理行(供 layout.rewindContent 撤回刚写入段用)。
|
|
60
|
+
* 当前行先 commit 再裁剪(消除 hasCurrent 边界);n ≥ totalRows 则全清,
|
|
61
|
+
* 但**不动 segMark** —— recall 走普通 contentWrite、不撞 md 段;
|
|
62
|
+
* segMark 由 reset() / commitSegment() 清,recall 不掺和。
|
|
63
|
+
*/
|
|
64
|
+
export function rewind(n) {
|
|
65
|
+
if (n <= 0)
|
|
66
|
+
return;
|
|
67
|
+
const cur = totalRows();
|
|
68
|
+
if (n >= cur) {
|
|
69
|
+
rows = [];
|
|
70
|
+
curSgr = '';
|
|
71
|
+
rowStartSgr = '';
|
|
72
|
+
curRaw = '';
|
|
73
|
+
hasCurrent = false;
|
|
74
|
+
return;
|
|
75
|
+
}
|
|
76
|
+
// 当前行先 commit 再裁剪(否则 hasCurrent 边界会被打穿)
|
|
77
|
+
if (hasCurrent) {
|
|
78
|
+
rows.push(rowStartSgr + curRaw + '\x1B[0m');
|
|
79
|
+
curRaw = '';
|
|
80
|
+
rowStartSgr = curSgr;
|
|
81
|
+
hasCurrent = false;
|
|
82
|
+
}
|
|
83
|
+
rows.splice(rows.length - n);
|
|
84
|
+
}
|
|
58
85
|
/** 标记段起点:快照当前缓冲状态,供 setLines 截断定位段头(md 流式渲染每 chunk 截断重渲)。 */
|
|
59
86
|
export function beginSegment() {
|
|
60
87
|
segMark = { rowIdx: rows.length, rowStartSgr, curSgr, curRaw, hasCurrent };
|