@edda-business/mcp 0.63.0 → 0.64.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- 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
|
-
*
|
|
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
|
|
7
|
-
*
|
|
6
|
+
* - http.js (hosted) — a fresh server per request,
|
|
7
|
+
* key scoped via AsyncLocalStorage
|
|
8
8
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
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
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
*
|
|
18
|
-
*
|
|
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
|
-
*
|
|
22
|
-
*
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
22
|
+
* TOOLS:
|
|
23
|
+
* search_company RETRIEVE across BOTH natures (documents + distilled), kind filter
|
|
24
|
+
* read_company_page read one full wiki page (by path from a search result, or id)
|
|
25
|
+
* push_company_file PUSH one or many files up into the company folders (shared wiki)
|
|
26
|
+
* update_company_page edit an existing wiki page (replace or append)
|
|
27
|
+
* create_company_folder 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,
|
|
35
|
+
import { get, post, patch } from "./client.js";
|
|
31
36
|
|
|
32
|
-
export const SERVER_VERSION = "0.
|
|
37
|
+
export const SERVER_VERSION = "0.64.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)
|
|
47
|
+
if (d < 1) return "today";
|
|
40
48
|
if (d === 1) return "1d ago";
|
|
41
|
-
if (d < 30)
|
|
49
|
+
if (d < 30) return `${d}d ago`;
|
|
42
50
|
const m = Math.floor(d / 30);
|
|
43
|
-
if (m < 12)
|
|
51
|
+
if (m < 12) return `${m}mo ago`;
|
|
44
52
|
return `${Math.floor(m / 12)}y ago`;
|
|
45
53
|
}
|
|
46
54
|
|
|
47
|
-
//
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
//
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
//
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
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_company 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
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"
|
|
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_company 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:
|
|
92
|
-
// The
|
|
98
|
+
// TOOL: search_company — 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
|
-
"
|
|
96
|
-
"
|
|
97
|
-
"
|
|
98
|
-
"
|
|
99
|
-
"
|
|
100
|
-
"
|
|
101
|
-
"
|
|
102
|
-
"'
|
|
103
|
+
"search_company",
|
|
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_company_page for the full text. " +
|
|
110
|
+
"Narrow with `kind`: 'documents' (wiki only), 'distilled' (activity only), or 'all' (default).",
|
|
103
111
|
{
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
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 ({
|
|
111
|
-
const
|
|
112
|
-
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
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_company_page
|
|
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
|
-
|
|
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:
|
|
297
|
-
//
|
|
145
|
+
// TOOL: read_company_page — GET /v2/company/pages/:id
|
|
146
|
+
// Read one full wiki page. Take the `path:` from a search_company result, or an id.
|
|
298
147
|
// ===========================================================================
|
|
299
148
|
server.tool(
|
|
300
|
-
"
|
|
301
|
-
"
|
|
302
|
-
"
|
|
303
|
-
"
|
|
304
|
-
"
|
|
305
|
-
"
|
|
306
|
-
"
|
|
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_company_page",
|
|
150
|
+
"Read the FULL text of one company wiki page — search_company 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 GRAPH — outgoing [[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
|
-
|
|
372
|
-
|
|
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 ({
|
|
375
|
-
|
|
376
|
-
|
|
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_company result (its `path:` line), or an `id`.");
|
|
378
168
|
try {
|
|
379
|
-
const r = await
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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_company_page 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
|
-
|
|
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 =
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
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_company 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:
|
|
603
|
-
//
|
|
193
|
+
// TOOL: push_company_file — POST /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
|
-
"
|
|
607
|
-
"
|
|
608
|
-
"
|
|
609
|
-
"
|
|
199
|
+
"push_company_file",
|
|
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_company. 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
|
-
|
|
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 ({
|
|
219
|
+
async ({ folder, subfolder, folder_id, visibility, pages }) => {
|
|
614
220
|
try {
|
|
615
|
-
const
|
|
616
|
-
|
|
617
|
-
|
|
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
|
-
|
|
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:
|
|
634
|
-
//
|
|
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.
|
|
236
|
+
// TOOL: update_company_page — PATCH /v2/company/pages/:id
|
|
237
|
+
// Edit an existing wiki page: replace its content, or append. Re-embeds on the server.
|
|
659
238
|
// ===========================================================================
|
|
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
239
|
server.tool(
|
|
663
|
-
"
|
|
664
|
-
"
|
|
240
|
+
"update_company_page",
|
|
241
|
+
"Edit an EXISTING company wiki page — revise it or append to it. Identify the page by `path` " +
|
|
242
|
+
"(the `path:` from a search_company 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_company_file. Distilled items (from sources) are read-only and can't be edited here.",
|
|
665
246
|
{
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
content: z.string().describe("The
|
|
669
|
-
|
|
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."),
|
|
670
252
|
},
|
|
671
|
-
async ({
|
|
672
|
-
|
|
673
|
-
|
|
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.
|
|
744
|
-
// ===========================================================================
|
|
745
|
-
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.",
|
|
752
|
-
{
|
|
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)."),
|
|
755
|
-
},
|
|
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
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
if (
|
|
260
|
+
if (!pageId) return text("Tell me which page to edit — pass the `path` from a search_company 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_company 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:
|
|
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_company_folder — 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
|
-
"
|
|
909
|
-
"
|
|
910
|
-
"
|
|
911
|
-
"
|
|
912
|
-
"
|
|
913
|
-
"
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
},
|
|
919
|
-
async ({ provider, credentials, name }) => {
|
|
286
|
+
"create_company_folder",
|
|
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_company_file 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/
|
|
922
|
-
|
|
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
|
|
925
|
-
if (msg.includes("
|
|
926
|
-
|
|
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
|
}
|