@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.
- package/README.md +6 -5
- package/bin/kmhub.mjs +20 -7
- package/coach-book-output-guard.mjs +760 -0
- package/index.mjs +2 -0
- package/package.json +8 -3
- package/prompts/briefing.md +29 -0
- package/prompts/luxury.md +70 -0
- package/prompts/play.md +49 -0
- package/prompts/run.md +36 -0
- package/prompts/setup.md +33 -0
- package/prompts/vs-booked.md +46 -0
- package/prompts/what-can-you-do.md +40 -0
- package/prompts.mjs +110 -0
- package/read-only-tools.json +142 -0
- package/remote.mjs +815 -99
- package/tools/balloon-costing.mjs +80 -0
- package/tools/booking-equipment.mjs +110 -0
- package/tools/bridges.mjs +54 -0
- package/tools/calendar.mjs +9 -0
- package/tools/capabilities.mjs +155 -0
- package/tools/catalog.mjs +288 -0
- package/tools/clubs.mjs +176 -0
- package/tools/coach.mjs +771 -0
- package/tools/compare.mjs +76 -0
- package/tools/core.mjs +21 -0
- package/tools/crm.mjs +12 -3
- package/tools/dubsado.mjs +137 -0
- package/tools/exports.mjs +128 -0
- package/tools/fact-review.mjs +125 -0
- package/tools/flows.mjs +261 -0
- package/tools/forms.mjs +158 -0
- package/tools/gols.mjs +134 -0
- package/tools/hr.mjs +162 -0
- package/tools/knowledge.mjs +4 -3
- package/tools/marketing.mjs +396 -0
- package/tools/meta.mjs +2 -2
- package/tools/military.mjs +244 -0
- package/tools/outreach.mjs +27 -4
- package/tools/pending.mjs +122 -0
- package/tools/photos.mjs +140 -0
- package/tools/plays.mjs +1 -1
- package/tools/profile.mjs +118 -0
- package/tools/radar.mjs +173 -0
- package/tools/recurring-invoices.mjs +149 -0
- package/tools/reengage.mjs +434 -0
- package/tools/schedules.mjs +55 -0
- package/tools/setup.mjs +168 -0
- package/tools/sops-bridges.mjs +86 -0
- package/tools/sops.mjs +314 -0
- package/tools/sourcing.mjs +50 -2
- package/tools/strategy.mjs +146 -0
- package/tools/studio.mjs +132 -0
- package/tools/venueradar.mjs +151 -0
- package/tools/voice.mjs +134 -0
- package/tools.mjs +70 -12
|
@@ -0,0 +1,288 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: catalog - the public booking catalog tree, readable and editable.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FAMILY EXISTS. On 01-Sep-2026 Mark Fuller asked his terminal to make a
|
|
5
|
+
* package appear under a second booking pathway and rename it there. The terminal
|
|
6
|
+
* read his whole catalog correctly and then had to hand him back to the mouse:
|
|
7
|
+
* km_get_offers_and_pricing returned a flat list with nothing saying which pathway
|
|
8
|
+
* each package sits under, and no tool could write a package at all. His written
|
|
9
|
+
* gap report asked for the mapping read, a package edit, a way to place a package
|
|
10
|
+
* on a pathway, the shared-or-copied design answer, and for every write to sit
|
|
11
|
+
* behind the confirm gate. This family is that list, in that order.
|
|
12
|
+
*
|
|
13
|
+
* THE DESIGN ANSWER his report demanded: a package lives under exactly ONE
|
|
14
|
+
* pathway (service_packages.pathway_id is a single reference, no join table).
|
|
15
|
+
* "Appear under a second pathway" is a COPY; renaming a copy never touches the
|
|
16
|
+
* original. The tools are named copy and move, not "attach", so the name cannot
|
|
17
|
+
* imply a data model the schema does not have.
|
|
18
|
+
*
|
|
19
|
+
* 🚨 THE BOOKING PAGE IS THE SHOPFRONT, a page on the open internet. Every write
|
|
20
|
+
* here is a confirmed write, same as form fields: KM Hub answers 409 with a plain
|
|
21
|
+
* sentence naming the package and pathway, and nothing happens until the person
|
|
22
|
+
* you are working with has said yes to that sentence.
|
|
23
|
+
*
|
|
24
|
+
* The family contract this file follows is documented in ./README.md.
|
|
25
|
+
*/
|
|
26
|
+
import { z } from 'zod';
|
|
27
|
+
|
|
28
|
+
export const FAMILY = 'catalog';
|
|
29
|
+
|
|
30
|
+
export const TOOLS = [
|
|
31
|
+
'km_get_catalog', 'km_update_package', 'km_copy_package_to_pathway', 'km_move_package_to_pathway',
|
|
32
|
+
'km_create_package', 'km_delete_package', 'km_get_package_composition', 'km_set_package_composition',
|
|
33
|
+
'km_create_pathway', 'km_update_pathway',
|
|
34
|
+
];
|
|
35
|
+
|
|
36
|
+
// Lands in `full` only. An EMPTY list means exactly that - ['*'] would opt into
|
|
37
|
+
// every profile, which is the mistake tools.mjs documents six families making.
|
|
38
|
+
export const PROFILES = [];
|
|
39
|
+
|
|
40
|
+
/** Route-level 404 (carries a routes list) vs a handler's not_found. See forms.mjs. */
|
|
41
|
+
function routeMissing(r) {
|
|
42
|
+
if (r.status === 405 || r.status === 501) return true;
|
|
43
|
+
return r.status === 404 && Array.isArray(r.data?.routes);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const NOT_SUPPORTED =
|
|
47
|
+
'This KM Hub is running a version that cannot read or edit the booking catalog tree from the terminal yet. The ' +
|
|
48
|
+
'workspace is fine and every other tool works as normal. The catalog is editable in the web app at ' +
|
|
49
|
+
'https://hub.kivimedia.co under Operations then Booking Catalogs.';
|
|
50
|
+
|
|
51
|
+
const CONFIRM_DESC =
|
|
52
|
+
'Leave this out on the first call. This action changes the public booking page, so it needs an explicit yes from '
|
|
53
|
+
+ 'the person you are working with: KM Hub answers 409 with a plain description of exactly what would change, plus '
|
|
54
|
+
+ 'a token. Show them that description in those words, wait for a real answer, and only then call again with the '
|
|
55
|
+
+ 'token and the SAME arguments. A yes for one change never authorises a different one.';
|
|
56
|
+
|
|
57
|
+
export function register(server, call, { out, text }) {
|
|
58
|
+
server.tool(
|
|
59
|
+
'km_get_catalog',
|
|
60
|
+
'The booking catalog the way a buyer sees it: pathways in display order, and which packages sit under each, ' +
|
|
61
|
+
'with price, duration, order and published state. This is the ONLY tool that shows the pathway-to-package ' +
|
|
62
|
+
'mapping, so reach for it before describing, editing, copying or moving anything on the public booking page; ' +
|
|
63
|
+
'the ids every write tool here needs come from it. ' +
|
|
64
|
+
'THE MODEL, so you never have to guess: a package lives under exactly ONE pathway. To show one under a ' +
|
|
65
|
+
'second pathway you COPY it there (km_copy_package_to_pathway); renaming the copy never touches the ' +
|
|
66
|
+
'original. A buyer sees a package only when both it and its pathway are published. Read only.',
|
|
67
|
+
{
|
|
68
|
+
catalog_id: z.string().optional().describe('From km_list_catalogs. Leave it out for the default catalog, which is almost always the one you want.'),
|
|
69
|
+
},
|
|
70
|
+
async ({ catalog_id }) => {
|
|
71
|
+
const q = catalog_id ? `?catalog_id=${encodeURIComponent(String(catalog_id).trim())}` : '';
|
|
72
|
+
const r = await call('GET', `/catalogs/tree${q}`);
|
|
73
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
74
|
+
return out(r);
|
|
75
|
+
},
|
|
76
|
+
);
|
|
77
|
+
|
|
78
|
+
server.tool(
|
|
79
|
+
'km_update_package',
|
|
80
|
+
'Edit one package on the public booking catalog: rename it, change its price or duration, edit its ' +
|
|
81
|
+
'description or badge, publish or unpublish it, or change its position in the pathway ' +
|
|
82
|
+
'(booking_display_seq, lowest first, which is also how you reorder). ' +
|
|
83
|
+
'Run km_get_catalog first and work from real ids. Send only the fields you are changing. ' +
|
|
84
|
+
'Prices are CENTS: base_price_cents 25000 is $250. The legacy proposal price is kept in step ' +
|
|
85
|
+
'automatically, so never try to set starting_price yourself. ' +
|
|
86
|
+
'🚨 booking_status is the PUBLIC switch: published means buyers see it on the booking page the moment you ' +
|
|
87
|
+
'save. This is a confirmed write; expect a 409 naming the package first.',
|
|
88
|
+
{
|
|
89
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
90
|
+
name: z.string().min(1).max(200).optional().describe('The name buyers read.'),
|
|
91
|
+
description: z.string().max(2000).nullable().optional().describe('Send null to clear it.'),
|
|
92
|
+
base_price_cents: z.number().int().min(0).nullable().optional().describe('The price the booking form charges, in cents. Null clears it.'),
|
|
93
|
+
duration_mins: z.number().int().min(0).nullable().optional(),
|
|
94
|
+
badge_text: z.string().max(200).nullable().optional().describe('The small badge on the package card, e.g. "Most popular". Null clears it.'),
|
|
95
|
+
per_child_pricing: z.boolean().optional().describe('True makes base_price_cents cover children_included children, with extras charged per child.'),
|
|
96
|
+
children_included: z.number().int().min(0).nullable().optional(),
|
|
97
|
+
price_per_extra_child_cents: z.number().int().min(0).nullable().optional(),
|
|
98
|
+
max_children: z.number().int().min(0).nullable().optional(),
|
|
99
|
+
booking_display_seq: z.number().int().min(0).optional().describe('Position inside its pathway, lowest first. Set these across siblings to reorder.'),
|
|
100
|
+
booking_status: z.enum(['draft', 'published']).optional().describe('published puts it on the public page. draft takes it off.'),
|
|
101
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
102
|
+
},
|
|
103
|
+
async ({ package_id, ...changes }) => {
|
|
104
|
+
const r = await call('PATCH', `/packages/${encodeURIComponent(String(package_id || '').trim())}`, changes);
|
|
105
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
106
|
+
return out(r);
|
|
107
|
+
},
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
server.tool(
|
|
111
|
+
'km_copy_package_to_pathway',
|
|
112
|
+
'Clone a package into another pathway, landing at the bottom of that pathway, with its add-on options and ' +
|
|
113
|
+
'choices copied along. This is HOW a package appears under a second pathway: a package lives under exactly ' +
|
|
114
|
+
'one, so a second appearance is a copy, and renaming the copy (new_name) never touches the original. ' +
|
|
115
|
+
'The copy keeps the original\'s booking_status, so copying a published package into a published pathway is ' +
|
|
116
|
+
'live immediately - the confirmation sentence says which you are getting. ' +
|
|
117
|
+
'After copying, edits to either one are separate: a price change on the original does NOT follow to the ' +
|
|
118
|
+
'copy, which is worth saying to the owner out loud. This is a confirmed write; expect a 409 first.',
|
|
119
|
+
{
|
|
120
|
+
package_id: z.string().describe('UUID of the package to copy, from km_get_catalog.'),
|
|
121
|
+
pathway_id: z.string().describe('UUID of the pathway to copy it into, from km_get_catalog.'),
|
|
122
|
+
new_name: z.string().min(1).max(200).optional().describe('What the copy is called. Leave it out to keep the same name.'),
|
|
123
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
124
|
+
},
|
|
125
|
+
async ({ package_id, ...body }) => {
|
|
126
|
+
const r = await call('POST', `/packages/${encodeURIComponent(String(package_id || '').trim())}/copy`, body);
|
|
127
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
128
|
+
return out(r);
|
|
129
|
+
},
|
|
130
|
+
);
|
|
131
|
+
|
|
132
|
+
server.tool(
|
|
133
|
+
'km_move_package_to_pathway',
|
|
134
|
+
'Move a package to a different pathway, landing at the bottom, optionally renaming it on the way. ' +
|
|
135
|
+
'It LEAVES its current pathway: a package lives under exactly one, so a move is a removal there and an ' +
|
|
136
|
+
'arrival here, in one step. When the owner wants it in BOTH places, use km_copy_package_to_pathway ' +
|
|
137
|
+
'instead. This is a confirmed write; expect a 409 naming both pathways first.',
|
|
138
|
+
{
|
|
139
|
+
package_id: z.string().describe('UUID of the package to move, from km_get_catalog.'),
|
|
140
|
+
pathway_id: z.string().describe('UUID of the destination pathway, from km_get_catalog.'),
|
|
141
|
+
new_name: z.string().min(1).max(200).optional().describe('Rename it as part of the move. Leave it out to keep the name.'),
|
|
142
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
143
|
+
},
|
|
144
|
+
async ({ package_id, ...body }) => {
|
|
145
|
+
const r = await call('POST', `/packages/${encodeURIComponent(String(package_id || '').trim())}/move`, body);
|
|
146
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
147
|
+
return out(r);
|
|
148
|
+
},
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
server.tool(
|
|
152
|
+
'km_create_pathway',
|
|
153
|
+
'Create a booking pathway: the CATEGORY that packages live under on the public booking page, like ' +
|
|
154
|
+
'"Birthday Parties" or "Monthly Subscriptions". Reach for this FIRST when building a booking page from ' +
|
|
155
|
+
'nothing, because km_create_package requires a pathway_id and a workspace that has never opened the ' +
|
|
156
|
+
'Catalog Builder has no pathway to give it - that is exactly the dead end this tool removes. ' +
|
|
157
|
+
'It is a DRAFT unless you say otherwise, so nothing reaches buyers by surprise. ' +
|
|
158
|
+
'catalog_id is optional: left out, it uses the workspace default catalog, and if the workspace has NO ' +
|
|
159
|
+
'catalog at all it creates one (Main Catalog) on the way, so this works on a completely empty workspace. ' +
|
|
160
|
+
'Run km_get_catalog afterwards to see the new pathway with its id, then create packages under it. ' +
|
|
161
|
+
'This is a confirmed write; expect a 409 naming the pathway and catalog first.',
|
|
162
|
+
{
|
|
163
|
+
name: z.string().min(1).max(200).describe('The category name buyers read, e.g. "Dazzling Decor Club".'),
|
|
164
|
+
catalog_id: z.string().optional().describe('From km_list_catalogs. Leave it out for the default catalog, which is almost always right - and which will be created if the workspace has none.'),
|
|
165
|
+
subtitle: z.string().max(300).nullable().optional().describe('The small line under the name on the booking page, e.g. "Billed monthly, 3-month minimum".'),
|
|
166
|
+
description: z.string().max(2000).nullable().optional(),
|
|
167
|
+
status: z.enum(['draft', 'published']).optional().describe('Leave it out for draft, the safe default. published puts the category on the public page - but buyers still see nothing under it until its packages are published too.'),
|
|
168
|
+
image_url: z.string().max(1000).nullable().optional().describe('An image for the category card. A public URL; this tool does not upload anything.'),
|
|
169
|
+
display_seq: z.number().int().min(0).optional().describe('Position among the pathways, lowest first. Leave it out to land at the bottom.'),
|
|
170
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
171
|
+
},
|
|
172
|
+
async (body) => {
|
|
173
|
+
const r = await call('POST', '/pathways', body);
|
|
174
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
175
|
+
return out(r);
|
|
176
|
+
},
|
|
177
|
+
);
|
|
178
|
+
|
|
179
|
+
server.tool(
|
|
180
|
+
'km_update_pathway',
|
|
181
|
+
'Edit one booking pathway (the category packages sit under): rename it, change its subtitle or description, ' +
|
|
182
|
+
'publish or unpublish it, or move its position among the pathways (display_seq, lowest first). ' +
|
|
183
|
+
'Run km_get_catalog first and work from real ids. Send only the fields you are changing. ' +
|
|
184
|
+
'🚨 status is the PUBLIC switch, and it is only HALF the switch: a buyer sees a package only when the ' +
|
|
185
|
+
'PACKAGE is published AND its pathway is published. Publishing a pathway whose packages are all drafts ' +
|
|
186
|
+
'shows buyers an empty category; unpublishing a pathway hides every package under it at once, however ' +
|
|
187
|
+
'published those packages are. The response says which of those you just did. ' +
|
|
188
|
+
'This is a confirmed write; expect a 409 naming the pathway first.',
|
|
189
|
+
{
|
|
190
|
+
pathway_id: z.string().describe('UUID of the pathway, from km_get_catalog.'),
|
|
191
|
+
name: z.string().min(1).max(200).optional().describe('The category name buyers read.'),
|
|
192
|
+
subtitle: z.string().max(300).nullable().optional().describe('The small line under the name. Send null to clear it.'),
|
|
193
|
+
description: z.string().max(2000).nullable().optional().describe('Send null to clear it.'),
|
|
194
|
+
status: z.enum(['draft', 'published']).optional().describe('published puts the category on the public page. draft takes it, and everything under it, off.'),
|
|
195
|
+
display_seq: z.number().int().min(0).optional().describe('Position among the pathways, lowest first. Set these across siblings to reorder.'),
|
|
196
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
197
|
+
},
|
|
198
|
+
async ({ pathway_id, ...changes }) => {
|
|
199
|
+
const r = await call('PATCH', `/pathways/${encodeURIComponent(String(pathway_id || '').trim())}`, changes);
|
|
200
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
201
|
+
return out(r);
|
|
202
|
+
},
|
|
203
|
+
);
|
|
204
|
+
|
|
205
|
+
server.tool(
|
|
206
|
+
'km_create_package',
|
|
207
|
+
'Create a new package on the public booking catalog, at the bottom of a pathway. It is a DRAFT unless you ' +
|
|
208
|
+
'say otherwise, so nothing reaches buyers by surprise. Run km_get_catalog first for the pathway id; if the ' +
|
|
209
|
+
'catalog is empty and there is no pathway to put this under, make one with km_create_pathway. ' +
|
|
210
|
+
'Price is CENTS: 36500 is $365; the legacy proposal price is kept in step automatically. ' +
|
|
211
|
+
'After creating, say what the package is MADE OF with km_set_package_composition - that is what lets ' +
|
|
212
|
+
'quotes offer it as editable service lines and shows the owner the discount they are giving. ' +
|
|
213
|
+
'This is a confirmed write; expect a 409 naming the package and pathway first.',
|
|
214
|
+
{
|
|
215
|
+
pathway_id: z.string().describe('UUID of the pathway it lives under, from km_get_catalog.'),
|
|
216
|
+
name: z.string().min(1).max(200).describe('The name buyers read.'),
|
|
217
|
+
base_price_cents: z.number().int().min(0).nullable().optional().describe('The price the booking form charges, in cents. Leave it out for no price yet.'),
|
|
218
|
+
description: z.string().max(2000).nullable().optional(),
|
|
219
|
+
booking_status: z.enum(['draft', 'published']).optional().describe('Leave it out for draft, the safe default. published shows it to buyers the moment the pathway is also published.'),
|
|
220
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
221
|
+
},
|
|
222
|
+
async (body) => {
|
|
223
|
+
const r = await call('POST', '/packages', body);
|
|
224
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
225
|
+
return out(r);
|
|
226
|
+
},
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
server.tool(
|
|
230
|
+
'km_delete_package',
|
|
231
|
+
'Delete a package from the booking catalog, permanently. Its composition links and add-on options are ' +
|
|
232
|
+
'deleted with it, and there is no undo - which is why unpublishing (km_update_package with ' +
|
|
233
|
+
"booking_status 'draft') is usually the better first move, and this tool is for packages that are truly " +
|
|
234
|
+
'wrong, duplicates, or test rows. If bookings or quotes reference the package the delete is refused, and ' +
|
|
235
|
+
'unpublishing is the answer. This is a confirmed write; expect a 409 naming the package first.',
|
|
236
|
+
{
|
|
237
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
238
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
239
|
+
},
|
|
240
|
+
async ({ package_id, ...body }) => {
|
|
241
|
+
const r = await call('DELETE', `/packages/${encodeURIComponent(String(package_id || '').trim())}`, body);
|
|
242
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
243
|
+
return out(r);
|
|
244
|
+
},
|
|
245
|
+
);
|
|
246
|
+
|
|
247
|
+
server.tool(
|
|
248
|
+
'km_get_package_composition',
|
|
249
|
+
'What a package is MADE OF: the services inside it with quantities, each priced LIVE off the rate card, ' +
|
|
250
|
+
'plus the parts total and the discount against the package price. A package is an assortment of services ' +
|
|
251
|
+
'the OWNER selected - "3 hours of Balloon Twisting + 1 x 30-minute Magic Show" - never everything on the ' +
|
|
252
|
+
'rate card. The response also carries available_services (the whole rate card with ids), so this one call ' +
|
|
253
|
+
'gives you everything km_set_package_composition needs. An empty composition is a fine state: the package ' +
|
|
254
|
+
'sells on its price alone. Read only.',
|
|
255
|
+
{
|
|
256
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
257
|
+
},
|
|
258
|
+
async ({ package_id }) => {
|
|
259
|
+
const r = await call('GET', `/packages/${encodeURIComponent(String(package_id || '').trim())}/composition`);
|
|
260
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
261
|
+
return out(r);
|
|
262
|
+
},
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
server.tool(
|
|
266
|
+
'km_set_package_composition',
|
|
267
|
+
'Set what a package is made of, in ONE call: the full list of services and how much of each. Whatever you ' +
|
|
268
|
+
'send REPLACES the current composition, so start from km_get_package_composition and send the complete ' +
|
|
269
|
+
'list, not just the change. quantity is in the service\'s own unit: a per-hour service with quantity 3 ' +
|
|
270
|
+
'means 3 hours; decimals like 1.5 are fine. An empty list clears the composition (the package keeps its ' +
|
|
271
|
+
'price and stays bookable). Composition never changes what a buyer pays - the package price stands; it is ' +
|
|
272
|
+
'what lets quotes explode a package into editable service lines and shows the owner the discount they are ' +
|
|
273
|
+
'giving off their own rates. This is a confirmed write; expect a 409 spelling the composition out first.',
|
|
274
|
+
{
|
|
275
|
+
package_id: z.string().describe('UUID of the package, from km_get_catalog.'),
|
|
276
|
+
services: z.array(z.object({
|
|
277
|
+
service_id: z.string().describe('UUID of a rate-card service, from available_services on km_get_package_composition.'),
|
|
278
|
+
quantity: z.number().gt(0).max(999).describe("How much of it, in the service's own unit. 3 on a per-hour service = 3 hours."),
|
|
279
|
+
})).max(50).describe('The COMPLETE composition. It replaces what is there. Empty array clears it.'),
|
|
280
|
+
confirm_token: z.string().optional().describe(CONFIRM_DESC),
|
|
281
|
+
},
|
|
282
|
+
async ({ package_id, ...body }) => {
|
|
283
|
+
const r = await call('PUT', `/packages/${encodeURIComponent(String(package_id || '').trim())}/composition`, body);
|
|
284
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
285
|
+
return out(r);
|
|
286
|
+
},
|
|
287
|
+
);
|
|
288
|
+
}
|
package/tools/clubs.mjs
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: clubs - the Clubs Radar directory, read from the terminal.
|
|
3
|
+
*
|
|
4
|
+
* Like the military family (and unlike every scan-shaped radar), the clubs market is ENUMERABLE
|
|
5
|
+
* rather than searchable: every chartered American Legion post, VFW post, Elks lodge and Moose
|
|
6
|
+
* lodge is a published row somewhere, and the engine has already pulled them in. So the thing a
|
|
7
|
+
* person wants from the terminal is not "what did the last scan find", it is "show me what is
|
|
8
|
+
* already in there", and that needs its own family rather than another entry in a mission list.
|
|
9
|
+
*
|
|
10
|
+
* km_clubs_directory -> GET /clubs/directory
|
|
11
|
+
* km_clubs_posts -> GET /clubs/posts
|
|
12
|
+
* km_clubs_post -> GET /clubs/posts/:ref
|
|
13
|
+
*
|
|
14
|
+
* 🚨 READ ONLY, and there are three separate reasons, all of which the descriptions say out loud:
|
|
15
|
+
*
|
|
16
|
+
* 1. NOTHING HERE SUBMITS, APPROVES OR SENDS. Writing to a post's mailbox or dialing its phone
|
|
17
|
+
* is a human act in KM Hub, behind the Approval Queue - club_radar drafts are a never-auto
|
|
18
|
+
* source, because a cold first impression to a veterans hall is exactly the message a human
|
|
19
|
+
* must read first.
|
|
20
|
+
* 2. NOTHING HERE STARTS A SWEEP. Queueing one is admin-only under RLS in the web app, and the
|
|
21
|
+
* API runs on the service role, which bypasses RLS: a start tool would quietly hand any
|
|
22
|
+
* read/write key a power the product refuses to a non-admin member. Whether a sweep is
|
|
23
|
+
* running is readable through km_list_radar_scans with kind 'club_radar_ingest'.
|
|
24
|
+
* 3. Reading costs NOTHING and sends NOTHING. The directory is shared reference data with
|
|
25
|
+
* global read, already populated before anybody runs anything, so there is no first click
|
|
26
|
+
* to make and no bill for looking.
|
|
27
|
+
*
|
|
28
|
+
* The family contract this file follows is documented in ./README.md.
|
|
29
|
+
*/
|
|
30
|
+
import { z } from 'zod';
|
|
31
|
+
|
|
32
|
+
export const FAMILY = 'clubs';
|
|
33
|
+
|
|
34
|
+
export const TOOLS = [
|
|
35
|
+
'km_clubs_directory',
|
|
36
|
+
'km_clubs_posts',
|
|
37
|
+
'km_clubs_post',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// Finding and reaching buyers is outreach work, so this family joins that profile. It is not in
|
|
41
|
+
// `core`: a session that only wants orientation should not carry three more schemas.
|
|
42
|
+
export const PROFILES = ['outreach'];
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Has this KM Hub simply never heard of the route, or did the route run and find nothing?
|
|
46
|
+
*
|
|
47
|
+
* 🚨 Same discrimination as the military family, for the same reason: asking for a post that is
|
|
48
|
+
* not in the directory is a real, ordinary 404. Collapsing the two would answer a plain typo with
|
|
49
|
+
* "your KM Hub does not have the clubs directory", which is a false statement about the product
|
|
50
|
+
* rather than a wrong answer about a post. So only the router's own self-describing 404, which
|
|
51
|
+
* carries the whole route list, counts as a missing route.
|
|
52
|
+
*/
|
|
53
|
+
function routeMissing(r) {
|
|
54
|
+
if (r.status === 405 || r.status === 501) return true;
|
|
55
|
+
if (r.status !== 404) return false;
|
|
56
|
+
const body = r && r.data && typeof r.data === 'object' ? r.data : null;
|
|
57
|
+
if (!body) return true;
|
|
58
|
+
if (Array.isArray(body.routes)) return true;
|
|
59
|
+
return typeof body.message === 'string' && body.message.startsWith('Unknown route');
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const NOT_SUPPORTED =
|
|
63
|
+
'This KM Hub does not serve the clubs directory to the terminal yet. Nothing is broken: the workspace is fine ' +
|
|
64
|
+
'and every other tool works as normal. The directory is there in the web app at https://hub.kivimedia.co under ' +
|
|
65
|
+
'Outbound, named Clubs Radar.';
|
|
66
|
+
|
|
67
|
+
const NO_SEND =
|
|
68
|
+
'What it will NOT do: it cannot write to a post, dial a phone, mark an engagement or send anything, and no ' +
|
|
69
|
+
'other tool here can either. Working a post - the ladder from first contact to a monthly residency - happens in ' +
|
|
70
|
+
'KM Hub at https://hub.kivimedia.co, on purpose. Never tell somebody a message went out.';
|
|
71
|
+
|
|
72
|
+
const NO_INVENTION =
|
|
73
|
+
'Never construct an address. A post that publishes no email keeps no email, and a VFW row with a phone and no ' +
|
|
74
|
+
'mailbox is the NORMAL published state for that order, not missing data. A row whose email_confidence says ' +
|
|
75
|
+
'"pattern" is a constructed guess from the order\'s scheme (lodge<NUM>@mooseunits.org) that must verify before ' +
|
|
76
|
+
'it may reach a draft - say so whenever one is shown.';
|
|
77
|
+
|
|
78
|
+
export function register(server, call, { out, text, qs }) {
|
|
79
|
+
server.tool(
|
|
80
|
+
'km_clubs_directory',
|
|
81
|
+
'The fraternal and veterans halls KM Hub has already enumerated: every chartered American Legion post, VFW ' +
|
|
82
|
+
'post, Elks lodge and Moose lodge in the directory, counted by order and by state, with how many rent their ' +
|
|
83
|
+
'hall, run a club room, and publish a mailbox. ' +
|
|
84
|
+
'Reach for it whenever somebody asks about clubs, lodges, posts, veterans halls, the Legion, the VFW, the ' +
|
|
85
|
+
'Elks or the Moose, and reach for it FIRST rather than describing the feature: this directory is shared ' +
|
|
86
|
+
'reference data that is already full before anybody runs anything, so there is nothing to start and nothing ' +
|
|
87
|
+
'to wait for. Reading it costs nothing, spends nothing and sends nothing. ' +
|
|
88
|
+
'🚨 Lead with the `directory` block, which is counted from the rows and not asserted. Say those numbers ' +
|
|
89
|
+
'before any individual row, because the single thing people get wrong about this surface is assuming it is ' +
|
|
90
|
+
'empty. ' +
|
|
91
|
+
'Why these buyers matter: posts run weekly member nights on a PRINTED schedule and most own a rentable hall ' +
|
|
92
|
+
'with an entertainment budget - one signed residency is a recurring monthly show. ' +
|
|
93
|
+
NO_INVENTION +
|
|
94
|
+
' ' +
|
|
95
|
+
'Whether a sweep is running is a different question, answered by km_list_radar_scans with kind ' +
|
|
96
|
+
"'club_radar_ingest'. Starting one is done in KM Hub, never from here. " +
|
|
97
|
+
NO_SEND,
|
|
98
|
+
{},
|
|
99
|
+
async () => {
|
|
100
|
+
const r = await call('GET', '/clubs/directory');
|
|
101
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
102
|
+
return out(r);
|
|
103
|
+
},
|
|
104
|
+
);
|
|
105
|
+
|
|
106
|
+
server.tool(
|
|
107
|
+
'km_clubs_posts',
|
|
108
|
+
'The posts themselves: one row per hall, with its printed meeting schedule (the free-demo-night slot IS that ' +
|
|
109
|
+
'line), the hall-rental and club-room flags, the route counts, the best mailbox where one is published, and ' +
|
|
110
|
+
'this workspace\'s own engagement status per post. ' +
|
|
111
|
+
'Use it when somebody wants the posts near them, the halls that rent, or a call list. Elks rows are the ' +
|
|
112
|
+
'warmest written route (published lodge emails); VFW rows are phone-first because that order publishes no ' +
|
|
113
|
+
'email - a phone-only row there is the office working normally. ' +
|
|
114
|
+
'Every filter is optional and `filters_applied` says back which ones are doing the narrowing, so an empty ' +
|
|
115
|
+
'result is explained rather than shrugged at. The radius filter needs lat, lng and radius_miles together, ' +
|
|
116
|
+
'and `skipped_no_coordinates` counts the rows it could not judge. ' +
|
|
117
|
+
NO_INVENTION +
|
|
118
|
+
' ' +
|
|
119
|
+
'Read only. Costs nothing, spends nothing. ' +
|
|
120
|
+
NO_SEND,
|
|
121
|
+
{
|
|
122
|
+
org: z
|
|
123
|
+
.enum(['legion', 'vfw', 'elks', 'moose'])
|
|
124
|
+
.optional()
|
|
125
|
+
.describe('One fraternal order. Leave it out to see all four.'),
|
|
126
|
+
state: z.string().optional().describe('Two-letter US state, for example "NJ".'),
|
|
127
|
+
city: z.string().optional().describe('City name, matched loosely.'),
|
|
128
|
+
hall_rental: z.boolean().optional().describe('True for posts whose hall is rentable - the venue-partnership list.'),
|
|
129
|
+
club_room: z.boolean().optional().describe('True for posts with a bar, canteen or club room on premises.'),
|
|
130
|
+
q: z.string().optional().describe('Free text against name, place, order and meeting schedule, in any order.'),
|
|
131
|
+
lat: z.number().optional().describe('Radius center latitude. Use with lng and radius_miles.'),
|
|
132
|
+
lng: z.number().optional().describe('Radius center longitude. Use with lat and radius_miles.'),
|
|
133
|
+
radius_miles: z.number().optional().describe('Radius in miles around lat/lng. Nearest posts come back first.'),
|
|
134
|
+
limit: z.number().int().min(1).max(200).optional().describe('How many posts to return. Default 40. Check `truncated`.'),
|
|
135
|
+
},
|
|
136
|
+
async ({ org, state, city, hall_rental, club_room, q, lat, lng, radius_miles, limit }) => {
|
|
137
|
+
const r = await call('GET', `/clubs/posts${qs({ org, state, city, hall_rental, club_room, q, lat, lng, radius_miles, limit })}`);
|
|
138
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
139
|
+
return out(r);
|
|
140
|
+
},
|
|
141
|
+
);
|
|
142
|
+
|
|
143
|
+
server.tool(
|
|
144
|
+
'km_clubs_post',
|
|
145
|
+
'One post in full: every published contact with the page it was read off and the verbatim fragment it came ' +
|
|
146
|
+
'from, the meeting schedule, the hall flags, and whatever engagement this workspace has already recorded. ' +
|
|
147
|
+
'Use it after km_clubs_posts when somebody picks a post, or whenever they name one directly. It takes the ' +
|
|
148
|
+
'club_key from a previous answer (for example "elks:nj:21") or a 9-digit EIN. ' +
|
|
149
|
+
'🚨 `email_confidence` decides whether an address may be written to as it stands: "published" was read off ' +
|
|
150
|
+
'the page named in source_url; "pattern" is a constructed guess that must verify first, and any draft ' +
|
|
151
|
+
'against it is refused until it does. `serves_posts` above 1 means one mailbox answers for several posts - ' +
|
|
152
|
+
'send it once, never once per hall. ' +
|
|
153
|
+
'`your_engagement` is this workspace\'s own ladder (researching, contacted, replied, demo booked, monthly ' +
|
|
154
|
+
'resident) and its sent marks - the record that keeps the same mailbox from being contacted twice. ' +
|
|
155
|
+
NO_INVENTION +
|
|
156
|
+
' ' +
|
|
157
|
+
'Read only. Costs nothing, spends nothing. ' +
|
|
158
|
+
NO_SEND,
|
|
159
|
+
{
|
|
160
|
+
post: z
|
|
161
|
+
.string()
|
|
162
|
+
.describe('The club_key from a previous answer, for example "elks:nj:21", or the post\'s 9-digit EIN.'),
|
|
163
|
+
},
|
|
164
|
+
async ({ post }) => {
|
|
165
|
+
const ref = String(post || '').trim();
|
|
166
|
+
if (!ref) {
|
|
167
|
+
return text(
|
|
168
|
+
'I need to know which post. km_clubs_posts lists them, and each row carries the club_key this tool takes.',
|
|
169
|
+
);
|
|
170
|
+
}
|
|
171
|
+
const r = await call('GET', `/clubs/posts/${encodeURIComponent(ref)}`);
|
|
172
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
173
|
+
return out(r);
|
|
174
|
+
},
|
|
175
|
+
);
|
|
176
|
+
}
|