@kivimedia/kmhub 2.9.1 → 2.11.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 +37 -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 -110
  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 +144 -134
  34. package/tools/hr.mjs +162 -162
  35. package/tools/knowledge.mjs +129 -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 +137 -134
  57. package/tools.mjs +407 -407
package/tools/hr.mjs CHANGED
@@ -1,162 +1,162 @@
1
- /**
2
- * Tool family: hr - the people who actually do the work.
3
- *
4
- * Workstream B5. The HR pillar was the starkest zero in the parity map: eight
5
- * navigable pages, no terminal reach at all. For a talent business that is not an
6
- * edge case, it is the department that delivers every booking.
7
- *
8
- * km_list_team -> GET /hr/team
9
- * km_get_team_member -> GET /hr/team/:userId
10
- * km_list_crew_assignments-> GET /hr/assignments
11
- * km_list_time_entries -> GET /hr/time
12
- * km_list_payroll -> GET /hr/payroll
13
- *
14
- * 🚨 EVERY TOOL HERE READS. Assigning somebody to a gig, approving time and marking
15
- * payroll paid all either move money or commit a human being to a date. The
16
- * write-scope policy (B8) is not settled, so none of that is reachable from here and
17
- * a model must say so rather than hunting for another route.
18
- *
19
- * 🚨 PERSONAL DATA IS NARROW ON PURPOSE. The users table holds home address,
20
- * coordinates, emergency contacts and a bio. None of it comes back. Name, email and
21
- * phone are the working contact set. Payroll payment references and notes are
22
- * withheld too: knowing a run is paid is operational, knowing the account it went to
23
- * is a liability sitting in a transcript.
24
- *
25
- * 🚨 ALL MONEY IS IN CENTS and named as cents. Nothing is silently divided.
26
- *
27
- * The family contract this file follows is documented in ./README.md.
28
- */
29
- import { z } from 'zod';
30
-
31
- export const FAMILY = 'hr';
32
-
33
- export const TOOLS = [
34
- 'km_list_team',
35
- 'km_get_team_member',
36
- 'km_list_crew_assignments',
37
- 'km_list_time_entries',
38
- 'km_list_payroll',
39
- ];
40
-
41
- // Placed by hand in tools.mjs. Its own listing here is the fallback.
42
- export const PROFILES = ['money'];
43
-
44
- function routeMissing(r) {
45
- return r.status === 404 || r.status === 405 || r.status === 501;
46
- }
47
-
48
- const NOT_SUPPORTED =
49
- 'Your KM Hub does not expose the HR surface to the terminal yet. That is not a fault: the workspace is fine and ' +
50
- 'every other tool works as normal. Your team, time and payroll are all there in the web app at ' +
51
- 'https://hub.kivimedia.co, and this connector will start reading them once your KM Hub is on a build that publishes them.';
52
-
53
- export function register(server, call, { out, text, qs }) {
54
- server.tool(
55
- 'km_list_team',
56
- 'Who is on this workspace: their name, working contact details, access level, the roles they actually perform and ' +
57
- 'the skills recorded against them. ' +
58
- 'Call it whenever the user talks about their team, crew, staff, performers or who could cover something. It is also ' +
59
- 'the right first call before suggesting anybody be put on a job, because assuming a one-person business when there ' +
60
- 'is a team, or the reverse, makes every following sentence wrong. ' +
61
- 'Roles here are two different things and the response keeps them apart: `access_level` is what they can do inside ' +
62
- 'KM Hub, `performing_roles` is what they do at an event. Do not confuse an admin for a lead performer. ' +
63
- '🚨 Home addresses, coordinates, emergency contacts and bios exist in the workspace and are deliberately NOT ' +
64
- 'available here. Do not look for them. Read only.',
65
- {
66
- limit: z.number().int().min(1).max(200).optional().describe('How many people. Default 50, which is more than most workspaces have.'),
67
- },
68
- async ({ limit }) => {
69
- const r = await call('GET', `/hr/team${qs({ limit })}`);
70
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
71
- return out(r);
72
- },
73
- );
74
-
75
- server.tool(
76
- 'km_get_team_member',
77
- 'One person in depth: their performing roles and proficiency, their skills and which are verified, their pay rates ' +
78
- 'including overtime and when each rate takes effect, and their recent assignments with what they earned and how ' +
79
- 'they were rated. ' +
80
- 'Use it before putting somebody forward for a job, when the user asks what somebody costs, or when deciding who ' +
81
- 'is right for a particular event. Rates carry `effective_from` and `effective_to`: a rate that has expired is not ' +
82
- 'what this person costs today, and quoting one is how a job gets priced wrong. ' +
83
- 'All money is in cents. Read only: changing a rate or an assignment happens in KM Hub.',
84
- {
85
- user_id: z.string().describe('The team member id, as km_list_team returned it.'),
86
- },
87
- async ({ user_id }) => {
88
- const r = await call('GET', `/hr/team/${encodeURIComponent(String(user_id || '').trim())}`);
89
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
90
- return out(r);
91
- },
92
- );
93
-
94
- server.tool(
95
- 'km_list_crew_assignments',
96
- 'Who is booked onto what, and crucially who has not said yes yet. Returns each assignment with its status, when it ' +
97
- 'was confirmed or declined and why, what was actually worked, what was earned and any rating. ' +
98
- '🚨 The number to look at is `unconfirmed`. A pending assignment is a person who has NOT agreed to be there, and a ' +
99
- 'job staffed entirely with pending assignments is a job with nobody on it. That distinction is invisible on a ' +
100
- 'calendar and it is the single most useful thing this tool surfaces, so raise it unprompted when it is not zero. ' +
101
- 'A decline with a reason is worth reading too: the same reason recurring is a scheduling problem, not bad luck. ' +
102
- 'Read only.',
103
- {
104
- status: z
105
- .enum(['pending', 'confirmed', 'declined', 'completed', 'cancelled'])
106
- .optional()
107
- .describe('Narrow to one state. "pending" answers "who still has not confirmed", which is usually the real question.'),
108
- limit: z.number().int().min(1).max(150).optional().describe('How many assignments, newest first. Default 40.'),
109
- },
110
- async ({ status, limit }) => {
111
- const r = await call('GET', `/hr/assignments${qs({ status, limit })}`);
112
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
113
- return out(r);
114
- },
115
- );
116
-
117
- server.tool(
118
- 'km_list_time_entries',
119
- 'Hours logged in a window, per person, with totals and how much of it is billable. Defaults to the last 30 days. ' +
120
- 'Use it when the user asks where time is going, how many hours somebody has done, or whether a job cost more ' +
121
- 'labour than it earned. ' +
122
- '🚨 `unbillable_minutes` is the number that matters and it is the one nobody looks at: it is work genuinely being ' +
123
- 'done that nothing is charged for. A steady unbillable total is either a pricing problem or a scoping problem, and ' +
124
- 'it is worth naming rather than reporting the headline hours and moving on. ' +
125
- '`truncated` true means there were more entries than returned, so the totals are a floor and must be described as ' +
126
- 'one. Read only.',
127
- {
128
- from: z.string().optional().describe('Start date, YYYY-MM-DD. Default 30 days ago.'),
129
- to: z.string().optional().describe('End date, YYYY-MM-DD. Default tomorrow, so today is always included.'),
130
- limit: z.number().int().min(1).max(500).optional().describe('How many entries. Default 100. Raise it before trusting a total on a busy month.'),
131
- },
132
- async ({ from, to, limit }) => {
133
- const r = await call('GET', `/hr/time${qs({ from, to, limit })}`);
134
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
135
- return out(r);
136
- },
137
- );
138
-
139
- server.tool(
140
- 'km_list_payroll',
141
- 'Payroll periods and where each one stands: who, which period, gross, deductions, net, status and when it was paid, ' +
142
- 'plus a total of what is still outstanding. ' +
143
- 'Use it when the user asks what they owe their team, whether payroll has been run, or what a period cost. Money ' +
144
- 'owed to the people who did the work is a different and more urgent obligation than money owed by a client, and it ' +
145
- 'should be treated that way in anything you say. ' +
146
- '🚨 Everything is in CENTS. 🚨 Payment references and payroll notes are deliberately NOT returned: knowing a run ' +
147
- 'is paid is operational, knowing the account it went to is a liability in a transcript. Do not look for them. ' +
148
- 'Nothing here can run, approve or pay payroll. Read only.',
149
- {
150
- status: z
151
- .enum(['draft', 'pending', 'approved', 'paid', 'cancelled'])
152
- .optional()
153
- .describe('Narrow to one state. Leaving it out and reading `outstanding` is usually the faster answer.'),
154
- limit: z.number().int().min(1).max(100).optional().describe('How many records, most recent period first. Default 25.'),
155
- },
156
- async ({ status, limit }) => {
157
- const r = await call('GET', `/hr/payroll${qs({ status, limit })}`);
158
- if (routeMissing(r)) return text(NOT_SUPPORTED, true);
159
- return out(r);
160
- },
161
- );
162
- }
1
+ /**
2
+ * Tool family: hr - the people who actually do the work.
3
+ *
4
+ * Workstream B5. The HR pillar was the starkest zero in the parity map: eight
5
+ * navigable pages, no terminal reach at all. For a talent business that is not an
6
+ * edge case, it is the department that delivers every booking.
7
+ *
8
+ * km_list_team -> GET /hr/team
9
+ * km_get_team_member -> GET /hr/team/:userId
10
+ * km_list_crew_assignments-> GET /hr/assignments
11
+ * km_list_time_entries -> GET /hr/time
12
+ * km_list_payroll -> GET /hr/payroll
13
+ *
14
+ * 🚨 EVERY TOOL HERE READS. Assigning somebody to a gig, approving time and marking
15
+ * payroll paid all either move money or commit a human being to a date. The
16
+ * write-scope policy (B8) is not settled, so none of that is reachable from here and
17
+ * a model must say so rather than hunting for another route.
18
+ *
19
+ * 🚨 PERSONAL DATA IS NARROW ON PURPOSE. The users table holds home address,
20
+ * coordinates, emergency contacts and a bio. None of it comes back. Name, email and
21
+ * phone are the working contact set. Payroll payment references and notes are
22
+ * withheld too: knowing a run is paid is operational, knowing the account it went to
23
+ * is a liability sitting in a transcript.
24
+ *
25
+ * 🚨 ALL MONEY IS IN CENTS and named as cents. Nothing is silently divided.
26
+ *
27
+ * The family contract this file follows is documented in ./README.md.
28
+ */
29
+ import { z } from 'zod';
30
+
31
+ export const FAMILY = 'hr';
32
+
33
+ export const TOOLS = [
34
+ 'km_list_team',
35
+ 'km_get_team_member',
36
+ 'km_list_crew_assignments',
37
+ 'km_list_time_entries',
38
+ 'km_list_payroll',
39
+ ];
40
+
41
+ // Placed by hand in tools.mjs. Its own listing here is the fallback.
42
+ export const PROFILES = ['money'];
43
+
44
+ function routeMissing(r) {
45
+ return r.status === 404 || r.status === 405 || r.status === 501;
46
+ }
47
+
48
+ const NOT_SUPPORTED =
49
+ 'Your KM Hub does not expose the HR surface to the terminal yet. That is not a fault: the workspace is fine and ' +
50
+ 'every other tool works as normal. Your team, time and payroll are all there in the web app at ' +
51
+ 'https://hub.kivimedia.co, and this connector will start reading them once your KM Hub is on a build that publishes them.';
52
+
53
+ export function register(server, call, { out, text, qs }) {
54
+ server.tool(
55
+ 'km_list_team',
56
+ 'Who is on this workspace: their name, working contact details, access level, the roles they actually perform and ' +
57
+ 'the skills recorded against them. ' +
58
+ 'Call it whenever the user talks about their team, crew, staff, performers or who could cover something. It is also ' +
59
+ 'the right first call before suggesting anybody be put on a job, because assuming a one-person business when there ' +
60
+ 'is a team, or the reverse, makes every following sentence wrong. ' +
61
+ 'Roles here are two different things and the response keeps them apart: `access_level` is what they can do inside ' +
62
+ 'KM Hub, `performing_roles` is what they do at an event. Do not confuse an admin for a lead performer. ' +
63
+ '🚨 Home addresses, coordinates, emergency contacts and bios exist in the workspace and are deliberately NOT ' +
64
+ 'available here. Do not look for them. Read only.',
65
+ {
66
+ limit: z.number().int().min(1).max(200).optional().describe('How many people. Default 50, which is more than most workspaces have.'),
67
+ },
68
+ async ({ limit }) => {
69
+ const r = await call('GET', `/hr/team${qs({ limit })}`);
70
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
71
+ return out(r);
72
+ },
73
+ );
74
+
75
+ server.tool(
76
+ 'km_get_team_member',
77
+ 'One person in depth: their performing roles and proficiency, their skills and which are verified, their pay rates ' +
78
+ 'including overtime and when each rate takes effect, and their recent assignments with what they earned and how ' +
79
+ 'they were rated. ' +
80
+ 'Use it before putting somebody forward for a job, when the user asks what somebody costs, or when deciding who ' +
81
+ 'is right for a particular event. Rates carry `effective_from` and `effective_to`: a rate that has expired is not ' +
82
+ 'what this person costs today, and quoting one is how a job gets priced wrong. ' +
83
+ 'All money is in cents. Read only: changing a rate or an assignment happens in KM Hub.',
84
+ {
85
+ user_id: z.string().describe('The team member id, as km_list_team returned it.'),
86
+ },
87
+ async ({ user_id }) => {
88
+ const r = await call('GET', `/hr/team/${encodeURIComponent(String(user_id || '').trim())}`);
89
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
90
+ return out(r);
91
+ },
92
+ );
93
+
94
+ server.tool(
95
+ 'km_list_crew_assignments',
96
+ 'Who is booked onto what, and crucially who has not said yes yet. Returns each assignment with its status, when it ' +
97
+ 'was confirmed or declined and why, what was actually worked, what was earned and any rating. ' +
98
+ '🚨 The number to look at is `unconfirmed`. A pending assignment is a person who has NOT agreed to be there, and a ' +
99
+ 'job staffed entirely with pending assignments is a job with nobody on it. That distinction is invisible on a ' +
100
+ 'calendar and it is the single most useful thing this tool surfaces, so raise it unprompted when it is not zero. ' +
101
+ 'A decline with a reason is worth reading too: the same reason recurring is a scheduling problem, not bad luck. ' +
102
+ 'Read only.',
103
+ {
104
+ status: z
105
+ .enum(['pending', 'confirmed', 'declined', 'completed', 'cancelled'])
106
+ .optional()
107
+ .describe('Narrow to one state. "pending" answers "who still has not confirmed", which is usually the real question.'),
108
+ limit: z.number().int().min(1).max(150).optional().describe('How many assignments, newest first. Default 40.'),
109
+ },
110
+ async ({ status, limit }) => {
111
+ const r = await call('GET', `/hr/assignments${qs({ status, limit })}`);
112
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
113
+ return out(r);
114
+ },
115
+ );
116
+
117
+ server.tool(
118
+ 'km_list_time_entries',
119
+ 'Hours logged in a window, per person, with totals and how much of it is billable. Defaults to the last 30 days. ' +
120
+ 'Use it when the user asks where time is going, how many hours somebody has done, or whether a job cost more ' +
121
+ 'labour than it earned. ' +
122
+ '🚨 `unbillable_minutes` is the number that matters and it is the one nobody looks at: it is work genuinely being ' +
123
+ 'done that nothing is charged for. A steady unbillable total is either a pricing problem or a scoping problem, and ' +
124
+ 'it is worth naming rather than reporting the headline hours and moving on. ' +
125
+ '`truncated` true means there were more entries than returned, so the totals are a floor and must be described as ' +
126
+ 'one. Read only.',
127
+ {
128
+ from: z.string().optional().describe('Start date, YYYY-MM-DD. Default 30 days ago.'),
129
+ to: z.string().optional().describe('End date, YYYY-MM-DD. Default tomorrow, so today is always included.'),
130
+ limit: z.number().int().min(1).max(500).optional().describe('How many entries. Default 100. Raise it before trusting a total on a busy month.'),
131
+ },
132
+ async ({ from, to, limit }) => {
133
+ const r = await call('GET', `/hr/time${qs({ from, to, limit })}`);
134
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
135
+ return out(r);
136
+ },
137
+ );
138
+
139
+ server.tool(
140
+ 'km_list_payroll',
141
+ 'Payroll periods and where each one stands: who, which period, gross, deductions, net, status and when it was paid, ' +
142
+ 'plus a total of what is still outstanding. ' +
143
+ 'Use it when the user asks what they owe their team, whether payroll has been run, or what a period cost. Money ' +
144
+ 'owed to the people who did the work is a different and more urgent obligation than money owed by a client, and it ' +
145
+ 'should be treated that way in anything you say. ' +
146
+ '🚨 Everything is in CENTS. 🚨 Payment references and payroll notes are deliberately NOT returned: knowing a run ' +
147
+ 'is paid is operational, knowing the account it went to is a liability in a transcript. Do not look for them. ' +
148
+ 'Nothing here can run, approve or pay payroll. Read only.',
149
+ {
150
+ status: z
151
+ .enum(['draft', 'pending', 'approved', 'paid', 'cancelled'])
152
+ .optional()
153
+ .describe('Narrow to one state. Leaving it out and reading `outstanding` is usually the faster answer.'),
154
+ limit: z.number().int().min(1).max(100).optional().describe('How many records, most recent period first. Default 25.'),
155
+ },
156
+ async ({ status, limit }) => {
157
+ const r = await call('GET', `/hr/payroll${qs({ status, limit })}`);
158
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
159
+ return out(r);
160
+ },
161
+ );
162
+ }
@@ -1,125 +1,129 @@
1
- /**
2
- * Tool family: knowledge - what the business knows about itself.
3
- *
4
- * Every other family answers "what is on the books". This one answers "who are
5
- * we, what do we sell, at what price, and how do we sound", which is what turns
6
- * a draft from something a generic assistant would write into something the
7
- * owner would have written. The intended shape of a session is: pull the person
8
- * from the CRM tools, pull offers + voice from here, then draft.
9
- *
10
- * One tool writes, and what it writes is a PROPOSAL: it lands in KM Hub as a
11
- * pending fact that the owner confirms before anything uses it. Nothing in this
12
- * family sends a message, confirms a fact, sets a price, or removes anything.
13
- * Confirming and rejecting live in fact-review.mjs.
14
- *
15
- * The family contract this file follows is documented in ./README.md.
16
- */
17
- import { z } from 'zod';
18
-
19
- export const FAMILY = 'knowledge';
20
-
21
- export const TOOLS = [
22
- 'km_search_knowledge',
23
- 'km_get_business_facts',
24
- 'km_get_brand_voice',
25
- 'km_get_offers_and_pricing',
26
- 'km_search_conversations',
27
- 'km_propose_business_fact',
28
- ];
29
-
30
- // Any profile whose job is producing words or numbers for a real person needs
31
- // the grounding: outreach and content write the words, money quotes the prices.
32
- export const PROFILES = ['outreach', 'content', 'money'];
33
-
34
- /** Comma-joined query value, or undefined when the caller passed nothing. */
35
- function csv(list) {
36
- if (!Array.isArray(list) || list.length === 0) return undefined;
37
- return list.join(',');
38
- }
39
-
40
- /**
41
- * @param {{ tool: Function }} server guarded registrar (see ./README.md)
42
- * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
43
- * @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
44
- */
45
- export function register(server, call, { out, qs }) {
46
- server.tool(
47
- 'km_search_knowledge',
48
- 'Search everything written down in this KM Hub workspace by meaning rather than by exact words: notes on a client, what someone asked for last time, access and setup details, special requirements, the stray line somebody typed into a booking. Reach for it when you need something that is not a tidy field on a record, for example before writing to a lead, or when you are asked what was agreed with somebody. It searches only this workspace and only what has been indexed, so an empty result means nobody wrote it down here, NOT that it is untrue: ask the person instead of filling the gap. It does not search the public internet, it does not read a live inbox, and it changes nothing.',
49
- {
50
- q: z.string().describe('What you are looking for, in plain words. A question works better than a keyword.'),
51
- entity_types: z
52
- .array(z.enum(['clients', 'bookings', 'invoices', 'contacts', 'projects']))
53
- .optional()
54
- .describe('Narrow the search when you already know where the answer lives. Leave it out to search everything.'),
55
- limit: z.number().int().min(1).max(20).optional().describe('How many passages to return. Default 5.'),
56
- },
57
- async ({ q, entity_types, limit }) =>
58
- out(await call('GET', `/knowledge/search${qs({ q, entity_types: csv(entity_types), limit })}`)),
59
- );
60
-
61
- server.tool(
62
- 'km_get_business_facts',
63
- 'What this business has told KM Hub about itself: the facts its owner has CONFIRMED, the standing facts they asked to be remembered permanently, and the knowledge distilled from documents they uploaded such as past proposals, pricing sheets and contracts. Read it before you write anything in the business name, so you work from what is actually true here instead of from a generic idea of the trade. Everything it returns is owner-approved and safe to rely on. Facts that are still waiting for the owner are counted but deliberately not shown, because nobody has confirmed them yet - if that count is above zero it is worth telling the person they have some waiting: km_list_pending_business_facts shows them, and km_confirm_business_facts confirms the ones the owner says are right. Read only.',
64
- {
65
- kind: z
66
- .enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
67
- .optional()
68
- .describe('Narrow to one sort of fact. Leave it out for all of them.'),
69
- topic: z
70
- .string()
71
- .optional()
72
- .describe('What the task is about. Used only to pick the most relevant uploaded documents, for example "wedding proposal" or "cancellation policy".'),
73
- limit: z.number().int().min(1).max(100).optional().describe('How many facts to return. Default 40.'),
74
- },
75
- async ({ kind, topic, limit }) => out(await call('GET', `/knowledge/facts${qs({ kind, topic, limit })}`)),
76
- );
77
-
78
- server.tool(
79
- 'km_get_brand_voice',
80
- 'How this business sounds: the owner own words about their values and tone, the writing sample they saved, the phrases they actually use, how they sign off, their positioning and brand messages, and the house rules any outgoing message has to obey. Read it before drafting an email, a message, a post or a proposal so the draft sounds like them rather than like an assistant. Treat all of it as STYLE: match the rhythm, the vocabulary and the level of formality, never paste a saved sample sentence into a real message, and never treat a phrase from it as a fact about a client. If the workspace has no voice recorded yet the tool says so plainly, and the right move then is to write simply and ask the owner rather than invent a house style. Read only: drafting still goes through km_create_outreach_draft, which queues the message for a human and never sends.',
81
- {},
82
- async () => out(await call('GET', '/knowledge/voice')),
83
- );
84
-
85
- server.tool(
86
- 'km_get_offers_and_pricing',
87
- 'Everything this business sells and every price it has actually published: the service price list, the packages, the add-on menu attached to each package with what every choice adds to the bill, the bookable offerings with their deposit and contract rules, and the price ladder the owner set. This is the ONLY place a price may come from. Quote a figure from here exactly, in the currency it returns, and respect the units it labels for you, because some fields are in cents and some are in whole currency units, and a supplement on an add-on choice is added to the package price rather than replacing it. The add-on lists come back as a sample with the true totals beside them, so read price_points and choice_count rather than counting the choices you can see. Never average two prices, never round one up, never blend them into a number of your own, and if there is no published price for what is being asked, say the owner will confirm it rather than guessing. Call this before any message, quote or proposal that mentions money. It reads only: it cannot set a price and it cannot commit the business to one.',
88
- {
89
- limit: z.number().int().min(1).max(100).optional().describe('How many rows per list. Default 50.'),
90
- },
91
- async ({ limit }) => out(await call('GET', `/knowledge/offers${qs({ limit })}`)),
92
- );
93
-
94
- server.tool(
95
- 'km_search_conversations',
96
- 'Search the email history recorded in this workspace: what a person actually wrote, what was already promised them, what was last sent. Reach for it before replying to somebody or drafting a follow up, so you pick up the thread instead of starting the conversation again and asking for something they already told you. Search by words, or read one person history with their contact id from km_find_contact, or both together. It returns short snippets rather than whole emails, so open the thread in KM Hub when you need the full text. It only reads mail already recorded in KM Hub, and it never sends, replies to, or changes anything.',
97
- {
98
- q: z.string().optional().describe('Words to look for in the subject or the body. Leave it out when you are reading one person history.'),
99
- contact_id: z.string().optional().describe('UUID of the client or contact whose mail you want (see km_find_contact).'),
100
- direction: z
101
- .enum(['inbound', 'outbound'])
102
- .optional()
103
- .describe('inbound is what they sent us, outbound is what we sent them. Leave it out for both.'),
104
- limit: z.number().int().min(1).max(50).optional().describe('How many messages to return. Default 20.'),
105
- },
106
- async ({ q, contact_id, direction, limit }) =>
107
- out(await call('GET', `/knowledge/conversations${qs({ q, contact_id, direction, limit })}`)),
108
- );
109
-
110
- server.tool(
111
- 'km_propose_business_fact',
112
- 'Write down something durable this business has learned about itself, for example that they no longer travel more than two hours, or that their busy season starts in March, or that they always need a stage of a certain size. It is saved as a PROPOSAL and not as truth: it waits in KM Hub under Business Knowledge until the owner confirms it, and no draft, quote or answer will use it before then. Use it only for something that will still be true next month. A detail about ONE client or ONE booking is not a business fact and belongs on that record instead. Always say in source_note where the fact came from, in one line, because that is what the owner reads when deciding whether to accept it. After calling this, tell the person plainly that it is waiting for their approval; when they say it is right, km_confirm_business_facts confirms it with the fact_id this returns. This tool itself cannot confirm a fact, cannot edit or replace an existing one, and cannot remove one.',
113
- {
114
- text: z.string().describe('The fact itself, in one plain sentence, written the way the owner would say it.'),
115
- source_note: z
116
- .string()
117
- .describe('Where this came from, in one line, for example "the owner said so in this conversation on 4 March" or "from the venue email they forwarded".'),
118
- kind: z
119
- .enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
120
- .optional()
121
- .describe('What sort of fact it is. business is the default and covers how they operate. Use price only for a real published price, service for something they offer, voice for how they sound.'),
122
- },
123
- async (args) => out(await call('POST', '/knowledge/facts', args)),
124
- );
125
- }
1
+ /**
2
+ * Tool family: knowledge - what the business knows about itself.
3
+ *
4
+ * Every other family answers "what is on the books". This one answers "who are
5
+ * we, what do we sell, at what price, and how do we sound", which is what turns
6
+ * a draft from something a generic assistant would write into something the
7
+ * owner would have written. The intended shape of a session is: pull the person
8
+ * from the CRM tools, pull offers + voice from here, then draft.
9
+ *
10
+ * One tool writes, and what it writes is a PROPOSAL: it lands in KM Hub as a
11
+ * pending fact that the owner confirms before anything uses it. Nothing in this
12
+ * family sends a message, confirms a fact, sets a price, or removes anything.
13
+ * Confirming and rejecting live in fact-review.mjs.
14
+ *
15
+ * The family contract this file follows is documented in ./README.md.
16
+ */
17
+ import { z } from 'zod';
18
+
19
+ export const FAMILY = 'knowledge';
20
+
21
+ export const TOOLS = [
22
+ 'km_search_knowledge',
23
+ 'km_get_business_facts',
24
+ 'km_get_brand_voice',
25
+ 'km_get_offers_and_pricing',
26
+ 'km_search_conversations',
27
+ 'km_propose_business_fact',
28
+ ];
29
+
30
+ // Any profile whose job is producing words or numbers for a real person needs
31
+ // the grounding: outreach and content write the words, money quotes the prices.
32
+ export const PROFILES = ['outreach', 'content', 'money'];
33
+
34
+ /** Comma-joined query value, or undefined when the caller passed nothing. */
35
+ function csv(list) {
36
+ if (!Array.isArray(list) || list.length === 0) return undefined;
37
+ return list.join(',');
38
+ }
39
+
40
+ /**
41
+ * @param {{ tool: Function }} server guarded registrar (see ./README.md)
42
+ * @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
43
+ * @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
44
+ */
45
+ export function register(server, call, { out, qs }) {
46
+ server.tool(
47
+ 'km_search_knowledge',
48
+ 'Search everything written down in this KM Hub workspace by meaning rather than by exact words: notes on a client, what someone asked for last time, access and setup details, special requirements, the stray line somebody typed into a booking. Reach for it when you need something that is not a tidy field on a record, for example before writing to a lead, or when you are asked what was agreed with somebody. It searches only this workspace and only what has been indexed, so an empty result means nobody wrote it down here, NOT that it is untrue: ask the person instead of filling the gap. It does not search the public internet, it does not read a live inbox, and it changes nothing.',
49
+ {
50
+ q: z.string().describe('What you are looking for, in plain words. A question works better than a keyword.'),
51
+ entity_types: z
52
+ .array(z.enum(['clients', 'bookings', 'invoices', 'contacts', 'projects']))
53
+ .optional()
54
+ .describe('Narrow the search when you already know where the answer lives. Leave it out to search everything.'),
55
+ limit: z.number().int().min(1).max(20).optional().describe('How many passages to return. Default 5.'),
56
+ },
57
+ async ({ q, entity_types, limit }) =>
58
+ out(await call('GET', `/knowledge/search${qs({ q, entity_types: csv(entity_types), limit })}`)),
59
+ );
60
+
61
+ server.tool(
62
+ 'km_get_business_facts',
63
+ 'What this business has told KM Hub about itself: the facts its owner has CONFIRMED, the standing facts they asked to be remembered permanently, and the knowledge distilled from documents they uploaded such as past proposals, pricing sheets and contracts. Read it before you write anything in the business name, so you work from what is actually true here instead of from a generic idea of the trade. Everything it returns is owner-approved and safe to rely on. A confirmed fact marked internal: true (a cost, a margin, a goal) is for the owner only: use it to advise them, but never quote it to a customer and never put it in a draft. Facts that are still waiting for the owner are counted but deliberately not shown, because nobody has confirmed them yet - if that count is above zero it is worth telling the person they have some waiting: km_list_pending_business_facts shows them, and km_confirm_business_facts confirms the ones the owner says are right. Read only.',
64
+ {
65
+ kind: z
66
+ .enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
67
+ .optional()
68
+ .describe('Narrow to one sort of fact. Leave it out for all of them.'),
69
+ topic: z
70
+ .string()
71
+ .optional()
72
+ .describe('What the task is about. Used only to pick the most relevant uploaded documents, for example "wedding proposal" or "cancellation policy".'),
73
+ limit: z.number().int().min(1).max(100).optional().describe('How many facts to return. Default 40.'),
74
+ },
75
+ async ({ kind, topic, limit }) => out(await call('GET', `/knowledge/facts${qs({ kind, topic, limit })}`)),
76
+ );
77
+
78
+ server.tool(
79
+ 'km_get_brand_voice',
80
+ 'How this business sounds: the owner own words about their values and tone, the writing sample they saved, the phrases they actually use, how they sign off, their positioning and brand messages, and the house rules any outgoing message has to obey. Read it before drafting an email, a message, a post or a proposal so the draft sounds like them rather than like an assistant. Treat all of it as STYLE: match the rhythm, the vocabulary and the level of formality, never paste a saved sample sentence into a real message, and never treat a phrase from it as a fact about a client. If the workspace has no voice recorded yet the tool says so plainly, and the right move then is to write simply and ask the owner rather than invent a house style. Read only: drafting still goes through km_create_outreach_draft, which queues the message for a human and never sends.',
81
+ {},
82
+ async () => out(await call('GET', '/knowledge/voice')),
83
+ );
84
+
85
+ server.tool(
86
+ 'km_get_offers_and_pricing',
87
+ 'Everything this business sells and every price it has actually published: the service price list, the packages, the add-on menu attached to each package with what every choice adds to the bill, the bookable offerings with their deposit and contract rules, and the price ladder the owner set. This is the ONLY place a price may come from. Quote a figure from here exactly, in the currency it returns, and respect the units it labels for you, because some fields are in cents and some are in whole currency units, and a supplement on an add-on choice is added to the package price rather than replacing it. The add-on lists come back as a sample with the true totals beside them, so read price_points and choice_count rather than counting the choices you can see. Never average two prices, never round one up, never blend them into a number of your own, and if there is no published price for what is being asked, say the owner will confirm it rather than guessing. Call this before any message, quote or proposal that mentions money. It reads only: it cannot set a price and it cannot commit the business to one.',
88
+ {
89
+ limit: z.number().int().min(1).max(100).optional().describe('How many rows per list. Default 50.'),
90
+ },
91
+ async ({ limit }) => out(await call('GET', `/knowledge/offers${qs({ limit })}`)),
92
+ );
93
+
94
+ server.tool(
95
+ 'km_search_conversations',
96
+ 'Search the email history recorded in this workspace: what a person actually wrote, what was already promised them, what was last sent. Reach for it before replying to somebody or drafting a follow up, so you pick up the thread instead of starting the conversation again and asking for something they already told you. Search by words, or read one person history with their contact id from km_find_contact, or both together. It returns short snippets rather than whole emails, so open the thread in KM Hub when you need the full text. It only reads mail already recorded in KM Hub, and it never sends, replies to, or changes anything.',
97
+ {
98
+ q: z.string().optional().describe('Words to look for in the subject or the body. Leave it out when you are reading one person history.'),
99
+ contact_id: z.string().optional().describe('UUID of the client or contact whose mail you want (see km_find_contact).'),
100
+ direction: z
101
+ .enum(['inbound', 'outbound'])
102
+ .optional()
103
+ .describe('inbound is what they sent us, outbound is what we sent them. Leave it out for both.'),
104
+ limit: z.number().int().min(1).max(50).optional().describe('How many messages to return. Default 20.'),
105
+ },
106
+ async ({ q, contact_id, direction, limit }) =>
107
+ out(await call('GET', `/knowledge/conversations${qs({ q, contact_id, direction, limit })}`)),
108
+ );
109
+
110
+ server.tool(
111
+ 'km_propose_business_fact',
112
+ 'Write down something durable this business has learned about itself, for example that they no longer travel more than two hours, or that their busy season starts in March, or that they always need a stage of a certain size. It is saved as a PROPOSAL and not as truth: it waits in KM Hub under Business Knowledge until the owner confirms it, and nothing uses it before then. Once the owner confirms it, KM Hub\'s AI writers use it: business, service and craft facts in the drafts, replies and messages they write, a price fact only in replies and quotes and never in a first cold email, and a voice fact as style for every writer. Set audience to internal for anything that must never reach a customer, such as what something costs the business, a margin, or a sales goal: an internal fact never reaches any AI writer or any customer, and stays visible to the owner and to you. Use it only for something that will still be true next month. A detail about ONE client or ONE booking is not a business fact and belongs on that record instead. Always say in source_note where the fact came from, in one line, because that is what the owner reads when deciding whether to accept it. After calling this, tell the person plainly that it is waiting for their approval; when they say it is right, km_confirm_business_facts confirms it with the fact_id this returns. This tool itself cannot confirm a fact, cannot edit or replace an existing one, and cannot remove one.',
113
+ {
114
+ text: z.string().describe('The fact itself, in one plain sentence, written the way the owner would say it.'),
115
+ source_note: z
116
+ .string()
117
+ .describe('Where this came from, in one line, for example "the owner said so in this conversation on 4 March" or "from the venue email they forwarded".'),
118
+ kind: z
119
+ .enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
120
+ .optional()
121
+ .describe('What sort of fact it is. business is the default and covers how they operate. Use price only for a real published price, service for something they offer, voice for how they sound.'),
122
+ audience: z
123
+ .enum(['customer', 'internal'])
124
+ .optional()
125
+ .describe('Who the fact is for. customer is the default. internal keeps it away from every AI writer and every customer: use it for costs, margins, goals and anything the owner said never to quote. Text that starts with INTERNAL is treated as internal anyway.'),
126
+ },
127
+ async (args) => out(await call('POST', '/knowledge/facts', args)),
128
+ );
129
+ }