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
|
@@ -0,0 +1,594 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* 任务会话(模型态消息)落盘 —— 方案 C 的存储层
|
|
3
|
+
*
|
|
4
|
+
* 为什么需要它:任务页原来是**无状态**的 —— 每轮请求都拿浏览器里的渲染态消息重建上下文,
|
|
5
|
+
* 而渲染态里只有「对白」(role + content),工具调用与工具结果(steps)在 `public/task.js`
|
|
6
|
+
* 和 `lib/hub.js` 两处被剥掉。于是 `read_file` / `grep` 读回来的文件正文从没进过模型上下文,
|
|
7
|
+
* 模型每轮只能重新 `list_dir` / `grep` —— 时间和 token 都浪费在重复探索上。
|
|
8
|
+
*
|
|
9
|
+
* 这里存的是**模型态**:`assistant.tool_calls` + `{role:'tool', tool_call_id, content}`,
|
|
10
|
+
* 与 `lib/agent.js` 往 `run.messages` 里 push 的形状完全一致。服务端持有它、每轮 append、
|
|
11
|
+
* 每轮落盘,前端只发「新指令 + taskId」。
|
|
12
|
+
*
|
|
13
|
+
* 与渲染态的关系:**两份并存,不合并**。
|
|
14
|
+
* · 渲染态独有 label / note / pending / notices —— 从原生消息反推不回来;
|
|
15
|
+
* · 模型态独有 tool_call_id / arguments / 完整工具输出 —— 渲染态(`sanitizeStep`)把它丢了。
|
|
16
|
+
* 所以渲染态继续留在 `tasks/<id>.json`(见 `lib/taskstore.js`),这里只放模型态。
|
|
17
|
+
*
|
|
18
|
+
* 文件布局:`<taskStoreDir>/sessions/<taskId>.json`
|
|
19
|
+
* —— 放在任务存储根**内部**而不是 `<root>/sessions/`:后者已经被 `lib/sessionstore.js`
|
|
20
|
+
* 的 CLI 会占用(`sessionstore.js` 的 resolveSessionDir)。放里面还能天然跟随任务的删除与 TTL。
|
|
21
|
+
*/
|
|
22
|
+
|
|
23
|
+
import { randomUUID } from 'node:crypto';
|
|
24
|
+
import { statSync } from 'node:fs';
|
|
25
|
+
import path from 'node:path';
|
|
26
|
+
import { createMessageStore } from './sessionstore.js';
|
|
27
|
+
import { isSafeId, TTL_MS as TASK_TTL_MS } from './taskstore.js';
|
|
28
|
+
|
|
29
|
+
/** 单会话落盘硬闸:防跑飞的兜底,真正的成本控制在 history.maxChars */
|
|
30
|
+
export const TASK_SESSION_MAX_BYTES = 8 * 1024 * 1024;
|
|
31
|
+
export const TASK_SESSION_MAX_MESSAGES = 4000;
|
|
32
|
+
/** 单条消息正文的落盘上限(工具结果可能是几十万字符的文件正文) */
|
|
33
|
+
export const MAX_MESSAGE_CHARS = 400000;
|
|
34
|
+
/** 迁移旧数据时,单条工具结果正文保留多少(超了就截断并标注) */
|
|
35
|
+
export const MIGRATE_STEP_CHARS = 20000;
|
|
36
|
+
|
|
37
|
+
export const HISTORY_MAX_CHARS = 60000;
|
|
38
|
+
export const HISTORY_KEEP_RECENT_TURNS = 3;
|
|
39
|
+
/** 是否注入「已读文件清单」(history.readFileDigest 的默认值) */
|
|
40
|
+
export const HISTORY_READ_FILE_DIGEST = true;
|
|
41
|
+
|
|
42
|
+
/** 压缩后的工具结果长这样,靠这个前缀识别「已经压过了」(压缩是幂等的) */
|
|
43
|
+
const COMPACTED_MARK = '[已压缩的历史工具结果] ';
|
|
44
|
+
/** 已读文件清单最多列几条、最多占多少字符 —— 它要进 system,不能自己变成大头 */
|
|
45
|
+
const DIGEST_MAX_FILES = 20;
|
|
46
|
+
const DIGEST_MAX_CHARS = 2000;
|
|
47
|
+
|
|
48
|
+
/** 迁移时给工具记录套的分隔符:要显眼到模型一眼能认出「这是历史工具输出」 */
|
|
49
|
+
const STEP_BLOCK_HEAD = '[工具调用记录(由旧版本迁移,调用参数已丢失)]';
|
|
50
|
+
const STEP_BLOCK_TAIL = '[记录结束]';
|
|
51
|
+
|
|
52
|
+
const clip = (s, max) => (s.length > max ? `${s.slice(0, max)}\n…(已截断,原文 ${s.length} 字符)` : s);
|
|
53
|
+
|
|
54
|
+
/* ---------- 会话对象 ---------- */
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* 新建一个任务会话。`id` 与 `taskId` 是两件事:
|
|
58
|
+
* · `id` 是**本次运行**的标识(挂起态落盘、baggage 头都用它,`lib/agent.js` 依赖它);
|
|
59
|
+
* · `taskId` 是**这条任务**的标识(会话文件按它命名,跨轮次稳定)。
|
|
60
|
+
*/
|
|
61
|
+
export function createTaskSession({ taskId, workDir, model, mode, system, limits = {} } = {}) {
|
|
62
|
+
if (!isSafeId(taskId)) throw Object.assign(new Error('任务 id 非法'), { status: 400 });
|
|
63
|
+
const base = typeof system === 'string' ? system : '';
|
|
64
|
+
return {
|
|
65
|
+
version: 1,
|
|
66
|
+
id: randomUUID(),
|
|
67
|
+
taskId,
|
|
68
|
+
// 字段名跟 lib/agent.js / lib/runner.js 对齐:它们读的都是 run.workingDir
|
|
69
|
+
workingDir: workDir || '',
|
|
70
|
+
model: model || '',
|
|
71
|
+
mode: mode || 'manual',
|
|
72
|
+
// system 单独存「干净」的那份:迁移说明与已读文件清单是每轮现拼的,
|
|
73
|
+
// 混进来会让「system 变没变」这个判断失去意义(每次都被当成变了 → 压缩标记反复作废)
|
|
74
|
+
system: base,
|
|
75
|
+
// 计划模式落盘的计划文档(相对工作目录,如 docs/20260916-xx.md)。
|
|
76
|
+
// 由 lib/hub.js 在收尾时写入,下一轮拼 system 时用它把模型按回计划上,见 lib/plandoc.js。
|
|
77
|
+
planFile: '',
|
|
78
|
+
messages: [{ role: 'system', content: base }],
|
|
79
|
+
usage: { prompt: 0, completion: 0 },
|
|
80
|
+
history: {
|
|
81
|
+
maxChars: limits.historyMaxChars ?? HISTORY_MAX_CHARS,
|
|
82
|
+
keepRecentTurns: limits.keepRecentTurns ?? HISTORY_KEEP_RECENT_TURNS,
|
|
83
|
+
compactedAt: null,
|
|
84
|
+
compactedTurns: 0,
|
|
85
|
+
},
|
|
86
|
+
// 已读文件快照:path → { lines, mtimeMs, atIndex }。用来判断「这份内容还在上文里」
|
|
87
|
+
// 以及「是不是被外部改过了」,见 readFileDigestBlock。
|
|
88
|
+
readFiles: {},
|
|
89
|
+
migrated: 'none',
|
|
90
|
+
degradedTurns: 0,
|
|
91
|
+
// 迁移说明单独存字段,而不是揉进 system 正文 ——
|
|
92
|
+
// 否则第二轮的 refreshSessionContext 重建 system 时会把它冲掉,
|
|
93
|
+
// 模型就以为那些「历史工具输出」是它自己读的当前内容了。
|
|
94
|
+
migrationNote: '',
|
|
95
|
+
needsRebuild: false,
|
|
96
|
+
touchedAt: Date.now(),
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* 把请求里的工作目录 / 模式 / 系统提示词同步进会话。
|
|
102
|
+
*
|
|
103
|
+
* 换了模式(比如从计划模式切到自动模式)必须换掉 system,否则模型还按上一轮的约束干活。
|
|
104
|
+
* 注意比的是 `session.system` 而不是 `messages[0].content` —— 后者每轮都会被拼上
|
|
105
|
+
* 迁移说明与已读文件清单,拿它比会「每次都认为变了」,把压缩标记冲掉,prompt 缓存也就没了。
|
|
106
|
+
*/
|
|
107
|
+
export function refreshSessionContext(session, { workDir, model, mode, system }) {
|
|
108
|
+
if (typeof workDir === 'string' && workDir) session.workingDir = workDir;
|
|
109
|
+
if (typeof model === 'string' && model) session.model = model;
|
|
110
|
+
if (typeof mode === 'string' && mode) session.mode = mode;
|
|
111
|
+
if (typeof system !== 'string') return session;
|
|
112
|
+
if (typeof session.system !== 'string') session.system = deriveBaseSystem(session);
|
|
113
|
+
if (session.system === system) return session;
|
|
114
|
+
|
|
115
|
+
session.system = system;
|
|
116
|
+
// system 是发出去的第一条消息,它一变,之前那份压缩结果就不再是「同一个前缀」了
|
|
117
|
+
session.history.compactedAt = null;
|
|
118
|
+
session.history.compactedTurns = 0;
|
|
119
|
+
return session;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** 老会话没存 `system`:从 messages[0] 里把迁移说明剥掉反推出来 */
|
|
123
|
+
function deriveBaseSystem(session) {
|
|
124
|
+
const head = Array.isArray(session.messages) ? session.messages[0] : null;
|
|
125
|
+
let base = head?.role === 'system' && typeof head.content === 'string' ? head.content : '';
|
|
126
|
+
const note = typeof session.migrationNote === 'string' ? session.migrationNote : '';
|
|
127
|
+
if (note && base.endsWith(note)) base = base.slice(0, -note.length).trimEnd();
|
|
128
|
+
return base;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/* ---------- 清洗与落盘 ---------- */
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* 从消息数组最前面丢一条,但**不能让历史以 tool 消息开头** ——
|
|
135
|
+
* `{role:'tool'}` 必须紧跟在带 `tool_calls` 的 assistant 后面,
|
|
136
|
+
* 否则严格校验的上游会直接 400。丢 assistant 时把它的 tool 回包一起丢掉。
|
|
137
|
+
* 返回实际丢掉的条数。
|
|
138
|
+
*/
|
|
139
|
+
function dropOldestTurn(messages) {
|
|
140
|
+
if (messages.length <= 1) return 0;
|
|
141
|
+
let removed = 0;
|
|
142
|
+
messages.splice(1, 1);
|
|
143
|
+
removed++;
|
|
144
|
+
while (messages.length > 1 && messages[1]?.role === 'tool') {
|
|
145
|
+
messages.splice(1, 1);
|
|
146
|
+
removed++;
|
|
147
|
+
}
|
|
148
|
+
return removed;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function trimMessage(m) {
|
|
152
|
+
const copy = { ...m };
|
|
153
|
+
if (typeof copy.content === 'string' && copy.content.length > MAX_MESSAGE_CHARS) {
|
|
154
|
+
copy.content = `${copy.content.slice(0, MAX_MESSAGE_CHARS)}\n…(落盘时截断)`;
|
|
155
|
+
}
|
|
156
|
+
return copy;
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/** 已读文件快照是「模型读过的路径 + 当时的 mtime」,封顶并只留数值字段,别让它长成又一个上下文 */
|
|
160
|
+
function sanitizeReadFiles(input) {
|
|
161
|
+
if (!input || typeof input !== 'object') return {};
|
|
162
|
+
const out = {};
|
|
163
|
+
let n = 0;
|
|
164
|
+
for (const [k, v] of Object.entries(input)) {
|
|
165
|
+
if (n++ >= 200) break; // 清单本身也是会长的,封个顶
|
|
166
|
+
if (typeof k !== 'string' || !k || !v || typeof v !== 'object') continue;
|
|
167
|
+
out[k.slice(0, 400)] = {
|
|
168
|
+
lines: Number(v.lines) || 0,
|
|
169
|
+
mtimeMs: Number(v.mtimeMs) || 0,
|
|
170
|
+
atIndex: Number(v.atIndex) || 0,
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
return out;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** 只留认识的字段,避免把上游 / 页面塞进来的任意对象原样写盘 */
|
|
177
|
+
export function sanitizeTaskSession(session, { maxBytes = TASK_SESSION_MAX_BYTES, maxMessages = TASK_SESSION_MAX_MESSAGES } = {}) {
|
|
178
|
+
if (!session || typeof session !== 'object' || !isSafeId(session.taskId)) return null;
|
|
179
|
+
const raw = Array.isArray(session.messages) ? session.messages.filter((m) => m && typeof m === 'object') : [];
|
|
180
|
+
const messages = raw.slice(-maxMessages).map(trimMessage);
|
|
181
|
+
|
|
182
|
+
const history = session.history && typeof session.history === 'object' ? session.history : {};
|
|
183
|
+
const base = typeof session.system === 'string' ? session.system : deriveBaseSystem(session);
|
|
184
|
+
const record = {
|
|
185
|
+
version: 1,
|
|
186
|
+
id: isSafeId(session.id) ? session.id : randomUUID(),
|
|
187
|
+
taskId: session.taskId,
|
|
188
|
+
workingDir: typeof session.workingDir === 'string' ? session.workingDir : '',
|
|
189
|
+
model: typeof session.model === 'string' ? session.model : '',
|
|
190
|
+
mode: typeof session.mode === 'string' ? session.mode : 'manual',
|
|
191
|
+
system: base,
|
|
192
|
+
// 计划文档路径(相对工作目录):落盘时一并记住,跨轮次/重启后仍能「按计划执行」
|
|
193
|
+
planFile: typeof session.planFile === 'string' ? session.planFile.slice(0, 200) : '',
|
|
194
|
+
messages,
|
|
195
|
+
usage: { prompt: Number(session.usage?.prompt) || 0, completion: Number(session.usage?.completion) || 0 },
|
|
196
|
+
history: {
|
|
197
|
+
maxChars: Number(history.maxChars) > 0 ? Number(history.maxChars) : HISTORY_MAX_CHARS,
|
|
198
|
+
keepRecentTurns: Number(history.keepRecentTurns) >= 0 ? Number(history.keepRecentTurns) : HISTORY_KEEP_RECENT_TURNS,
|
|
199
|
+
compactedAt: Number(history.compactedAt) || null,
|
|
200
|
+
compactedTurns: Number(history.compactedTurns) || 0,
|
|
201
|
+
},
|
|
202
|
+
// 已读文件快照:几公里外的 mtime 变化全靠它认出来,所以必须落盘(不然每次重启都当作「第一次读」)
|
|
203
|
+
readFiles: sanitizeReadFiles(session.readFiles),
|
|
204
|
+
// 迁移质量:none 全新 / native 逐条还原 / partial 有降级轮(工具参数不可恢复)
|
|
205
|
+
migrated: ['none', 'native', 'partial'].includes(session.migrated) ? session.migrated : 'none',
|
|
206
|
+
degradedTurns: Number(session.degradedTurns) || 0,
|
|
207
|
+
migrationNote: typeof session.migrationNote === 'string' ? session.migrationNote.slice(0, 4000) : '',
|
|
208
|
+
needsRebuild: Boolean(session.needsRebuild),
|
|
209
|
+
touchedAt: Number(session.touchedAt) || Date.now(),
|
|
210
|
+
};
|
|
211
|
+
|
|
212
|
+
// 体积兜底:从最老的整轮开始丢,system 与「最近 keepRecentTurns 轮」不丢
|
|
213
|
+
let dropped = 0;
|
|
214
|
+
const keepAtLeast = 1 + record.history.keepRecentTurns * 2; // system + 每轮至少 user + assistant
|
|
215
|
+
while (Buffer.byteLength(JSON.stringify(record)) > maxBytes && record.messages.length > keepAtLeast + 1) {
|
|
216
|
+
const n = dropOldestTurn(record.messages);
|
|
217
|
+
if (!n) break;
|
|
218
|
+
dropped += n;
|
|
219
|
+
}
|
|
220
|
+
return { record, dropped };
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* 任务会话存储(目录建议传 `<taskStoreDir>/sessions`)。
|
|
225
|
+
* `limits` 可以是对象,也可以是函数 —— 传函数是为了让配置改了之后立刻生效,
|
|
226
|
+
* 而不必重建 store。
|
|
227
|
+
*/
|
|
228
|
+
export function createTaskSessionStore(dir, limits = {}) {
|
|
229
|
+
const limitsOf = typeof limits === 'function' ? limits : () => limits;
|
|
230
|
+
return createMessageStore({
|
|
231
|
+
dir,
|
|
232
|
+
sanitize: (s) => sanitizeTaskSession(s, limitsOf()),
|
|
233
|
+
idOf: (r) => r.taskId,
|
|
234
|
+
maxItems: 0, // 按任务而不是按数量淘汰:任务被删/被 prune 时我们主动删会话
|
|
235
|
+
label: '任务会话',
|
|
236
|
+
});
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
/* ---------- 历史预算与「已读文件」清单(M3) ---------- */
|
|
240
|
+
|
|
241
|
+
const charCount = (messages) => (Array.isArray(messages) ? messages.reduce((n, m) => n + (typeof m?.content === 'string' ? m.content.length : 0), 0) : 0);
|
|
242
|
+
|
|
243
|
+
/**
|
|
244
|
+
* 保留区起点:最近 keepTurns 轮的第一条 user 消息的下标。
|
|
245
|
+
*
|
|
246
|
+
* 「一轮」按 user 消息切。压缩只动这个下标**之前**的工具结果 ——
|
|
247
|
+
* 最近几轮必须保全文,否则模型刚读完文件、下一轮就看不见内容了,等于逼它重读。
|
|
248
|
+
*/
|
|
249
|
+
function turnStartIndex(messages, keepTurns) {
|
|
250
|
+
if (!(keepTurns > 0)) return messages.length;
|
|
251
|
+
let seen = 0;
|
|
252
|
+
for (let i = messages.length - 1; i >= 1; i--) {
|
|
253
|
+
if (messages[i]?.role !== 'user') continue;
|
|
254
|
+
seen++;
|
|
255
|
+
if (seen === keepTurns) return i;
|
|
256
|
+
}
|
|
257
|
+
return 1; // 还没攒够这么多轮,全在保留区
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** tool_call_id → 工具名与参数。工具结果本身不带名字,得回头找它的调用 */
|
|
261
|
+
function toolCallIndex(messages) {
|
|
262
|
+
const map = new Map();
|
|
263
|
+
for (const m of Array.isArray(messages) ? messages : []) {
|
|
264
|
+
if (m?.role !== 'assistant' || !Array.isArray(m.tool_calls)) continue;
|
|
265
|
+
for (const tc of m.tool_calls) {
|
|
266
|
+
const name = tc?.function?.name;
|
|
267
|
+
if (tc?.id && name) map.set(tc.id, { name, args: tc.function.arguments });
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
return map;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** 从工具参数 JSON 里取路径;取不到就退回原始串(宁可难看,也别让模型以为没读过) */
|
|
274
|
+
function pathFromArgs(argsJson) {
|
|
275
|
+
try {
|
|
276
|
+
const v = JSON.parse(String(argsJson || ''));
|
|
277
|
+
if (v && typeof v.path === 'string' && v.path) return v.path;
|
|
278
|
+
if (v && typeof v.file === 'string' && v.file) return v.file;
|
|
279
|
+
} catch {
|
|
280
|
+
/* 参数不是合法 JSON:老数据或模型写坏了 */
|
|
281
|
+
}
|
|
282
|
+
return '';
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** 压成一行时给个规模感:「120 行」比「6120 字符」对模型更有用 */
|
|
286
|
+
function sizeLabel(content) {
|
|
287
|
+
const text = typeof content === 'string' ? content : '';
|
|
288
|
+
const lines = text ? text.split('\n').length : 0;
|
|
289
|
+
if (lines > 1) return `${lines} 行`;
|
|
290
|
+
return `${text.length} 字符`;
|
|
291
|
+
}
|
|
292
|
+
|
|
293
|
+
function isCompacted(m) {
|
|
294
|
+
return typeof m?.content === 'string' && m.content.startsWith(COMPACTED_MARK);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* 历史预算:把「较早的工具结果正文」压成一行,但**保留记录**。
|
|
299
|
+
*
|
|
300
|
+
* 为什么保留记录而不是丢掉整条:模型必须知道「这个文件我读过」。丢掉记录会让它
|
|
301
|
+
* 以为从没读过,于是又去 `list_dir` / `read_file` —— 正是我们要治的病。
|
|
302
|
+
* 压缩只削正文,命令行与结果规模都留在那里。
|
|
303
|
+
*
|
|
304
|
+
* prompt 缓存:只在总量超过预算时才动手(没超就一个字都不改)。一旦每轮都重新裁剪,
|
|
305
|
+
* 已经发出去的前缀就会漂移,上游的 prefix cache 全部失效 —— 省下的 token 还不如赔的多。
|
|
306
|
+
*/
|
|
307
|
+
export function compactHistory(session, limits = {}) {
|
|
308
|
+
const h = session.history && typeof session.history === 'object' ? session.history : (session.history = {});
|
|
309
|
+
// 以**当前配置**为准(R1-V8):会话里那份只是上次的快照,用户改了配置就该立刻生效
|
|
310
|
+
if (Number(limits.historyMaxChars) > 0) h.maxChars = Number(limits.historyMaxChars);
|
|
311
|
+
if (Number(limits.keepRecentTurns) >= 0) h.keepRecentTurns = Number(limits.keepRecentTurns);
|
|
312
|
+
if (!(Number(h.maxChars) > 0)) h.maxChars = HISTORY_MAX_CHARS;
|
|
313
|
+
if (!(Number(h.keepRecentTurns) >= 0)) h.keepRecentTurns = HISTORY_KEEP_RECENT_TURNS;
|
|
314
|
+
|
|
315
|
+
const before = charCount(session.messages);
|
|
316
|
+
if (before <= h.maxChars) return { compacted: false, before, after: before, pruned: 0 };
|
|
317
|
+
|
|
318
|
+
const calls = toolCallIndex(session.messages);
|
|
319
|
+
const boundary = turnStartIndex(session.messages, h.keepRecentTurns);
|
|
320
|
+
const snap = session.readFiles && typeof session.readFiles === 'object' ? session.readFiles : (session.readFiles = {});
|
|
321
|
+
let pruned = 0;
|
|
322
|
+
for (let i = 1; i < boundary; i++) {
|
|
323
|
+
const m = session.messages[i];
|
|
324
|
+
if (m?.role !== 'tool' || isCompacted(m)) continue;
|
|
325
|
+
const c = calls.get(m.tool_call_id);
|
|
326
|
+
const name = c?.name || 'tool';
|
|
327
|
+
const p = pathFromArgs(c?.args);
|
|
328
|
+
const lines = (typeof m.content === 'string' ? m.content : '').split('\n').length;
|
|
329
|
+
// 规模要在压缩**之前**记下来 —— 压完之后从正文里再也数不出来,
|
|
330
|
+
// 而「已读文件清单」还欠用户一个「这文件多大」
|
|
331
|
+
if (p) {
|
|
332
|
+
const prev = snap[p];
|
|
333
|
+
if (!prev || i > prev.atIndex) snap[p] = { lines, mtimeMs: prev?.mtimeMs || 0, atIndex: i };
|
|
334
|
+
}
|
|
335
|
+
const target = p || (c?.args ? clip(String(c.args), 120) : '');
|
|
336
|
+
m.content = `${COMPACTED_MARK}${name}${target ? ` ${target}` : ''} → ${sizeLabel(m.content)}(正文已压缩;需要内容请重新读取这个文件)`;
|
|
337
|
+
pruned++;
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
const after = charCount(session.messages);
|
|
341
|
+
if (pruned) {
|
|
342
|
+
h.compactedAt = Date.now();
|
|
343
|
+
h.compactedTurns = (Number(h.compactedTurns) || 0) + pruned;
|
|
344
|
+
}
|
|
345
|
+
return { compacted: pruned > 0, before, after, pruned };
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
/**
|
|
349
|
+
* 「已读文件清单」:告诉模型哪些文件在上文里、多大、什么时候改过。
|
|
350
|
+
*
|
|
351
|
+
* 这一条直接对着用户的抱怨(每轮都去读目录、搜文件):模型看不见「我读过」,
|
|
352
|
+
* 就只好再读一遍。清单不塞正文,只给路径 + 行数 + mtime,成本极低。
|
|
353
|
+
*
|
|
354
|
+
* mtime 变化要能报出来,否则模型会拿旧内容当现状。判断方式:
|
|
355
|
+
* · 先记下「第一次读到它」时的 mtime 与消息下标;
|
|
356
|
+
* · 之后模型自己又读过(下标变大)→ 刷新快照,不再报警(它手里的是新内容);
|
|
357
|
+
* · 下标没变而 mtime 变了 → 说明是外部改的,标注「已被外部修改」。
|
|
358
|
+
*/
|
|
359
|
+
export function readFileDigestBlock(session, { cwd } = {}) {
|
|
360
|
+
const msgs = Array.isArray(session.messages) ? session.messages : [];
|
|
361
|
+
const calls = toolCallIndex(msgs);
|
|
362
|
+
const files = new Map(); // path → { lines, atIndex, compacted }
|
|
363
|
+
msgs.forEach((m, i) => {
|
|
364
|
+
if (m?.role !== 'tool') return;
|
|
365
|
+
const c = calls.get(m.tool_call_id);
|
|
366
|
+
if (!c || c.name !== 'read_file') return;
|
|
367
|
+
const p = pathFromArgs(c.args);
|
|
368
|
+
if (!p) return;
|
|
369
|
+
files.set(p, {
|
|
370
|
+
lines: (typeof m.content === 'string' ? m.content : '').split('\n').length,
|
|
371
|
+
atIndex: i,
|
|
372
|
+
compacted: isCompacted(m),
|
|
373
|
+
});
|
|
374
|
+
});
|
|
375
|
+
if (!files.size) return '';
|
|
376
|
+
|
|
377
|
+
const snap = session.readFiles && typeof session.readFiles === 'object' ? session.readFiles : (session.readFiles = {});
|
|
378
|
+
const base = cwd || session.workingDir || '';
|
|
379
|
+
const rows = [];
|
|
380
|
+
for (const p of [...files.keys()].sort()) {
|
|
381
|
+
const cur = files.get(p);
|
|
382
|
+
const prev = snap[p];
|
|
383
|
+
const abs = path.isAbsolute(p) ? p : path.join(base, p);
|
|
384
|
+
// 正文被压过时行数只能沿用当初记下的那份:压缩后那一行数出来永远是「1 行」
|
|
385
|
+
const lines = cur.compacted && prev ? prev.lines : cur.lines;
|
|
386
|
+
let note;
|
|
387
|
+
try {
|
|
388
|
+
const st = statSync(abs);
|
|
389
|
+
if (!prev || cur.atIndex > prev.atIndex || !prev.mtimeMs) {
|
|
390
|
+
// 三种情况都以「当前」为准,且不报警:
|
|
391
|
+
// · 第一次读到它;
|
|
392
|
+
// · 模型自己又读了一遍(下标变大);
|
|
393
|
+
// · 快照是压缩时留下的,当时没记 mtime(mtimeMs = 0),拿现在这份补上
|
|
394
|
+
snap[p] = { lines, mtimeMs: st.mtimeMs, atIndex: cur.atIndex };
|
|
395
|
+
note = `${lines} 行`;
|
|
396
|
+
} else if (st.mtimeMs !== prev.mtimeMs) {
|
|
397
|
+
note = `${lines} 行,已被外部修改(现在 ${new Date(st.mtimeMs).toISOString().slice(0, 16).replace('T', ' ')},请重新读取)`;
|
|
398
|
+
} else {
|
|
399
|
+
note = `${lines} 行`;
|
|
400
|
+
}
|
|
401
|
+
} catch {
|
|
402
|
+
note = `${lines} 行,文件已不存在`;
|
|
403
|
+
}
|
|
404
|
+
// 压过的条目必须说清楚 —— 否则清单在上面说「不必重复读取」,
|
|
405
|
+
// 而它的正文其实已经被压缩掉了,模型会拿一段不存在的记忆当依据
|
|
406
|
+
if (cur.compacted) note += ',正文已压缩,需要时请重新读取';
|
|
407
|
+
rows.push(`- ${p}(${note})`);
|
|
408
|
+
}
|
|
409
|
+
if (!rows.length) return '';
|
|
410
|
+
|
|
411
|
+
const shown = rows.slice(-DIGEST_MAX_FILES);
|
|
412
|
+
let text =
|
|
413
|
+
'【已读文件清单】下面这些文件已经读过,正文在上下文里,**不必重复读取**(标注「已压缩」的除外):\n' +
|
|
414
|
+
shown.join('\n') +
|
|
415
|
+
(rows.length > shown.length ? `\n(另有 ${rows.length - shown.length} 个更早的文件未列出)` : '');
|
|
416
|
+
if (text.length > DIGEST_MAX_CHARS) text = `${text.slice(0, DIGEST_MAX_CHARS)}\n…(清单过长已截断)`;
|
|
417
|
+
return text;
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/**
|
|
421
|
+
* 把要发出去的消息组装好:system(+迁移说明+已读文件清单)永远在最前。
|
|
422
|
+
*
|
|
423
|
+
* 会就地更新 `session.messages[0]`,于是「会话文件里存的」与「实际发出去的」始终一致,
|
|
424
|
+
* 下一轮也就不会因为前缀漂移而废掉 prompt 缓存;而 `session.system` 保持干净,
|
|
425
|
+
* 用来判断用户的系统提示词到底变没变。
|
|
426
|
+
*/
|
|
427
|
+
export function buildOutgoingMessages(session, { cwd, readFileDigest = HISTORY_READ_FILE_DIGEST } = {}) {
|
|
428
|
+
const parts = [];
|
|
429
|
+
if (typeof session.system === 'string' && session.system.trim()) parts.push(session.system);
|
|
430
|
+
if (typeof session.migrationNote === 'string' && session.migrationNote.trim()) parts.push(session.migrationNote);
|
|
431
|
+
if (readFileDigest) {
|
|
432
|
+
const digest = readFileDigestBlock(session, { cwd });
|
|
433
|
+
if (digest) parts.push(digest);
|
|
434
|
+
}
|
|
435
|
+
const content = parts.join('\n\n');
|
|
436
|
+
const msgs = Array.isArray(session.messages) ? session.messages : (session.messages = []);
|
|
437
|
+
if (msgs[0]?.role === 'system') msgs[0] = { role: 'system', content };
|
|
438
|
+
else msgs.unshift({ role: 'system', content });
|
|
439
|
+
return msgs;
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
/**
|
|
443
|
+
* 每轮开跑前的统一准备:先按预算压缩,再拼出要发出去的消息。
|
|
444
|
+
* 让调用方只记一个入口,免得出现「压了没拼」或「拼了没压」的半套状态。
|
|
445
|
+
*/
|
|
446
|
+
export function prepareTaskSession(session, { limits = {}, cwd, readFileDigest = HISTORY_READ_FILE_DIGEST } = {}) {
|
|
447
|
+
const compaction = compactHistory(session, limits);
|
|
448
|
+
buildOutgoingMessages(session, { cwd: cwd || session.workingDir, readFileDigest });
|
|
449
|
+
return compaction;
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
/* ---------- 旧数据懒迁移 ---------- */
|
|
453
|
+
|
|
454
|
+
/** 把一条渲染态的 step 渲染成文本记录(方案 B 形态,仅用于迁移) */
|
|
455
|
+
function renderStepBlock(steps) {
|
|
456
|
+
const lines = [];
|
|
457
|
+
for (const s of Array.isArray(steps) ? steps : []) {
|
|
458
|
+
if (!s || typeof s !== 'object') continue;
|
|
459
|
+
const name = typeof s.label === 'string' && s.label ? s.label : typeof s.name === 'string' ? s.name : '工具调用';
|
|
460
|
+
const mark = s.status === 'ok' ? '✓' : s.status === 'fail' ? '✗' : '·';
|
|
461
|
+
const brief = typeof s.summary === 'string' && s.summary ? ` ${s.summary}` : '';
|
|
462
|
+
lines.push(`- ${mark} ${name}${brief}`);
|
|
463
|
+
if (typeof s.content === 'string' && s.content) lines.push(clip(s.content, MIGRATE_STEP_CHARS));
|
|
464
|
+
}
|
|
465
|
+
if (!lines.length) return '';
|
|
466
|
+
return `${STEP_BLOCK_HEAD}\n${lines.join('\n')}\n${STEP_BLOCK_TAIL}`;
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* 一条渲染态 step 能不能还原成原生 tool_call。
|
|
471
|
+
*
|
|
472
|
+
* `id` 就是模型给的 tool_call_id,`args` 是当时那份原始 JSON 参数串
|
|
473
|
+
* (lib/taskstore.js 的 sanitizeStep 会把它一起存下来)。三个都要有才敢还原,
|
|
474
|
+
* 缺一个就只能退回「折成文本」。
|
|
475
|
+
*/
|
|
476
|
+
function nativeStep(s) {
|
|
477
|
+
if (!s || typeof s !== 'object') return null;
|
|
478
|
+
const id = typeof s.id === 'string' ? s.id.trim() : '';
|
|
479
|
+
const name = typeof s.name === 'string' ? s.name.trim() : '';
|
|
480
|
+
const args = typeof s.args === 'string' ? s.args.trim() : '';
|
|
481
|
+
if (!id || !name || !args) return null;
|
|
482
|
+
return { id, name, args, content: typeof s.content === 'string' ? s.content : '' };
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
/**
|
|
486
|
+
* 把渲染态消息反推成模型态消息。
|
|
487
|
+
*
|
|
488
|
+
* 两条路:
|
|
489
|
+
* 1. **能还原成原生 tool_calls 就还原** —— 记录里 `id`/`args` 齐全时(新版本存过的任务),
|
|
490
|
+
* 重建出来的对话与当时发给模型的一致,含调用参数;
|
|
491
|
+
* 2. 只有 name / label / summary / content 时(更老的记录),把工具记录折成文本块附在
|
|
492
|
+
* assistant 正文后面。这是**迁移产物**,不是长期方案 —— 新产生的数据不再走这条路。
|
|
493
|
+
*
|
|
494
|
+
* 但「折成文本」也远好于丢掉:关键是模型能重新看到读过的文件内容,
|
|
495
|
+
* 而不是第二轮又去 `grep` 一遍。
|
|
496
|
+
*/
|
|
497
|
+
export function migrateFromRenderState(renderMessages, { system } = {}) {
|
|
498
|
+
const out = [{ role: 'system', content: typeof system === 'string' ? system : '' }];
|
|
499
|
+
let degraded = 0;
|
|
500
|
+
let restored = 0;
|
|
501
|
+
let skipped = 0;
|
|
502
|
+
let note = '';
|
|
503
|
+
|
|
504
|
+
for (const m of Array.isArray(renderMessages) ? renderMessages : []) {
|
|
505
|
+
if (!m || typeof m !== 'object') continue;
|
|
506
|
+
const text = typeof m.content === 'string' ? m.content : '';
|
|
507
|
+
if (m.role === 'user') {
|
|
508
|
+
if (text.trim()) out.push({ role: 'user', content: text });
|
|
509
|
+
continue;
|
|
510
|
+
}
|
|
511
|
+
if (m.role !== 'assistant') continue;
|
|
512
|
+
|
|
513
|
+
const steps = Array.isArray(m.steps) ? m.steps : [];
|
|
514
|
+
const natives = steps.map(nativeStep);
|
|
515
|
+
if (natives.length && natives.every(Boolean)) {
|
|
516
|
+
out.push({
|
|
517
|
+
role: 'assistant',
|
|
518
|
+
content: text,
|
|
519
|
+
tool_calls: natives.map((n) => ({ id: n.id, type: 'function', function: { name: n.name, arguments: n.args } })),
|
|
520
|
+
});
|
|
521
|
+
for (const n of natives) out.push({ role: 'tool', tool_call_id: n.id, content: clip(n.content, MIGRATE_STEP_CHARS) });
|
|
522
|
+
restored++;
|
|
523
|
+
continue;
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
const block = renderStepBlock(steps);
|
|
527
|
+
let content = text;
|
|
528
|
+
if (block) {
|
|
529
|
+
content = content.trim() ? `${content}\n\n${block}` : block;
|
|
530
|
+
degraded++;
|
|
531
|
+
}
|
|
532
|
+
// 纯工具轮(正文为空)也不能整条丢 —— 那正是原来「每次都要重新探索」的成因之一
|
|
533
|
+
if (!content.trim()) {
|
|
534
|
+
skipped++;
|
|
535
|
+
continue;
|
|
536
|
+
}
|
|
537
|
+
out.push({ role: 'assistant', content });
|
|
538
|
+
}
|
|
539
|
+
|
|
540
|
+
if (degraded) {
|
|
541
|
+
// 说明单独返回,由调用方存进 session.migrationNote 并拼到 system 后面。
|
|
542
|
+
// 揉进 out[0] 是不行的:下一轮 refreshSessionContext 重建 system 时会把它冲掉。
|
|
543
|
+
note =
|
|
544
|
+
'【历史迁移说明】\n上面的对话里有一部分工具结果由旧版本迁移而来,' +
|
|
545
|
+
'其调用参数已丢失、只保留了结果正文。它们反映的是**当时**的文件内容;' +
|
|
546
|
+
'如果你需要确认某个文件现在的内容,请重新读取,不要在没读的情况下假设它没变。';
|
|
547
|
+
}
|
|
548
|
+
return { messages: out, degraded, restored, skipped, migrated: degraded ? 'partial' : out.length > 1 ? 'native' : 'none', note };
|
|
549
|
+
}
|
|
550
|
+
|
|
551
|
+
/**
|
|
552
|
+
* 载入会话;没有就按需创建。
|
|
553
|
+
*
|
|
554
|
+
* `renderTask` 传一个「取渲染态任务」的函数(通常 `() => store.get(taskId)`):
|
|
555
|
+
* 存在却没会话文件时,说明这条任务是 C 之前建的 —— 现场懒迁移一次并落盘。
|
|
556
|
+
* 懒迁移而不是启动时批量迁移:任务有 TTL、上限 20 条,用户可能永远不再打开其中一些,
|
|
557
|
+
* 批量读全部文件既慢又可能留下半成品。
|
|
558
|
+
*/
|
|
559
|
+
export function loadOrCreateTaskSession({ sessions, taskId, workDir, model, mode, system, renderTask, limits = {} }) {
|
|
560
|
+
const existing = sessions.read(taskId);
|
|
561
|
+
if (existing) {
|
|
562
|
+
refreshSessionContext(existing, { workDir, model, mode, system });
|
|
563
|
+
// 会话文件来自更早的版本时补齐新增字段
|
|
564
|
+
if (!existing.history || typeof existing.history !== 'object') {
|
|
565
|
+
existing.history = { maxChars: HISTORY_MAX_CHARS, keepRecentTurns: HISTORY_KEEP_RECENT_TURNS, compactedAt: null, compactedTurns: 0 };
|
|
566
|
+
}
|
|
567
|
+
if (!existing.migrated) existing.migrated = 'none';
|
|
568
|
+
if (!existing.readFiles || typeof existing.readFiles !== 'object') existing.readFiles = {};
|
|
569
|
+
if (typeof existing.system !== 'string') existing.system = deriveBaseSystem(existing);
|
|
570
|
+
return { session: existing, created: false };
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
const fresh = createTaskSession({ taskId, workDir, model, mode, system, limits });
|
|
574
|
+
let task = null;
|
|
575
|
+
try {
|
|
576
|
+
task = typeof renderTask === 'function' ? renderTask() : null;
|
|
577
|
+
} catch {
|
|
578
|
+
task = null; // 渲染态读不出来不该阻塞这一轮
|
|
579
|
+
}
|
|
580
|
+
|
|
581
|
+
if (task && Array.isArray(task.messages) && task.messages.length) {
|
|
582
|
+
const built = migrateFromRenderState(task.messages, { system });
|
|
583
|
+
fresh.migrationNote = built.note;
|
|
584
|
+
fresh.messages = built.messages;
|
|
585
|
+
fresh.migrated = built.migrated;
|
|
586
|
+
fresh.degradedTurns = built.degraded;
|
|
587
|
+
// system 存干净的那份,迁移说明交给 buildOutgoingMessages 每轮现拼
|
|
588
|
+
fresh.messages[0] = { role: 'system', content: fresh.system };
|
|
589
|
+
}
|
|
590
|
+
return { session: fresh, created: true };
|
|
591
|
+
}
|
|
592
|
+
|
|
593
|
+
/** 任务会话的过期时间与任务一致(滑动 15 天) */
|
|
594
|
+
export const TASK_SESSION_TTL_MS = TASK_TTL_MS;
|