@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.
- package/README.md +170 -170
- package/bin/kmhub.mjs +896 -896
- package/coach-book-output-guard.mjs +760 -760
- package/index.mjs +57 -57
- package/package.json +56 -56
- package/prompts/briefing.md +29 -29
- package/prompts/luxury.md +70 -70
- package/prompts/play.md +49 -49
- package/prompts/run.md +37 -36
- package/prompts/setup.md +33 -33
- package/prompts/vs-booked.md +46 -46
- package/prompts/what-can-you-do.md +40 -40
- package/prompts.mjs +110 -110
- package/read-only-tools.json +143 -142
- package/remote.mjs +929 -929
- package/tools/balloon-costing.mjs +80 -80
- package/tools/booking-equipment.mjs +110 -110
- package/tools/bridges.mjs +54 -54
- package/tools/briefing.mjs +91 -91
- package/tools/calendar.mjs +170 -170
- package/tools/capabilities.mjs +155 -155
- package/tools/catalog.mjs +288 -288
- package/tools/clubs.mjs +176 -176
- package/tools/coach.mjs +771 -771
- package/tools/compare.mjs +76 -76
- package/tools/core.mjs +244 -244
- package/tools/crm.mjs +209 -209
- package/tools/dubsado.mjs +137 -137
- package/tools/exports.mjs +128 -128
- package/tools/fact-review.mjs +125 -125
- package/tools/flows.mjs +261 -261
- package/tools/forms.mjs +158 -158
- package/tools/gols.mjs +144 -134
- package/tools/hr.mjs +162 -162
- package/tools/knowledge.mjs +129 -125
- package/tools/marketing.mjs +396 -396
- package/tools/meta.mjs +245 -245
- package/tools/military.mjs +244 -244
- package/tools/money.mjs +235 -197
- package/tools/outreach.mjs +238 -238
- package/tools/pending.mjs +122 -122
- package/tools/photos.mjs +140 -140
- package/tools/plays.mjs +244 -244
- package/tools/profile.mjs +118 -118
- package/tools/radar.mjs +173 -173
- package/tools/recurring-invoices.mjs +149 -149
- package/tools/reengage.mjs +434 -434
- package/tools/schedules.mjs +55 -55
- package/tools/setup.mjs +168 -168
- package/tools/sops-bridges.mjs +86 -86
- package/tools/sops.mjs +314 -314
- package/tools/sourcing.mjs +268 -268
- package/tools/strategy.mjs +146 -146
- package/tools/studio.mjs +132 -132
- package/tools/venueradar.mjs +151 -151
- package/tools/voice.mjs +137 -134
- package/tools.mjs +407 -407
package/tools/forms.mjs
CHANGED
|
@@ -1,158 +1,158 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* forms.mjs - reading and editing the questions on a public booking form.
|
|
3
|
-
*
|
|
4
|
-
* WHY THIS FAMILY EXISTS. km_list_forms in the flows family says which forms are
|
|
5
|
-
* live and then stops: "Field configuration is not returned; editing happens in
|
|
6
|
-
* KM Hub." On 27-Aug-2026 Mark Fuller found his live booking form asking every
|
|
7
|
-
* customer for a phone number twice, tried to fix it from the terminal, and could
|
|
8
|
-
* not. There was no tool that could even SEE a form's fields. Worse, the web app
|
|
9
|
-
* has no editor for the built-in required flags at all, so for half of this there
|
|
10
|
-
* was nowhere to go.
|
|
11
|
-
*
|
|
12
|
-
* 🚨 A BOOKING FORM IS A PAGE ON THE OPEN INTERNET. Every other write reachable
|
|
13
|
-
* from the terminal changes something internal that a person can quietly correct.
|
|
14
|
-
* This one changes what a stranger is asked. That is why the edit is a confirmed
|
|
15
|
-
* write: KM Hub answers 409 with a sentence naming the questions being changed,
|
|
16
|
-
* and nothing happens until a human has said yes to that sentence.
|
|
17
|
-
*
|
|
18
|
-
* The family contract this file follows is documented in ./README.md.
|
|
19
|
-
*/
|
|
20
|
-
import { z } from 'zod';
|
|
21
|
-
|
|
22
|
-
export const FAMILY = 'forms';
|
|
23
|
-
|
|
24
|
-
export const TOOLS = ['km_get_form_fields', 'km_update_form_fields'];
|
|
25
|
-
|
|
26
|
-
export const PROFILES = ['outreach'];
|
|
27
|
-
|
|
28
|
-
/**
|
|
29
|
-
* Is this a 404 because the ROUTE does not exist, or because the FORM does not?
|
|
30
|
-
*
|
|
31
|
-
* Both families of 404 come back on the same wire, and conflating them would tell
|
|
32
|
-
* an owner "your KM Hub is too old" when the truth is "you gave me the wrong id".
|
|
33
|
-
* The route table's own 404 is the one that carries a `routes` list (index.ts
|
|
34
|
-
* generates it so the list can never drift); a handler's not_found never does.
|
|
35
|
-
*/
|
|
36
|
-
function routeMissing(r) {
|
|
37
|
-
if (r.status === 405 || r.status === 501) return true;
|
|
38
|
-
return r.status === 404 && Array.isArray(r.data?.routes);
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
const NOT_SUPPORTED =
|
|
42
|
-
'This KM Hub is running a version that cannot read or edit form fields from the terminal yet. The workspace is fine ' +
|
|
43
|
-
'and every other tool works as normal. Form fields are editable in the web app at https://hub.kivimedia.co.';
|
|
44
|
-
|
|
45
|
-
const optionSchema = z.union([
|
|
46
|
-
z.string().min(1).max(200),
|
|
47
|
-
z.object({
|
|
48
|
-
value: z.string().min(1).max(200).describe('What gets stored and, if tag_answer is on, becomes the tag.'),
|
|
49
|
-
label: z.string().min(1).max(200).optional().describe('What the customer reads. Defaults to the value.'),
|
|
50
|
-
}),
|
|
51
|
-
]);
|
|
52
|
-
|
|
53
|
-
const FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox', 'date', 'phone', 'email'];
|
|
54
|
-
|
|
55
|
-
export function register(server, call, { out, text }) {
|
|
56
|
-
server.tool(
|
|
57
|
-
'km_get_form_fields',
|
|
58
|
-
'Every question one booking form asks: the built-in ones the page renders itself (phone, zip, surface type, ' +
|
|
59
|
-
'preferred date and time, how they heard about you, guests of honour) with whether each is required, plus this ' +
|
|
60
|
-
"workspace's own custom questions with their type, options and keys. Also returns a warnings list for anything " +
|
|
61
|
-
'the form asks TWICE, which is invisible in the data because a built-in question and a custom one live in ' +
|
|
62
|
-
'different places and only meet on the rendered page. Use it before advising anything about a form, and always ' +
|
|
63
|
-
'before editing one, because the keys you need for an edit are only here. Get the form id from km_list_forms. ' +
|
|
64
|
-
'Read only.',
|
|
65
|
-
{
|
|
66
|
-
form_id: z.string().describe('UUID of the form. km_list_forms returns it. A slug will not work here.'),
|
|
67
|
-
},
|
|
68
|
-
async ({ form_id }) => {
|
|
69
|
-
const r = await call('GET', `/forms/${encodeURIComponent(String(form_id || '').trim())}`);
|
|
70
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
71
|
-
return out(r);
|
|
72
|
-
},
|
|
73
|
-
);
|
|
74
|
-
|
|
75
|
-
server.tool(
|
|
76
|
-
'km_update_form_fields',
|
|
77
|
-
'Change the questions on a booking form: remove a custom question, add one, edit one, or make a built-in question ' +
|
|
78
|
-
'required or optional. Use it when the owner says the form asks something twice, asks for the wrong thing, is ' +
|
|
79
|
-
'missing a question, or should stop making something compulsory. ' +
|
|
80
|
-
'🚨 THIS FORM IS A PUBLIC PAGE. Whatever you change here is what the next customer is asked. Making a field ' +
|
|
81
|
-
'required blocks every submission that leaves it blank, so a careless required flag is an outage nobody sees ' +
|
|
82
|
-
'until enquiries stop arriving. Run km_get_form_fields first and work from the real keys: this tool refuses a ' +
|
|
83
|
-
'key that is not on the form rather than quietly doing nothing. ' +
|
|
84
|
-
'Removing a custom field stops the question being asked from now on and does NOT delete answers already ' +
|
|
85
|
-
'collected. Only the built-in phone question reaches the client record in the CRM; a custom phone field is ' +
|
|
86
|
-
'stored on the submission and nowhere else, which is usually the reason a form has two of them.',
|
|
87
|
-
{
|
|
88
|
-
form_id: z.string().describe('UUID of the form, from km_list_forms or km_get_form_fields.'),
|
|
89
|
-
remove: z
|
|
90
|
-
.array(z.string())
|
|
91
|
-
.optional()
|
|
92
|
-
.describe('Keys of custom fields to stop asking, e.g. ["field_3"]. Keys come from km_get_form_fields.'),
|
|
93
|
-
add: z
|
|
94
|
-
.array(
|
|
95
|
-
z.object({
|
|
96
|
-
label: z.string().min(1).max(200).describe('The question the customer reads.'),
|
|
97
|
-
type: z.enum(FIELD_TYPES).describe('select, radio and checkbox need options. phone renders a tel input.'),
|
|
98
|
-
required: z.boolean().optional().describe('True makes it compulsory. Leave it out for optional.'),
|
|
99
|
-
placeholder: z.string().max(200).optional().describe('Grey hint text inside the box.'),
|
|
100
|
-
options: z.array(optionSchema).optional().describe('The choices, for select, radio and checkbox.'),
|
|
101
|
-
tag_answer: z
|
|
102
|
-
.boolean()
|
|
103
|
-
.optional()
|
|
104
|
-
.describe(
|
|
105
|
-
"Turn the customer's answer into a tag on their client record, so that segment can be emailed later. " +
|
|
106
|
-
'Only allowed on select, radio and checkbox: on free text it would create a new tag for every ' +
|
|
107
|
-
'spelling a customer uses.',
|
|
108
|
-
),
|
|
109
|
-
}),
|
|
110
|
-
)
|
|
111
|
-
.optional()
|
|
112
|
-
.describe('New questions. Keys are assigned automatically unless you supply one.'),
|
|
113
|
-
update: z
|
|
114
|
-
.array(
|
|
115
|
-
z.object({
|
|
116
|
-
key: z.string().describe('Key of the existing custom field to change.'),
|
|
117
|
-
label: z.string().min(1).max(200).optional(),
|
|
118
|
-
type: z.enum(FIELD_TYPES).optional(),
|
|
119
|
-
required: z.boolean().optional(),
|
|
120
|
-
placeholder: z.string().max(200).nullable().optional().describe('Send null to clear it.'),
|
|
121
|
-
options: z.array(optionSchema).optional(),
|
|
122
|
-
tag_answer: z.boolean().optional(),
|
|
123
|
-
}),
|
|
124
|
-
)
|
|
125
|
-
.optional()
|
|
126
|
-
.describe('Changes to existing custom questions. Anything you leave out stays as it is.'),
|
|
127
|
-
built_in_required: z
|
|
128
|
-
.object({
|
|
129
|
-
phone: z.boolean().optional(),
|
|
130
|
-
zip: z.boolean().optional(),
|
|
131
|
-
surface: z.boolean().optional(),
|
|
132
|
-
preferredDate: z.boolean().optional(),
|
|
133
|
-
preferredTime: z.boolean().optional(),
|
|
134
|
-
heardAbout: z.boolean().optional(),
|
|
135
|
-
guestOfHonor: z.boolean().optional(),
|
|
136
|
-
})
|
|
137
|
-
.optional()
|
|
138
|
-
.describe(
|
|
139
|
-
'Make the page\'s own questions compulsory or not, e.g. {"phone": true}. These are the only built-in fields ' +
|
|
140
|
-
'that exist; any other name is refused rather than saved where nothing would read it.',
|
|
141
|
-
),
|
|
142
|
-
confirm_token: z
|
|
143
|
-
.string()
|
|
144
|
-
.optional()
|
|
145
|
-
.describe(
|
|
146
|
-
'Leave this out on the first call. This action changes a public page, so it needs an explicit yes from the '
|
|
147
|
-
+ 'person you are working with: KM Hub answers 409 with a plain description of exactly which questions '
|
|
148
|
-
+ 'would change, plus a token. Show them that description in those words, wait for a real answer, and only '
|
|
149
|
-
+ 'then call again with the token and the SAME arguments. A yes for one change never authorises a different one.',
|
|
150
|
-
),
|
|
151
|
-
},
|
|
152
|
-
async ({ form_id, ...changes }) => {
|
|
153
|
-
const r = await call('PATCH', `/forms/${encodeURIComponent(String(form_id || '').trim())}/fields`, changes);
|
|
154
|
-
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
155
|
-
return out(r);
|
|
156
|
-
},
|
|
157
|
-
);
|
|
158
|
-
}
|
|
1
|
+
/**
|
|
2
|
+
* forms.mjs - reading and editing the questions on a public booking form.
|
|
3
|
+
*
|
|
4
|
+
* WHY THIS FAMILY EXISTS. km_list_forms in the flows family says which forms are
|
|
5
|
+
* live and then stops: "Field configuration is not returned; editing happens in
|
|
6
|
+
* KM Hub." On 27-Aug-2026 Mark Fuller found his live booking form asking every
|
|
7
|
+
* customer for a phone number twice, tried to fix it from the terminal, and could
|
|
8
|
+
* not. There was no tool that could even SEE a form's fields. Worse, the web app
|
|
9
|
+
* has no editor for the built-in required flags at all, so for half of this there
|
|
10
|
+
* was nowhere to go.
|
|
11
|
+
*
|
|
12
|
+
* 🚨 A BOOKING FORM IS A PAGE ON THE OPEN INTERNET. Every other write reachable
|
|
13
|
+
* from the terminal changes something internal that a person can quietly correct.
|
|
14
|
+
* This one changes what a stranger is asked. That is why the edit is a confirmed
|
|
15
|
+
* write: KM Hub answers 409 with a sentence naming the questions being changed,
|
|
16
|
+
* and nothing happens until a human has said yes to that sentence.
|
|
17
|
+
*
|
|
18
|
+
* The family contract this file follows is documented in ./README.md.
|
|
19
|
+
*/
|
|
20
|
+
import { z } from 'zod';
|
|
21
|
+
|
|
22
|
+
export const FAMILY = 'forms';
|
|
23
|
+
|
|
24
|
+
export const TOOLS = ['km_get_form_fields', 'km_update_form_fields'];
|
|
25
|
+
|
|
26
|
+
export const PROFILES = ['outreach'];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Is this a 404 because the ROUTE does not exist, or because the FORM does not?
|
|
30
|
+
*
|
|
31
|
+
* Both families of 404 come back on the same wire, and conflating them would tell
|
|
32
|
+
* an owner "your KM Hub is too old" when the truth is "you gave me the wrong id".
|
|
33
|
+
* The route table's own 404 is the one that carries a `routes` list (index.ts
|
|
34
|
+
* generates it so the list can never drift); a handler's not_found never does.
|
|
35
|
+
*/
|
|
36
|
+
function routeMissing(r) {
|
|
37
|
+
if (r.status === 405 || r.status === 501) return true;
|
|
38
|
+
return r.status === 404 && Array.isArray(r.data?.routes);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
const NOT_SUPPORTED =
|
|
42
|
+
'This KM Hub is running a version that cannot read or edit form fields from the terminal yet. The workspace is fine ' +
|
|
43
|
+
'and every other tool works as normal. Form fields are editable in the web app at https://hub.kivimedia.co.';
|
|
44
|
+
|
|
45
|
+
const optionSchema = z.union([
|
|
46
|
+
z.string().min(1).max(200),
|
|
47
|
+
z.object({
|
|
48
|
+
value: z.string().min(1).max(200).describe('What gets stored and, if tag_answer is on, becomes the tag.'),
|
|
49
|
+
label: z.string().min(1).max(200).optional().describe('What the customer reads. Defaults to the value.'),
|
|
50
|
+
}),
|
|
51
|
+
]);
|
|
52
|
+
|
|
53
|
+
const FIELD_TYPES = ['text', 'textarea', 'select', 'radio', 'checkbox', 'date', 'phone', 'email'];
|
|
54
|
+
|
|
55
|
+
export function register(server, call, { out, text }) {
|
|
56
|
+
server.tool(
|
|
57
|
+
'km_get_form_fields',
|
|
58
|
+
'Every question one booking form asks: the built-in ones the page renders itself (phone, zip, surface type, ' +
|
|
59
|
+
'preferred date and time, how they heard about you, guests of honour) with whether each is required, plus this ' +
|
|
60
|
+
"workspace's own custom questions with their type, options and keys. Also returns a warnings list for anything " +
|
|
61
|
+
'the form asks TWICE, which is invisible in the data because a built-in question and a custom one live in ' +
|
|
62
|
+
'different places and only meet on the rendered page. Use it before advising anything about a form, and always ' +
|
|
63
|
+
'before editing one, because the keys you need for an edit are only here. Get the form id from km_list_forms. ' +
|
|
64
|
+
'Read only.',
|
|
65
|
+
{
|
|
66
|
+
form_id: z.string().describe('UUID of the form. km_list_forms returns it. A slug will not work here.'),
|
|
67
|
+
},
|
|
68
|
+
async ({ form_id }) => {
|
|
69
|
+
const r = await call('GET', `/forms/${encodeURIComponent(String(form_id || '').trim())}`);
|
|
70
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
71
|
+
return out(r);
|
|
72
|
+
},
|
|
73
|
+
);
|
|
74
|
+
|
|
75
|
+
server.tool(
|
|
76
|
+
'km_update_form_fields',
|
|
77
|
+
'Change the questions on a booking form: remove a custom question, add one, edit one, or make a built-in question ' +
|
|
78
|
+
'required or optional. Use it when the owner says the form asks something twice, asks for the wrong thing, is ' +
|
|
79
|
+
'missing a question, or should stop making something compulsory. ' +
|
|
80
|
+
'🚨 THIS FORM IS A PUBLIC PAGE. Whatever you change here is what the next customer is asked. Making a field ' +
|
|
81
|
+
'required blocks every submission that leaves it blank, so a careless required flag is an outage nobody sees ' +
|
|
82
|
+
'until enquiries stop arriving. Run km_get_form_fields first and work from the real keys: this tool refuses a ' +
|
|
83
|
+
'key that is not on the form rather than quietly doing nothing. ' +
|
|
84
|
+
'Removing a custom field stops the question being asked from now on and does NOT delete answers already ' +
|
|
85
|
+
'collected. Only the built-in phone question reaches the client record in the CRM; a custom phone field is ' +
|
|
86
|
+
'stored on the submission and nowhere else, which is usually the reason a form has two of them.',
|
|
87
|
+
{
|
|
88
|
+
form_id: z.string().describe('UUID of the form, from km_list_forms or km_get_form_fields.'),
|
|
89
|
+
remove: z
|
|
90
|
+
.array(z.string())
|
|
91
|
+
.optional()
|
|
92
|
+
.describe('Keys of custom fields to stop asking, e.g. ["field_3"]. Keys come from km_get_form_fields.'),
|
|
93
|
+
add: z
|
|
94
|
+
.array(
|
|
95
|
+
z.object({
|
|
96
|
+
label: z.string().min(1).max(200).describe('The question the customer reads.'),
|
|
97
|
+
type: z.enum(FIELD_TYPES).describe('select, radio and checkbox need options. phone renders a tel input.'),
|
|
98
|
+
required: z.boolean().optional().describe('True makes it compulsory. Leave it out for optional.'),
|
|
99
|
+
placeholder: z.string().max(200).optional().describe('Grey hint text inside the box.'),
|
|
100
|
+
options: z.array(optionSchema).optional().describe('The choices, for select, radio and checkbox.'),
|
|
101
|
+
tag_answer: z
|
|
102
|
+
.boolean()
|
|
103
|
+
.optional()
|
|
104
|
+
.describe(
|
|
105
|
+
"Turn the customer's answer into a tag on their client record, so that segment can be emailed later. " +
|
|
106
|
+
'Only allowed on select, radio and checkbox: on free text it would create a new tag for every ' +
|
|
107
|
+
'spelling a customer uses.',
|
|
108
|
+
),
|
|
109
|
+
}),
|
|
110
|
+
)
|
|
111
|
+
.optional()
|
|
112
|
+
.describe('New questions. Keys are assigned automatically unless you supply one.'),
|
|
113
|
+
update: z
|
|
114
|
+
.array(
|
|
115
|
+
z.object({
|
|
116
|
+
key: z.string().describe('Key of the existing custom field to change.'),
|
|
117
|
+
label: z.string().min(1).max(200).optional(),
|
|
118
|
+
type: z.enum(FIELD_TYPES).optional(),
|
|
119
|
+
required: z.boolean().optional(),
|
|
120
|
+
placeholder: z.string().max(200).nullable().optional().describe('Send null to clear it.'),
|
|
121
|
+
options: z.array(optionSchema).optional(),
|
|
122
|
+
tag_answer: z.boolean().optional(),
|
|
123
|
+
}),
|
|
124
|
+
)
|
|
125
|
+
.optional()
|
|
126
|
+
.describe('Changes to existing custom questions. Anything you leave out stays as it is.'),
|
|
127
|
+
built_in_required: z
|
|
128
|
+
.object({
|
|
129
|
+
phone: z.boolean().optional(),
|
|
130
|
+
zip: z.boolean().optional(),
|
|
131
|
+
surface: z.boolean().optional(),
|
|
132
|
+
preferredDate: z.boolean().optional(),
|
|
133
|
+
preferredTime: z.boolean().optional(),
|
|
134
|
+
heardAbout: z.boolean().optional(),
|
|
135
|
+
guestOfHonor: z.boolean().optional(),
|
|
136
|
+
})
|
|
137
|
+
.optional()
|
|
138
|
+
.describe(
|
|
139
|
+
'Make the page\'s own questions compulsory or not, e.g. {"phone": true}. These are the only built-in fields ' +
|
|
140
|
+
'that exist; any other name is refused rather than saved where nothing would read it.',
|
|
141
|
+
),
|
|
142
|
+
confirm_token: z
|
|
143
|
+
.string()
|
|
144
|
+
.optional()
|
|
145
|
+
.describe(
|
|
146
|
+
'Leave this out on the first call. This action changes a public page, so it needs an explicit yes from the '
|
|
147
|
+
+ 'person you are working with: KM Hub answers 409 with a plain description of exactly which questions '
|
|
148
|
+
+ 'would change, plus a token. Show them that description in those words, wait for a real answer, and only '
|
|
149
|
+
+ 'then call again with the token and the SAME arguments. A yes for one change never authorises a different one.',
|
|
150
|
+
),
|
|
151
|
+
},
|
|
152
|
+
async ({ form_id, ...changes }) => {
|
|
153
|
+
const r = await call('PATCH', `/forms/${encodeURIComponent(String(form_id || '').trim())}/fields`, changes);
|
|
154
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
155
|
+
return out(r);
|
|
156
|
+
},
|
|
157
|
+
);
|
|
158
|
+
}
|
package/tools/gols.mjs
CHANGED
|
@@ -1,134 +1,144 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* Tool family: gols - GOLS, the Grand Opening / Event Scout, read and write.
|
|
3
|
-
*
|
|
4
|
-
* km_gols_finds -> GET /gols/finds (free)
|
|
5
|
-
* km_gols_scan -> POST /gols/scan (AI-metered: price first, then a confirm)
|
|
6
|
-
* km_gols_push_find -> POST /gols/finds/:id/push (confirm: creates a deal + queues a draft)
|
|
7
|
-
* km_gols_dismiss -> POST /gols/finds/:id/dismiss (free)
|
|
8
|
-
*
|
|
9
|
-
* Owners call it "GOLS", "gols", "grand openings", "new businesses opening near
|
|
10
|
-
* me". Every description below says all of those, because on 22-Sep-26 a session
|
|
11
|
-
* asked about "gols" found nothing matching and asked the owner to choose between
|
|
12
|
-
* three guesses.
|
|
13
|
-
*
|
|
14
|
-
* Nothing here contacts anybody. A pushed find's first message is a DRAFT in the
|
|
15
|
-
* Approval Queue. The family contract this file follows is documented in ./README.md.
|
|
16
|
-
*/
|
|
17
|
-
import { z } from 'zod';
|
|
18
|
-
|
|
19
|
-
export const FAMILY = 'gols';
|
|
20
|
-
|
|
21
|
-
export const TOOLS = ['km_gols_finds', 'km_gols_scan', 'km_gols_push_find', 'km_gols_dismiss'];
|
|
22
|
-
|
|
23
|
-
export const PROFILES = ['outreach'];
|
|
24
|
-
|
|
25
|
-
function routeMissing(r) {
|
|
26
|
-
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
27
|
-
}
|
|
28
|
-
|
|
29
|
-
const NOT_SUPPORTED =
|
|
30
|
-
'This KM Hub does not expose GOLS (the grand-opening scout) to the terminal yet. Nothing is broken; it is in the ' +
|
|
31
|
-
'web app at https://hub.kivimedia.co
|
|
32
|
-
|
|
33
|
-
const CONFIRM_TOKEN = z
|
|
34
|
-
.string()
|
|
35
|
-
.optional()
|
|
36
|
-
.describe(
|
|
37
|
-
'Leave this out on the first call. KM Hub answers 409 with a plain description of exactly what would happen, ' +
|
|
38
|
-
'plus a token. Show the person that description in those words, wait for a real yes, then call again with the ' +
|
|
39
|
-
'token and the SAME arguments.',
|
|
40
|
-
);
|
|
41
|
-
|
|
42
|
-
export function register(server, call, { out, text, qs }) {
|
|
43
|
-
server.tool(
|
|
44
|
-
'km_gols_finds',
|
|
45
|
-
'GOLS (the Grand Opening / Event Scout, also written "gols"):
|
|
46
|
-
'have
|
|
47
|
-
'
|
|
48
|
-
'
|
|
49
|
-
'
|
|
50
|
-
'opening
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
.
|
|
92
|
-
.
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: gols - GOLS, the Grand Opening / Event Scout, read and write.
|
|
3
|
+
*
|
|
4
|
+
* km_gols_finds -> GET /gols/finds (free)
|
|
5
|
+
* km_gols_scan -> POST /gols/scan (AI-metered: price first, then a confirm)
|
|
6
|
+
* km_gols_push_find -> POST /gols/finds/:id/push (confirm: creates a deal + queues a draft)
|
|
7
|
+
* km_gols_dismiss -> POST /gols/finds/:id/dismiss (free)
|
|
8
|
+
*
|
|
9
|
+
* Owners call it "GOLS", "gols", "grand openings", "new businesses opening near
|
|
10
|
+
* me". Every description below says all of those, because on 22-Sep-26 a session
|
|
11
|
+
* asked about "gols" found nothing matching and asked the owner to choose between
|
|
12
|
+
* three guesses.
|
|
13
|
+
*
|
|
14
|
+
* Nothing here contacts anybody. A pushed find's first message is a DRAFT in the
|
|
15
|
+
* Approval Queue. The family contract this file follows is documented in ./README.md.
|
|
16
|
+
*/
|
|
17
|
+
import { z } from 'zod';
|
|
18
|
+
|
|
19
|
+
export const FAMILY = 'gols';
|
|
20
|
+
|
|
21
|
+
export const TOOLS = ['km_gols_finds', 'km_gols_scan', 'km_gols_push_find', 'km_gols_dismiss'];
|
|
22
|
+
|
|
23
|
+
export const PROFILES = ['outreach'];
|
|
24
|
+
|
|
25
|
+
function routeMissing(r) {
|
|
26
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const NOT_SUPPORTED =
|
|
30
|
+
'This KM Hub does not expose GOLS (the grand-opening scout) to the terminal yet. Nothing is broken; it is in the ' +
|
|
31
|
+
'web app at https://hub.kivimedia.co/?page=gols. Nothing was charged.';
|
|
32
|
+
|
|
33
|
+
const CONFIRM_TOKEN = z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe(
|
|
37
|
+
'Leave this out on the first call. KM Hub answers 409 with a plain description of exactly what would happen, ' +
|
|
38
|
+
'plus a token. Show the person that description in those words, wait for a real yes, then call again with the ' +
|
|
39
|
+
'token and the SAME arguments.',
|
|
40
|
+
);
|
|
41
|
+
|
|
42
|
+
export function register(server, call, { out, text, qs }) {
|
|
43
|
+
server.tool(
|
|
44
|
+
'km_gols_finds',
|
|
45
|
+
'GOLS (the Grand Opening / Event Scout, also written "gols"): businesses near the owner that announced an opening ' +
|
|
46
|
+
'and have NOT opened yet, each scored hot / warm / cold for how likely it is to book event services for its ' +
|
|
47
|
+
'grand opening, launch or ribbon cutting. By default only openings at least 2 weeks away or with the date not ' +
|
|
48
|
+
'announced, soonest dated first; the reply counts what it hid (already opened, under 2 weeks away, not an ' +
|
|
49
|
+
'opening) and when "all" shows them. Reach for it when the owner asks about GOLS, grand openings, new businesses ' +
|
|
50
|
+
'near them, or "who is opening soon". `when` says "Opens <date>" only for a date the announcement stated; ' +
|
|
51
|
+
'"Announced, opening date not stated" means the article gave no date, so never invent one. `location` is the ' +
|
|
52
|
+
"business's own town, or null when the article did not say. Free, read only. To find more, use km_gols_scan; " +
|
|
53
|
+
'to act on one, km_gols_push_find.',
|
|
54
|
+
{
|
|
55
|
+
status: z
|
|
56
|
+
.enum(['new', 'pushed', 'dismissed', 'all'])
|
|
57
|
+
.optional()
|
|
58
|
+
.describe('Which finds. Default "new" = not yet acted on. "pushed" = already in the pipeline.'),
|
|
59
|
+
when: z
|
|
60
|
+
.enum(['upcoming', 'all'])
|
|
61
|
+
.optional()
|
|
62
|
+
.describe('Default "upcoming" = not open yet and at least 2 weeks out (or date not announced). "all" = every row, including past ones.'),
|
|
63
|
+
tier: z.enum(['hot', 'warm', 'cold']).optional().describe('Only this fit tier. Leave out for all.'),
|
|
64
|
+
city: z.string().optional().describe('Only finds from scans of this city, as the scan was run.'),
|
|
65
|
+
limit: z.number().int().min(1).max(100).optional().describe('How many. Default 30.'),
|
|
66
|
+
},
|
|
67
|
+
async ({ status, when, tier, city, limit }) => {
|
|
68
|
+
const r = await call('GET', `/gols/finds${qs({ status, when, tier, city, limit })}`);
|
|
69
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
70
|
+
return out(r);
|
|
71
|
+
},
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
server.tool(
|
|
75
|
+
'km_gols_scan',
|
|
76
|
+
[
|
|
77
|
+
'Run a GOLS scan (grand-opening scout): read the last 90 days of opening announcements in the news for ONE US',
|
|
78
|
+
'county (county + state: the county by name plus its biggest towns) or near a city (city + state), keep only',
|
|
79
|
+
'businesses that have NOT opened yet (finds that already opened, or that are outside the county, are dropped',
|
|
80
|
+
'and counted), score each one for event-services fit and save it. A business already on the list is updated,',
|
|
81
|
+
'not added twice. For several counties, call it once per county; each county is priced on its own. For a place',
|
|
82
|
+
'outside the US, such as Windsor, Ontario, scan the city. There is no look-back setting and no "recently',
|
|
83
|
+
'opened" mode. It takes about a minute and returns the finds.',
|
|
84
|
+
'THIS COSTS MONEY (AI time, billed to the workspace). Call it FIRST without confirm_spend: nothing runs and you',
|
|
85
|
+
'get the price. Tell the person the price, and on a yes call again with confirm_spend true; KM Hub then answers',
|
|
86
|
+
'409 with the exact action and a confirm_token, which you show them and send back with the same arguments.',
|
|
87
|
+
'Never start one on your own initiative. It contacts nobody.',
|
|
88
|
+
].join(' '),
|
|
89
|
+
{
|
|
90
|
+
county: z
|
|
91
|
+
.string()
|
|
92
|
+
.optional()
|
|
93
|
+
.describe('A US county to scan, as the person said it, for example "Oakland" or "Oakland County". Needs state. Leave out to scan around a city.'),
|
|
94
|
+
city: z.string().optional().describe('The city to scan around, for example "Southfield". Leave out when you pass county.'),
|
|
95
|
+
state: z.string().describe('State or province, for example "MI" or "Ontario".'),
|
|
96
|
+
radius_miles: z.number().int().min(1).max(200).optional().describe('How far around the city (city scans only). Default 25.'),
|
|
97
|
+
vertical: z.string().optional().describe('The trade to score fit for, only if it differs from the workspace, e.g. "balloon decor".'),
|
|
98
|
+
zip: z.string().optional().describe('ZIP code, only if the person gave one.'),
|
|
99
|
+
confirm_spend: z
|
|
100
|
+
.boolean()
|
|
101
|
+
.optional()
|
|
102
|
+
.describe('Leave out on the first call (price only, nothing runs). true ONLY after the person agreed to the price.'),
|
|
103
|
+
confirm_token: CONFIRM_TOKEN,
|
|
104
|
+
},
|
|
105
|
+
async (args) => {
|
|
106
|
+
const r = await call('POST', '/gols/scan', args);
|
|
107
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
108
|
+
return out(r);
|
|
109
|
+
},
|
|
110
|
+
);
|
|
111
|
+
|
|
112
|
+
server.tool(
|
|
113
|
+
'km_gols_push_find',
|
|
114
|
+
'Take one GOLS grand-opening find into the pipeline: it becomes a deal at Inbound, and KM Hub finds a contact ' +
|
|
115
|
+
'email for the business and writes a grand-opening approach as a DRAFT in the Approval Queue (a small AI and ' +
|
|
116
|
+
'lookup cost). Nobody is contacted until a person approves the draft. Needs an explicit yes: the first call ' +
|
|
117
|
+
'returns 409 with what would happen and a confirm_token. Pushing a find that is already pushed changes nothing.',
|
|
118
|
+
{
|
|
119
|
+
find_id: z.string().describe('The find id from km_gols_finds or km_gols_scan.'),
|
|
120
|
+
confirm_token: CONFIRM_TOKEN,
|
|
121
|
+
},
|
|
122
|
+
async ({ find_id, confirm_token }) => {
|
|
123
|
+
const id = String(find_id || '').trim();
|
|
124
|
+
if (!id) return text('I need the find id. km_gols_finds lists them.');
|
|
125
|
+
const r = await call('POST', `/gols/finds/${encodeURIComponent(id)}/push`, { confirm_token });
|
|
126
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
127
|
+
return out(r);
|
|
128
|
+
},
|
|
129
|
+
);
|
|
130
|
+
|
|
131
|
+
server.tool(
|
|
132
|
+
'km_gols_dismiss',
|
|
133
|
+
'Hide a GOLS grand-opening find the owner does not want, so it stops showing in the working list. Nothing is ' +
|
|
134
|
+
'deleted and nobody is contacted. Only works on finds still marked new.',
|
|
135
|
+
{ find_id: z.string().describe('The find id from km_gols_finds.') },
|
|
136
|
+
async ({ find_id }) => {
|
|
137
|
+
const id = String(find_id || '').trim();
|
|
138
|
+
if (!id) return text('I need the find id. km_gols_finds lists them.');
|
|
139
|
+
const r = await call('POST', `/gols/finds/${encodeURIComponent(id)}/dismiss`, {});
|
|
140
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
141
|
+
return out(r);
|
|
142
|
+
},
|
|
143
|
+
);
|
|
144
|
+
}
|