mocode-ai 1.1.7 → 1.1.9
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +45 -40
- package/README.zh-CN.md +51 -41
- package/dist/agent/core.js +137 -447
- package/dist/agent/index.js +5 -24
- package/dist/agent/spawn.js +5 -5
- package/dist/agent/work-discipline.js +16 -70
- package/dist/config/index.js +87 -33
- package/dist/context/age-aware.js +18 -48
- package/dist/context/artifacts.js +19 -17
- package/dist/context/budget.js +27 -28
- package/dist/context/classifier.js +0 -1
- package/dist/context/encoders/index.js +4 -11
- package/dist/context/index.js +4 -7
- package/dist/context/lifecycle.js +115 -483
- package/dist/context/pipeline.js +8 -15
- package/dist/context/relevance.js +77 -55
- package/dist/host/stdio.js +0 -6
- package/dist/i18n/index.js +20 -6
- package/dist/index.js +11 -1
- package/dist/llm/index.js +82 -9
- package/dist/mcp/index.js +0 -1
- package/dist/repl/index.js +69 -15
- package/dist/runtime/browser-manager.js +299 -0
- package/dist/runtime/dev-server-manager.js +354 -0
- package/dist/runtime/shutdown.js +26 -0
- package/dist/session/compact.js +86 -102
- package/dist/session/index.js +0 -1
- package/dist/session/notes.js +107 -0
- package/dist/session/scheduler.js +88 -92
- package/dist/session/trace-metrics.js +5 -92
- package/dist/session/trace.js +1 -10
- package/dist/tools/builtins/browser.js +199 -0
- package/dist/tools/builtins/dev-server.js +99 -0
- package/dist/tools/builtins/index.js +33 -19
- package/dist/tools/builtins/plan-update.js +144 -0
- package/dist/tools/builtins/screenshot.js +173 -0
- package/dist/tools/builtins/view-image.js +49 -0
- package/dist/tools/constants.js +38 -8
- package/dist/tools/registry.js +5 -29
- package/dist/ui/batch.js +3 -0
- package/dist/ui/content.js +19 -16
- package/dist/ui/layout.js +120 -46
- package/dist/ui/render.js +11 -0
- package/package.json +2 -2
- package/dist/agent/middleware/checklist.js +0 -59
- package/dist/session/drop.d.ts +0 -19
- package/dist/session/drop.js +0 -93
- package/dist/tools/builtins/drop-context.d.ts +0 -18
- package/dist/tools/builtins/drop-context.js +0 -68
- package/dist/verification/diagnostics.js +0 -108
- package/dist/verification/fingerprint.js +0 -54
- package/dist/verification/index.js +0 -333
- package/dist/verification/postconditions.js +0 -98
- package/dist/verification/targeted-tests.js +0 -96
- package/dist/verification/types.js +0 -1
|
@@ -0,0 +1,354 @@
|
|
|
1
|
+
// 常驻后台进程管理器(dev server 等)。
|
|
2
|
+
//
|
|
3
|
+
// 与 run_command 的关键区别:run_command 是前台命令,超时或本轮 abort 即 killTree;
|
|
4
|
+
// 这里的进程必须**跨工具调用存活**,所以生命周期只由显式 stop 或进程退出 / 全局 shutdown 决定。
|
|
5
|
+
// 传入的 signal 只用于取消「启动 + 就绪等待」,绝不用于杀已就绪的服务 —— 否则用户下一次
|
|
6
|
+
// Ctrl+C 会顺手把 dev server 干掉。
|
|
7
|
+
//
|
|
8
|
+
// 非安全边界:命令仍是任意 shell 命令(与 run_command 同级风险),故工具层声明 dangerous 风险,
|
|
9
|
+
// 由权限系统在执行前向用户确认。
|
|
10
|
+
import { spawn, spawnSync } from 'node:child_process';
|
|
11
|
+
import { closeSync, openSync, writeSync } from 'node:fs';
|
|
12
|
+
import { mkdir, open, stat } from 'node:fs/promises';
|
|
13
|
+
import { dirname, join } from 'node:path';
|
|
14
|
+
import { randomBytes } from 'node:crypto';
|
|
15
|
+
import { filterEnv, getSandboxRoot, isCommandDenied, jailResolve } from '../sandbox/index.js';
|
|
16
|
+
export class DevServerManagerError extends Error {
|
|
17
|
+
code;
|
|
18
|
+
constructor(code, message) {
|
|
19
|
+
super(message);
|
|
20
|
+
this.code = code;
|
|
21
|
+
this.name = 'DevServerManagerError';
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
const servers = new Map();
|
|
25
|
+
const DEFAULT_READY_TIMEOUT_MS = 30_000;
|
|
26
|
+
const MAX_READY_TIMEOUT_MS = 180_000;
|
|
27
|
+
const READY_POLL_INTERVAL_MS = 250;
|
|
28
|
+
const PROBE_TIMEOUT_MS = 2_000;
|
|
29
|
+
const STOP_GRACE_MS = 1_500;
|
|
30
|
+
const DEFAULT_LOG_BYTES = 8_000;
|
|
31
|
+
const MAX_LOG_BYTES = 64_000;
|
|
32
|
+
const RECENT_LOG_CHARS = 4_000;
|
|
33
|
+
const IS_WINDOWS = process.platform === 'win32';
|
|
34
|
+
function errorMessage(error) {
|
|
35
|
+
return error instanceof Error ? error.message : String(error);
|
|
36
|
+
}
|
|
37
|
+
/** 进程是否仍存活;EPERM 说明 pid 存在但不属于当前用户。 */
|
|
38
|
+
function isAlive(pid) {
|
|
39
|
+
try {
|
|
40
|
+
process.kill(pid, 0);
|
|
41
|
+
return true;
|
|
42
|
+
}
|
|
43
|
+
catch (error) {
|
|
44
|
+
return error.code === 'EPERM';
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
function snapshot(record) {
|
|
48
|
+
return {
|
|
49
|
+
id: record.id,
|
|
50
|
+
pid: record.pid,
|
|
51
|
+
state: record.state,
|
|
52
|
+
exitCode: record.exitCode,
|
|
53
|
+
signal: record.signal,
|
|
54
|
+
command: record.command,
|
|
55
|
+
cwd: record.cwd,
|
|
56
|
+
logPath: record.logPath,
|
|
57
|
+
startedAt: record.startedAt,
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
function requireRecord(id) {
|
|
61
|
+
const record = servers.get(id);
|
|
62
|
+
if (!record) {
|
|
63
|
+
const known = [...servers.keys()];
|
|
64
|
+
throw new DevServerManagerError('INVALID_ARGUMENTS', known.length
|
|
65
|
+
? `Unknown dev server id "${id}". Known ids: ${known.join(', ')}.`
|
|
66
|
+
: `Unknown dev server id "${id}". No dev server has been started in this session.`);
|
|
67
|
+
}
|
|
68
|
+
return record;
|
|
69
|
+
}
|
|
70
|
+
/** 只允许回环地址:防止 agent 借就绪探测去扫内网 / 打外网。 */
|
|
71
|
+
function validateReadyUrl(input) {
|
|
72
|
+
let url;
|
|
73
|
+
try {
|
|
74
|
+
url = new URL(input);
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
throw new DevServerManagerError('INVALID_ARGUMENTS', `Invalid readyUrl: ${input}`);
|
|
78
|
+
}
|
|
79
|
+
const host = url.hostname.toLowerCase().replace(/^\[|\]$/g, '');
|
|
80
|
+
const loopback = host === 'localhost' || host === '127.0.0.1' || host === '::1';
|
|
81
|
+
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
|
|
82
|
+
throw new DevServerManagerError('INVALID_ARGUMENTS', 'readyUrl must use http or https.');
|
|
83
|
+
}
|
|
84
|
+
if (!loopback || url.username || url.password) {
|
|
85
|
+
throw new DevServerManagerError('INVALID_ARGUMENTS', 'readyUrl must target localhost, 127.0.0.1, or ::1 without credentials.');
|
|
86
|
+
}
|
|
87
|
+
return url;
|
|
88
|
+
}
|
|
89
|
+
function compileReadyPattern(input) {
|
|
90
|
+
try {
|
|
91
|
+
return new RegExp(input, 'i');
|
|
92
|
+
}
|
|
93
|
+
catch (error) {
|
|
94
|
+
throw new DevServerManagerError('INVALID_ARGUMENTS', `Invalid readyPattern: ${errorMessage(error)}`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
function sleep(ms, signal) {
|
|
98
|
+
return new Promise((resolve, reject) => {
|
|
99
|
+
const abort = () => {
|
|
100
|
+
clearTimeout(timer);
|
|
101
|
+
reject(new DevServerManagerError('ABORTED', 'Dev server startup wait was aborted.'));
|
|
102
|
+
};
|
|
103
|
+
if (signal?.aborted) {
|
|
104
|
+
abort();
|
|
105
|
+
return;
|
|
106
|
+
}
|
|
107
|
+
const timer = setTimeout(() => {
|
|
108
|
+
signal?.removeEventListener('abort', abort);
|
|
109
|
+
resolve();
|
|
110
|
+
}, ms);
|
|
111
|
+
signal?.addEventListener('abort', abort, { once: true });
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
/** 任意 HTTP 响应都算就绪:dev server 常在 / 返回 404 但端口已可用。 */
|
|
115
|
+
async function probeReady(url) {
|
|
116
|
+
const controller = new AbortController();
|
|
117
|
+
const timer = setTimeout(() => controller.abort(), PROBE_TIMEOUT_MS);
|
|
118
|
+
try {
|
|
119
|
+
await fetch(url, { signal: controller.signal, redirect: 'manual' });
|
|
120
|
+
return true;
|
|
121
|
+
}
|
|
122
|
+
catch {
|
|
123
|
+
return false;
|
|
124
|
+
}
|
|
125
|
+
finally {
|
|
126
|
+
clearTimeout(timer);
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
function killTree(pid) {
|
|
130
|
+
try {
|
|
131
|
+
if (IS_WINDOWS) {
|
|
132
|
+
spawnSync('taskkill', ['/PID', String(pid), '/T', '/F'], { stdio: 'ignore' });
|
|
133
|
+
}
|
|
134
|
+
else {
|
|
135
|
+
process.kill(pid, 'SIGTERM');
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// 已退出或无权限:best-effort。
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
function finalizeExit(record, code, signalCode) {
|
|
143
|
+
record.state = record.stopRequested ? 'stopped' : 'exited';
|
|
144
|
+
record.exitCode = code;
|
|
145
|
+
record.signal = signalCode;
|
|
146
|
+
if (record.logFd !== null) {
|
|
147
|
+
try {
|
|
148
|
+
closeSync(record.logFd);
|
|
149
|
+
}
|
|
150
|
+
catch {
|
|
151
|
+
// 忽略:进程收尾期文件句柄可能已失效。
|
|
152
|
+
}
|
|
153
|
+
record.logFd = null;
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
/** 进程可能在事件回调之外被外部杀掉,读状态时顺带对账。 */
|
|
157
|
+
function reconcile(record) {
|
|
158
|
+
if (record.state === 'running' && !isAlive(record.pid)) {
|
|
159
|
+
finalizeExit(record, record.child.exitCode, record.child.signalCode);
|
|
160
|
+
}
|
|
161
|
+
return record;
|
|
162
|
+
}
|
|
163
|
+
export async function startDevServer(opts) {
|
|
164
|
+
const command = opts.command.trim();
|
|
165
|
+
if (!command) {
|
|
166
|
+
throw new DevServerManagerError('INVALID_ARGUMENTS', 'command is required to start a dev server.');
|
|
167
|
+
}
|
|
168
|
+
const denied = isCommandDenied(command);
|
|
169
|
+
if (denied) {
|
|
170
|
+
throw new DevServerManagerError('SANDBOX_DENIED', denied);
|
|
171
|
+
}
|
|
172
|
+
const root = getSandboxRoot() ?? process.cwd();
|
|
173
|
+
let cwd = root;
|
|
174
|
+
if (opts.cwd) {
|
|
175
|
+
try {
|
|
176
|
+
cwd = jailResolve(opts.cwd);
|
|
177
|
+
}
|
|
178
|
+
catch (error) {
|
|
179
|
+
throw new DevServerManagerError('SANDBOX_DENIED', errorMessage(error));
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
const readyUrl = opts.readyUrl ? validateReadyUrl(opts.readyUrl) : null;
|
|
183
|
+
const readyPattern = opts.readyPattern ? compileReadyPattern(opts.readyPattern) : null;
|
|
184
|
+
const timeoutMs = Math.min(Math.max(Number(opts.timeoutMs ?? DEFAULT_READY_TIMEOUT_MS), 1_000), MAX_READY_TIMEOUT_MS);
|
|
185
|
+
const id = `srv-${randomBytes(3).toString('hex')}`;
|
|
186
|
+
const logPath = jailResolve(join('.mocode', 'dev-servers', `${id}.log`));
|
|
187
|
+
await mkdir(dirname(logPath), { recursive: true });
|
|
188
|
+
let logFd;
|
|
189
|
+
try {
|
|
190
|
+
logFd = openSync(logPath, 'a');
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
throw new DevServerManagerError('EXECUTION_ERROR', `Unable to open log file: ${errorMessage(error)}`);
|
|
194
|
+
}
|
|
195
|
+
let child;
|
|
196
|
+
try {
|
|
197
|
+
child = spawn(IS_WINDOWS ? 'cmd.exe' : 'bash', IS_WINDOWS ? ['/d', '/s', '/c', command] : ['-c', command], {
|
|
198
|
+
cwd,
|
|
199
|
+
env: filterEnv(process.env),
|
|
200
|
+
windowsVerbatimArguments: IS_WINDOWS,
|
|
201
|
+
windowsHide: true,
|
|
202
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
catch (error) {
|
|
206
|
+
closeSync(logFd);
|
|
207
|
+
throw new DevServerManagerError('EXECUTION_ERROR', `Unable to spawn dev server: ${errorMessage(error)}`);
|
|
208
|
+
}
|
|
209
|
+
if (child.pid === undefined) {
|
|
210
|
+
closeSync(logFd);
|
|
211
|
+
throw new DevServerManagerError('EXECUTION_ERROR', 'Dev server process did not report a pid.');
|
|
212
|
+
}
|
|
213
|
+
const record = {
|
|
214
|
+
id,
|
|
215
|
+
pid: child.pid,
|
|
216
|
+
state: 'running',
|
|
217
|
+
exitCode: null,
|
|
218
|
+
signal: null,
|
|
219
|
+
command,
|
|
220
|
+
cwd,
|
|
221
|
+
logPath,
|
|
222
|
+
startedAt: new Date().toISOString(),
|
|
223
|
+
child,
|
|
224
|
+
logFd,
|
|
225
|
+
stopRequested: false,
|
|
226
|
+
recentLog: '',
|
|
227
|
+
};
|
|
228
|
+
servers.set(id, record);
|
|
229
|
+
const onChunk = (chunk) => {
|
|
230
|
+
const text = chunk.toString('utf8');
|
|
231
|
+
record.recentLog = (record.recentLog + text).slice(-RECENT_LOG_CHARS);
|
|
232
|
+
if (record.logFd === null)
|
|
233
|
+
return;
|
|
234
|
+
try {
|
|
235
|
+
writeSync(record.logFd, text);
|
|
236
|
+
}
|
|
237
|
+
catch {
|
|
238
|
+
// 日志写失败不影响进程本身。
|
|
239
|
+
}
|
|
240
|
+
};
|
|
241
|
+
child.stdout?.on('data', onChunk);
|
|
242
|
+
child.stderr?.on('data', onChunk);
|
|
243
|
+
child.on('error', (error) => onChunk(Buffer.from(`\n[spawn error] ${error.message}\n`)));
|
|
244
|
+
child.on('close', (code, signalCode) => finalizeExit(record, code, signalCode));
|
|
245
|
+
const deadline = Date.now() + timeoutMs;
|
|
246
|
+
const wantsReadySignal = Boolean(readyUrl || readyPattern);
|
|
247
|
+
let ready = false;
|
|
248
|
+
try {
|
|
249
|
+
while (wantsReadySignal && Date.now() < deadline) {
|
|
250
|
+
if (record.state !== 'running') {
|
|
251
|
+
throw new DevServerManagerError('PROCESS_FAILED', `Dev server exited before becoming ready (exitCode=${record.exitCode ?? 'null'}). Recent output:\n${record.recentLog.trim() || '(no output)'}`);
|
|
252
|
+
}
|
|
253
|
+
if (readyPattern?.test(record.recentLog)) {
|
|
254
|
+
ready = true;
|
|
255
|
+
break;
|
|
256
|
+
}
|
|
257
|
+
if (readyUrl && await probeReady(readyUrl)) {
|
|
258
|
+
ready = true;
|
|
259
|
+
break;
|
|
260
|
+
}
|
|
261
|
+
await sleep(READY_POLL_INTERVAL_MS, opts.signal);
|
|
262
|
+
}
|
|
263
|
+
}
|
|
264
|
+
catch (error) {
|
|
265
|
+
// 启动失败 / 被中断:不留孤儿进程。
|
|
266
|
+
record.stopRequested = true;
|
|
267
|
+
killTree(record.pid);
|
|
268
|
+
finalizeExit(record, record.child.exitCode, record.child.signalCode);
|
|
269
|
+
throw error;
|
|
270
|
+
}
|
|
271
|
+
if (wantsReadySignal && !ready) {
|
|
272
|
+
record.stopRequested = true;
|
|
273
|
+
killTree(record.pid);
|
|
274
|
+
finalizeExit(record, record.child.exitCode, record.child.signalCode);
|
|
275
|
+
throw new DevServerManagerError('TIMEOUT', `Dev server did not become ready within ${timeoutMs}ms and was stopped. Recent output:\n${record.recentLog.trim() || '(no output)'}`);
|
|
276
|
+
}
|
|
277
|
+
return {
|
|
278
|
+
...snapshot(reconcile(record)),
|
|
279
|
+
ready,
|
|
280
|
+
...(opts.readyUrl ? { readyUrl: opts.readyUrl } : {}),
|
|
281
|
+
...(opts.readyPattern ? { readyPattern: opts.readyPattern } : {}),
|
|
282
|
+
...(record.recentLog.trim() ? { logTail: record.recentLog.trim().slice(-1_000) } : {}),
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
export function listDevServers() {
|
|
286
|
+
return [...servers.values()].map((record) => snapshot(reconcile(record)));
|
|
287
|
+
}
|
|
288
|
+
export function getDevServerStatus(id) {
|
|
289
|
+
if (!id)
|
|
290
|
+
return listDevServers();
|
|
291
|
+
return [snapshot(reconcile(requireRecord(id)))];
|
|
292
|
+
}
|
|
293
|
+
export async function readDevServerLogs(id, opts = {}) {
|
|
294
|
+
const record = reconcile(requireRecord(id));
|
|
295
|
+
const size = (await stat(record.logPath).catch(() => null))?.size ?? 0;
|
|
296
|
+
const limit = Math.min(Math.max(Number(opts.limit ?? DEFAULT_LOG_BYTES), 1), MAX_LOG_BYTES);
|
|
297
|
+
// 缺省读尾部(最新输出);显式 offset 支持增量轮询。
|
|
298
|
+
const requested = opts.offset === undefined ? Math.max(0, size - limit) : Math.max(0, Number(opts.offset));
|
|
299
|
+
const start = Math.min(requested, size);
|
|
300
|
+
const length = Math.min(limit, size - start);
|
|
301
|
+
let content = '';
|
|
302
|
+
if (length > 0) {
|
|
303
|
+
const handle = await open(record.logPath, 'r');
|
|
304
|
+
try {
|
|
305
|
+
const buffer = Buffer.alloc(length);
|
|
306
|
+
await handle.read(buffer, 0, length, start);
|
|
307
|
+
content = buffer.toString('utf8');
|
|
308
|
+
}
|
|
309
|
+
finally {
|
|
310
|
+
await handle.close();
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
return {
|
|
314
|
+
id: record.id,
|
|
315
|
+
state: record.state,
|
|
316
|
+
offset: start,
|
|
317
|
+
nextOffset: start + length,
|
|
318
|
+
size,
|
|
319
|
+
hasMore: start + length < size,
|
|
320
|
+
content,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
export async function stopDevServer(id) {
|
|
324
|
+
const record = reconcile(requireRecord(id));
|
|
325
|
+
if (record.state !== 'running') {
|
|
326
|
+
return { ...snapshot(record), alreadyStopped: true };
|
|
327
|
+
}
|
|
328
|
+
record.stopRequested = true;
|
|
329
|
+
killTree(record.pid);
|
|
330
|
+
const deadline = Date.now() + STOP_GRACE_MS;
|
|
331
|
+
while (Date.now() < deadline && isAlive(record.pid)) {
|
|
332
|
+
await sleep(50);
|
|
333
|
+
}
|
|
334
|
+
if (isAlive(record.pid) && !IS_WINDOWS) {
|
|
335
|
+
try {
|
|
336
|
+
process.kill(record.pid, 'SIGKILL');
|
|
337
|
+
}
|
|
338
|
+
catch {
|
|
339
|
+
// 已退出。
|
|
340
|
+
}
|
|
341
|
+
}
|
|
342
|
+
finalizeExit(record, record.child.exitCode, record.child.signalCode);
|
|
343
|
+
return { ...snapshot(record), alreadyStopped: false };
|
|
344
|
+
}
|
|
345
|
+
/** 全局退出清理:同步树杀所有仍存活的后台进程。可重复调用。 */
|
|
346
|
+
export function stopAllDevServersSync() {
|
|
347
|
+
for (const record of servers.values()) {
|
|
348
|
+
if (record.state !== 'running')
|
|
349
|
+
continue;
|
|
350
|
+
record.stopRequested = true;
|
|
351
|
+
killTree(record.pid);
|
|
352
|
+
finalizeExit(record, record.child.exitCode, record.child.signalCode);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
// 常驻资源的统一退出清理。
|
|
2
|
+
//
|
|
3
|
+
// 后台 dev server 是我们自己 spawn 的子进程,进程退出不会自动带走(Windows 尤其),必须显式树杀;
|
|
4
|
+
// Playwright 浏览器进程在正常路径 await close(),硬退出路径依赖 Playwright 自身的 exit 处理。
|
|
5
|
+
// 两个入口都必须幂等:normal exit / SIGINT / uncaughtException 可能重复触发。
|
|
6
|
+
import { stopAllDevServersSync } from './dev-server-manager.js';
|
|
7
|
+
let asyncDone = false;
|
|
8
|
+
/** 正常退出路径:可 await,尽量优雅关闭。 */
|
|
9
|
+
export async function shutdownRuntime() {
|
|
10
|
+
if (asyncDone)
|
|
11
|
+
return;
|
|
12
|
+
asyncDone = true;
|
|
13
|
+
stopAllDevServersSync();
|
|
14
|
+
try {
|
|
15
|
+
// 动态 import:未用过 browser 工具时不该把 playwright 拉进进程。
|
|
16
|
+
const { closeAllBrowsers } = await import('./browser-manager.js');
|
|
17
|
+
await closeAllBrowsers();
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
// 浏览器从未启动或已崩溃:忽略。
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
/** 同步兜底(process exit / 信号 / 未捕获异常):只做同步树杀,不留孤儿 dev server。 */
|
|
24
|
+
export function shutdownRuntimeSync() {
|
|
25
|
+
stopAllDevServersSync();
|
|
26
|
+
}
|
package/dist/session/compact.js
CHANGED
|
@@ -187,6 +187,39 @@ function flattenGroups(groups) {
|
|
|
187
187
|
}
|
|
188
188
|
return out;
|
|
189
189
|
}
|
|
190
|
+
/** 微压缩单个 group(原地):① tool 结果 content ② assistant tool_calls.arguments(provenance stub)
|
|
191
|
+
* ③ assistant 正文。只裁模型/工具产物,不动 user 原话;保 tool_call_id 配对。返回是否有改动。 */
|
|
192
|
+
function microcompactGroup(g) {
|
|
193
|
+
let done = false;
|
|
194
|
+
for (const t of g.tools) {
|
|
195
|
+
const c = t.content;
|
|
196
|
+
if (typeof c === 'string' && c.length > MAX_OLD_TOOL_STUB) {
|
|
197
|
+
t.content = truncateMid(c, MAX_OLD_TOOL_STUB);
|
|
198
|
+
done = true;
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
const as = g.assistant;
|
|
202
|
+
if (as && as.role === 'assistant') {
|
|
203
|
+
if (typeof as.content === 'string' && as.content.length > MAX_OLD_TOOL_STUB) {
|
|
204
|
+
as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
|
|
205
|
+
done = true;
|
|
206
|
+
}
|
|
207
|
+
// tool_calls 参数:整体超长才进,provenance stub 大字段(保 path + JSON 合法)。
|
|
208
|
+
if (Array.isArray(as.tool_calls)) {
|
|
209
|
+
for (const tc of as.tool_calls) {
|
|
210
|
+
const args = tc?.function?.arguments;
|
|
211
|
+
if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
|
|
212
|
+
continue;
|
|
213
|
+
const stubbed = stubToolCallArguments(args);
|
|
214
|
+
if (stubbed !== args) {
|
|
215
|
+
tc.function.arguments = stubbed;
|
|
216
|
+
done = true;
|
|
217
|
+
}
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
return done;
|
|
222
|
+
}
|
|
190
223
|
// ── 默认摘要器:复用 chat(),空 handlers 不打印 ──────────────────────────
|
|
191
224
|
async function defaultSummarize(older, focus) {
|
|
192
225
|
// 摘要前剥离多模态 user 消息里的图片(base64 会撑爆摘要 prompt;image 对摘要无信息量)。
|
|
@@ -256,7 +289,14 @@ export async function compactHistory(history, opts) {
|
|
|
256
289
|
kept.unshift(g);
|
|
257
290
|
keptTokens = nextTokens;
|
|
258
291
|
}
|
|
259
|
-
|
|
292
|
+
let oldGroups = groups.slice(0, groups.length - kept.length);
|
|
293
|
+
// force(硬闸/手动强压):保护区不豁免——常规切分无旧区时只保最后一组,
|
|
294
|
+
// 其余全部进可压区(首轮/当前轮也一样)。仍按 group 边界切,不破坏 tool_call 配对。
|
|
295
|
+
if (oldGroups.length === 0 && opts.force && groups.length >= 2) {
|
|
296
|
+
kept.length = 0;
|
|
297
|
+
kept.push(groups[groups.length - 1]);
|
|
298
|
+
oldGroups = groups.slice(0, groups.length - 1);
|
|
299
|
+
}
|
|
260
300
|
const noop = {
|
|
261
301
|
compacted: false,
|
|
262
302
|
summarized: false,
|
|
@@ -269,65 +309,27 @@ export async function compactHistory(history, opts) {
|
|
|
269
309
|
if (oldGroups.length === 0) {
|
|
270
310
|
// 没有旧区可压缩
|
|
271
311
|
const protectedRatio = history.length > 0 ? kept.length / history.length : 0;
|
|
272
|
-
if (opts.force &&
|
|
273
|
-
//
|
|
274
|
-
//
|
|
275
|
-
const
|
|
276
|
-
const
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
if (Array.isArray(as.tool_calls)) {
|
|
293
|
-
for (const tc of as.tool_calls) {
|
|
294
|
-
const args = tc?.function?.arguments;
|
|
295
|
-
if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
|
|
296
|
-
continue;
|
|
297
|
-
const stubbed = stubToolCallArguments(args);
|
|
298
|
-
if (stubbed !== args) {
|
|
299
|
-
tc.function.arguments = stubbed;
|
|
300
|
-
microcompactDone2 = true;
|
|
301
|
-
}
|
|
302
|
-
}
|
|
303
|
-
}
|
|
304
|
-
}
|
|
312
|
+
if (opts.force && groups.length === 1) {
|
|
313
|
+
// force 但只剩单组(如首轮的单个 assistant+tools 组):原地微压缩、不动结构。
|
|
314
|
+
// 纯 user 原话组无可压项(不能毁掉用户请求),落到下方 noop 提示。
|
|
315
|
+
const single = groups[0];
|
|
316
|
+
const userOnly = single.tools.length === 0
|
|
317
|
+
&& single.assistant?.role === 'user';
|
|
318
|
+
if (!userOnly && microcompactGroup(single)) {
|
|
319
|
+
const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
|
|
320
|
+
state.lastEstimate = estimateAfter;
|
|
321
|
+
state.lastUsage = undefined;
|
|
322
|
+
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制微压缩(单组)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
323
|
+
return {
|
|
324
|
+
compacted: true,
|
|
325
|
+
summarized: false,
|
|
326
|
+
estimateBefore,
|
|
327
|
+
estimateAfter,
|
|
328
|
+
reason: 'microcompact',
|
|
329
|
+
protectedRatio,
|
|
330
|
+
oldGroupCount: 0,
|
|
331
|
+
};
|
|
305
332
|
}
|
|
306
|
-
const refs = collectArtifactRefs(older);
|
|
307
|
-
const summaryMsg = {
|
|
308
|
-
role: 'system',
|
|
309
|
-
content: older.length > 0
|
|
310
|
-
? `# 会话摘要(force)\n被跳过的早期对话 ${older.length} 条已微压缩(token 数减少)。${refs.length > 0 ? `\n[artifact refs: ${refs.join(', ')}]` : ''}`
|
|
311
|
-
: `# 会话摘要(force)\n无内容。`,
|
|
312
|
-
};
|
|
313
|
-
const rebuilt = [history[0], summaryMsg, ...keptAfter];
|
|
314
|
-
history.length = 0;
|
|
315
|
-
history.push(...rebuilt);
|
|
316
|
-
pruneAfterCompaction(history);
|
|
317
|
-
const estimateAfter = estimatePromptTokens(history, activeTools, state.correction);
|
|
318
|
-
state.lastEstimate = estimateAfter;
|
|
319
|
-
state.lastUsage = undefined;
|
|
320
|
-
layout.contentWrite(` ${ui.bold}${ui.accent}●${ui.reset} ${ui.accent}强制压缩(focus on early history)${ui.reset} ${ui.dim}${estimateBefore} → ${estimateAfter} tokens${ui.reset}\n`);
|
|
321
|
-
return {
|
|
322
|
-
compacted: true,
|
|
323
|
-
summarized: false,
|
|
324
|
-
historyRebuilt: true,
|
|
325
|
-
estimateBefore,
|
|
326
|
-
estimateAfter,
|
|
327
|
-
reason: microcompactDone2 ? 'microcompact' : 'summarize',
|
|
328
|
-
protectedRatio,
|
|
329
|
-
oldGroupCount: 0,
|
|
330
|
-
};
|
|
331
333
|
}
|
|
332
334
|
// 细分 noop 类型,供 repl 文案
|
|
333
335
|
const isEmpty = history.length <= 1; // 只有 system 提示
|
|
@@ -350,35 +352,8 @@ export async function compactHistory(history, opts) {
|
|
|
350
352
|
// ③ 旧 assistant 正文 content(模型长解释,回看价值低)。
|
|
351
353
|
let microcompactDone = false;
|
|
352
354
|
for (const g of oldGroups) {
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
if (typeof c === 'string' && c.length > MAX_OLD_TOOL_STUB) {
|
|
356
|
-
t.content = truncateMid(c, MAX_OLD_TOOL_STUB);
|
|
357
|
-
microcompactDone = true;
|
|
358
|
-
}
|
|
359
|
-
}
|
|
360
|
-
const as = g.assistant;
|
|
361
|
-
if (as && as.role === 'assistant') {
|
|
362
|
-
// ③ 旧正文 content
|
|
363
|
-
if (typeof as.content === 'string' && as.content.length > MAX_OLD_TOOL_STUB) {
|
|
364
|
-
as.content = truncateMid(as.content, MAX_OLD_TOOL_STUB);
|
|
365
|
-
microcompactDone = true;
|
|
366
|
-
}
|
|
367
|
-
// ② tool_calls 参数:整体超长才进,provenance stub 大字段(保 path + JSON 合法)。
|
|
368
|
-
// stubToolCallArguments:大字符串字段 → "<N 字符,已省略>";path 永不省(/rollback 依赖)。
|
|
369
|
-
if (Array.isArray(as.tool_calls)) {
|
|
370
|
-
for (const tc of as.tool_calls) {
|
|
371
|
-
const args = tc?.function?.arguments;
|
|
372
|
-
if (typeof args !== 'string' || args.length <= MAX_OLD_TOOL_STUB)
|
|
373
|
-
continue;
|
|
374
|
-
const stubbed = stubToolCallArguments(args);
|
|
375
|
-
if (stubbed !== args) {
|
|
376
|
-
tc.function.arguments = stubbed;
|
|
377
|
-
microcompactDone = true;
|
|
378
|
-
}
|
|
379
|
-
}
|
|
380
|
-
}
|
|
381
|
-
}
|
|
355
|
+
if (microcompactGroup(g))
|
|
356
|
+
microcompactDone = true;
|
|
382
357
|
}
|
|
383
358
|
// 第二层:摘要——把旧区(微压缩后)压成一条 system 摘要
|
|
384
359
|
const older = flattenGroups(oldGroups);
|
|
@@ -450,14 +425,14 @@ export async function compactHistory(history, opts) {
|
|
|
450
425
|
* 自动压缩门槛:agent 每步调 chat() 前调用。
|
|
451
426
|
* 用全量启发式估算(始终可用、安全侧、无 stale-usage 问题);超阈则压缩。
|
|
452
427
|
*
|
|
453
|
-
*
|
|
454
|
-
*
|
|
455
|
-
*
|
|
456
|
-
*
|
|
457
|
-
*
|
|
428
|
+
* The shared 80% pressure threshold is the only automatic trigger. Raw estimates
|
|
429
|
+
* participate so correction<1 cannot hide an oversized provider request.
|
|
430
|
+
*
|
|
431
|
+
* Budget Scheduler 模式在同一次 80% pressure 事件中先完成已启用的
|
|
432
|
+
* superseded / stale artifact / logs-search 清理,然后无条件继续压缩历史。
|
|
458
433
|
*
|
|
459
|
-
* 不传 report
|
|
460
|
-
*
|
|
434
|
+
* 不传 report 时使用同一个共享阈值,供子 agent 和关闭 scheduler 时 fallback;
|
|
435
|
+
* 不再维护独立的自动压缩触发线。
|
|
461
436
|
*
|
|
462
437
|
* manual 选项(repl /compact 用):true 时旁路 autoCompact 开关与 ROI 阈,
|
|
463
438
|
* 强制走 compactHistory(manual/force 参数透传)。返 CompactResult 给 caller 文案展示。
|
|
@@ -467,28 +442,37 @@ export async function maybeCompact(history, report, manualOpts, state = contextS
|
|
|
467
442
|
const est = estimatePromptTokens(history, activeTools, state.correction);
|
|
468
443
|
state.lastEstimate = est;
|
|
469
444
|
const isManual = manualOpts?.manual === true;
|
|
445
|
+
// Hard guard: the scheduler has already attempted pressure cleanup; if raw
|
|
446
|
+
// content remains near the provider limit, compact even when correction < 1.
|
|
447
|
+
let hardCap = typeof report?.rawTotal === 'number'
|
|
448
|
+
&& typeof report?.window === 'number'
|
|
449
|
+
&& report.rawTotal >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio * report.window;
|
|
470
450
|
// 手动路径:旁路 autoCompact / report / 总阈三重门
|
|
471
451
|
if (!isManual) {
|
|
472
|
-
if (!config.autoCompact)
|
|
452
|
+
if (!hardCap && !config.autoCompact)
|
|
473
453
|
return;
|
|
474
454
|
if (report) {
|
|
475
|
-
//
|
|
476
|
-
const needsCompact =
|
|
455
|
+
// Scheduler mode: the shared pressure report is the sole automatic trigger.
|
|
456
|
+
const needsCompact = hardCap || report.totalOver;
|
|
477
457
|
if (!needsCompact)
|
|
478
458
|
return;
|
|
479
459
|
}
|
|
480
460
|
else {
|
|
481
|
-
//
|
|
482
|
-
|
|
461
|
+
// Fallback mode uses the same threshold; there is no second compact gate.
|
|
462
|
+
const raw = estimatePromptTokens(history, activeTools, 1);
|
|
463
|
+
hardCap = raw >= DEFAULT_BUDGET_POLICY.pressureTriggerRatio * config.contextWindowTokens;
|
|
464
|
+
const pressureLine = DEFAULT_BUDGET_POLICY.pressureTriggerRatio * config.contextWindowTokens;
|
|
465
|
+
if (!hardCap && est < pressureLine)
|
|
483
466
|
return;
|
|
484
467
|
}
|
|
485
468
|
}
|
|
486
469
|
const r = await compactHistory(history, {
|
|
487
470
|
window: config.contextWindowTokens,
|
|
488
|
-
threshold:
|
|
471
|
+
threshold: DEFAULT_BUDGET_POLICY.pressureTriggerRatio,
|
|
489
472
|
focus: manualOpts?.focus,
|
|
490
473
|
manual: isManual,
|
|
491
|
-
force
|
|
474
|
+
// 硬闸触发时 force 直通保护区:首轮/当前轮不豁免。
|
|
475
|
+
force: manualOpts?.force === true || hardCap,
|
|
492
476
|
tools: activeTools,
|
|
493
477
|
contextState: state,
|
|
494
478
|
});
|
package/dist/session/index.js
CHANGED
|
@@ -10,7 +10,6 @@ export { compactHistory, maybeCompact, capToolResultForHistory, truncateMid, con
|
|
|
10
10
|
// 只执行可落地的 warn / compact_history;开关关闭时退化为 maybeCompact。
|
|
11
11
|
// repl /compact 命令调 manualCompact(history, focus?):与自动路径共享决策,focus 透传摘要 prompt。
|
|
12
12
|
export { runScheduler, manualCompact, createBudgetScheduler, } from './scheduler.js';
|
|
13
|
-
export { dropContextFromHistory, formatDropResult, } from './drop.js';
|
|
14
13
|
export { newSessionId, saveSession, loadSession, listSessions, sessionDir, } from './persist.js';
|
|
15
14
|
export { appendCurrentSessionTrace, appendCurrentSessionTraceEvent, appendCurrentSessionRuntimeEvent, createTraceEvent, } from './trace.js';
|
|
16
15
|
export { reduceTraceMetrics, readTraceEvents, readTraceMetrics } from './trace-metrics.js';
|