mocode-ai 0.2.9 → 0.3.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.
- package/README.md +11 -4
- package/README.zh-CN.md +11 -4
- package/dist/agent/index.js +8 -1
- package/dist/attachments/image.js +78 -0
- package/dist/config/index.js +14 -8
- package/dist/llm/capabilities.js +82 -0
- package/dist/llm/index.js +181 -5
- package/dist/plan/index.js +1 -1
- package/dist/plan/store.js +105 -27
- package/dist/repl/index.js +108 -3
- package/dist/session/compact.js +33 -1
- package/dist/tools/builtins/todolist.js +77 -13
- package/dist/ui/layout.js +1 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -18,16 +18,18 @@ MoCode isn't a chat box with a coat of paint — it's an agent that actually get
|
|
|
18
18
|
- **Plan / Auto dual mode** — In `plan` mode the agent is read-only (reads code, queries indexes, searches — never writes to disk, runs commands, or spawns sub-agents) and produces a plan; `auto` mode unlocks the full toolset. The agent can switch between the two on its own — scope out an unfamiliar codebase first, then start making changes.
|
|
19
19
|
- **Automatic context compression** — As the context window fills up, a three-tier compression kicks in (trim individual results → compact older tool results in place → summarize older turns), so long sessions never overflow. `/context` shows live token usage; `/compact` triggers manual compression (optionally with a focus hint to preserve what matters).
|
|
20
20
|
- **Cross-session long-term memory** — The agent can save project architecture, conventions, and lessons learned as long-term memory, auto-loaded in future sessions. A background process periodically reflects on conversations to mine things worth remembering. Memories can be created, searched, updated, and forgotten, with recall-based decay.
|
|
21
|
+
- **Working notepad (todolist)** — For complex multi-step tasks (≥3 file changes / ≥5 tool calls), the agent first writes a plan to `.mocode/plans/<id>.md` (file-based, survives context compression), then ticks each step as it goes. A live progress chip in the TUI status bar shows `plan: [title] (3/7) ▸ [current step]`. `finish` auto-archives completed plans to `plans/archive/`, with explicit `list / delete / unarchive` actions.
|
|
21
22
|
- **Interruptible and reversible** — Ctrl+C interrupts the current turn at any time (kills child processes recursively, rolls history back to before the turn started, leaves no half-finished tool calls). `/rollback` restores file changes from per-turn snapshots, with a per-file keep/undo choice — no git dependency required.
|
|
22
23
|
- **Sandbox protection** — File reads/writes go through a sandbox that blocks out-of-bounds paths (`../../`, absolute paths outside the root, symlink escapes, etc.), so the agent never touches files outside your working directory.
|
|
23
24
|
|
|
24
25
|
## Features
|
|
25
26
|
|
|
26
|
-
- **Streaming output + visible reasoning** — Responses render as they're generated; when the model supports reasoning, the thinking process is visible in real time and auto-collapses to save screen space
|
|
27
|
+
- **Streaming output + visible reasoning** — Responses render as they're generated; when the model supports reasoning, the thinking process is visible in real time and auto-collapses to save screen space.
|
|
27
28
|
- **Full-screen TUI** — Alt-screen mode with a fixed status bar, scrollback (PgUp/PgDn), typeahead while the agent is running, and auto-prefill for the next turn.
|
|
28
29
|
- **Session persistence** — Every turn is saved automatically; `--resume` / `/resume` picks up a past session.
|
|
29
30
|
- **Skills system** — Scans directories like `~/.mocode/skills/` automatically; each skill's description is injected into the system prompt, and the model calls `use_skill` to load the full instructions only when relevant (progressive disclosure: skim the summary first, load the body only if needed).
|
|
30
|
-
- **
|
|
31
|
+
- **Optional desktop pet** — A small floating window (`/pet`) shows a stateful character that mirrors agent activity (idle / thinking / tool running / waiting for human). Works as a separate process over WebSocket; quit it with `/pet quit`. Sits beside the terminal, never blocks it.
|
|
32
|
+
- **Slash commands** — `/exit` `/clear` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`, with dropdown filtering as you type.
|
|
31
33
|
|
|
32
34
|
## Installation
|
|
33
35
|
|
|
@@ -141,6 +143,7 @@ The agent operates in **the working directory it was launched from** — to have
|
|
|
141
143
|
| `switch_mode` | Switch between `plan` (read-only planning) and `auto` (full execution); the agent can call this itself to explore before acting |
|
|
142
144
|
| `drop_context` | Replace irrelevant old tool results in history with stubs to free up context (preserves tool_call_id pairing, leaves system prompt and current turn untouched, idempotent) |
|
|
143
145
|
| `task` | Spawn a sub-agent for an independent subtask (isolated history, optional restricted toolset, optional step cap); consecutive calls run in parallel automatically, returning only a summary |
|
|
146
|
+
| `todolist` | Working notepad: write a multi-step plan to `.mocode/plans/<id>.md` (survives compression) and tick steps as you go; `finish` auto-archives, with `list / delete / unarchive` for history |
|
|
144
147
|
| `memory_save` | Save a piece of cross-session long-term memory (title indexed, body fetched on demand) |
|
|
145
148
|
| `memory_search` | Search memory bodies by keyword; hits boost the recall count (affects forgetting decay) |
|
|
146
149
|
| `memory_list` | List the memory index (id/title/summary, no body) |
|
|
@@ -157,13 +160,17 @@ The agent operates in **the working directory it was launched from** — to have
|
|
|
157
160
|
| `/skills` | List discovered skills |
|
|
158
161
|
| `/compact` | Compress history (optionally with a focus hint: `/compact …`) |
|
|
159
162
|
| `/resume` | Resume a saved session |
|
|
160
|
-
| `/think` | Expand a collapsed reasoning section (`/think N`) |
|
|
161
163
|
| `/rollback` | Menu to pick a turn to roll back to (↑↓ · Enter) |
|
|
164
|
+
| `/memory` | Show memory library: entry count + recent index |
|
|
165
|
+
| `/reflect` | Manually trigger a background memory reflection pass |
|
|
162
166
|
| `/model` | Configure the LLM (baseURL / apiKey / model / context window), applied immediately + persisted |
|
|
163
167
|
| `/init` | Scan the project and generate `MOCODE.md` project memory (dispatched to the agent) |
|
|
164
168
|
| `/theme` | Switch color theme (↑↓ · Enter, or `/theme <name>` directly) |
|
|
165
169
|
| `/plan` | Switch to plan mode (read-only exploration + plan output, approve to switch to auto) |
|
|
166
170
|
| `/auto` | Switch back to auto mode (full toolset execution) |
|
|
171
|
+
| `/pet` | Toggle the optional desktop pet (floating window mirroring agent state) |
|
|
172
|
+
| `/pet skin` | Pick a pet skin (↑↓ · Enter) |
|
|
173
|
+
| `/pet quit` | Fully shut down the pet process (not just disconnect) |
|
|
167
174
|
|
|
168
175
|
Type `/` to trigger the dropdown menu, keep typing to filter; Esc to cancel.
|
|
169
176
|
|
|
@@ -199,4 +206,4 @@ npm run typecheck # tsc --noEmit
|
|
|
199
206
|
|
|
200
207
|
## Future extensions
|
|
201
208
|
|
|
202
|
-
|
|
209
|
+
MCP tool integration, a permission confirmation UI, and a real worktree-isolated sub-agent mode. The current version is a streaming, reasoning-visible, rollback-capable terminal coding agent with 20 tools, working-notepad planning, cross-session memory, parallel sub-agents, and an optional desktop pet.
|
package/README.zh-CN.md
CHANGED
|
@@ -18,16 +18,18 @@ mocode 不是一个套壳聊天框,而是一个能真正动手干活的 agent:
|
|
|
18
18
|
- **计划 / 执行双模式** — `plan` 模式下只读探查(读代码、查索引、搜索,绝不写盘、不跑命令、不派生子 agent),产出计划;`auto` 模式全量工具放开。agent 还能在两者间自切换——先把陌生代码库摸清,再动手改。
|
|
19
19
|
- **上下文自动压缩** — 接近窗口上限时三层压缩(单条结果裁剪 → 旧工具结果原地微压缩 → 旧对话摘要),长会话也不爆窗口;`/context` 实时显示 token 用量,`/compact` 可手动压缩(能带焦点指令聚焦保留)。
|
|
20
20
|
- **跨会话长期记忆** — agent 能把项目架构、约定、踩过的坑存成长期记忆,下次会话自动加载;后台还会定期从对话里反思挖掘值得记住的事。记忆可增删改、带召回衰减。
|
|
21
|
+
- **工作记事本(todolist)** — 复杂多步任务(≥3 处文件改动 / ≥5 步工具调用)时,agent 先把计划写到 `.mocode/plans/<id>.md`(落盘抗压缩),执行过程逐步勾选;TUI 状态栏实时显示进度 chip:`plan: [标题] (3/7) ▸ [当前步]`。`finish` 自动归档到 `plans/archive/`,`list / delete / unarchive` 管理历史。
|
|
21
22
|
- **可中断、可回滚** — Ctrl+C 随时打断当前轮次(树杀子进程,历史还原到本轮开始前,不留残半的工具调用);`/rollback` 按轮次快照恢复文件改动,逐个文件「保留/撤销」,不依赖 git。
|
|
22
23
|
- **沙箱防护** — 文件读写经沙箱拦截,挡掉越界路径(`../../`、绝对外圈、软链出圈等),不碰工作目录之外的文件。
|
|
23
24
|
|
|
24
25
|
## 特性
|
|
25
26
|
|
|
26
|
-
- **流式输出 + 思考可见** — 回复边生成边显示;模型支持 reasoning 时思考过程实时可见,思考段自动折叠(不占屏)
|
|
27
|
+
- **流式输出 + 思考可见** — 回复边生成边显示;模型支持 reasoning 时思考过程实时可见,思考段自动折叠(不占屏)
|
|
27
28
|
- **全屏 TUI** — 备用屏(alt screen)+ 固定底栏状态行 + 滚动回看(PgUp/PgDn),运行中可打字(typeahead),下一轮自动预填
|
|
28
29
|
- **会话持久化** — 每轮自动落盘,`--resume` / `/resume` 续接历史会话
|
|
29
30
|
- **Skills 系统** — 自动扫描 `~/.mocode/skills/` 等目录,description 注入系统提示,模型按需调 `use_skill` 加载完整指令(渐进式披露:先看简介,任务相关才加载正文)
|
|
30
|
-
-
|
|
31
|
+
- **可选桌宠** — 独立悬浮窗(`/pet`)显示一个小角色,镜像 agent 活动(空闲 / 思考 / 跑工具 / 等人工),独立进程走 WebSocket,`/pet quit` 完全关闭。挂在终端外,绝不挡终端。
|
|
32
|
+
- **斜杠命令** — `/exit` `/clear` `/context` `/skills` `/compact` `/resume` `/rollback` `/memory` `/reflect` `/init` `/theme` `/model` `/plan` `/auto` `/pet`,输入时下拉过滤
|
|
31
33
|
|
|
32
34
|
## 安装
|
|
33
35
|
|
|
@@ -141,6 +143,7 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
|
|
|
141
143
|
| `switch_mode` | 在 `plan`(只读规划)与 `auto`(全量执行)间切换;agent 可自行调用,先探查再动手 |
|
|
142
144
|
| `drop_context` | 把历史里无关的旧工具结果替换为存根释放上下文(保 tool_call_id 配对,不动 system 与当前轮;幂等) |
|
|
143
145
|
| `task` | 派生子 agent 执行独立子任务(独立历史、可受限工具集、可设步数上限);连续多个自动并行,只回摘要 |
|
|
146
|
+
| `todolist` | 工作记事本:把多步计划写到 `.mocode/plans/<id>.md`(抗压缩),边执行边勾选;`finish` 自动归档,`list / delete / unarchive` 管历史 |
|
|
144
147
|
| `memory_save` | 存一条跨会话长期记忆(标题进索引,正文按需取) |
|
|
145
148
|
| `memory_search` | 按关键词搜记忆正文,命中即提升召回计数(影响遗忘衰减) |
|
|
146
149
|
| `memory_list` | 列记忆索引(id/标题/摘要,无正文) |
|
|
@@ -157,13 +160,17 @@ agent 工作在**启动时所在的工作目录**——想让它操作某个项
|
|
|
157
160
|
| `/skills` | 列出已发现的 skill |
|
|
158
161
|
| `/compact` | 压缩历史(可带焦点 `/compact …`) |
|
|
159
162
|
| `/resume` | 续接已保存的会话 |
|
|
160
|
-
| `/think` | 展开折叠思考段(`/think N`) |
|
|
161
163
|
| `/rollback` | 菜单选轮次回滚(↑↓ · Enter) |
|
|
164
|
+
| `/memory` | 看记忆库:条目数 + 近期索引 |
|
|
165
|
+
| `/reflect` | 手动触发一次后台记忆反思 pass |
|
|
162
166
|
| `/model` | 配置大模型(baseURL / apiKey / model / 上下文窗口),即时生效 + 持久化 |
|
|
163
167
|
| `/init` | 扫描项目生成 `MOCODE.md` 项目记忆(发给 agent 执行) |
|
|
164
168
|
| `/theme` | 切换颜色主题(↑↓ · Enter,或 `/theme <name>` 直切) |
|
|
165
169
|
| `/plan` | 切到 plan 模式(只读探查 + 产出计划,审批后切 auto 执行) |
|
|
166
170
|
| `/auto` | 切回 auto 模式(全量工具执行) |
|
|
171
|
+
| `/pet` | 开关桌宠(独立悬浮窗,镜像 agent 状态动画) |
|
|
172
|
+
| `/pet skin` | 选桌宠皮肤(↑↓ · Enter) |
|
|
173
|
+
| `/pet quit` | 完全关闭桌宠进程(而非仅断开本连接) |
|
|
167
174
|
|
|
168
175
|
输入 `/` 触发下拉菜单,继续打字过滤;Esc 取消。
|
|
169
176
|
|
|
@@ -199,4 +206,4 @@ npm run typecheck # tsc --noEmit
|
|
|
199
206
|
|
|
200
207
|
## 可后续扩展
|
|
201
208
|
|
|
202
|
-
|
|
209
|
+
MCP 工具集成、权限确认 UI、真·worktree 隔离的子 agent 模式。当前版本已是流式、思考可见、可回滚的终端编码 agent:20 个工具、工作记事本规划、跨会话记忆、并行子 agent、可选桌宠。
|
package/dist/agent/index.js
CHANGED
|
@@ -11,6 +11,13 @@ import { beginTurn } from '../rollback/index.js';
|
|
|
11
11
|
import { config } from '../config/index.js';
|
|
12
12
|
import { runAgentCore, isMutationTool, } from './core.js';
|
|
13
13
|
import { createPetHooks } from '../pet/state.js';
|
|
14
|
+
/** 取 userInput 的首行:字符串直接 split;多模态 parts 找首个 text part 再 split。 */
|
|
15
|
+
function firstLineOf(ui) {
|
|
16
|
+
if (typeof ui === 'string')
|
|
17
|
+
return ui.split('\n')[0] ?? '';
|
|
18
|
+
const first = ui.find((p) => p.type === 'text');
|
|
19
|
+
return first?.text.split('\n')[0] ?? '';
|
|
20
|
+
}
|
|
14
21
|
/** 工具调用 ● 头:工具名 + 参数摘要(按 tool_calls 原顺序打印,让用户看到本轮跑哪些工具)。 */
|
|
15
22
|
function writeToolHeader(tc) {
|
|
16
23
|
const summary = summarizeToolCall(tc.name, tc.arguments);
|
|
@@ -54,7 +61,7 @@ export async function runAgent(history, userInput, signal,
|
|
|
54
61
|
/** 每步 chat() 返回后回调:repl 据此重算并重画状态行 context 用量条(运行中实时刷新,不冻结在轮首)。 */
|
|
55
62
|
onContextUpdate) {
|
|
56
63
|
// 开新轮次(回滚用):首行截断 40,供 /rollback 轮次菜单展示。
|
|
57
|
-
beginTurn(truncateDisplay(userInput
|
|
64
|
+
beginTurn(truncateDisplay(firstLineOf(userInput), 40));
|
|
58
65
|
layout.contentMode(); // 防御性:运行态光标归输入框光标位供 IME 锚定(enterRunningMode 已置,这里兜底)
|
|
59
66
|
// spinner:状态行最前面转圈(思考中 / 生成 / 执行 工具时,状态栏 lead 位显帧 + 文字)。
|
|
60
67
|
// 经 setStatus 注入状态行(spinnerFrame + statusText),composeStatus 把帧 + 文字放 lead 位;
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
2
|
+
import { basename, extname } from 'node:path';
|
|
3
|
+
import { createHash } from 'node:crypto';
|
|
4
|
+
import { jailResolve } from '../sandbox/jail.js';
|
|
5
|
+
export const MAX_INLINE_BYTES_DEFAULT = 4 * 1024 * 1024;
|
|
6
|
+
const MIME_BY_EXT = {
|
|
7
|
+
'.png': 'image/png',
|
|
8
|
+
'.jpg': 'image/jpeg',
|
|
9
|
+
'.jpeg': 'image/jpeg',
|
|
10
|
+
'.gif': 'image/gif',
|
|
11
|
+
'.webp': 'image/webp',
|
|
12
|
+
};
|
|
13
|
+
export function detectMime(p) {
|
|
14
|
+
return MIME_BY_EXT[extname(p).toLowerCase()] ?? null;
|
|
15
|
+
}
|
|
16
|
+
function formatBytes(n) {
|
|
17
|
+
if (n < 1024)
|
|
18
|
+
return `${n} B`;
|
|
19
|
+
if (n < 1024 * 1024)
|
|
20
|
+
return `${(n / 1024).toFixed(1)} KB`;
|
|
21
|
+
return `${(n / 1024 / 1024).toFixed(1)} MB`;
|
|
22
|
+
}
|
|
23
|
+
export function renderChip(att) {
|
|
24
|
+
return `📷 ${att.name} (${formatBytes(att.bytes)})`;
|
|
25
|
+
}
|
|
26
|
+
export async function loadImageAttachment(input, opts) {
|
|
27
|
+
const trimmed = input.trim();
|
|
28
|
+
if (!trimmed)
|
|
29
|
+
return { ok: false, reason: '路径为空' };
|
|
30
|
+
const mime = detectMime(trimmed);
|
|
31
|
+
if (!mime) {
|
|
32
|
+
return { ok: false, reason: `unsupported: ${extname(trimmed) || '(无扩展名)'} — 仅支持 png/jpg/jpeg/gif/webp` };
|
|
33
|
+
}
|
|
34
|
+
let abs;
|
|
35
|
+
try {
|
|
36
|
+
abs = jailResolve(trimmed);
|
|
37
|
+
}
|
|
38
|
+
catch (e) {
|
|
39
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
40
|
+
return { ok: false, reason: `outside sandbox: ${msg}` };
|
|
41
|
+
}
|
|
42
|
+
let st;
|
|
43
|
+
try {
|
|
44
|
+
st = await stat(abs);
|
|
45
|
+
}
|
|
46
|
+
catch (e) {
|
|
47
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
48
|
+
return { ok: false, reason: `无法访问文件: ${msg}` };
|
|
49
|
+
}
|
|
50
|
+
if (!st.isFile())
|
|
51
|
+
return { ok: false, reason: '不是普通文件' };
|
|
52
|
+
if (st.size > opts.maxBytes) {
|
|
53
|
+
return {
|
|
54
|
+
ok: false,
|
|
55
|
+
reason: `too large: ${formatBytes(st.size)} (max ${formatBytes(opts.maxBytes)}) — TODO: URL upload not yet supported`,
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
const buf = await readFile(abs);
|
|
59
|
+
const dataUrl = `data:${mime};base64,${buf.toString('base64')}`;
|
|
60
|
+
const id = createHash('sha1')
|
|
61
|
+
.update(abs)
|
|
62
|
+
.update('\0')
|
|
63
|
+
.update(String(st.size))
|
|
64
|
+
.update('\0')
|
|
65
|
+
.update(String(st.mtimeMs))
|
|
66
|
+
.digest('hex');
|
|
67
|
+
return {
|
|
68
|
+
ok: true,
|
|
69
|
+
att: {
|
|
70
|
+
id,
|
|
71
|
+
path: abs,
|
|
72
|
+
name: basename(abs),
|
|
73
|
+
bytes: st.size,
|
|
74
|
+
mime,
|
|
75
|
+
dataUrl,
|
|
76
|
+
},
|
|
77
|
+
};
|
|
78
|
+
}
|
package/dist/config/index.js
CHANGED
|
@@ -91,13 +91,15 @@ ${PLATFORM_NOTE}
|
|
|
91
91
|
- Use web_search for information beyond training data (new versions, news, real-time data, latest APIs); don't answer potentially outdated info from memory.
|
|
92
92
|
- Use web_fetch to read a specific URL (a link from search results, or a URL given by the user); it only fetches static HTML — if a JS-rendered page yields no body, switch to web_search (its results include cleaned body text).
|
|
93
93
|
- Call ask_human when you hit a decision point requiring user input (multiple implementation approaches, unclear intent, or needing extra info to proceed) — list options for the user to pick (they can also choose "custom input" to answer freely). Don't call it frequently when the task is clear and you can decide yourself; if the user cancels, switch approach or proceed with available info — don't re-ask the same question.
|
|
94
|
-
- **Drop irrelevant context** (use sparingly): call drop_context to stub-replace tool results in history that are BOTH (a) irrelevant to the current task AND (b) large (the freed tokens must clearly exceed the ~300 tokens the call itself costs — roughly only worth it when targeting ≥2 bulky results, e.g. wide grep/read sweeps that returned mostly-irrelevant hits). The call itself adds a tool-call round-trip, so don't call it for one small result or when you're near done. It preserves tool_call_id pairing (only content changes); the system prompt and current turn are never dropped. Use filters (toolNames / contains) to target precisely.
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
94
|
+
- **Drop irrelevant context** (use sparingly): call drop_context to stub-replace tool results in history that are BOTH (a) irrelevant to the current task AND (b) large (the freed tokens must clearly exceed the ~300 tokens the call itself costs — roughly only worth it when targeting ≥2 bulky results, e.g. wide grep/read sweeps that returned mostly-irrelevant hits). The call itself adds a tool-call round-trip, so don't call it for one small result or when you're near done. It preserves tool_call_id pairing (only content changes); the system prompt and current turn are never dropped. Use filters (toolNames / contains) to target precisely.
|
|
95
|
+
- **Batch independent tool calls in one turn**: the executor runs ALL returned tool calls before the next LLM call, so emitting [read_file, glob, read_file] together is dramatically cheaper than three separate turns. Default to bundling exploration reads and parallel writes.
|
|
96
|
+
- **Chain shell workflows in a single \`run_command\`**: use \`&&\`, \`;\`, \`|\`, \`>\`, heredocs to fold multi-step scripts (\`mkdir -p x && cat > x/file.ts <<'EOF' ... EOF && npm test\`) into one call. Only emit a follow-up turn when the result forces a decision (error, ambiguous output, branching logic).
|
|
97
|
+
|
|
98
|
+
## Large file writes (avoid token-cap truncation)
|
|
99
|
+
- \`write_file\` / \`edit_file\` arguments are part of the model's JSON output — a single tool call's content > ~5K tokens risks mid-stream truncation when the model's max output (default 8K–16K tokens) is exceeded, producing a "arguments 不是合法 JSON" error. Even with \`MAX_TOKENS=32000\` set, huge files still risk truncation.
|
|
100
|
+
- **For large files (rough threshold: >200 lines OR >5K tokens of content)**, default to one of these strategies instead of one giant \`write_file\`:
|
|
101
|
+
- **Skeleton + edit**: \`write_file\` a small skeleton (head + placeholders), then call \`edit_file\` repeatedly to append/replace sections — each edit stays well under the cap, and partial progress survives a stream error.
|
|
102
|
+
- **Shell heredoc**: \`run_command\` with \`cat > path <<'EOF' ... EOF\` (bash) or \`Set-Content -Path ... -Value @"..."@\` (PowerShell) — the file content bypasses the model's JSON output entirely, so no token cap applies. Prefer this for generated/structured content (JSON config, full HTML pages, large code dumps).
|
|
101
103
|
- For small files (≤200 lines, ≤5K tokens) just use \`write_file\` directly — no need to over-engineer.
|
|
102
104
|
|
|
103
105
|
## Failure Handling
|
|
@@ -122,7 +124,8 @@ ${PLATFORM_NOTE}
|
|
|
122
124
|
## Working notepad (todolist) — checklist for complex tasks
|
|
123
125
|
- For **complex multi-step tasks** (≥3 file changes OR ≥5 tool calls expected OR user says "先计划再执行" / "plan then do" / "按步骤来"), call the \`todolist\` tool FIRST to write a plan to \`.mocode/plans/<id>.md\`, then execute step by step, calling \`todolist update\` to mark progress. For trivial single-step tasks, skip it and just execute.
|
|
124
126
|
- The plan is file-backed (survives context compression, user can see/edit). The active plan summary is auto-injected into the system prompt each turn, so you can re-read it via \`todolist read\` whenever you're unsure of your place.
|
|
125
|
-
- Single plan per session: \`todolist create\` refuses if an in-progress plan already exists — finish or abandon it first.
|
|
127
|
+
- Single plan per session: \`todolist create\` refuses if an in-progress plan already exists — finish or abandon it first.
|
|
128
|
+
- **Lifecycle** (5 actions total): \`create\` / \`read\` / \`update\` / \`add_step\` / \`finish\` for normal flow. \`finish plan_status=finished\` AUTO-ARCHIVES the plan to \`.mocode/plans/archive/<id>.md\` (history preserved, active dir stays clean). To revisit old plans: \`list scope=archived\` (or \`all\`) + \`unarchive id=<id>\` to bring back. \`delete id=<id>\` permanently removes (any location); cannot delete the currently active plan.
|
|
126
129
|
- Don't over-use it: for a single edit or a quick lookup, \`todolist\` is overhead. The threshold is "this needs ≥3 steps OR I might forget the plan after context compaction."
|
|
127
130
|
|
|
128
131
|
## Termination & Reporting
|
|
@@ -160,6 +163,9 @@ export const config = {
|
|
|
160
163
|
searchApiKey: process.env.ANYSEARCH_API_KEY,
|
|
161
164
|
sandboxRoot: process.env.SANDBOX_ROOT || undefined,
|
|
162
165
|
searchBaseUrl: process.env.ANYSEARCH_BASE_URL || 'https://api.anysearch.com',
|
|
166
|
+
maxImageBytes: process.env.MOCODE_MAX_IMAGE_BYTES
|
|
167
|
+
? Number(process.env.MOCODE_MAX_IMAGE_BYTES)
|
|
168
|
+
: undefined,
|
|
163
169
|
theme: process.env.MOCODE_THEME || 'default',
|
|
164
170
|
themeFromShell,
|
|
165
171
|
llmKeysFromShell,
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 模型能力检测:仅用于 /image 等多模态输入的友好提示。
|
|
3
|
+
*
|
|
4
|
+
* 策略 — 保守 + 显式白名单/黑名单:
|
|
5
|
+
* - 命中 KNOWN_TEXT_ONLY_PREFIXES 任一前缀 → false(确定无视觉)
|
|
6
|
+
* - 命中 KNOWN_VISION_FAMILIES 任一前缀 → true(确定有视觉)
|
|
7
|
+
* - 其它(未识别 / 自定义代理 / 新发布模型)→ 默认 true(放行,让 API 拒收兜底)
|
|
8
|
+
*
|
|
9
|
+
* 理由:视觉能力在 2024 后已成新模型默认;自定义 OpenAI 代理常把上游视觉模型重命名;
|
|
10
|
+
* 过度保守会误伤合法配置。
|
|
11
|
+
*/
|
|
12
|
+
const KNOWN_TEXT_ONLY_PREFIXES = [
|
|
13
|
+
'gpt-3.5',
|
|
14
|
+
'gpt-3.5-turbo',
|
|
15
|
+
'gpt-4-0613', // 早期 gpt-4,无视觉
|
|
16
|
+
'gpt-4-0314',
|
|
17
|
+
'gpt-4-32k',
|
|
18
|
+
'o1-mini', // 纯推理,无视觉
|
|
19
|
+
'o1-preview',
|
|
20
|
+
'o3-mini',
|
|
21
|
+
'text-embedding-',
|
|
22
|
+
'text-davinci-',
|
|
23
|
+
'dall-e-', // 反过来:它是图像生成不是视觉理解
|
|
24
|
+
'whisper-',
|
|
25
|
+
'tts-',
|
|
26
|
+
'babbage-',
|
|
27
|
+
'davinci-',
|
|
28
|
+
'gpt-4o-mini-search', // 搜索专用,无视觉入口
|
|
29
|
+
];
|
|
30
|
+
const KNOWN_VISION_FAMILIES = [
|
|
31
|
+
'gpt-4o',
|
|
32
|
+
'gpt-4-turbo',
|
|
33
|
+
'gpt-4-vision',
|
|
34
|
+
'gpt-4.1',
|
|
35
|
+
'gpt-4.5',
|
|
36
|
+
'gpt-5',
|
|
37
|
+
'chatgpt-4o',
|
|
38
|
+
'o1', // o1 / o1-pro 支持视觉(与 o1-mini 不同)
|
|
39
|
+
'claude-3',
|
|
40
|
+
'claude-3.5',
|
|
41
|
+
'claude-3.7',
|
|
42
|
+
'claude-4',
|
|
43
|
+
'claude-sonnet-4',
|
|
44
|
+
'claude-opus-4',
|
|
45
|
+
'gemini-1.5',
|
|
46
|
+
'gemini-2',
|
|
47
|
+
'gemini-exp',
|
|
48
|
+
'pixtral',
|
|
49
|
+
'qwen-vl',
|
|
50
|
+
'qwen2-vl',
|
|
51
|
+
'qwen2.5-vl',
|
|
52
|
+
'llava',
|
|
53
|
+
'internvl',
|
|
54
|
+
'minicpm-v',
|
|
55
|
+
'glm-4v',
|
|
56
|
+
'yi-vl',
|
|
57
|
+
];
|
|
58
|
+
/** 归一化:小写、去空白;用于前缀比较。 */
|
|
59
|
+
function normalize(model) {
|
|
60
|
+
return model.trim().toLowerCase();
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* 判断模型是否支持视觉(多模态 image_url)。
|
|
64
|
+
* - 模型未配置(`config.model` 为空或 default 兜底字符串)→ true(不阻断,等 API 报错)
|
|
65
|
+
* - 命中 KNOWN_TEXT_ONLY_PREFIXES 任一前缀 → false(确定无视觉,优先查,避免 gpt-4o-mini vs -search 系列名冲突)
|
|
66
|
+
* - 命中 KNOWN_VISION_FAMILIES 任一前缀 → true
|
|
67
|
+
* - 其它(未识别 / 自定义代理 / 新发布模型)→ true(保守放行)
|
|
68
|
+
*/
|
|
69
|
+
export function modelSupportsVision(model) {
|
|
70
|
+
const m = normalize(model);
|
|
71
|
+
if (!m)
|
|
72
|
+
return true;
|
|
73
|
+
for (const p of KNOWN_TEXT_ONLY_PREFIXES) {
|
|
74
|
+
if (m === p || m.startsWith(p))
|
|
75
|
+
return false;
|
|
76
|
+
}
|
|
77
|
+
for (const p of KNOWN_VISION_FAMILIES) {
|
|
78
|
+
if (m === p || m.startsWith(p))
|
|
79
|
+
return true;
|
|
80
|
+
}
|
|
81
|
+
return true;
|
|
82
|
+
}
|
package/dist/llm/index.js
CHANGED
|
@@ -2,9 +2,25 @@ import OpenAI from 'openai';
|
|
|
2
2
|
import { config } from '../config/index.js';
|
|
3
3
|
import { tools } from '../tools/registry.js';
|
|
4
4
|
import { PLAN_DISABLED_TOOLS } from '../tools/constants.js';
|
|
5
|
+
/**
|
|
6
|
+
* LLM 调用重试策略:
|
|
7
|
+
* 可重试 → 429 (rate limit) / 5xx (server) / APIConnectionError / Node 网络错 (ETIMEDOUT 等)
|
|
8
|
+
* 不重试 → 400 (bad request) / 401 (auth) / 其他 4xx / 用户中断 (AbortError / APIUserAbortError)
|
|
9
|
+
*
|
|
10
|
+
* 退避:指数 + ±20% jitter,首等 1s,翻倍,封顶 30s;若后端返回 Retry-After 头则优先按其值。
|
|
11
|
+
* 默认 4 次尝试(1 初始 + 3 重试),要调改 RETRY_MAX_ATTEMPTS。
|
|
12
|
+
*
|
|
13
|
+
* SDK 内置 maxRetries 默认 2(对所有 5xx+网络错重试),与本策略叠加会双重重试 5xx —— 显式置 0 让
|
|
14
|
+
* 全部重试由 chat() 外层重试循环统一控,行为可预期。
|
|
15
|
+
*/
|
|
16
|
+
const RETRY_MAX_ATTEMPTS = 4;
|
|
17
|
+
const RETRY_BASE_MS = 1000;
|
|
18
|
+
const RETRY_MAX_MS = 30000;
|
|
19
|
+
const RETRY_JITTER = 0.2;
|
|
5
20
|
let client = new OpenAI({
|
|
6
21
|
baseURL: config.baseURL,
|
|
7
22
|
apiKey: config.apiKey,
|
|
23
|
+
maxRetries: 0,
|
|
8
24
|
});
|
|
9
25
|
/**
|
|
10
26
|
* 运行时重建 OpenAI 客户端(/model 切换 baseURL/apiKey 后调)。
|
|
@@ -16,8 +32,110 @@ export function reconfigureClient() {
|
|
|
16
32
|
client = new OpenAI({
|
|
17
33
|
baseURL: config.baseURL,
|
|
18
34
|
apiKey: config.apiKey,
|
|
35
|
+
maxRetries: 0,
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
// ── 重试 helper(纯函数 + sleep,被 chat() 调用;亦可单测导入验证)──────
|
|
39
|
+
/** 可被 AbortSignal 取消的 sleep;signal 已 abort 时立即抛 AbortError。 */
|
|
40
|
+
function sleep(ms, signal) {
|
|
41
|
+
return new Promise((resolve, reject) => {
|
|
42
|
+
if (signal?.aborted) {
|
|
43
|
+
reject(new DOMException('This operation was aborted', 'AbortError'));
|
|
44
|
+
return;
|
|
45
|
+
}
|
|
46
|
+
let onAbort;
|
|
47
|
+
const t = setTimeout(() => {
|
|
48
|
+
if (onAbort)
|
|
49
|
+
signal?.removeEventListener('abort', onAbort);
|
|
50
|
+
resolve();
|
|
51
|
+
}, ms);
|
|
52
|
+
onAbort = () => {
|
|
53
|
+
clearTimeout(t);
|
|
54
|
+
reject(new DOMException('This operation was aborted', 'AbortError'));
|
|
55
|
+
};
|
|
56
|
+
signal?.addEventListener('abort', onAbort, { once: true });
|
|
19
57
|
});
|
|
20
58
|
}
|
|
59
|
+
/**
|
|
60
|
+
* 判定一个 chat 失败是否值得重试。
|
|
61
|
+
* 用户中断(signal.aborted / AbortError / APIUserAbortError)始终返回 false,避免退避中把中断吞了。
|
|
62
|
+
* 4xx(除 429)全不重试 —— 这是客户端请求错,重试只会再错一次。
|
|
63
|
+
*/
|
|
64
|
+
export function isRetryableError(err, signal) {
|
|
65
|
+
if (signal?.aborted)
|
|
66
|
+
return false;
|
|
67
|
+
if (!err || typeof err !== 'object')
|
|
68
|
+
return false;
|
|
69
|
+
const e = err;
|
|
70
|
+
if (e.name === 'AbortError' || e.name === 'APIUserAbortError')
|
|
71
|
+
return false;
|
|
72
|
+
// OpenAI SDK APIError 走 status 分支(覆盖 4xx/5xx/429)
|
|
73
|
+
const status = e.status;
|
|
74
|
+
if (typeof status === 'number') {
|
|
75
|
+
if (status === 429)
|
|
76
|
+
return true;
|
|
77
|
+
if (status >= 500 && status < 600)
|
|
78
|
+
return true;
|
|
79
|
+
return false;
|
|
80
|
+
}
|
|
81
|
+
// Node 网络错 code(APIConnectionError 内部也会带一个)
|
|
82
|
+
const code = e.code;
|
|
83
|
+
if (code === 'ETIMEDOUT' ||
|
|
84
|
+
code === 'ECONNRESET' ||
|
|
85
|
+
code === 'ENOTFOUND' ||
|
|
86
|
+
code === 'EAI_AGAIN' ||
|
|
87
|
+
code === 'ECONNREFUSED' ||
|
|
88
|
+
code === 'EPIPE') {
|
|
89
|
+
return true;
|
|
90
|
+
}
|
|
91
|
+
// OpenAI SDK 的网络错类(无 status)
|
|
92
|
+
if (e.name === 'APIConnectionError' || e.name === 'APIConnectionTimeoutError')
|
|
93
|
+
return true;
|
|
94
|
+
// 兜底:错误信息里出现 timeout 字样(部分代理把错误折叠成普通 Error)
|
|
95
|
+
if (typeof e.message === 'string' && /\btime(d|ed)?\s*out\b|ETIMEDOUT/i.test(e.message)) {
|
|
96
|
+
return true;
|
|
97
|
+
}
|
|
98
|
+
return false;
|
|
99
|
+
}
|
|
100
|
+
/** 从 OpenAI APIError.headers 解析 Retry-After(秒);不支持或缺失返回 undefined。封顶 RETRY_MAX_MS。 */
|
|
101
|
+
export function getRetryAfterMs(err) {
|
|
102
|
+
const headers = err?.headers;
|
|
103
|
+
if (!headers)
|
|
104
|
+
return undefined;
|
|
105
|
+
let raw = null;
|
|
106
|
+
if (typeof headers.get === 'function') {
|
|
107
|
+
raw = headers.get('retry-after');
|
|
108
|
+
}
|
|
109
|
+
else if (typeof headers === 'object') {
|
|
110
|
+
raw = headers['retry-after'] ?? null;
|
|
111
|
+
}
|
|
112
|
+
if (!raw)
|
|
113
|
+
return undefined;
|
|
114
|
+
const n = Number(raw);
|
|
115
|
+
if (!Number.isFinite(n) || n < 0)
|
|
116
|
+
return undefined;
|
|
117
|
+
return Math.min(n * 1000, RETRY_MAX_MS);
|
|
118
|
+
}
|
|
119
|
+
/** 第 N 次失败后等多久(retryAfterMs 有就用它,否则指数;再加 ±RETRY_JITTER 抖动)。 */
|
|
120
|
+
export function computeBackoff(attempt, retryAfterMs) {
|
|
121
|
+
const exp = Math.min(RETRY_MAX_MS, RETRY_BASE_MS * 2 ** (attempt - 1));
|
|
122
|
+
const base = retryAfterMs ?? exp;
|
|
123
|
+
const lo = base * (1 - RETRY_JITTER);
|
|
124
|
+
const hi = base * (1 + RETRY_JITTER);
|
|
125
|
+
return Math.max(0, Math.round(lo + Math.random() * (hi - lo)));
|
|
126
|
+
}
|
|
127
|
+
function logRetry(attempt, err, waitMs) {
|
|
128
|
+
const e = err;
|
|
129
|
+
const tag = e.status ? `HTTP ${e.status}` : e.name || 'Error';
|
|
130
|
+
const msg = e.message ?? '未知错误';
|
|
131
|
+
// stderr 而非 stdout —— 不污染流式正文;行首换行防止黏在上一行尾巴。
|
|
132
|
+
process.stderr.write(`\n[llm] 第 ${attempt}/${RETRY_MAX_ATTEMPTS} 次失败(${tag}: ${msg}),${(waitMs / 1000).toFixed(1)}s 后重试…\n`);
|
|
133
|
+
}
|
|
134
|
+
let createImplOverride = null;
|
|
135
|
+
/** 仅供单测用:覆盖 chat() 内部实际调用的 create 桩。生产代码不要碰。 */
|
|
136
|
+
export function __setChatCreateImpl(impl) {
|
|
137
|
+
createImplOverride = impl;
|
|
138
|
+
}
|
|
21
139
|
/** 把内部工具定义转成 OpenAI 的 tool 格式 */
|
|
22
140
|
export const chatTools = tools.map((t) => ({
|
|
23
141
|
type: 'function',
|
|
@@ -37,12 +155,43 @@ export const planChatTools = chatTools.filter((t) => !PLAN_DISABLED_TOOLS.has(t.
|
|
|
37
155
|
* 流式调一次 LLM:增量回调文本,内部累加 tool_calls 片段。
|
|
38
156
|
* tool_calls 跨 chunk 按 index 累加(id / name / arguments 拼接)。
|
|
39
157
|
* include_usage 时末尾 chunk 携带 usage,先读再 continue(末尾 chunk 无 delta)。
|
|
158
|
+
*
|
|
159
|
+
* 包了一层重试:429/5xx/timeout/网络错按指数退避重试(默认 4 次),400/401/用户中断立即抛。
|
|
160
|
+
* 重试由 chat() 统一管,chatOnce() 只负责单次请求,职责单一便于单测。
|
|
40
161
|
*/
|
|
41
162
|
export async function chat(messages, handlers = {}, signal,
|
|
42
163
|
/** 覆盖默认工具 schema;plan 模式传 planChatTools(只读子集),缺省=全量 chatTools。 */
|
|
43
164
|
toolsOverride) {
|
|
165
|
+
let lastErr;
|
|
166
|
+
for (let attempt = 1; attempt <= RETRY_MAX_ATTEMPTS; attempt++) {
|
|
167
|
+
if (signal?.aborted) {
|
|
168
|
+
throw new DOMException('This operation was aborted', 'AbortError');
|
|
169
|
+
}
|
|
170
|
+
try {
|
|
171
|
+
return await chatOnce(messages, handlers, signal, toolsOverride);
|
|
172
|
+
}
|
|
173
|
+
catch (err) {
|
|
174
|
+
lastErr = err;
|
|
175
|
+
if (attempt >= RETRY_MAX_ATTEMPTS || !isRetryableError(err, signal)) {
|
|
176
|
+
throw err;
|
|
177
|
+
}
|
|
178
|
+
const wait = computeBackoff(attempt, getRetryAfterMs(err));
|
|
179
|
+
logRetry(attempt, err, wait);
|
|
180
|
+
// sleep 自己会在 signal abort 时抛 AbortError——透传,让 runAgentCore 的 catch 按中断处理。
|
|
181
|
+
await sleep(wait, signal);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
// 循环要么 return 要么 throw,理论上走不到这里;写出来让 TS 控制流分析满意。
|
|
185
|
+
throw lastErr;
|
|
186
|
+
}
|
|
187
|
+
/** 单次流式 LLM 请求(无重试);chat() 的内部实现,可被 __setChatCreateImpl 注入桩以做单测。 */
|
|
188
|
+
async function chatOnce(messages, handlers, signal, toolsOverride) {
|
|
44
189
|
// signal 透传给 SDK 第二参(RequestOptions);abort 后 for await 抛错,chat 不 catch,透传 runAgent 处理。
|
|
45
|
-
|
|
190
|
+
// createImplOverride 的 body 类型故意宽成 Record(测试桩用),生产走 client 分支时由 OpenAI 自己的类型守门。
|
|
191
|
+
const create = createImplOverride
|
|
192
|
+
? createImplOverride
|
|
193
|
+
: (body, opts) => client.chat.completions.create(body, opts);
|
|
194
|
+
const stream = await create({
|
|
46
195
|
model: config.model,
|
|
47
196
|
messages,
|
|
48
197
|
tools: toolsOverride ?? chatTools,
|
|
@@ -63,7 +212,7 @@ toolsOverride) {
|
|
|
63
212
|
totalTokens: chunk.usage.total_tokens,
|
|
64
213
|
};
|
|
65
214
|
}
|
|
66
|
-
const delta = chunk.choices[0]?.delta;
|
|
215
|
+
const delta = chunk.choices?.[0]?.delta;
|
|
67
216
|
if (!delta)
|
|
68
217
|
continue; // 末尾 usage-only chunk 等无 delta
|
|
69
218
|
if (delta.content) {
|
|
@@ -146,6 +295,33 @@ function contentToText(content) {
|
|
|
146
295
|
return String(content);
|
|
147
296
|
}
|
|
148
297
|
}
|
|
298
|
+
/** OpenAI 视觉模型单图固定 token(低细节 / auto 模式);高细节更大但属罕见路径,保守按 85 计。 */
|
|
299
|
+
const IMAGE_TOKEN_COST = 85;
|
|
300
|
+
/** 单个 content part 的 token:text part 走 estimateTokens,image_url part 固定 85。+2 结构开销。 */
|
|
301
|
+
function partTokens(part) {
|
|
302
|
+
if (!part || typeof part !== 'object')
|
|
303
|
+
return 0;
|
|
304
|
+
const p = part;
|
|
305
|
+
if (p.type === 'text')
|
|
306
|
+
return 2 + estimateTokens(p.text ?? '');
|
|
307
|
+
if (p.type === 'image_url')
|
|
308
|
+
return 2 + IMAGE_TOKEN_COST;
|
|
309
|
+
return 2;
|
|
310
|
+
}
|
|
311
|
+
/** 估算多模态 content 的 token(text parts + 固定每图 85);不把 base64 走 estimateTokens,避免 1MB 图算成 25 万 token。 */
|
|
312
|
+
export function contentTokens(content) {
|
|
313
|
+
if (content == null)
|
|
314
|
+
return 0;
|
|
315
|
+
if (typeof content === 'string')
|
|
316
|
+
return estimateTokens(content);
|
|
317
|
+
if (Array.isArray(content)) {
|
|
318
|
+
let sum = 0;
|
|
319
|
+
for (const p of content)
|
|
320
|
+
sum += partTokens(p);
|
|
321
|
+
return sum;
|
|
322
|
+
}
|
|
323
|
+
return estimateTokens(contentToText(content));
|
|
324
|
+
}
|
|
149
325
|
/** 估算单条消息的 token 数:结构开销 + content + tool_calls 参数。 */
|
|
150
326
|
export function messageTokens(m) {
|
|
151
327
|
const role = m.role;
|
|
@@ -154,14 +330,14 @@ export function messageTokens(m) {
|
|
|
154
330
|
structural = 3;
|
|
155
331
|
else if (role === 'tool')
|
|
156
332
|
structural = 6;
|
|
157
|
-
let body =
|
|
333
|
+
let body = contentTokens(m.content);
|
|
158
334
|
const tcs = m
|
|
159
335
|
.tool_calls;
|
|
160
336
|
if (tcs) {
|
|
161
337
|
for (const tc of tcs)
|
|
162
|
-
body += tc?.function?.arguments ?? '';
|
|
338
|
+
body += estimateTokens(tc?.function?.arguments ?? '');
|
|
163
339
|
}
|
|
164
|
-
return structural +
|
|
340
|
+
return structural + body;
|
|
165
341
|
}
|
|
166
342
|
/** 估算整段 messages 的 token 数(不含工具 schema,含 priming 常数)。 */
|
|
167
343
|
export function estimateMessagesTokens(messages) {
|
package/dist/plan/index.js
CHANGED
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 被 repl 依赖(注入 systemPrompt + 状态行 + listener 注册)+ tools/builtins/todolist 依赖。
|
|
7
7
|
export {
|
|
8
8
|
// store
|
|
9
|
-
plansDir, planPath, ensurePlansDir, newPlanId, parsePlan, serializePlan, readPlan, writePlan, deletePlan, updatePlan, listPlans, renderPlanForLLM, renderPlanChip, } from './store.js';
|
|
9
|
+
plansDir, archiveDir, planPath, ensurePlansDir, ensureArchiveDir, newPlanId, parsePlan, serializePlan, readPlan, writePlan, deletePlan, deletePlanAnywhere, updatePlan, listPlans, archivePlan, unarchivePlan, renderPlanForLLM, renderPlanChip, } from './store.js';
|
|
10
10
|
export { getActivePlan, setActivePlan, clearActivePlan, onActivePlanChange, hasActivePlan, getActivePlanSummary, } from './active.js';
|
|
11
11
|
import { getActivePlan } from './active.js';
|
|
12
12
|
import { renderPlanForLLM } from './store.js';
|
package/dist/plan/store.js
CHANGED
|
@@ -16,9 +16,13 @@ export function plansDir() {
|
|
|
16
16
|
const root = getSandboxRoot() ?? process.cwd();
|
|
17
17
|
return join(root, '.mocode', 'plans');
|
|
18
18
|
}
|
|
19
|
-
/**
|
|
20
|
-
export function
|
|
21
|
-
return join(plansDir(),
|
|
19
|
+
/** 归档目录:<root>/.mocode/plans/archive/。finish 自动归档、用户手动 unarchive 都走这里。 */
|
|
20
|
+
export function archiveDir() {
|
|
21
|
+
return join(plansDir(), 'archive');
|
|
22
|
+
}
|
|
23
|
+
/** 单 plan 文件路径:<root>/.mocode/plans/<id>.md。可选 baseDir:默认 plans,也可指 archive。 */
|
|
24
|
+
export function planPath(id, baseDir = plansDir()) {
|
|
25
|
+
return join(baseDir, `${id}.md`);
|
|
22
26
|
}
|
|
23
27
|
/** 确保 plans 目录存在(惰性,工具每次写前调)。 */
|
|
24
28
|
export function ensurePlansDir() {
|
|
@@ -26,7 +30,24 @@ export function ensurePlansDir() {
|
|
|
26
30
|
if (!existsSync(d))
|
|
27
31
|
mkdirSync(d, { recursive: true });
|
|
28
32
|
}
|
|
33
|
+
/** 确保归档目录存在(惰性)。 */
|
|
34
|
+
export function ensureArchiveDir() {
|
|
35
|
+
const d = archiveDir();
|
|
36
|
+
if (!existsSync(d))
|
|
37
|
+
mkdirSync(d, { recursive: true });
|
|
38
|
+
}
|
|
29
39
|
// ── ID 生成 ──
|
|
40
|
+
/** 本地时间戳(无时区后缀),与 filename 的 YYYY-MM-DDTHH-mm-ss 风格一致。
|
|
41
|
+
* 例:2026-07-04T22:54:51.729(本地时,便于人对读 frontmatter / progress log,
|
|
42
|
+
* 且与同 plan id 文件名"2026-07-04T22-54-51"对应)。
|
|
43
|
+
* 不带 Z 后缀 → JS Date 解析会按本地时读(一致 round-trip);
|
|
44
|
+
* 反例:toISOString() 返 2026-07-04T14:54:51.729Z(UTC),跟用户当地差 8 小时,易误解。 */
|
|
45
|
+
export function localIsoTimestamp(d = new Date()) {
|
|
46
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
47
|
+
const padMs = (n) => String(n).padStart(3, '0');
|
|
48
|
+
return (`${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}` +
|
|
49
|
+
`T${pad(d.getHours())}:${pad(d.getMinutes())}:${pad(d.getSeconds())}.${padMs(d.getMilliseconds())}`);
|
|
50
|
+
}
|
|
30
51
|
/** plan id:YYYY-MM-DDTHH-mm-ss-xxxxxx(本地时间,文件名安全)。xxxxxx = 4 字节随机 hex(防同秒并发撞)。 */
|
|
31
52
|
export function newPlanId(now = new Date()) {
|
|
32
53
|
const pad = (n) => String(n).padStart(2, '0');
|
|
@@ -52,7 +73,7 @@ const LOG_RE = /^-\s+(\S+)\s+(.+?)\s*$/;
|
|
|
52
73
|
* 解析成功但 step id 缺序 / 重号:按出现顺序重排为 1..N(保稳定)。
|
|
53
74
|
*/
|
|
54
75
|
export function parsePlan(raw, fallbackId) {
|
|
55
|
-
const now =
|
|
76
|
+
const now = localIsoTimestamp();
|
|
56
77
|
let meta = {};
|
|
57
78
|
let body = raw;
|
|
58
79
|
const m = FRONTMATTER_RE.exec(raw);
|
|
@@ -184,9 +205,10 @@ function checkboxMarker(s) {
|
|
|
184
205
|
}
|
|
185
206
|
}
|
|
186
207
|
// ── CRUD(直接落盘)──
|
|
187
|
-
/** 读 plan 文件;不存在 → null,坏文件 → fallbackId 最小可用 Plan(不抛,工具契约)。
|
|
188
|
-
|
|
189
|
-
|
|
208
|
+
/** 读 plan 文件;不存在 → null,坏文件 → fallbackId 最小可用 Plan(不抛,工具契约)。
|
|
209
|
+
* 可选 baseDir:默认 plans,传 archiveDir() 可读归档。 */
|
|
210
|
+
export function readPlan(id, baseDir = plansDir()) {
|
|
211
|
+
const p = planPath(id, baseDir);
|
|
190
212
|
if (!existsSync(p))
|
|
191
213
|
return null;
|
|
192
214
|
try {
|
|
@@ -210,9 +232,9 @@ export function writePlan(p) {
|
|
|
210
232
|
return false;
|
|
211
233
|
}
|
|
212
234
|
}
|
|
213
|
-
/** 删 plan 文件;不存在静默 ok
|
|
214
|
-
export function deletePlan(id) {
|
|
215
|
-
const p = planPath(id);
|
|
235
|
+
/** 删 plan 文件;不存在静默 ok。可选 baseDir:默认 plans,传 archiveDir() 删归档。 */
|
|
236
|
+
export function deletePlan(id, baseDir = plansDir()) {
|
|
237
|
+
const p = planPath(id, baseDir);
|
|
216
238
|
if (!existsSync(p))
|
|
217
239
|
return true;
|
|
218
240
|
try {
|
|
@@ -243,33 +265,89 @@ export function updatePlan(id, mutator) {
|
|
|
243
265
|
}
|
|
244
266
|
if (next === false)
|
|
245
267
|
return cur; // 无改动:返原对象
|
|
246
|
-
next.updated =
|
|
268
|
+
next.updated = localIsoTimestamp();
|
|
247
269
|
if (!writePlan(next))
|
|
248
270
|
return null;
|
|
249
271
|
return next;
|
|
250
272
|
}
|
|
251
|
-
/** 列 plans 目录下所有 plan(按 updated 倒序,最新在前)。解析失败的跳过。
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
273
|
+
/** 列 plans 目录下所有 plan(按 updated 倒序,最新在前)。解析失败的跳过。
|
|
274
|
+
* scope: 'active'(默认)=仅 plans/;'archived'=仅 archive/;'all'=合并(去重按 id)。 */
|
|
275
|
+
export function listPlans(scope = 'active') {
|
|
276
|
+
const dirs = [];
|
|
277
|
+
if (scope === 'active')
|
|
278
|
+
dirs.push(plansDir());
|
|
279
|
+
else if (scope === 'archived')
|
|
280
|
+
dirs.push(archiveDir());
|
|
281
|
+
else {
|
|
282
|
+
dirs.push(plansDir());
|
|
283
|
+
dirs.push(archiveDir());
|
|
262
284
|
}
|
|
263
285
|
const out = [];
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
286
|
+
const seen = new Set();
|
|
287
|
+
for (const dir of dirs) {
|
|
288
|
+
if (!existsSync(dir))
|
|
289
|
+
continue;
|
|
290
|
+
let names;
|
|
291
|
+
try {
|
|
292
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.md'));
|
|
293
|
+
}
|
|
294
|
+
catch {
|
|
295
|
+
continue;
|
|
296
|
+
}
|
|
297
|
+
for (const n of names) {
|
|
298
|
+
const id = n.slice(0, -3);
|
|
299
|
+
if (seen.has(id))
|
|
300
|
+
continue;
|
|
301
|
+
seen.add(id);
|
|
302
|
+
const p = readPlan(id, dir);
|
|
303
|
+
if (p)
|
|
304
|
+
out.push(p);
|
|
305
|
+
}
|
|
269
306
|
}
|
|
270
307
|
out.sort((a, b) => (a.updated < b.updated ? 1 : a.updated > b.updated ? -1 : 0));
|
|
271
308
|
return out;
|
|
272
309
|
}
|
|
310
|
+
// ── 归档/还原/全删 ──
|
|
311
|
+
/** 把 plans/<id>.md mv 到 plans/archive/<id>.md(atomic rename)。
|
|
312
|
+
* 源不存在 → false(已归档 / 删了 / 从未存在都算)。 */
|
|
313
|
+
export function archivePlan(id) {
|
|
314
|
+
const src = planPath(id, plansDir());
|
|
315
|
+
if (!existsSync(src))
|
|
316
|
+
return false;
|
|
317
|
+
ensureArchiveDir();
|
|
318
|
+
const dst = planPath(id, archiveDir());
|
|
319
|
+
try {
|
|
320
|
+
renameSync(src, dst);
|
|
321
|
+
}
|
|
322
|
+
catch {
|
|
323
|
+
return false;
|
|
324
|
+
}
|
|
325
|
+
return true;
|
|
326
|
+
}
|
|
327
|
+
/** 把 plans/archive/<id>.md 还原到 plans/<id>.md。源不存在 → false。 */
|
|
328
|
+
export function unarchivePlan(id) {
|
|
329
|
+
const src = planPath(id, archiveDir());
|
|
330
|
+
if (!existsSync(src))
|
|
331
|
+
return false;
|
|
332
|
+
ensurePlansDir();
|
|
333
|
+
const dst = planPath(id, plansDir());
|
|
334
|
+
try {
|
|
335
|
+
renameSync(src, dst);
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
return false;
|
|
339
|
+
}
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
/** 任一处存在就都删(活跃 + 归档)。用于 todolist delete 不关心来源。 */
|
|
343
|
+
export function deletePlanAnywhere(id) {
|
|
344
|
+
let any = false;
|
|
345
|
+
if (existsSync(planPath(id, plansDir())))
|
|
346
|
+
any = deletePlan(id, plansDir()) || any;
|
|
347
|
+
if (existsSync(planPath(id, archiveDir())))
|
|
348
|
+
any = deletePlan(id, archiveDir()) || any;
|
|
349
|
+
return any;
|
|
350
|
+
}
|
|
273
351
|
// ── 渲染(给 LLM / UI 用)──
|
|
274
352
|
/** 把 Plan 渲成给 LLM 看的紧凑摘要(多行文本)。 */
|
|
275
353
|
export function renderPlanForLLM(p) {
|
package/dist/repl/index.js
CHANGED
|
@@ -15,6 +15,7 @@ import { promptWithSlashMenu, promptTurnPicker, promptSessionPicker, promptTheme
|
|
|
15
15
|
import { promptIntervention } from '../ui/intervention.js';
|
|
16
16
|
import { tools } from '../tools/registry.js';
|
|
17
17
|
import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
|
|
18
|
+
import { loadImageAttachment, renderChip, MAX_INLINE_BYTES_DEFAULT, } from '../attachments/image.js';
|
|
18
19
|
import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
|
|
19
20
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
|
|
20
21
|
import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
|
|
@@ -44,6 +45,7 @@ const SLASH_COMMANDS = [
|
|
|
44
45
|
{ name: '/pet', desc: '开关桌宠(独立悬浮窗,展示 agent 状态动画)' },
|
|
45
46
|
{ name: '/pet skin', desc: '选择桌宠皮肤(↑↓·Enter)' },
|
|
46
47
|
{ name: '/pet quit', desc: '完全关闭桌宠进程(而非仅断开本连接)' },
|
|
48
|
+
{ name: '/image', desc: '附加本地图片到下一条消息(/image <path> · list · clear)' },
|
|
47
49
|
];
|
|
48
50
|
/** 主题名 → 一句描述(供 /theme 菜单 / 列表显示)。新增主题时在 src/ui/theme.ts THEMES 加键后于此补一句。 */
|
|
49
51
|
const THEME_DESCRIPTIONS = {
|
|
@@ -187,6 +189,10 @@ let runningPlaceholder = '';
|
|
|
187
189
|
let currentAbort = null;
|
|
188
190
|
let pendingPrefill = null; // /rollback 选中后预填的 user 输入(下轮 INPUT 态消费)
|
|
189
191
|
// agent 模式状态已提到 src/agent/mode.ts(共享叶子:switch_mode 工具可写、agent 每步读、repl 注册 onModeChange 监听器)。
|
|
192
|
+
/** 多模态 user 输入的附件状态。pending = 本轮尚未提交的待发图片;messageAttachments = 已 push 进 history 的图片元数据
|
|
193
|
+
* (供 renderHistory 复显文件名——base64 不可逆地塞进 history 后,只能从侧 channel 拿原文件名)。 */
|
|
194
|
+
let pendingAttachments = [];
|
|
195
|
+
const messageAttachments = new Map();
|
|
190
196
|
/** 运行态按键:滚动优先,再 Ctrl+C 中断,再 typeahead 编辑(单行,Enter=无操作)。 */
|
|
191
197
|
function onRunningKey(_str, key) {
|
|
192
198
|
if (!key)
|
|
@@ -307,6 +313,10 @@ function echoInput(lines) {
|
|
|
307
313
|
if (!layout.isActive())
|
|
308
314
|
return;
|
|
309
315
|
layout.contentWrite(formatUserMessage(lines));
|
|
316
|
+
// 多模态附件:每张一行 chip 跟在 user bubble 后(原 /rollback /resume 复显一致)
|
|
317
|
+
for (const a of pendingAttachments) {
|
|
318
|
+
layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
|
|
319
|
+
}
|
|
310
320
|
}
|
|
311
321
|
/** 把任意消息 content 拍平成字符串(OpenAI 可能 string / null / 多模态数组)。 */
|
|
312
322
|
function textOf(c) {
|
|
@@ -326,15 +336,35 @@ function textOf(c) {
|
|
|
326
336
|
* user→❯ 回显、assistant→正文(+ tool_calls 作 ● 行)、tool→↳ 结果预览;system 跳过。
|
|
327
337
|
* 思考段不持久(history 只存正文),故无思考折叠。渲染后续写位在末尾,紧接 enterInputMode 画输入框。
|
|
328
338
|
* 内容长于屏时 viewport 显尾(最近轮次),PgUp 可看更早——与流式态一致。
|
|
339
|
+
* user 多模态:用 textOf 取 text parts;若侧 channel messageAttachments 有原文件名则追加 chip 行
|
|
340
|
+
* (避免 base64 解码不可逆,旧 session 没侧 channel 时只显文本,文件名 fallback 到 image/* mime)。
|
|
329
341
|
*/
|
|
330
342
|
export function renderHistory(history) {
|
|
331
343
|
const idToName = new Map();
|
|
332
|
-
for (
|
|
344
|
+
for (let idx = 0; idx < history.length; idx++) {
|
|
345
|
+
const m = history[idx];
|
|
333
346
|
if (m.role === 'system')
|
|
334
347
|
continue;
|
|
335
348
|
if (m.role === 'user') {
|
|
336
349
|
const lines = textOf(m.content).split('\n');
|
|
337
350
|
layout.contentWrite(formatUserMessage(lines));
|
|
351
|
+
const atts = messageAttachments.get(idx);
|
|
352
|
+
if (atts && atts.length > 0) {
|
|
353
|
+
for (const a of atts) {
|
|
354
|
+
layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
else if (Array.isArray(m.content)) {
|
|
358
|
+
// 旧 session 没侧 channel:从 data URL 头抽 mime,显一个通用 chip
|
|
359
|
+
const c = m.content;
|
|
360
|
+
for (const p of c) {
|
|
361
|
+
if (p && typeof p === 'object' && p.type === 'image_url') {
|
|
362
|
+
const url = p.image_url?.url ?? '';
|
|
363
|
+
const mime = url.startsWith('data:') ? url.slice(5, url.indexOf(';')) : 'image';
|
|
364
|
+
layout.contentWrite(` ${ui.dim}📷 ${mime}${ui.reset}\n`);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
}
|
|
338
368
|
continue;
|
|
339
369
|
}
|
|
340
370
|
if (m.role === 'assistant') {
|
|
@@ -540,8 +570,26 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
540
570
|
* 跑一轮 agent(enterRunningMode 已由调用方完成):startRunningListener → runAgent → autosave / reflect。
|
|
541
571
|
* plan 模式传 planMode=true(runAgent 用 planChatTools 只读子集)。返 ok=正常结束(未中断 / 未抛错),
|
|
542
572
|
* 供调用方决定是否弹审批面板。execute 轮的合成输入也走这里。
|
|
573
|
+
*
|
|
574
|
+
* 多模态:把 pendingAttachments flush 进 userInput:有图时构造 ContentPart[] 数组;
|
|
575
|
+
* 无图时保持 string(向后兼容,且 messageTokens 走 estimateTokens 不走 IMAGE_TOKEN_COST)。
|
|
576
|
+
* side channel 记录本轮 msg 在 history 的 index → attachments,供 renderHistory 复显文件名。
|
|
543
577
|
*/
|
|
544
578
|
const runTurn = async (input, planMode, placeholder) => {
|
|
579
|
+
const imgs = pendingAttachments;
|
|
580
|
+
pendingAttachments = []; // 入口即清,即使后续抛错也不留陈旧附件
|
|
581
|
+
const userInput = imgs.length === 0
|
|
582
|
+
? input
|
|
583
|
+
: [
|
|
584
|
+
{ type: 'text', text: input },
|
|
585
|
+
...imgs.map((a) => ({
|
|
586
|
+
type: 'image_url',
|
|
587
|
+
image_url: { url: a.dataUrl, detail: 'auto' },
|
|
588
|
+
})),
|
|
589
|
+
];
|
|
590
|
+
const msgIndex = history.length; // runAgent push 后 = 这个 index
|
|
591
|
+
if (imgs.length)
|
|
592
|
+
messageAttachments.set(msgIndex, imgs);
|
|
545
593
|
let ok = false;
|
|
546
594
|
try {
|
|
547
595
|
const signal = startRunningListener(placeholder);
|
|
@@ -550,7 +598,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
550
598
|
setAgentMode(planMode ? 'plan' : 'auto');
|
|
551
599
|
// 运行中每步 chat() 返回后刷新状态行 context 用量条(用 fresh lastUsage / 估算),
|
|
552
600
|
// 否则整轮冻结在轮首 refreshStatusBase 的值,「执行 grep」时 2k/1000k 不动。
|
|
553
|
-
await runAgent(history,
|
|
601
|
+
await runAgent(history, userInput, signal, () => {
|
|
554
602
|
refreshStatusBase(history);
|
|
555
603
|
layout.drawStatusBar();
|
|
556
604
|
});
|
|
@@ -574,7 +622,20 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
574
622
|
}
|
|
575
623
|
catch (e) {
|
|
576
624
|
ok = false;
|
|
577
|
-
|
|
625
|
+
// 多模态相关错误友好提示:OpenAI/Anthropic 等会报 "does not support image" / "vision" / "multimodal" 等关键词,
|
|
626
|
+
// 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
|
|
627
|
+
const msg = e instanceof Error ? e.message : String(e);
|
|
628
|
+
const lower = msg.toLowerCase();
|
|
629
|
+
const looksLikeImageError = /\b(image|vision|multimodal|vision[-_ ]?capable|unsupported (media|image))\b/i.test(lower) ||
|
|
630
|
+
/不支持(视觉|图片|图像|多模态)/.test(msg) ||
|
|
631
|
+
(/图片|图像|视觉|多模态/.test(msg) && /不支持|invalid|reject|fail/i.test(lower));
|
|
632
|
+
if (looksLikeImageError) {
|
|
633
|
+
layout.contentWrite(`${ui.red}[错误]${ui.reset} 当前模型 ${ui.cyan}${config.model}${ui.reset} 不支持视觉输入。${ui.dim}原始:${msg}${ui.reset}\n`);
|
|
634
|
+
layout.contentWrite(`${ui.dim}提示:运行 /model 切换到支持视觉的模型(如 gpt-4o / claude-3.5-sonnet / gemini-1.5-pro)。${ui.reset}\n`);
|
|
635
|
+
}
|
|
636
|
+
else {
|
|
637
|
+
layout.contentWrite(`${ui.red}[错误]${ui.reset} ${msg}\n`);
|
|
638
|
+
}
|
|
578
639
|
}
|
|
579
640
|
finally {
|
|
580
641
|
stopRunningListener();
|
|
@@ -709,11 +770,55 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
709
770
|
currentSessionId = undefined; // 下轮起新会话文件
|
|
710
771
|
turnCount = 0; // 反思 cadence 重新计数
|
|
711
772
|
contextState.lastUsage = undefined;
|
|
773
|
+
pendingAttachments = []; // 一并清空待发图片
|
|
712
774
|
layout.clearContent();
|
|
713
775
|
layout.contentWrite(bannerString(banner()));
|
|
714
776
|
layout.contentWrite(`${ui.dim}(历史已清空,保留系统提示)${ui.reset}\n`);
|
|
715
777
|
continue;
|
|
716
778
|
}
|
|
779
|
+
// /image:附加本地图片到下一条 user 消息(支持 /image <path> · /image list · /image clear)。
|
|
780
|
+
// dispatch 阶段不调 runTurn:仅 mutate pendingAttachments 状态,提交时(runTurn 入口)才 flush 进 history。
|
|
781
|
+
if (line === '/image' || line === '/image list' || line === '/image clear' || line.startsWith('/image ')) {
|
|
782
|
+
if (line === '/image list' || (line === '/image' && pendingAttachments.length > 0)) {
|
|
783
|
+
// 空 /image 视为 list(无歧义;若用户想加图必须 /image <path>)
|
|
784
|
+
if (pendingAttachments.length === 0) {
|
|
785
|
+
layout.contentWrite(`${ui.dim}(无待发送图片)${ui.reset}\n`);
|
|
786
|
+
}
|
|
787
|
+
else {
|
|
788
|
+
for (const a of pendingAttachments) {
|
|
789
|
+
layout.contentWrite(` ${ui.dim}${renderChip(a)}${ui.reset}\n`);
|
|
790
|
+
}
|
|
791
|
+
}
|
|
792
|
+
continue;
|
|
793
|
+
}
|
|
794
|
+
if (line === '/image clear' || (line === '/image' && pendingAttachments.length === 0)) {
|
|
795
|
+
// /image 单独输入 + 无 pending:也走 list(空集)
|
|
796
|
+
if (line === '/image' && pendingAttachments.length === 0) {
|
|
797
|
+
layout.contentWrite(`${ui.dim}(无待发送图片)${ui.reset}\n`);
|
|
798
|
+
}
|
|
799
|
+
else {
|
|
800
|
+
pendingAttachments = [];
|
|
801
|
+
layout.contentWrite(`${ui.dim}(已清空待发送图片)${ui.reset}\n`);
|
|
802
|
+
}
|
|
803
|
+
continue;
|
|
804
|
+
}
|
|
805
|
+
const arg = line.slice('/image'.length).trim().replace(/^["']|["']$/g, '');
|
|
806
|
+
if (!arg) {
|
|
807
|
+
layout.contentWrite(`${ui.dim}用法: /image <path>${ui.reset}\n`);
|
|
808
|
+
continue;
|
|
809
|
+
}
|
|
810
|
+
const maxBytes = config.maxImageBytes ?? MAX_INLINE_BYTES_DEFAULT;
|
|
811
|
+
const r = await loadImageAttachment(arg, { maxBytes });
|
|
812
|
+
if (!r.ok) {
|
|
813
|
+
layout.contentWrite(`${ui.red}[image] ${r.reason}${ui.reset}\n`);
|
|
814
|
+
continue;
|
|
815
|
+
}
|
|
816
|
+
if (!pendingAttachments.find((a) => a.id === r.att.id)) {
|
|
817
|
+
pendingAttachments.push(r.att);
|
|
818
|
+
}
|
|
819
|
+
layout.contentWrite(` ${ui.dim}${renderChip(r.att)} — will attach to next message${ui.reset}\n`);
|
|
820
|
+
continue;
|
|
821
|
+
}
|
|
717
822
|
if (line === '/context') {
|
|
718
823
|
layout.contentWrite(` ${renderContextBar(history)}\n`);
|
|
719
824
|
continue;
|
package/dist/session/compact.js
CHANGED
|
@@ -109,6 +109,36 @@ function toText(content) {
|
|
|
109
109
|
return String(content);
|
|
110
110
|
}
|
|
111
111
|
}
|
|
112
|
+
/**
|
|
113
|
+
* 把多模态 content 拍平成纯文本(供摘要 transcript 用):text parts 拼接;image_url parts
|
|
114
|
+
* 替换为 `[图片已剥离: <mime>]` stub,避免 base64 进摘要 prompt(LLM 看到也无意义,反而撑爆 token)。
|
|
115
|
+
* 其它情况(string / 其它形状)原样返回。
|
|
116
|
+
*/
|
|
117
|
+
export function stripImagesForSummary(m) {
|
|
118
|
+
const c = m.content;
|
|
119
|
+
if (Array.isArray(c)) {
|
|
120
|
+
const parts = [];
|
|
121
|
+
let imageCount = 0;
|
|
122
|
+
for (const p of c) {
|
|
123
|
+
if (p && typeof p === 'object') {
|
|
124
|
+
const part = p;
|
|
125
|
+
if (part.type === 'text')
|
|
126
|
+
parts.push(part.text ?? '');
|
|
127
|
+
else if (part.type === 'image_url') {
|
|
128
|
+
imageCount++;
|
|
129
|
+
const mime = part.image_url?.url?.startsWith('data:')
|
|
130
|
+
? part.image_url.url.slice(5, part.image_url.url.indexOf(';'))
|
|
131
|
+
: 'image';
|
|
132
|
+
parts.push(`[图片已剥离: ${mime}]`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
if (imageCount > 0) {
|
|
137
|
+
return { ...m, content: parts.join('') };
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return m;
|
|
141
|
+
}
|
|
112
142
|
/** 从尾向头划分 group;history[0](system)排除。连续 tool 归到前导 assistant。 */
|
|
113
143
|
function groupFromEnd(history) {
|
|
114
144
|
const groups = [];
|
|
@@ -168,7 +198,9 @@ function flattenGroups(groups) {
|
|
|
168
198
|
}
|
|
169
199
|
// ── 默认摘要器:复用 chat(),空 handlers 不打印 ──────────────────────────
|
|
170
200
|
async function defaultSummarize(older, focus) {
|
|
171
|
-
|
|
201
|
+
// 摘要前剥离多模态 user 消息里的图片(base64 会撑爆摘要 prompt;image 对摘要无信息量)。
|
|
202
|
+
const stripped = older.map(stripImagesForSummary);
|
|
203
|
+
let transcript = stripped
|
|
172
204
|
.map((m) => {
|
|
173
205
|
const role = m.role;
|
|
174
206
|
let line = `${role}: ${toText(m.content)}`;
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
// 单 plan/会话:create 前若有 in_progress 活跃 plan → 拒绝(避免误覆盖);finish/abandoned 后可再建。
|
|
7
7
|
//
|
|
8
8
|
// 工具返回:把当前 plan 紧凑渲染给 LLM(不只返操作结果——让 LLM 单次调用后看到完整状态,无需再 read)。
|
|
9
|
-
import { newPlanId, readPlan, writePlan, updatePlan, listPlans, renderPlanForLLM, } from '../../plan/store.js';
|
|
9
|
+
import { newPlanId, readPlan, writePlan, updatePlan, listPlans, archivePlan, unarchivePlan, deletePlanAnywhere, renderPlanForLLM, localIsoTimestamp, } from '../../plan/store.js';
|
|
10
10
|
import { getActivePlan, setActivePlan, hasActivePlan, clearActivePlan } from '../../plan/active.js';
|
|
11
11
|
import { MAX_OUTPUT } from '../constants.js';
|
|
12
12
|
const VALID_STATUS = new Set([
|
|
@@ -23,14 +23,15 @@ export const todolistTool = {
|
|
|
23
23
|
'For complex multi-step tasks (≥3 file changes or ≥5 tool calls expected, OR user says "先计划再执行" / "plan then do"), CALL THIS FIRST to write the plan, then update each step as you go.',
|
|
24
24
|
'For simple single-step tasks, skip it and just execute.',
|
|
25
25
|
'Single plan per session: create refuses if an in-progress plan already exists — finish or abandon it first.',
|
|
26
|
+
'Lifecycle: finish AUTO-ARCHIVES the plan to .mocode/plans/archive/ (history preserved, active dir stays clean). To revisit, call list with scope=archived or unarchive. Use delete to permanently remove a plan (any location).',
|
|
26
27
|
].join(''),
|
|
27
28
|
parameters: {
|
|
28
29
|
type: 'object',
|
|
29
30
|
properties: {
|
|
30
31
|
action: {
|
|
31
32
|
type: 'string',
|
|
32
|
-
enum: ['create', 'read', 'update', 'add_step', 'finish'],
|
|
33
|
-
description: 'create=新计划;read=读当前活跃;update=改步骤状态;add_step=追加步骤;finish=收尾',
|
|
33
|
+
enum: ['create', 'read', 'update', 'add_step', 'finish', 'list', 'delete', 'unarchive'],
|
|
34
|
+
description: 'create=新计划;read=读当前活跃;update=改步骤状态;add_step=追加步骤;finish=收尾(自动归档);list=列计划;delete=永久删除;unarchive=从归档还原到活跃',
|
|
34
35
|
},
|
|
35
36
|
title: { type: 'string', description: 'create 必填:计划标题' },
|
|
36
37
|
goal: { type: 'string', description: 'create 可选:目标描述(写进「目标」段)' },
|
|
@@ -55,7 +56,16 @@ export const todolistTool = {
|
|
|
55
56
|
plan_status: {
|
|
56
57
|
type: 'string',
|
|
57
58
|
enum: ['finished', 'abandoned'],
|
|
58
|
-
description: 'finish 必填:finished=完成;abandoned=放弃(中途取消)',
|
|
59
|
+
description: 'finish 必填:finished=完成;abandoned=放弃(中途取消)。finished 触发自动归档。',
|
|
60
|
+
},
|
|
61
|
+
scope: {
|
|
62
|
+
type: 'string',
|
|
63
|
+
enum: ['active', 'archived', 'all'],
|
|
64
|
+
description: 'list 可选:范围(active=默认,仅进行中;archived=仅归档;all=全)。',
|
|
65
|
+
},
|
|
66
|
+
id: {
|
|
67
|
+
type: 'string',
|
|
68
|
+
description: 'delete / unarchive 必填:目标 plan id(list 可拿到)。',
|
|
59
69
|
},
|
|
60
70
|
},
|
|
61
71
|
required: ['action'],
|
|
@@ -69,8 +79,11 @@ export const todolistTool = {
|
|
|
69
79
|
case 'update': return doUpdate(args);
|
|
70
80
|
case 'add_step': return doAddStep(args);
|
|
71
81
|
case 'finish': return doFinish(args);
|
|
82
|
+
case 'list': return doList(args);
|
|
83
|
+
case 'delete': return doDelete(args);
|
|
84
|
+
case 'unarchive': return doUnarchive(args);
|
|
72
85
|
default:
|
|
73
|
-
return `错误:未知 action「${action}」,合法值:create / read / update / add_step / finish。`;
|
|
86
|
+
return `错误:未知 action「${action}」,合法值:create / read / update / add_step / finish / list / delete / unarchive。`;
|
|
74
87
|
}
|
|
75
88
|
}
|
|
76
89
|
catch (e) {
|
|
@@ -99,7 +112,7 @@ function doCreate(args) {
|
|
|
99
112
|
return `错误:已存在进行中的 plan「${cur?.title ?? ''}」(id=${cur?.id ?? ''}),需先 finish 后再建。`;
|
|
100
113
|
}
|
|
101
114
|
// 旧 plan 残留(finished/abandoned)→ 允许新建,不删历史(用户可后续 read 历史 plan)
|
|
102
|
-
const now =
|
|
115
|
+
const now = localIsoTimestamp();
|
|
103
116
|
const plan = {
|
|
104
117
|
id: newPlanId(),
|
|
105
118
|
title,
|
|
@@ -195,7 +208,7 @@ function doFinish(args) {
|
|
|
195
208
|
const updated = updatePlan(cur.id, (p) => {
|
|
196
209
|
p.status = ps;
|
|
197
210
|
p.log.push({
|
|
198
|
-
at:
|
|
211
|
+
at: localIsoTimestamp(),
|
|
199
212
|
text: note || (ps === 'finished' ? '完成' : '放弃'),
|
|
200
213
|
});
|
|
201
214
|
return p;
|
|
@@ -203,14 +216,65 @@ function doFinish(args) {
|
|
|
203
216
|
if (!updated)
|
|
204
217
|
return '错误:finish 写盘失败。';
|
|
205
218
|
setActivePlan(updated);
|
|
219
|
+
// finished → 自动归档到 plans/archive/(历史完整保留,活跃目录保持干净)。
|
|
220
|
+
// abandoned → 不归档(用户想丢就丢,但仍在 plans/ 下,可显式 delete 删掉)。
|
|
221
|
+
let archived = false;
|
|
222
|
+
if (ps === 'finished') {
|
|
223
|
+
archived = archivePlan(updated.id);
|
|
224
|
+
}
|
|
206
225
|
// finish 后清活跃缓存(下轮 read 自动从 in_progress 列表兜底,本会话不再「活跃」)
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
226
|
+
clearActivePlan();
|
|
227
|
+
const archivedNote = ps === 'finished'
|
|
228
|
+
? (archived ? '(已自动归档到 plans/archive/)' : '(⚠ 归档失败,plan 仍留在 plans/,可手动 list 排查)')
|
|
229
|
+
: '';
|
|
230
|
+
return `${renderSuccess('finish', updated)}\n${archivedNote}`.trimEnd();
|
|
231
|
+
}
|
|
232
|
+
function doList(args) {
|
|
233
|
+
const scopeRaw = String(args.scope ?? 'active');
|
|
234
|
+
const scope = scopeRaw === 'archived' || scopeRaw === 'all' ? scopeRaw : 'active';
|
|
235
|
+
const plans = listPlans(scope);
|
|
236
|
+
if (plans.length === 0) {
|
|
237
|
+
return `list: 无 ${scope === 'active' ? '进行中' : scope === 'archived' ? '已归档' : ''}plan。`;
|
|
238
|
+
}
|
|
239
|
+
// 一行一条:id | status | 标题 | 进度
|
|
240
|
+
const lines = [`list (${scope},${plans.length} 条):`];
|
|
241
|
+
for (const p of plans) {
|
|
242
|
+
const done = p.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
|
|
243
|
+
const status = p.status === 'in_progress' ? 'live' : p.status === 'finished' ? 'done' : 'gone';
|
|
244
|
+
lines.push(` ${p.id} [${status}] ${p.title} (${done}/${p.steps.length})`);
|
|
245
|
+
}
|
|
246
|
+
return lines.join('\n');
|
|
247
|
+
}
|
|
248
|
+
function doDelete(args) {
|
|
249
|
+
const id = String(args.id ?? '').trim();
|
|
250
|
+
if (!id)
|
|
251
|
+
return '错误:delete 必填 id(从 list 拿)。';
|
|
252
|
+
// 安全护栏:active 状态下不能直接 delete 当前活跃(避免误删正在用的 plan)
|
|
253
|
+
const cur = getActivePlan();
|
|
254
|
+
if (cur && cur.id === id) {
|
|
255
|
+
return `错误:不能 delete 当前活跃 plan「${cur.title}」,需先 finish(自动归档)再 delete 归档副本。`;
|
|
256
|
+
}
|
|
257
|
+
const ok = deletePlanAnywhere(id);
|
|
258
|
+
if (!ok)
|
|
259
|
+
return `错误:delete 失败,id「${id}」在 plans/ 和 plans/archive/ 都找不到。`;
|
|
260
|
+
return `delete ✓: 已永久删除「${id}」(.mocode/plans/ 与 archive/ 都不再存在)。`;
|
|
261
|
+
}
|
|
262
|
+
function doUnarchive(args) {
|
|
263
|
+
const id = String(args.id ?? '').trim();
|
|
264
|
+
if (!id)
|
|
265
|
+
return '错误:unarchive 必填 id(从 list scope=archived 拿)。';
|
|
266
|
+
if (!unarchivePlan(id)) {
|
|
267
|
+
return `错误:unarchive 失败,id「${id}」在 plans/archive/ 找不到(可能已还原,或被 delete)。`;
|
|
268
|
+
}
|
|
269
|
+
// 还原后读一次拿回 plan 对象(若 status=in_progress 还可顺手恢复为活跃)
|
|
270
|
+
const fresh = readPlan(id);
|
|
271
|
+
if (!fresh)
|
|
272
|
+
return `unarchive ✓: 「${id}」已还原到 plans/(读取失败,但文件在)。`;
|
|
273
|
+
if (fresh.status === 'in_progress') {
|
|
274
|
+
setActivePlan(fresh);
|
|
275
|
+
return `${renderSuccess('unarchive', fresh)}\n(已自动设为活跃)。`.trimEnd();
|
|
212
276
|
}
|
|
213
|
-
return renderSuccess('
|
|
277
|
+
return `${renderSuccess('unarchive', fresh)}\n(注:该 plan 状态为 ${fresh.status},未自动激活 —— 显式 create 才激活)`;
|
|
214
278
|
}
|
|
215
279
|
// ── helpers ──
|
|
216
280
|
/** 从盘上找一个 in_progress plan(进程级 state 丢失/首次访问时兜底)。无 → null。 */
|
package/dist/ui/layout.js
CHANGED
|
@@ -774,7 +774,7 @@ function composePlanLine(status, cols) {
|
|
|
774
774
|
if (!plan)
|
|
775
775
|
return ''; // 无 plan:画空,等 paint 路径 clearLine
|
|
776
776
|
// 整行左对齐,不留右段(plan 自带进度信息,不需要 cwd)
|
|
777
|
-
return `${ui.
|
|
777
|
+
return `${ui.yellow}${plan}${ui.reset}`;
|
|
778
778
|
}
|
|
779
779
|
/** 画状态行(plan 行 + spinner 行 + model 行,三行)。RUNNING 态 spinner 频繁调。
|
|
780
780
|
* 行号(footerH=6):
|