llm-api-gateway-cli 1.0.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/.env.example +10 -0
- package/README.md +1127 -0
- package/cli-agent.js +666 -0
- package/cli-anthropic.js +236 -0
- package/cli-claude-code.js +317 -0
- package/cli-openai.js +212 -0
- package/completions/_llm-api-gateway-cli +65 -0
- package/completions/llm-api-gateway-cli.bash +64 -0
- package/completions/llm-api-gateway-cli.fish +43 -0
- package/images/chat.png +0 -0
- package/images/settings.png +0 -0
- package/images/task.png +0 -0
- package/lib/agent.js +607 -0
- package/lib/commands.js +468 -0
- package/lib/common.js +196 -0
- package/lib/config.js +70 -0
- package/lib/configcmd.js +230 -0
- package/lib/hub.js +1494 -0
- package/lib/jsonstore.js +49 -0
- package/lib/mcp.js +375 -0
- package/lib/memory.js +109 -0
- package/lib/plandoc.js +178 -0
- package/lib/pricing.js +52 -0
- package/lib/runner.js +234 -0
- package/lib/runstore.js +96 -0
- package/lib/secrets.js +198 -0
- package/lib/sessionstore.js +269 -0
- package/lib/settings.js +517 -0
- package/lib/tasksession.js +594 -0
- package/lib/taskstore.js +740 -0
- package/lib/tools.js +927 -0
- package/package.json +55 -0
- package/public/app.js +1055 -0
- package/public/index.html +167 -0
- package/public/manual.css +215 -0
- package/public/manual.html +381 -0
- package/public/manual.js +186 -0
- package/public/models.js +121 -0
- package/public/render.js +250 -0
- package/public/styles.css +955 -0
- package/public/task-slash.js +493 -0
- package/public/task.css +739 -0
- package/public/task.html +220 -0
- package/public/task.js +3127 -0
- package/public/theme.js +91 -0
- package/public/tint.js +261 -0
- package/scripts/install.ps1 +537 -0
- package/scripts/install.sh +510 -0
- package/server.js +14 -0
- package/task-server.js +15 -0
package/lib/plandoc.js
ADDED
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 计划模式的计划落盘(P1–P3)
|
|
3
|
+
*
|
|
4
|
+
* 计划模式原来只是「模型在对话里给一段计划」—— 关掉页面、任务过期就没了,
|
|
5
|
+
* 切到自动模式后也没法让模型「照那份计划做」。这里把计划变成工作目录里的真实文件:
|
|
6
|
+
*
|
|
7
|
+
* <workDir>/docs/YYYYMMDD-<概要>.md
|
|
8
|
+
*
|
|
9
|
+
* 三条硬约束:
|
|
10
|
+
* 1. 目录不存在就**自动创建**(含中间层级),不要求用户先建 docs/;
|
|
11
|
+
* 2. 文件名带**日期 + 调整内容概要**,概要优先取计划自己的 `# 标题`;
|
|
12
|
+
* 3. 同名文件**只追加不覆盖** —— 同一主题同一天再计划一次,记成「更新」而不是把上文冲掉;
|
|
13
|
+
* 内容完全一致则直接不写盘(幂等)。
|
|
14
|
+
*
|
|
15
|
+
* 本模块不碰网络、不碰会话,纯文件逻辑,便于单测。
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import path from 'node:path';
|
|
19
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
20
|
+
|
|
21
|
+
/** 计划统一落在工作目录下的这个子目录 */
|
|
22
|
+
export const PLAN_DIR = 'docs';
|
|
23
|
+
|
|
24
|
+
/** 概要最长多少个字符 —— 文件名不是标题,够认出来就行 */
|
|
25
|
+
const SUMMARY_MAX = 30;
|
|
26
|
+
|
|
27
|
+
/** 单份计划正文上限(防止模型把整篇代码贴进来把会话目录撑爆) */
|
|
28
|
+
const PLAN_MAX_BYTES = 512 * 1024;
|
|
29
|
+
|
|
30
|
+
/** 文件名里不允许出现的字符:Windows 比 POSIX 更严,按 Windows 来 */
|
|
31
|
+
// eslint-disable-next-line no-control-regex
|
|
32
|
+
const ILLEGAL_IN_NAME = /[\\/:*?"<>|\u0000-\u001f]/g;
|
|
33
|
+
|
|
34
|
+
/** markdown 标题里常见的装饰字符,概要里留着只会让文件名难认 */
|
|
35
|
+
const DECORATION = /[`*_~#「」【】\[\]()()]/g;
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* 把一段文字压成可以当文件名用的「概要」。
|
|
39
|
+
* 空白折成 `-`,去掉非法字符与首尾的点/横线(Windows 不允许文件名以点结尾)。
|
|
40
|
+
*/
|
|
41
|
+
export function sanitizeSummary(raw) {
|
|
42
|
+
let s = typeof raw === 'string' ? raw : '';
|
|
43
|
+
s = s.replace(/\r?\n+/g, ' ').replace(/\s+/g, '-');
|
|
44
|
+
s = s.replace(DECORATION, '').replace(ILLEGAL_IN_NAME, '');
|
|
45
|
+
s = s.replace(/-{2,}/g, '-').replace(/^[-.\s]+|[-.\s]+$/g, '');
|
|
46
|
+
if (s.length > SUMMARY_MAX) s = s.slice(0, SUMMARY_MAX).replace(/[-.\s]+$/, '');
|
|
47
|
+
return s;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* 从计划正文里抽「调整内容概要」。
|
|
52
|
+
*
|
|
53
|
+
* 三级回落,任何一级拿不到都不会让落盘失败:
|
|
54
|
+
* 计划里的第一个 `# 标题`(模型被要求把概要写在第一行) → 传入的备用标题 → '计划'
|
|
55
|
+
*
|
|
56
|
+
* 顺带剥掉「迭代计划:」这类前缀 —— 日期已经在文件名前面了,再重复一次很难看。
|
|
57
|
+
*/
|
|
58
|
+
export function summarizeTitle(plan, fallback = '') {
|
|
59
|
+
const text = typeof plan === 'string' ? plan : '';
|
|
60
|
+
const m = text.match(/^[ \t]{0,3}#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$/m);
|
|
61
|
+
const raw = (m ? m[1] : '') || fallback || '';
|
|
62
|
+
const stripped = String(raw)
|
|
63
|
+
// 只剥「标签 + 分隔符」这种前缀:「迭代计划:xxx」→「xxx」。
|
|
64
|
+
// 不能只看开头是不是「计划」二字 —— 那样「计划模式落盘」会被削成「模式落盘」。
|
|
65
|
+
.replace(/^(?:迭代|优化|实施|执行|修复|重构|开发)?\s*计划\s*[::\-—]\s*/, '')
|
|
66
|
+
.replace(/^[::\-—\s]+/, '');
|
|
67
|
+
return sanitizeSummary(stripped) || '计划';
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/** 本地时区的 YYYYMMDD —— 用户看的日期必须是「他今天的日期」,不能用 UTC */
|
|
71
|
+
export function dateStamp(now = new Date()) {
|
|
72
|
+
const d = now instanceof Date ? now : new Date(now);
|
|
73
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
74
|
+
return `${d.getFullYear()}${p(d.getMonth() + 1)}${p(d.getDate())}`;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** 本地时区的 HH:MM,用于「更新」小节 */
|
|
78
|
+
function timeStamp(now = new Date()) {
|
|
79
|
+
const d = now instanceof Date ? now : new Date(now);
|
|
80
|
+
const p = (n) => String(n).padStart(2, '0');
|
|
81
|
+
return `${p(d.getHours())}:${p(d.getMinutes())}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** `YYYYMMDD-<概要>.md` */
|
|
85
|
+
export function planFileName(now, summary) {
|
|
86
|
+
return `${dateStamp(now)}-${summary || '计划'}.md`;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* 目标路径必须落回工作目录内 —— 与 `lib/tools.js` 的沙箱同口径。
|
|
91
|
+
* 概要已经清掉了分隔符,这里是第二道闸:模型给的标题再怎么花也出不去。
|
|
92
|
+
*/
|
|
93
|
+
function resolveInWorkDir(workDir, relPath) {
|
|
94
|
+
const root = path.resolve(workDir);
|
|
95
|
+
const abs = path.resolve(root, relPath);
|
|
96
|
+
const rel = path.relative(root, abs);
|
|
97
|
+
if (rel !== '' && (rel.startsWith('..') || path.isAbsolute(rel))) {
|
|
98
|
+
throw new Error(`路径越界:${relPath} 不在工作目录内`);
|
|
99
|
+
}
|
|
100
|
+
return abs;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** 计划正文规范化:统一换行 + 末尾留一个换行(git diff 干净) */
|
|
104
|
+
function normalizePlan(plan) {
|
|
105
|
+
let body = String(plan).replace(/\r\n/g, '\n').trim();
|
|
106
|
+
if (body.length > PLAN_MAX_BYTES) body = `${body.slice(0, PLAN_MAX_BYTES)}\n\n…(计划过长已截断)`;
|
|
107
|
+
return `${body}\n`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* 把一份计划写入 `<workDir>/docs/`。
|
|
112
|
+
*
|
|
113
|
+
* @param {object} o
|
|
114
|
+
* @param {string} o.workDir 工作目录(任务模式下必填)
|
|
115
|
+
* @param {string} o.plan 计划正文(markdown)
|
|
116
|
+
* @param {string=} o.title 备用概要:计划里没有 `# 标题` 时用(任务标题等)
|
|
117
|
+
* @param {Date|number=} o.now 注入时间,便于测试
|
|
118
|
+
* @returns {{relPath:string, absPath:string, summary:string, bytes:number, action:'created'|'updated'|'unchanged'}}
|
|
119
|
+
*/
|
|
120
|
+
export function savePlanDoc({ workDir, plan, title = '', now = new Date() } = {}) {
|
|
121
|
+
if (typeof workDir !== 'string' || !workDir.trim()) throw new Error('未指定工作目录,计划无法落盘');
|
|
122
|
+
if (typeof plan !== 'string' || !plan.trim()) throw new Error('计划正文为空,没有可落盘的内容');
|
|
123
|
+
|
|
124
|
+
const body = normalizePlan(plan);
|
|
125
|
+
const summary = summarizeTitle(plan, title);
|
|
126
|
+
const fileName = planFileName(now, summary);
|
|
127
|
+
const relPath = `${PLAN_DIR}/${fileName}`;
|
|
128
|
+
const absPath = resolveInWorkDir(workDir, relPath);
|
|
129
|
+
const dir = path.dirname(absPath);
|
|
130
|
+
|
|
131
|
+
// P2:没有 docs/ 就自动建 —— 这是用户最容易踩的一步,不能让他先手动 mkdir
|
|
132
|
+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
|
|
133
|
+
|
|
134
|
+
let content = body;
|
|
135
|
+
let action = 'created';
|
|
136
|
+
if (existsSync(absPath)) {
|
|
137
|
+
let old = '';
|
|
138
|
+
try {
|
|
139
|
+
old = readFileSync(absPath, 'utf8');
|
|
140
|
+
} catch {
|
|
141
|
+
old = ''; // 读不出来就当空的:写盘本身会报错,这里不掩盖
|
|
142
|
+
}
|
|
143
|
+
if (old.trim() === body.trim()) {
|
|
144
|
+
// 幂等:同一份计划重复落盘不产生第二条记录
|
|
145
|
+
return { relPath, absPath, summary, bytes: Buffer.byteLength(old, 'utf8'), action: 'unchanged' };
|
|
146
|
+
}
|
|
147
|
+
// 只追加不覆盖:用户/上一轮写在里面的内容不能被冲掉
|
|
148
|
+
content = `${old.trimEnd()}\n\n---\n\n## 更新(${timeStamp(now)})\n\n${body}`;
|
|
149
|
+
action = 'updated';
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
writeFileSync(absPath, content, 'utf8');
|
|
153
|
+
return { relPath, absPath, summary, bytes: Buffer.byteLength(content, 'utf8'), action };
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* 取「模型给出的那份计划」:最后一条**有正文且没有工具调用**的 assistant 消息。
|
|
158
|
+
* 工具轮的 content 常常是 null(或一句过渡话),拿它当计划就错了。
|
|
159
|
+
*/
|
|
160
|
+
export function lastAssistantText(messages) {
|
|
161
|
+
if (!Array.isArray(messages)) return '';
|
|
162
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
163
|
+
const m = messages[i];
|
|
164
|
+
if (!m || m.role !== 'assistant') continue;
|
|
165
|
+
if (Array.isArray(m.tool_calls) && m.tool_calls.length) continue;
|
|
166
|
+
if (typeof m.content === 'string' && m.content.trim()) return m.content;
|
|
167
|
+
}
|
|
168
|
+
return '';
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/** 会话里第一条用户消息 —— 任务标题之外的备用概要来源 */
|
|
172
|
+
export function firstUserText(messages) {
|
|
173
|
+
if (!Array.isArray(messages)) return '';
|
|
174
|
+
for (const m of messages) {
|
|
175
|
+
if (m && m.role === 'user' && typeof m.content === 'string' && m.content.trim()) return m.content;
|
|
176
|
+
}
|
|
177
|
+
return '';
|
|
178
|
+
}
|
package/lib/pricing.js
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 成本估算(M4)
|
|
3
|
+
*
|
|
4
|
+
* 网关通常接的是本地/自建上游(按 0 计),但也可能是转发到云端付费模型。
|
|
5
|
+
* 这里只做一件小事:给一个「按百万 token 单价 × 用量」的粗估,让 /cost 有数可看。
|
|
6
|
+
*
|
|
7
|
+
* 几点刻意为之:
|
|
8
|
+
* · 单价表内置在代码里,不联网、不引入依赖;
|
|
9
|
+
* · 认不出的模型**不猜**:返回 known=false,界面只说 token 数,不编美元数字;
|
|
10
|
+
* · 价格会变,所以这里只当量级参考,措辞上不承诺精确。
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
/** 单价单位:美元 / 百万 token */
|
|
14
|
+
const PRICE_TABLE = [
|
|
15
|
+
{ match: /gpt-4o-mini/i, prompt: 0.15, completion: 0.6 },
|
|
16
|
+
{ match: /gpt-4o/i, prompt: 2.5, completion: 10 },
|
|
17
|
+
{ match: /gpt-4\.1-mini/i, prompt: 0.4, completion: 1.6 },
|
|
18
|
+
{ match: /gpt-4\.1/i, prompt: 2, completion: 8 },
|
|
19
|
+
{ match: /o4-mini|o3-mini/i, prompt: 1.1, completion: 4.4 },
|
|
20
|
+
{ match: /claude-3-5-haiku|claude-3\.5-haiku/i, prompt: 0.8, completion: 4 },
|
|
21
|
+
{ match: /claude-3-5-sonnet|claude-3\.5-sonnet|claude-3-7-sonnet/i, prompt: 3, completion: 15 },
|
|
22
|
+
{ match: /claude-(opus|sonnet)-4|claude-4/i, prompt: 3, completion: 15 },
|
|
23
|
+
{ match: /deepseek-chat|deepseek-v3/i, prompt: 0.27, completion: 1.1 },
|
|
24
|
+
{ match: /deepseek-reasoner|deepseek-r1/i, prompt: 0.55, completion: 2.19 },
|
|
25
|
+
];
|
|
26
|
+
|
|
27
|
+
/** 找出模型对应的单价;认不出返回 null(不要瞎猜) */
|
|
28
|
+
export function priceFor(model) {
|
|
29
|
+
const name = String(model || '');
|
|
30
|
+
return PRICE_TABLE.find((r) => r.match.test(name)) || null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* @returns {{known:boolean, usd:number|null, prompt:number, completion:number, model:string}}
|
|
35
|
+
*/
|
|
36
|
+
export function estimateCost(usage, model) {
|
|
37
|
+
const prompt = Number(usage?.prompt) || 0;
|
|
38
|
+
const completion = Number(usage?.completion) || 0;
|
|
39
|
+
const price = priceFor(model);
|
|
40
|
+
if (!price) return { known: false, usd: null, prompt, completion, model: String(model || '') };
|
|
41
|
+
const usd = (prompt / 1e6) * price.prompt + (completion / 1e6) * price.completion;
|
|
42
|
+
return { known: true, usd, prompt, completion, model: String(model || '') };
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** 给 /cost 用的一行摘要(中文,认不出模型时只说 token) */
|
|
46
|
+
export function formatCost(usage, model) {
|
|
47
|
+
const c = estimateCost(usage, model);
|
|
48
|
+
const tokens = `输入 ${c.prompt} / 输出 ${c.completion} / 合计 ${c.prompt + c.completion} tokens`;
|
|
49
|
+
if (!c.known) return `${tokens}(模型 ${c.model || '未知'} 无内置单价,本地/自建上游按 0 计,这里只报 token)`;
|
|
50
|
+
const shown = c.usd < 0.01 ? c.usd.toFixed(6) : c.usd.toFixed(4);
|
|
51
|
+
return `${tokens} ≈ $${shown}(按月单价粗估,仅供参考)`;
|
|
52
|
+
}
|
package/lib/runner.js
ADDED
|
@@ -0,0 +1,234 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 传输无关的 Agent 运行器(M1)
|
|
3
|
+
*
|
|
4
|
+
* 把 lib/agent.js 的工具循环包成「建会话 → 发起任务 → 遇写入挂起 → 等批准 → 断点续跑」
|
|
5
|
+
* 的可复用流程。Web(task-server.js)与 CLI(cli-agent.js)都基于它,
|
|
6
|
+
* 这样两边处理挂起态、续跑与用量统计的方式保持一致,不会各自抄一遍后行为漂移。
|
|
7
|
+
*
|
|
8
|
+
* 关键点:runAgent 挂起时调用方必须自己保留会话(这里用内存对象承载);
|
|
9
|
+
* resumeWithApproval 会从断点继续,并且**可能再次挂起**(模型连续写多个文件),
|
|
10
|
+
* 所以续跑要放在循环里,直到不再挂起为止。
|
|
11
|
+
*
|
|
12
|
+
* M2 追加:会话可以被「恢复」——createSession 接受外部 id,restoreSession 从落盘记录
|
|
13
|
+
* 还原成可继续跑的会话对象,runTurn 通过 persist 回调把最新状态交回调用方(由它决定存哪)。
|
|
14
|
+
* 运行器本身仍然不认识存储实现,Web 与 CLI 各自接自己的 store。
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { randomUUID } from 'node:crypto';
|
|
18
|
+
import { runAgent, resumeWithApproval } from './agent.js';
|
|
19
|
+
|
|
20
|
+
/** 会话 id 必须是 UUID 形状:它同时是落盘文件名,形状不对就有目录穿越风险 */
|
|
21
|
+
const ID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/;
|
|
22
|
+
export const isSessionId = (id) => typeof id === 'string' && ID_RE.test(id);
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* 任务模式的系统提示词:讲清工作目录、工具用法与写入约束。
|
|
26
|
+
* mode 会改变对模型的约束 —— 计划模式必须明确「只读、只出计划」,
|
|
27
|
+
* 否则模型很容易一边调研一边就动手改了。
|
|
28
|
+
*
|
|
29
|
+
* `planFile` 是这条任务已经落盘的计划的**相对路径**(见 lib/plandoc.js)。
|
|
30
|
+
* 带上它以后:
|
|
31
|
+
* · 计划模式 → 在这份文档上继续完善;
|
|
32
|
+
* · 自动 / 手动 → 先读这份计划,按其中的步骤执行并把结果回填进去。
|
|
33
|
+
* 这正是「切到自动模式后依据上下文和计划处理」的落点:计划不在模型脑子里,在磁盘上。
|
|
34
|
+
*
|
|
35
|
+
* @param {string=} opts.planFile 已落盘计划的相对路径(如 `docs/20260916-xx.md`)
|
|
36
|
+
*/
|
|
37
|
+
export function buildTaskSystemPrompt(workDir, userSystem, mode = 'manual', { planFile } = {}) {
|
|
38
|
+
const base = `你是一个在用户本机工作目录内执行任务的编程助手,可以读写文件。
|
|
39
|
+
|
|
40
|
+
工作目录:${workDir}
|
|
41
|
+
所有文件工具都只能作用于该目录之内。调用工具时请一律使用**相对工作目录的相对路径**(例如 "src/index.js"),不要使用绝对路径。
|
|
42
|
+
|
|
43
|
+
工作方式:
|
|
44
|
+
1. 先用 list_dir / search_files 摸清结构,再用 read_file 读具体文件;
|
|
45
|
+
2. write_file 是**整体覆盖写入**,不是增量修改 —— 改文件前必须先 read_file 拿到完整内容,然后写入改动后的完整内容;只改几处优先用 apply_patch;
|
|
46
|
+
3. 不要臆测文件内容,需要什么就读什么;不确定就先搜索;
|
|
47
|
+
4. 完成或告一段落后,用简短的中文说明你做了什么、改了哪些文件。`;
|
|
48
|
+
|
|
49
|
+
const modeNote =
|
|
50
|
+
mode === 'plan'
|
|
51
|
+
? `
|
|
52
|
+
|
|
53
|
+
【当前是计划模式】
|
|
54
|
+
你现在**只能读取和搜索,不能修改任何文件**(写入工具没有提供给你,写了也会被拒绝)。
|
|
55
|
+
请先充分调研,然后给出一份**具体可执行的计划**,包含:
|
|
56
|
+
· 要改动哪些文件(写清相对路径);
|
|
57
|
+
· 每个文件具体改什么、为什么;
|
|
58
|
+
· 有没有风险或需要用户拍板的地方。
|
|
59
|
+
给出计划后停下等用户确认,不要试图开始动手。计划要具体到用户看完就能说「照这个做」。
|
|
60
|
+
|
|
61
|
+
【计划本身要写成一份完整文档】
|
|
62
|
+
你输出的这段计划会被系统**原样落盘**成工作目录里的 \`docs/YYYYMMDD-<概要>.md\`,所以:
|
|
63
|
+
· 第一行必须是 \`# <概要标题>\`:简短中文,不超过 30 字,概括这次要调整什么 —— 它就是文件名里的概要;
|
|
64
|
+
· 用标准 markdown 组织:需求 / 变更清单、改动方案(逐文件,路径写相对路径)、执行步骤、验收方式、风险;
|
|
65
|
+
· 执行步骤写成可勾选的清单(\`- [ ] 步骤\`),后续按计划执行时会被逐项勾掉;
|
|
66
|
+
· 不要写「我会先看看」这类过程话,只写这份文档本身。
|
|
67
|
+
你不用自己调用工具去建目录或写文件(计划模式也没有写入工具),落盘由系统完成。`
|
|
68
|
+
: mode === 'auto'
|
|
69
|
+
? `
|
|
70
|
+
|
|
71
|
+
【当前是自动模式】
|
|
72
|
+
用户已授权你**直接写入文件,不需要逐个确认**。请保持克制:
|
|
73
|
+
· 只改与当前任务直接相关的文件,不要顺手重构无关代码;
|
|
74
|
+
· 覆盖已有文件前务必先 read_file,避免把用户的内容冲掉;
|
|
75
|
+
· 每完成一步用一句话说明改了什么。`
|
|
76
|
+
: '';
|
|
77
|
+
|
|
78
|
+
// 已落盘的计划:切模式之后模型必须接着那份文档干,而不是凭记忆重来
|
|
79
|
+
const planNote = planFile
|
|
80
|
+
? `
|
|
81
|
+
|
|
82
|
+
【已落盘的计划】${planFile}
|
|
83
|
+
这条任务已经有一份写进工作目录的计划文档,它就是**本轮的作业依据**:
|
|
84
|
+
· 先用 read_file 读它,按其中的文件清单与执行步骤推进,不要另起炉灶;
|
|
85
|
+
· 每完成一项,用 apply_patch 把对应的 \`- [ ]\` 改成 \`- [x]\`;
|
|
86
|
+
· 在文档末尾的「执行结果」里补一行:改了什么、验证结果如何;
|
|
87
|
+
· 如果实际做法与计划不一致,**先更新这份文档**再继续,不要静默偏离。${
|
|
88
|
+
mode === 'plan' ? '\n · 当前是计划模式:在这一份上继续完善,落盘时会更新同一个文件。' : ''
|
|
89
|
+
}`
|
|
90
|
+
: '';
|
|
91
|
+
|
|
92
|
+
const extra = typeof userSystem === 'string' && userSystem.trim() ? `\n\n用户附加要求:\n${userSystem.trim()}` : '';
|
|
93
|
+
return base + modeNote + planNote + extra;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* 建一个会话对象。它就是 lib/agent.js 里的 run(含 messages / usage / pending),
|
|
98
|
+
* 额外带上模型与会话元数据,便于调用方持久化或复用。
|
|
99
|
+
*
|
|
100
|
+
* @param {string=} id 显式指定会话 id(--resume 时用);不合法就换一个新的,
|
|
101
|
+
* 绝不把外部字符串直接当文件名用。
|
|
102
|
+
*/
|
|
103
|
+
export function createSession({ workingDir, model, temperature, maxTokens, system, mode, id } = {}) {
|
|
104
|
+
return {
|
|
105
|
+
id: isSessionId(id) ? id : randomUUID(),
|
|
106
|
+
workingDir: workingDir || '',
|
|
107
|
+
model,
|
|
108
|
+
mode: mode || 'manual',
|
|
109
|
+
temperature: Number.isFinite(temperature) ? temperature : undefined,
|
|
110
|
+
maxTokens: Number.isFinite(maxTokens) && maxTokens > 0 ? maxTokens : undefined,
|
|
111
|
+
messages: [{ role: 'system', content: buildTaskSystemPrompt(workingDir, system, mode) }],
|
|
112
|
+
usage: { prompt: 0, completion: 0 },
|
|
113
|
+
pending: null,
|
|
114
|
+
pendingPreview: null,
|
|
115
|
+
touchedAt: Date.now(),
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* 从落盘的记录还原会话(--continue / --resume)。
|
|
121
|
+
*
|
|
122
|
+
* 与 createSession 的区别:**不重建 system 消息**,直接沿用存下来的 messages ——
|
|
123
|
+
* 那里面第一条就是当时的 system,重建反而会把当前的工作目录与模式混进去,
|
|
124
|
+
* 让「上次那个会话」变成另一个会话。overrides 只覆盖元数据。
|
|
125
|
+
*
|
|
126
|
+
* 记录不可用(缺 id / messages 不是数组)时返回 null,由调用方提示并新建。
|
|
127
|
+
*/
|
|
128
|
+
export function restoreSession(record, overrides = {}) {
|
|
129
|
+
if (!record || typeof record !== 'object' || !isSessionId(record.id) || !Array.isArray(record.messages)) return null;
|
|
130
|
+
return {
|
|
131
|
+
id: record.id,
|
|
132
|
+
workingDir: overrides.workingDir || record.workingDir || '',
|
|
133
|
+
model: overrides.model || record.model || '',
|
|
134
|
+
mode: overrides.mode || record.mode || 'manual',
|
|
135
|
+
temperature: Number.isFinite(record.temperature) ? record.temperature : undefined,
|
|
136
|
+
maxTokens: Number.isFinite(record.maxTokens) && record.maxTokens > 0 ? record.maxTokens : undefined,
|
|
137
|
+
messages: record.messages,
|
|
138
|
+
usage: { prompt: Number(record.usage?.prompt) || 0, completion: Number(record.usage?.completion) || 0 },
|
|
139
|
+
// 挂起态一并恢复:进程退出再回来时,那条待批准的工具调用还在
|
|
140
|
+
pending: record.pending && typeof record.pending === 'object' ? record.pending : null,
|
|
141
|
+
pendingPreview: record.pendingPreview && typeof record.pendingPreview === 'object' ? record.pendingPreview : null,
|
|
142
|
+
touchedAt: Number(record.touchedAt) || Date.now(),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/** 会话里已有多少条对话(不含 system),给「已恢复会话(N 条消息)」这类提示用 */
|
|
147
|
+
export function countTurns(session) {
|
|
148
|
+
return Array.isArray(session?.messages) ? session.messages.filter((m) => m?.role !== 'system').length : 0;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** 往会话里追加一条用户消息(空串会被忽略,返回 false) */
|
|
152
|
+
export function pushUser(session, text) {
|
|
153
|
+
const content = typeof text === 'string' ? text : '';
|
|
154
|
+
if (!content.trim()) return false;
|
|
155
|
+
session.messages.push({ role: 'user', content });
|
|
156
|
+
return true;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* 跑一轮对话(可含多步工具调用与多次写入批准)。
|
|
161
|
+
*
|
|
162
|
+
* @param {object} opts
|
|
163
|
+
* @param {object} opts.cfg { baseUrl, key }
|
|
164
|
+
* @param {object} opts.session createSession / restoreSession 的产物
|
|
165
|
+
* @param {string=} opts.prompt 本轮用户输入(省略则用会话里已有的 messages)
|
|
166
|
+
* @param {Function} opts.onEvent 事件回调,收到 lib/agent.js 发出的原始事件
|
|
167
|
+
* @param {Function} opts.askApproval 写入挂起时调用,签名 (pending, preview) => Promise<boolean>
|
|
168
|
+
* @param {Function=} opts.persist 每轮结束时回调(成功/失败/挂起都会调),由调用方落盘
|
|
169
|
+
* @param {AbortSignal=} opts.signal 中止信号
|
|
170
|
+
* @returns {Promise<{status:'done'|'aborted', hitLimit?:boolean}>}
|
|
171
|
+
*/
|
|
172
|
+
export async function runTurn({ cfg, session, prompt, onEvent, askApproval, persist, signal }) {
|
|
173
|
+
if (prompt != null && !pushUser(session, prompt)) {
|
|
174
|
+
throw Object.assign(new Error('本轮输入为空'), { status: 400 });
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 落盘一律放在 finally:挂起、中止、报错都各有各的「下次要接着跑」,
|
|
178
|
+
// 只在成功路径上写,等于挂起态永远不会被记住(这正是 M2 要修的)。
|
|
179
|
+
const save = () => {
|
|
180
|
+
if (typeof persist !== 'function') return;
|
|
181
|
+
session.touchedAt = Date.now();
|
|
182
|
+
try {
|
|
183
|
+
persist(session);
|
|
184
|
+
} catch {
|
|
185
|
+
/* 落盘失败不该影响这一轮的对话 */
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
try {
|
|
190
|
+
let outcome = await runAgent({ cfg, run: session, send: onEvent, signal });
|
|
191
|
+
|
|
192
|
+
// 挂起 → 询问 → 续跑;续跑可能再次挂起(连续写多个文件),因此循环处理
|
|
193
|
+
while (outcome.status === 'awaiting-approval') {
|
|
194
|
+
save(); // 挂起瞬间先存一次,用户此时关掉终端也还能回来批准
|
|
195
|
+
const approved = await askApproval(outcome.pending, outcome.preview);
|
|
196
|
+
outcome = await resumeWithApproval({ cfg, run: session, send: onEvent, signal, approved });
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
return outcome;
|
|
200
|
+
} finally {
|
|
201
|
+
save();
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* 「恢复一个挂起态」:进程退出前那条待批准的工具调用还在会话里,
|
|
207
|
+
* 回来时先问用户批不批,再接着跑。没有挂起态则返回 null(调用方照常开新一轮)。
|
|
208
|
+
*/
|
|
209
|
+
export async function resumePendingTurn({ cfg, session, onEvent, askApproval, persist, signal }) {
|
|
210
|
+
if (!session?.pending) return null;
|
|
211
|
+
const save = () => {
|
|
212
|
+
if (typeof persist !== 'function') return;
|
|
213
|
+
session.touchedAt = Date.now();
|
|
214
|
+
try {
|
|
215
|
+
persist(session);
|
|
216
|
+
} catch {
|
|
217
|
+
/* 忽略 */
|
|
218
|
+
}
|
|
219
|
+
};
|
|
220
|
+
try {
|
|
221
|
+
const approved = await askApproval(session.pending, session.pendingPreview || { path: '?', content: '' });
|
|
222
|
+
// 不要把 session.pending 提前清掉:resumeWithApproval 正是靠 run.pending 找到
|
|
223
|
+
// 「要执行哪个调用」,它自己会在开头清空。提前清会让它抛 409「没有待批准的写入」。
|
|
224
|
+
let outcome = await resumeWithApproval({ cfg, run: session, send: onEvent, signal, approved });
|
|
225
|
+
while (outcome.status === 'awaiting-approval') {
|
|
226
|
+
save();
|
|
227
|
+
const again = await askApproval(outcome.pending, outcome.preview);
|
|
228
|
+
outcome = await resumeWithApproval({ cfg, run: session, send: onEvent, signal, approved: again });
|
|
229
|
+
}
|
|
230
|
+
return outcome;
|
|
231
|
+
} finally {
|
|
232
|
+
save();
|
|
233
|
+
}
|
|
234
|
+
}
|
package/lib/runstore.js
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 挂起态(待批准的工具调用)落盘
|
|
3
|
+
*
|
|
4
|
+
* 原来挂起态只在内存里存 10 分钟,刷新页面或重启服务就没了,用户走开一会儿回来
|
|
5
|
+
* 就得重跑整个任务。现在把它写到磁盘上,TTL 也放宽到 60 分钟:
|
|
6
|
+
* 刷新页面、甚至重启服务,待批准的写入都还在,回来点一下就能接着跑。
|
|
7
|
+
*
|
|
8
|
+
* 文件布局:<dir>/<runId>.json,内容就是 agent 循环里的 run 对象。
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import path from 'node:path';
|
|
12
|
+
import { existsSync, mkdirSync, readFileSync, unlinkSync, readdirSync } from 'node:fs';
|
|
13
|
+
import { isSafeId } from './taskstore.js';
|
|
14
|
+
import { writeJsonAtomic } from './jsonstore.js';
|
|
15
|
+
|
|
16
|
+
export function createRunStore(dir) {
|
|
17
|
+
mkdirSync(dir, { recursive: true });
|
|
18
|
+
const file = (id) => path.join(dir, `${id}.json`);
|
|
19
|
+
|
|
20
|
+
const write = (run) => {
|
|
21
|
+
if (!isSafeId(run?.id)) throw new Error('挂起态 id 非法');
|
|
22
|
+
writeJsonAtomic(file(run.id), run);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const read = (id) => {
|
|
26
|
+
if (!isSafeId(id)) return null;
|
|
27
|
+
const f = file(id);
|
|
28
|
+
if (!existsSync(f)) return null;
|
|
29
|
+
try {
|
|
30
|
+
const data = JSON.parse(readFileSync(f, 'utf8'));
|
|
31
|
+
return data && typeof data === 'object' ? data : null;
|
|
32
|
+
} catch {
|
|
33
|
+
// 损坏的挂起态没有保留价值,清掉免得每次启动都报
|
|
34
|
+
try {
|
|
35
|
+
unlinkSync(f);
|
|
36
|
+
} catch {
|
|
37
|
+
/* 忽略 */
|
|
38
|
+
}
|
|
39
|
+
return null;
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const drop = (id) => {
|
|
44
|
+
if (!isSafeId(id)) return false;
|
|
45
|
+
try {
|
|
46
|
+
unlinkSync(file(id));
|
|
47
|
+
return true;
|
|
48
|
+
} catch {
|
|
49
|
+
return false;
|
|
50
|
+
}
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** 列出全部挂起态;顺带按 TTL 清掉过期的 */
|
|
54
|
+
const list = (ttlMs) => {
|
|
55
|
+
const out = [];
|
|
56
|
+
let names = [];
|
|
57
|
+
try {
|
|
58
|
+
names = readdirSync(dir);
|
|
59
|
+
} catch {
|
|
60
|
+
return out;
|
|
61
|
+
}
|
|
62
|
+
const now = Date.now();
|
|
63
|
+
for (const n of names) {
|
|
64
|
+
if (n.endsWith('.tmp')) {
|
|
65
|
+
// 上次写一半留下的残骸
|
|
66
|
+
try {
|
|
67
|
+
unlinkSync(path.join(dir, n));
|
|
68
|
+
} catch {
|
|
69
|
+
/* 忽略 */
|
|
70
|
+
}
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
if (!n.endsWith('.json')) continue;
|
|
74
|
+
const id = n.slice(0, -'.json'.length);
|
|
75
|
+
// 这个目录只归我们写,名字不是 UUID 的 .json 一定是垃圾(手滑、别的工具),直接清掉
|
|
76
|
+
if (!isSafeId(id)) {
|
|
77
|
+
try {
|
|
78
|
+
unlinkSync(path.join(dir, n));
|
|
79
|
+
} catch {
|
|
80
|
+
/* 忽略 */
|
|
81
|
+
}
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const run = read(id);
|
|
85
|
+
if (!run) continue;
|
|
86
|
+
if (ttlMs && now - (run.touchedAt || 0) > ttlMs) {
|
|
87
|
+
drop(id);
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
out.push(run);
|
|
91
|
+
}
|
|
92
|
+
return out;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
return { dir, write, read, drop, list };
|
|
96
|
+
}
|