chatccc 0.2.272 → 0.2.276

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.
Files changed (35) hide show
  1. package/README.md +24 -22
  2. package/config.sample.json +4 -4
  3. package/deepccc-agent/README.md +147 -79
  4. package/deepccc-agent/package.json +68 -65
  5. package/dist/deepccc-agent/src/attachments.js +192 -0
  6. package/dist/deepccc-agent/src/cli.js +54 -13
  7. package/dist/deepccc-agent/src/config.js +49 -4
  8. package/dist/deepccc-agent/src/context.js +299 -16
  9. package/dist/deepccc-agent/src/file-tools.js +33 -0
  10. package/dist/deepccc-agent/src/index.js +48 -16
  11. package/dist/deepccc-agent/src/tool-protocol.js +14 -3
  12. package/dist/deepccc-agent/src/web-entry.js +72 -0
  13. package/dist/deepccc-agent/src/web-page.js +414 -0
  14. package/dist/deepccc-agent/src/web-runtime.js +331 -0
  15. package/dist/deepccc-agent/src/web-server.js +476 -0
  16. package/dist/deepccc-agent/src/web-session-store.js +162 -0
  17. package/dist/deepccc-agent/src/web-tool-presentation.js +123 -0
  18. package/dist/src/adapters/ccc-adapter.js +1 -0
  19. package/dist/src/agent-capability-grants.js +26 -0
  20. package/dist/src/agent-delegate-task.js +5 -2
  21. package/dist/src/agent-file-rpc.js +6 -1
  22. package/dist/src/agent-image-rpc.js +6 -1
  23. package/dist/src/agent-team/web/agent-team-page.js +250 -250
  24. package/dist/src/cards.js +7 -4
  25. package/dist/src/im-skills.js +9 -2
  26. package/dist/src/orchestrator.js +117 -29
  27. package/dist/src/session-name.js +15 -0
  28. package/dist/src/session.js +43 -7
  29. package/dist/src/web-ui.js +68 -51
  30. package/im-skills/feishu-skill/receive-send-file.md +3 -2
  31. package/im-skills/feishu-skill/receive-send-image.md +3 -2
  32. package/im-skills/feishu-skill/send-file.mjs +6 -5
  33. package/im-skills/feishu-skill/send-image.mjs +6 -5
  34. package/im-skills/feishu-skill/skill.md +4 -2
  35. package/package.json +76 -76
@@ -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 [];
@@ -62,6 +101,8 @@ function normalizeToolCalls(value) {
62
101
  if (typeof raw.name !== "string" || raw.name.length === 0)
63
102
  continue;
64
103
  const call = { name: raw.name };
104
+ if (typeof raw.id === "string" && raw.id.length > 0)
105
+ call.id = raw.id;
65
106
  if (typeof raw.input === "string")
66
107
  call.input = raw.input;
67
108
  if (typeof raw.output === "string")
@@ -135,6 +176,18 @@ function readSessionInfo(contextDir, sessionId) {
135
176
  export function getBuiltinContextSession(sessionId, contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
136
177
  return readSessionInfo(contextDir, sessionId);
137
178
  }
179
+ export function readBuiltinContextState(sessionId, contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
180
+ const normalizedSessionId = normalizeBuiltinSessionId(sessionId);
181
+ const filePath = contextFilePath(contextDir, normalizedSessionId);
182
+ if (!existsSync(filePath))
183
+ return null;
184
+ try {
185
+ return normalizeState(JSON.parse(readFileSync(filePath, "utf8")), normalizedSessionId);
186
+ }
187
+ catch {
188
+ return null;
189
+ }
190
+ }
138
191
  export function listBuiltinContextSessions(contextDir = DEFAULT_BUILTIN_CONTEXT_DIR) {
139
192
  if (!existsSync(contextDir))
140
193
  return [];
@@ -161,8 +214,11 @@ export function latestBuiltinSessionForCwd(cwd, contextDir = DEFAULT_BUILTIN_CON
161
214
  * 按字符类型加权,比旧版 chars/3 更接近真实:CJK 字符 ≈ 1 token/字,
162
215
  * 其他字符 ≈ 3.5 chars/token。避免中文长上下文被严重低估导致压缩过晚。
163
216
  */
164
- export function estimateBuiltinContextTokens(summary, messages) {
165
- 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) {
166
222
  let cjk = 0;
167
223
  for (const ch of text) {
168
224
  if (/[\u3400-\u4DBF\u4E00-\u9FFF\uF900-\uFAFF\u3000-\u303F\uFF00-\uFFEF]/.test(ch))
@@ -171,6 +227,30 @@ export function estimateBuiltinContextTokens(summary, messages) {
171
227
  const other = text.length - cjk;
172
228
  return Math.ceil(cjk + other / 3.5);
173
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
+ }
174
254
  export function serializeMessagesForSummary(messages) {
175
255
  return messages
176
256
  .map((message, index) => `### ${index + 1}. ${message.role}\n${message.content}`)
@@ -188,23 +268,205 @@ export const DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS = 32_000;
188
268
  export const DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS = 24_000;
189
269
  const ASSISTANT_TRUNCATED_MARKER = "...[助手回复已在上下文中截断]...";
190
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
+ }
191
289
  /**
192
- * 构造持久化的 assistant 消息:content 保持既有"正文 + [Tool transcript]"文本格式
193
- * (模型上下文行为不变),同时附加结构化 toolCalls 存档(供 session-search 等检索)。
290
+ * 构造持久化 assistant 消息。content 仅保存回答正文,工具事件保存在
291
+ * toolCalls/timeline,避免内部 transcript 语法回流并诱导模型伪造工具执行。
194
292
  */
195
293
  export function buildPersistedAssistantMessage(params) {
196
294
  const maxAssistantChars = params.maxAssistantChars ?? DEFAULT_PERSISTED_ASSISTANT_TEXT_CHARS;
197
295
  const maxTranscriptChars = params.maxTranscriptChars ?? DEFAULT_PERSISTED_TOOL_TRANSCRIPT_CHARS;
198
296
  const persistedAssistantText = truncateMiddle(params.fullText, maxAssistantChars, ASSISTANT_TRUNCATED_MARKER);
199
- const content = params.transcriptLines.length > 0
200
- ? `${persistedAssistantText}\n\n[工具记录]\n${truncateMiddle(params.transcriptLines.join("\n"), maxTranscriptChars, TOOL_TRANSCRIPT_TRUNCATED_MARKER)}`
201
- : persistedAssistantText;
202
- const message = { role: "assistant", content };
203
- const toolCalls = normalizeToolCalls(params.toolCalls);
297
+ const message = { role: "assistant", content: persistedAssistantText };
298
+ const toolCalls = truncateStructuredToolCalls(normalizeToolCalls(params.toolCalls), maxTranscriptChars);
204
299
  if (toolCalls.length > 0)
205
300
  message.toolCalls = toolCalls;
301
+ const timeline = truncatePersistedTimeline(normalizeTimeline(params.timeline));
302
+ if (timeline.length > 0)
303
+ message.timeline = timeline;
206
304
  return message;
207
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 completeToolStep = () => {
406
+ // OpenAI/LiteLLM requires every tool message to immediately follow the
407
+ // assistant message that declared all matching tool_calls for that step.
408
+ flushAssistant();
409
+ for (const [toolCallId, toolName] of pendingCalls) {
410
+ toolParts.push({
411
+ type: "tool-result",
412
+ toolCallId,
413
+ toolName,
414
+ output: { type: "text", value: "[tool execution interrupted; result unavailable]" },
415
+ });
416
+ }
417
+ pendingCalls.clear();
418
+ if (!toolParts.length)
419
+ return;
420
+ messages.push({ role: "tool", content: toolParts });
421
+ toolParts = [];
422
+ };
423
+ for (const entry of timeline) {
424
+ if (entry.type === "text") {
425
+ if (pendingCalls.size > 0 || toolParts.length > 0)
426
+ completeToolStep();
427
+ const previous = assistantParts[assistantParts.length - 1];
428
+ if (previous?.type === "text")
429
+ previous.text += entry.text;
430
+ else if (entry.text)
431
+ assistantParts.push({ type: "text", text: entry.text });
432
+ }
433
+ else if (entry.type === "tool_use") {
434
+ // Consecutive tool calls belong to one assistant step. Only close the
435
+ // previous step after at least one result has arrived.
436
+ if (toolParts.length > 0)
437
+ completeToolStep();
438
+ knownNames.set(entry.id, entry.name);
439
+ pendingCalls.set(entry.id, entry.name);
440
+ emittedCallIds.add(entry.id);
441
+ assistantParts.push({
442
+ type: "tool-call",
443
+ toolCallId: entry.id,
444
+ toolName: entry.name,
445
+ input: parseToolInput(entry.input),
446
+ });
447
+ }
448
+ else {
449
+ if (!emittedCallIds.has(entry.tool_use_id))
450
+ continue;
451
+ const toolName = entry.name ?? knownNames.get(entry.tool_use_id);
452
+ if (!toolName)
453
+ continue;
454
+ flushAssistant();
455
+ pendingCalls.delete(entry.tool_use_id);
456
+ toolParts.push({
457
+ type: "tool-result",
458
+ toolCallId: entry.tool_use_id,
459
+ toolName,
460
+ output: parseToolOutput(entry.output),
461
+ });
462
+ }
463
+ }
464
+ if (pendingCalls.size > 0 || toolParts.length > 0)
465
+ completeToolStep();
466
+ else
467
+ flushAssistant();
468
+ return messages;
469
+ }
208
470
  function serializeMessagesForCompaction(messages) {
209
471
  const sections = [];
210
472
  let remaining = MAX_COMPACTION_SOURCE_CHARS;
@@ -245,6 +507,7 @@ export class BuiltinContextManager {
245
507
  contextDir;
246
508
  sessionId;
247
509
  compactAtTokens;
510
+ maxToolContextTokens;
248
511
  keepRecentMessages;
249
512
  cwd;
250
513
  state;
@@ -255,6 +518,8 @@ export class BuiltinContextManager {
255
518
  this.cwd = options.cwd;
256
519
  this.compactAtTokens = options.compactAtTokens
257
520
  ?? Math.floor((options.contextWindow ?? DEFAULT_CONTEXT_WINDOW_TOKENS) * COMPACTION_THRESHOLD_RATIO);
521
+ this.maxToolContextTokens = Math.max(1, options.maxToolContextTokens
522
+ ?? Math.min(DEFAULT_MAX_TOOL_CONTEXT_TOKENS, Math.floor(this.compactAtTokens * TOOL_CONTEXT_BUDGET_RATIO)));
258
523
  this.keepRecentMessages = Math.max(1, options.keepRecentMessages ?? DEFAULT_KEEP_RECENT_MESSAGES);
259
524
  this.state = this.load();
260
525
  }
@@ -294,7 +559,12 @@ export class BuiltinContextManager {
294
559
  // Keep malformed provider output on disk for diagnosis, but quarantine it
295
560
  // from future prompts so one protocol failure cannot teach the model to
296
561
  // repeat the same invalid tool syntax on every later turn.
297
- messages.push(...this.modelSafeMessages());
562
+ for (const [index, message] of this.modelSafeMessages().entries()) {
563
+ if (message.role === "user")
564
+ messages.push({ role: "user", content: message.content });
565
+ else
566
+ messages.push(...replayStructuredAssistantMessage(message, index));
567
+ }
298
568
  return messages;
299
569
  }
300
570
  planCompaction() {
@@ -302,16 +572,19 @@ export class BuiltinContextManager {
302
572
  // leaks here too, otherwise a later summary could reintroduce the bad syntax.
303
573
  const messages = this.modelSafeMessages();
304
574
  const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
305
- if (estimated <= this.compactAtTokens)
575
+ const toolEstimated = estimateBuiltinToolContextTokens(messages);
576
+ if (estimated <= this.compactAtTokens && toolEstimated <= this.maxToolContextTokens)
306
577
  return null;
307
578
  if (messages.length <= 1)
308
579
  return null;
309
580
  const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
310
581
  const recentTokenBudget = Math.max(1, Math.floor(this.compactAtTokens * RECENT_CONTEXT_BUDGET_RATIO));
582
+ const recentToolBudget = Math.max(1, Math.floor(this.maxToolContextTokens * RECENT_TOOL_CONTEXT_BUDGET_RATIO));
311
583
  let splitAt = messages.length - 1;
312
584
  for (let index = messages.length - 2; index >= earliestAllowed; index -= 1) {
313
585
  const candidate = messages.slice(index);
314
- if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget)
586
+ if (estimateBuiltinContextTokens("", candidate) > recentTokenBudget
587
+ || estimateBuiltinToolContextTokens(candidate) > recentToolBudget)
315
588
  break;
316
589
  splitAt = index;
317
590
  }
@@ -322,8 +595,8 @@ export class BuiltinContextManager {
322
595
  splitAt = 1;
323
596
  return {
324
597
  previousSummary: this.state.summary,
325
- oldMessages: this.state.messages.slice(0, splitAt),
326
- recentMessages: this.state.messages.slice(splitAt),
598
+ oldMessages: messages.slice(0, splitAt),
599
+ recentMessages: messages.slice(splitAt),
327
600
  };
328
601
  }
329
602
  applyCompaction(summary, plan) {
@@ -347,7 +620,17 @@ export class BuiltinContextManager {
347
620
  renameSync(tmp, this.contextFilePath);
348
621
  }
349
622
  modelSafeMessages() {
350
- return this.state.messages.filter((message) => message.role !== "assistant" || !hasMalformedToolProtocolText(message.content));
623
+ return this.state.messages.map((message) => {
624
+ if (message.role !== "assistant")
625
+ return message;
626
+ const hasStructuredTools = Boolean(message.toolCalls?.length
627
+ || message.timeline?.some((entry) => entry.type === "tool_use" || entry.type === "tool_result"));
628
+ const copiedStoredTranscript = hasImitatedToolTranscriptText(message.content) && hasStructuredTools;
629
+ if (hasMalformedToolProtocolText(message.content) && !copiedStoredTranscript) {
630
+ return { role: "assistant", content: QUARANTINED_PROTOCOL_REPLY };
631
+ }
632
+ return { ...message, content: stripStoredToolTranscript(message.content) };
633
+ });
351
634
  }
352
635
  load() {
353
636
  if (!this.persist || !existsSync(this.contextFilePath))
@@ -8,6 +8,7 @@ import { basename, dirname, isAbsolute, join, relative, resolve, sep } from "nod
8
8
  import { createInterface } from "node:readline";
9
9
  import { jsonSchema, tool } from "ai";
10
10
  import { isDangerousCommand } from "./permissions.js";
11
+ import { detectImageMime, MAX_ATTACHMENT_BYTES } from "./attachments.js";
11
12
  import { killProcessTree } from "./proc-tree-kill.js";
12
13
  import { searchBuiltinSessions, } from "./session-search.js";
13
14
  import { webFetchForTool, webSearchForTool, } from "./web-tools.js";
@@ -74,6 +75,25 @@ async function pathExists(path) {
74
75
  return false;
75
76
  }
76
77
  }
78
+ export async function presentFileForTool(cwd, input) {
79
+ const path = resolveToolPath(cwd, input.path);
80
+ const info = await stat(path).catch(() => null);
81
+ if (!info?.isFile())
82
+ throw new Error(`Image file not found: ${input.path}`);
83
+ if (info.size > MAX_ATTACHMENT_BYTES)
84
+ throw new Error(`Image file exceeds the 20 MB limit: ${input.path}`);
85
+ const mimeType = detectImageMime(await readFile(path));
86
+ if (!mimeType)
87
+ throw new Error(`present_file only supports PNG, JPEG, or WebP images: ${input.path}`);
88
+ const caption = input.caption?.trim();
89
+ return {
90
+ path,
91
+ name: basename(path),
92
+ mimeType,
93
+ size: info.size,
94
+ ...(caption ? { caption: caption.slice(0, 500) } : {}),
95
+ };
96
+ }
77
97
  function sha256(buffer) {
78
98
  return createHash("sha256").update(buffer).digest("hex");
79
99
  }
@@ -1011,6 +1031,19 @@ export function createBuiltinFileTools(cwd, options = {}) {
1011
1031
  }),
1012
1032
  execute: (input, options) => searchCodeForTool(cwd, input, options.abortSignal),
1013
1033
  }),
1034
+ present_file: tool({
1035
+ description: "把本地 PNG、JPEG 或 WebP 图片作为会话图片展示给用户。仅在图片文件已经生成并需要交付时调用。",
1036
+ inputSchema: jsonSchema({
1037
+ type: "object",
1038
+ additionalProperties: false,
1039
+ properties: {
1040
+ path: { type: "string", description: "图片绝对路径或相对于会话工作目录的路径。" },
1041
+ caption: { type: "string", description: "可选的图片说明。" },
1042
+ },
1043
+ required: ["path"],
1044
+ }),
1045
+ execute: (input) => presentFileForTool(cwd, input),
1046
+ }),
1014
1047
  run_command: tool({
1015
1048
  description: "在本地工作区运行非交互式 shell 命令。用于测试、git 和包脚本。返回 stdout/stderr 和 exitCode;非零退出码不是工具错误。",
1016
1049
  inputSchema: jsonSchema({
@@ -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
  }
@@ -249,6 +251,7 @@ export class ChatSession {
249
251
  maxSteps;
250
252
  effort;
251
253
  maxOutputTokens;
254
+ streaming;
252
255
  permissionMode;
253
256
  permissionResolver;
254
257
  permissionGate;
@@ -316,6 +319,7 @@ export class ChatSession {
316
319
  this.provider = normalizeDeepCccProvider(overrides.provider ?? appConfig.provider);
317
320
  this.effort = (overrides.effort ?? appConfig.effort ?? "").trim();
318
321
  this.maxOutputTokens = normalizeMaxOutputTokens(overrides.maxOutputTokens ?? appConfig.maxOutputTokens);
322
+ this.streaming = overrides.streaming ?? appConfig.streaming;
319
323
  this.apiKey = apiKey;
320
324
  this.baseURL = baseURL;
321
325
  const provider = this.provider === "anthropic"
@@ -351,6 +355,7 @@ export class ChatSession {
351
355
  cwd: this.cwd,
352
356
  contextWindow: options.contextWindow ?? appConfig.contextWindow,
353
357
  compactAtTokens: options.compactAtTokens,
358
+ maxToolContextTokens: options.maxToolContextTokens,
354
359
  keepRecentMessages: options.keepRecentMessages,
355
360
  });
356
361
  this.permissionGate = new PermissionGate(this.permissionMode, this.permissionResolver);
@@ -391,6 +396,8 @@ export class ChatSession {
391
396
  // assistant 消息 toolCalls 字段;[Tool transcript] 文本视图仍按原格式生成。
392
397
  const toolCallsById = new Map();
393
398
  const toolCallOrder = [];
399
+ const timeline = [];
400
+ let toolContext = [];
394
401
  try {
395
402
  if (this.context.planCompaction()) {
396
403
  yield { type: "status", phase: "compacting" };
@@ -458,9 +465,10 @@ export class ChatSession {
458
465
  for (let attempt = 0; attempt < 2; attempt += 1) {
459
466
  fullText = "";
460
467
  safeAccumulated = "";
461
- const toolContext = [];
468
+ toolContext = [];
462
469
  toolCallsById.clear();
463
470
  toolCallOrder.length = 0;
471
+ timeline.length = 0;
464
472
  let lastReasoningProgressAt;
465
473
  const attemptMessages = attempt === 0
466
474
  ? modelMessages
@@ -470,7 +478,7 @@ export class ChatSession {
470
478
  messages: attemptMessages,
471
479
  };
472
480
  let stream;
473
- if (appConfig.streaming) {
481
+ if (this.streaming) {
474
482
  const result = streamText(generationOptions);
475
483
  stream = result.fullStream ?? textStreamToFullStream(result.textStream);
476
484
  }
@@ -491,6 +499,11 @@ export class ChatSession {
491
499
  }
492
500
  else if (part.type === "text-delta") {
493
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 });
494
507
  // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
495
508
  // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
496
509
  const safeText = applyPrivacy(part.text);
@@ -498,9 +511,11 @@ export class ChatSession {
498
511
  yield { type: "text", text: safeText, accumulated: safeAccumulated };
499
512
  }
500
513
  else if (part.type === "tool-call") {
501
- toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
502
- toolCallsById.set(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 });
503
517
  toolCallOrder.push(part.toolCallId);
518
+ timeline.push({ type: "tool_use", id: part.toolCallId, name: part.toolName, input });
504
519
  yield {
505
520
  type: "tool_use",
506
521
  id: part.toolCallId,
@@ -509,10 +524,12 @@ export class ChatSession {
509
524
  };
510
525
  }
511
526
  else if (part.type === "tool-result") {
512
- 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}`);
513
529
  const call = toolCallsById.get(part.toolCallId);
514
530
  if (call)
515
- 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 });
516
533
  yield {
517
534
  type: "tool_result",
518
535
  tool_use_id: part.toolCallId,
@@ -529,6 +546,7 @@ export class ChatSession {
529
546
  call.output = message;
530
547
  call.is_error = true;
531
548
  }
549
+ timeline.push({ type: "tool_result", tool_use_id: part.toolCallId, name: part.toolName, output: message, is_error: true });
532
550
  yield {
533
551
  type: "tool_result",
534
552
  tool_use_id: part.toolCallId,
@@ -544,7 +562,7 @@ export class ChatSession {
544
562
  }
545
563
  }
546
564
  if (hasMalformedToolProtocolText(fullText)) {
547
- 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} `
548
566
  + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
549
567
  rawLog?.writeLine(safeRawStreamJson({
550
568
  type: "deepccc_tool_protocol_recovery",
@@ -557,8 +575,8 @@ export class ChatSession {
557
575
  continue;
558
576
  }
559
577
  throw new Error(toolCallOrder.length > 0
560
- ? "工具调用协议异常:检测到混合的结构化与 DSML 文本调用,为避免重复执行工具,本轮已安全终止"
561
- : "工具调用协议异常:模型重试后仍输出了无效的 DSML 工具调用");
578
+ ? "工具调用协议异常:检测到混合的结构化调用与伪造工具文本,为避免重复执行工具,本轮已安全终止"
579
+ : "工具调用协议异常:模型重试后仍输出了无效或伪造的工具调用文本");
562
580
  }
563
581
  completed = true;
564
582
  const collectedToolCalls = toolCallOrder
@@ -568,6 +586,7 @@ export class ChatSession {
568
586
  fullText,
569
587
  transcriptLines: toolContext,
570
588
  toolCalls: collectedToolCalls,
589
+ timeline,
571
590
  }));
572
591
  yield { type: "done", text: safeAccumulated };
573
592
  return;
@@ -579,9 +598,22 @@ export class ChatSession {
579
598
  if (malformedProtocolOutput)
580
599
  yield { type: "text_reset" };
581
600
  if (err.name === "AbortError" || signal?.aborted) {
582
- // 被中断时,不保存不完整的助手消息
583
- if (fullText && !malformedProtocolOutput) {
584
- 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
+ }));
585
617
  }
586
618
  yield { type: "done", text: safeAccumulated };
587
619
  return;