@opennous/mcp 0.8.9 → 0.10.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/LICENSE +661 -0
- package/package.json +17 -8
- package/src/index.js +253 -573
package/src/index.js
CHANGED
|
@@ -1,670 +1,350 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
* Nous MCP Server —
|
|
4
|
+
* Nous MCP Server — the context layer for GTM agents.
|
|
5
5
|
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
6
|
+
* Reads from and writes to the v2 evidence substrate via the Context API.
|
|
7
|
+
* The agent never sees raw rows — it gets engineered, epistemics-tagged
|
|
8
|
+
* context. It never "updates" — it records observations; Nous derives.
|
|
8
9
|
*
|
|
10
|
+
* Required env:
|
|
11
|
+
* NOUS_API_KEY — workspace API key (Settings -> API Keys)
|
|
9
12
|
* Optional:
|
|
10
|
-
* NOUS_API_URL
|
|
13
|
+
* NOUS_API_URL — API base URL (default: https://api.opennous.cloud)
|
|
11
14
|
*
|
|
12
|
-
* Tools:
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
20
|
-
* search — semantic search across workspace memories
|
|
15
|
+
* Tools (all v2 — thin clients of the Context API):
|
|
16
|
+
* get_context — engineered context for a task (draft_email, follow_up, ...)
|
|
17
|
+
* get_account — the full account record: every claim + the timeline
|
|
18
|
+
* record — record what happened / what you learned (observe, never update)
|
|
19
|
+
* query — retrieve + summarise a corpus of activity across many people
|
|
20
|
+
* attention — what needs your attention (accounts gone quiet, facts decayed)
|
|
21
|
+
* verify — re-check a fact before acting on it
|
|
22
|
+
* get_workspace_facts — workspace-level facts (ICP, market, pricing, product, competitors)
|
|
21
23
|
*/
|
|
22
24
|
|
|
23
25
|
import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
24
26
|
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
25
27
|
import { z } from "zod";
|
|
26
|
-
import { validateConfig, get, post
|
|
28
|
+
import { validateConfig, get, post } from "./client.js";
|
|
27
29
|
|
|
28
|
-
// ───
|
|
29
|
-
|
|
30
|
-
// Activity types with known direction
|
|
31
|
-
const ALWAYS_OUT = new Set(["email_sent", "follow_up_sent", "proposal_sent"]);
|
|
32
|
-
const ALWAYS_IN = new Set(["website_visit", "content_download", "trial_started", "email_reply", "linkedin_replied"]);
|
|
33
|
-
const OUT_SOURCES = new Set(["agent", "mcp", "sdk", "api"]);
|
|
34
|
-
|
|
35
|
-
function actDir(a) {
|
|
36
|
-
if (ALWAYS_OUT.has(a.type)) return "out";
|
|
37
|
-
if (ALWAYS_IN.has(a.type)) return "in";
|
|
38
|
-
// LinkedIn webhook captures both sent and received — check description
|
|
39
|
-
if (a.source === "linkedin" && a.type === "linkedin_message") {
|
|
40
|
-
const d = (a.description || "").toLowerCase();
|
|
41
|
-
if (d.startsWith("linkedin message sent") || d.includes(" sent to ")) return "out";
|
|
42
|
-
return "in";
|
|
43
|
-
}
|
|
44
|
-
if (OUT_SOURCES.has(a.source)) return "out";
|
|
45
|
-
if (a.source) return "in"; // any other source = webhook-delivered = inbound
|
|
46
|
-
return "neutral";
|
|
47
|
-
}
|
|
48
|
-
|
|
49
|
-
const ARROW = { out: "→", in: "←", neutral: "↔" };
|
|
50
|
-
|
|
51
|
-
// Higher = show first when capping 7-30d to top 3
|
|
52
|
-
const SIGNAL_WEIGHT = {
|
|
53
|
-
proposal_sent: 10, trial_started: 9, call_held: 8, meeting_held: 8,
|
|
54
|
-
email_reply: 6, email_sent: 6, follow_up_sent: 5,
|
|
55
|
-
linkedin_message: 4, content_download: 4,
|
|
56
|
-
website_visit: 3, linkedin_connected: 3, manual_note: 2,
|
|
57
|
-
};
|
|
30
|
+
// ─── helpers ──────────────────────────────────────────────────────────────────
|
|
58
31
|
|
|
59
32
|
function relAge(ts) {
|
|
33
|
+
if (!ts) return "—";
|
|
60
34
|
const d = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
|
|
61
|
-
if (d < 1)
|
|
35
|
+
if (d < 1) return "today";
|
|
62
36
|
if (d === 1) return "1d ago";
|
|
63
|
-
if (d < 30)
|
|
37
|
+
if (d < 30) return `${d}d ago`;
|
|
64
38
|
const m = Math.floor(d / 30);
|
|
65
|
-
if (m < 12)
|
|
39
|
+
if (m < 12) return `${m}mo ago`;
|
|
66
40
|
return `${Math.floor(m / 12)}y ago`;
|
|
67
41
|
}
|
|
68
42
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
function fmtType(t) {
|
|
74
|
-
return t.replace(/_/g, " ");
|
|
75
|
-
}
|
|
43
|
+
const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " ");
|
|
44
|
+
const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
|
|
45
|
+
const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
|
|
76
46
|
|
|
77
47
|
validateConfig();
|
|
78
48
|
|
|
79
49
|
const server = new McpServer({
|
|
80
50
|
name: "nous",
|
|
81
|
-
version: "0.
|
|
82
|
-
description:
|
|
51
|
+
version: "0.10.0",
|
|
52
|
+
description:
|
|
53
|
+
"Nous — the context layer for GTM agents. Call get_context before drafting outreach or " +
|
|
54
|
+
"preparing for a meeting. Call record after every interaction, or whenever you learn something.",
|
|
83
55
|
icons: [
|
|
84
56
|
{ src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
|
|
85
57
|
],
|
|
86
58
|
});
|
|
87
59
|
|
|
88
|
-
//
|
|
89
|
-
// TOOL:
|
|
90
|
-
// The
|
|
91
|
-
//
|
|
92
|
-
// ---------------------------------------------------------------------------
|
|
60
|
+
// ===========================================================================
|
|
61
|
+
// TOOL: get_context — POST /v2/context
|
|
62
|
+
// The headline tool. Engineered, intent-shaped context for a specific task.
|
|
63
|
+
// ===========================================================================
|
|
93
64
|
server.tool(
|
|
94
|
-
"
|
|
95
|
-
"Get
|
|
65
|
+
"get_context",
|
|
66
|
+
"Get engineered context for a specific task about a person or company. Pass their email (or " +
|
|
67
|
+
"entity id) and the intent. Returns a focused, ranked context block: the facts that matter for " +
|
|
68
|
+
"that task — each with a confidence and a freshness — plus the recent timeline, the buying-group " +
|
|
69
|
+
"stakeholders, and open predictions. Call this before drafting outreach, preparing for a meeting, " +
|
|
70
|
+
"or making any decision about a person. A fact's freshness tells you whether to trust it: 'fresh' " +
|
|
71
|
+
"act on it, 'suspect'/'expired' verify first.",
|
|
96
72
|
{
|
|
97
|
-
|
|
98
|
-
|
|
73
|
+
focus: z.string().describe("Who to look up — an email, a LinkedIn URL, a domain, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
|
|
74
|
+
intent: z.enum(["draft_email", "follow_up", "meeting_prep", "call_prep", "account_review"])
|
|
75
|
+
.optional()
|
|
76
|
+
.describe("What you are about to do — shapes which context surfaces (default: account_review)"),
|
|
77
|
+
budget_tokens: z.number().optional().describe("Approximate token budget for the context block"),
|
|
99
78
|
},
|
|
100
|
-
async ({
|
|
101
|
-
|
|
102
|
-
|
|
79
|
+
async ({ focus, intent, budget_tokens }) => {
|
|
80
|
+
const ctx = await post("/v2/context", { focus, intent: intent ?? "account_review", budget_tokens });
|
|
81
|
+
|
|
82
|
+
// a name matched several people — surface the candidates to choose from
|
|
83
|
+
if (ctx.status === "ambiguous") {
|
|
84
|
+
const opts = (ctx.candidates ?? []).map(c =>
|
|
85
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
86
|
+
return { content: [{ type: "text", text:
|
|
87
|
+
`"${focus}" matches several people. Call get_context again with one of these entity ids:\n${opts}` }] };
|
|
103
88
|
}
|
|
104
89
|
|
|
105
|
-
const
|
|
106
|
-
const since90 = new Date(Date.now() - 90 * 86400000).toISOString();
|
|
107
|
-
|
|
108
|
-
const [c, actData] = await Promise.all([
|
|
109
|
-
get(`/v1/contacts/${identifier}`),
|
|
110
|
-
get(`/v1/contacts/${identifier}/activity`, { limit: 100, since: since90 }).catch(() => ({ activities: [] })),
|
|
111
|
-
]);
|
|
112
|
-
|
|
113
|
-
const contactId = c.id || c.contact_id;
|
|
114
|
-
const name = c.name || c.email;
|
|
115
|
-
const header = [name, c.title, c.company].filter(Boolean).join(" · ");
|
|
116
|
-
const scores = [
|
|
117
|
-
`Stage: ${c.pipeline_stage || "identified"}`,
|
|
118
|
-
c.icp_score != null ? `ICP: ${c.icp_score}` : null,
|
|
119
|
-
c.deal_health_score != null ? `Health: ${c.deal_health_score}` : null,
|
|
120
|
-
].filter(Boolean).join(" | ");
|
|
90
|
+
const lines = [ctx.summary, ""];
|
|
121
91
|
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
const now = Date.now();
|
|
127
|
-
const ms7 = 7 * 86400000;
|
|
128
|
-
const ms30 = 30 * 86400000;
|
|
129
|
-
|
|
130
|
-
const hot = []; // 0–7 days → full body + direction arrow
|
|
131
|
-
const warm = []; // 7–30 days → description + direction arrow, capped to top 3 by signal weight
|
|
132
|
-
const counts = {}; // 30–90 days → counts by type
|
|
133
|
-
|
|
134
|
-
for (const a of activities) {
|
|
135
|
-
const age = now - new Date(a.occurred_at).getTime();
|
|
136
|
-
if (age < ms7) hot.push(a);
|
|
137
|
-
else if (age < ms30) warm.push(a);
|
|
138
|
-
else counts[a.type] = (counts[a.type] || 0) + 1;
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
// ── Status line — derived from most recent activity direction ─────────────
|
|
142
|
-
if (activities.length) {
|
|
143
|
-
const latest = activities[0];
|
|
144
|
-
const dir = actDir(latest);
|
|
145
|
-
const when = relAge(latest.occurred_at);
|
|
146
|
-
if (dir === "in") {
|
|
147
|
-
lines.push(`\nStatus: ← Waiting on your reply · ${when}`);
|
|
148
|
-
} else if (dir === "out") {
|
|
149
|
-
lines.push(`\nStatus: → You reached out · ${when}`);
|
|
150
|
-
} else {
|
|
151
|
-
lines.push(`\nStatus: ↔ Last touch · ${when}`);
|
|
92
|
+
if (ctx.claims?.length) {
|
|
93
|
+
lines.push(`FACTS (${ctx.meta?.claims_returned ?? ctx.claims.length}):`);
|
|
94
|
+
for (const c of ctx.claims) {
|
|
95
|
+
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
152
96
|
}
|
|
97
|
+
lines.push("");
|
|
153
98
|
}
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
lines.push(
|
|
99
|
+
if (ctx.workspace?.length) {
|
|
100
|
+
lines.push("YOUR CONTEXT (ICP / product / positioning):");
|
|
101
|
+
for (const w of ctx.workspace) lines.push(` ${w.property}: ${fmtVal(w.value)}`);
|
|
102
|
+
lines.push("");
|
|
157
103
|
}
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
const arrow = ARROW[actDir(a)];
|
|
164
|
-
const content = a.body || a.description || null;
|
|
165
|
-
lines.push(` ${arrow} ${fmtShortDate(a.occurred_at)} ${fmtType(a.type)}${content ? `: "${content}"` : ""}`);
|
|
166
|
-
}
|
|
104
|
+
if (ctx.timeline?.length) {
|
|
105
|
+
lines.push("TIMELINE:");
|
|
106
|
+
for (const t of ctx.timeline) {
|
|
107
|
+
if (t.tier === "count") lines.push(` ${t.count}× ${fmtType(t.type)}`);
|
|
108
|
+
else lines.push(` ${relAge(t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
|
|
167
109
|
}
|
|
168
|
-
|
|
169
|
-
const topWarm = warm
|
|
170
|
-
.slice()
|
|
171
|
-
.sort((a, b) => (SIGNAL_WEIGHT[b.type] ?? 0) - (SIGNAL_WEIGHT[a.type] ?? 0))
|
|
172
|
-
.slice(0, 3);
|
|
173
|
-
lines.push(`\n7–30 days (showing ${topWarm.length} of ${warm.length}):`);
|
|
174
|
-
for (const a of topWarm) {
|
|
175
|
-
const arrow = ARROW[actDir(a)];
|
|
176
|
-
const desc = a.description ? `: ${a.description}` : "";
|
|
177
|
-
lines.push(` ${arrow} ${fmtShortDate(a.occurred_at)} ${fmtType(a.type)}${desc}`);
|
|
178
|
-
}
|
|
179
|
-
}
|
|
180
|
-
if (Object.keys(counts).length) {
|
|
181
|
-
const summary = Object.entries(counts)
|
|
182
|
-
.sort(([, a], [, b]) => b - a)
|
|
183
|
-
.map(([t, n]) => `${n}× ${fmtType(t)}`)
|
|
184
|
-
.join(" · ");
|
|
185
|
-
lines.push(`\nHistory (30–90d): ${summary}`);
|
|
186
|
-
}
|
|
187
|
-
} else {
|
|
188
|
-
lines.push("\nNo activity in the last 90 days.");
|
|
110
|
+
lines.push("");
|
|
189
111
|
}
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
const companyFacts = allFacts.filter(f => f.scope === "company");
|
|
195
|
-
|
|
196
|
-
if (contactFacts.length) {
|
|
197
|
-
const shown = contactFacts.slice(0, 5);
|
|
198
|
-
const extra = contactFacts.length - shown.length;
|
|
199
|
-
lines.push(`\nFacts${extra > 0 ? ` (+${extra} more)` : ""}:`);
|
|
200
|
-
for (const f of shown) {
|
|
201
|
-
const age = f.written_at ? ` · ${relAge(f.written_at)}` : "";
|
|
202
|
-
lines.push(` [${f.category}] ${f.content}${age}`);
|
|
203
|
-
}
|
|
112
|
+
if (ctx.stakeholders?.length) {
|
|
113
|
+
lines.push("STAKEHOLDERS:");
|
|
114
|
+
for (const s of ctx.stakeholders) lines.push(` ${s.name ?? "—"} — ${s.role ?? ""}`);
|
|
115
|
+
lines.push("");
|
|
204
116
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
const age = f.written_at ? ` · ${relAge(f.written_at)}` : "";
|
|
210
|
-
lines.push(` [${f.category}] ${f.content}${age}`);
|
|
117
|
+
if (ctx.predictions?.length) {
|
|
118
|
+
lines.push("PREDICTIONS:");
|
|
119
|
+
for (const p of ctx.predictions) {
|
|
120
|
+
lines.push(` ${p.kind}: ${fmtVal(p.value)} (${pct(p.confidence)})`);
|
|
211
121
|
}
|
|
212
122
|
}
|
|
213
|
-
|
|
214
|
-
const text = lines.join("\n");
|
|
215
123
|
return {
|
|
216
|
-
content: [{
|
|
217
|
-
type: "text",
|
|
218
|
-
text: `${text}\n\n(contact_id: ${contactId}${c.company_id ? ` · company_id: ${c.company_id}` : ""})`,
|
|
219
|
-
}],
|
|
124
|
+
content: [{ type: "text", text: `${lines.join("\n").trim()}\n\n(entity_id: ${ctx.entity?.id})` }],
|
|
220
125
|
};
|
|
221
126
|
}
|
|
222
127
|
);
|
|
223
128
|
|
|
224
|
-
//
|
|
225
|
-
// TOOL:
|
|
226
|
-
//
|
|
227
|
-
//
|
|
228
|
-
// ---------------------------------------------------------------------------
|
|
229
|
-
|
|
230
|
-
const TRACK_NEXT_STEP = {
|
|
231
|
-
call_held: "Call remember if you learned something worth keeping.",
|
|
232
|
-
meeting_held: "Call remember if you learned something worth keeping.",
|
|
233
|
-
email_reply: "They replied — draft your response or update the pipeline stage.",
|
|
234
|
-
linkedin_replied: "They replied — draft your response or update the pipeline stage.",
|
|
235
|
-
proposal_sent: "Flag for follow-up if no reply in 3 days.",
|
|
236
|
-
follow_up_sent: "Consider updating the pipeline stage if they're not engaging.",
|
|
237
|
-
trial_started: "High-intent signal — consider moving to evaluating stage.",
|
|
238
|
-
linkedin_connected: "Good opener — send an intro message.",
|
|
239
|
-
};
|
|
240
|
-
|
|
129
|
+
// ===========================================================================
|
|
130
|
+
// TOOL: get_account — GET /v2/accounts/:id
|
|
131
|
+
// The full account-record projection. For a focused view, prefer get_context.
|
|
132
|
+
// ===========================================================================
|
|
241
133
|
server.tool(
|
|
242
|
-
"
|
|
243
|
-
"
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
async ({ email, contact_id, type, description, occurred_at }) => {
|
|
259
|
-
if (!email && !contact_id) {
|
|
260
|
-
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
const body = { source: "agent", type };
|
|
264
|
-
if (contact_id) body.contact_id = contact_id;
|
|
265
|
-
else body.email = email;
|
|
266
|
-
if (description) body.description = description;
|
|
267
|
-
if (occurred_at) body.occurred_at = occurred_at;
|
|
268
|
-
|
|
269
|
-
const result = await post("/v1/track", body);
|
|
270
|
-
|
|
271
|
-
const parts = [`Logged \`${type}\`${description ? `: "${description}"` : ""}.`];
|
|
272
|
-
|
|
273
|
-
if (result.created_contact) {
|
|
274
|
-
parts.push(`(New contact created — contact_id: ${result.contact_id})`);
|
|
134
|
+
"get_account",
|
|
135
|
+
"Get the full account record for a person or company — every known fact (claim) with its " +
|
|
136
|
+
"confidence and freshness, plus the recent activity timeline. Pass an email or entity UUID. " +
|
|
137
|
+
"For a task-specific, ranked view, prefer get_context.",
|
|
138
|
+
{ id: z.string().describe("Email address or entity UUID") },
|
|
139
|
+
async ({ id }) => {
|
|
140
|
+
const rec = await get(`/v2/accounts/${encodeURIComponent(id)}`);
|
|
141
|
+
const lines = [`${rec.type} · ${rec.entity_id}`, ""];
|
|
142
|
+
|
|
143
|
+
const claims = Object.values(rec.claims ?? {});
|
|
144
|
+
if (claims.length) {
|
|
145
|
+
lines.push(`FACTS (${claims.length}):`);
|
|
146
|
+
for (const c of claims) {
|
|
147
|
+
lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
|
|
148
|
+
}
|
|
149
|
+
lines.push("");
|
|
275
150
|
}
|
|
276
|
-
|
|
277
|
-
if (
|
|
278
|
-
|
|
151
|
+
const obs = rec.recent_observations ?? [];
|
|
152
|
+
if (obs.length) {
|
|
153
|
+
lines.push(`TIMELINE (${obs.length}):`);
|
|
154
|
+
for (const o of obs.slice(0, 30)) {
|
|
155
|
+
lines.push(` ${relAge(o.observed_at)} ${fmtType(o.property)}`);
|
|
156
|
+
}
|
|
279
157
|
}
|
|
280
|
-
|
|
281
|
-
const hint = TRACK_NEXT_STEP[type];
|
|
282
|
-
if (hint) parts.push(`Next: ${hint}`);
|
|
283
|
-
|
|
284
|
-
return {
|
|
285
|
-
content: [{ type: "text", text: parts.join("\n") }],
|
|
286
|
-
};
|
|
158
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
287
159
|
}
|
|
288
160
|
);
|
|
289
161
|
|
|
290
|
-
//
|
|
291
|
-
// TOOL:
|
|
292
|
-
//
|
|
293
|
-
//
|
|
294
|
-
// "Sent email on Tuesday" = log (use track for that).
|
|
295
|
-
// ---------------------------------------------------------------------------
|
|
162
|
+
// ===========================================================================
|
|
163
|
+
// TOOL: record — POST /v2/observations
|
|
164
|
+
// The single write verb. You observe — Nous derives the updated facts.
|
|
165
|
+
// ===========================================================================
|
|
296
166
|
server.tool(
|
|
297
|
-
"
|
|
298
|
-
"
|
|
167
|
+
"record",
|
|
168
|
+
"Record what happened or what you learned about a person or company. You never overwrite " +
|
|
169
|
+
"anything — you observe, and Nous derives the updated facts. Use kind:'event' for an interaction " +
|
|
170
|
+
"(property like 'interaction.email_sent', 'interaction.call_held', 'interaction.email_reply') and " +
|
|
171
|
+
"kind:'state' for a fact (property like 'job_title', 'deal.proposal_amount'). Examples — sent an " +
|
|
172
|
+
"email: {kind:'event',property:'interaction.email_sent',value:{description:'intro email'}}; " +
|
|
173
|
+
"learned their title changed: {kind:'state',property:'job_title',value:'VP of Engineering'}; " +
|
|
174
|
+
"a fact ended (they left): {kind:'state',property:'job_title',value:null}.",
|
|
299
175
|
{
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
.optional()
|
|
305
|
-
.describe("
|
|
306
|
-
|
|
176
|
+
focus: z.string().describe("Email address or entity UUID of the person or company"),
|
|
177
|
+
observations: z.array(z.object({
|
|
178
|
+
kind: z.enum(["event", "state"]).describe("event = an interaction; state = a fact"),
|
|
179
|
+
property: z.string().describe("e.g. 'interaction.email_sent' or 'job_title'"),
|
|
180
|
+
value: z.any().optional().describe("the event detail or the fact value; null = the fact ended"),
|
|
181
|
+
source: z.string().optional().describe("where this came from (default: agent)"),
|
|
182
|
+
})).describe("One or more observations to record"),
|
|
307
183
|
},
|
|
308
|
-
async ({
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
if (
|
|
312
|
-
|
|
313
|
-
const c = await get(`/v1/contact/${identifier}`);
|
|
314
|
-
if (!c.company_id) {
|
|
315
|
-
return { content: [{ type: "text", text: "Cannot scope to company — contact has no company_id. Storing in workspace memory instead." }] };
|
|
316
|
-
}
|
|
317
|
-
body.company_id = c.company_id;
|
|
318
|
-
} else if (contact_id) {
|
|
319
|
-
body.contact_id = contact_id;
|
|
320
|
-
} else if (email) {
|
|
321
|
-
body.email = email;
|
|
184
|
+
async ({ focus, observations }) => {
|
|
185
|
+
const result = await post("/v2/observations", { focus, observations });
|
|
186
|
+
const parts = [`Recorded ${result.recorded} observation${result.recorded !== 1 ? "s" : ""}.`];
|
|
187
|
+
if (result.claims_recomputed?.length) {
|
|
188
|
+
parts.push(`Facts updated: ${result.claims_recomputed.join(", ")}.`);
|
|
322
189
|
}
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
const result = await post("/v1/remember", body);
|
|
326
|
-
const stored = result.stored ?? 0;
|
|
327
|
-
|
|
328
|
-
if (stored === 0) {
|
|
329
|
-
return { content: [{ type: "text", text: "No new facts extracted — already known or too vague." }] };
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const scope = company ? "company memory" : (contact_id || email) ? "contact memory" : "workspace memory";
|
|
333
|
-
const factList = (result.facts ?? []).map(f => {
|
|
334
|
-
const superseded = f.superseded ? " (superseded previous)" : "";
|
|
335
|
-
return `"${f.content}"${superseded}`;
|
|
336
|
-
}).join(", ");
|
|
337
|
-
|
|
338
|
-
return {
|
|
339
|
-
content: [{
|
|
340
|
-
type: "text",
|
|
341
|
-
text: `Stored ${stored} fact${stored !== 1 ? "s" : ""} in ${scope} [${category}]: ${factList}`,
|
|
342
|
-
}],
|
|
343
|
-
};
|
|
190
|
+
parts.push(`(entity_id: ${result.entity_id})`);
|
|
191
|
+
return { content: [{ type: "text", text: parts.join("\n") }] };
|
|
344
192
|
}
|
|
345
193
|
);
|
|
346
194
|
|
|
347
|
-
//
|
|
348
|
-
// TOOL:
|
|
349
|
-
//
|
|
350
|
-
//
|
|
195
|
+
// ===========================================================================
|
|
196
|
+
// TOOL: query — POST /v2/query
|
|
197
|
+
// Retrieve a corpus of activity across many people. You do the analysis.
|
|
198
|
+
// ===========================================================================
|
|
351
199
|
server.tool(
|
|
352
|
-
"
|
|
353
|
-
"
|
|
200
|
+
"query",
|
|
201
|
+
"Retrieve and summarise activity across many people. Three powers:\n" +
|
|
202
|
+
" 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
|
|
203
|
+
"most-recent matching activity). Use for 'hottest leads', 'who replied this week', " +
|
|
204
|
+
"'who's in evaluating stage'.\n" +
|
|
205
|
+
" 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'no-reply leads'. " +
|
|
206
|
+
"'activity in 30d MINUS activity in 5d' = 'cooled leads'.\n" +
|
|
207
|
+
" 3. rollups.by_value appears when scope.kind='state' — counts entities by current value " +
|
|
208
|
+
"(use scope.property='stage' for funnel reports).",
|
|
354
209
|
{
|
|
355
|
-
|
|
210
|
+
scope: z.object({
|
|
211
|
+
kind: z.enum(["event", "state"]).optional(),
|
|
212
|
+
property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied"),
|
|
213
|
+
source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
|
|
214
|
+
entity_id: z.string().optional().describe("scope to one person/company"),
|
|
215
|
+
since_days: z.number().optional().describe("only activity within the last N days"),
|
|
216
|
+
limit: z.number().optional().describe("max items (default 50, cap 200)"),
|
|
217
|
+
}).describe("Corpus filter"),
|
|
218
|
+
without: z.object({
|
|
219
|
+
kind: z.enum(["event", "state"]).optional(),
|
|
220
|
+
property: z.string().optional(),
|
|
221
|
+
source: z.string().optional(),
|
|
222
|
+
entity_id: z.string().optional(),
|
|
223
|
+
since_days: z.number().optional(),
|
|
224
|
+
}).optional().describe("Subtract entities matching this scope from the result — same shape as scope. Enables 'sent but no reply', 'cooled in last N days'."),
|
|
225
|
+
return: z.enum(["observations", "entities"]).optional()
|
|
226
|
+
.describe("observations (default) = one row per observation. entities = one row per entity, ranked by most-recent matching activity."),
|
|
227
|
+
question: z.string().optional().describe("What you want to learn — echoed back; enables semantic ranking"),
|
|
356
228
|
},
|
|
357
|
-
async ({
|
|
358
|
-
const
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
];
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
if (contacts.length) {
|
|
371
|
-
lines.push(`\nContacts (${contacts.length} of ${c.total_contacts ?? contacts.length}):`);
|
|
372
|
-
for (const con of contacts) {
|
|
373
|
-
const role = con.title ? ` · ${con.title}` : "";
|
|
374
|
-
lines.push(` ${con.name || con.email}${role} — ${con.pipeline_stage || "identified"} (${con.contact_id || con.id})`);
|
|
375
|
-
}
|
|
229
|
+
async ({ scope, without, return: returnMode, question }) => {
|
|
230
|
+
const body = { scope, question };
|
|
231
|
+
if (without) body.without = without;
|
|
232
|
+
if (returnMode) body.return = returnMode;
|
|
233
|
+
const r = await post("/v2/query", body);
|
|
234
|
+
const head = `${r.matched} match${r.matched !== 1 ? "es" : ""}` +
|
|
235
|
+
(r.sampled ? ` (showing ${r.returned})` : "") +
|
|
236
|
+
(r.return === "entities" ? " · grouped by entity" : "");
|
|
237
|
+
const roll = Object.entries(r.rollups?.by_type ?? {})
|
|
238
|
+
.map(([t, n]) => `${n}× ${fmtType(t)}`).join(" · ");
|
|
239
|
+
const lines = [head, roll].filter(Boolean);
|
|
240
|
+
if (r.rollups?.by_value && Object.keys(r.rollups.by_value).length) {
|
|
241
|
+
lines.push("BY VALUE: " + Object.entries(r.rollups.by_value).map(([v, n]) => `${v}: ${n}`).join(", "));
|
|
376
242
|
}
|
|
377
|
-
|
|
378
|
-
const
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
243
|
+
lines.push("");
|
|
244
|
+
for (const it of r.items ?? []) {
|
|
245
|
+
if (r.return === "entities") {
|
|
246
|
+
lines.push(` ${it.entity_name ?? it.entity_id} ` +
|
|
247
|
+
`(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${relAge(it.most_recent_at)})` +
|
|
248
|
+
(it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
|
|
249
|
+
(it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
|
|
250
|
+
} else {
|
|
251
|
+
lines.push(` ${relAge(it.when)} ${it.entity_name ?? it.entity_id} ` +
|
|
252
|
+
`${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
|
|
384
253
|
}
|
|
385
254
|
}
|
|
386
|
-
|
|
387
|
-
return {
|
|
388
|
-
content: [{ type: "text", text: `${lines.join("\n")}\n\n(company_id: ${c.company_id})` }],
|
|
389
|
-
};
|
|
255
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
390
256
|
}
|
|
391
257
|
);
|
|
392
258
|
|
|
393
|
-
//
|
|
394
|
-
// TOOL:
|
|
395
|
-
//
|
|
396
|
-
//
|
|
259
|
+
// ===========================================================================
|
|
260
|
+
// TOOL: attention — GET /v2/attention
|
|
261
|
+
// What to look at: accounts gone quiet, key facts decayed.
|
|
262
|
+
// ===========================================================================
|
|
397
263
|
server.tool(
|
|
398
|
-
"
|
|
399
|
-
"
|
|
264
|
+
"attention",
|
|
265
|
+
"What needs your attention across the workspace right now — accounts that have gone quiet and " +
|
|
266
|
+
"key facts that have decayed. Returns ranked items, each with what happened and a suggested " +
|
|
267
|
+
"action. Call this to decide who to work next.",
|
|
400
268
|
{
|
|
401
|
-
|
|
402
|
-
contact_id: z.string().optional().describe("Scope to one contact — uses lenient matching (threshold 0.45)"),
|
|
403
|
-
company_id: z.string().optional().describe("Scope to one company"),
|
|
404
|
-
limit: z.number().min(1).max(20).optional().describe("Max results (default 10)"),
|
|
269
|
+
limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
|
|
405
270
|
},
|
|
406
|
-
async ({
|
|
407
|
-
const
|
|
408
|
-
if (
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
const data = await post("/v1/search", body);
|
|
412
|
-
const results = data.results ?? [];
|
|
413
|
-
|
|
414
|
-
if (!results.length) {
|
|
415
|
-
return { content: [{ type: "text", text: `No memories found for "${q}".` }] };
|
|
271
|
+
async ({ limit }) => {
|
|
272
|
+
const r = await get("/v2/attention", limit ? { limit } : {});
|
|
273
|
+
if (!r.items?.length) {
|
|
274
|
+
return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
|
|
416
275
|
}
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
const scope = r.scope === "contact" && r.contact_id ? ` · contact:${r.contact_id}`
|
|
421
|
-
: r.scope === "company" && r.company_id ? ` · company:${r.company_id}`
|
|
422
|
-
: r.scope === "workspace" ? ` · workspace`
|
|
423
|
-
: "";
|
|
424
|
-
return ` [${r.category}]${age}${scope} ${r.content}`;
|
|
425
|
-
});
|
|
426
|
-
|
|
427
|
-
return {
|
|
428
|
-
content: [{
|
|
429
|
-
type: "text",
|
|
430
|
-
text: `Search results for "${q}" (${results.length}):\n${lines.join("\n")}`,
|
|
431
|
-
}],
|
|
432
|
-
};
|
|
276
|
+
const lines = r.items.map(it =>
|
|
277
|
+
` ${it.entity_name ?? it.entity_id} — ${it.what}\n → ${it.suggested_action}`);
|
|
278
|
+
return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
|
|
433
279
|
}
|
|
434
280
|
);
|
|
435
281
|
|
|
436
|
-
//
|
|
437
|
-
// TOOL:
|
|
438
|
-
//
|
|
439
|
-
//
|
|
282
|
+
// ===========================================================================
|
|
283
|
+
// TOOL: verify — POST /v2/verify
|
|
284
|
+
// Re-check a fact before acting on it — the calibration check.
|
|
285
|
+
// ===========================================================================
|
|
440
286
|
server.tool(
|
|
441
|
-
"
|
|
442
|
-
"
|
|
287
|
+
"verify",
|
|
288
|
+
"Re-check a specific fact before you act on it — e.g. an email or a deal stage that looks stale " +
|
|
289
|
+
"in get_context. Pass the person/company and the property name. Returns the fact re-derived from " +
|
|
290
|
+
"current evidence, and tells you whether it is still unverified.",
|
|
443
291
|
{
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
.describe("Filter by pipeline stage — omit to list all"),
|
|
447
|
-
filter: z.enum(["hot", "engaged"]).optional()
|
|
448
|
-
.describe("hot = active in last 14d with health≥45; engaged = active in last 60d"),
|
|
449
|
-
limit: z.number().min(1).max(50).optional().describe("Max contacts to return (default 20)"),
|
|
292
|
+
focus: z.string().describe("Email, LinkedIn URL, entity UUID, or name"),
|
|
293
|
+
property: z.string().describe("The fact to re-check — e.g. 'email', 'job_title', 'pipeline_stage'"),
|
|
450
294
|
},
|
|
451
|
-
async ({
|
|
452
|
-
const
|
|
453
|
-
if (
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const total = data.total ?? contacts.length;
|
|
459
|
-
|
|
460
|
-
if (!contacts.length) {
|
|
461
|
-
return { content: [{ type: "text", text: stage ? `No contacts in stage "${stage}".` : "No contacts found." }] };
|
|
295
|
+
async ({ focus, property }) => {
|
|
296
|
+
const r = await post("/v2/verify", { focus, property });
|
|
297
|
+
if (r.status === "ambiguous") {
|
|
298
|
+
const opts = (r.candidates ?? []).map(c =>
|
|
299
|
+
` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
|
|
300
|
+
return { content: [{ type: "text", text:
|
|
301
|
+
`"${focus}" matches several people. Call verify again with one of these entity ids:\n${opts}` }] };
|
|
462
302
|
}
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
: 999;
|
|
467
|
-
|
|
468
|
-
const shown = contacts;
|
|
469
|
-
|
|
470
|
-
const lines = shown.map(c => {
|
|
471
|
-
const name = c.name || c.email;
|
|
472
|
-
const company = c.company ? ` @ ${c.company}` : "";
|
|
473
|
-
const icp = c.icp_score != null ? ` · ICP:${c.icp_score}` : "";
|
|
474
|
-
const d = daysSince(c);
|
|
475
|
-
const age = d === 999 ? " · never touched" : ` · ${relAge(c.last_activity_at)}`;
|
|
476
|
-
const flag = d > 7 && (c.icp_score ?? 0) >= 70 ? "!! " : " ";
|
|
477
|
-
return `${flag}${name}${company} — ${c.pipeline_stage || "identified"}${icp}${age} (${c.id})`;
|
|
478
|
-
});
|
|
479
|
-
|
|
480
|
-
const header = `Contacts${stage ? ` — ${stage}` : ""}${filter ? ` [${filter}]` : ""} (${shown.length} of ${total}, sorted by urgency):`;
|
|
481
|
-
const legend = shown.some(c => daysSince(c) > 7 && (c.icp_score ?? 0) >= 70)
|
|
482
|
-
? "!! = high ICP, gone cold\n"
|
|
483
|
-
: "";
|
|
484
|
-
|
|
485
|
-
return {
|
|
486
|
-
content: [{
|
|
487
|
-
type: "text",
|
|
488
|
-
text: `${header}\n${legend}${lines.join("\n")}`,
|
|
489
|
-
}],
|
|
490
|
-
};
|
|
303
|
+
const a = r.after ?? {};
|
|
304
|
+
return { content: [{ type: "text", text:
|
|
305
|
+
`${property}: ${fmtVal(a.value)} [${pct(a.confidence)} · ${a.freshness}]\n${r.note ?? ""}` }] };
|
|
491
306
|
}
|
|
492
307
|
);
|
|
493
308
|
|
|
494
|
-
//
|
|
495
|
-
// TOOL:
|
|
496
|
-
//
|
|
497
|
-
//
|
|
309
|
+
// ===========================================================================
|
|
310
|
+
// TOOL: get_workspace_facts — GET /v2/workspace/facts
|
|
311
|
+
// The user's OWN playbook: ICP, market, product, pricing, competitors.
|
|
312
|
+
// Use this for any question about the user's business — NOT get_account.
|
|
313
|
+
// ===========================================================================
|
|
498
314
|
server.tool(
|
|
499
|
-
"
|
|
500
|
-
"
|
|
315
|
+
"get_workspace_facts",
|
|
316
|
+
"Get workspace-level facts the user has recorded about THEIR OWN business — ICP, target market, " +
|
|
317
|
+
"product, pricing, competitors, playbooks. These are NOT facts about people or companies; they " +
|
|
318
|
+
"are the user's own playbook. Use this for any question about the user's ICP, target buyer, " +
|
|
319
|
+
"pricing, market, or differentiators. ALWAYS prefer this over query/get_account when the " +
|
|
320
|
+
"question is about the user's business.",
|
|
501
321
|
{
|
|
502
|
-
|
|
503
|
-
.
|
|
504
|
-
|
|
505
|
-
|
|
322
|
+
categories: z.array(z.string()).optional()
|
|
323
|
+
.describe("Optional category filter, e.g. ['ICP'] or ['Pricing','Competitors']. Omit for all."),
|
|
324
|
+
limit: z.number().min(1).max(500).optional()
|
|
325
|
+
.describe("Max facts to return (default 50)"),
|
|
506
326
|
},
|
|
507
|
-
async ({
|
|
508
|
-
const params = {
|
|
509
|
-
if (
|
|
510
|
-
|
|
511
|
-
const
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
return { content: [{ type: "text", text: `No workspace facts stored yet${category ? ` in category "${category}"` : ""}. Use remember (without a contact) to store ICP, product, or market facts.` }] };
|
|
516
|
-
}
|
|
517
|
-
|
|
518
|
-
const byCategory = {};
|
|
519
|
-
for (const m of memories) {
|
|
520
|
-
if (!byCategory[m.category]) byCategory[m.category] = [];
|
|
521
|
-
byCategory[m.category].push(m.content);
|
|
327
|
+
async ({ categories, limit }) => {
|
|
328
|
+
const params = {};
|
|
329
|
+
if (categories?.length) params.categories = categories.join(",");
|
|
330
|
+
if (limit != null) params.limit = limit;
|
|
331
|
+
const r = await get("/v2/workspace/facts", params);
|
|
332
|
+
if (!r.facts?.length) {
|
|
333
|
+
return { content: [{ type: "text", text:
|
|
334
|
+
"No workspace facts recorded yet. The user can add them in the Intelligence tab." }] };
|
|
522
335
|
}
|
|
523
|
-
|
|
336
|
+
const groups = {};
|
|
337
|
+
for (const f of r.facts) (groups[f.category] ??= []).push(f);
|
|
524
338
|
const lines = [];
|
|
525
|
-
for (const [cat, facts] of Object.entries(
|
|
526
|
-
lines.push(
|
|
527
|
-
for (const f of facts) lines.push(`
|
|
339
|
+
for (const [cat, facts] of Object.entries(groups)) {
|
|
340
|
+
lines.push(`${cat.toUpperCase()} (${facts.length}):`);
|
|
341
|
+
for (const f of facts) lines.push(` ${f.content} [${relAge(f.recorded_at)}]`);
|
|
342
|
+
lines.push("");
|
|
528
343
|
}
|
|
529
|
-
|
|
530
|
-
return {
|
|
531
|
-
content: [{
|
|
532
|
-
type: "text",
|
|
533
|
-
text: `Workspace knowledge (${memories.length} fact${memories.length !== 1 ? "s" : ""}):\n\n${lines.join("\n")}`,
|
|
534
|
-
}],
|
|
535
|
-
};
|
|
536
|
-
}
|
|
537
|
-
);
|
|
538
|
-
|
|
539
|
-
// ---------------------------------------------------------------------------
|
|
540
|
-
// TOOL: create_contact
|
|
541
|
-
// Add a new contact with full profile fields. Returns the new contact_id.
|
|
542
|
-
// ---------------------------------------------------------------------------
|
|
543
|
-
server.tool(
|
|
544
|
-
"create_contact",
|
|
545
|
-
"Create a new contact with their full profile — name, title, company, phone, LinkedIn, and notes. Use when you have a new person to add before tracking any activity. Returns an error if the email already exists.",
|
|
546
|
-
{
|
|
547
|
-
email: z.string().describe("Contact's email address (required, must be unique)"),
|
|
548
|
-
first_name: z.string().optional().describe("First name"),
|
|
549
|
-
last_name: z.string().optional().describe("Last name"),
|
|
550
|
-
company: z.string().optional().describe("Company name"),
|
|
551
|
-
job_title: z.string().optional().describe("Job title or role"),
|
|
552
|
-
phone: z.string().optional().describe("Phone number"),
|
|
553
|
-
linkedin_url: z.string().optional().describe("LinkedIn profile URL"),
|
|
554
|
-
notes: z.string().optional().describe("Free-form notes about this contact"),
|
|
555
|
-
},
|
|
556
|
-
async ({ email, first_name, last_name, company, job_title, phone, linkedin_url, notes }) => {
|
|
557
|
-
const result = await post("/v1/contacts", {
|
|
558
|
-
email, first_name, last_name, company, job_title, phone, linkedin_url, notes,
|
|
559
|
-
});
|
|
560
|
-
|
|
561
|
-
const name = result.name || result.email;
|
|
562
|
-
const meta = [result.job_title, result.company].filter(Boolean).join(" · ");
|
|
563
|
-
return {
|
|
564
|
-
content: [{
|
|
565
|
-
type: "text",
|
|
566
|
-
text: [
|
|
567
|
-
`Created contact: ${name}`,
|
|
568
|
-
meta ? meta : null,
|
|
569
|
-
`contact_id: ${result.id}`,
|
|
570
|
-
`Stage: ${result.pipeline_stage}`,
|
|
571
|
-
].filter(Boolean).join("\n"),
|
|
572
|
-
}],
|
|
573
|
-
};
|
|
574
|
-
}
|
|
575
|
-
);
|
|
576
|
-
|
|
577
|
-
// ---------------------------------------------------------------------------
|
|
578
|
-
// TOOL: update_contact
|
|
579
|
-
// Update profile fields on an existing contact.
|
|
580
|
-
// ---------------------------------------------------------------------------
|
|
581
|
-
server.tool(
|
|
582
|
-
"update_contact",
|
|
583
|
-
"Update one or more profile fields on an existing contact — name, title, company, phone, LinkedIn, notes. Pass email or contact_id to identify the contact, then any fields to change. Only provided fields are updated.",
|
|
584
|
-
{
|
|
585
|
-
email: z.string().optional().describe("Contact's email address (to identify them)"),
|
|
586
|
-
contact_id: z.string().optional().describe("Contact UUID (alternative to email)"),
|
|
587
|
-
first_name: z.string().optional().describe("Updated first name"),
|
|
588
|
-
last_name: z.string().optional().describe("Updated last name"),
|
|
589
|
-
company: z.string().optional().describe("Updated company name"),
|
|
590
|
-
job_title: z.string().optional().describe("Updated job title or role"),
|
|
591
|
-
phone: z.string().optional().describe("Updated phone number"),
|
|
592
|
-
linkedin_url: z.string().optional().describe("Updated LinkedIn profile URL"),
|
|
593
|
-
notes: z.string().optional().describe("Updated free-form notes"),
|
|
594
|
-
},
|
|
595
|
-
async ({ email, contact_id, first_name, last_name, company, job_title, phone, linkedin_url, notes }) => {
|
|
596
|
-
if (!email && !contact_id) {
|
|
597
|
-
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
598
|
-
}
|
|
599
|
-
const identifier = encodeURIComponent(contact_id || email);
|
|
600
|
-
const result = await patch(`/v1/contacts/${identifier}`, {
|
|
601
|
-
first_name, last_name, company, job_title, phone, linkedin_url, notes,
|
|
602
|
-
});
|
|
603
|
-
const name = result.name || result.email;
|
|
604
|
-
const meta = [result.job_title, result.company].filter(Boolean).join(" · ");
|
|
605
|
-
return {
|
|
606
|
-
content: [{
|
|
607
|
-
type: "text",
|
|
608
|
-
text: [
|
|
609
|
-
`Updated contact: ${name}`,
|
|
610
|
-
meta ? meta : null,
|
|
611
|
-
`contact_id: ${result.id}`,
|
|
612
|
-
].filter(Boolean).join("\n"),
|
|
613
|
-
}],
|
|
614
|
-
};
|
|
615
|
-
}
|
|
616
|
-
);
|
|
617
|
-
|
|
618
|
-
// ---------------------------------------------------------------------------
|
|
619
|
-
// TOOL: delete_contact
|
|
620
|
-
// Permanently remove a contact and all their associated data.
|
|
621
|
-
// ---------------------------------------------------------------------------
|
|
622
|
-
server.tool(
|
|
623
|
-
"delete_contact",
|
|
624
|
-
"Permanently delete a contact and all their data — activities and memories. Use for duplicates, test entries, or explicit removal requests. Cannot be undone. Pass email or contact_id.",
|
|
625
|
-
{
|
|
626
|
-
email: z.string().optional().describe("Contact's email address"),
|
|
627
|
-
contact_id: z.string().optional().describe("Contact UUID"),
|
|
628
|
-
},
|
|
629
|
-
async ({ email, contact_id }) => {
|
|
630
|
-
if (!email && !contact_id) {
|
|
631
|
-
return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
|
|
632
|
-
}
|
|
633
|
-
|
|
634
|
-
const identifier = encodeURIComponent(contact_id || email);
|
|
635
|
-
const result = await del(`/v1/contacts/${identifier}`);
|
|
636
|
-
|
|
637
|
-
return {
|
|
638
|
-
content: [{
|
|
639
|
-
type: "text",
|
|
640
|
-
text: `Deleted contact ${result.email} (${result.contact_id}).`,
|
|
641
|
-
}],
|
|
642
|
-
};
|
|
643
|
-
}
|
|
644
|
-
);
|
|
645
|
-
|
|
646
|
-
// ---------------------------------------------------------------------------
|
|
647
|
-
// TOOL: delete_memory
|
|
648
|
-
// Soft-delete a workspace memory by ID (marks is_active: false).
|
|
649
|
-
// Use to remove outdated or conflicting facts found via get_memories.
|
|
650
|
-
// ---------------------------------------------------------------------------
|
|
651
|
-
server.tool(
|
|
652
|
-
"delete_memory",
|
|
653
|
-
"Delete a workspace memory by ID — marks it inactive so it no longer appears in get_memories or search. Use to clean up outdated, conflicting, or incorrect facts. Get the memory ID from get_memories output.",
|
|
654
|
-
{
|
|
655
|
-
memory_id: z.string().describe("Memory UUID — get this from get_memories output"),
|
|
656
|
-
},
|
|
657
|
-
async ({ memory_id }) => {
|
|
658
|
-
const result = await del(`/v1/memory/${encodeURIComponent(memory_id)}`);
|
|
659
|
-
return {
|
|
660
|
-
content: [{
|
|
661
|
-
type: "text",
|
|
662
|
-
text: `Deleted memory (${result.id}): "${result.content}"`,
|
|
663
|
-
}],
|
|
664
|
-
};
|
|
344
|
+
return { content: [{ type: "text", text: lines.join("\n").trim() }] };
|
|
665
345
|
}
|
|
666
346
|
);
|
|
667
347
|
|
|
668
|
-
//
|
|
348
|
+
// ───────────────────────────────────────────────────────────────────────────
|
|
669
349
|
const transport = new StdioServerTransport();
|
|
670
350
|
await server.connect(transport);
|