mocode-ai 0.2.8 → 0.2.9
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/config/index.js +14 -1
- package/dist/context/classifier.js +2 -0
- package/dist/plan/active.js +45 -0
- package/dist/plan/index.js +33 -0
- package/dist/plan/state.js +11 -0
- package/dist/plan/store.js +351 -0
- package/dist/repl/index.js +17 -3
- package/dist/sandbox/policy.js +1 -0
- package/dist/tools/builtins/index.js +2 -0
- package/dist/tools/builtins/todolist.js +231 -0
- package/dist/ui/layout.js +43 -14
- package/package.json +1 -1
package/dist/config/index.js
CHANGED
|
@@ -91,7 +91,14 @@ ${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.
|
|
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
|
+
## Large file writes (avoid token-cap truncation)
|
|
97
|
+
- \`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.
|
|
98
|
+
- **For large files (rough threshold: >200 lines OR >5K tokens of content)**, default to one of these strategies instead of one giant \`write_file\`:
|
|
99
|
+
- **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.
|
|
100
|
+
- **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
|
+
- For small files (≤200 lines, ≤5K tokens) just use \`write_file\` directly — no need to over-engineer.
|
|
95
102
|
|
|
96
103
|
## Failure Handling
|
|
97
104
|
- Tools return errors as strings (edit_file no match or non-unique, run_command non-zero exit, etc.). Analyze the root cause, adjust, then retry — don't resend the same call verbatim.
|
|
@@ -112,6 +119,12 @@ ${PLATFORM_NOTE}
|
|
|
112
119
|
- Default is AUTO mode: you research and execute with all tools (read/edit/run_command/memory/web/skills).
|
|
113
120
|
- For complex or multi-step tasks, the user may switch to PLAN mode (Shift+Tab): your editing/command/memory-write tools are then removed from your tool list, and you must research with read-only tools only and produce a step-by-step plan (no execution). On approval the session returns to auto mode to execute the plan.
|
|
114
121
|
|
|
122
|
+
## Working notepad (todolist) — checklist for complex tasks
|
|
123
|
+
- 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
|
+
- 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. After \`todolist finish\`, the plan is archived and a new one can be created.
|
|
126
|
+
- 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
|
+
|
|
115
128
|
## Termination & Reporting
|
|
116
129
|
- Stop immediately when no more tools are needed; give conclusions directly.
|
|
117
130
|
- Report honestly: say success when successful, say where you're stuck when failing, and mention anything skipped. Reference code in "path:line" format (e.g., src/index.ts:42). Keep it concise.`;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
// 活跃 plan 进程级缓存:单 plan/会话的内存状态 + 变更通知。
|
|
2
|
+
//
|
|
3
|
+
// 仿 src/agent/mode.ts 形态:零依赖、setter/getter/listener,不反向引用业务。
|
|
4
|
+
// todo 工具每次改 plan → 调 setActivePlan(newPlan) → listener 触发(repl 刷 status 行 + history[0])。
|
|
5
|
+
// store 仍是文件源(本缓存与之可能短暂不一致——以文件为准,缓存仅供快速读)。
|
|
6
|
+
import { renderPlanChip } from './store.js';
|
|
7
|
+
let active = null;
|
|
8
|
+
const listeners = new Set();
|
|
9
|
+
/** 取当前活跃 plan(完整对象,工具用)。无 → null。 */
|
|
10
|
+
export function getActivePlan() {
|
|
11
|
+
return active;
|
|
12
|
+
}
|
|
13
|
+
/** 设活跃 plan。同一 id 重复设也走 listener(repl 借此刷 status 行——即使内容未变,显式刷新有助)。 */
|
|
14
|
+
export function setActivePlan(plan) {
|
|
15
|
+
active = plan;
|
|
16
|
+
const snap = plan ? toSnapshot(plan) : null;
|
|
17
|
+
for (const cb of listeners) {
|
|
18
|
+
try {
|
|
19
|
+
cb(snap);
|
|
20
|
+
}
|
|
21
|
+
catch { /* listener 抛错不阻断其他 listener */ }
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
/** 清活跃 plan(plan finish/abandon 后由 repl/todolist 调)。 */
|
|
25
|
+
export function clearActivePlan() {
|
|
26
|
+
setActivePlan(null);
|
|
27
|
+
}
|
|
28
|
+
/** 注册活跃 plan 变更监听器。返注销函数(不常用,模式 listener 一次性常驻)。 */
|
|
29
|
+
export function onActivePlanChange(cb) {
|
|
30
|
+
listeners.add(cb);
|
|
31
|
+
return () => listeners.delete(cb);
|
|
32
|
+
}
|
|
33
|
+
/** 是否有活跃 plan(in_progress 状态)。finished/abandoned 不算「活跃」。 */
|
|
34
|
+
export function hasActivePlan() {
|
|
35
|
+
return active !== null && active.status === 'in_progress';
|
|
36
|
+
}
|
|
37
|
+
/** 给 status 行用的极简摘要(避免调用方读 Plan 全字段)。无 → 空串。 */
|
|
38
|
+
/** 状态行 chip 用的短摘要(无 ANSI 颜色,由 layout 上色)。maxWidth 默认 56,留 room 给右段。 */
|
|
39
|
+
export function getActivePlanSummary(maxWidth = 56) {
|
|
40
|
+
return renderPlanChip(active, maxWidth);
|
|
41
|
+
}
|
|
42
|
+
function toSnapshot(p) {
|
|
43
|
+
const done = p.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
|
|
44
|
+
return { id: p.id, title: p.title, status: p.status, done, total: p.steps.length };
|
|
45
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
// plan 子系统 barrel:
|
|
2
|
+
// - store:文件级 CRUD(markdown 解析/序列化,落盘原子)
|
|
3
|
+
// - active:进程级活跃 plan 缓存 + 变更通知(单 plan/会话)
|
|
4
|
+
// - buildActivePlanSection:给 systemPrompt 用的活跃 plan 摘要段
|
|
5
|
+
//
|
|
6
|
+
// 被 repl 依赖(注入 systemPrompt + 状态行 + listener 注册)+ tools/builtins/todolist 依赖。
|
|
7
|
+
export {
|
|
8
|
+
// store
|
|
9
|
+
plansDir, planPath, ensurePlansDir, newPlanId, parsePlan, serializePlan, readPlan, writePlan, deletePlan, updatePlan, listPlans, renderPlanForLLM, renderPlanChip, } from './store.js';
|
|
10
|
+
export { getActivePlan, setActivePlan, clearActivePlan, onActivePlanChange, hasActivePlan, getActivePlanSummary, } from './active.js';
|
|
11
|
+
import { getActivePlan } from './active.js';
|
|
12
|
+
import { renderPlanForLLM } from './store.js';
|
|
13
|
+
const ACTIVE_PLAN_HEADER = '## 当前活跃计划';
|
|
14
|
+
/**
|
|
15
|
+
* 拼给 systemPrompt 注入的活跃 plan 摘要段。
|
|
16
|
+
* - 无活跃 plan → 空串(repl 直接跳过拼接,systemPrompt 长度不变)。
|
|
17
|
+
* - 有 → 紧凑 markdown(目标 + 步骤 checkbox + 进度日志末 5 条),header + 内容。
|
|
18
|
+
*
|
|
19
|
+
* 触发:setActivePlan 后由 repl listener 调;也供 buildSystemMessage 直接同步取(repl 入口)。
|
|
20
|
+
* 上限:超 MAX_ACTIVE_PLAN_CHARS 截到尾部(罕见,plan 文件本身就小)。
|
|
21
|
+
*/
|
|
22
|
+
export function buildActivePlanSection() {
|
|
23
|
+
const p = getActivePlan();
|
|
24
|
+
if (!p)
|
|
25
|
+
return '';
|
|
26
|
+
const body = renderPlanForLLM(p);
|
|
27
|
+
const full = `${ACTIVE_PLAN_HEADER}\n${body}`;
|
|
28
|
+
if (full.length <= MAX_ACTIVE_PLAN_CHARS)
|
|
29
|
+
return full;
|
|
30
|
+
return full.slice(0, MAX_ACTIVE_PLAN_CHARS) + '\n…(plan 摘要已截断)';
|
|
31
|
+
}
|
|
32
|
+
/** 注入 systemPrompt 的活跃 plan 摘要上限(字符)。 */
|
|
33
|
+
export const MAX_ACTIVE_PLAN_CHARS = 3000;
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// 活跃 plan 的进程级状态(共享叶子)。
|
|
2
|
+
//
|
|
3
|
+
// 职责:
|
|
4
|
+
// - 跟踪「当前会话的活跃 plan」——单 plan/会话(state 缓存,store 是文件源)。
|
|
5
|
+
// - 暴露 onActivePlanChange listener(repl 借此刷 status 行 + history[0])。
|
|
6
|
+
//
|
|
7
|
+
// 与 mode.ts 同形态:零依赖、不落盘、不反向引用业务;被 plan/、tools/builtins/todolist.ts、repl 依赖。
|
|
8
|
+
//
|
|
9
|
+
// 不在子 agent 持久化(子 agent 走 spawn,独立 history,不该继承主 plan——但 plan 文件本身在 sandboxRoot
|
|
10
|
+
// 下,子 agent 仍能 readPlan 看到全部历史 plan;state 缓存不传,只属于主 agent)。
|
|
11
|
+
export { getActivePlan, setActivePlan, clearActivePlan, onActivePlanChange, hasActivePlan, getActivePlanSummary, } from './active.js';
|
|
@@ -0,0 +1,351 @@
|
|
|
1
|
+
// 单 plan 的文件级存储:CRUD + markdown 解析/序列化。
|
|
2
|
+
//
|
|
3
|
+
// 设计目标
|
|
4
|
+
// - 文件是唯一事实源(抗压缩、可视化、用户可改)。每次写都 atomic(rename 覆盖)。
|
|
5
|
+
// - 同会话单 plan(state.ts 跟踪「活跃」,本模块不参与并发控制——单进程单活跃 plan 串行调用)。
|
|
6
|
+
// - 解析宽松:坏 frontmatter / 缺段 → 退化到最小可用 Plan,不抛。
|
|
7
|
+
// - 序列化稳定:每段顺序固定(frontmatter → 目标 → 步骤 → 进度日志),便于 git diff 友好。
|
|
8
|
+
//
|
|
9
|
+
// 不依赖 agent / ui / llm;仅 node:fs + node:path。仿 memory/store.ts 的「叶子」分层。
|
|
10
|
+
import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { join } from 'node:path';
|
|
12
|
+
import { getSandboxRoot } from '../sandbox/root.js';
|
|
13
|
+
// ── 路径 ──
|
|
14
|
+
/** plans 目录:<sandboxRoot>/.mocode/plans/。无 sandboxRoot 退到 cwd(防御)。 */
|
|
15
|
+
export function plansDir() {
|
|
16
|
+
const root = getSandboxRoot() ?? process.cwd();
|
|
17
|
+
return join(root, '.mocode', 'plans');
|
|
18
|
+
}
|
|
19
|
+
/** 单 plan 文件路径:<root>/.mocode/plans/<id>.md。 */
|
|
20
|
+
export function planPath(id) {
|
|
21
|
+
return join(plansDir(), `${id}.md`);
|
|
22
|
+
}
|
|
23
|
+
/** 确保 plans 目录存在(惰性,工具每次写前调)。 */
|
|
24
|
+
export function ensurePlansDir() {
|
|
25
|
+
const d = plansDir();
|
|
26
|
+
if (!existsSync(d))
|
|
27
|
+
mkdirSync(d, { recursive: true });
|
|
28
|
+
}
|
|
29
|
+
// ── ID 生成 ──
|
|
30
|
+
/** plan id:YYYY-MM-DDTHH-mm-ss-xxxxxx(本地时间,文件名安全)。xxxxxx = 4 字节随机 hex(防同秒并发撞)。 */
|
|
31
|
+
export function newPlanId(now = new Date()) {
|
|
32
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
33
|
+
const y = now.getFullYear();
|
|
34
|
+
const mo = pad(now.getMonth() + 1);
|
|
35
|
+
const d = pad(now.getDate());
|
|
36
|
+
const h = pad(now.getHours());
|
|
37
|
+
const mi = pad(now.getMinutes());
|
|
38
|
+
const s = pad(now.getSeconds());
|
|
39
|
+
// crypto 不必要;Math.random 够防同秒撞(碰撞概率 ~1/2^32)
|
|
40
|
+
const r = Math.floor(Math.random() * 0xffffffff).toString(16).padStart(8, '0');
|
|
41
|
+
return `${y}-${mo}-${d}T${h}-${mi}-${s}-${r}`;
|
|
42
|
+
}
|
|
43
|
+
// ── 解析(宽松,坏文件不抛,退化到最小可用 Plan)──
|
|
44
|
+
const FRONTMATTER_RE = /^---\n([\s\S]*?)\n---\n([\s\S]*)$/;
|
|
45
|
+
const CHECKBOX_RE = /^(\s*)-\s+\[([ xX\/~])\]\s+(\d+)\.\s+(.+?)\s*$/;
|
|
46
|
+
const LOG_RE = /^-\s+(\S+)\s+(.+?)\s*$/;
|
|
47
|
+
/**
|
|
48
|
+
* 解析 plan 文件为 Plan 对象。失败(无 frontmatter / 段缺失 / 坏 checkbox)→ 退化:
|
|
49
|
+
* - 无 frontmatter:把整文当 title 段
|
|
50
|
+
* - 缺字段:空串 / 空数组
|
|
51
|
+
* - 坏 checkbox:跳过该行,不污染好行
|
|
52
|
+
* 解析成功但 step id 缺序 / 重号:按出现顺序重排为 1..N(保稳定)。
|
|
53
|
+
*/
|
|
54
|
+
export function parsePlan(raw, fallbackId) {
|
|
55
|
+
const now = new Date().toISOString();
|
|
56
|
+
let meta = {};
|
|
57
|
+
let body = raw;
|
|
58
|
+
const m = FRONTMATTER_RE.exec(raw);
|
|
59
|
+
if (m) {
|
|
60
|
+
meta = parseFrontmatter(m[1]);
|
|
61
|
+
body = m[2];
|
|
62
|
+
}
|
|
63
|
+
const id = meta.id || fallbackId;
|
|
64
|
+
const title = meta.title || extractFirstHeading(body) || '(无标题)';
|
|
65
|
+
const status = normalizePlanStatus(meta.status);
|
|
66
|
+
const created = meta.created || now;
|
|
67
|
+
const updated = meta.updated || now;
|
|
68
|
+
const { goal, steps, log } = parseBody(body);
|
|
69
|
+
// 步骤 id 规范化:1..N(防文件手改致 id 跳号)
|
|
70
|
+
const normSteps = steps.map((s, i) => ({ ...s, id: i + 1 }));
|
|
71
|
+
return { id, title, status, created, updated, goal, steps: normSteps, log };
|
|
72
|
+
}
|
|
73
|
+
function parseFrontmatter(s) {
|
|
74
|
+
const out = {};
|
|
75
|
+
for (const line of s.split('\n')) {
|
|
76
|
+
const idx = line.indexOf(':');
|
|
77
|
+
if (idx <= 0)
|
|
78
|
+
continue;
|
|
79
|
+
const k = line.slice(0, idx).trim();
|
|
80
|
+
const v = line.slice(idx + 1).trim();
|
|
81
|
+
if (k)
|
|
82
|
+
out[k] = v;
|
|
83
|
+
}
|
|
84
|
+
return out;
|
|
85
|
+
}
|
|
86
|
+
function normalizePlanStatus(s) {
|
|
87
|
+
if (s === 'finished' || s === 'abandoned')
|
|
88
|
+
return s;
|
|
89
|
+
return 'in_progress';
|
|
90
|
+
}
|
|
91
|
+
function extractFirstHeading(body) {
|
|
92
|
+
const m = /^#\s+(.+?)\s*$/m.exec(body);
|
|
93
|
+
return m ? m[1] : '';
|
|
94
|
+
}
|
|
95
|
+
function parseBody(body) {
|
|
96
|
+
const sections = {};
|
|
97
|
+
const re = /^##\s+(.+?)\s*$/gm;
|
|
98
|
+
let lastKey = '';
|
|
99
|
+
let lastStart = -1;
|
|
100
|
+
let m;
|
|
101
|
+
while ((m = re.exec(body)) !== null) {
|
|
102
|
+
if (lastKey)
|
|
103
|
+
sections[lastKey] = body.slice(lastStart, m.index);
|
|
104
|
+
lastKey = m[1].trim();
|
|
105
|
+
lastStart = m.index + m[0].length;
|
|
106
|
+
}
|
|
107
|
+
if (lastKey)
|
|
108
|
+
sections[lastKey] = body.slice(lastStart);
|
|
109
|
+
const goal = (sections['目标'] || sections['Goal'] || '').trim();
|
|
110
|
+
const stepBlock = sections['步骤'] || sections['Steps'] || '';
|
|
111
|
+
const logBlock = sections['进度日志'] || sections['Log'] || sections['进度'] || '';
|
|
112
|
+
const steps = [];
|
|
113
|
+
for (const line of stepBlock.split('\n')) {
|
|
114
|
+
const cm = CHECKBOX_RE.exec(line);
|
|
115
|
+
if (!cm)
|
|
116
|
+
continue;
|
|
117
|
+
const marker = cm[2].toLowerCase();
|
|
118
|
+
const status = marker === 'x' ? 'done' :
|
|
119
|
+
marker === '/' ? 'in_progress' :
|
|
120
|
+
marker === '~' ? 'skipped' :
|
|
121
|
+
'pending';
|
|
122
|
+
const id = Number(cm[3]);
|
|
123
|
+
const title = cm[4].trim();
|
|
124
|
+
if (!Number.isFinite(id) || id < 1)
|
|
125
|
+
continue;
|
|
126
|
+
steps.push({ id, title, status });
|
|
127
|
+
}
|
|
128
|
+
const log = [];
|
|
129
|
+
for (const line of logBlock.split('\n')) {
|
|
130
|
+
const lm = LOG_RE.exec(line);
|
|
131
|
+
if (!lm)
|
|
132
|
+
continue;
|
|
133
|
+
log.push({ at: lm[1], text: lm[2] });
|
|
134
|
+
}
|
|
135
|
+
return { goal, steps, log };
|
|
136
|
+
}
|
|
137
|
+
// ── 序列化 ──
|
|
138
|
+
/** 序列化为 markdown 文本。固定段序:frontmatter → 目标 → 步骤 → 进度日志。 */
|
|
139
|
+
export function serializePlan(p) {
|
|
140
|
+
const lines = [];
|
|
141
|
+
lines.push('---');
|
|
142
|
+
lines.push(`id: ${p.id}`);
|
|
143
|
+
lines.push(`title: ${p.title}`);
|
|
144
|
+
lines.push(`status: ${p.status}`);
|
|
145
|
+
lines.push(`created: ${p.created}`);
|
|
146
|
+
lines.push(`updated: ${p.updated}`);
|
|
147
|
+
lines.push('---');
|
|
148
|
+
lines.push('');
|
|
149
|
+
lines.push(`# ${p.title}`);
|
|
150
|
+
lines.push('');
|
|
151
|
+
lines.push('## 目标');
|
|
152
|
+
lines.push(p.goal || '(未填写)');
|
|
153
|
+
lines.push('');
|
|
154
|
+
lines.push('## 步骤');
|
|
155
|
+
if (p.steps.length === 0) {
|
|
156
|
+
lines.push('(无步骤)');
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
for (const s of p.steps) {
|
|
160
|
+
lines.push(`- [${checkboxMarker(s.status)}] ${s.id}. ${s.title}`);
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
lines.push('');
|
|
164
|
+
lines.push('## 进度日志');
|
|
165
|
+
if (p.log.length === 0) {
|
|
166
|
+
lines.push('(无)');
|
|
167
|
+
}
|
|
168
|
+
else {
|
|
169
|
+
for (const e of p.log) {
|
|
170
|
+
lines.push(`- ${e.at} ${e.text}`);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
lines.push('');
|
|
174
|
+
return lines.join('\n');
|
|
175
|
+
}
|
|
176
|
+
function checkboxMarker(s) {
|
|
177
|
+
switch (s) {
|
|
178
|
+
case 'done': return 'x';
|
|
179
|
+
case 'in_progress': return '/';
|
|
180
|
+
case 'skipped': return '~';
|
|
181
|
+
case 'failed': return 'x'; // 用 x + log 标 failed(checkbox 集 [ ]/x/[x] 之外的细态不上,降级)
|
|
182
|
+
case 'pending':
|
|
183
|
+
default: return ' ';
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
// ── CRUD(直接落盘)──
|
|
187
|
+
/** 读 plan 文件;不存在 → null,坏文件 → fallbackId 最小可用 Plan(不抛,工具契约)。 */
|
|
188
|
+
export function readPlan(id) {
|
|
189
|
+
const p = planPath(id);
|
|
190
|
+
if (!existsSync(p))
|
|
191
|
+
return null;
|
|
192
|
+
try {
|
|
193
|
+
return parsePlan(readFileSync(p, 'utf8'), id);
|
|
194
|
+
}
|
|
195
|
+
catch {
|
|
196
|
+
return null;
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
/** 写 plan 文件(atomic:写 .tmp 再 rename)。返回是否成功。 */
|
|
200
|
+
export function writePlan(p) {
|
|
201
|
+
try {
|
|
202
|
+
ensurePlansDir();
|
|
203
|
+
const target = planPath(p.id);
|
|
204
|
+
const tmp = target + '.tmp';
|
|
205
|
+
writeFileSync(tmp, serializePlan(p), 'utf8');
|
|
206
|
+
renameSync(tmp, target);
|
|
207
|
+
return true;
|
|
208
|
+
}
|
|
209
|
+
catch {
|
|
210
|
+
return false;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
/** 删 plan 文件;不存在静默 ok。 */
|
|
214
|
+
export function deletePlan(id) {
|
|
215
|
+
const p = planPath(id);
|
|
216
|
+
if (!existsSync(p))
|
|
217
|
+
return true;
|
|
218
|
+
try {
|
|
219
|
+
unlinkSync(p);
|
|
220
|
+
return true;
|
|
221
|
+
}
|
|
222
|
+
catch {
|
|
223
|
+
return false;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
/**
|
|
227
|
+
* 原子读-改-写:读 → mutator 改 → 写。mutator 返回 false 视为"无改动",跳过写。
|
|
228
|
+
* mutator 抛错 → 写失败 → 返 null(不抛,工具契约)。
|
|
229
|
+
*
|
|
230
|
+
* mutator 同步:本 store 走同步 fs,mutator 必须同步(返 Promise 视为"无信号"——返原对象);
|
|
231
|
+
* 设计如此以避免把 fs API 改成 async(同步 fs 在 node 单 tick 原子,无需锁)。
|
|
232
|
+
*/
|
|
233
|
+
export function updatePlan(id, mutator) {
|
|
234
|
+
const cur = readPlan(id);
|
|
235
|
+
if (!cur)
|
|
236
|
+
return null;
|
|
237
|
+
let next;
|
|
238
|
+
try {
|
|
239
|
+
next = mutator(cur);
|
|
240
|
+
}
|
|
241
|
+
catch {
|
|
242
|
+
return null;
|
|
243
|
+
}
|
|
244
|
+
if (next === false)
|
|
245
|
+
return cur; // 无改动:返原对象
|
|
246
|
+
next.updated = new Date().toISOString();
|
|
247
|
+
if (!writePlan(next))
|
|
248
|
+
return null;
|
|
249
|
+
return next;
|
|
250
|
+
}
|
|
251
|
+
/** 列 plans 目录下所有 plan(按 updated 倒序,最新在前)。解析失败的跳过。 */
|
|
252
|
+
export function listPlans() {
|
|
253
|
+
const dir = plansDir();
|
|
254
|
+
if (!existsSync(dir))
|
|
255
|
+
return [];
|
|
256
|
+
let names;
|
|
257
|
+
try {
|
|
258
|
+
names = readdirSync(dir).filter((n) => n.endsWith('.md'));
|
|
259
|
+
}
|
|
260
|
+
catch {
|
|
261
|
+
return [];
|
|
262
|
+
}
|
|
263
|
+
const out = [];
|
|
264
|
+
for (const n of names) {
|
|
265
|
+
const id = n.slice(0, -3);
|
|
266
|
+
const p = readPlan(id);
|
|
267
|
+
if (p)
|
|
268
|
+
out.push(p);
|
|
269
|
+
}
|
|
270
|
+
out.sort((a, b) => (a.updated < b.updated ? 1 : a.updated > b.updated ? -1 : 0));
|
|
271
|
+
return out;
|
|
272
|
+
}
|
|
273
|
+
// ── 渲染(给 LLM / UI 用)──
|
|
274
|
+
/** 把 Plan 渲成给 LLM 看的紧凑摘要(多行文本)。 */
|
|
275
|
+
export function renderPlanForLLM(p) {
|
|
276
|
+
const lines = [];
|
|
277
|
+
lines.push(`# ${p.title} [${p.status}]`);
|
|
278
|
+
if (p.goal)
|
|
279
|
+
lines.push(`\n## 目标\n${p.goal}`);
|
|
280
|
+
const done = p.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
|
|
281
|
+
lines.push(`\n## 步骤 (${done}/${p.steps.length} 完成)`);
|
|
282
|
+
if (p.steps.length === 0) {
|
|
283
|
+
lines.push('(无)');
|
|
284
|
+
}
|
|
285
|
+
else {
|
|
286
|
+
for (const s of p.steps) {
|
|
287
|
+
lines.push(`- [${checkboxMarker(s.status)}] ${s.id}. ${s.title}`);
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
if (p.log.length > 0) {
|
|
291
|
+
lines.push(`\n## 进度日志(最近 ${Math.min(p.log.length, 5)} 条)`);
|
|
292
|
+
for (const e of p.log.slice(-5))
|
|
293
|
+
lines.push(`- ${e.at} ${e.text}`);
|
|
294
|
+
}
|
|
295
|
+
return lines.join('\n');
|
|
296
|
+
}
|
|
297
|
+
/**
|
|
298
|
+
* 状态行 chip 的短摘要:`plan: 标题 (done/total) ▸ <当前 in_progress 步骤>`。
|
|
299
|
+
* 无活跃 plan → 空串。
|
|
300
|
+
*
|
|
301
|
+
* 字符截断(非终端宽,固定长度 ~50 字符,方便状态行收纳):
|
|
302
|
+
* - 标题(plan 标题):超 10 字截断,加 "..."
|
|
303
|
+
* - 步骤标题:超 17 字截断,加 "..."
|
|
304
|
+
* - 例:`plan: 3D 贪吃蛇游戏 (0/9) ▸ 1. 搭建项目骨架:创建 snake3d...`
|
|
305
|
+
*
|
|
306
|
+
* 当前步骤取 status=in_progress;无 in_progress 但有 pending 时回退到第一项 pending
|
|
307
|
+
* (LLM 刚标记某步 done 还没动 next 时,这样能看清下一步)。
|
|
308
|
+
* 全 done → 不带 tail;finished → 拼「✓ N/N」。
|
|
309
|
+
*
|
|
310
|
+
* maxWidth 是软上限:极端窄(<26)时只保 head+count 不带 tail;否则按上述字符限。
|
|
311
|
+
*/
|
|
312
|
+
const PLAN_TITLE_MAX = 10;
|
|
313
|
+
const STEP_TITLE_MAX = 17;
|
|
314
|
+
const TRUNC_DOTS = '...';
|
|
315
|
+
export function renderPlanChip(p, maxWidth = 56) {
|
|
316
|
+
if (!p)
|
|
317
|
+
return '';
|
|
318
|
+
const title = truncateByChars(p.title, PLAN_TITLE_MAX);
|
|
319
|
+
const head = p.status === 'finished'
|
|
320
|
+
? `plan ✓ ${title}`
|
|
321
|
+
: `plan: ${title}`;
|
|
322
|
+
const total = p.steps.length;
|
|
323
|
+
const done = p.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
|
|
324
|
+
const count = `(${done}/${total})`;
|
|
325
|
+
const fixed = `${head} ${count}`;
|
|
326
|
+
// 极窄:没空间塞步骤,只保 head+count
|
|
327
|
+
if (maxWidth < 26)
|
|
328
|
+
return fixed;
|
|
329
|
+
const cur = p.status === 'in_progress'
|
|
330
|
+
? p.steps.find((s) => s.status === 'in_progress')
|
|
331
|
+
?? p.steps.find((s) => s.status === 'pending')
|
|
332
|
+
: null; // finished / abandoned → 不显当前步
|
|
333
|
+
if (!cur)
|
|
334
|
+
return fixed;
|
|
335
|
+
const stepTitle = truncateByChars(cur.title, STEP_TITLE_MAX);
|
|
336
|
+
return `${fixed} ▸ ${cur.id}. ${stepTitle}`;
|
|
337
|
+
}
|
|
338
|
+
/** 字符数截断(非显示宽):超 max 字符截到 max + "..."(3 字符省略号固定)。
|
|
339
|
+
* <= max → 原样返回;> max → 前 max 字符 + "..."。 */
|
|
340
|
+
function truncateByChars(s, max) {
|
|
341
|
+
if (s.length <= max)
|
|
342
|
+
return s;
|
|
343
|
+
return s.slice(0, max) + TRUNC_DOTS;
|
|
344
|
+
}
|
|
345
|
+
function truncateForChip(s, max = 18) {
|
|
346
|
+
if (s.length <= max)
|
|
347
|
+
return s;
|
|
348
|
+
if (max <= 1)
|
|
349
|
+
return '…';
|
|
350
|
+
return s.slice(0, max - 1) + '…';
|
|
351
|
+
}
|
package/dist/repl/index.js
CHANGED
|
@@ -17,8 +17,9 @@ import { tools } from '../tools/registry.js';
|
|
|
17
17
|
import { estimateMessagesTokens, reconfigureClient, } from '../llm/index.js';
|
|
18
18
|
import { compactHistory, contextState, newSessionId, saveSession, loadSession, listSessions, } from '../session/index.js';
|
|
19
19
|
import { listTurns, planRollback, applyRollback, persistSnapshots, loadSnapshots, rebuildFromHistory, resetState, } from '../rollback/index.js';
|
|
20
|
-
import { listSkills, effectiveSystemPrompt } from '../skills/index.js';
|
|
20
|
+
import { listSkills, effectiveSystemPrompt, } from '../skills/index.js';
|
|
21
21
|
import { buildMemorySection, buildMemoryIndexSection, kickoffReflection, drainMemoryBackground, getLastReflectResult, clearLastReflectResult, snapshotTranscript, formatReflectResult, loadAll, } from '../memory/index.js';
|
|
22
|
+
import { buildActivePlanSection, onActivePlanChange, hasActivePlan, getActivePlanSummary, } from '../plan/index.js';
|
|
22
23
|
/**
|
|
23
24
|
* readline 的 prompt 必须是纯文本(无 ANSI):readline 按字符数算光标位置,
|
|
24
25
|
* 颜色码会让光标错位、编辑时漂移。颜色只用在直接 stdout.write 的横幅 / 工具行 / 回复。
|
|
@@ -138,13 +139,14 @@ function renderContextBarInline(history) {
|
|
|
138
139
|
const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.cyan;
|
|
139
140
|
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
|
|
140
141
|
}
|
|
141
|
-
/** 状态行基线:模型 / context / cwd /
|
|
142
|
+
/** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip。repl 在轮次边界、切模式、plan 变更时调。 */
|
|
142
143
|
function refreshStatusBase(history) {
|
|
143
144
|
layout.setStatusBase({
|
|
144
145
|
model: config.model,
|
|
145
146
|
contextBar: renderContextBarInline(history),
|
|
146
147
|
cwd: process.cwd(),
|
|
147
148
|
modeTag: getAgentMode() === 'plan' ? 'plan' : 'auto',
|
|
149
|
+
planSummary: hasActivePlan() ? getActivePlanSummary(process.stdout.columns ?? 80) : '',
|
|
148
150
|
});
|
|
149
151
|
}
|
|
150
152
|
/** 命令 → 运行态状态文字 + 底栏 dim 占位。 */
|
|
@@ -378,10 +380,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
378
380
|
setSandboxRoot(sandboxRootOverride ?? config.sandboxRoot ?? process.cwd());
|
|
379
381
|
// 构造系统提示:auto 用 base;plan 在 config.systemPrompt 后追加 PLAN_MODE_SUFFIX。
|
|
380
382
|
// 切模式时 applyMode 重算 history[0](history[0] 恒 system,compaction 保它,不破坏)。
|
|
383
|
+
// 活跃 plan 摘要拼在 memory 段后(systemPrompt 的尾段),todo 工具变更后 listener 重写 history[0]。
|
|
381
384
|
const buildSystemMessage = (planMode) => effectiveSystemPrompt(config.systemPrompt +
|
|
382
385
|
(planMode ? PLAN_MODE_SUFFIX : '') +
|
|
383
386
|
buildMemorySection() +
|
|
384
|
-
buildMemoryIndexSection()
|
|
387
|
+
buildMemoryIndexSection() +
|
|
388
|
+
buildActivePlanSection());
|
|
385
389
|
// 有预加载(--resume)则用它,并把 history[0] 刷成当前 system prompt(config 可能已变);
|
|
386
390
|
// 否则新会话只塞 system 提示(默认 auto)。
|
|
387
391
|
const history = initialHistory && initialHistory.length
|
|
@@ -447,6 +451,16 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
447
451
|
applyMode(m === 'plan');
|
|
448
452
|
refreshStatusBase(history);
|
|
449
453
|
});
|
|
454
|
+
// 注册活跃 plan 变更监听器:todolist 工具每次 create/update/add_step/finish 后调 setActivePlan,
|
|
455
|
+
// 触发本 listener 重写 history[0](plan 摘要段刷新)+ 刷状态行 plan chip。
|
|
456
|
+
// 不调 drawStatusBar:INPUT 态靠 prompt.ts redraw;RUNNING 态靠 200ms turnTimer 兜底。
|
|
457
|
+
// listener 内访问 history 是闭包捕获,保持同一引用(repl 持有)。
|
|
458
|
+
onActivePlanChange(() => {
|
|
459
|
+
if (history[0]?.role === 'system') {
|
|
460
|
+
history[0] = { role: 'system', content: buildSystemMessage(getAgentMode() === 'plan') };
|
|
461
|
+
}
|
|
462
|
+
refreshStatusBase(history);
|
|
463
|
+
});
|
|
450
464
|
/**
|
|
451
465
|
* 回滚子流程(由 /rollback 触发):菜单(↑/↓)选轮次 → 选中第 X 轮 = 删第 X 轮及之后 + 预填第 X 轮 user 输入
|
|
452
466
|
* (仿 Claude Code rewind,Enter 重新跑该轮);被删轮次的文件改动走二选一菜单(promptRevertChoice:
|
package/dist/sandbox/policy.js
CHANGED
|
@@ -17,6 +17,7 @@ import { memoryListTool } from './memory-list.js';
|
|
|
17
17
|
import { memoryUpdateTool } from './memory-update.js';
|
|
18
18
|
import { memoryForgetTool } from './memory-forget.js';
|
|
19
19
|
import { taskTool } from './task.js';
|
|
20
|
+
import { todolistTool } from './todolist.js';
|
|
20
21
|
/**
|
|
21
22
|
* 所有内置工具,按注册顺序排列。
|
|
22
23
|
* 加新工具:在本目录新建 `xxx.ts` 导出一个 Tool,再在下面数组里加一行。无需改 agent / llm。
|
|
@@ -41,4 +42,5 @@ export const builtinTools = [
|
|
|
41
42
|
memoryUpdateTool,
|
|
42
43
|
memoryForgetTool,
|
|
43
44
|
taskTool, // 派生子 agent(独立 history + 可受限工具集);plan 模式禁用(见 PLAN_DISABLED_TOOLS)
|
|
45
|
+
todolistTool, // 工作记事本(plan 文件:复杂任务 checklist,落盘抗压缩);plan 模式可用(便于「先 plan 再 auto」时落地执行清单)
|
|
44
46
|
];
|
|
@@ -0,0 +1,231 @@
|
|
|
1
|
+
// todolist 工具:LLM 的「工作记事本」——把隐式思考外化为持久 checklist,抗压缩、可视化。
|
|
2
|
+
//
|
|
3
|
+
// 落地:plan/store.ts(文件源)+ plan/active.ts(进程级活跃缓存)+ 本工具(写盘 + 通知 listener)。
|
|
4
|
+
//
|
|
5
|
+
// 契约对齐「调度器永不抛错、永远返回字符串」(tools/registry.ts executeTool)。
|
|
6
|
+
// 单 plan/会话:create 前若有 in_progress 活跃 plan → 拒绝(避免误覆盖);finish/abandoned 后可再建。
|
|
7
|
+
//
|
|
8
|
+
// 工具返回:把当前 plan 紧凑渲染给 LLM(不只返操作结果——让 LLM 单次调用后看到完整状态,无需再 read)。
|
|
9
|
+
import { newPlanId, readPlan, writePlan, updatePlan, listPlans, renderPlanForLLM, } from '../../plan/store.js';
|
|
10
|
+
import { getActivePlan, setActivePlan, hasActivePlan, clearActivePlan } from '../../plan/active.js';
|
|
11
|
+
import { MAX_OUTPUT } from '../constants.js';
|
|
12
|
+
const VALID_STATUS = new Set([
|
|
13
|
+
'pending', 'in_progress', 'done', 'skipped', 'failed',
|
|
14
|
+
]);
|
|
15
|
+
const VALID_PLAN_STATUS = new Set([
|
|
16
|
+
'in_progress', 'finished', 'abandoned',
|
|
17
|
+
]);
|
|
18
|
+
// ── 工具定义 ──
|
|
19
|
+
export const todolistTool = {
|
|
20
|
+
name: 'todolist',
|
|
21
|
+
description: [
|
|
22
|
+
'Maintain a working "notepad" plan in .mocode/plans/<id>.md (file-based, survives context compression).',
|
|
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
|
+
'For simple single-step tasks, skip it and just execute.',
|
|
25
|
+
'Single plan per session: create refuses if an in-progress plan already exists — finish or abandon it first.',
|
|
26
|
+
].join(''),
|
|
27
|
+
parameters: {
|
|
28
|
+
type: 'object',
|
|
29
|
+
properties: {
|
|
30
|
+
action: {
|
|
31
|
+
type: 'string',
|
|
32
|
+
enum: ['create', 'read', 'update', 'add_step', 'finish'],
|
|
33
|
+
description: 'create=新计划;read=读当前活跃;update=改步骤状态;add_step=追加步骤;finish=收尾',
|
|
34
|
+
},
|
|
35
|
+
title: { type: 'string', description: 'create 必填:计划标题' },
|
|
36
|
+
goal: { type: 'string', description: 'create 可选:目标描述(写进「目标」段)' },
|
|
37
|
+
steps: {
|
|
38
|
+
type: 'array',
|
|
39
|
+
items: { type: 'string' },
|
|
40
|
+
description: 'create 必填:步骤标题数组(从 1 起自动编号)',
|
|
41
|
+
},
|
|
42
|
+
step_id: {
|
|
43
|
+
type: 'number',
|
|
44
|
+
description: 'update 必填:步骤编号(1-based;create 后 read 返回的 id)',
|
|
45
|
+
},
|
|
46
|
+
status: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
enum: ['pending', 'in_progress', 'done', 'skipped', 'failed'],
|
|
49
|
+
description: 'update 必填:目标状态',
|
|
50
|
+
},
|
|
51
|
+
note: {
|
|
52
|
+
type: 'string',
|
|
53
|
+
description: 'update / finish 可选:追加到进度日志的一行说明(可空)',
|
|
54
|
+
},
|
|
55
|
+
plan_status: {
|
|
56
|
+
type: 'string',
|
|
57
|
+
enum: ['finished', 'abandoned'],
|
|
58
|
+
description: 'finish 必填:finished=完成;abandoned=放弃(中途取消)',
|
|
59
|
+
},
|
|
60
|
+
},
|
|
61
|
+
required: ['action'],
|
|
62
|
+
},
|
|
63
|
+
async execute(args) {
|
|
64
|
+
const action = String(args.action ?? '');
|
|
65
|
+
try {
|
|
66
|
+
switch (action) {
|
|
67
|
+
case 'create': return doCreate(args);
|
|
68
|
+
case 'read': return doRead();
|
|
69
|
+
case 'update': return doUpdate(args);
|
|
70
|
+
case 'add_step': return doAddStep(args);
|
|
71
|
+
case 'finish': return doFinish(args);
|
|
72
|
+
default:
|
|
73
|
+
return `错误:未知 action「${action}」,合法值:create / read / update / add_step / finish。`;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch (e) {
|
|
77
|
+
// 兜底:工具内部不抛,但防御性 catch 一道(契约对齐「永不抛」)。
|
|
78
|
+
const why = e instanceof Error ? e.message : String(e);
|
|
79
|
+
return `错误:todolist 内部异常: ${why}`;
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
};
|
|
83
|
+
// ── actions ──
|
|
84
|
+
function doCreate(args) {
|
|
85
|
+
const title = String(args.title ?? '').trim();
|
|
86
|
+
if (!title)
|
|
87
|
+
return '错误:create 必填 title。';
|
|
88
|
+
const goal = String(args.goal ?? '').trim();
|
|
89
|
+
const rawSteps = Array.isArray(args.steps) ? args.steps : null;
|
|
90
|
+
if (!rawSteps || rawSteps.length === 0)
|
|
91
|
+
return '错误:create 必填 steps(非空字符串数组)。';
|
|
92
|
+
const steps = rawSteps
|
|
93
|
+
.map((s) => String(s ?? '').trim())
|
|
94
|
+
.filter((s) => s.length > 0);
|
|
95
|
+
if (steps.length === 0)
|
|
96
|
+
return '错误:create 的 steps 全是空字符串。';
|
|
97
|
+
if (hasActivePlan()) {
|
|
98
|
+
const cur = getActivePlan();
|
|
99
|
+
return `错误:已存在进行中的 plan「${cur?.title ?? ''}」(id=${cur?.id ?? ''}),需先 finish 后再建。`;
|
|
100
|
+
}
|
|
101
|
+
// 旧 plan 残留(finished/abandoned)→ 允许新建,不删历史(用户可后续 read 历史 plan)
|
|
102
|
+
const now = new Date().toISOString();
|
|
103
|
+
const plan = {
|
|
104
|
+
id: newPlanId(),
|
|
105
|
+
title,
|
|
106
|
+
status: 'in_progress',
|
|
107
|
+
created: now,
|
|
108
|
+
updated: now,
|
|
109
|
+
goal,
|
|
110
|
+
steps: steps.map((t, i) => ({ id: i + 1, title: t, status: 'pending' })),
|
|
111
|
+
log: [{ at: now, text: `创建计划: ${steps.length} 步` }],
|
|
112
|
+
};
|
|
113
|
+
if (!writePlan(plan))
|
|
114
|
+
return '错误:写 plan 文件失败(检查 .mocode/plans 目录权限)。';
|
|
115
|
+
setActivePlan(plan);
|
|
116
|
+
return renderSuccess('create', plan);
|
|
117
|
+
}
|
|
118
|
+
function doRead() {
|
|
119
|
+
const cur = getActivePlan();
|
|
120
|
+
if (!cur) {
|
|
121
|
+
// 兜底:state 缓存空但文件可能存在(sandboxRoot 切了 / 进程重启)→ 列 plans 找一个最新的 in_progress
|
|
122
|
+
const fallback = findInProgressFromDisk();
|
|
123
|
+
if (fallback) {
|
|
124
|
+
setActivePlan(fallback);
|
|
125
|
+
return renderSuccess('read', fallback);
|
|
126
|
+
}
|
|
127
|
+
return '错误:无活跃 plan。先用 action=create 开一个。';
|
|
128
|
+
}
|
|
129
|
+
// 重新读盘以保最新(其他路径可能改了文件)
|
|
130
|
+
const fresh = readPlan(cur.id);
|
|
131
|
+
if (fresh)
|
|
132
|
+
setActivePlan(fresh);
|
|
133
|
+
return renderSuccess('read', fresh ?? cur);
|
|
134
|
+
}
|
|
135
|
+
function doUpdate(args) {
|
|
136
|
+
const cur = getActivePlan();
|
|
137
|
+
if (!cur)
|
|
138
|
+
return '错误:无活跃 plan 可 update。先 create。';
|
|
139
|
+
const stepId = Number(args.step_id);
|
|
140
|
+
if (!Number.isFinite(stepId) || stepId < 1) {
|
|
141
|
+
return `错误:update 必填 step_id(>=1 的整数),收到「${args.step_id}」。`;
|
|
142
|
+
}
|
|
143
|
+
const status = String(args.status ?? '');
|
|
144
|
+
if (!VALID_STATUS.has(status)) {
|
|
145
|
+
return `错误:update 的 status 非法「${status}」,合法:pending / in_progress / done / skipped / failed。`;
|
|
146
|
+
}
|
|
147
|
+
const note = String(args.note ?? '').trim();
|
|
148
|
+
const updated = updatePlan(cur.id, (p) => {
|
|
149
|
+
const step = p.steps.find((s) => s.id === stepId);
|
|
150
|
+
if (!step)
|
|
151
|
+
return false; // 无改动,工具返错
|
|
152
|
+
step.status = status;
|
|
153
|
+
const at = new Date().toISOString();
|
|
154
|
+
const logText = note || `step ${stepId} → ${status}`;
|
|
155
|
+
p.log.push({ at, text: logText });
|
|
156
|
+
return p;
|
|
157
|
+
});
|
|
158
|
+
if (!updated) {
|
|
159
|
+
return `错误:update 失败(找不到 step_id=${stepId},可能 plan 已不存在或 step 编号越界)。`;
|
|
160
|
+
}
|
|
161
|
+
// step 不存在(updatePlan 内 mutator 返 false → updatePlan 返原对象,需二次校验)
|
|
162
|
+
if (!updated.steps.some((s) => s.id === stepId)) {
|
|
163
|
+
return `错误:找不到 step_id=${stepId}(plan 共 ${updated.steps.length} 步)。`;
|
|
164
|
+
}
|
|
165
|
+
setActivePlan(updated);
|
|
166
|
+
return renderSuccess('update', updated);
|
|
167
|
+
}
|
|
168
|
+
function doAddStep(args) {
|
|
169
|
+
const cur = getActivePlan();
|
|
170
|
+
if (!cur)
|
|
171
|
+
return '错误:无活跃 plan 可 add_step。先 create。';
|
|
172
|
+
const text = String(args.title ?? args.note ?? '').trim();
|
|
173
|
+
if (!text)
|
|
174
|
+
return '错误:add_step 必填 title(单条步骤标题)。';
|
|
175
|
+
const updated = updatePlan(cur.id, (p) => {
|
|
176
|
+
const nextId = p.steps.length > 0 ? Math.max(...p.steps.map((s) => s.id)) + 1 : 1;
|
|
177
|
+
p.steps.push({ id: nextId, title: text, status: 'pending' });
|
|
178
|
+
p.log.push({ at: new Date().toISOString(), text: `add step: ${text}` });
|
|
179
|
+
return p;
|
|
180
|
+
});
|
|
181
|
+
if (!updated)
|
|
182
|
+
return '错误:add_step 写盘失败。';
|
|
183
|
+
setActivePlan(updated);
|
|
184
|
+
return renderSuccess('add_step', updated);
|
|
185
|
+
}
|
|
186
|
+
function doFinish(args) {
|
|
187
|
+
const cur = getActivePlan();
|
|
188
|
+
if (!cur)
|
|
189
|
+
return '错误:无活跃 plan 可 finish。';
|
|
190
|
+
const ps = String(args.plan_status ?? 'finished');
|
|
191
|
+
if (!VALID_PLAN_STATUS.has(ps)) {
|
|
192
|
+
return `错误:finish 的 plan_status 非法「${ps}」,合法:finished / abandoned。`;
|
|
193
|
+
}
|
|
194
|
+
const note = String(args.note ?? '').trim();
|
|
195
|
+
const updated = updatePlan(cur.id, (p) => {
|
|
196
|
+
p.status = ps;
|
|
197
|
+
p.log.push({
|
|
198
|
+
at: new Date().toISOString(),
|
|
199
|
+
text: note || (ps === 'finished' ? '完成' : '放弃'),
|
|
200
|
+
});
|
|
201
|
+
return p;
|
|
202
|
+
});
|
|
203
|
+
if (!updated)
|
|
204
|
+
return '错误:finish 写盘失败。';
|
|
205
|
+
setActivePlan(updated);
|
|
206
|
+
// finish 后清活跃缓存(下轮 read 自动从 in_progress 列表兜底,本会话不再「活跃」)
|
|
207
|
+
if (ps === 'abandoned')
|
|
208
|
+
clearActivePlan();
|
|
209
|
+
else {
|
|
210
|
+
// finished 也清:「活跃」专指 in_progress;finished 是历史归档
|
|
211
|
+
clearActivePlan();
|
|
212
|
+
}
|
|
213
|
+
return renderSuccess('finish', updated);
|
|
214
|
+
}
|
|
215
|
+
// ── helpers ──
|
|
216
|
+
/** 从盘上找一个 in_progress plan(进程级 state 丢失/首次访问时兜底)。无 → null。 */
|
|
217
|
+
function findInProgressFromDisk() {
|
|
218
|
+
const all = listPlans();
|
|
219
|
+
return all.find((p) => p.status === 'in_progress') ?? null;
|
|
220
|
+
}
|
|
221
|
+
/** 工具结果统一格式:`<action>: <一行摘要>\n\n<完整 plan 渲染>`。
|
|
222
|
+
* 超 MAX_OUTPUT 截 plan 渲染尾部(罕见;plan 文件本身就小)。 */
|
|
223
|
+
function renderSuccess(action, p) {
|
|
224
|
+
const done = p.steps.filter((s) => s.status === 'done' || s.status === 'skipped').length;
|
|
225
|
+
const head = `${action} ✓: 「${p.title}」 进度 ${done}/${p.steps.length} (status=${p.status})`;
|
|
226
|
+
const body = renderPlanForLLM(p);
|
|
227
|
+
const full = `${head}\n\n${body}`;
|
|
228
|
+
if (full.length <= MAX_OUTPUT)
|
|
229
|
+
return full;
|
|
230
|
+
return full.slice(0, MAX_OUTPUT) + `\n\n…(plan 渲染已截断 ${full.length - MAX_OUTPUT} 字符)`;
|
|
231
|
+
}
|
package/dist/ui/layout.js
CHANGED
|
@@ -742,15 +742,19 @@ function composeSpinnerLine(status, cols) {
|
|
|
742
742
|
const rightStr = tail ? `${ui.yellow}${tail}${ui.reset}` : '';
|
|
743
743
|
return twoColumn(lead, leadW, rightStr, tailW, cols);
|
|
744
744
|
}
|
|
745
|
-
/** 下线之下那行(model 行):左 =
|
|
745
|
+
/** 下线之下那行(model 行):左 = 模式标识;右 = context + cwd,右端对齐。
|
|
746
|
+
* 活跃 plan chip 不再放这里,改放 spinner 行上方的「虚拟空行」(contentBottom+1,见 drawStatusBar),
|
|
747
|
+
* 既不挤 model 行,又给输入区上方留出可视分隔带。 */
|
|
746
748
|
function composeModelLine(status, cols) {
|
|
747
749
|
const ctx = status.contextBar; // 已带色
|
|
748
750
|
const ctxW = ansiDisplayWidth(ctx);
|
|
749
|
-
//
|
|
751
|
+
// 左段:仅模式标识
|
|
750
752
|
const modeTag = status.modeTag ?? '';
|
|
751
|
-
const
|
|
752
|
-
|
|
753
|
+
const modeColor = modeTag === 'plan' ? ui.yellow : ui.brightCyan;
|
|
754
|
+
const modePart = modeTag
|
|
755
|
+
? `${modeColor}${modeTag}${ui.reset}`
|
|
753
756
|
: '';
|
|
757
|
+
const leftStr = modePart;
|
|
754
758
|
const leftW = modeTag ? displayWidth(modeTag) : 0;
|
|
755
759
|
// 右段:ctx + sep + cwd,右端对齐。cwd 按预算截断,极窄(<6)隐藏。
|
|
756
760
|
const minGap = 2;
|
|
@@ -761,18 +765,36 @@ function composeModelLine(status, cols) {
|
|
|
761
765
|
const rightW = ctxW + STATUS_SEP_W + cwdW;
|
|
762
766
|
return twoColumn(leftStr, leftW, rightStr, rightW, cols);
|
|
763
767
|
}
|
|
764
|
-
/**
|
|
765
|
-
*
|
|
766
|
-
*
|
|
768
|
+
/** spinner 行上方的「虚拟空行」(contentBottom+1)。
|
|
769
|
+
* - 有活跃 plan:显「plan: <summary> ▸ N. step」整行左对齐(yellow + dim)
|
|
770
|
+
* - 无活跃 plan:空(保留原分隔视觉,避免内容贴输入区)
|
|
771
|
+
* 这行在 DECSTBM 滚动区外([1, contentBottom]),稳定不滚。 */
|
|
772
|
+
function composePlanLine(status, cols) {
|
|
773
|
+
const plan = (status.planSummary ?? '').trim();
|
|
774
|
+
if (!plan)
|
|
775
|
+
return ''; // 无 plan:画空,等 paint 路径 clearLine
|
|
776
|
+
// 整行左对齐,不留右段(plan 自带进度信息,不需要 cwd)
|
|
777
|
+
return `${ui.dim}│ ${ui.yellow}${plan}${ui.reset}${ui.dim}`;
|
|
778
|
+
}
|
|
779
|
+
/** 画状态行(plan 行 + spinner 行 + model 行,三行)。RUNNING 态 spinner 频繁调。
|
|
780
|
+
* 行号(footerH=6):
|
|
781
|
+
* plan 行 = contentBottom+1 (活跃 plan 时显 chip;无则空)
|
|
782
|
+
* spinner 行 = contentBottom+2 (◆ 空闲 / ⠹ 思考中… / etc)
|
|
783
|
+
* 上线 = contentBottom+3 (画在 paintInput)
|
|
784
|
+
* 输入行 = contentBottom+4
|
|
785
|
+
* 下线 = contentBottom+5
|
|
786
|
+
* model 行 = rows (屏底:auto + ctx + cwd) */
|
|
767
787
|
export function drawStatusBar(status) {
|
|
768
788
|
if (!active || !base)
|
|
769
789
|
return;
|
|
770
790
|
const s = status ?? { ...base, status: statusText, spinnerFrame };
|
|
771
791
|
const g = getGeo();
|
|
772
|
-
const
|
|
792
|
+
const planRow = g.contentBottom + 1;
|
|
793
|
+
const spinnerRow = g.contentBottom + 2;
|
|
773
794
|
const modelRow = g.rows; // 屏底:model 行
|
|
774
|
-
//
|
|
775
|
-
let out = cup(
|
|
795
|
+
// 一次写入:三行 cup+clear+内容,末尾 cup 回续写位/输入框光标
|
|
796
|
+
let out = cup(planRow, 1) + esc.clearLine + composePlanLine(s, g.cols) +
|
|
797
|
+
cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(s, g.cols) +
|
|
776
798
|
cup(modelRow, 1) + esc.clearLine + composeModelLine(s, g.cols);
|
|
777
799
|
if (mode === 'running') {
|
|
778
800
|
// 运行态(回尾 / 滚动回看均):cup 回输入框光标位(供 IME 锚定)。
|
|
@@ -907,7 +929,7 @@ export function clearLiveAtCursor() {
|
|
|
907
929
|
frameRow = 0;
|
|
908
930
|
frameCol = 0;
|
|
909
931
|
}
|
|
910
|
-
/** 更新状态行基线(模型 / context / cwd / 模式标识)。repl 在轮次边界与切模式时调。 */
|
|
932
|
+
/** 更新状态行基线(模型 / context / cwd / 模式标识 / 活跃 plan chip)。repl 在轮次边界与切模式时调。 */
|
|
911
933
|
export function setStatusBase(b) {
|
|
912
934
|
base = b;
|
|
913
935
|
}
|
|
@@ -1037,10 +1059,17 @@ export function paintInput(view) {
|
|
|
1037
1059
|
const line = slice[g.contentBottom - 1] ?? '';
|
|
1038
1060
|
buf += cup(g.contentBottom, 1) + esc.clearLine + line;
|
|
1039
1061
|
}
|
|
1040
|
-
// 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容)
|
|
1041
|
-
|
|
1062
|
+
// 2c. 虚拟空行(内容区与状态栏之间的视觉间隔,属底栏非内容):
|
|
1063
|
+
// - 无活跃 plan:清空保留作分隔(原设计)
|
|
1064
|
+
// - 有活跃 plan:渲染 plan chip(整帧重画时也要更新,避免 listener 漏触发后残留)
|
|
1065
|
+
{
|
|
1066
|
+
const plan = (base.planSummary ?? '').trim();
|
|
1067
|
+
buf += cup(g.contentBottom + 1, 1) + esc.clearLine;
|
|
1068
|
+
if (plan)
|
|
1069
|
+
buf += `${ui.yellow}${plan}${ui.reset}`;
|
|
1070
|
+
}
|
|
1042
1071
|
// 3. 状态行:spinner 行 + model 行(两行式底栏)
|
|
1043
|
-
const spinnerRow = g.contentBottom + 2; // +1
|
|
1072
|
+
const spinnerRow = g.contentBottom + 2; // +1 虚拟空行(plan 行),+2 spinner 行
|
|
1044
1073
|
const modelRow = g.rows; // 屏底:model 行
|
|
1045
1074
|
const status = { ...base, status: statusText, spinnerFrame };
|
|
1046
1075
|
buf += cup(spinnerRow, 1) + esc.clearLine + composeSpinnerLine(status, g.cols);
|