@kivimedia/kmhub 2.9.0 → 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.
Files changed (57) hide show
  1. package/README.md +170 -170
  2. package/bin/kmhub.mjs +896 -896
  3. package/coach-book-output-guard.mjs +760 -760
  4. package/index.mjs +57 -57
  5. package/package.json +56 -56
  6. package/prompts/briefing.md +29 -29
  7. package/prompts/luxury.md +70 -70
  8. package/prompts/play.md +49 -49
  9. package/prompts/run.md +36 -36
  10. package/prompts/setup.md +33 -33
  11. package/prompts/vs-booked.md +46 -46
  12. package/prompts/what-can-you-do.md +40 -40
  13. package/prompts.mjs +110 -109
  14. package/read-only-tools.json +143 -142
  15. package/remote.mjs +929 -929
  16. package/tools/balloon-costing.mjs +80 -80
  17. package/tools/booking-equipment.mjs +110 -110
  18. package/tools/bridges.mjs +54 -54
  19. package/tools/briefing.mjs +91 -91
  20. package/tools/calendar.mjs +170 -170
  21. package/tools/capabilities.mjs +155 -155
  22. package/tools/catalog.mjs +288 -288
  23. package/tools/clubs.mjs +176 -176
  24. package/tools/coach.mjs +771 -771
  25. package/tools/compare.mjs +76 -76
  26. package/tools/core.mjs +244 -244
  27. package/tools/crm.mjs +209 -209
  28. package/tools/dubsado.mjs +137 -137
  29. package/tools/exports.mjs +128 -128
  30. package/tools/fact-review.mjs +125 -125
  31. package/tools/flows.mjs +261 -261
  32. package/tools/forms.mjs +158 -158
  33. package/tools/gols.mjs +134 -134
  34. package/tools/hr.mjs +162 -162
  35. package/tools/knowledge.mjs +125 -125
  36. package/tools/marketing.mjs +396 -396
  37. package/tools/meta.mjs +245 -245
  38. package/tools/military.mjs +244 -244
  39. package/tools/money.mjs +235 -197
  40. package/tools/outreach.mjs +238 -238
  41. package/tools/pending.mjs +122 -122
  42. package/tools/photos.mjs +140 -140
  43. package/tools/plays.mjs +244 -244
  44. package/tools/profile.mjs +118 -118
  45. package/tools/radar.mjs +173 -173
  46. package/tools/recurring-invoices.mjs +149 -149
  47. package/tools/reengage.mjs +434 -434
  48. package/tools/schedules.mjs +55 -55
  49. package/tools/setup.mjs +168 -168
  50. package/tools/sops-bridges.mjs +86 -86
  51. package/tools/sops.mjs +314 -314
  52. package/tools/sourcing.mjs +268 -268
  53. package/tools/strategy.mjs +146 -146
  54. package/tools/studio.mjs +132 -132
  55. package/tools/venueradar.mjs +151 -151
  56. package/tools/voice.mjs +134 -134
  57. package/tools.mjs +407 -407
@@ -1,151 +1,151 @@
1
- /**
2
- * Tool family: venueradar - the Venue Radar directory, read from the terminal.
3
- *
4
- * ONE directory for the entertainment-buying venue market, in lanes: bars and restaurants,
5
- * taprooms and tasting rooms (breweries/wineries), and banquet halls / event venues. The engine
6
- * builds it from Google Places county tiles, Open Brewery DB and the TTB winery CSV, then crawls
7
- * each venue's OWN pages for two evidence lanes: proof it already books recurring entertainment
8
- * (trivia, live music, game night - each signal quoted), and a published preferred-vendor list
9
- * (the categories it names are stored; a category ABSENT from the venue's own list is the
10
- * placement pitch). So the thing a person wants from the terminal is "show me what is already in
11
- * there", filtered by lane, and that needs its own family rather than another mission list entry.
12
- *
13
- * km_venue_radar_venues -> GET /venue-radar/venues
14
- * km_venue_radar_venue -> GET /venue-radar/venues/:ref
15
- *
16
- * 🚨 READ ONLY, and there are three separate reasons, all of which the descriptions say out loud:
17
- *
18
- * 1. NOTHING HERE SUBMITS, APPROVES OR SENDS. Writing to a venue or dialing it is a human act
19
- * in KM Hub, behind the Approval Queue - venue_radar drafts are a never-auto source, because
20
- * a cold first impression to a venue is exactly the message a human must read first.
21
- * 2. NOTHING HERE STARTS A SWEEP. Queueing one is admin-only under RLS in the web app, and the
22
- * API runs on the service role, which bypasses RLS: a start tool would quietly hand any
23
- * read/write key a power the product refuses to a non-admin member. Whether a sweep is
24
- * running is readable through km_list_radar_scans with kind 'venue_radar_ingest'.
25
- * 3. Reading costs NOTHING and sends NOTHING. The directory is shared reference data with
26
- * global read, so there is no first click 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 = 'venueradar';
33
-
34
- export const TOOLS = [
35
- 'km_venue_radar_venues',
36
- 'km_venue_radar_venue',
37
- ];
38
-
39
- // Finding and reaching buyers is outreach work, so this family joins that profile. It is not in
40
- // `core`: a session that only wants orientation should not carry two more schemas.
41
- export const PROFILES = ['outreach'];
42
-
43
- /**
44
- * Has this KM Hub simply never heard of the route, or did the route run and find nothing?
45
- *
46
- * 🚨 Same discrimination as the military and clubs families, for the same reason: asking for a
47
- * venue that is not in the directory is a real, ordinary 404. Collapsing the two would answer a
48
- * plain typo with "your KM Hub does not have the venue directory", which is a false statement
49
- * about the product rather than a wrong answer about a venue. So only the router's own
50
- * self-describing 404, which carries the whole route list, counts as a missing route.
51
- */
52
- function routeMissing(r) {
53
- if (r.status === 405 || r.status === 501) return true;
54
- if (r.status !== 404) return false;
55
- const body = r && r.data && typeof r.data === 'object' ? r.data : null;
56
- if (!body) return true;
57
- if (Array.isArray(body.routes)) return true;
58
- return typeof body.message === 'string' && body.message.startsWith('Unknown route');
59
- }
60
-
61
- const NOT_SUPPORTED =
62
- 'This KM Hub does not serve the venue directory to the terminal yet. Nothing is broken: the workspace is fine ' +
63
- 'and every other tool works as normal. The directory is there in the web app at https://hub.kivimedia.co under ' +
64
- 'Outbound, named Venue Radar.';
65
-
66
- const NO_SEND =
67
- 'What it will NOT do: it cannot write to a venue, dial a phone, mark a lane status or send anything, and no ' +
68
- 'other tool here can either. Working a venue - the ladder to a weekly residency, or the funnel onto its vendor ' +
69
- 'list - happens in KM Hub at https://hub.kivimedia.co, on purpose. Never tell somebody a message went out.';
70
-
71
- const NO_INVENTION =
72
- 'Never construct an address or a claim. books_entertainment is counted from signals quoted off the venue\'s own ' +
73
- 'pages, a venue that publishes no buyer keeps no buyer, and an email marked guess must verify before it may ' +
74
- 'reach a draft - say so whenever one is shown.';
75
-
76
- export function register(server, call, { out, text, qs }) {
77
- server.tool(
78
- 'km_venue_radar_venues',
79
- 'The venues KM Hub has already gathered and evidenced: bars, restaurants, breweries/taprooms, wineries, ' +
80
- 'banquet halls and event venues, each with its two evidence lanes - proof it books recurring entertainment ' +
81
- '(trivia, live music, game night, each signal quoted from its own pages) and whether it publishes a ' +
82
- 'preferred-vendor list, with the categories that list names. ' +
83
- 'Reach for it whenever somebody asks about bars, restaurants, taprooms, breweries, wineries, banquet halls ' +
84
- 'or event venues as BUYERS, and reach for it FIRST rather than describing the feature: reading it costs ' +
85
- 'nothing, spends nothing and sends nothing. ' +
86
- '🚨 The lane filter is the point. lane "entertainment" answers "who already pays for a weekly night I could ' +
87
- 'fill"; lane "vendor_list" answers "which halls keep a vendor list my category is missing from" - the ' +
88
- 'categories their own list names come back per venue, so the absent-category pitch is readable straight off ' +
89
- 'the row. Per venue you also get this workspace\'s own working state, the fit score and the already_client ' +
90
- 'flag (computed against this workspace\'s own client list - the "has not booked us yet" filter). ' +
91
- 'A venue with no lane data yet is NORMAL, not missing: the enrich sweep fills evidence venue by venue. ' +
92
- NO_INVENTION +
93
- ' ' +
94
- 'Whether a sweep is running is a different question, answered by km_list_radar_scans with kind ' +
95
- "'venue_radar_ingest'. Starting one is done in KM Hub, never from here. " +
96
- NO_SEND,
97
- {
98
- kind: z
99
- .enum(['bar', 'restaurant', 'brewery', 'winery', 'banquet_hall', 'event_venue'])
100
- .optional()
101
- .describe('One venue kind. Leave it out to see all six.'),
102
- lane: z
103
- .enum(['entertainment', 'vendor_list'])
104
- .optional()
105
- .describe('Evidence lane: "entertainment" = proven recurring entertainment buyer; "vendor_list" = publishes a preferred-vendor list.'),
106
- county: z.string().optional().describe('County name, matched loosely, for example "Monmouth".'),
107
- state: z.string().optional().describe('Two-letter US state, for example "NJ".'),
108
- city: z.string().optional().describe('City name, matched loosely.'),
109
- q: z.string().optional().describe('Free text against name, place, kind and subtype, in any order.'),
110
- limit: z.number().int().min(1).max(200).optional().describe('How many venues to return. Default 40. Check `truncated`.'),
111
- },
112
- async ({ kind, lane, county, state, city, q, limit }) => {
113
- const r = await call('GET', `/venue-radar/venues${qs({ kind, lane, county, state, city, q, limit })}`);
114
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
115
- return out(r);
116
- },
117
- );
118
-
119
- server.tool(
120
- 'km_venue_radar_venue',
121
- 'One venue in full: every entertainment signal with the page it was quoted from, the vendor list with the ' +
122
- 'categories it names, the buyer (name, title, role, published email or labelled guess, with its evidence), ' +
123
- 'and whatever working state this workspace has already recorded on both lanes. ' +
124
- 'Use it after km_venue_radar_venues when somebody picks a venue, or whenever they name one directly. It ' +
125
- 'takes the venue_key from a previous answer (for example "obdb:some-brewery-id") or a Google place id. ' +
126
- '🚨 An evidence entry marked stale means the quarterly recheck could no longer find that signal on its page ' +
127
- '- the trail is kept, never deleted, and a stale trivia night is not a live pitch. buyer.email was read ' +
128
- 'verbatim off the page in its source_url; buyer.email_guess must verify before any draft, and any draft ' +
129
- 'against it is refused until it does. ' +
130
- NO_INVENTION +
131
- ' ' +
132
- 'Read only. Costs nothing, spends nothing. ' +
133
- NO_SEND,
134
- {
135
- venue: z
136
- .string()
137
- .describe('The venue_key from a previous answer, for example "obdb:some-brewery-id", or the venue\'s Google place id.'),
138
- },
139
- async ({ venue }) => {
140
- const ref = String(venue || '').trim();
141
- if (!ref) {
142
- return text(
143
- 'I need to know which venue. km_venue_radar_venues lists them, and each row carries the venue_key this tool takes.',
144
- );
145
- }
146
- const r = await call('GET', `/venue-radar/venues/${encodeURIComponent(ref)}`);
147
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
148
- return out(r);
149
- },
150
- );
151
- }
1
+ /**
2
+ * Tool family: venueradar - the Venue Radar directory, read from the terminal.
3
+ *
4
+ * ONE directory for the entertainment-buying venue market, in lanes: bars and restaurants,
5
+ * taprooms and tasting rooms (breweries/wineries), and banquet halls / event venues. The engine
6
+ * builds it from Google Places county tiles, Open Brewery DB and the TTB winery CSV, then crawls
7
+ * each venue's OWN pages for two evidence lanes: proof it already books recurring entertainment
8
+ * (trivia, live music, game night - each signal quoted), and a published preferred-vendor list
9
+ * (the categories it names are stored; a category ABSENT from the venue's own list is the
10
+ * placement pitch). So the thing a person wants from the terminal is "show me what is already in
11
+ * there", filtered by lane, and that needs its own family rather than another mission list entry.
12
+ *
13
+ * km_venue_radar_venues -> GET /venue-radar/venues
14
+ * km_venue_radar_venue -> GET /venue-radar/venues/:ref
15
+ *
16
+ * 🚨 READ ONLY, and there are three separate reasons, all of which the descriptions say out loud:
17
+ *
18
+ * 1. NOTHING HERE SUBMITS, APPROVES OR SENDS. Writing to a venue or dialing it is a human act
19
+ * in KM Hub, behind the Approval Queue - venue_radar drafts are a never-auto source, because
20
+ * a cold first impression to a venue is exactly the message a human must read first.
21
+ * 2. NOTHING HERE STARTS A SWEEP. Queueing one is admin-only under RLS in the web app, and the
22
+ * API runs on the service role, which bypasses RLS: a start tool would quietly hand any
23
+ * read/write key a power the product refuses to a non-admin member. Whether a sweep is
24
+ * running is readable through km_list_radar_scans with kind 'venue_radar_ingest'.
25
+ * 3. Reading costs NOTHING and sends NOTHING. The directory is shared reference data with
26
+ * global read, so there is no first click 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 = 'venueradar';
33
+
34
+ export const TOOLS = [
35
+ 'km_venue_radar_venues',
36
+ 'km_venue_radar_venue',
37
+ ];
38
+
39
+ // Finding and reaching buyers is outreach work, so this family joins that profile. It is not in
40
+ // `core`: a session that only wants orientation should not carry two more schemas.
41
+ export const PROFILES = ['outreach'];
42
+
43
+ /**
44
+ * Has this KM Hub simply never heard of the route, or did the route run and find nothing?
45
+ *
46
+ * 🚨 Same discrimination as the military and clubs families, for the same reason: asking for a
47
+ * venue that is not in the directory is a real, ordinary 404. Collapsing the two would answer a
48
+ * plain typo with "your KM Hub does not have the venue directory", which is a false statement
49
+ * about the product rather than a wrong answer about a venue. So only the router's own
50
+ * self-describing 404, which carries the whole route list, counts as a missing route.
51
+ */
52
+ function routeMissing(r) {
53
+ if (r.status === 405 || r.status === 501) return true;
54
+ if (r.status !== 404) return false;
55
+ const body = r && r.data && typeof r.data === 'object' ? r.data : null;
56
+ if (!body) return true;
57
+ if (Array.isArray(body.routes)) return true;
58
+ return typeof body.message === 'string' && body.message.startsWith('Unknown route');
59
+ }
60
+
61
+ const NOT_SUPPORTED =
62
+ 'This KM Hub does not serve the venue directory to the terminal yet. Nothing is broken: the workspace is fine ' +
63
+ 'and every other tool works as normal. The directory is there in the web app at https://hub.kivimedia.co under ' +
64
+ 'Outbound, named Venue Radar.';
65
+
66
+ const NO_SEND =
67
+ 'What it will NOT do: it cannot write to a venue, dial a phone, mark a lane status or send anything, and no ' +
68
+ 'other tool here can either. Working a venue - the ladder to a weekly residency, or the funnel onto its vendor ' +
69
+ 'list - happens in KM Hub at https://hub.kivimedia.co, on purpose. Never tell somebody a message went out.';
70
+
71
+ const NO_INVENTION =
72
+ 'Never construct an address or a claim. books_entertainment is counted from signals quoted off the venue\'s own ' +
73
+ 'pages, a venue that publishes no buyer keeps no buyer, and an email marked guess must verify before it may ' +
74
+ 'reach a draft - say so whenever one is shown.';
75
+
76
+ export function register(server, call, { out, text, qs }) {
77
+ server.tool(
78
+ 'km_venue_radar_venues',
79
+ 'The venues KM Hub has already gathered and evidenced: bars, restaurants, breweries/taprooms, wineries, ' +
80
+ 'banquet halls and event venues, each with its two evidence lanes - proof it books recurring entertainment ' +
81
+ '(trivia, live music, game night, each signal quoted from its own pages) and whether it publishes a ' +
82
+ 'preferred-vendor list, with the categories that list names. ' +
83
+ 'Reach for it whenever somebody asks about bars, restaurants, taprooms, breweries, wineries, banquet halls ' +
84
+ 'or event venues as BUYERS, and reach for it FIRST rather than describing the feature: reading it costs ' +
85
+ 'nothing, spends nothing and sends nothing. ' +
86
+ '🚨 The lane filter is the point. lane "entertainment" answers "who already pays for a weekly night I could ' +
87
+ 'fill"; lane "vendor_list" answers "which halls keep a vendor list my category is missing from" - the ' +
88
+ 'categories their own list names come back per venue, so the absent-category pitch is readable straight off ' +
89
+ 'the row. Per venue you also get this workspace\'s own working state, the fit score and the already_client ' +
90
+ 'flag (computed against this workspace\'s own client list - the "has not booked us yet" filter). ' +
91
+ 'A venue with no lane data yet is NORMAL, not missing: the enrich sweep fills evidence venue by venue. ' +
92
+ NO_INVENTION +
93
+ ' ' +
94
+ 'Whether a sweep is running is a different question, answered by km_list_radar_scans with kind ' +
95
+ "'venue_radar_ingest'. Starting one is done in KM Hub, never from here. " +
96
+ NO_SEND,
97
+ {
98
+ kind: z
99
+ .enum(['bar', 'restaurant', 'brewery', 'winery', 'banquet_hall', 'event_venue'])
100
+ .optional()
101
+ .describe('One venue kind. Leave it out to see all six.'),
102
+ lane: z
103
+ .enum(['entertainment', 'vendor_list'])
104
+ .optional()
105
+ .describe('Evidence lane: "entertainment" = proven recurring entertainment buyer; "vendor_list" = publishes a preferred-vendor list.'),
106
+ county: z.string().optional().describe('County name, matched loosely, for example "Monmouth".'),
107
+ state: z.string().optional().describe('Two-letter US state, for example "NJ".'),
108
+ city: z.string().optional().describe('City name, matched loosely.'),
109
+ q: z.string().optional().describe('Free text against name, place, kind and subtype, in any order.'),
110
+ limit: z.number().int().min(1).max(200).optional().describe('How many venues to return. Default 40. Check `truncated`.'),
111
+ },
112
+ async ({ kind, lane, county, state, city, q, limit }) => {
113
+ const r = await call('GET', `/venue-radar/venues${qs({ kind, lane, county, state, city, q, limit })}`);
114
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
115
+ return out(r);
116
+ },
117
+ );
118
+
119
+ server.tool(
120
+ 'km_venue_radar_venue',
121
+ 'One venue in full: every entertainment signal with the page it was quoted from, the vendor list with the ' +
122
+ 'categories it names, the buyer (name, title, role, published email or labelled guess, with its evidence), ' +
123
+ 'and whatever working state this workspace has already recorded on both lanes. ' +
124
+ 'Use it after km_venue_radar_venues when somebody picks a venue, or whenever they name one directly. It ' +
125
+ 'takes the venue_key from a previous answer (for example "obdb:some-brewery-id") or a Google place id. ' +
126
+ '🚨 An evidence entry marked stale means the quarterly recheck could no longer find that signal on its page ' +
127
+ '- the trail is kept, never deleted, and a stale trivia night is not a live pitch. buyer.email was read ' +
128
+ 'verbatim off the page in its source_url; buyer.email_guess must verify before any draft, and any draft ' +
129
+ 'against it is refused until it does. ' +
130
+ NO_INVENTION +
131
+ ' ' +
132
+ 'Read only. Costs nothing, spends nothing. ' +
133
+ NO_SEND,
134
+ {
135
+ venue: z
136
+ .string()
137
+ .describe('The venue_key from a previous answer, for example "obdb:some-brewery-id", or the venue\'s Google place id.'),
138
+ },
139
+ async ({ venue }) => {
140
+ const ref = String(venue || '').trim();
141
+ if (!ref) {
142
+ return text(
143
+ 'I need to know which venue. km_venue_radar_venues lists them, and each row carries the venue_key this tool takes.',
144
+ );
145
+ }
146
+ const r = await call('GET', `/venue-radar/venues/${encodeURIComponent(ref)}`);
147
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
148
+ return out(r);
149
+ },
150
+ );
151
+ }
package/tools/voice.mjs CHANGED
@@ -1,134 +1,134 @@
1
- /**
2
- * Tool family: voice - telling KM Hub how to write, from the terminal.
3
- *
4
- * km_update_brand_voice -> PATCH /knowledge/voice (brand_values, sign_emails_as)
5
- * km_set_writing_rules -> PUT /knowledge/writing-rules (the house rules)
6
- * km_set_service_rule -> PUT /knowledge/service-rules (one package's writing rule)
7
- *
8
- * WHY THIS EXISTS. km_get_brand_voice and km_get_offers_and_pricing could READ how
9
- * the business sounds and what it sells, and the owner had no way to change either
10
- * from a terminal conversation. Ziv, 22-Sep-26: "allow Mark to chat through terminal
11
- * mode with rules about the services, the voice, anything you write". These are the
12
- * write halves, in their own family so the read family (knowledge.mjs) is untouched.
13
- *
14
- * 🚨 Every tool here is a confirmed write. KM Hub answers 409 with a server-written
15
- * sentence naming the before and after. Put that sentence to the person, wait for a
16
- * real yes, then call again with the confirm_token and the SAME arguments.
17
- *
18
- * The family contract this file follows is documented in ./README.md.
19
- */
20
- import { z } from 'zod';
21
-
22
- export const FAMILY = 'voice';
23
-
24
- export const TOOLS = ['km_update_brand_voice', 'km_set_writing_rules', 'km_set_service_rule'];
25
-
26
- // Same profiles as the knowledge family that reads these values: anything that
27
- // writes words for a real person should be able to correct how it writes them.
28
- export const PROFILES = ['outreach', 'content', 'money'];
29
-
30
- function routeMissing(r) {
31
- return r.status === 404 || r.status === 405 || r.status === 501;
32
- }
33
-
34
- const NOT_SUPPORTED =
35
- 'Your KM Hub does not accept voice and writing-rule changes from the terminal yet. Nothing is wrong with the ' +
36
- 'workspace. The brand voice box is in the web app at https://hub.kivimedia.co under Settings, General, and this ' +
37
- 'connector will edit it once your KM Hub is on a build that publishes these routes.';
38
-
39
- const CONFIRM_TOKEN = z
40
- .string()
41
- .optional()
42
- .describe(
43
- 'Leave this out on the first call. KM Hub answers 409 with the sentence to put to the person and a token. Only '
44
- + 'after they actually say yes, call again with the token and the SAME arguments. A yes for one change never '
45
- + 'authorises a different one.',
46
- );
47
-
48
- /** Forward only what the caller set: undefined means "leave alone", null means "clear". */
49
- function defined(args) {
50
- const body = {};
51
- for (const [k, v] of Object.entries(args)) if (v !== undefined) body[k] = v;
52
- return body;
53
- }
54
-
55
- export function register(server, call, { out, text }) {
56
- server.tool(
57
- 'km_update_brand_voice',
58
- 'Change how this business sounds in everything the AI writes: the brand voice box (the owner\'s own words about ' +
59
- 'their values, tone and style) and the name every email is signed with. It is the same box the owner sees in ' +
60
- 'KM Hub under Settings, General, and in the Sales Playbook Values tab, and every AI writer reads it: first ' +
61
- 'replies, rewrites, review replies, broadcasts, templates, follow-ups. ' +
62
- 'Call km_get_brand_voice first so you change from what is actually there. When the person asks to ADD to their ' +
63
- 'voice, send the existing text with the addition, never just the new sentence, or the rest is lost. Write ' +
64
- 'what they said in their words; do not polish it into marketing copy. ' +
65
- '🚨 A confirmed write. KM Hub answers 409 with the exact before and after; say that to the person, wait for a ' +
66
- 'real yes in this turn, then call again with the confirm_token and the same arguments.',
67
- {
68
- brand_values: z
69
- .string()
70
- .nullable()
71
- .optional()
72
- .describe('The full brand voice text, up to 4000 characters. It REPLACES what is there. Send null to clear it.'),
73
- sign_emails_as: z
74
- .string()
75
- .nullable()
76
- .optional()
77
- .describe('The name every email is signed with, for example "Mark" or "Mark and the Twisty Art team". Send null to clear it.'),
78
- confirm_token: CONFIRM_TOKEN,
79
- },
80
- async (args) => {
81
- const r = await call('PATCH', '/knowledge/voice', defined(args));
82
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
83
- return out(r);
84
- },
85
- );
86
-
87
- server.tool(
88
- 'km_set_writing_rules',
89
- 'Set the house writing rules: the standing instructions every message the AI writes for this business must obey, ' +
90
- 'for example "never quote a price in the first email", "always offer a phone call", "never call our performers ' +
91
- 'clowns". They are mandatory, not style, and they apply to every AI writer including automatic ones. ' +
92
- 'Use append to add one rule to the end (the usual case, and safe); use rules only when the person wants the whole ' +
93
- 'list rewritten, and then send every rule they want kept, because rules replaces the list. km_get_brand_voice ' +
94
- 'shows the current list under voice.house_rules. ' +
95
- '🚨 A confirmed write: KM Hub answers 409 with the before and after, the person says yes, you call again with ' +
96
- 'the confirm_token and the same arguments.',
97
- {
98
- append: z.string().optional().describe('One or more new rules to add to the end of the list.'),
99
- rules: z
100
- .string()
101
- .nullable()
102
- .optional()
103
- .describe('The complete list of rules, one per line, up to 3000 characters. Replaces the list. Send null to clear every rule.'),
104
- confirm_token: CONFIRM_TOKEN,
105
- },
106
- async (args) => {
107
- const r = await call('PUT', '/knowledge/writing-rules', defined(args));
108
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
109
- return out(r);
110
- },
111
- );
112
-
113
- server.tool(
114
- 'km_set_service_rule',
115
- 'Give one service its own writing rule: an instruction the AI must follow whenever a message mentions that ' +
116
- 'service, for example "Balloon Twisting: always say each child gets one design, never a number of balloons" or ' +
117
- '"Face Painting: mention we only use FDA compliant paints". One rule per service; setting it again replaces it, ' +
118
- 'null removes it. Name the service by package_id or by its exact name from km_get_offers_and_pricing, which ' +
119
- 'also lists every current rule under service_rules. ' +
120
- '🚨 A confirmed write: KM Hub answers 409 with the before and after, the person says yes, you call again with ' +
121
- 'the confirm_token and the same arguments.',
122
- {
123
- package_id: z.string().optional().describe('The service package id from km_get_offers_and_pricing (proposal_packages[].id).'),
124
- package_name: z.string().optional().describe('Or the exact service name, when you do not have the id.'),
125
- rule: z.string().nullable().describe('The rule, up to 1000 characters. Send null to remove this service\'s rule.'),
126
- confirm_token: CONFIRM_TOKEN,
127
- },
128
- async (args) => {
129
- const r = await call('PUT', '/knowledge/service-rules', defined(args));
130
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
131
- return out(r);
132
- },
133
- );
134
- }
1
+ /**
2
+ * Tool family: voice - telling KM Hub how to write, from the terminal.
3
+ *
4
+ * km_update_brand_voice -> PATCH /knowledge/voice (brand_values, sign_emails_as)
5
+ * km_set_writing_rules -> PUT /knowledge/writing-rules (the house rules)
6
+ * km_set_service_rule -> PUT /knowledge/service-rules (one package's writing rule)
7
+ *
8
+ * WHY THIS EXISTS. km_get_brand_voice and km_get_offers_and_pricing could READ how
9
+ * the business sounds and what it sells, and the owner had no way to change either
10
+ * from a terminal conversation. Ziv, 22-Sep-26: "allow Mark to chat through terminal
11
+ * mode with rules about the services, the voice, anything you write". These are the
12
+ * write halves, in their own family so the read family (knowledge.mjs) is untouched.
13
+ *
14
+ * 🚨 Every tool here is a confirmed write. KM Hub answers 409 with a server-written
15
+ * sentence naming the before and after. Put that sentence to the person, wait for a
16
+ * real yes, then call again with the confirm_token and the SAME arguments.
17
+ *
18
+ * The family contract this file follows is documented in ./README.md.
19
+ */
20
+ import { z } from 'zod';
21
+
22
+ export const FAMILY = 'voice';
23
+
24
+ export const TOOLS = ['km_update_brand_voice', 'km_set_writing_rules', 'km_set_service_rule'];
25
+
26
+ // Same profiles as the knowledge family that reads these values: anything that
27
+ // writes words for a real person should be able to correct how it writes them.
28
+ export const PROFILES = ['outreach', 'content', 'money'];
29
+
30
+ function routeMissing(r) {
31
+ return r.status === 404 || r.status === 405 || r.status === 501;
32
+ }
33
+
34
+ const NOT_SUPPORTED =
35
+ 'Your KM Hub does not accept voice and writing-rule changes from the terminal yet. Nothing is wrong with the ' +
36
+ 'workspace. The brand voice box is in the web app at https://hub.kivimedia.co under Settings, General, and this ' +
37
+ 'connector will edit it once your KM Hub is on a build that publishes these routes.';
38
+
39
+ const CONFIRM_TOKEN = z
40
+ .string()
41
+ .optional()
42
+ .describe(
43
+ 'Leave this out on the first call. KM Hub answers 409 with the sentence to put to the person and a token. Only '
44
+ + 'after they actually say yes, call again with the token and the SAME arguments. A yes for one change never '
45
+ + 'authorises a different one.',
46
+ );
47
+
48
+ /** Forward only what the caller set: undefined means "leave alone", null means "clear". */
49
+ function defined(args) {
50
+ const body = {};
51
+ for (const [k, v] of Object.entries(args)) if (v !== undefined) body[k] = v;
52
+ return body;
53
+ }
54
+
55
+ export function register(server, call, { out, text }) {
56
+ server.tool(
57
+ 'km_update_brand_voice',
58
+ 'Change how this business sounds in everything the AI writes: the brand voice box (the owner\'s own words about ' +
59
+ 'their values, tone and style) and the name every email is signed with. It is the same box the owner sees in ' +
60
+ 'KM Hub under Settings, General, and in the Sales Playbook Values tab, and every AI writer reads it: first ' +
61
+ 'replies, rewrites, review replies, broadcasts, templates, follow-ups. ' +
62
+ 'Call km_get_brand_voice first so you change from what is actually there. When the person asks to ADD to their ' +
63
+ 'voice, send the existing text with the addition, never just the new sentence, or the rest is lost. Write ' +
64
+ 'what they said in their words; do not polish it into marketing copy. ' +
65
+ '🚨 A confirmed write. KM Hub answers 409 with the exact before and after; say that to the person, wait for a ' +
66
+ 'real yes in this turn, then call again with the confirm_token and the same arguments.',
67
+ {
68
+ brand_values: z
69
+ .string()
70
+ .nullable()
71
+ .optional()
72
+ .describe('The full brand voice text, up to 4000 characters. It REPLACES what is there. Send null to clear it.'),
73
+ sign_emails_as: z
74
+ .string()
75
+ .nullable()
76
+ .optional()
77
+ .describe('The name every email is signed with, for example "Mark" or "Mark and the Twisty Art team". Send null to clear it.'),
78
+ confirm_token: CONFIRM_TOKEN,
79
+ },
80
+ async (args) => {
81
+ const r = await call('PATCH', '/knowledge/voice', defined(args));
82
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
83
+ return out(r);
84
+ },
85
+ );
86
+
87
+ server.tool(
88
+ 'km_set_writing_rules',
89
+ 'Set the house writing rules: the standing instructions every message the AI writes for this business must obey, ' +
90
+ 'for example "never quote a price in the first email", "always offer a phone call", "never call our performers ' +
91
+ 'clowns". They are mandatory, not style, and they apply to every AI writer including automatic ones. ' +
92
+ 'Use append to add one rule to the end (the usual case, and safe); use rules only when the person wants the whole ' +
93
+ 'list rewritten, and then send every rule they want kept, because rules replaces the list. km_get_brand_voice ' +
94
+ 'shows the current list under voice.house_rules. ' +
95
+ '🚨 A confirmed write: KM Hub answers 409 with the before and after, the person says yes, you call again with ' +
96
+ 'the confirm_token and the same arguments.',
97
+ {
98
+ append: z.string().optional().describe('One or more new rules to add to the end of the list.'),
99
+ rules: z
100
+ .string()
101
+ .nullable()
102
+ .optional()
103
+ .describe('The complete list of rules, one per line, up to 3000 characters. Replaces the list. Send null to clear every rule.'),
104
+ confirm_token: CONFIRM_TOKEN,
105
+ },
106
+ async (args) => {
107
+ const r = await call('PUT', '/knowledge/writing-rules', defined(args));
108
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
109
+ return out(r);
110
+ },
111
+ );
112
+
113
+ server.tool(
114
+ 'km_set_service_rule',
115
+ 'Give one service its own writing rule: an instruction the AI must follow whenever a message mentions that ' +
116
+ 'service, for example "Balloon Twisting: always say each child gets one design, never a number of balloons" or ' +
117
+ '"Face Painting: mention we only use FDA compliant paints". One rule per service; setting it again replaces it, ' +
118
+ 'null removes it. Name the service by package_id or by its exact name from km_get_offers_and_pricing, which ' +
119
+ 'also lists every current rule under service_rules. ' +
120
+ '🚨 A confirmed write: KM Hub answers 409 with the before and after, the person says yes, you call again with ' +
121
+ 'the confirm_token and the same arguments.',
122
+ {
123
+ package_id: z.string().optional().describe('The service package id from km_get_offers_and_pricing (proposal_packages[].id).'),
124
+ package_name: z.string().optional().describe('Or the exact service name, when you do not have the id.'),
125
+ rule: z.string().nullable().describe('The rule, up to 1000 characters. Send null to remove this service\'s rule.'),
126
+ confirm_token: CONFIRM_TOKEN,
127
+ },
128
+ async (args) => {
129
+ const r = await call('PUT', '/knowledge/service-rules', defined(args));
130
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
131
+ return out(r);
132
+ },
133
+ );
134
+ }