mocode-ai 0.6.9 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/config/index.js +15 -5
- package/dist/index.js +1 -1
- package/dist/project-skill/index.js +9 -94
- package/dist/project-snapshot/llm-snapshot.js +40 -17
- package/dist/repl/index.js +99 -55
- package/dist/session/persist.js +13 -5
- package/dist/tools/builtins/project-skill-update.js +3 -5
- package/dist/ui/prompt.js +64 -11
- package/package.json +1 -1
package/dist/config/index.js
CHANGED
|
@@ -262,11 +262,20 @@ This is your private working surface — write intermediate findings, decisions,
|
|
|
262
262
|
and anything you might need to recall later. The file survives context compaction.
|
|
263
263
|
|
|
264
264
|
### WHEN TO WRITE
|
|
265
|
+
The notepad is opt-in for complex work, not a routine task log. Use it only when the task has at least 3 meaningful steps, spans multiple investigation/implementation phases, or contains details that are genuinely at risk of being lost to context compaction.
|
|
266
|
+
|
|
267
|
+
Do NOT create, read, or update the notepad for simple tasks, including:
|
|
268
|
+
- Questions that can be answered directly
|
|
269
|
+
- One-step commands or lookups
|
|
270
|
+
- Small, localized edits that can be completed without intermediate notes
|
|
271
|
+
- Work that only needs a few tool calls and fits comfortably in the current context
|
|
272
|
+
|
|
273
|
+
For qualifying complex work:
|
|
265
274
|
- After exploring code and discovering key constraints → add a section
|
|
266
|
-
- Before making a design decision → record reasoning and alternatives considered
|
|
267
|
-
- When accumulating data across tool calls → store intermediates
|
|
268
|
-
- When you realize
|
|
269
|
-
- After completing a phase → summarize what you learned
|
|
275
|
+
- Before making a consequential design decision → record reasoning and alternatives considered
|
|
276
|
+
- When accumulating data across many tool calls → store concise intermediates
|
|
277
|
+
- When you realize important information may be lost after compaction → write it down
|
|
278
|
+
- After completing a substantial phase → summarize what you learned
|
|
270
279
|
|
|
271
280
|
### FORMAT (markdown, section-based)
|
|
272
281
|
Use \`## <topic>\` headers to organize. Each section is self-contained.
|
|
@@ -313,7 +322,8 @@ Write the plan as a top-level \`## Plan:\` section. The system extracts this for
|
|
|
313
322
|
Rules:
|
|
314
323
|
- Only ONE active \`## Plan:\` section at a time.
|
|
315
324
|
- Mark steps \`[x]\` as you complete them; append a line to \`### Progress\` after each phase.
|
|
316
|
-
-
|
|
325
|
+
- Before your final response, reconcile every step with the work actually completed, then delete the plan section or rename it to \`## Done: <title>\`.
|
|
326
|
+
- The host hides an unchanged active plan when an agent turn ends as a safety fallback; this does not edit the notepad. Keep updating the plan during execution so live progress remains accurate.
|
|
317
327
|
|
|
318
328
|
## Termination & Reporting
|
|
319
329
|
- Stop immediately when no more tools are needed; give conclusions directly.
|
package/dist/index.js
CHANGED
|
@@ -81,7 +81,7 @@ async function main() {
|
|
|
81
81
|
}
|
|
82
82
|
const updateNotice = checkAndMaybeUpdate();
|
|
83
83
|
const { startRepl } = await import('./repl/index.js');
|
|
84
|
-
await startRepl(loaded.history, loaded.id, updateNotice, sandboxRootOverride);
|
|
84
|
+
await startRepl(loaded.history, loaded.id, updateNotice, sandboxRootOverride, loaded.queryHistory);
|
|
85
85
|
}
|
|
86
86
|
else {
|
|
87
87
|
const updateNotice = checkAndMaybeUpdate();
|
|
@@ -38,24 +38,12 @@ export function readProjectSkill() {
|
|
|
38
38
|
return null;
|
|
39
39
|
}
|
|
40
40
|
}
|
|
41
|
-
/**
|
|
42
|
-
const MAX_SKILL_CHARS = 4000;
|
|
43
|
-
/**
|
|
44
|
-
* 写入/更新项目 skill。先备份旧内容再写新内容。
|
|
45
|
-
* 返回 { ok, error? }: ok=false 时 error 说明原因(超限/IO 失败)。
|
|
46
|
-
*/
|
|
41
|
+
/** 写入/更新项目 skill。内容不设字符上限;写入前备份旧内容。 */
|
|
47
42
|
export function writeProjectSkill(content) {
|
|
48
43
|
const trimmed = content.trim();
|
|
49
|
-
if (trimmed.length > MAX_SKILL_CHARS) {
|
|
50
|
-
return {
|
|
51
|
-
ok: false,
|
|
52
|
-
error: `内容超过上限(${trimmed.length}/${MAX_SKILL_CHARS} 字符)。请精简后重试。`,
|
|
53
|
-
};
|
|
54
|
-
}
|
|
55
44
|
const p = skillPath();
|
|
56
45
|
const dir = path.dirname(p);
|
|
57
46
|
try {
|
|
58
|
-
// 备份旧内容(如果有)
|
|
59
47
|
if (existsSync(p)) {
|
|
60
48
|
copyFileSync(p, backupPath());
|
|
61
49
|
}
|
|
@@ -67,95 +55,22 @@ export function writeProjectSkill(content) {
|
|
|
67
55
|
return { ok: false, error: `写入失败: ${e.message}` };
|
|
68
56
|
}
|
|
69
57
|
}
|
|
70
|
-
/**
|
|
71
|
-
* 追加内容到项目 skill 末尾(以 \n\n 分隔)。
|
|
72
|
-
* 同样受 MAX_SKILL_CHARS 限制。
|
|
73
|
-
*/
|
|
58
|
+
/** 追加内容到项目 skill 末尾(以 \n\n 分隔),内容不设字符上限。 */
|
|
74
59
|
export function appendProjectSkill(addition) {
|
|
75
60
|
const existing = readProjectSkill() ?? '';
|
|
76
61
|
const trimmed = addition.trim();
|
|
77
62
|
if (!trimmed)
|
|
78
63
|
return { ok: false, error: '追加内容为空' };
|
|
79
64
|
const separator = existing ? '\n\n' : '';
|
|
80
|
-
|
|
81
|
-
return writeProjectSkill(merged);
|
|
65
|
+
return writeProjectSkill(existing + separator + trimmed);
|
|
82
66
|
}
|
|
83
|
-
/**
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
*/
|
|
87
|
-
export async function compressContent(content, signal) {
|
|
88
|
-
try {
|
|
89
|
-
// 动态导入避免循环依赖
|
|
90
|
-
const { chat } = await import('../llm/index.js');
|
|
91
|
-
const messages = [
|
|
92
|
-
{
|
|
93
|
-
role: 'system',
|
|
94
|
-
content: 'You are a technical writer. Compress the following project skill content to fit within ' +
|
|
95
|
-
`${MAX_SKILL_CHARS} characters while preserving the most important information. ` +
|
|
96
|
-
'Keep concrete examples, paths, and actionable insights. Remove redundancy and verbose explanations. ' +
|
|
97
|
-
'Output ONLY the compressed content, no explanations.',
|
|
98
|
-
},
|
|
99
|
-
{ role: 'user', content },
|
|
100
|
-
];
|
|
101
|
-
const result = await chat(messages, {}, signal);
|
|
102
|
-
const compressed = result.content?.trim();
|
|
103
|
-
if (!compressed)
|
|
104
|
-
return null;
|
|
105
|
-
return compressed;
|
|
106
|
-
}
|
|
107
|
-
catch {
|
|
108
|
-
return null;
|
|
109
|
-
}
|
|
67
|
+
/** 兼容旧调用名:字数限制取消后直接写入,不再调用 LLM 压缩。 */
|
|
68
|
+
export async function writeProjectSkillWithCompression(content, _maxAttempts = 3, _signal) {
|
|
69
|
+
return { ...writeProjectSkill(content), compressed: false };
|
|
110
70
|
}
|
|
111
|
-
/**
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
*/
|
|
115
|
-
export async function writeProjectSkillWithCompression(content, maxAttempts = 3, signal) {
|
|
116
|
-
let current = content;
|
|
117
|
-
let compressed = false;
|
|
118
|
-
for (let attempt = 0; attempt < maxAttempts; attempt++) {
|
|
119
|
-
const result = writeProjectSkill(current);
|
|
120
|
-
if (result.ok) {
|
|
121
|
-
return { ok: true, compressed };
|
|
122
|
-
}
|
|
123
|
-
// 非超限错误直接返回
|
|
124
|
-
if (!result.error?.includes('内容超过上限')) {
|
|
125
|
-
return result;
|
|
126
|
-
}
|
|
127
|
-
// 超限时调用 LLM 压缩
|
|
128
|
-
const compressedContent = await compressContent(current, signal);
|
|
129
|
-
if (!compressedContent) {
|
|
130
|
-
return {
|
|
131
|
-
ok: false,
|
|
132
|
-
error: `压缩失败: ${result.error}`,
|
|
133
|
-
};
|
|
134
|
-
}
|
|
135
|
-
current = compressedContent;
|
|
136
|
-
compressed = true;
|
|
137
|
-
}
|
|
138
|
-
// 多次压缩后仍超限
|
|
139
|
-
const finalCheck = writeProjectSkill(current);
|
|
140
|
-
if (finalCheck.ok) {
|
|
141
|
-
return { ok: true, compressed: true };
|
|
142
|
-
}
|
|
143
|
-
return {
|
|
144
|
-
ok: false,
|
|
145
|
-
error: `经过 ${maxAttempts} 次压缩仍超限: ${finalCheck.error}`,
|
|
146
|
-
};
|
|
147
|
-
}
|
|
148
|
-
/**
|
|
149
|
-
* 追加时自动压缩:合并后超限则调用 LLM 压缩,最多尝试 maxAttempts 次。
|
|
150
|
-
*/
|
|
151
|
-
export async function appendProjectSkillWithCompression(addition, maxAttempts = 3, signal) {
|
|
152
|
-
const existing = readProjectSkill() ?? '';
|
|
153
|
-
const trimmed = addition.trim();
|
|
154
|
-
if (!trimmed)
|
|
155
|
-
return { ok: false, error: '追加内容为空' };
|
|
156
|
-
const separator = existing ? '\n\n' : '';
|
|
157
|
-
const merged = existing + separator + trimmed;
|
|
158
|
-
return writeProjectSkillWithCompression(merged, maxAttempts, signal);
|
|
71
|
+
/** 兼容旧调用名:字数限制取消后直接追加,不再调用 LLM 压缩。 */
|
|
72
|
+
export async function appendProjectSkillWithCompression(addition, _maxAttempts = 3, _signal) {
|
|
73
|
+
return { ...appendProjectSkill(addition), compressed: false };
|
|
159
74
|
}
|
|
160
75
|
/**
|
|
161
76
|
* 生成系统提示词注入段。
|
|
@@ -19,9 +19,9 @@ Do NOT explain WHY or HOW (that's Project Skill's job).
|
|
|
19
19
|
5. Identify tech stack from dependencies
|
|
20
20
|
|
|
21
21
|
## Output Format
|
|
22
|
-
Output a markdown document between these exact delimiters:
|
|
22
|
+
Output only a markdown document between these exact delimiter lines. The delimiters are not Markdown fences and must each appear on their own line:
|
|
23
23
|
|
|
24
|
-
|
|
24
|
+
<snapshot-md>
|
|
25
25
|
# Project Snapshot
|
|
26
26
|
|
|
27
27
|
## Description
|
|
@@ -49,7 +49,7 @@ src/
|
|
|
49
49
|
tools/
|
|
50
50
|
ui/
|
|
51
51
|
\`\`\`
|
|
52
|
-
|
|
52
|
+
</snapshot-md>
|
|
53
53
|
|
|
54
54
|
## Rules
|
|
55
55
|
1. **Description**: ≤80字,中文,从 README 和 package.json description 提炼。不要"一个..."开头。
|
|
@@ -60,7 +60,36 @@ src/
|
|
|
60
60
|
6. 不要包含:设计决策、注意事项、坑点、约定 → 这些是 Skill 的职责。
|
|
61
61
|
7. 总 markdown ≤ 2500 字符。
|
|
62
62
|
8. 使用中文。
|
|
63
|
+
9. 不要在 <snapshot-md> 和 </snapshot-md> 之外输出任何内容。
|
|
63
64
|
`;
|
|
65
|
+
/**
|
|
66
|
+
* 提取子 agent 输出中的 markdown。
|
|
67
|
+
* 优先使用无歧义标签,同时兼容历史自定义围栏和标准 Markdown 围栏。
|
|
68
|
+
*/
|
|
69
|
+
function extractSnapshotMarkdown(summary) {
|
|
70
|
+
const tagged = summary.match(/<snapshot-md>[ \t]*\r?\n?([\s\S]*?)\r?\n?[ \t]*<\/snapshot-md>/i);
|
|
71
|
+
if (tagged?.[1]?.trim())
|
|
72
|
+
return tagged[1].trim();
|
|
73
|
+
const legacy = summary.match(/```snapshot-md[ \t]*\r?\n([\s\S]*?)\r?\n```snapshot-md[ \t]*(?:\r?\n|$)/i);
|
|
74
|
+
if (legacy?.[1]?.trim())
|
|
75
|
+
return legacy[1].trim();
|
|
76
|
+
const opening = summary.match(/^```(?:snapshot-md|markdown)[ \t]*\r?$/im);
|
|
77
|
+
if (opening?.index === undefined)
|
|
78
|
+
return undefined;
|
|
79
|
+
let bodyStart = opening.index + opening[0].length;
|
|
80
|
+
if (summary[bodyStart] === '\n')
|
|
81
|
+
bodyStart += 1;
|
|
82
|
+
const body = summary.slice(bodyStart);
|
|
83
|
+
const closings = [...body.matchAll(/^`{3,}[ \t]*\r?$/gm)];
|
|
84
|
+
const closing = closings.at(-1);
|
|
85
|
+
if (closing?.index === undefined)
|
|
86
|
+
return undefined;
|
|
87
|
+
let content = body.slice(0, closing.index).trim();
|
|
88
|
+
// 有些模型会把内层和外层的结束围栏连成六个反引号。
|
|
89
|
+
if (closing[0].trim().length >= 6)
|
|
90
|
+
content = `${content}\n\`\`\``;
|
|
91
|
+
return content || undefined;
|
|
92
|
+
}
|
|
64
93
|
/**
|
|
65
94
|
* 生成 LLM 快照(markdown 格式)
|
|
66
95
|
* @param root 项目根目录
|
|
@@ -101,21 +130,15 @@ export async function generateLLMSnapshot(root, signal) {
|
|
|
101
130
|
transcript: result.transcript,
|
|
102
131
|
};
|
|
103
132
|
}
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
ok: false,
|
|
112
|
-
error: '无法从子 agent 输出中提取 markdown',
|
|
113
|
-
transcript: result.transcript,
|
|
114
|
-
};
|
|
115
|
-
}
|
|
116
|
-
return { ok: true, content: fallbackMatch[1].trim(), transcript: result.transcript };
|
|
133
|
+
const content = extractSnapshotMarkdown(result.summary);
|
|
134
|
+
if (!content) {
|
|
135
|
+
return {
|
|
136
|
+
ok: false,
|
|
137
|
+
error: '无法从子 agent 输出中提取 markdown',
|
|
138
|
+
transcript: result.transcript,
|
|
139
|
+
};
|
|
117
140
|
}
|
|
118
|
-
return { ok: true, content
|
|
141
|
+
return { ok: true, content, transcript: result.transcript };
|
|
119
142
|
}
|
|
120
143
|
catch (e) {
|
|
121
144
|
return {
|
package/dist/repl/index.js
CHANGED
|
@@ -237,28 +237,51 @@ function renderContextBarInline(history) {
|
|
|
237
237
|
const pctCol = pct >= config.compactThreshold ? ui.yellow : ui.accent;
|
|
238
238
|
return `${ui.gray}[${pctCol}${bar}${ui.reset}] ${pctCol}${Math.round(pct * 100)}%${ui.reset} ${ui.dim}${k(est)}/${k(win)}${ui.reset}`;
|
|
239
239
|
}
|
|
240
|
-
|
|
241
|
-
|
|
240
|
+
// 宿主侧记录已结束轮次最后看到的 plan。notes.md 仍完整保留,只抑制未变化的旧 plan 状态栏,
|
|
241
|
+
// 避免 agent 忘记把 `## Plan:` 改成 `## Done:` 时输入框上方永久悬挂。
|
|
242
|
+
let settledPlanFingerprint;
|
|
243
|
+
/** 读取 notes.md 中唯一活跃的 `## Plan:` 段。进度只统计该段,避免其他笔记 checkbox 污染计数。 */
|
|
244
|
+
function readPlanStatusFromNotes() {
|
|
242
245
|
const sessionId = getCurrentSessionId();
|
|
243
246
|
if (!sessionId)
|
|
244
|
-
return
|
|
247
|
+
return null;
|
|
245
248
|
const root = getSandboxRoot() ?? process.cwd();
|
|
246
249
|
const p = path.join(root, '.mocode', 'sessions', sessionId, 'notes.md');
|
|
247
250
|
try {
|
|
248
|
-
const
|
|
249
|
-
const
|
|
251
|
+
const normalized = fs.readFileSync(p, 'utf8').replace(/\r\n?/g, '\n');
|
|
252
|
+
const lines = normalized.split('\n');
|
|
253
|
+
const start = lines.findIndex((line) => /^## Plan:\s*.+$/.test(line));
|
|
254
|
+
if (start < 0)
|
|
255
|
+
return null;
|
|
256
|
+
const endOffset = lines.slice(start + 1).findIndex((line) => /^##\s/.test(line));
|
|
257
|
+
const end = endOffset < 0 ? lines.length : start + 1 + endOffset;
|
|
258
|
+
const section = lines.slice(start, end).join('\n').trimEnd();
|
|
259
|
+
const title = lines[start].match(/^## Plan:\s*(.+)$/)?.[1].trim();
|
|
250
260
|
if (!title)
|
|
251
|
-
return
|
|
252
|
-
const total = (
|
|
253
|
-
const done = (
|
|
254
|
-
const current =
|
|
261
|
+
return null;
|
|
262
|
+
const total = (section.match(/^\s*-\s*\[[ xX]\]\s*\d+\./gm) || []).length;
|
|
263
|
+
const done = (section.match(/^\s*-\s*\[[xX]\]\s*\d+\./gm) || []).length;
|
|
264
|
+
const current = section.match(/^\s*-\s*\[ \]\s*\d+\.\s*(.+)$/m)?.[1].trim();
|
|
255
265
|
const summary = `plan: ${title} (${done}/${total})`;
|
|
256
|
-
|
|
266
|
+
// mtime 让“相同内容被重写为一项新计划”也能重新出现,而不被旧轮次误抑制。
|
|
267
|
+
const fingerprint = `${sessionId}\0${fs.statSync(p).mtimeMs}\0${section}`;
|
|
268
|
+
return { fingerprint, summary: current ? `${summary} ▸ ${current}` : summary };
|
|
257
269
|
}
|
|
258
270
|
catch {
|
|
259
|
-
return
|
|
271
|
+
return null;
|
|
260
272
|
}
|
|
261
273
|
}
|
|
274
|
+
/** 将当前 plan 标记为已结算。只影响状态栏,不修改 agent 的工作笔记。 */
|
|
275
|
+
function settlePlanStatus() {
|
|
276
|
+
settledPlanFingerprint = readPlanStatusFromNotes()?.fingerprint;
|
|
277
|
+
}
|
|
278
|
+
/** 从 notes.md 读取活跃 plan 摘要;已结算且未变化的旧 plan 不再显示。 */
|
|
279
|
+
function readPlanFromNotes() {
|
|
280
|
+
const plan = readPlanStatusFromNotes();
|
|
281
|
+
if (!plan || plan.fingerprint === settledPlanFingerprint)
|
|
282
|
+
return '';
|
|
283
|
+
return plan.summary;
|
|
284
|
+
}
|
|
262
285
|
/** 状态行基线:模型 / context / cwd / 模式标识 / 活跃 plan chip / 本轮 token。repl 在轮次边界、切模式、plan 变更时调。 */
|
|
263
286
|
function refreshStatusBase(history, lastTurnUsage) {
|
|
264
287
|
layout.setStatusBase({
|
|
@@ -536,6 +559,13 @@ function textOf(c) {
|
|
|
536
559
|
}
|
|
537
560
|
return String(c);
|
|
538
561
|
}
|
|
562
|
+
/** 从旧 session 的消息历史回填输入历史;新 session 使用独立 queryHistory,避免混入合成 user 消息。 */
|
|
563
|
+
function queryHistoryFromMessages(messages) {
|
|
564
|
+
return messages
|
|
565
|
+
.filter((message) => message.role === 'user')
|
|
566
|
+
.map((message) => textOf(message.content))
|
|
567
|
+
.filter((query) => query.trim().length > 0);
|
|
568
|
+
}
|
|
539
569
|
/**
|
|
540
570
|
* 把会话历史渲染成静态文本进内容区(回滚 / 续接 / --resume 后复显上下文):
|
|
541
571
|
* user→❯ 回显、assistant→正文(+ tool_calls 折叠成 ● 摘要行)、tool→↳ 结果预览;system 跳过。
|
|
@@ -649,7 +679,7 @@ export function renderHistory(history) {
|
|
|
649
679
|
* contentWrite 落入内容区(滚动区域内自动滚动,底栏不动)。history 由本模块持有,在轮次间持久;
|
|
650
680
|
* agent 只读取并追加(+ 经 session/ 压缩)。每轮成功结束后自动落盘,退出后可用 --resume / /resume 续接。
|
|
651
681
|
*/
|
|
652
|
-
export async function startRepl(initialHistory, sessionId, updateNotice = null, sandboxRootOverride) {
|
|
682
|
+
export async function startRepl(initialHistory, sessionId, updateNotice = null, sandboxRootOverride, initialQueryHistory) {
|
|
653
683
|
// 模式重置:agentMode 不落盘,每个 REPL 会话从 auto 开始(/resume / --resume 亦重置)。
|
|
654
684
|
setAgentMode('auto');
|
|
655
685
|
// 沙箱根:文件操作边界。优先级 --sandbox-root > SANDBOX_ROOT env > process.cwd()。
|
|
@@ -686,6 +716,10 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
686
716
|
const history = initialHistory && initialHistory.length
|
|
687
717
|
? initialHistory
|
|
688
718
|
: [{ role: 'system', content: buildSystemMessage(false) }];
|
|
719
|
+
// 新 session 使用独立输入历史;旧 session 没有该字段时从 user 消息兼容回填一次。
|
|
720
|
+
let queryHistory = initialQueryHistory
|
|
721
|
+
? [...initialQueryHistory]
|
|
722
|
+
: queryHistoryFromMessages(history);
|
|
689
723
|
if (initialHistory &&
|
|
690
724
|
initialHistory.length &&
|
|
691
725
|
history[0]?.role === 'system') {
|
|
@@ -839,7 +873,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
839
873
|
currentSessionId = newSessionId();
|
|
840
874
|
setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
|
|
841
875
|
try {
|
|
842
|
-
saveSession(history, currentSessionId);
|
|
876
|
+
saveSession(history, currentSessionId, queryHistory);
|
|
843
877
|
}
|
|
844
878
|
catch {
|
|
845
879
|
// 落盘失败不阻断
|
|
@@ -896,16 +930,15 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
896
930
|
layout.drawStatusBar();
|
|
897
931
|
});
|
|
898
932
|
// 本轮 token 累计(底栏模式 chip 右边显示)。undefined = 后端不开 include_usage。
|
|
933
|
+
// 状态栏统一在 finally 刷新,确保正常、中断、异常都经过同一 plan 收尾路径。
|
|
899
934
|
lastTurnUsage = result.usage;
|
|
900
|
-
refreshStatusBase(history, lastTurnUsage); // 即时刷状态行显示本轮 token chip
|
|
901
|
-
layout.drawStatusBar();
|
|
902
935
|
ok = !signal.aborted; // 中断(Ctrl+C)→ runAgent 已还原 history,ok=false 不弹审批
|
|
903
936
|
// 成功轮次自动落盘(崩溃也保住上一轮);新会话首轮分配 id
|
|
904
937
|
if (!currentSessionId)
|
|
905
938
|
currentSessionId = newSessionId();
|
|
906
939
|
setCurrentSessionId(currentSessionId, process.cwd()); // 同步到 session/state,确保 notes.md 存在
|
|
907
940
|
try {
|
|
908
|
-
saveSession(history, currentSessionId);
|
|
941
|
+
saveSession(history, currentSessionId, queryHistory);
|
|
909
942
|
}
|
|
910
943
|
catch {
|
|
911
944
|
// 落盘失败不阻断 REPL
|
|
@@ -920,6 +953,16 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
920
953
|
}
|
|
921
954
|
catch (e) {
|
|
922
955
|
ok = false;
|
|
956
|
+
// 请求失败也保存已确认提交的 query,确保立即退出后仍可通过 ↑ 或 resume 找回。
|
|
957
|
+
if (!currentSessionId)
|
|
958
|
+
currentSessionId = newSessionId();
|
|
959
|
+
setCurrentSessionId(currentSessionId, process.cwd());
|
|
960
|
+
try {
|
|
961
|
+
saveSession(history, currentSessionId, queryHistory);
|
|
962
|
+
}
|
|
963
|
+
catch {
|
|
964
|
+
// 落盘失败不覆盖原始请求错误
|
|
965
|
+
}
|
|
923
966
|
// 多模态相关错误友好提示:OpenAI/Anthropic 等会报 "does not support image" / "vision" / "multimodal" 等关键词,
|
|
924
967
|
// 直接给原文对中文用户不友好。这里翻译成中文 + 提示 /model 换视觉模型。
|
|
925
968
|
const msg = e instanceof Error ? e.message : String(e);
|
|
@@ -937,6 +980,13 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
937
980
|
}
|
|
938
981
|
finally {
|
|
939
982
|
stopRunningListener();
|
|
983
|
+
// 纯 plan 轮正常结束后仍需等待审批/细化,继续展示;其余终态统一结算。
|
|
984
|
+
// 结算只隐藏当前 fingerprint,不修改 notes;后续内容或 mtime 变化会自动重新显示。
|
|
985
|
+
const waitingForPlanApproval = ok && planMode && getAgentMode() === 'plan';
|
|
986
|
+
if (!waitingForPlanApproval)
|
|
987
|
+
settlePlanStatus();
|
|
988
|
+
refreshStatusBase(history, lastTurnUsage);
|
|
989
|
+
layout.drawStatusBar();
|
|
940
990
|
}
|
|
941
991
|
layout.contentWrite('\n'); // 轮次之间空行
|
|
942
992
|
return ok;
|
|
@@ -955,6 +1005,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
955
1005
|
}
|
|
956
1006
|
history.length = 0;
|
|
957
1007
|
history.push(...loaded.history);
|
|
1008
|
+
queryHistory = loaded.queryHistory
|
|
1009
|
+
? [...loaded.queryHistory]
|
|
1010
|
+
: queryHistoryFromMessages(loaded.history);
|
|
958
1011
|
setAgentMode('auto'); // 续接重置为 auto(mode 不落盘;listener 重写 history[0] 回 auto,与 loaded 幂等)
|
|
959
1012
|
currentSessionId = loaded.id;
|
|
960
1013
|
setCurrentSessionId(loaded.id, process.cwd()); // 切换会话:确保该会话的 notes.md 存在
|
|
@@ -985,6 +1038,7 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
985
1038
|
input = await promptWithSlashMenu({
|
|
986
1039
|
prompt: PROMPT,
|
|
987
1040
|
commands: buildSlashCommands(),
|
|
1041
|
+
queryHistory,
|
|
988
1042
|
onCycleMode: cycleMode,
|
|
989
1043
|
// /rollback 预填优先;否则上一轮运行中 typeahead 打的字 → 预填进输入框,用户可改可发
|
|
990
1044
|
...(pendingPrefill
|
|
@@ -1011,9 +1065,12 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1011
1065
|
const cmd = line.split(/\s+/)[0];
|
|
1012
1066
|
// 语言命令把视觉分隔放在确认文案之后;避免回显后先空一行、下一条命令却紧贴确认。
|
|
1013
1067
|
echoInput(input, cmd !== '/language');
|
|
1014
|
-
const
|
|
1068
|
+
const state = runningStateFor(cmd);
|
|
1069
|
+
const placeholder = line === '/snapshot_refresh' || line === '/project_skill init'
|
|
1070
|
+
? ''
|
|
1071
|
+
: state.placeholder;
|
|
1015
1072
|
refreshStatusBase(history);
|
|
1016
|
-
layout.enterRunningMode(status, placeholder);
|
|
1073
|
+
layout.enterRunningMode(state.status, placeholder);
|
|
1017
1074
|
if (line === '/help') {
|
|
1018
1075
|
layout.contentWrite(`${ui.bold}${t('help.title')}${ui.reset}\n`);
|
|
1019
1076
|
layout.contentWrite(`${ui.dim}${t('help.hint')}${ui.reset}\n`);
|
|
@@ -1311,10 +1368,11 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1311
1368
|
}
|
|
1312
1369
|
// buildSnapshot 是异步操作(完全由 LLM 生成),显示进度提示
|
|
1313
1370
|
layout.contentWrite(`${ui.dim}正在重新生成项目快照 (LLM 分析中)...${ui.reset}\n`);
|
|
1371
|
+
const signal = startRunningListener('');
|
|
1314
1372
|
try {
|
|
1315
1373
|
// 清除缓存,强制重新生成
|
|
1316
1374
|
clearSnapshotCache();
|
|
1317
|
-
const result = await buildSnapshot(
|
|
1375
|
+
const result = await buildSnapshot(signal, true);
|
|
1318
1376
|
if (result.snapshot) {
|
|
1319
1377
|
layout.contentWrite(`${ui.cyan}✓ 项目快照已刷新${ui.reset} (${result.snapshot.builtAt})\n`);
|
|
1320
1378
|
layout.contentWrite(`${ui.dim}已生成 markdown 快照,注入到系统提示词中${ui.reset}\n`);
|
|
@@ -1336,6 +1394,9 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1336
1394
|
const msg = e instanceof Error ? e.message : String(e);
|
|
1337
1395
|
layout.contentWrite(`${ui.red}[错误]${ui.reset} 刷新快照失败: ${msg}\n`);
|
|
1338
1396
|
}
|
|
1397
|
+
finally {
|
|
1398
|
+
stopRunningListener();
|
|
1399
|
+
}
|
|
1339
1400
|
continue;
|
|
1340
1401
|
}
|
|
1341
1402
|
if (line === '/sessions') {
|
|
@@ -1903,48 +1964,29 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
1903
1964
|
}
|
|
1904
1965
|
continue;
|
|
1905
1966
|
}
|
|
1906
|
-
// /project_skill init —
|
|
1967
|
+
// /project_skill init — 作为一条普通主 Agent 请求生成/优化 skill,不再派生高消耗子 agent。
|
|
1907
1968
|
if (arg === 'init') {
|
|
1908
1969
|
if (!isProjectSkillEnabled()) {
|
|
1909
1970
|
layout.contentWrite(`${ui.yellow}⚠ 项目专属 Skill 尚未开启${ui.reset},请先运行 ${ui.cyan}/project_skill on${ui.reset}\n`);
|
|
1910
1971
|
continue;
|
|
1911
1972
|
}
|
|
1912
|
-
const
|
|
1913
|
-
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
|
|
1918
|
-
|
|
1919
|
-
|
|
1920
|
-
|
|
1921
|
-
|
|
1922
|
-
|
|
1923
|
-
|
|
1924
|
-
// 显示探索过程日志
|
|
1925
|
-
if (result.transcript) {
|
|
1926
|
-
layout.contentWrite(`${ui.dim}--- 探索过程 ---${ui.reset}\n`);
|
|
1927
|
-
layout.contentWrite(result.transcript);
|
|
1928
|
-
layout.contentWrite(`${ui.dim}--- 探索结束 ---${ui.reset}\n\n`);
|
|
1929
|
-
}
|
|
1930
|
-
if (result.ok && result.content) {
|
|
1931
|
-
const writeResult = writeProjectSkill(result.content);
|
|
1932
|
-
if (writeResult.ok) {
|
|
1933
|
-
layout.contentWrite(`${ui.green}✓ 项目 Skill 已生成${ui.reset}\n`);
|
|
1934
|
-
layout.contentWrite(`${ui.dim}内容预览:${ui.reset}\n${result.content.slice(0, 500)}${result.content.length > 500 ? '...' : ''}\n`);
|
|
1935
|
-
layout.contentWrite(`\n${ui.dim}文件: ${ui.accent}.mocode/project-skill.md${ui.reset} (${result.content.length} 字符)\n`);
|
|
1936
|
-
layout.contentWrite(`${ui.dim}下次启动时会自动注入到系统提示词。Agent 也会在开发过程中持续更新。${ui.reset}\n`);
|
|
1937
|
-
layout.contentWrite(`${ui.dim}提示: 可用 ${ui.cyan}/project_skill view${ui.reset} 查看完整内容,或手动编辑文件完善。${ui.dim}${ui.reset}\n`);
|
|
1938
|
-
}
|
|
1939
|
-
else {
|
|
1940
|
-
layout.contentWrite(`${ui.red}✗ 写入失败:${ui.reset} ${writeResult.error}\n`);
|
|
1941
|
-
}
|
|
1973
|
+
const initPrompt = `请直接初始化或优化当前项目的 Project Skill,并完成写入。
|
|
1974
|
+
|
|
1975
|
+
要求:
|
|
1976
|
+
1. 直接由你完成,禁止调用 task 工具或派生任何子 agent。
|
|
1977
|
+
2. 优先利用系统提示中已有的 Project Snapshot 和 Project Skill;不要重复扫描其中已有的目录、依赖、命令和模块清单。
|
|
1978
|
+
3. 最多进行 1 次 codegraph 探索;只有缺少关键依据时,才额外进行少量定点 read_file/grep。禁止全仓 glob 和逐文件扫描。
|
|
1979
|
+
4. Skill 只记录 Snapshot 无法提供的 WHY/HOW/GOTCHAS/CONVENTIONS:设计取舍、关键调用链、非直觉边界、项目约定和可操作坑点。使用具体路径和例子,删除重复或过时内容。
|
|
1980
|
+
5. 最终内容应完整、结构清晰。调用 project_skill_update,使用 action=write 一次性写入完整内容。
|
|
1981
|
+
6. 写入成功后只简短说明更新了哪些关键洞察,不要输出完整 Skill。`;
|
|
1982
|
+
const previousMode = getAgentMode();
|
|
1983
|
+
try {
|
|
1984
|
+
await runTurn(initPrompt, false, '');
|
|
1942
1985
|
}
|
|
1943
|
-
|
|
1944
|
-
|
|
1945
|
-
if (
|
|
1946
|
-
|
|
1947
|
-
}
|
|
1986
|
+
finally {
|
|
1987
|
+
// init 是一次明确写操作,临时使用 auto;完成后恢复用户原来的模式。
|
|
1988
|
+
if (previousMode === 'plan')
|
|
1989
|
+
setAgentMode('plan');
|
|
1948
1990
|
}
|
|
1949
1991
|
continue;
|
|
1950
1992
|
}
|
|
@@ -2097,6 +2139,8 @@ export async function startRepl(initialHistory, sessionId, updateNotice = null,
|
|
|
2097
2139
|
layout.enterInputMode(t('repl.idle'));
|
|
2098
2140
|
continue;
|
|
2099
2141
|
}
|
|
2142
|
+
// 只记录已过撤回窗口的真实用户 query;slash 命令和合成执行轮不会走到这里。
|
|
2143
|
+
queryHistory.push(joined);
|
|
2100
2144
|
const initialPlan = getAgentMode() === 'plan'; // 轮首模式(在 runTurn 之前读)
|
|
2101
2145
|
const ok = await runTurn(joined, initialPlan, placeholder);
|
|
2102
2146
|
// plan 轮正常结束(未中断 / 未抛错)→ 看轮末模式决定:
|
package/dist/session/persist.js
CHANGED
|
@@ -44,22 +44,27 @@ function firstUserOf(history) {
|
|
|
44
44
|
function sessionPath(id) {
|
|
45
45
|
return path.join(config.sessionDir, id, 'session.json');
|
|
46
46
|
}
|
|
47
|
-
/**
|
|
48
|
-
export function saveSession(history, id) {
|
|
47
|
+
/** 保存会话到磁盘。全新且没有 query 的会话不创建文件;已有会话即使回滚为空也必须覆盖旧记录。 */
|
|
48
|
+
export function saveSession(history, id, queryHistory = []) {
|
|
49
49
|
const meta = {
|
|
50
50
|
id,
|
|
51
51
|
createdAt: idToIso(id),
|
|
52
52
|
model: config.model,
|
|
53
|
-
firstUser: history.length > 1
|
|
53
|
+
firstUser: history.length > 1
|
|
54
|
+
? firstUserOf(history)
|
|
55
|
+
: truncateDisplay((queryHistory[0] ?? '').replace(/\n/g, ' ').trim(), 40),
|
|
54
56
|
};
|
|
55
57
|
const currentPath = sessionPath(id);
|
|
56
58
|
const legacyPath = path.join(config.sessionDir, `${id}.json`);
|
|
57
|
-
if (history.length <= 1 &&
|
|
59
|
+
if (history.length <= 1 &&
|
|
60
|
+
queryHistory.length === 0 &&
|
|
61
|
+
!existsSync(currentPath) &&
|
|
62
|
+
!existsSync(legacyPath)) {
|
|
58
63
|
return meta;
|
|
59
64
|
}
|
|
60
65
|
const dir = path.join(config.sessionDir, id);
|
|
61
66
|
mkdirSync(dir, { recursive: true });
|
|
62
|
-
const record = { ...meta, history };
|
|
67
|
+
const record = { ...meta, history, queryHistory: [...queryHistory] };
|
|
63
68
|
writeFileSync(currentPath, JSON.stringify(record), 'utf8');
|
|
64
69
|
// 一旦写入新式目录,删除旧式扁平副本,避免已回滚消息仍残留在磁盘。
|
|
65
70
|
if (existsSync(legacyPath))
|
|
@@ -86,6 +91,9 @@ export function loadSession(id) {
|
|
|
86
91
|
model: rec.model ?? '',
|
|
87
92
|
firstUser: rec.firstUser ?? '',
|
|
88
93
|
history: rec.history,
|
|
94
|
+
queryHistory: Array.isArray(rec.queryHistory)
|
|
95
|
+
? rec.queryHistory.filter((query) => typeof query === 'string')
|
|
96
|
+
: undefined,
|
|
89
97
|
};
|
|
90
98
|
}
|
|
91
99
|
catch {
|
|
@@ -38,13 +38,11 @@ export const projectSkillUpdateTool = {
|
|
|
38
38
|
'**何时更新**:发现新架构模式、踩坑后总结、学到新约定、完成重要重构、用户纠正理解时。\n' +
|
|
39
39
|
'\n' +
|
|
40
40
|
'**注意事项**:\n' +
|
|
41
|
-
'-
|
|
41
|
+
'- 内容长度不设硬上限,按项目实际复杂度完整记录有价值的信息\n' +
|
|
42
42
|
'- 写可操作的内容,避免空泛描述\n' +
|
|
43
43
|
'- 用具体路径和例子(不要写"有多个模块",要写"src/agent 负责 agent 循环")\n' +
|
|
44
|
-
'-
|
|
45
|
-
'- 更新前建议先 `read`
|
|
46
|
-
'\n' +
|
|
47
|
-
'**自动压缩**:内容超限时会自动调用 LLM 压缩(最多 3 次),无需手动精简。',
|
|
44
|
+
'- 定期整理,删除过时和重复信息\n' +
|
|
45
|
+
'- 更新前建议先 `read` 看一下现有内容,避免重复。',
|
|
48
46
|
risk: 'safe',
|
|
49
47
|
parameters: {
|
|
50
48
|
type: 'object',
|
package/dist/ui/prompt.js
CHANGED
|
@@ -90,6 +90,59 @@ export async function promptWithSlashMenu(opts) {
|
|
|
90
90
|
let resolved = false;
|
|
91
91
|
let resolve;
|
|
92
92
|
let reject;
|
|
93
|
+
const queryHistory = opts.queryHistory ?? [];
|
|
94
|
+
let historyIndex = queryHistory.length; // length = 草稿哨兵;0..length-1 = 历史项
|
|
95
|
+
let historyDraft = null;
|
|
96
|
+
/** 用历史 query 替换编辑缓冲;历史内容恢复为普通可编辑文本。 */
|
|
97
|
+
function loadHistoryEntry(value) {
|
|
98
|
+
lines = value.split('\n');
|
|
99
|
+
cl = lines.length - 1;
|
|
100
|
+
cc = lines[cl].length;
|
|
101
|
+
chip = null;
|
|
102
|
+
chipPre = '';
|
|
103
|
+
selected = 0;
|
|
104
|
+
menuTop = 0;
|
|
105
|
+
justSawCR = false;
|
|
106
|
+
computeFiltered();
|
|
107
|
+
redraw();
|
|
108
|
+
}
|
|
109
|
+
/** 在首次离开最新位置时保存完整草稿,然后向更旧的 query 移动。 */
|
|
110
|
+
function recallPrevious() {
|
|
111
|
+
if (queryHistory.length === 0 || historyIndex <= 0)
|
|
112
|
+
return;
|
|
113
|
+
if (historyIndex === queryHistory.length) {
|
|
114
|
+
historyDraft = {
|
|
115
|
+
lines: [...lines],
|
|
116
|
+
cl,
|
|
117
|
+
cc,
|
|
118
|
+
chip,
|
|
119
|
+
chipPre,
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
historyIndex--;
|
|
123
|
+
loadHistoryEntry(queryHistory[historyIndex]);
|
|
124
|
+
}
|
|
125
|
+
/** 向更新的 query 移动;越过最新历史时恢复进入历史前的草稿。 */
|
|
126
|
+
function recallNext() {
|
|
127
|
+
if (historyIndex >= queryHistory.length)
|
|
128
|
+
return;
|
|
129
|
+
historyIndex++;
|
|
130
|
+
if (historyIndex < queryHistory.length) {
|
|
131
|
+
loadHistoryEntry(queryHistory[historyIndex]);
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
const draft = historyDraft;
|
|
135
|
+
lines = draft ? [...draft.lines] : [''];
|
|
136
|
+
cl = draft?.cl ?? 0;
|
|
137
|
+
cc = draft?.cc ?? 0;
|
|
138
|
+
chip = draft?.chip ?? null;
|
|
139
|
+
chipPre = draft?.chipPre ?? '';
|
|
140
|
+
selected = 0;
|
|
141
|
+
menuTop = 0;
|
|
142
|
+
justSawCR = false;
|
|
143
|
+
computeFiltered();
|
|
144
|
+
redraw();
|
|
145
|
+
}
|
|
93
146
|
/** 菜单行(预渲染,带色)——向上展开进内容区底,由 layout 贴入。最多显示 MENU_MAX_VISIBLE 条,支持上下滚动。 */
|
|
94
147
|
function menuLines() {
|
|
95
148
|
if (!menuOpen || filtered.length === 0)
|
|
@@ -456,6 +509,8 @@ export async function promptWithSlashMenu(opts) {
|
|
|
456
509
|
filtered = [];
|
|
457
510
|
selected = 0;
|
|
458
511
|
menuTop = 0;
|
|
512
|
+
historyIndex = queryHistory.length;
|
|
513
|
+
historyDraft = null;
|
|
459
514
|
if (pasteTimer) {
|
|
460
515
|
clearTimeout(pasteTimer);
|
|
461
516
|
pasteTimer = null;
|
|
@@ -518,19 +573,11 @@ export async function promptWithSlashMenu(opts) {
|
|
|
518
573
|
pasteParts.push(s);
|
|
519
574
|
return;
|
|
520
575
|
}
|
|
521
|
-
// 滚动回看键(优先;不触发回尾):PgUp/PgDn
|
|
522
|
-
//
|
|
523
|
-
// 兼鼠标滚轮——alt 屏内(经 \x1B[?1007h)滚轮转发 ↑/↓,1 行/格太慢故放大到 5。
|
|
524
|
-
const plainArrowScroll = (key.name === 'up' || key.name === 'down') &&
|
|
525
|
-
!key.ctrl &&
|
|
526
|
-
!key.meta &&
|
|
527
|
-
!key.shift &&
|
|
528
|
-
lines.length <= 1 &&
|
|
529
|
-
!(menuOpen && filtered.length > 0);
|
|
576
|
+
// 滚动回看键(优先;不触发回尾):PgUp/PgDn 翻页,Ctrl+↑/↓ 每次 5 行。
|
|
577
|
+
// 裸 ↑/↓ 留给斜杠菜单、多行光标和 query 历史导航;鼠标滚轮由 SGR mouse 事件处理。
|
|
530
578
|
if (key.name === 'pageup' ||
|
|
531
579
|
key.name === 'pagedown' ||
|
|
532
|
-
(key.ctrl && (key.name === 'up' || key.name === 'down'))
|
|
533
|
-
plainArrowScroll) {
|
|
580
|
+
(key.ctrl && (key.name === 'up' || key.name === 'down'))) {
|
|
534
581
|
const pageH = layout.getGeo().contentBottom;
|
|
535
582
|
if (key.name === 'pageup')
|
|
536
583
|
layout.scrollBy(pageH);
|
|
@@ -622,6 +669,9 @@ export async function promptWithSlashMenu(opts) {
|
|
|
622
669
|
cc = Math.min(cc, lines[cl].length);
|
|
623
670
|
redraw();
|
|
624
671
|
}
|
|
672
|
+
else {
|
|
673
|
+
recallPrevious();
|
|
674
|
+
}
|
|
625
675
|
return;
|
|
626
676
|
case 'down':
|
|
627
677
|
if (menuOpen && filtered.length) {
|
|
@@ -633,6 +683,9 @@ export async function promptWithSlashMenu(opts) {
|
|
|
633
683
|
cc = Math.min(cc, lines[cl].length);
|
|
634
684
|
redraw();
|
|
635
685
|
}
|
|
686
|
+
else {
|
|
687
|
+
recallNext();
|
|
688
|
+
}
|
|
636
689
|
return;
|
|
637
690
|
case 'tab':
|
|
638
691
|
if (menuOpen && filtered[selected])
|