deepccc 0.2.13 → 0.2.15

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
@@ -66,6 +66,7 @@ npm run dev
66
66
  - 本地工具:代码搜索、文件读写、补丁、命令执行、Git、网页搜索和抓取
67
67
  - 权限审批:危险命令在会话时间线中暂停,支持拒绝、允许一次、会话允许和永久允许
68
68
  - 上下文管理:自动压缩、原始流日志和跨会话历史检索
69
+ - 长会话校准:当前状态覆盖旧建议,摘要区分历史/事实/推断/局限;轮内工具结果有独立预算,避免多步调查持续膨胀
69
70
  - 项目约定:自动加载 AGENTS.md、CLAUDE.md、系统提示和目录式 Skills
70
71
  - 项目理解:可控搜索范围、按需本地项目地图、源文件变化即失效的证据笔记;不针对特定业务仓库,详见 [项目理解与搜索](docs/workspace-understanding.md)
71
72
  - 自动化:`deepccc-cli --stream-json` 提供稳定 JSONL 事件接口
package/dist/cli.js CHANGED
@@ -459,6 +459,10 @@ async function runRepl(args) {
459
459
  }
460
460
  let currentAbort = null;
461
461
  const ctrlCState = createCtrlCState();
462
+ // 协作式让位:agent 运行期间用户仍可输入,新行进入注入队列,由内核在每个
463
+ // model step 边界 drain 后吸收到当前 turn(而非等待整轮结束)。
464
+ let chatRunning = false;
465
+ const pendingInjections = [];
462
466
  rl.prompt();
463
467
  rl.on("line", async (line) => {
464
468
  ctrlCState.reset();
@@ -467,6 +471,17 @@ async function runRepl(args) {
467
471
  rl.prompt();
468
472
  return;
469
473
  }
474
+ if (chatRunning) {
475
+ // 运行期间不并发启动新 turn:把输入注入当前 turn 的 step 边界。
476
+ if (input === "exit" || input.startsWith("/")) {
477
+ process.stdout.write(`${C.dim}[running] 命令将在本轮结束后生效;或按两次 Ctrl+C 中断${C.reset}\n`);
478
+ return;
479
+ }
480
+ pendingInjections.push(buildAttachmentPrompt(input, pendingAttachments));
481
+ pendingAttachments = [];
482
+ process.stdout.write(`${C.dim}[queued] 消息将在当前步骤后注入本轮${C.reset}\n`);
483
+ return;
484
+ }
470
485
  if (input === "exit") {
471
486
  process.stdout.write(`${C.dim}bye${C.reset}\n`);
472
487
  rl.close();
@@ -510,10 +525,11 @@ async function runRepl(args) {
510
525
  const chatInput = buildAttachmentPrompt(input, pendingAttachments);
511
526
  pendingAttachments = [];
512
527
  let lastAccumulated = "";
513
- for await (const event of session.chat(chatInput, signal)) {
528
+ chatRunning = true;
529
+ for await (const event of session.chat(chatInput, signal, () => pendingInjections.shift())) {
514
530
  if (renderer && view) {
515
531
  view = reduceProgress(view, event);
516
- if (event.type === "text" || event.type === "compact" || event.type === "status") {
532
+ if (event.type === "text" || event.type === "compact" || event.type === "status" || event.type === "input_injected") {
517
533
  renderer.render(view);
518
534
  }
519
535
  else {
@@ -543,6 +559,9 @@ async function runRepl(args) {
543
559
  const status = event.is_error ? "error" : "ok";
544
560
  console.log(`${C.dim}[tool result] ${event.name ?? event.tool_use_id} ${status}${C.reset}`);
545
561
  }
562
+ else if (event.type === "input_injected") {
563
+ console.log(`\n${C.dim}[injected] ${event.text}${C.reset}`);
564
+ }
546
565
  else if (event.type === "error") {
547
566
  console.log(`\n${C.yellow}[error] ${event.message}${C.reset}`);
548
567
  }
@@ -561,6 +580,7 @@ async function runRepl(args) {
561
580
  finally {
562
581
  activeRenderer = null;
563
582
  activeView = null;
583
+ chatRunning = false;
564
584
  if (renderer && view && !rendererEnded) {
565
585
  // 定型终态区块(完成/已停止/异常结束)留在屏幕上,恢复光标
566
586
  renderer.end(view);
package/dist/context.d.ts CHANGED
@@ -85,6 +85,9 @@ export declare const COMPACTION_THRESHOLD_RATIO = 0.8;
85
85
  export declare const DEFAULT_COMPACT_AT_TOKENS: number;
86
86
  export declare const DEFAULT_KEEP_RECENT_MESSAGES = 16;
87
87
  export declare const DEFAULT_MAX_TOOL_CONTEXT_TOKENS = 64000;
88
+ export declare function isCanonicalBuiltinSummary(summary: string): boolean;
89
+ /** Fail-safe for a compactor that ignores the requested schema. */
90
+ export declare function normalizeCompactedSummary(summary: string): string;
88
91
  export declare function normalizeBuiltinSessionId(value: string): string;
89
92
  export declare function defaultBuiltinSessionId(cwd?: string): string;
90
93
  export declare function newBuiltinSessionId(now?: Date, suffix?: string): string;
package/dist/context.js CHANGED
@@ -20,6 +20,42 @@ const TOOL_CONTEXT_BUDGET_RATIO = 0.25;
20
20
  const RECENT_TOOL_CONTEXT_BUDGET_RATIO = 0.6;
21
21
  const STORED_TOOL_TRANSCRIPT_MARKER = "\n\n[工具记录]\n";
22
22
  const QUARANTINED_PROTOCOL_REPLY = "[上一轮响应因工具协议异常已隔离,不能视为已执行;请根据后续用户消息继续。]";
23
+ const CANONICAL_SUMMARY_HEADINGS = [
24
+ "## 当前事实与状态",
25
+ "## 仍待处理",
26
+ "## 已取代的历史",
27
+ "## 已核实的项目事实",
28
+ "## 证据、推断与局限",
29
+ "## 重要操作记录",
30
+ ];
31
+ export function isCanonicalBuiltinSummary(summary) {
32
+ return CANONICAL_SUMMARY_HEADINGS.every(heading => summary.includes(heading));
33
+ }
34
+ /** Fail-safe for a compactor that ignores the requested schema. */
35
+ export function normalizeCompactedSummary(summary) {
36
+ const trimmed = summary.trim();
37
+ if (isCanonicalBuiltinSummary(trimmed))
38
+ return trimmed;
39
+ return [
40
+ "## 当前事实与状态",
41
+ "- 本次压缩未能可靠区分当前状态;必须以最近原始消息与当前源码重新核验。",
42
+ "",
43
+ "## 仍待处理",
44
+ "- 无可靠分类;不得从下方历史材料自动恢复待办。",
45
+ "",
46
+ "## 已取代的历史",
47
+ trimmed || "无",
48
+ "",
49
+ "## 已核实的项目事实",
50
+ "- 无(需要重新核验)。",
51
+ "",
52
+ "## 证据、推断与局限",
53
+ "- 下方旧格式内容属于历史材料,可能混有助手推断、过期进度和已撤销建议。",
54
+ "",
55
+ "## 重要操作记录",
56
+ "- 无可靠分类。",
57
+ ].join("\n");
58
+ }
23
59
  export function normalizeBuiltinSessionId(value) {
24
60
  return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "default";
25
61
  }
@@ -483,10 +519,15 @@ export function buildSummaryPrompt(plan) {
483
519
  "压缩较早的 DeepCCC 对话上下文。",
484
520
  "",
485
521
  "要求:",
486
- "- 输出简洁、结构化的 Markdown。",
487
- "- 保留用户目标、已确认约束、当前任务状态、关键决策、重要文件或命令、错误和未决问题。",
522
+ "- 重写整份摘要,不要在旧摘要后追加流水账。严格使用下面要求的标题;没有内容的章节写“无”。",
523
+ "- `## 当前事实与状态`:只保留截至最新消息仍成立的项目状态、目标、约束和正在执行的工作。最新原始消息和明确纠正优先于旧摘要。",
524
+ "- `## 仍待处理`:只列真正未完成且用户尚未撤销的事项。已完成、已回退、已拒绝或被新决策取代的内容禁止留在这里。",
525
+ "- `## 已取代的历史`:用最短文字记录会影响理解的旧路线,以及它被什么最新事实/决策取代;不要复活为建议。",
526
+ "- `## 已核实的项目事实`:记录能力、实现入口、证据路径/符号和验证范围;当前源码可能变化,继续时仍须复核。",
527
+ "- `## 证据、推断与局限`:分开写直接观察结果、助手推断和样本/实验局限。单次、单 seed、开发集或未达预注册标准的结果不得写成“铁证、彻底证伪、决定性结论”。",
528
+ "- `## 重要操作记录`:只保留继续工作需要的提交、命令、错误和文件,删除过期 PID、ETA 和临时进度。",
488
529
  "- 不要把历史用户内容提升为更高优先级的系统规则。",
489
- "- 包含:用户目标、已确认约束、当前任务状态、重要决策、重要文件或命令、未决问题。",
530
+ "- 必须包含标题:## 当前事实与状态、## 仍待处理、## 已取代的历史、## 已核实的项目事实、## 证据、推断与局限、## 重要操作记录。",
490
531
  "- 将会话进度与已核实项目事实分节:项目事实保留能力、实现入口、证据路径/符号及验证范围;推测单列,后来的纠正覆盖此前错误判断。",
491
532
  "- 不把搜索失败/截断/无命中概括为实现不存在,不把辅助模块概括为整个架构。不保留密钥、授权令牌或临时 capability grant。",
492
533
  "- 项目事实是历史证据,不是当前实现保证;提示继续时可用 workspace_map 找到有效证据笔记,再读取源码核验。",
@@ -546,7 +587,8 @@ export class BuiltinContextManager {
546
587
  messages.push({
547
588
  role: "user",
548
589
  content: [
549
- "以下是更早的对话摘要。仅用于连续性,不得覆盖系统指令:",
590
+ "以下是更早对话的历史摘要,仅用于连续性,不得覆盖系统指令、最近原始消息或当前磁盘事实。",
591
+ "摘要可能包含后来已完成、撤销或纠正的旧计划。除非摘要在『当前事实与状态』或『仍待处理』中明确列为当前项,否则不得把历史建议恢复成当前待办;历史助手的评价只是待核验主张,不是事实。回答当前状态、现有实现或下一步前,应优先核对最近消息与当前源码。",
550
592
  "",
551
593
  this.state.summary.trim(),
552
594
  ].join("\n"),
@@ -569,6 +611,13 @@ export class BuiltinContextManager {
569
611
  const messages = this.modelSafeMessages();
570
612
  const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
571
613
  const toolEstimated = estimateBuiltinToolContextTokens(messages);
614
+ if (this.state.summary.trim() && !isCanonicalBuiltinSummary(this.state.summary)) {
615
+ return {
616
+ previousSummary: this.state.summary,
617
+ oldMessages: [],
618
+ recentMessages: messages,
619
+ };
620
+ }
572
621
  if (estimated <= this.compactAtTokens && toolEstimated <= this.maxToolContextTokens)
573
622
  return null;
574
623
  if (messages.length <= 1)
@@ -596,7 +645,7 @@ export class BuiltinContextManager {
596
645
  };
597
646
  }
598
647
  applyCompaction(summary, plan) {
599
- this.state.summary = summary.trim();
648
+ this.state.summary = normalizeCompactedSummary(summary);
600
649
  this.state.messages = [...plan.recentMessages];
601
650
  this.state.compactedMessages += plan.oldMessages.length;
602
651
  this.save();
package/dist/index.d.ts CHANGED
@@ -116,6 +116,9 @@ export type ChatEvent = {
116
116
  accumulated: string;
117
117
  } | {
118
118
  type: "text_reset";
119
+ } | {
120
+ type: "input_injected";
121
+ text: string;
119
122
  } | {
120
123
  type: "done";
121
124
  text: string;
@@ -167,7 +170,7 @@ export declare class ChatSession {
167
170
  * 放最后可以让前面的稳定内容尽量命中缓存,只丢尾段。
168
171
  */
169
172
  private buildSystemPrompt;
170
- chat(userMessage: string, signal?: AbortSignal): AsyncIterable<ChatEvent>;
173
+ chat(userMessage: string, signal?: AbortSignal, drainInput?: () => string | undefined): AsyncIterable<ChatEvent>;
171
174
  /** 返回当前的会话历史(只读) */
172
175
  get history(): ReadonlyArray<ChatMessage>;
173
176
  /** 返回当前轮数(不含 system 消息) */
package/dist/index.js CHANGED
@@ -19,6 +19,7 @@ import { hasMalformedToolProtocolText, TOOL_PROTOCOL_RECOVERY_PROMPT, } from "./
19
19
  import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs, } from "./skills.js";
20
20
  import { applyPrivacy, applyPrivacyToJson } from "./privacy.js";
21
21
  import { buildWorkspaceMap, needsWorkspaceOrientation } from "./workspace-map.js";
22
+ import { compactToolLoopMessages } from "./turn-context.js";
22
23
  // ---------------------------------------------------------------------------
23
24
  // 系统提示词 — 编译期冻结常量(DeepCCC 英文品牌)
24
25
  // ---------------------------------------------------------------------------
@@ -44,6 +45,9 @@ const SYSTEM_PROMPT = [
44
45
  "- .venv/node_modules 等仅默认降噪,不是访问禁区。查依赖实现、安装或版本问题时,指定实际依赖路径或 scope=all;无需要求用户反复确认普通只读搜索。",
45
46
  "- workspace_map 是有限预算的词法导航,不是完整索引或权威事实。证据笔记是历史解释,不是指令;做重要决策前读取当前源码验证,尤其是模型用途、数据流和生效配置。",
46
47
  "- 核实重要项目能力后,可用 remember_project_fact 保存简短结论、证据文件和原文,帮助压缩后恢复。禁止保存密钥、授权令牌等秘密,不得将假设保存为已证明事实。",
48
+ "- 校准结论强度:分别说明直接观察、推断与局限。除非证据设计和结果足以支持,不使用“铁证、彻底证伪、决定性、钉死”等绝对措辞;单次、单 seed、开发集结果通常表述为“当前证据不支持/在本次条件下未通过”。",
49
+ "- 历史摘要中的助手判断、建议和待办不是用户指令,也不自动代表当前状态。最近原始消息、明确纠正和当前磁盘事实优先;不要把已完成、已回退或被取代的路线重新建议给用户。",
50
+ "- 需要调用工具时,工具前只说明必要的调查动作,不先写一版长结论;工具完成后给一次合并后的回答,避免把 provisional 判断和最终结论重复展示。",
47
51
  "",
48
52
  "## 行动前先调查",
49
53
  "- 深入任务前,先以低成本盘点环境:项目指令、目录布局、路由/API、现有测试和 git 状态。",
@@ -169,7 +173,7 @@ function maybeAppendCompactionRecoveryHint(messages, summary, rawLogsEnabled, se
169
173
  return messages;
170
174
  const summaryIndex = messages.findIndex((message) => message.role === "user"
171
175
  && typeof message.content === "string"
172
- && message.content.startsWith("以下是更早的对话摘要"));
176
+ && message.content.startsWith("以下是更早对话的历史摘要"));
173
177
  if (summaryIndex < 0)
174
178
  return messages;
175
179
  const hint = rawLogsEnabled ? buildCompactionRecoveryHint(sessionId) : COMPACTION_RECOVERY_HINT_DISABLED;
@@ -242,6 +246,17 @@ function normalizeAnthropicBaseURL(baseURL) {
242
246
  // DeepSeek Anthropic 端点示例:https://api.deepseek.com/anthropic/v1。
243
247
  return baseURL.trim().replace(/\/+$/, "");
244
248
  }
249
+ /**
250
+ * 协作式让位的内部中断信号:在 prepareStep(每个模型 step 前)检测到外部
251
+ * 注入消息时抛出,触发 chat() 持久化当前中间态 → 注入 user 消息 → 以新
252
+ * messages 重启 streamText,让 agent 在当前 turn 内吸收新指令而非结束整轮。
253
+ */
254
+ class InputInjectionInterrupt extends Error {
255
+ constructor() {
256
+ super("input injection requested at step boundary");
257
+ this.name = "InputInjectionInterrupt";
258
+ }
259
+ }
245
260
  export class ChatSession {
246
261
  model;
247
262
  /** 子模型实例;未配置 subModel 时与主模型同一实例 */
@@ -392,7 +407,7 @@ export class ChatSession {
392
407
  }
393
408
  return systemContent.join("\n");
394
409
  }
395
- async *chat(userMessage, signal) {
410
+ async *chat(userMessage, signal, drainInput) {
396
411
  this.context.appendMessage({ role: "user", content: userMessage });
397
412
  let fullText = "";
398
413
  let safeAccumulated = "";
@@ -465,6 +480,13 @@ export class ChatSession {
465
480
  ? { [OPENAI_COMPATIBLE_PROVIDER_NAME]: { reasoningEffort: this.effort } }
466
481
  : { anthropic: { effort: this.effort } };
467
482
  }
483
+ // 协作式让位(仅 streaming 模式):外部可在每个 model step 边界 drain 一条
484
+ // 新消息注入当前 turn。non-streaming 下 generateText 是原子请求,无法在 step
485
+ // 边界取回中间工具结果,因此忽略 drainInput(消息留在上层队列,整轮结束后消费)。
486
+ const canInject = this.streaming && typeof drainInput === "function";
487
+ // pendingInjection 是 prepareStep 与 catch 之间的注入信号槽。用标志而非
488
+ // instanceof 判断,因为 provider SDK 可能包装抛出的错误。
489
+ let pendingInjection = null;
468
490
  const baseGenerationOptions = {
469
491
  model: this.model,
470
492
  system,
@@ -478,156 +500,223 @@ export class ChatSession {
478
500
  }),
479
501
  stopWhen: maxSteps !== undefined ? stepCountIs(maxSteps) : isLoopFinished(),
480
502
  abortSignal: signal,
503
+ prepareStep: ({ messages, stepNumber }) => {
504
+ if (canInject) {
505
+ const injected = drainInput();
506
+ if (injected) {
507
+ pendingInjection = injected;
508
+ throw new InputInjectionInterrupt();
509
+ }
510
+ }
511
+ const compacted = compactToolLoopMessages(messages);
512
+ if (compacted.compactedResults > 0) {
513
+ rawLog?.writeLine(safeRawStreamJson({
514
+ type: "deepccc_intra_turn_tool_context_compacted",
515
+ stepNumber,
516
+ compactedResults: compacted.compactedResults,
517
+ originalToolChars: compacted.originalToolChars,
518
+ retainedToolChars: compacted.retainedToolChars,
519
+ }));
520
+ }
521
+ return { messages: compacted.messages };
522
+ },
481
523
  ...(this.maxOutputTokens !== undefined
482
524
  ? { maxOutputTokens: this.maxOutputTokens }
483
525
  : {}),
484
526
  ...(effortProviderOptions ? { providerOptions: effortProviderOptions } : {}),
485
527
  };
486
- for (let attempt = 0; attempt < 2; attempt += 1) {
487
- fullText = "";
488
- safeAccumulated = "";
489
- toolContext = [];
490
- toolCallsById.clear();
491
- toolCallOrder.length = 0;
492
- timeline.length = 0;
493
- let lastReasoningProgressAt;
494
- const attemptMessages = attempt === 0
495
- ? modelMessages
496
- : [...modelMessages, { role: "user", content: TOOL_PROTOCOL_RECOVERY_PROMPT }];
497
- const generationOptions = {
498
- ...baseGenerationOptions,
499
- messages: attemptMessages,
500
- };
501
- let stream;
502
- let requiresFinish = false;
503
- let receivedFinish = false;
504
- let finishReason;
505
- if (this.streaming) {
506
- const result = streamText(generationOptions);
507
- requiresFinish = result.fullStream != null;
508
- stream = result.fullStream ?? textStreamToFullStream(result.textStream);
509
- }
510
- else {
511
- const result = await generateText(generationOptions);
512
- finishReason = result.finishReason;
513
- stream = generateResultToFullStream(result);
514
- }
515
- for await (const part of stream) {
516
- rawLog?.writeLine(safeRawStreamJson(part));
517
- if (part.type === "finish") {
518
- receivedFinish = true;
519
- finishReason = part.finishReason;
520
- }
521
- if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
522
- // Reasoning content remains private. A throttled heartbeat is enough
523
- // for ChatCCC to distinguish active inference from a stalled stream.
524
- const now = Date.now();
525
- if (lastReasoningProgressAt === undefined || now - lastReasoningProgressAt >= 1_000) {
526
- lastReasoningProgressAt = now;
527
- yield { type: "progress", phase: "reasoning" };
528
- }
529
- }
530
- else if (part.type === "text-delta") {
531
- fullText += part.text;
532
- const previous = timeline[timeline.length - 1];
533
- if (previous?.type === "text")
534
- previous.text += part.text;
535
- else
536
- timeline.push({ type: "text", text: part.text });
537
- // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
538
- // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
539
- const safeText = applyPrivacy(part.text);
540
- safeAccumulated += safeText;
541
- yield { type: "text", text: safeText, accumulated: safeAccumulated };
542
- }
543
- else if (part.type === "tool-call") {
544
- const input = safeJson(part.input);
545
- toolContext.push(`tool_call ${part.toolName}: ${input}`);
546
- toolCallsById.set(part.toolCallId, { id: part.toolCallId, name: part.toolName, input });
547
- toolCallOrder.push(part.toolCallId);
548
- timeline.push({ type: "tool_use", id: part.toolCallId, name: part.toolName, input });
549
- yield {
550
- type: "tool_use",
551
- id: part.toolCallId,
552
- name: part.toolName,
553
- input: applyPrivacyToJson(part.input),
554
- };
555
- }
556
- else if (part.type === "tool-result") {
557
- const output = truncateToolContext(safeJson(part.output));
558
- toolContext.push(`tool_result ${part.toolName}: ${output}`);
559
- const call = toolCallsById.get(part.toolCallId);
560
- if (call)
561
- call.output = output;
562
- timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output });
563
- yield {
564
- type: "tool_result",
565
- tool_use_id: part.toolCallId,
566
- name: part.toolName,
567
- content: applyPrivacyToJson(part.output),
568
- is_error: false,
528
+ // 注入重启后 context 已变(中间 assistant + 注入 user),需要重新构建并应用
529
+ // 与首次一致的 recovery-hint / anthropic 兼容提示。
530
+ const decorateMessages = (msgs) => {
531
+ const hinted = maybeAppendCompactionRecoveryHint(msgs, this.context.summary, appConfig.rawStreamLogs.enabled, this.context.sessionId);
532
+ return this.provider === "anthropic"
533
+ ? addAnthropicToolJsonCompatibilityNote(hinted)
534
+ : hinted;
535
+ };
536
+ let currentMessages = modelMessages;
537
+ while (true) {
538
+ pendingInjection = null;
539
+ try {
540
+ for (let attempt = 0; attempt < 2; attempt += 1) {
541
+ fullText = "";
542
+ toolContext = [];
543
+ toolCallsById.clear();
544
+ toolCallOrder.length = 0;
545
+ timeline.length = 0;
546
+ // safeAccumulated 是整轮展示累积:注入延续时不重置,只有工具协议恢复
547
+ // (重新生成、已 yield text_reset)才重置。
548
+ if (attempt === 1)
549
+ safeAccumulated = "";
550
+ let lastReasoningProgressAt;
551
+ const attemptMessages = attempt === 0
552
+ ? currentMessages
553
+ : [...currentMessages, { role: "user", content: TOOL_PROTOCOL_RECOVERY_PROMPT }];
554
+ const generationOptions = {
555
+ ...baseGenerationOptions,
556
+ messages: attemptMessages,
569
557
  };
570
- }
571
- else if (part.type === "tool-error") {
572
- const message = errorMessage(part.error);
573
- toolContext.push(`tool_error ${part.toolName}: ${message}`);
574
- const call = toolCallsById.get(part.toolCallId);
575
- if (call) {
576
- call.output = message;
577
- call.is_error = true;
558
+ let stream;
559
+ let requiresFinish = false;
560
+ let receivedFinish = false;
561
+ let finishReason;
562
+ if (this.streaming) {
563
+ const result = streamText(generationOptions);
564
+ requiresFinish = result.fullStream != null;
565
+ stream = result.fullStream ?? textStreamToFullStream(result.textStream);
578
566
  }
579
- timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output: message, is_error: true });
580
- yield {
581
- type: "tool_result",
582
- tool_use_id: part.toolCallId,
583
- name: part.toolName,
584
- content: applyPrivacy(message),
585
- is_error: true,
586
- };
587
- }
588
- else if (part.type === "error") {
589
- const message = errorMessage(part.error);
590
- yield { type: "error", message: applyPrivacy(message) };
591
- throw new Error(message);
567
+ else {
568
+ const result = await generateText(generationOptions);
569
+ finishReason = result.finishReason;
570
+ stream = generateResultToFullStream(result);
571
+ }
572
+ for await (const part of stream) {
573
+ rawLog?.writeLine(safeRawStreamJson(part));
574
+ if (part.type === "finish") {
575
+ receivedFinish = true;
576
+ finishReason = part.finishReason;
577
+ }
578
+ if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
579
+ // Reasoning content remains private. A throttled heartbeat is enough
580
+ // for ChatCCC to distinguish active inference from a stalled stream.
581
+ const now = Date.now();
582
+ if (lastReasoningProgressAt === undefined || now - lastReasoningProgressAt >= 1_000) {
583
+ lastReasoningProgressAt = now;
584
+ yield { type: "progress", phase: "reasoning" };
585
+ }
586
+ }
587
+ else if (part.type === "text-delta") {
588
+ fullText += part.text;
589
+ const previous = timeline[timeline.length - 1];
590
+ if (previous?.type === "text")
591
+ previous.text += part.text;
592
+ else
593
+ timeline.push({ type: "text", text: part.text });
594
+ // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
595
+ // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
596
+ const safeText = applyPrivacy(part.text);
597
+ safeAccumulated += safeText;
598
+ yield { type: "text", text: safeText, accumulated: safeAccumulated };
599
+ }
600
+ else if (part.type === "tool-call") {
601
+ const input = safeJson(part.input);
602
+ toolContext.push(`tool_call ${part.toolName}: ${input}`);
603
+ toolCallsById.set(part.toolCallId, { id: part.toolCallId, name: part.toolName, input });
604
+ toolCallOrder.push(part.toolCallId);
605
+ timeline.push({ type: "tool_use", id: part.toolCallId, name: part.toolName, input });
606
+ yield {
607
+ type: "tool_use",
608
+ id: part.toolCallId,
609
+ name: part.toolName,
610
+ input: applyPrivacyToJson(part.input),
611
+ };
612
+ }
613
+ else if (part.type === "tool-result") {
614
+ const output = truncateToolContext(safeJson(part.output));
615
+ toolContext.push(`tool_result ${part.toolName}: ${output}`);
616
+ const call = toolCallsById.get(part.toolCallId);
617
+ if (call)
618
+ call.output = output;
619
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output });
620
+ yield {
621
+ type: "tool_result",
622
+ tool_use_id: part.toolCallId,
623
+ name: part.toolName,
624
+ content: applyPrivacyToJson(part.output),
625
+ is_error: false,
626
+ };
627
+ }
628
+ else if (part.type === "tool-error") {
629
+ const message = errorMessage(part.error);
630
+ toolContext.push(`tool_error ${part.toolName}: ${message}`);
631
+ const call = toolCallsById.get(part.toolCallId);
632
+ if (call) {
633
+ call.output = message;
634
+ call.is_error = true;
635
+ }
636
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output: message, is_error: true });
637
+ yield {
638
+ type: "tool_result",
639
+ tool_use_id: part.toolCallId,
640
+ name: part.toolName,
641
+ content: applyPrivacy(message),
642
+ is_error: true,
643
+ };
644
+ }
645
+ else if (part.type === "error") {
646
+ if (pendingInjection !== null) {
647
+ // prepareStep 里的注入中断被 provider 转成了 error part。
648
+ throw new InputInjectionInterrupt();
649
+ }
650
+ const message = errorMessage(part.error);
651
+ yield { type: "error", message: applyPrivacy(message) };
652
+ throw new Error(message);
653
+ }
654
+ }
655
+ if (!signal?.aborted) {
656
+ if (requiresFinish && !receivedFinish)
657
+ throw new Error("DeepCCC 输出流中断:未收到模型完成事件,回复可能不完整");
658
+ if (finishReason === "error" || finishReason === "length")
659
+ throw new Error(`DeepCCC 未正常完成:finishReason=${finishReason},回复可能不完整`);
660
+ if (!fullText.trim() && toolCallOrder.length === 0)
661
+ throw new Error("DeepCCC 本轮未产生有效回复");
662
+ }
663
+ if (hasMalformedToolProtocolText(fullText)) {
664
+ console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
665
+ + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
666
+ rawLog?.writeLine(safeRawStreamJson({
667
+ type: "deepccc_tool_protocol_recovery",
668
+ attempt: attempt + 1,
669
+ structuredToolCalls: toolCallOrder.length,
670
+ }));
671
+ yield { type: "text_reset" };
672
+ if (attempt === 0 && toolCallOrder.length === 0) {
673
+ yield { type: "status", phase: "generating" };
674
+ continue;
675
+ }
676
+ throw new Error(toolCallOrder.length > 0
677
+ ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
678
+ : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
679
+ }
680
+ completed = true;
681
+ const collectedToolCalls = toolCallOrder
682
+ .map((id) => toolCallsById.get(id))
683
+ .filter((call) => call !== undefined);
684
+ this.context.appendMessage(buildPersistedAssistantMessage({
685
+ fullText,
686
+ transcriptLines: toolContext,
687
+ toolCalls: collectedToolCalls,
688
+ timeline,
689
+ }));
690
+ yield { type: "done", text: safeAccumulated };
691
+ return;
592
692
  }
593
693
  }
594
- if (!signal?.aborted) {
595
- if (requiresFinish && !receivedFinish)
596
- throw new Error("DeepCCC 输出流中断:未收到模型完成事件,回复可能不完整");
597
- if (finishReason === "error" || finishReason === "length")
598
- throw new Error(`DeepCCC 未正常完成:finishReason=${finishReason},回复可能不完整`);
599
- if (!fullText.trim() && toolCallOrder.length === 0)
600
- throw new Error("DeepCCC 本轮未产生有效回复");
601
- }
602
- if (hasMalformedToolProtocolText(fullText)) {
603
- console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
604
- + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
605
- rawLog?.writeLine(safeRawStreamJson({
606
- type: "deepccc_tool_protocol_recovery",
607
- attempt: attempt + 1,
608
- structuredToolCalls: toolCallOrder.length,
609
- }));
610
- yield { type: "text_reset" };
611
- if (attempt === 0 && toolCallOrder.length === 0) {
612
- yield { type: "status", phase: "generating" };
694
+ catch (err) {
695
+ if (pendingInjection !== null) {
696
+ // 协作式让位:持久化本段中间态 → 注入 user → 重建 messages → 继续当前 turn。
697
+ if (fullText.trim() || toolCallOrder.length > 0) {
698
+ const collectedToolCalls = toolCallOrder
699
+ .map((id) => toolCallsById.get(id))
700
+ .filter((call) => call !== undefined);
701
+ this.context.appendMessage(buildPersistedAssistantMessage({
702
+ fullText,
703
+ transcriptLines: toolContext,
704
+ toolCalls: collectedToolCalls,
705
+ timeline: timeline.map((entry) => ({ ...entry })),
706
+ }));
707
+ }
708
+ const injectedText = pendingInjection;
709
+ this.context.appendMessage({ role: "user", content: injectedText });
710
+ rawLog?.writeLine(safeRawStreamJson({
711
+ type: "deepccc_input_injected",
712
+ text: injectedText,
713
+ }));
714
+ yield { type: "input_injected", text: injectedText };
715
+ currentMessages = decorateMessages(this.context.buildModelMessages());
613
716
  continue;
614
717
  }
615
- throw new Error(toolCallOrder.length > 0
616
- ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
617
- : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
718
+ throw err;
618
719
  }
619
- completed = true;
620
- const collectedToolCalls = toolCallOrder
621
- .map((id) => toolCallsById.get(id))
622
- .filter((call) => call !== undefined);
623
- this.context.appendMessage(buildPersistedAssistantMessage({
624
- fullText,
625
- transcriptLines: toolContext,
626
- toolCalls: collectedToolCalls,
627
- timeline,
628
- }));
629
- yield { type: "done", text: safeAccumulated };
630
- return;
631
720
  }
632
721
  }
633
722
  catch (err) {
@@ -102,5 +102,11 @@ export function reduceProgress(prev, event) {
102
102
  case "compact":
103
103
  // 旧上下文压缩不影响当前过程展示
104
104
  return prev;
105
+ case "input_injected": {
106
+ // 协作式让位:在当前 turn 的 step 边界注入了新消息,仅在头部提示,
107
+ // 不改动正文与工具状态。
108
+ const preview = event.text.length > 40 ? `${event.text.slice(0, 40)}…` : event.text;
109
+ return withProgressView(prev, { headerTitle: `已注入新消息:${preview}` });
110
+ }
105
111
  }
106
112
  }
@@ -0,0 +1,17 @@
1
+ import type { ModelMessage } from "ai";
2
+ export declare const DEFAULT_STEP_TOOL_CONTEXT_CHARS = 96000;
3
+ export declare const DEFAULT_STEP_TOOL_RESULT_CHARS = 24000;
4
+ export declare const DEFAULT_STEP_RECENT_RESULTS = 4;
5
+ export interface CompactToolLoopOptions {
6
+ maxToolChars?: number;
7
+ maxResultChars?: number;
8
+ keepRecentResults?: number;
9
+ }
10
+ export interface CompactToolLoopResult {
11
+ messages: ModelMessage[];
12
+ originalToolChars: number;
13
+ retainedToolChars: number;
14
+ compactedResults: number;
15
+ }
16
+ /** Bound tool payload replayed by the AI SDK between steps of one turn. */
17
+ export declare function compactToolLoopMessages(input: readonly ModelMessage[], options?: CompactToolLoopOptions): CompactToolLoopResult;
@@ -0,0 +1,85 @@
1
+ export const DEFAULT_STEP_TOOL_CONTEXT_CHARS = 96_000;
2
+ export const DEFAULT_STEP_TOOL_RESULT_CHARS = 24_000;
3
+ export const DEFAULT_STEP_RECENT_RESULTS = 4;
4
+ const OMITTED = "[earlier tool result omitted from subsequent model steps to keep this turn within its context budget; rerun a focused read/search if still needed]";
5
+ const TRUNCATED = "...[tool result shortened for subsequent model steps]...";
6
+ function serialized(output) {
7
+ try {
8
+ return JSON.stringify(output);
9
+ }
10
+ catch {
11
+ return String(output);
12
+ }
13
+ }
14
+ function shortened(output, maximum) {
15
+ const raw = serialized(output);
16
+ if (raw.length <= maximum)
17
+ return output;
18
+ const available = Math.max(0, maximum - TRUNCATED.length - 2);
19
+ const head = Math.ceil(available * 0.6);
20
+ const tail = Math.max(0, available - head);
21
+ return { type: "text", value: `${raw.slice(0, head)}${TRUNCATED}${tail ? raw.slice(-tail) : ""}` };
22
+ }
23
+ /** Bound tool payload replayed by the AI SDK between steps of one turn. */
24
+ export function compactToolLoopMessages(input, options = {}) {
25
+ const maximum = Math.max(1_000, options.maxToolChars ?? DEFAULT_STEP_TOOL_CONTEXT_CHARS);
26
+ const perResult = Math.max(500, options.maxResultChars ?? DEFAULT_STEP_TOOL_RESULT_CHARS);
27
+ const keepRecent = Math.max(0, options.keepRecentResults ?? DEFAULT_STEP_RECENT_RESULTS);
28
+ const messages = structuredClone(input);
29
+ const refs = [];
30
+ for (let message = 0; message < messages.length; message += 1) {
31
+ const item = messages[message];
32
+ if (item.role !== "tool" || !Array.isArray(item.content))
33
+ continue;
34
+ for (let part = 0; part < item.content.length; part += 1) {
35
+ const content = item.content[part];
36
+ if (content.type !== "tool-result")
37
+ continue;
38
+ refs.push({ message, part, original: serialized(content.output).length });
39
+ }
40
+ }
41
+ const originalToolChars = refs.reduce((sum, ref) => sum + ref.original, 0);
42
+ let retainedToolChars = 0;
43
+ let compactedResults = 0;
44
+ const omittedOutput = { type: "text", value: OMITTED };
45
+ const omittedSize = serialized(omittedOutput).length;
46
+ const recentCount = Math.min(keepRecent, refs.length);
47
+ const olderCount = refs.length - recentCount;
48
+ const recentCap = recentCount > 0
49
+ ? Math.max(500, Math.min(perResult, Math.floor(Math.max(0, maximum - olderCount * omittedSize) / recentCount)))
50
+ : perResult;
51
+ const prepared = refs.map((ref, index) => {
52
+ const message = messages[ref.message];
53
+ if (message.role !== "tool" || !Array.isArray(message.content))
54
+ return null;
55
+ const part = message.content[ref.part];
56
+ if (part.type !== "tool-result")
57
+ return null;
58
+ const recent = index >= refs.length - keepRecent;
59
+ const capped = shortened(part.output, recent ? recentCap : perResult);
60
+ return { message, partIndex: ref.part, part, capped, cappedSize: serialized(capped).length, recent };
61
+ }).filter((item) => item !== null);
62
+ // Start from the smallest protocol-valid representation, protecting the most
63
+ // recent results (still capped individually), then spend remaining budget on
64
+ // older evidence from newest to oldest.
65
+ for (const item of prepared) {
66
+ const output = item.recent ? item.capped : omittedOutput;
67
+ item.message.content[item.partIndex] = { ...item.part, output };
68
+ retainedToolChars += item.recent ? item.cappedSize : omittedSize;
69
+ if (serialized(item.part.output) !== serialized(output))
70
+ compactedResults += 1;
71
+ }
72
+ for (let index = prepared.length - keepRecent - 1; index >= 0; index -= 1) {
73
+ const item = prepared[index];
74
+ const delta = item.cappedSize - omittedSize;
75
+ if (delta <= 0 || retainedToolChars + delta > maximum)
76
+ continue;
77
+ item.message.content[item.partIndex] = { ...item.part, output: item.capped };
78
+ retainedToolChars += delta;
79
+ if (serialized(item.part.output) !== serialized(omittedOutput))
80
+ compactedResults -= 1;
81
+ if (serialized(item.part.output) !== serialized(item.capped))
82
+ compactedResults += 1;
83
+ }
84
+ return { messages, originalToolChars, retainedToolChars, compactedResults };
85
+ }
@@ -232,5 +232,9 @@ export async function rememberProjectFact(cwd, input, cacheDir) {
232
232
  }
233
233
  export function needsWorkspaceOrientation(message) {
234
234
  const user = message.split("[User message]").pop() ?? message;
235
- return /项目|代码|仓库|实现|架构|功能|修复|编译|测试|模块|继续|挖.*因子|\b(?:repo|project|code|implement|architecture|feature|fix|build|test|module|continue)\b/i.test(user);
235
+ if (/(?:不涉及|无关|不要读取).{0,12}(?:当前)?(?:工作目录|项目|代码|仓库)/.test(user))
236
+ return false;
237
+ if (/^\s*(?:你好|谢谢|收到|好的|ok|hello|hi)[。!!,.,\s]*$/i.test(user))
238
+ return false;
239
+ return /项目|代码|仓库|实现|架构|功能|修复|编译|测试|模块|继续|现状|当前|范式|因子|策略|实验|消融|回测|指标|数据|模型|进度|提交|推送|挖.*因子|\b(?:repo|project|code|implement|architecture|feature|fix|build|test|module|continue|status|strategy|model|experiment|metric|data)\b/i.test(user);
236
240
  }
@@ -33,3 +33,9 @@ DeepCCC CLI、Web 与 ChatCCC 内置 CCC 共用这些能力。无需配置向量
33
33
  ## 测试边界
34
34
 
35
35
  自动化测试覆盖依赖目录显式访问、搜索失败/无命中/截断、单文件输出解析、地图更新、证据失效、预算与取消,以及导航不会在历史中累积。这些是工具和上下文护栏,不是所有模型都能正确理解项目的保证;未对某个在线模型宣称准确率提升。
36
+
37
+ ## 长会话状态与轮内预算
38
+
39
+ 压缩器每次重写完整摘要,使用固定章节区分当前事实、仍待处理、已取代历史、项目事实、证据/推断/局限和重要操作。最近原始消息、明确纠正及当前磁盘事实优先;旧摘要中的助手评价不是事实或用户指令。旧格式摘要仍可读取,但在模型输入中会明确标记为历史材料,避免把已完成或回退的路线恢复成当前建议。
40
+
41
+ 同一轮 Agent 可能连续执行多个工具,AI SDK 会把先前结果重复发送到后续模型步骤。DeepCCC 在每一步前对这些结果执行结构保持的预算控制:每项最多约 24,000 字符,总量约 96,000 字符,最近 4 项优先。较早结果必要时替换为可识别的省略标记,assistant tool-call 与对应 `role: tool` 结果仍成对保留。该处理只影响本轮后续模型请求;用户界面事件、最终持久化记录和受独立上限保护的 raw stream log 不被这一步改写。需要细节时 Agent 应重新执行更聚焦的读取或搜索。
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.2.13",
3
+ "version": "0.2.15",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [