@edda-business/mcp 0.63.0 → 0.65.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 (2) hide show
  1. package/package.json +1 -1
  2. package/src/server.js +217 -951
package/src/server.js CHANGED
@@ -1,76 +1,82 @@
1
1
  /**
2
- * Edda MCP server factory.
2
+ * Edda MCP server factory — the COMPANY SHARED-KNOWLEDGE lens.
3
3
  *
4
- * Builds an McpServer with the v2 tools registered. Both entrypoints use it:
4
+ * Both entrypoints use it:
5
5
  * - index.js (stdio bin, published as @edda-business/mcp) — one server, env-scoped key
6
- * - http.js (hosted, mcp.opennous.cloud) — a fresh server per request,
7
- * key scoped via AsyncLocalStorage
6
+ * - http.js (hosted) — a fresh server per request,
7
+ * key scoped via AsyncLocalStorage
8
8
  *
9
- * The tools are thin clients of the Context API (see client.js). The agent never
10
- * sees raw rowsit gets engineered, epistemics-tagged context. It never
11
- * "updates" it records observations; Edda derives.
9
+ * SCOPE (deliberately minimal). This MCP is ONLY the company's shared knowledge.
10
+ * Personal knowledge is NOT here each user's own working files live on their VPS as a
11
+ * real folder (the edda-vault-sync daemon mirrors that folder ⇄ Supabase), and the agent
12
+ * reads/writes them with its native file tools. The GTM/account graph is a separate product
13
+ * (Nous, @opennous/mcp). So this server exposes five tools and nothing else.
12
14
  *
13
- * Tools, by group (v0.56 — consolidated, Cerebras-style):
14
- * RETRIEVE search (unified, scope=all|company|personal|notes) · get_context ·
15
- * get_account · who_knows · query · attention · verify
16
- * WRITE save_note · save_personal_file · update_personal_file · add_company_wiki_page ·
17
- * add_company_wiki_pages (bulk) · create_company_wiki_folder
18
- * FIX merge_contacts (action=merge|split)
19
- * RUN list_integrations · connect_integration
15
+ * Company knowledge has TWO natures, joined by search:
16
+ * DOCUMENTS — the wiki: declared pages someone writes (policies, playbooks, decisions,
17
+ * SOPs). You browse/read/write these.
18
+ * STREAMS — distilled/derived knowledge that flows in from connected sources
19
+ * (Slack, HubSpot, Stripe, calls). Nobody writes these as pages — the worker distills
20
+ * + embeds them. You only RETRIEVE them (they surface in search tagged ·distilled).
20
21
  *
21
- * Deprecated aliases kept for back-compat (forward to the above, removed after a window):
22
- * search_company_knowledge · search_my_vault · search_notes search
23
- * save_to_vault/propose_vault_file save_personal_file · update_vault_file update_personal_file
24
- * propose_company_file add_company_wiki_page · propose_company_files add_company_wiki_pages
25
- * create_folder create_company_wiki_folder · unmerge_contacts merge_contacts(action:split)
22
+ * TOOLS:
23
+ * search RETRIEVE across BOTH natures (documents + distilled), kind filter
24
+ * read read one full wiki page (by path from a search result, or id)
25
+ * push PUSH one or many files up into the company folders (shared wiki)
26
+ * update edit an existing wiki page (replace or append)
27
+ * create organize the wiki tree
28
+ *
29
+ * Sources (connecting Slack/HubSpot/… so distillation happens) are wired in the Edda web
30
+ * app, not here — the agent only reads and writes knowledge.
26
31
  */
27
32
 
28
33
  import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
29
34
  import { z } from "zod";
30
- import { get, post, del } from "./client.js";
35
+ import { get, post, patch } from "./client.js";
31
36
 
32
- export const SERVER_VERSION = "0.63.0";
37
+ export const SERVER_VERSION = "0.65.0";
33
38
 
34
39
  // ─── helpers ──────────────────────────────────────────────────────────────────
35
40
 
41
+ const pct = (c) => (c == null ? "recent" : `${Math.round((c ?? 0) * 100)}%`);
42
+ const text = (s) => ({ content: [{ type: "text", text: s }] });
43
+
36
44
  function relAge(ts) {
37
45
  if (!ts) return "—";
38
46
  const d = Math.floor((Date.now() - new Date(ts).getTime()) / 86400000);
39
- if (d < 1) return "today";
47
+ if (d < 1) return "today";
40
48
  if (d === 1) return "1d ago";
41
- if (d < 30) return `${d}d ago`;
49
+ if (d < 30) return `${d}d ago`;
42
50
  const m = Math.floor(d / 30);
43
- if (m < 12) return `${m}mo ago`;
51
+ if (m < 12) return `${m}mo ago`;
44
52
  return `${Math.floor(m / 12)}y ago`;
45
53
  }
46
54
 
47
- // Absolute calendar date + clock time, in the user's local zone (this server runs
48
- // on their machine over stdio, so toLocaleString is already local). For meetings,
49
- // "Tue, Jun 16, 3:00 PM" beats relAge's fuzzy "today" — and relAge can't represent
50
- // the future at all, so every scheduled call would otherwise read "today".
51
- function fmtWhen(ts) {
52
- if (!ts) return "—";
53
- return new Date(ts).toLocaleString("en-US", {
54
- weekday: "short", month: "short", day: "numeric",
55
- hour: "numeric", minute: "2-digit", timeZoneName: "short",
56
- });
57
- }
58
-
59
- // When to show an absolute datetime vs a relative age. Meetings/calls always get
60
- // the exact time (you need to know it's 3pm, not "today"); so does anything
61
- // future-dated (a scheduled event), which relAge would collapse to "today".
62
- function whenLabel(type, ts) {
63
- const t = String(type || "");
64
- const isMeeting = t.includes("meeting") || t.includes("call");
65
- const isFuture = ts && new Date(ts).getTime() > Date.now();
66
- return (isMeeting || isFuture) ? fmtWhen(ts) : relAge(ts);
55
+ // Normalise a name/path segment for matching: lowercase, trimmed, no trailing .md.
56
+ const norm = (s) => String(s ?? "").trim().toLowerCase().replace(/\.md$/, "");
57
+
58
+ // Resolve a company page PATH (a search result's `path:` folder[/subfolder]/name.md) to its
59
+ // page id, by listing the live wiki and matching on basename, disambiguated by folder prefix.
60
+ // Returns { id } or { error } (ambiguous lists candidates so the caller can pass an id).
61
+ async function resolvePathToId(path) {
62
+ const list = await get("/v2/company/pages");
63
+ const pages = list?.pages ?? [];
64
+ const base = norm(String(path).split("/").pop());
65
+ let cands = pages.filter((p) => norm(p.name) === base);
66
+ if (!cands.length) return { error: `No company page named "${String(path).split("/").pop()}". Use search to find the exact path first.` };
67
+ if (cands.length > 1) {
68
+ // Prefer a candidate whose folder slug appears in the requested path.
69
+ const pref = cands.filter((p) => p.folder && norm(path).includes(norm(p.folder)));
70
+ if (pref.length === 1) return { id: pref[0].id };
71
+ cands = pref.length ? pref : cands;
72
+ if (cands.length > 1) {
73
+ const opts = cands.map((p) => ` • ${[p.folder, p.name].filter(Boolean).join("/")} (id: ${p.id})`).join("\n");
74
+ return { error: `Several pages match "${base}". Pass one of these ids as \`id\`:\n${opts}` };
75
+ }
76
+ }
77
+ return { id: cands[0].id };
67
78
  }
68
79
 
69
- const fmtType = (p) => (p || "").replace(/^interaction\./, "").replace(/_/g, " ");
70
- const fmtVal = (v) => (v != null && typeof v === "object") ? JSON.stringify(v) : String(v ?? "");
71
- const pct = (c) => `${Math.round((c ?? 0) * 100)}%`;
72
-
73
-
74
80
  // ─── factory ──────────────────────────────────────────────────────────────────
75
81
 
76
82
  export function createServer() {
@@ -78,967 +84,227 @@ export function createServer() {
78
84
  name: "edda",
79
85
  version: SERVER_VERSION,
80
86
  description:
81
- "Edda — the company knowledge layer for AI agents. The agent reads engineered, " +
82
- "epistemics-tagged context instead of raw rows. Call get_context before preparing for a " +
83
- "meeting or a decision about a person; search for how the company works; " +
84
- "save_note to keep a brief or transcript on a contact.",
87
+ "Edda — the company's shared knowledge for AI agents. Search the company wiki (declared " +
88
+ "policies, playbooks, decisions) AND distilled knowledge from connected sources (Slack, " +
89
+ "HubSpot, calls) in one call; read a full page; and contribute pages/folders back. Reach " +
90
+ "for search before answering from generic knowledge. (Personal files live on the " +
91
+ "user's own disk; this is the shared company layer only.)",
85
92
  icons: [
86
93
  { src: "https://opennous.cloud/newlogoP.png", mimeType: "image/png", sizes: ["64x64"] },
87
94
  ],
88
95
  });
89
96
 
90
97
  // ===========================================================================
91
- // TOOL: get_context — POST /v2/context
92
- // The headline tool. Engineered, intent-shaped context for a specific task.
98
+ // TOOL: search — POST /v2/company/search
99
+ // The primary read surface. Hybrid retrieval (RRF + rerank) fused across the DECLARED wiki
100
+ // and the DERIVED distilled layer, ACL-filtered. `kind` narrows to one nature.
93
101
  // ===========================================================================
94
102
  server.tool(
95
- "get_context",
96
- "Get engineered context for a specific task about a person or company. Pass their email (or " +
97
- "entity id) and the intent. Returns a focused, ranked context block: the durable FACTS we've " +
98
- "learned about them (each with a confidence and a freshness), the recent timeline, the other " +
99
- "people at their company and how they relate, and the relevant notes and records. Call this before " +
100
- "a meeting or any decision about a person or company, so you act on what we actually know rather " +
101
- "than generic guesses. A fact's freshness tells you whether to trust it: 'fresh' act on it, " +
102
- "'suspect'/'expired' verify first.",
103
+ "search",
104
+ "Search the COMPANY'S SHARED KNOWLEDGE in one call both natures at once: the DECLARED wiki " +
105
+ "(policies, playbooks, decisions, SOPs, how-we-work) AND DERIVED knowledge distilled from " +
106
+ "connected sources (Slack, HubSpot, Stripe, calls). This is the default retrieval tool: reach " +
107
+ "for it before answering from generic knowledge about how the company works or what happened. " +
108
+ "Each hit is tagged [company] (a wiki page) or [company·distilled] (from activity), with a " +
109
+ "snippet; wiki hits include a `path:` you can pass to read for the full text. " +
110
+ "Narrow with `kind`: 'documents' (wiki only), 'distilled' (activity only), or 'all' (default).",
103
111
  {
104
- 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."),
105
- intent: z.enum(["follow_up", "meeting_prep", "call_prep", "account_review"])
106
- .optional()
107
- .describe("What you are about to do — shapes which context surfaces (default: account_review)"),
108
- budget_tokens: z.number().optional().describe("Approximate token budget for the context block"),
112
+ question: z.string().describe("Natural-language query."),
113
+ kind: z.enum(["all", "documents", "distilled"]).optional().describe("Which nature to search: 'documents' (wiki), 'distilled' (from sources), or 'all' (default)."),
114
+ limit: z.number().optional().describe("Max results (default 8, max 20)."),
109
115
  },
110
- async ({ focus, intent, budget_tokens }) => {
111
- const ctx = await post("/v2/context", { focus, intent: intent ?? "account_review", budget_tokens });
112
-
113
- // a name matched several people surface the candidates to choose from
114
- if (ctx.status === "ambiguous") {
115
- const opts = (ctx.candidates ?? []).map(c =>
116
- ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
117
- return { content: [{ type: "text", text:
118
- `"${focus}" matches several people. Call get_context again with one of these entity ids:\n${opts}` }] };
119
- }
120
-
121
- const lines = [ctx.summary, ""];
122
-
123
- if (ctx.facts?.length) {
124
- // Atomic memory — the durable, decision-relevant facts learned about them.
125
- lines.push(`FACTS (${ctx.facts.length} — durable memory about them):`);
126
- for (const f of ctx.facts) lines.push(` [${f.category}] ${f.content}${f.date ? ` (${relAge(f.date)})` : ""}`);
127
- lines.push("");
128
- }
129
- if (ctx.claims?.length) {
130
- lines.push(`ATTRIBUTES (${ctx.meta?.claims_returned ?? ctx.claims.length}):`);
131
- for (const c of ctx.claims) {
132
- lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
133
- }
134
- lines.push("");
135
- }
136
- if (ctx.timeline?.length) {
137
- lines.push("TIMELINE:");
138
- for (const t of ctx.timeline) {
139
- if (t.tier === "count") lines.push(` ${t.count}× ${fmtType(t.type)}`);
140
- else lines.push(` ${whenLabel(t.type, t.when)} ${fmtType(t.type)}${t.summary ? `: ${t.summary}` : ""}`);
141
- }
142
- lines.push("");
116
+ async ({ question, kind, limit }) => {
117
+ const want = kind || "all";
118
+ const perLimit = Math.min(Number(limit) || 8, 20);
119
+ // Over-fetch when filtering so a one-nature view still fills up.
120
+ const reqLimit = want === "all" ? perLimit : Math.min(perLimit * 2, 20);
121
+ const r = await post("/v2/company/search", { question, limit: reqLimit });
122
+ let docs = r?.documents ?? [];
123
+ if (want === "documents") docs = docs.filter((d) => d.kind === "declared");
124
+ if (want === "distilled") docs = docs.filter((d) => d.kind === "derived");
125
+ docs = docs.slice(0, perLimit);
126
+ if (!docs.length) {
127
+ return text(`Nothing matched "${question}"${want !== "all" ? ` in ${want}` : ""} in the company knowledge.`);
143
128
  }
144
- if (ctx.documents?.length) {
145
- // Meeting briefs / notes / transcripts kept on the contact — an overview
146
- // (snippets only). To pull relevant content, use search_notes (semantic).
147
- lines.push("DOCUMENTS (notes & meeting records use search_notes to search their content):");
148
- for (const d of ctx.documents) {
149
- const when = d.date ? ` [${relAge(d.date)}]` : "";
150
- const from = d.source_member ? ` · from ${d.source_member}` : "";
151
- lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${from}${when}`);
152
- if (d.snippet) lines.push(` ${d.snippet}`);
153
- }
129
+ const lines = [`Company knowledge for "${question}"${want !== "all" ? ` (${want})` : ""}:`, ""];
130
+ for (const d of docs) {
131
+ const declared = d.kind === "declared";
132
+ const tag = declared ? "[company]" : "[company·distilled]";
133
+ const from = d.source_member ? ` · from ${d.source_member}` : "";
134
+ lines.push(`${tag} ${String(d.name ?? "").replace(/\.md$/, "")}${from} (${pct(d.similarity)})`);
135
+ if (declared && d.ref) lines.push(` path: ${d.ref}`); // pass to read
136
+ else if (d.ref) lines.push(` source: ${d.ref}`); // distilled: origin, read-only
137
+ if (d.snippet) lines.push(` ${d.snippet}`);
154
138
  lines.push("");
155
139
  }
156
- if (ctx.stakeholders?.length) {
157
- // The people connected to this company and how they relate — the local map, so
158
- // the agent sees the whole picture, not one person in isolation.
159
- const c = ctx.committee;
160
- lines.push(c?.company ? `PEOPLE AT ${c.company}:` : "RELATED PEOPLE:");
161
- for (const s of ctx.stakeholders) {
162
- if (s.role === "company") continue; // the company is the header
163
- const bits = [];
164
- if (s.role) bits.push(s.role);
165
- if (s.confirmed === false) bits.push("mentioned, unconfirmed");
166
- const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
167
- lines.push(` ${s.name ?? "—"}${bits.length ? ` (${bits.join(", ")})` : ""}${rel}`);
168
- }
169
- lines.push("");
170
- }
171
- return {
172
- content: [{ type: "text", text: `${lines.join("\n").trim()}\n\n(entity_id: ${ctx.entity?.id})` }],
173
- };
174
- }
175
- );
176
-
177
- // ===========================================================================
178
- // TOOL: get_account — GET /v2/accounts/:id
179
- // The full account-record projection. For a focused view, prefer get_context.
180
- // ===========================================================================
181
- server.tool(
182
- "get_account",
183
- "Get the full record for a person or company — the durable FACTS we've learned about them (their " +
184
- "role, company, history, and anything else worth remembering), every attribute (claim) with its " +
185
- "confidence and freshness, plus what they actually SAID and did, ranked by how much it tells you. " +
186
- "Pass an email or entity UUID, and the intent you're working toward so the record is shaped for it.",
187
- {
188
- id: z.string().describe("Who to look up — an email, an entity UUID, or a name. A name may match several people; you'll get candidates to choose from."),
189
- intent: z
190
- .enum(["meeting_prep", "call_prep", "account_review", "follow_up"])
191
- .optional()
192
- .describe(
193
- "What you're about to do. Shapes how much of their history comes back: a meeting brief wants " +
194
- "the conversation in detail, an email draft wants one hook. Defaults to account_review.",
195
- ),
140
+ return text(lines.join("\n").trim());
196
141
  },
197
- async ({ id, intent }) => {
198
- // Ask for the RANKED record, not the raw one.
199
- //
200
- // The timeline this tool used to print was chronological and contentless —
201
- // "3d ago email_sent" — which tells an agent that something happened and
202
- // nothing about what. Ranked activity carries the source and the substance,
203
- // so the model reads what was actually said instead of a list of event names.
204
- const q = new URLSearchParams({ intent: intent ?? "account_review", compress: "1" });
205
- const rec = await get(`/v2/accounts/${encodeURIComponent(id)}?${q}`);
206
-
207
- // A name matched several people — surface the candidates to choose from.
208
- // Without this, the header line below reads `rec.type`/`rec.entity_id` off the
209
- // ambiguous response (which carries neither) and prints "undefined · undefined".
210
- if (rec.status === "ambiguous") {
211
- const opts = (rec.candidates ?? []).map(c =>
212
- ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
213
- return { content: [{ type: "text", text:
214
- `"${id}" matches several people. Call get_account again with one of these entity ids:\n${opts}` }] };
215
- }
216
-
217
- const lines = [`${rec.type} · ${rec.entity_id}`, ""];
218
-
219
- if (rec.facts?.length) {
220
- // Atomic memory — the durable, decision-relevant facts learned about them.
221
- lines.push(`FACTS (${rec.facts.length} — durable memory about them):`);
222
- for (const f of rec.facts) lines.push(` [${f.category}] ${f.content}${f.date ? ` (${relAge(f.date)})` : ""}`);
223
- lines.push("");
224
- }
225
- const docs = rec.documents ?? [];
226
- if (docs.length) {
227
- // Saved briefs / notes / transcripts kept on the contact — previews only.
228
- // The agent needs to KNOW these exist so it never reports "no brief on
229
- // file" when one is saved; the full body is read with search_notes.
230
- lines.push(`DOCUMENTS (${docs.length} — saved notes & meeting records, read with search_notes):`);
231
- for (const d of docs) {
232
- const when = d.date ? ` (${relAge(d.date)})` : "";
233
- lines.push(` ${d.type.replace(/_/g, " ")}${d.title ? ` · ${d.title}` : ""}${when}`);
234
- if (d.snippet) lines.push(` ${d.snippet}`);
235
- }
236
- lines.push("");
237
- }
238
- // The people connected to this company and how they relate. Same structure get_context surfaces.
239
- if (rec.stakeholders?.length) {
240
- const c = rec.committee;
241
- lines.push(c?.company ? `PEOPLE AT ${c.company}:` : "RELATED PEOPLE:");
242
- for (const s of rec.stakeholders) {
243
- if (s.role === "company") continue;
244
- const bits = [];
245
- if (s.role) bits.push(s.role);
246
- if (s.confirmed === false) bits.push("mentioned, unconfirmed");
247
- const rel = s.relationships?.length ? ` — ${s.relationships.join("; ")}` : "";
248
- lines.push(` ${s.name ?? "—"}${bits.length ? ` (${bits.join(", ")})` : ""}${rel}`);
249
- }
250
- lines.push("");
251
- }
252
- const claims = Object.values(rec.claims ?? {});
253
- if (claims.length) {
254
- lines.push(`ATTRIBUTES (${claims.length}):`);
255
- for (const c of claims) {
256
- lines.push(` ${c.property}: ${fmtVal(c.value)} [${pct(c.confidence)} · ${c.freshness}]`);
257
- }
258
- lines.push("");
259
- }
260
- // What they actually said and did — the most telling first, each with the
261
- // system it came from, so a claim in the answer can always be traced back.
262
- const activity = rec.key_activity ?? [];
263
- if (activity.length) {
264
- lines.push(`WHAT HAPPENED (${activity.length} most telling):`);
265
- for (const a of activity) {
266
- const when = a.when ? relAge(a.when) : "";
267
- const head = ` ${a.what}${a.source ? ` · ${a.source}` : ""}${when ? ` · ${when}` : ""}`;
268
- lines.push(a.detail ? `${head}\n ${a.detail}` : head);
269
- }
270
- lines.push("");
271
- }
272
-
273
- // Say what was left out, and why. An agent that is handed 18 of 300
274
- // interactions and does not know it will happily conclude that nothing else
275
- // ever happened.
276
- const sum = rec.activity_summary;
277
- if (sum?.note) lines.push(sum.note);
278
- else if (sum?.total_observations) {
279
- lines.push(`${sum.total_observations} interactions on record.`);
280
- }
281
-
282
- // Fall back to the raw timeline if an older API didn't rank anything.
283
- if (!activity.length && rec.recent_observations?.length) {
284
- const obs = rec.recent_observations;
285
- lines.push(`TIMELINE (${obs.length}):`);
286
- for (const o of obs.slice(0, 30)) {
287
- lines.push(` ${whenLabel(o.property, o.observed_at)} ${fmtType(o.property)}`);
288
- }
289
- }
290
-
291
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
292
- }
293
142
  );
294
143
 
295
144
  // ===========================================================================
296
- // TOOL: merge_contactsPOST /v2/accounts/merge
297
- // Fold a duplicate person into one account record. Agent-only dedup.
145
+ // TOOL: readGET /v2/company/pages/:id
146
+ // Read one full wiki page. Take the `path:` from a search result, or an id.
298
147
  // ===========================================================================
299
148
  server.tool(
300
- "merge_contacts",
301
- "Reconcile identity MERGE two duplicate records for the same person into one, or SPLIT a wrongly-merged " +
302
- "record back out. Set `action`: 'merge' (default) folds `drop` into the survivor `keep`; 'split' reverses a " +
303
- "merge (pass the merged-away id as `drop`, or just `keep` to undo the most recent merge into it). Merge is " +
304
- "lossless + reversiblethe duplicate's identifiers re-attach to the survivor so a future match on either " +
305
- "resolves to one account. If a name matches several people you'll get candidates to disambiguate. Use merge " +
306
- "when the same human exists twice (a LinkedIn connection with no email + a Cal.com booking that never linked); " +
307
- "use split when two DIFFERENT people were merged by mistake.",
308
- {
309
- action: z.enum(["merge", "split"]).optional().describe("'merge' (default) folds drop into keep; 'split' reverses a prior merge."),
310
- keep: z.string().optional().describe("The survivor — email, LinkedIn URL, entity UUID, or name. For split, the account to undo the most recent merge into (if drop is omitted)."),
311
- drop: z.string().optional().describe("For merge: the duplicate to fold into keep. For split: the merged-away entity id from the merge result."),
312
- },
313
- async ({ action, keep, drop }) => {
314
- // SPLIT — reverse a prior merge (was unmerge_contacts).
315
- if (action === "split") {
316
- if (!drop && !keep) return { content: [{ type: "text", text: "To split, give the merged-away id as `drop`, or the `keep` survivor whose last merge to undo." }] };
317
- try {
318
- const u = await post("/v2/accounts/unmerge", { drop_id: drop, keep });
319
- if (u.status === "ambiguous") {
320
- const opts = (u.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
321
- return { content: [{ type: "text", text: `"${keep}" matches several people. Re-call merge_contacts action:split with one of these entity ids as keep:\n${opts}` }] };
322
- }
323
- const lines = [
324
- `Split — ${u.drop_id} is its own account again.`,
325
- ` identifiers restored: ${u.identifiers}, claims: ${u.claims}, observations: ${u.observations}, relationships: ${u.relationships}`,
326
- u.contact_restored ? ` the contact record was recreated.` : null,
327
- ].filter(Boolean);
328
- return { content: [{ type: "text", text: lines.join("\n") }] };
329
- } catch (e) {
330
- const msg = /not_reversible/.test(e.message) ? "That merge can't be reversed — it predates reversible-merge tracking, or it was already split."
331
- : /no_reversible_merge/.test(e.message) ? "No un-reversed merge on that survivor to undo."
332
- : /entity_not_found/.test(e.message) ? "Couldn't find that survivor — check the keep identifier."
333
- : `Couldn't split: ${e.message}`;
334
- return { content: [{ type: "text", text: msg }] };
335
- }
336
- }
337
-
338
- // MERGE (default).
339
- if (!keep || !drop) return { content: [{ type: "text", text: "To merge, pass both `keep` (survivor) and `drop` (duplicate)." }] };
340
- const r = await post("/v2/accounts/merge", { keep, drop });
341
-
342
- if (r.status === "ambiguous") {
343
- const opts = (r.candidates ?? []).map(c =>
344
- ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
345
- const term = r.which === "keep" ? keep : drop;
346
- return { content: [{ type: "text", text:
347
- `"${term}" (the ${r.which}) matches several people. Re-call merge_contacts with one of these entity ids as ${r.which}:\n${opts}` }] };
348
- }
349
-
350
- const moved = Object.entries(r.rows_repointed ?? {}).map(([t, n]) => `${n} ${t}`).join(", ");
351
- const lines = [
352
- `Merged — folded ${r.drop_id} into ${r.keep_id}.`,
353
- ` identifiers re-attached: ${r.identifiers_moved} (a future match on either now resolves to one account)`,
354
- ` claims moved: ${r.claims_moved}${r.claims_conflicted ? ` (${r.claims_conflicted} kept on survivor)` : ""}`,
355
- ` observations moved: ${r.observations_moved}`,
356
- (r.relationships_repointed || r.relationships_removed)
357
- ? ` relationships: ${r.relationships_repointed} re-pointed, ${r.relationships_removed} pruned` : null,
358
- moved ? ` re-pointed: ${moved}` : null,
359
- `Reversible: if this was wrong, merge_contacts action:split drop "${r.drop_id}" puts it all back.`,
360
- ].filter(Boolean);
361
- return { content: [{ type: "text", text: lines.join("\n") }] };
362
- }
363
- );
364
-
365
- // DEPRECATED — use merge_contacts with action:'split'. Kept so existing agents keep working.
366
- server.tool(
367
- "unmerge_contacts",
368
- "DEPRECATED — use `merge_contacts` with action:'split'. Reverses a merge, splitting a wrongly-merged " +
369
- "record back into its own account (by `drop_id`, or by `keep` to undo the most recent merge into it).",
149
+ "read",
150
+ "Read the FULL text of one company wiki page search returns snippets; use this to " +
151
+ "get the whole document. Pass the `path:` shown under a [company] search hit (e.g. " +
152
+ "'Company/Policies/Refund Policy.md'), or an `id` if you already have one. Also returns the " +
153
+ "page's LINK GRAPHoutgoing [[links]] and backlinks so you can TRAVERSE the wiki to " +
154
+ "complete the picture (read a linked page next, or see what references this one). Distilled " +
155
+ "hits ([company·distilled]) aren't pages their content is in the search snippet.",
370
156
  {
371
- drop_id: z.string().optional().describe("The merged-away entity's id, from the merge_contacts result. Provide this OR keep."),
372
- keep: z.string().optional().describe("The survivor (email, LinkedIn URL, entity UUID, or name) — undoes the most recent un-reversed merge into it."),
157
+ path: z.string().optional().describe("The page path from a search result's `path:` line, e.g. 'Company/Policies/Refund Policy.md'. Provide this OR id."),
158
+ id: z.string().optional().describe("The page id, if you already have it (advanced)."),
373
159
  },
374
- async ({ drop_id, keep }) => {
375
- if (!drop_id && !keep) {
376
- return { content: [{ type: "text", text: "Give me the drop_id from the merge result, or the `keep` survivor whose last merge to undo." }] };
160
+ async ({ path, id }) => {
161
+ let pageId = id;
162
+ if (!pageId && path) {
163
+ const res = await resolvePathToId(path);
164
+ if (res.error) return text(res.error);
165
+ pageId = res.id;
377
166
  }
167
+ if (!pageId) return text("Tell me which page to read — pass the `path` from a search result (its `path:` line), or an `id`.");
378
168
  try {
379
- const r = await post("/v2/accounts/unmerge", { drop_id, keep });
380
- if (r.status === "ambiguous") {
381
- const opts = (r.candidates ?? []).map(c => ` • ${c.name ?? "(unnamed)"} [${c.entity_id}]`).join("\n");
382
- return { content: [{ type: "text", text: `"${keep}" matches several people. Re-call unmerge_contacts with one of these entity ids as keep:\n${opts}` }] };
169
+ const r = await get(`/v2/company/pages/${encodeURIComponent(pageId)}`);
170
+ const p = r?.page;
171
+ if (!p) return text("Couldn't read that page.");
172
+ const meta = [p.folder || "", p.kind, p.read_only ? "read-only" : null, `updated ${relAge(p.updated_at)}`].filter(Boolean).join(" · ");
173
+ const out = [`# ${String(p.name ?? "").replace(/\.md$/, "")}`, `(${meta})`, "", p.content || "(empty page)"];
174
+ // Link graph — lets the agent TRAVERSE the wiki (read a linked page next; see what points here).
175
+ const outgoing = p.ingest?.outgoing ?? [];
176
+ const backlinks = p.ingest?.backlinks ?? [];
177
+ if (outgoing.length || backlinks.length) {
178
+ out.push("", "---", "Links (read on any of these to traverse):");
179
+ if (outgoing.length) out.push(" → links to: " + outgoing.map((l) => `${l.target}${l.resolved ? "" : " (unresolved)"}`).join(", "));
180
+ if (backlinks.length) out.push(" ← linked from: " + backlinks.map((b) => b.source).join(", "));
383
181
  }
384
- const lines = [
385
- `Un-merged — ${r.drop_id} is its own account again.`,
386
- ` identifiers restored: ${r.identifiers}, claims: ${r.claims}, observations: ${r.observations}, relationships: ${r.relationships}`,
387
- r.contact_restored ? ` the contact record was recreated.` : null,
388
- ].filter(Boolean);
389
- return { content: [{ type: "text", text: lines.join("\n") }] };
182
+ return text(out.join("\n"));
390
183
  } catch (e) {
391
- const msg = /not_reversible/.test(e.message)
392
- ? "That merge can't be reversedit predates reversible-merge tracking, or it was already un-merged."
393
- : /no_reversible_merge/.test(e.message)
394
- ? "No un-reversed merge on that survivor to undo."
395
- : /entity_not_found/.test(e.message)
396
- ? "Couldn't find that survivor — check the keep identifier."
397
- : `Couldn't un-merge: ${e.message}`;
398
- return { content: [{ type: "text", text: msg }] };
399
- }
400
- }
401
- );
402
-
403
-
404
-
405
- // ===========================================================================
406
- // TOOL: query — POST /v2/query
407
- // Retrieve a corpus of activity across many people. You do the analysis.
408
- // ===========================================================================
409
- server.tool(
410
- "query",
411
- "Retrieve and summarise activity across many people and companies. Powers:\n" +
412
- " 1. return:'entities' groups results by person/company (one row per entity, ranked by " +
413
- "most-recent matching activity). Use for 'who we talked to this week', 'who replied recently'.\n" +
414
- " 2. `without` subtracts entities — 'sent in 5d MINUS replied in 5d' = 'wrote to, no reply back'. " +
415
- "'activity in 30d MINUS activity in 5d' = 'people who've gone quiet'.\n" +
416
- " 3. rollups.by_value appears when scope.kind='state' — counts entities by a current value " +
417
- "(scope.property='<state property>').\n" +
418
- " 4. Scheduled meetings/calls are events with property 'interaction.meeting_scheduled' and a " +
419
- "future-dated `when`. For 'what's booked today/this week', set property:'interaction.meeting_scheduled' " +
420
- "with from/to bounding the day or week (since_days only looks backward and can't reach them), and " +
421
- "order:'asc' to list soonest-first. Meeting rows render the absolute date and time.\n" +
422
- " 5. scope.facts:true + question searches the FACTS corpus (durable facts about people/companies) " +
423
- "instead of activity — cross-record semantic fact search like 'who is hiring' or 'who mentioned " +
424
- "switching tools'. return:'entities' gives the single best-matching fact per record. (A single " +
425
- "record's facts already come back inline with get_account.)",
426
- {
427
- scope: z.object({
428
- kind: z.enum(["event", "state"]).optional(),
429
- property: z.string().optional().describe("property prefix — 'interaction.email' covers email_sent and email_replied; 'interaction.meeting_scheduled' for booked meetings"),
430
- source: z.string().optional().describe("e.g. 'gmail', 'linkedin', 'slack'"),
431
- entity_id: z.string().optional().describe("scope to one person/company"),
432
- since_days: z.number().optional().describe("only activity within the last N days (backward only)"),
433
- from: z.string().optional().describe("ISO timestamp — only activity at/after this (absolute lower bound; use for date windows like 'today')"),
434
- to: z.string().optional().describe("ISO timestamp — only activity at/before this (absolute upper bound). Combine from+to for a window; future-dated for upcoming meetings"),
435
- order: z.enum(["asc", "desc"]).optional().describe("observed_at order (default desc, newest first). Use 'asc' for an upcoming-meeting schedule (soonest first)"),
436
- limit: z.number().optional().describe("max items (default 50, cap 200)"),
437
- facts: z.boolean().optional().describe("search the FACTS corpus (durable facts about people/companies) instead of activity. Needs `question` — a cross-record semantic fact search, e.g. 'who is hiring'. return:'entities' = the best matching fact per record."),
438
- }).describe("Corpus filter"),
439
- without: z.object({
440
- kind: z.enum(["event", "state"]).optional(),
441
- property: z.string().optional(),
442
- source: z.string().optional(),
443
- entity_id: z.string().optional(),
444
- since_days: z.number().optional(),
445
- }).optional().describe("Subtract entities matching this scope from the result — same shape as scope. Enables 'sent but no reply', 'cooled in last N days'."),
446
- return: z.enum(["observations", "entities"]).optional()
447
- .describe("observations (default) = one row per observation. entities = one row per entity, ranked by most-recent matching activity."),
448
- question: z.string().optional().describe("What you want to learn — echoed back; enables semantic ranking"),
449
- },
450
- async ({ scope, without, return: returnMode, question }) => {
451
- const body = { scope, question };
452
- if (without) body.without = without;
453
- if (returnMode) body.return = returnMode;
454
- const r = await post("/v2/query", body);
455
- const head = `${r.matched} match${r.matched !== 1 ? "es" : ""}` +
456
- (r.sampled ? ` (showing ${r.returned})` : "") +
457
- (r.corpus === "facts" ? " · facts" : r.return === "entities" ? " · grouped by entity" : "");
458
- const roll = Object.entries(r.rollups?.by_type ?? {})
459
- .map(([t, n]) => `${n}× ${fmtType(t)}`).join(" · ");
460
- const lines = [head, roll].filter(Boolean);
461
- if (r.rollups?.by_value && Object.keys(r.rollups.by_value).length) {
462
- lines.push("BY VALUE: " + Object.entries(r.rollups.by_value).map(([v, n]) => `${v}: ${n}`).join(", "));
463
- }
464
- lines.push("");
465
- for (const it of r.items ?? []) {
466
- if (r.corpus === "facts") {
467
- lines.push(` ${it.entity_name ?? it.entity_id} [${it.category}] ${it.content}` +
468
- (it.date ? ` [${relAge(it.date)}]` : "") +
469
- (it.similarity != null ? ` (${it.similarity})` : ""));
470
- } else if (r.return === "entities") {
471
- lines.push(` ${it.entity_name ?? it.entity_id} ` +
472
- `(${it.matches} match${it.matches !== 1 ? "es" : ""}, last ${whenLabel(it.most_recent_type, it.most_recent_at)})` +
473
- (it.most_recent_value != null ? ` → ${fmtVal(it.most_recent_value)}` : "") +
474
- (it.most_recent_summary ? `\n ${it.most_recent_summary}` : ""));
475
- } else {
476
- lines.push(` ${whenLabel(it.type, it.when)} ${it.entity_name ?? it.entity_id} ` +
477
- `${fmtType(it.type)}${it.summary ? `: ${it.summary}` : ""}`);
478
- }
479
- }
480
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
481
- }
482
- );
483
-
484
- // ===========================================================================
485
- // TOOL: attention — GET /v2/attention
486
- // What to look at: accounts gone quiet, key facts decayed.
487
- // ===========================================================================
488
- server.tool(
489
- "attention",
490
- "What needs attention across the company right now — upcoming meetings and calls in the next 7 " +
491
- "days (each with its date and time, soonest first), people and relationships that have gone quiet, " +
492
- "and facts that have decayed and should be re-verified. Returns ranked items (time-critical " +
493
- "meetings lead), each with what's happening and a suggested next step. Use it to decide what to " +
494
- "follow up on, or to answer 'what's coming up' / 'what's on my calendar this week'. For a precise " +
495
- "single-day list, use query with property:'interaction.meeting_scheduled' and from/to.",
496
- {
497
- limit: z.number().min(1).max(100).optional().describe("Max items (default 25)"),
498
- },
499
- async ({ limit }) => {
500
- const r = await get("/v2/attention", limit ? { limit } : {});
501
- if (!r.items?.length) {
502
- return { content: [{ type: "text", text: "Nothing needs attention right now." }] };
503
- }
504
- // Upcoming meetings carry a `when` — render the absolute local date+time.
505
- //
506
- // Each item also names where it came from: the calendar holding the call, the
507
- // transcript the promise was captured from. An agent that can cite the call
508
- // someone made a promise ON is making an argument; one that just asserts the
509
- // promise is asking to be trusted.
510
- const lines = r.items.map(it => {
511
- const when = it.when ? `${fmtWhen(it.when)} — ` : "";
512
- const from = it.source ? ` [${it.source}]` : "";
513
- return ` ${when}${it.entity_name ?? it.entity_id} — ${it.what}${from}\n → ${it.suggested_action}`;
514
- });
515
- return { content: [{ type: "text", text: `Needs attention (${r.items.length}):\n${lines.join("\n")}` }] };
516
- }
517
- );
518
-
519
-
520
- // ===========================================================================
521
- // TOOL: verify — POST /v2/verify
522
- // Re-check a fact before acting on it — the calibration check.
523
- // ===========================================================================
524
- server.tool(
525
- "verify",
526
- "Re-check a specific fact before you act on it — e.g. an email or a job title that looks stale " +
527
- "in get_context. Pass the person/company and the property name. Returns the fact re-derived from " +
528
- "current evidence, and tells you whether it is still unverified.",
529
- {
530
- focus: z.string().describe("Email, LinkedIn URL, entity UUID, or name"),
531
- property: z.string().describe("The fact to re-check — e.g. 'email', 'job_title', 'company'"),
532
- },
533
- async ({ focus, property }) => {
534
- const r = await post("/v2/verify", { focus, property });
535
- if (r.status === "ambiguous") {
536
- const opts = (r.candidates ?? []).map(c =>
537
- ` • ${c.name ?? "(unnamed)"}${c.detail ? ` — ${c.detail}` : ""} [${c.entity_id}]`).join("\n");
538
- return { content: [{ type: "text", text:
539
- `"${focus}" matches several people. Call verify again with one of these entity ids:\n${opts}` }] };
184
+ const msg = String(e?.message || e);
185
+ if (msg.includes("not_found") || msg.includes("(404)")) return text("No page with that id use search to find the path, then read it.");
186
+ if (msg.includes("forbidden") || msg.includes("(403)")) return text("You don't have access to read that company page.");
187
+ throw e;
540
188
  }
541
- const a = r.after ?? {};
542
- return { content: [{ type: "text", text:
543
- `${property}: ${fmtVal(a.value)} [${pct(a.confidence)} · ${a.freshness}]\n${r.note ?? ""}` }] };
544
- }
545
- );
546
-
547
- // ===========================================================================
548
- // TOOL: save_note — POST /v2/notes
549
- // Attach a long-form artifact to a CONTACT: a meeting brief you wrote, a
550
- // transcript, pre-meeting prep, or a plain note. Append-only and dated, so the
551
- // contact builds up a document trail across meetings.
552
- // ===========================================================================
553
- server.tool(
554
- "save_note",
555
- "Save a note or document onto a person or company so it is kept on their record — a meeting " +
556
- "brief you wrote, a transcript, pre-meeting prep, research, or a plain note. Use this whenever " +
557
- "you produce something durable about a specific contact that's worth keeping for next time (e.g. " +
558
- "after writing a meeting brief, save it to the contact so future meetings can reference it). " +
559
- "Notes are append-only and dated, so a contact builds a document trail across meetings — later " +
560
- "you can read the last few and see what changed. Put the full text in `content` — it's kept for " +
561
- "agents to read; the UI shows the title and date, not the whole body.",
562
- {
563
- focus: z.string().describe("Who to attach it to — an email, LinkedIn URL, domain, or entity UUID (not a bare name)."),
564
- content: z.string().describe("The full note or document text (a short note or a complete brief/transcript)."),
565
- type: z.enum(["note", "meeting_brief", "transcript", "meeting_notes", "pre_meeting", "research"])
566
- .optional().describe("What kind of document this is (default: note)."),
567
- title: z.string().optional().describe("A short name, e.g. 'Pre-meeting brief — renewal' or 'Transcript — Jun 1'."),
568
- date: z.string().optional().describe("The relevant date (e.g. the meeting date, ISO or plain). Defaults to now."),
569
- },
570
- async ({ focus, content, type, title, date }) => {
571
- const r = await post("/v2/notes", { focus, content, type, title, date });
572
- const label = title || (r.doc_type || "note").replace(/_/g, " ");
573
- return { content: [{ type: "text", text: `Saved ${label} to ${focus}.` }] };
574
- },
575
- );
576
-
577
- // ===========================================================================
578
- // TOOL: list_my_files — GET /v2/personal/tree
579
- // Navigate the member's personal PARA vault like a filesystem: Inbox · Projects ·
580
- // Areas · Resources · Archive · People · Companies. Returns every file as a path.
581
- // ===========================================================================
582
- server.tool(
583
- "list_my_files",
584
- "List the files in your personal workspace — a PARA knowledge tree (Inbox, Projects, Areas, " +
585
- "Resources, Archive, People, Companies) of markdown files you own. Use this to SEE what's there " +
586
- "and navigate it like a filesystem before reading or writing. Returns each file's path " +
587
- "(e.g. 'projects/acme-rollout/notes.md'); read one with read_my_file, write with save_personal_file.",
588
- {
589
- folder: z.string().optional().describe("Optional: only list files under this top-level folder (e.g. 'projects')."),
590
- },
591
- async ({ folder }) => {
592
- const d = await get("/v2/personal/tree");
593
- let files = d.files ?? [];
594
- if (folder) files = files.filter((f) => f.folder === String(folder).toLowerCase());
595
- if (!files.length) return { content: [{ type: "text", text: folder ? `No files in ${folder}/ yet.` : "Your personal workspace is empty." }] };
596
- const lines = files.map((f) => ` ${f.path}${f.status === "inbox_pending" ? " (pending approval)" : ""}`).join("\n");
597
- return { content: [{ type: "text", text: `${files.length} file(s):\n${lines}` }] };
598
189
  },
599
190
  );
600
191
 
601
192
  // ===========================================================================
602
- // TOOL: read_my_fileGET /v2/personal/file?path=…
603
- // Read one personal-vault file by its path.
193
+ // TOOL: pushPOST /v2/company/pages/batch
194
+ // PUSH one or many files UP into the shared company folders (the personal→company promotion).
195
+ // Hybrid write policy: a write role publishes live; a viewer sends the batch to the company
196
+ // inbox to approve.
604
197
  // ===========================================================================
605
198
  server.tool(
606
- "read_my_file",
607
- "Read one file from your personal workspace by its path (e.g. 'companies/acme.md' or " +
608
- "'projects/acme-rollout/notes.md'). Use list_my_files first to find the path. Returns the " +
609
- "markdown content so you can work with it, then save changes with save_personal_file.",
199
+ "push",
200
+ "PUSH a file (or several) UP into the COMPANY WIKI promote knowledge from your own work into " +
201
+ "the shared company folders every member's agent can read via search. Typically you " +
202
+ "push a personal/working file up so the company has it. All pushed files go into the SAME " +
203
+ "folder (resolved or created once). Name the target with `folder` — a name or path like " +
204
+ "'Projects/Data Centre' (top-level folders are fixed; a bare name becomes a project under " +
205
+ "Projects) — plus optional `subfolder`, or an existing `folder_id`. A write role publishes it " +
206
+ "live (searchable now); a viewer's push lands in the company inbox for an admin to approve. " +
207
+ "Max 50 files. This is the SHARED company wiki — the user's own private files live on their disk.",
610
208
  {
611
- path: z.string().describe("The file path: 'folder/[subfolder/]name.md' (folder is one of inbox/projects/areas/resources/archive/people/companies)."),
209
+ folder: z.string().optional().describe("Folder to file all pages under, by NAME or path. Top-level is fixed — a bare name becomes a project under Projects; 'Top-Level/Sub' nests under a fixed folder. Created if missing."),
210
+ subfolder: z.string().optional().describe("Optional subfolder within `folder`."),
211
+ folder_id: z.string().optional().describe("Id of an existing folder (alternative to `folder`). Omit both for the default folder."),
212
+ visibility: z.enum(["owner", "department", "company"]).optional().describe("Default visibility: 'department' (default), 'company' (everyone), or 'owner' (just you). A page can override its own."),
213
+ pages: z.array(z.object({
214
+ name: z.string().describe("File/page name, e.g. 'Refund Policy'. A '.md' suffix is added if missing."),
215
+ content: z.string().optional().describe("The full markdown content."),
216
+ visibility: z.enum(["owner", "department", "company"]).optional().describe("Optional per-file visibility override."),
217
+ })).describe("The file(s) to push — one entry for a single file, many for a bulk push. Each { name, content, visibility? }."),
612
218
  },
613
- async ({ path }) => {
219
+ async ({ folder, subfolder, folder_id, visibility, pages }) => {
614
220
  try {
615
- const d = await get("/v2/personal/file", { path });
616
- let text = `# ${d.path}\n\n${d.content || "(empty)"}`;
617
- const out = (d.outgoing ?? []).filter((l) => l.resolved);
618
- const back = d.backlinks ?? [];
619
- if (out.length || back.length) {
620
- text += `\n\n---\n`;
621
- if (out.length) text += `Links to: ${out.map((l) => `${l.target}${l.kind.startsWith("relation:") ? ` (${l.kind.slice(9)})` : ""}`).join(", ")}\n`;
622
- if (back.length) text += `Linked from: ${back.map((l) => l.source).join(", ")}\n`;
623
- }
624
- return { content: [{ type: "text", text }] };
221
+ const r = await post("/v2/company/pages/batch", { folder, subfolder, folder_id, visibility, pages });
222
+ const where = r?.status === "live" ? "published live — searchable now" : "sent to the company inbox for an admin to approve";
223
+ return text(`${r?.count ?? pages?.length ?? 0} file(s) ${where} in "${r?.folder ?? folder ?? "wiki"}".`);
625
224
  } catch (e) {
626
- const msg = String(e?.message || "");
627
- return { content: [{ type: "text", text: msg.includes("404") ? `No file at ${path}. Use list_my_files to see what's there.` : `Couldn't read ${path}.` }] };
225
+ const msg = String(e?.message || e);
226
+ if (msg.includes("forbidden") || msg.includes("(403)")) return text("You don't have access to push to this company wiki.");
227
+ if (msg.includes("bad_folder")) return text("That folder path couldn't be resolved or created — check the name, or pass folder_id.");
228
+ if (msg.includes("too_many")) return text("Too many files — max 50 per call. Split into batches.");
229
+ if (msg.includes("pages_required")) return text("Give at least one file ({ name, content }).");
230
+ throw e;
628
231
  }
629
232
  },
630
233
  );
631
234
 
632
235
  // ===========================================================================
633
- // TOOL: create_projectPOST /v2/personal/project
634
- // Scaffold a project folder with the standard sub-structure.
635
- // ===========================================================================
636
- server.tool(
637
- "create_project",
638
- "Create a new project in your workspace, scaffolded with the standard structure: Context, " +
639
- "Working Documents, Decisions, People & Companies, Deliverables, Raw Documents, Archive. " +
640
- "Use this when you start a real piece of work so everything about it has a home. Then write " +
641
- "files into projects/<name>/<subfolder>/… (e.g. the current status goes in Context).",
642
- {
643
- name: z.string().describe("The project name, e.g. 'Acme rollout'."),
644
- },
645
- async ({ name }) => {
646
- const d = await post("/v2/personal/project", { name });
647
- return { content: [{ type: "text", text: `Created project "${d.project}" at ${d.path}/ with: ${(d.folders || []).join(", ")}.` }] };
648
- },
649
- );
650
-
651
- // ===========================================================================
652
- // TOOL: propose_vault_file — POST /v2/personal/propose
653
- // Propose a markdown file into the member's PERSONAL vault. It lands in their
654
- // inbox as a proposal — the member approves it before it is filed into a folder
655
- // or synced to their Git. This is how an agent contributes to a member's own
656
- // notes (thoughts, decisions, briefs, content) without writing anything without
657
- // consent. Distinct from `save_note` (which attaches a document to a CONTACT's
658
- // record) — this is the member's private vault, not an account.
659
- // ===========================================================================
660
- // DEPRECATED — use save_to_vault. Kept so existing agents keep working; forwards to the
661
- // same direct personal-vault save (the member owns their vault, so no inbox step).
662
- server.tool(
663
- "propose_vault_file",
664
- "DEPRECATED — use `save_personal_file`. Saves a markdown file into the member's own private knowledge.",
665
- {
666
- folder: z.string().describe("The vault folder (inbox | projects | decisions | companies | people | resources | archive)."),
667
- name: z.string().describe("The file name, ending in .md."),
668
- content: z.string().describe("The full markdown content."),
669
- subfolder: z.string().optional().describe("Optional subfolder within the folder."),
670
- },
671
- async ({ folder, name, content, subfolder }) => {
672
- const r = await post("/v2/personal/files", { folder, name, content, subfolder });
673
- return { content: [{ type: "text", text: `"${name}" is waiting in your Inbox to approve — once you do, it files into ${r?.folder || folder}.` }] };
674
- },
675
- );
676
-
677
- // ===========================================================================
678
- // TOOL: search — POST /v2/search (unified; replaces the three scoped searches)
679
- // One retrieval surface across company knowledge + personal vault + notes, ACL-
680
- // filtered per layer and fused best-first. `scope` narrows it. Cerebras-style:
681
- // one search, the agent orchestrates; get_context is the synthesized counterpart.
682
- // ===========================================================================
683
- const runSearch = async ({ question, scope, limit, folder }) => {
684
- const r = await post("/v2/search", { question, scope, limit, folder });
685
- if (!r.documents?.length) {
686
- return { content: [{ type: "text", text: `Nothing matched "${question}"${scope && scope !== "all" ? ` in ${scope}` : ""}.` }] };
687
- }
688
- const lines = [`Results for "${question}"${scope && scope !== "all" ? ` (${scope})` : ""}:`, ""];
689
- for (const d of r.documents) {
690
- const tag = d.scope === "company" ? (d.kind === "derived" ? "[company·derived]" : "[company]")
691
- : d.scope === "personal" ? "[my vault]" : "[note]";
692
- const where = d.ref ? `${d.ref} · ` : "";
693
- const from = d.source_member ? ` · from ${d.source_member}` : "";
694
- const match = d.similarity == null ? "recent" : pct(d.similarity);
695
- lines.push(` ${tag} ${where}${String(d.name ?? "").replace(/\.md$/, "")}${from} (${match})`);
696
- if (d.snippet) lines.push(` ${d.snippet}`);
697
- if (d.entity_id) lines.push(` (entity_id: ${d.entity_id})`);
698
- }
699
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
700
- };
701
-
702
- server.tool(
703
- "search",
704
- "Search the company's knowledge in one call — DECLARED + DERIVED company knowledge (policies, playbooks, " +
705
- "decisions, and what actually happened on accounts), the member's OWN private vault (their notes/drafts), " +
706
- "and NOTES kept on contacts — each ACL-filtered, fused best-first. This is the default retrieval tool: reach " +
707
- "for it before answering from generic knowledge. Narrow with `scope`: 'company', 'personal', 'notes', or " +
708
- "'all' (default — everything this key may read). Returns matching items tagged by layer with a snippet. For " +
709
- "a synthesized brief on ONE person/company use get_context; for who knows a topic use who_knows.",
710
- {
711
- question: z.string().describe("Natural-language query."),
712
- scope: z.enum(["all", "company", "personal", "notes"]).optional().describe("Which layer(s) to search. Default 'all' — fused across every layer this key can read."),
713
- limit: z.number().optional().describe("Max results (default 8, max 20)."),
714
- folder: z.string().optional().describe("Optional — for scope 'personal', restrict to one vault folder."),
715
- },
716
- async (args) => runSearch(args),
717
- );
718
-
719
- // ── Deprecated aliases → search. Kept so existing agents/configs keep working; removed after a window. ──
720
- server.tool(
721
- "search_company_knowledge",
722
- "DEPRECATED — use `search` with scope 'company'. Searches the shared company knowledge (declared + derived).",
723
- { question: z.string(), limit: z.number().optional() },
724
- async ({ question, limit }) => runSearch({ question, scope: "company", limit }),
725
- );
726
- server.tool(
727
- "search_my_vault",
728
- "DEPRECATED — use `search` with scope 'personal'. Searches the member's own private vault.",
729
- { question: z.string(), folder: z.string().optional(), limit: z.number().optional() },
730
- async ({ question, folder, limit }) => runSearch({ question, scope: "personal", limit, folder }),
731
- );
732
- server.tool(
733
- "search_notes",
734
- "DEPRECATED — use `search` with scope 'notes'. Searches notes/documents kept on contacts.",
735
- { question: z.string(), limit: z.number().optional() },
736
- async ({ question, limit }) => runSearch({ question, scope: "notes", limit }),
737
- );
738
-
739
- // ===========================================================================
740
- // TOOL: who_knows — POST /v2/who-knows
741
- // Expertise routing: internal people whose own knowledge (meetings, notes,
742
- // messages) matches a topic, ranked. Honest by construction — someone only
743
- // surfaces if their distilled knowledge actually matched.
236
+ // TOOL: updatePATCH /v2/company/pages/:id
237
+ // Edit an existing wiki page: replace its content, or append. Re-embeds on the server.
744
238
  // ===========================================================================
745
239
  server.tool(
746
- "who_knows",
747
- "Find the internal people who know about a topic ranked by how much of their OWN knowledge " +
748
- "(meetings, notes, messages distilled into the brain) matches it. Use for 'who knows about X', " +
749
- "'who's the expert on Y', 'who should I ask about Z'. Returns people with a relevance score and a " +
750
- "sample of the matching knowledge. Honest by construction: someone only appears if their knowledge " +
751
- "genuinely matched an empty result means no one's tracked knowledge covers it yet.",
240
+ "update",
241
+ "Edit an EXISTING company wiki page revise it or append to it. Identify the page by `path` " +
242
+ "(the `path:` from a search result) or `id`. By default the new `content` REPLACES the " +
243
+ "page; set append:true to add to the end instead (kept for a rolling log or a running doc). " +
244
+ "You can also rename it with `name`. Re-embedded so search picks up the change. For a NEW page " +
245
+ "use push. Distilled items (from sources) are read-only and can't be edited here.",
752
246
  {
753
- topic: z.string().describe("The topic/area/system to find experts on."),
754
- limit: z.number().optional().describe("Max people to return (default 5, max 20)."),
247
+ path: z.string().optional().describe("The page path from a search result's `path:` line. Provide this OR id."),
248
+ id: z.string().optional().describe("The page id (advanced). Provide this OR path."),
249
+ content: z.string().describe("The new markdown. Replaces the page unless append is true."),
250
+ append: z.boolean().optional().describe("If true, append to the end of the page instead of replacing it."),
251
+ name: z.string().optional().describe("Optionally rename the page."),
755
252
  },
756
- async ({ topic, limit }) => {
757
- const r = await post("/v2/who-knows", { topic, limit });
758
- if (!r.people?.length) {
759
- return { content: [{ type: "text", text: `No one's tracked knowledge matched "${topic}" yet.` }] };
253
+ async ({ path, id, content, append, name }) => {
254
+ let pageId = id;
255
+ if (!pageId && path) {
256
+ const res = await resolvePathToId(path);
257
+ if (res.error) return text(res.error);
258
+ pageId = res.id;
760
259
  }
761
- const lines = [`People who know about "${topic}":`, ""];
762
- for (const p of r.people) {
763
- lines.push(` ${p.name} (${p.mentions} matching${p.relevance != null ? `, relevance ${p.relevance}` : ""})`);
764
- if (p.sample) lines.push(` ${p.sample}`);
260
+ if (!pageId) return text("Tell me which page to edit — pass the `path` from a search result, or an `id`.");
261
+ try {
262
+ const body = {};
263
+ if (append) {
264
+ const cur = await get(`/v2/company/pages/${encodeURIComponent(pageId)}`);
265
+ body.content = `${cur?.page?.content || ""}\n\n${content}`;
266
+ } else {
267
+ body.content = content;
268
+ }
269
+ if (name) body.name = name;
270
+ await patch(`/v2/company/pages/${encodeURIComponent(pageId)}`, body);
271
+ return text(`Updated "${name ? name.replace(/\.md$/, "") : (path ? String(path).split("/").pop().replace(/\.md$/, "") : pageId)}"${append ? " (appended)" : ""} — re-embedded, searchable now.`);
272
+ } catch (e) {
273
+ const msg = String(e?.message || e);
274
+ if (msg.includes("not_found") || msg.includes("(404)")) return text("No editable page with that id — distilled items are read-only. Use search to find a wiki page.");
275
+ if (msg.includes("forbidden") || msg.includes("(403)")) return text("You don't have access to edit that company page.");
276
+ throw e;
765
277
  }
766
- return { content: [{ type: "text", text: lines.join("\n").trim() }] };
767
278
  },
768
279
  );
769
280
 
770
281
  // ===========================================================================
771
- // TOOL: propose_company_file — POST /v2/company/pages
772
- // The WRITE side of the company wiki (search is the read side).
773
- // Writes an in-app company page directly (no GitHub). Members with a write role
774
- // publish it live; viewers land it in the company inbox for an admin to approve.
775
- // ===========================================================================
776
- const ADD_WIKI_PAGE_SCHEMA = {
777
- name: z.string().describe("The page name, e.g. 'Refund Policy' or 'Q3 Planning — 2026-08-22'. A '.md' suffix is added if missing."),
778
- content: z.string().describe("The full markdown content of the page."),
779
- visibility: z.enum(["owner", "department", "company"]).optional().describe("Who can see the page: 'department' (the team it's filed in — default), 'company' (everyone), or 'owner' (just you)."),
780
- folder: z.string().optional().describe("Folder to file under, by NAME or path. Top-level folders are fixed — a bare name becomes a project under Projects (e.g. 'Agency' → Projects/Agency); 'Top-Level/Sub' nests under a fixed folder. Created if missing (write role). Use this OR folder_id."),
781
- subfolder: z.string().optional().describe("Optional subfolder within `folder`, e.g. 'Specs'."),
782
- folder_id: z.string().optional().describe("Id of an existing company folder to file under (alternative to `folder`). Omit both to use the default folder."),
783
- };
784
- const runAddCompanyWikiPage = async ({ name, content, visibility, folder, subfolder, folder_id }) => {
785
- try {
786
- const r = await post("/v2/company/pages", { name, content, visibility, folder, subfolder, folder_id });
787
- const live = r?.page?.status === "live";
788
- const where = live ? `published live to the company wiki — searchable now` : `sent to the company inbox for an admin to approve`;
789
- return { content: [{ type: "text", text: `"${r?.page?.name ?? name}" ${where}.` }] };
790
- } catch (e) {
791
- const msg = String(e?.message || e);
792
- if (msg.includes("forbidden") || msg.includes("(403)")) return { content: [{ type: "text", text: `Not saved — you don't have access to write to this company wiki.` }] };
793
- if (msg.includes("bad_folder")) return { content: [{ type: "text", text: `Not saved — that folder_id isn't a company folder in this workspace. Omit it to use the default folder.` }] };
794
- throw e;
795
- }
796
- };
797
- server.tool(
798
- "add_company_wiki_page",
799
- "Add a markdown page to the COMPANY WIKI — the shared, declared knowledge (policies, playbooks, " +
800
- "decisions, SOPs, how-we-work) every member's agent can read via search. A member with a write role " +
801
- "publishes it live; a viewer's page lands in the company inbox for an admin to approve. File it with " +
802
- "`folder` — a name or path like 'Projects/Data Centre' (created if missing) — plus optional `subfolder`. " +
803
- "Set `visibility`: 'department' (the team it's filed in — default), 'company' (everyone), or 'owner' " +
804
- "(just you); a page in a project inherits the project's Shared-with setting for OTHER departments. To add " +
805
- "many at once use add_company_wiki_pages. NOT the member's private files (use save_personal_file), NOT a contact note (save_note).",
806
- ADD_WIKI_PAGE_SCHEMA,
807
- runAddCompanyWikiPage,
808
- );
809
- // DEPRECATED alias → add_company_wiki_page. Kept so existing agents keep working.
810
- server.tool(
811
- "propose_company_file",
812
- "DEPRECATED — use `add_company_wiki_page`. Adds a markdown page to the shared company wiki.",
813
- ADD_WIKI_PAGE_SCHEMA,
814
- runAddCompanyWikiPage,
815
- );
816
-
817
-
818
- // ===========================================================================
819
- // TOOL: create_folder — POST /v2/company/folders { path }
282
+ // TOOL: create — POST /v2/company/folders
820
283
  // Create a folder / nested path in the shared company tree so pages can be filed into it.
821
284
  // ===========================================================================
822
- const CREATE_WIKI_FOLDER_SCHEMA = { path: z.string().describe("Folder name or path. A bare name → a project under Projects; 'Top-Level/Sub' nests under a fixed top-level folder.") };
823
- const runCreateCompanyWikiFolder = async ({ path }) => {
824
- try {
825
- const r = await post("/v2/company/folders", { path });
826
- const made = r?.created?.length ? ` (created ${r.created.join(" / ")})` : " (already existed)";
827
- return { content: [{ type: "text", text: `Folder "${r?.folder?.name ?? path}" ready${made}. folder_id: ${r?.folder?.id ?? "?"}` }] };
828
- } catch (e) {
829
- const msg = String(e?.message || e);
830
- if (msg.includes("read_only") || msg.includes("(403)")) return { content: [{ type: "text", text: "You don't have a write role in this company wiki, so you can't create folders here." }] };
831
- if (msg.includes("bad_path")) return { content: [{ type: "text", text: "Couldn't create that path — give a folder name or 'Parent/Child' path." }] };
832
- throw e;
833
- }
834
- };
835
- server.tool(
836
- "create_company_wiki_folder",
837
- "Create a folder in the COMPANY WIKI tree so you can file pages into it. The TOP-LEVEL folders are FIXED " +
838
- "(Company · Projects · Decisions · Companies · People · Raw Documents · Archive) — you cannot add new ones. " +
839
- "A bare name (or a path that doesn't start with one of those) becomes a PROJECT under Projects: 'Agency' → " +
840
- "Projects/Agency. To nest under a specific top-level folder, start the path with it, e.g. 'Company/Policies' " +
841
- "or 'Projects/Data Centre/Specs'. A project gets a 'Shared with' control the Ambassador uses to grant " +
842
- "departments. A write role is required; the folder appears immediately. Then add pages with " +
843
- "add_company_wiki_page / add_company_wiki_pages using the same path. Returns the folder id.",
844
- CREATE_WIKI_FOLDER_SCHEMA,
845
- runCreateCompanyWikiFolder,
846
- );
847
- // DEPRECATED alias → create_company_wiki_folder.
848
- server.tool(
849
- "create_folder",
850
- "DEPRECATED — use `create_company_wiki_folder`. Creates a folder in the shared company wiki tree.",
851
- CREATE_WIKI_FOLDER_SCHEMA,
852
- runCreateCompanyWikiFolder,
853
- );
854
-
855
- // ===========================================================================
856
- // TOOL: propose_company_files — POST /v2/company/pages/batch
857
- // Bulk-add many pages into ONE company folder (resolved/created once).
858
- // ===========================================================================
859
- const ADD_WIKI_PAGES_SCHEMA = {
860
- folder: z.string().optional().describe("Folder to file all pages under. Top-level is fixed — a bare name becomes a project under Projects (e.g. 'Agency' → Projects/Agency); 'Top-Level/Sub' nests under a fixed folder. Created if missing."),
861
- subfolder: z.string().optional().describe("Optional subfolder within `folder`."),
862
- folder_id: z.string().optional().describe("Id of an existing folder (alternative to `folder`)."),
863
- visibility: z.enum(["owner", "department", "company"]).optional().describe("Default visibility for the pages (a page can override its own). Default 'department'."),
864
- pages: z.array(z.object({
865
- name: z.string().describe("Page name."),
866
- content: z.string().optional().describe("Markdown content."),
867
- visibility: z.enum(["owner", "department", "company"]).optional().describe("Optional per-page visibility."),
868
- })).describe("The pages to add: each { name, content, visibility? }."),
869
- };
870
- const runAddCompanyWikiPages = async ({ folder, subfolder, folder_id, visibility, pages }) => {
871
- try {
872
- const r = await post("/v2/company/pages/batch", { folder, subfolder, folder_id, visibility, pages });
873
- const where = r?.status === "live" ? "published live — searchable now" : "sent to the company inbox for an admin to approve";
874
- return { content: [{ type: "text", text: `${r?.count ?? 0} page(s) ${where} in "${r?.folder ?? folder ?? "wiki"}".` }] };
875
- } catch (e) {
876
- const msg = String(e?.message || e);
877
- if (msg.includes("forbidden") || msg.includes("(403)")) return { content: [{ type: "text", text: "You don't have access to write to this company wiki." }] };
878
- if (msg.includes("bad_folder")) return { content: [{ type: "text", text: "That folder path couldn't be resolved or created — check the name, or pass folder_id." }] };
879
- if (msg.includes("too_many")) return { content: [{ type: "text", text: "Too many pages — max 50 per call. Split into batches." }] };
880
- if (msg.includes("pages_required")) return { content: [{ type: "text", text: "Give at least one page ({ name, content })." }] };
881
- throw e;
882
- }
883
- };
884
- server.tool(
885
- "add_company_wiki_pages",
886
- "Add MANY markdown pages to the COMPANY WIKI in ONE call — all into the SAME folder (resolved or created " +
887
- "once). Use it to bulk-file: 'put these 10 docs in Data Centre'. Same rules as add_company_wiki_page: a " +
888
- "write role publishes them live; a viewer sends the whole batch to the company inbox. Name the target with " +
889
- "`folder` (a path, created if missing) + optional `subfolder`, or an existing `folder_id`. Max 50 pages.",
890
- ADD_WIKI_PAGES_SCHEMA,
891
- runAddCompanyWikiPages,
892
- );
893
- // DEPRECATED alias → add_company_wiki_pages.
894
- server.tool(
895
- "propose_company_files",
896
- "DEPRECATED — use `add_company_wiki_pages`. Bulk-adds pages to the shared company wiki.",
897
- ADD_WIKI_PAGES_SCHEMA,
898
- runAddCompanyWikiPages,
899
- );
900
-
901
- // ===========================================================================
902
- // TOOL: connect_integration — POST /v2/workspace/integrations
903
- // The agent connects a KEY-BASED integration for the user (no clicking through
904
- // the Integrations page). OAuth providers still need a browser, so this is
905
- // limited to providers that authenticate with an API key/token.
906
- // ===========================================================================
907
285
  server.tool(
908
- "connect_integration",
909
- "Connect a data source that feeds the knowledge base a provider that authenticates with an API " +
910
- "key or token. Ask the user for the provider's key, then call this; it verifies the credentials " +
911
- "before saving. Providers that use a browser sign-in (OAuth, e.g. Gmail or calendar) can't be " +
912
- "connected this way for those, point the user to the Integrations page. Once connected, the " +
913
- "source starts feeding the knowledge base.",
914
- {
915
- provider: z.string().describe("Provider name, lowercase."),
916
- credentials: z.record(z.string()).describe("The provider's credentials as key/value, e.g. { api_key: '...' } or { access_token: '...' }."),
917
- name: z.string().optional().describe("Optional label for the connection."),
918
- },
919
- async ({ provider, credentials, name }) => {
286
+ "create",
287
+ "Create a folder in the COMPANY WIKI tree so you can file pages into it. The TOP-LEVEL folders " +
288
+ "are FIXED (Company · Projects · Decisions · Companies · People · Raw Documents · Archive) you " +
289
+ "cannot add new ones. A bare name (or a path not starting with a fixed folder) becomes a PROJECT " +
290
+ "under Projects: 'Agency' Projects/Agency. To nest under a specific top-level folder, start the " +
291
+ "path with it, e.g. 'Company/Policies' or 'Projects/Data Centre/Specs'. A write role is required; " +
292
+ "the folder appears immediately. Then push files into it with push using the same " +
293
+ "path. Returns the folder id.",
294
+ { path: z.string().describe("Folder name or path. A bare name a project under Projects; 'Top-Level/Sub' nests under a fixed top-level folder.") },
295
+ async ({ path }) => {
920
296
  try {
921
- const r = await post("/v2/workspace/integrations", { provider, credentials, name });
922
- return { content: [{ type: "text", text: `Connected ${r.connection?.provider ?? provider}.${r.message ? ` ${r.message}` : ""}` }] };
297
+ const r = await post("/v2/company/folders", { path });
298
+ const made = r?.created?.length ? ` (created ${r.created.join(" / ")})` : " (already existed)";
299
+ return text(`Folder "${r?.folder?.name ?? path}" ready${made}. folder_id: ${r?.folder?.id ?? "?"}`);
923
300
  } catch (e) {
924
- const msg = String(e?.message ?? e);
925
- if (msg.includes("oauth_provider")) {
926
- return { content: [{ type: "text", text: `${provider} uses a browser sign-in, so it can't be connected with a key. Tell the user to connect it on the Integrations page.` }] };
927
- }
928
- if (msg.includes("invalid_credentials")) {
929
- return { content: [{ type: "text", text: `Those credentials didn't verify for ${provider}. Ask the user to double-check the key and try again.` }] };
930
- }
931
- if (msg.includes("unknown_provider")) {
932
- return { content: [{ type: "text", text: `No provider named "${provider}". Ask the user which tool they mean.` }] };
933
- }
301
+ const msg = String(e?.message || e);
302
+ if (msg.includes("read_only") || msg.includes("(403)")) return text("You don't have a write role in this company wiki, so you can't create folders here.");
303
+ if (msg.includes("bad_path")) return text("Couldn't create that path give a folder name or 'Parent/Child' path.");
934
304
  throw e;
935
305
  }
936
- }
937
- );
938
-
939
-
940
-
941
- // ===========================================================================
942
- // TOOL: save_to_vault — POST /v2/personal/files
943
- // Save a file into the MEMBER'S OWN private vault folder. Extracts a PDF/DOCX to text.
944
- // ===========================================================================
945
- const SAVE_PERSONAL_SCHEMA = {
946
- folder: z.string().describe("The personal folder: inbox | projects | decisions | companies | people | raw documents | archive."),
947
- name: z.string().describe("The file name, e.g. 'Q3 Plan.md' or 'vendor-contract.pdf'."),
948
- content: z.string().optional().describe("The text/markdown content, when you already have it as text."),
949
- file: z.string().optional().describe("A PDF or DOCX as base64 (a data: URL is fine). It's extracted to text automatically. Provide this OR content."),
950
- mime: z.string().optional().describe("The file's MIME type, e.g. 'application/pdf' or the docx type — helps pick the right extractor."),
951
- subfolder: z.string().optional().describe("Optional subfolder within the folder."),
952
- };
953
- const runSavePersonalFile = async ({ folder, name, content, file, mime, subfolder }) => {
954
- try {
955
- const r = await post("/v2/personal/files", { folder, name, content, file_base64: file, mime, subfolder });
956
- return { content: [{ type: "text", text: `"${name}" is waiting in your Inbox to approve — once you do, it files into ${r?.folder || folder} and becomes searchable.` }] };
957
- } catch (e) {
958
- const msg = String(e?.message || e);
959
- if (msg.includes("no_text_extracted")) return { content: [{ type: "text", text: `Couldn't read any text from that file — a scanned/image-only PDF has no extractable text. Text, markdown, or a text-based PDF/DOCX works.` }] };
960
- if (msg.includes("content_or_file_required")) return { content: [{ type: "text", text: `Nothing to save — pass either text content or a base64 file.` }] };
961
- throw e;
962
- }
963
- };
964
- server.tool(
965
- "save_personal_file",
966
- "Save a file into the MEMBER'S OWN personal knowledge (their private files — 'My Knowledge'). It lands in " +
967
- "their INBOX for review with the target folder you name remembered (inbox, projects, decisions, companies, " +
968
- "people, raw documents, archive); the member approves it in the app, which files it and makes it searchable. " +
969
- "Nothing enters their knowledge without their approval. Use it when the member asks you to keep or file " +
970
- "something — 'save this to raw documents', 'put this PDF in projects'. Pass markdown/text as `content`, OR a " +
971
- "PDF/DOCX as base64 in `file` (auto-extracted). PRIVATE to the member. NOT the company wiki (use " +
972
- "add_company_wiki_page), NOT a note on a contact (use save_note).",
973
- SAVE_PERSONAL_SCHEMA,
974
- runSavePersonalFile,
975
- );
976
- // DEPRECATED alias → save_personal_file.
977
- server.tool(
978
- "save_to_vault",
979
- "DEPRECATED — use `save_personal_file`. Saves a file into the member's own private knowledge.",
980
- SAVE_PERSONAL_SCHEMA,
981
- runSavePersonalFile,
982
- );
983
-
984
- // ===========================================================================
985
- // TOOL: update_vault_file — POST /v2/personal/files/update
986
- // Edit an existing personal file. Snapshots the old content to version history,
987
- // then replaces/appends + re-embeds. save_to_vault is for NEW files (→ Inbox);
988
- // this edits one that's already live.
989
- // ===========================================================================
990
- const UPDATE_PERSONAL_SCHEMA = {
991
- file_id: z.string().optional().describe("The file's id (from a search/save result). Provide this OR path."),
992
- path: z.string().optional().describe("The file's path, e.g. 'raw documents/Edda Architecture.md'. Provide this OR file_id."),
993
- content: z.string().describe("The new markdown content. Replaces the file's content unless append is true."),
994
- append: z.boolean().optional().describe("If true, append this to the end of the file instead of replacing it."),
995
- };
996
- const runUpdatePersonalFile = async ({ file_id, path, content, append }) => {
997
- try {
998
- const r = await post("/v2/personal/files/update", { file_id, path, content, append });
999
- return { content: [{ type: "text", text: r?.note || "Updated the file." }] };
1000
- } catch (e) {
1001
- const msg = String(e?.message || e);
1002
- if (msg.includes("not_found")) return { content: [{ type: "text", text: "Couldn't find that file — check the file_id or path (folder/name.md)." }] };
1003
- if (msg.includes("file_id_or_path_required")) return { content: [{ type: "text", text: "Tell me which file to edit — pass file_id or path." }] };
1004
- if (msg.includes("content_required")) return { content: [{ type: "text", text: "Give me the new content to write." }] };
1005
- throw e;
1006
- }
1007
- };
1008
- server.tool(
1009
- "update_personal_file",
1010
- "Edit an EXISTING file in the member's own personal knowledge — revise it or append to it. Identify the file " +
1011
- "by `file_id` (from a search or save result) or by `path` ('folder/[subfolder/]name.md'). The previous " +
1012
- "content is saved to version history FIRST (never lost), then your new content replaces it — or is added to " +
1013
- "the end with append:true — and the file is re-embedded; the member sees a fresh 'updated' time. Use this to " +
1014
- "keep a living document current: a rolling brief, a spec, a running log. For a NEW file use save_personal_file " +
1015
- "(which lands in the Inbox for approval); this edits one that already exists and is live.",
1016
- UPDATE_PERSONAL_SCHEMA,
1017
- runUpdatePersonalFile,
1018
- );
1019
- // DEPRECATED alias → update_personal_file.
1020
- server.tool(
1021
- "update_vault_file",
1022
- "DEPRECATED — use `update_personal_file`. Edits/append an existing file in the member's own knowledge.",
1023
- UPDATE_PERSONAL_SCHEMA,
1024
- runUpdatePersonalFile,
1025
- );
1026
-
1027
- server.tool(
1028
- "list_integrations",
1029
- "List the integrations connected to this workspace (Gmail, HubSpot, Apollo, Instantly, LinkedIn, …) " +
1030
- "— what's wired in and whether it's verified. Use before telling the user to connect something, or " +
1031
- "to answer \"what's connected here?\".",
1032
- {},
1033
- async () => {
1034
- const r = await get("/v2/workspace/integrations");
1035
- if (!r.integrations?.length) return { content: [{ type: "text", text: "No integrations connected yet." }] };
1036
- const lines = r.integrations.map(i => ` • ${i.display_name}${i.category ? ` (${i.category})` : ""}${i.verified ? "" : " — not verified"}`).join("\n");
1037
- return { content: [{ type: "text", text: `Connected integrations:\n${lines}` }] };
1038
- }
306
+ },
1039
307
  );
1040
308
 
1041
-
1042
-
1043
309
  return server;
1044
310
  }