@kivimedia/kmhub 2.0.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -5
  2. package/bin/kmhub.mjs +20 -7
  3. package/coach-book-output-guard.mjs +760 -0
  4. package/index.mjs +2 -0
  5. package/package.json +8 -3
  6. package/prompts/briefing.md +29 -0
  7. package/prompts/luxury.md +70 -0
  8. package/prompts/play.md +49 -0
  9. package/prompts/run.md +36 -0
  10. package/prompts/setup.md +33 -0
  11. package/prompts/vs-booked.md +46 -0
  12. package/prompts/what-can-you-do.md +40 -0
  13. package/prompts.mjs +110 -0
  14. package/read-only-tools.json +142 -0
  15. package/remote.mjs +815 -99
  16. package/tools/balloon-costing.mjs +80 -0
  17. package/tools/booking-equipment.mjs +110 -0
  18. package/tools/bridges.mjs +54 -0
  19. package/tools/calendar.mjs +9 -0
  20. package/tools/capabilities.mjs +155 -0
  21. package/tools/catalog.mjs +288 -0
  22. package/tools/clubs.mjs +176 -0
  23. package/tools/coach.mjs +771 -0
  24. package/tools/compare.mjs +76 -0
  25. package/tools/core.mjs +21 -0
  26. package/tools/crm.mjs +12 -3
  27. package/tools/dubsado.mjs +137 -0
  28. package/tools/exports.mjs +128 -0
  29. package/tools/fact-review.mjs +125 -0
  30. package/tools/flows.mjs +261 -0
  31. package/tools/forms.mjs +158 -0
  32. package/tools/gols.mjs +134 -0
  33. package/tools/hr.mjs +162 -0
  34. package/tools/knowledge.mjs +4 -3
  35. package/tools/marketing.mjs +396 -0
  36. package/tools/meta.mjs +2 -2
  37. package/tools/military.mjs +244 -0
  38. package/tools/outreach.mjs +27 -4
  39. package/tools/pending.mjs +122 -0
  40. package/tools/photos.mjs +140 -0
  41. package/tools/plays.mjs +1 -1
  42. package/tools/profile.mjs +118 -0
  43. package/tools/radar.mjs +173 -0
  44. package/tools/recurring-invoices.mjs +149 -0
  45. package/tools/reengage.mjs +434 -0
  46. package/tools/schedules.mjs +55 -0
  47. package/tools/setup.mjs +168 -0
  48. package/tools/sops-bridges.mjs +86 -0
  49. package/tools/sops.mjs +314 -0
  50. package/tools/sourcing.mjs +50 -2
  51. package/tools/strategy.mjs +146 -0
  52. package/tools/studio.mjs +132 -0
  53. package/tools/venueradar.mjs +151 -0
  54. package/tools/voice.mjs +134 -0
  55. package/tools.mjs +70 -12
@@ -0,0 +1,168 @@
1
+ /**
2
+ * Tool family: setup - what this workspace is on, connected to, and paying for.
3
+ *
4
+ * Workstream B7. Setup was 3 of 21, the largest remaining gap, and the least
5
+ * glamorous pillar in the product. It is also where a model most often GUESSES:
6
+ * asked "am I paying for this", "is my email set up properly" or "is Stripe
7
+ * connected", a connector with no reach here invents a plausible answer.
8
+ *
9
+ * km_subscription -> GET /setup/subscription
10
+ * km_ai_usage -> GET /setup/usage
11
+ * km_list_integrations-> GET /setup/integrations
12
+ * km_deliverability -> GET /setup/deliverability
13
+ * km_workspace_config -> GET /setup/config
14
+ *
15
+ * 🚨🚨 km_list_integrations returns WHICH providers are connected and NEVER a
16
+ * credential. There is no parameter that changes that and no verbosity that reveals
17
+ * one. Do not ask for a key, do not offer to read one, and do not suggest another
18
+ * route to it. A truncated secret is still a secret.
19
+ *
20
+ * 🚨 READ ONLY. Changing a plan, a spend cap or a connection is money or a door.
21
+ *
22
+ * The family contract this file follows is documented in ./README.md.
23
+ */
24
+ import { z } from 'zod';
25
+
26
+ export const FAMILY = 'setup';
27
+
28
+ export const TOOLS = [
29
+ 'km_subscription',
30
+ 'km_ai_usage',
31
+ 'km_list_integrations',
32
+ 'km_deliverability',
33
+ 'km_workspace_config',
34
+ 'km_calendar_feed',
35
+ 'km_sms_config',
36
+ ];
37
+
38
+ export const PROFILES = ['*'];
39
+
40
+ function routeMissing(r) {
41
+ return r.status === 404 || r.status === 405 || r.status === 501;
42
+ }
43
+
44
+ const NOT_SUPPORTED =
45
+ 'Your KM Hub does not expose the settings surface to the terminal yet. That is not a fault: the workspace is fine ' +
46
+ 'and every other tool works as normal. Billing, connections and deliverability are all in the web app at ' +
47
+ 'https://hub.kivimedia.co, and this connector will read them once your KM Hub is on a build that publishes them.';
48
+
49
+ export function register(server, call, { out, text, qs }) {
50
+ server.tool(
51
+ 'km_subscription',
52
+ 'What this workspace is actually on: the plan, whether it is active, trialing or comped, when it renews, whether it ' +
53
+ 'is set to cancel, and any monthly spend cap. ' +
54
+ 'Call it when the user asks about their plan, their bill, what they are paying for, or whether something is ' +
55
+ 'included. Never answer those from memory or from what a plan name sounds like. ' +
56
+ '🚨 `cancel_at_period_end` true is worth saying out loud unprompted: Terminal Mode itself stops working when the ' +
57
+ 'subscription lapses, so a user planning work past that date should know. `trial_days_left` deserves the same ' +
58
+ 'treatment. Amounts are in cents. Read only: changing a plan happens in KM Hub.',
59
+ {},
60
+ async () => {
61
+ const r = await call('GET', '/setup/subscription');
62
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
63
+ return out(r);
64
+ },
65
+ );
66
+
67
+ server.tool(
68
+ 'km_ai_usage',
69
+ 'What the AI side of the workspace has cost this month, against the spend cap if one is set. These are the same ' +
70
+ 'figures the billing page reads, so the terminal and the web app cannot disagree. ' +
71
+ 'Use it before starting anything expensive, when the user asks what their usage looks like, or if a paid operation ' +
72
+ 'has stopped working for no obvious reason. ' +
73
+ '🚨 If a cap is set and usage is close to it, say so plainly. Work simply stops when a cap is reached, and finding ' +
74
+ 'that out mid-task is far worse than being told in advance. Amounts are in cents. Read only.',
75
+ {},
76
+ async () => {
77
+ const r = await call('GET', '/setup/usage');
78
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
79
+ return out(r);
80
+ },
81
+ );
82
+
83
+ server.tool(
84
+ 'km_list_integrations',
85
+ 'Which third-party providers this workspace has connected: payments, email, calendar, lead sources and so on. ' +
86
+ 'Use it before suggesting anything that depends on an integration, and when the user asks whether something is ' +
87
+ 'hooked up. A feature that needs Stripe is not available on a workspace without Stripe, and finding that out by ' +
88
+ 'trying is a bad experience you can avoid with one call. ' +
89
+ '🚨 This returns the NAMES of connected providers and never a credential value. There is no argument, no verbosity ' +
90
+ 'and no other route that returns one. Do not ask the user to paste a key into the terminal either: connecting and ' +
91
+ 'rotating providers happens in KM Hub at https://hub.kivimedia.co, where the secret stays. Read only.',
92
+ {},
93
+ async () => {
94
+ const r = await call('GET', '/setup/integrations');
95
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
96
+ return out(r);
97
+ },
98
+ );
99
+
100
+ server.tool(
101
+ 'km_deliverability',
102
+ 'Whether the mail this business sends is actually arriving: daily delivery, bounce and complaint figures over a ' +
103
+ 'window. ' +
104
+ 'Call it before recommending any outbound push, and whenever outreach or a newsletter is underperforming. ' +
105
+ '🚨 Deliverability is the quietest way a business loses money, because mail that never arrived looks exactly like ' +
106
+ 'mail nobody answered, and every diagnosis downstream of that mistake is wrong. Rising bounces or complaints matter ' +
107
+ 'more than a falling open rate: they put the sending domain itself at risk, which costs every future send and not ' +
108
+ 'just this one. Raise it unprompted when you see it. ' +
109
+ 'Domain setup and authentication live in KM Hub. Read only.',
110
+ {
111
+ days: z.number().int().min(1).max(90).optional().describe('How many days back. Default 30.'),
112
+ },
113
+ async ({ days }) => {
114
+ const r = await call('GET', `/setup/deliverability${qs({ days })}`);
115
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
116
+ return out(r);
117
+ },
118
+ );
119
+
120
+ server.tool(
121
+ 'km_workspace_config',
122
+ 'How this workspace is shaped: the custom fields it records beyond the standard model, its holidays and whether each ' +
123
+ 'blocks availability, and its email templates with their merge variables. ' +
124
+ '🚨 Read the custom fields before concluding a business does not track something. A workspace that records "room ' +
125
+ 'layout" or "dietary notes" as a custom field is telling you what matters to it, and answering "KM Hub does not ' +
126
+ 'store that" when it does is a bad and avoidable mistake. ' +
127
+ 'A holiday with blocks_availability true is a date that genuinely is not bookable, which matters before offering ' +
128
+ 'anybody a date. Template bodies are not returned: the list is for choosing one, and editing happens in KM Hub. ' +
129
+ 'Read only.',
130
+ {},
131
+ async () => {
132
+ const r = await call('GET', '/setup/config');
133
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
134
+ return out(r);
135
+ },
136
+ );
137
+
138
+ server.tool(
139
+ 'km_calendar_feed',
140
+ 'Whether this workspace publishes its diary as a subscribable calendar feed, and when that was set up. ' +
141
+ 'Use it when the user asks about syncing their calendar elsewhere. ' +
142
+ '🚨 The feed URL contains a private token and is NEVER returned here. Anybody holding it has a permanent ' +
143
+ 'subscription to this diary, and it cannot be rotated without breaking every calendar already subscribed, so it is ' +
144
+ 'not something to put in a terminal transcript. Point them at KM Hub to copy it. Read only.',
145
+ {},
146
+ async () => {
147
+ const r = await call('GET', '/setup/calendar-feed');
148
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
149
+ return out(r);
150
+ },
151
+ );
152
+
153
+ server.tool(
154
+ 'km_sms_config',
155
+ 'Whether SMS is switched on for this workspace, which provider it uses, whether credentials are configured, and the ' +
156
+ 'number clients see. ' +
157
+ 'Call it before suggesting anything that texts a client: a workspace with SMS off cannot send one, and finding that ' +
158
+ 'out by trying wastes the user time. ' +
159
+ '🚨 Credential VALUES are never returned, only whether they are present. The sending number IS returned, ' +
160
+ 'because it is the public identity clients already see on their phones. Read only.',
161
+ {},
162
+ async () => {
163
+ const r = await call('GET', '/setup/sms-config');
164
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
165
+ return out(r);
166
+ },
167
+ );
168
+ }
@@ -0,0 +1,86 @@
1
+ /**
2
+ * Tool family: sops-bridges - the SOP, delivered to the moment of the work.
3
+ *
4
+ * WHY THIS IS A SECOND FAMILY AND NOT MORE TOOLS IN sops.mjs
5
+ * The sops family is about the LIBRARY: writing procedures, publishing them,
6
+ * attaching them to things. These two tools are about a JOB. Somebody is about
7
+ * to load a truck for Saturday, and the only question they have is "what do I
8
+ * need to know before this one". km_sops_for can only answer that thing by
9
+ * thing; km_sop_brief answers it for the whole booking at once - the booking's
10
+ * own procedures plus the venue's, the event type's, every piece of gear's and
11
+ * every crew role's, deduplicated, critical steps first.
12
+ *
13
+ * That is the difference between KM Hub SOPs and the tool J&M cancelled after
14
+ * two days. GembaDocs holds the same 21-step warehouse procedure and cannot say
15
+ * which of its documents matter tonight, because it has never heard of the
16
+ * venue, the gear or the person driving the truck.
17
+ *
18
+ * WHAT IS DELIBERATELY NOT HERE
19
+ * The bridge resolve route (POST sops/bridge/resolve) has no tool. It exists so
20
+ * DJEP, the Zoho bridge and the SMPL bridge can ask by name when they do not
21
+ * know a KM Hub uuid. A model in this conversation always has the booking, so
22
+ * giving it a hint-matching tool would only invite it to guess a venue name
23
+ * when it could have passed an id. Bridges call that route directly.
24
+ *
25
+ * SAFETY. Both tools read. Nothing here sends, charges or changes anything.
26
+ *
27
+ * The family contract this file follows is documented in ./README.md.
28
+ */
29
+ import { z } from 'zod';
30
+
31
+ export const FAMILY = 'sops-bridges';
32
+
33
+ export const TOOLS = ['km_sop_brief', 'km_sop_pullsheet'];
34
+
35
+ // Empty, not ['*'] - see the note in sops.mjs. ['*'] pins a family into every
36
+ // profile, which is the opposite of what this comment used to claim.
37
+ export const PROFILES = [];
38
+
39
+ export function register(server, call, { out, text }) {
40
+ server.tool(
41
+ 'km_sop_brief',
42
+ 'Everything somebody should read before working a specific booking: the SOPs attached to the booking itself, plus the ' +
43
+ 'ones attached to its venue, its event type, every piece of gear on it, every crew role staffed on it, and the client. ' +
44
+ 'Deduplicated, ordered so the critical procedures come first, with a flat list of every critical step across all of them. ' +
45
+ 'REACH FOR THIS WHENEVER A JOB IS BEING PREPARED, BRIEFED OR TALKED THROUGH, and reach for it before you answer from ' +
46
+ 'memory: a written procedure beats a good guess, and this is the only tool that finds every procedure that touches one ' +
47
+ 'job in a single call. Worth running unprompted the day before an event - an SOP that exists and is not read is the same ' +
48
+ 'as no SOP at all. ' +
49
+ 'It also answers usefully when the answer is nothing: it names the venue, the gear and the roles that have no procedure ' +
50
+ 'written for them, which is the list of what only lives in somebody\'s head. ' +
51
+ 'Only PUBLISHED SOPs appear. A draft is somebody still thinking and an archived one is no longer how the work is done, ' +
52
+ 'and putting either in front of a crew member carries the authority of a document it has not earned. Read only.',
53
+ {
54
+ booking_id: z.string()
55
+ .describe('The booking id, or the booking number people say out loud (for example JM-1042). Either works.'),
56
+ },
57
+ async ({ booking_id }) => out(await call('GET', `/sops/brief/${encodeURIComponent(booking_id)}`)),
58
+ );
59
+
60
+ server.tool(
61
+ 'km_sop_pullsheet',
62
+ 'The same job brief as km_sop_brief, rendered as a plain-text block to paste into a pull sheet, a production system or a ' +
63
+ 'printed sheet taped to a road case. It carries the SOP reference numbers, titles, revisions, why each one applies, and ' +
64
+ 'the CRITICAL STEPS ONLY - not the full procedure. ' +
65
+ 'Reach for it when the user asks for something to paste, print, send to the crew, or add to a pull sheet or a DJEP ' +
66
+ 'export, and when they want the short version rather than every step. ' +
67
+ 'It is plain text on purpose: it lands in systems with no HTML, so hand it over exactly as it comes back rather than ' +
68
+ 'reformatting it into a table. Use km_sop_brief instead when somebody needs to actually follow a procedure, because the ' +
69
+ 'non-critical steps are the ones missing here. Read only.',
70
+ {
71
+ booking_id: z.string()
72
+ .describe('The booking id, or the booking number people say out loud. Either works.'),
73
+ },
74
+ async ({ booking_id }) => {
75
+ const r = await call('GET', `/sops/pullsheet/${encodeURIComponent(booking_id)}`);
76
+ // The block is the product, so it goes back as prose. Wrapped in JSON its
77
+ // line breaks arrive as the two characters backslash-n, and the thing a
78
+ // person is meant to paste stops being pasteable.
79
+ const block = r && r.ok && r.data && typeof r.data.text === 'string' ? r.data.text : '';
80
+ if (!block) return out(r);
81
+
82
+ const warnings = Array.isArray(r.data.warnings) ? r.data.warnings.filter(Boolean) : [];
83
+ return text(warnings.length ? `${block}\n\n${warnings.map((w) => `note: ${w}`).join('\n')}` : block);
84
+ },
85
+ );
86
+ }
package/tools/sops.mjs ADDED
@@ -0,0 +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
+ }