@kivimedia/kmhub 2.0.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 +169 -0
- package/bin/kmhub.mjs +883 -0
- package/index.mjs +55 -0
- package/package.json +51 -0
- package/remote.mjs +213 -0
- package/tools/briefing.mjs +91 -0
- package/tools/calendar.mjs +161 -0
- package/tools/core.mjs +223 -0
- package/tools/crm.mjs +200 -0
- package/tools/knowledge.mjs +124 -0
- package/tools/meta.mjs +245 -0
- package/tools/money.mjs +197 -0
- package/tools/outreach.mjs +215 -0
- package/tools/plays.mjs +244 -0
- package/tools/sourcing.mjs +220 -0
- package/tools.mjs +349 -0
package/index.mjs
ADDED
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kmhub-mcp (C9) - a standalone LOCAL (stdio) MCP server that wraps the KM Hub public
|
|
4
|
+
* REST API (the kmhub-api edge function) as MCP tools. Any MCP client (Claude Desktop,
|
|
5
|
+
* Claude Code, IDEs) can then read your queue and create leads, authenticated with a
|
|
6
|
+
* single KM Hub API key from the environment.
|
|
7
|
+
*
|
|
8
|
+
* For the multi-tenant, zero-local-install variant (one VPS process serving many orgs
|
|
9
|
+
* over Streamable HTTP, each request scoped by its own Bearer token), see remote.mjs.
|
|
10
|
+
*
|
|
11
|
+
* Setup:
|
|
12
|
+
* 1. In KM Hub: Settings > Developer & API > create an API key (kmh_live_...).
|
|
13
|
+
* 2. npm install (in this folder).
|
|
14
|
+
* 3. Add to your MCP client config with env KMHUB_API_KEY=kmh_live_...
|
|
15
|
+
*
|
|
16
|
+
* Env:
|
|
17
|
+
* KMHUB_API_KEY required, the org's key
|
|
18
|
+
* KMHUB_API_BASE override the kmhub-api base URL
|
|
19
|
+
* KMHUB_PROFILE which tool families to load: core, outreach, money, content, full.
|
|
20
|
+
* Default full (everything). A narrower profile means fewer tool
|
|
21
|
+
* schemas in every turn, which is real context back for the model.
|
|
22
|
+
*
|
|
23
|
+
* One org-scoped auth substrate: the same key + routes the Telegram bot / Zapier use.
|
|
24
|
+
*/
|
|
25
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
26
|
+
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js';
|
|
27
|
+
import {
|
|
28
|
+
DEFAULT_BASE,
|
|
29
|
+
SERVER_NAME,
|
|
30
|
+
SERVER_VERSION,
|
|
31
|
+
makeCaller,
|
|
32
|
+
registerTools,
|
|
33
|
+
resolveProfile,
|
|
34
|
+
} from './tools.mjs';
|
|
35
|
+
|
|
36
|
+
const BASE = DEFAULT_BASE;
|
|
37
|
+
const KEY = process.env.KMHUB_API_KEY;
|
|
38
|
+
if (!KEY) {
|
|
39
|
+
console.error('kmhub-mcp: set KMHUB_API_KEY (create one in KM Hub > Settings > Developer & API).');
|
|
40
|
+
process.exit(1);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
// An unknown KMHUB_PROFILE falls back to full rather than failing to start: a typo in
|
|
44
|
+
// a config file should never cost someone their whole toolset.
|
|
45
|
+
const PROFILE = resolveProfile(process.env.KMHUB_PROFILE);
|
|
46
|
+
|
|
47
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
48
|
+
const { families, tools } = registerTools(server, makeCaller(BASE, KEY), { profile: PROFILE });
|
|
49
|
+
|
|
50
|
+
const transport = new StdioServerTransport();
|
|
51
|
+
await server.connect(transport);
|
|
52
|
+
console.error(
|
|
53
|
+
`kmhub-mcp ${SERVER_VERSION}: connected (stdio), profile '${PROFILE}' ` +
|
|
54
|
+
`(families: ${families.join(', ')}). Tools: ${tools.join(', ')}.`,
|
|
55
|
+
);
|
package/package.json
ADDED
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@kivimedia/kmhub",
|
|
3
|
+
"version": "2.0.0",
|
|
4
|
+
"description": "KM Hub Terminal Mode. Installs the KM Hub MCP connector into your own Claude Code on your own machine, and ships the kmhub CLI that registers, updates and diagnoses it.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"kmhub",
|
|
7
|
+
"kivimedia",
|
|
8
|
+
"mcp",
|
|
9
|
+
"model-context-protocol",
|
|
10
|
+
"claude-code",
|
|
11
|
+
"terminal-mode",
|
|
12
|
+
"crm"
|
|
13
|
+
],
|
|
14
|
+
"homepage": "https://hub.kivimedia.co",
|
|
15
|
+
"license": "UNLICENSED",
|
|
16
|
+
"author": "Kivi Media (https://hub.kivimedia.co)",
|
|
17
|
+
"repository": {
|
|
18
|
+
"type": "git",
|
|
19
|
+
"url": "git+https://github.com/kivimedia/kmhub.git",
|
|
20
|
+
"directory": "mcp-server"
|
|
21
|
+
},
|
|
22
|
+
"type": "module",
|
|
23
|
+
"engines": {
|
|
24
|
+
"node": ">=18.17"
|
|
25
|
+
},
|
|
26
|
+
"publishConfig": {
|
|
27
|
+
"access": "public"
|
|
28
|
+
},
|
|
29
|
+
"bin": {
|
|
30
|
+
"kmhub": "bin/kmhub.mjs",
|
|
31
|
+
"kmhub-mcp": "index.mjs",
|
|
32
|
+
"kmhub-mcp-remote": "remote.mjs"
|
|
33
|
+
},
|
|
34
|
+
"files": [
|
|
35
|
+
"bin/kmhub.mjs",
|
|
36
|
+
"index.mjs",
|
|
37
|
+
"remote.mjs",
|
|
38
|
+
"tools.mjs",
|
|
39
|
+
"tools/*.mjs"
|
|
40
|
+
],
|
|
41
|
+
"scripts": {
|
|
42
|
+
"start": "node index.mjs",
|
|
43
|
+
"start:remote": "node remote.mjs",
|
|
44
|
+
"check:version": "node -e \"const fs=require('fs');const p=JSON.parse(fs.readFileSync('package.json','utf8'));const t=fs.readFileSync('tools.mjs','utf8');const m=t.match(/SERVER_VERSION\\s*=\\s*'([^']+)'/);if(!m){console.error('kmhub: could not find SERVER_VERSION in tools.mjs, so the two identities cannot be compared.');process.exit(1)}if(m[1]!==p.version){console.error('kmhub: version mismatch. package.json is '+p.version+' and tools.mjs SERVER_VERSION is '+m[1]+'. Make them the same string in one commit, then publish.');process.exit(1)}console.log('kmhub: version check ok, both say '+p.version)\"",
|
|
45
|
+
"prepublishOnly": "npm run check:version"
|
|
46
|
+
},
|
|
47
|
+
"dependencies": {
|
|
48
|
+
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
49
|
+
"zod": "^3.23.8"
|
|
50
|
+
}
|
|
51
|
+
}
|
package/remote.mjs
ADDED
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* kmhub-mcp REMOTE (P5) - a Streamable-HTTP MCP server for KM Hub power users.
|
|
4
|
+
*
|
|
5
|
+
* ONE process serves MANY orgs. There is no server-wide API key. Instead every
|
|
6
|
+
* request authenticates itself with a KM Hub org API key sent as a Bearer token:
|
|
7
|
+
*
|
|
8
|
+
* Authorization: Bearer kmh_live_xxxxxxxx
|
|
9
|
+
*
|
|
10
|
+
* That token is bound to a fresh, per-request McpServer whose tools call kmhub-api
|
|
11
|
+
* with exactly that token. The kmhub-api edge function then does the authoritative
|
|
12
|
+
* org scoping + scope check (read vs write), so one caller can never reach another
|
|
13
|
+
* tenant's rows. This is the multi-tenant twin of the local stdio server (index.mjs);
|
|
14
|
+
* both share the SAME tool definitions from tools.mjs.
|
|
15
|
+
*
|
|
16
|
+
* Transport: MCP Streamable HTTP, STATELESS mode (sessionIdGenerator: undefined) -
|
|
17
|
+
* each POST /mcp is a self-contained request/response, so there is no session state
|
|
18
|
+
* to leak across tenants. Deployed on the VPS as pm2 process `kmhub-mcp`; see
|
|
19
|
+
* DEPLOY-VPS.md.
|
|
20
|
+
*
|
|
21
|
+
* Endpoints:
|
|
22
|
+
* GET /health -> { ok: true, ... } (no auth; for pm2 / nginx / uptime checks)
|
|
23
|
+
* POST /mcp -> MCP Streamable HTTP (Bearer token required)
|
|
24
|
+
* GET /mcp -> 405 (no server-initiated SSE stream in stateless mode)
|
|
25
|
+
*
|
|
26
|
+
* Tool profiles: a caller can load a subset of the tools instead of all of them, which
|
|
27
|
+
* costs the model far fewer tool schemas per turn. Two ways to ask, both per-request so
|
|
28
|
+
* the server stays stateless and two tenants can hold different profiles at the same time:
|
|
29
|
+
* POST /mcp?profile=money (query string, wins if both are present)
|
|
30
|
+
* X-KMHub-Profile: money (header)
|
|
31
|
+
* Known profiles: core, outreach, money, content, full. Anything else falls back to full
|
|
32
|
+
* rather than erroring, because a typo should never cost someone their tools.
|
|
33
|
+
*
|
|
34
|
+
* Env:
|
|
35
|
+
* PORT listen port (default 8830)
|
|
36
|
+
* HOST bind address (default 0.0.0.0)
|
|
37
|
+
* KMHUB_API_BASE override the kmhub-api base URL (default = the shared one)
|
|
38
|
+
* MCP_RATE_LIMIT max POST /mcp requests per token per minute (default 120)
|
|
39
|
+
*/
|
|
40
|
+
import http from 'node:http';
|
|
41
|
+
import { McpServer } from '@modelcontextprotocol/sdk/server/mcp.js';
|
|
42
|
+
import { StreamableHTTPServerTransport } from '@modelcontextprotocol/sdk/server/streamableHttp.js';
|
|
43
|
+
import {
|
|
44
|
+
DEFAULT_BASE,
|
|
45
|
+
DEFAULT_PROFILE,
|
|
46
|
+
FAMILY_NAMES,
|
|
47
|
+
PROFILE_NAMES,
|
|
48
|
+
SERVER_NAME,
|
|
49
|
+
SERVER_VERSION,
|
|
50
|
+
TOOL_NAMES,
|
|
51
|
+
makeCaller,
|
|
52
|
+
registerTools,
|
|
53
|
+
resolveProfile,
|
|
54
|
+
toolNamesFor,
|
|
55
|
+
} from './tools.mjs';
|
|
56
|
+
|
|
57
|
+
const PORT = Number(process.env.PORT || 8830);
|
|
58
|
+
const HOST = process.env.HOST || '0.0.0.0';
|
|
59
|
+
const BASE = DEFAULT_BASE;
|
|
60
|
+
const RATE_LIMIT = Number(process.env.MCP_RATE_LIMIT || 120); // requests / token / minute
|
|
61
|
+
const WINDOW_MS = 60_000;
|
|
62
|
+
|
|
63
|
+
// ---- light in-memory per-token rate limiter -------------------------------
|
|
64
|
+
// First line of defense at the MCP layer. kmhub-api still enforces the
|
|
65
|
+
// authoritative per-key limit (60 reads / 20 writes per minute) downstream.
|
|
66
|
+
const buckets = new Map(); // token -> { count, resetAt }
|
|
67
|
+
|
|
68
|
+
function rateLimited(token) {
|
|
69
|
+
const now = Date.now();
|
|
70
|
+
let b = buckets.get(token);
|
|
71
|
+
if (!b || now >= b.resetAt) {
|
|
72
|
+
b = { count: 0, resetAt: now + WINDOW_MS };
|
|
73
|
+
buckets.set(token, b);
|
|
74
|
+
}
|
|
75
|
+
b.count += 1;
|
|
76
|
+
if (b.count > RATE_LIMIT) {
|
|
77
|
+
return Math.max(1, Math.ceil((b.resetAt - now) / 1000)); // retry-after seconds
|
|
78
|
+
}
|
|
79
|
+
return 0;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Periodically evict stale buckets so memory does not grow unbounded.
|
|
83
|
+
setInterval(() => {
|
|
84
|
+
const now = Date.now();
|
|
85
|
+
for (const [token, b] of buckets) if (now >= b.resetAt) buckets.delete(token);
|
|
86
|
+
}, WINDOW_MS).unref();
|
|
87
|
+
|
|
88
|
+
// ---- helpers --------------------------------------------------------------
|
|
89
|
+
function sendJson(res, status, obj) {
|
|
90
|
+
const body = JSON.stringify(obj);
|
|
91
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body) });
|
|
92
|
+
res.end(body);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// A well-formed JSON-RPC error response (id null: the request never reached a handler).
|
|
96
|
+
function sendRpcError(res, status, code, message, extraHeaders = {}) {
|
|
97
|
+
const body = JSON.stringify({ jsonrpc: '2.0', error: { code, message }, id: null });
|
|
98
|
+
res.writeHead(status, { 'content-type': 'application/json', 'content-length': Buffer.byteLength(body), ...extraHeaders });
|
|
99
|
+
res.end(body);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function bearerToken(req) {
|
|
103
|
+
const h = req.headers['authorization'] || req.headers['Authorization'];
|
|
104
|
+
if (!h || typeof h !== 'string') return null;
|
|
105
|
+
const m = h.match(/^Bearer\s+(.+)$/i);
|
|
106
|
+
return m ? m[1].trim() : null;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Which tool profile this ONE request asked for. Query string beats header; an
|
|
110
|
+
// unknown or missing value resolves to `full`, never an error. Derived per request
|
|
111
|
+
// and passed down by argument - nothing about the profile is ever stored, so the
|
|
112
|
+
// server stays stateless and two tenants can hold two different profiles at once.
|
|
113
|
+
function requestProfile(req, url) {
|
|
114
|
+
const fromQuery = url.searchParams.get('profile');
|
|
115
|
+
const raw = req.headers['x-kmhub-profile'];
|
|
116
|
+
const fromHeader = Array.isArray(raw) ? raw[0] : raw;
|
|
117
|
+
return resolveProfile((fromQuery && fromQuery.trim()) || (typeof fromHeader === 'string' ? fromHeader.trim() : ''));
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ---- one MCP request, scoped to one org's token ---------------------------
|
|
121
|
+
async function handleMcpPost(req, res, token, profile) {
|
|
122
|
+
const server = new McpServer({ name: SERVER_NAME, version: SERVER_VERSION });
|
|
123
|
+
registerTools(server, makeCaller(BASE, token), { profile });
|
|
124
|
+
|
|
125
|
+
const transport = new StreamableHTTPServerTransport({ sessionIdGenerator: undefined });
|
|
126
|
+
|
|
127
|
+
// Tear the per-request server + transport down when the response closes.
|
|
128
|
+
res.on('close', () => {
|
|
129
|
+
transport.close().catch(() => {});
|
|
130
|
+
server.close().catch(() => {});
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
await server.connect(transport);
|
|
135
|
+
// Let the transport read + parse the request body and write the response.
|
|
136
|
+
await transport.handleRequest(req, res);
|
|
137
|
+
} catch (err) {
|
|
138
|
+
console.error('kmhub-mcp remote: request error', err);
|
|
139
|
+
if (!res.headersSent) {
|
|
140
|
+
sendRpcError(res, 500, -32603, 'Internal server error');
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// ---- router ---------------------------------------------------------------
|
|
146
|
+
const httpServer = http.createServer(async (req, res) => {
|
|
147
|
+
const url = new URL(req.url, `http://${req.headers.host || 'localhost'}`);
|
|
148
|
+
const path = url.pathname.replace(/\/+$/, '') || '/';
|
|
149
|
+
|
|
150
|
+
// Health: no auth, cheap, used by pm2 / nginx / uptime monitors.
|
|
151
|
+
if (req.method === 'GET' && (path === '/health' || path === '/healthz')) {
|
|
152
|
+
return sendJson(res, 200, {
|
|
153
|
+
ok: true,
|
|
154
|
+
server: SERVER_NAME,
|
|
155
|
+
version: SERVER_VERSION,
|
|
156
|
+
transport: 'streamable-http',
|
|
157
|
+
tools: TOOL_NAMES.length,
|
|
158
|
+
families: FAMILY_NAMES,
|
|
159
|
+
default_profile: DEFAULT_PROFILE,
|
|
160
|
+
profiles: Object.fromEntries(PROFILE_NAMES.map((p) => [p, toolNamesFor(p).length])),
|
|
161
|
+
});
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
if (path === '/mcp') {
|
|
165
|
+
// Streamable HTTP stateless: only POST carries JSON-RPC. GET (server SSE) is unused.
|
|
166
|
+
if (req.method === 'GET' || req.method === 'DELETE') {
|
|
167
|
+
return sendRpcError(res, 405, -32000, 'Method not allowed: this server is stateless (POST /mcp only).', {
|
|
168
|
+
allow: 'POST',
|
|
169
|
+
});
|
|
170
|
+
}
|
|
171
|
+
if (req.method !== 'POST') {
|
|
172
|
+
return sendRpcError(res, 405, -32000, 'Method not allowed.', { allow: 'POST' });
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
const token = bearerToken(req);
|
|
176
|
+
if (!token) {
|
|
177
|
+
return sendRpcError(
|
|
178
|
+
res,
|
|
179
|
+
401,
|
|
180
|
+
-32001,
|
|
181
|
+
'Missing KM Hub API key. Send it as: Authorization: Bearer kmh_live_...',
|
|
182
|
+
{ 'www-authenticate': 'Bearer realm="kmhub-mcp"' },
|
|
183
|
+
);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const retryAfter = rateLimited(token);
|
|
187
|
+
if (retryAfter) {
|
|
188
|
+
return sendRpcError(res, 429, -32002, `Rate limit exceeded. Retry after ${retryAfter}s.`, {
|
|
189
|
+
'retry-after': String(retryAfter),
|
|
190
|
+
});
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
return handleMcpPost(req, res, token, requestProfile(req, url));
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return sendRpcError(res, 404, -32601, `Not found: ${req.method} ${path}`);
|
|
197
|
+
});
|
|
198
|
+
|
|
199
|
+
httpServer.listen(PORT, HOST, () => {
|
|
200
|
+
console.error(
|
|
201
|
+
`kmhub-mcp remote ${SERVER_VERSION}: listening on http://${HOST}:${PORT} (POST /mcp, GET /health). ` +
|
|
202
|
+
`Families: ${FAMILY_NAMES.join(', ')}. Profiles: ${PROFILE_NAMES.join(', ')} (default ${DEFAULT_PROFILE}). ` +
|
|
203
|
+
`Tools: ${TOOL_NAMES.join(', ')}.`,
|
|
204
|
+
);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
// Graceful shutdown for pm2 restarts.
|
|
208
|
+
for (const sig of ['SIGINT', 'SIGTERM']) {
|
|
209
|
+
process.on(sig, () => {
|
|
210
|
+
httpServer.close(() => process.exit(0));
|
|
211
|
+
setTimeout(() => process.exit(0), 3000).unref();
|
|
212
|
+
});
|
|
213
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: briefing - the morning handoff. One tool, one call, the whole day.
|
|
3
|
+
*
|
|
4
|
+
* WHY A WHOLE FAMILY FOR ONE TOOL
|
|
5
|
+
* Every other family is a door onto one part of the workspace. This one is the
|
|
6
|
+
* front door. A Terminal Mode session starts cold every single time: the model
|
|
7
|
+
* knows the workspace exists and knows nothing about what is happening in it. The
|
|
8
|
+
* old opening move was km_waiting, which hands back six counts, and then four or
|
|
9
|
+
* five list calls to turn those counts back into things a person can act on. That
|
|
10
|
+
* is five round trips before anyone has said a useful sentence, and a guess in the
|
|
11
|
+
* middle of it about which of the six numbers actually mattered.
|
|
12
|
+
*
|
|
13
|
+
* km_briefing is that whole opening move as one call, with the guess replaced by a
|
|
14
|
+
* ranking done on the server where the data already is. It comes back as ONE
|
|
15
|
+
* ordered list, most-worth-doing first, every entry carrying the reason it is
|
|
16
|
+
* there, plus the diary, plus the raw gate counts, plus one suggested play when
|
|
17
|
+
* the shape of the day genuinely calls for one.
|
|
18
|
+
*
|
|
19
|
+
* WHAT IT DOES NOT DO
|
|
20
|
+
* It reads. It cannot send, approve, complete, schedule or spend, and the play it
|
|
21
|
+
* suggests is never one that can spend the client's money. Everything it surfaces
|
|
22
|
+
* still ends where it ended before: a draft in the Approval Queue, a decision in
|
|
23
|
+
* front of a human.
|
|
24
|
+
*
|
|
25
|
+
* The route behind it is GET /briefing. That route owns the ranking rule and the
|
|
26
|
+
* suggestion rule, so this file passes two optional parameters through and
|
|
27
|
+
* reshapes nothing.
|
|
28
|
+
*
|
|
29
|
+
* The family contract this file follows is documented in ./README.md.
|
|
30
|
+
*/
|
|
31
|
+
import { z } from 'zod';
|
|
32
|
+
|
|
33
|
+
export const FAMILY = 'briefing';
|
|
34
|
+
|
|
35
|
+
export const TOOLS = ['km_briefing'];
|
|
36
|
+
|
|
37
|
+
// Every profile. A session that can read invoices but cannot ask what needs doing
|
|
38
|
+
// today is a session that starts every conversation from nothing. This is the one
|
|
39
|
+
// tool whose absence is felt in every other family's work.
|
|
40
|
+
export const PROFILES = ['*'];
|
|
41
|
+
|
|
42
|
+
/** The API has not shipped this route yet (404 / 405 / 501 all mean the same here). */
|
|
43
|
+
function routeMissing(r) {
|
|
44
|
+
return r.status === 404 || r.status === 405 || r.status === 501;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* If the workspace is on an older kmhub-api, say so in one sentence and name the
|
|
49
|
+
* fallback. This is the first tool of the session, so an unexplained JSON 404 here
|
|
50
|
+
* would set the tone for everything after it.
|
|
51
|
+
*/
|
|
52
|
+
const NOT_SUPPORTED =
|
|
53
|
+
'This KM Hub is on an older version of the API that does not have the morning briefing yet. Nothing is broken and no data is missing. Fall back to km_waiting for the gate counts, then km_list_outreach_drafts, km_list_outreach_replies, km_list_tasks with overdue true, km_list_invoices with overdue true, and km_my_schedule for the diary. Say plainly that you are stitching it together by hand rather than pretending you have the ranked version.';
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
57
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
58
|
+
* @param {{ out: Function, text: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
|
|
59
|
+
*/
|
|
60
|
+
export function register(server, call, { out, text, qs }) {
|
|
61
|
+
server.tool(
|
|
62
|
+
'km_briefing',
|
|
63
|
+
'THE FIRST TOOL TO REACH FOR on any open question about the day. If the user says any of "what needs me today", "what have I got on", "morning", "catch me up", "where are we", "what should I do first", "anything urgent", "what did I miss", or opens a session without a specific task, call this before anything else and answer from what it returns. ' +
|
|
64
|
+
'It REPLACES the old opening sequence entirely: km_waiting plus km_list_outreach_drafts plus km_list_outreach_replies plus km_list_tasks plus km_list_invoices plus km_my_schedule, all in one call. Do not run those first and do not run them afterwards to double check. Go to a list tool only when the user asks for more of one specific thing than the briefing carried, for example the full text of a draft (km_read_outreach_draft) or every invoice rather than only the late ones (km_list_invoices). ' +
|
|
65
|
+
'What comes back, in one response: a headline sentence you can read straight out loud; `items`, ONE list ranked in the order a person should actually deal with them, each with a plain reason it is there; `calendar`, today and the next few days including any date that is only pencilled in and has nothing signed behind it; `waiting`, the same raw gate counts km_waiting would have given you; and `suggested_play` when the shape of the day genuinely calls for one. ' +
|
|
66
|
+
'The ranking is not a list of lists. A new enquiry nobody has answered and a reply nobody has handled sit near the top because they are the things that stop being worth anything if you leave them, and money that is already late and worth real amounts outranks something merely unread. A gig today is near the top too, but as a fact to absorb rather than a decision to make. Trust the order: work down it, and do not silently re-sort it into your own idea of importance. Each item carries `score` so you can see why it sits where it does, and `next_tool` naming the read tool that opens it. ' +
|
|
67
|
+
'Read the `notes` array and the `complete` flag before you summarise. When a part of the workspace could not be read, the briefing says which part rather than quietly showing an empty list, and you must pass that on rather than telling somebody they have a clear day when you do not know that. ' +
|
|
68
|
+
'This tool only reads. It never sends, approves, completes, schedules or spends anything, and a play it suggests is never one that can spend money.',
|
|
69
|
+
{
|
|
70
|
+
days: z
|
|
71
|
+
.number()
|
|
72
|
+
.int()
|
|
73
|
+
.min(1)
|
|
74
|
+
.max(14)
|
|
75
|
+
.optional()
|
|
76
|
+
.describe('How far ahead the calendar part looks, in days including today. Default 3. Raise it when the user asks about the week or the fortnight ahead.'),
|
|
77
|
+
limit: z
|
|
78
|
+
.number()
|
|
79
|
+
.int()
|
|
80
|
+
.min(1)
|
|
81
|
+
.max(60)
|
|
82
|
+
.optional()
|
|
83
|
+
.describe('How many ranked items to return, most important first. Default 25, which is already more than a person will do in a morning. `counts.truncated` tells you when there were more.'),
|
|
84
|
+
},
|
|
85
|
+
async ({ days, limit }) => {
|
|
86
|
+
const r = await call('GET', `/briefing${qs({ days, limit })}`);
|
|
87
|
+
if (routeMissing(r)) return text(NOT_SUPPORTED, true);
|
|
88
|
+
return out(r);
|
|
89
|
+
},
|
|
90
|
+
);
|
|
91
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool family: calendar - the diary. What is on, what is free, and what one gig is.
|
|
3
|
+
*
|
|
4
|
+
* core.mjs already lists bookings and calendar events. This family is for the
|
|
5
|
+
* questions a performer asks in a car park at 5pm, where the answer has to
|
|
6
|
+
* arrive in one tool call and be readable out loud:
|
|
7
|
+
*
|
|
8
|
+
* km_my_schedule what have I got this week
|
|
9
|
+
* km_check_availability am I free on that date, and at that hour
|
|
10
|
+
* km_get_booking tell me everything about this gig
|
|
11
|
+
* km_update_booking fix the details of a gig that is already agreed
|
|
12
|
+
* km_blocked_dates which days are already spoken for
|
|
13
|
+
*
|
|
14
|
+
* The availability answer is computed by the same database functions that drive
|
|
15
|
+
* the public booking widget and the lead gate, never re-derived on the way past,
|
|
16
|
+
* because a second opinion about a free Saturday is how a real person ends up
|
|
17
|
+
* double booked. When those functions decline to answer, the tool says it cannot
|
|
18
|
+
* tell rather than guessing "free".
|
|
19
|
+
*
|
|
20
|
+
* km_update_booking is deliberately the narrowest write in the whole server. It
|
|
21
|
+
* cannot move a date or a time, cannot touch money, and cannot cancel or
|
|
22
|
+
* complete anything, because those commit the business to something a person
|
|
23
|
+
* should be the one to commit to. It can confirm, and the description says in
|
|
24
|
+
* plain words what confirming does to the calendar.
|
|
25
|
+
*
|
|
26
|
+
* The family contract this file follows is documented in ./README.md.
|
|
27
|
+
*/
|
|
28
|
+
import { z } from 'zod';
|
|
29
|
+
|
|
30
|
+
export const FAMILY = 'calendar';
|
|
31
|
+
|
|
32
|
+
export const TOOLS = [
|
|
33
|
+
'km_my_schedule',
|
|
34
|
+
'km_check_availability',
|
|
35
|
+
'km_get_booking',
|
|
36
|
+
'km_update_booking',
|
|
37
|
+
'km_blocked_dates',
|
|
38
|
+
];
|
|
39
|
+
|
|
40
|
+
// The diary is the context every other family reasons against: a money question
|
|
41
|
+
// and a follow-up question both start with what is already on the books. It
|
|
42
|
+
// carries five tools, so the cost of joining every profile is small.
|
|
43
|
+
export const PROFILES = ['*'];
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* @param {{ tool: Function }} server guarded registrar (see ./README.md)
|
|
47
|
+
* @param {(method: string, path: string, body?: any) => Promise<{ok:boolean,status:number,data:any}>} call
|
|
48
|
+
* @param {{ out: Function, qs: Function }} helpers shared response helpers from ../tools.mjs
|
|
49
|
+
*/
|
|
50
|
+
export function register(server, call, { out, qs }) {
|
|
51
|
+
server.tool(
|
|
52
|
+
'km_my_schedule',
|
|
53
|
+
'What is on in KM Hub, day by day, in one call. Reach for this when the question is specifically '
|
|
54
|
+
+ 'about the DIARY: a named week, a named day, or the next gig. "What have I got this week", '
|
|
55
|
+
+ '"am I busy Saturday", "when is my next gig". Do NOT answer the broad question about the day from '
|
|
56
|
+
+ 'here: if the person says "what needs me today", "catch me up", "what should I do first", or just '
|
|
57
|
+
+ 'opens a session with nothing specific, km_briefing is the tool. It carries this diary AND the '
|
|
58
|
+
+ 'enquiries, replies, tasks and money that are waiting, ranked against each other, none of which '
|
|
59
|
+
+ 'this tool can see. Running both means paying for the same diary twice. It returns a plain-English '
|
|
60
|
+
+ 'headline, one readable line per day, and the detail behind each line: the gigs with their times, '
|
|
61
|
+
+ 'client, venue, guest count and fee, anything else in the diary, days blocked off, and dates that '
|
|
62
|
+
+ 'are only pencilled in by a live lead. Days are counted in the workspace timezone, so "today" means '
|
|
63
|
+
+ 'today where the performer is. Defaults to the next 7 days starting today. Read only: it never '
|
|
64
|
+
+ 'changes anything. Use km_check_availability instead when the question is whether a specific date '
|
|
65
|
+
+ 'is free rather than what is already booked.',
|
|
66
|
+
{
|
|
67
|
+
from: z.string().optional().describe('First day, YYYY-MM-DD. Defaults to today in the workspace timezone.'),
|
|
68
|
+
days: z.number().int().min(1).max(31).optional().describe('How many days to cover, 1 to 31. Default 7.'),
|
|
69
|
+
},
|
|
70
|
+
async ({ from, days }) => out(await call('GET', `/schedule${qs({ from, days })}`)),
|
|
71
|
+
);
|
|
72
|
+
|
|
73
|
+
server.tool(
|
|
74
|
+
'km_check_availability',
|
|
75
|
+
'Is a date free for a booking. Ask this before telling anyone a date might work. The verdict comes '
|
|
76
|
+
+ 'from the same availability check that the public booking page and the lead forms use, so it '
|
|
77
|
+
+ 'already counts confirmed bookings, days blocked off by hand, weekends the workspace does not work, '
|
|
78
|
+
+ 'and dates a live lead has pencilled in. Give start_time (and optionally end_time or '
|
|
79
|
+
+ 'duration_minutes, plus setup, packdown and travel padding) and it answers for that time window '
|
|
80
|
+
+ 'instead of the whole day, so two non-overlapping gigs on one day both read as possible. Alongside '
|
|
81
|
+
+ 'each verdict it lists what is actually in the diary that day, so a "taken" comes with the gig that '
|
|
82
|
+
+ 'is taking it. If the workspace has switched its availability check off, the tool reports that it '
|
|
83
|
+
+ 'cannot give a verdict and shows the diary instead: it will never call a day free when it does not '
|
|
84
|
+
+ 'know. Read only, and it never reserves or holds anything.',
|
|
85
|
+
{
|
|
86
|
+
from: z.string().optional().describe('First date to check, YYYY-MM-DD. Defaults to today in the workspace timezone.'),
|
|
87
|
+
to: z.string().optional().describe('Last date to check, YYYY-MM-DD. Defaults to the same day as from. Up to 92 days for a whole-day check, 14 days when a start_time is given.'),
|
|
88
|
+
start_time: z.string().optional().describe('Start of the slot, HH:MM in 24 hour time. Supply it to check a time window rather than the whole day.'),
|
|
89
|
+
end_time: z.string().optional().describe('End of the slot, HH:MM in 24 hour time. Only meaningful with start_time; without it the workspace default duration is used.'),
|
|
90
|
+
duration_minutes: z.number().int().min(1).max(1440).optional().describe('How long the gig runs, when end_time is unknown.'),
|
|
91
|
+
buffer_before_minutes: z.number().int().min(0).max(1440).optional().describe('Setup time needed before the slot.'),
|
|
92
|
+
buffer_after_minutes: z.number().int().min(0).max(1440).optional().describe('Packdown time needed after the slot.'),
|
|
93
|
+
travel_minutes: z.number().int().min(0).max(1440).optional().describe('Travel time each way, added to both ends of the window.'),
|
|
94
|
+
},
|
|
95
|
+
async (args) => out(await call('GET', `/availability${qs(args)}`)),
|
|
96
|
+
);
|
|
97
|
+
|
|
98
|
+
server.tool(
|
|
99
|
+
'km_get_booking',
|
|
100
|
+
'Everything about one booking in KM Hub: the date, the start, end and setup times, the client with '
|
|
101
|
+
+ 'their phone and email, the venue with its address and on-site contact, the event type and guest '
|
|
102
|
+
+ 'count, the fee with deposit and outstanding balance, the client notes, internal notes and special '
|
|
103
|
+
+ 'requirements, the pipeline deal it belongs to, and any diary entries attached to it. This is the '
|
|
104
|
+
+ 'tool for "what is this gig", "where am I playing on Saturday", "who is the contact for the Kaplan '
|
|
105
|
+
+ 'wedding" and "what do I need to bring". Needs the booking id, which km_my_schedule and '
|
|
106
|
+
+ 'km_list_bookings both return. Read only.',
|
|
107
|
+
{
|
|
108
|
+
booking_id: z.string().describe('UUID of the booking (from km_my_schedule or km_list_bookings).'),
|
|
109
|
+
},
|
|
110
|
+
async ({ booking_id }) => out(await call('GET', `/bookings/${encodeURIComponent(String(booking_id || '').trim())}`)),
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
server.tool(
|
|
114
|
+
'km_update_booking',
|
|
115
|
+
'Correct the working details of a gig that is already agreed: its title, event type, guest count, '
|
|
116
|
+
+ 'setup time, venue, client notes, internal notes, special requirements, and its status. '
|
|
117
|
+
+ 'CONFIRMING IS CONSEQUENTIAL: setting status to confirmed marks that whole date as taken across '
|
|
118
|
+
+ 'KM Hub, so the public booking page, the lead forms and every availability check start turning '
|
|
119
|
+
+ 'other people away from it. Moving the status back to pending releases the date and offers it '
|
|
120
|
+
+ 'again. Only make that call when the performer has actually said the gig is on or off. '
|
|
121
|
+
+ 'WHAT THIS TOOL WILL NOT DO, by design, so do not try: it cannot move a booking to another date, '
|
|
122
|
+
+ 'cannot change the start or end time, cannot change the fee, deposit or balance, cannot mark money '
|
|
123
|
+
+ 'as paid, cannot mark a gig completed, and cannot cancel one. Those bind the business or free a '
|
|
124
|
+
+ 'date for someone else, so a person does them in KM Hub. It also refuses to touch a gig that is '
|
|
125
|
+
+ 'already completed or cancelled, and any booking owned by the meeting scheduler. Every change is '
|
|
126
|
+
+ 'written to the AI activity log so the owner can see it; booking edits are not auto-undoable, so '
|
|
127
|
+
+ 'get it right rather than counting on undo.',
|
|
128
|
+
{
|
|
129
|
+
booking_id: z.string().describe('UUID of the booking (from km_my_schedule or km_list_bookings).'),
|
|
130
|
+
title: z.string().optional().describe('What the gig is called on the calendar.'),
|
|
131
|
+
event_type: z.string().optional().describe('e.g. wedding, corporate, birthday. The value "meeting" is reserved by the scheduler and is refused.'),
|
|
132
|
+
guest_count: z.number().int().min(0).nullable().optional().describe('How many people are expected. Send null to clear it.'),
|
|
133
|
+
setup_start_time: z.string().nullable().optional().describe('Load-in time, HH:MM in 24 hour time. This is the performer\'s own logistics, not the event start. Send null to clear it.'),
|
|
134
|
+
venue_id: z.string().optional().describe('UUID of a venue already in this workspace.'),
|
|
135
|
+
client_notes: z.string().optional().describe('Notes the client would be shown.'),
|
|
136
|
+
internal_notes: z.string().optional().describe('Notes only the team sees.'),
|
|
137
|
+
special_requirements: z.string().optional().describe('Anything the gig needs: access, power, dress code, dietary.'),
|
|
138
|
+
status: z
|
|
139
|
+
.enum(['pending', 'confirmed', 'in_progress'])
|
|
140
|
+
.optional()
|
|
141
|
+
.describe('confirmed marks the date taken everywhere; pending releases it; in_progress means the gig is under way. Cancelled and completed are not available here.'),
|
|
142
|
+
},
|
|
143
|
+
async ({ booking_id, ...changes }) =>
|
|
144
|
+
out(await call('PATCH', `/bookings/${encodeURIComponent(String(booking_id || '').trim())}`, changes)),
|
|
145
|
+
);
|
|
146
|
+
|
|
147
|
+
server.tool(
|
|
148
|
+
'km_blocked_dates',
|
|
149
|
+
'The days that are already spoken for in KM Hub without being a gig: dates blocked off by hand with '
|
|
150
|
+
+ 'their reason (holiday, family, studio time), and active holds, including dates a live lead has '
|
|
151
|
+
+ 'pencilled in and that will expire on their own if the lead goes cold. Use it to answer "what am I '
|
|
152
|
+
+ 'blocked out for", "why does that weekend show as unavailable", or before suggesting dates to '
|
|
153
|
+
+ 'someone. Defaults to the next 90 days. Read only: blocking or unblocking a date decides whether '
|
|
154
|
+
+ 'real work can be booked, so a person does that in KM Hub.',
|
|
155
|
+
{
|
|
156
|
+
from: z.string().optional().describe('First date, YYYY-MM-DD. Defaults to today in the workspace timezone.'),
|
|
157
|
+
to: z.string().optional().describe('Last date, YYYY-MM-DD. Defaults to 90 days out. At most a year at a time.'),
|
|
158
|
+
},
|
|
159
|
+
async ({ from, to }) => out(await call('GET', `/blocked-dates${qs({ from, to })}`)),
|
|
160
|
+
);
|
|
161
|
+
}
|