chatccc 0.2.185 → 0.2.186
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/agent-prompts/cursor_specific.md +12 -1
- package/config.sample.json +24 -4
- package/package.json +1 -1
- package/src/__tests__/config-reload.test.ts +9 -4
- package/src/__tests__/config-sample.test.ts +23 -4
- package/src/__tests__/raw-stream-log.test.ts +106 -0
- package/src/adapters/cursor-adapter.ts +46 -21
- package/src/adapters/raw-stream-log.ts +124 -0
- package/src/config.ts +85 -39
|
@@ -1,3 +1,14 @@
|
|
|
1
1
|
## 计划/问答模式权限规则
|
|
2
2
|
|
|
3
|
-
如果用户消息以 `/plan` 或 `/ask` 开头,说明当前处于只读模式。**遇到任何需要用户同意的权限请求时,不要向用户申请权限,立即调用 stop-stuck-loop 接口提前结束本轮会话。**
|
|
3
|
+
如果用户消息以 `/plan` 或 `/ask` 开头,说明当前处于只读模式。**遇到任何需要用户同意的权限请求时,不要向用户申请权限,立即调用 stop-stuck-loop 接口提前结束本轮会话。**
|
|
4
|
+
|
|
5
|
+
## 用户问题澄清规则
|
|
6
|
+
|
|
7
|
+
当需要在开始实现前向用户确认问题、让用户做选择,或用户要求“从第一性原理出发挖掘真实需求 / 判断需求是否合理 / 开始实现前有什么问题要问我”时,**不要调用 `AskQuestion`、多选表单或任何结构化选择工具**。
|
|
8
|
+
|
|
9
|
+
必须直接用普通文本回复用户:
|
|
10
|
+
|
|
11
|
+
- 先简要说明你理解到的真实需求和你对合理性的判断。
|
|
12
|
+
- 再列出需要用户确认的问题;如果有推荐选项,就用文本说明“我建议 X,原因是 Y”。
|
|
13
|
+
- 问题数量尽量少,只问会影响实现边界、风险或不可逆决策的问题。
|
|
14
|
+
- 不要把这些问题放进工具调用参数里,也不要只在卡片/工具结果中呈现。
|
package/config.sample.json
CHANGED
|
@@ -12,10 +12,30 @@
|
|
|
12
12
|
"port": 15166,
|
|
13
13
|
"chromePath": ""
|
|
14
14
|
},
|
|
15
|
-
"port": 18080,
|
|
16
|
-
"gitTimeoutSeconds": 180,
|
|
17
|
-
"allowInterrupt": false,
|
|
18
|
-
"
|
|
15
|
+
"port": 18080,
|
|
16
|
+
"gitTimeoutSeconds": 180,
|
|
17
|
+
"allowInterrupt": false,
|
|
18
|
+
"rawStreamLogs": {
|
|
19
|
+
"claude": {
|
|
20
|
+
"enabled": false,
|
|
21
|
+
"maxBytesPerTurn": 52428800,
|
|
22
|
+
"retentionDays": 7,
|
|
23
|
+
"keepCompleted": false
|
|
24
|
+
},
|
|
25
|
+
"cursor": {
|
|
26
|
+
"enabled": false,
|
|
27
|
+
"maxBytesPerTurn": 52428800,
|
|
28
|
+
"retentionDays": 7,
|
|
29
|
+
"keepCompleted": false
|
|
30
|
+
},
|
|
31
|
+
"codex": {
|
|
32
|
+
"enabled": false,
|
|
33
|
+
"maxBytesPerTurn": 52428800,
|
|
34
|
+
"retentionDays": 7,
|
|
35
|
+
"keepCompleted": false
|
|
36
|
+
}
|
|
37
|
+
},
|
|
38
|
+
"claude": {
|
|
19
39
|
"enabled": false,
|
|
20
40
|
"defaultAgent": true,
|
|
21
41
|
"model": "",
|
package/package.json
CHANGED
|
@@ -41,10 +41,15 @@ const baseAppConfig: AppConfig = {
|
|
|
41
41
|
feishu: { appId: "INITIAL_APP", appSecret: "INITIAL_SECRET" },
|
|
42
42
|
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
43
43
|
chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
|
|
44
|
-
port: 18080,
|
|
45
|
-
gitTimeoutSeconds: 180,
|
|
46
|
-
allowInterrupt: false,
|
|
47
|
-
|
|
44
|
+
port: 18080,
|
|
45
|
+
gitTimeoutSeconds: 180,
|
|
46
|
+
allowInterrupt: false,
|
|
47
|
+
rawStreamLogs: {
|
|
48
|
+
claude: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
|
|
49
|
+
cursor: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
|
|
50
|
+
codex: { enabled: false, maxBytesPerTurn: 52_428_800, retentionDays: 7, keepCompleted: false },
|
|
51
|
+
},
|
|
52
|
+
claude: {
|
|
48
53
|
enabled: true,
|
|
49
54
|
defaultAgent: true,
|
|
50
55
|
model: "initial-model",
|
|
@@ -28,7 +28,7 @@ describe("config.sample.json", () => {
|
|
|
28
28
|
expect(sample.claude?.subagentModel).toBe("");
|
|
29
29
|
});
|
|
30
30
|
|
|
31
|
-
it("keeps Chrome CDP guard disabled by default with port 15166", () => {
|
|
31
|
+
it("keeps Chrome CDP guard disabled by default with port 15166", () => {
|
|
32
32
|
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
33
33
|
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
34
34
|
chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
|
|
@@ -36,6 +36,25 @@ describe("config.sample.json", () => {
|
|
|
36
36
|
|
|
37
37
|
expect(sample.chromeDevtools?.enabled).toBe(false);
|
|
38
38
|
expect(sample.chromeDevtools?.port).toBe(15166);
|
|
39
|
-
expect(sample.chromeDevtools?.chromePath).toBe("");
|
|
40
|
-
});
|
|
41
|
-
|
|
39
|
+
expect(sample.chromeDevtools?.chromePath).toBe("");
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it("keeps raw stream logs disabled by default for every agent", () => {
|
|
43
|
+
const configSamplePath = join(process.cwd(), "config.sample.json");
|
|
44
|
+
const sample = JSON.parse(readFileSync(configSamplePath, "utf8")) as {
|
|
45
|
+
rawStreamLogs?: Record<string, {
|
|
46
|
+
enabled?: unknown;
|
|
47
|
+
maxBytesPerTurn?: unknown;
|
|
48
|
+
retentionDays?: unknown;
|
|
49
|
+
keepCompleted?: unknown;
|
|
50
|
+
}>;
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
for (const tool of ["claude", "cursor", "codex"]) {
|
|
54
|
+
expect(sample.rawStreamLogs?.[tool]?.enabled).toBe(false);
|
|
55
|
+
expect(sample.rawStreamLogs?.[tool]?.maxBytesPerTurn).toBe(52_428_800);
|
|
56
|
+
expect(sample.rawStreamLogs?.[tool]?.retentionDays).toBe(7);
|
|
57
|
+
expect(sample.rawStreamLogs?.[tool]?.keepCompleted).toBe(false);
|
|
58
|
+
}
|
|
59
|
+
});
|
|
60
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { gunzipSync } from "node:zlib";
|
|
2
|
+
import { mkdtemp, readFile, rm, stat, writeFile } from "node:fs/promises";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
|
|
6
|
+
import { describe, expect, it } from "vitest";
|
|
7
|
+
|
|
8
|
+
import {
|
|
9
|
+
createRawStreamLog,
|
|
10
|
+
sanitizeLogPathSegment,
|
|
11
|
+
} from "../adapters/raw-stream-log.ts";
|
|
12
|
+
|
|
13
|
+
describe("raw stream log", () => {
|
|
14
|
+
it("does nothing when disabled", async () => {
|
|
15
|
+
const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
|
|
16
|
+
try {
|
|
17
|
+
const log = await createRawStreamLog({
|
|
18
|
+
enabled: false,
|
|
19
|
+
rootDir: root,
|
|
20
|
+
tool: "cursor",
|
|
21
|
+
sessionId: "sid",
|
|
22
|
+
label: "turn",
|
|
23
|
+
maxBytesPerTurn: 1024,
|
|
24
|
+
retentionDays: 7,
|
|
25
|
+
});
|
|
26
|
+
expect(log).toBeNull();
|
|
27
|
+
} finally {
|
|
28
|
+
await rm(root, { recursive: true, force: true });
|
|
29
|
+
}
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it("writes gzipped JSONL and can keep the file", async () => {
|
|
33
|
+
const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
|
|
34
|
+
try {
|
|
35
|
+
const log = await createRawStreamLog({
|
|
36
|
+
enabled: true,
|
|
37
|
+
rootDir: root,
|
|
38
|
+
tool: "cursor",
|
|
39
|
+
sessionId: "sid/unsafe",
|
|
40
|
+
label: "turn:1",
|
|
41
|
+
maxBytesPerTurn: 1024,
|
|
42
|
+
retentionDays: 7,
|
|
43
|
+
});
|
|
44
|
+
expect(log).not.toBeNull();
|
|
45
|
+
log!.writeLine('{"type":"assistant","text":"hello"}');
|
|
46
|
+
await log!.close({ keep: true });
|
|
47
|
+
|
|
48
|
+
const raw = gunzipSync(await readFile(log!.filePath)).toString("utf-8");
|
|
49
|
+
expect(raw).toBe('{"type":"assistant","text":"hello"}\n');
|
|
50
|
+
expect(await stat(log!.filePath)).toBeTruthy();
|
|
51
|
+
} finally {
|
|
52
|
+
await rm(root, { recursive: true, force: true });
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it("writes a truncation marker once maxBytesPerTurn is exceeded", async () => {
|
|
57
|
+
const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
|
|
58
|
+
try {
|
|
59
|
+
const log = await createRawStreamLog({
|
|
60
|
+
enabled: true,
|
|
61
|
+
rootDir: root,
|
|
62
|
+
tool: "cursor",
|
|
63
|
+
sessionId: "sid",
|
|
64
|
+
label: "turn",
|
|
65
|
+
maxBytesPerTurn: 12,
|
|
66
|
+
retentionDays: 7,
|
|
67
|
+
});
|
|
68
|
+
log!.writeLine('{"a":1}');
|
|
69
|
+
log!.writeLine('{"too":"large"}');
|
|
70
|
+
log!.writeLine('{"ignored":true}');
|
|
71
|
+
await log!.close({ keep: true });
|
|
72
|
+
|
|
73
|
+
const raw = gunzipSync(await readFile(log!.filePath)).toString("utf-8");
|
|
74
|
+
expect(raw).toContain('{"a":1}');
|
|
75
|
+
expect(raw).toContain("chatccc_raw_stream_log_truncated");
|
|
76
|
+
expect(raw).not.toContain("ignored");
|
|
77
|
+
} finally {
|
|
78
|
+
await rm(root, { recursive: true, force: true });
|
|
79
|
+
}
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it("removes the file when keep is false", async () => {
|
|
83
|
+
const root = await mkdtemp(join(tmpdir(), "chatccc-raw-log-"));
|
|
84
|
+
try {
|
|
85
|
+
const log = await createRawStreamLog({
|
|
86
|
+
enabled: true,
|
|
87
|
+
rootDir: root,
|
|
88
|
+
tool: "cursor",
|
|
89
|
+
sessionId: "sid",
|
|
90
|
+
label: "turn",
|
|
91
|
+
maxBytesPerTurn: 1024,
|
|
92
|
+
retentionDays: 7,
|
|
93
|
+
});
|
|
94
|
+
log!.writeLine('{"type":"result"}');
|
|
95
|
+
await log!.close({ keep: false });
|
|
96
|
+
await expect(stat(log!.filePath)).rejects.toThrow();
|
|
97
|
+
} finally {
|
|
98
|
+
await rm(root, { recursive: true, force: true });
|
|
99
|
+
}
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it("sanitizes path segments", () => {
|
|
103
|
+
expect(sanitizeLogPathSegment("../sid:1/2")).toBe("sid_1_2");
|
|
104
|
+
expect(sanitizeLogPathSegment("")).toBe("unknown");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -20,12 +20,16 @@ import type {
|
|
|
20
20
|
SessionInfo,
|
|
21
21
|
} from "./adapter-interface.ts";
|
|
22
22
|
import { parseUserCommand } from "./adapter-interface.ts";
|
|
23
|
-
import { CURSOR_AGENT_COMMAND, CURSOR_AGENT_ARGS } from "../config.ts";
|
|
23
|
+
import { config, CURSOR_AGENT_COMMAND, CURSOR_AGENT_ARGS, RAW_STREAM_LOGS_DIR } from "../config.ts";
|
|
24
24
|
import {
|
|
25
25
|
defaultCursorSessionMetaStore,
|
|
26
26
|
type CursorSessionMetaStore,
|
|
27
27
|
} from "./cursor-session-meta-store.ts";
|
|
28
|
-
import { killProcessTree } from "./proc-tree-kill.ts";
|
|
28
|
+
import { killProcessTree } from "./proc-tree-kill.ts";
|
|
29
|
+
import {
|
|
30
|
+
createRawStreamLog,
|
|
31
|
+
type RawStreamLogHandle,
|
|
32
|
+
} from "./raw-stream-log.ts";
|
|
29
33
|
|
|
30
34
|
// ---------------------------------------------------------------------------
|
|
31
35
|
// 特殊注入提示
|
|
@@ -344,11 +348,12 @@ function spawnAgent(
|
|
|
344
348
|
return proc;
|
|
345
349
|
}
|
|
346
350
|
|
|
347
|
-
async function* readJsonLines(
|
|
348
|
-
proc: ChildProcess,
|
|
349
|
-
signal?: AbortSignal,
|
|
350
|
-
debugTag?: string,
|
|
351
|
-
|
|
351
|
+
async function* readJsonLines(
|
|
352
|
+
proc: ChildProcess,
|
|
353
|
+
signal?: AbortSignal,
|
|
354
|
+
debugTag?: string,
|
|
355
|
+
rawLog?: RawStreamLogHandle | null,
|
|
356
|
+
): AsyncGenerator<CursorMessageLine> {
|
|
352
357
|
const tag = debugTag ?? "cursor";
|
|
353
358
|
const rl = createInterface({ input: proc.stdout!, crlfDelay: Infinity });
|
|
354
359
|
// abort 时主动 close readline,避免等待 Windows 管道自然关闭(可能延迟数分钟)
|
|
@@ -359,9 +364,10 @@ async function* readJsonLines(
|
|
|
359
364
|
for await (const line of rl) {
|
|
360
365
|
if (signal?.aborted) break;
|
|
361
366
|
lineCount++;
|
|
362
|
-
const trimmed = line.trim();
|
|
363
|
-
if (!trimmed) continue;
|
|
364
|
-
|
|
367
|
+
const trimmed = line.trim();
|
|
368
|
+
if (!trimmed) continue;
|
|
369
|
+
rawLog?.writeLine(trimmed);
|
|
370
|
+
try {
|
|
365
371
|
yield JSON.parse(trimmed) as CursorMessageLine;
|
|
366
372
|
} catch { /* 非 JSON 行静默跳过 */ }
|
|
367
373
|
}
|
|
@@ -426,15 +432,32 @@ class CursorAdapter implements ToolAdapter {
|
|
|
426
432
|
cmd.mode ?? undefined,
|
|
427
433
|
);
|
|
428
434
|
this.activeProcs.add(proc);
|
|
429
|
-
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
435
|
+
if (proc.pid !== undefined) options?.onProcessStart?.({ pid: proc.pid });
|
|
436
|
+
|
|
437
|
+
const rawLogConfig = config.rawStreamLogs.cursor;
|
|
438
|
+
let rawLog: RawStreamLogHandle | null = null;
|
|
439
|
+
try {
|
|
440
|
+
rawLog = await createRawStreamLog({
|
|
441
|
+
enabled: rawLogConfig.enabled,
|
|
442
|
+
rootDir: RAW_STREAM_LOGS_DIR,
|
|
443
|
+
tool: "cursor",
|
|
444
|
+
sessionId,
|
|
445
|
+
label: "prompt",
|
|
446
|
+
maxBytesPerTurn: rawLogConfig.maxBytesPerTurn,
|
|
447
|
+
retentionDays: rawLogConfig.retentionDays,
|
|
448
|
+
});
|
|
449
|
+
} catch (err) {
|
|
450
|
+
console.error(`[Cursor raw stream log] create failed: ${(err as Error).message}`);
|
|
451
|
+
}
|
|
430
452
|
|
|
431
453
|
// 见 codex-adapter.ts 同位置注释:spawn 用了 shell:true,必须杀整棵树,
|
|
432
454
|
// 否则 abort 后真正在跑的孙进程 cursor-agent 还会继续输出 & 占用资源。
|
|
433
|
-
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
434
|
-
signal?.addEventListener("abort", onAbort, { once: true });
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
455
|
+
const onAbort = () => { void killProcessTree(proc.pid); };
|
|
456
|
+
signal?.addEventListener("abort", onAbort, { once: true });
|
|
457
|
+
let sawResult = false;
|
|
458
|
+
|
|
459
|
+
try {
|
|
460
|
+
for await (const raw of readJsonLines(proc, signal, sessionId, rawLog)) {
|
|
438
461
|
if (signal?.aborted) break;
|
|
439
462
|
if (
|
|
440
463
|
raw.type === "system" &&
|
|
@@ -450,15 +473,17 @@ class CursorAdapter implements ToolAdapter {
|
|
|
450
473
|
if (normalized) yield normalized;
|
|
451
474
|
|
|
452
475
|
// result 是流末事件,收到后立即结束进程,防止 CLI 僵死导致 readline 挂起。
|
|
453
|
-
if (raw.type === "result") {
|
|
454
|
-
|
|
476
|
+
if (raw.type === "result") {
|
|
477
|
+
sawResult = true;
|
|
478
|
+
void killProcessTree(proc.pid);
|
|
455
479
|
break;
|
|
456
480
|
}
|
|
457
481
|
}
|
|
458
482
|
} finally {
|
|
459
483
|
signal?.removeEventListener("abort", onAbort);
|
|
460
|
-
await killProcessTree(proc.pid);
|
|
461
|
-
|
|
484
|
+
await killProcessTree(proc.pid);
|
|
485
|
+
await rawLog?.close({ keep: rawLogConfig.keepCompleted || signal?.aborted === true || !sawResult });
|
|
486
|
+
this.activeProcs.delete(proc);
|
|
462
487
|
if (proc.pid !== undefined) options?.onProcessExit?.({ pid: proc.pid });
|
|
463
488
|
console.log(`[Cursor debug] prompt end: sessionId=${sessionId}, signalAborted=${signal?.aborted ?? false}`);
|
|
464
489
|
}
|
|
@@ -494,4 +519,4 @@ export function createCursorAdapter(
|
|
|
494
519
|
options: CreateCursorAdapterOptions = {},
|
|
495
520
|
): ToolAdapter {
|
|
496
521
|
return new CursorAdapter(options.metaStore ?? defaultCursorSessionMetaStore, options.model);
|
|
497
|
-
}
|
|
522
|
+
}
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { createWriteStream, type Dirent } from "node:fs";
|
|
2
|
+
import { mkdir, readdir, rm, stat, unlink } from "node:fs/promises";
|
|
3
|
+
import { once } from "node:events";
|
|
4
|
+
import { join } from "node:path";
|
|
5
|
+
import { createGzip } from "node:zlib";
|
|
6
|
+
|
|
7
|
+
export interface RawStreamLogOptions {
|
|
8
|
+
enabled: boolean;
|
|
9
|
+
rootDir: string;
|
|
10
|
+
tool: string;
|
|
11
|
+
sessionId: string;
|
|
12
|
+
label: string;
|
|
13
|
+
maxBytesPerTurn: number;
|
|
14
|
+
retentionDays: number;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export interface RawStreamLogHandle {
|
|
18
|
+
filePath: string;
|
|
19
|
+
writeLine(line: string): void;
|
|
20
|
+
close(options: { keep: boolean }): Promise<void>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function sanitizeLogPathSegment(value: string): string {
|
|
24
|
+
const safe = value.replace(/[^a-zA-Z0-9._-]+/g, "_").replace(/^[._]+|_+$/g, "");
|
|
25
|
+
return safe || "unknown";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async function cleanupOldRawStreamLogs(rootDir: string, retentionDays: number): Promise<void> {
|
|
29
|
+
if (!Number.isFinite(retentionDays) || retentionDays <= 0) return;
|
|
30
|
+
const cutoff = Date.now() - retentionDays * 24 * 60 * 60 * 1000;
|
|
31
|
+
|
|
32
|
+
const visit = async (dir: string): Promise<void> => {
|
|
33
|
+
let entries: Dirent[];
|
|
34
|
+
try {
|
|
35
|
+
entries = await readdir(dir, { withFileTypes: true });
|
|
36
|
+
} catch {
|
|
37
|
+
return;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
await Promise.all(entries.map(async (entry) => {
|
|
41
|
+
const path = join(dir, entry.name);
|
|
42
|
+
if (entry.isDirectory()) {
|
|
43
|
+
await visit(path);
|
|
44
|
+
try {
|
|
45
|
+
await rm(path, { recursive: false });
|
|
46
|
+
} catch {
|
|
47
|
+
// Directory is not empty or already gone.
|
|
48
|
+
}
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if (!entry.isFile()) return;
|
|
53
|
+
try {
|
|
54
|
+
const info = await stat(path);
|
|
55
|
+
if (info.mtimeMs < cutoff) await rm(path, { force: true });
|
|
56
|
+
} catch {
|
|
57
|
+
// Best-effort cleanup only.
|
|
58
|
+
}
|
|
59
|
+
}));
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
await visit(rootDir);
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export async function createRawStreamLog(options: RawStreamLogOptions): Promise<RawStreamLogHandle | null> {
|
|
66
|
+
if (!options.enabled) return null;
|
|
67
|
+
|
|
68
|
+
const maxBytes = Math.max(0, Math.floor(options.maxBytesPerTurn));
|
|
69
|
+
const tool = sanitizeLogPathSegment(options.tool);
|
|
70
|
+
const session = sanitizeLogPathSegment(options.sessionId);
|
|
71
|
+
const label = sanitizeLogPathSegment(options.label);
|
|
72
|
+
const dir = join(options.rootDir, tool, session);
|
|
73
|
+
await mkdir(dir, { recursive: true });
|
|
74
|
+
void cleanupOldRawStreamLogs(join(options.rootDir, tool), options.retentionDays);
|
|
75
|
+
|
|
76
|
+
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
|
|
77
|
+
const filePath = join(dir, `${timestamp}-${label}.jsonl.gz`);
|
|
78
|
+
const output = createWriteStream(filePath);
|
|
79
|
+
const gzip = createGzip();
|
|
80
|
+
gzip.pipe(output);
|
|
81
|
+
|
|
82
|
+
let bytes = 0;
|
|
83
|
+
let truncated = false;
|
|
84
|
+
let ended = false;
|
|
85
|
+
|
|
86
|
+
const writeLine = (line: string): void => {
|
|
87
|
+
if (ended || truncated) return;
|
|
88
|
+
const payload = `${line}\n`;
|
|
89
|
+
const payloadBytes = Buffer.byteLength(payload, "utf-8");
|
|
90
|
+
if (maxBytes > 0 && bytes + payloadBytes > maxBytes) {
|
|
91
|
+
const marker = JSON.stringify({
|
|
92
|
+
type: "chatccc_raw_stream_log_truncated",
|
|
93
|
+
reason: "max_bytes_per_turn_exceeded",
|
|
94
|
+
maxBytesPerTurn: maxBytes,
|
|
95
|
+
writtenBytes: bytes,
|
|
96
|
+
});
|
|
97
|
+
gzip.write(`${marker}\n`);
|
|
98
|
+
truncated = true;
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
bytes += payloadBytes;
|
|
102
|
+
gzip.write(payload);
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
const close = async ({ keep }: { keep: boolean }): Promise<void> => {
|
|
106
|
+
if (ended) return;
|
|
107
|
+
ended = true;
|
|
108
|
+
gzip.end();
|
|
109
|
+
try {
|
|
110
|
+
await once(output, "finish");
|
|
111
|
+
} catch {
|
|
112
|
+
// Ignore close errors; caller cannot recover from debug log failures.
|
|
113
|
+
}
|
|
114
|
+
if (!keep) {
|
|
115
|
+
try {
|
|
116
|
+
await unlink(filePath);
|
|
117
|
+
} catch {
|
|
118
|
+
// Best-effort cleanup only.
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
return { filePath, writeLine, close };
|
|
124
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -39,9 +39,10 @@ export const USER_DATA_DIR = join(homedir(), ".chatccc");
|
|
|
39
39
|
export const PID_FILE = join(USER_DATA_DIR, "state", "runtime.pid");
|
|
40
40
|
|
|
41
41
|
export const LOG_DIR = join(USER_DATA_DIR, "logs");
|
|
42
|
-
export const fileLog = setupFileLogging(LOG_DIR, "index");
|
|
43
|
-
|
|
44
|
-
export const CHAT_LOGS_DIR = join(USER_DATA_DIR, "state", "chat_logs");
|
|
42
|
+
export const fileLog = setupFileLogging(LOG_DIR, "index");
|
|
43
|
+
|
|
44
|
+
export const CHAT_LOGS_DIR = join(USER_DATA_DIR, "state", "chat_logs");
|
|
45
|
+
export const RAW_STREAM_LOGS_DIR = join(LOG_DIR, "raw-streams");
|
|
45
46
|
|
|
46
47
|
export async function appendChatLog(chatId: string, sender: string, text: string): Promise<void> {
|
|
47
48
|
try {
|
|
@@ -113,27 +114,41 @@ export interface PlatformsConfig {
|
|
|
113
114
|
ilink: PlatformConfig;
|
|
114
115
|
}
|
|
115
116
|
|
|
116
|
-
export interface ChromeDevtoolsConfig {
|
|
117
|
+
export interface ChromeDevtoolsConfig {
|
|
117
118
|
/** 是否由 ChatCCC 守护一个常驻 Chrome CDP 实例 */
|
|
118
119
|
enabled: boolean;
|
|
119
120
|
/** Chrome remote debugging 端口,默认 15166 */
|
|
120
121
|
port: number;
|
|
121
122
|
/** Chrome 可执行文件路径;留空时按常见安装位置自动探测 */
|
|
122
|
-
chromePath: string;
|
|
123
|
-
}
|
|
124
|
-
|
|
125
|
-
export interface
|
|
123
|
+
chromePath: string;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export interface RawStreamAgentLogConfig {
|
|
127
|
+
enabled: boolean;
|
|
128
|
+
maxBytesPerTurn: number;
|
|
129
|
+
retentionDays: number;
|
|
130
|
+
keepCompleted: boolean;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
export interface RawStreamLogsConfig {
|
|
134
|
+
claude: RawStreamAgentLogConfig;
|
|
135
|
+
cursor: RawStreamAgentLogConfig;
|
|
136
|
+
codex: RawStreamAgentLogConfig;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export interface AppConfig {
|
|
126
140
|
feishu: FeishuConfig;
|
|
127
141
|
platforms: PlatformsConfig;
|
|
128
142
|
chromeDevtools: ChromeDevtoolsConfig;
|
|
129
143
|
port: number;
|
|
130
|
-
gitTimeoutSeconds: number;
|
|
131
|
-
/** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
|
|
132
|
-
allowInterrupt: boolean;
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
144
|
+
gitTimeoutSeconds: number;
|
|
145
|
+
/** 若为 false,AI 生成过程中用户发送消息不会打断,须先点「停止」再发送新消息 */
|
|
146
|
+
allowInterrupt: boolean;
|
|
147
|
+
rawStreamLogs: RawStreamLogsConfig;
|
|
148
|
+
claude: ClaudeConfig;
|
|
149
|
+
cursor: CursorConfig;
|
|
150
|
+
codex: CodexConfig;
|
|
151
|
+
}
|
|
137
152
|
|
|
138
153
|
export type AgentTool = "claude" | "cursor" | "codex";
|
|
139
154
|
export const AGENT_TOOLS: AgentTool[] = ["claude", "cursor", "codex"];
|
|
@@ -343,20 +358,42 @@ function normalizeCursorAvatarBatteryMode(raw: unknown): CursorAvatarBatteryMode
|
|
|
343
358
|
return raw === "onDemandUse" ? "onDemandUse" : "apiPercent";
|
|
344
359
|
}
|
|
345
360
|
|
|
346
|
-
function normalizeCursorOnDemandMonthlyBudget(raw: unknown): number {
|
|
347
|
-
const value = typeof raw === "string" ? Number(raw.trim()) : Number(raw);
|
|
348
|
-
return Number.isFinite(value) && value > 0 ? value : 1000;
|
|
349
|
-
}
|
|
350
|
-
|
|
351
|
-
function
|
|
352
|
-
const
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
361
|
+
function normalizeCursorOnDemandMonthlyBudget(raw: unknown): number {
|
|
362
|
+
const value = typeof raw === "string" ? Number(raw.trim()) : Number(raw);
|
|
363
|
+
return Number.isFinite(value) && value > 0 ? value : 1000;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
function normalizePositiveInteger(raw: unknown, fallback: number): number {
|
|
367
|
+
const value = typeof raw === "string" ? Number(raw.trim()) : Number(raw);
|
|
368
|
+
return Number.isInteger(value) && value > 0 ? value : fallback;
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
function normalizeRawStreamAgentLogConfig(raw: unknown): RawStreamAgentLogConfig {
|
|
372
|
+
const obj = typeof raw === "object" && raw !== null
|
|
373
|
+
? raw as Record<string, unknown>
|
|
374
|
+
: {};
|
|
375
|
+
return {
|
|
376
|
+
enabled: typeof obj.enabled === "boolean" ? obj.enabled : false,
|
|
377
|
+
maxBytesPerTurn: normalizePositiveInteger(obj.maxBytesPerTurn, 50 * 1024 * 1024),
|
|
378
|
+
retentionDays: normalizePositiveInteger(obj.retentionDays, 7),
|
|
379
|
+
keepCompleted: typeof obj.keepCompleted === "boolean" ? obj.keepCompleted : false,
|
|
380
|
+
};
|
|
381
|
+
}
|
|
382
|
+
|
|
383
|
+
function loadConfig(): AppConfig {
|
|
384
|
+
const defaults: AppConfig = {
|
|
385
|
+
feishu: { appId: "", appSecret: "" },
|
|
386
|
+
platforms: { feishu: { enabled: true }, ilink: { enabled: true } },
|
|
387
|
+
chromeDevtools: { enabled: false, port: 15166, chromePath: "" },
|
|
388
|
+
port: 18080,
|
|
389
|
+
gitTimeoutSeconds: 180,
|
|
390
|
+
allowInterrupt: false,
|
|
391
|
+
rawStreamLogs: {
|
|
392
|
+
claude: { enabled: false, maxBytesPerTurn: 50 * 1024 * 1024, retentionDays: 7, keepCompleted: false },
|
|
393
|
+
cursor: { enabled: false, maxBytesPerTurn: 50 * 1024 * 1024, retentionDays: 7, keepCompleted: false },
|
|
394
|
+
codex: { enabled: false, maxBytesPerTurn: 50 * 1024 * 1024, retentionDays: 7, keepCompleted: false },
|
|
395
|
+
},
|
|
396
|
+
claude: { enabled: false, defaultAgent: true, model: "", subagentModel: "", effort: "", apiKey: "", baseUrl: "", maxTurn: 0 },
|
|
360
397
|
cursor: {
|
|
361
398
|
enabled: false,
|
|
362
399
|
defaultAgent: false,
|
|
@@ -416,9 +453,10 @@ function loadConfig(): AppConfig {
|
|
|
416
453
|
avatarBatteryMode?: unknown;
|
|
417
454
|
onDemandMonthlyBudget?: unknown;
|
|
418
455
|
};
|
|
419
|
-
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
|
|
420
|
-
chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
|
|
421
|
-
|
|
456
|
+
codex?: { enabled?: unknown; defaultAgent?: unknown; path?: unknown; command?: unknown; model?: unknown; effort?: unknown };
|
|
457
|
+
chromeDevtools?: { enabled?: unknown; port?: unknown; chromePath?: unknown };
|
|
458
|
+
rawStreamLogs?: unknown;
|
|
459
|
+
};
|
|
422
460
|
try {
|
|
423
461
|
parsed = JSON.parse(raw);
|
|
424
462
|
} catch (err) {
|
|
@@ -428,9 +466,12 @@ function loadConfig(): AppConfig {
|
|
|
428
466
|
|
|
429
467
|
const feishu = parsed.feishu ?? { appId: "", appSecret: "" };
|
|
430
468
|
const claude = parsed.claude ?? {} as Partial<ClaudeConfig>;
|
|
431
|
-
const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
|
|
432
|
-
const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
|
|
433
|
-
const chromeDevtoolsRaw = (parsed.chromeDevtools ?? {}) as NonNullable<typeof parsed.chromeDevtools>;
|
|
469
|
+
const cursorRaw = (parsed.cursor ?? {}) as NonNullable<typeof parsed.cursor>;
|
|
470
|
+
const codexRaw = (parsed.codex ?? {}) as NonNullable<typeof parsed.codex>;
|
|
471
|
+
const chromeDevtoolsRaw = (parsed.chromeDevtools ?? {}) as NonNullable<typeof parsed.chromeDevtools>;
|
|
472
|
+
const rawStreamLogsRaw = typeof parsed.rawStreamLogs === "object" && parsed.rawStreamLogs !== null
|
|
473
|
+
? parsed.rawStreamLogs as unknown as Record<string, unknown>
|
|
474
|
+
: {};
|
|
434
475
|
|
|
435
476
|
// 兼容旧字段 `command`:命中时打印一次性 warning 提示用户改名
|
|
436
477
|
const onLegacyField = (label: string, value: string): void => {
|
|
@@ -519,10 +560,15 @@ function loadConfig(): AppConfig {
|
|
|
519
560
|
: 15166,
|
|
520
561
|
chromePath: normalizeOptionalConfigField(chromeDevtoolsRaw.chromePath, { label: "chromeDevtools.chromePath" }),
|
|
521
562
|
},
|
|
522
|
-
port: typeof parsed.port === "number" ? parsed.port : 18080,
|
|
523
|
-
gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
|
|
524
|
-
allowInterrupt: typeof parsed.allowInterrupt === "boolean" ? parsed.allowInterrupt : false,
|
|
525
|
-
|
|
563
|
+
port: typeof parsed.port === "number" ? parsed.port : 18080,
|
|
564
|
+
gitTimeoutSeconds: typeof parsed.gitTimeoutSeconds === "number" ? parsed.gitTimeoutSeconds : 180,
|
|
565
|
+
allowInterrupt: typeof parsed.allowInterrupt === "boolean" ? parsed.allowInterrupt : false,
|
|
566
|
+
rawStreamLogs: {
|
|
567
|
+
claude: normalizeRawStreamAgentLogConfig(rawStreamLogsRaw.claude),
|
|
568
|
+
cursor: normalizeRawStreamAgentLogConfig(rawStreamLogsRaw.cursor),
|
|
569
|
+
codex: normalizeRawStreamAgentLogConfig(rawStreamLogsRaw.codex),
|
|
570
|
+
},
|
|
571
|
+
claude: {
|
|
526
572
|
enabled: claudeEnabled,
|
|
527
573
|
defaultAgent: defaultTool === "claude",
|
|
528
574
|
model: normalizeOptionalConfigField(claude.model, { label: "claude.model" }),
|