@kivimedia/kmhub 2.9.1 → 2.10.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 +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +36 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -110
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +134 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +125 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +134 -134
- package/tools.mjs +407 -407
package/tools/sops.mjs
CHANGED
|
@@ -1,314 +1,314 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool family: sops - the written-down version of how this business actually works.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS EXISTS
|
|
5
|
-
* J&M Events paid for GembaDocs and cancelled it two days later. The document
|
|
6
|
-
* half of that tool is easy; what neither GembaDocs nor Scribe can do is know
|
|
7
|
-
* what a job is. A GembaDocs SOP is a page somebody has to remember to go and
|
|
8
|
-
* find. Here, an SOP hangs off the venue, the gear, the event type or the crew
|
|
9
|
-
* role, so `km_sops_for` can answer "what do I need to know before this job"
|
|
10
|
-
* from the job itself.
|
|
11
|
-
*
|
|
12
|
-
* That is why km_sops_for exists and why it is the tool to reach for first.
|
|
13
|
-
*
|
|
14
|
-
* SAFETY. Nothing here sends, charges or moves anything. The one real gate is
|
|
15
|
-
* publishing: a draft is somebody thinking out loud, a published SOP is what a
|
|
16
|
-
* crew member on a loading dock will actually follow, and km_publish_sop is the
|
|
17
|
-
* line between them. That is why publishing refuses an SOP with no steps.
|
|
18
|
-
*
|
|
19
|
-
* The family contract this file follows is documented in ./README.md.
|
|
20
|
-
*/
|
|
21
|
-
import { z } from 'zod';
|
|
22
|
-
|
|
23
|
-
export const FAMILY = 'sops';
|
|
24
|
-
|
|
25
|
-
export const TOOLS = [
|
|
26
|
-
'km_sops_for',
|
|
27
|
-
'km_list_sops',
|
|
28
|
-
'km_get_sop',
|
|
29
|
-
'km_create_sop',
|
|
30
|
-
'km_update_sop',
|
|
31
|
-
'km_set_sop_steps',
|
|
32
|
-
'km_publish_sop',
|
|
33
|
-
'km_attach_sop',
|
|
34
|
-
'km_detach_sop',
|
|
35
|
-
'km_sop_images',
|
|
36
|
-
'km_set_sop_step_image',
|
|
37
|
-
'km_set_sop_step_video',
|
|
38
|
-
'km_delete_sop',
|
|
39
|
-
];
|
|
40
|
-
|
|
41
|
-
// Lands in `full` only, which is what an EMPTY list means here - not ['*'].
|
|
42
|
-
//
|
|
43
|
-
// tools.mjs is explicit about this and I got it wrong first time: a family the
|
|
44
|
-
// PROFILES map has never PLACED falls through to its own declaration, and ['*']
|
|
45
|
-
// opts into every profile. Measured: all 11 SOP tools landed in `core`, taking
|
|
46
|
-
// it from 45 tools to 45 including a quarter that a caller asking for the
|
|
47
|
-
// minimal set never wanted. The file's own header records six families making
|
|
48
|
-
// exactly this mistake and calls the resulting split theatre.
|
|
49
|
-
//
|
|
50
|
-
// To put SOPs into a narrow profile deliberately, name the family in the
|
|
51
|
-
// PROFILES map in tools.mjs. That is a decision, and it belongs there.
|
|
52
|
-
export const PROFILES = [];
|
|
53
|
-
|
|
54
|
-
// equipment_item is the real gear table; catalog_item is the legacy spelling
|
|
55
|
-
// mig 673 shipped, kept valid so nothing already written breaks.
|
|
56
|
-
const TARGET_TYPES = ['booking', 'gig', 'venue', 'equipment_item', 'catalog_item', 'event_type', 'crew_role', 'client'];
|
|
57
|
-
|
|
58
|
-
const stepShape = z.object({
|
|
59
|
-
body: z.string().describe('What to DO, in one instruction. Keep it under about 120 characters where you can - it prints beside a photo.'),
|
|
60
|
-
title: z.string().optional().describe('Optional short heading for the step.'),
|
|
61
|
-
reasons_why: z.string().optional()
|
|
62
|
-
.describe('WHY the step matters, kept separate from the instruction on purpose. This is where "the warehouse never sees it if it is not written here" goes.'),
|
|
63
|
-
is_critical: z.boolean().optional()
|
|
64
|
-
.describe('True when getting this step wrong costs money, damages gear, or reaches a client. It prints as a warning triangle. Use it sparingly - three flags in a twelve-step SOP still mean something, ten do not.'),
|
|
65
|
-
is_text_only: z.boolean().optional()
|
|
66
|
-
.describe('True when the step deliberately has no picture. Defaults to true automatically when there is no image_path, so the PDF never prints an empty frame.'),
|
|
67
|
-
image_path: z.string().optional().describe('Storage path in the org-sop-images bucket, if this step has a photograph.'),
|
|
68
|
-
video_path: z.string().optional().describe('R2 key (kmsop/<org>/<uuid>.mp4) of the clip on this step, as returned by km_set_sop_step_video. Clips live on Cloudflare R2, not Supabase storage.'),
|
|
69
|
-
video_alt: z.string().optional().describe('What the clip shows. Printed as the caption of the video link on every PDF style.'),
|
|
70
|
-
image_alt: z.string().optional().describe('What the photograph shows, for anyone who cannot see it.'),
|
|
71
|
-
capture_meta: z.record(z.unknown()).optional()
|
|
72
|
-
.describe('Provenance when a browser capture wrote this step: {url, selector, click, viewport, captured_at}. Lets the step be re-captured when the screen it describes moves.'),
|
|
73
|
-
});
|
|
74
|
-
|
|
75
|
-
export function register(server, call, { out }) {
|
|
76
|
-
// ── the one that justifies the whole family ─────────────────────────────
|
|
77
|
-
server.tool(
|
|
78
|
-
'km_sops_for',
|
|
79
|
-
'Every published SOP attached to a specific thing: a booking, a venue, a piece of gear, an event type or a crew role. ' +
|
|
80
|
-
'THIS IS THE TOOL TO REACH FOR FIRST. It answers "what does somebody need to know before doing this job" from the job ' +
|
|
81
|
-
'itself, which is the whole reason SOPs live in KM Hub rather than in a separate documents app nobody opens. ' +
|
|
82
|
-
'Reach for it when a booking is being prepared, when crew are being briefed, when somebody asks how a venue is loaded in, ' +
|
|
83
|
-
'or when a piece of equipment is going out and you want the setup procedure for it. ' +
|
|
84
|
-
'Worth running unprompted before a job: an SOP that exists and is not read is the same as no SOP. ' +
|
|
85
|
-
'Draft SOPs are deliberately excluded - a draft is not guidance yet. Read only.',
|
|
86
|
-
{
|
|
87
|
-
target_type: z.enum(TARGET_TYPES).describe('What kind of thing you are asking about.'),
|
|
88
|
-
target_id: z.string().describe('The id of that booking, venue, gear item, event type or role.'),
|
|
89
|
-
},
|
|
90
|
-
async ({ target_type, target_id }) =>
|
|
91
|
-
out(await call('GET', `/sops/for/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`)),
|
|
92
|
-
);
|
|
93
|
-
|
|
94
|
-
server.tool(
|
|
95
|
-
'km_list_sops',
|
|
96
|
-
'The SOP library for this workspace: reference number, title, folder, status and revision. ' +
|
|
97
|
-
'Reach for it when the user asks what procedures exist, what is documented, or wants to find an SOP by name. ' +
|
|
98
|
-
'An empty library is worth saying out loud - it means every process in the business lives only in somebody\'s head, ' +
|
|
99
|
-
'and the answer to "what happens when that person is away" is nothing. ' +
|
|
100
|
-
'Filter by status to separate what is actually in use from what is still being written. Read only.',
|
|
101
|
-
{
|
|
102
|
-
status: z.enum(['draft', 'published', 'archived']).optional()
|
|
103
|
-
.describe('published = in use. draft = being written and NOT yet guidance. archived = kept for history.'),
|
|
104
|
-
folder: z.string().optional().describe('Only SOPs filed in this folder or any folder nested under it, e.g. "Operations" also returns "Operations/Equipment".'),
|
|
105
|
-
tag: z.string().optional().describe('Only SOPs carrying this tag.'),
|
|
106
|
-
q: z.string().optional().describe('Free text against title and summary.'),
|
|
107
|
-
},
|
|
108
|
-
async (args) => {
|
|
109
|
-
const p = new URLSearchParams();
|
|
110
|
-
for (const [k, v] of Object.entries(args)) if (v) p.set(k, String(v));
|
|
111
|
-
const qs = p.toString();
|
|
112
|
-
return out(await call('GET', `/sops${qs ? `?${qs}` : ''}`));
|
|
113
|
-
},
|
|
114
|
-
);
|
|
115
|
-
|
|
116
|
-
server.tool(
|
|
117
|
-
'km_get_sop',
|
|
118
|
-
'One SOP in full: every step in order with its reasons-why, which steps are flagged critical, and what the SOP is ' +
|
|
119
|
-
'attached to. Reach for it before editing an SOP, when somebody asks how a specific job is done, or when you are about ' +
|
|
120
|
-
'to answer a question that a written procedure already answers better than you can. Read only.',
|
|
121
|
-
{ sop_id: z.string().describe('The SOP id from km_list_sops or km_sops_for.') },
|
|
122
|
-
async ({ sop_id }) => out(await call('GET', `/sops/${encodeURIComponent(sop_id)}`)),
|
|
123
|
-
);
|
|
124
|
-
|
|
125
|
-
// ── writing ─────────────────────────────────────────────────────────────
|
|
126
|
-
server.tool(
|
|
127
|
-
'km_create_sop',
|
|
128
|
-
'Write a new SOP, steps and all. It is created as a DRAFT and is not guidance until km_publish_sop runs, so this is safe ' +
|
|
129
|
-
'to use while still working the wording out. ' +
|
|
130
|
-
'Reach for it when the user describes how something is done and it is worth keeping, when a call transcript contains a ' +
|
|
131
|
-
'procedure, or when a process was just worked out and would otherwise be lost. ' +
|
|
132
|
-
'Write steps the way somebody standing in a warehouse reads them: one action each, present tense, the thing to do first. ' +
|
|
133
|
-
'Put the reason in reasons_why rather than folding it into the instruction - a step that explains itself mid-sentence ' +
|
|
134
|
-
'is a step nobody finishes. Flag a step critical only when getting it wrong costs money or reaches a client.',
|
|
135
|
-
{
|
|
136
|
-
title: z.string().describe('What this procedure is called. It is what people search for, so name it after the job, not the tool.'),
|
|
137
|
-
summary: z.string().optional().describe('One line on when this SOP applies.'),
|
|
138
|
-
folder: z.string().optional().describe('Folder path. Nest with "/" (or ">"), e.g. "Operations/Processes" or "Operations/Equipment".'),
|
|
139
|
-
tags: z.array(z.string()).optional().describe('Free tags, e.g. ["audio", "load-in"]. Stored lower case, de-duplicated, max 20.'),
|
|
140
|
-
is_critical: z.boolean().optional().describe('True when the whole procedure is safety or money critical.'),
|
|
141
|
-
source: z.enum(['manual', 'capture', 'import', 'mobile']).optional()
|
|
142
|
-
.describe('Where the steps came from. "capture" means a browser extension wrote them; "mobile" means somebody photographed real work.'),
|
|
143
|
-
steps: z.array(stepShape).optional().describe('The steps, in order. You can create the SOP empty and add them later.'),
|
|
144
|
-
},
|
|
145
|
-
async (args) => out(await call('POST', '/sops', args)),
|
|
146
|
-
);
|
|
147
|
-
|
|
148
|
-
server.tool(
|
|
149
|
-
'km_update_sop',
|
|
150
|
-
'Change an SOP\'s title, summary, folder, tags, status or whole-SOP critical flag. tags REPLACES the whole tag list: ' +
|
|
151
|
-
'read the current tags with km_get_sop and send them back with your change. Does not touch the steps - use ' +
|
|
152
|
-
'km_set_sop_steps for those. Reach for it to refile, rename, or archive a procedure that is no longer how the work is done.',
|
|
153
|
-
{
|
|
154
|
-
sop_id: z.string(),
|
|
155
|
-
title: z.string().optional(),
|
|
156
|
-
summary: z.string().optional(),
|
|
157
|
-
folder: z.string().optional().describe('Folder path, nested with "/", e.g. "Operations/Equipment". Empty string unfiles it.'),
|
|
158
|
-
tags: z.array(z.string()).optional().describe('The COMPLETE tag list. An empty array removes every tag.'),
|
|
159
|
-
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
160
|
-
is_critical: z.boolean().optional(),
|
|
161
|
-
},
|
|
162
|
-
async ({ sop_id, ...patch }) => out(await call('PATCH', `/sops/${encodeURIComponent(sop_id)}`, patch)),
|
|
163
|
-
);
|
|
164
|
-
|
|
165
|
-
server.tool(
|
|
166
|
-
'km_set_sop_steps',
|
|
167
|
-
'Replace the entire step list of an SOP with the list you pass. ' +
|
|
168
|
-
'🚨 THIS IS A REPLACE, NOT AN APPEND. Whatever is there now is deleted and what you send becomes the SOP. Read the ' +
|
|
169
|
-
'current steps with km_get_sop first and send them back with your changes included, or you will silently delete work ' +
|
|
170
|
-
'somebody else wrote. Steps are renumbered from 1 in the order you send them.',
|
|
171
|
-
{
|
|
172
|
-
sop_id: z.string(),
|
|
173
|
-
steps: z.array(stepShape).describe('The COMPLETE step list, in order. An empty array deletes every step.'),
|
|
174
|
-
},
|
|
175
|
-
async ({ sop_id, steps }) => out(await call('PUT', `/sops/${encodeURIComponent(sop_id)}/steps`, { steps })),
|
|
176
|
-
);
|
|
177
|
-
|
|
178
|
-
server.tool(
|
|
179
|
-
'km_publish_sop',
|
|
180
|
-
'Publish an SOP and freeze the current version into its revision history. ' +
|
|
181
|
-
'This is the line between a draft somebody is still thinking about and a document a crew member on a loading dock will ' +
|
|
182
|
-
'follow, so treat it as a real decision rather than a save button. Confirm with the user before publishing something ' +
|
|
183
|
-
'you wrote yourself. ' +
|
|
184
|
-
'The frozen snapshot is the audit trail: it can be read back even after the live steps move on, which is what makes the ' +
|
|
185
|
-
'revision number mean anything. Publishing an SOP with no steps is refused. ' +
|
|
186
|
-
'Say what changed in changes_detail - a revision history with no reasons in it is just a list of dates.',
|
|
187
|
-
{
|
|
188
|
-
sop_id: z.string(),
|
|
189
|
-
changes_detail: z.string().optional().describe('What changed and why, for the revision history.'),
|
|
190
|
-
revision_bump: z.enum(['minor', 'major']).optional()
|
|
191
|
-
.describe('minor (default) moves 1.000 to 1.001 - wording, a clearer photo. major moves 1.000 to 2.000 - the procedure itself changed and anyone trained on the old one needs telling.'),
|
|
192
|
-
},
|
|
193
|
-
async ({ sop_id, ...body }) => out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/publish`, body)),
|
|
194
|
-
);
|
|
195
|
-
|
|
196
|
-
// ── the link that makes an SOP findable ─────────────────────────────────
|
|
197
|
-
server.tool(
|
|
198
|
-
'km_attach_sop',
|
|
199
|
-
'Attach an SOP to a booking, venue, piece of gear, event type or crew role, so it surfaces automatically whenever ' +
|
|
200
|
-
'somebody is working on that thing. ' +
|
|
201
|
-
'This is what stops a good procedure from being a document nobody opens. Reach for it every time you write an SOP: an ' +
|
|
202
|
-
'unattached SOP is only findable by someone who already knows it exists. ' +
|
|
203
|
-
'A venue load-in procedure belongs on the venue. A projector setup belongs on the gear item. A prep process belongs on ' +
|
|
204
|
-
'the event type. Attaching the same SOP to the same thing twice is harmless.',
|
|
205
|
-
{
|
|
206
|
-
sop_id: z.string(),
|
|
207
|
-
target_type: z.enum(TARGET_TYPES),
|
|
208
|
-
target_id: z.string().describe('The id of the booking, venue, gear item, event type or role.'),
|
|
209
|
-
note: z.string().optional().describe('Why this SOP is on this thing, e.g. "ballroom only, not the terrace".'),
|
|
210
|
-
},
|
|
211
|
-
async ({ sop_id, ...body }) => out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/links`, body)),
|
|
212
|
-
);
|
|
213
|
-
|
|
214
|
-
server.tool(
|
|
215
|
-
'km_detach_sop',
|
|
216
|
-
'Take an SOP back off a booking, venue, gear item, event type or role. The SOP itself is untouched - this only stops it ' +
|
|
217
|
-
'appearing against that one thing.',
|
|
218
|
-
{
|
|
219
|
-
sop_id: z.string(),
|
|
220
|
-
target_type: z.enum(TARGET_TYPES),
|
|
221
|
-
target_id: z.string(),
|
|
222
|
-
},
|
|
223
|
-
async ({ sop_id, target_type, target_id }) => {
|
|
224
|
-
const p = new URLSearchParams({ target_type, target_id });
|
|
225
|
-
return out(await call('DELETE', `/sops/${encodeURIComponent(sop_id)}/links?${p.toString()}`));
|
|
226
|
-
},
|
|
227
|
-
);
|
|
228
|
-
// ── the photographs ─────────────────────────────────────────────────────
|
|
229
|
-
server.tool(
|
|
230
|
-
'km_sop_images',
|
|
231
|
-
'Look at the photographs on an SOP. Returns a short-lived link per step, plus the instruction that goes with it. ' +
|
|
232
|
-
'REACH FOR THIS WHENEVER THE PICTURES MATTER, which is most of the time: an SOP is half words and half "the screen ' +
|
|
233
|
-
'looks like THIS", and every other tool here hands back a storage path in a private bucket that cannot be opened. ' +
|
|
234
|
-
'Use it to check a captured SOP actually shows what it claims, to write image_alt text for steps that have none, ' +
|
|
235
|
-
'or to answer a question about what a screen looks like. ' +
|
|
236
|
-
'A step reported as missing_bytes has an image recorded whose file is gone from the bucket, which is worth saying ' +
|
|
237
|
-
'out loud rather than treating as a step with no picture. The links expire, so fetch them when you need them ' +
|
|
238
|
-
'rather than storing them. Read only.',
|
|
239
|
-
{ sop_id: z.string().describe('The SOP id from km_list_sops, km_get_sop or km_sops_for.') },
|
|
240
|
-
async ({ sop_id }) => out(await call('GET', `/sops/${encodeURIComponent(sop_id)}/images`)),
|
|
241
|
-
);
|
|
242
|
-
|
|
243
|
-
server.tool(
|
|
244
|
-
'km_set_sop_step_image',
|
|
245
|
-
'Put a photograph on a step. This is what turns an SOP from a description into something somebody can follow: ' +
|
|
246
|
-
'"the cable goes in THIS port" cannot be written, only shown. ' +
|
|
247
|
-
'Reach for it when a step is about what something looks like, when km_sop_images reports a step with no picture ' +
|
|
248
|
-
'that clearly needs one, or when replacing a photograph of a screen that has since changed. ' +
|
|
249
|
-
'PREFER image_url. A photograph is megabytes; sending it as base64 through this tool means pushing all of it ' +
|
|
250
|
-
'through the conversation, which is slow and often refused outright. Give the server a public https URL and it ' +
|
|
251
|
-
'fetches the picture itself. Use image_base64 only for something genuinely small. ' +
|
|
252
|
-
'Re-sending replaces that step\'s photograph rather than adding a second one, so a retry is safe. ' +
|
|
253
|
-
'Write image_alt every time: it is what somebody who cannot see the picture gets, and it is the only part of a ' +
|
|
254
|
-
'photograph that survives into a text brief.',
|
|
255
|
-
{
|
|
256
|
-
sop_id: z.string(),
|
|
257
|
-
step_no: z.number().int().positive().describe('Which step, counting from 1, as km_get_sop numbers them.'),
|
|
258
|
-
image_url: z.string().optional()
|
|
259
|
-
.describe('Public https URL for the server to fetch. Not a private address, and it must not redirect.'),
|
|
260
|
-
image_base64: z.string().optional()
|
|
261
|
-
.describe('The raw bytes, base64. Only for small images - prefer image_url.'),
|
|
262
|
-
image_alt: z.string().optional().describe('What the photograph shows, for anyone who cannot see it.'),
|
|
263
|
-
},
|
|
264
|
-
async ({ sop_id, step_no, ...body }) =>
|
|
265
|
-
out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/steps/${step_no}/image`, body)),
|
|
266
|
-
);
|
|
267
|
-
|
|
268
|
-
server.tool(
|
|
269
|
-
'km_set_sop_step_video',
|
|
270
|
-
'Put a video clip on a step, or take it off. Clips live on Cloudflare R2 and the bytes never pass through this ' +
|
|
271
|
-
'tool: call it with mime and size to get a presigned upload_url, PUT the file there (any HTTP client, ' +
|
|
272
|
-
'Content-Type = the mime), then call again with the returned path to attach it. Pass remove:true to clear a ' +
|
|
273
|
-
'clip. MP4, MOV or WebM, 500MB max. Write video_alt: it is the caption printed beside the link on the PDF.',
|
|
274
|
-
{
|
|
275
|
-
sop_id: z.string(),
|
|
276
|
-
step_no: z.number().int().positive().describe('Which step, counting from 1.'),
|
|
277
|
-
mime: z.string().optional().describe('video/mp4, video/quicktime or video/webm - to request an upload_url.'),
|
|
278
|
-
size: z.number().int().positive().optional().describe('File size in bytes - with mime.'),
|
|
279
|
-
path: z.string().optional().describe('The path from the first call, once the PUT succeeded - to attach.'),
|
|
280
|
-
video_alt: z.string().optional().describe('What the clip shows.'),
|
|
281
|
-
remove: z.boolean().optional().describe('true to clear the clip from the step.'),
|
|
282
|
-
},
|
|
283
|
-
async ({ sop_id, step_no, ...body }) =>
|
|
284
|
-
out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/steps/${step_no}/video`, body)),
|
|
285
|
-
);
|
|
286
|
-
|
|
287
|
-
// ── the one that does not come back ─────────────────────────────────────
|
|
288
|
-
server.tool(
|
|
289
|
-
'km_delete_sop',
|
|
290
|
-
'Permanently delete an SOP: every step, every photograph, every attachment and its whole revision history. ' +
|
|
291
|
-
'NOTHING HERE COMES BACK, and the revision history is the audit trail of how the procedure changed, so deleting it ' +
|
|
292
|
-
'destroys more than the current version. ' +
|
|
293
|
-
'ARCHIVING IS ALMOST ALWAYS THE RIGHT ANSWER INSTEAD: km_update_sop with status "archived" keeps the SOP readable ' +
|
|
294
|
-
'and stops it being offered as guidance, which is what somebody usually means by "get rid of it". Reach for delete ' +
|
|
295
|
-
'only for something created by mistake, a duplicate, or a test. ' +
|
|
296
|
-
'Confirm with the user first, in their own words, and pass confirm_reference_no to prove you read the right SOP. ' +
|
|
297
|
-
'There are TWO gates and both are deliberate: confirm_reference_no proves you looked at the right SOP, and KM Hub ' +
|
|
298
|
-
'separately answers 409 with a confirmation sentence and a confirm_token. SHOW THAT SENTENCE TO THE USER, wait for ' +
|
|
299
|
-
'an explicit yes, then retry ONCE with the exact token and otherwise identical arguments. The token is bound to ' +
|
|
300
|
-
'those arguments, so changing anything on the retry invalidates it and you start again, which is the point.',
|
|
301
|
-
{
|
|
302
|
-
sop_id: z.string(),
|
|
303
|
-
confirm_reference_no: z.number().int()
|
|
304
|
-
.describe('The reference number of the SOP you intend to destroy, from km_get_sop. It must match, which is the point: it proves you looked at the one you are deleting. Send it on the FIRST call, not only the retry, or the token will be bound to a different set of arguments.'),
|
|
305
|
-
confirm_token: z.string().optional()
|
|
306
|
-
.describe('Use only the exact token KM Hub returned in its 409, and only after the user has explicitly approved the sentence that came with it.'),
|
|
307
|
-
},
|
|
308
|
-
async ({ sop_id, confirm_reference_no, confirm_token }) =>
|
|
309
|
-
out(await call('DELETE', `/sops/${encodeURIComponent(sop_id)}`, {
|
|
310
|
-
confirm_reference_no,
|
|
311
|
-
...(confirm_token ? { confirm_token } : {}),
|
|
312
|
-
})),
|
|
313
|
-
);
|
|
314
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: sops - the written-down version of how this business actually works.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS EXISTS
|
|
5
|
+
* J&M Events paid for GembaDocs and cancelled it two days later. The document
|
|
6
|
+
* half of that tool is easy; what neither GembaDocs nor Scribe can do is know
|
|
7
|
+
* what a job is. A GembaDocs SOP is a page somebody has to remember to go and
|
|
8
|
+
* find. Here, an SOP hangs off the venue, the gear, the event type or the crew
|
|
9
|
+
* role, so `km_sops_for` can answer "what do I need to know before this job"
|
|
10
|
+
* from the job itself.
|
|
11
|
+
*
|
|
12
|
+
* That is why km_sops_for exists and why it is the tool to reach for first.
|
|
13
|
+
*
|
|
14
|
+
* SAFETY. Nothing here sends, charges or moves anything. The one real gate is
|
|
15
|
+
* publishing: a draft is somebody thinking out loud, a published SOP is what a
|
|
16
|
+
* crew member on a loading dock will actually follow, and km_publish_sop is the
|
|
17
|
+
* line between them. That is why publishing refuses an SOP with no steps.
|
|
18
|
+
*
|
|
19
|
+
* The family contract this file follows is documented in ./README.md.
|
|
20
|
+
*/
|
|
21
|
+
import { z } from 'zod';
|
|
22
|
+
|
|
23
|
+
export const FAMILY = 'sops';
|
|
24
|
+
|
|
25
|
+
export const TOOLS = [
|
|
26
|
+
'km_sops_for',
|
|
27
|
+
'km_list_sops',
|
|
28
|
+
'km_get_sop',
|
|
29
|
+
'km_create_sop',
|
|
30
|
+
'km_update_sop',
|
|
31
|
+
'km_set_sop_steps',
|
|
32
|
+
'km_publish_sop',
|
|
33
|
+
'km_attach_sop',
|
|
34
|
+
'km_detach_sop',
|
|
35
|
+
'km_sop_images',
|
|
36
|
+
'km_set_sop_step_image',
|
|
37
|
+
'km_set_sop_step_video',
|
|
38
|
+
'km_delete_sop',
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Lands in `full` only, which is what an EMPTY list means here - not ['*'].
|
|
42
|
+
//
|
|
43
|
+
// tools.mjs is explicit about this and I got it wrong first time: a family the
|
|
44
|
+
// PROFILES map has never PLACED falls through to its own declaration, and ['*']
|
|
45
|
+
// opts into every profile. Measured: all 11 SOP tools landed in `core`, taking
|
|
46
|
+
// it from 45 tools to 45 including a quarter that a caller asking for the
|
|
47
|
+
// minimal set never wanted. The file's own header records six families making
|
|
48
|
+
// exactly this mistake and calls the resulting split theatre.
|
|
49
|
+
//
|
|
50
|
+
// To put SOPs into a narrow profile deliberately, name the family in the
|
|
51
|
+
// PROFILES map in tools.mjs. That is a decision, and it belongs there.
|
|
52
|
+
export const PROFILES = [];
|
|
53
|
+
|
|
54
|
+
// equipment_item is the real gear table; catalog_item is the legacy spelling
|
|
55
|
+
// mig 673 shipped, kept valid so nothing already written breaks.
|
|
56
|
+
const TARGET_TYPES = ['booking', 'gig', 'venue', 'equipment_item', 'catalog_item', 'event_type', 'crew_role', 'client'];
|
|
57
|
+
|
|
58
|
+
const stepShape = z.object({
|
|
59
|
+
body: z.string().describe('What to DO, in one instruction. Keep it under about 120 characters where you can - it prints beside a photo.'),
|
|
60
|
+
title: z.string().optional().describe('Optional short heading for the step.'),
|
|
61
|
+
reasons_why: z.string().optional()
|
|
62
|
+
.describe('WHY the step matters, kept separate from the instruction on purpose. This is where "the warehouse never sees it if it is not written here" goes.'),
|
|
63
|
+
is_critical: z.boolean().optional()
|
|
64
|
+
.describe('True when getting this step wrong costs money, damages gear, or reaches a client. It prints as a warning triangle. Use it sparingly - three flags in a twelve-step SOP still mean something, ten do not.'),
|
|
65
|
+
is_text_only: z.boolean().optional()
|
|
66
|
+
.describe('True when the step deliberately has no picture. Defaults to true automatically when there is no image_path, so the PDF never prints an empty frame.'),
|
|
67
|
+
image_path: z.string().optional().describe('Storage path in the org-sop-images bucket, if this step has a photograph.'),
|
|
68
|
+
video_path: z.string().optional().describe('R2 key (kmsop/<org>/<uuid>.mp4) of the clip on this step, as returned by km_set_sop_step_video. Clips live on Cloudflare R2, not Supabase storage.'),
|
|
69
|
+
video_alt: z.string().optional().describe('What the clip shows. Printed as the caption of the video link on every PDF style.'),
|
|
70
|
+
image_alt: z.string().optional().describe('What the photograph shows, for anyone who cannot see it.'),
|
|
71
|
+
capture_meta: z.record(z.unknown()).optional()
|
|
72
|
+
.describe('Provenance when a browser capture wrote this step: {url, selector, click, viewport, captured_at}. Lets the step be re-captured when the screen it describes moves.'),
|
|
73
|
+
});
|
|
74
|
+
|
|
75
|
+
export function register(server, call, { out }) {
|
|
76
|
+
// ── the one that justifies the whole family ─────────────────────────────
|
|
77
|
+
server.tool(
|
|
78
|
+
'km_sops_for',
|
|
79
|
+
'Every published SOP attached to a specific thing: a booking, a venue, a piece of gear, an event type or a crew role. ' +
|
|
80
|
+
'THIS IS THE TOOL TO REACH FOR FIRST. It answers "what does somebody need to know before doing this job" from the job ' +
|
|
81
|
+
'itself, which is the whole reason SOPs live in KM Hub rather than in a separate documents app nobody opens. ' +
|
|
82
|
+
'Reach for it when a booking is being prepared, when crew are being briefed, when somebody asks how a venue is loaded in, ' +
|
|
83
|
+
'or when a piece of equipment is going out and you want the setup procedure for it. ' +
|
|
84
|
+
'Worth running unprompted before a job: an SOP that exists and is not read is the same as no SOP. ' +
|
|
85
|
+
'Draft SOPs are deliberately excluded - a draft is not guidance yet. Read only.',
|
|
86
|
+
{
|
|
87
|
+
target_type: z.enum(TARGET_TYPES).describe('What kind of thing you are asking about.'),
|
|
88
|
+
target_id: z.string().describe('The id of that booking, venue, gear item, event type or role.'),
|
|
89
|
+
},
|
|
90
|
+
async ({ target_type, target_id }) =>
|
|
91
|
+
out(await call('GET', `/sops/for/${encodeURIComponent(target_type)}/${encodeURIComponent(target_id)}`)),
|
|
92
|
+
);
|
|
93
|
+
|
|
94
|
+
server.tool(
|
|
95
|
+
'km_list_sops',
|
|
96
|
+
'The SOP library for this workspace: reference number, title, folder, status and revision. ' +
|
|
97
|
+
'Reach for it when the user asks what procedures exist, what is documented, or wants to find an SOP by name. ' +
|
|
98
|
+
'An empty library is worth saying out loud - it means every process in the business lives only in somebody\'s head, ' +
|
|
99
|
+
'and the answer to "what happens when that person is away" is nothing. ' +
|
|
100
|
+
'Filter by status to separate what is actually in use from what is still being written. Read only.',
|
|
101
|
+
{
|
|
102
|
+
status: z.enum(['draft', 'published', 'archived']).optional()
|
|
103
|
+
.describe('published = in use. draft = being written and NOT yet guidance. archived = kept for history.'),
|
|
104
|
+
folder: z.string().optional().describe('Only SOPs filed in this folder or any folder nested under it, e.g. "Operations" also returns "Operations/Equipment".'),
|
|
105
|
+
tag: z.string().optional().describe('Only SOPs carrying this tag.'),
|
|
106
|
+
q: z.string().optional().describe('Free text against title and summary.'),
|
|
107
|
+
},
|
|
108
|
+
async (args) => {
|
|
109
|
+
const p = new URLSearchParams();
|
|
110
|
+
for (const [k, v] of Object.entries(args)) if (v) p.set(k, String(v));
|
|
111
|
+
const qs = p.toString();
|
|
112
|
+
return out(await call('GET', `/sops${qs ? `?${qs}` : ''}`));
|
|
113
|
+
},
|
|
114
|
+
);
|
|
115
|
+
|
|
116
|
+
server.tool(
|
|
117
|
+
'km_get_sop',
|
|
118
|
+
'One SOP in full: every step in order with its reasons-why, which steps are flagged critical, and what the SOP is ' +
|
|
119
|
+
'attached to. Reach for it before editing an SOP, when somebody asks how a specific job is done, or when you are about ' +
|
|
120
|
+
'to answer a question that a written procedure already answers better than you can. Read only.',
|
|
121
|
+
{ sop_id: z.string().describe('The SOP id from km_list_sops or km_sops_for.') },
|
|
122
|
+
async ({ sop_id }) => out(await call('GET', `/sops/${encodeURIComponent(sop_id)}`)),
|
|
123
|
+
);
|
|
124
|
+
|
|
125
|
+
// ── writing ─────────────────────────────────────────────────────────────
|
|
126
|
+
server.tool(
|
|
127
|
+
'km_create_sop',
|
|
128
|
+
'Write a new SOP, steps and all. It is created as a DRAFT and is not guidance until km_publish_sop runs, so this is safe ' +
|
|
129
|
+
'to use while still working the wording out. ' +
|
|
130
|
+
'Reach for it when the user describes how something is done and it is worth keeping, when a call transcript contains a ' +
|
|
131
|
+
'procedure, or when a process was just worked out and would otherwise be lost. ' +
|
|
132
|
+
'Write steps the way somebody standing in a warehouse reads them: one action each, present tense, the thing to do first. ' +
|
|
133
|
+
'Put the reason in reasons_why rather than folding it into the instruction - a step that explains itself mid-sentence ' +
|
|
134
|
+
'is a step nobody finishes. Flag a step critical only when getting it wrong costs money or reaches a client.',
|
|
135
|
+
{
|
|
136
|
+
title: z.string().describe('What this procedure is called. It is what people search for, so name it after the job, not the tool.'),
|
|
137
|
+
summary: z.string().optional().describe('One line on when this SOP applies.'),
|
|
138
|
+
folder: z.string().optional().describe('Folder path. Nest with "/" (or ">"), e.g. "Operations/Processes" or "Operations/Equipment".'),
|
|
139
|
+
tags: z.array(z.string()).optional().describe('Free tags, e.g. ["audio", "load-in"]. Stored lower case, de-duplicated, max 20.'),
|
|
140
|
+
is_critical: z.boolean().optional().describe('True when the whole procedure is safety or money critical.'),
|
|
141
|
+
source: z.enum(['manual', 'capture', 'import', 'mobile']).optional()
|
|
142
|
+
.describe('Where the steps came from. "capture" means a browser extension wrote them; "mobile" means somebody photographed real work.'),
|
|
143
|
+
steps: z.array(stepShape).optional().describe('The steps, in order. You can create the SOP empty and add them later.'),
|
|
144
|
+
},
|
|
145
|
+
async (args) => out(await call('POST', '/sops', args)),
|
|
146
|
+
);
|
|
147
|
+
|
|
148
|
+
server.tool(
|
|
149
|
+
'km_update_sop',
|
|
150
|
+
'Change an SOP\'s title, summary, folder, tags, status or whole-SOP critical flag. tags REPLACES the whole tag list: ' +
|
|
151
|
+
'read the current tags with km_get_sop and send them back with your change. Does not touch the steps - use ' +
|
|
152
|
+
'km_set_sop_steps for those. Reach for it to refile, rename, or archive a procedure that is no longer how the work is done.',
|
|
153
|
+
{
|
|
154
|
+
sop_id: z.string(),
|
|
155
|
+
title: z.string().optional(),
|
|
156
|
+
summary: z.string().optional(),
|
|
157
|
+
folder: z.string().optional().describe('Folder path, nested with "/", e.g. "Operations/Equipment". Empty string unfiles it.'),
|
|
158
|
+
tags: z.array(z.string()).optional().describe('The COMPLETE tag list. An empty array removes every tag.'),
|
|
159
|
+
status: z.enum(['draft', 'published', 'archived']).optional(),
|
|
160
|
+
is_critical: z.boolean().optional(),
|
|
161
|
+
},
|
|
162
|
+
async ({ sop_id, ...patch }) => out(await call('PATCH', `/sops/${encodeURIComponent(sop_id)}`, patch)),
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
server.tool(
|
|
166
|
+
'km_set_sop_steps',
|
|
167
|
+
'Replace the entire step list of an SOP with the list you pass. ' +
|
|
168
|
+
'🚨 THIS IS A REPLACE, NOT AN APPEND. Whatever is there now is deleted and what you send becomes the SOP. Read the ' +
|
|
169
|
+
'current steps with km_get_sop first and send them back with your changes included, or you will silently delete work ' +
|
|
170
|
+
'somebody else wrote. Steps are renumbered from 1 in the order you send them.',
|
|
171
|
+
{
|
|
172
|
+
sop_id: z.string(),
|
|
173
|
+
steps: z.array(stepShape).describe('The COMPLETE step list, in order. An empty array deletes every step.'),
|
|
174
|
+
},
|
|
175
|
+
async ({ sop_id, steps }) => out(await call('PUT', `/sops/${encodeURIComponent(sop_id)}/steps`, { steps })),
|
|
176
|
+
);
|
|
177
|
+
|
|
178
|
+
server.tool(
|
|
179
|
+
'km_publish_sop',
|
|
180
|
+
'Publish an SOP and freeze the current version into its revision history. ' +
|
|
181
|
+
'This is the line between a draft somebody is still thinking about and a document a crew member on a loading dock will ' +
|
|
182
|
+
'follow, so treat it as a real decision rather than a save button. Confirm with the user before publishing something ' +
|
|
183
|
+
'you wrote yourself. ' +
|
|
184
|
+
'The frozen snapshot is the audit trail: it can be read back even after the live steps move on, which is what makes the ' +
|
|
185
|
+
'revision number mean anything. Publishing an SOP with no steps is refused. ' +
|
|
186
|
+
'Say what changed in changes_detail - a revision history with no reasons in it is just a list of dates.',
|
|
187
|
+
{
|
|
188
|
+
sop_id: z.string(),
|
|
189
|
+
changes_detail: z.string().optional().describe('What changed and why, for the revision history.'),
|
|
190
|
+
revision_bump: z.enum(['minor', 'major']).optional()
|
|
191
|
+
.describe('minor (default) moves 1.000 to 1.001 - wording, a clearer photo. major moves 1.000 to 2.000 - the procedure itself changed and anyone trained on the old one needs telling.'),
|
|
192
|
+
},
|
|
193
|
+
async ({ sop_id, ...body }) => out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/publish`, body)),
|
|
194
|
+
);
|
|
195
|
+
|
|
196
|
+
// ── the link that makes an SOP findable ─────────────────────────────────
|
|
197
|
+
server.tool(
|
|
198
|
+
'km_attach_sop',
|
|
199
|
+
'Attach an SOP to a booking, venue, piece of gear, event type or crew role, so it surfaces automatically whenever ' +
|
|
200
|
+
'somebody is working on that thing. ' +
|
|
201
|
+
'This is what stops a good procedure from being a document nobody opens. Reach for it every time you write an SOP: an ' +
|
|
202
|
+
'unattached SOP is only findable by someone who already knows it exists. ' +
|
|
203
|
+
'A venue load-in procedure belongs on the venue. A projector setup belongs on the gear item. A prep process belongs on ' +
|
|
204
|
+
'the event type. Attaching the same SOP to the same thing twice is harmless.',
|
|
205
|
+
{
|
|
206
|
+
sop_id: z.string(),
|
|
207
|
+
target_type: z.enum(TARGET_TYPES),
|
|
208
|
+
target_id: z.string().describe('The id of the booking, venue, gear item, event type or role.'),
|
|
209
|
+
note: z.string().optional().describe('Why this SOP is on this thing, e.g. "ballroom only, not the terrace".'),
|
|
210
|
+
},
|
|
211
|
+
async ({ sop_id, ...body }) => out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/links`, body)),
|
|
212
|
+
);
|
|
213
|
+
|
|
214
|
+
server.tool(
|
|
215
|
+
'km_detach_sop',
|
|
216
|
+
'Take an SOP back off a booking, venue, gear item, event type or role. The SOP itself is untouched - this only stops it ' +
|
|
217
|
+
'appearing against that one thing.',
|
|
218
|
+
{
|
|
219
|
+
sop_id: z.string(),
|
|
220
|
+
target_type: z.enum(TARGET_TYPES),
|
|
221
|
+
target_id: z.string(),
|
|
222
|
+
},
|
|
223
|
+
async ({ sop_id, target_type, target_id }) => {
|
|
224
|
+
const p = new URLSearchParams({ target_type, target_id });
|
|
225
|
+
return out(await call('DELETE', `/sops/${encodeURIComponent(sop_id)}/links?${p.toString()}`));
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
// ── the photographs ─────────────────────────────────────────────────────
|
|
229
|
+
server.tool(
|
|
230
|
+
'km_sop_images',
|
|
231
|
+
'Look at the photographs on an SOP. Returns a short-lived link per step, plus the instruction that goes with it. ' +
|
|
232
|
+
'REACH FOR THIS WHENEVER THE PICTURES MATTER, which is most of the time: an SOP is half words and half "the screen ' +
|
|
233
|
+
'looks like THIS", and every other tool here hands back a storage path in a private bucket that cannot be opened. ' +
|
|
234
|
+
'Use it to check a captured SOP actually shows what it claims, to write image_alt text for steps that have none, ' +
|
|
235
|
+
'or to answer a question about what a screen looks like. ' +
|
|
236
|
+
'A step reported as missing_bytes has an image recorded whose file is gone from the bucket, which is worth saying ' +
|
|
237
|
+
'out loud rather than treating as a step with no picture. The links expire, so fetch them when you need them ' +
|
|
238
|
+
'rather than storing them. Read only.',
|
|
239
|
+
{ sop_id: z.string().describe('The SOP id from km_list_sops, km_get_sop or km_sops_for.') },
|
|
240
|
+
async ({ sop_id }) => out(await call('GET', `/sops/${encodeURIComponent(sop_id)}/images`)),
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
server.tool(
|
|
244
|
+
'km_set_sop_step_image',
|
|
245
|
+
'Put a photograph on a step. This is what turns an SOP from a description into something somebody can follow: ' +
|
|
246
|
+
'"the cable goes in THIS port" cannot be written, only shown. ' +
|
|
247
|
+
'Reach for it when a step is about what something looks like, when km_sop_images reports a step with no picture ' +
|
|
248
|
+
'that clearly needs one, or when replacing a photograph of a screen that has since changed. ' +
|
|
249
|
+
'PREFER image_url. A photograph is megabytes; sending it as base64 through this tool means pushing all of it ' +
|
|
250
|
+
'through the conversation, which is slow and often refused outright. Give the server a public https URL and it ' +
|
|
251
|
+
'fetches the picture itself. Use image_base64 only for something genuinely small. ' +
|
|
252
|
+
'Re-sending replaces that step\'s photograph rather than adding a second one, so a retry is safe. ' +
|
|
253
|
+
'Write image_alt every time: it is what somebody who cannot see the picture gets, and it is the only part of a ' +
|
|
254
|
+
'photograph that survives into a text brief.',
|
|
255
|
+
{
|
|
256
|
+
sop_id: z.string(),
|
|
257
|
+
step_no: z.number().int().positive().describe('Which step, counting from 1, as km_get_sop numbers them.'),
|
|
258
|
+
image_url: z.string().optional()
|
|
259
|
+
.describe('Public https URL for the server to fetch. Not a private address, and it must not redirect.'),
|
|
260
|
+
image_base64: z.string().optional()
|
|
261
|
+
.describe('The raw bytes, base64. Only for small images - prefer image_url.'),
|
|
262
|
+
image_alt: z.string().optional().describe('What the photograph shows, for anyone who cannot see it.'),
|
|
263
|
+
},
|
|
264
|
+
async ({ sop_id, step_no, ...body }) =>
|
|
265
|
+
out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/steps/${step_no}/image`, body)),
|
|
266
|
+
);
|
|
267
|
+
|
|
268
|
+
server.tool(
|
|
269
|
+
'km_set_sop_step_video',
|
|
270
|
+
'Put a video clip on a step, or take it off. Clips live on Cloudflare R2 and the bytes never pass through this ' +
|
|
271
|
+
'tool: call it with mime and size to get a presigned upload_url, PUT the file there (any HTTP client, ' +
|
|
272
|
+
'Content-Type = the mime), then call again with the returned path to attach it. Pass remove:true to clear a ' +
|
|
273
|
+
'clip. MP4, MOV or WebM, 500MB max. Write video_alt: it is the caption printed beside the link on the PDF.',
|
|
274
|
+
{
|
|
275
|
+
sop_id: z.string(),
|
|
276
|
+
step_no: z.number().int().positive().describe('Which step, counting from 1.'),
|
|
277
|
+
mime: z.string().optional().describe('video/mp4, video/quicktime or video/webm - to request an upload_url.'),
|
|
278
|
+
size: z.number().int().positive().optional().describe('File size in bytes - with mime.'),
|
|
279
|
+
path: z.string().optional().describe('The path from the first call, once the PUT succeeded - to attach.'),
|
|
280
|
+
video_alt: z.string().optional().describe('What the clip shows.'),
|
|
281
|
+
remove: z.boolean().optional().describe('true to clear the clip from the step.'),
|
|
282
|
+
},
|
|
283
|
+
async ({ sop_id, step_no, ...body }) =>
|
|
284
|
+
out(await call('POST', `/sops/${encodeURIComponent(sop_id)}/steps/${step_no}/video`, body)),
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
// ── the one that does not come back ─────────────────────────────────────
|
|
288
|
+
server.tool(
|
|
289
|
+
'km_delete_sop',
|
|
290
|
+
'Permanently delete an SOP: every step, every photograph, every attachment and its whole revision history. ' +
|
|
291
|
+
'NOTHING HERE COMES BACK, and the revision history is the audit trail of how the procedure changed, so deleting it ' +
|
|
292
|
+
'destroys more than the current version. ' +
|
|
293
|
+
'ARCHIVING IS ALMOST ALWAYS THE RIGHT ANSWER INSTEAD: km_update_sop with status "archived" keeps the SOP readable ' +
|
|
294
|
+
'and stops it being offered as guidance, which is what somebody usually means by "get rid of it". Reach for delete ' +
|
|
295
|
+
'only for something created by mistake, a duplicate, or a test. ' +
|
|
296
|
+
'Confirm with the user first, in their own words, and pass confirm_reference_no to prove you read the right SOP. ' +
|
|
297
|
+
'There are TWO gates and both are deliberate: confirm_reference_no proves you looked at the right SOP, and KM Hub ' +
|
|
298
|
+
'separately answers 409 with a confirmation sentence and a confirm_token. SHOW THAT SENTENCE TO THE USER, wait for ' +
|
|
299
|
+
'an explicit yes, then retry ONCE with the exact token and otherwise identical arguments. The token is bound to ' +
|
|
300
|
+
'those arguments, so changing anything on the retry invalidates it and you start again, which is the point.',
|
|
301
|
+
{
|
|
302
|
+
sop_id: z.string(),
|
|
303
|
+
confirm_reference_no: z.number().int()
|
|
304
|
+
.describe('The reference number of the SOP you intend to destroy, from km_get_sop. It must match, which is the point: it proves you looked at the one you are deleting. Send it on the FIRST call, not only the retry, or the token will be bound to a different set of arguments.'),
|
|
305
|
+
confirm_token: z.string().optional()
|
|
306
|
+
.describe('Use only the exact token KM Hub returned in its 409, and only after the user has explicitly approved the sentence that came with it.'),
|
|
307
|
+
},
|
|
308
|
+
async ({ sop_id, confirm_reference_no, confirm_token }) =>
|
|
309
|
+
out(await call('DELETE', `/sops/${encodeURIComponent(sop_id)}`, {
|
|
310
|
+
confirm_reference_no,
|
|
311
|
+
...(confirm_token ? { confirm_token } : {}),
|
|
312
|
+
})),
|
|
313
|
+
);
|
|
314
|
+
}
|