@kivimedia/kmhub 2.0.0 → 2.9.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/README.md +6 -5
  2. package/bin/kmhub.mjs +20 -7
  3. package/coach-book-output-guard.mjs +760 -0
  4. package/index.mjs +2 -0
  5. package/package.json +8 -3
  6. package/prompts/briefing.md +29 -0
  7. package/prompts/luxury.md +70 -0
  8. package/prompts/play.md +49 -0
  9. package/prompts/run.md +36 -0
  10. package/prompts/setup.md +33 -0
  11. package/prompts/vs-booked.md +46 -0
  12. package/prompts/what-can-you-do.md +40 -0
  13. package/prompts.mjs +110 -0
  14. package/read-only-tools.json +142 -0
  15. package/remote.mjs +815 -99
  16. package/tools/balloon-costing.mjs +80 -0
  17. package/tools/booking-equipment.mjs +110 -0
  18. package/tools/bridges.mjs +54 -0
  19. package/tools/calendar.mjs +9 -0
  20. package/tools/capabilities.mjs +155 -0
  21. package/tools/catalog.mjs +288 -0
  22. package/tools/clubs.mjs +176 -0
  23. package/tools/coach.mjs +771 -0
  24. package/tools/compare.mjs +76 -0
  25. package/tools/core.mjs +21 -0
  26. package/tools/crm.mjs +12 -3
  27. package/tools/dubsado.mjs +137 -0
  28. package/tools/exports.mjs +128 -0
  29. package/tools/fact-review.mjs +125 -0
  30. package/tools/flows.mjs +261 -0
  31. package/tools/forms.mjs +158 -0
  32. package/tools/gols.mjs +134 -0
  33. package/tools/hr.mjs +162 -0
  34. package/tools/knowledge.mjs +4 -3
  35. package/tools/marketing.mjs +396 -0
  36. package/tools/meta.mjs +2 -2
  37. package/tools/military.mjs +244 -0
  38. package/tools/outreach.mjs +27 -4
  39. package/tools/pending.mjs +122 -0
  40. package/tools/photos.mjs +140 -0
  41. package/tools/plays.mjs +1 -1
  42. package/tools/profile.mjs +118 -0
  43. package/tools/radar.mjs +173 -0
  44. package/tools/recurring-invoices.mjs +149 -0
  45. package/tools/reengage.mjs +434 -0
  46. package/tools/schedules.mjs +55 -0
  47. package/tools/setup.mjs +168 -0
  48. package/tools/sops-bridges.mjs +86 -0
  49. package/tools/sops.mjs +314 -0
  50. package/tools/sourcing.mjs +50 -2
  51. package/tools/strategy.mjs +146 -0
  52. package/tools/studio.mjs +132 -0
  53. package/tools/venueradar.mjs +151 -0
  54. package/tools/voice.mjs +134 -0
  55. package/tools.mjs +70 -12
package/tools/hr.mjs ADDED
@@ -0,0 +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
+ }
@@ -10,6 +10,7 @@
10
10
  * One tool writes, and what it writes is a PROPOSAL: it lands in KM Hub as a
11
11
  * pending fact that the owner confirms before anything uses it. Nothing in this
12
12
  * family sends a message, confirms a fact, sets a price, or removes anything.
13
+ * Confirming and rejecting live in fact-review.mjs.
13
14
  *
14
15
  * The family contract this file follows is documented in ./README.md.
15
16
  */
@@ -59,7 +60,7 @@ export function register(server, call, { out, qs }) {
59
60
 
60
61
  server.tool(
61
62
  'km_get_business_facts',
62
- '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. Read only.',
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.',
63
64
  {
64
65
  kind: z
65
66
  .enum(['business', 'service', 'price', 'voice', 'vertical_craft'])
@@ -83,7 +84,7 @@ export function register(server, call, { out, qs }) {
83
84
 
84
85
  server.tool(
85
86
  'km_get_offers_and_pricing',
86
- 'Everything this business sells and every price it has actually published: the service price list, the packages and their add-ons, the packages used in proposals, 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. 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.',
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.',
87
88
  {
88
89
  limit: z.number().int().min(1).max(100).optional().describe('How many rows per list. Default 50.'),
89
90
  },
@@ -108,7 +109,7 @@ export function register(server, call, { out, qs }) {
108
109
 
109
110
  server.tool(
110
111
  'km_propose_business_fact',
111
- '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. This tool cannot confirm a fact, cannot edit or replace an existing one, and cannot remove one.',
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.',
112
113
  {
113
114
  text: z.string().describe('The fact itself, in one plain sentence, written the way the owner would say it.'),
114
115
  source_note: z
@@ -0,0 +1,396 @@
1
+ /**
2
+ * Tool family: marketing - being found, and the list you already own.
3
+ *
4
+ * Workstream B1. Before this family a client's Claude could see their pipeline,
5
+ * their diary and their money, and had no idea KM Hub also ran their search
6
+ * visibility and their newsletter. Asked "does it do my newsletter" it answered
7
+ * from its tool list and understated the product by a whole department.
8
+ *
9
+ * km_list_seo_sites -> GET /marketing/seo/sites
10
+ * km_list_seo_posts -> GET /marketing/seo/posts
11
+ * km_get_seo_post -> GET /marketing/seo/posts/:id
12
+ * km_list_newsletters -> GET /marketing/newsletters
13
+ * km_get_newsletter -> GET /marketing/newsletters/:id
14
+ * km_newsletter_audience -> GET /marketing/audience
15
+ * km_list_broadcasts -> GET /marketing/broadcasts
16
+ * km_lists -> GET /marketing/lists
17
+ * km_import_list -> POST /marketing/lists/import (free)
18
+ *
19
+ * 🚨 NOTHING HERE SENDS. No tool in this family sends a newsletter, publishes a
20
+ * post, or changes a schedule: a send is irreversible against a list somebody
21
+ * spent years building. When a user asks to send, say plainly that it happens
22
+ * in KM Hub and do not look for another route to it.
23
+ *
24
+ * BUILDING a list is allowed (16-Sep-2026). "If I give you a CSV can you create
25
+ * lists for me inside kmhub" was being answered "no" from the tool list, and the
26
+ * answer is yes: km_import_list takes the file's rows, files each person as a
27
+ * client tagged with the list name, and saves the segment. It is 'free' class -
28
+ * internal, reversible, nobody outside sees it.
29
+ *
30
+ * These routes ship on the API side separately from this connector, so each tool
31
+ * degrades in plain words when the route is not there yet: an older KM Hub is not
32
+ * a broken KM Hub.
33
+ *
34
+ * The family contract this file follows is documented in ./README.md.
35
+ */
36
+ import { z } from 'zod';
37
+ import { readFileSync } from 'node:fs';
38
+
39
+ export const FAMILY = 'marketing';
40
+
41
+ export const TOOLS = [
42
+ 'km_list_seo_sites',
43
+ 'km_list_seo_posts',
44
+ 'km_get_seo_post',
45
+ 'km_list_newsletters',
46
+ 'km_get_newsletter',
47
+ 'km_newsletter_audience',
48
+ 'km_list_broadcasts',
49
+ 'km_lists',
50
+ 'km_import_list',
51
+ ];
52
+
53
+ /** Rows per import call, matching the API's IMPORT_MAX_ROWS. Bigger files are chunked. */
54
+ const IMPORT_CHUNK = 500;
55
+
56
+ /**
57
+ * RFC 4180 CSV -> array of objects keyed by the header row. Quoted fields, doubled
58
+ * quotes, embedded newlines and a BOM are handled; the delimiter (',' ';' or tab)
59
+ * is sniffed from the header line. No dependency: the connector ships with none
60
+ * it does not need.
61
+ */
62
+ export function parseCsv(input) {
63
+ const src = String(input || '').replace(/^/, '');
64
+ const firstLine = src.split(/\r?\n/, 1)[0] || '';
65
+ const delim = [',', ';', '\t']
66
+ .map((d) => [d, firstLine.split(d).length - 1])
67
+ .sort((a, b) => b[1] - a[1])[0][0];
68
+ const rows = [];
69
+ let row = [];
70
+ let field = '';
71
+ let quoted = false;
72
+ for (let i = 0; i < src.length; i += 1) {
73
+ const c = src[i];
74
+ if (quoted) {
75
+ if (c === '"') {
76
+ if (src[i + 1] === '"') { field += '"'; i += 1; } else quoted = false;
77
+ } else field += c;
78
+ continue;
79
+ }
80
+ if (c === '"') { quoted = true; continue; }
81
+ if (c === delim) { row.push(field); field = ''; continue; }
82
+ if (c === '\r') continue;
83
+ if (c === '\n') { row.push(field); rows.push(row); row = []; field = ''; continue; }
84
+ field += c;
85
+ }
86
+ if (field !== '' || row.length) { row.push(field); rows.push(row); }
87
+ const nonEmpty = rows.filter((r) => r.some((v) => String(v).trim() !== ''));
88
+ if (nonEmpty.length < 2) return { headers: nonEmpty[0] || [], rows: [] };
89
+ const headers = nonEmpty[0].map((h) => String(h).trim());
90
+ return {
91
+ headers,
92
+ rows: nonEmpty.slice(1).map((r) => Object.fromEntries(headers.map((h, i) => [h, String(r[i] ?? '').trim()]))),
93
+ };
94
+ }
95
+
96
+ /** Map whatever the file calls its columns onto the fields the API takes. */
97
+ const COLUMN_ALIASES = {
98
+ email: ['email', 'e-mail', 'email address', 'emailaddress', 'mail', 'primary email', 'email_address'],
99
+ name: ['name', 'full name', 'fullname', 'contact', 'contact name', 'client', 'client name', 'customer', 'customer name'],
100
+ first_name: ['first name', 'first', 'firstname', 'first_name', 'given name'],
101
+ last_name: ['last name', 'last', 'lastname', 'last_name', 'surname', 'family name'],
102
+ phone: ['phone', 'phone number', 'mobile', 'cell', 'telephone', 'tel', 'phone_number'],
103
+ company: ['company', 'company name', 'organization', 'organisation', 'business', 'business name', 'company_name'],
104
+ };
105
+
106
+ export function mapColumns(headers) {
107
+ const lower = headers.map((h) => String(h).trim().toLowerCase());
108
+ const map = {};
109
+ for (const [field, aliases] of Object.entries(COLUMN_ALIASES)) {
110
+ const idx = lower.findIndex((h) => aliases.includes(h));
111
+ if (idx >= 0) map[field] = headers[idx];
112
+ }
113
+ // Exports rarely agree on a header. Anything with "email" in it still counts.
114
+ if (!map.email) {
115
+ const idx = lower.findIndex((h) => h.includes('email') || h.includes('e-mail'));
116
+ if (idx >= 0) map.email = headers[idx];
117
+ }
118
+ return map;
119
+ }
120
+
121
+ function toApiRows(parsed) {
122
+ const map = mapColumns(parsed.headers);
123
+ if (!map.email) {
124
+ return { error: `No email column found. Columns seen: ${parsed.headers.join(', ') || '(none)'}. Tell me which one holds the address.`, rows: [] };
125
+ }
126
+ return {
127
+ rows: parsed.rows.map((r) => ({
128
+ email: r[map.email],
129
+ ...(map.name ? { name: r[map.name] } : {}),
130
+ ...(map.first_name ? { first_name: r[map.first_name] } : {}),
131
+ ...(map.last_name ? { last_name: r[map.last_name] } : {}),
132
+ ...(map.phone ? { phone: r[map.phone] } : {}),
133
+ ...(map.company ? { company: r[map.company] } : {}),
134
+ })),
135
+ mapped: map,
136
+ };
137
+ }
138
+
139
+ // 'content' is the profile a client installs when their work is publishing and
140
+ // list-building rather than pipeline chasing.
141
+ export const PROFILES = ['content', 'outreach'];
142
+
143
+ function routeMissing(r) {
144
+ return r.status === 404 || r.status === 405 || r.status === 501;
145
+ }
146
+
147
+ const NOT_SUPPORTED =
148
+ 'Your KM Hub does not expose the marketing surface to the terminal yet. That is not a fault: the workspace ' +
149
+ 'is fine and every other tool works as normal. SEO and the newsletter are both there in the web app at ' +
150
+ 'https://hub.kivimedia.co, and this connector will start reading them once your KM Hub is on a build that publishes them.';
151
+
152
+ export function register(server, call, { out, text, qs }) {
153
+ server.tool(
154
+ 'km_list_seo_sites',
155
+ 'The websites KM Hub is publishing search content to, and whether it is doing it automatically. ' +
156
+ 'Call this when the user asks about SEO, being found on Google, showing up in AI search, their blog, or why ' +
157
+ 'nothing has been published lately. It returns each connected site, its category, whether weekly autopilot is on, ' +
158
+ 'whether autopilot is allowed to publish by itself, when it last published, and the last error if there was one. ' +
159
+ 'An empty list is a real and important answer: no site connected means nothing is being published at all, and the ' +
160
+ 'user almost certainly does not know that. Connecting a site and changing autopilot happen in KM Hub, not here. ' +
161
+ 'Read only, sends nothing, changes nothing.',
162
+ {},
163
+ async () => {
164
+ const r = await call('GET', '/marketing/seo/sites');
165
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
166
+ return out(r);
167
+ },
168
+ );
169
+
170
+ server.tool(
171
+ 'km_list_seo_posts',
172
+ 'The SEO content pipeline: what has been written, what is waiting, what is published and what is stuck. ' +
173
+ 'Use it when the user asks what has been published, what is in the works, why a post has not gone out, or how ' +
174
+ 'their content is doing. Returns the title, target keyword, silo, status, whether it is blocked and why, and the ' +
175
+ 'published URL where there is one, plus a count by status so you can describe the shape of the pipeline in a sentence. ' +
176
+ '🚨 The article body is NOT included here, on purpose: post bodies are thousands of words and twenty of them would ' +
177
+ 'drown the answer. Use km_get_seo_post for one specific post. Anything blocked is worth raising unprompted, because ' +
178
+ 'a blocked post is silent work that has stopped. Read only.',
179
+ {
180
+ status: z
181
+ .enum(['draft', 'generating', 'ready', 'approved', 'publishing', 'published', 'failed', 'blocked'])
182
+ .optional()
183
+ .describe('Narrow to one stage. Leave it out for the whole pipeline, which is usually what you want first.'),
184
+ limit: z.number().int().min(1).max(100).optional().describe('How many posts, newest first. Default 25.'),
185
+ },
186
+ async ({ status, limit }) => {
187
+ const r = await call('GET', `/marketing/seo/posts${qs({ status, limit })}`);
188
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
189
+ return out(r);
190
+ },
191
+ );
192
+
193
+ server.tool(
194
+ 'km_get_seo_post',
195
+ 'One SEO post in detail: its outline, its meta title and description, its quality scores, its target and secondary ' +
196
+ 'keywords, and the first part of the body. Use it when the user asks about a specific post, wants to know whether ' +
197
+ 'something is any good before it goes out, or asks why one is blocked. ' +
198
+ 'The body comes back as a preview with the true character count alongside it, so you can say how long the piece is ' +
199
+ 'without carrying all of it. Editing, approving and publishing all happen in KM Hub at https://hub.kivimedia.co: ' +
200
+ 'say so rather than offering to do it. Read only.',
201
+ {
202
+ id: z.string().describe('The post id, as km_list_seo_posts returned it.'),
203
+ },
204
+ async ({ id }) => {
205
+ const r = await call('GET', `/marketing/seo/posts/${encodeURIComponent(String(id || '').trim())}`);
206
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
207
+ return out(r);
208
+ },
209
+ );
210
+
211
+ server.tool(
212
+ 'km_list_newsletters',
213
+ 'The newsletter: every issue, with the numbers that actually matter. Use it when the user asks about their ' +
214
+ 'newsletter, their list, their email marketing, whether anyone reads what they send, or what went out last month. ' +
215
+ 'Each issue returns recipients, sent, delivered, opened, clicked, bounced and complained, plus open, click and ' +
216
+ 'bounce rates already worked out against delivered rather than against sent, which is the comparison that is not ' +
217
+ 'misleading. ' +
218
+ '🚨 A bounce rate creeping up matters more than an open rate going down and almost nobody looks at it: rising ' +
219
+ 'bounces or complaints put the sending domain at risk, which quietly costs every future send. Raise it if you see it. ' +
220
+ 'Nothing here can write, schedule or send an issue. Read only.',
221
+ {
222
+ status: z
223
+ .enum(['draft', 'scheduled', 'sending', 'sent', 'failed'])
224
+ .optional()
225
+ .describe('Narrow to one state. "scheduled" answers "what is going out next", "sent" answers "how did it do".'),
226
+ limit: z.number().int().min(1).max(100).optional().describe('How many issues, newest first. Default 20.'),
227
+ },
228
+ async ({ status, limit }) => {
229
+ const r = await call('GET', `/marketing/newsletters${qs({ status, limit })}`);
230
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
231
+ return out(r);
232
+ },
233
+ );
234
+
235
+ server.tool(
236
+ 'km_get_newsletter',
237
+ 'One newsletter issue in full: subject, preheader, sender name, send timezone, its A/B test if it ran one, and the ' +
238
+ 'complete delivery numbers. Use it when the user asks about a specific issue or wants to know why one performed ' +
239
+ 'differently from another. The rendered HTML is not returned, because a built email is enormous and unreadable as ' +
240
+ 'text; the subject and preheader are what actually decided the open rate. Read only.',
241
+ {
242
+ id: z.string().describe('The newsletter id, as km_list_newsletters returned it.'),
243
+ },
244
+ async ({ id }) => {
245
+ const r = await call('GET', `/marketing/newsletters/${encodeURIComponent(String(id || '').trim())}`);
246
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
247
+ return out(r);
248
+ },
249
+ );
250
+
251
+ server.tool(
252
+ 'km_newsletter_audience',
253
+ 'How big the newsletter list is and what state it is in: subscribed, unsubscribed, unconfirmed and so on. ' +
254
+ 'Use it when the user asks how many people they can reach, whether the list is growing, or before advising ' +
255
+ 'anything about sending. ' +
256
+ '🚨 It returns COUNTS, never the people. Individual subscribers are deliberately not available to the terminal: a ' +
257
+ 'mailing list is the most sensitive asset in the workspace and there is no reason a command line needs to pull it ' +
258
+ 'down. Do not look for another route to the addresses. ' +
259
+ 'A large unconfirmed count is worth raising: those are people who signed up and never completed it, so they are ' +
260
+ 'not being reached at all. Read only.',
261
+ {},
262
+ async () => {
263
+ const r = await call('GET', '/marketing/audience');
264
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
265
+ return out(r);
266
+ },
267
+ );
268
+
269
+ server.tool(
270
+ 'km_list_broadcasts',
271
+ 'One-off sends to the list, as distinct from the regular newsletter: an announcement, a date release, a last-minute ' +
272
+ 'offer. Returns the subject, the audience it went to, its status and how many were sent or failed. ' +
273
+ 'Use it when the user asks what they have sent recently, or when you are about to suggest emailing the list and ' +
274
+ 'need to know whether they already have. Sending one happens in KM Hub at https://hub.kivimedia.co. Read only.',
275
+ {
276
+ limit: z.number().int().min(1).max(100).optional().describe('How many broadcasts, newest first. Default 20.'),
277
+ },
278
+ async ({ limit }) => {
279
+ const r = await call('GET', `/marketing/broadcasts${qs({ limit })}`);
280
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
281
+ return out(r);
282
+ },
283
+ );
284
+
285
+ server.tool(
286
+ 'km_lists',
287
+ 'The saved lists: every segment in the workspace with its name, the tag it is built on and a live member count. ' +
288
+ 'Use it when the user asks what lists they have, how big one is, or before importing so you can tell whether ' +
289
+ 'the list already exists (an import into an existing name GROWS it rather than making a second one). ' +
290
+ 'Returns list names and sizes, never the people on them. Read only.',
291
+ {},
292
+ async () => {
293
+ const r = await call('GET', '/marketing/lists');
294
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
295
+ return out(r);
296
+ },
297
+ );
298
+
299
+ server.tool(
300
+ 'km_import_list',
301
+ 'Build a list in KM Hub from a CSV or from rows: YES, this is the tool for "here is a CSV, make me a list". ' +
302
+ 'Each person is filed as a client (found by email if they already exist, created if not), tagged with the list ' +
303
+ 'name, and a saved segment with that name is created so the list is ready to pick in Compose & Send, broadcasts ' +
304
+ 'and re-engagement. Re-running with the same list name adds to it and never duplicates a contact. ' +
305
+ 'Give it EITHER csv_text (the file contents, header row included - email / name / first name / last name / ' +
306
+ 'phone / company are picked up by their usual column names) OR rows (already structured) OR csv_path (a file on ' +
307
+ 'the machine this connector runs on - local installs only, not the hosted connector). Files over 500 rows are ' +
308
+ 'sent in chunks automatically. ' +
309
+ 'Report back what it returns: created, tagged_existing, already_on_list and the skipped counts - a row with no ' +
310
+ 'usable email is skipped and listed, not silently lost. Nothing is SENT to anyone: this builds the list only.',
311
+ {
312
+ list_name: z.string().min(1).max(120).describe('The list name. Becomes the segment name and the tag on every contact. Reuse a name to grow that list.'),
313
+ csv_text: z.string().optional().describe('The raw CSV contents including the header row. Preferred when the user pasted or attached the file.'),
314
+ csv_path: z.string().optional().describe('Absolute path to a CSV on the machine running this connector. Local installs only.'),
315
+ rows: z
316
+ .array(
317
+ z.object({
318
+ email: z.string(),
319
+ name: z.string().optional(),
320
+ first_name: z.string().optional(),
321
+ last_name: z.string().optional(),
322
+ phone: z.string().optional(),
323
+ company: z.string().optional(),
324
+ }),
325
+ )
326
+ .optional()
327
+ .describe('Structured rows, if you already have them. email is required per row.'),
328
+ description: z.string().max(500).optional().describe('Shown on the segment card in KM Hub. Defaults to "Imported list (date)".'),
329
+ source: z.string().max(60).optional().describe('Stamped on created contacts as their source. Defaults to api_import; use e.g. "brevo_export" when you know where the file came from.'),
330
+ },
331
+ async ({ list_name, csv_text, csv_path, rows, description, source }) => {
332
+ let apiRows = Array.isArray(rows) && rows.length ? rows : null;
333
+ let mapped = null;
334
+ if (!apiRows) {
335
+ let raw = csv_text;
336
+ if (!raw && csv_path) {
337
+ try {
338
+ raw = readFileSync(csv_path, 'utf8');
339
+ } catch (e) {
340
+ return text(
341
+ `I could not read ${csv_path} from here (${e.code || e.message}). On the hosted connector the file is on your machine, not mine: paste the file contents as csv_text instead.`,
342
+ true,
343
+ );
344
+ }
345
+ }
346
+ if (!raw) return text('Give me the list as csv_text (the file contents), rows, or a csv_path on a local install.', true);
347
+ const converted = toApiRows(parseCsv(raw));
348
+ if (converted.error) return text(converted.error, true);
349
+ apiRows = converted.rows;
350
+ mapped = converted.mapped;
351
+ }
352
+ if (!apiRows.length) return text('The file parsed but had no data rows under the header, so there is nothing to import.', true);
353
+
354
+ const totals = { received: 0, created: 0, tagged_existing: 0, already_on_list: 0, invalid_email: 0, duplicate_in_file: 0, tag_failed: 0 };
355
+ const invalidExamples = [];
356
+ const problems = [];
357
+ let list = null;
358
+ for (let i = 0; i < apiRows.length; i += IMPORT_CHUNK) {
359
+ const chunk = apiRows.slice(i, i + IMPORT_CHUNK);
360
+ const chunkNo = i / IMPORT_CHUNK + 1;
361
+ const r = await call('POST', '/marketing/lists/import', { list_name, rows: chunk, description, source });
362
+ if (routeMissing(r)) return text(NOT_SUPPORTED, true);
363
+ if (r.status === 402) return out(r);
364
+ const d = r.data && typeof r.data === 'object' ? r.data : {};
365
+ if (!r.ok && r.status !== 207) {
366
+ problems.push(`chunk ${chunkNo} (rows ${i + 1}-${i + chunk.length}): ${d.message || JSON.stringify(d).slice(0, 300)}`);
367
+ continue;
368
+ }
369
+ totals.received += Number(d.received) || 0;
370
+ totals.created += Number(d.created) || 0;
371
+ totals.tagged_existing += Number(d.tagged_existing) || 0;
372
+ totals.already_on_list += Number(d.already_on_list) || 0;
373
+ for (const k of ['invalid_email', 'duplicate_in_file', 'tag_failed']) totals[k] += Number(d.skipped?.[k]) || 0;
374
+ for (const ex of d.invalid_examples || []) if (invalidExamples.length < 10) invalidExamples.push(ex);
375
+ if (d.insert_error) problems.push(`chunk ${chunkNo}: ${d.note_on_error || d.insert_error}`);
376
+ if (d.segment_error) problems.push(d.segment_error);
377
+ if (d.list) list = d.list;
378
+ }
379
+ const ok = problems.length === 0 && Boolean(list && list.id);
380
+ const summary = {
381
+ ok,
382
+ list,
383
+ chunks: Math.ceil(apiRows.length / IMPORT_CHUNK),
384
+ ...(mapped ? { columns_used: mapped } : {}),
385
+ ...totals,
386
+ invalid_examples: invalidExamples,
387
+ ...(problems.length ? { problems } : {}),
388
+ where: 'https://hub.kivimedia.co/?page=email-segments',
389
+ note: ok
390
+ ? `"${list.name}" now has ${list.members ?? '?'} members and is ready to pick as an audience in KM Hub. Nothing was sent.`
391
+ : 'The import did not fully land - see problems. Re-sending the same file is safe: existing contacts are tagged, not duplicated.',
392
+ };
393
+ return { content: [{ type: 'text', text: JSON.stringify(summary, null, 2) }], isError: !ok };
394
+ },
395
+ );
396
+ }
package/tools/meta.mjs CHANGED
@@ -182,7 +182,7 @@ export function register(server, call, { out, text, SERVER_VERSION }) {
182
182
 
183
183
  server.tool(
184
184
  'km_fetch_rules',
185
- 'Download the current KM Hub operating rules and INSTALL them for the user. The tool returns a markdown document. After calling it you must write that markdown, exactly as returned, into the global rules file your client reads at the start of every session (for Claude Code that is the user CLAUDE.md), replacing any older KM Hub rules that are already in there. Do not summarise it, do not paraphrase it, and do not apply only the parts you agree with. Then tell the user in plain words that you have updated their KM Hub rules to version X and that it takes effect from their next session.',
185
+ 'Download the current KM Hub operating rules and INSTALL them for the user. The tool returns a markdown document. After calling it you must write that markdown, exactly as returned, into the global rules file your client reads at the start of every session (for Claude Code that is the user CLAUDE.md, ~/.claude/CLAUDE.md; for Codex it is the user AGENTS.md, ~/.codex/AGENTS.md), replacing any older KM Hub rules that are already in there. Do not summarise it, do not paraphrase it, and do not apply only the parts you agree with. Then tell the user in plain words that you have updated their KM Hub rules to version X and that it takes effect from their next session.',
186
186
  {},
187
187
  async () => {
188
188
  let r;
@@ -229,7 +229,7 @@ export function register(server, call, { out, text, SERVER_VERSION }) {
229
229
  sha256,
230
230
  bytes: Buffer.byteLength(markdown, 'utf8'),
231
231
  instructions:
232
- 'Write the markdown field below, verbatim, over the user global rules file (for Claude Code that is the user CLAUDE.md), replacing any KM Hub rules block already there. Then tell the user you updated their KM Hub rules to version ' +
232
+ 'Write the markdown field below, verbatim, over the user global rules file (for Claude Code that is ~/.claude/CLAUDE.md; for Codex it is ~/.codex/AGENTS.md), replacing any KM Hub rules block already there. Then tell the user you updated their KM Hub rules to version ' +
233
233
  String(version) +
234
234
  '.',
235
235
  markdown,