@foxden-app/foxclaw 0.13.7 → 0.13.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,20 @@
2
2
 
3
3
  All notable FoxClaw changes are listed here. Each release note is bilingual so GitHub Releases and the npm package are useful to both Chinese and English readers.
4
4
 
5
+ ## 0.13.8 - 2026-10-06
6
+
7
+ ### 中文
8
+
9
+ - 任务结束后将所有过程小结合并为一条默认收起的 Telegram 归档消息:收起显示总耗时与 Token 总/入/出/缓存,展开按时间段展示全部小结;最终结论独立保留,移除其他小结和工作状态卡。
10
+ - Codex 按本轮模型请求累计原生 Token 用量,忽略重复及其他会话/轮次通知;未提供的计数显示“—”。记录任务与小结时间,交付重试不增加耗时。
11
+ - 超过富消息文字或内容块上限时,在同一归档内显示几条预览及“查看全部小结”HTML 文件,保留完整内容,不额外发布消息或公开网页。合并成功后才删除原消息,持久化清理进度;失败可通过重启或 `/recover` 继续,避免丢失小结或重发最终结论。
12
+
13
+ ### English
14
+
15
+ - Consolidate all progress summaries into one closed Telegram archive at completion. Its header shows duration and total/input/output/cached tokens; expand to see timed summaries. Keep the final answer separate and remove other summaries and the working card.
16
+ - Accumulate native Codex token usage for this turn's model requests, excluding duplicate and foreign notifications. Show unavailable counters as “—”. Persist task and summary timing so delivery retries do not inflate duration.
17
+ - When rich message text or block limits are exceeded, embed previews and a complete HTML archive in the same message. No extra messages or public hosting. Delete originals only after successful consolidation and persist cleanup checkpoints; resume failures after restart or through `/recover` without losing summaries or resending the final answer.
18
+
5
19
  ## 0.13.7 - 2026-10-06
6
20
 
7
21
  ### 中文
@@ -3,6 +3,7 @@ import type { ChannelTextEvent } from '../../core/channel_events.js';
3
3
  import type { ChannelPort } from '../../core/channel_port.js';
4
4
  import type { TelegramGateway } from '../../telegram/gateway.js';
5
5
  import type { TelegramRemoteFile } from '../../telegram/api.js';
6
+ import type { TaskCommentaryArchive } from '../../core/commentary_archive.js';
6
7
  export type InlineKeyboard = Array<Array<{
7
8
  text: string;
8
9
  callback_data: string;
@@ -18,6 +19,7 @@ export declare class TelegramMessagingPort implements ChannelPort {
18
19
  updateTaskPreview(scopeId: string, taskId: string, messageId: number, html: string): Promise<number>;
19
20
  endTaskPreview(scopeId: string, taskId: string): Promise<void>;
20
21
  sendTaskCommentary(scopeId: string, text: string): Promise<number>;
22
+ archiveTaskCommentary(scopeId: string, messageId: number, archive: TaskCommentaryArchive): Promise<void>;
21
23
  foldTaskCommentary(scopeId: string, messageId: number, text: string): Promise<void>;
22
24
  readonly capabilities: {
23
25
  editableMessages: boolean;
@@ -6,6 +6,7 @@ import { parseTelegramTargetFromBridgeScope } from '../../core/bridge_scope.js';
6
6
  import { renderTelegramMarkdownRichHtml } from '../../telegram/rich_markdown.js';
7
7
  import { escapeTelegramHtml } from '../../telegram/html.js';
8
8
  import { telegramRichHtml, telegramRichMarkdown } from '../../telegram/rich.js';
9
+ import { buildTelegramCommentaryArchive } from '../../telegram/commentary_archive.js';
9
10
  /**
10
11
  * Telegram outbound operations addressed by bridge scope id (`telegram:…`).
11
12
  */
@@ -22,6 +23,16 @@ export class TelegramMessagingPort {
22
23
  async sendTaskCommentary(scopeId, text) {
23
24
  return this.sendRichMarkdown(scopeId, text);
24
25
  }
26
+ async archiveTaskCommentary(scopeId, messageId, archive) {
27
+ const target = parseTelegramTargetFromBridgeScope(scopeId);
28
+ const rendered = buildTelegramCommentaryArchive(archive);
29
+ if (rendered.document) {
30
+ await this.gateway.editRichMessageWithDocument(target.chatId, messageId, rendered.html, rendered.document);
31
+ }
32
+ else {
33
+ await this.gateway.editRichMessage(target.chatId, messageId, telegramRichHtml(rendered.html, { skipEntityDetection: true }), []);
34
+ }
35
+ }
25
36
  async foldTaskCommentary(scopeId, messageId, text) {
26
37
  const target = parseTelegramTargetFromBridgeScope(scopeId);
27
38
  try {
@@ -41,6 +41,8 @@ export class CodexEngineAdapter {
41
41
  let turnId = null;
42
42
  let cancelled = false;
43
43
  const earlyNotifications = [];
44
+ let usage;
45
+ let lastUsageTotal;
44
46
  let promptText = request.prompt;
45
47
  if (request.stagedAttachments && request.stagedAttachments.length > 0 && !request.prompt.includes('Telegram attachments:')) {
46
48
  promptText = buildAttachmentPrompt(request.prompt, request.stagedAttachments);
@@ -68,6 +70,27 @@ export class CodexEngineAdapter {
68
70
  return;
69
71
  }
70
72
  try {
73
+ if (msg.method === 'thread/tokenUsage/updated') {
74
+ if (msg.params?.turnId && msg.params.turnId !== turnId)
75
+ return;
76
+ const native = msg.params?.tokenUsage;
77
+ const current = native?.last;
78
+ if (!current)
79
+ return;
80
+ // `last` is one model request; `total` is cumulative for the thread.
81
+ // Repeated notifications with the same cumulative counters are not new usage.
82
+ const signature = native.total ? JSON.stringify(native.total) : undefined;
83
+ if (signature && signature === lastUsageTotal)
84
+ return;
85
+ lastUsageTotal = signature;
86
+ usage ??= {};
87
+ for (const [key, nativeKey] of [['inputTokens', 'inputTokens'], ['outputTokens', 'outputTokens'], ['cachedTokens', 'cachedInputTokens'], ['totalTokens', 'totalTokens']]) {
88
+ const value = current[nativeKey];
89
+ if (typeof value === 'number' && Number.isFinite(value) && value >= 0)
90
+ usage[key] = (usage[key] ?? 0) + value;
91
+ }
92
+ return;
93
+ }
71
94
  const ev = normalizeTurnActivityEvent(msg);
72
95
  if (!ev)
73
96
  return;
@@ -123,6 +146,7 @@ export class CodexEngineAdapter {
123
146
  ...(failure ? { error: String(failure) } : {}),
124
147
  response: accumulatedResponse,
125
148
  conversationId: threadId,
149
+ usage,
126
150
  };
127
151
  emitter.emit('result', finalResult);
128
152
  resolveWaiters(finalResult);
@@ -142,7 +166,7 @@ export class CodexEngineAdapter {
142
166
  return;
143
167
  cleanup();
144
168
  finalResult = { kind: 'result', status: 'ERROR', response: responseText(),
145
- error: 'Codex connection closed before the task outcome was confirmed.', outcomeUnknown: true, conversationId: threadId };
169
+ error: 'Codex connection closed before the task outcome was confirmed.', outcomeUnknown: true, conversationId: threadId, usage };
146
170
  emitter.emit('result', finalResult);
147
171
  resolveWaiters(finalResult);
148
172
  };
@@ -271,7 +295,7 @@ export class CodexEngineAdapter {
271
295
  if (threadId && turnId)
272
296
  await this.client.interruptTurn(threadId, turnId);
273
297
  cleanup();
274
- finalResult = { kind: 'result', status: 'INTERRUPTED', response: accumulatedResponse, conversationId: threadId };
298
+ finalResult = { kind: 'result', status: 'INTERRUPTED', response: accumulatedResponse, conversationId: threadId, usage };
275
299
  emitter.emit('result', finalResult);
276
300
  resolveWaiters(finalResult);
277
301
  })();
@@ -3,6 +3,7 @@ import type { AppLocale } from '../types.js';
3
3
  import type { ChannelTextEvent, ChannelCallbackEvent, ChannelInbound } from './channel_events.js';
4
4
  import type { ParsedCommand } from '../controller/commands.js';
5
5
  import type { AttachmentDownloader } from './attachments.js';
6
+ import type { TaskCommentaryArchive } from './commentary_archive.js';
6
7
  /** Bridge-local handles; a transport maps these to its native message identifiers. */
7
8
  export type ChannelMessageRef = number;
8
9
  export type ChannelInlineKeyboard = Array<Array<{
@@ -34,6 +35,7 @@ export interface ChannelPort extends AttachmentDownloader {
34
35
  updateTaskPreview?(scopeId: string, taskId: string, messageId: number, html: string): Promise<number>;
35
36
  sendTaskCommentary?(scopeId: string, text: string): Promise<number>;
36
37
  foldTaskCommentary?(scopeId: string, messageId: number, text: string): Promise<void>;
38
+ archiveTaskCommentary?(scopeId: string, messageId: number, archive: TaskCommentaryArchive): Promise<void>;
37
39
  endTaskPreview?(scopeId: string, taskId: string): Promise<void>;
38
40
  resolveAction?(event: ChannelTextEvent): ChannelCallbackEvent | null;
39
41
  editPlain(scopeId: string, messageId: ChannelMessageRef, text: string, keyboard?: ChannelInlineKeyboard): Promise<void>;
@@ -0,0 +1,13 @@
1
+ import type { AppLocale } from '../types.js';
2
+ import type { EngineTurnResult } from './engine_spi.js';
3
+ export interface TaskCommentaryArchive {
4
+ startedAt: number;
5
+ endedAt: number;
6
+ locale: AppLocale;
7
+ usage?: EngineTurnResult['usage'];
8
+ entries: Array<{
9
+ text: string;
10
+ startedAt?: number | undefined;
11
+ endedAt?: number | undefined;
12
+ }>;
13
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -30,6 +30,7 @@ export interface UnifiedActiveTurn {
30
30
  settling?: boolean;
31
31
  separateFinal?: boolean;
32
32
  commentaryIds?: Set<string>;
33
+ lastCommentaryTime?: number;
33
34
  }
34
35
  export declare class UnifiedChannelOrchestrator {
35
36
  readonly config: AppConfig;
@@ -539,7 +539,7 @@ export class UnifiedChannelOrchestrator {
539
539
  const task = this.store.taskJournal.get(turn.taskId);
540
540
  const folded = buildFoldedToolsSummary({ toolLines: turn.toolLines, toolCount: turn.toolCount, stepIndex: turn.stepIndex, startTime: turn.startTime, locale: task.request.locale });
541
541
  const response = `${task.request.locale === 'zh' ? '🛑 已停止任务;排队任务保留。' : '🛑 Task stopped; queued tasks kept.'}${turn.accumulatedText.trim() ? `\n\n${turn.accumulatedText.trim()}` : ''}`;
542
- this.store.taskJournal.update(turn.taskId, 'delivery_pending', { result: { kind: 'result', status: 'INTERRUPTED', response, conversationId: turn.threadId }, delivery: combineSummaryAndResponse(folded, response), deliveredChunks: 0, previewMessageId: turn.messageId, separateFinal: turn.separateFinal === true });
542
+ this.store.taskJournal.update(turn.taskId, 'delivery_pending', { result: { kind: 'result', status: 'INTERRUPTED', response, conversationId: turn.threadId }, taskEndedAt: Date.now(), delivery: turn.separateFinal ? chunkMessage(response) : combineSummaryAndResponse(folded, response), deliveredChunks: 0, previewMessageId: turn.messageId, separateFinal: turn.separateFinal === true });
543
543
  try {
544
544
  await this.deliverTaskChunks(turn.taskId);
545
545
  this.resolveDeliveredTask(turn.taskId);
@@ -635,27 +635,60 @@ export class UnifiedChannelOrchestrator {
635
635
  task = this.store.taskJournal.update(task.id, 'delivery_pending', { deliveredChunks: index + 1 });
636
636
  }
637
637
  if (task.separateFinal) {
638
- for (let index = 0; index < (task.commentary?.length ?? 0); index++) {
639
- const item = task.commentary[index];
640
- if (item.folded || this.messaging.capabilities?.editableMessages === false)
641
- continue;
642
- try {
643
- if (this.messaging.foldTaskCommentary)
644
- await this.messaging.foldTaskCommentary(task.event.scopeId, item.messageId, item.text);
645
- else
646
- await this.editMessage(task.event.scopeId, item.messageId, `<blockquote expandable>${escapeHtml(item.text)}</blockquote>`, []);
638
+ if (task.commentary?.length && this.messaging.archiveTaskCommentary && this.messaging.capabilities?.editableMessages !== false) {
639
+ const messageId = task.commentaryArchiveMessageId ?? task.commentary[0].messageId;
640
+ if (!task.commentaryArchiveReady) {
641
+ const entries = [];
642
+ let previousSource;
643
+ for (const item of task.commentary) {
644
+ if (item.sourceMessageId && item.sourceMessageId === previousSource)
645
+ entries.at(-1).text += `\n${item.text}`;
646
+ else
647
+ entries.push({ text: item.text, startedAt: item.startedAt, endedAt: item.endedAt });
648
+ previousSource = item.sourceMessageId;
649
+ }
650
+ await this.messaging.archiveTaskCommentary(task.event.scopeId, messageId, {
651
+ startedAt: task.taskStartedAt ?? task.createdAt, endedAt: task.taskEndedAt ?? task.updatedAt,
652
+ locale: task.request.locale, usage: task.result?.usage, entries,
653
+ });
654
+ task = this.store.taskJournal.update(task.id, 'delivery_pending', { commentaryArchiveMessageId: messageId, commentaryArchiveReady: true, commentary: task.commentary.map(item => ({ ...item, folded: false })) });
655
+ }
656
+ for (let index = 0; index < task.commentary.length; index++) {
657
+ const item = task.commentary[index];
658
+ if (item.folded)
659
+ continue;
660
+ if (item.messageId !== messageId)
661
+ await this.messaging.deleteMessage(task.event.scopeId, item.messageId);
647
662
  const commentary = [...task.commentary];
648
663
  commentary[index] = { ...item, folded: true };
649
664
  task = this.store.taskJournal.update(task.id, 'delivery_pending', { commentary });
650
665
  }
651
- catch (error) {
652
- this.logger.warn('orchestrator.commentary_fold_failed', { taskId, messageId: item.messageId, error: String(error) });
666
+ }
667
+ else {
668
+ for (let index = 0; index < (task.commentary?.length ?? 0); index++) {
669
+ const item = task.commentary[index];
670
+ if (item.folded || this.messaging.capabilities?.editableMessages === false)
671
+ continue;
672
+ try {
673
+ if (this.messaging.foldTaskCommentary)
674
+ await this.messaging.foldTaskCommentary(task.event.scopeId, item.messageId, item.text);
675
+ else
676
+ await this.editMessage(task.event.scopeId, item.messageId, `<blockquote expandable>${escapeHtml(item.text)}</blockquote>`, []);
677
+ const commentary = [...task.commentary];
678
+ commentary[index] = { ...item, folded: true };
679
+ task = this.store.taskJournal.update(task.id, 'delivery_pending', { commentary });
680
+ }
681
+ catch (error) {
682
+ this.logger.warn('orchestrator.commentary_fold_failed', { taskId, messageId: item.messageId, error: String(error) });
683
+ }
653
684
  }
654
685
  }
655
686
  if (!task.previewSettled) {
656
687
  if (task.previewMessageId > 0 && this.messaging.capabilities?.editableMessages !== false) {
657
- if (task.finalPreviewText)
688
+ if (task.finalPreviewText && !task.commentaryArchiveReady)
658
689
  await this.editMessage(task.event.scopeId, task.previewMessageId, task.finalPreviewText, []);
690
+ else if (task.commentaryArchiveReady)
691
+ await this.messaging.deleteMessage(task.event.scopeId, task.previewMessageId);
659
692
  else
660
693
  await this.messaging.deleteMessage(task.event.scopeId, task.previewMessageId).catch(() => { });
661
694
  }
@@ -1151,7 +1184,8 @@ export class UnifiedChannelOrchestrator {
1151
1184
  throw new Error('Orchestrator is stopped');
1152
1185
  }
1153
1186
  let execution;
1154
- task = this.store.taskJournal.update(task.id, 'running', { result: null, delivery: [], deliveredChunks: 0, previewSettled: false, separateFinal: adapter.supportsCommentary === true });
1187
+ const taskStartedAt = task.taskStartedAt ?? Date.now();
1188
+ task = this.store.taskJournal.update(task.id, 'running', { result: null, delivery: [], deliveredChunks: 0, previewSettled: false, separateFinal: adapter.supportsCommentary === true, taskStartedAt });
1155
1189
  try {
1156
1190
  execution = adapter.executeTurn(req);
1157
1191
  }
@@ -1174,12 +1208,13 @@ export class UnifiedChannelOrchestrator {
1174
1208
  stepIndex: 1,
1175
1209
  toolCount: 0,
1176
1210
  currentTool: null,
1177
- startTime: Date.now(),
1211
+ startTime: taskStartedAt,
1178
1212
  previewKey: turnKey,
1179
1213
  queueId: options?.queueId,
1180
1214
  taskId: task.id,
1181
1215
  separateFinal: adapter.supportsCommentary === true,
1182
1216
  commentaryIds: new Set(),
1217
+ lastCommentaryTime: task.commentary?.at(-1)?.endedAt ?? taskStartedAt,
1183
1218
  };
1184
1219
  this.activeTurns.set(scopeId, activeTurn);
1185
1220
  this.messaging.sendTypingInScope(scopeId).catch(() => { });
@@ -1242,6 +1277,9 @@ export class UnifiedChannelOrchestrator {
1242
1277
  activeTurn.commentaryIds?.add(message.messageId);
1243
1278
  activeTurn.separateFinal = true;
1244
1279
  activeTurn.accumulatedText = '';
1280
+ const endedAt = Date.now();
1281
+ const startedAt = activeTurn.lastCommentaryTime ?? activeTurn.startTime;
1282
+ activeTurn.lastCommentaryTime = endedAt;
1245
1283
  void this.messageOperations.run(`${scopeId}:preview:${activeTurn.taskId}`, async () => {
1246
1284
  if (!ownsTurn())
1247
1285
  return;
@@ -1252,7 +1290,7 @@ export class UnifiedChannelOrchestrator {
1252
1290
  : await this.messaging.sendRichMarkdown(scopeId, text);
1253
1291
  const current = this.store.taskJournal.get(activeTurn.taskId);
1254
1292
  this.store.taskJournal.update(current.id, current.state, {
1255
- commentary: [...(current.commentary ?? []), { messageId, text, folded: false }], separateFinal: true,
1293
+ commentary: [...(current.commentary ?? []), { messageId, text, folded: false, sourceMessageId: message.messageId, startedAt, endedAt }], separateFinal: true,
1256
1294
  });
1257
1295
  }
1258
1296
  }).catch(error => this.logger.warn('orchestrator.commentary_send_failed', { taskId: activeTurn.taskId, error: String(error) }));
@@ -1325,7 +1363,9 @@ export class UnifiedChannelOrchestrator {
1325
1363
  const task = this.store.taskJournal.get(activeTurn.taskId);
1326
1364
  if (task.state === 'awaiting_confirmation')
1327
1365
  return;
1328
- if (task.state === 'delivery_pending' && task.deliveredChunks < task.delivery.length) {
1366
+ if (task.state === 'delivery_pending' && (task.deliveredChunks < task.delivery.length
1367
+ || (task.separateFinal && this.messaging.archiveTaskCommentary && this.messaging.capabilities?.editableMessages !== false && task.commentary?.length && (!task.commentaryArchiveReady || task.commentary.some(item => !item.folded)))
1368
+ || (task.separateFinal && !task.previewSettled))) {
1329
1369
  this.logger.warn('orchestrator.delivery_pending', { taskId: task.id, scopeId });
1330
1370
  return;
1331
1371
  }
@@ -1340,7 +1380,7 @@ export class UnifiedChannelOrchestrator {
1340
1380
  if (!ownsTurn() || activeTurn.settling)
1341
1381
  return;
1342
1382
  activeTurn.settling = true;
1343
- this.store.taskJournal.update(activeTurn.taskId, 'running', { result: res, request: { ...req, threadId: res.conversationId || activeTurn.threadId } });
1383
+ this.store.taskJournal.update(activeTurn.taskId, 'running', { result: res, taskEndedAt: Date.now(), request: { ...req, threadId: res.conversationId || activeTurn.threadId } });
1344
1384
  try {
1345
1385
  if (activeTurn.flushTimer)
1346
1386
  clearTimeout(activeTurn.flushTimer);
@@ -1471,7 +1511,7 @@ export class UnifiedChannelOrchestrator {
1471
1511
  if (!ownsTurn() || activeTurn.settling)
1472
1512
  return;
1473
1513
  activeTurn.settling = true;
1474
- this.store.taskJournal.update(activeTurn.taskId, 'running', { result: { kind: 'result', status: 'ERROR', response: activeTurn.accumulatedText, error: err.message, conversationId: activeTurn.threadId } });
1514
+ this.store.taskJournal.update(activeTurn.taskId, 'running', { taskEndedAt: Date.now(), result: { kind: 'result', status: 'ERROR', response: activeTurn.accumulatedText, error: err.message, conversationId: activeTurn.threadId } });
1475
1515
  try {
1476
1516
  if (activeTurn.flushTimer)
1477
1517
  clearTimeout(activeTurn.flushTimer);
@@ -16,7 +16,14 @@ export interface JournalTask {
16
16
  messageId: number;
17
17
  text: string;
18
18
  folded: boolean;
19
+ sourceMessageId?: string;
20
+ startedAt?: number;
21
+ endedAt?: number;
19
22
  }>;
23
+ taskStartedAt?: number;
24
+ taskEndedAt?: number;
25
+ commentaryArchiveMessageId?: number;
26
+ commentaryArchiveReady?: boolean;
20
27
  separateFinal?: boolean;
21
28
  finalPreviewText?: string;
22
29
  previewSettled?: boolean;
@@ -0,0 +1,11 @@
1
+ import type { TaskCommentaryArchive } from '../core/commentary_archive.js';
2
+ export interface TelegramCommentaryArchive {
3
+ html: string;
4
+ document?: {
5
+ filename: string;
6
+ contents: Buffer;
7
+ contentType: string;
8
+ };
9
+ }
10
+ export declare function commentaryArchiveHeader(archive: TaskCommentaryArchive): string;
11
+ export declare function buildTelegramCommentaryArchive(archive: TaskCommentaryArchive): TelegramCommentaryArchive;
@@ -0,0 +1,36 @@
1
+ import { escapeTelegramHtml } from './html.js';
2
+ import { renderTelegramMarkdownRichHtml } from './rich_markdown.js';
3
+ export function commentaryArchiveHeader(archive) {
4
+ const zh = archive.locale === 'zh';
5
+ const seconds = Math.max(0, Math.round((archive.endedAt - archive.startedAt) / 1000));
6
+ const duration = seconds < 60 ? `${seconds}s` : `${Math.floor(seconds / 60)}m${seconds % 60}s`;
7
+ const count = (n) => typeof n === 'number' && Number.isFinite(n) ? Math.round(Math.max(0, n)).toLocaleString('en-US') : '—';
8
+ const usage = archive.usage;
9
+ const total = usage?.totalTokens ?? (usage?.inputTokens !== undefined && usage.outputTokens !== undefined ? usage.inputTokens + usage.outputTokens : undefined);
10
+ return `${zh ? '总时间' : 'Total time'}: ${duration} ${zh ? 'Token(总/入/出/缓存)' : 'Tokens (total/in/out/cache)'}: ${[total, usage?.inputTokens, usage?.outputTokens, usage?.cachedTokens].map(count).join('/')}`;
11
+ }
12
+ function interval(entry) {
13
+ const clock = (time) => time === undefined ? '—' : new Date(time).toLocaleTimeString('en-GB', { hour12: false });
14
+ return `${clock(entry.startedAt)}–${clock(entry.endedAt)}`;
15
+ }
16
+ function entryHtml(entry) {
17
+ return `<p><b>${escapeTelegramHtml(interval(entry))}</b></p>${renderTelegramMarkdownRichHtml(entry.text)}`;
18
+ }
19
+ export function buildTelegramCommentaryArchive(archive) {
20
+ const header = commentaryArchiveHeader(archive);
21
+ const body = archive.entries.map(entryHtml).join('\n');
22
+ const wrap = (content) => `<details><summary>${escapeTelegramHtml(header)}</summary>${content}</details>`;
23
+ const html = wrap(body);
24
+ // Conservative budgets also cover expanded entities and nested list blocks.
25
+ const blocks = (html.match(/<(?:p|h[1-6]|pre|ul|ol|li|blockquote|details)\b/g) ?? []).length;
26
+ if (html.length <= 30000 && blocks <= 450)
27
+ return { html };
28
+ const zh = archive.locale === 'zh';
29
+ const preview = archive.entries.slice(0, 3).map(entry => entryHtml({ ...entry, text: entry.text.length > 600 ? `${entry.text.slice(0, 600)}…` : entry.text })).join('\n');
30
+ const label = zh ? '查看全部小结' : 'View all summaries';
31
+ const fullDocument = `<!doctype html><html lang="${zh ? 'zh' : 'en'}"><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta http-equiv="Content-Security-Policy" content="default-src 'none'; style-src 'unsafe-inline'"><title>${label}</title><style>body{font:16px/1.7 system-ui,sans-serif;max-width:900px;margin:24px auto;padding:0 20px;overflow-wrap:anywhere}pre{white-space:pre-wrap;background:#f3f4f6;padding:12px}code{font-family:monospace}</style><body><h1>${escapeTelegramHtml(header)}</h1>${body}</body></html>`;
32
+ return {
33
+ html: wrap(`${preview}<p>${zh ? `共 ${archive.entries.length} 条小结,完整内容见下方文件。` : `${archive.entries.length} summaries; complete content in the file below.`}</p><figure><tg-document src="tg://document?id=commentary_archive"></tg-document><figcaption>${label}</figcaption></figure>`),
34
+ document: { filename: zh ? '查看全部小结.html' : 'all-summaries.html', contents: Buffer.from(fullDocument), contentType: 'text/html' },
35
+ };
36
+ }
@@ -75,6 +75,11 @@ export declare class TelegramGateway extends EventEmitter {
75
75
  text: string;
76
76
  callback_data: string;
77
77
  }>>): Promise<void>;
78
+ editRichMessageWithDocument(chatId: string, messageId: number, html: string, document: {
79
+ filename: string;
80
+ contents: Buffer;
81
+ contentType: string;
82
+ }): Promise<void>;
78
83
  clearMessageInlineKeyboard(chatId: string, messageId: number): Promise<void>;
79
84
  private sendMessageWithOptions;
80
85
  private editMessageWithOptions;
@@ -180,6 +180,17 @@ export class TelegramGateway extends EventEmitter {
180
180
  throw new Error(result.description || 'Failed to edit Telegram rich message');
181
181
  }
182
182
  }
183
+ async editRichMessageWithDocument(chatId, messageId, html, document) {
184
+ const result = await callTelegramMultipartApi(this.botToken, 'editMessageText', {
185
+ chat_id: chatId, message_id: String(messageId),
186
+ rich_message: JSON.stringify({ html, skip_entity_detection: true,
187
+ media: [{ id: 'commentary_archive', media: { type: 'document', media: 'attach://commentary_archive' } }] }),
188
+ reply_markup: JSON.stringify({ inline_keyboard: [] }),
189
+ }, [{ fieldName: 'commentary_archive', ...document }]);
190
+ if (!result.ok && !String(result.description || '').includes('message is not modified')) {
191
+ throw new Error(result.description || 'Failed to attach commentary archive');
192
+ }
193
+ }
183
194
  async clearMessageInlineKeyboard(chatId, messageId) {
184
195
  const result = await callTelegramApi(this.botToken, 'editMessageReplyMarkup', {
185
196
  chat_id: chatId,
@@ -222,7 +233,7 @@ export class TelegramGateway extends EventEmitter {
222
233
  chat_id: chatId,
223
234
  message_id: messageId,
224
235
  });
225
- if (!result.ok) {
236
+ if (!result.ok && !String(result.description || '').includes('message to delete not found')) {
226
237
  throw new Error(result.description || 'Failed to delete Telegram message');
227
238
  }
228
239
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@foxden-app/foxclaw",
3
- "version": "0.13.7",
3
+ "version": "0.13.8",
4
4
  "description": "Foxden local execution claw for controlling Codex, OpenCode, Antigravity, and DeepSeek Harness from trusted chat interfaces.",
5
5
  "type": "module",
6
6
  "main": "dist/main.js",