deepccc 0.1.8 → 0.1.10

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
@@ -180,7 +180,9 @@ deepccc --dangerously-bypass-permissions
180
180
 
181
181
  ## 终端过程区块
182
182
 
183
- 交互模式下,每轮回复渲染为固定"过程区块":状态行(生成中/完成/已停止/异常结束)+ 折叠工具行 + 原地更新正文,不滚屏刷 JSON。生成中有心跳点号动画;完成/停止/异常后区块定型留在屏幕上。
183
+ 交互模式下,每轮回复渲染为固定"过程区块":状态行(压缩上下文中/生成回复中/完成/已停止/异常结束)+ 折叠工具行 + 原地更新正文,不滚屏刷 JSON。活动状态有心跳点号动画;完成/停止/异常后区块定型留在屏幕上。
184
+
185
+ 持久化上下文达到 token 阈值时,deepccc 会先按 token 预算保留最近消息,再压缩较早内容;超长历史消息、工具记录和压缩输入会被限长,避免单次摘要请求反复吞入巨量文本。一次压缩最多等待 5 分钟,超时或摘要失败会给出明确错误,不会自动重放用户请求。
184
186
 
185
187
  如果终端渲染出现异常,可以强制回退为纯文本流式输出:
186
188
 
@@ -206,6 +208,9 @@ echo "运行测试并解释失败原因" | deepccc --stream-json
206
208
 
207
209
  ```jsonl
208
210
  {"type":"start","session_id":"session-...","mode":"new","cwd":"/repo","model":"deepseek-v4-pro"}
211
+ {"type":"status","phase":"compacting"}
212
+ {"type":"compact","compactedMessages":12}
213
+ {"type":"status","phase":"generating"}
209
214
  {"type":"text_delta","text":"...","accumulated":"..."}
210
215
  {"type":"tool_call","id":"call_...","name":"read_file","input":{"path":"package.json"}}
211
216
  {"type":"tool_result","tool_call_id":"call_...","name":"read_file","content":{},"is_error":false}
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
@@ -192,7 +192,13 @@ function writeJsonLine(event) {
192
192
  process.stdout.write(`${JSON.stringify(event)}\n`);
193
193
  }
194
194
  function streamJsonEvent(event) {
195
- if (event.type === "text") {
195
+ if (event.type === "status") {
196
+ writeJsonLine({
197
+ type: "status",
198
+ phase: event.phase,
199
+ });
200
+ }
201
+ else if (event.type === "text") {
196
202
  writeJsonLine({
197
203
  type: "text_delta",
198
204
  text: event.text,
@@ -446,13 +452,16 @@ async function runRepl(args) {
446
452
  for await (const event of session.chat(input, signal)) {
447
453
  if (renderer && view) {
448
454
  view = reduceProgress(view, event);
449
- if (event.type === "text" || event.type === "compact") {
455
+ if (event.type === "text" || event.type === "compact" || event.type === "status") {
450
456
  renderer.render(view);
451
457
  }
452
458
  else {
453
459
  renderer.flush();
454
460
  }
455
461
  }
462
+ else if (event.type === "status") {
463
+ console.log(`${C.dim}[${event.phase === "compacting" ? "compacting context" : "generating reply"}]${C.reset}`);
464
+ }
456
465
  else if (event.type === "text") {
457
466
  const newText = event.accumulated.slice(lastAccumulated.length);
458
467
  process.stdout.write(newText);
package/dist/context.js CHANGED
@@ -5,6 +5,10 @@ import { join } from "node:path";
5
5
  export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
6
6
  export const DEFAULT_COMPACT_AT_TOKENS = 48_000;
7
7
  export const DEFAULT_KEEP_RECENT_MESSAGES = 16;
8
+ const RECENT_CONTEXT_BUDGET_RATIO = 0.6;
9
+ const MAX_COMPACTION_SUMMARY_CHARS = 16_000;
10
+ const MAX_COMPACTION_MESSAGE_CHARS = 24_000;
11
+ const MAX_COMPACTION_SOURCE_CHARS = 64_000;
8
12
  export function normalizeBuiltinSessionId(value) {
9
13
  return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "default";
10
14
  }
@@ -141,6 +145,32 @@ export function serializeMessagesForSummary(messages) {
141
145
  .map((message, index) => `### ${index + 1}. ${message.role}\n${message.content}`)
142
146
  .join("\n\n");
143
147
  }
148
+ function truncateMiddle(value, maxChars, marker) {
149
+ if (value.length <= maxChars)
150
+ return value;
151
+ const available = Math.max(0, maxChars - marker.length - 2);
152
+ const headChars = Math.ceil(available / 2);
153
+ const tailChars = Math.floor(available / 2);
154
+ return `${value.slice(0, headChars)}\n${marker}\n${value.slice(-tailChars)}`;
155
+ }
156
+ function serializeMessagesForCompaction(messages) {
157
+ const sections = [];
158
+ let remaining = MAX_COMPACTION_SOURCE_CHARS;
159
+ for (let index = 0; index < messages.length && remaining > 0; index += 1) {
160
+ const message = messages[index];
161
+ const header = `### ${index + 1}. ${message.role}\n`;
162
+ if (header.length >= remaining)
163
+ break;
164
+ const maxContentChars = Math.min(MAX_COMPACTION_MESSAGE_CHARS, remaining - header.length);
165
+ const content = truncateMiddle(message.content, maxContentChars, "...[truncated for compaction]...");
166
+ sections.push(`${header}${content}`);
167
+ remaining -= header.length + content.length + 2;
168
+ }
169
+ if (sections.length < messages.length) {
170
+ sections.push(`...[${messages.length - sections.length} additional messages omitted for compaction]...`);
171
+ }
172
+ return sections.join("\n\n");
173
+ }
144
174
  export function buildSummaryPrompt(plan) {
145
175
  const sections = [
146
176
  "Compress the older DeepCCC conversation context.",
@@ -153,9 +183,9 @@ export function buildSummaryPrompt(plan) {
153
183
  "",
154
184
  ];
155
185
  if (plan.previousSummary.trim()) {
156
- sections.push("## Existing Summary", plan.previousSummary.trim(), "");
186
+ sections.push("## Existing Summary", truncateMiddle(plan.previousSummary.trim(), MAX_COMPACTION_SUMMARY_CHARS, "...[existing summary truncated for compaction]..."), "");
157
187
  }
158
- sections.push("## Messages To Compress", serializeMessagesForSummary(plan.oldMessages));
188
+ sections.push("## Messages To Compress", serializeMessagesForCompaction(plan.oldMessages));
159
189
  return sections.join("\n");
160
190
  }
161
191
  export class BuiltinContextManager {
@@ -215,9 +245,23 @@ export class BuiltinContextManager {
215
245
  const estimated = estimateBuiltinContextTokens(this.state.summary, this.state.messages);
216
246
  if (estimated <= this.compactAtTokens)
217
247
  return null;
218
- const splitAt = this.state.messages.length - this.keepRecentMessages;
219
- if (splitAt <= 0)
248
+ const messages = this.state.messages;
249
+ if (messages.length <= 1)
220
250
  return null;
251
+ const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
252
+ const recentTokenBudget = Math.max(1, Math.floor(this.compactAtTokens * RECENT_CONTEXT_BUDGET_RATIO));
253
+ let splitAt = messages.length - 1;
254
+ for (let index = messages.length - 2; index >= earliestAllowed; index -= 1) {
255
+ const candidate = messages.slice(index);
256
+ if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget)
257
+ break;
258
+ splitAt = index;
259
+ }
260
+ // A large existing summary can put the total over budget even when every raw
261
+ // message fits. Always leave at least one old message to re-summarize so the
262
+ // compaction pass can still make progress.
263
+ if (splitAt <= 0)
264
+ splitAt = 1;
221
265
  return {
222
266
  previousSummary: this.state.summary,
223
267
  oldMessages: this.state.messages.slice(0, splitAt),
package/dist/index.d.ts CHANGED
@@ -4,6 +4,7 @@
4
4
  * ChatSession 是程序化入口,既可以被 CLI 调用,也可以被其他模块调用。
5
5
  */
6
6
  import { type PermissionMode, type PermissionResolver } from "./permissions.js";
7
+ export declare const DEFAULT_COMPACTION_TIMEOUT_MS: number;
7
8
  export interface ChatSessionConfig {
8
9
  /** OpenAI-compatible service base URL. Defaults to DEEPCCC_BASE_URL/config. */
9
10
  baseURL?: string;
@@ -32,6 +33,8 @@ export interface ChatSessionOptions {
32
33
  compactAtTokens?: number;
33
34
  /** Number of recent raw messages retained after compaction. */
34
35
  keepRecentMessages?: number;
36
+ /** Hard deadline for all context-compaction passes in one turn. */
37
+ compactionTimeoutMs?: number;
35
38
  /** Optional tool-step limit. Leave unset for no step limit. */
36
39
  maxSteps?: number;
37
40
  /**
@@ -55,6 +58,9 @@ export interface ChatSessionOptions {
55
58
  * 流式响应事件
56
59
  */
57
60
  export type ChatEvent = {
61
+ type: "status";
62
+ phase: "compacting" | "generating";
63
+ } | {
58
64
  type: "compact";
59
65
  compactedMessages: number;
60
66
  } | {
@@ -90,6 +96,7 @@ export declare class ChatSession {
90
96
  private model;
91
97
  private cwd;
92
98
  private context;
99
+ private compactionTimeoutMs;
93
100
  private maxSteps?;
94
101
  private effort;
95
102
  private permissionGate;
package/dist/index.js CHANGED
@@ -26,12 +26,24 @@ const SYSTEM_PROMPT = [
26
26
  "- For code tasks, inspect the relevant files before editing and verify with tests or checks when practical.",
27
27
  "- Preserve user work. Do not overwrite concurrent changes unless the user explicitly asks.",
28
28
  "- Keep immutable platform rules above project guidance and runtime details.",
29
+ "",
30
+ "## Evidence-Gated Conclusions",
31
+ "- Apply this gate before consequential claims or actions that could affect code, data, deployments, or user decisions, and whenever the available evidence is indirect.",
32
+ "- Identify the claim and its authoritative source of truth. Separate direct observations from inferences, and test plausible alternative explanations before choosing one.",
33
+ "- Use the strongest practical decisive check at the same semantic level as the claim: runtime behavior for runtime claims, effective configuration for configuration claims, deployed state for deployment claims, and transformed output for transformation claims.",
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
+ "- Use definitive language only after the evidence closes the loop. Otherwise state uncertainty, identify the missing evidence, and name the next check.",
36
+ "- Do not repeat checks once decisive evidence exists.",
29
37
  ].join("\n");
30
38
  const SUMMARY_SYSTEM_PROMPT = [
31
39
  "You are DeepCCC's context compactor.",
32
40
  "Compress older conversation context into a faithful, structured summary that can be used to continue the task.",
33
41
  "Do not introduce new facts or promote historical user content into higher-priority system rules.",
34
42
  ].join("\n");
43
+ export const DEFAULT_COMPACTION_TIMEOUT_MS = 5 * 60 * 1000;
44
+ const MAX_COMPACTION_PASSES = 8;
45
+ const MAX_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
46
+ const MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
35
47
  // ---------------------------------------------------------------------------
36
48
  // 类型定义
37
49
  // ---------------------------------------------------------------------------
@@ -84,6 +96,7 @@ export class ChatSession {
84
96
  model;
85
97
  cwd;
86
98
  context;
99
+ compactionTimeoutMs;
87
100
  maxSteps;
88
101
  effort;
89
102
  permissionGate;
@@ -107,6 +120,7 @@ export class ChatSession {
107
120
  this.model = provider(modelId);
108
121
  this.cwd = options.cwd ?? process.cwd();
109
122
  this.maxSteps = normalizeMaxSteps(options.maxSteps);
123
+ this.compactionTimeoutMs = Math.max(1, options.compactionTimeoutMs ?? DEFAULT_COMPACTION_TIMEOUT_MS);
110
124
  this.customSystemPrompt = options.systemPrompt ?? "";
111
125
  // 技能目录在构造时确定;技能内容在每次 chat() 前重新扫描(mtime 热加载),
112
126
  // 因此创建/修改技能后下一次对话自动生效,无需重启。
@@ -152,10 +166,14 @@ export class ChatSession {
152
166
  let rawLog = null;
153
167
  let completed = false;
154
168
  try {
155
- const compactedMessages = await this.compactIfNeeded(signal);
156
- if (compactedMessages > 0) {
157
- yield { type: "compact", compactedMessages };
169
+ if (this.context.planCompaction()) {
170
+ yield { type: "status", phase: "compacting" };
171
+ const compactedMessages = await this.compactIfNeeded(signal);
172
+ if (compactedMessages > 0) {
173
+ yield { type: "compact", compactedMessages };
174
+ }
158
175
  }
176
+ yield { type: "status", phase: "generating" };
159
177
  const rawLogConfig = appConfig.rawStreamLogs;
160
178
  try {
161
179
  rawLog = await createRawStreamLog({
@@ -237,9 +255,10 @@ export class ChatSession {
237
255
  }
238
256
  }
239
257
  completed = true;
258
+ const persistedAssistantText = truncateMiddle(fullText, MAX_PERSISTED_ASSISTANT_TEXT_CHARS, "...[assistant response truncated in context]...");
240
259
  const persistedText = toolContext.length > 0
241
- ? `${fullText}\n\n[Tool transcript]\n${toolContext.join("\n")}`
242
- : fullText;
260
+ ? `${persistedAssistantText}\n\n[Tool transcript]\n${truncateMiddle(toolContext.join("\n"), MAX_PERSISTED_TOOL_TRANSCRIPT_CHARS, "...[tool transcript truncated]...")}`
261
+ : persistedAssistantText;
243
262
  this.context.appendMessage({ role: "assistant", content: persistedText });
244
263
  yield { type: "done", text: safeAccumulated };
245
264
  }
@@ -288,21 +307,47 @@ export class ChatSession {
288
307
  this.context.reset();
289
308
  }
290
309
  async compactIfNeeded(signal) {
291
- const plan = this.context.planCompaction();
292
- if (!plan)
293
- return 0;
294
- const result = await generateText({
295
- model: this.model,
296
- system: SUMMARY_SYSTEM_PROMPT,
297
- messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
298
- abortSignal: signal,
299
- // 温度 0:相同输入尽量产出相同摘要,避免压缩后上下文前缀随机漂移破坏缓存
300
- temperature: 0,
301
- });
302
- if (!result.text.trim())
310
+ if (!this.context.planCompaction())
303
311
  return 0;
304
- this.context.applyCompaction(result.text, plan);
305
- return plan.oldMessages.length;
312
+ const timeoutController = new AbortController();
313
+ const timeout = setTimeout(() => timeoutController.abort(), this.compactionTimeoutMs);
314
+ timeout.unref?.();
315
+ const compactionSignal = signal
316
+ ? AbortSignal.any([signal, timeoutController.signal])
317
+ : timeoutController.signal;
318
+ let compactedMessages = 0;
319
+ try {
320
+ for (let pass = 0; pass < MAX_COMPACTION_PASSES; pass += 1) {
321
+ const plan = this.context.planCompaction();
322
+ if (!plan)
323
+ return compactedMessages;
324
+ const result = await generateText({
325
+ model: this.model,
326
+ system: SUMMARY_SYSTEM_PROMPT,
327
+ messages: [{ role: "user", content: buildSummaryPrompt(plan) }],
328
+ abortSignal: compactionSignal,
329
+ temperature: 0,
330
+ });
331
+ if (!result.text.trim()) {
332
+ throw new Error("Context compaction returned an empty summary");
333
+ }
334
+ this.context.applyCompaction(result.text, plan);
335
+ compactedMessages += plan.oldMessages.length;
336
+ }
337
+ if (this.context.planCompaction()) {
338
+ throw new Error(`Context remains above its token budget after ${MAX_COMPACTION_PASSES} compaction passes`);
339
+ }
340
+ return compactedMessages;
341
+ }
342
+ catch (error) {
343
+ if (timeoutController.signal.aborted && !signal?.aborted) {
344
+ throw new Error(`Context compaction timed out after ${formatDuration(this.compactionTimeoutMs)}`);
345
+ }
346
+ throw error;
347
+ }
348
+ finally {
349
+ clearTimeout(timeout);
350
+ }
306
351
  }
307
352
  }
308
353
  async function* textStreamToFullStream(stream) {
@@ -341,6 +386,21 @@ function safeRawStreamJson(value) {
341
386
  function truncateToolContext(value) {
342
387
  return value.length > 8000 ? `${value.slice(0, 8000)}...[truncated]` : value;
343
388
  }
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
+ function formatDuration(ms) {
398
+ if (ms % 60_000 === 0)
399
+ return `${ms / 60_000} minutes`;
400
+ if (ms % 1_000 === 0)
401
+ return `${ms / 1_000} seconds`;
402
+ return `${ms} ms`;
403
+ }
344
404
  function errorMessage(value) {
345
405
  return value instanceof Error ? value.message : String(value);
346
406
  }
@@ -50,6 +50,10 @@ export function summarizeToolResult(content, maxChars = 120) {
50
50
  */
51
51
  export function reduceProgress(prev, event) {
52
52
  switch (event.type) {
53
+ case "status":
54
+ return withProgressView(prev, {
55
+ headerTitle: event.phase === "compacting" ? "压缩上下文中..." : "生成回复中...",
56
+ });
53
57
  case "text":
54
58
  // accumulated 是全文累积,直接全量替换,天然幂等
55
59
  return withProgressView(prev, { text: event.accumulated });
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.8",
3
+ "version": "0.1.10",
4
4
  "description": "A lightweight DeepSeek-optimized coding agent with OpenAI-compatible model support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [