chatccc 0.2.258 → 0.2.259

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "deepccc",
3
- "version": "0.1.26",
3
+ "version": "0.1.27",
4
4
  "description": "A lightweight coding agent with OpenAI-compatible and Anthropic Messages API support.",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -2,6 +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
6
  export const DEFAULT_BUILTIN_CONTEXT_DIR = join(homedir(), ".deepccc", "sessions");
6
7
  /** 默认模型上下文窗口:1M(DeepSeek V4 Pro/Flash 原生规格)。 */
7
8
  export const DEFAULT_CONTEXT_WINDOW_TOKENS = 1_048_576;
@@ -290,14 +291,19 @@ export class BuiltinContextManager {
290
291
  ].join("\n"),
291
292
  });
292
293
  }
293
- messages.push(...this.state.messages);
294
+ // Keep malformed provider output on disk for diagnosis, but quarantine it
295
+ // from future prompts so one protocol failure cannot teach the model to
296
+ // repeat the same invalid tool syntax on every later turn.
297
+ messages.push(...this.modelSafeMessages());
294
298
  return messages;
295
299
  }
296
300
  planCompaction() {
297
- const estimated = estimateBuiltinContextTokens(this.state.summary, this.state.messages);
301
+ // The compaction model is still a model input: exclude quarantined protocol
302
+ // leaks here too, otherwise a later summary could reintroduce the bad syntax.
303
+ const messages = this.modelSafeMessages();
304
+ const estimated = estimateBuiltinContextTokens(this.state.summary, messages);
298
305
  if (estimated <= this.compactAtTokens)
299
306
  return null;
300
- const messages = this.state.messages;
301
307
  if (messages.length <= 1)
302
308
  return null;
303
309
  const earliestAllowed = Math.max(0, messages.length - this.keepRecentMessages);
@@ -340,6 +346,9 @@ export class BuiltinContextManager {
340
346
  writeFileSync(tmp, content, "utf8");
341
347
  renameSync(tmp, this.contextFilePath);
342
348
  }
349
+ modelSafeMessages() {
350
+ return this.state.messages.filter((message) => message.role !== "assistant" || !hasMalformedToolProtocolText(message.content));
351
+ }
343
352
  load() {
344
353
  if (!this.persist || !existsSync(this.contextFilePath))
345
354
  return emptyState(this.sessionId, this.cwd);
@@ -15,6 +15,7 @@ import { createRawStreamLog, } from "./raw-stream-log.js";
15
15
  import { buildPersistedAssistantMessage, buildSummaryPrompt, BuiltinContextManager, defaultBuiltinSessionId, } from "./context.js";
16
16
  import { createBuiltinFileTools, MAX_TASK_OUTPUT_CHARS } from "./file-tools.js";
17
17
  import { PermissionGate } from "./permissions.js";
18
+ import { hasMalformedToolProtocolText, TOOL_PROTOCOL_RECOVERY_PROMPT, } from "./tool-protocol.js";
18
19
  import { buildDefaultSkillDirs, buildSkillsIndexPrompt, scanSkillsDirs, } from "./skills.js";
19
20
  import { applyPrivacy, applyPrivacyToJson } from "./privacy.js";
20
21
  // ---------------------------------------------------------------------------
@@ -400,7 +401,6 @@ export class ChatSession {
400
401
  catch (err) {
401
402
  console.error(`[DeepCCC raw stream log] create failed: ${errorMessage(err)}`);
402
403
  }
403
- const toolContext = [];
404
404
  const maxSteps = this.maxSteps;
405
405
  // 每次对话前重新扫描技能索引(并行 + mtime 缓存,开销极小):
406
406
  // 新技能/修改的技能在下一次对话自动生效(热加载)。
@@ -423,10 +423,9 @@ export class ChatSession {
423
423
  ? { deepseek: { reasoningEffort: this.effort } }
424
424
  : { anthropic: { effort: this.effort } };
425
425
  }
426
- const generationOptions = {
426
+ const baseGenerationOptions = {
427
427
  model: this.model,
428
428
  system,
429
- messages: modelMessages,
430
429
  tools: createBuiltinFileTools(this.cwd, {
431
430
  permissionGate: this.permissionGate,
432
431
  runTask: this.runTask,
@@ -435,88 +434,132 @@ export class ChatSession {
435
434
  abortSignal: signal,
436
435
  ...(effortProviderOptions ? { providerOptions: effortProviderOptions } : {}),
437
436
  };
438
- let stream;
439
- if (appConfig.streaming) {
440
- const result = streamText(generationOptions);
441
- stream = result.fullStream ?? textStreamToFullStream(result.textStream);
442
- }
443
- else {
444
- const result = await generateText(generationOptions);
445
- stream = generateResultToFullStream(result);
446
- }
447
- for await (const part of stream) {
448
- rawLog?.writeLine(safeRawStreamJson(part));
449
- if (part.type === "text-delta") {
450
- fullText += part.text;
451
- // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
452
- // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
453
- const safeText = applyPrivacy(part.text);
454
- safeAccumulated += safeText;
455
- yield { type: "text", text: safeText, accumulated: safeAccumulated };
437
+ for (let attempt = 0; attempt < 2; attempt += 1) {
438
+ fullText = "";
439
+ safeAccumulated = "";
440
+ const toolContext = [];
441
+ toolCallsById.clear();
442
+ toolCallOrder.length = 0;
443
+ let lastReasoningProgressAt;
444
+ const attemptMessages = attempt === 0
445
+ ? modelMessages
446
+ : [...modelMessages, { role: "user", content: TOOL_PROTOCOL_RECOVERY_PROMPT }];
447
+ const generationOptions = {
448
+ ...baseGenerationOptions,
449
+ messages: attemptMessages,
450
+ };
451
+ let stream;
452
+ if (appConfig.streaming) {
453
+ const result = streamText(generationOptions);
454
+ stream = result.fullStream ?? textStreamToFullStream(result.textStream);
456
455
  }
457
- else if (part.type === "tool-call") {
458
- toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
459
- toolCallsById.set(part.toolCallId, { name: part.toolName, input: safeJson(part.input) });
460
- toolCallOrder.push(part.toolCallId);
461
- yield {
462
- type: "tool_use",
463
- id: part.toolCallId,
464
- name: part.toolName,
465
- input: applyPrivacyToJson(part.input),
466
- };
456
+ else {
457
+ const result = await generateText(generationOptions);
458
+ stream = generateResultToFullStream(result);
467
459
  }
468
- else if (part.type === "tool-result") {
469
- toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
470
- const call = toolCallsById.get(part.toolCallId);
471
- if (call) {
472
- call.output = truncateToolContext(safeJson(part.output));
460
+ for await (const part of stream) {
461
+ rawLog?.writeLine(safeRawStreamJson(part));
462
+ if (part.type === "reasoning-start" || part.type === "reasoning-delta") {
463
+ // Reasoning content remains private. A throttled heartbeat is enough
464
+ // for ChatCCC to distinguish active inference from a stalled stream.
465
+ const now = Date.now();
466
+ if (lastReasoningProgressAt === undefined || now - lastReasoningProgressAt >= 1_000) {
467
+ lastReasoningProgressAt = now;
468
+ yield { type: "progress", phase: "reasoning" };
469
+ }
473
470
  }
474
- yield {
475
- type: "tool_result",
476
- tool_use_id: part.toolCallId,
477
- name: part.toolName,
478
- content: applyPrivacyToJson(part.output),
479
- is_error: false,
480
- };
481
- }
482
- else if (part.type === "tool-error") {
483
- const message = errorMessage(part.error);
484
- toolContext.push(`tool_error ${part.toolName}: ${message}`);
485
- const call = toolCallsById.get(part.toolCallId);
486
- if (call) {
487
- call.output = message;
488
- call.is_error = true;
471
+ else if (part.type === "text-delta") {
472
+ fullText += part.text;
473
+ // 隐私替换只在展示层:safeAccumulated 供事件消费者(终端/JSONL)使用,
474
+ // fullText 原文用于持久化上下文,避免替换结果回流污染上下文。
475
+ const safeText = applyPrivacy(part.text);
476
+ safeAccumulated += safeText;
477
+ yield { type: "text", text: safeText, accumulated: safeAccumulated };
478
+ }
479
+ else if (part.type === "tool-call") {
480
+ toolContext.push(`tool_call ${part.toolName}: ${safeJson(part.input)}`);
481
+ toolCallsById.set(part.toolCallId, { name: part.toolName, input: safeJson(part.input) });
482
+ toolCallOrder.push(part.toolCallId);
483
+ yield {
484
+ type: "tool_use",
485
+ id: part.toolCallId,
486
+ name: part.toolName,
487
+ input: applyPrivacyToJson(part.input),
488
+ };
489
+ }
490
+ else if (part.type === "tool-result") {
491
+ toolContext.push(`tool_result ${part.toolName}: ${truncateToolContext(safeJson(part.output))}`);
492
+ const call = toolCallsById.get(part.toolCallId);
493
+ if (call)
494
+ call.output = truncateToolContext(safeJson(part.output));
495
+ yield {
496
+ type: "tool_result",
497
+ tool_use_id: part.toolCallId,
498
+ name: part.toolName,
499
+ content: applyPrivacyToJson(part.output),
500
+ is_error: false,
501
+ };
502
+ }
503
+ else if (part.type === "tool-error") {
504
+ const message = errorMessage(part.error);
505
+ toolContext.push(`tool_error ${part.toolName}: ${message}`);
506
+ const call = toolCallsById.get(part.toolCallId);
507
+ if (call) {
508
+ call.output = message;
509
+ call.is_error = true;
510
+ }
511
+ yield {
512
+ type: "tool_result",
513
+ tool_use_id: part.toolCallId,
514
+ name: part.toolName,
515
+ content: applyPrivacy(message),
516
+ is_error: true,
517
+ };
518
+ }
519
+ else if (part.type === "error") {
520
+ const message = errorMessage(part.error);
521
+ yield { type: "error", message: applyPrivacy(message) };
522
+ throw new Error(message);
489
523
  }
490
- yield {
491
- type: "tool_result",
492
- tool_use_id: part.toolCallId,
493
- name: part.toolName,
494
- content: applyPrivacy(message),
495
- is_error: true,
496
- };
497
524
  }
498
- else if (part.type === "error") {
499
- const message = errorMessage(part.error);
500
- yield { type: "error", message: applyPrivacy(message) };
501
- throw new Error(message);
525
+ if (hasMalformedToolProtocolText(fullText)) {
526
+ console.warn(`[DeepCCC] malformed DSML tool output detected for ${this.context.sessionId} `
527
+ + `(attempt ${attempt + 1}/2, structuredToolCalls=${toolCallOrder.length})`);
528
+ rawLog?.writeLine(safeRawStreamJson({
529
+ type: "deepccc_tool_protocol_recovery",
530
+ attempt: attempt + 1,
531
+ structuredToolCalls: toolCallOrder.length,
532
+ }));
533
+ yield { type: "text_reset" };
534
+ if (attempt === 0 && toolCallOrder.length === 0) {
535
+ yield { type: "status", phase: "generating" };
536
+ continue;
537
+ }
538
+ throw new Error(toolCallOrder.length > 0
539
+ ? "工具调用协议异常:检测到混合的结构化与 DSML 文本调用,为避免重复执行工具,本轮已安全终止"
540
+ : "工具调用协议异常:模型重试后仍输出了无效的 DSML 工具调用");
502
541
  }
542
+ completed = true;
543
+ const collectedToolCalls = toolCallOrder
544
+ .map((id) => toolCallsById.get(id))
545
+ .filter((call) => call !== undefined);
546
+ this.context.appendMessage(buildPersistedAssistantMessage({
547
+ fullText,
548
+ transcriptLines: toolContext,
549
+ toolCalls: collectedToolCalls,
550
+ }));
551
+ yield { type: "done", text: safeAccumulated };
552
+ return;
503
553
  }
504
- completed = true;
505
- const collectedToolCalls = toolCallOrder
506
- .map((id) => toolCallsById.get(id))
507
- .filter((call) => call !== undefined);
508
- this.context.appendMessage(buildPersistedAssistantMessage({
509
- fullText,
510
- transcriptLines: toolContext,
511
- toolCalls: collectedToolCalls,
512
- }));
513
- yield { type: "done", text: safeAccumulated };
514
554
  }
515
555
  catch (err) {
516
556
  const message = err instanceof Error ? err.message : String(err);
557
+ const malformedProtocolOutput = hasMalformedToolProtocolText(fullText);
558
+ if (malformedProtocolOutput)
559
+ yield { type: "text_reset" };
517
560
  if (err.name === "AbortError" || signal?.aborted) {
518
561
  // 被中断时,不保存不完整的助手消息
519
- if (fullText) {
562
+ if (fullText && !malformedProtocolOutput) {
520
563
  this.context.appendMessage({ role: "assistant", content: `${fullText}\n[interrupted]` });
521
564
  }
522
565
  yield { type: "done", text: safeAccumulated };
@@ -57,6 +57,10 @@ export function reduceProgress(prev, event) {
57
57
  case "text":
58
58
  // accumulated 是全文累积,直接全量替换,天然幂等
59
59
  return withProgressView(prev, { text: event.accumulated });
60
+ case "progress":
61
+ return withProgressView(prev, { headerTitle: "思考中..." });
62
+ case "text_reset":
63
+ return withProgressView(prev, { text: "", tools: [] });
60
64
  case "tool_use": {
61
65
  const tool = {
62
66
  id: event.id ?? `tool-${prev.tools.length + 1}`,
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Detects provider output that leaked DeepSeek's internal DSML tool-call syntax
3
+ * into normal assistant text. A trailing closing tag is intentionally required:
4
+ * mentioning DSML in prose or a code sample must not trigger recovery.
5
+ */
6
+ const MALFORMED_DSML_TRAILER = /<\/[||]{2}DSML[||]{2}(?:parameter|invoke)>\s*$/iu;
7
+ const RENDERED_TOOL_CALL = /\[调用\s+[A-Za-z_][\w.-]*\]/u;
8
+ const DSML_INVOKE_TAG = /<[||]{2}DSML[||]{2}invoke\b/iu;
9
+ export function hasMalformedToolProtocolText(text) {
10
+ return MALFORMED_DSML_TRAILER.test(text)
11
+ && (RENDERED_TOOL_CALL.test(text) || DSML_INVOKE_TAG.test(text));
12
+ }
13
+ export const TOOL_PROTOCOL_RECOVERY_PROMPT = [
14
+ "[系统恢复提示] 上一次响应泄漏了内部工具调用协议,因此已被丢弃。",
15
+ "请重新完成当前用户请求。需要调用工具时,只能使用 API 提供的结构化工具调用;不要把 DSML 或工具参数作为普通文本输出。",
16
+ "不要提及本恢复提示,也不要假装工具已经执行。",
17
+ ].join("\n");
@@ -55,6 +55,18 @@ export function createCccAdapter(options = {}) {
55
55
  }],
56
56
  };
57
57
  }
58
+ else if (event.type === "progress") {
59
+ yield {
60
+ type: "assistant",
61
+ blocks: [{ type: "agent_progress", phase: event.phase }],
62
+ };
63
+ }
64
+ else if (event.type === "text_reset") {
65
+ yield {
66
+ type: "assistant",
67
+ blocks: [{ type: "text_reset" }],
68
+ };
69
+ }
58
70
  else if (event.type === "text") {
59
71
  yield {
60
72
  type: "assistant",
@@ -67,6 +67,10 @@ export function updateAgentActivity(tracker, block, now = Date.now()) {
67
67
  if (tracker.activeTools.size > 0)
68
68
  return false;
69
69
  switch (block.type) {
70
+ case "agent_progress":
71
+ return setActivity(tracker, { kind: "thinking", startedAt: now });
72
+ case "text_reset":
73
+ return setActivity(tracker, { kind: "responding", startedAt: now });
70
74
  case "agent_status":
71
75
  return setActivity(tracker, {
72
76
  kind: block.status === "compacting" ? "compacting" : "responding",
@@ -57,6 +57,10 @@ export function reduceProgress(prev, event) {
57
57
  case "text":
58
58
  // accumulated 是全文累积,直接全量替换,天然幂等
59
59
  return withProgressView(prev, { text: event.accumulated });
60
+ case "progress":
61
+ return withProgressView(prev, { headerTitle: "思考中..." });
62
+ case "text_reset":
63
+ return withProgressView(prev, { text: "", tools: [] });
60
64
  case "tool_use": {
61
65
  const tool = {
62
66
  id: event.id ?? `tool-${prev.tools.length + 1}`,
@@ -2,10 +2,10 @@
2
2
  * Tracks how long the displayed output character count has remained unchanged.
3
3
  * Leaving a monitored phase clears the window; returning starts a fresh one.
4
4
  */
5
- export function observeResponseProgress(previous, isMonitoredPhase, totalChars, now = Date.now()) {
5
+ export function observeResponseProgress(previous, isMonitoredPhase, totalChars, now = Date.now(), heartbeat = false) {
6
6
  if (!isMonitoredPhase)
7
7
  return undefined;
8
- if (previous?.totalChars === totalChars)
8
+ if (!heartbeat && previous?.totalChars === totalChars)
9
9
  return previous;
10
10
  return { totalChars, unchangedSince: now };
11
11
  }
@@ -233,7 +233,7 @@ function formatAutoEndedReply(finalReply) {
233
233
  * 状态或资源保护,不能把它们消耗的时间算入回复停滞窗口。
234
234
  */
235
235
  function monitorsOutputProgress(kind) {
236
- return kind === "responding";
236
+ return kind === "responding" || kind === "thinking";
237
237
  }
238
238
  function formatTerminalReply(status, finalReply, terminalError) {
239
239
  if (status === "auto_ended")
@@ -528,9 +528,9 @@ export function getAdapterForTool(tool, sessionId) {
528
528
  apiKey: config.ccc.DEEPSEEK_API_KEY,
529
529
  baseURL: config.ccc.DEEPSEEK_BASE_URL,
530
530
  model: effectiveModel || undefined,
531
- effort: effectiveEffort || undefined,
532
531
  compactionTimeoutMs: config.ccc.compactionTimeoutMs,
533
532
  contextWindow: config.ccc.contextWindow,
533
+ ...(effectiveEffort ? { effort: effectiveEffort } : {}),
534
534
  // 留空("")不传 → ChatSession 跟随 DeepCCC 内核配置(~/.deepccc/config.json 或 DEEPCCC_PROVIDER)
535
535
  ...(config.ccc.provider ? { provider: config.ccc.provider } : {}),
536
536
  ...(config.ccc.subModel ? { subModel: config.ccc.subModel } : {}),
@@ -761,6 +761,14 @@ export function accumulateBlockContent(block, state, toolCallMap) {
761
761
  // 覆盖而非追加:适配器已保证这是一段完整最终文本(如 Cursor 流末快照)
762
762
  state.finalCompleteText = block.text;
763
763
  break;
764
+ case "text_reset":
765
+ state.accumulatedContent = "";
766
+ state.finalText = "";
767
+ state.finalCompleteText = "";
768
+ state.chunkCount = 0;
769
+ break;
770
+ case "agent_progress":
771
+ break;
764
772
  case "compact_boundary": {
765
773
  const triggerLabel = block.trigger === "manual" ? "手动" : "自动"; // 手动 / 自动
766
774
  state.accumulatedContent +=
@@ -1238,7 +1246,15 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1238
1246
  }
1239
1247
  }
1240
1248
  let activityChanged = false;
1249
+ let progressHeartbeat = false;
1250
+ let outputReset = false;
1241
1251
  for (const block of unifiedMsg.blocks) {
1252
+ if (block.type === "agent_progress")
1253
+ progressHeartbeat = true;
1254
+ if (block.type === "text_reset") {
1255
+ outputReset = true;
1256
+ toolCallMap.clear();
1257
+ }
1242
1258
  if (updateAgentActivity(activityTracker, block))
1243
1259
  activityChanged = true;
1244
1260
  accumulateBlockContent(block, state, toolCallMap);
@@ -1263,11 +1279,11 @@ export async function runAgentSession(sessionId, userText, platform, _chatId, ms
1263
1279
  prompt.responseProgress = observeResponseProgress(
1264
1280
  // starting → responding 本身是一次有效进展,即便首个文本块仍为空也应
1265
1281
  // 重新计时;其它活动阶段会清空观察窗口。
1266
- activityChanged ? undefined : prompt.responseProgress, monitorsOutputProgress(activityTracker.activity.kind), totalChars, Date.now());
1282
+ activityChanged ? undefined : prompt.responseProgress, monitorsOutputProgress(activityTracker.activity.kind), totalChars, Date.now(), progressHeartbeat);
1267
1283
  }
1268
1284
  // 定时写入文件
1269
1285
  const now2 = Date.now();
1270
- if (activityChanged || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1286
+ if (activityChanged || outputReset || now2 - lastFileWrite >= FILE_WRITE_INTERVAL_MS) {
1271
1287
  lastFileWrite = now2;
1272
1288
  await writeStreamState({
1273
1289
  sessionId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "chatccc",
3
- "version": "0.2.258",
3
+ "version": "0.2.259",
4
4
  "description": "Feishu bot bridge for Claude Code",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",