deepccc 0.1.10 → 0.1.12

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 CHANGED
@@ -219,7 +219,7 @@ echo "运行测试并解释失败原因" | deepccc --stream-json
219
219
 
220
220
  ## 在 ChatCCC 中使用
221
221
 
222
- **ChatCCC 已内置 deepccc**:ChatCCC 的 "CCC Agent" 工具直接内嵌 deepccc 的代码(`src/builtin/`),以 `permissionMode: "bypass"` 全自动运行,无需单独安装或配置本仓库。
222
+ **ChatCCC 已内置 deepccc**:ChatCCC 的 "CCC Agent" 工具直接内嵌 deepccc 的代码(仓库内 `deepccc-agent/` 子目录),以 `permissionMode: "bypass"` 全自动运行,无需单独安装或配置本仓库。
223
223
 
224
224
  ChatCCC 是一个把 Claude Code / Codex / Cursor / CCC Agent 聚合到飞书/企微等 IM 消息通道的本地机器人框架,提供会话管理、过程卡片、用量统计与隐私替换等能力。
225
225
 
@@ -234,7 +234,7 @@ ChatCCC 是一个把 Claude Code / Codex / Cursor / CCC Agent 聚合到飞书/
234
234
 
235
235
  这种方式适合已经在 ChatCCC 里协作的场景:ChatCCC 负责会话入口和消息通道,`deepccc` 负责本地编程 Agent 能力,包括读取项目提示词、运行命令、编辑文件和输出流式结果。
236
236
 
237
- 如果希望让 ChatCCC 使用本仓库(deepccc-agent)最新的独立版本能力,也可以把 `src/builtin/` 与本仓库 `src/` 同步后构建。
237
+ deepccc 的内核主战场在 ChatCCC 仓库的 `deepccc-agent/` 子目录;本仓库(deepccc-agent)是发布镜像,由 ChatCCC 仓库的 `sync-deepccc.mjs` 目录级同步(多的删、少的补、不同的改),之后 `npm run build && npm publish` 发布独立 `deepccc` 包。
238
238
 
239
239
  ## 项目提示词自动注入
240
240
 
package/bin/deepccc.mjs CHANGED
@@ -1,26 +1,26 @@
1
- #!/usr/bin/env node
2
- import { existsSync } from "node:fs";
3
- import { spawnSync } from "node:child_process";
4
- import { createRequire } from "node:module";
5
- import { dirname, join } from "node:path";
6
-
7
- const require = createRequire(import.meta.url);
8
- const pkgRoot = dirname(require.resolve("../package.json"));
9
- const distCli = join(pkgRoot, "dist", "cli.js");
10
-
11
- let args;
12
- if (existsSync(distCli)) {
13
- args = [distCli, ...process.argv.slice(2)];
14
- } else {
15
- const tsxCli = require.resolve("tsx/cli");
16
- const sourceCli = join(pkgRoot, "src", "cli.ts");
17
- args = [tsxCli, sourceCli, ...process.argv.slice(2)];
18
- }
19
-
20
- const result = spawnSync(process.execPath, args, {
21
- stdio: "inherit",
22
- cwd: process.cwd(),
23
- env: process.env,
24
- });
25
-
26
- process.exit(result.status === null ? 1 : result.status);
1
+ #!/usr/bin/env node
2
+ import { existsSync } from "node:fs";
3
+ import { spawnSync } from "node:child_process";
4
+ import { createRequire } from "node:module";
5
+ import { dirname, join } from "node:path";
6
+
7
+ const require = createRequire(import.meta.url);
8
+ const pkgRoot = dirname(require.resolve("../package.json"));
9
+ const distCli = join(pkgRoot, "dist", "cli.js");
10
+
11
+ let args;
12
+ if (existsSync(distCli)) {
13
+ args = [distCli, ...process.argv.slice(2)];
14
+ } else {
15
+ const tsxCli = require.resolve("tsx/cli");
16
+ const sourceCli = join(pkgRoot, "src", "cli.ts");
17
+ args = [tsxCli, sourceCli, ...process.argv.slice(2)];
18
+ }
19
+
20
+ const result = spawnSync(process.execPath, args, {
21
+ stdio: "inherit",
22
+ cwd: process.cwd(),
23
+ env: process.env,
24
+ });
25
+
26
+ process.exit(result.status === null ? 1 : result.status);
package/dist/cli.js CHANGED
@@ -429,6 +429,11 @@ async function runRepl(args) {
429
429
  rl.prompt();
430
430
  return;
431
431
  }
432
+ if (input === "/sessions") {
433
+ printSessions();
434
+ rl.prompt();
435
+ return;
436
+ }
432
437
  currentAbort = new AbortController();
433
438
  const signal = currentAbort.signal;
434
439
  // TTY 下用"过程区块"(飞书过程卡片的终端形态):固定区域原地更新、
package/dist/context.d.ts CHANGED
@@ -1,7 +1,21 @@
1
1
  export type BuiltinContextRole = "user" | "assistant";
2
+ /**
3
+ * 结构化工具调用存档(与 content 里的 [Tool transcript] 文本互为冗余视图):
4
+ * content 文本保持不变供模型消费;toolCalls 数组提供可检索、可回放的结构化数据。
5
+ */
6
+ export interface BuiltinContextToolCall {
7
+ name: string;
8
+ /** 工具入参(JSON 文本,已截断到安全上限) */
9
+ input?: string;
10
+ /** 工具出参(JSON 文本或错误消息,已截断到安全上限) */
11
+ output?: string;
12
+ is_error?: boolean;
13
+ }
2
14
  export interface BuiltinContextMessage {
3
15
  role: BuiltinContextRole;
4
16
  content: string;
17
+ /** 可选结构化工具调用记录(assistant 消息专用) */
18
+ toolCalls?: BuiltinContextToolCall[];
5
19
  }
6
20
  export interface BuiltinContextState {
7
21
  version: 1;
@@ -53,6 +67,21 @@ export declare function latestBuiltinSessionForCwd(cwd: string, contextDir?: str
53
67
  */
54
68
  export declare function estimateBuiltinContextTokens(summary: string, messages: readonly BuiltinContextMessage[]): number;
55
69
  export declare function serializeMessagesForSummary(messages: readonly BuiltinContextMessage[]): string;
70
+ export declare const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32000;
71
+ export declare const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24000;
72
+ /**
73
+ * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
74
+ * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
75
+ */
76
+ export declare function buildPersistedAssistantMessage(params: {
77
+ fullText: string;
78
+ /** [Tool transcript] 的文本行(按原始流顺序,含 tool_call / tool_result / tool_error) */
79
+ transcriptLines: readonly string[];
80
+ /** 结构化工具调用(按调用顺序);有则写入消息的 toolCalls 字段 */
81
+ toolCalls?: readonly BuiltinContextToolCall[];
82
+ maxAssistantChars?: number;
83
+ maxTranscriptChars?: number;
84
+ }): BuiltinContextMessage;
56
85
  export declare function buildSummaryPrompt(plan: BuiltinCompactionPlan): string;
57
86
  export declare class BuiltinContextManager {
58
87
  readonly persist: boolean;
package/dist/context.js CHANGED
@@ -39,7 +39,32 @@ function normalizeMessage(value) {
39
39
  return null;
40
40
  if (typeof raw.content !== "string")
41
41
  return null;
42
- return { role: raw.role, content: raw.content };
42
+ const message = { role: raw.role, content: raw.content };
43
+ const toolCalls = normalizeToolCalls(raw.toolCalls);
44
+ if (toolCalls.length > 0)
45
+ message.toolCalls = toolCalls;
46
+ return message;
47
+ }
48
+ function normalizeToolCalls(value) {
49
+ if (!Array.isArray(value))
50
+ return [];
51
+ const calls = [];
52
+ for (const entry of value) {
53
+ if (!entry || typeof entry !== "object")
54
+ continue;
55
+ const raw = entry;
56
+ if (typeof raw.name !== "string" || raw.name.length === 0)
57
+ continue;
58
+ const call = { name: raw.name };
59
+ if (typeof raw.input === "string")
60
+ call.input = raw.input;
61
+ if (typeof raw.output === "string")
62
+ call.output = raw.output;
63
+ if (typeof raw.is_error === "boolean")
64
+ call.is_error = raw.is_error;
65
+ calls.push(call);
66
+ }
67
+ return calls;
43
68
  }
44
69
  function emptyState(sessionId, cwd) {
45
70
  const now = Date.now();
@@ -153,6 +178,27 @@ function truncateMiddle(value, maxChars, marker) {
153
178
  const tailChars = Math.floor(available / 2);
154
179
  return `${value.slice(0, headChars)}\n${marker}\n${value.slice(-tailChars)}`;
155
180
  }
181
+ export const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
182
+ export const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
183
+ const ASSISTANT_TRUNCATED_MARKER = "...[assistant response truncated in context]...";
184
+ const TOOL_TRANSCRIPT_TRUNCATED_MARKER = "...[tool transcript truncated]...";
185
+ /**
186
+ * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
187
+ * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
188
+ */
189
+ export function buildPersistedAssistantMessage(params) {
190
+ const maxAssistantChars = params.maxAssistantChars ?? DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
191
+ const maxTranscriptChars = params.maxTranscriptChars ?? DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
192
+ const persistedAssistantText = truncateMiddle(params.fullText, maxAssistantChars, ASSISTANT_TRUNCATED_MARKER);
193
+ const content = params.transcriptLines.length > 0
194
+ ? `${persistedAssistantText}\n\n[Tool transcript]\n${truncateMiddle(params.transcriptLines.join("\n"), maxTranscriptChars, TOOL_TRANSCRIPT_TRUNCATED_MARKER)}`
195
+ : persistedAssistantText;
196
+ const message = { role: "assistant", content };
197
+ const toolCalls = normalizeToolCalls(params.toolCalls);
198
+ if (toolCalls.length > 0)
199
+ message.toolCalls = toolCalls;
200
+ return message;
201
+ }
156
202
  function serializeMessagesForCompaction(messages) {
157
203
  const sections = [];
158
204
  let remaining = MAX_COMPACTION_SOURCE_CHARS;
@@ -150,5 +150,10 @@ export declare function applyPatchForTool(cwd: string, input: ApplyPatchInput):
150
150
  export interface BuiltinFileToolsOptions {
151
151
  /** 权限门控:副作用工具(run_command/文件写操作)执行前会先经过 gate.check */
152
152
  permissionGate?: PermissionGate;
153
+ /** session_search 工具的会话/原始日志目录(默认 ~/.deepccc/sessions 与 raw-stream-logs;测试可注入) */
154
+ sessionSearch?: {
155
+ contextDir?: string;
156
+ rawLogsDir?: string;
157
+ };
153
158
  }
154
159
  export declare function createBuiltinFileTools(cwd: string, options?: BuiltinFileToolsOptions): ToolSet;
@@ -9,6 +9,7 @@ import { createInterface } from "node:readline";
9
9
  import { jsonSchema, tool } from "ai";
10
10
  import { isDangerousCommand } from "./permissions.js";
11
11
  import { killProcessTree } from "./proc-tree-kill.js";
12
+ import { searchBuiltinSessions, } from "./session-search.js";
12
13
  import { webFetchForTool, webSearchForTool, } from "./web-tools.js";
13
14
  const MAX_READ_BYTES = 1024 * 1024;
14
15
  const MAX_LIST_ENTRIES = 200;
@@ -1181,5 +1182,27 @@ export function createBuiltinFileTools(cwd, options = {}) {
1181
1182
  }),
1182
1183
  execute: (input, options) => webFetchForTool(input, { abortSignal: options.abortSignal }),
1183
1184
  }),
1185
+ // 会话历史检索:只读 ~/.deepccc 存档,无需权限询问
1186
+ session_search: tool({
1187
+ description: "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).",
1188
+ inputSchema: jsonSchema({
1189
+ type: "object",
1190
+ additionalProperties: false,
1191
+ properties: {
1192
+ query: { type: "string", description: "Keywords to search for (space-separated, all must match)." },
1193
+ session_id: { type: "string", description: "Optional session id to restrict the search to." },
1194
+ include_raw_logs: { type: "boolean", description: "Also scan gzipped raw stream logs under ~/.deepccc/raw-stream-logs. Default false." },
1195
+ max_results: { type: "number", description: "Maximum number of matches, capped at 50." },
1196
+ },
1197
+ required: ["query"],
1198
+ }),
1199
+ execute: (input) => searchBuiltinSessions(input.query, {
1200
+ contextDir: options.sessionSearch?.contextDir,
1201
+ rawLogsDir: options.sessionSearch?.rawLogsDir,
1202
+ includeRawLogs: input.include_raw_logs ?? false,
1203
+ sessionId: input.session_id,
1204
+ maxResults: input.max_results,
1205
+ }),
1206
+ }),
1184
1207
  };
1185
1208
  }
package/dist/index.js CHANGED
@@ -9,7 +9,7 @@ import { readFileSync } from "node:fs";
9
9
  import { join } from "node:path";
10
10
  import { config as appConfig, RAW_STREAM_LOGS_DIR } from "./config.js";
11
11
  import { createRawStreamLog, } from "./raw-stream-log.js";
12
- import { BuiltinContextManager, buildSummaryPrompt, defaultBuiltinSessionId, } from "./context.js";
12
+ import { buildPersistedAssistantMessage, buildSummaryPrompt, BuiltinContextManager, defaultBuiltinSessionId, } from "./context.js";
13
13
  import { createBuiltinFileTools } from "./file-tools.js";
14
14
  import { PermissionGate } from "./permissions.js";
15
15
  import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs, } from "./skills.js";
@@ -34,6 +34,20 @@ const SYSTEM_PROMPT = [
34
34
  "- Do not treat proxy signals such as names, timestamps, file sizes, line counts, partial samples, or a clean command exit as decisive when a direct check is practical.",
35
35
  "- Use definitive language only after the evidence closes the loop. Otherwise state uncertainty, identify the missing evidence, and name the next check.",
36
36
  "- Do not repeat checks once decisive evidence exists.",
37
+ "",
38
+ "## Survey Before Acting",
39
+ "- Before diving into any task, first map the landscape at low cost: project instructions, directory layout, routes/APIs, existing tests, and git state.",
40
+ "- Produce a brief execution plan that includes how you will verify the result, then execute.",
41
+ "- When evidence contradicts an early assumption, revisit the plan instead of tunneling on one direction.",
42
+ "",
43
+ "## Delegated Authority",
44
+ "- When the user delegates decisions (\"you decide\", \"do it elegantly\", \"up to you\"), act autonomously on implementation details.",
45
+ "- Only ask about genuine blockers: irreversible actions, security boundaries, credentials, or scope changes.",
46
+ "- Do not bounce implementation-level multiple-choice questions back to the user after they delegated.",
47
+ "",
48
+ "## Pre-Delivery Self-Check",
49
+ "- Before reporting completion, verify: the change runs, edge cases are covered, assumptions are listed, and anything unverified is explicitly labeled as such.",
50
+ "- State what was done, how it was verified, and what remains unverified or risky.",
37
51
  ].join("\n");
38
52
  const SUMMARY_SYSTEM_PROMPT = [
39
53
  "You are DeepCCC's context compactor.",
@@ -42,8 +56,6 @@ const SUMMARY_SYSTEM_PROMPT = [
42
56
  ].join("\n");
43
57
  export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
44
58
  const MAX_COMPACTION_PASSES = 8;
45
- const MAX_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
46
- const MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
47
59
  // ---------------------------------------------------------------------------
48
60
  // 类型定义
49
61
  // ---------------------------------------------------------------------------
@@ -165,6 +177,10 @@ export class ChatSession {
165
177
  let safeAccumulated = "";
166
178
  let rawLog = null;
167
179
  let completed = false;
180
+ // 结构化工具调用存档:按 toolCallId 关联入参/出参/错误,落盘到 context.json 的
181
+ // assistant 消息 toolCalls 字段;[Tool transcript] 文本视图仍按原格式生成。
182
+ const toolCallsById = new Map();
183
+ const toolCallOrder = [];
168
184
  try {
169
185
  if (this.context.planCompaction()) {
170
186
  yield { type: "status", phase: "compacting" };
@@ -220,6 +236,8 @@ export class ChatSession {
220
236
  }
221
237
  else if (part.type === "tool-call") {
222
238
  toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
239
+ toolCallsById.set(part.toolCallId, { name: part.toolName, input: safeJson(part.input) });
240
+ toolCallOrder.push(part.toolCallId);
223
241
  yield {
224
242
  type: "tool_use",
225
243
  id: part.toolCallId,
@@ -229,6 +247,10 @@ export class ChatSession {
229
247
  }
230
248
  else if (part.type === "tool-result") {
231
249
  toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
250
+ const call = toolCallsById.get(part.toolCallId);
251
+ if (call) {
252
+ call.output = truncateToolContext(safeJson(part.output));
253
+ }
232
254
  yield {
233
255
  type: "tool_result",
234
256
  tool_use_id: part.toolCallId,
@@ -240,6 +262,11 @@ export class ChatSession {
240
262
  else if (part.type === "tool-error") {
241
263
  const message = errorMessage(part.error);
242
264
  toolContext.push(`tool_error ${part.toolName}: ${message}`);
265
+ const call = toolCallsById.get(part.toolCallId);
266
+ if (call) {
267
+ call.output = message;
268
+ call.is_error = true;
269
+ }
243
270
  yield {
244
271
  type: "tool_result",
245
272
  tool_use_id: part.toolCallId,
@@ -255,11 +282,14 @@ export class ChatSession {
255
282
  }
256
283
  }
257
284
  completed = true;
258
- const persistedAssistantText = truncateMiddle(fullText, MAX_PERSISTED_ASSISTANT_TEXT_CHARS, "...[assistant response truncated in context]...");
259
- const persistedText = toolContext.length > 0
260
- ? `${persistedAssistantText}\n\n[Tool transcript]\n${truncateMiddle(toolContext.join("\n"), MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS, "...[tool transcript truncated]...")}`
261
- : persistedAssistantText;
262
- this.context.appendMessage({ role: "assistant", content: persistedText });
285
+ const collectedToolCalls = toolCallOrder
286
+ .map((id) => toolCallsById.get(id))
287
+ .filter((call) => call !== undefined);
288
+ this.context.appendMessage(buildPersistedAssistantMessage({
289
+ fullText,
290
+ transcriptLines: toolContext,
291
+ toolCalls: collectedToolCalls,
292
+ }));
263
293
  yield { type: "done", text: safeAccumulated };
264
294
  }
265
295
  catch (err) {
@@ -386,14 +416,6 @@ function safeRawStreamJson(value) {
386
416
  function truncateToolContext(value) {
387
417
  return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
388
418
  }
389
- function truncateMiddle(value, maxChars, marker) {
390
- if (value.length <= maxChars)
391
- return value;
392
- const available = Math.max(0, maxChars - marker.length - 2);
393
- const headChars = Math.ceil(available / 2);
394
- const tailChars = Math.floor(available / 2);
395
- return `${value.slice(0, headChars)}\n${marker}\n${value.slice(-tailChars)}`;
396
- }
397
419
  function formatDuration(ms) {
398
420
  if (ms % 60_000 === 0)
399
421
  return `${ms / 60_000} minutes`;
@@ -0,0 +1,59 @@
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
+ import { type BuiltinContextRole } from "./context.js";
13
+ export interface SessionSearchOptions {
14
+ /** 会话目录,默认 ~/.deepccc/sessions */
15
+ contextDir?: string;
16
+ /** raw-stream-logs 根目录,默认 ~/.deepccc/raw-stream-logs */
17
+ rawLogsDir?: string;
18
+ /** 是否搜索 gzip 原始流日志(默认 false,较慢) */
19
+ includeRawLogs?: boolean;
20
+ /** 只搜索指定 sessionId(目录名或 state.sessionId 匹配) */
21
+ sessionId?: string;
22
+ /** 结果上限(默认 20,上限 50) */
23
+ maxResults?: number;
24
+ /** 单条命中片段最大字符数(默认 400) */
25
+ maxSnippetChars?: number;
26
+ /** 单个 raw log 文件最多解压检索的字节数(默认 4MB) */
27
+ maxRawLogBytesPerFile?: number;
28
+ }
29
+ export interface SessionSearchInput {
30
+ query: string;
31
+ session_id?: string;
32
+ include_raw_logs?: boolean;
33
+ max_results?: number;
34
+ }
35
+ export interface SessionSearchMatch {
36
+ sessionId: string;
37
+ source: "context" | "summary" | "raw-log";
38
+ role?: BuiltinContextRole;
39
+ /** 在 messages 数组中的下标 */
40
+ messageIndex?: number;
41
+ /** 在 message.toolCalls 数组中的下标(仅结构化工具调用命中时) */
42
+ toolCallIndex?: number;
43
+ toolCallName?: string;
44
+ snippet: string;
45
+ filePath: string;
46
+ }
47
+ export interface SessionSearchOutput {
48
+ query: string;
49
+ terms: string[];
50
+ matches: SessionSearchMatch[];
51
+ truncated: boolean;
52
+ scannedSessions: number;
53
+ scannedRawLogFiles: number;
54
+ }
55
+ /**
56
+ * 关键词检索历史会话存档。context.json 为同步扫描;raw-stream-logs 为
57
+ * gzip 逐行解压(异步,仅 options.includeRawLogs 时启用)。
58
+ */
59
+ export declare function searchBuiltinSessions(query: string, options?: SessionSearchOptions): Promise<SessionSearchOutput>;
@@ -0,0 +1,276 @@
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
+ import { createReadStream, existsSync, readdirSync, readFileSync, statSync } from "node:fs";
13
+ import { join } from "node:path";
14
+ import { createInterface } from "node:readline";
15
+ import { createGunzip } from "node:zlib";
16
+ import { RAW_STREAM_LOGS_DIR } from "./config.js";
17
+ import { DEFAULT_BUILTIN_CONTEXT_DIR } from "./context.js";
18
+ const DEFAULT_MAX_RESULTS = 20;
19
+ const MAX_RESULTS_CAP = 50;
20
+ const DEFAULT_MAX_SNIPPET_CHARS = 400;
21
+ const MAX_SNIPPET_CHARS_CAP = 2_000;
22
+ const DEFAULT_MAX_RAW_LOG_BYTES_PER_FILE = 4 * 1024 * 1024;
23
+ function clamp(value, min, max) {
24
+ return Math.min(max, Math.max(min, value));
25
+ }
26
+ function tokenize(query) {
27
+ return query
28
+ .toLowerCase()
29
+ .split(/\s+/)
30
+ .map((term) => term.trim())
31
+ .filter((term) => term.length > 0);
32
+ }
33
+ /** 多词 AND、大小写不敏感:候选文本必须包含全部关键词 */
34
+ function matchesTerms(text, terms) {
35
+ const lower = text.toLowerCase();
36
+ return terms.every((term) => lower.includes(term));
37
+ }
38
+ function makeSnippet(text, terms, maxChars) {
39
+ if (text.length <= maxChars)
40
+ return text;
41
+ const lower = text.toLowerCase();
42
+ let hitIndex = -1;
43
+ for (const term of terms) {
44
+ const index = lower.indexOf(term);
45
+ if (index !== -1) {
46
+ hitIndex = index;
47
+ break;
48
+ }
49
+ }
50
+ const half = Math.floor(maxChars / 2);
51
+ if (hitIndex === -1) {
52
+ return `${text.slice(0, half)}…${text.slice(-half)}`;
53
+ }
54
+ const start = Math.max(0, hitIndex - half);
55
+ const end = Math.min(text.length, hitIndex + half);
56
+ const prefix = start > 0 ? "…" : "";
57
+ const suffix = end < text.length ? "…" : "";
58
+ return `${prefix}${text.slice(start, end)}${suffix}`;
59
+ }
60
+ function searchContextDir(query, options, maxResults, maxSnippetChars) {
61
+ const dir = options.contextDir ?? DEFAULT_BUILTIN_CONTEXT_DIR;
62
+ const terms = tokenize(query);
63
+ const matches = [];
64
+ if (terms.length === 0 || !existsSync(dir)) {
65
+ return { matches, scannedSessions: 0, totalHits: 0 };
66
+ }
67
+ const restrictTo = options.sessionId?.trim();
68
+ let scannedSessions = 0;
69
+ let totalHits = 0;
70
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
71
+ if (!entry.isDirectory())
72
+ continue;
73
+ if (restrictTo && entry.name !== restrictTo)
74
+ continue;
75
+ const filePath = join(dir, entry.name, "context.json");
76
+ if (!existsSync(filePath))
77
+ continue;
78
+ let state;
79
+ try {
80
+ state = JSON.parse(readFileSync(filePath, "utf8"));
81
+ }
82
+ catch {
83
+ continue; // 损坏或非 JSON 的会话文件跳过
84
+ }
85
+ if (!state || typeof state !== "object")
86
+ continue;
87
+ if (restrictTo && state.sessionId !== restrictTo && entry.name !== restrictTo)
88
+ continue;
89
+ scannedSessions += 1;
90
+ const sessionId = typeof state.sessionId === "string" ? state.sessionId : entry.name;
91
+ if (typeof state.summary === "string" && matchesTerms(state.summary, terms)) {
92
+ totalHits += 1;
93
+ matches.push({
94
+ sessionId,
95
+ source: "summary",
96
+ snippet: makeSnippet(state.summary, terms, maxSnippetChars),
97
+ filePath,
98
+ });
99
+ }
100
+ if (Array.isArray(state.messages)) {
101
+ state.messages.forEach((message, messageIndex) => {
102
+ if (!message || typeof message !== "object")
103
+ return;
104
+ const raw = message;
105
+ const role = raw.role === "user" || raw.role === "assistant" ? raw.role : undefined;
106
+ if (typeof raw.content === "string" && matchesTerms(raw.content, terms)) {
107
+ totalHits += 1;
108
+ matches.push({
109
+ sessionId,
110
+ source: "context",
111
+ role,
112
+ messageIndex,
113
+ snippet: makeSnippet(raw.content, terms, maxSnippetChars),
114
+ filePath,
115
+ });
116
+ }
117
+ if (Array.isArray(raw.toolCalls)) {
118
+ raw.toolCalls.forEach((call, toolCallIndex) => {
119
+ if (!call || typeof call !== "object")
120
+ return;
121
+ const callRaw = call;
122
+ const name = typeof callRaw.name === "string" ? callRaw.name : "";
123
+ const input = typeof callRaw.input === "string" ? callRaw.input : "";
124
+ const output = typeof callRaw.output === "string" ? callRaw.output : "";
125
+ if (name.length === 0 && input.length === 0 && output.length === 0)
126
+ return;
127
+ const haystack = [name, input, output].join("\n");
128
+ if (!matchesTerms(haystack, terms))
129
+ return;
130
+ totalHits += 1;
131
+ const source = output || input || name;
132
+ matches.push({
133
+ sessionId,
134
+ source: "context",
135
+ role,
136
+ messageIndex,
137
+ toolCallIndex,
138
+ toolCallName: name,
139
+ snippet: makeSnippet(source, terms, maxSnippetChars),
140
+ filePath,
141
+ });
142
+ });
143
+ }
144
+ });
145
+ }
146
+ }
147
+ return { matches: matches.slice(0, maxResults), scannedSessions, totalHits };
148
+ }
149
+ async function searchRawLogs(query, options, maxResults, maxSnippetChars) {
150
+ const terms = tokenize(query);
151
+ const rootDir = options.rawLogsDir ?? RAW_STREAM_LOGS_DIR;
152
+ const matches = [];
153
+ if (terms.length === 0 || !existsSync(rootDir)) {
154
+ return { matches, scannedFiles: 0, truncated: false };
155
+ }
156
+ const toolRoot = join(rootDir, "deepccc");
157
+ if (!existsSync(toolRoot))
158
+ return { matches, scannedFiles: 0, truncated: false };
159
+ const restrictTo = options.sessionId?.trim();
160
+ const maxBytesPerFile = Math.max(0, options.maxRawLogBytesPerFile ?? DEFAULT_MAX_RAW_LOG_BYTES_PER_FILE);
161
+ // 收集所有 .jsonl.gz 文件,按 mtime 新→旧排序(最新轮次优先)
162
+ const files = [];
163
+ for (const sessionEntry of readdirSync(toolRoot, { withFileTypes: true })) {
164
+ if (!sessionEntry.isDirectory())
165
+ continue;
166
+ if (restrictTo && sessionEntry.name !== restrictTo)
167
+ continue;
168
+ const sessionDir = join(toolRoot, sessionEntry.name);
169
+ let entries;
170
+ try {
171
+ entries = readdirSync(sessionDir, { withFileTypes: true });
172
+ }
173
+ catch {
174
+ continue;
175
+ }
176
+ for (const fileEntry of entries) {
177
+ if (!fileEntry.isFile() || !fileEntry.name.endsWith(".jsonl.gz"))
178
+ continue;
179
+ const filePath = join(sessionDir, fileEntry.name);
180
+ try {
181
+ const info = statSync(filePath);
182
+ files.push({ path: filePath, sessionId: sessionEntry.name, mtimeMs: info.mtimeMs });
183
+ }
184
+ catch {
185
+ // 无法 stat 的文件跳过
186
+ }
187
+ }
188
+ }
189
+ files.sort((a, b) => b.mtimeMs - a.mtimeMs);
190
+ let scannedFiles = 0;
191
+ let truncated = false;
192
+ for (const file of files) {
193
+ if (matches.length >= maxResults) {
194
+ truncated = true;
195
+ break;
196
+ }
197
+ scannedFiles += 1;
198
+ const snippets = await searchGzipFileLines(file.path, terms, maxBytesPerFile, maxSnippetChars);
199
+ for (const snippet of snippets) {
200
+ matches.push({
201
+ sessionId: file.sessionId,
202
+ source: "raw-log",
203
+ snippet,
204
+ filePath: file.path,
205
+ });
206
+ if (matches.length >= maxResults)
207
+ break;
208
+ }
209
+ }
210
+ return { matches: matches.slice(0, maxResults), scannedFiles, truncated };
211
+ }
212
+ async function searchGzipFileLines(filePath, terms, maxBytes, maxSnippetChars) {
213
+ const hits = [];
214
+ let bytes = 0;
215
+ try {
216
+ const source = createReadStream(filePath);
217
+ const gunzip = createGunzip();
218
+ // 防崩溃(线上事故):截断/损坏的 gzip 会在 zlib 层 emit 'error'(如
219
+ // "unexpected end of file")。若无人监听,错误事件会升级为 uncaughtException
220
+ // 直接杀死整个服务(崩溃黑匣子 exit(1))。显式挂监听把错误降级为
221
+ // “跳过该文件”:destroy 流让 for await 正常结束,不再冒泡。
222
+ source.on("error", () => gunzip.destroy());
223
+ gunzip.on("error", () => gunzip.destroy());
224
+ const reader = createInterface({ input: source.pipe(gunzip), crlfDelay: Infinity });
225
+ // readline 会把 input 流的 error 转发到自己身上,同样需要监听避免冒泡
226
+ reader.on("error", () => { });
227
+ for await (const line of reader) {
228
+ bytes += Buffer.byteLength(line, "utf-8");
229
+ if (bytes > maxBytes)
230
+ break;
231
+ if (!matchesTerms(line, terms))
232
+ continue;
233
+ hits.push(makeSnippet(line, terms, maxSnippetChars));
234
+ if (hits.length >= 50)
235
+ break;
236
+ }
237
+ }
238
+ catch {
239
+ // 损坏的 gzip 文件按 best-effort 跳过
240
+ }
241
+ return hits;
242
+ }
243
+ /**
244
+ * 关键词检索历史会话存档。context.json 为同步扫描;raw-stream-logs 为
245
+ * gzip 逐行解压(异步,仅 options.includeRawLogs 时启用)。
246
+ */
247
+ export async function searchBuiltinSessions(query, options = {}) {
248
+ const terms = tokenize(query);
249
+ const maxResults = clamp(options.maxResults ?? DEFAULT_MAX_RESULTS, 1, MAX_RESULTS_CAP);
250
+ const maxSnippetChars = clamp(options.maxSnippetChars ?? DEFAULT_MAX_SNIPPET_CHARS, 80, MAX_SNIPPET_CHARS_CAP);
251
+ if (terms.length === 0) {
252
+ return {
253
+ query,
254
+ terms,
255
+ matches: [],
256
+ truncated: false,
257
+ scannedSessions: 0,
258
+ scannedRawLogFiles: 0,
259
+ };
260
+ }
261
+ const contextResult = searchContextDir(query, options, maxResults, maxSnippetChars);
262
+ let rawResult = { matches: [], scannedFiles: 0, truncated: false };
263
+ if (options.includeRawLogs) {
264
+ rawResult = await searchRawLogs(query, options, maxResults, maxSnippetChars);
265
+ }
266
+ const matches = [...contextResult.matches, ...rawResult.matches].slice(0, maxResults);
267
+ const truncated = contextResult.totalHits > maxResults || rawResult.truncated || rawResult.matches.length > maxResults;
268
+ return {
269
+ query,
270
+ terms,
271
+ matches,
272
+ truncated,
273
+ scannedSessions: contextResult.scannedSessions,
274
+ scannedRawLogFiles: rawResult.scannedFiles,
275
+ };
276
+ }
package/package.json CHANGED
@@ -1,62 +1,62 @@
1
- {
2
- "name": "deepccc",
3
- "version": "0.1.10",
4
- "description": "A lightweight DeepSeek-optimized coding agent with OpenAI-compatible model support.",
5
- "license": "Apache-2.0",
6
- "keywords": [
7
- "agent",
8
- "deepseek",
9
- "coding-agent",
10
- "openai-compatible",
11
- "cli"
12
- ],
13
- "repository": {
14
- "type": "git",
15
- "url": "git+https://github.com/wzj998/deepccc-agent.git"
16
- },
17
- "bugs": {
18
- "url": "https://github.com/wzj998/deepccc-agent/issues"
19
- },
20
- "homepage": "https://github.com/wzj998/deepccc-agent#readme",
21
- "type": "module",
22
- "main": "./dist/index.js",
23
- "types": "./dist/index.d.ts",
24
- "exports": {
25
- ".": {
26
- "types": "./dist/index.d.ts",
27
- "import": "./dist/index.js"
28
- }
29
- },
30
- "bin": {
31
- "deepccc": "bin/deepccc.mjs"
32
- },
33
- "files": [
34
- "dist/",
35
- "bin/",
36
- "docs/",
37
- "package.json",
38
- "README.md",
39
- "LICENSE"
40
- ],
41
- "scripts": {
42
- "build": "node node_modules/typescript/bin/tsc -p tsconfig.build.json",
43
- "prepack": "node node_modules/typescript/bin/tsc -p tsconfig.build.json",
44
- "test": "vitest run",
45
- "test:watch": "vitest",
46
- "typecheck": "node node_modules/typescript/bin/tsc --noEmit"
47
- },
48
- "dependencies": {
49
- "@ai-sdk/openai-compatible": "^2.0.47",
50
- "@vscode/ripgrep": "^1.18.0",
51
- "ai": "^6.0.184"
52
- },
53
- "devDependencies": {
54
- "@types/node": "^20.0.0",
55
- "tsx": "^4.0.0",
56
- "typescript": "^5.0.0",
57
- "vitest": "^3.2.4"
58
- },
59
- "engines": {
60
- "node": ">=20"
61
- }
62
- }
1
+ {
2
+ "name": "deepccc",
3
+ "version": "0.1.12",
4
+ "description": "A lightweight DeepSeek-optimized coding agent with OpenAI-compatible model support.",
5
+ "license": "Apache-2.0",
6
+ "keywords": [
7
+ "agent",
8
+ "deepseek",
9
+ "coding-agent",
10
+ "openai-compatible",
11
+ "cli"
12
+ ],
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/wzj998/deepccc-agent.git"
16
+ },
17
+ "bugs": {
18
+ "url": "https://github.com/wzj998/deepccc-agent/issues"
19
+ },
20
+ "homepage": "https://github.com/wzj998/deepccc-agent#readme",
21
+ "type": "module",
22
+ "main": "./dist/index.js",
23
+ "types": "./dist/index.d.ts",
24
+ "exports": {
25
+ ".": {
26
+ "types": "./dist/index.d.ts",
27
+ "import": "./dist/index.js"
28
+ }
29
+ },
30
+ "bin": {
31
+ "deepccc": "bin/deepccc.mjs"
32
+ },
33
+ "files": [
34
+ "dist/",
35
+ "bin/",
36
+ "docs/",
37
+ "package.json",
38
+ "README.md",
39
+ "LICENSE"
40
+ ],
41
+ "scripts": {
42
+ "build": "node node_modules/typescript/bin/tsc -p tsconfig.build.json",
43
+ "prepack": "node node_modules/typescript/bin/tsc -p tsconfig.build.json",
44
+ "test": "vitest run",
45
+ "test:watch": "vitest",
46
+ "typecheck": "node node_modules/typescript/bin/tsc --noEmit"
47
+ },
48
+ "dependencies": {
49
+ "@ai-sdk/openai-compatible": "^2.0.47",
50
+ "@vscode/ripgrep": "^1.18.0",
51
+ "ai": "^6.0.184"
52
+ },
53
+ "devDependencies": {
54
+ "@types/node": "^20.0.0",
55
+ "tsx": "^4.0.0",
56
+ "typescript": "^5.0.0",
57
+ "vitest": "^3.2.4"
58
+ },
59
+ "engines": {
60
+ "node": ">=20"
61
+ }
62
+ }