chatccc 0.2.242 → 0.2.243

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.
Files changed (56) hide show
  1. package/README.md +5 -5
  2. package/bin/cccagent.mjs +17 -17
  3. package/deepccc-agent/bin/deepccc.mjs +26 -26
  4. package/deepccc-agent/package.json +62 -62
  5. package/deepccc-agent/src/__tests__/chat-session.test.ts +578 -522
  6. package/deepccc-agent/src/__tests__/cli-json.test.ts +49 -49
  7. package/deepccc-agent/src/__tests__/config.test.ts +26 -26
  8. package/deepccc-agent/src/__tests__/context.test.ts +319 -319
  9. package/deepccc-agent/src/__tests__/file-tools.test.ts +240 -240
  10. package/deepccc-agent/src/__tests__/permissions.test.ts +195 -195
  11. package/deepccc-agent/src/__tests__/progress-reducer.test.ts +121 -121
  12. package/deepccc-agent/src/__tests__/session-search.test.ts +262 -262
  13. package/deepccc-agent/src/__tests__/session-select.test.ts +116 -116
  14. package/deepccc-agent/src/__tests__/sigint.test.ts +56 -56
  15. package/deepccc-agent/src/__tests__/skills.test.ts +284 -284
  16. package/deepccc-agent/src/__tests__/terminal-renderer.test.ts +247 -247
  17. package/deepccc-agent/src/__tests__/web-tools.test.ts +220 -220
  18. package/deepccc-agent/src/config.ts +84 -84
  19. package/deepccc-agent/src/context.ts +465 -465
  20. package/deepccc-agent/src/file-log.ts +38 -38
  21. package/deepccc-agent/src/index.ts +22 -0
  22. package/deepccc-agent/src/proc-tree-kill.ts +61 -61
  23. package/deepccc-agent/src/progress/cards-helpers.ts +76 -76
  24. package/deepccc-agent/src/progress/reducer.ts +113 -113
  25. package/deepccc-agent/src/progress/terminal-renderer.ts +294 -294
  26. package/deepccc-agent/src/progress/view.ts +77 -77
  27. package/deepccc-agent/src/raw-stream-log.ts +124 -124
  28. package/deepccc-agent/src/session-search.ts +370 -370
  29. package/deepccc-agent/src/session-select.ts +48 -48
  30. package/deepccc-agent/src/sigint.ts +50 -50
  31. package/deepccc-agent/src/skills.ts +205 -205
  32. package/deepccc-agent/src/web-tools.ts +313 -313
  33. package/deepccc-agent/tsconfig.build.json +13 -13
  34. package/deepccc-agent/tsconfig.json +13 -13
  35. package/deepccc-agent/vitest.config.ts +7 -7
  36. package/package.json +1 -1
  37. package/src/__tests__/builtin-chat-session.test.ts +522 -522
  38. package/src/__tests__/builtin-config.test.ts +26 -26
  39. package/src/__tests__/builtin-context.test.ts +319 -319
  40. package/src/__tests__/builtin-file-tools.test.ts +240 -240
  41. package/src/__tests__/builtin-permissions.test.ts +211 -211
  42. package/src/__tests__/builtin-session-search.test.ts +262 -262
  43. package/src/__tests__/builtin-session-select.test.ts +116 -116
  44. package/src/__tests__/builtin-sigint.test.ts +56 -56
  45. package/src/__tests__/builtin-skills.test.ts +284 -284
  46. package/src/__tests__/builtin-web-tools.test.ts +220 -220
  47. package/src/__tests__/config.test.ts +17 -17
  48. package/src/__tests__/progress-reducer.test.ts +121 -121
  49. package/src/__tests__/session-ccc-config.test.ts +45 -45
  50. package/src/__tests__/session.test.ts +298 -298
  51. package/src/adapters/ccc-adapter.ts +145 -145
  52. package/src/config-utils.ts +13 -13
  53. package/src/config.ts +13 -13
  54. package/src/progress/reducer.ts +113 -113
  55. package/src/session-chat-binding.ts +83 -83
  56. package/src/session.ts +311 -311
@@ -1,370 +1,370 @@
1
- /**
2
- * session-search.ts — DeepCCC 历史会话关键词检索
3
- *
4
- * 供 agent 通过 session_search 工具按需查找很久以前的原始消息/工具调用:
5
- * - 主数据源:~/.deepccc/sessions/<sessionId>/context.json(明文 JSON,含 summary、
6
- * messages.content 与结构化 toolCalls)
7
- * - 可选数据源:~/.deepccc/raw-stream-logs/deepccc/<sessionId>/*.jsonl.gz(gzip 原始流,
8
- * 逐行解压检索,默认关闭)
9
- *
10
- * 纯关键词匹配(多词 AND、大小写不敏感),不依赖向量索引。
11
- */
12
-
13
- import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
- import { join } from "node:path";
15
- import { createInterface } from "node:readline";
16
- import { createGunzip } from "node:zlib";
17
-
18
- import { RAW_STREAM_LOGS_DIR } from "./config.js";
19
- import { DEFAULT_BUILTIN_CONTEXT_DIR, type BuiltinContextRole } from "./context.js";
20
-
21
- export interface SessionSearchOptions {
22
- /** 会话目录,默认 ~/.deepccc/sessions */
23
- contextDir?: string;
24
- /** raw-stream-logs 根目录,默认 ~/.deepccc/raw-stream-logs */
25
- rawLogsDir?: string;
26
- /** 是否搜索 gzip 原始流日志(默认 false,较慢) */
27
- includeRawLogs?: boolean;
28
- /** 只搜索指定 sessionId(目录名或 state.sessionId 匹配) */
29
- sessionId?: string;
30
- /** 结果上限(默认 20,上限 50) */
31
- maxResults?: number;
32
- /** 单条命中片段最大字符数(默认 400) */
33
- maxSnippetChars?: number;
34
- /** 单个 raw log 文件最多解压检索的字节数(默认 4MB) */
35
- maxRawLogBytesPerFile?: number;
36
- }
37
-
38
- export interface SessionSearchInput {
39
- query: string;
40
- session_id?: string;
41
- include_raw_logs?: boolean;
42
- max_results?: number;
43
- }
44
-
45
- export interface SessionSearchMatch {
46
- sessionId: string;
47
- source: "context" | "summary" | "raw-log";
48
- role?: BuiltinContextRole;
49
- /** 在 messages 数组中的下标 */
50
- messageIndex?: number;
51
- /** 在 message.toolCalls 数组中的下标(仅结构化工具调用命中时) */
52
- toolCallIndex?: number;
53
- toolCallName?: string;
54
- snippet: string;
55
- filePath: string;
56
- }
57
-
58
- export interface SessionSearchOutput {
59
- query: string;
60
- terms: string[];
61
- matches: SessionSearchMatch[];
62
- truncated: boolean;
63
- scannedSessions: number;
64
- scannedRawLogFiles: number;
65
- }
66
-
67
- const DEFAULT_MAX_RESULTS = 20;
68
- const MAX_RESULTS_CAP = 50;
69
- const DEFAULT_MAX_SNIPPET_CHARS = 400;
70
- const MAX_SNIPPET_CHARS_CAP = 2_000;
71
- const DEFAULT_MAX_RAW_LOG_BYTES_PER_FILE = 4 * 1024 * 1024;
72
-
73
- function clamp(value: number, min: number, max: number): number {
74
- return Math.min(max, Math.max(min, value));
75
- }
76
-
77
- function tokenize(query: string): string[] {
78
- return query
79
- .toLowerCase()
80
- .split(/\s+/)
81
- .map((term) => term.trim())
82
- .filter((term) => term.length > 0);
83
- }
84
-
85
- /** 多词 AND、大小写不敏感:候选文本必须包含全部关键词 */
86
- function matchesTerms(text: string, terms: readonly string[]): boolean {
87
- const lower = text.toLowerCase();
88
- return terms.every((term) => lower.includes(term));
89
- }
90
-
91
- function makeSnippet(text: string, terms: readonly string[], maxChars: number): string {
92
- if (text.length <= maxChars) return text;
93
- const lower = text.toLowerCase();
94
- let hitIndex = -1;
95
- for (const term of terms) {
96
- const index = lower.indexOf(term);
97
- if (index !== -1) {
98
- hitIndex = index;
99
- break;
100
- }
101
- }
102
- const half = Math.floor(maxChars / 2);
103
- if (hitIndex === -1) {
104
- return `${text.slice(0, half)}…${text.slice(-half)}`;
105
- }
106
- const start = Math.max(0, hitIndex - half);
107
- const end = Math.min(text.length, hitIndex + half);
108
- const prefix = start > 0 ? "…" : "";
109
- const suffix = end < text.length ? "…" : "";
110
- return `${prefix}${text.slice(start, end)}${suffix}`;
111
- }
112
-
113
- interface ContextSearchResult {
114
- matches: SessionSearchMatch[];
115
- scannedSessions: number;
116
- /** 命中总数(含被 maxResults 截断的部分),用于精确的 truncated 判定 */
117
- totalHits: number;
118
- }
119
-
120
- function searchContextDir(
121
- query: string,
122
- options: SessionSearchOptions,
123
- maxResults: number,
124
- maxSnippetChars: number,
125
- ): ContextSearchResult {
126
- const dir = options.contextDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
127
- const terms = tokenize(query);
128
- const matches: SessionSearchMatch[] = [];
129
- if (terms.length === 0 || !existsSync(dir)) {
130
- return { matches, scannedSessions: 0, totalHits: 0 };
131
- }
132
-
133
- const restrictTo = options.sessionId?.trim();
134
-
135
- let scannedSessions = 0;
136
- let totalHits = 0;
137
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
138
- if (!entry.isDirectory()) continue;
139
- if (restrictTo && entry.name !== restrictTo) continue;
140
- const filePath = join(dir, entry.name, "context.json");
141
- if (!existsSync(filePath)) continue;
142
-
143
- let state: {
144
- sessionId?: unknown;
145
- summary?: unknown;
146
- messages?: unknown;
147
- };
148
- try {
149
- state = JSON.parse(readFileSync(filePath, "utf8")) as typeof state;
150
- } catch {
151
- continue; // 损坏或非 JSON 的会话文件跳过
152
- }
153
- if (!state || typeof state !== "object") continue;
154
- if (restrictTo && state.sessionId !== restrictTo && entry.name !== restrictTo) continue;
155
- scannedSessions += 1;
156
- const sessionId = typeof state.sessionId === "string" ? state.sessionId : entry.name;
157
-
158
- if (typeof state.summary === "string" && matchesTerms(state.summary, terms)) {
159
- totalHits += 1;
160
- matches.push({
161
- sessionId,
162
- source: "summary",
163
- snippet: makeSnippet(state.summary, terms, maxSnippetChars),
164
- filePath,
165
- });
166
- }
167
-
168
- if (Array.isArray(state.messages)) {
169
- state.messages.forEach((message, messageIndex) => {
170
- if (!message || typeof message !== "object") return;
171
- const raw = message as { role?: unknown; content?: unknown; toolCalls?: unknown };
172
- const role: BuiltinContextRole | undefined =
173
- raw.role === "user" || raw.role === "assistant" ? raw.role : undefined;
174
-
175
- if (typeof raw.content === "string" && matchesTerms(raw.content, terms)) {
176
- totalHits += 1;
177
- matches.push({
178
- sessionId,
179
- source: "context",
180
- role,
181
- messageIndex,
182
- snippet: makeSnippet(raw.content, terms, maxSnippetChars),
183
- filePath,
184
- });
185
- }
186
-
187
- if (Array.isArray(raw.toolCalls)) {
188
- raw.toolCalls.forEach((call, toolCallIndex) => {
189
- if (!call || typeof call !== "object") return;
190
- const callRaw = call as { name?: unknown; input?: unknown; output?: unknown };
191
- const name = typeof callRaw.name === "string" ? callRaw.name : "";
192
- const input = typeof callRaw.input === "string" ? callRaw.input : "";
193
- const output = typeof callRaw.output === "string" ? callRaw.output : "";
194
- if (name.length === 0 && input.length === 0 && output.length === 0) return;
195
- const haystack = [name, input, output].join("\n");
196
- if (!matchesTerms(haystack, terms)) return;
197
- totalHits += 1;
198
- const source = output || input || name;
199
- matches.push({
200
- sessionId,
201
- source: "context",
202
- role,
203
- messageIndex,
204
- toolCallIndex,
205
- toolCallName: name,
206
- snippet: makeSnippet(source, terms, maxSnippetChars),
207
- filePath,
208
- });
209
- });
210
- }
211
- });
212
- }
213
- }
214
-
215
- return { matches: matches.slice(0, maxResults), scannedSessions, totalHits };
216
- }
217
-
218
- interface RawLogSearchResult {
219
- matches: SessionSearchMatch[];
220
- scannedFiles: number;
221
- /** raw log 流式解压提前截断(达到 maxResults 停止扫描剩余文件) */
222
- truncated: boolean;
223
- }
224
-
225
- async function searchRawLogs(
226
- query: string,
227
- options: SessionSearchOptions,
228
- maxResults: number,
229
- maxSnippetChars: number,
230
- ): Promise<RawLogSearchResult> {
231
- const terms = tokenize(query);
232
- const rootDir = options.rawLogsDir ?? RAW_STREAM_LOGS_DIR;
233
- const matches: SessionSearchMatch[] = [];
234
- if (terms.length === 0 || !existsSync(rootDir)) {
235
- return { matches, scannedFiles: 0, truncated: false };
236
- }
237
-
238
- const toolRoot = join(rootDir, "deepccc");
239
- if (!existsSync(toolRoot)) return { matches, scannedFiles: 0, truncated: false };
240
-
241
- const restrictTo = options.sessionId?.trim();
242
- const maxBytesPerFile = Math.max(0, options.maxRawLogBytesPerFile ?? DEFAULT_MAX_RAW_LOG_BYTES_PER_FILE);
243
-
244
- // 收集所有 .jsonl.gz 文件,按 mtime 新→旧排序(最新轮次优先)
245
- const files: { path: string; sessionId: string; mtimeMs: number }[] = [];
246
- for (const sessionEntry of readdirSync(toolRoot, { withFileTypes: true })) {
247
- if (!sessionEntry.isDirectory()) continue;
248
- if (restrictTo && sessionEntry.name !== restrictTo) continue;
249
- const sessionDir = join(toolRoot, sessionEntry.name);
250
- let entries;
251
- try {
252
- entries = readdirSync(sessionDir, { withFileTypes: true });
253
- } catch {
254
- continue;
255
- }
256
- for (const fileEntry of entries) {
257
- if (!fileEntry.isFile() || !fileEntry.name.endsWith(".jsonl.gz")) continue;
258
- const filePath = join(sessionDir, fileEntry.name);
259
- try {
260
- const info = statSync(filePath);
261
- files.push({ path: filePath, sessionId: sessionEntry.name, mtimeMs: info.mtimeMs });
262
- } catch {
263
- // 无法 stat 的文件跳过
264
- }
265
- }
266
- }
267
- files.sort((a, b) => b.mtimeMs - a.mtimeMs);
268
-
269
- let scannedFiles = 0;
270
- let truncated = false;
271
- for (const file of files) {
272
- if (matches.length >= maxResults) {
273
- truncated = true;
274
- break;
275
- }
276
- scannedFiles += 1;
277
- const snippets = await searchGzipFileLines(file.path, terms, maxBytesPerFile, maxSnippetChars);
278
- for (const snippet of snippets) {
279
- matches.push({
280
- sessionId: file.sessionId,
281
- source: "raw-log",
282
- snippet,
283
- filePath: file.path,
284
- });
285
- if (matches.length >= maxResults) break;
286
- }
287
- }
288
-
289
- return { matches: matches.slice(0, maxResults), scannedFiles, truncated };
290
- }
291
-
292
- async function searchGzipFileLines(
293
- filePath: string,
294
- terms: readonly string[],
295
- maxBytes: number,
296
- maxSnippetChars: number,
297
- ): Promise<string[]> {
298
- const hits: string[] = [];
299
- let bytes = 0;
300
- try {
301
- const source = createReadStream(filePath);
302
- const gunzip = createGunzip();
303
- // 防崩溃(线上事故):截断/损坏的 gzip 会在 zlib 层 emit 'error'(如
304
- // "unexpected end of file")。若无人监听,错误事件会升级为 uncaughtException
305
- // 直接杀死整个服务(崩溃黑匣子 exit(1))。显式挂监听把错误降级为
306
- // “跳过该文件”:destroy 流让 for await 正常结束,不再冒泡。
307
- source.on("error", () => gunzip.destroy());
308
- gunzip.on("error", () => gunzip.destroy());
309
- const reader = createInterface({ input: source.pipe(gunzip), crlfDelay: Infinity });
310
- // readline 会把 input 流的 error 转发到自己身上,同样需要监听避免冒泡
311
- reader.on("error", () => {});
312
- for await (const line of reader) {
313
- bytes += Buffer.byteLength(line, "utf-8");
314
- if (bytes > maxBytes) break;
315
- if (!matchesTerms(line, terms)) continue;
316
- hits.push(makeSnippet(line, terms, maxSnippetChars));
317
- if (hits.length >= 50) break;
318
- }
319
- } catch {
320
- // 损坏的 gzip 文件按 best-effort 跳过
321
- }
322
- return hits;
323
- }
324
-
325
- /**
326
- * 关键词检索历史会话存档。context.json 为同步扫描;raw-stream-logs 为
327
- * gzip 逐行解压(异步,仅 options.includeRawLogs 时启用)。
328
- */
329
- export async function searchBuiltinSessions(
330
- query: string,
331
- options: SessionSearchOptions = {},
332
- ): Promise<SessionSearchOutput> {
333
- const terms = tokenize(query);
334
- const maxResults = clamp(options.maxResults ?? DEFAULT_MAX_RESULTS, 1, MAX_RESULTS_CAP);
335
- const maxSnippetChars = clamp(
336
- options.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS,
337
- 80,
338
- MAX_SNIPPET_CHARS_CAP,
339
- );
340
-
341
- if (terms.length === 0) {
342
- return {
343
- query,
344
- terms,
345
- matches: [],
346
- truncated: false,
347
- scannedSessions: 0,
348
- scannedRawLogFiles: 0,
349
- };
350
- }
351
-
352
- const contextResult = searchContextDir(query, options, maxResults, maxSnippetChars);
353
- let rawResult: RawLogSearchResult = { matches: [], scannedFiles: 0, truncated: false };
354
- if (options.includeRawLogs) {
355
- rawResult = await searchRawLogs(query, options, maxResults, maxSnippetChars);
356
- }
357
-
358
- const matches = [...contextResult.matches, ...rawResult.matches].slice(0, maxResults);
359
- const truncated =
360
- contextResult.totalHits > maxResults || rawResult.truncated || rawResult.matches.length > maxResults;
361
-
362
- return {
363
- query,
364
- terms,
365
- matches,
366
- truncated,
367
- scannedSessions: contextResult.scannedSessions,
368
- scannedRawLogFiles: rawResult.scannedFiles,
369
- };
370
- }
1
+ /**
2
+ * session-search.ts — DeepCCC 历史会话关键词检索
3
+ *
4
+ * 供 agent 通过 session_search 工具按需查找很久以前的原始消息/工具调用:
5
+ * - 主数据源:~/.deepccc/sessions/<sessionId>/context.json(明文 JSON,含 summary、
6
+ * messages.content 与结构化 toolCalls)
7
+ * - 可选数据源:~/.deepccc/raw-stream-logs/deepccc/<sessionId>/*.jsonl.gz(gzip 原始流,
8
+ * 逐行解压检索,默认关闭)
9
+ *
10
+ * 纯关键词匹配(多词 AND、大小写不敏感),不依赖向量索引。
11
+ */
12
+
13
+ import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
14
+ import { join } from "node:path";
15
+ import { createInterface } from "node:readline";
16
+ import { createGunzip } from "node:zlib";
17
+
18
+ import { RAW_STREAM_LOGS_DIR } from "./config.js";
19
+ import { DEFAULT_BUILTIN_CONTEXT_DIR, type BuiltinContextRole } from "./context.js";
20
+
21
+ export interface SessionSearchOptions {
22
+ /** 会话目录,默认 ~/.deepccc/sessions */
23
+ contextDir?: string;
24
+ /** raw-stream-logs 根目录,默认 ~/.deepccc/raw-stream-logs */
25
+ rawLogsDir?: string;
26
+ /** 是否搜索 gzip 原始流日志(默认 false,较慢) */
27
+ includeRawLogs?: boolean;
28
+ /** 只搜索指定 sessionId(目录名或 state.sessionId 匹配) */
29
+ sessionId?: string;
30
+ /** 结果上限(默认 20,上限 50) */
31
+ maxResults?: number;
32
+ /** 单条命中片段最大字符数(默认 400) */
33
+ maxSnippetChars?: number;
34
+ /** 单个 raw log 文件最多解压检索的字节数(默认 4MB) */
35
+ maxRawLogBytesPerFile?: number;
36
+ }
37
+
38
+ export interface SessionSearchInput {
39
+ query: string;
40
+ session_id?: string;
41
+ include_raw_logs?: boolean;
42
+ max_results?: number;
43
+ }
44
+
45
+ export interface SessionSearchMatch {
46
+ sessionId: string;
47
+ source: "context" | "summary" | "raw-log";
48
+ role?: BuiltinContextRole;
49
+ /** 在 messages 数组中的下标 */
50
+ messageIndex?: number;
51
+ /** 在 message.toolCalls 数组中的下标(仅结构化工具调用命中时) */
52
+ toolCallIndex?: number;
53
+ toolCallName?: string;
54
+ snippet: string;
55
+ filePath: string;
56
+ }
57
+
58
+ export interface SessionSearchOutput {
59
+ query: string;
60
+ terms: string[];
61
+ matches: SessionSearchMatch[];
62
+ truncated: boolean;
63
+ scannedSessions: number;
64
+ scannedRawLogFiles: number;
65
+ }
66
+
67
+ const DEFAULT_MAX_RESULTS = 20;
68
+ const MAX_RESULTS_CAP = 50;
69
+ const DEFAULT_MAX_SNIPPET_CHARS = 400;
70
+ const MAX_SNIPPET_CHARS_CAP = 2_000;
71
+ const DEFAULT_MAX_RAW_LOG_BYTES_PER_FILE = 4 * 1024 * 1024;
72
+
73
+ function clamp(value: number, min: number, max: number): number {
74
+ return Math.min(max, Math.max(min, value));
75
+ }
76
+
77
+ function tokenize(query: string): string[] {
78
+ return query
79
+ .toLowerCase()
80
+ .split(/\s+/)
81
+ .map((term) => term.trim())
82
+ .filter((term) => term.length > 0);
83
+ }
84
+
85
+ /** 多词 AND、大小写不敏感:候选文本必须包含全部关键词 */
86
+ function matchesTerms(text: string, terms: readonly string[]): boolean {
87
+ const lower = text.toLowerCase();
88
+ return terms.every((term) => lower.includes(term));
89
+ }
90
+
91
+ function makeSnippet(text: string, terms: readonly string[], maxChars: number): string {
92
+ if (text.length <= maxChars) return text;
93
+ const lower = text.toLowerCase();
94
+ let hitIndex = -1;
95
+ for (const term of terms) {
96
+ const index = lower.indexOf(term);
97
+ if (index !== -1) {
98
+ hitIndex = index;
99
+ break;
100
+ }
101
+ }
102
+ const half = Math.floor(maxChars / 2);
103
+ if (hitIndex === -1) {
104
+ return `${text.slice(0, half)}…${text.slice(-half)}`;
105
+ }
106
+ const start = Math.max(0, hitIndex - half);
107
+ const end = Math.min(text.length, hitIndex + half);
108
+ const prefix = start > 0 ? "…" : "";
109
+ const suffix = end < text.length ? "…" : "";
110
+ return `${prefix}${text.slice(start, end)}${suffix}`;
111
+ }
112
+
113
+ interface ContextSearchResult {
114
+ matches: SessionSearchMatch[];
115
+ scannedSessions: number;
116
+ /** 命中总数(含被 maxResults 截断的部分),用于精确的 truncated 判定 */
117
+ totalHits: number;
118
+ }
119
+
120
+ function searchContextDir(
121
+ query: string,
122
+ options: SessionSearchOptions,
123
+ maxResults: number,
124
+ maxSnippetChars: number,
125
+ ): ContextSearchResult {
126
+ const dir = options.contextDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
127
+ const terms = tokenize(query);
128
+ const matches: SessionSearchMatch[] = [];
129
+ if (terms.length === 0 || !existsSync(dir)) {
130
+ return { matches, scannedSessions: 0, totalHits: 0 };
131
+ }
132
+
133
+ const restrictTo = options.sessionId?.trim();
134
+
135
+ let scannedSessions = 0;
136
+ let totalHits = 0;
137
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
138
+ if (!entry.isDirectory()) continue;
139
+ if (restrictTo && entry.name !== restrictTo) continue;
140
+ const filePath = join(dir, entry.name, "context.json");
141
+ if (!existsSync(filePath)) continue;
142
+
143
+ let state: {
144
+ sessionId?: unknown;
145
+ summary?: unknown;
146
+ messages?: unknown;
147
+ };
148
+ try {
149
+ state = JSON.parse(readFileSync(filePath, "utf8")) as typeof state;
150
+ } catch {
151
+ continue; // 损坏或非 JSON 的会话文件跳过
152
+ }
153
+ if (!state || typeof state !== "object") continue;
154
+ if (restrictTo && state.sessionId !== restrictTo && entry.name !== restrictTo) continue;
155
+ scannedSessions += 1;
156
+ const sessionId = typeof state.sessionId === "string" ? state.sessionId : entry.name;
157
+
158
+ if (typeof state.summary === "string" && matchesTerms(state.summary, terms)) {
159
+ totalHits += 1;
160
+ matches.push({
161
+ sessionId,
162
+ source: "summary",
163
+ snippet: makeSnippet(state.summary, terms, maxSnippetChars),
164
+ filePath,
165
+ });
166
+ }
167
+
168
+ if (Array.isArray(state.messages)) {
169
+ state.messages.forEach((message, messageIndex) => {
170
+ if (!message || typeof message !== "object") return;
171
+ const raw = message as { role?: unknown; content?: unknown; toolCalls?: unknown };
172
+ const role: BuiltinContextRole | undefined =
173
+ raw.role === "user" || raw.role === "assistant" ? raw.role : undefined;
174
+
175
+ if (typeof raw.content === "string" && matchesTerms(raw.content, terms)) {
176
+ totalHits += 1;
177
+ matches.push({
178
+ sessionId,
179
+ source: "context",
180
+ role,
181
+ messageIndex,
182
+ snippet: makeSnippet(raw.content, terms, maxSnippetChars),
183
+ filePath,
184
+ });
185
+ }
186
+
187
+ if (Array.isArray(raw.toolCalls)) {
188
+ raw.toolCalls.forEach((call, toolCallIndex) => {
189
+ if (!call || typeof call !== "object") return;
190
+ const callRaw = call as { name?: unknown; input?: unknown; output?: unknown };
191
+ const name = typeof callRaw.name === "string" ? callRaw.name : "";
192
+ const input = typeof callRaw.input === "string" ? callRaw.input : "";
193
+ const output = typeof callRaw.output === "string" ? callRaw.output : "";
194
+ if (name.length === 0 && input.length === 0 && output.length === 0) return;
195
+ const haystack = [name, input, output].join("\n");
196
+ if (!matchesTerms(haystack, terms)) return;
197
+ totalHits += 1;
198
+ const source = output || input || name;
199
+ matches.push({
200
+ sessionId,
201
+ source: "context",
202
+ role,
203
+ messageIndex,
204
+ toolCallIndex,
205
+ toolCallName: name,
206
+ snippet: makeSnippet(source, terms, maxSnippetChars),
207
+ filePath,
208
+ });
209
+ });
210
+ }
211
+ });
212
+ }
213
+ }
214
+
215
+ return { matches: matches.slice(0, maxResults), scannedSessions, totalHits };
216
+ }
217
+
218
+ interface RawLogSearchResult {
219
+ matches: SessionSearchMatch[];
220
+ scannedFiles: number;
221
+ /** raw log 流式解压提前截断(达到 maxResults 停止扫描剩余文件) */
222
+ truncated: boolean;
223
+ }
224
+
225
+ async function searchRawLogs(
226
+ query: string,
227
+ options: SessionSearchOptions,
228
+ maxResults: number,
229
+ maxSnippetChars: number,
230
+ ): Promise<RawLogSearchResult> {
231
+ const terms = tokenize(query);
232
+ const rootDir = options.rawLogsDir ?? RAW_STREAM_LOGS_DIR;
233
+ const matches: SessionSearchMatch[] = [];
234
+ if (terms.length === 0 || !existsSync(rootDir)) {
235
+ return { matches, scannedFiles: 0, truncated: false };
236
+ }
237
+
238
+ const toolRoot = join(rootDir, "deepccc");
239
+ if (!existsSync(toolRoot)) return { matches, scannedFiles: 0, truncated: false };
240
+
241
+ const restrictTo = options.sessionId?.trim();
242
+ const maxBytesPerFile = Math.max(0, options.maxRawLogBytesPerFile ?? DEFAULT_MAX_RAW_LOG_BYTES_PER_FILE);
243
+
244
+ // 收集所有 .jsonl.gz 文件,按 mtime 新→旧排序(最新轮次优先)
245
+ const files: { path: string; sessionId: string; mtimeMs: number }[] = [];
246
+ for (const sessionEntry of readdirSync(toolRoot, { withFileTypes: true })) {
247
+ if (!sessionEntry.isDirectory()) continue;
248
+ if (restrictTo && sessionEntry.name !== restrictTo) continue;
249
+ const sessionDir = join(toolRoot, sessionEntry.name);
250
+ let entries;
251
+ try {
252
+ entries = readdirSync(sessionDir, { withFileTypes: true });
253
+ } catch {
254
+ continue;
255
+ }
256
+ for (const fileEntry of entries) {
257
+ if (!fileEntry.isFile() || !fileEntry.name.endsWith(".jsonl.gz")) continue;
258
+ const filePath = join(sessionDir, fileEntry.name);
259
+ try {
260
+ const info = statSync(filePath);
261
+ files.push({ path: filePath, sessionId: sessionEntry.name, mtimeMs: info.mtimeMs });
262
+ } catch {
263
+ // 无法 stat 的文件跳过
264
+ }
265
+ }
266
+ }
267
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
268
+
269
+ let scannedFiles = 0;
270
+ let truncated = false;
271
+ for (const file of files) {
272
+ if (matches.length >= maxResults) {
273
+ truncated = true;
274
+ break;
275
+ }
276
+ scannedFiles += 1;
277
+ const snippets = await searchGzipFileLines(file.path, terms, maxBytesPerFile, maxSnippetChars);
278
+ for (const snippet of snippets) {
279
+ matches.push({
280
+ sessionId: file.sessionId,
281
+ source: "raw-log",
282
+ snippet,
283
+ filePath: file.path,
284
+ });
285
+ if (matches.length >= maxResults) break;
286
+ }
287
+ }
288
+
289
+ return { matches: matches.slice(0, maxResults), scannedFiles, truncated };
290
+ }
291
+
292
+ async function searchGzipFileLines(
293
+ filePath: string,
294
+ terms: readonly string[],
295
+ maxBytes: number,
296
+ maxSnippetChars: number,
297
+ ): Promise<string[]> {
298
+ const hits: string[] = [];
299
+ let bytes = 0;
300
+ try {
301
+ const source = createReadStream(filePath);
302
+ const gunzip = createGunzip();
303
+ // 防崩溃(线上事故):截断/损坏的 gzip 会在 zlib 层 emit 'error'(如
304
+ // "unexpected end of file")。若无人监听,错误事件会升级为 uncaughtException
305
+ // 直接杀死整个服务(崩溃黑匣子 exit(1))。显式挂监听把错误降级为
306
+ // “跳过该文件”:destroy 流让 for await 正常结束,不再冒泡。
307
+ source.on("error", () => gunzip.destroy());
308
+ gunzip.on("error", () => gunzip.destroy());
309
+ const reader = createInterface({ input: source.pipe(gunzip), crlfDelay: Infinity });
310
+ // readline 会把 input 流的 error 转发到自己身上,同样需要监听避免冒泡
311
+ reader.on("error", () => {});
312
+ for await (const line of reader) {
313
+ bytes += Buffer.byteLength(line, "utf-8");
314
+ if (bytes > maxBytes) break;
315
+ if (!matchesTerms(line, terms)) continue;
316
+ hits.push(makeSnippet(line, terms, maxSnippetChars));
317
+ if (hits.length >= 50) break;
318
+ }
319
+ } catch {
320
+ // 损坏的 gzip 文件按 best-effort 跳过
321
+ }
322
+ return hits;
323
+ }
324
+
325
+ /**
326
+ * 关键词检索历史会话存档。context.json 为同步扫描;raw-stream-logs 为
327
+ * gzip 逐行解压(异步,仅 options.includeRawLogs 时启用)。
328
+ */
329
+ export async function searchBuiltinSessions(
330
+ query: string,
331
+ options: SessionSearchOptions = {},
332
+ ): Promise<SessionSearchOutput> {
333
+ const terms = tokenize(query);
334
+ const maxResults = clamp(options.maxResults ?? DEFAULT_MAX_RESULTS, 1, MAX_RESULTS_CAP);
335
+ const maxSnippetChars = clamp(
336
+ options.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS,
337
+ 80,
338
+ MAX_SNIPPET_CHARS_CAP,
339
+ );
340
+
341
+ if (terms.length === 0) {
342
+ return {
343
+ query,
344
+ terms,
345
+ matches: [],
346
+ truncated: false,
347
+ scannedSessions: 0,
348
+ scannedRawLogFiles: 0,
349
+ };
350
+ }
351
+
352
+ const contextResult = searchContextDir(query, options, maxResults, maxSnippetChars);
353
+ let rawResult: RawLogSearchResult = { matches: [], scannedFiles: 0, truncated: false };
354
+ if (options.includeRawLogs) {
355
+ rawResult = await searchRawLogs(query, options, maxResults, maxSnippetChars);
356
+ }
357
+
358
+ const matches = [...contextResult.matches, ...rawResult.matches].slice(0, maxResults);
359
+ const truncated =
360
+ contextResult.totalHits > maxResults || rawResult.truncated || rawResult.matches.length > maxResults;
361
+
362
+ return {
363
+ query,
364
+ terms,
365
+ matches,
366
+ truncated,
367
+ scannedSessions: contextResult.scannedSessions,
368
+ scannedRawLogFiles: rawResult.scannedFiles,
369
+ };
370
+ }