@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.
Files changed (3) hide show
  1. package/LICENSE +661 -0
  2. package/package.json +17 -8
  3. 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 — contact intelligence for GTM agents.
4
+ * Nous MCP Server — the context layer for GTM agents.
5
5
  *
6
- * Required env vars:
7
- * NOUS_API_KEY — Your Nous API key (Settings API Keys)
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 — API base URL (default: https://api.opennous.cloud)
13
+ * NOUS_API_URL — API base URL (default: https://api.opennous.cloud)
11
14
  *
12
- * Tools:
13
- * get_contact full contact profile: identity + activities + facts + summary
14
- * get_company — full company profile: org details + all contacts + company facts
15
- * create_contact add a new contact with full profile fields
16
- * update_contact update profile fields on an existing contact
17
- * track record that something happened (call, email, meeting, visit)
18
- * remember store a fact learned about a contact or company
19
- * list_contactsfind contacts by pipeline stage
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_factsworkspace-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, patch, del } from "./client.js";
28
+ import { validateConfig, get, post } from "./client.js";
27
29
 
28
- // ─── Context engineering helpers ──────────────────────────────────────────────
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) return "today";
35
+ if (d < 1) return "today";
62
36
  if (d === 1) return "1d ago";
63
- if (d < 30) return `${d}d ago`;
37
+ if (d < 30) return `${d}d ago`;
64
38
  const m = Math.floor(d / 30);
65
- if (m < 12) return `${m}mo ago`;
39
+ if (m < 12) return `${m}mo ago`;
66
40
  return `${Math.floor(m / 12)}y ago`;
67
41
  }
68
42
 
69
- function fmtShortDate(ts) {
70
- return new Date(ts).toLocaleDateString("en-US", { month: "short", day: "numeric" });
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.8.9",
82
- description: "Nous — contact intelligence for GTM agents. Call get_contact before acting on any person. Call track and remember after every interaction.",
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: get_contact
90
- // The one call you need. Returns everything known about a contact:
91
- // identity, pipeline stage, memory summary, recent activities, and facts.
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
- "get_contact",
95
- "Get everything known about a contact identity, pipeline stage, status (are you waiting on them or are they waiting on you?), AI summary, recent activities with direction arrows (← inbound / → outbound), and stored facts. Call this before writing any email, preparing for a call, or making a decision. Also call get_memories for outreach history. Pass email or contact_id.",
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
- email: z.string().optional().describe("Contact's email address"),
98
- contact_id: z.string().optional().describe("Contact UUID use if you already have it"),
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 ({ email, contact_id }) => {
101
- if (!email && !contact_id) {
102
- return { content: [{ type: "text", text: "Error: provide either email or contact_id." }] };
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 identifier = encodeURIComponent(contact_id || email);
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
- const lines = [header, scores];
123
-
124
- // ── Activity timeline temporal compression ──────────────────────────────
125
- const activities = actData.activities ?? [];
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
- if (c.memory_summary || c.summary) {
156
- lines.push(`\nSummary: ${c.memory_summary || c.summary}`);
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
- if (hot.length || warm.length || Object.keys(counts).length) {
160
- if (hot.length) {
161
- lines.push(`\nLast 7 days (${hot.length}):`);
162
- for (const a of hot) {
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
- if (warm.length) {
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
- // ── Facts — capped at 5, newest first with relative age ──────────────────
192
- const allFacts = c.facts ?? [];
193
- const contactFacts = allFacts.filter(f => f.scope !== "company");
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
- if (companyFacts.length) {
207
- lines.push(`\nCompany facts:`);
208
- for (const f of companyFacts) {
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: track
226
- // Record that something happened a call, email, meeting, website visit.
227
- // For what you *learned*, also call remember.
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
- "track",
243
- "Record an interaction with a contact email sent, call held, meeting completed, website visit, etc. Pass email or contact_id. If you learned something meaningful, also call remember.",
244
- {
245
- email: z.string().optional().describe("Contact's email address"),
246
- contact_id: z.string().optional().describe("Contact UUID"),
247
- type: z.enum([
248
- "email_sent", "email_reply",
249
- "call_held", "meeting_held",
250
- "linkedin_message", "linkedin_connected", "linkedin_replied",
251
- "follow_up_sent", "proposal_sent",
252
- "website_visit", "content_download", "trial_started",
253
- "manual_note",
254
- ]).describe("Activity type"),
255
- description: z.string().optional().describe("Brief summary of what happened"),
256
- occurred_at: z.string().optional().describe("ISO timestamp — defaults to now"),
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 (result.stage_after && result.stage_before && result.stage_after !== result.stage_before) {
278
- parts.push(`Stage advanced: ${result.stage_before} → ${result.stage_after}.`);
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: remember
292
- // Store a fact you learnedabout a person or their company.
293
- // Write facts, not logs. "Concerned about integration complexity" = fact.
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
- "remember",
298
- "Store a fact — about a contact, their company, or your own workspace (ICP, product, market). Omit email and contact_id to store workspace-level facts. Pass email or contact_id to scope to a person. Set company=true to tag to their whole org. Similar existing facts in the same scope are automatically superseded — no duplicates accumulate.",
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
- email: z.string().optional().describe("Contact's email omit for workspace-level facts"),
301
- contact_id: z.string().optional().describe("Contact UUID — omit for workspace-level facts"),
302
- fact: z.string().describe("The fact to store one clear, reusable sentence"),
303
- category: z.enum(["ICP", "Product", "Pricing", "Market", "Competitors", "Team", "Patterns", "General"])
304
- .optional()
305
- .describe("Memory category (default: General)"),
306
- company: z.boolean().optional().describe("Set true to tag this fact to the whole company, not just this person"),
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 ({ email, contact_id, fact, category = "General", company = false }) => {
309
- let body = { text: fact, category, source: "agent" };
310
-
311
- if (company && (email || contact_id)) {
312
- const identifier = encodeURIComponent(contact_id || email);
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
- // else: no email/contact_id = workspace-level fact
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: get_company
349
- // Full company profile org details + every contact at that company + company facts.
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
- "get_company",
353
- "Get the full profile for a company — org details, all contacts at that account, and company-level facts. Pass a company UUID (available on any contact as company_id).",
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
- company_id: z.string().describe("Company UUID — get this from contact.company_id"),
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 ({ company_id }) => {
358
- const c = await get(`/v1/company/${encodeURIComponent(company_id)}`);
359
-
360
- const lines = [
361
- `${c.name}${c.domain ? ` (${c.domain})` : ""}`,
362
- [
363
- c.industry ? `Industry: ${c.industry}` : null,
364
- c.employee_count ? `Size: ${c.employee_count}` : null,
365
- c.deal_health_score != null ? `Health: ${c.deal_health_score}` : null,
366
- ].filter(Boolean).join(" | "),
367
- ];
368
-
369
- const contacts = c.contacts ?? [];
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 facts = c.facts ?? [];
379
- if (facts.length) {
380
- lines.push(`\nCompany facts:`);
381
- for (const f of facts) {
382
- const age = f.written_at ? ` · ${relAge(f.written_at)}` : "";
383
- lines.push(` [${f.category}] ${f.content}${age}`);
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: search
395
- // Semantic search across workspace memories. Scope to one contact or search workspace-wide.
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
- "search",
399
- "Semantic search across all stored facts in the workspace. Scope to one contact or company, or omit to search workspace-wide. Useful for finding context before drafting a message or preparing for a call.",
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
- q: z.string().describe("Search query e.g. 'budget concerns', 'Salesforce migration'"),
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 ({ q, contact_id, company_id, limit = 10 }) => {
407
- const body = { q, limit };
408
- if (contact_id) body.contact_id = contact_id;
409
- if (company_id) body.company_id = company_id;
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
- const lines = results.map(r => {
419
- const age = r.written_at ? ` · ${relAge(r.written_at)}` : "";
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: list_contacts
438
- // Find contacts by pipeline stage useful for identifying who to reach out to next.
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
- "list_contacts",
442
- "List contacts sorted by urgency (high ICP + longest since last touch first). Use stage to focus on a specific pipeline stage. Contacts marked !! are high-ICP and have gone cold — prioritize those.",
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
- stage: z.enum(["identified", "aware", "interested", "evaluating", "client"])
445
- .optional()
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 ({ stage, filter, limit = 20 }) => {
452
- const params = { limit, sort: "urgency" };
453
- if (stage) params.pipeline_stage = stage;
454
- if (filter) params.filter = filter;
455
-
456
- const data = await get("/v1/contacts", params);
457
- const contacts = data.contacts ?? [];
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
- const daysSince = c => c.last_activity_at
465
- ? Math.floor((Date.now() - new Date(c.last_activity_at).getTime()) / 86400000)
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: get_memories
496
- // Load all workspace-level facts ICP, product, market, competitive intel.
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
- "get_memories",
500
- "Load all workspace-level facts your ICP, product description, pricing, market positioning, and competitive intel. Call this before drafting outreach, preparing a pitch, or any task that requires knowing your own company context. Not for contact or company facts — use get_contact for those.",
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
- category: z.enum(["ICP", "Product", "Pricing", "Market", "Competitors", "Team", "Patterns", "General"])
503
- .optional()
504
- .describe("Filter by category — omit to get all"),
505
- limit: z.number().min(1).max(200).optional().describe("Max facts to return (default 50)"),
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 ({ category, limit = 50 }) => {
508
- const params = { limit };
509
- if (category) params.category = category;
510
-
511
- const data = await get("/v1/memories", params);
512
- const memories = data.memories ?? [];
513
-
514
- if (!memories.length) {
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(byCategory)) {
526
- lines.push(`[${cat}]`);
527
- for (const f of facts) lines.push(` ${f}`);
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);