@foxden-app/foxclaw 0.11.1 → 0.13.0

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 (138) hide show
  1. package/.env.example +18 -1
  2. package/CHANGELOG.md +36 -0
  3. package/README.md +4 -3
  4. package/README_EN.md +4 -3
  5. package/dist/antigravity/account_ui.d.ts +34 -0
  6. package/dist/antigravity/account_ui.js +354 -0
  7. package/dist/antigravity/adapter.js +1 -1
  8. package/dist/antigravity/auth.d.ts +4 -1
  9. package/dist/antigravity/auth.js +17 -5
  10. package/dist/antigravity/controller.d.ts +3 -106
  11. package/dist/antigravity/controller.js +2 -2083
  12. package/dist/antigravity/conversation_ui.d.ts +56 -0
  13. package/dist/antigravity/conversation_ui.js +509 -0
  14. package/dist/antigravity/ui_callbacks.d.ts +6 -0
  15. package/dist/antigravity/ui_callbacks.js +6 -0
  16. package/dist/bridge/unified_bridge.d.ts +114 -0
  17. package/dist/bridge/unified_bridge.js +1343 -0
  18. package/dist/channels/bridge_messaging_router.d.ts +8 -4
  19. package/dist/channels/bridge_messaging_router.js +15 -4
  20. package/dist/channels/telegram/telegram_messaging_port.d.ts +16 -0
  21. package/dist/channels/telegram/telegram_messaging_port.js +22 -2
  22. package/dist/channels/text_actions.d.ts +14 -0
  23. package/dist/channels/text_actions.js +48 -0
  24. package/dist/channels/weixin/weixin_channel_adapter.d.ts +3 -4
  25. package/dist/channels/weixin/weixin_channel_adapter.js +2 -2
  26. package/dist/channels/weixin/weixin_messaging_port.d.ts +16 -1
  27. package/dist/channels/weixin/weixin_messaging_port.js +34 -7
  28. package/dist/codex_app/activity.d.ts +62 -0
  29. package/dist/codex_app/activity.js +418 -0
  30. package/dist/codex_app/adapter.d.ts +2 -2
  31. package/dist/codex_app/adapter.js +55 -25
  32. package/dist/codex_app/backend_ui.d.ts +4 -0
  33. package/dist/codex_app/backend_ui.js +48 -0
  34. package/dist/codex_app/client.js +0 -12
  35. package/dist/config.d.ts +4 -0
  36. package/dist/config.js +41 -5
  37. package/dist/controller/access.d.ts +1 -11
  38. package/dist/controller/access.js +1 -33
  39. package/dist/controller/activity.d.ts +1 -62
  40. package/dist/controller/activity.js +2 -418
  41. package/dist/controller/auth_errors.d.ts +11 -0
  42. package/dist/controller/auth_errors.js +124 -0
  43. package/dist/controller/auth_files.d.ts +13 -0
  44. package/dist/controller/auth_files.js +156 -0
  45. package/dist/controller/auth_presentation.d.ts +77 -0
  46. package/dist/controller/auth_presentation.js +697 -0
  47. package/dist/controller/auth_quota_service.d.ts +27 -0
  48. package/dist/controller/auth_quota_service.js +151 -0
  49. package/dist/controller/controller.d.ts +30 -56
  50. package/dist/controller/controller.js +181 -3497
  51. package/dist/controller/interactions.d.ts +74 -0
  52. package/dist/controller/interactions.js +560 -0
  53. package/dist/controller/local_usage_service.d.ts +19 -0
  54. package/dist/controller/local_usage_service.js +62 -0
  55. package/dist/controller/native_panel_service.d.ts +55 -0
  56. package/dist/controller/native_panel_service.js +214 -0
  57. package/dist/controller/native_panels.d.ts +61 -0
  58. package/dist/controller/native_panels.js +784 -0
  59. package/dist/controller/shared_helpers.d.ts +53 -0
  60. package/dist/controller/shared_helpers.js +399 -0
  61. package/dist/controller/state_types.d.ts +390 -0
  62. package/dist/controller/state_types.js +82 -0
  63. package/dist/controller/turn_rendering.d.ts +22 -0
  64. package/dist/controller/turn_rendering.js +288 -0
  65. package/dist/core/access.d.ts +11 -0
  66. package/dist/core/access.js +33 -0
  67. package/dist/core/attachment_files.d.ts +13 -0
  68. package/dist/core/attachment_files.js +180 -0
  69. package/dist/core/attachment_types.d.ts +22 -0
  70. package/dist/core/attachment_types.js +1 -0
  71. package/dist/core/attachments.d.ts +5 -3
  72. package/dist/core/attachments.js +2 -2
  73. package/dist/core/backend_registry.d.ts +22 -0
  74. package/dist/core/backend_registry.js +50 -0
  75. package/dist/core/backend_ui.d.ts +28 -0
  76. package/dist/core/backend_ui.js +1 -0
  77. package/dist/core/channel_events.d.ts +48 -0
  78. package/dist/core/channel_events.js +1 -0
  79. package/dist/core/channel_port.d.ts +52 -8
  80. package/dist/core/engine_spi.d.ts +20 -5
  81. package/dist/core/listener_scope.d.ts +11 -0
  82. package/dist/core/listener_scope.js +10 -0
  83. package/dist/core/message_text.d.ts +2 -0
  84. package/dist/core/message_text.js +28 -0
  85. package/dist/core/network_errors.d.ts +1 -0
  86. package/dist/core/network_errors.js +23 -0
  87. package/dist/core/orchestrator.d.ts +57 -22
  88. package/dist/core/orchestrator.js +877 -477
  89. package/dist/core/queued_engine_input.d.ts +12 -0
  90. package/dist/core/queued_engine_input.js +16 -0
  91. package/dist/core/runtime_supervisor.d.ts +19 -0
  92. package/dist/core/runtime_supervisor.js +45 -0
  93. package/dist/core/scope_operations.d.ts +7 -0
  94. package/dist/core/scope_operations.js +19 -0
  95. package/dist/core/stream_preview.js +21 -9
  96. package/dist/dsh/adapter.d.ts +48 -0
  97. package/dist/dsh/adapter.js +205 -0
  98. package/dist/dsh/backend.d.ts +7 -0
  99. package/dist/dsh/backend.js +21 -0
  100. package/dist/dsh/client.d.ts +46 -0
  101. package/dist/dsh/client.js +184 -0
  102. package/dist/dsh/permissions_plugin.d.ts +30 -0
  103. package/dist/dsh/permissions_plugin.js +21 -0
  104. package/dist/dsh/runtime.d.ts +20 -0
  105. package/dist/dsh/runtime.js +36 -0
  106. package/dist/dsh/ui.d.ts +38 -0
  107. package/dist/dsh/ui.js +342 -0
  108. package/dist/i18n.d.ts +4 -0
  109. package/dist/i18n.js +12 -0
  110. package/dist/main.js +144 -72
  111. package/dist/opencode/adapter.js +28 -9
  112. package/dist/opencode/controller.js +1 -1
  113. package/dist/store/channel_inbox.d.ts +14 -0
  114. package/dist/store/channel_inbox.js +21 -0
  115. package/dist/store/database.d.ts +10 -4
  116. package/dist/store/database.js +13 -3
  117. package/dist/store/task_journal.d.ts +32 -0
  118. package/dist/store/task_journal.js +70 -0
  119. package/dist/telegram/api.d.ts +2 -2
  120. package/dist/telegram/api.js +10 -28
  121. package/dist/telegram/gateway.d.ts +13 -27
  122. package/dist/telegram/gateway.js +86 -43
  123. package/dist/telegram/media.d.ts +2 -34
  124. package/dist/telegram/media.js +1 -180
  125. package/dist/telegram/task_preview.d.ts +13 -0
  126. package/dist/telegram/task_preview.js +92 -0
  127. package/dist/telegram/task_result.d.ts +2 -0
  128. package/dist/telegram/task_result.js +12 -0
  129. package/dist/telegram/text.d.ts +1 -1
  130. package/dist/telegram/text.js +2 -27
  131. package/dist/types.d.ts +2 -2
  132. package/docs/dsh.md +83 -0
  133. package/docs/release.md +13 -11
  134. package/docs/zh/architecture-review.md +106 -0
  135. package/docs/zh/dsh.md +83 -0
  136. package/docs/zh/release.md +13 -11
  137. package/docs/zh/telegram-experience.md +40 -0
  138. package/package.json +7 -5
@@ -0,0 +1,288 @@
1
+ import { TELEGRAM_RICH_MESSAGE_TEXT_LIMIT } from '../telegram/rich.js';
2
+ import { escapeTelegramHtml, telegramBold, telegramDetails, telegramExpandableBlockquote } from '../telegram/html.js';
3
+ import { renderTelegramMarkdownRichHtml } from '../telegram/rich_markdown.js';
4
+ import { TOOL_ARCHIVE_MAX_LINES, TOOL_ARCHIVE_LINE_LIMIT, } from './state_types.js';
5
+ export function ensureTurnSegment(active, itemId, phase, outputKind, isPlan) {
6
+ let segment = active.segments.find((entry) => entry.itemId === itemId);
7
+ if (segment) {
8
+ if (phase !== undefined) {
9
+ segment.phase = phase;
10
+ }
11
+ if (outputKind !== undefined) {
12
+ segment.outputKind = outputKind;
13
+ }
14
+ if (isPlan !== undefined) {
15
+ segment.isPlan = segment.isPlan || isPlan;
16
+ }
17
+ return segment;
18
+ }
19
+ segment = {
20
+ itemId,
21
+ phase: phase ?? null,
22
+ outputKind: outputKind ?? 'commentary',
23
+ isPlan: Boolean(isPlan),
24
+ text: '',
25
+ completed: false,
26
+ startedAtMs: Date.now(),
27
+ completedAtMs: null,
28
+ messages: [],
29
+ voiceSnippetId: null,
30
+ };
31
+ active.segments.push(segment);
32
+ return segment;
33
+ }
34
+ export function renderCollapsedCommentary(locale, segments) {
35
+ const firstAt = segments[0]?.startedAtMs ?? Date.now();
36
+ const lastSegment = segments[segments.length - 1];
37
+ const lastAt = lastSegment?.completedAtMs ?? lastSegment?.startedAtMs ?? firstAt;
38
+ const range = firstAt === lastAt
39
+ ? formatClockTimestamp(firstAt)
40
+ : `${formatClockTimestamp(firstAt)} - ${formatClockTimestamp(lastAt)}`;
41
+ const summary = locale === 'zh'
42
+ ? `过程汇报 · ${segments.length} 条 · ${range}`
43
+ : `Progress · ${segments.length} updates · ${range}`;
44
+ const maxBodyLength = TELEGRAM_RICH_MESSAGE_TEXT_LIMIT - summary.length - 1024;
45
+ const blocks = [];
46
+ let bodyLength = 0;
47
+ let omitted = 0;
48
+ for (const segment of segments) {
49
+ const endAt = segment.completedAtMs ?? segment.startedAtMs;
50
+ const timestamp = endAt === segment.startedAtMs
51
+ ? formatClockTimestamp(segment.startedAtMs)
52
+ : `${formatClockTimestamp(segment.startedAtMs)} - ${formatClockTimestamp(endAt)}`;
53
+ const block = `<h4>${escapeTelegramHtml(timestamp)}</h4>\n${renderTelegramMarkdownRichHtml(segment.text)}`;
54
+ if (bodyLength + block.length > maxBodyLength) {
55
+ omitted += 1;
56
+ continue;
57
+ }
58
+ blocks.push(block);
59
+ bodyLength += block.length;
60
+ }
61
+ if (omitted > 0) {
62
+ blocks.push(`<p>${escapeTelegramHtml(locale === 'zh' ? `另有 ${omitted} 条过长内容未收入归档` : `${omitted} oversized updates omitted`)}</p>`);
63
+ }
64
+ return telegramDetails(summary, blocks.join('\n'));
65
+ }
66
+ export function formatClockTimestamp(timestampMs) {
67
+ const date = new Date(timestampMs);
68
+ const pad = (value) => String(value).padStart(2, '0');
69
+ return `${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
70
+ }
71
+ export function createToolBatchState() {
72
+ return {
73
+ openCallIds: new Set(),
74
+ actionKeys: new Set(),
75
+ actionLines: [],
76
+ counts: { files: 0, searches: 0, edits: 0, commands: 0 },
77
+ finalizeTimer: null,
78
+ };
79
+ }
80
+ export function incrementToolBatchCount(counts, kind) {
81
+ counts[kind] += 1;
82
+ }
83
+ export function formatToolBatchStatus(locale, counts, actionLines, inProgress) {
84
+ const heading = formatToolBatchHeading(locale, counts, inProgress);
85
+ const detailLines = actionLines.slice(0, 6);
86
+ if (detailLines.length === 0) {
87
+ return heading;
88
+ }
89
+ return [heading, ...detailLines].join('\n');
90
+ }
91
+ export function renderArchivedToolBatchStatus(locale, counts, actionLines) {
92
+ const text = formatToolBatchStatus(locale, counts, actionLines, false);
93
+ if (actionLines.length === 0) {
94
+ return { text, html: null };
95
+ }
96
+ const heading = formatToolBatchHeading(locale, counts, false);
97
+ const detailLines = actionLines
98
+ .slice(0, TOOL_ARCHIVE_MAX_LINES)
99
+ .map(line => truncateInline(line, TOOL_ARCHIVE_LINE_LIMIT));
100
+ const html = [
101
+ telegramBold(heading),
102
+ telegramExpandableBlockquote(detailLines.join('\n')),
103
+ ].join('\n');
104
+ return { text, html };
105
+ }
106
+ export function formatToolBatchHeading(locale, counts, inProgress) {
107
+ const parts = formatToolBatchCountParts(locale, counts);
108
+ const hasBrowse = counts.files > 0 || counts.searches > 0;
109
+ const hasEdit = counts.edits > 0;
110
+ const hasCommand = counts.commands > 0;
111
+ let verb;
112
+ if (hasEdit && !hasBrowse && !hasCommand) {
113
+ verb = locale === 'zh' ? (inProgress ? '正在编辑' : '已编辑') : (inProgress ? 'Editing' : 'Edited');
114
+ }
115
+ else if (hasBrowse && !hasEdit && !hasCommand) {
116
+ verb = locale === 'zh' ? (inProgress ? '正在浏览' : '已浏览') : (inProgress ? 'Browsing' : 'Browsed');
117
+ }
118
+ else if (hasCommand && !hasBrowse && !hasEdit) {
119
+ verb = locale === 'zh' ? (inProgress ? '正在运行' : '已运行') : (inProgress ? 'Running' : 'Ran');
120
+ }
121
+ else {
122
+ verb = locale === 'zh' ? (inProgress ? '正在处理' : '已处理') : (inProgress ? 'Processing' : 'Processed');
123
+ }
124
+ if (parts.length === 0) {
125
+ return locale === 'zh'
126
+ ? `${verb}操作...`
127
+ : `${verb} operations...`;
128
+ }
129
+ return locale === 'zh'
130
+ ? `${verb} ${parts.join(',')}`
131
+ : `${verb} ${parts.join(', ')}`;
132
+ }
133
+ export function formatToolBatchCountParts(locale, counts) {
134
+ const parts = [];
135
+ if (counts.files > 0) {
136
+ parts.push(locale === 'zh' ? `${counts.files} 个文件` : pluralize(counts.files, 'file'));
137
+ }
138
+ if (counts.searches > 0) {
139
+ parts.push(locale === 'zh' ? `${counts.searches} 个搜索` : pluralize(counts.searches, 'search'));
140
+ }
141
+ if (counts.edits > 0) {
142
+ parts.push(locale === 'zh' ? `${counts.edits} 个编辑` : pluralize(counts.edits, 'edit'));
143
+ }
144
+ if (counts.commands > 0) {
145
+ parts.push(locale === 'zh' ? `${counts.commands} 个命令` : pluralize(counts.commands, 'command'));
146
+ }
147
+ return parts;
148
+ }
149
+ export function pluralize(count, noun) {
150
+ if (count === 1) {
151
+ return `1 ${noun}`;
152
+ }
153
+ const plural = noun === 'search'
154
+ ? 'searches'
155
+ : noun === 'file'
156
+ ? 'files'
157
+ : `${noun}s`;
158
+ return `${count} ${plural}`;
159
+ }
160
+ export function describeExecCommand(event) {
161
+ const descriptors = (event.parsedCmd ?? [])
162
+ .map((entry) => describeParsedCommand(entry))
163
+ .filter((entry) => entry !== null);
164
+ if (descriptors.length > 0) {
165
+ return descriptors;
166
+ }
167
+ const commandText = renderShellCommand(event.command);
168
+ return [{
169
+ kind: 'commands',
170
+ key: `command:${commandText}`,
171
+ line: `$ ${commandText}`,
172
+ }];
173
+ }
174
+ export function describeParsedCommand(entry) {
175
+ const type = typeof entry?.type === 'string' ? entry.type : '';
176
+ const path = compactPath(entry?.path ?? entry?.name ?? null);
177
+ const query = typeof entry?.query === 'string' ? entry.query : null;
178
+ switch (type) {
179
+ case 'search':
180
+ return {
181
+ kind: 'searches',
182
+ key: `search:${path ?? '.'}:${query ?? ''}`,
183
+ line: path ? `Searched for ${truncateInline(query || '', 80)} in ${path}` : `Searched for ${truncateInline(query || '', 80)}`,
184
+ };
185
+ case 'read':
186
+ return {
187
+ kind: 'files',
188
+ key: `read:${path ?? 'unknown'}`,
189
+ line: `Read ${path ?? 'file'}`,
190
+ };
191
+ case 'list_files':
192
+ return {
193
+ kind: 'files',
194
+ key: `list:${path ?? 'workspace'}`,
195
+ line: path ? `Listed ${path}` : 'Listed files',
196
+ };
197
+ case 'write':
198
+ case 'edit':
199
+ case 'apply_patch':
200
+ return {
201
+ kind: 'edits',
202
+ key: `${type}:${path ?? 'workspace'}`,
203
+ line: `Edited ${path ?? 'files'}`,
204
+ };
205
+ case 'move':
206
+ return {
207
+ kind: 'edits',
208
+ key: `${type}:${path ?? 'workspace'}`,
209
+ line: `Moved ${path ?? 'files'}`,
210
+ };
211
+ case 'copy':
212
+ return {
213
+ kind: 'edits',
214
+ key: `${type}:${path ?? 'workspace'}`,
215
+ line: `Copied ${path ?? 'files'}`,
216
+ };
217
+ case 'delete':
218
+ return {
219
+ kind: 'edits',
220
+ key: `${type}:${path ?? 'workspace'}`,
221
+ line: `Deleted ${path ?? 'files'}`,
222
+ };
223
+ case 'mkdir':
224
+ return {
225
+ kind: 'edits',
226
+ key: `${type}:${path ?? 'workspace'}`,
227
+ line: `Created ${path ?? 'files'}`,
228
+ };
229
+ default:
230
+ return null;
231
+ }
232
+ }
233
+ export function compactPath(value) {
234
+ if (typeof value !== 'string' || !value.trim()) {
235
+ return null;
236
+ }
237
+ return value.replace(/^\.\//, '');
238
+ }
239
+ export function renderShellCommand(command) {
240
+ if (command.length >= 3 && (command[0] === '/bin/zsh' || command[0] === 'zsh') && command[1] === '-lc') {
241
+ return command[2] ?? command.join(' ');
242
+ }
243
+ return command.join(' ');
244
+ }
245
+ export function truncateInline(value, limit) {
246
+ if (value.length <= limit) {
247
+ return value;
248
+ }
249
+ return `${value.slice(0, Math.max(0, limit - 1))}…`;
250
+ }
251
+ export function extractLatestPlanMarkdown(active) {
252
+ const segmentPlan = extractLatestPlanSegmentMarkdown(active);
253
+ if (segmentPlan) {
254
+ return segmentPlan;
255
+ }
256
+ return extractLatestProposedPlanMarkdown(active);
257
+ }
258
+ export function extractLatestPlanSegmentMarkdown(active) {
259
+ for (let index = active.segments.length - 1; index >= 0; index -= 1) {
260
+ const segment = active.segments[index];
261
+ if (!segment.isPlan) {
262
+ continue;
263
+ }
264
+ const text = segment.text.trim();
265
+ if (text) {
266
+ return text;
267
+ }
268
+ }
269
+ return null;
270
+ }
271
+ export function extractLatestProposedPlanMarkdown(active) {
272
+ return extractLatestProposedPlanBlock([
273
+ active.finalText,
274
+ active.buffer,
275
+ ...active.segments.map(segment => segment.text),
276
+ ].filter((text) => typeof text === 'string' && text.length > 0).join('\n'));
277
+ }
278
+ export function extractLatestProposedPlanBlock(text) {
279
+ const pattern = /<proposed_plan\b[^>]*>([\s\S]*?)<\/proposed_plan>/gi;
280
+ let latest = null;
281
+ for (let match = pattern.exec(text); match !== null; match = pattern.exec(text)) {
282
+ const plan = match[1]?.trim();
283
+ if (plan) {
284
+ latest = plan;
285
+ }
286
+ }
287
+ return latest;
288
+ }
@@ -0,0 +1,11 @@
1
+ import type { AppConfig } from '../config.js';
2
+ import type { AccessPresetValue, ApprovalPolicyValue, ChatSessionSettings, SandboxModeValue } from '../types.js';
3
+ export interface ResolvedAccessMode {
4
+ preset: AccessPresetValue;
5
+ approvalPolicy: ApprovalPolicyValue;
6
+ sandboxMode: SandboxModeValue;
7
+ }
8
+ export declare const ACCESS_PRESETS: AccessPresetValue[];
9
+ export declare function normalizeAccessPreset(value: string | null | undefined): AccessPresetValue | null;
10
+ export declare function resolveConfiguredAccessPreset(settings: Pick<ChatSessionSettings, 'accessPreset'> | null | undefined): AccessPresetValue;
11
+ export declare function resolveAccessMode(config: Pick<AppConfig, 'defaultApprovalPolicy' | 'defaultSandboxMode'>, settings: Pick<ChatSessionSettings, 'accessPreset'> | null | undefined): ResolvedAccessMode;
@@ -0,0 +1,33 @@
1
+ export const ACCESS_PRESETS = ['read-only', 'default', 'full-access'];
2
+ export function normalizeAccessPreset(value) {
3
+ if (value === 'read-only' || value === 'default' || value === 'full-access') {
4
+ return value;
5
+ }
6
+ return null;
7
+ }
8
+ export function resolveConfiguredAccessPreset(settings) {
9
+ return settings?.accessPreset ?? 'default';
10
+ }
11
+ export function resolveAccessMode(config, settings) {
12
+ const preset = resolveConfiguredAccessPreset(settings);
13
+ switch (preset) {
14
+ case 'read-only':
15
+ return {
16
+ preset,
17
+ approvalPolicy: 'on-request',
18
+ sandboxMode: 'read-only',
19
+ };
20
+ case 'full-access':
21
+ return {
22
+ preset,
23
+ approvalPolicy: 'never',
24
+ sandboxMode: 'danger-full-access',
25
+ };
26
+ default:
27
+ return {
28
+ preset: 'default',
29
+ approvalPolicy: config.defaultApprovalPolicy,
30
+ sandboxMode: config.defaultSandboxMode,
31
+ };
32
+ }
33
+ }
@@ -0,0 +1,13 @@
1
+ export declare const ATTACHMENT_INBOX_DIR = ".telegram-inbox";
2
+ export declare const DEFAULT_REMOTE_DOWNLOAD_LIMIT_BYTES: number;
3
+ import type { InboundAttachment, StagedAttachment } from './attachment_types.js';
4
+ interface PlannedAttachmentPath {
5
+ fileName: string;
6
+ localPath: string;
7
+ relativePath: string;
8
+ }
9
+ export declare function isNativeImageAttachment(attachment: InboundAttachment): boolean;
10
+ export declare function planAttachmentStoragePath(cwd: string, threadId: string, attachment: InboundAttachment, remoteFilePath?: string | null, now?: Date): PlannedAttachmentPath;
11
+ export declare function buildAttachmentPrompt(userText: string, attachments: readonly StagedAttachment[]): string;
12
+ export declare function summarizeInput(text: string, attachments: readonly InboundAttachment[]): string;
13
+ export {};
@@ -0,0 +1,180 @@
1
+ import path from 'node:path';
2
+ export const ATTACHMENT_INBOX_DIR = '.telegram-inbox';
3
+ export const DEFAULT_REMOTE_DOWNLOAD_LIMIT_BYTES = 20 * 1024 * 1024;
4
+ const FALLBACK_EXTENSION_BY_KIND = {
5
+ photo: '.jpg',
6
+ document: '',
7
+ audio: '.mp3',
8
+ voice: '.ogg',
9
+ video: '.mp4',
10
+ animation: '.mp4',
11
+ sticker: '.webp',
12
+ videoNote: '.mp4',
13
+ };
14
+ const MIME_EXTENSION_MAP = {
15
+ 'application/gzip': '.gz',
16
+ 'application/json': '.json',
17
+ 'application/pdf': '.pdf',
18
+ 'application/zip': '.zip',
19
+ 'audio/mpeg': '.mp3',
20
+ 'audio/mp4': '.m4a',
21
+ 'audio/ogg': '.ogg',
22
+ 'audio/wav': '.wav',
23
+ 'image/gif': '.gif',
24
+ 'image/jpeg': '.jpg',
25
+ 'image/png': '.png',
26
+ 'image/webp': '.webp',
27
+ 'text/csv': '.csv',
28
+ 'text/html': '.html',
29
+ 'text/markdown': '.md',
30
+ 'text/plain': '.txt',
31
+ 'video/mp4': '.mp4',
32
+ 'video/quicktime': '.mov',
33
+ };
34
+ export function isNativeImageAttachment(attachment) {
35
+ if (attachment.kind === 'photo') {
36
+ return true;
37
+ }
38
+ if (attachment.kind === 'sticker') {
39
+ return !attachment.isAnimated && !attachment.isVideo;
40
+ }
41
+ if (attachment.kind !== 'document') {
42
+ return false;
43
+ }
44
+ const lowerMime = attachment.mimeType?.toLowerCase() ?? '';
45
+ if (lowerMime === 'image/jpeg' || lowerMime === 'image/png' || lowerMime === 'image/webp' || lowerMime === 'image/gif') {
46
+ return true;
47
+ }
48
+ const extension = extensionFromName(attachment.fileName);
49
+ return extension === '.jpg' || extension === '.jpeg' || extension === '.png' || extension === '.webp' || extension === '.gif';
50
+ }
51
+ export function planAttachmentStoragePath(cwd, threadId, attachment, remoteFilePath, now = new Date()) {
52
+ const dateSegment = now.toISOString().slice(0, 10);
53
+ const threadSegment = sanitizePathSegment(threadId);
54
+ const timestamp = now.toISOString().slice(0, 19).replace(/[-:]/g, '').replace('T', '-');
55
+ const resolvedFileName = resolveAttachmentFileName(attachment, remoteFilePath);
56
+ const uniquePrefix = `${timestamp}-${sanitizePathSegment(attachment.fileUniqueId).slice(0, 16)}`;
57
+ const joinedName = truncateFileName(`${uniquePrefix}-${resolvedFileName}`, 160);
58
+ const relativePath = path.join(ATTACHMENT_INBOX_DIR, dateSegment, threadSegment, joinedName);
59
+ return {
60
+ fileName: resolvedFileName,
61
+ localPath: path.resolve(cwd, relativePath),
62
+ relativePath,
63
+ };
64
+ }
65
+ export function buildAttachmentPrompt(userText, attachments) {
66
+ const normalizedText = userText.trim();
67
+ const lines = [];
68
+ if (normalizedText) {
69
+ lines.push(normalizedText, '');
70
+ }
71
+ else {
72
+ lines.push('User sent attachments without a caption.', '');
73
+ }
74
+ lines.push('Attachments:');
75
+ attachments.forEach((attachment, index) => {
76
+ lines.push(`${index + 1}. ${describeAttachment(attachment)}`);
77
+ lines.push(` filename: ${attachment.fileName}`);
78
+ lines.push(` path: ${attachment.localPath}`);
79
+ if (attachment.mimeType)
80
+ lines.push(` mime: ${attachment.mimeType}`);
81
+ if (attachment.fileSize !== null)
82
+ lines.push(` size_bytes: ${attachment.fileSize}`);
83
+ if (attachment.width !== null && attachment.height !== null) {
84
+ lines.push(` dimensions: ${attachment.width}x${attachment.height}`);
85
+ }
86
+ if (attachment.durationSeconds !== null) {
87
+ lines.push(` duration_seconds: ${attachment.durationSeconds}`);
88
+ }
89
+ if (attachment.nativeImage) {
90
+ lines.push(' attached_as: localImage');
91
+ }
92
+ });
93
+ lines.push('', 'Use the local file paths above when you inspect these attachments.');
94
+ return lines.join('\n');
95
+ }
96
+ export function summarizeInput(text, attachments) {
97
+ const lines = [];
98
+ const normalizedText = text.trim();
99
+ if (normalizedText) {
100
+ lines.push(normalizedText);
101
+ }
102
+ if (attachments.length > 0) {
103
+ lines.push(`[attachments: ${attachments.map((attachment) => `${attachment.kind}:${attachment.fileName ?? attachment.fileUniqueId}`).join(', ')}]`);
104
+ }
105
+ return truncateSummary(lines.join(' '), 500);
106
+ }
107
+ function resolveAttachmentFileName(attachment, remoteFilePath) {
108
+ const providedName = attachment.fileName ? path.basename(attachment.fileName) : '';
109
+ const remoteName = remoteFilePath ? path.basename(remoteFilePath) : '';
110
+ const baseCandidate = providedName || remoteName || `${attachment.kind}-${attachment.fileUniqueId}`;
111
+ const sanitizedBase = sanitizeFileName(baseCandidate);
112
+ const extension = extensionFromName(sanitizedBase)
113
+ || extensionFromName(remoteName)
114
+ || extensionFromMimeType(attachment.mimeType)
115
+ || fallbackExtensionByKind(attachment.kind);
116
+ if (!extension) {
117
+ return sanitizedBase;
118
+ }
119
+ const withoutExtension = sanitizedBase.slice(0, sanitizedBase.length - extensionFromName(sanitizedBase).length) || `${attachment.kind}-${attachment.fileUniqueId}`;
120
+ return truncateFileName(`${withoutExtension}${extension}`, 120);
121
+ }
122
+ function describeAttachment(attachment) {
123
+ switch (attachment.kind) {
124
+ case 'photo':
125
+ return 'photo';
126
+ case 'document':
127
+ return 'document';
128
+ case 'audio':
129
+ return 'audio file';
130
+ case 'voice':
131
+ return 'voice message';
132
+ case 'video':
133
+ return 'video';
134
+ case 'animation':
135
+ return 'animation';
136
+ case 'sticker':
137
+ return attachment.isAnimated || attachment.isVideo ? 'animated sticker' : 'sticker';
138
+ case 'videoNote':
139
+ return 'video note';
140
+ }
141
+ }
142
+ function sanitizeFileName(fileName) {
143
+ const trimmed = fileName.trim().replace(/[\\/]+/g, '-');
144
+ const sanitized = trimmed
145
+ .replace(/[^\w.\-()+@]+/g, '-')
146
+ .replace(/-+/g, '-')
147
+ .replace(/^\.+/, '')
148
+ .replace(/^-+/, '')
149
+ .replace(/[.-]+$/, '');
150
+ return sanitized || 'attachment';
151
+ }
152
+ function sanitizePathSegment(value) {
153
+ return value.replace(/[^\w.-]+/g, '-').replace(/-+/g, '-').replace(/^-+|-+$/g, '') || 'thread';
154
+ }
155
+ function extensionFromMimeType(mimeType) {
156
+ if (!mimeType)
157
+ return '';
158
+ return MIME_EXTENSION_MAP[mimeType.toLowerCase()] ?? '';
159
+ }
160
+ function extensionFromName(fileName) {
161
+ if (!fileName)
162
+ return '';
163
+ const extension = path.extname(fileName).toLowerCase();
164
+ return extension === '.' ? '' : extension;
165
+ }
166
+ function truncateFileName(fileName, maxLength) {
167
+ if (fileName.length <= maxLength)
168
+ return fileName;
169
+ const extension = extensionFromName(fileName);
170
+ const base = extension ? fileName.slice(0, -extension.length) : fileName;
171
+ return `${base.slice(0, Math.max(1, maxLength - extension.length))}${extension}`;
172
+ }
173
+ function truncateSummary(text, maxLength) {
174
+ if (text.length <= maxLength)
175
+ return text;
176
+ return `${text.slice(0, maxLength - 3)}...`;
177
+ }
178
+ function fallbackExtensionByKind(kind) {
179
+ return FALLBACK_EXTENSION_BY_KIND[kind] ?? '';
180
+ }
@@ -0,0 +1,22 @@
1
+ export type AttachmentKind = 'photo' | 'document' | 'audio' | 'voice' | 'video' | 'animation' | 'sticker' | 'videoNote';
2
+ export interface InboundAttachment {
3
+ kind: AttachmentKind;
4
+ fileId: string;
5
+ fileUniqueId: string;
6
+ fileName: string | null;
7
+ mimeType: string | null;
8
+ fileSize: number | null;
9
+ width: number | null;
10
+ height: number | null;
11
+ durationSeconds: number | null;
12
+ isAnimated: boolean;
13
+ isVideo: boolean;
14
+ /** When set, {@link stageAttachments} copies this file instead of Telegram Bot API download. */
15
+ localPath?: string;
16
+ }
17
+ export interface StagedAttachment extends InboundAttachment {
18
+ fileName: string;
19
+ localPath: string;
20
+ relativePath: string;
21
+ nativeImage: boolean;
22
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -1,6 +1,6 @@
1
1
  import type { Logger } from '../logger.js';
2
- import { type StagedTelegramAttachment, type TelegramInboundAttachment } from '../telegram/media.js';
3
- export interface TelegramFileDownloader {
2
+ import type { StagedAttachment, InboundAttachment } from './attachment_types.js';
3
+ export interface AttachmentDownloader {
4
4
  getFile(fileId: string): Promise<{
5
5
  file_id: string;
6
6
  file_path?: string;
@@ -8,4 +8,6 @@ export interface TelegramFileDownloader {
8
8
  }>;
9
9
  downloadResolvedFile(filePath: string, destination: string): Promise<unknown>;
10
10
  }
11
- export declare function stageInboundAttachments(downloader: TelegramFileDownloader, cwd: string, threadId: string, attachments: readonly TelegramInboundAttachment[], logger?: Logger): Promise<StagedTelegramAttachment[]>;
11
+ export declare function stageInboundAttachments(downloader: AttachmentDownloader, cwd: string, threadId: string, attachments: readonly InboundAttachment[], logger?: Logger): Promise<StagedAttachment[]>;
12
+ /** Compatibility for existing attachment clients. */
13
+ export type TelegramFileDownloader = AttachmentDownloader;
@@ -1,6 +1,6 @@
1
1
  import fsPromises from 'node:fs/promises';
2
2
  import path from 'node:path';
3
- import { planAttachmentStoragePath, isNativeImageAttachment, TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES, } from '../telegram/media.js';
3
+ import { planAttachmentStoragePath, isNativeImageAttachment, DEFAULT_REMOTE_DOWNLOAD_LIMIT_BYTES, } from './attachment_files.js';
4
4
  export async function stageInboundAttachments(downloader, cwd, threadId, attachments, logger) {
5
5
  const staged = [];
6
6
  for (const attachment of attachments) {
@@ -23,7 +23,7 @@ export async function stageInboundAttachments(downloader, cwd, threadId, attachm
23
23
  }
24
24
  const remoteFile = await downloader.getFile(attachment.fileId);
25
25
  const resolvedSize = attachment.fileSize ?? remoteFile.file_size ?? null;
26
- if (resolvedSize !== null && resolvedSize > TELEGRAM_BOT_API_DOWNLOAD_LIMIT_BYTES) {
26
+ if (resolvedSize !== null && resolvedSize > DEFAULT_REMOTE_DOWNLOAD_LIMIT_BYTES) {
27
27
  logger?.warn('attachments.too_large', {
28
28
  fileId: attachment.fileId,
29
29
  size: resolvedSize,
@@ -0,0 +1,22 @@
1
+ import type { BackendDescriptor } from './engine_spi.js';
2
+ import type { BackendUi, BackendUiHost } from './backend_ui.js';
3
+ /** Owns backend definitions and their panel lifecycle. Discovery may refresh metadata, never execution identity. */
4
+ export declare class BackendRegistry {
5
+ private readonly host;
6
+ private readonly entries;
7
+ private stopping;
8
+ private readonly interfaces;
9
+ constructor(host: BackendUiHost);
10
+ register(backend: BackendDescriptor): void;
11
+ set(id: string, backend: BackendDescriptor): void;
12
+ has(id: string): boolean;
13
+ keys(): IterableIterator<string>;
14
+ values(): IterableIterator<BackendDescriptor>;
15
+ get(id: string): BackendDescriptor | undefined;
16
+ list(): BackendDescriptor[];
17
+ ui(id: string): BackendUi | undefined;
18
+ callbackOwner(data: string): BackendUi | undefined;
19
+ getPendingApprovals(): number;
20
+ stop(): Promise<void>;
21
+ private stopInterfaces;
22
+ }
@@ -0,0 +1,50 @@
1
+ /** Owns backend definitions and their panel lifecycle. Discovery may refresh metadata, never execution identity. */
2
+ export class BackendRegistry {
3
+ host;
4
+ entries = new Map();
5
+ stopping;
6
+ interfaces = new Map();
7
+ constructor(host) {
8
+ this.host = host;
9
+ }
10
+ register(backend) {
11
+ if (this.stopping)
12
+ throw new Error('Backend registry is stopped');
13
+ if (!backend.id)
14
+ throw new Error('Backend id is required');
15
+ const existing = this.entries.get(backend.id);
16
+ if (existing && (existing.adapter !== backend.adapter || (backend.createUi !== undefined && existing.createUi !== backend.createUi))) {
17
+ throw new Error(`Backend '${backend.id}' is already registered with a different runtime`);
18
+ }
19
+ const ui = !existing ? backend.createUi?.(this.host) : undefined;
20
+ this.entries.set(backend.id, { ...existing, ...backend });
21
+ if (ui)
22
+ this.interfaces.set(backend.id, ui);
23
+ }
24
+ set(id, backend) {
25
+ if (id !== backend.id)
26
+ throw new Error('Backend registry key must match its id');
27
+ this.register(backend);
28
+ }
29
+ has(id) { return this.entries.has(id); }
30
+ keys() { return this.entries.keys(); }
31
+ values() { return this.entries.values(); }
32
+ get(id) { return this.entries.get(id); }
33
+ list() { return [...this.entries.values()]; }
34
+ ui(id) { return this.interfaces.get(id); }
35
+ callbackOwner(data) {
36
+ return [...this.interfaces.values()].find(ui => ui.ownsCallback?.(data));
37
+ }
38
+ getPendingApprovals() {
39
+ return [...this.interfaces.values()].reduce((sum, ui) => sum + (ui.getPendingApprovals?.() ?? 0), 0);
40
+ }
41
+ stop() {
42
+ return this.stopping ??= this.stopInterfaces();
43
+ }
44
+ async stopInterfaces() {
45
+ const results = await Promise.allSettled([...this.interfaces.values()].map(ui => ui.stop?.()));
46
+ const failure = results.find(result => result.status === 'rejected');
47
+ if (failure?.status === 'rejected')
48
+ throw failure.reason;
49
+ }
50
+ }