@openclaw/qqbot 2026.5.2 → 2026.5.3-beta.2

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 (203) hide show
  1. package/dist/api.js +646 -0
  2. package/dist/approval-cg0SVahb.js +94 -0
  3. package/dist/channel-N6Y_Rcjp.js +551 -0
  4. package/dist/channel-plugin-api.js +2 -0
  5. package/dist/channel.setup-ByCRAe5H.js +25 -0
  6. package/dist/config-D6545NkC.js +94 -0
  7. package/dist/config-schema-DFcjQw73.js +309 -0
  8. package/dist/exec-approvals-COUsM6wZ.js +138 -0
  9. package/dist/gateway-CmSUJKSt.js +6726 -0
  10. package/dist/handler-runtime-Bqm6N0WG.js +120 -0
  11. package/dist/index.js +29 -0
  12. package/dist/narrowing-BoieBTIU.js +25 -0
  13. package/dist/outbound-BJfhwrPg.js +1357 -0
  14. package/dist/request-context-DXtpwWui.js +921 -0
  15. package/dist/resolve-D_06fV6-.js +284 -0
  16. package/dist/runtime-BJAS3eXW.js +18 -0
  17. package/dist/runtime-api.js +2 -0
  18. package/dist/secret-contract-api.js +56 -0
  19. package/dist/sender-p-B14eLG.js +1987 -0
  20. package/dist/setup-entry.js +15 -0
  21. package/dist/setup-plugin-api.js +2 -0
  22. package/dist/string-normalize-Ci6NM5DE.js +98 -0
  23. package/dist/target-parser-Y0prnrXD.js +245 -0
  24. package/package.json +15 -6
  25. package/api.ts +0 -56
  26. package/channel-plugin-api.ts +0 -1
  27. package/index.ts +0 -29
  28. package/runtime-api.ts +0 -9
  29. package/setup-entry.ts +0 -9
  30. package/setup-plugin-api.ts +0 -3
  31. package/src/bridge/approval/capability.ts +0 -237
  32. package/src/bridge/approval/handler-runtime.ts +0 -204
  33. package/src/bridge/bootstrap.ts +0 -135
  34. package/src/bridge/channel-entry.ts +0 -18
  35. package/src/bridge/commands/framework-context-adapter.ts +0 -60
  36. package/src/bridge/commands/framework-registration.ts +0 -47
  37. package/src/bridge/commands/from-parser.test.ts +0 -86
  38. package/src/bridge/commands/from-parser.ts +0 -60
  39. package/src/bridge/commands/result-dispatcher.ts +0 -76
  40. package/src/bridge/config-shared.ts +0 -132
  41. package/src/bridge/config.ts +0 -111
  42. package/src/bridge/gateway.ts +0 -174
  43. package/src/bridge/logger.ts +0 -31
  44. package/src/bridge/narrowing.ts +0 -31
  45. package/src/bridge/plugin-version.test.ts +0 -146
  46. package/src/bridge/plugin-version.ts +0 -102
  47. package/src/bridge/runtime.ts +0 -25
  48. package/src/bridge/sdk-adapter.ts +0 -131
  49. package/src/bridge/setup/finalize.ts +0 -144
  50. package/src/bridge/setup/surface.ts +0 -34
  51. package/src/bridge/tools/channel.ts +0 -58
  52. package/src/bridge/tools/index.ts +0 -15
  53. package/src/bridge/tools/remind.test.ts +0 -124
  54. package/src/bridge/tools/remind.ts +0 -91
  55. package/src/channel.setup.ts +0 -33
  56. package/src/channel.ts +0 -288
  57. package/src/command-auth.test.ts +0 -62
  58. package/src/config-schema.ts +0 -84
  59. package/src/config.test.ts +0 -364
  60. package/src/engine/access/access-control.test.ts +0 -198
  61. package/src/engine/access/access-control.ts +0 -226
  62. package/src/engine/access/index.ts +0 -16
  63. package/src/engine/access/resolve-policy.test.ts +0 -59
  64. package/src/engine/access/resolve-policy.ts +0 -57
  65. package/src/engine/access/sender-match.test.ts +0 -60
  66. package/src/engine/access/sender-match.ts +0 -55
  67. package/src/engine/access/types.ts +0 -53
  68. package/src/engine/adapter/audio.port.ts +0 -27
  69. package/src/engine/adapter/commands.port.ts +0 -22
  70. package/src/engine/adapter/history.port.ts +0 -52
  71. package/src/engine/adapter/index.ts +0 -139
  72. package/src/engine/adapter/mention-gate.port.ts +0 -50
  73. package/src/engine/adapter/types.ts +0 -38
  74. package/src/engine/api/api-client.ts +0 -212
  75. package/src/engine/api/media-chunked.test.ts +0 -336
  76. package/src/engine/api/media-chunked.ts +0 -622
  77. package/src/engine/api/media.ts +0 -218
  78. package/src/engine/api/messages.ts +0 -293
  79. package/src/engine/api/retry.ts +0 -217
  80. package/src/engine/api/routes.ts +0 -95
  81. package/src/engine/api/token.ts +0 -271
  82. package/src/engine/approval/index.test.ts +0 -22
  83. package/src/engine/approval/index.ts +0 -224
  84. package/src/engine/commands/builtin/log-helpers.ts +0 -319
  85. package/src/engine/commands/builtin/register-all.ts +0 -17
  86. package/src/engine/commands/builtin/register-approve.ts +0 -201
  87. package/src/engine/commands/builtin/register-basic.ts +0 -95
  88. package/src/engine/commands/builtin/register-clear-storage.ts +0 -187
  89. package/src/engine/commands/builtin/register-logs.ts +0 -20
  90. package/src/engine/commands/builtin/register-streaming.ts +0 -137
  91. package/src/engine/commands/builtin/state.ts +0 -31
  92. package/src/engine/commands/slash-command-auth.ts +0 -48
  93. package/src/engine/commands/slash-command-handler.ts +0 -146
  94. package/src/engine/commands/slash-commands-impl.test.ts +0 -8
  95. package/src/engine/commands/slash-commands-impl.ts +0 -61
  96. package/src/engine/commands/slash-commands.ts +0 -199
  97. package/src/engine/config/credential-backup.test.ts +0 -88
  98. package/src/engine/config/credential-backup.ts +0 -107
  99. package/src/engine/config/credentials.ts +0 -76
  100. package/src/engine/config/group.test.ts +0 -234
  101. package/src/engine/config/group.ts +0 -299
  102. package/src/engine/config/resolve.test.ts +0 -152
  103. package/src/engine/config/resolve.ts +0 -283
  104. package/src/engine/config/setup-logic.ts +0 -84
  105. package/src/engine/engine-import-boundary.test.ts +0 -73
  106. package/src/engine/gateway/codec.ts +0 -47
  107. package/src/engine/gateway/constants.ts +0 -117
  108. package/src/engine/gateway/event-dispatcher.ts +0 -177
  109. package/src/engine/gateway/gateway-connection.ts +0 -371
  110. package/src/engine/gateway/gateway.ts +0 -291
  111. package/src/engine/gateway/inbound-attachments.test.ts +0 -126
  112. package/src/engine/gateway/inbound-attachments.ts +0 -360
  113. package/src/engine/gateway/inbound-context.ts +0 -195
  114. package/src/engine/gateway/inbound-pipeline.self-echo.test.ts +0 -218
  115. package/src/engine/gateway/inbound-pipeline.ts +0 -235
  116. package/src/engine/gateway/interaction-handler.ts +0 -220
  117. package/src/engine/gateway/message-queue.test.ts +0 -282
  118. package/src/engine/gateway/message-queue.ts +0 -499
  119. package/src/engine/gateway/outbound-dispatch.test.ts +0 -231
  120. package/src/engine/gateway/outbound-dispatch.ts +0 -575
  121. package/src/engine/gateway/reconnect.ts +0 -199
  122. package/src/engine/gateway/stages/access-stage.ts +0 -132
  123. package/src/engine/gateway/stages/assembly-stage.ts +0 -156
  124. package/src/engine/gateway/stages/content-stage.test.ts +0 -77
  125. package/src/engine/gateway/stages/content-stage.ts +0 -77
  126. package/src/engine/gateway/stages/envelope-stage.test.ts +0 -152
  127. package/src/engine/gateway/stages/envelope-stage.ts +0 -144
  128. package/src/engine/gateway/stages/group-gate-stage.ts +0 -292
  129. package/src/engine/gateway/stages/index.ts +0 -18
  130. package/src/engine/gateway/stages/quote-stage.ts +0 -113
  131. package/src/engine/gateway/stages/refidx-stage.ts +0 -62
  132. package/src/engine/gateway/stages/stub-contexts.ts +0 -116
  133. package/src/engine/gateway/types.ts +0 -264
  134. package/src/engine/gateway/typing-keepalive.ts +0 -79
  135. package/src/engine/group/activation.test.ts +0 -114
  136. package/src/engine/group/activation.ts +0 -147
  137. package/src/engine/group/history.test.ts +0 -314
  138. package/src/engine/group/history.ts +0 -321
  139. package/src/engine/group/mention.test.ts +0 -141
  140. package/src/engine/group/mention.ts +0 -197
  141. package/src/engine/group/message-gating.test.ts +0 -188
  142. package/src/engine/group/message-gating.ts +0 -216
  143. package/src/engine/messaging/decode-media-path.ts +0 -82
  144. package/src/engine/messaging/media-source.ts +0 -215
  145. package/src/engine/messaging/media-type-detect.ts +0 -37
  146. package/src/engine/messaging/outbound-audio-port.ts +0 -38
  147. package/src/engine/messaging/outbound-deliver.ts +0 -810
  148. package/src/engine/messaging/outbound-media-send.ts +0 -702
  149. package/src/engine/messaging/outbound-reply.ts +0 -27
  150. package/src/engine/messaging/outbound-result-helpers.ts +0 -54
  151. package/src/engine/messaging/outbound-types.ts +0 -45
  152. package/src/engine/messaging/outbound.ts +0 -485
  153. package/src/engine/messaging/reply-dispatcher.ts +0 -597
  154. package/src/engine/messaging/reply-limiter.ts +0 -164
  155. package/src/engine/messaging/sender.ts +0 -729
  156. package/src/engine/messaging/streaming-c2c.ts +0 -1192
  157. package/src/engine/messaging/streaming-media-send.ts +0 -544
  158. package/src/engine/messaging/target-parser.ts +0 -104
  159. package/src/engine/ref/format-message-ref.ts +0 -142
  160. package/src/engine/ref/format-ref-entry.test.ts +0 -60
  161. package/src/engine/ref/format-ref-entry.ts +0 -27
  162. package/src/engine/ref/store.ts +0 -208
  163. package/src/engine/ref/types.ts +0 -27
  164. package/src/engine/session/known-users.ts +0 -137
  165. package/src/engine/session/session-store.ts +0 -204
  166. package/src/engine/tools/channel-api.ts +0 -244
  167. package/src/engine/tools/remind-logic.test.ts +0 -280
  168. package/src/engine/tools/remind-logic.ts +0 -377
  169. package/src/engine/types.ts +0 -313
  170. package/src/engine/utils/attachment-tags.test.ts +0 -186
  171. package/src/engine/utils/attachment-tags.ts +0 -174
  172. package/src/engine/utils/audio.test.ts +0 -250
  173. package/src/engine/utils/audio.ts +0 -585
  174. package/src/engine/utils/data-paths.ts +0 -38
  175. package/src/engine/utils/diagnostics.ts +0 -109
  176. package/src/engine/utils/file-utils.test.ts +0 -72
  177. package/src/engine/utils/file-utils.ts +0 -225
  178. package/src/engine/utils/format.test.ts +0 -68
  179. package/src/engine/utils/format.ts +0 -70
  180. package/src/engine/utils/image-size.test.ts +0 -158
  181. package/src/engine/utils/image-size.ts +0 -249
  182. package/src/engine/utils/log.test.ts +0 -28
  183. package/src/engine/utils/log.ts +0 -61
  184. package/src/engine/utils/media-tags.test.ts +0 -32
  185. package/src/engine/utils/media-tags.ts +0 -177
  186. package/src/engine/utils/payload.test.ts +0 -68
  187. package/src/engine/utils/payload.ts +0 -145
  188. package/src/engine/utils/platform-storage-laziness.test.ts +0 -63
  189. package/src/engine/utils/platform.test.ts +0 -148
  190. package/src/engine/utils/platform.ts +0 -343
  191. package/src/engine/utils/request-context.ts +0 -60
  192. package/src/engine/utils/string-normalize.ts +0 -91
  193. package/src/engine/utils/stt.test.ts +0 -104
  194. package/src/engine/utils/stt.ts +0 -100
  195. package/src/engine/utils/text-parsing.test.ts +0 -29
  196. package/src/engine/utils/text-parsing.ts +0 -155
  197. package/src/engine/utils/upload-cache.ts +0 -96
  198. package/src/engine/utils/voice-text.ts +0 -15
  199. package/src/exec-approvals.ts +0 -218
  200. package/src/manifest-schema.test.ts +0 -56
  201. package/src/qqbot-test-support.ts +0 -29
  202. package/src/types.ts +0 -210
  203. package/tsconfig.json +0 -16
@@ -1,313 +0,0 @@
1
- /**
2
- * Core API layer public types.
3
- *
4
- * These types are independent of the root `src/types.ts` and only define
5
- * what the `core/api/` modules need. The old `src/types.ts` remains
6
- * untouched for backward compatibility.
7
- */
8
-
9
- // ============ Structured API Error ============
10
-
11
- /**
12
- * Structured API error with HTTP status, path, and optional business error code.
13
- *
14
- * Compared to the old `api.ts` which throws plain `Error`, this carries
15
- * machine-readable fields for downstream retry/fallback decisions.
16
- */
17
- export class ApiError extends Error {
18
- override readonly name = "ApiError";
19
-
20
- constructor(
21
- message: string,
22
- /** HTTP status code returned by the QQ Open Platform. */
23
- public readonly httpStatus: number,
24
- /** API path that produced the error (e.g. `/v2/users/{id}/messages`). */
25
- public readonly path: string,
26
- /** Business error code from the response body (`code` or `err_code`). */
27
- public readonly bizCode?: number,
28
- /** Original error message from the response body. */
29
- public readonly bizMessage?: string,
30
- ) {
31
- super(message);
32
- }
33
- }
34
-
35
- // ============ Logger ============
36
-
37
- /**
38
- * Unified logger interface used across all engine/ modules.
39
- *
40
- * Replaces the previously fragmented ApiLogger, GatewayLogger, ReconnectLogger,
41
- * MessageRefLogger, PathLogger, and SenderLogger interfaces.
42
- *
43
- * `info` and `error` are required; `warn` and `debug` are optional because
44
- * some callers (e.g. the framework-injected `ctx.log`) may not provide them.
45
- */
46
- export interface EngineLogger {
47
- info: (msg: string, meta?: Record<string, unknown>) => void;
48
- error: (msg: string, meta?: Record<string, unknown>) => void;
49
- warn?: (msg: string, meta?: Record<string, unknown>) => void;
50
- debug?: (msg: string, meta?: Record<string, unknown>) => void;
51
- }
52
-
53
- // ============ Chat Scope ============
54
-
55
- /** Chat scope used to unify C2C/Group path construction. */
56
- export type ChatScope = "c2c" | "group";
57
-
58
- // ============ Message Response ============
59
-
60
- /** Standard message send response from the QQ Open Platform. */
61
- export interface MessageResponse {
62
- id: string;
63
- timestamp: number | string;
64
- /** Reference index for future quoting. */
65
- ext_info?: {
66
- ref_idx?: string;
67
- };
68
- }
69
-
70
- // ============ Media Types ============
71
-
72
- /** QQ Open Platform media file type codes. */
73
- export enum MediaFileType {
74
- IMAGE = 1,
75
- VIDEO = 2,
76
- VOICE = 3,
77
- FILE = 4,
78
- }
79
-
80
- /** Media upload response from the QQ Open Platform. */
81
- export interface UploadMediaResponse {
82
- file_uuid: string;
83
- file_info: string;
84
- ttl: number;
85
- id?: string;
86
- }
87
-
88
- /** Structured metadata recorded for outbound messages. */
89
- export interface OutboundMeta {
90
- /** Message text content. */
91
- text?: string;
92
- /** Media type tag. */
93
- mediaType?: "image" | "voice" | "video" | "file";
94
- /** Remote URL of the media source. */
95
- mediaUrl?: string;
96
- /** Local file path of the media source. */
97
- mediaLocalPath?: string;
98
- /** Original TTS text (voice messages only). */
99
- ttsText?: string;
100
- }
101
-
102
- // ============ API Client Config ============
103
-
104
- /** Configuration for the core HTTP client. */
105
- export interface ApiClientConfig {
106
- /** Base URL for the QQ Open Platform REST API. */
107
- baseUrl?: string;
108
- /** Default request timeout in milliseconds. */
109
- defaultTimeoutMs?: number;
110
- /** File upload request timeout in milliseconds. */
111
- fileUploadTimeoutMs?: number;
112
- /** Logger instance. */
113
- logger?: EngineLogger;
114
- /** User-Agent header value, or a getter function for dynamic resolution. */
115
- userAgent?: string | (() => string);
116
- }
117
-
118
- // ============ Chunked Upload Types ============
119
-
120
- /** Individual upload part metadata. */
121
- export interface UploadPart {
122
- /** Part index (1-based). */
123
- index: number;
124
- /** Pre-signed upload URL. */
125
- presigned_url: string;
126
- }
127
-
128
- /** Response from the upload_prepare endpoint. */
129
- export interface UploadPrepareResponse {
130
- /** Upload task identifier. */
131
- upload_id: string;
132
- /** Block size in bytes. */
133
- block_size: number;
134
- /** Pre-signed upload parts. */
135
- parts: UploadPart[];
136
- /** Server-suggested upload concurrency. */
137
- concurrency?: number;
138
- /** Server-suggested retry timeout for upload_part_finish (seconds). */
139
- retry_timeout?: number;
140
- }
141
-
142
- /** File hash information for upload_prepare. */
143
- export interface UploadPrepareHashes {
144
- /** Whole-file MD5 (hex). */
145
- md5: string;
146
- /** Whole-file SHA1 (hex). */
147
- sha1: string;
148
- /** MD5 of the first 10,002,432 bytes (hex). */
149
- md5_10m: string;
150
- }
151
-
152
- // ============ Stream Message Types ============
153
-
154
- /** Stream message input mode (C2C stream_messages API). */
155
- export const StreamInputMode = {
156
- /** Each chunk replaces full message content. */
157
- REPLACE: "replace",
158
- } as const;
159
- export type StreamInputMode = (typeof StreamInputMode)[keyof typeof StreamInputMode];
160
-
161
- /** Stream message input state (numeric per QQ Open Platform). */
162
- export const StreamInputState = {
163
- GENERATING: 1,
164
- DONE: 10,
165
- } as const;
166
- export type StreamInputState = (typeof StreamInputState)[keyof typeof StreamInputState];
167
-
168
- /** Stream message content type. */
169
- export const StreamContentType = {
170
- MARKDOWN: "markdown",
171
- } as const;
172
- export type StreamContentType = (typeof StreamContentType)[keyof typeof StreamContentType];
173
-
174
- /** Stream message request body for `/v2/users/{openid}/stream_messages`. */
175
- export interface StreamMessageRequest {
176
- input_mode: StreamInputMode;
177
- input_state: StreamInputState;
178
- content_type: StreamContentType;
179
- content_raw: string;
180
- event_id: string;
181
- msg_id: string;
182
- stream_msg_id?: string;
183
- msg_seq: number;
184
- index: number;
185
- }
186
-
187
- // ============ Inline Keyboard Types ============
188
-
189
- /** Inline keyboard button for approval/interaction flows. */
190
- export interface KeyboardButton {
191
- id: string;
192
- render_data: {
193
- label: string;
194
- visited_label: string;
195
- style: number;
196
- };
197
- action: {
198
- type: number;
199
- permission: { type: number };
200
- data: string;
201
- click_limit?: number;
202
- };
203
- group_id?: string;
204
- }
205
-
206
- /**
207
- * Inline keyboard structure attached to messages.
208
- * Sent as the `keyboard` field in the message body:
209
- * `{ "keyboard": { "content": { "rows": [...] } } }`
210
- */
211
- export interface InlineKeyboard {
212
- content: {
213
- rows: Array<{ buttons: KeyboardButton[] }>;
214
- };
215
- }
216
-
217
- // ============ Interaction Event Types ============
218
-
219
- /** Button interaction event (INTERACTION_CREATE). */
220
- export interface InteractionEvent {
221
- /** Event ID — used to acknowledge the interaction (PUT /interactions/{id}). */
222
- id: string;
223
- /** Event sub-type: 11=message button, 12=c2c quick menu. */
224
- type: number;
225
- /** Scene identifier: c2c / group / guild. */
226
- scene?: string;
227
- /** Chat type: 0=guild, 1=group, 2=c2c. */
228
- chat_type?: number;
229
- timestamp?: string;
230
- guild_id?: string;
231
- channel_id?: string;
232
- /** C2C user openid (c2c scene only). */
233
- user_openid?: string;
234
- /** Group openid (group scene only). */
235
- group_openid?: string;
236
- /** Group member openid (group scene only). */
237
- group_member_openid?: string;
238
- version: number;
239
- data: {
240
- type: number;
241
- resolved: {
242
- button_data?: string;
243
- button_id?: string;
244
- user_id?: string;
245
- feature_id?: string;
246
- message_id?: string;
247
- };
248
- };
249
- }
250
-
251
- // ============ Account Config View ============
252
-
253
- import type { QQBotDmPolicy, QQBotGroupPolicy } from "./access/types.js";
254
-
255
- /**
256
- * Typed view of known per-account configuration fields.
257
- *
258
- * Used for `as QQBotAccountConfigView` casts when reading fields from
259
- * the raw `Record<string, unknown>` config. The actual config type
260
- * stays `Record<string, unknown>` to avoid schema incompatibility.
261
- */
262
- export interface QQBotAccountConfigView {
263
- allowFrom?: Array<string | number>;
264
- groupAllowFrom?: Array<string | number>;
265
- dmPolicy?: QQBotDmPolicy;
266
- groupPolicy?: QQBotGroupPolicy;
267
- groups?: Record<string, Record<string, unknown>>;
268
- streaming?:
269
- | boolean
270
- | {
271
- mode?: string;
272
- c2cStreamApi?: boolean;
273
- };
274
- audioFormatPolicy?: {
275
- uploadDirectFormats?: string[];
276
- transcodeEnabled?: boolean;
277
- };
278
- voiceDirectUploadFormats?: string[];
279
- }
280
-
281
- // ============ Gateway Account ============
282
-
283
- /**
284
- * Resolved account configuration — shared across gateway/ and messaging/ layers.
285
- *
286
- * Lifted here from gateway/types.ts to eliminate the circular type dependency
287
- * where messaging/ had to import from gateway/.
288
- */
289
- export interface GatewayAccount {
290
- accountId: string;
291
- appId: string;
292
- clientSecret: string;
293
- markdownSupport: boolean;
294
- systemPrompt?: string;
295
- config: Record<string, unknown> & {
296
- allowFrom?: Array<string | number>;
297
- groupAllowFrom?: Array<string | number>;
298
- dmPolicy?: "open" | "allowlist" | "disabled";
299
- groupPolicy?: "open" | "allowlist" | "disabled";
300
- streaming?:
301
- | boolean
302
- | {
303
- mode?: string;
304
- /** When true, use QQ C2C `stream_messages` for DMs. Boolean `true` is equivalent. */
305
- c2cStreamApi?: boolean;
306
- };
307
- audioFormatPolicy?: {
308
- uploadDirectFormats?: string[];
309
- transcodeEnabled?: boolean;
310
- };
311
- voiceDirectUploadFormats?: string[];
312
- };
313
- }
@@ -1,186 +0,0 @@
1
- import { describe, expect, it } from "vitest";
2
- import {
3
- formatAttachmentTags,
4
- renderAttachmentTags,
5
- TRANSCRIPT_SOURCE_LABELS,
6
- type AttachmentSummary,
7
- } from "./attachment-tags.js";
8
-
9
- describe("engine/utils/attachment-tags", () => {
10
- // ────────────────────────── shared body (mode-agnostic) ──────────────────────────
11
-
12
- describe("shared tag body", () => {
13
- it("returns empty string for missing/empty input", () => {
14
- expect(formatAttachmentTags()).toBe("");
15
- expect(formatAttachmentTags([])).toBe("");
16
- });
17
-
18
- it("collapses to MEDIA:{source} when a path/url is present", () => {
19
- expect(formatAttachmentTags([{ type: "image", localPath: "/tmp/a.png" }])).toBe(
20
- "MEDIA:/tmp/a.png",
21
- );
22
- expect(formatAttachmentTags([{ type: "file", url: "https://x/y.pdf" }])).toBe(
23
- "MEDIA:https://x/y.pdf",
24
- );
25
- });
26
-
27
- it("inlines voice transcript only for voice attachments", () => {
28
- expect(
29
- formatAttachmentTags([{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi" }]),
30
- ).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
31
- // Non-voice attachments never get the transcript suffix even if one
32
- // is present on the summary.
33
- expect(
34
- formatAttachmentTags([
35
- { type: "image", localPath: "/tmp/i.png", transcript: "unused" } as AttachmentSummary,
36
- ]),
37
- ).toBe("MEDIA:/tmp/i.png");
38
- });
39
-
40
- it("falls back to bracketed tags when no source is available", () => {
41
- expect(formatAttachmentTags([{ type: "image" }])).toBe("[image]");
42
- expect(formatAttachmentTags([{ type: "image", filename: "a.png" }])).toBe("[image: a.png]");
43
- expect(formatAttachmentTags([{ type: "voice" }])).toBe("[voice]");
44
- expect(formatAttachmentTags([{ type: "voice", transcript: "t" }])).toBe(
45
- '[voice (transcript: "t")]',
46
- );
47
- expect(formatAttachmentTags([{ type: "video" }])).toBe("[video]");
48
- expect(formatAttachmentTags([{ type: "file", filename: "b.pdf" }])).toBe("[file: b.pdf]");
49
- expect(formatAttachmentTags([{ type: "unknown" }])).toBe("[attachment]");
50
- });
51
-
52
- it("joins multiple entries with newline in inline mode", () => {
53
- expect(
54
- formatAttachmentTags([
55
- { type: "image", localPath: "/tmp/a.png" },
56
- { type: "voice", transcript: "hi" },
57
- ]),
58
- ).toBe('MEDIA:/tmp/a.png\n[voice (transcript: "hi")]');
59
- });
60
- });
61
-
62
- // ────────────────────────── ref mode = body + source suffix ──────────────────────────
63
-
64
- describe("ref mode consistency with inline", () => {
65
- it("produces the same body as inline for non-voice attachments", () => {
66
- const att: AttachmentSummary[] = [
67
- { type: "image", localPath: "/tmp/a.png" },
68
- { type: "file", filename: "b.pdf" },
69
- ];
70
- // Rendered one at a time so separator differences don't matter.
71
- for (const a of att) {
72
- expect(renderAttachmentTags([a], { mode: "inline" })).toBe(
73
- renderAttachmentTags([a], { mode: "ref" }),
74
- );
75
- }
76
- });
77
-
78
- it("produces the same body as inline for voice without transcriptSource", () => {
79
- const cases: AttachmentSummary[] = [
80
- { type: "voice" },
81
- { type: "voice", transcript: "hi" },
82
- { type: "voice", localPath: "/tmp/v.wav", transcript: "hi" },
83
- ];
84
- for (const a of cases) {
85
- expect(renderAttachmentTags([a], { mode: "inline" })).toBe(
86
- renderAttachmentTags([a], { mode: "ref" }),
87
- );
88
- }
89
- });
90
-
91
- it("appends ' [source: …]' ONLY for voice + transcript + transcriptSource in ref mode", () => {
92
- // ref mode: suffix appears.
93
- expect(
94
- renderAttachmentTags(
95
- [{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }],
96
- { mode: "ref" },
97
- ),
98
- ).toBe('MEDIA:/tmp/v.wav (transcript: "hi") [source: local STT]');
99
-
100
- // inline mode: suffix NEVER appears, even with transcriptSource set.
101
- expect(
102
- renderAttachmentTags(
103
- [{ type: "voice", localPath: "/tmp/v.wav", transcript: "hi", transcriptSource: "stt" }],
104
- { mode: "inline" },
105
- ),
106
- ).toBe('MEDIA:/tmp/v.wav (transcript: "hi")');
107
- });
108
-
109
- it("omits the source suffix when transcriptSource is missing (both modes identical)", () => {
110
- const att: AttachmentSummary = { type: "voice", transcript: "hi" };
111
- expect(renderAttachmentTags([att], { mode: "ref" })).toBe(
112
- renderAttachmentTags([att], { mode: "inline" }),
113
- );
114
- });
115
-
116
- it("joins with space in ref mode", () => {
117
- expect(
118
- renderAttachmentTags(
119
- [
120
- { type: "image", filename: "a.png" },
121
- { type: "voice", transcript: "hi" },
122
- ],
123
- { mode: "ref" },
124
- ),
125
- ).toBe('[image: a.png] [voice (transcript: "hi")]');
126
- });
127
- });
128
-
129
- // ────────────────────────── Prompt-contract regression guards ──────────────────────────
130
-
131
- describe("prompt contract", () => {
132
- it("exposes the transcript-source labels table", () => {
133
- expect(TRANSCRIPT_SOURCE_LABELS.stt).toBe("local STT");
134
- expect(TRANSCRIPT_SOURCE_LABELS.asr).toBe("platform ASR");
135
- expect(TRANSCRIPT_SOURCE_LABELS.tts).toBe("TTS source");
136
- expect(TRANSCRIPT_SOURCE_LABELS.fallback).toBe("fallback text");
137
- });
138
-
139
- it("uses the single canonical keyword 'transcript:' (never 'content:')", () => {
140
- // If anyone reintroduces 'content:' the regex below will match and fail the test.
141
- const samples = [
142
- formatAttachmentTags([{ type: "voice", transcript: "t" }]),
143
- renderAttachmentTags([{ type: "voice", transcript: "t", transcriptSource: "asr" }], {
144
- mode: "ref",
145
- }),
146
- ];
147
- for (const s of samples) {
148
- expect(s).toMatch(/transcript:/);
149
- expect(s).not.toMatch(/content:/);
150
- }
151
- });
152
-
153
- it("uses the single canonical type label 'voice' (never 'voice message')", () => {
154
- const samples = [
155
- renderAttachmentTags([{ type: "voice" }], { mode: "inline" }),
156
- renderAttachmentTags([{ type: "voice", transcript: "hi" }], { mode: "ref" }),
157
- ];
158
- for (const s of samples) {
159
- expect(s).not.toMatch(/voice message/);
160
- }
161
- });
162
- });
163
-
164
- // ────────────────────────── Options ──────────────────────────
165
-
166
- describe("options", () => {
167
- it("respects a custom separator", () => {
168
- expect(
169
- renderAttachmentTags(
170
- [
171
- { type: "image", filename: "a" },
172
- { type: "video", filename: "b" },
173
- ],
174
- { mode: "inline", separator: " | " },
175
- ),
176
- ).toBe("[image: a] | [video: b]");
177
- });
178
-
179
- it("returns the emptyFallback when input is empty", () => {
180
- expect(renderAttachmentTags(undefined, { mode: "ref", emptyFallback: "(none)" })).toBe(
181
- "(none)",
182
- );
183
- expect(renderAttachmentTags([], { mode: "inline", emptyFallback: "" })).toBe("");
184
- });
185
- });
186
- });
@@ -1,174 +0,0 @@
1
- /**
2
- * Single source of truth for rendering attachment summaries as
3
- * human-readable tags that the LLM sees.
4
- *
5
- * There is exactly ONE vocabulary shared by every consumer:
6
- *
7
- * • Type labels: `image` / `voice` / `video` / `file` / `attachment`
8
- * • Keyword for voice text: `transcript:` (never `content:`)
9
- * • With source: `MEDIA:{source}` (no bracketed alias)
10
- * • Without source: `[{type}]` or `[{type}: {filename}]`
11
- *
12
- * Both consumers (group history / current inbound turn, and the ref-index
13
- * quoted-message block) call the same function with the same vocabulary.
14
- * They differ only on two orthogonal dimensions:
15
- *
16
- * 1. `transcriptSource` — ref mode appends `[source: local STT]` (or
17
- * similar) after a voice transcript so the model knows where the
18
- * text came from. Inline mode omits this (the current turn knows
19
- * its own STT provenance).
20
- *
21
- * 2. Separator — inline joins with `\n` (history replay is multi-line),
22
- * ref joins with a space (quoted block is rendered inline).
23
- *
24
- * These are the ONLY permitted differences between modes. Any new
25
- * decoration must be added in both modes or behind an explicit option
26
- * documented here, otherwise the model ends up learning two dialects.
27
- *
28
- * Zero external dependencies — pure string formatting.
29
- */
30
-
31
- import type { RefAttachmentSummary } from "../ref/types.js";
32
-
33
- // ============ Types ============
34
-
35
- /** Canonical attachment shape shared by history entries and ref entries. */
36
- export type AttachmentSummary = RefAttachmentSummary;
37
-
38
- /**
39
- * Rendering mode.
40
- *
41
- * - `"inline"`: current turn + history replay. No transcript-source tag.
42
- * Tags are separated by newlines.
43
- * - `"ref"`: quoted-message block. Appends `[source: …]` to voice
44
- * transcripts when `transcriptSource` is present. Tags are separated
45
- * by spaces so the block fits on one line.
46
- */
47
- type RenderMode = "inline" | "ref";
48
-
49
- /** Human-readable labels for transcript provenance (prompt contract). */
50
- export const TRANSCRIPT_SOURCE_LABELS: Record<
51
- NonNullable<RefAttachmentSummary["transcriptSource"]>,
52
- string
53
- > = {
54
- stt: "local STT",
55
- asr: "platform ASR",
56
- tts: "TTS source",
57
- fallback: "fallback text",
58
- };
59
-
60
- /** Options controlling how the tag list is rendered. */
61
- interface RenderOptions {
62
- mode: RenderMode;
63
- /** Separator between tags. Defaults per mode: inline=`\n`, ref=` `. */
64
- separator?: string;
65
- /** Returned when `attachments` is empty/undefined. Defaults to `""`. */
66
- emptyFallback?: string;
67
- }
68
-
69
- // ============ Public API ============
70
-
71
- /**
72
- * Render a list of attachments into an LLM-facing tag string.
73
- *
74
- * Shared grammar (both modes):
75
- *
76
- * ```
77
- * attachment_with_source := "MEDIA:" SOURCE [voice_suffix]
78
- * voice_suffix := ' (transcript: "' TEXT '")' [source_suffix]
79
- * attachment_no_source := "[" TYPE_LABEL [": " FILENAME] [voice_suffix_bare] "]" [source_suffix_bare]
80
- * voice_suffix_bare := ' (transcript: "' TEXT '")'
81
- * source_suffix := " [source: " LABEL "]" ← ref mode only
82
- * source_suffix_bare := " [source: " LABEL "]" ← ref mode only
83
- * TYPE_LABEL := "image" | "voice" | "video" | "file" | "attachment"
84
- * ```
85
- *
86
- * The **only** mode-dependent decoration is the `source_suffix` (present
87
- * in `ref`, absent in `inline`). Every other token is identical.
88
- */
89
- export function renderAttachmentTags(
90
- attachments: readonly AttachmentSummary[] | undefined,
91
- options: RenderOptions,
92
- ): string {
93
- if (!attachments?.length) {
94
- return options.emptyFallback ?? "";
95
- }
96
-
97
- const parts: string[] = [];
98
- for (const att of attachments) {
99
- parts.push(renderOne(att, options.mode));
100
- }
101
-
102
- const separator = options.separator ?? (options.mode === "ref" ? " " : "\n");
103
- return parts.join(separator);
104
- }
105
-
106
- /**
107
- * Shorthand for `renderAttachmentTags(attachments, { mode: "inline" })`.
108
- *
109
- * Kept as the primary entry point for group history / current-turn
110
- * rendering where the terse inline form is always wanted.
111
- */
112
- export function formatAttachmentTags(attachments?: readonly AttachmentSummary[]): string {
113
- return renderAttachmentTags(attachments, { mode: "inline" });
114
- }
115
-
116
- // ============ Internal ============
117
-
118
- /**
119
- * Render a single attachment.
120
- *
121
- * The function is split into two orthogonal concerns:
122
- * - `renderBody`: the shared "MEDIA:{source}…" or "[type…]" string.
123
- * - `renderSourceSuffix`: ref-mode-only `" [source: …]"` tail.
124
- *
125
- * Both consumers produce the same body; only the suffix differs.
126
- */
127
- function renderOne(att: AttachmentSummary, mode: RenderMode): string {
128
- const body = renderBody(att);
129
- const suffix = mode === "ref" ? renderSourceSuffix(att) : "";
130
- return body + suffix;
131
- }
132
-
133
- /** Shared, mode-agnostic body of the tag. */
134
- function renderBody(att: AttachmentSummary): string {
135
- const source = att.localPath || att.url;
136
- const voiceSuffix =
137
- att.type === "voice" && att.transcript ? ` (transcript: "${att.transcript}")` : "";
138
-
139
- if (source) {
140
- return `MEDIA:${source}${voiceSuffix}`;
141
- }
142
-
143
- const label = labelForType(att.type);
144
- const namePart = att.filename ? `: ${att.filename}` : "";
145
- return `[${label}${namePart}${voiceSuffix}]`;
146
- }
147
-
148
- /**
149
- * Ref-mode-only tail that records where a voice transcript came from.
150
- * Empty string when the attachment isn't a transcribed voice message.
151
- */
152
- function renderSourceSuffix(att: AttachmentSummary): string {
153
- if (att.type !== "voice" || !att.transcript || !att.transcriptSource) {
154
- return "";
155
- }
156
- const label = TRANSCRIPT_SOURCE_LABELS[att.transcriptSource] ?? att.transcriptSource;
157
- return ` [source: ${label}]`;
158
- }
159
-
160
- /** Canonical single-word label for each attachment type. */
161
- function labelForType(type: AttachmentSummary["type"]): string {
162
- switch (type) {
163
- case "image":
164
- return "image";
165
- case "voice":
166
- return "voice";
167
- case "video":
168
- return "video";
169
- case "file":
170
- return "file";
171
- default:
172
- return "attachment";
173
- }
174
- }