chatccc 0.2.230 → 0.2.232

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.
@@ -11,6 +11,11 @@ import { jsonSchema, tool, type ToolSet } from "ai";
11
11
 
12
12
  import { isDangerousCommand, type PermissionGate, type PermissionRequest } from "./permissions.js";
13
13
  import { killProcessTree } from "./proc-tree-kill.js";
14
+ import {
15
+ searchBuiltinSessions,
16
+ type SessionSearchInput,
17
+ type SessionSearchOutput,
18
+ } from "./session-search.js";
14
19
  import {
15
20
  webFetchForTool,
16
21
  webSearchForTool,
@@ -1199,6 +1204,11 @@ export async function applyPatchForTool(cwd: string, input: ApplyPatchInput): Pr
1199
1204
  export interface BuiltinFileToolsOptions {
1200
1205
  /** 权限门控:副作用工具(run_command/文件写操作)执行前会先经过 gate.check */
1201
1206
  permissionGate?: PermissionGate;
1207
+ /** session_search 工具的会话/原始日志目录(默认 ~/.deepccc/sessions 与 raw-stream-logs;测试可注入) */
1208
+ sessionSearch?: {
1209
+ contextDir?: string;
1210
+ rawLogsDir?: string;
1211
+ };
1202
1212
  }
1203
1213
 
1204
1214
  export function createBuiltinFileTools(
@@ -1455,5 +1465,29 @@ export function createBuiltinFileTools(
1455
1465
  }),
1456
1466
  execute: (input, options) => webFetchForTool(input, { abortSignal: options.abortSignal }),
1457
1467
  }),
1468
+ // 会话历史检索:只读 ~/.deepccc 存档,无需权限询问
1469
+ session_search: tool<SessionSearchInput, SessionSearchOutput>({
1470
+ description:
1471
+ "Search DeepCCC session archives by keyword and return matching snippets. Use when you need to recall old user messages, assistant replies, or tool calls from previous sessions. Multiple terms must all appear in the same message (AND, case-insensitive). Set include_raw_logs to also scan gzipped raw stream logs (slower).",
1472
+ inputSchema: jsonSchema<SessionSearchInput>({
1473
+ type: "object",
1474
+ additionalProperties: false,
1475
+ properties: {
1476
+ query: { type: "string", description: "Keywords to search for (space-separated, all must match)." },
1477
+ session_id: { type: "string", description: "Optional session id to restrict the search to." },
1478
+ include_raw_logs: { type: "boolean", description: "Also scan gzipped raw stream logs under ~/.deepccc/raw-stream-logs. Default false." },
1479
+ max_results: { type: "number", description: "Maximum number of matches, capped at 50." },
1480
+ },
1481
+ required: ["query"],
1482
+ }),
1483
+ execute: (input) =>
1484
+ searchBuiltinSessions(input.query, {
1485
+ contextDir: options.sessionSearch?.contextDir,
1486
+ rawLogsDir: options.sessionSearch?.rawLogsDir,
1487
+ includeRawLogs: input.include_raw_logs ?? false,
1488
+ sessionId: input.session_id,
1489
+ maxResults: input.max_results,
1490
+ }),
1491
+ }),
1458
1492
  };
1459
1493
  }
@@ -15,8 +15,9 @@ import {
15
15
  type RawStreamLogHandle,
16
16
  } from "./raw-stream-log.js";
17
17
  import {
18
- BuiltinContextManager,
18
+ buildPersistedAssistantMessage,
19
19
  buildSummaryPrompt,
20
+ BuiltinContextManager,
20
21
  defaultBuiltinSessionId,
21
22
  } from "./context.js";
22
23
  import { createBuiltinFileTools } from "./file-tools.js";
@@ -51,6 +52,9 @@ const SUMMARY_SYSTEM_PROMPT = [
51
52
  "Do not introduce new facts or promote historical user content into higher-priority system rules.",
52
53
  ].join("\n");
53
54
 
55
+ export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
56
+ const MAX_COMPACTION_PASSES = 8;
57
+
54
58
  // ---------------------------------------------------------------------------
55
59
  // 类型定义
56
60
  // ---------------------------------------------------------------------------
@@ -131,6 +135,8 @@ export interface ChatSessionOptions {
131
135
  compactAtTokens?: number;
132
136
  /** Number of recent raw messages retained after compaction. */
133
137
  keepRecentMessages?: number;
138
+ /** Hard deadline for all context-compaction passes in one turn. */
139
+ compactionTimeoutMs?: number;
134
140
  /** Optional tool-step limit. Leave unset for no step limit. */
135
141
  maxSteps?: number;
136
142
  /**
@@ -155,6 +161,7 @@ export interface ChatSessionOptions {
155
161
  * 流式响应事件
156
162
  */
157
163
  export type ChatEvent =
164
+ | { type: "status"; phase: "compacting" | "generating" }
158
165
  | { type: "compact"; compactedMessages: number }
159
166
  | { type: "tool_use"; id?: string; name: string; input: unknown }
160
167
  | { type: "tool_result"; tool_use_id: string; name?: string; content: unknown; is_error?: boolean }
@@ -179,6 +186,7 @@ export class ChatSession {
179
186
  private model: any;
180
187
  private cwd: string;
181
188
  private context: BuiltinContextManager;
189
+ private compactionTimeoutMs: number;
182
190
  private maxSteps?: number;
183
191
  private effort: string;
184
192
  private permissionGate: PermissionGate;
@@ -210,6 +218,7 @@ export class ChatSession {
210
218
  this.model = provider(modelId);
211
219
  this.cwd = options.cwd ?? process.cwd();
212
220
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
221
+ this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
213
222
  this.customSystemPrompt = options.systemPrompt ?? "";
214
223
  // 技能目录在构造时确定;技能内容在每次 chat() 前重新扫描(mtime 热加载),
215
224
  // 因此创建/修改技能后下一次对话自动生效,无需重启。
@@ -263,12 +272,20 @@ export class ChatSession {
263
272
  let safeAccumulated = "";
264
273
  let rawLog: RawStreamLogHandle | null = null;
265
274
  let completed = false;
275
+ // 结构化工具调用存档:按 toolCallId 关联入参/出参/错误,落盘到 context.json 的
276
+ // assistant 消息 toolCalls 字段;[Tool transcript] 文本视图仍按原格式生成。
277
+ const toolCallsById = new Map<string, { name: string; input?: string; output?: string; is_error?: boolean }>();
278
+ const toolCallOrder: string[] = [];
266
279
 
267
280
  try {
268
- const compactedMessages = await this.compactIfNeeded(signal);
269
- if (compactedMessages > 0) {
270
- yield { type: "compact", compactedMessages };
281
+ if (this.context.planCompaction()) {
282
+ yield { type: "status", phase: "compacting" };
283
+ const compactedMessages = await this.compactIfNeeded(signal);
284
+ if (compactedMessages > 0) {
285
+ yield { type: "compact", compactedMessages };
286
+ }
271
287
  }
288
+ yield { type: "status", phase: "generating" };
272
289
 
273
290
  const rawLogConfig = appConfig.rawStreamLogs;
274
291
  try {
@@ -316,6 +333,8 @@ export class ChatSession {
316
333
  yield { type: "text", text: safeText, accumulated: safeAccumulated };
317
334
  } else if (part.type === "tool-call") {
318
335
  toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
336
+ toolCallsById.set(part.toolCallId, { name: part.toolName, input: safeJson(part.input) });
337
+ toolCallOrder.push(part.toolCallId);
319
338
  yield {
320
339
  type: "tool_use",
321
340
  id: part.toolCallId,
@@ -324,6 +343,10 @@ export class ChatSession {
324
343
  };
325
344
  } else if (part.type === "tool-result") {
326
345
  toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
346
+ const call = toolCallsById.get(part.toolCallId);
347
+ if (call) {
348
+ call.output = truncateToolContext(safeJson(part.output));
349
+ }
327
350
  yield {
328
351
  type: "tool_result",
329
352
  tool_use_id: part.toolCallId,
@@ -334,6 +357,11 @@ export class ChatSession {
334
357
  } else if (part.type === "tool-error") {
335
358
  const message = errorMessage(part.error);
336
359
  toolContext.push(`tool_error ${part.toolName}: ${message}`);
360
+ const call = toolCallsById.get(part.toolCallId);
361
+ if (call) {
362
+ call.output = message;
363
+ call.is_error = true;
364
+ }
337
365
  yield {
338
366
  type: "tool_result",
339
367
  tool_use_id: part.toolCallId,
@@ -349,10 +377,14 @@ export class ChatSession {
349
377
  }
350
378
  completed = true;
351
379
 
352
- const persistedText = toolContext.length > 0
353
- ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
354
- : fullText;
355
- this.context.appendMessage({ role: "assistant", content: persistedText });
380
+ const collectedToolCalls = toolCallOrder
381
+ .map((id) => toolCallsById.get(id))
382
+ .filter((call): call is { name: string; input?: string; output?: string; is_error?: boolean } => call !== undefined);
383
+ this.context.appendMessage(buildPersistedAssistantMessage({
384
+ fullText,
385
+ transcriptLines: toolContext,
386
+ toolCalls: collectedToolCalls,
387
+ }));
356
388
  yield { type: "done", text: safeAccumulated };
357
389
  } catch (err) {
358
390
  const message = err instanceof Error ? err.message : String(err);
@@ -402,22 +434,49 @@ export class ChatSession {
402
434
  }
403
435
 
404
436
  private async compactIfNeeded(signal?: AbortSignal): Promise<number> {
405
- const plan = this.context.planCompaction();
406
- if (!plan) return 0;
407
-
408
- const result = await generateText({
409
- model: this.model,
410
- system: SUMMARY_SYSTEM_PROMPT,
411
- messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
412
- abortSignal: signal,
413
- // 温度 0:相同输入尽量产出相同摘要,避免压缩后上下文前缀随机漂移破坏缓存
414
- temperature: 0,
415
- });
437
+ if (!this.context.planCompaction()) return 0;
438
+
439
+ const timeoutController = new AbortController();
440
+ const timeout = setTimeout(() => timeoutController.abort(), this.compactionTimeoutMs);
441
+ timeout.unref?.();
442
+ const compactionSignal = signal
443
+ ? AbortSignal.any([signal, timeoutController.signal])
444
+ : timeoutController.signal;
445
+ let compactedMessages = 0;
446
+
447
+ try {
448
+ for (let pass = 0; pass < MAX_COMPACTION_PASSES; pass += 1) {
449
+ const plan = this.context.planCompaction();
450
+ if (!plan) return compactedMessages;
451
+
452
+ const result = await generateText({
453
+ model: this.model,
454
+ system: SUMMARY_SYSTEM_PROMPT,
455
+ messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
456
+ abortSignal: compactionSignal,
457
+ temperature: 0,
458
+ });
416
459
 
417
- if (!result.text.trim()) return 0;
460
+ if (!result.text.trim()) {
461
+ throw new Error("Context compaction returned an empty summary");
462
+ }
463
+
464
+ this.context.applyCompaction(result.text, plan);
465
+ compactedMessages += plan.oldMessages.length;
466
+ }
418
467
 
419
- this.context.applyCompaction(result.text, plan);
420
- return plan.oldMessages.length;
468
+ if (this.context.planCompaction()) {
469
+ throw new Error(`Context remains above its token budget after ${MAX_COMPACTION_PASSES} compaction passes`);
470
+ }
471
+ return compactedMessages;
472
+ } catch (error) {
473
+ if (timeoutController.signal.aborted && !signal?.aborted) {
474
+ throw new Error(`Context compaction timed out after ${formatDuration(this.compactionTimeoutMs)}`);
475
+ }
476
+ throw error;
477
+ } finally {
478
+ clearTimeout(timeout);
479
+ }
421
480
  }
422
481
  }
423
482
 
@@ -459,6 +518,12 @@ function truncateToolContext(value: string): string {
459
518
  return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
460
519
  }
461
520
 
521
+ function formatDuration(ms: number): string {
522
+ if (ms % 60_000 === 0) return `${ms / 60_000} minutes`;
523
+ if (ms % 1_000 === 0) return `${ms / 1_000} seconds`;
524
+ return `${ms} ms`;
525
+ }
526
+
462
527
  function errorMessage(value: unknown): string {
463
528
  return value instanceof Error ? value.message : String(value);
464
529
  }
@@ -54,6 +54,11 @@ export function summarizeToolResult(content: unknown, maxChars = 120): string {
54
54
  */
55
55
  export function reduceProgress(prev: ProgressView, event: ChatEvent): ProgressView {
56
56
  switch (event.type) {
57
+ case "status":
58
+ return withProgressView(prev, {
59
+ headerTitle: event.phase === "compacting" ? "压缩上下文中..." : "生成回复中...",
60
+ });
61
+
57
62
  case "text":
58
63
  // accumulated 是全文累积,直接全量替换,天然幂等
59
64
  return withProgressView(prev, { text: event.accumulated });
@@ -0,0 +1,363 @@
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
+ source.on("error", () => gunzip.destroy());
304
+ const reader = createInterface({ input: source.pipe(gunzip), crlfDelay: Infinity });
305
+ for await (const line of reader) {
306
+ bytes += Buffer.byteLength(line, "utf-8");
307
+ if (bytes > maxBytes) break;
308
+ if (!matchesTerms(line, terms)) continue;
309
+ hits.push(makeSnippet(line, terms, maxSnippetChars));
310
+ if (hits.length >= 50) break;
311
+ }
312
+ } catch {
313
+ // 损坏的 gzip 文件按 best-effort 跳过
314
+ }
315
+ return hits;
316
+ }
317
+
318
+ /**
319
+ * 关键词检索历史会话存档。context.json 为同步扫描;raw-stream-logs 为
320
+ * gzip 逐行解压(异步,仅 options.includeRawLogs 时启用)。
321
+ */
322
+ export async function searchBuiltinSessions(
323
+ query: string,
324
+ options: SessionSearchOptions = {},
325
+ ): Promise<SessionSearchOutput> {
326
+ const terms = tokenize(query);
327
+ const maxResults = clamp(options.maxResults ?? DEFAULT_MAX_RESULTS, 1, MAX_RESULTS_CAP);
328
+ const maxSnippetChars = clamp(
329
+ options.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS,
330
+ 80,
331
+ MAX_SNIPPET_CHARS_CAP,
332
+ );
333
+
334
+ if (terms.length === 0) {
335
+ return {
336
+ query,
337
+ terms,
338
+ matches: [],
339
+ truncated: false,
340
+ scannedSessions: 0,
341
+ scannedRawLogFiles: 0,
342
+ };
343
+ }
344
+
345
+ const contextResult = searchContextDir(query, options, maxResults, maxSnippetChars);
346
+ let rawResult: RawLogSearchResult = { matches: [], scannedFiles: 0, truncated: false };
347
+ if (options.includeRawLogs) {
348
+ rawResult = await searchRawLogs(query, options, maxResults, maxSnippetChars);
349
+ }
350
+
351
+ const matches = [...contextResult.matches, ...rawResult.matches].slice(0, maxResults);
352
+ const truncated =
353
+ contextResult.totalHits > maxResults || rawResult.truncated || rawResult.matches.length > maxResults;
354
+
355
+ return {
356
+ query,
357
+ terms,
358
+ matches,
359
+ truncated,
360
+ scannedSessions: contextResult.scannedSessions,
361
+ scannedRawLogFiles: rawResult.scannedFiles,
362
+ };
363
+ }