vessels 0.10.0 → 0.12.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.
- package/README.md +2 -0
- package/dist/index.js +288 -29
- package/package.json +1 -1
- package/template/agent/README.md +25 -7
- package/template/agent/package.json +1 -1
- package/template/agent/src/agent.ts +19 -22
- package/template/agent/src/protocol.ts +20 -6
- package/template/agent/src/vessels-tools.ts +167 -25
package/README.md
CHANGED
|
@@ -53,6 +53,8 @@ vessels webhooks enable <id>
|
|
|
53
53
|
vessels webhooks disable <id>
|
|
54
54
|
|
|
55
55
|
vessels push --vessel <id> --message <text> --key <vsl_xxx>
|
|
56
|
+
vessels event --vessel <id> --title <text> [--body <markdown>] [--tone info|alert|success] --key <vsl_xxx>
|
|
57
|
+
vessels mark --vessel <id> --label <text> [--type <kind>] [--subtext <text>] [--tone neutral|success|warning|danger] [--url <link>] --key <vsl_xxx>
|
|
56
58
|
vessels message --vessel <id> --message <text>
|
|
57
59
|
```
|
|
58
60
|
|
package/dist/index.js
CHANGED
|
@@ -4056,7 +4056,7 @@ var coerce = {
|
|
|
4056
4056
|
var NEVER = INVALID;
|
|
4057
4057
|
|
|
4058
4058
|
// ../types/src/index.ts
|
|
4059
|
-
var SourceSchema = external_exports.enum(["agent", "user", "system"]);
|
|
4059
|
+
var SourceSchema = external_exports.enum(["agent", "user", "system", "event", "mark"]);
|
|
4060
4060
|
var InteractionTypeSchema = external_exports.enum([
|
|
4061
4061
|
"approval",
|
|
4062
4062
|
"choice",
|
|
@@ -4064,18 +4064,53 @@ var InteractionTypeSchema = external_exports.enum([
|
|
|
4064
4064
|
"text_input",
|
|
4065
4065
|
"questions"
|
|
4066
4066
|
]);
|
|
4067
|
+
var AckStageSchema = external_exports.enum(["received", "processing", "completed", "dropped"]);
|
|
4068
|
+
var AckPayloadSchema = external_exports.object({
|
|
4069
|
+
stage: AckStageSchema,
|
|
4070
|
+
reason: external_exports.string().max(280).optional()
|
|
4071
|
+
}).strict();
|
|
4072
|
+
var ChoiceOptionSchema = external_exports.object({
|
|
4073
|
+
id: external_exports.string().min(1),
|
|
4074
|
+
label: external_exports.string().min(1)
|
|
4075
|
+
});
|
|
4076
|
+
var EditableTypeSchema = external_exports.enum(["text", "number", "currency", "date", "choice"]);
|
|
4077
|
+
var EditableSchema = external_exports.object({
|
|
4078
|
+
/** Stable id — the key this value uses in the response `edits`, and the
|
|
4079
|
+
* `editableId` a card field binds to. */
|
|
4080
|
+
id: external_exports.string().min(1),
|
|
4081
|
+
type: EditableTypeSchema,
|
|
4082
|
+
/** Initial value (number for number/currency, the option id for choice, a string
|
|
4083
|
+
* otherwise). Optional — a bound card field supplies the displayed value — but
|
|
4084
|
+
* recommended, so the agent has a baseline to diff the returned edit against. */
|
|
4085
|
+
value: external_exports.union([external_exports.string(), external_exports.number()]).optional(),
|
|
4086
|
+
/** Edit-field label; falls back to the bound card field's label. */
|
|
4087
|
+
label: external_exports.string().optional(),
|
|
4088
|
+
/** type:'choice' — the options the value is picked from. */
|
|
4089
|
+
options: external_exports.array(ChoiceOptionSchema).min(1).optional(),
|
|
4090
|
+
/** Numeric bounds for number/currency, validated on submit. */
|
|
4091
|
+
min: external_exports.number().optional(),
|
|
4092
|
+
max: external_exports.number().optional()
|
|
4093
|
+
}).refine((d) => {
|
|
4094
|
+
var _a;
|
|
4095
|
+
return d.type !== "choice" || (((_a = d.options) == null ? void 0 : _a.length) ?? 0) > 0;
|
|
4096
|
+
}, {
|
|
4097
|
+
message: "editable of type 'choice' requires non-empty options"
|
|
4098
|
+
});
|
|
4067
4099
|
var ApprovalInteractionSchema = external_exports.object({
|
|
4068
4100
|
type: external_exports.literal("approval"),
|
|
4069
4101
|
prompt: external_exports.string().min(1),
|
|
4070
4102
|
approveLabel: external_exports.string().optional(),
|
|
4071
4103
|
rejectLabel: external_exports.string().optional(),
|
|
4072
4104
|
reasonRequired: external_exports.boolean().optional(),
|
|
4105
|
+
// `false` ⇒ a one-way consent/proceed gate: a single full-width Approve button,
|
|
4106
|
+
// no reject path. Defaults to true (the two-way Approve/Reject decision).
|
|
4107
|
+
rejectable: external_exports.boolean().optional(),
|
|
4108
|
+
// Edit-then-approve: values the human may change before approving. Each binds to a
|
|
4109
|
+
// card field via that field's `editableId` (Phase 1); changed values return in the
|
|
4110
|
+
// response `edits`, keyed by editable id. The body markdown stays read-only.
|
|
4111
|
+
editables: external_exports.array(EditableSchema).max(20).optional(),
|
|
4073
4112
|
metadata: external_exports.record(external_exports.unknown()).optional()
|
|
4074
4113
|
});
|
|
4075
|
-
var ChoiceOptionSchema = external_exports.object({
|
|
4076
|
-
id: external_exports.string().min(1),
|
|
4077
|
-
label: external_exports.string().min(1)
|
|
4078
|
-
});
|
|
4079
4114
|
var ChoiceInteractionSchema = external_exports.object({
|
|
4080
4115
|
type: external_exports.literal("choice"),
|
|
4081
4116
|
prompt: external_exports.string().min(1),
|
|
@@ -4116,7 +4151,7 @@ var QuestionSchema = external_exports.object({
|
|
|
4116
4151
|
id: external_exports.string().min(1),
|
|
4117
4152
|
/** The question text shown to the human. */
|
|
4118
4153
|
question: external_exports.string().min(1),
|
|
4119
|
-
/** Optional short chip label (≤
|
|
4154
|
+
/** Optional short chip label (≤24 chars) — e.g. "Date", "Guests". */
|
|
4120
4155
|
header: external_exports.string().max(24).optional(),
|
|
4121
4156
|
options: external_exports.array(QuestionOptionSchema).min(2).max(4),
|
|
4122
4157
|
/** Allow selecting more than one option (checkboxes instead of radios). */
|
|
@@ -4158,25 +4193,43 @@ var CardFieldSchema = external_exports.object({
|
|
|
4158
4193
|
label: external_exports.string().min(1),
|
|
4159
4194
|
value: external_exports.string(),
|
|
4160
4195
|
// Optional: render the value as a tappable link, deep-linking the human into
|
|
4161
|
-
// your own web UI (e.g. an admin tray). Rendered on full-size
|
|
4162
|
-
//
|
|
4196
|
+
// your own web UI (e.g. an admin tray). Rendered on full-size surface cards,
|
|
4197
|
+
// not the compact vessel-list preview.
|
|
4163
4198
|
url: external_exports.string().url().max(2048).optional(),
|
|
4164
4199
|
// Optional colour hint — pure styling, no behaviour. 'default' === unset.
|
|
4165
|
-
tone: external_exports.enum(["default", "success", "warning", "danger"]).optional()
|
|
4200
|
+
tone: external_exports.enum(["default", "success", "warning", "danger"]).optional(),
|
|
4201
|
+
// Edit-then-approve (Phase 1): binds this field to an approval `editables[]` entry
|
|
4202
|
+
// by id. When the message's interaction is an approval declaring that editable, the
|
|
4203
|
+
// field renders as an input in edit mode and the changed value returns in `edits`.
|
|
4204
|
+
// Inert everywhere else (plain surfaces, events).
|
|
4205
|
+
editableId: external_exports.string().optional()
|
|
4166
4206
|
});
|
|
4167
4207
|
var CardSchema = external_exports.object({
|
|
4168
4208
|
// Optional: a glance-facts card under a surface takes its heading from the
|
|
4169
4209
|
// surface `title`, so a card title is redundant there. Still allowed (e.g. a
|
|
4170
|
-
// standalone card on a bubble
|
|
4210
|
+
// standalone card on a bubble).
|
|
4171
4211
|
title: external_exports.string().min(1).optional(),
|
|
4172
4212
|
fields: external_exports.array(CardFieldSchema)
|
|
4173
4213
|
});
|
|
4214
|
+
var DetailFieldSchema = external_exports.object({
|
|
4215
|
+
label: external_exports.string().min(1),
|
|
4216
|
+
value: external_exports.string(),
|
|
4217
|
+
// Render the value as a tappable link, deep-linking the human into your own
|
|
4218
|
+
// web UI (e.g. their CRM record). Opens in a new tab.
|
|
4219
|
+
url: external_exports.string().url().max(2048).optional(),
|
|
4220
|
+
// Colour hint — pure styling, no behaviour. 'default' === unset.
|
|
4221
|
+
tone: external_exports.enum(["default", "success", "warning", "danger"]).optional(),
|
|
4222
|
+
// Show a copy button on the value (phone numbers, emails, ids the human grabs).
|
|
4223
|
+
copyable: external_exports.boolean().optional()
|
|
4224
|
+
});
|
|
4225
|
+
var DetailsSchema = external_exports.object({
|
|
4226
|
+
fields: external_exports.array(DetailFieldSchema).max(20)
|
|
4227
|
+
});
|
|
4174
4228
|
var AttachmentSchema = external_exports.discriminatedUnion("type", [
|
|
4175
4229
|
external_exports.object({ type: external_exports.literal("image"), url: external_exports.string().url() }),
|
|
4176
4230
|
external_exports.object({ type: external_exports.literal("file"), url: external_exports.string().url(), filename: external_exports.string().optional() })
|
|
4177
4231
|
]);
|
|
4178
4232
|
var VesselStatusSchema = external_exports.enum(["active", "waiting", "resolved"]);
|
|
4179
|
-
var DisplaySchema = external_exports.enum(["bubble", "document"]);
|
|
4180
4233
|
var KindSchema = external_exports.enum(["bubble", "surface"]);
|
|
4181
4234
|
var PushPayloadSchema = external_exports.object({
|
|
4182
4235
|
message: external_exports.string().min(1).max(1e4).optional(),
|
|
@@ -4189,7 +4242,8 @@ var PushPayloadSchema = external_exports.object({
|
|
|
4189
4242
|
(v) => JSON.stringify(v).length < 16e3,
|
|
4190
4243
|
"metadata exceeds 16KB limit"
|
|
4191
4244
|
).optional(),
|
|
4192
|
-
|
|
4245
|
+
/** Vessel reference record (CRM-style identity), shown in the top bar. `null` clears. Replaces wholesale. */
|
|
4246
|
+
details: DetailsSchema.nullable().optional(),
|
|
4193
4247
|
/** @deprecated `waiting` is now system-derived from the message's interaction; you don't set status. Still accepted for back-compat. */
|
|
4194
4248
|
vesselStatus: VesselStatusSchema.optional(),
|
|
4195
4249
|
labels: external_exports.array(external_exports.string().min(1).max(50)).max(10).optional(),
|
|
@@ -4207,9 +4261,7 @@ var PushPayloadSchema = external_exports.object({
|
|
|
4207
4261
|
/** Bubble (chat) vs surface (composed artifact). Defaults from interaction/card. */
|
|
4208
4262
|
kind: KindSchema.optional(),
|
|
4209
4263
|
/** Surface heading. Ignored on bubbles. */
|
|
4210
|
-
title: external_exports.string().max(200).optional()
|
|
4211
|
-
/** @deprecated legacy presentation hint — use `kind`. 'document' → surface. */
|
|
4212
|
-
display: DisplaySchema.optional()
|
|
4264
|
+
title: external_exports.string().max(200).optional()
|
|
4213
4265
|
}).refine((d) => d.message || d.agentActivity || d.tokenStream, {
|
|
4214
4266
|
message: "One of message, agentActivity, or tokenStream is required"
|
|
4215
4267
|
});
|
|
@@ -4219,7 +4271,8 @@ var PushManyPayloadSchema = external_exports.object({
|
|
|
4219
4271
|
vesselTitle: external_exports.string().optional(),
|
|
4220
4272
|
card: CardSchema.optional(),
|
|
4221
4273
|
interaction: InteractionSchema.optional(),
|
|
4222
|
-
|
|
4274
|
+
/** Vessel reference record (CRM-style identity), shown in the top bar. `null` clears. Replaces wholesale. */
|
|
4275
|
+
details: DetailsSchema.nullable().optional(),
|
|
4223
4276
|
/** @deprecated `waiting` is now system-derived from the message's interaction; you don't set status. Still accepted for back-compat. */
|
|
4224
4277
|
vesselStatus: VesselStatusSchema.optional(),
|
|
4225
4278
|
attachments: external_exports.array(AttachmentSchema).max(10).optional(),
|
|
@@ -4229,9 +4282,89 @@ var PushManyPayloadSchema = external_exports.object({
|
|
|
4229
4282
|
"metadata exceeds 16KB limit"
|
|
4230
4283
|
).optional(),
|
|
4231
4284
|
kind: KindSchema.optional(),
|
|
4232
|
-
title: external_exports.string().max(200).optional()
|
|
4233
|
-
|
|
4234
|
-
|
|
4285
|
+
title: external_exports.string().max(200).optional()
|
|
4286
|
+
});
|
|
4287
|
+
var EventToneSchema = external_exports.enum(["info", "alert", "success"]);
|
|
4288
|
+
var ButtonSchema = external_exports.object({
|
|
4289
|
+
label: external_exports.string().min(1).max(100),
|
|
4290
|
+
url: external_exports.string().url().max(2048),
|
|
4291
|
+
tone: external_exports.enum(["default", "primary"]).optional()
|
|
4292
|
+
});
|
|
4293
|
+
var EventSectionSchema = external_exports.object({
|
|
4294
|
+
heading: external_exports.string().min(1).max(200),
|
|
4295
|
+
body: external_exports.string().min(1).max(1e4),
|
|
4296
|
+
collapsed: external_exports.boolean().optional()
|
|
4297
|
+
});
|
|
4298
|
+
var EventPayloadSchema = external_exports.object({
|
|
4299
|
+
/** The banner heading. */
|
|
4300
|
+
title: external_exports.string().min(1).max(200),
|
|
4301
|
+
/** Block-markdown body (tables, lists, headings, blockquotes, rules). */
|
|
4302
|
+
body: external_exports.string().min(1).max(1e4).optional(),
|
|
4303
|
+
/** Accent tone. Defaults to `info`. */
|
|
4304
|
+
tone: EventToneSchema.optional(),
|
|
4305
|
+
/** Which vessel — your own external id (created on first write, like push). */
|
|
4306
|
+
vessel: external_exports.string().optional(),
|
|
4307
|
+
/** Set the vessel title (only on explicit send, like push). */
|
|
4308
|
+
vesselTitle: external_exports.string().optional(),
|
|
4309
|
+
/** Glance-facts card (same shape as surfaces — supports per-field url/tone). */
|
|
4310
|
+
card: CardSchema.optional(),
|
|
4311
|
+
/** Deep-link buttons. */
|
|
4312
|
+
buttons: external_exports.array(ButtonSchema).max(6).optional(),
|
|
4313
|
+
/** Collapsible detail sections. */
|
|
4314
|
+
sections: external_exports.array(EventSectionSchema).max(20).optional(),
|
|
4315
|
+
labels: external_exports.array(external_exports.string().min(1).max(50)).max(10).optional(),
|
|
4316
|
+
attachments: external_exports.array(AttachmentSchema).max(10).optional(),
|
|
4317
|
+
metadata: external_exports.record(external_exports.unknown()).refine(
|
|
4318
|
+
(v) => JSON.stringify(v).length < 16e3,
|
|
4319
|
+
"metadata exceeds 16KB limit"
|
|
4320
|
+
).optional()
|
|
4321
|
+
}).refine((d) => d.body || d.card || d.buttons && d.buttons.length || d.sections && d.sections.length, {
|
|
4322
|
+
message: "An event needs at least one of: body, card, buttons, or sections"
|
|
4323
|
+
});
|
|
4324
|
+
var EventDataSchema = external_exports.object({
|
|
4325
|
+
tone: EventToneSchema,
|
|
4326
|
+
buttons: external_exports.array(ButtonSchema).optional(),
|
|
4327
|
+
sections: external_exports.array(EventSectionSchema).optional()
|
|
4328
|
+
});
|
|
4329
|
+
var MarkTypeSchema = external_exports.enum([
|
|
4330
|
+
"email_sent",
|
|
4331
|
+
"payment_recorded",
|
|
4332
|
+
"status_changed",
|
|
4333
|
+
"booking_created",
|
|
4334
|
+
"invoice_voided",
|
|
4335
|
+
"generic"
|
|
4336
|
+
]);
|
|
4337
|
+
var MarkToneSchema = external_exports.enum(["neutral", "success", "warning", "danger"]);
|
|
4338
|
+
var MarkPayloadSchema = external_exports.object({
|
|
4339
|
+
/** The chip's primary line — what happened (e.g. "Confirmation email sent"). */
|
|
4340
|
+
label: external_exports.string().min(1).max(120),
|
|
4341
|
+
/** Semantic kind — selects a default icon + tone. Defaults to `generic`. */
|
|
4342
|
+
type: MarkTypeSchema.optional(),
|
|
4343
|
+
/** Optional small secondary line under the label (an amount, an id, a target). */
|
|
4344
|
+
subtext: external_exports.string().min(1).max(160).optional(),
|
|
4345
|
+
/** Override the default glyph with a literal icon/emoji (e.g. '✉️'). */
|
|
4346
|
+
icon: external_exports.string().min(1).max(8).optional(),
|
|
4347
|
+
/** Override the default tone. */
|
|
4348
|
+
tone: MarkToneSchema.optional(),
|
|
4349
|
+
/** Deep-link into your own UI — renders a right chevron, opens the URL. NOT an interaction. */
|
|
4350
|
+
url: external_exports.string().url().max(2048).optional(),
|
|
4351
|
+
/** Which vessel — your own external id (created on first write, like push). */
|
|
4352
|
+
vessel: external_exports.string().optional(),
|
|
4353
|
+
/** Set the vessel title (only on explicit send, like push). */
|
|
4354
|
+
vesselTitle: external_exports.string().optional(),
|
|
4355
|
+
labels: external_exports.array(external_exports.string().min(1).max(50)).max(10).optional(),
|
|
4356
|
+
metadata: external_exports.record(external_exports.unknown()).refine(
|
|
4357
|
+
(v) => JSON.stringify(v).length < 16e3,
|
|
4358
|
+
"metadata exceeds 16KB limit"
|
|
4359
|
+
).optional()
|
|
4360
|
+
});
|
|
4361
|
+
var MarkDataSchema = external_exports.object({
|
|
4362
|
+
type: MarkTypeSchema,
|
|
4363
|
+
label: external_exports.string(),
|
|
4364
|
+
tone: MarkToneSchema,
|
|
4365
|
+
subtext: external_exports.string().optional(),
|
|
4366
|
+
icon: external_exports.string().optional(),
|
|
4367
|
+
url: external_exports.string().optional()
|
|
4235
4368
|
});
|
|
4236
4369
|
var MessagePatchSchema = external_exports.object({
|
|
4237
4370
|
content: external_exports.string().min(1).max(1e4).optional(),
|
|
@@ -4244,15 +4377,17 @@ var MessagePatchSchema = external_exports.object({
|
|
|
4244
4377
|
/** Switch kind: 'surface' = full-width artifact, 'bubble' or null = chat bubble. */
|
|
4245
4378
|
kind: KindSchema.nullable().optional(),
|
|
4246
4379
|
/** Update the surface heading, or `null` to clear it. */
|
|
4247
|
-
title: external_exports.string().max(200).nullable().optional()
|
|
4248
|
-
/** @deprecated use `kind`. */
|
|
4249
|
-
display: DisplaySchema.nullable().optional()
|
|
4380
|
+
title: external_exports.string().max(200).nullable().optional()
|
|
4250
4381
|
}).refine((d) => Object.values(d).some((v) => v !== void 0), {
|
|
4251
4382
|
message: "At least one field required"
|
|
4252
4383
|
});
|
|
4253
4384
|
var ApprovalResponseSchema = external_exports.object({
|
|
4254
4385
|
action: external_exports.enum(["approved", "rejected"]),
|
|
4255
|
-
reason: external_exports.string().optional()
|
|
4386
|
+
reason: external_exports.string().optional(),
|
|
4387
|
+
// Edit-then-approve: the values the human changed, keyed by editable id. Present
|
|
4388
|
+
// only on `approved`, and only for values that actually changed (no change ⇒ a
|
|
4389
|
+
// plain approve). Each value is validated against its declared editable server-side.
|
|
4390
|
+
edits: external_exports.record(external_exports.union([external_exports.string(), external_exports.number()])).optional()
|
|
4256
4391
|
});
|
|
4257
4392
|
var ChoiceResponseSchema = external_exports.object({
|
|
4258
4393
|
selected: external_exports.string(),
|
|
@@ -4279,6 +4414,29 @@ var InteractionResponseSchema = external_exports.discriminatedUnion("interaction
|
|
|
4279
4414
|
external_exports.object({ interactionType: external_exports.literal("text_input"), response: TextInputResponseSchema }),
|
|
4280
4415
|
external_exports.object({ interactionType: external_exports.literal("questions"), response: QuestionsResponseSchema })
|
|
4281
4416
|
]);
|
|
4417
|
+
var InteractionOutcomeSchema = external_exports.enum([
|
|
4418
|
+
"approved",
|
|
4419
|
+
// green ✓
|
|
4420
|
+
"rejected",
|
|
4421
|
+
// red ✗
|
|
4422
|
+
"cancelled",
|
|
4423
|
+
// neutral — withdrawn before a decision
|
|
4424
|
+
"expired"
|
|
4425
|
+
// neutral — no longer relevant
|
|
4426
|
+
]);
|
|
4427
|
+
var InteractionResolveSchema = external_exports.object({
|
|
4428
|
+
outcome: InteractionOutcomeSchema,
|
|
4429
|
+
/** Badge text shown in place of the buttons. Defaults to the outcome name. */
|
|
4430
|
+
label: external_exports.string().min(1).max(80).optional(),
|
|
4431
|
+
/** Optional note; surfaced when the resolved receipt expands. */
|
|
4432
|
+
reason: external_exports.string().max(2e3).optional()
|
|
4433
|
+
});
|
|
4434
|
+
var AgentResolutionSchema = external_exports.object({
|
|
4435
|
+
resolvedByAgent: external_exports.literal(true),
|
|
4436
|
+
outcome: InteractionOutcomeSchema,
|
|
4437
|
+
label: external_exports.string().optional(),
|
|
4438
|
+
reason: external_exports.string().optional()
|
|
4439
|
+
});
|
|
4282
4440
|
var WebhookVesselSchema = external_exports.object({
|
|
4283
4441
|
id: external_exports.string(),
|
|
4284
4442
|
external_id: external_exports.string().nullable(),
|
|
@@ -4356,6 +4514,24 @@ var WebhookVesselCreatedPayloadSchema = external_exports.object({
|
|
|
4356
4514
|
attachments: external_exports.array(WebhookEventAttachmentSchema).optional()
|
|
4357
4515
|
})
|
|
4358
4516
|
});
|
|
4517
|
+
var WebhookVesselArchivedPayloadSchema = external_exports.object({
|
|
4518
|
+
event: external_exports.literal("vessel.archived"),
|
|
4519
|
+
vessel_id: external_exports.string(),
|
|
4520
|
+
workspace_id: external_exports.string(),
|
|
4521
|
+
timestamp: external_exports.string(),
|
|
4522
|
+
data: external_exports.object({
|
|
4523
|
+
vessel: WebhookVesselSchema
|
|
4524
|
+
})
|
|
4525
|
+
});
|
|
4526
|
+
var WebhookVesselDeletedPayloadSchema = external_exports.object({
|
|
4527
|
+
event: external_exports.literal("vessel.deleted"),
|
|
4528
|
+
vessel_id: external_exports.string(),
|
|
4529
|
+
workspace_id: external_exports.string(),
|
|
4530
|
+
timestamp: external_exports.string(),
|
|
4531
|
+
data: external_exports.object({
|
|
4532
|
+
vessel: WebhookVesselSchema
|
|
4533
|
+
})
|
|
4534
|
+
});
|
|
4359
4535
|
var WebhookMessageCancelledPayloadSchema = external_exports.object({
|
|
4360
4536
|
event: external_exports.literal("message.cancelled"),
|
|
4361
4537
|
vessel_id: external_exports.string(),
|
|
@@ -4370,6 +4546,8 @@ var WebhookPayloadSchema = external_exports.discriminatedUnion("event", [
|
|
|
4370
4546
|
WebhookInteractionResponsePayloadSchema,
|
|
4371
4547
|
WebhookUserMessagePayloadSchema,
|
|
4372
4548
|
WebhookVesselCreatedPayloadSchema,
|
|
4549
|
+
WebhookVesselArchivedPayloadSchema,
|
|
4550
|
+
WebhookVesselDeletedPayloadSchema,
|
|
4373
4551
|
WebhookMessageCancelledPayloadSchema
|
|
4374
4552
|
]);
|
|
4375
4553
|
|
|
@@ -4490,10 +4668,10 @@ async function cmdLogout() {
|
|
|
4490
4668
|
console.log("Logged out.");
|
|
4491
4669
|
}
|
|
4492
4670
|
async function cmdWhoami() {
|
|
4493
|
-
var _a
|
|
4671
|
+
var _a;
|
|
4494
4672
|
const data = await api("/api/v1/me");
|
|
4495
4673
|
console.log(`Email: ${readConfig().email ?? "unknown"}`);
|
|
4496
|
-
console.log(`Workspace: ${(_a = data.workspace) == null ? void 0 : _a.name}
|
|
4674
|
+
console.log(`Workspace: ${(_a = data.workspace) == null ? void 0 : _a.name}`);
|
|
4497
4675
|
console.log(`User ID: ${data.userId}`);
|
|
4498
4676
|
}
|
|
4499
4677
|
async function cmdKeysList() {
|
|
@@ -4547,7 +4725,7 @@ async function cmdWebhooksCreate(args) {
|
|
|
4547
4725
|
console.error("URL must start with https://");
|
|
4548
4726
|
process.exit(1);
|
|
4549
4727
|
}
|
|
4550
|
-
const DEFAULT_EVENTS = ["interaction.response", "message.user", "vessel.created"];
|
|
4728
|
+
const DEFAULT_EVENTS = ["interaction.response", "message.user", "vessel.created", "vessel.archived", "vessel.deleted"];
|
|
4551
4729
|
let events;
|
|
4552
4730
|
if (flags.events) {
|
|
4553
4731
|
events = flags.events.split(/[,\s]+/).filter(Boolean);
|
|
@@ -4686,6 +4864,76 @@ async function cmdPush(args) {
|
|
|
4686
4864
|
}
|
|
4687
4865
|
console.log(`Message sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
|
|
4688
4866
|
}
|
|
4867
|
+
var EVENT_TONES = ["info", "alert", "success"];
|
|
4868
|
+
async function cmdEvent(args) {
|
|
4869
|
+
const flags = parseFlags(args);
|
|
4870
|
+
const apiKey = flags.key ?? process.env.VESSELS_API_KEY;
|
|
4871
|
+
if (!apiKey) {
|
|
4872
|
+
console.error("Provide an API key via --key or VESSELS_API_KEY env var");
|
|
4873
|
+
process.exit(1);
|
|
4874
|
+
}
|
|
4875
|
+
if (!flags.title) {
|
|
4876
|
+
console.error("Usage: vessels event --vessel <id> --title <text> [--body <markdown>] [--tone info|alert|success] --key <api_key>");
|
|
4877
|
+
process.exit(1);
|
|
4878
|
+
}
|
|
4879
|
+
if (flags.tone && !EVENT_TONES.includes(flags.tone)) {
|
|
4880
|
+
console.error(`--tone must be one of: ${EVENT_TONES.join(", ")}`);
|
|
4881
|
+
process.exit(1);
|
|
4882
|
+
}
|
|
4883
|
+
const payload = { title: flags.title, vessel: flags.vessel || void 0 };
|
|
4884
|
+
if (flags.body) payload.body = flags.body;
|
|
4885
|
+
if (flags.tone) payload.tone = flags.tone;
|
|
4886
|
+
const res = await fetch(`${BASE_URL}/api/v1/event`, {
|
|
4887
|
+
method: "POST",
|
|
4888
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
4889
|
+
body: JSON.stringify(payload)
|
|
4890
|
+
});
|
|
4891
|
+
const data = await res.json();
|
|
4892
|
+
if (!res.ok) {
|
|
4893
|
+
console.error("Event failed:", data.error);
|
|
4894
|
+
process.exit(1);
|
|
4895
|
+
}
|
|
4896
|
+
console.log(`Event sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
|
|
4897
|
+
}
|
|
4898
|
+
var MARK_TYPES = ["email_sent", "payment_recorded", "status_changed", "booking_created", "invoice_voided", "generic"];
|
|
4899
|
+
var MARK_TONES = ["neutral", "success", "warning", "danger"];
|
|
4900
|
+
async function cmdMark(args) {
|
|
4901
|
+
const flags = parseFlags(args);
|
|
4902
|
+
const apiKey = flags.key ?? process.env.VESSELS_API_KEY;
|
|
4903
|
+
if (!apiKey) {
|
|
4904
|
+
console.error("Provide an API key via --key or VESSELS_API_KEY env var");
|
|
4905
|
+
process.exit(1);
|
|
4906
|
+
}
|
|
4907
|
+
if (!flags.label) {
|
|
4908
|
+
console.error("Usage: vessels mark --vessel <id> --label <text> [--type <kind>] [--subtext <text>] [--icon <emoji>] [--tone neutral|success|warning|danger] [--url <link>] --key <api_key>");
|
|
4909
|
+
process.exit(1);
|
|
4910
|
+
}
|
|
4911
|
+
if (flags.type && !MARK_TYPES.includes(flags.type)) {
|
|
4912
|
+
console.error(`--type must be one of: ${MARK_TYPES.join(", ")}`);
|
|
4913
|
+
process.exit(1);
|
|
4914
|
+
}
|
|
4915
|
+
if (flags.tone && !MARK_TONES.includes(flags.tone)) {
|
|
4916
|
+
console.error(`--tone must be one of: ${MARK_TONES.join(", ")}`);
|
|
4917
|
+
process.exit(1);
|
|
4918
|
+
}
|
|
4919
|
+
const payload = { label: flags.label, vessel: flags.vessel || void 0 };
|
|
4920
|
+
if (flags.type) payload.type = flags.type;
|
|
4921
|
+
if (flags.subtext) payload.subtext = flags.subtext;
|
|
4922
|
+
if (flags.icon) payload.icon = flags.icon;
|
|
4923
|
+
if (flags.tone) payload.tone = flags.tone;
|
|
4924
|
+
if (flags.url) payload.url = flags.url;
|
|
4925
|
+
const res = await fetch(`${BASE_URL}/api/v1/mark`, {
|
|
4926
|
+
method: "POST",
|
|
4927
|
+
headers: { "Content-Type": "application/json", Authorization: `Bearer ${apiKey}` },
|
|
4928
|
+
body: JSON.stringify(payload)
|
|
4929
|
+
});
|
|
4930
|
+
const data = await res.json();
|
|
4931
|
+
if (!res.ok) {
|
|
4932
|
+
console.error("Mark failed:", data.error);
|
|
4933
|
+
process.exit(1);
|
|
4934
|
+
}
|
|
4935
|
+
console.log(`Mark sent. vessel_id=${data.vessel_id} message_id=${data.message_id}`);
|
|
4936
|
+
}
|
|
4689
4937
|
var FEEDBACK_TYPES = ["bug", "feature", "other"];
|
|
4690
4938
|
async function cmdFeedback(args) {
|
|
4691
4939
|
const flags = parseFlags(args);
|
|
@@ -4728,7 +4976,7 @@ function zodIssueLines(err) {
|
|
|
4728
4976
|
return err.issues.map((i) => `${i.path.join(".") || "(payload)"}: ${i.message}`);
|
|
4729
4977
|
}
|
|
4730
4978
|
function summarisePayload(p, isMany) {
|
|
4731
|
-
var _a, _b;
|
|
4979
|
+
var _a, _b, _c;
|
|
4732
4980
|
const lines = [];
|
|
4733
4981
|
if (isMany) lines.push(` vessels: ${p.vessels.length}`);
|
|
4734
4982
|
else if (p.vessel) lines.push(` vessel: ${p.vessel}`);
|
|
@@ -4738,7 +4986,7 @@ function summarisePayload(p, isMany) {
|
|
|
4738
4986
|
}
|
|
4739
4987
|
if ((_a = p.interaction) == null ? void 0 : _a.type) lines.push(` interaction: ${p.interaction.type}`);
|
|
4740
4988
|
if (p.card) lines.push(` card: "${p.card.title}" (${((_b = p.card.fields) == null ? void 0 : _b.length) ?? 0} fields)`);
|
|
4741
|
-
if (p.
|
|
4989
|
+
if (p.details !== void 0) lines.push(` details: ${p.details === null ? "clear" : `${((_c = p.details.fields) == null ? void 0 : _c.length) ?? 0} field(s)`}`);
|
|
4742
4990
|
if (p.vesselStatus) lines.push(` vesselStatus: ${p.vesselStatus}`);
|
|
4743
4991
|
if (Array.isArray(p.attachments)) lines.push(` attachments: ${p.attachments.length}`);
|
|
4744
4992
|
if (Array.isArray(p.suggestions)) lines.push(` suggestions: ${p.suggestions.length}`);
|
|
@@ -4955,7 +5203,7 @@ Setup complete.
|
|
|
4955
5203
|
if (flags["webhook-url"]) {
|
|
4956
5204
|
const webhookData = await api("/api/v1/webhooks", {
|
|
4957
5205
|
method: "POST",
|
|
4958
|
-
body: JSON.stringify({ url: flags["webhook-url"], events: ["interaction.response", "message.user", "vessel.created"] })
|
|
5206
|
+
body: JSON.stringify({ url: flags["webhook-url"], events: ["interaction.response", "message.user", "vessel.created", "vessel.archived", "vessel.deleted"] })
|
|
4959
5207
|
});
|
|
4960
5208
|
console.log(` VESSELS_WEBHOOK_SECRET=${webhookData.endpoint.secret}`);
|
|
4961
5209
|
}
|
|
@@ -5062,6 +5310,15 @@ Commands:
|
|
|
5062
5310
|
vessels push --vessel <id> --message <text> --key <api_key>
|
|
5063
5311
|
(--key can be omitted if VESSELS_API_KEY is set)
|
|
5064
5312
|
|
|
5313
|
+
vessels event --vessel <id> --title <text> [--body <markdown>] [--tone info|alert|success] --key <api_key>
|
|
5314
|
+
Paint a human-facing event banner (a fact from your backend \u2014 booking, alert).
|
|
5315
|
+
Fires a notification, no webhook. Renders as a full-width tinted artifact.
|
|
5316
|
+
|
|
5317
|
+
vessels mark --vessel <id> --label <text> [--type <kind>] [--subtext <text>] [--icon <emoji>] [--tone neutral|success|warning|danger] [--url <link>] --key <api_key>
|
|
5318
|
+
Drop a slim timeline chip recording a committed action (email sent, payment
|
|
5319
|
+
recorded). --type \u2208 email_sent|payment_recorded|status_changed|booking_created|
|
|
5320
|
+
invoice_voided|generic. Fires a notification, no webhook.
|
|
5321
|
+
|
|
5065
5322
|
vessels message --vessel <id> --message <text>
|
|
5066
5323
|
Send a message as the logged-in user and print webhook delivery logs.
|
|
5067
5324
|
Accepts vessel UUID or external_id (e.g. booking-123).
|
|
@@ -5152,6 +5409,8 @@ Run: vessels help`);
|
|
|
5152
5409
|
if (cmd === "conversation" || cmd === "trace") return cmdConversation([sub, ...rest].filter(Boolean));
|
|
5153
5410
|
if (cmd === "feedback") return cmdFeedback([sub, ...rest].filter(Boolean));
|
|
5154
5411
|
if (cmd === "push") return cmdPush([sub, ...rest].filter(Boolean));
|
|
5412
|
+
if (cmd === "event") return cmdEvent([sub, ...rest].filter(Boolean));
|
|
5413
|
+
if (cmd === "mark") return cmdMark([sub, ...rest].filter(Boolean));
|
|
5155
5414
|
if (cmd === "message") return cmdMessage([sub, ...rest].filter(Boolean));
|
|
5156
5415
|
if (cmd === "validate") return cmdValidate([sub, ...rest].filter(Boolean));
|
|
5157
5416
|
console.error(`Unknown command: ${cmd}
|
package/package.json
CHANGED
package/template/agent/README.md
CHANGED
|
@@ -54,7 +54,7 @@ Everything else is the **engine** and you rarely touch it:
|
|
|
54
54
|
2. The server verifies the signature, **ACKs 200 immediately**, and runs the turn in the background.
|
|
55
55
|
3. The engine leads with a one-line reply, opens a live working card, plans, calls **your** tools,
|
|
56
56
|
and ends with exactly one finishing tool — a message (`finish`) or a human decision
|
|
57
|
-
(`request_approval` / `choice` / `checklist` / `text`).
|
|
57
|
+
(`request_approval` / `choice` / `checklist` / `text` / `questions`).
|
|
58
58
|
4. When the human answers, Vessels sends another webhook and the loop continues — the engine loads
|
|
59
59
|
the prior conversation from your store, so the agent picks up exactly where it left off.
|
|
60
60
|
|
|
@@ -87,10 +87,14 @@ runTurn` shape stays the same; only the server wrapper changes.
|
|
|
87
87
|
## The full Vessels surface
|
|
88
88
|
|
|
89
89
|
The engine already exposes the breadth of Vessels to the model, so your agent can use all of it:
|
|
90
|
-
chat **bubbles** and full-width **surfaces**; all
|
|
91
|
-
`checklist` / `text
|
|
92
|
-
**metadata** that rides back to you for routing);
|
|
93
|
-
|
|
90
|
+
chat **bubbles** and full-width **surfaces**; all five **interactions** (`approval` / `choice` /
|
|
91
|
+
`checklist` / `text` / `questions` — the multi-question form, with labels, `allowCustom`,
|
|
92
|
+
`minSelections`, `reasonRequired`, and **metadata** that rides back to you for routing);
|
|
93
|
+
**edit-then-approve** (the operator changes a value — a price, a date — and approves, via
|
|
94
|
+
`editables` bound to card fields or inline `{{id}}` body tokens; only the changes come back) and
|
|
95
|
+
one-way **consent gates** (`rejectable:false`); the live **working card** with a ticking **plan**,
|
|
96
|
+
auto-narrated **steps**, and **token streaming**; the vessel's persistent **details** record
|
|
97
|
+
(CRM-style reference facts, shown in the top bar), **labels** (triage/status tags), **attachments**
|
|
94
98
|
(images/files you host), **preview links**, **suggested replies**, vessel **naming/renaming**, and
|
|
95
99
|
user-initiated vessel **types**; plus **inbound files** the human sends you — downloaded, stored on
|
|
96
100
|
your infra, resolved, and (for images) read by the model via **vision** (see below). Idempotency
|
|
@@ -113,10 +117,24 @@ your externally-reachable base (your tunnel in dev) so that link is fetchable. I
|
|
|
113
117
|
`putInboundFile` at object storage (S3/R2/GCS) and return a CDN URL — nothing else changes.
|
|
114
118
|
|
|
115
119
|
A few Vessels features live on **your backend**, not inside a turn — call them on the `vessels`
|
|
116
|
-
SDK directly:
|
|
117
|
-
|
|
120
|
+
SDK directly (none of these round-trip through the agent's turn loop):
|
|
121
|
+
|
|
122
|
+
- `vessels.event({ vessel, title, tone, card?, buttons?, sections? })` — paint a **backend event**
|
|
123
|
+
on the human's timeline: a fact from *your* system (a booking landed, an alert fired), not your
|
|
124
|
+
agent's voice. A full-width tinted banner with deep-link buttons and collapsible sections. It is
|
|
125
|
+
purely presentational — it fires **no webhook and no poll event**, so the agent never hears about
|
|
126
|
+
it through Vessels. Fire it in parallel with triggering the agent off the same fact (e.g. POST a
|
|
127
|
+
`vessel.created`/proactive trigger to this server), and the agent works and replies in its own
|
|
128
|
+
voice alongside the banner.
|
|
118
129
|
- `vessels.pushMany({ vessels: [...], message, interaction })` — broadcast the same message/decision
|
|
119
130
|
to many vessels at once (e.g. "course closed Saturday" to every affected booking).
|
|
131
|
+
- `vessels.updateVessel(externalId, { title?, labels?, details?, archived?, pinned? })` — change
|
|
132
|
+
vessel-level state **without** posting a message; `vessels.archiveVessel(id)` / `listVessels()` /
|
|
133
|
+
`deleteVessel(id)` are the lifecycle helpers around it.
|
|
134
|
+
- `vessels.clearMessages(vessel, { beforeMessageId? | afterMessageId? | before? | after? })` /
|
|
135
|
+
`vessels.deleteMessage(id)` — the durable primitive behind an agent's **/rewind**: trim the
|
|
136
|
+
human-visible feed back to a point (it does **not** touch your agent's own context, which lives
|
|
137
|
+
in your store). Resolve the operator's "rewind to yesterday" to a timestamp/id, then call this.
|
|
120
138
|
- `vessels.getMessages({ vessel })` — re-read a vessel's human-facing history to reconcile a
|
|
121
139
|
stateless/restarted worker (not your memory — that's your store).
|
|
122
140
|
- `vessels.validatePush(payload)` — check a payload against the server schema without sending.
|
|
@@ -16,7 +16,7 @@
|
|
|
16
16
|
* You usually don't edit this file. The pieces worth knowing are flagged inline.
|
|
17
17
|
*/
|
|
18
18
|
import Anthropic from '@anthropic-ai/sdk';
|
|
19
|
-
import type { MessageParam, Tool, ToolUseBlock, TextBlock,
|
|
19
|
+
import type { MessageParam, Tool, ToolUseBlock, TextBlock, ToolResultBlockParam, ImageBlockParam } from '@anthropic-ai/sdk/resources/messages';
|
|
20
20
|
import { Vessels } from 'vessels-sdk';
|
|
21
21
|
import type { PushOptions, AgentActivityType, AgentTodoStatus, InboundAttachment } from 'vessels-sdk';
|
|
22
22
|
import { ROLE } from './role.js';
|
|
@@ -29,6 +29,7 @@ import {
|
|
|
29
29
|
buildInteraction,
|
|
30
30
|
defaultNarrate,
|
|
31
31
|
sanitizeCard,
|
|
32
|
+
sanitizeDetails,
|
|
32
33
|
cleanPreviewUrl,
|
|
33
34
|
cleanTitle,
|
|
34
35
|
cleanSuggestions,
|
|
@@ -40,8 +41,7 @@ import { handleInboundFiles, attachImagesToLastUserMessage } from './inbound.js'
|
|
|
40
41
|
|
|
41
42
|
const MODEL = process.env.ANTHROPIC_MODEL || 'claude-sonnet-4-6';
|
|
42
43
|
const MAX_TURN_STEPS = 12; // tool hops within a single turn before we force an ending
|
|
43
|
-
const
|
|
44
|
-
const MAX_TOKENS = 4096; // think + response combined
|
|
44
|
+
const MAX_TOKENS = 4096;
|
|
45
45
|
const TURN_BUDGET_MS = 75_000; // safety cap — stop starting model calls past this and finish gracefully
|
|
46
46
|
const MAX_HISTORY = 80; // cap persisted conversation messages (trimmed at a safe boundary)
|
|
47
47
|
const DEBUG = process.env.DEBUG === '1' || process.env.DEBUG === 'true';
|
|
@@ -190,7 +190,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
190
190
|
let cardOpened = false;
|
|
191
191
|
let acknowledged = false; // the agent already sent its "on it" bubble → card needs no message
|
|
192
192
|
let pendingTitle = vesselTitle; // rides whichever push lands first
|
|
193
|
-
let
|
|
193
|
+
let pendingDetails: ReturnType<typeof sanitizeDetails> | undefined;
|
|
194
194
|
let pendingLabels: string[] | undefined;
|
|
195
195
|
const ensureWorkingCard = async (): Promise<void> => {
|
|
196
196
|
if (cardOpened) return;
|
|
@@ -198,14 +198,14 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
198
198
|
const r = await safePush(vessels, {
|
|
199
199
|
vessel,
|
|
200
200
|
...(pendingTitle ? { vesselTitle: pendingTitle } : {}),
|
|
201
|
-
...(
|
|
201
|
+
...(pendingDetails !== undefined ? { details: pendingDetails } : {}),
|
|
202
202
|
...(pendingLabels ? { labels: pendingLabels } : {}),
|
|
203
203
|
...(acknowledged ? {} : { message: opening }),
|
|
204
204
|
agentActivity: { type: 'thinking', label: 'Getting started' },
|
|
205
205
|
idempotencyKey: `${idempotencyKeyBase}:work`,
|
|
206
206
|
});
|
|
207
207
|
pendingTitle = undefined;
|
|
208
|
-
|
|
208
|
+
pendingDetails = undefined;
|
|
209
209
|
pendingLabels = undefined;
|
|
210
210
|
activityId = r.messageId ?? null;
|
|
211
211
|
};
|
|
@@ -278,7 +278,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
278
278
|
kind?: 'bubble' | 'surface';
|
|
279
279
|
title?: string;
|
|
280
280
|
card?: unknown;
|
|
281
|
-
|
|
281
|
+
details?: unknown;
|
|
282
282
|
labels?: string[];
|
|
283
283
|
interaction?: Record<string, unknown> | null;
|
|
284
284
|
suggestions?: string[];
|
|
@@ -290,12 +290,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
290
290
|
let ended = false;
|
|
291
291
|
const recordEnding = (name: string, input: Record<string, unknown>) => {
|
|
292
292
|
const msg = String(input.message ?? '');
|
|
293
|
-
const
|
|
293
|
+
const endDetails = sanitizeDetails(input.details);
|
|
294
294
|
const endLabels = cleanLabels(input.labels);
|
|
295
295
|
const endTitle = cleanTitle(input.vesselTitle);
|
|
296
296
|
if (endTitle && !pendingTitle) pendingTitle = endTitle;
|
|
297
297
|
if (name === 'finish') {
|
|
298
|
-
pushes.push({ message: msg || 'All done.',
|
|
298
|
+
pushes.push({ message: msg || 'All done.', details: endDetails, labels: endLabels });
|
|
299
299
|
} else {
|
|
300
300
|
const interaction = buildInteraction(name, input);
|
|
301
301
|
// keepWorking is purely semantic — it tells the model the task isn't finished, so the
|
|
@@ -306,7 +306,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
306
306
|
kind: 'surface',
|
|
307
307
|
title: cleanTitle(input.title),
|
|
308
308
|
card: sanitizeCard(input.card, input),
|
|
309
|
-
|
|
309
|
+
details: endDetails,
|
|
310
310
|
labels: endLabels,
|
|
311
311
|
interaction,
|
|
312
312
|
previewUrl: cleanPreviewUrl(input.previewUrl),
|
|
@@ -328,12 +328,10 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
328
328
|
const ms = anthropic.messages.stream({
|
|
329
329
|
model: MODEL,
|
|
330
330
|
max_tokens: MAX_TOKENS,
|
|
331
|
-
thinking: { type: 'enabled', budget_tokens: THINKING_BUDGET },
|
|
332
331
|
system: systemPrompt,
|
|
333
332
|
tools: ALL_TOOLS,
|
|
334
333
|
messages,
|
|
335
334
|
});
|
|
336
|
-
ms.on('thinking', (delta) => writeStream(delta));
|
|
337
335
|
ms.on('text', (delta) => writeStream(delta));
|
|
338
336
|
ms.on('error', () => {});
|
|
339
337
|
const resp = await ms.finalMessage();
|
|
@@ -342,8 +340,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
342
340
|
|
|
343
341
|
const toolUses = resp.content.filter((b): b is ToolUseBlock => b.type === 'tool_use');
|
|
344
342
|
if (DEBUG) {
|
|
345
|
-
|
|
346
|
-
log('model_done', { step, stop: resp.stop_reason, tools: toolUses.map((t) => t.name), thinking: thinking.slice(0, 200) });
|
|
343
|
+
log('model_done', { step, stop: resp.stop_reason, tools: toolUses.map((t) => t.name) });
|
|
347
344
|
}
|
|
348
345
|
|
|
349
346
|
// No tool call — treat any plain text as a final message and stop.
|
|
@@ -366,7 +363,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
366
363
|
const named = cleanTitle(pIn.vesselTitle);
|
|
367
364
|
if (named) pendingTitle = named;
|
|
368
365
|
}
|
|
369
|
-
if (
|
|
366
|
+
if (pendingDetails === undefined) pendingDetails = sanitizeDetails(pIn.details);
|
|
370
367
|
if (!pendingLabels) pendingLabels = cleanLabels(pIn.labels);
|
|
371
368
|
}
|
|
372
369
|
|
|
@@ -429,12 +426,12 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
429
426
|
} else if (tu.name === 'show_document') {
|
|
430
427
|
const title = cleanTitle(input.title);
|
|
431
428
|
const body = String(input.body ?? '').trim();
|
|
432
|
-
const
|
|
429
|
+
const docDetails = sanitizeDetails(input.details);
|
|
433
430
|
const docLabels = cleanLabels(input.labels);
|
|
434
431
|
if (body) {
|
|
435
|
-
pushes.push({ message: body, kind: 'surface', title, card: sanitizeCard(input.card, input),
|
|
436
|
-
} else if (
|
|
437
|
-
if (
|
|
432
|
+
pushes.push({ message: body, kind: 'surface', title, card: sanitizeCard(input.card, input), details: docDetails, labels: docLabels, attachments: cleanAttachments(input.attachments) });
|
|
433
|
+
} else if (docDetails !== undefined || docLabels) {
|
|
434
|
+
if (docDetails !== undefined) pendingDetails = docDetails;
|
|
438
435
|
if (docLabels) pendingLabels = docLabels;
|
|
439
436
|
}
|
|
440
437
|
toolResults.push({ type: 'tool_result', tool_use_id: tu.id, content: 'shown' });
|
|
@@ -529,10 +526,10 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
529
526
|
const isLast = i === pushes.length - 1;
|
|
530
527
|
const titleForThisPush = i === 0 && pendingTitle ? pendingTitle : undefined;
|
|
531
528
|
if (titleForThisPush) pendingTitle = undefined;
|
|
532
|
-
const
|
|
529
|
+
const detailsForThisPush = p.details !== undefined ? p.details : (i === 0 ? pendingDetails : undefined);
|
|
533
530
|
const labelsForThisPush = p.labels ?? (i === 0 ? pendingLabels : undefined);
|
|
534
531
|
if (i === 0) {
|
|
535
|
-
|
|
532
|
+
pendingDetails = undefined;
|
|
536
533
|
pendingLabels = undefined;
|
|
537
534
|
}
|
|
538
535
|
const r = await safePush(vessels, {
|
|
@@ -542,7 +539,7 @@ export async function runTurn(opts: RunTurnOpts): Promise<void> {
|
|
|
542
539
|
kind: p.kind,
|
|
543
540
|
title: p.title,
|
|
544
541
|
card: p.card as PushOptions['card'],
|
|
545
|
-
...(
|
|
542
|
+
...(detailsForThisPush !== undefined ? { details: detailsForThisPush as PushOptions['details'] } : {}),
|
|
546
543
|
...(labelsForThisPush ? { labels: labelsForThisPush } : {}),
|
|
547
544
|
interaction: (p.interaction ?? undefined) as PushOptions['interaction'],
|
|
548
545
|
suggestions: p.suggestions,
|
|
@@ -63,11 +63,15 @@ Tools:
|
|
|
63
63
|
1. Plan + triage — drive the live ticking checklist AND surface the vessel's state:
|
|
64
64
|
- plan(todos) — declare 3–4 tasks up front. On a vessel's FIRST plan(), ALSO set:
|
|
65
65
|
• labels: 1–3 triage tags in your own vocabulary — how this vessel shows up in the
|
|
66
|
-
operator's list.
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
66
|
+
operator's list. This is where STATUS lives — re-send to retag as the situation
|
|
67
|
+
moves. Replace-semantics (send the full set).
|
|
68
|
+
• details: {fields:[{label, value, url?, tone?, copyable?}]} — the vessel's persistent
|
|
69
|
+
REFERENCE record: stable identity facts about who/what this vessel is about (client
|
|
70
|
+
name, phone/email — set copyable:true on those; a url to deep-link into your own
|
|
71
|
+
admin). It is NOT status — never re-send details just to reflect a state change (that's
|
|
72
|
+
what labels are for); only rewrite it when the underlying facts change. Replaces
|
|
73
|
+
wholesale; null clears.
|
|
74
|
+
Set labels and details on the first plan(); leave them unless the facts/status change.
|
|
71
75
|
- Advance the plan by passing task:"<exact label>" ON the work tool itself (your backend
|
|
72
76
|
tools and send_update take it) — that ticks the plan in the SAME call. Do NOT waste a
|
|
73
77
|
whole turn on a lone step(); only use step() when you advance with no tool to run. You do
|
|
@@ -82,6 +86,16 @@ Tools:
|
|
|
82
86
|
summarise it as "draft is ready"; show the real text.
|
|
83
87
|
- Reserve a previewUrl for something too long or rich to inline (a multi-page document, a
|
|
84
88
|
PDF) — a link to open, not a substitute for showing short text.
|
|
89
|
+
- EDIT-THEN-APPROVE: when the operator is more likely to TWEAK a value than to reject (a
|
|
90
|
+
price, a date, a quantity), declare editables on request_approval instead of forcing a
|
|
91
|
+
reject-and-re-ask. Each editable is {id, type (text|number|currency|date|choice), value}.
|
|
92
|
+
Bind each to where it shows: either a card field carrying the same editableId, OR an inline
|
|
93
|
+
{{id}} token written straight into the message body (works in a table cell). "Edit & approve"
|
|
94
|
+
turns those into inputs; only the values they CHANGED come back to you in the response, and
|
|
95
|
+
you apply the new values in your backend. The body prose stays read-only — only the bound
|
|
96
|
+
values are editable. Don't over-use it: most approvals are a clean yes/no.
|
|
97
|
+
- CONSENT GATE: for a one-way "proceed?" with no real reject path (a confirmation before an
|
|
98
|
+
irreversible action), set rejectable:false — it renders a single full-width Approve button.
|
|
85
99
|
|
|
86
100
|
3. Finishing tools — pick EXACTLY ONE as the FINAL action of your turn:
|
|
87
101
|
- request_approval — yes/no sign-off (optionally with a previewUrl to review)
|
|
@@ -108,7 +122,7 @@ Flow:
|
|
|
108
122
|
- A [SYSTEM EVENT] means you are PROACTIVELY reaching the operator. They ALREADY see your
|
|
109
123
|
opening line and a live ticking plan — so DO NOT re-announce the event or restate its
|
|
110
124
|
details in a chat message. Get straight to work.
|
|
111
|
-
- plan(todos, labels,
|
|
125
|
+
- plan(todos, labels, details) → call each task's backend tool WITH task:"<label>" (it ticks
|
|
112
126
|
the plan + auto-narrates) → then ONE finishing tool. END the turn with a single, complete
|
|
113
127
|
result: the finishing message (one sentence) plus, for approvals, a compact card. Never drop
|
|
114
128
|
a bare card and trail off.
|
|
@@ -17,17 +17,45 @@ import type { AgentActivityType } from 'vessels-sdk';
|
|
|
17
17
|
const CARD_SCHEMA = {
|
|
18
18
|
type: 'object' as const,
|
|
19
19
|
properties: {
|
|
20
|
-
title: { type: 'string' },
|
|
20
|
+
title: { type: 'string', description: 'Optional heading. Redundant on a surface (the surface title is the heading); useful on a standalone bubble card.' },
|
|
21
21
|
fields: {
|
|
22
22
|
type: 'array',
|
|
23
23
|
items: {
|
|
24
24
|
type: 'object',
|
|
25
|
-
properties: {
|
|
25
|
+
properties: {
|
|
26
|
+
label: { type: 'string' },
|
|
27
|
+
value: { type: 'string' },
|
|
28
|
+
url: { type: 'string', description: 'Render the value as a link deep into YOUR own admin (opens a new tab). Shown on surface cards, not the list preview.' },
|
|
29
|
+
tone: { type: 'string', enum: ['default', 'success', 'warning', 'danger'], description: 'Colour hint only — no behaviour.' },
|
|
30
|
+
editableId: { type: 'string', description: 'Edit-then-approve: bind this field to an approval `editables[]` entry of the same id, so it becomes an input on "Edit & approve". Inert without that approval.' },
|
|
31
|
+
},
|
|
26
32
|
required: ['label', 'value'],
|
|
27
33
|
},
|
|
28
34
|
},
|
|
29
35
|
},
|
|
30
|
-
required: ['
|
|
36
|
+
required: ['fields'],
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/** Edit-then-approve: the values the human may change before approving. Each binds to a
|
|
40
|
+
* card field (by matching `editableId`) OR to an inline `{{id}}` token in the body. */
|
|
41
|
+
const EDITABLES_SCHEMA = {
|
|
42
|
+
type: 'array' as const,
|
|
43
|
+
maxItems: 20,
|
|
44
|
+
description:
|
|
45
|
+
'Edit-then-approve — let the operator change specific VALUES before approving (e.g. "make it 1700, not 1500"), instead of rejecting and re-asking. Each entry binds to a card field with a matching `editableId`, or to an inline `{{id}}` token in the message body. On approve, only the values they changed come back in the response `edits` (keyed by id) — apply them in YOUR system. The body prose stays read-only.',
|
|
46
|
+
items: {
|
|
47
|
+
type: 'object',
|
|
48
|
+
properties: {
|
|
49
|
+
id: { type: 'string', description: 'Stable id — the response `edits` key and the `editableId`/`{{id}}` it binds to.' },
|
|
50
|
+
type: { type: 'string', enum: ['text', 'number', 'currency', 'date', 'choice'], description: 'text | number | currency (money) | date (YYYY-MM-DD) | choice (pick an option id).' },
|
|
51
|
+
value: { description: 'The current/baseline value (a number for number/currency, the option id for choice, else a string). Recommended so you can diff the returned edit.' },
|
|
52
|
+
label: { type: 'string', description: 'Edit-field label; falls back to the bound card field label.' },
|
|
53
|
+
options: { type: 'array', description: "type:'choice' only — the options to pick from.", items: { type: 'object', properties: { id: { type: 'string' }, label: { type: 'string' } }, required: ['id', 'label'] }, minItems: 1 },
|
|
54
|
+
min: { type: 'number', description: 'Numeric lower bound (number/currency), validated on submit.' },
|
|
55
|
+
max: { type: 'number', description: 'Numeric upper bound (number/currency), validated on submit.' },
|
|
56
|
+
},
|
|
57
|
+
required: ['id', 'type'],
|
|
58
|
+
},
|
|
31
59
|
};
|
|
32
60
|
|
|
33
61
|
/** Inject this into a tool's input schema so the model can tick the plan in the same call. */
|
|
@@ -47,10 +75,28 @@ const OPTIONS_SCHEMA = {
|
|
|
47
75
|
minItems: 1,
|
|
48
76
|
};
|
|
49
77
|
|
|
50
|
-
const
|
|
51
|
-
|
|
78
|
+
const DETAILS_FIELD = {
|
|
79
|
+
type: 'object' as const,
|
|
80
|
+
properties: {
|
|
81
|
+
fields: {
|
|
82
|
+
type: 'array',
|
|
83
|
+
maxItems: 20,
|
|
84
|
+
items: {
|
|
85
|
+
type: 'object',
|
|
86
|
+
properties: {
|
|
87
|
+
label: { type: 'string' },
|
|
88
|
+
value: { type: 'string' },
|
|
89
|
+
url: { type: 'string', description: 'Deep-link the value into YOUR own admin/CRM (opens in a new tab).' },
|
|
90
|
+
tone: { type: 'string', enum: ['default', 'success', 'warning', 'danger'], description: 'Colour hint only — no behaviour.' },
|
|
91
|
+
copyable: { type: 'boolean', description: "Show a copy button (use on phone numbers, emails, ids the human grabs)." },
|
|
92
|
+
},
|
|
93
|
+
required: ['label', 'value'],
|
|
94
|
+
},
|
|
95
|
+
},
|
|
96
|
+
},
|
|
97
|
+
required: ['fields'],
|
|
52
98
|
description:
|
|
53
|
-
"
|
|
99
|
+
"The vessel's persistent reference record — CRM-style identity facts about who/what this vessel is about (client name, phone/email with copyable:true, links into your admin via url). NOT status (status lives in labels). Replaces wholesale on each write; send null to clear.",
|
|
54
100
|
};
|
|
55
101
|
|
|
56
102
|
const LABELS_FIELD = {
|
|
@@ -116,11 +162,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
116
162
|
description:
|
|
117
163
|
'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
164
|
},
|
|
119
|
-
|
|
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
|
-
},
|
|
165
|
+
details: DETAILS_FIELD,
|
|
124
166
|
},
|
|
125
167
|
required: ['todos'],
|
|
126
168
|
},
|
|
@@ -168,7 +210,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
168
210
|
},
|
|
169
211
|
card: CARD_SCHEMA,
|
|
170
212
|
attachments: ATTACHMENTS_FIELD,
|
|
171
|
-
|
|
213
|
+
details: DETAILS_FIELD,
|
|
172
214
|
labels: LABELS_FIELD,
|
|
173
215
|
},
|
|
174
216
|
required: ['title', 'body'],
|
|
@@ -190,9 +232,11 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
190
232
|
approveLabel: { type: 'string' },
|
|
191
233
|
rejectLabel: { type: 'string' },
|
|
192
234
|
reasonRequired: { type: 'boolean', description: 'Require the human to type a reason with their approve/reject.' },
|
|
235
|
+
rejectable: { type: 'boolean', description: 'Set FALSE for a one-way consent/proceed gate — a single full-width Approve button, no reject path. Defaults true (the two-way Approve/Reject decision).' },
|
|
236
|
+
editables: EDITABLES_SCHEMA,
|
|
193
237
|
previewUrl: { type: 'string', description: 'Optional link for an artifact too rich to inline. For text, inline it in `message`.' },
|
|
194
238
|
card: CARD_SCHEMA,
|
|
195
|
-
|
|
239
|
+
details: DETAILS_FIELD,
|
|
196
240
|
labels: LABELS_FIELD,
|
|
197
241
|
metadata: METADATA_FIELD,
|
|
198
242
|
keepWorking: KEEP_WORKING_FIELD,
|
|
@@ -213,7 +257,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
213
257
|
allowCustom: { type: 'boolean', description: 'Let the human type their own answer instead of picking an option.' },
|
|
214
258
|
customPlaceholder: { type: 'string', description: 'Placeholder for the custom-answer field (when allowCustom).' },
|
|
215
259
|
card: CARD_SCHEMA,
|
|
216
|
-
|
|
260
|
+
details: DETAILS_FIELD,
|
|
217
261
|
labels: LABELS_FIELD,
|
|
218
262
|
metadata: METADATA_FIELD,
|
|
219
263
|
keepWorking: KEEP_WORKING_FIELD,
|
|
@@ -234,7 +278,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
234
278
|
minSelections: { type: 'number', description: 'Minimum number the human must select.' },
|
|
235
279
|
submitLabel: { type: 'string', description: 'Label for the submit button.' },
|
|
236
280
|
card: CARD_SCHEMA,
|
|
237
|
-
|
|
281
|
+
details: DETAILS_FIELD,
|
|
238
282
|
labels: LABELS_FIELD,
|
|
239
283
|
metadata: METADATA_FIELD,
|
|
240
284
|
keepWorking: KEEP_WORKING_FIELD,
|
|
@@ -254,7 +298,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
254
298
|
placeholder: { type: 'string' },
|
|
255
299
|
multiline: { type: 'boolean' },
|
|
256
300
|
submitLabel: { type: 'string', description: 'Label for the submit button.' },
|
|
257
|
-
|
|
301
|
+
details: DETAILS_FIELD,
|
|
258
302
|
labels: LABELS_FIELD,
|
|
259
303
|
metadata: METADATA_FIELD,
|
|
260
304
|
keepWorking: KEEP_WORKING_FIELD,
|
|
@@ -303,7 +347,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
303
347
|
},
|
|
304
348
|
},
|
|
305
349
|
submitLabel: { type: 'string' },
|
|
306
|
-
|
|
350
|
+
details: DETAILS_FIELD,
|
|
307
351
|
labels: LABELS_FIELD,
|
|
308
352
|
metadata: METADATA_FIELD,
|
|
309
353
|
keepWorking: KEEP_WORKING_FIELD,
|
|
@@ -319,7 +363,7 @@ export const CONTROL_TOOLS: Tool[] = [
|
|
|
319
363
|
properties: {
|
|
320
364
|
message: { type: 'string' },
|
|
321
365
|
vesselTitle: { type: 'string', description: 'Rename the vessel as you wrap up (≤6 words). Omit otherwise.' },
|
|
322
|
-
|
|
366
|
+
details: DETAILS_FIELD,
|
|
323
367
|
labels: LABELS_FIELD,
|
|
324
368
|
},
|
|
325
369
|
required: ['message'],
|
|
@@ -374,14 +418,18 @@ export function buildInteraction(toolName: string, input: Record<string, unknown
|
|
|
374
418
|
const meta = cleanMetadata(input.metadata);
|
|
375
419
|
const withMeta = (obj: Record<string, unknown>) => (meta ? { ...obj, metadata: meta } : obj);
|
|
376
420
|
switch (toolName) {
|
|
377
|
-
case 'request_approval':
|
|
421
|
+
case 'request_approval': {
|
|
422
|
+
const editables = sanitizeEditables(input.editables);
|
|
378
423
|
return withMeta({
|
|
379
424
|
type: 'approval',
|
|
380
425
|
prompt: String(input.prompt),
|
|
381
426
|
...(input.approveLabel ? { approveLabel: String(input.approveLabel) } : {}),
|
|
382
427
|
...(input.rejectLabel ? { rejectLabel: String(input.rejectLabel) } : {}),
|
|
383
428
|
...(input.reasonRequired ? { reasonRequired: true } : {}),
|
|
429
|
+
...(input.rejectable === false ? { rejectable: false } : {}),
|
|
430
|
+
...(editables ? { editables } : {}),
|
|
384
431
|
});
|
|
432
|
+
}
|
|
385
433
|
case 'request_choice':
|
|
386
434
|
return withMeta({
|
|
387
435
|
type: 'choice',
|
|
@@ -432,7 +480,15 @@ export function renderInteractionResponse(
|
|
|
432
480
|
case 'approval': {
|
|
433
481
|
const action = String(response.action ?? '');
|
|
434
482
|
const reason = response.reason ? ` (${response.reason})` : '';
|
|
435
|
-
|
|
483
|
+
// Edit-then-approve: the operator changed one or more editable values before approving.
|
|
484
|
+
// Surface them so the agent applies the NEW values in its backend, not the originals.
|
|
485
|
+
const edits = response.edits && typeof response.edits === 'object' && !Array.isArray(response.edits)
|
|
486
|
+
? (response.edits as Record<string, unknown>)
|
|
487
|
+
: null;
|
|
488
|
+
const editLine = edits && Object.keys(edits).length
|
|
489
|
+
? ` — and changed: ${Object.entries(edits).map(([k, v]) => `${k} → ${String(v)}`).join(', ')} (apply these new values)`
|
|
490
|
+
: '';
|
|
491
|
+
return `${head}I ${action === 'approved' ? 'APPROVED' : 'REJECTED'}${reason}${editLine}.`;
|
|
436
492
|
}
|
|
437
493
|
case 'choice': {
|
|
438
494
|
const sel = response.customValue ? `${response.selected} → "${response.customValue}"` : response.selected;
|
|
@@ -468,11 +524,18 @@ export function renderInteractionResponse(
|
|
|
468
524
|
|
|
469
525
|
// ─── Defensive payload sanitisers ────────────────────────────────────────────────
|
|
470
526
|
|
|
471
|
-
|
|
527
|
+
type CardField = { label: string; value: string; url?: string; tone?: 'default' | 'success' | 'warning' | 'danger'; editableId?: string };
|
|
528
|
+
|
|
529
|
+
/**
|
|
530
|
+
* Return a valid card {title?, fields:[{label,value,url?,tone?,editableId?}]}, salvaging a
|
|
531
|
+
* sibling `fields`, or undefined. Title is optional (redundant on a surface). Per-field
|
|
532
|
+
* `url`/`tone`/`editableId` are preserved — `editableId` is what binds a field to an
|
|
533
|
+
* approval's `editables[]` for edit-then-approve.
|
|
534
|
+
*/
|
|
472
535
|
export function sanitizeCard(
|
|
473
536
|
raw: unknown,
|
|
474
537
|
input?: Record<string, unknown>
|
|
475
|
-
): { title
|
|
538
|
+
): { title?: string; fields: CardField[] } | undefined {
|
|
476
539
|
const obj = raw && typeof raw === 'object' && !Array.isArray(raw) ? (raw as Record<string, unknown>) : {};
|
|
477
540
|
const titleRaw = typeof obj.title === 'string' ? obj.title : typeof raw === 'string' ? raw : '';
|
|
478
541
|
const title = String(titleRaw).replace(/<[^>]*>/g, '').replace(/\s+/g, ' ').trim();
|
|
@@ -483,10 +546,89 @@ export function sanitizeCard(
|
|
|
483
546
|
: [];
|
|
484
547
|
const fields = fieldsSrc
|
|
485
548
|
.filter((f): f is Record<string, unknown> => !!f && typeof f === 'object')
|
|
486
|
-
.map((f) =>
|
|
549
|
+
.map((f) => {
|
|
550
|
+
const field: CardField = { label: String(f.label ?? '').trim(), value: String(f.value ?? '').trim() };
|
|
551
|
+
if (typeof f.url === 'string' && /^https?:\/\/\S+$/.test(f.url.trim())) field.url = f.url.trim();
|
|
552
|
+
if (f.tone === 'success' || f.tone === 'warning' || f.tone === 'danger' || f.tone === 'default') field.tone = f.tone;
|
|
553
|
+
if (typeof f.editableId === 'string' && f.editableId.trim()) field.editableId = f.editableId.trim();
|
|
554
|
+
return field;
|
|
555
|
+
})
|
|
487
556
|
.filter((f) => f.label && f.value);
|
|
488
|
-
if (
|
|
489
|
-
return { title, fields };
|
|
557
|
+
if (fields.length === 0) return undefined;
|
|
558
|
+
return { ...(title ? { title } : {}), fields };
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
type EditableField = {
|
|
562
|
+
id: string;
|
|
563
|
+
type: 'text' | 'number' | 'currency' | 'date' | 'choice';
|
|
564
|
+
value?: string | number;
|
|
565
|
+
label?: string;
|
|
566
|
+
options?: { id: string; label: string }[];
|
|
567
|
+
min?: number;
|
|
568
|
+
max?: number;
|
|
569
|
+
};
|
|
570
|
+
|
|
571
|
+
/**
|
|
572
|
+
* Edit-then-approve: sanitise the `editables` an approval declares. A malformed entry is
|
|
573
|
+
* dropped (never 400s the whole push); a `choice` with no valid options is dropped (the API
|
|
574
|
+
* rejects it). Returns undefined when nothing usable survives.
|
|
575
|
+
*/
|
|
576
|
+
export function sanitizeEditables(raw: unknown): EditableField[] | undefined {
|
|
577
|
+
if (!Array.isArray(raw)) return undefined;
|
|
578
|
+
const TYPES = new Set(['text', 'number', 'currency', 'date', 'choice']);
|
|
579
|
+
const out: EditableField[] = [];
|
|
580
|
+
for (const e of raw) {
|
|
581
|
+
if (!e || typeof e !== 'object') continue;
|
|
582
|
+
const o = e as Record<string, unknown>;
|
|
583
|
+
const id = String(o.id ?? '').trim();
|
|
584
|
+
const type = String(o.type ?? '');
|
|
585
|
+
if (!id || !TYPES.has(type)) continue;
|
|
586
|
+
const field: EditableField = { id, type: type as EditableField['type'] };
|
|
587
|
+
if (typeof o.value === 'number' || typeof o.value === 'string') field.value = o.value;
|
|
588
|
+
if (typeof o.label === 'string' && o.label.trim()) field.label = o.label.trim();
|
|
589
|
+
if (typeof o.min === 'number') field.min = o.min;
|
|
590
|
+
if (typeof o.max === 'number') field.max = o.max;
|
|
591
|
+
if (type === 'choice') {
|
|
592
|
+
const opts = Array.isArray(o.options)
|
|
593
|
+
? (o.options as unknown[])
|
|
594
|
+
.filter((x): x is Record<string, unknown> => !!x && typeof x === 'object')
|
|
595
|
+
.map((x) => ({ id: String(x.id ?? '').trim(), label: String(x.label ?? '').trim() }))
|
|
596
|
+
.filter((x) => x.id && x.label)
|
|
597
|
+
: [];
|
|
598
|
+
if (!opts.length) continue; // a choice editable requires options — else the API rejects it
|
|
599
|
+
field.options = opts;
|
|
600
|
+
}
|
|
601
|
+
out.push(field);
|
|
602
|
+
if (out.length >= 20) break;
|
|
603
|
+
}
|
|
604
|
+
return out.length ? out : undefined;
|
|
605
|
+
}
|
|
606
|
+
|
|
607
|
+
type DetailField = { label: string; value: string; url?: string; tone?: 'default' | 'success' | 'warning' | 'danger'; copyable?: boolean };
|
|
608
|
+
|
|
609
|
+
/**
|
|
610
|
+
* The vessel's reference record. `null` clears it (passes through). A malformed
|
|
611
|
+
* shape collapses to undefined (omit) so it never 400s the whole push.
|
|
612
|
+
*/
|
|
613
|
+
export function sanitizeDetails(raw: unknown): { fields: DetailField[] } | null | undefined {
|
|
614
|
+
if (raw === null) return null;
|
|
615
|
+
if (!raw || typeof raw !== 'object' || Array.isArray(raw)) return undefined;
|
|
616
|
+
const fieldsSrc = Array.isArray((raw as Record<string, unknown>).fields) ? ((raw as Record<string, unknown>).fields as unknown[]) : [];
|
|
617
|
+
const fields: DetailField[] = [];
|
|
618
|
+
for (const f of fieldsSrc) {
|
|
619
|
+
if (!f || typeof f !== 'object') continue;
|
|
620
|
+
const o = f as Record<string, unknown>;
|
|
621
|
+
const label = String(o.label ?? '').trim();
|
|
622
|
+
const value = String(o.value ?? '').trim();
|
|
623
|
+
if (!label || !value) continue;
|
|
624
|
+
const field: DetailField = { label, value };
|
|
625
|
+
if (typeof o.url === 'string' && /^https?:\/\/\S+$/.test(o.url.trim())) field.url = o.url.trim();
|
|
626
|
+
if (o.tone === 'success' || o.tone === 'warning' || o.tone === 'danger' || o.tone === 'default') field.tone = o.tone;
|
|
627
|
+
if (o.copyable === true) field.copyable = true;
|
|
628
|
+
fields.push(field);
|
|
629
|
+
if (fields.length >= 20) break;
|
|
630
|
+
}
|
|
631
|
+
return fields.length ? { fields } : undefined;
|
|
490
632
|
}
|
|
491
633
|
|
|
492
634
|
/** Only pass a previewUrl the API will accept (http/https), else undefined. */
|