c0de-agent 1.0.0 → 1.1.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/cli/deps.d.ts +16 -5
- package/dist/cli/deps.js +18 -5
- package/dist/cli/index.js +8 -2
- package/dist/core/agent.js +5 -0
- package/dist/core/config.js +1 -0
- package/dist/core/loop.d.ts +10 -0
- package/dist/core/loop.js +559 -295
- package/dist/core/slash.js +3 -4
- package/dist/core/title.js +3 -2
- package/dist/core/types.d.ts +2 -0
- package/dist/core/worktree.js +6 -4
- package/dist/dap/session.js +3 -3
- package/dist/llm/provider.js +5 -1
- package/dist/plugins/loader.js +3 -2
- package/dist/server/agent-manager.d.ts +2 -0
- package/dist/server/agent-manager.js +8 -0
- package/dist/server/app.js +3 -0
- package/dist/server/context.js +2 -0
- package/dist/server/dev.d.ts +4 -1
- package/dist/server/dev.js +92 -27
- package/dist/server/permission/store.d.ts +2 -0
- package/dist/server/permission/store.js +9 -0
- package/dist/server/routes/chat.js +37 -1
- package/dist/server/routes/session.js +75 -1
- package/dist/server/routes/terminal.d.ts +5 -0
- package/dist/server/routes/terminal.js +66 -0
- package/dist/server/server.d.ts +14 -1
- package/dist/server/server.js +100 -28
- package/dist/server/terminal/pty-manager.d.ts +53 -0
- package/dist/server/terminal/pty-manager.js +160 -0
- package/dist/server/types.d.ts +3 -0
- package/dist/session/archive.d.ts +1 -1
- package/dist/session/compaction.d.ts +8 -2
- package/dist/session/compaction.js +85 -16
- package/dist/session/shake.d.ts +66 -0
- package/dist/session/shake.js +304 -0
- package/dist/session/types.d.ts +1 -1
- package/dist/shared/types/agent.d.ts +27 -0
- package/dist/shared/types/config.d.ts +10 -0
- package/dist/shared/types/llm.d.ts +1 -0
- package/dist/shared/types/tool.d.ts +3 -0
- package/package.json +11 -3
package/dist/server/server.js
CHANGED
|
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync } from 'node:fs';
|
|
|
3
3
|
import { connect as tcpConnect } from 'node:net';
|
|
4
4
|
import { join } from 'node:path';
|
|
5
5
|
import { serve } from '@hono/node-server';
|
|
6
|
+
import { WebSocketServer } from 'ws';
|
|
6
7
|
import { BUILTIN_AGENTS, createAgentRegistry } from '../core/agents/index.js';
|
|
7
8
|
import { loadConfig } from '../core/config.js';
|
|
8
9
|
import { decryptSecret } from '../core/secret.js';
|
|
@@ -12,6 +13,7 @@ import { initPlugins } from '../plugins/index.js';
|
|
|
12
13
|
import { createDefaultRegistry, createDefaultURLRegistry } from '../tools/index.js';
|
|
13
14
|
import { checkForUpdate, createHandoffServer, createUpdateScheduler, requestHandoff, restoreSessions, } from '../update/index.js';
|
|
14
15
|
import { createAgentManager } from './agent-manager.js';
|
|
16
|
+
import { PTYManager } from './terminal/pty-manager.js';
|
|
15
17
|
import { createApp } from './app.js';
|
|
16
18
|
import { createPermissionStore } from './permission/store.js';
|
|
17
19
|
/** 把 config.providers 注册到新建的 LLM registry(修复此前空 registry 的遗漏)。 */
|
|
@@ -54,33 +56,15 @@ function resolveDbDir(cwd) {
|
|
|
54
56
|
return envDir;
|
|
55
57
|
return join(cwd, '.c0de', 'pglite');
|
|
56
58
|
}
|
|
57
|
-
/**
|
|
58
|
-
|
|
59
|
+
/**
|
|
60
|
+
* 围绕已有 DB handle 组装 ServerContext(不建/不关闭 DB)。
|
|
61
|
+
*
|
|
62
|
+
* dev 热重载重建复用此函数:PGLite 单写者约束下 DB handle 必须跨重载存活,
|
|
63
|
+
* 但 ctx 其余资源(agentManager/permissionStore/registries/plugins)全部重建为新实例。
|
|
64
|
+
* 返回的 dispose 清理 ctx 资源但**不 close db**——db 由调用方持有。
|
|
65
|
+
*/
|
|
66
|
+
async function buildServerContext(db, opts = {}) {
|
|
59
67
|
const cwd = opts.cwd ?? process.cwd();
|
|
60
|
-
const ownsDb = !opts.db;
|
|
61
|
-
// 持久化 PGLite 数据:默认 <cwd>/.c0de/pglite(与 .c0de/cache、.c0de/config.json 同约定),
|
|
62
|
-
// 可用 C0DE_DB_DIR 覆盖。此前默认 in-memory,进程重启即丢全部会话/消息/调用详情。
|
|
63
|
-
// 测试注入 opts.db 时跳过(保持 in-memory 隔离)。
|
|
64
|
-
let db;
|
|
65
|
-
if (opts.db) {
|
|
66
|
-
db = opts.db;
|
|
67
|
-
}
|
|
68
|
-
else {
|
|
69
|
-
const dataDir = resolveDbDir(cwd);
|
|
70
|
-
if (!existsSync(dataDir))
|
|
71
|
-
mkdirSync(dataDir, { recursive: true });
|
|
72
|
-
db = await createDB({ driver: 'pglite', dataDir });
|
|
73
|
-
}
|
|
74
|
-
// migrateDB 失败时必须 close db,否则 PGLite WASM 实例泄漏并锁住 dataDir,
|
|
75
|
-
// 导致后续重试全部 abort(RuntimeError: Aborted())。
|
|
76
|
-
try {
|
|
77
|
-
await migrateDB(db);
|
|
78
|
-
}
|
|
79
|
-
catch (err) {
|
|
80
|
-
if (ownsDb)
|
|
81
|
-
await db.close().catch(() => { });
|
|
82
|
-
throw err;
|
|
83
|
-
}
|
|
84
68
|
if (opts.restoreFrom) {
|
|
85
69
|
const snapshot = JSON.parse(readFileSync(opts.restoreFrom, 'utf8'));
|
|
86
70
|
await restoreSessions(db, snapshot);
|
|
@@ -120,6 +104,7 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
120
104
|
initialDelayMs: config.update.initialDelayMs,
|
|
121
105
|
}),
|
|
122
106
|
cwd,
|
|
107
|
+
ptyManager: new PTYManager(),
|
|
123
108
|
};
|
|
124
109
|
// spec §18.3 handoff server:旧实例监听随机端口,收到 POST /handoff 时
|
|
125
110
|
// 序列化当前会话状态 + 优雅关闭,让新实例接管。config.update.enabled=false
|
|
@@ -136,10 +121,63 @@ async function bootstrapServerContext(opts = {}) {
|
|
|
136
121
|
}
|
|
137
122
|
return {
|
|
138
123
|
ctx,
|
|
139
|
-
|
|
124
|
+
dispose: async () => {
|
|
125
|
+
// dev 重建前调用:中止活跃 run + settle pending permission +
|
|
126
|
+
// 停 scheduler + 关 handoff。**不 close db**(调用方持有)。
|
|
127
|
+
ctx.agentManager.dispose();
|
|
128
|
+
ctx.permissionStore.dispose();
|
|
140
129
|
ctx.updateScheduler.stop();
|
|
130
|
+
ctx.ptyManager.dispose();
|
|
141
131
|
if (handoffServer)
|
|
142
132
|
await handoffServer.close();
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
/** dev 专用:创建 + migrate PGLite,跨热重载复用(单写者,只建一次)。 */
|
|
137
|
+
async function createDevDb(cwd) {
|
|
138
|
+
const dataDir = resolveDbDir(cwd);
|
|
139
|
+
if (!existsSync(dataDir))
|
|
140
|
+
mkdirSync(dataDir, { recursive: true });
|
|
141
|
+
const db = await createDB({ driver: 'pglite', dataDir });
|
|
142
|
+
// migrateDB 失败必须 close,否则 WASM 实例泄漏锁住 dataDir。
|
|
143
|
+
try {
|
|
144
|
+
await migrateDB(db);
|
|
145
|
+
}
|
|
146
|
+
catch (err) {
|
|
147
|
+
await db.close().catch(() => { });
|
|
148
|
+
throw err;
|
|
149
|
+
}
|
|
150
|
+
return db;
|
|
151
|
+
}
|
|
152
|
+
/** 初始化 DB + 配置 + 注册表,返回 ServerContext + 清理函数(dev 与独立后端共用)。 */
|
|
153
|
+
async function bootstrapServerContext(opts = {}) {
|
|
154
|
+
const cwd = opts.cwd ?? process.cwd();
|
|
155
|
+
const ownsDb = !opts.db;
|
|
156
|
+
// 持久化 PGLite 数据:默认 <cwd>/.c0de/pglite(与 .c0de/cache、.c0de/config.json 同约定),
|
|
157
|
+
// 可用 C0DE_DB_DIR 覆盖。此前默认 in-memory,进程重启即丢全部会话/消息/调用详情。
|
|
158
|
+
// 测试注入 opts.db 时跳过(保持 in-memory 隔离)。
|
|
159
|
+
let db;
|
|
160
|
+
if (opts.db) {
|
|
161
|
+
db = opts.db;
|
|
162
|
+
// 注入 db 仍需 migrate(测试可能注入全新 in-memory db)。
|
|
163
|
+
// migrateDB 失败时若 ownsDb 才 close(注入的由调用方管)。
|
|
164
|
+
try {
|
|
165
|
+
await migrateDB(db);
|
|
166
|
+
}
|
|
167
|
+
catch (err) {
|
|
168
|
+
if (ownsDb)
|
|
169
|
+
await db.close().catch(() => { });
|
|
170
|
+
throw err;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
else {
|
|
174
|
+
db = await createDevDb(cwd);
|
|
175
|
+
}
|
|
176
|
+
const { ctx, dispose } = await buildServerContext(db, opts);
|
|
177
|
+
return {
|
|
178
|
+
ctx,
|
|
179
|
+
close: async () => {
|
|
180
|
+
await dispose();
|
|
143
181
|
if (ownsDb)
|
|
144
182
|
await db.close();
|
|
145
183
|
},
|
|
@@ -184,14 +222,48 @@ async function startServer(opts = {}) {
|
|
|
184
222
|
if (ctx.config.update.enabled)
|
|
185
223
|
ctx.updateScheduler.start();
|
|
186
224
|
const server = serve({ fetch: app.fetch, port });
|
|
225
|
+
// WebSocket:终端双向流。Hono v2 无原生 WS,用 ws 包直接挂载到 HTTP server。
|
|
226
|
+
// 匹配 /api/terminal/:id/ws → ptyManager.attachWebSocket
|
|
227
|
+
const wss = new WebSocketServer({ noServer: true });
|
|
228
|
+
const expectedToken = ctx.config.security.authEnabled ? ctx.config.security.token : undefined;
|
|
229
|
+
server.on('upgrade', (req, socket, head) => {
|
|
230
|
+
const url = new URL(req.url ?? '', `http://${req.headers.host ?? 'localhost'}`);
|
|
231
|
+
const match = url.pathname.match(/^\/api\/terminal\/([^/]+)\/ws$/);
|
|
232
|
+
if (!match) {
|
|
233
|
+
socket.destroy();
|
|
234
|
+
return;
|
|
235
|
+
}
|
|
236
|
+
// 认证:token 通过 query 参数传递(浏览器 WS 无法设置 Authorization header)
|
|
237
|
+
if (expectedToken) {
|
|
238
|
+
const token = url.searchParams.get('token');
|
|
239
|
+
if (token !== expectedToken) {
|
|
240
|
+
socket.write('HTTP/1.1 401 Unauthorized\r\n\r\n');
|
|
241
|
+
socket.destroy();
|
|
242
|
+
return;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
const ptyId = match[1];
|
|
246
|
+
if (!ptyId) {
|
|
247
|
+
socket.destroy();
|
|
248
|
+
return;
|
|
249
|
+
}
|
|
250
|
+
wss.handleUpgrade(req, socket, head, (ws) => {
|
|
251
|
+
const attached = ctx.ptyManager.attachWebSocket(ptyId, ws);
|
|
252
|
+
if (!attached) {
|
|
253
|
+
ws.send(JSON.stringify({ type: 'error', message: 'Terminal not found' }));
|
|
254
|
+
ws.close(1008, 'Terminal not found');
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
});
|
|
187
258
|
let closed = false;
|
|
188
259
|
const close = async () => {
|
|
189
260
|
if (closed)
|
|
190
261
|
return;
|
|
191
262
|
closed = true;
|
|
263
|
+
wss.close();
|
|
192
264
|
server.close();
|
|
193
265
|
await closeCtx();
|
|
194
266
|
};
|
|
195
267
|
return { app, port, close };
|
|
196
268
|
}
|
|
197
|
-
export { bootstrapServerContext, buildRegistryFromConfig, startServer, syncRegistryFromConfig };
|
|
269
|
+
export { bootstrapServerContext, buildRegistryFromConfig, buildServerContext, createDevDb, startServer, syncRegistryFromConfig, };
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { WebSocket } from 'ws';
|
|
2
|
+
/** PTY 会话信息(返回给前端)。 */
|
|
3
|
+
export interface PTYInfo {
|
|
4
|
+
id: string;
|
|
5
|
+
pid: number;
|
|
6
|
+
title: string;
|
|
7
|
+
cols: number;
|
|
8
|
+
rows: number;
|
|
9
|
+
cwd: string;
|
|
10
|
+
/** shell 程序路径。 */
|
|
11
|
+
shell: string;
|
|
12
|
+
}
|
|
13
|
+
export interface CreatePTYOptions {
|
|
14
|
+
cwd: string;
|
|
15
|
+
cols?: number;
|
|
16
|
+
rows?: number;
|
|
17
|
+
title?: string;
|
|
18
|
+
/** 覆盖默认 shell;不传则自动检测。 */
|
|
19
|
+
shell?: string;
|
|
20
|
+
}
|
|
21
|
+
/**
|
|
22
|
+
* PTY 生命周期管理器。
|
|
23
|
+
*
|
|
24
|
+
* 负责 spawn / write / resize / kill 伪终端进程,并将 PTY 输出
|
|
25
|
+
* 通过 WebSocket 推送到前端。一个 PTY 可同时挂多个 WebSocket
|
|
26
|
+
* (多标签共享同一终端),任一 WS 断开不影响进程。
|
|
27
|
+
*/
|
|
28
|
+
export declare class PTYManager {
|
|
29
|
+
private entries;
|
|
30
|
+
/** 创建新 PTY 会话。 */
|
|
31
|
+
create(opts: CreatePTYOptions): PTYInfo;
|
|
32
|
+
/** 向 PTY stdin 写入数据。 */
|
|
33
|
+
write(id: string, data: string): void;
|
|
34
|
+
/** 调整 PTY 尺寸。 */
|
|
35
|
+
resize(id: string, cols: number, rows: number): void;
|
|
36
|
+
/** 更新 PTY 标题。 */
|
|
37
|
+
setTitle(id: string, title: string): void;
|
|
38
|
+
/** 终止 PTY 进程。 */
|
|
39
|
+
kill(id: string): void;
|
|
40
|
+
/** 获取 PTY 信息。 */
|
|
41
|
+
get(id: string): PTYInfo | undefined;
|
|
42
|
+
/** 列出所有活跃 PTY。 */
|
|
43
|
+
list(): PTYInfo[];
|
|
44
|
+
/**
|
|
45
|
+
* 将 WebSocket 挂载到 PTY,建立双向数据流:
|
|
46
|
+
* - PTY onData → WS send(终端输出)
|
|
47
|
+
* - WS onMessage → PTY write(用户输入)
|
|
48
|
+
* - WS onClose → 摘除连接(PTY 保持存活)
|
|
49
|
+
*/
|
|
50
|
+
attachWebSocket(id: string, ws: WebSocket): boolean;
|
|
51
|
+
/** 终止所有 PTY 进程(服务器关闭时调用)。 */
|
|
52
|
+
dispose(): void;
|
|
53
|
+
}
|
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
// src/server/terminal/pty-manager.ts
|
|
2
|
+
import { spawn } from 'node-pty';
|
|
3
|
+
import { randomUUID } from 'node:crypto';
|
|
4
|
+
/** 检测当前平台默认 shell。 */
|
|
5
|
+
function detectShell() {
|
|
6
|
+
if (process.platform === 'win32') {
|
|
7
|
+
return process.env.COMSPEC ?? 'cmd.exe';
|
|
8
|
+
}
|
|
9
|
+
return process.env.SHELL ?? '/bin/bash';
|
|
10
|
+
}
|
|
11
|
+
const DEFAULT_COLS = 80;
|
|
12
|
+
const DEFAULT_ROWS = 24;
|
|
13
|
+
const MAX_TITLE_LEN = 100;
|
|
14
|
+
function truncateTitle(title) {
|
|
15
|
+
const clean = title.replace(/[\r\n]/g, ' ').trim();
|
|
16
|
+
return clean.length > MAX_TITLE_LEN ? `${clean.slice(0, MAX_TITLE_LEN)}…` : clean;
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* PTY 生命周期管理器。
|
|
20
|
+
*
|
|
21
|
+
* 负责 spawn / write / resize / kill 伪终端进程,并将 PTY 输出
|
|
22
|
+
* 通过 WebSocket 推送到前端。一个 PTY 可同时挂多个 WebSocket
|
|
23
|
+
* (多标签共享同一终端),任一 WS 断开不影响进程。
|
|
24
|
+
*/
|
|
25
|
+
export class PTYManager {
|
|
26
|
+
entries = new Map();
|
|
27
|
+
/** 创建新 PTY 会话。 */
|
|
28
|
+
create(opts) {
|
|
29
|
+
const id = `pty_${randomUUID()}`;
|
|
30
|
+
const cols = opts.cols ?? DEFAULT_COLS;
|
|
31
|
+
const rows = opts.rows ?? DEFAULT_ROWS;
|
|
32
|
+
const shell = opts.shell ?? detectShell();
|
|
33
|
+
const pty = spawn(shell, [], {
|
|
34
|
+
name: 'xterm-256color',
|
|
35
|
+
cols,
|
|
36
|
+
rows,
|
|
37
|
+
cwd: opts.cwd,
|
|
38
|
+
env: {
|
|
39
|
+
...process.env,
|
|
40
|
+
TERM: 'xterm-256color',
|
|
41
|
+
COLORTERM: 'truecolor',
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
const info = {
|
|
45
|
+
id,
|
|
46
|
+
pid: pty.pid,
|
|
47
|
+
title: truncateTitle(opts.title ?? shell),
|
|
48
|
+
cols,
|
|
49
|
+
rows,
|
|
50
|
+
cwd: opts.cwd,
|
|
51
|
+
shell,
|
|
52
|
+
};
|
|
53
|
+
const entry = { pty, info, sockets: new Set() };
|
|
54
|
+
// PTY 输出 → 广播到所有挂载的 WebSocket
|
|
55
|
+
pty.onData((data) => {
|
|
56
|
+
for (const ws of entry.sockets) {
|
|
57
|
+
if (ws.readyState === ws.OPEN) {
|
|
58
|
+
ws.send(data);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
// PTY 退出 → 通知所有 WS 并清理
|
|
63
|
+
pty.onExit(({ exitCode }) => {
|
|
64
|
+
for (const ws of entry.sockets) {
|
|
65
|
+
if (ws.readyState === ws.OPEN) {
|
|
66
|
+
ws.send(JSON.stringify({ type: 'exit', exitCode }));
|
|
67
|
+
ws.close(1000, 'pty exited');
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
this.entries.delete(id);
|
|
71
|
+
});
|
|
72
|
+
this.entries.set(id, entry);
|
|
73
|
+
return info;
|
|
74
|
+
}
|
|
75
|
+
/** 向 PTY stdin 写入数据。 */
|
|
76
|
+
write(id, data) {
|
|
77
|
+
const entry = this.entries.get(id);
|
|
78
|
+
if (!entry)
|
|
79
|
+
throw new Error(`PTY not found: ${id}`);
|
|
80
|
+
entry.pty.write(data);
|
|
81
|
+
}
|
|
82
|
+
/** 调整 PTY 尺寸。 */
|
|
83
|
+
resize(id, cols, rows) {
|
|
84
|
+
const entry = this.entries.get(id);
|
|
85
|
+
if (!entry)
|
|
86
|
+
throw new Error(`PTY not found: ${id}`);
|
|
87
|
+
entry.pty.resize(Math.max(1, cols), Math.max(1, rows));
|
|
88
|
+
entry.info.cols = cols;
|
|
89
|
+
entry.info.rows = rows;
|
|
90
|
+
}
|
|
91
|
+
/** 更新 PTY 标题。 */
|
|
92
|
+
setTitle(id, title) {
|
|
93
|
+
const entry = this.entries.get(id);
|
|
94
|
+
if (!entry)
|
|
95
|
+
throw new Error(`PTY not found: ${id}`);
|
|
96
|
+
entry.info.title = truncateTitle(title);
|
|
97
|
+
}
|
|
98
|
+
/** 终止 PTY 进程。 */
|
|
99
|
+
kill(id) {
|
|
100
|
+
const entry = this.entries.get(id);
|
|
101
|
+
if (!entry)
|
|
102
|
+
return;
|
|
103
|
+
// 通知所有 WS
|
|
104
|
+
for (const ws of entry.sockets) {
|
|
105
|
+
if (ws.readyState === ws.OPEN) {
|
|
106
|
+
ws.send(JSON.stringify({ type: 'exit', exitCode: 0 }));
|
|
107
|
+
ws.close(1000, 'pty killed');
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
try {
|
|
111
|
+
entry.pty.kill();
|
|
112
|
+
}
|
|
113
|
+
catch {
|
|
114
|
+
// 进程可能已退出
|
|
115
|
+
}
|
|
116
|
+
this.entries.delete(id);
|
|
117
|
+
}
|
|
118
|
+
/** 获取 PTY 信息。 */
|
|
119
|
+
get(id) {
|
|
120
|
+
return this.entries.get(id)?.info;
|
|
121
|
+
}
|
|
122
|
+
/** 列出所有活跃 PTY。 */
|
|
123
|
+
list() {
|
|
124
|
+
return [...this.entries.values()].map((e) => ({ ...e.info }));
|
|
125
|
+
}
|
|
126
|
+
/**
|
|
127
|
+
* 将 WebSocket 挂载到 PTY,建立双向数据流:
|
|
128
|
+
* - PTY onData → WS send(终端输出)
|
|
129
|
+
* - WS onMessage → PTY write(用户输入)
|
|
130
|
+
* - WS onClose → 摘除连接(PTY 保持存活)
|
|
131
|
+
*/
|
|
132
|
+
attachWebSocket(id, ws) {
|
|
133
|
+
const entry = this.entries.get(id);
|
|
134
|
+
if (!entry)
|
|
135
|
+
return false;
|
|
136
|
+
entry.sockets.add(ws);
|
|
137
|
+
ws.on('message', (data) => {
|
|
138
|
+
const text = typeof data === 'string' ? data : data.toString('utf8');
|
|
139
|
+
try {
|
|
140
|
+
entry.pty.write(text);
|
|
141
|
+
}
|
|
142
|
+
catch {
|
|
143
|
+
// PTY 可能已退出
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
ws.on('close', () => {
|
|
147
|
+
entry.sockets.delete(ws);
|
|
148
|
+
});
|
|
149
|
+
ws.on('error', () => {
|
|
150
|
+
entry.sockets.delete(ws);
|
|
151
|
+
});
|
|
152
|
+
return true;
|
|
153
|
+
}
|
|
154
|
+
/** 终止所有 PTY 进程(服务器关闭时调用)。 */
|
|
155
|
+
dispose() {
|
|
156
|
+
for (const id of this.entries.keys()) {
|
|
157
|
+
this.kill(id);
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
}
|
package/dist/server/types.d.ts
CHANGED
|
@@ -8,6 +8,7 @@ import type { Config as SharedConfig } from '../shared/types/config.js';
|
|
|
8
8
|
import type { URLRegistry } from '../shared/types/tool.js';
|
|
9
9
|
import type { ToolRegistry } from '../tools/types.js';
|
|
10
10
|
import type { HandoffServer, UpdateScheduler } from '../update/index.js';
|
|
11
|
+
import type { PTYManager } from './terminal/pty-manager.js';
|
|
11
12
|
import type { AgentManager } from './agent-manager.js';
|
|
12
13
|
import type { PermissionStore } from './permission/store.js';
|
|
13
14
|
/** 持有所有服务依赖的不可变上下文(config 字段可变用于 PATCH 更新)。 */
|
|
@@ -37,6 +38,8 @@ type ServerContext = {
|
|
|
37
38
|
server: HandoffServer;
|
|
38
39
|
};
|
|
39
40
|
cwd: string;
|
|
41
|
+
/** 终端 PTY 管理器(Web 终端面板用)。 */
|
|
42
|
+
ptyManager: PTYManager;
|
|
40
43
|
/** 测试注入:覆盖 LLM chat stream。生产环境为 undefined。 */
|
|
41
44
|
chatStream?: typeof chatStreamFn;
|
|
42
45
|
};
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import type { DB } from '../db/client.js';
|
|
2
2
|
import type { ArchiveRef, CompactionArchive, SessionEntry } from './types.js';
|
|
3
3
|
/** Archive original entries before compaction/squash. Returns the archive id. */
|
|
4
|
-
declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash', summary: string, compactionId: string): Promise<string>;
|
|
4
|
+
declare function archiveOriginalEntries(handle: DB, sessionId: string, entries: SessionEntry[], archiveType: 'compaction' | 'squash' | 'shake', summary: string, compactionId: string): Promise<string>;
|
|
5
5
|
/** Get an archive by id. Returns null for invalid or non-existent ids. */
|
|
6
6
|
declare function getArchive(handle: DB, id: string): Promise<CompactionArchive | null>;
|
|
7
7
|
/** Get the original entries stored in an archive. */
|
|
@@ -9,8 +9,14 @@ import type { CompactionConfig, CompactionResult, HotFile, Summarizer } from './
|
|
|
9
9
|
declare function findSafeCutPoint(messages: Message[], preferredCut: number): number;
|
|
10
10
|
/** Extract frequently-accessed files (read ≥ 2 times) from message history. */
|
|
11
11
|
declare function extractHotFiles(messages: Message[]): HotFile[];
|
|
12
|
-
/**
|
|
13
|
-
|
|
12
|
+
/**
|
|
13
|
+
* Build the LLM summarization prompt for a set of messages.
|
|
14
|
+
*
|
|
15
|
+
* 当存在 previousSummary(上一次压缩生成的摘要)时,prompt 头部改为增量更新指令,
|
|
16
|
+
* 引导模型在已有摘要上叠加新事实、剔除过时信息,从而避免连续多次压缩导致的
|
|
17
|
+
* 信息逐次丢失。无 previousSummary 时退回原始的从零压缩指令。
|
|
18
|
+
*/
|
|
19
|
+
declare function buildCompactionPrompt(messages: Message[], previousSummary?: string): string;
|
|
14
20
|
/**
|
|
15
21
|
* Compact a session: summarize old messages, archive them, keep recent ones.
|
|
16
22
|
*
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { generateId } from '../shared/index.js';
|
|
2
2
|
import { archiveOriginalEntries } from './archive.js';
|
|
3
|
-
import { deleteEntriesByIds, getMessages, insertEntry } from './message.js';
|
|
3
|
+
import { deleteEntriesByIds, getEntries, getMessages, insertEntry } from './message.js';
|
|
4
4
|
import { upsertFileSnapshot } from './snapshot.js';
|
|
5
5
|
import { estimateMessageTokens, estimateTokens } from './token.js';
|
|
6
6
|
/**
|
|
@@ -51,30 +51,80 @@ function extractHotFiles(messages) {
|
|
|
51
51
|
}
|
|
52
52
|
return hot.sort((a, b) => b.accessCount - a.accessCount).slice(0, 10);
|
|
53
53
|
}
|
|
54
|
-
/**
|
|
55
|
-
|
|
54
|
+
/** 工具输出在压缩 prompt 中保留的最大字符数(参考 opencode)。 */
|
|
55
|
+
const TOOL_OUTPUT_MAX_CHARS = 2000;
|
|
56
|
+
/** 截断超长字符串:head 60% + "[truncated]" + tail 40%,而非硬切。 */
|
|
57
|
+
function truncateToolOutput(s) {
|
|
58
|
+
if (s.length <= TOOL_OUTPUT_MAX_CHARS)
|
|
59
|
+
return s;
|
|
60
|
+
const head = Math.floor(TOOL_OUTPUT_MAX_CHARS * 0.6);
|
|
61
|
+
const tail = TOOL_OUTPUT_MAX_CHARS - head;
|
|
62
|
+
return `${s.slice(0, head)}[truncated]${s.slice(-tail)}`;
|
|
63
|
+
}
|
|
64
|
+
/** 序列化单个 content part:text/thinking 保持原样,tool 输出超长时截断。 */
|
|
65
|
+
function serializePart(p) {
|
|
66
|
+
if (p._tag === 'text' || p._tag === 'thinking')
|
|
67
|
+
return p.text;
|
|
68
|
+
if (p._tag === 'tool_call') {
|
|
69
|
+
const input = truncateToolOutput(JSON.stringify(p.input));
|
|
70
|
+
return JSON.stringify({ _tag: p._tag, id: p.id, tool: p.tool, input });
|
|
71
|
+
}
|
|
72
|
+
if (p._tag === 'tool_result') {
|
|
73
|
+
const output = truncateToolOutput(JSON.stringify(p.output));
|
|
74
|
+
return JSON.stringify({ _tag: p._tag, id: p.id, tool: p.tool, output });
|
|
75
|
+
}
|
|
76
|
+
return JSON.stringify(p);
|
|
77
|
+
}
|
|
78
|
+
/**
|
|
79
|
+
* Build the LLM summarization prompt for a set of messages.
|
|
80
|
+
*
|
|
81
|
+
* 当存在 previousSummary(上一次压缩生成的摘要)时,prompt 头部改为增量更新指令,
|
|
82
|
+
* 引导模型在已有摘要上叠加新事实、剔除过时信息,从而避免连续多次压缩导致的
|
|
83
|
+
* 信息逐次丢失。无 previousSummary 时退回原始的从零压缩指令。
|
|
84
|
+
*/
|
|
85
|
+
function buildCompactionPrompt(messages, previousSummary) {
|
|
56
86
|
const history = messages
|
|
57
|
-
.map((m) => `[${m.role}] ${m.content.map((p) => (p
|
|
87
|
+
.map((m) => `[${m.role}] ${m.content.map((p) => serializePart(p)).join(' ')}`)
|
|
58
88
|
.join('\n');
|
|
59
|
-
|
|
89
|
+
const sections = `## Agenda
|
|
90
|
+
逐条列出对话中出现的议题/任务,按处理顺序排列。每条格式:
|
|
91
|
+
- **[议题标题]** — ✅已解决 / ⏳进行中 / 🔒阻塞 / 📋待办
|
|
92
|
+
- ✅/🔒 → 一行:最终结论或卡点
|
|
93
|
+
- ⏳/📋 → 完整保留:目标、约束、已尝试方向、相关文件路径、关键决策、待确认问题
|
|
60
94
|
|
|
61
95
|
## Goal
|
|
62
|
-
|
|
96
|
+
用户此次会话的总体目标(若 Agenda 已涵盖,写"见 Agenda")
|
|
63
97
|
|
|
64
|
-
##
|
|
65
|
-
|
|
98
|
+
## Constraints & Preferences
|
|
99
|
+
用户约束、偏好、规范要求(或"(none)")
|
|
66
100
|
|
|
67
|
-
## Decisions
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
## Next Steps
|
|
71
|
-
接下来要做什么
|
|
101
|
+
## Key Decisions
|
|
102
|
+
做出的关键决策及原因
|
|
72
103
|
|
|
73
104
|
## Critical Context
|
|
74
|
-
|
|
105
|
+
必须记住的技术事实(文件路径、变量名、命令、错误信息、未解决问题)
|
|
75
106
|
|
|
76
107
|
## Modified Files
|
|
77
|
-
|
|
108
|
+
修改过的文件路径及变更摘要
|
|
109
|
+
|
|
110
|
+
## Relevant Files
|
|
111
|
+
对任务重要的文件/目录路径及原因`;
|
|
112
|
+
const header = previousSummary
|
|
113
|
+
? `更新以下已有【议题驱动】摘要。重点:
|
|
114
|
+
- 已解决的议题:状态更新为✅并压缩为一行结论;
|
|
115
|
+
- 新增议题:补入 Agenda 并完整保留其描述与约束;
|
|
116
|
+
- 尚未解决的议题:保持其原有描述与约束不变,只叠加本轮新进展。
|
|
117
|
+
|
|
118
|
+
<previous-summary>
|
|
119
|
+
${previousSummary}
|
|
120
|
+
</previous-summary>`
|
|
121
|
+
: `将以下对话历史压缩为一份【议题驱动】的结构化摘要。
|
|
122
|
+
核心原则——非对称保留:已解决的议题只留一行结论;尚未解决/待办的议题
|
|
123
|
+
必须完整保留其描述、约束、已尝试方向、相关文件与待确认问题,它们是后续
|
|
124
|
+
工作的蓝图,绝不可被稀释。`;
|
|
125
|
+
return `${header}
|
|
126
|
+
|
|
127
|
+
${sections}
|
|
78
128
|
|
|
79
129
|
---
|
|
80
130
|
对话历史:
|
|
@@ -115,6 +165,22 @@ function findKeepRecentStart(messages, keepRecentTokens) {
|
|
|
115
165
|
// Always retain at least the most recent message.
|
|
116
166
|
return Math.min(start, messages.length - 1);
|
|
117
167
|
}
|
|
168
|
+
/**
|
|
169
|
+
* 查找 session 最新的 compaction 摘要,用于增量摘要(P0-2)。
|
|
170
|
+
*
|
|
171
|
+
* 从全部 entries 中筛选 tag='compaction' 的记录,取最后一条(时间序最晚)的
|
|
172
|
+
* summary。无历史摘要时返回 undefined,buildCompactionPrompt 据此退回从零压缩模式。
|
|
173
|
+
*/
|
|
174
|
+
async function findPreviousSummary(handle, sessionId) {
|
|
175
|
+
const entries = await getEntries(handle, sessionId);
|
|
176
|
+
for (let i = entries.length - 1; i >= 0; i--) {
|
|
177
|
+
const entry = entries[i];
|
|
178
|
+
if (entry && '_tag' in entry && entry._tag === 'compaction') {
|
|
179
|
+
return entry.summary;
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return undefined;
|
|
183
|
+
}
|
|
118
184
|
/**
|
|
119
185
|
* Compact a session: summarize old messages, archive them, keep recent ones.
|
|
120
186
|
*
|
|
@@ -144,7 +210,10 @@ async function compactSession(handle, sessionId, summarizer, config) {
|
|
|
144
210
|
if (compactMessages.length === 0) {
|
|
145
211
|
return { compacted: false, reason: 'nothing_to_compact' };
|
|
146
212
|
}
|
|
147
|
-
|
|
213
|
+
// 查询当前 session 最新的 compaction 摘要,作为增量更新的基线(P0-2)。
|
|
214
|
+
// 这样连续多次压缩时,新摘要会基于已有摘要叠加而非从零重建,避免早期信息逐次丢失。
|
|
215
|
+
const previousSummary = await findPreviousSummary(handle, sessionId);
|
|
216
|
+
const prompt = buildCompactionPrompt(compactMessages, previousSummary);
|
|
148
217
|
// Run the (slow, network) summarizer OUTSIDE the transaction so we don't
|
|
149
218
|
// hold a DB transaction open across an LLM call.
|
|
150
219
|
const summary = await summarizer(prompt);
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import type { Message } from '../shared/types/message.js';
|
|
2
|
+
export interface ShakeConfig {
|
|
3
|
+
/** 保护最近 N token 的上下文不被 shake。 */
|
|
4
|
+
protectTokens: number;
|
|
5
|
+
/** 总节省 token < minSavings 时不 shake(preview 路径用)。 */
|
|
6
|
+
minSavings: number;
|
|
7
|
+
/** fenced/XML block 的最小 token 阈值。 */
|
|
8
|
+
fenceMinTokens: number;
|
|
9
|
+
/** 受保护的工具名列表(其 tool_result 不被 shake)。 */
|
|
10
|
+
protectedTools: string[];
|
|
11
|
+
}
|
|
12
|
+
/** Auto-shake 默认配置:保护活跃尾部,保守阈值。 */
|
|
13
|
+
export declare const DEFAULT_SHAKE_CONFIG: ShakeConfig;
|
|
14
|
+
export type ShakeRegion = {
|
|
15
|
+
kind: 'toolResult';
|
|
16
|
+
id: string;
|
|
17
|
+
messageId: string;
|
|
18
|
+
messageIndex: number;
|
|
19
|
+
partIndex: number;
|
|
20
|
+
tokens: number;
|
|
21
|
+
originalText: string;
|
|
22
|
+
label: string;
|
|
23
|
+
/** tool_call_id(tool_result part.id),前端用于跨消息合并后匹配渲染块。 */
|
|
24
|
+
toolCallId: string;
|
|
25
|
+
} | {
|
|
26
|
+
kind: 'block';
|
|
27
|
+
id: string;
|
|
28
|
+
messageId: string;
|
|
29
|
+
messageIndex: number;
|
|
30
|
+
partIndex: number;
|
|
31
|
+
start: number;
|
|
32
|
+
end: number;
|
|
33
|
+
tokens: number;
|
|
34
|
+
originalText: string;
|
|
35
|
+
label: string;
|
|
36
|
+
};
|
|
37
|
+
/** API 返回给前端的区域视图。 */
|
|
38
|
+
export type ShakeRegionView = {
|
|
39
|
+
id: string;
|
|
40
|
+
kind: 'toolResult' | 'block';
|
|
41
|
+
messageId: string;
|
|
42
|
+
messageIndex: number;
|
|
43
|
+
partIndex: number;
|
|
44
|
+
tokens: number;
|
|
45
|
+
label: string;
|
|
46
|
+
preview: string;
|
|
47
|
+
placeholder: string;
|
|
48
|
+
isAfterProtectWindow: boolean;
|
|
49
|
+
/** tool_result 的 tool_call_id(仅 toolResult 类别),前端跨消息合并后匹配渲染块。 */
|
|
50
|
+
toolCallId?: string;
|
|
51
|
+
};
|
|
52
|
+
/**
|
|
53
|
+
* 定位 fenced 代码块和顶层 XML 元素 span。返回字符偏移 [start, end) 数组,
|
|
54
|
+
* 覆盖完整块(含围栏/标签行,不含尾换行)。围栏内抑制 XML 检测。
|
|
55
|
+
* 未闭合围栏/标签不产生 range(保守策略)。
|
|
56
|
+
*/
|
|
57
|
+
export declare function scanTextForBlockRanges(text: string): Array<{
|
|
58
|
+
start: number;
|
|
59
|
+
end: number;
|
|
60
|
+
}>;
|
|
61
|
+
/** 收集可 shake 的区域。纯函数,不修改输入。 */
|
|
62
|
+
export declare function collectShakeRegions(messages: Message[], config: ShakeConfig): ShakeRegion[];
|
|
63
|
+
/** 原位替换选中区域。返回新数组,不修改原数组。 */
|
|
64
|
+
export declare function applyShakeRegions(messages: Message[], regions: ShakeRegion[]): Message[];
|
|
65
|
+
/** 区域转 API 视图。protectWindow 窗口内的标记 isAfterProtectWindow=false。 */
|
|
66
|
+
export declare function toRegionViews(regions: ShakeRegion[], config: ShakeConfig, messages: Message[]): ShakeRegionView[];
|