deepccc 0.2.7 → 0.2.9

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
@@ -155,7 +155,7 @@ deepccc
155
155
 
156
156
  Web UI 支持新建、恢复、重命名和删除多会话,多个会话可以同时运行,即使它们指向同一个工作目录。每个会话可独立选择 model、subModel 和 effort,并持续复用 CLI 已保存的历史。注意:当前版本不自动创建 Git worktree;同目录的多个运行中 Agent 直接修改同一组文件,页面会提示覆盖与冲突风险。
157
157
 
158
- 模型文本、reasoning 心跳和工具事件通过 SSE 实时更新。每次工具调用与对应结果合并成一张卡片,折叠态显示 emoji、工具名、状态和关键参数;展开后调用参数默认保留前 8/后 4 行,工具结果保留前 12/后 6 行,省略内容可继续展开。工具卡及省略行的展开状态保存在当前浏览器标签页的 `sessionStorage`,持续生成、切换会话和刷新页面均不会自动收起。消息正文支持标题、表格、列表、引用、链接和代码块等常用 Markdown。
158
+ 模型文本、reasoning 心跳和工具事件通过 SSE 实时更新。每轮消息按真实发生顺序持久化和回放,因此刷新后仍会保持“阶段说明 → 工具调用 → 后续结论”的交错时间线;旧版会话没有顺序数据时,会兼容显示为“工具调用 → 最终回答”。每次工具调用与对应结果合并成一张卡片,折叠态显示 emoji、工具名、状态和关键参数;展开后调用参数默认保留前 8/后 4 行,工具结果保留前 12/后 6 行,省略内容可继续展开。工具卡及省略行的展开状态保存在当前浏览器标签页的 `sessionStorage`,持续生成、切换会话和刷新页面均不会自动收起。消息正文支持标题、表格、列表、引用、链接和代码块等常用 Markdown。
159
159
 
160
160
  图片始终按本地附件处理,不转换为 Provider 原生多模态消息。Web 支持文件选择、剪贴板粘贴和拖拽,每条消息最多 10 张 PNG/JPEG/WebP、单张最大 20 MB;附件复制到 `~/.deepccc/attachments/<session-id>/`,Agent 收到本地绝对路径后使用可用工具自行处理。Agent 可调用 `present_file` 把当前工作目录或会话附件目录中的图片直接展示在会话中;删除会话时对应附件一并清理。
161
161
 
@@ -180,8 +180,9 @@ Provider 发送 `max_tokens`,使用模型服务端默认值。可通过 `DEEPC
180
180
  或命令行 `--max-output-tokens` 覆盖;只接受正整数。该限制会同时覆盖模型思考内容、
181
181
  工具参数和最终回答,设置过小可能导致工具调用或长回复被截断。
182
182
 
183
- `contextWindow` 是模型上下文窗口(token),默认 `1048576`(1M,DeepSeek V4 Pro/Flash
184
- 原生规格);上下文压缩阈值自动 = `contextWindow × 0.8`(超出即把较早消息压缩为摘要)。
183
+ `contextWindow` 是模型上下文窗口(token),默认 `1048576`(1M,DeepSeek V4 Pro/Flash
184
+ 原生规格);常规上下文压缩阈值自动 = `contextWindow × 0.8`。工具输入与结果另有独立预算:
185
+ 默认取 `min(64000, 常规压缩阈值 × 25%)`,超过后会提前压缩,避免长会话积累大量低信号工具输出。
185
186
  可通过 `DEEPCCC_CONTEXT_WINDOW` 环境变量覆盖。⚠️ 超过模型/服务端实际上限时请求会被
186
187
  API 拒绝(context length exceeded),实际窗口以模型与所用服务端为准(如 litellm 的
187
188
  `max_input_tokens`)。
@@ -324,7 +325,7 @@ deepccc-cli --dangerously-bypass-permissions
324
325
 
325
326
  交互模式下,每轮回复渲染为固定"过程区块":状态行(压缩上下文中/生成回复中/完成/已停止/异常结束)+ 折叠工具行 + 原地更新正文,不滚屏刷 JSON。活动状态有心跳点号动画;完成/停止/异常后区块定型留在屏幕上。
326
327
 
327
- 持久化上下文达到 token 阈值时,deepccc 会先按 token 预算保留最近消息,再压缩较早内容;超长历史消息、工具记录和压缩输入会被限长,避免单次摘要请求反复吞入巨量文本。一次压缩最多等待 5 分钟,超时或摘要失败会给出明确错误,不会自动重放用户请求。
328
+ 持久化上下文达到常规 token 阈值或独立工具预算时,deepccc 会先按预算保留最近消息,再压缩较早内容;工具历史使用标准结构化 tool-call/tool-result 消息重放,不会把内部 `[工具记录]` 文本重复送回模型。若模型仍输出伪工具记录,系统会丢弃并重试一次;重复失败时安全终止,避免把未执行命令当成真实结果。超长历史消息、工具记录和压缩输入会被限长,一次压缩最多等待 5 分钟。
328
329
 
329
330
  如果终端渲染出现异常,可以强制回退为纯文本流式输出:
330
331
 
package/dist/context.d.ts CHANGED
@@ -1,7 +1,8 @@
1
+ import type { ModelMessage } from "ai";
1
2
  export type BuiltinContextRole = "user" | "assistant";
2
3
  /**
3
- * 结构化工具调用存档(与 content 里的 [Tool transcript] 文本互为冗余视图):
4
- * content 文本保持不变供模型消费;toolCalls 数组提供可检索、可回放的结构化数据。
4
+ * 结构化工具调用存档。content 只保存面向用户的回答正文;工具输入与结果
5
+ * 独立保存,供模型按标准 tool-call/tool-result 消息重放以及 UI 检索、回放。
5
6
  */
6
7
  export interface BuiltinContextToolCall {
7
8
  /** Provider tool-call id,用于 Web 在流式运行与持久化消息之间保持同一张工具卡状态。 */
@@ -13,11 +14,28 @@ export interface BuiltinContextToolCall {
13
14
  output?: string;
14
15
  is_error?: boolean;
15
16
  }
17
+ export type BuiltinContextTimelineEntry = {
18
+ type: "text";
19
+ text: string;
20
+ } | {
21
+ type: "tool_use";
22
+ id: string;
23
+ name: string;
24
+ input?: string;
25
+ } | {
26
+ type: "tool_result";
27
+ tool_use_id: string;
28
+ name?: string;
29
+ output?: string;
30
+ is_error?: boolean;
31
+ };
16
32
  export interface BuiltinContextMessage {
17
33
  role: BuiltinContextRole;
18
34
  content: string;
19
35
  /** 可选结构化工具调用记录(assistant 消息专用) */
20
36
  toolCalls?: BuiltinContextToolCall[];
37
+ /** 文本与工具事件的真实发生顺序,同时用于 UI 与模型的结构化历史重放。 */
38
+ timeline?: BuiltinContextTimelineEntry[];
21
39
  }
22
40
  export interface BuiltinContextState {
23
41
  version: 1;
@@ -54,6 +72,8 @@ export interface BuiltinContextOptions {
54
72
  contextWindow?: number;
55
73
  /** 直接指定压缩阈值(token),优先于 contextWindow 的 80% 派生。 */
56
74
  compactAtTokens?: number;
75
+ /** Independent budget for retained structured tool inputs/results. */
76
+ maxToolContextTokens?: number;
57
77
  keepRecentMessages?: number;
58
78
  }
59
79
  export declare const DEFAULT_BUILTIN_CONTEXT_DIR: string;
@@ -64,6 +84,7 @@ export declare const COMPACTION_THRESHOLD_RATIO = 0.8;
64
84
  /** 默认压缩阈值 = 1M × 0.8。 */
65
85
  export declare const DEFAULT_COMPACT_AT_TOKENS: number;
66
86
  export declare const DEFAULT_KEEP_RECENT_MESSAGES = 16;
87
+ export declare const DEFAULT_MAX_TOOL_CONTEXT_TOKENS = 64000;
67
88
  export declare function normalizeBuiltinSessionId(value: string): string;
68
89
  export declare function defaultBuiltinSessionId(cwd?: string): string;
69
90
  export declare function newBuiltinSessionId(now?: Date, suffix?: string): string;
@@ -71,25 +92,23 @@ export declare function getBuiltinContextSession(sessionId: string, contextDir?:
71
92
  export declare function readBuiltinContextState(sessionId: string, contextDir?: string): BuiltinContextState | null;
72
93
  export declare function listBuiltinContextSessions(contextDir?: string): BuiltinContextSessionInfo[];
73
94
  export declare function latestBuiltinSessionForCwd(cwd: string, contextDir?: string): BuiltinContextSessionInfo | null;
74
- /**
75
- * 估算上下文的 token 数(用于决定何时压缩)。
76
- * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
77
- * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
78
- */
95
+ export declare function estimateBuiltinToolContextTokens(messages: readonly BuiltinContextMessage[]): number;
79
96
  export declare function estimateBuiltinContextTokens(summary: string, messages: readonly BuiltinContextMessage[]): number;
80
97
  export declare function serializeMessagesForSummary(messages: readonly BuiltinContextMessage[]): string;
81
98
  export declare const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32000;
82
99
  export declare const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24000;
83
100
  /**
84
- * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
85
- * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
101
+ * 构造持久化 assistant 消息。content 仅保存回答正文,工具事件保存在
102
+ * toolCalls/timeline,避免内部 transcript 语法回流并诱导模型伪造工具执行。
86
103
  */
87
104
  export declare function buildPersistedAssistantMessage(params: {
88
105
  fullText: string;
89
- /** [Tool transcript] 的文本行(按原始流顺序,含 tool_call / tool_result / tool_error) */
106
+ /** @deprecated 兼容旧调用方;工具记录只从 toolCalls/timeline 持久化。 */
90
107
  transcriptLines: readonly string[];
91
108
  /** 结构化工具调用(按调用顺序);有则写入消息的 toolCalls 字段 */
92
109
  toolCalls?: readonly BuiltinContextToolCall[];
110
+ /** 文本和工具事件的有序时间线;不参与模型输入。 */
111
+ timeline?: readonly BuiltinContextTimelineEntry[];
93
112
  maxAssistantChars?: number;
94
113
  maxTranscriptChars?: number;
95
114
  }): BuiltinContextMessage;
@@ -99,6 +118,7 @@ export declare class BuiltinContextManager {
99
118
  readonly contextDir: string;
100
119
  readonly sessionId: string;
101
120
  readonly compactAtTokens: number;
121
+ readonly maxToolContextTokens: number;
102
122
  readonly keepRecentMessages: number;
103
123
  private readonly cwd?;
104
124
  private state;
@@ -109,7 +129,7 @@ export declare class BuiltinContextManager {
109
129
  get contextFilePath(): string;
110
130
  appendMessage(message: BuiltinContextMessage): void;
111
131
  setSummary(summary: string): void;
112
- buildModelMessages(): BuiltinContextMessage[];
132
+ buildModelMessages(): ModelMessage[];
113
133
  planCompaction(): BuiltinCompactionPlan | null;
114
134
  applyCompaction(summary: string, plan: BuiltinCompactionPlan): void;
115
135
  reset(): void;
package/dist/context.js CHANGED
@@ -2,7 +2,7 @@ import { createHash, randomBytes } from "node:crypto";
2
2
  import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
3
3
  import { homedir } from "node:os";
4
4
  import { join } from "node:path";
5
- import { hasMalformedToolProtocolText } from "./tool-protocol.js";
5
+ import { hasImitatedToolTranscriptText, hasMalformedToolProtocolText, } from "./tool-protocol.js";
6
6
  export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
7
7
  /** 默认模型上下文窗口:1M(DeepSeek V4 Pro/Flash 原生规格)。 */
8
8
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576;
@@ -15,6 +15,11 @@ const RECENT_CONTEXT_BUDGET_RATIO = 0.6;
15
15
  const MAX_COMPACTION_SUMMARY_CHARS = 8_000;
16
16
  const MAX_COMPACTION_MESSAGE_CHARS = 24_000;
17
17
  const MAX_COMPACTION_SOURCE_CHARS = 64_000;
18
+ export const DEFAULT_MAX_TOOL_CONTEXT_TOKENS = 64_000;
19
+ const TOOL_CONTEXT_BUDGET_RATIO = 0.25;
20
+ const RECENT_TOOL_CONTEXT_BUDGET_RATIO = 0.6;
21
+ const STORED_TOOL_TRANSCRIPT_MARKER = "\n\n[工具记录]\n";
22
+ const QUARANTINED_PROTOCOL_REPLY = "[上一轮响应因工具协议异常已隔离,不能视为已执行;请根据后续用户消息继续。]";
18
23
  export function normalizeBuiltinSessionId(value) {
19
24
  return value.replace(/[^a-zA-Z0-9_.-]+/g, "_").replace(/^_+|_+$/g, "") || "default";
20
25
  }
@@ -49,8 +54,42 @@ function normalizeMessage(value) {
49
54
  const toolCalls = normalizeToolCalls(raw.toolCalls);
50
55
  if (toolCalls.length > 0)
51
56
  message.toolCalls = toolCalls;
57
+ const timeline = normalizeTimeline(raw.timeline);
58
+ if (timeline.length > 0)
59
+ message.timeline = timeline;
52
60
  return message;
53
61
  }
62
+ function normalizeTimeline(value) {
63
+ if (!Array.isArray(value))
64
+ return [];
65
+ const entries = [];
66
+ for (const item of value.slice(0, 1000)) {
67
+ if (!item || typeof item !== "object" || Array.isArray(item))
68
+ continue;
69
+ const raw = item;
70
+ if (raw.type === "text" && typeof raw.text === "string") {
71
+ entries.push({ type: "text", text: raw.text });
72
+ }
73
+ else if (raw.type === "tool_use" && typeof raw.id === "string" && typeof raw.name === "string") {
74
+ entries.push({
75
+ type: "tool_use",
76
+ id: raw.id,
77
+ name: raw.name,
78
+ ...(typeof raw.input === "string" ? { input: raw.input } : {}),
79
+ });
80
+ }
81
+ else if (raw.type === "tool_result" && typeof raw.tool_use_id === "string") {
82
+ entries.push({
83
+ type: "tool_result",
84
+ tool_use_id: raw.tool_use_id,
85
+ ...(typeof raw.name === "string" ? { name: raw.name } : {}),
86
+ ...(typeof raw.output === "string" ? { output: raw.output } : {}),
87
+ ...(typeof raw.is_error === "boolean" ? { is_error: raw.is_error } : {}),
88
+ });
89
+ }
90
+ }
91
+ return entries;
92
+ }
54
93
  function normalizeToolCalls(value) {
55
94
  if (!Array.isArray(value))
56
95
  return [];
@@ -175,8 +214,11 @@ export function latestBuiltinSessionForCwd(cwd, contextDir = DEFAULT_BUILTIN_CON
175
214
  * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
176
215
  * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
177
216
  */
178
- export function estimateBuiltinContextTokens(summary, messages) {
179
- const text = summary + messages.reduce((sum, m) => sum + `${m.role}\n${m.content}\n`, "");
217
+ function stripStoredToolTranscript(content) {
218
+ const markerIndex = content.indexOf(STORED_TOOL_TRANSCRIPT_MARKER);
219
+ return markerIndex >= 0 ? content.slice(0, markerIndex) : content;
220
+ }
221
+ function estimateTextTokens(text) {
180
222
  let cjk = 0;
181
223
  for (const ch of text) {
182
224
  if (/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch))
@@ -185,6 +227,30 @@ export function estimateBuiltinContextTokens(summary, messages) {
185
227
  const other = text.length - cjk;
186
228
  return Math.ceil(cjk + other / 3.5);
187
229
  }
230
+ export function estimateBuiltinToolContextTokens(messages) {
231
+ let text = "";
232
+ for (const message of messages) {
233
+ if (message.role !== "assistant")
234
+ continue;
235
+ if (message.toolCalls?.length) {
236
+ for (const call of message.toolCalls) {
237
+ text += `${call.name}\n${call.input ?? ""}\n${call.output ?? ""}\n`;
238
+ }
239
+ continue;
240
+ }
241
+ for (const entry of message.timeline ?? []) {
242
+ if (entry.type === "tool_use")
243
+ text += `${entry.name}\n${entry.input ?? ""}\n`;
244
+ if (entry.type === "tool_result")
245
+ text += `${entry.name ?? ""}\n${entry.output ?? ""}\n`;
246
+ }
247
+ }
248
+ return estimateTextTokens(text);
249
+ }
250
+ export function estimateBuiltinContextTokens(summary, messages) {
251
+ const text = summary + messages.reduce((sum, message) => sum + `${message.role}\n${stripStoredToolTranscript(message.content)}\n`, "");
252
+ return estimateTextTokens(text) + estimateBuiltinToolContextTokens(messages);
253
+ }
188
254
  export function serializeMessagesForSummary(messages) {
189
255
  return messages
190
256
  .map((message, index) => `### ${index + 1}. ${message.role}\n${message.content}`)
@@ -202,23 +268,196 @@ export const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
202
268
  export const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
203
269
  const ASSISTANT_TRUNCATED_MARKER = "...[助手回复已在上下文中截断]...";
204
270
  const TOOL_TRANSCRIPT_TRUNCATED_MARKER = "...[工具记录已截断]...";
271
+ function truncateStructuredToolCalls(calls, maxChars) {
272
+ const payloads = calls.flatMap((call) => [call.input ?? "", call.output ?? ""]).filter(Boolean);
273
+ const total = payloads.reduce((sum, value) => sum + value.length, 0);
274
+ if (total <= maxChars)
275
+ return calls;
276
+ const sharedBudget = Math.max(0, maxChars - payloads.length);
277
+ const truncatePayload = (value) => {
278
+ if (value === undefined || value.length === 0)
279
+ return value;
280
+ const budget = 1 + Math.floor(sharedBudget * value.length / Math.max(1, total));
281
+ return truncateMiddle(value, budget, TOOL_TRANSCRIPT_TRUNCATED_MARKER);
282
+ };
283
+ return calls.map((call) => ({
284
+ ...call,
285
+ ...(call.input !== undefined ? { input: truncatePayload(call.input) } : {}),
286
+ ...(call.output !== undefined ? { output: truncatePayload(call.output) } : {}),
287
+ }));
288
+ }
205
289
  /**
206
- * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
207
- * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
290
+ * 构造持久化 assistant 消息。content 仅保存回答正文,工具事件保存在
291
+ * toolCalls/timeline,避免内部 transcript 语法回流并诱导模型伪造工具执行。
208
292
  */
209
293
  export function buildPersistedAssistantMessage(params) {
210
294
  const maxAssistantChars = params.maxAssistantChars ?? DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
211
295
  const maxTranscriptChars = params.maxTranscriptChars ?? DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
212
296
  const persistedAssistantText = truncateMiddle(params.fullText, maxAssistantChars, ASSISTANT_TRUNCATED_MARKER);
213
- const content = params.transcriptLines.length > 0
214
- ? `${persistedAssistantText}\n\n[工具记录]\n${truncateMiddle(params.transcriptLines.join("\n"), maxTranscriptChars, TOOL_TRANSCRIPT_TRUNCATED_MARKER)}`
215
- : persistedAssistantText;
216
- const message = { role: "assistant", content };
217
- const toolCalls = normalizeToolCalls(params.toolCalls);
297
+ const message = { role: "assistant", content: persistedAssistantText };
298
+ const toolCalls = truncateStructuredToolCalls(normalizeToolCalls(params.toolCalls), maxTranscriptChars);
218
299
  if (toolCalls.length > 0)
219
300
  message.toolCalls = toolCalls;
301
+ const timeline = truncatePersistedTimeline(normalizeTimeline(params.timeline));
302
+ if (timeline.length > 0)
303
+ message.timeline = timeline;
220
304
  return message;
221
305
  }
306
+ function truncatePersistedTimeline(entries) {
307
+ const maxTextChars = DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
308
+ const maxToolChars = DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
309
+ const totalText = entries.reduce((sum, entry) => sum + (entry.type === "text" ? entry.text.length : 0), 0);
310
+ const textPayloads = entries.filter((entry) => entry.type === "text" && entry.text.length > 0).length;
311
+ const totalTool = entries.reduce((sum, entry) => {
312
+ if (entry.type === "tool_use")
313
+ return sum + (entry.input?.length ?? 0);
314
+ if (entry.type === "tool_result")
315
+ return sum + (entry.output?.length ?? 0);
316
+ return sum;
317
+ }, 0);
318
+ const toolPayloads = entries.filter((entry) => (entry.type === "tool_use" && (entry.input?.length ?? 0) > 0)
319
+ || (entry.type === "tool_result" && (entry.output?.length ?? 0) > 0)).length;
320
+ const payloadBudget = (length, total, count, maximum) => {
321
+ if (total <= maximum)
322
+ return length;
323
+ const sharedBudget = Math.max(0, maximum - count);
324
+ return length > 0 ? 1 + Math.floor(sharedBudget * length / Math.max(1, total)) : 0;
325
+ };
326
+ const truncatePayload = (value, budget, marker) => {
327
+ if (value.length <= budget)
328
+ return value;
329
+ if (budget <= marker.length + 2)
330
+ return value.slice(0, budget);
331
+ return truncateMiddle(value, budget, marker);
332
+ };
333
+ return entries.map((entry) => {
334
+ if (entry.type === "text") {
335
+ const budget = payloadBudget(entry.text.length, totalText, textPayloads, maxTextChars);
336
+ return { ...entry, text: truncatePayload(entry.text, budget, ASSISTANT_TRUNCATED_MARKER) };
337
+ }
338
+ if (entry.type === "tool_use" && entry.input !== undefined) {
339
+ const budget = payloadBudget(entry.input.length, totalTool, toolPayloads, maxToolChars);
340
+ return { ...entry, input: truncatePayload(entry.input, budget, TOOL_TRANSCRIPT_TRUNCATED_MARKER) };
341
+ }
342
+ if (entry.type === "tool_result" && entry.output !== undefined) {
343
+ const budget = payloadBudget(entry.output.length, totalTool, toolPayloads, maxToolChars);
344
+ return { ...entry, output: truncatePayload(entry.output, budget, TOOL_TRANSCRIPT_TRUNCATED_MARKER) };
345
+ }
346
+ return entry;
347
+ });
348
+ }
349
+ function parseToolInput(value) {
350
+ if (!value?.trim())
351
+ return {};
352
+ try {
353
+ return JSON.parse(value);
354
+ }
355
+ catch {
356
+ return { raw: value };
357
+ }
358
+ }
359
+ function parseToolOutput(value) {
360
+ if (!value?.trim())
361
+ return { type: "text", value: "" };
362
+ try {
363
+ return { type: "json", value: JSON.parse(value) };
364
+ }
365
+ catch {
366
+ return { type: "text", value };
367
+ }
368
+ }
369
+ function replayStructuredAssistantMessage(message, messageIndex) {
370
+ const plainText = stripStoredToolTranscript(message.content);
371
+ const hasStructuredTools = Boolean(message.toolCalls?.length
372
+ || message.timeline?.some((entry) => entry.type === "tool_use" || entry.type === "tool_result"));
373
+ if (!hasStructuredTools)
374
+ return [{ role: "assistant", content: plainText }];
375
+ const timeline = message.timeline?.length
376
+ ? message.timeline
377
+ : [
378
+ ...(message.toolCalls ?? []).map((call, callIndex) => ({
379
+ type: "tool_use",
380
+ id: call.id ?? `context-tool-${messageIndex}-${callIndex}`,
381
+ name: call.name,
382
+ ...(call.input !== undefined ? { input: call.input } : {}),
383
+ })),
384
+ ...(message.toolCalls ?? []).map((call, callIndex) => ({
385
+ type: "tool_result",
386
+ tool_use_id: call.id ?? `context-tool-${messageIndex}-${callIndex}`,
387
+ name: call.name,
388
+ ...(call.output !== undefined ? { output: call.output } : {}),
389
+ ...(call.is_error !== undefined ? { is_error: call.is_error } : {}),
390
+ })),
391
+ ...(plainText ? [{ type: "text", text: plainText }] : []),
392
+ ];
393
+ const messages = [];
394
+ let assistantParts = [];
395
+ let toolParts = [];
396
+ const pendingCalls = new Map();
397
+ const knownNames = new Map();
398
+ const emittedCallIds = new Set();
399
+ const flushAssistant = () => {
400
+ if (!assistantParts.length)
401
+ return;
402
+ messages.push({ role: "assistant", content: assistantParts });
403
+ assistantParts = [];
404
+ };
405
+ const flushTools = () => {
406
+ for (const [toolCallId, toolName] of pendingCalls) {
407
+ toolParts.push({
408
+ type: "tool-result",
409
+ toolCallId,
410
+ toolName,
411
+ output: { type: "text", value: "[tool execution interrupted; result unavailable]" },
412
+ });
413
+ }
414
+ pendingCalls.clear();
415
+ if (!toolParts.length)
416
+ return;
417
+ messages.push({ role: "tool", content: toolParts });
418
+ toolParts = [];
419
+ };
420
+ for (const entry of timeline) {
421
+ if (entry.type === "text") {
422
+ flushTools();
423
+ const previous = assistantParts[assistantParts.length - 1];
424
+ if (previous?.type === "text")
425
+ previous.text += entry.text;
426
+ else if (entry.text)
427
+ assistantParts.push({ type: "text", text: entry.text });
428
+ }
429
+ else if (entry.type === "tool_use") {
430
+ flushTools();
431
+ knownNames.set(entry.id, entry.name);
432
+ pendingCalls.set(entry.id, entry.name);
433
+ emittedCallIds.add(entry.id);
434
+ assistantParts.push({
435
+ type: "tool-call",
436
+ toolCallId: entry.id,
437
+ toolName: entry.name,
438
+ input: parseToolInput(entry.input),
439
+ });
440
+ }
441
+ else {
442
+ if (!emittedCallIds.has(entry.tool_use_id))
443
+ continue;
444
+ const toolName = entry.name ?? knownNames.get(entry.tool_use_id);
445
+ if (!toolName)
446
+ continue;
447
+ flushAssistant();
448
+ pendingCalls.delete(entry.tool_use_id);
449
+ toolParts.push({
450
+ type: "tool-result",
451
+ toolCallId: entry.tool_use_id,
452
+ toolName,
453
+ output: parseToolOutput(entry.output),
454
+ });
455
+ }
456
+ }
457
+ flushAssistant();
458
+ flushTools();
459
+ return messages;
460
+ }
222
461
  function serializeMessagesForCompaction(messages) {
223
462
  const sections = [];
224
463
  let remaining = MAX_COMPACTION_SOURCE_CHARS;
@@ -259,6 +498,7 @@ export class BuiltinContextManager {
259
498
  contextDir;
260
499
  sessionId;
261
500
  compactAtTokens;
501
+ maxToolContextTokens;
262
502
  keepRecentMessages;
263
503
  cwd;
264
504
  state;
@@ -269,6 +509,8 @@ export class BuiltinContextManager {
269
509
  this.cwd = options.cwd;
270
510
  this.compactAtTokens = options.compactAtTokens
271
511
  ?? Math.floor((options.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS) * COMPACTION_THRESHOLD_RATIO);
512
+ this.maxToolContextTokens = Math.max(1, options.maxToolContextTokens
513
+ ?? Math.min(DEFAULT_MAX_TOOL_CONTEXT_TOKENS, Math.floor(this.compactAtTokens * TOOL_CONTEXT_BUDGET_RATIO)));
272
514
  this.keepRecentMessages = Math.max(1, options.keepRecentMessages ?? DEFAULT_KEEP_RECENT_MESSAGES);
273
515
  this.state = this.load();
274
516
  }
@@ -308,7 +550,12 @@ export class BuiltinContextManager {
308
550
  // Keep malformed provider output on disk for diagnosis, but quarantine it
309
551
  // from future prompts so one protocol failure cannot teach the model to
310
552
  // repeat the same invalid tool syntax on every later turn.
311
- messages.push(...this.modelSafeMessages());
553
+ for (const [index, message] of this.modelSafeMessages().entries()) {
554
+ if (message.role === "user")
555
+ messages.push({ role: "user", content: message.content });
556
+ else
557
+ messages.push(...replayStructuredAssistantMessage(message, index));
558
+ }
312
559
  return messages;
313
560
  }
314
561
  planCompaction() {
@@ -316,16 +563,19 @@ export class BuiltinContextManager {
316
563
  // leaks here too, otherwise a later summary could reintroduce the bad syntax.
317
564
  const messages = this.modelSafeMessages();
318
565
  const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
319
- if (estimated <= this.compactAtTokens)
566
+ const toolEstimated = estimateBuiltinToolContextTokens(messages);
567
+ if (estimated <= this.compactAtTokens && toolEstimated <= this.maxToolContextTokens)
320
568
  return null;
321
569
  if (messages.length <= 1)
322
570
  return null;
323
571
  const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
324
572
  const recentTokenBudget = Math.max(1, Math.floor(this.compactAtTokens * RECENT_CONTEXT_BUDGET_RATIO));
573
+ const recentToolBudget = Math.max(1, Math.floor(this.maxToolContextTokens * RECENT_TOOL_CONTEXT_BUDGET_RATIO));
325
574
  let splitAt = messages.length - 1;
326
575
  for (let index = messages.length - 2; index >= earliestAllowed; index -= 1) {
327
576
  const candidate = messages.slice(index);
328
- if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget)
577
+ if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget
578
+ || estimateBuiltinToolContextTokens(candidate) > recentToolBudget)
329
579
  break;
330
580
  splitAt = index;
331
581
  }
@@ -336,8 +586,8 @@ export class BuiltinContextManager {
336
586
  splitAt = 1;
337
587
  return {
338
588
  previousSummary: this.state.summary,
339
- oldMessages: this.state.messages.slice(0, splitAt),
340
- recentMessages: this.state.messages.slice(splitAt),
589
+ oldMessages: messages.slice(0, splitAt),
590
+ recentMessages: messages.slice(splitAt),
341
591
  };
342
592
  }
343
593
  applyCompaction(summary, plan) {
@@ -361,7 +611,17 @@ export class BuiltinContextManager {
361
611
  renameSync(tmp, this.contextFilePath);
362
612
  }
363
613
  modelSafeMessages() {
364
- return this.state.messages.filter((message) => message.role !== "assistant" || !hasMalformedToolProtocolText(message.content));
614
+ return this.state.messages.map((message) => {
615
+ if (message.role !== "assistant")
616
+ return message;
617
+ const hasStructuredTools = Boolean(message.toolCalls?.length
618
+ || message.timeline?.some((entry) => entry.type === "tool_use" || entry.type === "tool_result"));
619
+ const copiedStoredTranscript = hasImitatedToolTranscriptText(message.content) && hasStructuredTools;
620
+ if (hasMalformedToolProtocolText(message.content) && !copiedStoredTranscript) {
621
+ return { role: "assistant", content: QUARANTINED_PROTOCOL_REPLY };
622
+ }
623
+ return { ...message, content: stripStoredToolTranscript(message.content) };
624
+ });
365
625
  }
366
626
  load() {
367
627
  if (!this.persist || !existsSync(this.contextFilePath))
package/dist/index.d.ts CHANGED
@@ -55,6 +55,8 @@ export interface ChatSessionOptions {
55
55
  sessionId?: string;
56
56
  /** Compact older context when the rough token estimate exceeds this value. */
57
57
  compactAtTokens?: number;
58
+ /** Independent retained-tool budget; defaults to min(64K, compaction threshold x 25%). */
59
+ maxToolContextTokens?: number;
58
60
  /**
59
61
  * 模型上下文窗口(token),默认 1048576(1M)。压缩阈值自动 = contextWindow × 0.8;
60
62
  * 显式 compactAtTokens 优先于该派生值。
package/dist/index.js CHANGED
@@ -5,7 +5,7 @@
5
5
  */
6
6
  import { createOpenAICompatible } from "@ai-sdk/openai-compatible";
7
7
  import { createAnthropic } from "@ai-sdk/anthropic";
8
- import { generateText, isLoopFinished, stepCountIs, streamText } from "ai";
8
+ import { generateText, isLoopFinished, stepCountIs, streamText, } from "ai";
9
9
  import { existsSync, readFileSync } from "node:fs";
10
10
  import { homedir } from "node:os";
11
11
  import { isAbsolute, join, resolve } from "node:path";
@@ -144,7 +144,7 @@ function addAnthropicToolJsonCompatibilityNote(messages) {
144
144
  // 部分 Anthropic→OpenAI/Ark 转换器会用 response_format=json_object
145
145
  // 实现工具调用,却只在 messages 中校验 JSON 关键词、不读取顶层 system。
146
146
  // 这里仅声明工具参数的编码方式,并明确不要求普通最终回复输出 JSON。
147
- return messages.map((message, index) => (index === lastUserIndex
147
+ return messages.map((message, index) => (index === lastUserIndex && message.role === "user" && typeof message.content === "string"
148
148
  ? {
149
149
  ...message,
150
150
  content: `${message.content}\n\n${ANTHROPIC_TOOL_JSON_COMPATIBILITY_NOTE}`,
@@ -161,11 +161,13 @@ function addAnthropicToolJsonCompatibilityNote(messages) {
161
161
  function maybeAppendCompactionRecoveryHint(messages, summary, rawLogsEnabled, sessionId) {
162
162
  if (!summary.trim())
163
163
  return messages;
164
- const summaryIndex = messages.findIndex((message) => message.role === "user" && message.content.startsWith("以下是更早的对话摘要"));
164
+ const summaryIndex = messages.findIndex((message) => message.role === "user"
165
+ && typeof message.content === "string"
166
+ && message.content.startsWith("以下是更早的对话摘要"));
165
167
  if (summaryIndex < 0)
166
168
  return messages;
167
169
  const hint = rawLogsEnabled ? buildCompactionRecoveryHint(sessionId) : COMPACTION_RECOVERY_HINT_DISABLED;
168
- return messages.map((message, index) => (index === summaryIndex
170
+ return messages.map((message, index) => (index === summaryIndex && message.role === "user" && typeof message.content === "string"
169
171
  ? { ...message, content: `${message.content}\n\n${hint}` }
170
172
  : message));
171
173
  }
@@ -353,6 +355,7 @@ export class ChatSession {
353
355
  cwd: this.cwd,
354
356
  contextWindow: options.contextWindow ?? appConfig.contextWindow,
355
357
  compactAtTokens: options.compactAtTokens,
358
+ maxToolContextTokens: options.maxToolContextTokens,
356
359
  keepRecentMessages: options.keepRecentMessages,
357
360
  });
358
361
  this.permissionGate = new PermissionGate(this.permissionMode, this.permissionResolver);
@@ -393,6 +396,8 @@ export class ChatSession {
393
396
  // assistant 消息 toolCalls 字段;[Tool transcript] 文本视图仍按原格式生成。
394
397
  const toolCallsById = new Map();
395
398
  const toolCallOrder = [];
399
+ const timeline = [];
400
+ let toolContext = [];
396
401
  try {
397
402
  if (this.context.planCompaction()) {
398
403
  yield { type: "status", phase: "compacting" };
@@ -460,9 +465,10 @@ export class ChatSession {
460
465
  for (let attempt = 0; attempt < 2; attempt += 1) {
461
466
  fullText = "";
462
467
  safeAccumulated = "";
463
- const toolContext = [];
468
+ toolContext = [];
464
469
  toolCallsById.clear();
465
470
  toolCallOrder.length = 0;
471
+ timeline.length = 0;
466
472
  let lastReasoningProgressAt;
467
473
  const attemptMessages = attempt === 0
468
474
  ? modelMessages
@@ -493,6 +499,11 @@ export class ChatSession {
493
499
  }
494
500
  else if (part.type === "text-delta") {
495
501
  fullText += part.text;
502
+ const previous = timeline[timeline.length - 1];
503
+ if (previous?.type === "text")
504
+ previous.text += part.text;
505
+ else
506
+ timeline.push({ type: "text", text: part.text });
496
507
  // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
497
508
  // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
498
509
  const safeText = applyPrivacy(part.text);
@@ -500,9 +511,11 @@ export class ChatSession {
500
511
  yield { type: "text", text: safeText, accumulated: safeAccumulated };
501
512
  }
502
513
  else if (part.type === "tool-call") {
503
- toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
504
- toolCallsById.set(part.toolCallId, { id: part.toolCallId, name: part.toolName, input: safeJson(part.input) });
514
+ const input = safeJson(part.input);
515
+ toolContext.push(`tool_call ${part.toolName}: ${input}`);
516
+ toolCallsById.set(part.toolCallId, { id: part.toolCallId, name: part.toolName, input });
505
517
  toolCallOrder.push(part.toolCallId);
518
+ timeline.push({ type: "tool_use", id: part.toolCallId, name: part.toolName, input });
506
519
  yield {
507
520
  type: "tool_use",
508
521
  id: part.toolCallId,
@@ -511,10 +524,12 @@ export class ChatSession {
511
524
  };
512
525
  }
513
526
  else if (part.type === "tool-result") {
514
- toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
527
+ const output = truncateToolContext(safeJson(part.output));
528
+ toolContext.push(`tool_result ${part.toolName}: ${output}`);
515
529
  const call = toolCallsById.get(part.toolCallId);
516
530
  if (call)
517
- call.output = truncateToolContext(safeJson(part.output));
531
+ call.output = output;
532
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output });
518
533
  yield {
519
534
  type: "tool_result",
520
535
  tool_use_id: part.toolCallId,
@@ -531,6 +546,7 @@ export class ChatSession {
531
546
  call.output = message;
532
547
  call.is_error = true;
533
548
  }
549
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output: message, is_error: true });
534
550
  yield {
535
551
  type: "tool_result",
536
552
  tool_use_id: part.toolCallId,
@@ -546,7 +562,7 @@ export class ChatSession {
546
562
  }
547
563
  }
548
564
  if (hasMalformedToolProtocolText(fullText)) {
549
- console.warn(`[DeepCCC] malformed DSML tool output detected for ${this.context.sessionId} `
565
+ console.warn(`[DeepCCC] malformed tool protocol text detected for ${this.context.sessionId} `
550
566
  + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
551
567
  rawLog?.writeLine(safeRawStreamJson({
552
568
  type: "deepccc_tool_protocol_recovery",
@@ -559,8 +575,8 @@ export class ChatSession {
559
575
  continue;
560
576
  }
561
577
  throw new Error(toolCallOrder.length > 0
562
- ? "工具调用协议异常:检测到混合的结构化与 DSML 文本调用,为避免重复执行工具,本轮已安全终止"
563
- : "工具调用协议异常:模型重试后仍输出了无效的 DSML 工具调用");
578
+ ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
579
+ : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
564
580
  }
565
581
  completed = true;
566
582
  const collectedToolCalls = toolCallOrder
@@ -570,6 +586,7 @@ export class ChatSession {
570
586
  fullText,
571
587
  transcriptLines: toolContext,
572
588
  toolCalls: collectedToolCalls,
589
+ timeline,
573
590
  }));
574
591
  yield { type: "done", text: safeAccumulated };
575
592
  return;
@@ -581,9 +598,22 @@ export class ChatSession {
581
598
  if (malformedProtocolOutput)
582
599
  yield { type: "text_reset" };
583
600
  if (err.name === "AbortError" || signal?.aborted) {
584
- // 被中断时,不保存不完整的助手消息
585
- if (fullText && !malformedProtocolOutput) {
586
- this.context.appendMessage({ role: "assistant", content: `${fullText}\n[interrupted]` });
601
+ if ((fullText || toolCallOrder.length > 0) && !malformedProtocolOutput) {
602
+ const collectedToolCalls = toolCallOrder
603
+ .map((id) => toolCallsById.get(id))
604
+ .filter((call) => call !== undefined);
605
+ const interruptedTimeline = timeline.map((entry) => ({ ...entry }));
606
+ const previous = interruptedTimeline[interruptedTimeline.length - 1];
607
+ if (previous?.type === "text")
608
+ previous.text += "\n[interrupted]";
609
+ else
610
+ interruptedTimeline.push({ type: "text", text: "[interrupted]" });
611
+ this.context.appendMessage(buildPersistedAssistantMessage({
612
+ fullText: `${fullText}\n[interrupted]`,
613
+ transcriptLines: toolContext,
614
+ toolCalls: collectedToolCalls,
615
+ timeline: interruptedTimeline,
616
+ }));
587
617
  }
588
618
  yield { type: "done", text: safeAccumulated };
589
619
  return;
@@ -1,2 +1,8 @@
1
+ /**
2
+ * Detects a model copying DeepCCC's persisted/debug transcript syntax into
3
+ * normal assistant text. Both the dedicated header and an event line are
4
+ * required so documentation and ordinary discussion remain valid.
5
+ */
6
+ export declare function hasImitatedToolTranscriptText(text: string): boolean;
1
7
  export declare function hasMalformedToolProtocolText(text: string): boolean;
2
8
  export declare const TOOL_PROTOCOL_RECOVERY_PROMPT: string;
@@ -6,12 +6,23 @@
6
6
  const MALFORMED_DSML_TRAILER = /<\/[||]{2}DSML[||]{2}(?:parameter|invoke)>\s*$/iu;
7
7
  const RENDERED_TOOL_CALL = /\[调用\s+[A-Za-z_][\w.-]*\]/u;
8
8
  const DSML_INVOKE_TAG = /<[||]{2}DSML[||]{2}invoke\b/iu;
9
+ const TOOL_TRANSCRIPT_HEADER = /(?:^|\n)\s*\[工具记录\]\s*(?:\n|$)/u;
10
+ const TOOL_TRANSCRIPT_EVENT = /(?:^|\n)\s*tool_(?:call|result|error)\s+[A-Za-z_][\w.-]*\s*:/u;
11
+ /**
12
+ * Detects a model copying DeepCCC's persisted/debug transcript syntax into
13
+ * normal assistant text. Both the dedicated header and an event line are
14
+ * required so documentation and ordinary discussion remain valid.
15
+ */
16
+ export function hasImitatedToolTranscriptText(text) {
17
+ return TOOL_TRANSCRIPT_HEADER.test(text) && TOOL_TRANSCRIPT_EVENT.test(text);
18
+ }
9
19
  export function hasMalformedToolProtocolText(text) {
10
- return MALFORMED_DSML_TRAILER.test(text)
20
+ const malformedDsml = MALFORMED_DSML_TRAILER.test(text)
11
21
  && (RENDERED_TOOL_CALL.test(text) || DSML_INVOKE_TAG.test(text));
22
+ return malformedDsml || hasImitatedToolTranscriptText(text);
12
23
  }
13
24
  export const TOOL_PROTOCOL_RECOVERY_PROMPT = [
14
25
  "[系统恢复提示] 上一次响应泄漏了内部工具调用协议,因此已被丢弃。",
15
- "请重新完成当前用户请求。需要调用工具时,只能使用 API 提供的结构化工具调用;不要把 DSML 或工具参数作为普通文本输出。",
16
- "不要提及本恢复提示,也不要假装工具已经执行。",
26
+ "请重新完成当前用户请求。需要调用工具时,只能使用 API 提供的结构化工具调用;不要把 DSML、[工具调用]、[工具记录] 或工具参数作为普通文本输出。",
27
+ "不要提及本恢复提示,不要伪造工具已执行,也不要根据未执行的命令声称得到了结果。",
17
28
  ].join("\n");
package/dist/web-page.js CHANGED
@@ -325,7 +325,10 @@ export const DEEPCCC_WEB_PAGE = `<!DOCTYPE html>
325
325
  function setToolState(key,value){try{sessionStorage.setItem(toolStorageKey(key),value?'1':'0');}catch(error){}}
326
326
  function clearToolSessionState(sessionId){try{var prefix='deepccc:tool-ui:v1:'+sessionId+':';for(var index=sessionStorage.length-1;index>=0;index--){var key=sessionStorage.key(index);if(key&&key.indexOf(prefix)===0)sessionStorage.removeItem(key);}}catch(error){}}
327
327
  function toolSectionHtml(call,kind,value,headLines,tailLines){if(value===undefined)return'';var result=truncateToolPayload(value,headLines,tailLines);var stateKey=call.uiKey+':'+kind+'-full';var expanded=toolState(stateKey);var text=expanded?result.full:result.preview;var label=kind==='input'?'调用参数':'工具结果';var button=result.truncated?'<button type="button" class="tool-overflow-toggle" data-tool-overflow="'+escapeHtml(stateKey)+'">'+escapeHtml(expanded?'收起省略内容':(result.omittedLines>0?'展开省略的 '+result.omittedLines+' 行':'展开完整内容'))+'</button>':'';return'<section class="tool-section"><div class="tool-section-title">'+label+'</div><pre>'+escapeHtml(text)+'</pre>'+button+'</section>';}
328
- function messageHtml(role,content,toolCalls,messageIndex){var decoded=role==='user'?decodeAttachmentMessage(content):{text:withoutToolTranscript(content),attachments:[]};var tools='';(toolCalls||[]).forEach(function(call,index){tools+=toolCardHtml({uiKey:call.id?'tool:'+call.id:'message:'+messageIndex+':tool:'+index,name:call.name,input:call.input,output:call.output,isError:!!call.is_error,pending:false});});return '<article class="message '+role+'"><div class="avatar">'+(role==='user'?'你':'D')+'</div><div class="bubble"><div class="role">'+(role==='user'?'YOU':'DEEPCCC')+'</div><div class="markdown">'+markdown(decoded.text)+'</div>'+attachmentGallery(decoded.attachments,state.detail.sessionId)+tools+'</div></article>';}
328
+ function timelineHtml(timeline,messageIndex){var results={};(timeline||[]).forEach(function(item){if(item&&item.type==='tool_result'&&item.tool_use_id)results[item.tool_use_id]=item;});var renderedCalls={};var html='';(timeline||[]).forEach(function(item,index){if(!item)return;if(item.type==='text'){if(item.text)html+='<div class="markdown">'+markdown(item.text)+'</div>';return;}if(item.type==='tool_use'){var id=item.id||('timeline-'+messageIndex+'-'+index);var result=results[id];renderedCalls[id]=true;html+=toolCardHtml({uiKey:'tool:'+id,name:item.name||'tool',input:item.input,output:result?result.output:undefined,isError:!!(result&&result.is_error),pending:!result});return;}if(item.type==='tool_result'&&!renderedCalls[item.tool_use_id]){var fallbackId=item.tool_use_id||('result-'+messageIndex+'-'+index);renderedCalls[fallbackId]=true;html+=toolCardHtml({uiKey:'tool:'+fallbackId,name:item.name||'tool',output:item.output,isError:!!item.is_error,pending:false});}});return html;}
329
+ function liveTimeline(events){var timeline=[];(events||[]).forEach(function(event){if(event.type!=='agent'||!event.data)return;var item=event.data;if(item.type==='text_reset'){timeline.length=0;return;}if(item.type==='text'){var previous=timeline[timeline.length-1];if(previous&&previous.type==='text')previous.text+=(item.text||'');else timeline.push({type:'text',text:item.text||''});return;}if(item.type==='tool_use')timeline.push({type:'tool_use',id:item.id,name:item.name,input:item.input});else if(item.type==='tool_result')timeline.push({type:'tool_result',tool_use_id:item.tool_use_id,name:item.name,output:item.content,is_error:!!item.is_error});});return timeline;}
330
+ function legacyToolsHtml(toolCalls,messageIndex){var tools='';(toolCalls||[]).forEach(function(call,index){tools+=toolCardHtml({uiKey:call.id?'tool:'+call.id:'message:'+messageIndex+':tool:'+index,name:call.name,input:call.input,output:call.output,isError:!!call.is_error,pending:false});});return tools;}
331
+ function messageHtml(role,content,toolCalls,messageIndex,timeline){var decoded=role==='user'?decodeAttachmentMessage(content):{text:withoutToolTranscript(content),attachments:[]};var body;if(role==='user')body='<div class="markdown">'+markdown(decoded.text)+'</div>'+attachmentGallery(decoded.attachments,state.detail.sessionId);else if(timeline&&timeline.length)body=timelineHtml(timeline,messageIndex);else body=legacyToolsHtml(toolCalls,messageIndex)+'<div class="markdown">'+markdown(decoded.text)+'</div>';return '<article class="message '+role+'"><div class="avatar">'+(role==='user'?'你':'D')+'</div><div class="bubble"><div class="role">'+(role==='user'?'YOU':'DEEPCCC')+'</div>'+body+'</div></article>';}
329
332
  function buildToolGroups(events){var groups=[];var byId={};var order=[];(events||[]).forEach(function(event){if(event.type!=='agent'||!event.data)return;var item=event.data;if(item.type==='tool_use'){var id=item.id||('call-'+order.length);var group={id:id,uiKey:'tool:'+id,name:item.name||'tool',input:item.input,output:undefined,isError:false,pending:true};byId[id]=group;order.push(id);groups.push(group);}else if(item.type==='tool_result'){var key=item.tool_use_id;var target=key?byId[key]:null;if(target){target.output=item.content;target.isError=!!item.is_error;target.pending=false;}}});return groups;}
330
333
  function toolCardHtml(call){var open=toolState(call.uiKey+':card');var present=call.name==='present_file'&&!call.pending?artifactFromOutput(call.output):null;var inputHtml=toolSectionHtml(call,'input',call.input,8,4);var outputHtml=call.pending?'':(present?'<section class="tool-section"><div class="tool-section-title">工具结果</div>'+artifactGallery([{name:'present_file',output:call.output}],state.detail.sessionId)+'</section>':toolSectionHtml(call,'output',call.output==null?'(无输出)':call.output,12,6));return'<details class="tool-card" data-tool-state="'+escapeHtml(call.uiKey+':card')+'"'+(open?' open':'')+'><summary><span class="tool-summary-text">'+escapeHtml(toolSummary(call))+'</span></summary>'+inputHtml+outputHtml+'</details>';}
331
334
  function toolCardsHtml(calls){return (calls||[]).map(toolCardHtml).join('');}
@@ -356,11 +359,10 @@ export const DEEPCCC_WEB_PAGE = `<!DOCTYPE html>
356
359
  renderAttachmentDrafts();
357
360
  var html='<div class="thread">';
358
361
  if(!(detail.messages||[]).length&&detail.status!=='running')html+='<div class="empty-thread"><strong>准备开始这个会话</strong><span>在下方输入任务,可附加截图。DeepCCC 会读取当前项目指令、调用工具并持续保存上下文。</span></div>';
359
- (detail.messages||[]).forEach(function(message,index){html+=messageHtml(message.role,message.content,message.toolCalls,index);});
362
+ (detail.messages||[]).forEach(function(message,index){html+=messageHtml(message.role,message.content,message.toolCalls,index,message.timeline);});
360
363
  if(detail.status==='running'){
361
- var live=(detail.events||[]).slice().reverse().find(function(event){return event.type==='agent'&&event.data&&event.data.type==='text';});
362
- if(live)html+=messageHtml('assistant',live.data.accumulated||live.data.text||'',[],'live');
363
- html+=toolCardsHtml(buildToolGroups(detail.events));
364
+ var currentTimeline=liveTimeline(detail.events);
365
+ if(currentTimeline.length)html+=messageHtml('assistant','',[],'live',currentTimeline);
364
366
  }
365
367
  var approvals=(detail.approvals||[]).slice();
366
368
  if(detail.pendingApproval&&!approvals.some(function(item){return item.approvalId===detail.pendingApproval.approvalId;}))approvals.push(Object.assign({status:'pending'},detail.pendingApproval));
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.2.7",
3
+ "version": "0.2.9",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [