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.
- package/README.md +1 -1
- package/package.json +1 -1
- package/src/__tests__/agent-activity.test.ts +10 -0
- package/src/__tests__/builtin-chat-session.test.ts +449 -329
- package/src/__tests__/builtin-context.test.ts +158 -2
- package/src/__tests__/builtin-file-tools.test.ts +240 -240
- package/src/__tests__/builtin-session-search.test.ts +228 -0
- package/src/__tests__/builtin-skills.test.ts +284 -284
- package/src/__tests__/ccc-adapter.test.ts +34 -0
- package/src/__tests__/progress-reducer.test.ts +11 -0
- package/src/__tests__/restart.test.ts +132 -132
- package/src/__tests__/session.test.ts +199 -204
- package/src/__tests__/terminal-error.test.ts +54 -54
- package/src/adapters/adapter-interface.ts +15 -9
- package/src/adapters/ccc-adapter.ts +11 -1
- package/src/agent-activity.ts +5 -0
- package/src/builtin/cli.ts +15 -2
- package/src/builtin/context.ts +465 -333
- package/src/builtin/file-tools.ts +34 -0
- package/src/builtin/index.ts +87 -22
- package/src/builtin/progress/reducer.ts +5 -0
- package/src/builtin/session-search.ts +363 -0
- package/src/builtin/skills.ts +205 -205
- package/src/orchestrator.ts +2494 -2494
- package/src/progress/reducer.ts +5 -0
- package/src/session.ts +201 -200
- package/src/stream-state.ts +21 -21
- package/src/terminal-error.ts +129 -129
|
@@ -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
|
}
|
package/src/builtin/index.ts
CHANGED
|
@@ -15,8 +15,9 @@ import {
|
|
|
15
15
|
type RawStreamLogHandle,
|
|
16
16
|
} from "./raw-stream-log.js";
|
|
17
17
|
import {
|
|
18
|
-
|
|
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
|
-
|
|
269
|
-
|
|
270
|
-
|
|
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
|
|
353
|
-
|
|
354
|
-
:
|
|
355
|
-
this.context.appendMessage({
|
|
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
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
const
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
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
|
-
|
|
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
|
-
|
|
420
|
-
|
|
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
|
+
}
|