@kivimedia/kmhub 2.0.0 → 2.9.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 (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 +109 -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,76 @@
1
+ /**
2
+ * Tool family: compare - the honest, computed comparison.
3
+ *
4
+ * Workstream E1. A comparison in a slide is out of date the day after it is
5
+ * written. This counts KM Hub's side at the moment somebody asks, from the same
6
+ * sources the product runs on.
7
+ *
8
+ * 🚨 IT COUNTS KM HUB ONLY, ON PURPOSE. The API cannot see a competitor's product,
9
+ * and a stored figure about somebody else's software is a claim nobody can
10
+ * re-verify and nobody notices going stale. Booked is counted from the plugin
11
+ * actually installed on the reader's machine, by the /kmhub:vs-booked command, or
12
+ * it is reported as not installed. Never fill the gap from memory.
13
+ *
14
+ * The family contract this file follows is documented in ./README.md.
15
+ */
16
+ import { readFileSync } from 'node:fs';
17
+
18
+ export const FAMILY = 'compare';
19
+
20
+ export const TOOLS = ['km_vs_booked'];
21
+
22
+ export const PROFILES = ['*'];
23
+
24
+ function routeMissing(r) {
25
+ return r.status === 404 || r.status === 405 || r.status === 501;
26
+ }
27
+
28
+ const NOT_SUPPORTED =
29
+ 'Your KM Hub does not publish the comparison route yet. That is not a fault: the workspace is fine and every ' +
30
+ 'other tool works as normal.';
31
+
32
+ /** The connector owns the tool and reach numbers, so they are read here, not fetched. */
33
+ function localCounts() {
34
+ try {
35
+ const caps = JSON.parse(readFileSync(new URL('../capabilities.json', import.meta.url), 'utf8'));
36
+ return {
37
+ gui_pages: caps?.counts?.pages ?? null,
38
+ pages_reachable_from_terminal: caps?.counts?.reachable_from_terminal ?? null,
39
+ pages_web_only_by_design: caps?.counts?.web_only_by_design ?? null,
40
+ pages_not_built_yet: caps?.counts?.web_only_not_built_yet ?? null,
41
+ pillars: caps?.counts?.pillars ?? null,
42
+ };
43
+ } catch {
44
+ return null;
45
+ }
46
+ }
47
+
48
+ export function register(server, call, { out, text }) {
49
+ server.tool(
50
+ 'km_vs_booked',
51
+ 'The live, counted comparison of KM Hub Terminal Mode against Booked Solid, for when the user asks how the two ' +
52
+ 'compare, whether to switch, or which does more. ' +
53
+ 'Everything it returns about KM Hub is counted at the moment you ask, from the play catalogue, the active play ' +
54
+ 'library and the connector capability map. Nothing is a stored marketing figure. ' +
55
+ '🚨 IT RETURNS NOTHING ABOUT BOOKED, and `booked` comes back null on purpose. To count Booked, use the installed ' +
56
+ 'Booked Solid client\'s own capability inventory and CLI when that client is available on the same machine. ' +
57
+ 'Treat its documented tool names and play identifiers as the source of the count. If Booked is not installed, ' +
58
+ 'SAY SO and give KM Hub numbers alone. Never quote a Booked ' +
59
+ 'figure from memory: an uncounted number is worse than an absent one, because nobody can tell afterwards which ' +
60
+ 'it was. ' +
61
+ '🚨 Read `what_booked_does_better` and say at least one true thing in its favour BEFORE anything in KM Hub\'s. ' +
62
+ 'It works with no internet, nothing leaves the client machine, and no subscription sits between somebody and ' +
63
+ 'their own records. A model that produces an all-green comparison is a brochure, and the reader discounts ' +
64
+ 'everything after the first one-sided line. ' +
65
+ 'Read `the_honest_framing` too: these are different SHAPES of product, so a play count alone misleads in both ' +
66
+ 'directions. Read only, sends nothing, changes nothing.',
67
+ {},
68
+ async () => {
69
+ const r = await call('GET', '/compare/terminal');
70
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
71
+ if (!r.ok) return out(r);
72
+ const body = { ...(r.data || {}), kmhub_connector: localCounts() };
73
+ return { content: [{ type: 'text', text: JSON.stringify(body, null, 2) }] };
74
+ },
75
+ );
76
+ }
package/tools/core.mjs CHANGED
@@ -18,6 +18,7 @@ export const FAMILY = 'core';
18
18
  */
19
19
  export const TOOLS = [
20
20
  'km_me',
21
+ 'km_workspaces',
21
22
  'km_waiting',
22
23
  'km_list_clients',
23
24
  'km_create_lead',
@@ -49,6 +50,17 @@ export function register(server, call, { out }) {
49
50
  async () => out(await call('GET', '/me')),
50
51
  );
51
52
 
53
+ server.tool(
54
+ 'km_workspaces',
55
+ 'Every KM Hub workspace the person at this keyboard belongs to, which single one this connector actually opens, and how to reach the others. ' +
56
+ 'Call it the moment someone talks about a second brand, expects data that is not here, says they switched workspace in the web app, or asks why the numbers look like the wrong business. ' +
57
+ 'The thing it exists to explain: an API key is welded to ONE workspace, so switching brand in the KM Hub web app does not move this connection and never will - the key carries the workspace, not the browser session. Someone can restart Claude all day and still be in the same brand. ' +
58
+ 'It hands out no access and no keys: names and ids only. Reaching another workspace means installing a second connector with a key from that workspace, which the note in the response explains. ' +
59
+ 'It is cheap, it reads, and it changes nothing.',
60
+ {},
61
+ async () => out(await call('GET', '/workspaces')),
62
+ );
63
+
52
64
  server.tool(
53
65
  'km_waiting',
54
66
  'Raw gate counts and nothing else: how many approvals, new enquiries, open tasks, unpaid invoices and unsigned contracts are sitting in the workspace right now. It is a gauge, not an answer. ' +
@@ -217,6 +229,15 @@ export function register(server, call, { out }) {
217
229
  .enum(['pending', 'confirmed'])
218
230
  .optional()
219
231
  .describe('Default pending (owner reviews); confirmed puts it straight on the calendar.'),
232
+ confirm_token: z
233
+ .string()
234
+ .optional()
235
+ .describe(
236
+ 'Leave this out on the first call. This action needs an explicit yes from the person you are working with: '
237
+ + 'KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show them that '
238
+ + 'description in those words, wait for a real answer, and only then call again with the token and the SAME '
239
+ + 'arguments. A yes for one action never authorises a different one.',
240
+ ),
220
241
  },
221
242
  async (args) => out(await call('POST', '/bookings', args)),
222
243
  );
package/tools/crm.mjs CHANGED
@@ -123,9 +123,18 @@ export function register(server, call, { out, qs }) {
123
123
  .describe('open = still live, won = the work is theirs, lost = they said no, abandoned = it went quiet and is not coming back.'),
124
124
  lost_reason: z.string().optional().describe('Why it was lost, in the person\'s own words. Only kept for lost or abandoned.'),
125
125
  reason: z.string().optional().describe('A short note kept on the deal history explaining the change.'),
126
+ confirm_token: z
127
+ .string()
128
+ .optional()
129
+ .describe(
130
+ 'Leave this out on the first call. This action needs an explicit yes from the person you are working with: '
131
+ + 'KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show them that '
132
+ + 'description in those words, wait for a real answer, and only then call again with the token and the SAME '
133
+ + 'arguments. A yes for one action never authorises a different one.',
134
+ ),
126
135
  },
127
- async ({ deal_id, status, lost_reason, reason }) =>
128
- out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/status`, { status, lost_reason, reason })),
136
+ async ({ deal_id, status, lost_reason, reason, confirm_token }) =>
137
+ out(await call('PATCH', `/deals/${encodeURIComponent(String(deal_id ?? '').trim())}/status`, { status, lost_reason, reason, confirm_token })),
129
138
  );
130
139
 
131
140
  server.tool(
@@ -161,7 +170,7 @@ export function register(server, call, { out, qs }) {
161
170
  'Add a job to the workspace to-do list, optionally tied to a client or a booking, so a follow-up someone mentioned in passing does not get lost. Good for "chase the deposit on Friday", "send the Hendersons their timeline", "call the venue back". A new task always starts as not done and is never assigned to a particular person, because who does the work is the owner\'s call and not yours. A bare date like 2026-09-14 means the END of that day, so a task due today does not read as late until tomorrow. Creating a task is fully undoable from the AI activity log.',
162
171
  {
163
172
  name: z.string().describe('What the task is, in one line. This is what the owner will read on their list.'),
164
- description: z.string().optional().describe('Any detail that will not fit in the name.'),
173
+ description: z.string().optional().describe('Any detail that will not fit in the name. Up to 20,000 characters, and a longer one is refused with its length rather than stored short.'),
165
174
  due_date: z.string().optional().describe('YYYY-MM-DD, or a full ISO timestamp when the time of day matters.'),
166
175
  client_id: z.string().optional().describe('Tie it to a client (UUID), so it shows on their record.'),
167
176
  booking_id: z.string().optional().describe('Tie it to a booking (UUID), so it shows against that job.'),
@@ -0,0 +1,137 @@
1
+ /**
2
+ * Tool family: dubsado - the Dubsado lead reply loop.
3
+ *
4
+ * For workspaces whose Dubsado account is wired through the Kivi dubsado-bridge
5
+ * (today: Dazzling Balloons). The bridge polls Dubsado every 5 minutes, LeadIQ
6
+ * stores each lead and writes an AI reply draft, and the send goes back out
7
+ * through Dubsado itself so the reply sits in the real project email thread and
8
+ * comes from the business's own sender.
9
+ *
10
+ * The terminal is the editor: there is no regenerate machinery here. Read the
11
+ * draft, rework the wording with the person in chat, send the final text.
12
+ *
13
+ * The family contract this file follows is documented in ./README.md.
14
+ */
15
+ export const FAMILY = 'dubsado';
16
+
17
+ export const TOOLS = [
18
+ 'km_dubsado_status',
19
+ 'km_dubsado_leads',
20
+ 'km_dubsado_lead',
21
+ 'km_dubsado_send_reply',
22
+ 'km_dubsado_sync',
23
+ ];
24
+
25
+ export const PROFILES = ['*'];
26
+
27
+ function routeMissing(r) {
28
+ return r.status === 404 || r.status === 405 || r.status === 501;
29
+ }
30
+
31
+ const NOT_SUPPORTED =
32
+ 'This workspace does not have the Dubsado connection yet. That is not a fault: every other tool works as ' +
33
+ 'normal. Connecting a Dubsado account is done by the Kivi Media team.';
34
+
35
+ export function register(server, call, { out, text, qs, z }) {
36
+ server.tool(
37
+ 'km_dubsado_status',
38
+ 'Health check for the Dubsado connection: whether the bridge that syncs leads from Dubsado is alive, when ' +
39
+ 'the last sync happened, and the newest lead it has. Call it first in a session about Dubsado leads, and ' +
40
+ 'whenever a lead the person expects to see is missing. If bridge.alive is false, say plainly that syncing ' +
41
+ 'and sending are down until the Kivi Media team revives the session, and do not attempt a send.',
42
+ {},
43
+ async () => {
44
+ const r = await call('GET', '/dubsado/status');
45
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
46
+ return out(r);
47
+ },
48
+ );
49
+
50
+ server.tool(
51
+ 'km_dubsado_leads',
52
+ 'The Dubsado leads in this workspace, newest first, each with its reply_state: no_draft_yet, ' +
53
+ 'draft_ready_for_review (the AI already wrote a reply nobody approved), approved_awaiting_send, or ' +
54
+ 'reply_sent. Use it to answer "any new inquiries?", to find a lead by name, or to sweep for ' +
55
+ 'draft_ready_for_review leads that deserve attention. New inquiries land here up to ~5 minutes after ' +
56
+ 'they hit Dubsado. This lists; read one lead in full with km_dubsado_lead before judging any draft.',
57
+ {
58
+ q: z.string().optional().describe('Search by client name, email, or event type.'),
59
+ status: z.string().optional().describe('Filter by pipeline stage, e.g. inquiry, qualified, proposal_sent, booked.'),
60
+ limit: z.number().int().min(1).max(100).optional().describe('How many leads, default 20.'),
61
+ },
62
+ async ({ q, status, limit }) => {
63
+ const r = await call('GET', `/dubsado/leads${qs({ q, status, limit })}`);
64
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
65
+ return out(r);
66
+ },
67
+ );
68
+
69
+ server.tool(
70
+ 'km_dubsado_lead',
71
+ 'One Dubsado lead in full: contact details, event info, the form answers they submitted, the whole email ' +
72
+ 'thread so far, and every AI reply draft with its state. Accepts the lead_id from km_dubsado_leads (or a ' +
73
+ '24 character Dubsado job id). Read this BEFORE proposing any reply: the thread and form answers are the ' +
74
+ 'context that makes a reply sound informed instead of canned. Show the newest unsent draft to the person, ' +
75
+ 'take their edits in chat, and only then reach for km_dubsado_send_reply.',
76
+ {
77
+ lead_id: z.string().describe('Lead UUID from km_dubsado_leads, or the Dubsado job id.'),
78
+ },
79
+ async ({ lead_id }) => {
80
+ const r = await call('GET', `/dubsado/leads/${encodeURIComponent(String(lead_id || '').trim())}`);
81
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
82
+ return out(r);
83
+ },
84
+ );
85
+
86
+ server.tool(
87
+ 'km_dubsado_send_reply',
88
+ 'Send a reply email to a Dubsado lead, through Dubsado, from the business\'s own sender. It lands in the ' +
89
+ 'lead\'s real Dubsado project thread and cannot be unsent, so the person must have seen and approved the ' +
90
+ 'exact final wording first. Pass draft_id for an existing AI draft, with subject and body carrying the ' +
91
+ 'final wording after any edits; or pass just subject + body for a reply written here in the terminal. ' +
92
+ 'The recipient is always the lead\'s own email on file and cannot be changed here. Plain text is fine: ' +
93
+ 'blank lines become paragraphs. The send is refused for leads whose follow-ups were halted and for leads ' +
94
+ 'in a terminal stage like booked or lost. ' +
95
+ 'On success, tell the person it went out, to whom, and with what subject.',
96
+ {
97
+ lead_id: z.string().describe('Lead UUID from km_dubsado_leads, or the Dubsado job id.'),
98
+ draft_id: z.string().optional().describe('UUID of the AI draft being approved (from km_dubsado_lead).'),
99
+ subject: z.string().optional().describe('Final subject line. Overrides the draft subject when draft_id is given.'),
100
+ body: z.string().optional().describe('Final reply text. Overrides the draft text when draft_id is given.'),
101
+ confirm_token: z
102
+ .string()
103
+ .optional()
104
+ .describe(
105
+ 'Leave this out on the first call. This action needs an explicit yes from the person you are working ' +
106
+ 'with: KM Hub answers 409 with a plain description of exactly what would happen, plus a token. Show ' +
107
+ 'them that description in those words, wait for a real answer, and only then call again with the ' +
108
+ 'token and the SAME arguments. A yes for one action never authorises a different one.',
109
+ ),
110
+ },
111
+ async ({ lead_id, ...rest }) => {
112
+ const r = await call(
113
+ 'POST',
114
+ `/dubsado/leads/${encodeURIComponent(String(lead_id || '').trim())}/send`,
115
+ rest,
116
+ );
117
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
118
+ return out(r);
119
+ },
120
+ );
121
+
122
+ server.tool(
123
+ 'km_dubsado_sync',
124
+ 'Ask the bridge to re-pull recent leads from Dubsado now instead of waiting for the next 5 minute poll. ' +
125
+ 'Use it when a lead the person can see in Dubsado has not appeared in km_dubsado_leads. Re-pulled leads ' +
126
+ 'land within about 5 minutes of this call, so check the list again after that, not immediately. ' +
127
+ 'days_back widens how far back the re-pull looks.',
128
+ {
129
+ days_back: z.number().int().min(1).max(90).optional().describe('Re-pull leads updated in the last N days.'),
130
+ },
131
+ async ({ days_back }) => {
132
+ const r = await call('POST', '/dubsado/sync', days_back ? { days_back } : {});
133
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
134
+ return out(r);
135
+ },
136
+ );
137
+ }
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Tool family: exports - getting your own data back out, from a terminal.
3
+ *
4
+ * This family exists because of one email. A customer with three workspaces asked
5
+ * for "a full export of all my data, including the most recent", said the system
6
+ * was hard to navigate, and said he could not work out how to see his invoices.
7
+ * Everything he asked for was already in KM Hub and already exportable, and there
8
+ * was still no way to answer him without telling him to go and click through a
9
+ * screen he had just told us he could not find. A person asking for their own
10
+ * records should never be handed a navigation problem.
11
+ *
12
+ * Four tools:
13
+ * km_export_data one dataset, csv or xlsx
14
+ * km_export_everything the whole workspace, one ZIP of readable CSVs
15
+ * km_export_status how far an export got, and its link once ready
16
+ * km_list_exports what has been exported lately
17
+ *
18
+ * These do not build files. They queue the same job the Export Data screen
19
+ * queues, and the same worker builds it, so an export started here and an export
20
+ * started in the browser are byte-for-byte the same file.
21
+ *
22
+ * 🚨 A DOWNLOAD LINK IS A CREDENTIAL. The signed URL needs no login and lives for
23
+ * seven days. Hand it to the person who asked, use it to fetch the file, and do
24
+ * not write it into a document, a commit, a ticket or a chat log that outlives
25
+ * the download.
26
+ *
27
+ * No PROFILES export on purpose. Exporting is not the job of the narrow money,
28
+ * outreach or content profiles, and a family that pins itself into every profile
29
+ * makes the profiles meaningless. It ships in `full`, which is the default.
30
+ *
31
+ * The family contract this file follows is documented in ./README.md.
32
+ */
33
+ import { z } from 'zod';
34
+
35
+ export const FAMILY = 'exports';
36
+
37
+ export const TOOLS = [
38
+ 'km_export_data',
39
+ 'km_export_everything',
40
+ 'km_export_status',
41
+ 'km_list_exports',
42
+ ];
43
+
44
+ const LINK_WARNING =
45
+ 'The download link that comes back is a signed URL: anyone holding it can fetch the file without signing in, and it expires after seven days. Give it to the person who asked for it and do not paste it anywhere it will be stored.';
46
+
47
+ const NOT_INSTANT =
48
+ 'The file is built in the background. This returns an export id straight away; call km_export_status with that id until it says ready, which is usually a few seconds and can be a couple of minutes for a large workspace.';
49
+
50
+ /**
51
+ * @param {{ tool: Function }} server guarded registrar (see ./README.md)
52
+ * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
53
+ * @param {{ out: Function, text: Function, qs: Function }} helpers
54
+ */
55
+ export function register(server, call, { out, qs }) {
56
+ server.tool(
57
+ 'km_export_data',
58
+ 'Export one set of records from this workspace to a spreadsheet file: invoices, bookings, clients, payments, quotes, leads, expenses, gigs, venues, vendors, equipment, team members, or the outreach tables. ' +
59
+ 'Reach for this whenever someone asks to be sent, given, or shown their data outside KM Hub: "send me my invoices", "export the client list", "I need last year in a spreadsheet", "can I get all of this out", "my accountant wants the payments". ' +
60
+ 'It exports every column of the chosen dataset. Narrow it by date with date_from and date_to when the person named a period, and leave both off when they asked for everything. ' +
61
+ 'If they want the whole workspace rather than one dataset, use km_export_everything instead. ' +
62
+ NOT_INSTANT + ' ' + LINK_WARNING,
63
+ {
64
+ dataset: z
65
+ .string()
66
+ .describe(
67
+ 'What to export. One of: invoices, bookings, clients, payments, quotes, inquiries, expenses, gigs, venues, vendors, equipment_items, kmhub_export_team_members, outreach_campaigns, outreach_campaign_prospect_status, outreach_replies, outreach_drafts, booking_drafts. Everyday words are understood too: events, jobs and projects mean bookings, contacts and customers mean clients, bills means invoices, leads and enquiries mean inquiries, equipment, team and crew are understood.',
68
+ ),
69
+ format: z
70
+ .enum(['csv', 'xlsx'])
71
+ .optional()
72
+ .describe('csv (the default, opens anywhere) or xlsx for Excel.'),
73
+ date_from: z.string().optional().describe('Only records from this date onward, as YYYY-MM-DD. Leave off for everything.'),
74
+ date_to: z.string().optional().describe('Only records before this date, as YYYY-MM-DD. Leave off for everything.'),
75
+ date_field: z
76
+ .string()
77
+ .optional()
78
+ .describe('Which date the range applies to. Defaults to created_at. For bookings, event_date is usually what a person means by "last year".'),
79
+ label: z.string().optional().describe('A name for this export, shown in the export history. Say what it is, for instance "2025 invoices for the accountant".'),
80
+ },
81
+ async ({ dataset, format, date_from, date_to, date_field, label }) =>
82
+ out(await call('POST', '/exports', { dataset, format, date_from, date_to, date_field, label })),
83
+ );
84
+
85
+ server.tool(
86
+ 'km_export_everything',
87
+ 'Export the ENTIRE workspace as one ZIP file: every booking, lead, deal, client, quote, invoice, payment, venue, vendor, expense, gig, team member, email history, outreach record and Booking Brain artifact, each as its own readable CSV inside the archive. ' +
88
+ 'This is the one to reach for when someone asks for "all my data", "a full export", "everything you have on us", or is leaving and wants their records. ' +
89
+ 'Prefer km_export_data when they named one thing: a person who asked for their invoices does not want a forty-file archive to dig through. ' +
90
+ 'This is a confirmed action. The first call comes back with a sentence describing exactly what will be packaged and a confirm_token. Put that sentence to the person, wait for an explicit yes, then call again with the same inputs plus that token. Never send the token without asking. ' +
91
+ NOT_INSTANT + ' ' + LINK_WARNING,
92
+ {
93
+ label: z.string().optional().describe('A name for this export, shown in the export history.'),
94
+ confirm_token: z
95
+ .string()
96
+ .min(1)
97
+ .max(4096)
98
+ .optional()
99
+ .describe('Only ever the exact token returned in the 409 response, and only after the person has said yes to the sentence that came with it.'),
100
+ },
101
+ async ({ label, confirm_token }) =>
102
+ out(await call('POST', '/exports/everything', { label, ...(confirm_token ? { confirm_token } : {}) })),
103
+ );
104
+
105
+ server.tool(
106
+ 'km_export_status',
107
+ 'Check one export: whether it is still building, what it is doing right now, how many rows it has read, and once it is ready the file size, the row count and the download link. ' +
108
+ 'Call this after km_export_data or km_export_everything, using the export_id they returned. If it is still building, say what it is doing rather than just "working on it", and check again in a few seconds. ' +
109
+ 'A failed export comes back with the reason in error_message, which is usually a date range that matched too many rows. ' +
110
+ LINK_WARNING,
111
+ {
112
+ export_id: z.string().describe('The export_id returned when the export was started.'),
113
+ },
114
+ async ({ export_id }) => out(await call('GET', `/exports/${encodeURIComponent(export_id)}`)),
115
+ );
116
+
117
+ server.tool(
118
+ 'km_list_exports',
119
+ 'List the exports built from this workspace recently: what was exported, when, in what format, how big it was and whether it is still available. ' +
120
+ 'Useful when someone asks "did I already pull that", "what did we send the accountant", or when an earlier export id has been lost. ' +
121
+ 'Download links are deliberately not included here. Ask for one export by id with km_export_status to get its link. ' +
122
+ 'This tool only reads. It cannot start or delete an export.',
123
+ {
124
+ limit: z.number().int().min(1).max(100).optional().describe('How many to list. Defaults to 20.'),
125
+ },
126
+ async ({ limit }) => out(await call('GET', `/exports${qs({ limit })}`)),
127
+ );
128
+ }
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Tool family: fact-review - the owner confirms or rejects business facts from the terminal.
3
+ *
4
+ * km_list_pending_business_facts -> GET /knowledge/facts/pending
5
+ * km_confirm_business_facts -> POST /knowledge/facts/confirm (confirmed write)
6
+ * km_reject_business_facts -> POST /knowledge/facts/reject (confirmed write)
7
+ *
8
+ * WHY THIS EXISTS. km_propose_business_fact could only put a fact in the queue, so
9
+ * a conversation where the owner said "confirmed, these three are correct" still
10
+ * ended with "go and click confirm in the web app" (Brant Matthews, 23-Sep-26).
11
+ * Ziv: "you need to be able to confirm for me". The read and propose tools stay in
12
+ * knowledge.mjs, untouched in shape; this is the review half in its own family.
13
+ *
14
+ * 🚨 Both writes are confirmed writes. KM Hub answers 409 with a server-written
15
+ * list of the exact facts; put that to the person, wait for a real yes, then call
16
+ * again with the confirm_token and the SAME fact_ids. Reject is gated too because
17
+ * a rejection cannot be undone.
18
+ *
19
+ * The family contract this file follows is documented in ./README.md.
20
+ */
21
+ import { z } from 'zod';
22
+
23
+ export const FAMILY = 'fact-review';
24
+
25
+ export const TOOLS = ['km_list_pending_business_facts', 'km_confirm_business_facts', 'km_reject_business_facts'];
26
+
27
+ // Same profiles as the knowledge family that proposes and reads these facts.
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 cannot review business facts from the terminal yet. Nothing is wrong with the workspace and nothing ' +
36
+ 'was changed. The owner can confirm or reject them in the web app at https://hub.kivimedia.co under Settings, ' +
37
+ 'Connect, Business Knowledge.';
38
+
39
+ const FACT_IDS = z
40
+ .array(z.string())
41
+ .min(1)
42
+ .max(25)
43
+ .describe('The ids of the facts, from km_list_pending_business_facts. Up to 25 at once.');
44
+
45
+ const CONFIRM_TOKEN = z
46
+ .string()
47
+ .optional()
48
+ .describe(
49
+ 'Leave this out on the first call. KM Hub answers 409 with the list to put to the person and a token. Only ' +
50
+ 'after they actually say yes, call again with the token and the SAME fact_ids and reason. A yes for one ' +
51
+ 'list never authorises a different one.',
52
+ );
53
+
54
+ const REASON = z
55
+ .string()
56
+ .optional()
57
+ .describe('Optional, one line, in the owner\'s words, for example "Brant confirmed on the call". Kept with the decision.');
58
+
59
+ /** A 404 from a route that does not exist yet, as opposed to a fact that does not exist. */
60
+ function unsupported(r) {
61
+ if (!routeMissing(r)) return false;
62
+ const err = r.data && typeof r.data === 'object' ? r.data.error : undefined;
63
+ return err !== 'not_found' || /Unknown route/i.test(String(r.data?.message || ''));
64
+ }
65
+
66
+ export function register(server, call, { out, text }) {
67
+ server.tool(
68
+ 'km_list_pending_business_facts',
69
+ 'The business facts that are waiting for the owner to say yes or no: things somebody proposed with ' +
70
+ 'km_propose_business_fact, or that KM Hub picked up from an uploaded document or the owner\'s emails. Each one ' +
71
+ 'comes with its id, its exact text and where it came from. Call it when km_get_business_facts reports ' +
72
+ 'pending_proposals above zero, when the person asks what is waiting for them, or before confirming anything, so ' +
73
+ 'you have the ids. NONE of these is confirmed: never use one in a draft, a quote or an answer. Read each to the ' +
74
+ 'owner in its exact words and let them decide. Read only.',
75
+ {},
76
+ async () => {
77
+ const r = await call('GET', '/knowledge/facts/pending');
78
+ if (unsupported(r)) return text(NOT_SUPPORTED, true);
79
+ return out(r);
80
+ },
81
+ );
82
+
83
+ server.tool(
84
+ 'km_confirm_business_facts',
85
+ 'Confirm business facts the owner says are TRUE, the same as the confirm button in KM Hub under Business ' +
86
+ 'Knowledge. Once confirmed, every draft, quote, play and scout relies on the fact, so a wrong one ends up in ' +
87
+ 'real messages to real clients. Use it when the person has read the facts and says they are right. Several ' +
88
+ 'can go in one call, so the person answers once for the whole list. It is recorded as confirmed by the person ' +
89
+ 'this connector belongs to, who has to be an owner or admin of the workspace. ' +
90
+ 'Get the ids from km_list_pending_business_facts, and pass only the ones they actually said yes to. ' +
91
+ '🚨 A confirmed write. The first call, without confirm_token, returns 409 with `what_would_happen`: the list ' +
92
+ 'of exact facts, written by KM Hub. Show it in those words, wait for a real yes, then call again with the ' +
93
+ 'confirm_token and the SAME fact_ids. Once confirmed, a fact cannot be changed or withdrawn from the terminal, ' +
94
+ 'and confirming a corrected wording later does NOT retire the old one (both would be in use), so confirm only ' +
95
+ 'what the owner has read and said is right.',
96
+ { fact_ids: FACT_IDS, reason: REASON, confirm_token: CONFIRM_TOKEN },
97
+ review('/knowledge/facts/confirm'),
98
+ );
99
+
100
+ server.tool(
101
+ 'km_reject_business_facts',
102
+ 'Reject business facts the owner says are WRONG, the same as the reject button in KM Hub under Business ' +
103
+ 'Knowledge, so nothing ever uses them. Only on the owner\'s word about these specific facts, never on your own ' +
104
+ 'judgement that one looks doubtful: if you think one is wrong, say so and let them decide. A rejected fact ' +
105
+ 'cannot be reopened, and the same wording cannot be proposed again; if the owner wants a corrected version, ' +
106
+ 'propose the corrected wording with km_propose_business_fact. ' +
107
+ '🚨 A confirmed write, because it cannot be undone: the first call returns 409 with the exact facts; show them, ' +
108
+ 'wait for a real yes, then call again with the confirm_token and the SAME fact_ids. Afterwards, say plainly ' +
109
+ 'which facts were rejected.',
110
+ { fact_ids: FACT_IDS, reason: REASON, confirm_token: CONFIRM_TOKEN },
111
+ review('/knowledge/facts/reject'),
112
+ );
113
+
114
+ /** Same body on the ask and on the answer, so the confirm token's fingerprint matches. */
115
+ function review(path) {
116
+ return async ({ fact_ids, reason, confirm_token }) => {
117
+ const body = { fact_ids };
118
+ if (reason !== undefined) body.reason = reason;
119
+ if (confirm_token) body.confirm_token = String(confirm_token);
120
+ const r = await call('POST', path, body);
121
+ if (unsupported(r)) return text(NOT_SUPPORTED, true);
122
+ return out(r);
123
+ };
124
+ }
125
+ }