vessels 0.6.0 → 0.8.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.
@@ -0,0 +1,545 @@
1
+ /**
2
+ * THE VESSELS CONTROL TOOLS — the fixed set of tools the model uses to talk to the
3
+ * operator (lead with a reply, plan, narrate, show a surface, ask for a decision,
4
+ * finish). These mirror the Vessels product 1:1 and are domain-free; you normally
5
+ * leave this file alone and add YOUR tools in `tools.ts`.
6
+ *
7
+ * Also here: the defensive sanitisers (the model occasionally emits a malformed
8
+ * optional field — one bad field would 400 the whole push, dropping the message and
9
+ * the state it carries), the interaction-payload builder, and the human-response
10
+ * renderer.
11
+ */
12
+ import type { Tool } from '@anthropic-ai/sdk/resources/messages';
13
+ import type { AgentActivityType } from 'vessels-sdk';
14
+
15
+ // ─── Shared schema fragments ────────────────────────────────────────────────────
16
+
17
+ const CARD_SCHEMA = {
18
+ type: 'object' as const,
19
+ properties: {
20
+ title: { type: 'string' },
21
+ fields: {
22
+ type: 'array',
23
+ items: {
24
+ type: 'object',
25
+ properties: { label: { type: 'string' }, value: { type: 'string' } },
26
+ required: ['label', 'value'],
27
+ },
28
+ },
29
+ },
30
+ required: ['title', 'fields'],
31
+ };
32
+
33
+ /** Inject this into a tool's input schema so the model can tick the plan in the same call. */
34
+ export const TASK_FIELD = {
35
+ type: 'string' as const,
36
+ description:
37
+ 'The EXACT plan() task label this work belongs to — ticks the plan to it (no separate step() needed).',
38
+ };
39
+
40
+ const OPTIONS_SCHEMA = {
41
+ type: 'array' as const,
42
+ items: {
43
+ type: 'object',
44
+ properties: { id: { type: 'string' }, label: { type: 'string' } },
45
+ required: ['id', 'label'],
46
+ },
47
+ minItems: 1,
48
+ };
49
+
50
+ const PIN_CARD_FIELD = {
51
+ ...CARD_SCHEMA,
52
+ description:
53
+ "Update the vessel's pinned header card to the entity's current state. Re-send the full card to replace it.",
54
+ };
55
+
56
+ const LABELS_FIELD = {
57
+ type: 'array' as const,
58
+ items: { type: 'string' },
59
+ maxItems: 10,
60
+ description: "Replace the vessel's triage labels with this full set (≤10). Only send if they should change.",
61
+ };
62
+
63
+ const ATTACHMENTS_FIELD = {
64
+ type: 'array' as const,
65
+ maxItems: 10,
66
+ items: {
67
+ type: 'object',
68
+ properties: {
69
+ type: { type: 'string', enum: ['image', 'file'] },
70
+ url: { type: 'string', description: 'A public http(s) URL YOU host. Vessels renders it; it does not store files.' },
71
+ filename: { type: 'string', description: 'Display name for a file (optional).' },
72
+ },
73
+ required: ['type', 'url'],
74
+ },
75
+ description: 'Images render inline; files render as a download link. Only URLs you already host (e.g. from a backend tool).',
76
+ };
77
+
78
+ const METADATA_FIELD = {
79
+ type: 'object' as const,
80
+ description:
81
+ 'Opaque key/value data that rides back to YOU verbatim in the webhook for this interaction (routing/correlation — e.g. your own record id). Vessels never interprets it.',
82
+ additionalProperties: true,
83
+ };
84
+
85
+ const KEEP_WORKING_FIELD = {
86
+ type: 'boolean' as const,
87
+ description:
88
+ 'TRUE when this question is a MID-PLAN checkpoint — you still have remaining plan steps to do AFTER you get the answer. The working card stays live (paused on the operator, plan intact, not greyed out), and you pick the SAME plan back up on their reply instead of starting over. Use it for a multi-step plan where one step needs a sign-off before the next. OMIT (or false) for the FINAL decision of the turn — that seals the plan and hands back.',
89
+ };
90
+
91
+ // ─── The control tools ──────────────────────────────────────────────────────────
92
+
93
+ export const CONTROL_TOOLS: Tool[] = [
94
+ {
95
+ name: 'plan',
96
+ description:
97
+ 'Declare your task list on the live working card. Tasks tick off as you work. Call once early; you may call again to revise the list.',
98
+ input_schema: {
99
+ type: 'object',
100
+ properties: {
101
+ todos: {
102
+ type: 'array',
103
+ items: { type: 'string', description: 'A short task label' },
104
+ minItems: 1,
105
+ maxItems: 12,
106
+ },
107
+ vesselTitle: {
108
+ type: 'string',
109
+ description:
110
+ "Set or RENAME this vessel's title (≤6 words, specific). You can rename ANY time the operator asks or when a clearer name emerges. Omit when you are not (re)naming.",
111
+ },
112
+ labels: {
113
+ type: 'array',
114
+ items: { type: 'string' },
115
+ maxItems: 10,
116
+ description:
117
+ 'Triage tags for this vessel, in your own vocabulary. REPLACE-semantics — send the full set. Set 1–3 on the first plan() of a vessel; omit later unless they change.',
118
+ },
119
+ pinCard: {
120
+ ...CARD_SCHEMA,
121
+ description:
122
+ "A persistent header card pinning the entity's current state at a glance — stays put as the chat scrolls. Set it when you first have the key facts; re-send to UPDATE it as state changes.",
123
+ },
124
+ },
125
+ required: ['todos'],
126
+ },
127
+ },
128
+ {
129
+ name: 'step',
130
+ description:
131
+ 'Tick the plan to the task you are now working on (EXACT label from plan()). The backend tools you call next auto-narrate under it — you do NOT write the step text yourself.',
132
+ input_schema: {
133
+ type: 'object',
134
+ properties: {
135
+ task: { type: 'string', description: 'The exact plan task label you are now on' },
136
+ },
137
+ required: ['task'],
138
+ },
139
+ },
140
+ {
141
+ name: 'send_update',
142
+ description: 'Post a progress message to the operator, then keep working this turn. Not a question.',
143
+ input_schema: {
144
+ type: 'object',
145
+ properties: {
146
+ message: { type: 'string' },
147
+ card: CARD_SCHEMA,
148
+ suggestions: { type: 'array', items: { type: 'string' }, maxItems: 5, description: 'Up to 5 tappable quick-reply chips that pre-fill the human\'s input.' },
149
+ attachments: ATTACHMENTS_FIELD,
150
+ previewUrl: { type: 'string', description: 'A single link card shown under the message (a draft/dashboard to open). Presentation only, no decision.' },
151
+ task: TASK_FIELD,
152
+ },
153
+ required: ['message'],
154
+ },
155
+ },
156
+ {
157
+ name: 'show_document',
158
+ description:
159
+ 'Show the operator a read-only ARTIFACT — a composed document they read but do not act on (a summary, report, proposal, breakdown). Renders as a full-width SURFACE: a title heading, an optional card of glance-facts, and a block-markdown body. Does NOT end the turn and has no buttons. For something that needs a DECISION, attach an interaction with a request_* tool instead.',
160
+ input_schema: {
161
+ type: 'object',
162
+ properties: {
163
+ title: { type: 'string', description: 'The surface heading' },
164
+ body: {
165
+ type: 'string',
166
+ description:
167
+ 'The artifact in block markdown — tables (| a | b |), bullet lists (- ), numbered lists (1. ), blockquotes (> ), bold headings (## ), **bold**, *italic*, `code`, [links](url).',
168
+ },
169
+ card: CARD_SCHEMA,
170
+ attachments: ATTACHMENTS_FIELD,
171
+ pinCard: PIN_CARD_FIELD,
172
+ labels: LABELS_FIELD,
173
+ },
174
+ required: ['title', 'body'],
175
+ },
176
+ },
177
+ {
178
+ name: 'request_approval',
179
+ description: 'Ask the operator to approve or reject — renders as a full-width Review Card SURFACE. Ends your turn.',
180
+ input_schema: {
181
+ type: 'object',
182
+ properties: {
183
+ title: { type: 'string', description: 'The surface heading.' },
184
+ message: {
185
+ type: 'string',
186
+ description:
187
+ 'The artifact BODY in block markdown — the thing being reviewed. If approving text you wrote, put the FULL text here so they read the real words inline, never a "draft is ready" summary. Use tables/lists/blockquotes/**bold** as needed.',
188
+ },
189
+ prompt: { type: 'string', description: 'The yes/no decision question, shown under the title' },
190
+ approveLabel: { type: 'string' },
191
+ rejectLabel: { type: 'string' },
192
+ reasonRequired: { type: 'boolean', description: 'Require the human to type a reason with their approve/reject.' },
193
+ previewUrl: { type: 'string', description: 'Optional link for an artifact too rich to inline. For text, inline it in `message`.' },
194
+ card: CARD_SCHEMA,
195
+ pinCard: PIN_CARD_FIELD,
196
+ labels: LABELS_FIELD,
197
+ metadata: METADATA_FIELD,
198
+ keepWorking: KEEP_WORKING_FIELD,
199
+ },
200
+ required: ['message', 'prompt'],
201
+ },
202
+ },
203
+ {
204
+ name: 'request_choice',
205
+ description: 'Ask the operator to pick one option — a full-width surface. Ends your turn.',
206
+ input_schema: {
207
+ type: 'object',
208
+ properties: {
209
+ title: { type: 'string', description: 'The surface heading' },
210
+ message: { type: 'string', description: 'Body / context (block markdown)' },
211
+ prompt: { type: 'string', description: 'The decision question' },
212
+ options: OPTIONS_SCHEMA,
213
+ allowCustom: { type: 'boolean', description: 'Let the human type their own answer instead of picking an option.' },
214
+ customPlaceholder: { type: 'string', description: 'Placeholder for the custom-answer field (when allowCustom).' },
215
+ card: CARD_SCHEMA,
216
+ pinCard: PIN_CARD_FIELD,
217
+ labels: LABELS_FIELD,
218
+ metadata: METADATA_FIELD,
219
+ keepWorking: KEEP_WORKING_FIELD,
220
+ },
221
+ required: ['message', 'prompt', 'options'],
222
+ },
223
+ },
224
+ {
225
+ name: 'request_checklist',
226
+ description: 'Ask the operator to select one or more options — a full-width surface. Ends your turn.',
227
+ input_schema: {
228
+ type: 'object',
229
+ properties: {
230
+ title: { type: 'string', description: 'The surface heading' },
231
+ message: { type: 'string', description: 'Body / context (block markdown)' },
232
+ prompt: { type: 'string', description: 'The decision question' },
233
+ options: OPTIONS_SCHEMA,
234
+ minSelections: { type: 'number', description: 'Minimum number the human must select.' },
235
+ submitLabel: { type: 'string', description: 'Label for the submit button.' },
236
+ card: CARD_SCHEMA,
237
+ pinCard: PIN_CARD_FIELD,
238
+ labels: LABELS_FIELD,
239
+ metadata: METADATA_FIELD,
240
+ keepWorking: KEEP_WORKING_FIELD,
241
+ },
242
+ required: ['message', 'prompt', 'options'],
243
+ },
244
+ },
245
+ {
246
+ name: 'request_text',
247
+ description: 'Ask the operator for a free-text answer — a full-width surface. Ends your turn.',
248
+ input_schema: {
249
+ type: 'object',
250
+ properties: {
251
+ title: { type: 'string', description: 'The surface heading' },
252
+ message: { type: 'string', description: 'Body / context (block markdown)' },
253
+ prompt: { type: 'string', description: 'The question' },
254
+ placeholder: { type: 'string' },
255
+ multiline: { type: 'boolean' },
256
+ submitLabel: { type: 'string', description: 'Label for the submit button.' },
257
+ pinCard: PIN_CARD_FIELD,
258
+ labels: LABELS_FIELD,
259
+ metadata: METADATA_FIELD,
260
+ keepWorking: KEEP_WORKING_FIELD,
261
+ },
262
+ required: ['message', 'prompt'],
263
+ },
264
+ },
265
+ {
266
+ name: 'request_questions',
267
+ description:
268
+ 'Ask the operator SEVERAL questions AT ONCE — a short form they fill in and submit together. Each question is a single- or multi-select over 2–4 options, with an optional free-text Other. Use when a step needs a few answers at once instead of a back-and-forth. A full-width surface; ends your turn (or pauses it mid-plan with keepWorking).',
269
+ input_schema: {
270
+ type: 'object',
271
+ properties: {
272
+ title: { type: 'string', description: 'The surface heading' },
273
+ message: { type: 'string', description: 'Optional context body (block markdown) above the questions' },
274
+ prompt: { type: 'string', description: 'One line framing the batch of questions' },
275
+ questions: {
276
+ type: 'array',
277
+ minItems: 1,
278
+ maxItems: 4,
279
+ items: {
280
+ type: 'object',
281
+ properties: {
282
+ id: { type: 'string', description: 'Stable id used to key this answer' },
283
+ question: { type: 'string', description: 'The question text' },
284
+ header: { type: 'string', description: 'Short chip label, ≤12 chars (e.g. "Date", "Guests")' },
285
+ options: {
286
+ type: 'array',
287
+ minItems: 2,
288
+ maxItems: 4,
289
+ items: {
290
+ type: 'object',
291
+ properties: {
292
+ id: { type: 'string' },
293
+ label: { type: 'string' },
294
+ description: { type: 'string', description: 'Optional one-line explanation' },
295
+ },
296
+ required: ['id', 'label'],
297
+ },
298
+ },
299
+ multiSelect: { type: 'boolean', description: 'Allow more than one option (checkboxes).' },
300
+ allowOther: { type: 'boolean', description: 'Offer a free-text Other field (default true).' },
301
+ },
302
+ required: ['id', 'question', 'options'],
303
+ },
304
+ },
305
+ submitLabel: { type: 'string' },
306
+ pinCard: PIN_CARD_FIELD,
307
+ labels: LABELS_FIELD,
308
+ metadata: METADATA_FIELD,
309
+ keepWorking: KEEP_WORKING_FIELD,
310
+ },
311
+ required: ['prompt', 'questions'],
312
+ },
313
+ },
314
+ {
315
+ name: 'finish',
316
+ description: 'Conclude — no further human action needed. Ends your turn.',
317
+ input_schema: {
318
+ type: 'object',
319
+ properties: {
320
+ message: { type: 'string' },
321
+ vesselTitle: { type: 'string', description: 'Rename the vessel as you wrap up (≤6 words). Omit otherwise.' },
322
+ pinCard: PIN_CARD_FIELD,
323
+ labels: LABELS_FIELD,
324
+ },
325
+ required: ['message'],
326
+ },
327
+ },
328
+ {
329
+ name: 'quick_reply',
330
+ description:
331
+ 'Your FIRST action every turn: fire a one-line conversational reply to the operator RIGHT NOW (a chat bubble). It is pushed immediately. The `done` flag decides whether the turn ends here:\n• done: true → this line FULLY resolves the turn and you are handing back to the operator now. Use ONLY for a clarifying question back, or an answer you can give from what you ALREADY know (no lookup). The turn ends.\n• done: false (or omitted) → this is your "on it" line; you are about to keep working. You MUST then call plan() and work the steps. The working card opens right after this bubble.\nNEVER set done: true on a line that promises follow-up ("on it", "pulling that now", "let me check", "I\'ll get you…") or on any answer that needs a lookup — that strands the operator. When in doubt, leave done false and keep working.',
332
+ input_schema: {
333
+ type: 'object',
334
+ properties: {
335
+ message: { type: 'string', description: 'The short conversational line to send back' },
336
+ done: {
337
+ type: 'boolean',
338
+ description:
339
+ 'TRUE only if this line fully resolves the turn (a question back, or an answer you already know) and you are handing back to the operator. FALSE/omitted means you are about to keep working — you must then call plan(). Defaults to false (keep working).',
340
+ },
341
+ vesselTitle: {
342
+ type: 'string',
343
+ description: 'Rename the vessel as part of this reply (≤6 words). The rename takes effect immediately; confirm it in your message. Omit when not renaming.',
344
+ },
345
+ },
346
+ required: ['message'],
347
+ },
348
+ },
349
+ ];
350
+
351
+ /** Names of the built-in control tools — used to route a tool_use to native handling vs a backend tool. */
352
+ export const CONTROL_TOOL_NAMES = new Set(CONTROL_TOOLS.map((t) => t.name));
353
+
354
+ /** The tools that END a turn — exactly one is the final action. */
355
+ export const ENDING_TOOLS = new Set(['request_approval', 'request_choice', 'request_checklist', 'request_text', 'request_questions', 'finish']);
356
+
357
+ // ─── Default narration ──────────────────────────────────────────────────────────
358
+
359
+ /** Turn a tool name into a Title Case label when a backend tool supplies no `narrate`. */
360
+ export function defaultNarrate(name: string): { type: AgentActivityType; label: string } {
361
+ const label = name.replace(/[_-]+/g, ' ').replace(/\b\w/g, (c) => c.toUpperCase()).trim();
362
+ return { type: 'tool_use', label };
363
+ }
364
+
365
+ // ─── Interaction mapping ────────────────────────────────────────────────────────
366
+
367
+ /** Only pass interaction metadata that is a plain object (rides back verbatim in the webhook). */
368
+ function cleanMetadata(raw: unknown): Record<string, unknown> | undefined {
369
+ return raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : undefined;
370
+ }
371
+
372
+ /** Build the camelCase `interaction` object a push expects from a request_* tool input. */
373
+ export function buildInteraction(toolName: string, input: Record<string, unknown>): Record<string, unknown> | null {
374
+ const meta = cleanMetadata(input.metadata);
375
+ const withMeta = (obj: Record<string, unknown>) => (meta ? { ...obj, metadata: meta } : obj);
376
+ switch (toolName) {
377
+ case 'request_approval':
378
+ return withMeta({
379
+ type: 'approval',
380
+ prompt: String(input.prompt),
381
+ ...(input.approveLabel ? { approveLabel: String(input.approveLabel) } : {}),
382
+ ...(input.rejectLabel ? { rejectLabel: String(input.rejectLabel) } : {}),
383
+ ...(input.reasonRequired ? { reasonRequired: true } : {}),
384
+ });
385
+ case 'request_choice':
386
+ return withMeta({
387
+ type: 'choice',
388
+ prompt: String(input.prompt),
389
+ options: input.options,
390
+ ...(input.allowCustom ? { allowCustom: true } : {}),
391
+ ...(input.customPlaceholder ? { customPlaceholder: String(input.customPlaceholder) } : {}),
392
+ });
393
+ case 'request_checklist':
394
+ return withMeta({
395
+ type: 'checklist',
396
+ prompt: String(input.prompt),
397
+ options: input.options,
398
+ ...(typeof input.minSelections === 'number' ? { minSelections: input.minSelections } : {}),
399
+ ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
400
+ });
401
+ case 'request_text':
402
+ return withMeta({
403
+ type: 'text_input',
404
+ prompt: String(input.prompt),
405
+ ...(input.placeholder ? { placeholder: String(input.placeholder) } : {}),
406
+ ...(input.multiline ? { multiline: true } : {}),
407
+ ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
408
+ });
409
+ case 'request_questions':
410
+ return withMeta({
411
+ type: 'questions',
412
+ prompt: String(input.prompt),
413
+ questions: input.questions,
414
+ ...(input.submitLabel ? { submitLabel: String(input.submitLabel) } : {}),
415
+ });
416
+ default:
417
+ return null;
418
+ }
419
+ }
420
+
421
+ /** Render a human's interaction response as a readable user turn for the model.
422
+ * `interaction` (the original interaction object) is optional but lets the questions
423
+ * renderer map option ids back to their human labels. */
424
+ export function renderInteractionResponse(
425
+ interactionType: string,
426
+ response: Record<string, unknown>,
427
+ prompt?: string,
428
+ interaction?: Record<string, unknown> | null
429
+ ): string {
430
+ const head = prompt ? `Re: "${prompt}" — ` : '';
431
+ switch (interactionType) {
432
+ case 'approval': {
433
+ const action = String(response.action ?? '');
434
+ const reason = response.reason ? ` (${response.reason})` : '';
435
+ return `${head}I ${action === 'approved' ? 'APPROVED' : 'REJECTED'}${reason}.`;
436
+ }
437
+ case 'choice': {
438
+ const sel = response.customValue ? `${response.selected} → "${response.customValue}"` : response.selected;
439
+ return `${head}I chose: ${sel}.`;
440
+ }
441
+ case 'checklist': {
442
+ const items = Array.isArray(response.selected) ? response.selected.join(', ') : '';
443
+ return `${head}I selected: ${items || '(none)'}.`;
444
+ }
445
+ case 'text_input':
446
+ return `${head}${response.text ?? ''}`;
447
+ case 'questions': {
448
+ const qs = Array.isArray(interaction?.questions)
449
+ ? (interaction!.questions as Array<{ id: string; question?: string; header?: string; options?: Array<{ id: string; label: string }> }>)
450
+ : [];
451
+ const answers = Array.isArray(response.answers)
452
+ ? (response.answers as Array<{ questionId: string; selected?: string[]; other?: string }>)
453
+ : [];
454
+ const lines = answers.map((a) => {
455
+ const q = qs.find((x) => x.id === a.questionId);
456
+ const label = q?.question ?? q?.header ?? a.questionId;
457
+ const opts = q?.options ?? [];
458
+ const picked = (a.selected ?? []).map((id) => opts.find((o) => o.id === id)?.label ?? id);
459
+ if (a.other) picked.push(a.other);
460
+ return `• ${label}: ${picked.length ? picked.join(', ') : '(none)'}`;
461
+ });
462
+ return `${head}I answered:\n${lines.join('\n')}`;
463
+ }
464
+ default:
465
+ return `${head}${JSON.stringify(response)}`;
466
+ }
467
+ }
468
+
469
+ // ─── Defensive payload sanitisers ────────────────────────────────────────────────
470
+
471
+ /** Return a valid card {title, fields:[{label,value}]}, salvaging a sibling `fields`, or undefined. */
472
+ export function sanitizeCard(
473
+ raw: unknown,
474
+ input?: Record<string, unknown>
475
+ ): { title: string; fields: { label: string; value: string }[] } | undefined {
476
+ const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
477
+ const titleRaw = typeof obj.title === 'string' ? obj.title : typeof raw === 'string' ? raw : '';
478
+ const title = String(titleRaw).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
479
+ const fieldsSrc = Array.isArray(obj.fields)
480
+ ? obj.fields
481
+ : Array.isArray(input?.fields)
482
+ ? (input!.fields as unknown[])
483
+ : [];
484
+ const fields = fieldsSrc
485
+ .filter((f): f is Record<string, unknown> => !!f && typeof f === 'object')
486
+ .map((f) => ({ label: String(f.label ?? '').trim(), value: String(f.value ?? '').trim() }))
487
+ .filter((f) => f.label && f.value);
488
+ if (!title || fields.length === 0) return undefined;
489
+ return { title, fields };
490
+ }
491
+
492
+ /** Only pass a previewUrl the API will accept (http/https), else undefined. */
493
+ export function cleanPreviewUrl(raw: unknown): string | undefined {
494
+ if (typeof raw !== 'string') return undefined;
495
+ const u = raw.trim();
496
+ return /^https?:\/\/\S+$/.test(u) ? u : undefined;
497
+ }
498
+
499
+ /** A surface heading: trimmed, tag-stripped, ≤200 chars, else undefined. */
500
+ export function cleanTitle(raw: unknown): string | undefined {
501
+ if (typeof raw !== 'string') return undefined;
502
+ const t = raw.replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim().slice(0, 200);
503
+ return t || undefined;
504
+ }
505
+
506
+ /** Up to 5 non-empty suggestion strings, else undefined. */
507
+ export function cleanSuggestions(raw: unknown): string[] | undefined {
508
+ if (!Array.isArray(raw)) return undefined;
509
+ const out = raw.map((s) => String(s ?? '').trim()).filter(Boolean).slice(0, 5);
510
+ return out.length ? out : undefined;
511
+ }
512
+
513
+ /** Up to 10 attachments with a valid http(s) url and image|file type, else undefined. */
514
+ export function cleanAttachments(raw: unknown): Array<{ type: 'image' | 'file'; url: string; filename?: string }> | undefined {
515
+ if (!Array.isArray(raw)) return undefined;
516
+ const out: Array<{ type: 'image' | 'file'; url: string; filename?: string }> = [];
517
+ for (const a of raw) {
518
+ if (!a || typeof a !== 'object') continue;
519
+ const o = a as Record<string, unknown>;
520
+ const type = o.type === 'image' ? 'image' : o.type === 'file' ? 'file' : undefined;
521
+ const url = typeof o.url === 'string' && /^https?:\/\/\S+$/.test(o.url.trim()) ? o.url.trim() : undefined;
522
+ if (!type || !url) continue;
523
+ const filename = typeof o.filename === 'string' && o.filename.trim() ? o.filename.trim() : undefined;
524
+ out.push({ type, url, ...(type === 'file' && filename ? { filename } : {}) });
525
+ if (out.length >= 10) break;
526
+ }
527
+ return out.length ? out : undefined;
528
+ }
529
+
530
+ /** Up to 10 triage labels, tag-stripped, ≤50 chars, deduped (case-insensitive), else undefined. */
531
+ export function cleanLabels(raw: unknown): string[] | undefined {
532
+ if (!Array.isArray(raw)) return undefined;
533
+ const seen = new Set<string>();
534
+ const out: string[] = [];
535
+ for (const s of raw) {
536
+ const t = String(s ?? '').replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim().slice(0, 50);
537
+ const key = t.toLowerCase();
538
+ if (t && !seen.has(key)) {
539
+ seen.add(key);
540
+ out.push(t);
541
+ }
542
+ if (out.length >= 10) break;
543
+ }
544
+ return out.length ? out : undefined;
545
+ }
@@ -0,0 +1,17 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2022",
4
+ "module": "NodeNext",
5
+ "moduleResolution": "NodeNext",
6
+ "lib": ["ES2022"],
7
+ "types": ["node"],
8
+ "strict": true,
9
+ "esModuleInterop": true,
10
+ "skipLibCheck": true,
11
+ "forceConsistentCasingInFileNames": true,
12
+ "resolveJsonModule": true,
13
+ "outDir": "dist",
14
+ "rootDir": "src"
15
+ },
16
+ "include": ["src"]
17
+ }