@datasynx/agentic-crm 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +767 -0
- package/dist/agent-config-zPvcqu07.js +14 -0
- package/dist/agent-config-zPvcqu07.js.map +1 -0
- package/dist/approvals-DpjxGHFp.js +67 -0
- package/dist/approvals-DpjxGHFp.js.map +1 -0
- package/dist/ask-CID3jnuL.js +52 -0
- package/dist/ask-CID3jnuL.js.map +1 -0
- package/dist/audit-log-DNMY9mUZ.js +49 -0
- package/dist/audit-log-DNMY9mUZ.js.map +1 -0
- package/dist/auth-CyFuu9X_.js +2 -0
- package/dist/auth-DFWwWcYD.js +93 -0
- package/dist/auth-DFWwWcYD.js.map +1 -0
- package/dist/autofill-Di_-SP7t.js +51 -0
- package/dist/autofill-Di_-SP7t.js.map +1 -0
- package/dist/backup-CeMk9z86.js +417 -0
- package/dist/backup-CeMk9z86.js.map +1 -0
- package/dist/backup-f_hC7rBV.js +2 -0
- package/dist/calendly-Bft_wwji.js +52 -0
- package/dist/calendly-Bft_wwji.js.map +1 -0
- package/dist/calendly-D3coO92o.cjs +53 -0
- package/dist/calendly-D3coO92o.cjs.map +1 -0
- package/dist/chunk-DakpK96I.cjs +43 -0
- package/dist/churn-C28IgnAj.js +54 -0
- package/dist/churn-C28IgnAj.js.map +1 -0
- package/dist/cli.js +4396 -0
- package/dist/cli.js.map +1 -0
- package/dist/colors-BG07TZQz.js +11 -0
- package/dist/colors-BG07TZQz.js.map +1 -0
- package/dist/compliance-B1kk5-YS.js +115 -0
- package/dist/compliance-B1kk5-YS.js.map +1 -0
- package/dist/compliance-B91zNvCR.cjs +156 -0
- package/dist/compliance-B91zNvCR.cjs.map +1 -0
- package/dist/compliance-CKSBoQUe.js +118 -0
- package/dist/compliance-CKSBoQUe.js.map +1 -0
- package/dist/compliance-CujOqAKk.js +2 -0
- package/dist/context-builder-BzWAp3Zs.js +96 -0
- package/dist/context-builder-BzWAp3Zs.js.map +1 -0
- package/dist/context-builder-DlrRcqmJ.js +2 -0
- package/dist/conversation-intel-mm7Lhemh.js +72 -0
- package/dist/conversation-intel-mm7Lhemh.js.map +1 -0
- package/dist/custom-fields-CzNeD3_v.js +2 -0
- package/dist/custom-fields-Pl2t9xzp.js +73 -0
- package/dist/custom-fields-Pl2t9xzp.js.map +1 -0
- package/dist/custom-objects-BHgn1GEX.js +78 -0
- package/dist/custom-objects-BHgn1GEX.js.map +1 -0
- package/dist/custom-objects-CIFrmQ2V.js +2 -0
- package/dist/customer-dir-DIylZ8Q6.js +75 -0
- package/dist/customer-dir-DIylZ8Q6.js.map +1 -0
- package/dist/daemon/worker.js +207 -0
- package/dist/daemon/worker.js.map +1 -0
- package/dist/enrichment-3XvgGDfB.js +103 -0
- package/dist/enrichment-3XvgGDfB.js.map +1 -0
- package/dist/file-lock-B_zi7NQl.js +22 -0
- package/dist/file-lock-B_zi7NQl.js.map +1 -0
- package/dist/gmail-auth-BP6cJwfw.js +40 -0
- package/dist/gmail-auth-BP6cJwfw.js.map +1 -0
- package/dist/gmail-auth-DxakCtGm.cjs +44 -0
- package/dist/gmail-auth-DxakCtGm.cjs.map +1 -0
- package/dist/gmail-auth-OComS92L.js +40 -0
- package/dist/gmail-auth-OComS92L.js.map +1 -0
- package/dist/gmail-push-watch-DELQFMPk.js +20 -0
- package/dist/gmail-push-watch-DELQFMPk.js.map +1 -0
- package/dist/gmail-sender-StTpJ9Ub.js +32 -0
- package/dist/gmail-sender-StTpJ9Ub.js.map +1 -0
- package/dist/gmail-sync-DIaxInDT.js +204 -0
- package/dist/gmail-sync-DIaxInDT.js.map +1 -0
- package/dist/gmail-sync-hHm9gaWd.cjs +218 -0
- package/dist/gmail-sync-hHm9gaWd.cjs.map +1 -0
- package/dist/gmail-sync-rQaVqKWd.js +214 -0
- package/dist/gmail-sync-rQaVqKWd.js.map +1 -0
- package/dist/gmail-webhook-handler-DS7OlRPX.js +3 -0
- package/dist/gmail-webhook-handler-e5Od25FX.js +97 -0
- package/dist/gmail-webhook-handler-e5Od25FX.js.map +1 -0
- package/dist/goal-engine-CUZSpERI.js +2 -0
- package/dist/goal-engine-KpBftn4V.js +295 -0
- package/dist/goal-engine-KpBftn4V.js.map +1 -0
- package/dist/google-drive-sync-DEPcqFca.js +105 -0
- package/dist/google-drive-sync-DEPcqFca.js.map +1 -0
- package/dist/hybrid-search-BmHttLrR.js +40 -0
- package/dist/hybrid-search-BmHttLrR.js.map +1 -0
- package/dist/hygiene-DZqfYpFf.js +38 -0
- package/dist/hygiene-DZqfYpFf.js.map +1 -0
- package/dist/identity-CI6olMNm.js +41 -0
- package/dist/identity-CI6olMNm.js.map +1 -0
- package/dist/identity-gyfWdrcX.js +2 -0
- package/dist/import-hubspot-BaK71U_K.js +588 -0
- package/dist/import-hubspot-BaK71U_K.js.map +1 -0
- package/dist/index-V8BFaH-b.d.ts +539 -0
- package/dist/index-V8BFaH-b.d.ts.map +1 -0
- package/dist/index-YqwMd6aQ.d.cts +538 -0
- package/dist/index-YqwMd6aQ.d.cts.map +1 -0
- package/dist/index.cjs +185 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +538 -0
- package/dist/index.d.cts.map +1 -0
- package/dist/index.d.ts +539 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +165 -0
- package/dist/index.js.map +1 -0
- package/dist/interactions-writer-CrPStUll.cjs +77 -0
- package/dist/interactions-writer-CrPStUll.cjs.map +1 -0
- package/dist/interactions-writer-DO3KcSR3.js +52 -0
- package/dist/interactions-writer-DO3KcSR3.js.map +1 -0
- package/dist/interactions-writer-SLHnoEeE.js +46 -0
- package/dist/interactions-writer-SLHnoEeE.js.map +1 -0
- package/dist/interactions-writer-dSPy1XfO.js +2 -0
- package/dist/knowledge-base-D0Fh40kc.js +1013 -0
- package/dist/knowledge-base-D0Fh40kc.js.map +1 -0
- package/dist/lancedb-CCBbpulq.js +2 -0
- package/dist/lancedb-rlvWoPwl.js +98 -0
- package/dist/lancedb-rlvWoPwl.js.map +1 -0
- package/dist/lead-model-BCFzyktm.js +109 -0
- package/dist/lead-model-BCFzyktm.js.map +1 -0
- package/dist/llm-DEjWcqmW.js +2 -0
- package/dist/llm-DvzZqva0.js +372 -0
- package/dist/llm-DvzZqva0.js.map +1 -0
- package/dist/llm-Z8RIYkpF.js +174 -0
- package/dist/llm-Z8RIYkpF.js.map +1 -0
- package/dist/llm-iijeXmgq.cjs +198 -0
- package/dist/llm-iijeXmgq.cjs.map +1 -0
- package/dist/mcp-CdTJWTJf.d.cts +12 -0
- package/dist/mcp-CdTJWTJf.d.cts.map +1 -0
- package/dist/mcp-CdTJWTJf.d.ts +12 -0
- package/dist/mcp-CdTJWTJf.d.ts.map +1 -0
- package/dist/mcp.cjs +7464 -0
- package/dist/mcp.cjs.map +1 -0
- package/dist/mcp.d.cts +12 -0
- package/dist/mcp.d.cts.map +1 -0
- package/dist/mcp.d.ts +12 -0
- package/dist/mcp.d.ts.map +1 -0
- package/dist/mcp.js +7448 -0
- package/dist/mcp.js.map +1 -0
- package/dist/memory-Bb6ky3kb.js +58 -0
- package/dist/memory-Bb6ky3kb.js.map +1 -0
- package/dist/memory-Cy6-Tbyl.js +2 -0
- package/dist/metrics-DH8wHvya.js +26 -0
- package/dist/metrics-DH8wHvya.js.map +1 -0
- package/dist/microsoft-auth-B8_S45gh.js +17 -0
- package/dist/microsoft-auth-B8_S45gh.js.map +1 -0
- package/dist/microsoft-calendar-B6MMtUQK.js +67 -0
- package/dist/microsoft-calendar-B6MMtUQK.js.map +1 -0
- package/dist/microsoft-sync-CpZVoSuq.js +68 -0
- package/dist/microsoft-sync-CpZVoSuq.js.map +1 -0
- package/dist/nba-3wanmJ0U.js +48 -0
- package/dist/nba-3wanmJ0U.js.map +1 -0
- package/dist/notification-dispatcher-0vYNngWe.js +97 -0
- package/dist/notification-dispatcher-0vYNngWe.js.map +1 -0
- package/dist/opportunity-score-BTMOQSTV.js +47 -0
- package/dist/opportunity-score-BTMOQSTV.js.map +1 -0
- package/dist/pipedrive-client-CdGKpH9b.js +17 -0
- package/dist/pipedrive-client-CdGKpH9b.js.map +1 -0
- package/dist/pipeline-writer-BqBrYrQc.js +2 -0
- package/dist/pipeline-writer-BvVquKIe.js +96 -0
- package/dist/pipeline-writer-BvVquKIe.js.map +1 -0
- package/dist/pipeline-writer-N2omexxp.cjs +121 -0
- package/dist/pipeline-writer-N2omexxp.cjs.map +1 -0
- package/dist/pipeline-writer-eufx_0o1.js +102 -0
- package/dist/pipeline-writer-eufx_0o1.js.map +1 -0
- package/dist/proactive-agent-BgQXw3ac.js +96 -0
- package/dist/proactive-agent-BgQXw3ac.js.map +1 -0
- package/dist/proactive-worker-BrLHNhjH.js +229 -0
- package/dist/proactive-worker-BrLHNhjH.js.map +1 -0
- package/dist/push-manager-CdqIIkuh.js +108 -0
- package/dist/push-manager-CdqIIkuh.js.map +1 -0
- package/dist/push-manager-CowY-0IK.js +2 -0
- package/dist/quote-generator-BfwENXzg.js +133 -0
- package/dist/quote-generator-BfwENXzg.js.map +1 -0
- package/dist/quote-generator-OhSFsi3x.js +2 -0
- package/dist/rbac-C7c8tcES.js +2 -0
- package/dist/rbac-CTIktZaC.js +91 -0
- package/dist/rbac-CTIktZaC.js.map +1 -0
- package/dist/relationship-health-odxEoQdJ.js +454 -0
- package/dist/relationship-health-odxEoQdJ.js.map +1 -0
- package/dist/revenue-simulation-BJdRTEHc.js +2 -0
- package/dist/revenue-simulation-Bqf2DLVB.js +251 -0
- package/dist/revenue-simulation-Bqf2DLVB.js.map +1 -0
- package/dist/rolldown-runtime-D7D4PA-g.js +13 -0
- package/dist/salesforce-client-rhZFa_p5.js +51 -0
- package/dist/salesforce-client-rhZFa_p5.js.map +1 -0
- package/dist/segments-BqcD5HIl.js +61 -0
- package/dist/segments-BqcD5HIl.js.map +1 -0
- package/dist/sequence-engine-CCTHEBgi.js +2 -0
- package/dist/sequence-engine-J1lTW_in.js +91 -0
- package/dist/sequence-engine-J1lTW_in.js.map +1 -0
- package/dist/sequence-store-DaaWr0Os.js +221 -0
- package/dist/sequence-store-DaaWr0Os.js.map +1 -0
- package/dist/server-Dyva03K8.js +4287 -0
- package/dist/server-Dyva03K8.js.map +1 -0
- package/dist/session-B9AilxOE.js +81 -0
- package/dist/session-B9AilxOE.js.map +1 -0
- package/dist/session-D0qFkBla.cjs +82 -0
- package/dist/session-D0qFkBla.cjs.map +1 -0
- package/dist/session-D9ub6Wl1.js +79 -0
- package/dist/session-D9ub6Wl1.js.map +1 -0
- package/dist/session-mWHA71Lw.js +2 -0
- package/dist/session-store-B0QZE8Bx.cjs +697 -0
- package/dist/session-store-B0QZE8Bx.cjs.map +1 -0
- package/dist/session-store-C8tEvMPw.js +543 -0
- package/dist/session-store-C8tEvMPw.js.map +1 -0
- package/dist/session-store-CEa39Dxs.js +15 -0
- package/dist/session-store-CEa39Dxs.js.map +1 -0
- package/dist/sla-engine-5IhTsBUR.js +2 -0
- package/dist/sla-engine-BqX-7u-7.js +53 -0
- package/dist/sla-engine-BqX-7u-7.js.map +1 -0
- package/dist/sop-DkhVChGy.js +2 -0
- package/dist/sop-Vp0UPWFW.js +70 -0
- package/dist/sop-Vp0UPWFW.js.map +1 -0
- package/dist/survey-engine-C06hcQt3.js +2 -0
- package/dist/survey-engine-DBjCYqCv.js +147 -0
- package/dist/survey-engine-DBjCYqCv.js.map +1 -0
- package/dist/sync-state-ChaLbamC.js +33 -0
- package/dist/sync-state-ChaLbamC.js.map +1 -0
- package/dist/sync-state-CwLSt_1m.js +2 -0
- package/dist/ticket-writer-CjqKeIRD.js +2 -0
- package/dist/ticket-writer-j2oX_Wal.js +134 -0
- package/dist/ticket-writer-j2oX_Wal.js.map +1 -0
- package/dist/tone-Bdm5uaht.js +48 -0
- package/dist/tone-Bdm5uaht.js.map +1 -0
- package/dist/tone-DRKlZgPr.cjs +43 -0
- package/dist/tone-DRKlZgPr.cjs.map +1 -0
- package/dist/tone-vNb2DAAD.js +39 -0
- package/dist/tone-vNb2DAAD.js.map +1 -0
- package/dist/transcript-watcher-CL2QUygI.js +132 -0
- package/dist/transcript-watcher-CL2QUygI.js.map +1 -0
- package/dist/unmatched-transcripts-BsH5bhkU.js +26 -0
- package/dist/unmatched-transcripts-BsH5bhkU.js.map +1 -0
- package/dist/unmatched-transcripts-D0PrJ9iz.js +2 -0
- package/dist/update-deal-BNwPGaTV.js +2 -0
- package/dist/update-deal-DKC79skb.js +91 -0
- package/dist/update-deal-DKC79skb.js.map +1 -0
- package/dist/usage-CClTf5e6.cjs +57 -0
- package/dist/usage-CClTf5e6.cjs.map +1 -0
- package/dist/usage-D0-TYJkw.js +93 -0
- package/dist/usage-D0-TYJkw.js.map +1 -0
- package/dist/usage-D0u9a-lV.js +54 -0
- package/dist/usage-D0u9a-lV.js.map +1 -0
- package/dist/vault-C1D3zScD.js +2 -0
- package/dist/vault-DXCg29W-.js +86 -0
- package/dist/vault-DXCg29W-.js.map +1 -0
- package/dist/webhooks-7EpA05Qr.js +138 -0
- package/dist/webhooks-7EpA05Qr.js.map +1 -0
- package/dist/webhooks-BO2UAnmn.js +94 -0
- package/dist/webhooks-BO2UAnmn.js.map +1 -0
- package/dist/webhooks-Xn6zO6kd.cjs +97 -0
- package/dist/webhooks-Xn6zO6kd.cjs.map +1 -0
- package/dist/write-queue-BDolUxfs.cjs +26 -0
- package/dist/write-queue-BDolUxfs.cjs.map +1 -0
- package/dist/write-queue-IbsAjUnh.js +21 -0
- package/dist/write-queue-IbsAjUnh.js.map +1 -0
- package/package.json +142 -0
|
@@ -0,0 +1,4287 @@
|
|
|
1
|
+
import { a as writeMainFacts, i as readMainFacts, n as ensureCustomerDir, r as listCustomerSlugs, t as customerExists } from "./customer-dir-DIylZ8Q6.js";
|
|
2
|
+
import { n as getSession } from "./session-store-CEa39Dxs.js";
|
|
3
|
+
import { a as searchKbSimple, n as getKbArticle, o as writeKbArticle, r as getKbMetaForExport, s as CAPABILITIES_TEXT } from "./knowledge-base-D0Fh40kc.js";
|
|
4
|
+
import { i as readBackupLog, n as listBackupsInDir, o as runBackup } from "./backup-CeMk9z86.js";
|
|
5
|
+
import { r as updateSlugSyncState, t as getLastGmailSync } from "./sync-state-ChaLbamC.js";
|
|
6
|
+
import { t as withFileQueue } from "./write-queue-IbsAjUnh.js";
|
|
7
|
+
import { n as formatInteractionEntry, t as appendInteraction } from "./interactions-writer-SLHnoEeE.js";
|
|
8
|
+
import { i as writeAuditEntry, n as getActor, r as readAuditLog, t as filterAuditLog } from "./audit-log-DNMY9mUZ.js";
|
|
9
|
+
import { a as enforceRbac, n as canSeeCustomer } from "./rbac-CTIktZaC.js";
|
|
10
|
+
import { f as getPipelineStages, i as buildSimulationInput, l as runSimulation, n as buildConfidenceMessage } from "./revenue-simulation-Bqf2DLVB.js";
|
|
11
|
+
import { t as readPipeline } from "./pipeline-writer-BvVquKIe.js";
|
|
12
|
+
import { a as updateGraphFromInteraction, c as readGraph, i as writeHealth, n as readHealth, o as findPath, r as updateHealthFromInteraction, s as getStakeholders, t as computeCustomerHealth } from "./relationship-health-odxEoQdJ.js";
|
|
13
|
+
import { t as callLlm } from "./llm-DvzZqva0.js";
|
|
14
|
+
import { d as pursueGoal, i as getActiveGoals, p as readGoals } from "./goal-engine-KpBftn4V.js";
|
|
15
|
+
import { n as readSubscriptions, r as register, s as writeSubscriptions } from "./push-manager-CdqIIkuh.js";
|
|
16
|
+
import { a as writeEnrollment, c as extractVariables, d as getTemplate, f as listTemplates, i as updateEnrollment, l as interpolate, n as listSequences, r as readEnrollments, s as buildVariablesFromCustomer, t as getSequence } from "./sequence-store-DaaWr0Os.js";
|
|
17
|
+
import { n as listQuotes, r as readQuote, t as generateQuote } from "./quote-generator-BfwENXzg.js";
|
|
18
|
+
import { i as upsertTicket, n as nextTicketId, r as readTickets, t as listAllTickets } from "./ticket-writer-j2oX_Wal.js";
|
|
19
|
+
import { i as loadSlaRules, t as calcSlaDue } from "./sla-engine-BqX-7u-7.js";
|
|
20
|
+
import { i as getSurvey, l as savePendingSurvey, n as calcNpsScore, o as loadSurveyResponses, r as generateSurveyToken, t as buildSurveyEmail } from "./survey-engine-DBjCYqCv.js";
|
|
21
|
+
import { t as buildContext } from "./context-builder-BzWAp3Zs.js";
|
|
22
|
+
import { a as loadCustomObjects, i as listRecords, n as defineCustomObject, t as createRecord } from "./custom-objects-BHgn1GEX.js";
|
|
23
|
+
import { r as searchKnowledge } from "./lancedb-rlvWoPwl.js";
|
|
24
|
+
import { t as buildDailyBriefing } from "./proactive-agent-BgQXw3ac.js";
|
|
25
|
+
import { a as protectedResourceMetadata, o as verifyBearer, r as isAuthRequired, s as wwwAuthenticateHeader } from "./auth-DFWwWcYD.js";
|
|
26
|
+
import { i as verifyGmailPubSubSignature, n as decodeGmailPubSubPayload, r as handleGmailPushEvent } from "./gmail-webhook-handler-e5Od25FX.js";
|
|
27
|
+
import { n as registerUpdateDeal } from "./update-deal-DKC79skb.js";
|
|
28
|
+
import path from "path";
|
|
29
|
+
import fs from "fs";
|
|
30
|
+
import matter from "gray-matter";
|
|
31
|
+
import { z } from "zod";
|
|
32
|
+
import crypto from "crypto";
|
|
33
|
+
import yaml from "js-yaml";
|
|
34
|
+
import { McpServer, ResourceTemplate } from "@modelcontextprotocol/sdk/server/mcp.js";
|
|
35
|
+
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
|
36
|
+
import { StreamableHTTPServerTransport } from "@modelcontextprotocol/sdk/server/streamableHttp.js";
|
|
37
|
+
//#region src/core/oauth-store.ts
|
|
38
|
+
let _auth = null;
|
|
39
|
+
async function initOAuthFromDisk(dataDir) {
|
|
40
|
+
const credPath = path.join(dataDir, ".agentic", "gmail-credentials.json");
|
|
41
|
+
const tokenPath = path.join(dataDir, ".agentic", "gmail-token.json");
|
|
42
|
+
if (!fs.existsSync(credPath) || !fs.existsSync(tokenPath)) return false;
|
|
43
|
+
try {
|
|
44
|
+
const { getGmailAuth: loadAuth } = await import("./gmail-auth-OComS92L.js");
|
|
45
|
+
_auth = await loadAuth(credPath, tokenPath);
|
|
46
|
+
return true;
|
|
47
|
+
} catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
function getGmailAuth() {
|
|
52
|
+
return _auth;
|
|
53
|
+
}
|
|
54
|
+
//#endregion
|
|
55
|
+
//#region src/sync/microsoft-webhook-handler.ts
|
|
56
|
+
function verifyMicrosoftGraphSignature(body, expectedClientState) {
|
|
57
|
+
const notifications = body.value ?? [];
|
|
58
|
+
if (notifications.length === 0) return expectedClientState === "";
|
|
59
|
+
return notifications.every((n) => n.clientState === expectedClientState);
|
|
60
|
+
}
|
|
61
|
+
function handleMicrosoftValidationRequest(queryParams) {
|
|
62
|
+
const token = queryParams["validationToken"];
|
|
63
|
+
if (token) return {
|
|
64
|
+
isValidation: true,
|
|
65
|
+
token
|
|
66
|
+
};
|
|
67
|
+
return { isValidation: false };
|
|
68
|
+
}
|
|
69
|
+
function findSubscriptionByMsId(subs, subscriptionId) {
|
|
70
|
+
return subs.find((s) => s.provider === "microsoft-graph" && s.status === "active" && s.providerData.microsoftSubscriptionId === subscriptionId) ?? null;
|
|
71
|
+
}
|
|
72
|
+
async function handleMicrosoftPushEvent(dataDir, notifications, accessToken, options = {}) {
|
|
73
|
+
const subs = await readSubscriptions(dataDir);
|
|
74
|
+
const { fetchMessageFn, appendInteractionFn = appendInteraction } = options;
|
|
75
|
+
let processed = 0;
|
|
76
|
+
let skipped = 0;
|
|
77
|
+
let anyProcessed = false;
|
|
78
|
+
for (const notification of notifications) {
|
|
79
|
+
const sub = findSubscriptionByMsId(subs, notification.subscriptionId);
|
|
80
|
+
if (!sub) {
|
|
81
|
+
skipped++;
|
|
82
|
+
continue;
|
|
83
|
+
}
|
|
84
|
+
const messageId = notification.resourceData?.id;
|
|
85
|
+
if (!messageId || !fetchMessageFn) {
|
|
86
|
+
skipped++;
|
|
87
|
+
continue;
|
|
88
|
+
}
|
|
89
|
+
try {
|
|
90
|
+
const message = await fetchMessageFn(accessToken, messageId);
|
|
91
|
+
if (!message) {
|
|
92
|
+
skipped++;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
const from = message.from?.emailAddress?.address ?? "unknown";
|
|
96
|
+
const sourceRef = `msgraph://message/${message.id}`;
|
|
97
|
+
await appendInteractionFn(dataDir, sub.slug, {
|
|
98
|
+
date: message.receivedDateTime ? new Date(message.receivedDateTime).toISOString().slice(0, 10) : (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
99
|
+
type: "Email",
|
|
100
|
+
direction: "inbound",
|
|
101
|
+
with: from,
|
|
102
|
+
subject: message.subject ?? "(no subject)",
|
|
103
|
+
summary: message.bodyPreview ?? "(no preview)",
|
|
104
|
+
nextSteps: [],
|
|
105
|
+
sourceRef,
|
|
106
|
+
synced: (/* @__PURE__ */ new Date()).toISOString()
|
|
107
|
+
});
|
|
108
|
+
processed++;
|
|
109
|
+
anyProcessed = true;
|
|
110
|
+
const idx = subs.findIndex((s) => s.id === sub.id);
|
|
111
|
+
if (idx !== -1) subs[idx] = {
|
|
112
|
+
...subs[idx],
|
|
113
|
+
eventsProcessed: subs[idx].eventsProcessed + 1,
|
|
114
|
+
lastEventAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
115
|
+
};
|
|
116
|
+
} catch {
|
|
117
|
+
skipped++;
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
if (anyProcessed) await writeSubscriptions(dataDir, subs);
|
|
121
|
+
return {
|
|
122
|
+
processed,
|
|
123
|
+
skipped
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
//#endregion
|
|
127
|
+
//#region src/sync/slack-webhook-handler.ts
|
|
128
|
+
function verifySlackSignature(body, headers, signingSecret) {
|
|
129
|
+
const sig = headers["x-slack-signature"];
|
|
130
|
+
const ts = headers["x-slack-request-timestamp"];
|
|
131
|
+
if (!sig || !ts) return false;
|
|
132
|
+
const tsNum = Number(ts);
|
|
133
|
+
if (Math.abs(Date.now() / 1e3 - tsNum) > 300) return false;
|
|
134
|
+
const sigBase = `v0:${ts}:${body}`;
|
|
135
|
+
const expected = "v0=" + crypto.createHmac("sha256", signingSecret).update(sigBase).digest("hex");
|
|
136
|
+
try {
|
|
137
|
+
const sigBuf = Buffer.from(sig);
|
|
138
|
+
const expBuf = Buffer.from(expected);
|
|
139
|
+
if (sigBuf.length !== expBuf.length) return false;
|
|
140
|
+
return crypto.timingSafeEqual(sigBuf, expBuf);
|
|
141
|
+
} catch {
|
|
142
|
+
return false;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
function handleSlackUrlVerification(body) {
|
|
146
|
+
if (body.type === "url_verification") return {
|
|
147
|
+
isVerification: true,
|
|
148
|
+
challenge: body.challenge ?? ""
|
|
149
|
+
};
|
|
150
|
+
return { isVerification: false };
|
|
151
|
+
}
|
|
152
|
+
function findSubscriptionByTeam(subs, teamId) {
|
|
153
|
+
return subs.find((s) => s.provider === "slack" && s.status === "active" && (!teamId || s.providerData.slackTeamId === teamId)) ?? null;
|
|
154
|
+
}
|
|
155
|
+
async function handleSlackPushEvent(dataDir, event, botToken, options = {}) {
|
|
156
|
+
if (event.type !== "message") return {
|
|
157
|
+
processed: 0,
|
|
158
|
+
skipped: 1
|
|
159
|
+
};
|
|
160
|
+
if (event.bot_id) return {
|
|
161
|
+
processed: 0,
|
|
162
|
+
skipped: 1
|
|
163
|
+
};
|
|
164
|
+
if (!event.text?.trim()) return {
|
|
165
|
+
processed: 0,
|
|
166
|
+
skipped: 1
|
|
167
|
+
};
|
|
168
|
+
const subs = await readSubscriptions(dataDir);
|
|
169
|
+
const sub = findSubscriptionByTeam(subs, options.teamId);
|
|
170
|
+
if (!sub) return {
|
|
171
|
+
processed: 0,
|
|
172
|
+
skipped: 1
|
|
173
|
+
};
|
|
174
|
+
const { appendInteractionFn = appendInteraction, fetchUserInfoFn } = options;
|
|
175
|
+
let senderName = event.user ?? "unknown";
|
|
176
|
+
if (fetchUserInfoFn && event.user) try {
|
|
177
|
+
const info = await fetchUserInfoFn(botToken, event.user);
|
|
178
|
+
senderName = info.name ?? info.email ?? event.user;
|
|
179
|
+
} catch {}
|
|
180
|
+
const ts = event.ts ? (/* @__PURE__ */ new Date(Number(event.ts) * 1e3)).toISOString().slice(0, 10) : (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
181
|
+
const sourceRef = `slack://channel/${event.channel ?? "dm"}/ts/${event.ts ?? Date.now()}`;
|
|
182
|
+
try {
|
|
183
|
+
await appendInteractionFn(dataDir, sub.slug, {
|
|
184
|
+
date: ts,
|
|
185
|
+
type: "Meeting",
|
|
186
|
+
direction: "inbound",
|
|
187
|
+
with: senderName,
|
|
188
|
+
subject: `Slack message in ${event.channel ?? "DM"}`,
|
|
189
|
+
summary: event.text.slice(0, 300),
|
|
190
|
+
nextSteps: [],
|
|
191
|
+
sourceRef,
|
|
192
|
+
synced: (/* @__PURE__ */ new Date()).toISOString()
|
|
193
|
+
});
|
|
194
|
+
const idx = subs.findIndex((s) => s.id === sub.id);
|
|
195
|
+
if (idx !== -1) {
|
|
196
|
+
subs[idx] = {
|
|
197
|
+
...subs[idx],
|
|
198
|
+
eventsProcessed: subs[idx].eventsProcessed + 1,
|
|
199
|
+
lastEventAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
200
|
+
};
|
|
201
|
+
await writeSubscriptions(dataDir, subs);
|
|
202
|
+
}
|
|
203
|
+
return {
|
|
204
|
+
processed: 1,
|
|
205
|
+
skipped: 0
|
|
206
|
+
};
|
|
207
|
+
} catch {
|
|
208
|
+
return {
|
|
209
|
+
processed: 0,
|
|
210
|
+
skipped: 1
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
//#endregion
|
|
215
|
+
//#region src/mcp/tools/get-capabilities.ts
|
|
216
|
+
async function handleGetCapabilities() {
|
|
217
|
+
return { content: [{
|
|
218
|
+
type: "text",
|
|
219
|
+
text: CAPABILITIES_TEXT
|
|
220
|
+
}] };
|
|
221
|
+
}
|
|
222
|
+
function registerGetCapabilities(server) {
|
|
223
|
+
server.registerTool("get_capabilities", {
|
|
224
|
+
title: "Get Capabilities",
|
|
225
|
+
description: "Returns all available MCP tools, their inputs, and the CRM workflow guide. Call this first to understand what DatasynxOpenCRM can do.",
|
|
226
|
+
inputSchema: z.object({})
|
|
227
|
+
}, async () => handleGetCapabilities());
|
|
228
|
+
}
|
|
229
|
+
//#endregion
|
|
230
|
+
//#region src/mcp/tools/get-active-session.ts
|
|
231
|
+
async function handleGetActiveSession() {
|
|
232
|
+
const session = getSession();
|
|
233
|
+
const result = session ? {
|
|
234
|
+
hasSession: true,
|
|
235
|
+
customerSlug: session.customerSlug,
|
|
236
|
+
customerName: session.customerName,
|
|
237
|
+
startedAt: session.startedAt,
|
|
238
|
+
...session.owner !== void 0 ? { owner: session.owner } : {}
|
|
239
|
+
} : { hasSession: false };
|
|
240
|
+
return { content: [{
|
|
241
|
+
type: "text",
|
|
242
|
+
text: JSON.stringify(result, null, 2)
|
|
243
|
+
}] };
|
|
244
|
+
}
|
|
245
|
+
function registerGetActiveSession(server) {
|
|
246
|
+
server.registerTool("get_active_session", {
|
|
247
|
+
title: "Get Active Session",
|
|
248
|
+
description: "Check which customer is currently active in the session store. Returns session info if a customer session is open, otherwise returns hasSession: false.",
|
|
249
|
+
inputSchema: z.object({})
|
|
250
|
+
}, async () => handleGetActiveSession());
|
|
251
|
+
}
|
|
252
|
+
//#endregion
|
|
253
|
+
//#region src/mcp/tools/get-customer-context.ts
|
|
254
|
+
const DATA_DIR$50 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
255
|
+
function triggerOnQuerySync(dataDir, slug) {
|
|
256
|
+
const auth = getGmailAuth();
|
|
257
|
+
if (!auth) return;
|
|
258
|
+
const lastSync = getLastGmailSync(dataDir, slug);
|
|
259
|
+
const thirtyMinAgo = /* @__PURE__ */ new Date(Date.now() - 1800 * 1e3);
|
|
260
|
+
if (lastSync && lastSync >= thirtyMinAgo) return;
|
|
261
|
+
const sourcesPath = path.join(dataDir, "customers", slug, "sources.json");
|
|
262
|
+
if (!fs.existsSync(sourcesPath)) return;
|
|
263
|
+
try {
|
|
264
|
+
const sources = JSON.parse(fs.readFileSync(sourcesPath, "utf-8"));
|
|
265
|
+
if (!sources.gmail?.enabled || !sources.gmail.query) return;
|
|
266
|
+
const query = sources.gmail.query;
|
|
267
|
+
import("./gmail-sync-DIaxInDT.js").then(({ syncGmail }) => syncGmail({
|
|
268
|
+
slug,
|
|
269
|
+
dataDir,
|
|
270
|
+
auth,
|
|
271
|
+
query
|
|
272
|
+
}).then(() => updateSlugSyncState(dataDir, slug, { lastGmailSync: (/* @__PURE__ */ new Date()).toISOString() })).catch(() => {})).catch(() => {});
|
|
273
|
+
} catch {}
|
|
274
|
+
}
|
|
275
|
+
async function handleGetCustomerContext(input, dataDir = DATA_DIR$50) {
|
|
276
|
+
const targetSlug = input.slug ?? getSession()?.customerSlug;
|
|
277
|
+
if (!targetSlug) return {
|
|
278
|
+
content: [{
|
|
279
|
+
type: "text",
|
|
280
|
+
text: "No customer specified and no active session. Use: get_customer_context({ slug: 'acme-corp' })"
|
|
281
|
+
}],
|
|
282
|
+
isError: true
|
|
283
|
+
};
|
|
284
|
+
const actor = process.env["DXCRM_ACTOR"] ?? "system";
|
|
285
|
+
if (!canSeeCustomer(dataDir, actor, targetSlug)) return {
|
|
286
|
+
content: [{
|
|
287
|
+
type: "text",
|
|
288
|
+
text: `Access denied: '${actor}' cannot view customer '${targetSlug}'.`
|
|
289
|
+
}],
|
|
290
|
+
isError: true
|
|
291
|
+
};
|
|
292
|
+
triggerOnQuerySync(dataDir, targetSlug);
|
|
293
|
+
try {
|
|
294
|
+
return { content: [{
|
|
295
|
+
type: "text",
|
|
296
|
+
text: await buildContext(dataDir, targetSlug)
|
|
297
|
+
}] };
|
|
298
|
+
} catch (err) {
|
|
299
|
+
return {
|
|
300
|
+
content: [{
|
|
301
|
+
type: "text",
|
|
302
|
+
text: `Error: ${err.message}`
|
|
303
|
+
}],
|
|
304
|
+
isError: true
|
|
305
|
+
};
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
function registerGetCustomerContext(server) {
|
|
309
|
+
server.registerTool("get_customer_context", {
|
|
310
|
+
title: "Get Customer Context",
|
|
311
|
+
description: `Returns a complete, LLM-ready context block for a customer.
|
|
312
|
+
Use this before any customer-related conversation or action.
|
|
313
|
+
Automatically triggers a background Gmail sync if last sync was >30 minutes ago.
|
|
314
|
+
|
|
315
|
+
Args:
|
|
316
|
+
slug: Customer ID (e.g. "acme-corp"). Leave empty to use active session customer.
|
|
317
|
+
|
|
318
|
+
Returns: Structured markdown with Quick Reference, Contacts, Critical Context,
|
|
319
|
+
Recent Activity (last 10 interactions), Pipeline, and Open Questions.
|
|
320
|
+
|
|
321
|
+
Performance: <3 seconds. Token budget: <3000.`,
|
|
322
|
+
inputSchema: z.object({ slug: z.string().optional().describe("Customer slug (e.g. 'acme-corp'). Leave empty for active session customer.") })
|
|
323
|
+
}, async ({ slug }) => handleGetCustomerContext({ ...slug !== void 0 ? { slug } : {} }));
|
|
324
|
+
}
|
|
325
|
+
//#endregion
|
|
326
|
+
//#region src/mcp/tools/search-customer-knowledge.ts
|
|
327
|
+
const DATA_DIR$49 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
328
|
+
async function handleSearchCustomerKnowledge(input, dataDir = DATA_DIR$49) {
|
|
329
|
+
const limit = input.limit ?? 5;
|
|
330
|
+
try {
|
|
331
|
+
const results = await searchKnowledge(dataDir, input.slug, input.query, limit);
|
|
332
|
+
const response = results.length === 0 ? {
|
|
333
|
+
results: [],
|
|
334
|
+
message: `No results found for "${input.query}" in customer "${input.slug}". The customer may not have been synced yet. Run dxcrm sync to index emails and transcripts.`
|
|
335
|
+
} : { results };
|
|
336
|
+
return { content: [{
|
|
337
|
+
type: "text",
|
|
338
|
+
text: JSON.stringify(response, null, 2)
|
|
339
|
+
}] };
|
|
340
|
+
} catch {
|
|
341
|
+
return { content: [{
|
|
342
|
+
type: "text",
|
|
343
|
+
text: JSON.stringify({
|
|
344
|
+
results: [],
|
|
345
|
+
message: `Search unavailable for customer "${input.slug}". LanceDB may not be initialized.`
|
|
346
|
+
})
|
|
347
|
+
}] };
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
function registerSearchCustomerKnowledge(server) {
|
|
351
|
+
server.registerTool("search_customer_knowledge", {
|
|
352
|
+
title: "Search Customer Knowledge",
|
|
353
|
+
description: `Hybrid vector + full-text search across all emails and transcripts for a customer.
|
|
354
|
+
Use when you need to find specific information from past communications.
|
|
355
|
+
|
|
356
|
+
Args:
|
|
357
|
+
slug: Customer ID (e.g. "acme-corp")
|
|
358
|
+
query: Natural language search query (e.g. "pricing discussion", "GDPR concerns")
|
|
359
|
+
limit: Max results to return (default 5)
|
|
360
|
+
|
|
361
|
+
Returns: { results: Array<{ content, score, source }> }
|
|
362
|
+
If no results: returns empty array with a helpful sync suggestion.`,
|
|
363
|
+
inputSchema: z.object({
|
|
364
|
+
slug: z.string().describe("Customer slug (e.g. 'acme-corp')"),
|
|
365
|
+
query: z.string().describe("Search query (natural language or keywords)"),
|
|
366
|
+
limit: z.number().int().min(1).max(50).optional().describe("Max results to return (default 5)")
|
|
367
|
+
})
|
|
368
|
+
}, async ({ slug, query, limit }) => handleSearchCustomerKnowledge({
|
|
369
|
+
slug,
|
|
370
|
+
query,
|
|
371
|
+
...limit !== void 0 ? { limit } : {}
|
|
372
|
+
}));
|
|
373
|
+
}
|
|
374
|
+
//#endregion
|
|
375
|
+
//#region src/mcp/tools/list-customers.ts
|
|
376
|
+
const DATA_DIR$48 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
377
|
+
function extractLastInteractionDate(interactionsPath) {
|
|
378
|
+
if (!fs.existsSync(interactionsPath)) return void 0;
|
|
379
|
+
const content = fs.readFileSync(interactionsPath, "utf-8");
|
|
380
|
+
const match = /^## (\d{4}-\d{2}-\d{2})/m.exec(content);
|
|
381
|
+
return match ? match[1] : void 0;
|
|
382
|
+
}
|
|
383
|
+
async function handleListCustomers(input, dataDir = DATA_DIR$48) {
|
|
384
|
+
const customersDir = path.join(dataDir, "customers");
|
|
385
|
+
const customers = [];
|
|
386
|
+
if (!fs.existsSync(customersDir)) return { content: [{
|
|
387
|
+
type: "text",
|
|
388
|
+
text: JSON.stringify([], null, 2)
|
|
389
|
+
}] };
|
|
390
|
+
const entries = fs.readdirSync(customersDir);
|
|
391
|
+
for (const entry of entries) {
|
|
392
|
+
const customerDir = path.join(customersDir, entry);
|
|
393
|
+
try {
|
|
394
|
+
if (!fs.statSync(customerDir).isDirectory()) continue;
|
|
395
|
+
} catch {
|
|
396
|
+
continue;
|
|
397
|
+
}
|
|
398
|
+
const mainFactsPath = path.join(customerDir, "main_facts.md");
|
|
399
|
+
if (!fs.existsSync(mainFactsPath)) continue;
|
|
400
|
+
try {
|
|
401
|
+
const data = matter(fs.readFileSync(mainFactsPath, "utf-8")).data;
|
|
402
|
+
const name = typeof data["name"] === "string" ? data["name"] : entry;
|
|
403
|
+
const stage = typeof data["relationship_stage"] === "string" ? data["relationship_stage"] : "unknown";
|
|
404
|
+
const dealValue = typeof data["deal_value"] === "number" ? data["deal_value"] : void 0;
|
|
405
|
+
const lastInteraction = extractLastInteractionDate(path.join(customerDir, "interactions.md"));
|
|
406
|
+
const summary = {
|
|
407
|
+
slug: entry,
|
|
408
|
+
name,
|
|
409
|
+
stage,
|
|
410
|
+
...lastInteraction !== void 0 ? { lastInteraction } : {},
|
|
411
|
+
...dealValue !== void 0 ? { dealValue } : {}
|
|
412
|
+
};
|
|
413
|
+
if (input.filter) {
|
|
414
|
+
const filterLower = input.filter.toLowerCase();
|
|
415
|
+
if (!(name.toLowerCase().includes(filterLower) || entry.toLowerCase().includes(filterLower) || stage.toLowerCase().includes(filterLower))) continue;
|
|
416
|
+
}
|
|
417
|
+
if (!canSeeCustomer(dataDir, process.env["DXCRM_ACTOR"] ?? "system", entry)) continue;
|
|
418
|
+
customers.push(summary);
|
|
419
|
+
} catch {
|
|
420
|
+
continue;
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
return { content: [{
|
|
424
|
+
type: "text",
|
|
425
|
+
text: JSON.stringify(customers, null, 2)
|
|
426
|
+
}] };
|
|
427
|
+
}
|
|
428
|
+
function registerListCustomers(server) {
|
|
429
|
+
server.registerTool("list_customers", {
|
|
430
|
+
title: "List Customers",
|
|
431
|
+
description: `List all customers with their pipeline stage, last interaction date, and deal value.
|
|
432
|
+
Useful for morning briefings and pipeline overviews.
|
|
433
|
+
|
|
434
|
+
Args:
|
|
435
|
+
filter: Optional substring to filter by name or slug (case-insensitive)
|
|
436
|
+
|
|
437
|
+
Returns: Array of { slug, name, stage, lastInteraction?, dealValue? }`,
|
|
438
|
+
inputSchema: z.object({ filter: z.string().optional().describe("Substring filter on customer name or slug (case-insensitive)") })
|
|
439
|
+
}, async ({ filter }) => handleListCustomers({ ...filter !== void 0 ? { filter } : {} }));
|
|
440
|
+
}
|
|
441
|
+
//#endregion
|
|
442
|
+
//#region src/mcp/tools/log-interaction.ts
|
|
443
|
+
const DATA_DIR$47 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
444
|
+
async function handleLogInteraction(input, dataDir = DATA_DIR$47) {
|
|
445
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().split("T")[0];
|
|
446
|
+
const interactionDate = input.date ?? today;
|
|
447
|
+
const sourceRef = input.source ?? `agent://log/${Date.now()}`;
|
|
448
|
+
const entry = {
|
|
449
|
+
date: interactionDate,
|
|
450
|
+
type: input.type,
|
|
451
|
+
with: input.with,
|
|
452
|
+
summary: input.summary,
|
|
453
|
+
nextSteps: input.nextSteps ?? [],
|
|
454
|
+
sourceRef,
|
|
455
|
+
synced: (/* @__PURE__ */ new Date()).toISOString(),
|
|
456
|
+
...input.direction !== void 0 ? { direction: input.direction } : {}
|
|
457
|
+
};
|
|
458
|
+
const interactionsPath = path.join(dataDir, "customers", input.slug, "interactions.md");
|
|
459
|
+
const entryText = formatInteractionEntry(entry);
|
|
460
|
+
try {
|
|
461
|
+
enforceRbac(dataDir, "log_interaction");
|
|
462
|
+
await appendInteraction(dataDir, input.slug, entry);
|
|
463
|
+
updateGraphFromInteraction(dataDir, input.slug, {
|
|
464
|
+
withStr: input.with,
|
|
465
|
+
interactionDate
|
|
466
|
+
}).catch(() => {});
|
|
467
|
+
updateHealthFromInteraction(dataDir, input.slug).catch(() => {});
|
|
468
|
+
const mainFactsPath = path.join(dataDir, "customers", input.slug, "main_facts.md");
|
|
469
|
+
if (fs.existsSync(mainFactsPath)) try {
|
|
470
|
+
const raw = matter(fs.readFileSync(mainFactsPath, "utf-8"));
|
|
471
|
+
raw.data.last_touchpoint = interactionDate;
|
|
472
|
+
let serialized = matter.stringify(raw.content, raw.data);
|
|
473
|
+
serialized = serialized.replace(/^(last_touchpoint:\s*)['"](\d{4}-\d{2}-\d{2})['"]/m, "$1$2");
|
|
474
|
+
fs.writeFileSync(mainFactsPath, serialized, "utf-8");
|
|
475
|
+
} catch {}
|
|
476
|
+
writeAuditEntry(dataDir, {
|
|
477
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
478
|
+
actor: getActor(),
|
|
479
|
+
tool: "log_interaction",
|
|
480
|
+
slug: input.slug,
|
|
481
|
+
summary: input.summary
|
|
482
|
+
});
|
|
483
|
+
return { content: [{
|
|
484
|
+
type: "text",
|
|
485
|
+
text: JSON.stringify({
|
|
486
|
+
success: true,
|
|
487
|
+
path: interactionsPath,
|
|
488
|
+
entry: entryText
|
|
489
|
+
}, null, 2)
|
|
490
|
+
}] };
|
|
491
|
+
} catch (err) {
|
|
492
|
+
return { content: [{
|
|
493
|
+
type: "text",
|
|
494
|
+
text: JSON.stringify({
|
|
495
|
+
success: false,
|
|
496
|
+
error: err.message
|
|
497
|
+
}, null, 2)
|
|
498
|
+
}] };
|
|
499
|
+
}
|
|
500
|
+
}
|
|
501
|
+
function registerLogInteraction(server) {
|
|
502
|
+
server.registerTool("log_interaction", {
|
|
503
|
+
title: "Log Interaction",
|
|
504
|
+
description: `Write a new interaction entry to the CRM. Use after every call, meeting, or email.
|
|
505
|
+
Format matches auto-synced entries exactly — immediately searchable.
|
|
506
|
+
|
|
507
|
+
Args:
|
|
508
|
+
slug: Customer ID
|
|
509
|
+
type: Interaction type ("Email" | "Call" | "Meeting" | "Note" | "Demo" | "Proposal" | "Contract" | "Other")
|
|
510
|
+
summary: 2-5 sentences describing what happened (min 1 char)
|
|
511
|
+
with: Who was involved (name or email address)
|
|
512
|
+
nextSteps: Array of action items (optional)
|
|
513
|
+
direction: "inbound" or "outbound" (optional)
|
|
514
|
+
source: Source reference string (optional, auto-generated if omitted)
|
|
515
|
+
|
|
516
|
+
Returns: { success: boolean, path: string, entry: string }`,
|
|
517
|
+
inputSchema: z.object({
|
|
518
|
+
slug: z.string().describe("Customer slug (e.g. 'acme-corp')"),
|
|
519
|
+
type: z.enum([
|
|
520
|
+
"Email",
|
|
521
|
+
"Call",
|
|
522
|
+
"Meeting",
|
|
523
|
+
"Note",
|
|
524
|
+
"Demo",
|
|
525
|
+
"Proposal",
|
|
526
|
+
"Contract",
|
|
527
|
+
"Other"
|
|
528
|
+
]).describe("Type of interaction"),
|
|
529
|
+
summary: z.string().min(1).describe("2-5 sentence summary of what happened"),
|
|
530
|
+
with: z.string().describe("Who was involved (name or email)"),
|
|
531
|
+
nextSteps: z.array(z.string()).optional().describe("Action items for follow-up"),
|
|
532
|
+
direction: z.enum(["inbound", "outbound"]).optional().describe("Direction of communication"),
|
|
533
|
+
source: z.string().optional().describe("Source reference (e.g. gmail://thread/123). Auto-generated if omitted."),
|
|
534
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Date of interaction (YYYY-MM-DD). Defaults to today.")
|
|
535
|
+
})
|
|
536
|
+
}, async ({ slug, type, summary, with: withStr, nextSteps, direction, source, date }) => handleLogInteraction({
|
|
537
|
+
slug,
|
|
538
|
+
type,
|
|
539
|
+
summary,
|
|
540
|
+
with: withStr,
|
|
541
|
+
...nextSteps !== void 0 ? { nextSteps } : {},
|
|
542
|
+
...direction !== void 0 ? { direction } : {},
|
|
543
|
+
...source !== void 0 ? { source } : {},
|
|
544
|
+
...date !== void 0 ? { date } : {}
|
|
545
|
+
}));
|
|
546
|
+
}
|
|
547
|
+
//#endregion
|
|
548
|
+
//#region src/mcp/tools/export-customer.ts
|
|
549
|
+
const DATA_DIR$46 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
550
|
+
function countInteractions(content) {
|
|
551
|
+
const matches = content.match(/^## \d{4}-\d{2}-\d{2}/gm);
|
|
552
|
+
return matches ? matches.length : 0;
|
|
553
|
+
}
|
|
554
|
+
async function handleExportCustomer(input, dataDir = DATA_DIR$46) {
|
|
555
|
+
enforceRbac(dataDir, "export_customer");
|
|
556
|
+
const customerDir = path.join(dataDir, "customers", input.slug);
|
|
557
|
+
if (!fs.existsSync(customerDir)) return {
|
|
558
|
+
content: [{
|
|
559
|
+
type: "text",
|
|
560
|
+
text: `Error: Customer '${input.slug}' not found. Check 'list_customers()' for available customers.`
|
|
561
|
+
}],
|
|
562
|
+
isError: true
|
|
563
|
+
};
|
|
564
|
+
const format = input.format ?? "json";
|
|
565
|
+
const mainFactsPath = path.join(customerDir, "main_facts.md");
|
|
566
|
+
let mainFacts = {};
|
|
567
|
+
let mainFactsContent = "";
|
|
568
|
+
if (fs.existsSync(mainFactsPath)) {
|
|
569
|
+
const raw = matter(fs.readFileSync(mainFactsPath, "utf-8"));
|
|
570
|
+
mainFacts = raw.data;
|
|
571
|
+
mainFactsContent = raw.content ?? "";
|
|
572
|
+
}
|
|
573
|
+
const interactionsPath = path.join(customerDir, "interactions.md");
|
|
574
|
+
let interactionsContent = "";
|
|
575
|
+
let interactionsCount = 0;
|
|
576
|
+
if (fs.existsSync(interactionsPath)) {
|
|
577
|
+
interactionsContent = fs.readFileSync(interactionsPath, "utf-8");
|
|
578
|
+
interactionsCount = countInteractions(interactionsContent);
|
|
579
|
+
}
|
|
580
|
+
const pipeline = await readPipeline(dataDir, input.slug);
|
|
581
|
+
const attachmentsDir = path.join(customerDir, "attachments");
|
|
582
|
+
const attachments = [];
|
|
583
|
+
if (fs.existsSync(attachmentsDir)) try {
|
|
584
|
+
const files = fs.readdirSync(attachmentsDir);
|
|
585
|
+
for (const f of files) try {
|
|
586
|
+
if (fs.statSync(path.join(attachmentsDir, f)).isFile()) attachments.push(f);
|
|
587
|
+
} catch {}
|
|
588
|
+
} catch {}
|
|
589
|
+
if (format === "markdown") return { content: [{
|
|
590
|
+
type: "text",
|
|
591
|
+
text: [
|
|
592
|
+
`# Export: ${input.slug}`,
|
|
593
|
+
"",
|
|
594
|
+
"## Main Facts",
|
|
595
|
+
mainFactsContent.trim() || "(no content)",
|
|
596
|
+
"",
|
|
597
|
+
"## Metadata",
|
|
598
|
+
Object.entries(mainFacts).map(([k, v]) => `- **${k}**: ${JSON.stringify(v)}`).join("\n") || "(no metadata)",
|
|
599
|
+
"",
|
|
600
|
+
`## Interactions (${interactionsCount} total)`,
|
|
601
|
+
interactionsContent.trim() || "(no interactions)",
|
|
602
|
+
"",
|
|
603
|
+
"## Pipeline",
|
|
604
|
+
pipeline.length > 0 ? pipeline.map((d) => `- **${d.name}** · ${d.stage}${d.value !== void 0 ? ` · €${d.value}` : ""}${d.close_date ? ` · close: ${d.close_date}` : ""}`).join("\n") : "(no deals)",
|
|
605
|
+
"",
|
|
606
|
+
`## Attachments (${attachments.length})`,
|
|
607
|
+
attachments.length > 0 ? attachments.map((f) => `- ${f}`).join("\n") : "(none)"
|
|
608
|
+
].join("\n")
|
|
609
|
+
}] };
|
|
610
|
+
const exported = {
|
|
611
|
+
slug: input.slug,
|
|
612
|
+
exportedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
613
|
+
mainFacts,
|
|
614
|
+
interactionsCount,
|
|
615
|
+
pipeline,
|
|
616
|
+
attachments
|
|
617
|
+
};
|
|
618
|
+
return { content: [{
|
|
619
|
+
type: "text",
|
|
620
|
+
text: JSON.stringify(exported, null, 2)
|
|
621
|
+
}] };
|
|
622
|
+
}
|
|
623
|
+
function registerExportCustomer(server) {
|
|
624
|
+
server.registerTool("export_customer", {
|
|
625
|
+
title: "Export Customer",
|
|
626
|
+
description: `Export all customer data (main_facts + interactions count + pipeline deals).
|
|
627
|
+
Useful for reporting, audits, or creating backups.
|
|
628
|
+
|
|
629
|
+
Args:
|
|
630
|
+
slug: Customer ID (e.g. "acme-corp")
|
|
631
|
+
format: Output format — "json" (default) or "markdown"
|
|
632
|
+
|
|
633
|
+
Returns:
|
|
634
|
+
JSON: { slug, exportedAt, mainFacts, interactionsCount, pipeline }
|
|
635
|
+
Markdown: Formatted document with all sections`,
|
|
636
|
+
inputSchema: z.object({
|
|
637
|
+
slug: z.string().describe("Customer slug (e.g. 'acme-corp')"),
|
|
638
|
+
format: z.enum(["json", "markdown"]).optional().describe("Output format: 'json' (default) or 'markdown'")
|
|
639
|
+
})
|
|
640
|
+
}, async ({ slug, format }) => handleExportCustomer({
|
|
641
|
+
slug,
|
|
642
|
+
...format !== void 0 ? { format } : {}
|
|
643
|
+
}));
|
|
644
|
+
}
|
|
645
|
+
//#endregion
|
|
646
|
+
//#region src/mcp/tools/update-customer-facts.ts
|
|
647
|
+
const DATA_DIR$45 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
648
|
+
async function handleUpdateCustomerFacts(input, dataDir = DATA_DIR$45) {
|
|
649
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
650
|
+
try {
|
|
651
|
+
enforceRbac(dataDir, "update_customer_facts");
|
|
652
|
+
let existing;
|
|
653
|
+
let created = false;
|
|
654
|
+
if (!customerExists(dataDir, input.slug)) {
|
|
655
|
+
await ensureCustomerDir(dataDir, input.slug);
|
|
656
|
+
existing = {
|
|
657
|
+
name: input.name ?? input.slug,
|
|
658
|
+
relationship_stage: "prospect",
|
|
659
|
+
currency: "EUR",
|
|
660
|
+
tags: [],
|
|
661
|
+
created: today,
|
|
662
|
+
updated: today
|
|
663
|
+
};
|
|
664
|
+
created = true;
|
|
665
|
+
} else existing = await readMainFacts(dataDir, input.slug);
|
|
666
|
+
const updated = {
|
|
667
|
+
...existing,
|
|
668
|
+
...input.name !== void 0 ? { name: input.name } : {},
|
|
669
|
+
...input.domain !== void 0 ? { domain: input.domain } : {},
|
|
670
|
+
...input.email !== void 0 ? { email: input.email } : {},
|
|
671
|
+
...input.phone !== void 0 ? { phone: input.phone } : {},
|
|
672
|
+
...input.industry !== void 0 ? { industry: input.industry } : {},
|
|
673
|
+
...input.relationshipStage !== void 0 ? { relationship_stage: input.relationshipStage } : {},
|
|
674
|
+
...input.dealValue !== void 0 ? { deal_value: input.dealValue } : {},
|
|
675
|
+
...input.primaryContact !== void 0 ? { primary_contact: input.primaryContact } : {},
|
|
676
|
+
...input.timezone !== void 0 ? { timezone: input.timezone } : {},
|
|
677
|
+
...input.tags !== void 0 ? { tags: input.tags } : {},
|
|
678
|
+
updated: today
|
|
679
|
+
};
|
|
680
|
+
await writeMainFacts(dataDir, input.slug, updated);
|
|
681
|
+
writeAuditEntry(dataDir, {
|
|
682
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
683
|
+
actor: getActor(),
|
|
684
|
+
tool: "update_customer_facts",
|
|
685
|
+
slug: input.slug,
|
|
686
|
+
summary: Object.keys(input).filter((k) => k !== "slug").join(", ")
|
|
687
|
+
});
|
|
688
|
+
return { content: [{
|
|
689
|
+
type: "text",
|
|
690
|
+
text: JSON.stringify({
|
|
691
|
+
success: true,
|
|
692
|
+
created,
|
|
693
|
+
facts: updated
|
|
694
|
+
}, null, 2)
|
|
695
|
+
}] };
|
|
696
|
+
} catch (err) {
|
|
697
|
+
return { content: [{
|
|
698
|
+
type: "text",
|
|
699
|
+
text: JSON.stringify({
|
|
700
|
+
success: false,
|
|
701
|
+
error: err.message
|
|
702
|
+
}, null, 2)
|
|
703
|
+
}] };
|
|
704
|
+
}
|
|
705
|
+
}
|
|
706
|
+
function registerUpdateCustomerFacts(server) {
|
|
707
|
+
server.registerTool("update_customer_facts", {
|
|
708
|
+
title: "Update Customer Facts",
|
|
709
|
+
description: `Create or update a customer's main_facts.md profile.
|
|
710
|
+
If the customer slug does not exist yet, creates the customer directory and initial profile.
|
|
711
|
+
If it exists, merges the provided fields into existing data.
|
|
712
|
+
|
|
713
|
+
Use to add a new customer ("create acme-corp") or update existing info.
|
|
714
|
+
|
|
715
|
+
Args:
|
|
716
|
+
slug: Customer ID / slug — e.g. "acme-corp" (kebab-case, no spaces)
|
|
717
|
+
name: Company name (used as display name)
|
|
718
|
+
domain: Primary domain (e.g. "acme.com")
|
|
719
|
+
email: Primary contact email
|
|
720
|
+
phone: Phone number
|
|
721
|
+
industry: Industry vertical
|
|
722
|
+
relationshipStage: "prospect" | "active" | "churned" | "paused"
|
|
723
|
+
dealValue: Expected deal value in EUR
|
|
724
|
+
primaryContact: Primary contact person name
|
|
725
|
+
timezone: Timezone (e.g. "Europe/Berlin")
|
|
726
|
+
tags: Array of tags (replaces existing tags)
|
|
727
|
+
|
|
728
|
+
Returns: { success: boolean, created: boolean, facts: object }`,
|
|
729
|
+
inputSchema: z.object({
|
|
730
|
+
slug: z.string().describe("Customer slug (e.g. 'acme-corp')"),
|
|
731
|
+
name: z.string().optional().describe("Company name"),
|
|
732
|
+
domain: z.string().optional().describe("Primary domain"),
|
|
733
|
+
email: z.string().optional().describe("Primary contact email"),
|
|
734
|
+
phone: z.string().optional().describe("Phone number"),
|
|
735
|
+
industry: z.string().optional().describe("Industry vertical"),
|
|
736
|
+
relationshipStage: z.enum([
|
|
737
|
+
"prospect",
|
|
738
|
+
"active",
|
|
739
|
+
"churned",
|
|
740
|
+
"paused"
|
|
741
|
+
]).optional().describe("Relationship stage"),
|
|
742
|
+
dealValue: z.number().optional().describe("Expected deal value in EUR"),
|
|
743
|
+
primaryContact: z.string().optional().describe("Primary contact person name"),
|
|
744
|
+
timezone: z.string().optional().describe("Timezone (e.g. Europe/Berlin)"),
|
|
745
|
+
tags: z.array(z.string()).optional().describe("Tags (replaces existing)")
|
|
746
|
+
})
|
|
747
|
+
}, async (input) => handleUpdateCustomerFacts(input));
|
|
748
|
+
}
|
|
749
|
+
//#endregion
|
|
750
|
+
//#region src/core/deal-health.ts
|
|
751
|
+
function grade(score) {
|
|
752
|
+
if (score >= 80) return "A";
|
|
753
|
+
if (score >= 65) return "B";
|
|
754
|
+
if (score >= 50) return "C";
|
|
755
|
+
if (score >= 35) return "D";
|
|
756
|
+
return "F";
|
|
757
|
+
}
|
|
758
|
+
function scoreDeal(deal, signals) {
|
|
759
|
+
let score = 100;
|
|
760
|
+
const warnings = [];
|
|
761
|
+
if (signals.daysSinceLastActivity > 60) {
|
|
762
|
+
score -= 40;
|
|
763
|
+
warnings.push(`No activity in ${signals.daysSinceLastActivity} days`);
|
|
764
|
+
} else if (signals.daysSinceLastActivity > 30) {
|
|
765
|
+
score -= 25;
|
|
766
|
+
warnings.push(`Low activity — last touch ${signals.daysSinceLastActivity} days ago`);
|
|
767
|
+
} else if (signals.daysSinceLastActivity > 14) score -= 10;
|
|
768
|
+
if (signals.daysInCurrentStage > 90) {
|
|
769
|
+
score -= 25;
|
|
770
|
+
warnings.push(`Stuck in "${deal.stage}" for ${signals.daysInCurrentStage} days`);
|
|
771
|
+
} else if (signals.daysInCurrentStage > 45) {
|
|
772
|
+
score -= 12;
|
|
773
|
+
warnings.push(`Slow progress in "${deal.stage}"`);
|
|
774
|
+
}
|
|
775
|
+
if (signals.daysToClose !== void 0) {
|
|
776
|
+
if (signals.daysToClose < 0) {
|
|
777
|
+
score -= 20;
|
|
778
|
+
warnings.push("Close date passed");
|
|
779
|
+
} else if (signals.daysToClose < 7) {
|
|
780
|
+
score -= 10;
|
|
781
|
+
warnings.push("Close date in less than 7 days");
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
if (signals.probability !== void 0) {
|
|
785
|
+
if (signals.probability < 20 && deal.stage !== "lead") {
|
|
786
|
+
score -= 15;
|
|
787
|
+
warnings.push(`Low probability (${signals.probability}%) for stage "${deal.stage}"`);
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
score = Math.max(0, score);
|
|
791
|
+
return {
|
|
792
|
+
score,
|
|
793
|
+
grade: grade(score),
|
|
794
|
+
signals,
|
|
795
|
+
warnings
|
|
796
|
+
};
|
|
797
|
+
}
|
|
798
|
+
//#endregion
|
|
799
|
+
//#region src/mcp/tools/get-deal-health.ts
|
|
800
|
+
const DATA_DIR$44 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
801
|
+
async function handleGetDealHealth(input, dataDir = DATA_DIR$44) {
|
|
802
|
+
try {
|
|
803
|
+
const deals = await readPipeline(dataDir, input.slug);
|
|
804
|
+
const today = /* @__PURE__ */ new Date();
|
|
805
|
+
const results = [];
|
|
806
|
+
for (const deal of deals) {
|
|
807
|
+
const updatedDate = deal.updated ? new Date(deal.updated) : today;
|
|
808
|
+
const daysSinceLastActivity = Math.floor((today.getTime() - updatedDate.getTime()) / (1e3 * 60 * 60 * 24));
|
|
809
|
+
const daysToClose = deal.close_date ? Math.floor((new Date(deal.close_date).getTime() - today.getTime()) / (1e3 * 60 * 60 * 24)) : void 0;
|
|
810
|
+
const health = scoreDeal(deal, {
|
|
811
|
+
daysSinceLastActivity,
|
|
812
|
+
daysInCurrentStage: daysSinceLastActivity,
|
|
813
|
+
...daysToClose !== void 0 ? { daysToClose } : {},
|
|
814
|
+
...deal.probability !== void 0 ? { probability: deal.probability } : {}
|
|
815
|
+
});
|
|
816
|
+
results.push({
|
|
817
|
+
deal: deal.name,
|
|
818
|
+
stage: deal.stage,
|
|
819
|
+
...health
|
|
820
|
+
});
|
|
821
|
+
}
|
|
822
|
+
return { content: [{
|
|
823
|
+
type: "text",
|
|
824
|
+
text: JSON.stringify({
|
|
825
|
+
slug: input.slug,
|
|
826
|
+
deals: results
|
|
827
|
+
}, null, 2)
|
|
828
|
+
}] };
|
|
829
|
+
} catch (err) {
|
|
830
|
+
return { content: [{
|
|
831
|
+
type: "text",
|
|
832
|
+
text: JSON.stringify({
|
|
833
|
+
success: false,
|
|
834
|
+
error: err.message
|
|
835
|
+
}, null, 2)
|
|
836
|
+
}] };
|
|
837
|
+
}
|
|
838
|
+
}
|
|
839
|
+
function registerGetDealHealth(server) {
|
|
840
|
+
server.registerTool("get_deal_health", {
|
|
841
|
+
title: "Get Deal Health",
|
|
842
|
+
description: `Score the health of all deals for a customer (0–100). Grade A–F based on activity recency, stage velocity, close date proximity, and probability.
|
|
843
|
+
|
|
844
|
+
Returns: { slug, deals: [{ deal, stage, score, grade, signals, warnings }] }`,
|
|
845
|
+
inputSchema: z.object({ slug: z.string().describe("Customer slug") })
|
|
846
|
+
}, async ({ slug }) => handleGetDealHealth({ slug }));
|
|
847
|
+
}
|
|
848
|
+
//#endregion
|
|
849
|
+
//#region src/mcp/tools/get-pipeline-forecast.ts
|
|
850
|
+
const DATA_DIR$43 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
851
|
+
async function handleGetPipelineForecast(input, dataDir = DATA_DIR$43) {
|
|
852
|
+
try {
|
|
853
|
+
const customersDir = path.join(dataDir, "customers");
|
|
854
|
+
if (!fs.existsSync(customersDir)) return { content: [{
|
|
855
|
+
type: "text",
|
|
856
|
+
text: JSON.stringify({
|
|
857
|
+
deals: [],
|
|
858
|
+
totalWeightedValue: 0,
|
|
859
|
+
byStage: {}
|
|
860
|
+
}, null, 2)
|
|
861
|
+
}] };
|
|
862
|
+
const slugs = fs.readdirSync(customersDir).filter((d) => {
|
|
863
|
+
if (input.filter && !d.includes(input.filter)) return false;
|
|
864
|
+
return fs.statSync(path.join(customersDir, d)).isDirectory();
|
|
865
|
+
});
|
|
866
|
+
const allDeals = [];
|
|
867
|
+
for (const slug of slugs) {
|
|
868
|
+
const pipelinePath = path.join(customersDir, slug, "pipeline.md");
|
|
869
|
+
if (!fs.existsSync(pipelinePath)) continue;
|
|
870
|
+
const { readPipeline } = await import("./pipeline-writer-BqBrYrQc.js");
|
|
871
|
+
const deals = await readPipeline(dataDir, slug).catch(() => []);
|
|
872
|
+
for (const deal of deals) {
|
|
873
|
+
if (deal.stage === "won" || deal.stage === "lost") continue;
|
|
874
|
+
const prob = deal.probability ?? 50;
|
|
875
|
+
const value = deal.value ?? 0;
|
|
876
|
+
const forecastDeal = {
|
|
877
|
+
slug,
|
|
878
|
+
dealName: deal.name,
|
|
879
|
+
stage: deal.stage,
|
|
880
|
+
value,
|
|
881
|
+
probability: prob,
|
|
882
|
+
weightedValue: Math.round(value * prob / 100)
|
|
883
|
+
};
|
|
884
|
+
if (deal.close_date !== void 0) forecastDeal.closeDate = deal.close_date;
|
|
885
|
+
allDeals.push(forecastDeal);
|
|
886
|
+
}
|
|
887
|
+
}
|
|
888
|
+
const totalWeightedValue = allDeals.reduce((sum, d) => sum + d.weightedValue, 0);
|
|
889
|
+
const byStage = allDeals.reduce((acc, d) => {
|
|
890
|
+
if (!acc[d.stage]) acc[d.stage] = {
|
|
891
|
+
count: 0,
|
|
892
|
+
weightedValue: 0
|
|
893
|
+
};
|
|
894
|
+
acc[d.stage].count++;
|
|
895
|
+
acc[d.stage].weightedValue += d.weightedValue;
|
|
896
|
+
return acc;
|
|
897
|
+
}, {});
|
|
898
|
+
return { content: [{
|
|
899
|
+
type: "text",
|
|
900
|
+
text: JSON.stringify({
|
|
901
|
+
deals: allDeals,
|
|
902
|
+
totalWeightedValue,
|
|
903
|
+
byStage
|
|
904
|
+
}, null, 2)
|
|
905
|
+
}] };
|
|
906
|
+
} catch (err) {
|
|
907
|
+
return { content: [{
|
|
908
|
+
type: "text",
|
|
909
|
+
text: JSON.stringify({
|
|
910
|
+
success: false,
|
|
911
|
+
error: err.message
|
|
912
|
+
}, null, 2)
|
|
913
|
+
}] };
|
|
914
|
+
}
|
|
915
|
+
}
|
|
916
|
+
function registerGetPipelineForecast(server) {
|
|
917
|
+
server.registerTool("get_pipeline_forecast", {
|
|
918
|
+
title: "Get Pipeline Forecast",
|
|
919
|
+
description: `Aggregate weighted pipeline revenue across all customers. Groups open deals by stage, computes probability-weighted expected revenue.
|
|
920
|
+
|
|
921
|
+
Returns: { deals: [...], totalWeightedValue: number, byStage: { stage: { count, weightedValue } } }`,
|
|
922
|
+
inputSchema: z.object({ filter: z.string().optional().describe("Filter by customer slug substring") })
|
|
923
|
+
}, async ({ filter }) => handleGetPipelineForecast({ ...filter !== void 0 ? { filter } : {} }));
|
|
924
|
+
}
|
|
925
|
+
//#endregion
|
|
926
|
+
//#region src/mcp/tools/summarize-meeting.ts
|
|
927
|
+
const DATA_DIR$42 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
928
|
+
async function handleSummarizeMeeting(input, dataDir = DATA_DIR$42) {
|
|
929
|
+
try {
|
|
930
|
+
let summary = input.transcript.slice(0, 400);
|
|
931
|
+
let nextSteps = [];
|
|
932
|
+
try {
|
|
933
|
+
const { callLlm } = await import("./llm-DEjWcqmW.js");
|
|
934
|
+
const response = await callLlm(`Summarize this meeting transcript in 3-5 sentences and extract action items.\n\nTranscript:\n${input.transcript.slice(0, 3e3)}\n\nRespond as JSON: { "summary": "...", "nextSteps": ["..."] }`);
|
|
935
|
+
const parsed = JSON.parse(response);
|
|
936
|
+
summary = parsed.summary ?? summary;
|
|
937
|
+
nextSteps = parsed.nextSteps ?? [];
|
|
938
|
+
} catch {}
|
|
939
|
+
const date = input.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
940
|
+
const sourceRef = `agent://meeting/${Date.now()}`;
|
|
941
|
+
await appendInteraction(dataDir, input.slug, {
|
|
942
|
+
date,
|
|
943
|
+
type: "Meeting",
|
|
944
|
+
with: input.with ?? "Meeting Participant",
|
|
945
|
+
summary,
|
|
946
|
+
nextSteps,
|
|
947
|
+
sourceRef,
|
|
948
|
+
synced: (/* @__PURE__ */ new Date()).toISOString()
|
|
949
|
+
});
|
|
950
|
+
writeAuditEntry(dataDir, {
|
|
951
|
+
timestamp: (/* @__PURE__ */ new Date()).toISOString(),
|
|
952
|
+
actor: getActor(),
|
|
953
|
+
tool: "summarize_meeting",
|
|
954
|
+
slug: input.slug,
|
|
955
|
+
summary: summary.slice(0, 100)
|
|
956
|
+
});
|
|
957
|
+
return { content: [{
|
|
958
|
+
type: "text",
|
|
959
|
+
text: JSON.stringify({
|
|
960
|
+
success: true,
|
|
961
|
+
summary,
|
|
962
|
+
nextSteps,
|
|
963
|
+
sourceRef
|
|
964
|
+
}, null, 2)
|
|
965
|
+
}] };
|
|
966
|
+
} catch (err) {
|
|
967
|
+
return { content: [{
|
|
968
|
+
type: "text",
|
|
969
|
+
text: JSON.stringify({
|
|
970
|
+
success: false,
|
|
971
|
+
error: err.message
|
|
972
|
+
}, null, 2)
|
|
973
|
+
}] };
|
|
974
|
+
}
|
|
975
|
+
}
|
|
976
|
+
function registerSummarizeMeeting(server) {
|
|
977
|
+
server.registerTool("summarize_meeting", {
|
|
978
|
+
title: "Summarize Meeting",
|
|
979
|
+
description: `Summarize a meeting transcript and log it as an interaction. Uses LLM to extract key points and action items (falls back to raw text slice if LLM unavailable).
|
|
980
|
+
|
|
981
|
+
Args:
|
|
982
|
+
slug: Customer ID
|
|
983
|
+
transcript: Full meeting transcript text
|
|
984
|
+
with: Participant name(s) (optional)
|
|
985
|
+
date: Meeting date YYYY-MM-DD (optional, defaults to today)
|
|
986
|
+
|
|
987
|
+
Returns: { success, summary, nextSteps, sourceRef }`,
|
|
988
|
+
inputSchema: z.object({
|
|
989
|
+
slug: z.string().describe("Customer slug"),
|
|
990
|
+
transcript: z.string().min(1).describe("Full meeting transcript"),
|
|
991
|
+
with: z.string().optional().describe("Participant names"),
|
|
992
|
+
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().describe("Meeting date")
|
|
993
|
+
})
|
|
994
|
+
}, async ({ slug, transcript, with: withStr, date }) => handleSummarizeMeeting({
|
|
995
|
+
slug,
|
|
996
|
+
transcript,
|
|
997
|
+
...withStr !== void 0 ? { with: withStr } : {},
|
|
998
|
+
...date !== void 0 ? { date } : {}
|
|
999
|
+
}));
|
|
1000
|
+
}
|
|
1001
|
+
//#endregion
|
|
1002
|
+
//#region src/mcp/tools/get-pipeline-stages.ts
|
|
1003
|
+
const DATA_DIR$41 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1004
|
+
async function handleGetPipelineStages(_input, dataDir = DATA_DIR$41) {
|
|
1005
|
+
const stages = getPipelineStages(dataDir);
|
|
1006
|
+
return { content: [{
|
|
1007
|
+
type: "text",
|
|
1008
|
+
text: JSON.stringify({ stages }, null, 2)
|
|
1009
|
+
}] };
|
|
1010
|
+
}
|
|
1011
|
+
function registerGetPipelineStages(server) {
|
|
1012
|
+
server.registerTool("get_pipeline_stages", {
|
|
1013
|
+
title: "Get Pipeline Stages",
|
|
1014
|
+
description: "Returns all configured pipeline stages. Falls back to default stages (lead, qualified, proposal, negotiation, won, lost) if no custom stages are configured.",
|
|
1015
|
+
inputSchema: z.object({})
|
|
1016
|
+
}, async () => handleGetPipelineStages({}));
|
|
1017
|
+
}
|
|
1018
|
+
//#endregion
|
|
1019
|
+
//#region src/core/cross-customer.ts
|
|
1020
|
+
async function searchAcrossCustomers(dataDir, query, limit = 5, excludeSlug) {
|
|
1021
|
+
const slugs = listCustomerSlugs(dataDir).filter((d) => d !== excludeSlug);
|
|
1022
|
+
const allResults = [];
|
|
1023
|
+
for (const slug of slugs) {
|
|
1024
|
+
const results = await searchKnowledge(dataDir, slug, query, 2);
|
|
1025
|
+
for (const r of results) allResults.push({
|
|
1026
|
+
slug,
|
|
1027
|
+
relevantContent: r.content.slice(0, 200),
|
|
1028
|
+
score: r.score
|
|
1029
|
+
});
|
|
1030
|
+
}
|
|
1031
|
+
return allResults.sort((a, b) => b.score - a.score).slice(0, limit);
|
|
1032
|
+
}
|
|
1033
|
+
//#endregion
|
|
1034
|
+
//#region src/mcp/tools/get-market-intelligence.ts
|
|
1035
|
+
const DATA_DIR$40 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1036
|
+
async function handleGetMarketIntelligence(input, dataDir = DATA_DIR$40) {
|
|
1037
|
+
const excludeSlug = input.excludeCurrentCustomer ? input.slug : void 0;
|
|
1038
|
+
const all = listCustomerSlugs(dataDir);
|
|
1039
|
+
const totalCustomersSearched = excludeSlug ? all.filter((s) => s !== excludeSlug).length : all.length;
|
|
1040
|
+
const results = await searchAcrossCustomers(dataDir, input.query, 10, excludeSlug);
|
|
1041
|
+
return { content: [{
|
|
1042
|
+
type: "text",
|
|
1043
|
+
text: JSON.stringify({
|
|
1044
|
+
query: input.query,
|
|
1045
|
+
results,
|
|
1046
|
+
totalCustomersSearched
|
|
1047
|
+
}, null, 2)
|
|
1048
|
+
}] };
|
|
1049
|
+
}
|
|
1050
|
+
function registerGetMarketIntelligence(server) {
|
|
1051
|
+
server.registerTool("get_market_intelligence", {
|
|
1052
|
+
title: "Get Market Intelligence",
|
|
1053
|
+
description: "Search across all customers to find patterns, common topics, or similar issues. Uses semantic search (LanceDB) across all customer knowledge bases. Results use slug (not real names) for privacy.",
|
|
1054
|
+
inputSchema: z.object({
|
|
1055
|
+
query: z.string().describe("What to search for across all customers"),
|
|
1056
|
+
excludeCurrentCustomer: z.boolean().optional().describe("Exclude the current customer from results"),
|
|
1057
|
+
slug: z.string().optional().describe("Current customer slug (used with excludeCurrentCustomer)")
|
|
1058
|
+
})
|
|
1059
|
+
}, async ({ query, excludeCurrentCustomer, slug }) => handleGetMarketIntelligence({
|
|
1060
|
+
query,
|
|
1061
|
+
...excludeCurrentCustomer !== void 0 ? { excludeCurrentCustomer } : {},
|
|
1062
|
+
...slug !== void 0 ? { slug } : {}
|
|
1063
|
+
}));
|
|
1064
|
+
}
|
|
1065
|
+
//#endregion
|
|
1066
|
+
//#region src/mcp/tools/get-relationship-graph.ts
|
|
1067
|
+
const DATA_DIR$39 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1068
|
+
function summarizeNode(n) {
|
|
1069
|
+
return {
|
|
1070
|
+
id: n.id,
|
|
1071
|
+
name: n.label,
|
|
1072
|
+
email: n.properties["email"]
|
|
1073
|
+
};
|
|
1074
|
+
}
|
|
1075
|
+
async function handleGetRelationshipGraph(input, dataDir = DATA_DIR$39) {
|
|
1076
|
+
try {
|
|
1077
|
+
const graph = readGraph(dataDir, input.slug);
|
|
1078
|
+
const stakeholders = getStakeholders(graph);
|
|
1079
|
+
const ownerContactIds = graph.nodes.filter((n) => n.type === "person" && n.properties["isOwnerContact"] === true).map((n) => n.id);
|
|
1080
|
+
const economicBuyerIds = stakeholders.economicBuyers.map((n) => n.id);
|
|
1081
|
+
const warmIntroPaths = [];
|
|
1082
|
+
for (const ebId of economicBuyerIds) for (const ownerId of ownerContactIds) {
|
|
1083
|
+
const p = findPath(graph, ownerId, ebId);
|
|
1084
|
+
if (p.length > 1) {
|
|
1085
|
+
warmIntroPaths.push({
|
|
1086
|
+
target: ebId,
|
|
1087
|
+
path: p
|
|
1088
|
+
});
|
|
1089
|
+
break;
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
return { content: [{
|
|
1093
|
+
type: "text",
|
|
1094
|
+
text: JSON.stringify({
|
|
1095
|
+
slug: input.slug,
|
|
1096
|
+
nodeCount: graph.nodes.length,
|
|
1097
|
+
edgeCount: graph.edges.length,
|
|
1098
|
+
updatedAt: graph.updatedAt,
|
|
1099
|
+
stakeholders: {
|
|
1100
|
+
champions: stakeholders.champions.map(summarizeNode),
|
|
1101
|
+
blockers: stakeholders.blockers.map(summarizeNode),
|
|
1102
|
+
economicBuyers: stakeholders.economicBuyers.map(summarizeNode),
|
|
1103
|
+
allContacts: stakeholders.allContacts.map(summarizeNode),
|
|
1104
|
+
missingRoles: stakeholders.missingRoles
|
|
1105
|
+
},
|
|
1106
|
+
warmIntroPaths,
|
|
1107
|
+
nodes: graph.nodes,
|
|
1108
|
+
edges: graph.edges
|
|
1109
|
+
}, null, 2)
|
|
1110
|
+
}] };
|
|
1111
|
+
} catch (err) {
|
|
1112
|
+
return { content: [{
|
|
1113
|
+
type: "text",
|
|
1114
|
+
text: JSON.stringify({
|
|
1115
|
+
success: false,
|
|
1116
|
+
error: err.message
|
|
1117
|
+
}, null, 2)
|
|
1118
|
+
}] };
|
|
1119
|
+
}
|
|
1120
|
+
}
|
|
1121
|
+
function registerGetRelationshipGraph(server) {
|
|
1122
|
+
server.registerTool("get_relationship_graph", {
|
|
1123
|
+
title: "Get Relationship Graph",
|
|
1124
|
+
description: `Returns the knowledge graph for a customer: all known contacts, companies,
|
|
1125
|
+
and the relationships between them (KNOWS, WORKS_AT, IS_CHAMPION, IS_BLOCKER, IS_ECONOMIC_BUYER).
|
|
1126
|
+
|
|
1127
|
+
The graph auto-populates from every log_interaction call.
|
|
1128
|
+
Use this before a complex deal conversation to understand the stakeholder map.
|
|
1129
|
+
|
|
1130
|
+
Args:
|
|
1131
|
+
slug: Customer slug
|
|
1132
|
+
|
|
1133
|
+
Returns: {
|
|
1134
|
+
stakeholders: { champions[], blockers[], economicBuyers[], allContacts[], missingRoles[] },
|
|
1135
|
+
nodes: GraphNode[],
|
|
1136
|
+
edges: GraphEdge[]
|
|
1137
|
+
}`,
|
|
1138
|
+
inputSchema: z.object({ slug: z.string().describe("Customer slug (e.g. 'acme-corp')") })
|
|
1139
|
+
}, async ({ slug }) => handleGetRelationshipGraph({ slug }));
|
|
1140
|
+
}
|
|
1141
|
+
//#endregion
|
|
1142
|
+
//#region src/mcp/tools/get-relationship-health.ts
|
|
1143
|
+
const DATA_DIR$38 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1144
|
+
const MAX_HEALTH_AGE_MS = 3600 * 1e3;
|
|
1145
|
+
async function handleGetRelationshipHealth(input, dataDir = DATA_DIR$38) {
|
|
1146
|
+
try {
|
|
1147
|
+
let health = readHealth(dataDir, input.slug);
|
|
1148
|
+
if (health === null || Date.now() - new Date(health.updatedAt).getTime() > MAX_HEALTH_AGE_MS) {
|
|
1149
|
+
health = computeCustomerHealth(dataDir, input.slug);
|
|
1150
|
+
writeHealth(dataDir, input.slug, health);
|
|
1151
|
+
}
|
|
1152
|
+
return { content: [{
|
|
1153
|
+
type: "text",
|
|
1154
|
+
text: JSON.stringify({
|
|
1155
|
+
slug: input.slug,
|
|
1156
|
+
overallHealth: health.overallHealth,
|
|
1157
|
+
updatedAt: health.updatedAt,
|
|
1158
|
+
atRiskContacts: health.contacts.filter((c) => c.riskFlags.length > 0).map((c) => c.email ?? c.contactId),
|
|
1159
|
+
coldContacts: health.contacts.filter((c) => c.trend === "cold").map((c) => c.email ?? c.contactId),
|
|
1160
|
+
contacts: health.contacts
|
|
1161
|
+
}, null, 2)
|
|
1162
|
+
}] };
|
|
1163
|
+
} catch (err) {
|
|
1164
|
+
return { content: [{
|
|
1165
|
+
type: "text",
|
|
1166
|
+
text: JSON.stringify({
|
|
1167
|
+
success: false,
|
|
1168
|
+
error: err.message
|
|
1169
|
+
}, null, 2)
|
|
1170
|
+
}] };
|
|
1171
|
+
}
|
|
1172
|
+
}
|
|
1173
|
+
function registerGetRelationshipHealth(server) {
|
|
1174
|
+
server.registerTool("get_relationship_health", {
|
|
1175
|
+
title: "Get Relationship Health",
|
|
1176
|
+
description: `Returns health scores for all contacts of a customer.
|
|
1177
|
+
Scores decay automatically when communication cadence breaks — without any manual input.
|
|
1178
|
+
|
|
1179
|
+
Each contact gets:
|
|
1180
|
+
- score (0–100), grade (A–F), trend (rising|stable|declining|cold)
|
|
1181
|
+
- riskFlags: NO_CONTACT_14D, NO_CONTACT_30D, CHAMPION_SILENT
|
|
1182
|
+
- recommendation: concrete next action
|
|
1183
|
+
|
|
1184
|
+
overallHealth is the average across all contacts.
|
|
1185
|
+
atRiskContacts + coldContacts are pre-filtered for quick triage.
|
|
1186
|
+
Health auto-updates after every log_interaction call. Recomputes if stale (>1h).
|
|
1187
|
+
|
|
1188
|
+
Args:
|
|
1189
|
+
slug: Customer slug
|
|
1190
|
+
|
|
1191
|
+
Returns: {
|
|
1192
|
+
overallHealth: number,
|
|
1193
|
+
atRiskContacts: string[],
|
|
1194
|
+
coldContacts: string[],
|
|
1195
|
+
contacts: ContactHealth[]
|
|
1196
|
+
}`,
|
|
1197
|
+
inputSchema: z.object({ slug: z.string().describe("Customer slug (e.g. 'acme-corp')") })
|
|
1198
|
+
}, async ({ slug }) => handleGetRelationshipHealth({ slug }));
|
|
1199
|
+
}
|
|
1200
|
+
//#endregion
|
|
1201
|
+
//#region src/core/playbooks.ts
|
|
1202
|
+
function playbooksDir(dataDir, slug) {
|
|
1203
|
+
return path.join(dataDir, "customers", slug, "playbooks");
|
|
1204
|
+
}
|
|
1205
|
+
function listPlaybooks(dataDir, slug) {
|
|
1206
|
+
const dir = playbooksDir(dataDir, slug);
|
|
1207
|
+
if (!fs.existsSync(dir)) return [];
|
|
1208
|
+
return fs.readdirSync(dir).filter((f) => f.endsWith(".md")).map((f) => {
|
|
1209
|
+
const filePath = path.join(dir, f);
|
|
1210
|
+
const parsed = matter(fs.readFileSync(filePath, "utf-8"));
|
|
1211
|
+
return {
|
|
1212
|
+
slug,
|
|
1213
|
+
name: f.replace(/\.md$/, ""),
|
|
1214
|
+
frontmatter: parsed.data,
|
|
1215
|
+
content: parsed.content.trim(),
|
|
1216
|
+
path: filePath
|
|
1217
|
+
};
|
|
1218
|
+
});
|
|
1219
|
+
}
|
|
1220
|
+
async function writePlaybook(dataDir, slug, playbook) {
|
|
1221
|
+
const dir = playbooksDir(dataDir, slug);
|
|
1222
|
+
const filePath = path.join(dir, `${playbook.name}.md`);
|
|
1223
|
+
await withFileQueue(filePath, async () => {
|
|
1224
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
1225
|
+
const raw = matter.stringify(playbook.content, playbook.frontmatter);
|
|
1226
|
+
fs.writeFileSync(filePath, raw, "utf-8");
|
|
1227
|
+
});
|
|
1228
|
+
}
|
|
1229
|
+
function toKebabCase(name) {
|
|
1230
|
+
return name.replace(/[^a-z0-9-]/gi, "-").toLowerCase().replace(/-+/g, "-").replace(/^-|-$/g, "");
|
|
1231
|
+
}
|
|
1232
|
+
function parseTokens(tokens) {
|
|
1233
|
+
return tokens.flatMap((token) => {
|
|
1234
|
+
if (token.startsWith("deal_stage_")) return [{
|
|
1235
|
+
type: "stage",
|
|
1236
|
+
stage: token.slice(11)
|
|
1237
|
+
}];
|
|
1238
|
+
const valueGt = token.match(/^value\s*>\s*(\d+)$/);
|
|
1239
|
+
if (valueGt) return [{
|
|
1240
|
+
type: "value_gt",
|
|
1241
|
+
value: Number(valueGt[1])
|
|
1242
|
+
}];
|
|
1243
|
+
const valueLt = token.match(/^value\s*<\s*(\d+)$/);
|
|
1244
|
+
if (valueLt) return [{
|
|
1245
|
+
type: "value_lt",
|
|
1246
|
+
value: Number(valueLt[1])
|
|
1247
|
+
}];
|
|
1248
|
+
const stalledGt = token.match(/^days_stalled\s*>\s*(\d+)$/);
|
|
1249
|
+
if (stalledGt) return [{
|
|
1250
|
+
type: "days_stalled_gt",
|
|
1251
|
+
value: Number(stalledGt[1])
|
|
1252
|
+
}];
|
|
1253
|
+
const stalledLt = token.match(/^days_stalled\s*<\s*(\d+)$/);
|
|
1254
|
+
if (stalledLt) return [{
|
|
1255
|
+
type: "days_stalled_lt",
|
|
1256
|
+
value: Number(stalledLt[1])
|
|
1257
|
+
}];
|
|
1258
|
+
const healthLt = token.match(/^health\s*<\s*(\d+)$/);
|
|
1259
|
+
if (healthLt) return [{
|
|
1260
|
+
type: "health_lt",
|
|
1261
|
+
value: Number(healthLt[1])
|
|
1262
|
+
}];
|
|
1263
|
+
const healthGt = token.match(/^health\s*>\s*(\d+)$/);
|
|
1264
|
+
if (healthGt) return [{
|
|
1265
|
+
type: "health_gt",
|
|
1266
|
+
value: Number(healthGt[1])
|
|
1267
|
+
}];
|
|
1268
|
+
if (token === "no_champion") return [{ type: "no_champion" }];
|
|
1269
|
+
if (token === "has_champion") return [{ type: "has_champion" }];
|
|
1270
|
+
return [];
|
|
1271
|
+
});
|
|
1272
|
+
}
|
|
1273
|
+
function parseTriggerFull(triggerStr) {
|
|
1274
|
+
if (!triggerStr?.trim()) return {
|
|
1275
|
+
conditions: [],
|
|
1276
|
+
operator: "AND"
|
|
1277
|
+
};
|
|
1278
|
+
const orTokens = triggerStr.split(/\s+OR\s+/).map((t) => t.trim()).filter(Boolean);
|
|
1279
|
+
if (orTokens.length > 1) return {
|
|
1280
|
+
conditions: parseTokens(orTokens),
|
|
1281
|
+
operator: "OR"
|
|
1282
|
+
};
|
|
1283
|
+
return {
|
|
1284
|
+
conditions: parseTokens(triggerStr.split(/\s+AND\s+/).map((t) => t.trim()).filter(Boolean)),
|
|
1285
|
+
operator: "AND"
|
|
1286
|
+
};
|
|
1287
|
+
}
|
|
1288
|
+
function evaluateCondition(cond, deal, daysSinceContact) {
|
|
1289
|
+
switch (cond.type) {
|
|
1290
|
+
case "stage": return deal.stage === cond.stage;
|
|
1291
|
+
case "value_gt": return deal.value > (cond.value ?? 0);
|
|
1292
|
+
case "value_lt": return deal.value < (cond.value ?? Infinity);
|
|
1293
|
+
case "days_stalled_gt": return daysSinceContact > (cond.value ?? 0);
|
|
1294
|
+
case "days_stalled_lt": return daysSinceContact < (cond.value ?? Infinity);
|
|
1295
|
+
case "health_lt": return deal.healthScore < (cond.value ?? 100);
|
|
1296
|
+
case "health_gt": return deal.healthScore > (cond.value ?? 0);
|
|
1297
|
+
case "no_champion": return !deal.championPresent;
|
|
1298
|
+
case "has_champion": return deal.championPresent;
|
|
1299
|
+
default: return false;
|
|
1300
|
+
}
|
|
1301
|
+
}
|
|
1302
|
+
function matchPlaybooks(playbooks, deal, daysSinceContact = 0) {
|
|
1303
|
+
const results = [];
|
|
1304
|
+
for (const pb of playbooks) {
|
|
1305
|
+
const { conditions, operator } = parseTriggerFull(pb.frontmatter.trigger);
|
|
1306
|
+
if (conditions.length === 0) continue;
|
|
1307
|
+
const matched = conditions.filter((c) => evaluateCondition(c, deal, daysSinceContact));
|
|
1308
|
+
if (operator === "OR" ? matched.length > 0 : matched.length === conditions.length) results.push({
|
|
1309
|
+
playbook: pb,
|
|
1310
|
+
score: 1,
|
|
1311
|
+
matchedConditions: matched,
|
|
1312
|
+
totalConditions: conditions.length
|
|
1313
|
+
});
|
|
1314
|
+
}
|
|
1315
|
+
return results.sort((a, b) => {
|
|
1316
|
+
const rateDiff = (b.playbook.frontmatter.successRate ?? 0) - (a.playbook.frontmatter.successRate ?? 0);
|
|
1317
|
+
return rateDiff !== 0 ? rateDiff : (b.playbook.frontmatter.usedCount ?? 0) - (a.playbook.frontmatter.usedCount ?? 0);
|
|
1318
|
+
});
|
|
1319
|
+
}
|
|
1320
|
+
function buildDistillPrompt(slug, dealName, outcome, interactions) {
|
|
1321
|
+
return `You are analyzing a sales deal to extract a reusable playbook.
|
|
1322
|
+
|
|
1323
|
+
Customer: ${slug}
|
|
1324
|
+
Deal: ${dealName}
|
|
1325
|
+
Outcome: ${outcome}
|
|
1326
|
+
Interactions (chronological):
|
|
1327
|
+
${interactions.slice(0, 4e3)}
|
|
1328
|
+
|
|
1329
|
+
Extract a reusable playbook from this deal's journey.
|
|
1330
|
+
|
|
1331
|
+
Allowed trigger tokens (combine with " AND "):
|
|
1332
|
+
- deal_stage_<stage> (e.g. deal_stage_negotiation)
|
|
1333
|
+
- value > <n> (e.g. value > 50000)
|
|
1334
|
+
- value < <n>
|
|
1335
|
+
- days_stalled > <n> (e.g. days_stalled > 7)
|
|
1336
|
+
- days_stalled < <n>
|
|
1337
|
+
- health < <n> (e.g. health < 60)
|
|
1338
|
+
- health > <n>
|
|
1339
|
+
- no_champion
|
|
1340
|
+
- has_champion
|
|
1341
|
+
|
|
1342
|
+
Return JSON only (no markdown wrapper):
|
|
1343
|
+
{
|
|
1344
|
+
"name": "<kebab-case-playbook-name>",
|
|
1345
|
+
"trigger": "<DSL string using allowed tokens>",
|
|
1346
|
+
"content": "<markdown with ## Situation, ## Steps, ## Warnings sections>",
|
|
1347
|
+
"successRate": <0.0-1.0>,
|
|
1348
|
+
"reasoning": "<why these trigger conditions>"
|
|
1349
|
+
}`;
|
|
1350
|
+
}
|
|
1351
|
+
function parseLlmDistillation(response, outcomeFallback = .5) {
|
|
1352
|
+
try {
|
|
1353
|
+
const match = response.match(/\{[\s\S]*\}/);
|
|
1354
|
+
if (!match) return null;
|
|
1355
|
+
const parsed = JSON.parse(match[0]);
|
|
1356
|
+
if (!parsed.name || !parsed.trigger || !parsed.content) return null;
|
|
1357
|
+
return {
|
|
1358
|
+
name: parsed.name,
|
|
1359
|
+
trigger: parsed.trigger,
|
|
1360
|
+
content: parsed.content,
|
|
1361
|
+
successRate: typeof parsed.successRate === "number" ? parsed.successRate : outcomeFallback,
|
|
1362
|
+
reasoning: parsed.reasoning ?? ""
|
|
1363
|
+
};
|
|
1364
|
+
} catch {
|
|
1365
|
+
return null;
|
|
1366
|
+
}
|
|
1367
|
+
}
|
|
1368
|
+
async function distillPlaybook(dataDir, slug, dealName, outcome, llmFn = callLlm) {
|
|
1369
|
+
const interactionsPath = path.join(dataDir, "customers", slug, "interactions.md");
|
|
1370
|
+
if (!fs.existsSync(interactionsPath)) return {
|
|
1371
|
+
ok: false,
|
|
1372
|
+
errorKind: "no_interactions"
|
|
1373
|
+
};
|
|
1374
|
+
const distillation = parseLlmDistillation(await llmFn(buildDistillPrompt(slug, dealName, outcome, fs.readFileSync(interactionsPath, "utf-8"))), outcome === "won" ? 1 : 0);
|
|
1375
|
+
if (!distillation) return {
|
|
1376
|
+
ok: false,
|
|
1377
|
+
errorKind: "parse_failed"
|
|
1378
|
+
};
|
|
1379
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1380
|
+
const name = toKebabCase(distillation.name);
|
|
1381
|
+
const playbook = {
|
|
1382
|
+
slug,
|
|
1383
|
+
name,
|
|
1384
|
+
frontmatter: {
|
|
1385
|
+
trigger: distillation.trigger,
|
|
1386
|
+
successRate: distillation.successRate,
|
|
1387
|
+
usedCount: 1,
|
|
1388
|
+
lastUpdated: today
|
|
1389
|
+
},
|
|
1390
|
+
content: distillation.content,
|
|
1391
|
+
path: path.join(playbooksDir(dataDir, slug), `${name}.md`)
|
|
1392
|
+
};
|
|
1393
|
+
await writePlaybook(dataDir, slug, playbook);
|
|
1394
|
+
return {
|
|
1395
|
+
ok: true,
|
|
1396
|
+
playbook,
|
|
1397
|
+
reasoning: distillation.reasoning
|
|
1398
|
+
};
|
|
1399
|
+
}
|
|
1400
|
+
//#endregion
|
|
1401
|
+
//#region src/agents/deal-agent.ts
|
|
1402
|
+
function agentQueuePath(dataDir, slug) {
|
|
1403
|
+
return path.join(dataDir, "customers", slug, "agent-queue.json");
|
|
1404
|
+
}
|
|
1405
|
+
function readAgentQueue(dataDir, slug) {
|
|
1406
|
+
const p = agentQueuePath(dataDir, slug);
|
|
1407
|
+
if (!fs.existsSync(p)) return {
|
|
1408
|
+
schemaVersion: "1",
|
|
1409
|
+
slug,
|
|
1410
|
+
pendingActions: [],
|
|
1411
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1412
|
+
};
|
|
1413
|
+
try {
|
|
1414
|
+
return JSON.parse(fs.readFileSync(p, "utf-8"));
|
|
1415
|
+
} catch {
|
|
1416
|
+
return {
|
|
1417
|
+
schemaVersion: "1",
|
|
1418
|
+
slug,
|
|
1419
|
+
pendingActions: [],
|
|
1420
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1421
|
+
};
|
|
1422
|
+
}
|
|
1423
|
+
}
|
|
1424
|
+
function writeAgentQueue(dataDir, slug, queue) {
|
|
1425
|
+
const p = agentQueuePath(dataDir, slug);
|
|
1426
|
+
const dir = path.dirname(p);
|
|
1427
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
1428
|
+
const updated = {
|
|
1429
|
+
...queue,
|
|
1430
|
+
updatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1431
|
+
};
|
|
1432
|
+
fs.writeFileSync(p, JSON.stringify(updated, null, 2), "utf-8");
|
|
1433
|
+
}
|
|
1434
|
+
function makeActionId() {
|
|
1435
|
+
return `da_${Date.now()}_${Math.random().toString(36).slice(2, 8)}`;
|
|
1436
|
+
}
|
|
1437
|
+
function buildRecentInteractionsSummary(interactionsPath) {
|
|
1438
|
+
if (!fs.existsSync(interactionsPath)) return "(no interactions)";
|
|
1439
|
+
return fs.readFileSync(interactionsPath, "utf-8").split(/(?=^## \d{4}-\d{2}-\d{2})/m).filter((b) => b.trim().length > 0).slice(0, 3).map((b) => {
|
|
1440
|
+
const dateMatch = b.match(/^## (\d{4}-\d{2}-\d{2}) · (\w+)/m);
|
|
1441
|
+
const summaryMatch = b.match(/^\*\*Summary:\*\*\s*(.+)$/m);
|
|
1442
|
+
if (!dateMatch || !summaryMatch) return "";
|
|
1443
|
+
return `[${dateMatch[1]}/${dateMatch[2]}] ${summaryMatch[1].trim()}`;
|
|
1444
|
+
}).filter(Boolean).join("\n");
|
|
1445
|
+
}
|
|
1446
|
+
function buildContextSummary(data) {
|
|
1447
|
+
return [
|
|
1448
|
+
`Deal: ${data.deal.name} | Stage: ${data.deal.stage} | Value: €${data.deal.value ?? "?"} | Close: ${data.deal.close_date ?? "not set"}`,
|
|
1449
|
+
`Days since activity: ${data.daysSinceLastActivity} | Days to close: ${data.daysToClose ?? "?"}`,
|
|
1450
|
+
`Deal health: grade ${data.dealHealthScore.grade} (score ${data.dealHealthScore.score})`,
|
|
1451
|
+
`Warnings: ${data.dealHealthScore.warnings.join("; ") || "none"}`,
|
|
1452
|
+
``,
|
|
1453
|
+
`Relationship health: ${data.health.overallHealth}/100`,
|
|
1454
|
+
`At-risk contacts: ${data.atRiskContacts.join(", ") || "none"}`,
|
|
1455
|
+
`Cold contacts: ${data.coldContacts.join(", ") || "none"}`,
|
|
1456
|
+
`Missing stakeholder roles: ${data.missingRoles.map((r) => r.role).join(", ") || "none"}`,
|
|
1457
|
+
`Champions identified: ${data.championCount}`,
|
|
1458
|
+
``,
|
|
1459
|
+
`Recent interactions:`,
|
|
1460
|
+
data.recentInteractionsSummary || "(none)"
|
|
1461
|
+
].join("\n");
|
|
1462
|
+
}
|
|
1463
|
+
async function observeDeal(dataDir, slug, dealName, today) {
|
|
1464
|
+
const deal = (await readPipeline(dataDir, slug).catch(() => [])).find((d) => d.name.toLowerCase() === dealName.toLowerCase());
|
|
1465
|
+
if (!deal) return null;
|
|
1466
|
+
const todayDate = new Date(today);
|
|
1467
|
+
const updatedDate = deal.updated ? new Date(deal.updated) : todayDate;
|
|
1468
|
+
const daysSinceLastActivity = Math.floor((todayDate.getTime() - updatedDate.getTime()) / 864e5);
|
|
1469
|
+
const daysInCurrentStage = daysSinceLastActivity;
|
|
1470
|
+
const daysToClose = deal.close_date && deal.close_date.trim() !== "" ? Math.floor((new Date(deal.close_date).getTime() - todayDate.getTime()) / 864e5) : void 0;
|
|
1471
|
+
const dealHealthScore = scoreDeal(deal, {
|
|
1472
|
+
daysSinceLastActivity,
|
|
1473
|
+
daysInCurrentStage,
|
|
1474
|
+
...daysToClose !== void 0 ? { daysToClose } : {},
|
|
1475
|
+
...deal.probability !== void 0 ? { probability: deal.probability } : {}
|
|
1476
|
+
});
|
|
1477
|
+
const health = computeCustomerHealth(dataDir, slug, today);
|
|
1478
|
+
const atRiskContacts = health.contacts.filter((c) => c.riskFlags.length > 0).map((c) => c.email ?? c.contactId);
|
|
1479
|
+
const coldContacts = health.contacts.filter((c) => c.trend === "cold").map((c) => c.email ?? c.contactId);
|
|
1480
|
+
const stakeholders = getStakeholders(readGraph(dataDir, slug));
|
|
1481
|
+
const missingRoles = stakeholders.missingRoles.map((r) => ({
|
|
1482
|
+
role: r.role,
|
|
1483
|
+
urgency: r.urgency
|
|
1484
|
+
}));
|
|
1485
|
+
const championCount = stakeholders.champions.length;
|
|
1486
|
+
const recentInteractionsSummary = buildRecentInteractionsSummary(path.join(dataDir, "customers", slug, "interactions.md"));
|
|
1487
|
+
const contextSummary = buildContextSummary({
|
|
1488
|
+
deal,
|
|
1489
|
+
daysSinceLastActivity,
|
|
1490
|
+
daysInCurrentStage,
|
|
1491
|
+
daysToClose,
|
|
1492
|
+
dealHealthScore,
|
|
1493
|
+
health,
|
|
1494
|
+
atRiskContacts,
|
|
1495
|
+
coldContacts,
|
|
1496
|
+
missingRoles,
|
|
1497
|
+
championCount,
|
|
1498
|
+
recentInteractionsSummary
|
|
1499
|
+
});
|
|
1500
|
+
const obs = {
|
|
1501
|
+
deal,
|
|
1502
|
+
daysSinceLastActivity,
|
|
1503
|
+
daysInCurrentStage,
|
|
1504
|
+
dealHealthScore,
|
|
1505
|
+
overallRelationshipHealth: health.overallHealth,
|
|
1506
|
+
atRiskContacts,
|
|
1507
|
+
coldContacts,
|
|
1508
|
+
missingRoles,
|
|
1509
|
+
championCount,
|
|
1510
|
+
recentInteractionsSummary,
|
|
1511
|
+
contextSummary
|
|
1512
|
+
};
|
|
1513
|
+
if (daysToClose !== void 0) obs.daysToClose = daysToClose;
|
|
1514
|
+
const dealSnap = {
|
|
1515
|
+
slug,
|
|
1516
|
+
name: deal.name,
|
|
1517
|
+
stage: deal.stage,
|
|
1518
|
+
value: deal.value ?? 0,
|
|
1519
|
+
probability: deal.probability ?? 50,
|
|
1520
|
+
healthScore: health.overallHealth,
|
|
1521
|
+
daysSinceContact: daysSinceLastActivity,
|
|
1522
|
+
championPresent: championCount > 0
|
|
1523
|
+
};
|
|
1524
|
+
const matchingPlaybooks = matchPlaybooks(listPlaybooks(dataDir, slug), dealSnap, daysSinceLastActivity);
|
|
1525
|
+
if (matchingPlaybooks.length > 0) obs.matchingPlaybooks = matchingPlaybooks;
|
|
1526
|
+
return obs;
|
|
1527
|
+
}
|
|
1528
|
+
function buildLlmPrompt(obs, config) {
|
|
1529
|
+
const instruction = config.instruction ?? "Analyze this deal and recommend next actions.";
|
|
1530
|
+
const playbookSection = obs.matchingPlaybooks && obs.matchingPlaybooks.length > 0 ? `\n## Matching Playbooks (${obs.matchingPlaybooks.length} found — apply these proven tactics)\n` + obs.matchingPlaybooks.slice(0, 2).map((m) => `### ${m.playbook.name} (${Math.round(m.playbook.frontmatter.successRate * 100)}% success rate, used ${m.playbook.frontmatter.usedCount}x)\n${m.playbook.content.slice(0, 500)}`).join("\n\n") : "";
|
|
1531
|
+
return `You are a CRM deal agent. Analyze the deal situation and return an action plan.
|
|
1532
|
+
Return ONLY valid JSON — no markdown, no explanation.
|
|
1533
|
+
|
|
1534
|
+
${obs.contextSummary}${playbookSection}
|
|
1535
|
+
|
|
1536
|
+
Instruction: ${instruction}
|
|
1537
|
+
|
|
1538
|
+
Respond with JSON matching exactly:
|
|
1539
|
+
{
|
|
1540
|
+
"assessment": "<2-3 sentence situation assessment>",
|
|
1541
|
+
"riskLevel": "low" | "medium" | "high" | "critical",
|
|
1542
|
+
"plan": [
|
|
1543
|
+
{ "step": 1, "action": "<what to do>", "priority": "urgent" | "high" | "medium" | "low", "reason": "<why>" }
|
|
1544
|
+
],
|
|
1545
|
+
"actions": [
|
|
1546
|
+
{
|
|
1547
|
+
"type": "log_interaction" | "update_deal" | "alert" | "schedule_meeting",
|
|
1548
|
+
"payload": { /* tool-specific fields */ },
|
|
1549
|
+
"confidence": 0.0-1.0,
|
|
1550
|
+
"reasoning": "<why this action>"
|
|
1551
|
+
}
|
|
1552
|
+
]
|
|
1553
|
+
}
|
|
1554
|
+
|
|
1555
|
+
Payload schema per type:
|
|
1556
|
+
- log_interaction: { slug, type: "Note"|"Call"|"Meeting", summary, with }
|
|
1557
|
+
- update_deal: { slug, dealName, stage?, probability?, closeDate?, notes? }
|
|
1558
|
+
- alert: { slug, message, urgency: "critical"|"high"|"medium" }
|
|
1559
|
+
- schedule_meeting: { slug, with, notes }`;
|
|
1560
|
+
}
|
|
1561
|
+
function parseLlmResponse(response) {
|
|
1562
|
+
try {
|
|
1563
|
+
const cleaned = response.replace(/^```(?:json)?\n?/m, "").replace(/\n?```$/m, "").trim();
|
|
1564
|
+
const parsed = JSON.parse(cleaned);
|
|
1565
|
+
if (!parsed.assessment || !parsed.riskLevel || !Array.isArray(parsed.plan)) return null;
|
|
1566
|
+
return {
|
|
1567
|
+
assessment: String(parsed.assessment),
|
|
1568
|
+
riskLevel: parsed.riskLevel,
|
|
1569
|
+
plan: Array.isArray(parsed.plan) ? parsed.plan : [],
|
|
1570
|
+
actions: Array.isArray(parsed.actions) ? parsed.actions : []
|
|
1571
|
+
};
|
|
1572
|
+
} catch {
|
|
1573
|
+
return null;
|
|
1574
|
+
}
|
|
1575
|
+
}
|
|
1576
|
+
function buildRuleBasedAnalysis(obs, config) {
|
|
1577
|
+
const plan = [];
|
|
1578
|
+
const actions = [];
|
|
1579
|
+
let riskLevel = "low";
|
|
1580
|
+
if (obs.dealHealthScore.grade === "F" || obs.coldContacts.length > 0) riskLevel = "critical";
|
|
1581
|
+
else if (obs.dealHealthScore.grade === "D" || obs.atRiskContacts.length > 0) riskLevel = "high";
|
|
1582
|
+
else if (obs.dealHealthScore.grade === "C") riskLevel = "medium";
|
|
1583
|
+
let step = 1;
|
|
1584
|
+
if (obs.matchingPlaybooks && obs.matchingPlaybooks.length > 0) for (const match of obs.matchingPlaybooks.slice(0, 2)) {
|
|
1585
|
+
plan.push({
|
|
1586
|
+
step: step++,
|
|
1587
|
+
action: `Apply playbook: "${match.playbook.name}"`,
|
|
1588
|
+
priority: "high",
|
|
1589
|
+
reason: `Proven tactic (${Math.round(match.playbook.frontmatter.successRate * 100)}% success, used ${match.playbook.frontmatter.usedCount}x) — trigger: ${match.playbook.frontmatter.trigger}`
|
|
1590
|
+
});
|
|
1591
|
+
actions.push({
|
|
1592
|
+
type: "alert",
|
|
1593
|
+
payload: {
|
|
1594
|
+
slug: config.slug,
|
|
1595
|
+
message: `Playbook available: "${match.playbook.name}" (${Math.round(match.playbook.frontmatter.successRate * 100)}% success rate)`,
|
|
1596
|
+
playbookContent: match.playbook.content.slice(0, 1e3),
|
|
1597
|
+
urgency: "high"
|
|
1598
|
+
},
|
|
1599
|
+
confidence: match.playbook.frontmatter.successRate,
|
|
1600
|
+
reasoning: `Trigger matched: ${match.playbook.frontmatter.trigger}`
|
|
1601
|
+
});
|
|
1602
|
+
}
|
|
1603
|
+
if (obs.coldContacts.length > 0) {
|
|
1604
|
+
plan.push({
|
|
1605
|
+
step: step++,
|
|
1606
|
+
action: `Re-engage cold contacts: ${obs.coldContacts.join(", ")}`,
|
|
1607
|
+
priority: "urgent",
|
|
1608
|
+
reason: "No contact in 30+ days"
|
|
1609
|
+
});
|
|
1610
|
+
actions.push({
|
|
1611
|
+
type: "alert",
|
|
1612
|
+
payload: {
|
|
1613
|
+
slug: config.slug,
|
|
1614
|
+
message: `Cold contacts: ${obs.coldContacts.join(", ")}`,
|
|
1615
|
+
urgency: "critical"
|
|
1616
|
+
},
|
|
1617
|
+
confidence: .95,
|
|
1618
|
+
reasoning: "No contact in 30+ days"
|
|
1619
|
+
});
|
|
1620
|
+
}
|
|
1621
|
+
if (obs.atRiskContacts.length > 0) {
|
|
1622
|
+
plan.push({
|
|
1623
|
+
step: step++,
|
|
1624
|
+
action: `Schedule call with at-risk contacts`,
|
|
1625
|
+
priority: "high",
|
|
1626
|
+
reason: "14+ days without contact"
|
|
1627
|
+
});
|
|
1628
|
+
actions.push({
|
|
1629
|
+
type: "schedule_meeting",
|
|
1630
|
+
payload: {
|
|
1631
|
+
slug: config.slug,
|
|
1632
|
+
with: obs.atRiskContacts[0] ?? "",
|
|
1633
|
+
notes: "Scheduled by deal agent — relationship at risk"
|
|
1634
|
+
},
|
|
1635
|
+
confidence: .8,
|
|
1636
|
+
reasoning: "At-risk contact identified"
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
if (obs.missingRoles.some((r) => r.role === "economic_buyer")) plan.push({
|
|
1640
|
+
step: step++,
|
|
1641
|
+
action: "Identify economic buyer",
|
|
1642
|
+
priority: "high",
|
|
1643
|
+
reason: "No budget owner identified"
|
|
1644
|
+
});
|
|
1645
|
+
if (obs.daysToClose !== void 0 && obs.daysToClose < 14 && obs.dealHealthScore.grade !== "A") {
|
|
1646
|
+
plan.push({
|
|
1647
|
+
step: step++,
|
|
1648
|
+
action: "Update deal close date or probability",
|
|
1649
|
+
priority: "urgent",
|
|
1650
|
+
reason: `Close date in ${obs.daysToClose} days, deal at grade ${obs.dealHealthScore.grade}`
|
|
1651
|
+
});
|
|
1652
|
+
actions.push({
|
|
1653
|
+
type: "update_deal",
|
|
1654
|
+
payload: {
|
|
1655
|
+
slug: config.slug,
|
|
1656
|
+
dealName: config.dealName,
|
|
1657
|
+
notes: `Reviewed by deal agent — ${obs.daysToClose}d to close`
|
|
1658
|
+
},
|
|
1659
|
+
confidence: .75,
|
|
1660
|
+
reasoning: "Close date imminent"
|
|
1661
|
+
});
|
|
1662
|
+
}
|
|
1663
|
+
if (plan.length === 0) plan.push({
|
|
1664
|
+
step: 1,
|
|
1665
|
+
action: "Maintain current cadence",
|
|
1666
|
+
priority: "low",
|
|
1667
|
+
reason: "Deal healthy"
|
|
1668
|
+
});
|
|
1669
|
+
return {
|
|
1670
|
+
assessment: `Deal "${config.dealName}" in stage "${obs.deal.stage}" — health grade ${obs.dealHealthScore.grade} (${obs.dealHealthScore.score}/100). Risk: ${riskLevel}.`,
|
|
1671
|
+
riskLevel,
|
|
1672
|
+
plan,
|
|
1673
|
+
actions
|
|
1674
|
+
};
|
|
1675
|
+
}
|
|
1676
|
+
function selectActions(analysis, obs, config) {
|
|
1677
|
+
return analysis.actions.map((a) => {
|
|
1678
|
+
const dealValue = obs.deal.value ?? 0;
|
|
1679
|
+
const autoExecutable = config.autonomyLevel === "act" && a.confidence >= .7 && dealValue < config.valueThreshold;
|
|
1680
|
+
return {
|
|
1681
|
+
actionId: makeActionId(),
|
|
1682
|
+
type: a.type,
|
|
1683
|
+
payload: a.payload,
|
|
1684
|
+
confidence: a.confidence,
|
|
1685
|
+
reasoning: a.reasoning,
|
|
1686
|
+
requiresHumanApproval: !autoExecutable,
|
|
1687
|
+
status: "pending",
|
|
1688
|
+
createdAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1689
|
+
};
|
|
1690
|
+
});
|
|
1691
|
+
}
|
|
1692
|
+
const VALID_STAGES = [
|
|
1693
|
+
"lead",
|
|
1694
|
+
"qualified",
|
|
1695
|
+
"proposal",
|
|
1696
|
+
"negotiation",
|
|
1697
|
+
"won",
|
|
1698
|
+
"lost"
|
|
1699
|
+
];
|
|
1700
|
+
async function executeAction(action, dataDir) {
|
|
1701
|
+
const slug = action.payload["slug"];
|
|
1702
|
+
if (!slug) return "skipped";
|
|
1703
|
+
switch (action.type) {
|
|
1704
|
+
case "log_interaction": {
|
|
1705
|
+
const { appendInteraction } = await import("./interactions-writer-dSPy1XfO.js");
|
|
1706
|
+
await appendInteraction(dataDir, slug, {
|
|
1707
|
+
date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
1708
|
+
type: action.payload["type"] ?? "Note",
|
|
1709
|
+
with: String(action.payload["with"] ?? "agent"),
|
|
1710
|
+
summary: String(action.payload["summary"] ?? ""),
|
|
1711
|
+
nextSteps: [],
|
|
1712
|
+
sourceRef: `agent://deal-agent/${action.actionId}`,
|
|
1713
|
+
synced: (/* @__PURE__ */ new Date()).toISOString()
|
|
1714
|
+
});
|
|
1715
|
+
return "executed";
|
|
1716
|
+
}
|
|
1717
|
+
case "schedule_meeting": {
|
|
1718
|
+
const { appendInteraction } = await import("./interactions-writer-dSPy1XfO.js");
|
|
1719
|
+
await appendInteraction(dataDir, slug, {
|
|
1720
|
+
date: (/* @__PURE__ */ new Date()).toISOString().slice(0, 10),
|
|
1721
|
+
type: "Note",
|
|
1722
|
+
with: String(action.payload["with"] ?? ""),
|
|
1723
|
+
summary: `[Agent scheduled] ${String(action.payload["notes"] ?? "Meeting scheduled by deal agent")}`,
|
|
1724
|
+
nextSteps: [`Schedule meeting with ${String(action.payload["with"] ?? "contact")}`],
|
|
1725
|
+
sourceRef: `agent://deal-agent/${action.actionId}`,
|
|
1726
|
+
synced: (/* @__PURE__ */ new Date()).toISOString()
|
|
1727
|
+
});
|
|
1728
|
+
return "executed";
|
|
1729
|
+
}
|
|
1730
|
+
case "update_deal": {
|
|
1731
|
+
const { handleUpdateDeal } = await import("./update-deal-BNwPGaTV.js");
|
|
1732
|
+
const payload = action.payload;
|
|
1733
|
+
const validStage = VALID_STAGES.find((s) => s === payload.stage);
|
|
1734
|
+
await handleUpdateDeal({
|
|
1735
|
+
slug: payload.slug,
|
|
1736
|
+
dealName: payload.dealName,
|
|
1737
|
+
...validStage !== void 0 ? { stage: validStage } : {},
|
|
1738
|
+
...payload.value !== void 0 ? { value: payload.value } : {},
|
|
1739
|
+
...payload.probability !== void 0 ? { probability: payload.probability } : {},
|
|
1740
|
+
...payload.closeDate !== void 0 ? { closeDate: payload.closeDate } : {},
|
|
1741
|
+
...payload.notes !== void 0 ? { notes: payload.notes } : {}
|
|
1742
|
+
}, dataDir);
|
|
1743
|
+
return "executed";
|
|
1744
|
+
}
|
|
1745
|
+
case "alert": {
|
|
1746
|
+
const queue = readAgentQueue(dataDir, slug);
|
|
1747
|
+
const alertAction = {
|
|
1748
|
+
...action,
|
|
1749
|
+
status: "pending"
|
|
1750
|
+
};
|
|
1751
|
+
if (!queue.pendingActions.find((a) => a.actionId === action.actionId)) {
|
|
1752
|
+
queue.pendingActions.push(alertAction);
|
|
1753
|
+
writeAgentQueue(dataDir, slug, queue);
|
|
1754
|
+
}
|
|
1755
|
+
return "executed";
|
|
1756
|
+
}
|
|
1757
|
+
}
|
|
1758
|
+
}
|
|
1759
|
+
async function runDealAgent(config, dataDir, llmFn = callLlm) {
|
|
1760
|
+
const timestamp = (/* @__PURE__ */ new Date()).toISOString();
|
|
1761
|
+
const obs = await observeDeal(dataDir, config.slug, config.dealName, config.today);
|
|
1762
|
+
if (!obs) throw new Error(`Deal "${config.dealName}" not found for customer "${config.slug}"`);
|
|
1763
|
+
let analysis;
|
|
1764
|
+
try {
|
|
1765
|
+
analysis = parseLlmResponse(await llmFn(buildLlmPrompt(obs, config))) ?? buildRuleBasedAnalysis(obs, config);
|
|
1766
|
+
} catch {
|
|
1767
|
+
analysis = buildRuleBasedAnalysis(obs, config);
|
|
1768
|
+
}
|
|
1769
|
+
const allActions = selectActions(analysis, obs, config);
|
|
1770
|
+
const actionsQueued = [];
|
|
1771
|
+
const actionsExecuted = [];
|
|
1772
|
+
if (config.autonomyLevel === "observe") {} else if (config.autonomyLevel === "suggest") {
|
|
1773
|
+
if (allActions.length > 0) {
|
|
1774
|
+
const queue = readAgentQueue(dataDir, config.slug);
|
|
1775
|
+
for (const action of allActions) {
|
|
1776
|
+
queue.pendingActions.push({
|
|
1777
|
+
...action,
|
|
1778
|
+
requiresHumanApproval: true
|
|
1779
|
+
});
|
|
1780
|
+
actionsQueued.push(action);
|
|
1781
|
+
}
|
|
1782
|
+
writeAgentQueue(dataDir, config.slug, queue);
|
|
1783
|
+
}
|
|
1784
|
+
} else {
|
|
1785
|
+
const queue = readAgentQueue(dataDir, config.slug);
|
|
1786
|
+
let queueDirty = false;
|
|
1787
|
+
for (const action of allActions) if (!action.requiresHumanApproval) {
|
|
1788
|
+
const outcome = await executeAction(action, dataDir).catch(() => "skipped");
|
|
1789
|
+
actionsExecuted.push({
|
|
1790
|
+
...action,
|
|
1791
|
+
status: outcome === "executed" ? "executed" : "skipped"
|
|
1792
|
+
});
|
|
1793
|
+
} else {
|
|
1794
|
+
queue.pendingActions.push(action);
|
|
1795
|
+
actionsQueued.push(action);
|
|
1796
|
+
queueDirty = true;
|
|
1797
|
+
}
|
|
1798
|
+
if (queueDirty) writeAgentQueue(dataDir, config.slug, queue);
|
|
1799
|
+
}
|
|
1800
|
+
const trace = {
|
|
1801
|
+
timestamp,
|
|
1802
|
+
slug: config.slug,
|
|
1803
|
+
dealName: config.dealName,
|
|
1804
|
+
autonomyLevel: config.autonomyLevel,
|
|
1805
|
+
observation: obs.contextSummary,
|
|
1806
|
+
plan: analysis.plan.map((s) => `${s.step}. ${s.action} [${s.priority}]`),
|
|
1807
|
+
actionsConsidered: allActions,
|
|
1808
|
+
actionTaken: actionsExecuted[0] ?? null,
|
|
1809
|
+
outcome: actionsExecuted.length > 0 ? "executed" : actionsQueued.length > 0 ? "queued" : "observed"
|
|
1810
|
+
};
|
|
1811
|
+
return {
|
|
1812
|
+
slug: config.slug,
|
|
1813
|
+
dealName: config.dealName,
|
|
1814
|
+
assessment: analysis.assessment,
|
|
1815
|
+
riskLevel: analysis.riskLevel,
|
|
1816
|
+
plan: analysis.plan,
|
|
1817
|
+
actionsQueued,
|
|
1818
|
+
actionsExecuted,
|
|
1819
|
+
trace
|
|
1820
|
+
};
|
|
1821
|
+
}
|
|
1822
|
+
//#endregion
|
|
1823
|
+
//#region src/mcp/tools/run-deal-agent.ts
|
|
1824
|
+
const DATA_DIR$37 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1825
|
+
async function handleRunDealAgent(input, dataDir = DATA_DIR$37) {
|
|
1826
|
+
try {
|
|
1827
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1828
|
+
const result = await runDealAgent({
|
|
1829
|
+
slug: input.slug,
|
|
1830
|
+
dealName: input.dealName,
|
|
1831
|
+
autonomyLevel: input.autonomyLevel ?? "suggest",
|
|
1832
|
+
valueThreshold: input.valueThreshold ?? 5e4,
|
|
1833
|
+
today,
|
|
1834
|
+
...input.instruction !== void 0 ? { instruction: input.instruction } : {}
|
|
1835
|
+
}, dataDir);
|
|
1836
|
+
return { content: [{
|
|
1837
|
+
type: "text",
|
|
1838
|
+
text: JSON.stringify(result, null, 2)
|
|
1839
|
+
}] };
|
|
1840
|
+
} catch (err) {
|
|
1841
|
+
return { content: [{
|
|
1842
|
+
type: "text",
|
|
1843
|
+
text: JSON.stringify({
|
|
1844
|
+
success: false,
|
|
1845
|
+
error: err.message
|
|
1846
|
+
}, null, 2)
|
|
1847
|
+
}] };
|
|
1848
|
+
}
|
|
1849
|
+
}
|
|
1850
|
+
function registerRunDealAgent(server) {
|
|
1851
|
+
server.registerTool("run_deal_agent", {
|
|
1852
|
+
title: "Run Deal Agent",
|
|
1853
|
+
description: `Analyzes a specific deal and generates a prioritized action plan.
|
|
1854
|
+
|
|
1855
|
+
Three autonomy levels:
|
|
1856
|
+
- observe: analyze and return plan, no side effects
|
|
1857
|
+
- suggest (default): queue actions for human review in agent-queue.json
|
|
1858
|
+
- act: auto-execute actions with confidence ≥ 0.7 and value < valueThreshold
|
|
1859
|
+
|
|
1860
|
+
Each action includes confidence score and reasoning (glass-box).
|
|
1861
|
+
Returns full trace for inspection.
|
|
1862
|
+
|
|
1863
|
+
Args:
|
|
1864
|
+
slug: Customer slug
|
|
1865
|
+
dealName: Exact deal name
|
|
1866
|
+
autonomyLevel: "observe" | "suggest" | "act" (default: "suggest")
|
|
1867
|
+
instruction: Optional context/question for the agent
|
|
1868
|
+
valueThreshold: EUR value above which no auto-execution (default: 50000)
|
|
1869
|
+
|
|
1870
|
+
Returns: { assessment, riskLevel, plan[], actionsQueued[], actionsExecuted[], trace }`,
|
|
1871
|
+
inputSchema: z.object({
|
|
1872
|
+
slug: z.string().describe("Customer slug"),
|
|
1873
|
+
dealName: z.string().describe("Exact deal name"),
|
|
1874
|
+
autonomyLevel: z.enum([
|
|
1875
|
+
"observe",
|
|
1876
|
+
"suggest",
|
|
1877
|
+
"act"
|
|
1878
|
+
]).optional().describe("Autonomy level (default: suggest)"),
|
|
1879
|
+
instruction: z.string().optional().describe("Optional instruction for the agent"),
|
|
1880
|
+
valueThreshold: z.number().optional().describe("EUR value above which no auto-execution (default: 50000)")
|
|
1881
|
+
})
|
|
1882
|
+
}, async ({ slug, dealName, autonomyLevel, instruction, valueThreshold }) => handleRunDealAgent({
|
|
1883
|
+
slug,
|
|
1884
|
+
dealName,
|
|
1885
|
+
...autonomyLevel !== void 0 ? { autonomyLevel } : {},
|
|
1886
|
+
...instruction !== void 0 ? { instruction } : {},
|
|
1887
|
+
...valueThreshold !== void 0 ? { valueThreshold } : {}
|
|
1888
|
+
}));
|
|
1889
|
+
}
|
|
1890
|
+
//#endregion
|
|
1891
|
+
//#region src/mcp/tools/approve-agent-action.ts
|
|
1892
|
+
const DATA_DIR$36 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1893
|
+
async function handleApproveAgentAction(input, dataDir = DATA_DIR$36) {
|
|
1894
|
+
try {
|
|
1895
|
+
const queue = readAgentQueue(dataDir, input.slug);
|
|
1896
|
+
const idx = queue.pendingActions.findIndex((a) => a.actionId === input.actionId);
|
|
1897
|
+
if (idx === -1) return { content: [{
|
|
1898
|
+
type: "text",
|
|
1899
|
+
text: JSON.stringify({
|
|
1900
|
+
success: false,
|
|
1901
|
+
error: `Action ${input.actionId} not found in queue`
|
|
1902
|
+
}, null, 2)
|
|
1903
|
+
}] };
|
|
1904
|
+
const action = queue.pendingActions[idx];
|
|
1905
|
+
if (!input.approved) {
|
|
1906
|
+
queue.pendingActions[idx] = {
|
|
1907
|
+
...action,
|
|
1908
|
+
status: "rejected"
|
|
1909
|
+
};
|
|
1910
|
+
writeAgentQueue(dataDir, input.slug, queue);
|
|
1911
|
+
return { content: [{
|
|
1912
|
+
type: "text",
|
|
1913
|
+
text: JSON.stringify({
|
|
1914
|
+
success: true,
|
|
1915
|
+
actionId: input.actionId,
|
|
1916
|
+
status: "rejected"
|
|
1917
|
+
}, null, 2)
|
|
1918
|
+
}] };
|
|
1919
|
+
}
|
|
1920
|
+
const outcome = await executeAction(action, dataDir);
|
|
1921
|
+
queue.pendingActions[idx] = {
|
|
1922
|
+
...action,
|
|
1923
|
+
status: outcome === "executed" ? "executed" : "skipped"
|
|
1924
|
+
};
|
|
1925
|
+
writeAgentQueue(dataDir, input.slug, queue);
|
|
1926
|
+
return { content: [{
|
|
1927
|
+
type: "text",
|
|
1928
|
+
text: JSON.stringify({
|
|
1929
|
+
success: true,
|
|
1930
|
+
actionId: input.actionId,
|
|
1931
|
+
status: queue.pendingActions[idx].status
|
|
1932
|
+
}, null, 2)
|
|
1933
|
+
}] };
|
|
1934
|
+
} catch (err) {
|
|
1935
|
+
return { content: [{
|
|
1936
|
+
type: "text",
|
|
1937
|
+
text: JSON.stringify({
|
|
1938
|
+
success: false,
|
|
1939
|
+
error: err.message
|
|
1940
|
+
}, null, 2)
|
|
1941
|
+
}] };
|
|
1942
|
+
}
|
|
1943
|
+
}
|
|
1944
|
+
function registerApproveAgentAction(server) {
|
|
1945
|
+
server.registerTool("approve_agent_action", {
|
|
1946
|
+
title: "Approve Agent Action",
|
|
1947
|
+
description: `Approve or reject a pending action from the deal agent queue.
|
|
1948
|
+
|
|
1949
|
+
Find actionId in the actionsQueued array returned by run_deal_agent.
|
|
1950
|
+
|
|
1951
|
+
Args:
|
|
1952
|
+
slug: Customer slug
|
|
1953
|
+
actionId: Action ID from the agent queue
|
|
1954
|
+
approved: true to execute, false to reject
|
|
1955
|
+
|
|
1956
|
+
Returns: { success, actionId, status }`,
|
|
1957
|
+
inputSchema: z.object({
|
|
1958
|
+
slug: z.string(),
|
|
1959
|
+
actionId: z.string(),
|
|
1960
|
+
approved: z.boolean()
|
|
1961
|
+
})
|
|
1962
|
+
}, async ({ slug, actionId, approved }) => handleApproveAgentAction({
|
|
1963
|
+
slug,
|
|
1964
|
+
actionId,
|
|
1965
|
+
approved
|
|
1966
|
+
}));
|
|
1967
|
+
}
|
|
1968
|
+
//#endregion
|
|
1969
|
+
//#region src/mcp/tools/simulate-revenue.ts
|
|
1970
|
+
const DATA_DIR$35 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
1971
|
+
async function handleSimulateRevenue(input, dataDir = DATA_DIR$35) {
|
|
1972
|
+
try {
|
|
1973
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
1974
|
+
const horizon = input.horizon ?? "quarter";
|
|
1975
|
+
const simInput = await buildSimulationInput(dataDir, horizon, today);
|
|
1976
|
+
if (input.iterations !== void 0) simInput.iterations = input.iterations;
|
|
1977
|
+
const result = runSimulation(simInput);
|
|
1978
|
+
const confidence = buildConfidenceMessage(result, simInput.deals.length);
|
|
1979
|
+
return { content: [{
|
|
1980
|
+
type: "text",
|
|
1981
|
+
text: JSON.stringify({
|
|
1982
|
+
forecast: result,
|
|
1983
|
+
confidence,
|
|
1984
|
+
dealCount: simInput.deals.length,
|
|
1985
|
+
horizon,
|
|
1986
|
+
simulatedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
1987
|
+
}, null, 2)
|
|
1988
|
+
}] };
|
|
1989
|
+
} catch (err) {
|
|
1990
|
+
return { content: [{
|
|
1991
|
+
type: "text",
|
|
1992
|
+
text: JSON.stringify({
|
|
1993
|
+
success: false,
|
|
1994
|
+
error: err.message
|
|
1995
|
+
}, null, 2)
|
|
1996
|
+
}] };
|
|
1997
|
+
}
|
|
1998
|
+
}
|
|
1999
|
+
function registerSimulateRevenue(server) {
|
|
2000
|
+
server.registerTool("simulate_revenue", {
|
|
2001
|
+
title: "Simulate Revenue",
|
|
2002
|
+
description: `Monte Carlo pipeline revenue simulation with P10/P50/P90 confidence intervals.
|
|
2003
|
+
|
|
2004
|
+
Adjusts deal win probabilities using relationship health scores (D12) and
|
|
2005
|
+
champion presence (D11). Returns the range of possible quarterly/annual outcomes.
|
|
2006
|
+
|
|
2007
|
+
Use this instead of (or alongside) get_pipeline_forecast when you need:
|
|
2008
|
+
- Uncertainty quantification (not just expected value)
|
|
2009
|
+
- At-risk revenue identification
|
|
2010
|
+
- Deal sensitivity analysis ("which deal matters most")
|
|
2011
|
+
- Month-by-month close distribution
|
|
2012
|
+
|
|
2013
|
+
Args:
|
|
2014
|
+
horizon: "quarter" (default) | "year"
|
|
2015
|
+
iterations: simulation iterations (default: 10000)
|
|
2016
|
+
|
|
2017
|
+
Returns: { forecast: { p10, p50, p90, expected, stdDev, atRiskRevenue, byCloseMonth, topRisks, sensitivityMap }, confidence, dealCount, horizon }`,
|
|
2018
|
+
inputSchema: z.object({
|
|
2019
|
+
horizon: z.enum(["quarter", "year"]).optional().describe("Forecast horizon (default: \"quarter\")"),
|
|
2020
|
+
iterations: z.number().optional().describe("Monte Carlo iterations (default: 10000)")
|
|
2021
|
+
})
|
|
2022
|
+
}, async ({ horizon, iterations }) => handleSimulateRevenue({
|
|
2023
|
+
...horizon !== void 0 ? { horizon } : {},
|
|
2024
|
+
...iterations !== void 0 ? { iterations } : {}
|
|
2025
|
+
}));
|
|
2026
|
+
}
|
|
2027
|
+
//#endregion
|
|
2028
|
+
//#region src/mcp/tools/get-playbook.ts
|
|
2029
|
+
const DATA_DIR$34 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2030
|
+
async function handleGetPlaybook(input, dataDir = DATA_DIR$34) {
|
|
2031
|
+
try {
|
|
2032
|
+
const playbooks = listPlaybooks(dataDir, input.slug);
|
|
2033
|
+
if (!(input.stage !== void 0 || input.value !== void 0 || input.healthScore !== void 0)) return { content: [{
|
|
2034
|
+
type: "text",
|
|
2035
|
+
text: JSON.stringify({
|
|
2036
|
+
matches: playbooks.map((pb) => ({
|
|
2037
|
+
name: pb.name,
|
|
2038
|
+
trigger: pb.frontmatter.trigger,
|
|
2039
|
+
successRate: pb.frontmatter.successRate,
|
|
2040
|
+
usedCount: pb.frontmatter.usedCount,
|
|
2041
|
+
lastUpdated: pb.frontmatter.lastUpdated,
|
|
2042
|
+
content: pb.content
|
|
2043
|
+
})),
|
|
2044
|
+
totalPlaybooks: playbooks.length,
|
|
2045
|
+
slug: input.slug
|
|
2046
|
+
}, null, 2)
|
|
2047
|
+
}] };
|
|
2048
|
+
const matches = matchPlaybooks(playbooks, {
|
|
2049
|
+
slug: input.slug,
|
|
2050
|
+
name: "",
|
|
2051
|
+
stage: input.stage ?? "lead",
|
|
2052
|
+
value: input.value ?? 0,
|
|
2053
|
+
probability: 50,
|
|
2054
|
+
healthScore: input.healthScore ?? 60,
|
|
2055
|
+
daysSinceContact: input.daysSinceContact ?? 0,
|
|
2056
|
+
championPresent: input.championPresent ?? false
|
|
2057
|
+
}, input.daysSinceContact ?? 0);
|
|
2058
|
+
return { content: [{
|
|
2059
|
+
type: "text",
|
|
2060
|
+
text: JSON.stringify({
|
|
2061
|
+
matches: matches.map((m) => ({
|
|
2062
|
+
name: m.playbook.name,
|
|
2063
|
+
score: m.score,
|
|
2064
|
+
matchedConditions: m.matchedConditions,
|
|
2065
|
+
trigger: m.playbook.frontmatter.trigger,
|
|
2066
|
+
successRate: m.playbook.frontmatter.successRate,
|
|
2067
|
+
usedCount: m.playbook.frontmatter.usedCount,
|
|
2068
|
+
content: m.playbook.content
|
|
2069
|
+
})),
|
|
2070
|
+
totalPlaybooks: playbooks.length,
|
|
2071
|
+
slug: input.slug
|
|
2072
|
+
}, null, 2)
|
|
2073
|
+
}] };
|
|
2074
|
+
} catch (err) {
|
|
2075
|
+
return { content: [{
|
|
2076
|
+
type: "text",
|
|
2077
|
+
text: JSON.stringify({
|
|
2078
|
+
success: false,
|
|
2079
|
+
error: err.message
|
|
2080
|
+
}, null, 2)
|
|
2081
|
+
}] };
|
|
2082
|
+
}
|
|
2083
|
+
}
|
|
2084
|
+
function registerGetPlaybook(server) {
|
|
2085
|
+
server.registerTool("get_playbook", {
|
|
2086
|
+
title: "Get Playbook",
|
|
2087
|
+
description: `Retrieve playbooks for a customer. With deal context, returns only matching playbooks sorted by success rate. Without deal context, returns all playbooks.
|
|
2088
|
+
|
|
2089
|
+
Use after run_deal_agent or before a sales call to get proven guidance for the current situation.
|
|
2090
|
+
|
|
2091
|
+
Args:
|
|
2092
|
+
slug: Customer ID (required)
|
|
2093
|
+
stage: Deal stage (optional — enables trigger matching)
|
|
2094
|
+
value: Deal value in euros (optional)
|
|
2095
|
+
healthScore: Relationship health score 0–100 (optional)
|
|
2096
|
+
daysSinceContact: Days since last contact / days_stalled proxy (optional)
|
|
2097
|
+
championPresent: Whether a champion is identified (optional)
|
|
2098
|
+
|
|
2099
|
+
Returns: { matches: [{ name, score, trigger, successRate, usedCount, content }], totalPlaybooks, slug }`,
|
|
2100
|
+
inputSchema: z.object({
|
|
2101
|
+
slug: z.string().describe("Customer ID"),
|
|
2102
|
+
stage: z.string().optional().describe("Deal stage"),
|
|
2103
|
+
value: z.number().optional().describe("Deal value in euros"),
|
|
2104
|
+
healthScore: z.number().optional().describe("Health score 0–100"),
|
|
2105
|
+
daysSinceContact: z.number().optional().describe("Days since last contact"),
|
|
2106
|
+
championPresent: z.boolean().optional().describe("Champion identified")
|
|
2107
|
+
})
|
|
2108
|
+
}, async ({ slug, stage, value, healthScore, daysSinceContact, championPresent }) => handleGetPlaybook({
|
|
2109
|
+
slug,
|
|
2110
|
+
...stage !== void 0 ? { stage } : {},
|
|
2111
|
+
...value !== void 0 ? { value } : {},
|
|
2112
|
+
...healthScore !== void 0 ? { healthScore } : {},
|
|
2113
|
+
...daysSinceContact !== void 0 ? { daysSinceContact } : {},
|
|
2114
|
+
...championPresent !== void 0 ? { championPresent } : {}
|
|
2115
|
+
}, DATA_DIR$34));
|
|
2116
|
+
}
|
|
2117
|
+
//#endregion
|
|
2118
|
+
//#region src/mcp/tools/create-playbook.ts
|
|
2119
|
+
const DATA_DIR$33 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2120
|
+
async function handleCreatePlaybook(input, dataDir = DATA_DIR$33) {
|
|
2121
|
+
try {
|
|
2122
|
+
const name = toKebabCase(input.name);
|
|
2123
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2124
|
+
const filePath = path.join(playbooksDir(dataDir, input.slug), `${name}.md`);
|
|
2125
|
+
const playbook = {
|
|
2126
|
+
slug: input.slug,
|
|
2127
|
+
name,
|
|
2128
|
+
frontmatter: {
|
|
2129
|
+
trigger: input.trigger,
|
|
2130
|
+
successRate: input.successRate ?? .5,
|
|
2131
|
+
usedCount: 0,
|
|
2132
|
+
lastUpdated: today
|
|
2133
|
+
},
|
|
2134
|
+
content: input.content,
|
|
2135
|
+
path: filePath
|
|
2136
|
+
};
|
|
2137
|
+
await writePlaybook(dataDir, input.slug, playbook);
|
|
2138
|
+
return { content: [{
|
|
2139
|
+
type: "text",
|
|
2140
|
+
text: JSON.stringify({
|
|
2141
|
+
success: true,
|
|
2142
|
+
playbook: {
|
|
2143
|
+
name,
|
|
2144
|
+
trigger: input.trigger,
|
|
2145
|
+
successRate: playbook.frontmatter.successRate,
|
|
2146
|
+
usedCount: 0,
|
|
2147
|
+
lastUpdated: today,
|
|
2148
|
+
path: filePath
|
|
2149
|
+
}
|
|
2150
|
+
}, null, 2)
|
|
2151
|
+
}] };
|
|
2152
|
+
} catch (err) {
|
|
2153
|
+
return { content: [{
|
|
2154
|
+
type: "text",
|
|
2155
|
+
text: JSON.stringify({
|
|
2156
|
+
success: false,
|
|
2157
|
+
error: err.message
|
|
2158
|
+
}, null, 2)
|
|
2159
|
+
}] };
|
|
2160
|
+
}
|
|
2161
|
+
}
|
|
2162
|
+
function registerCreatePlaybook(server) {
|
|
2163
|
+
server.registerTool("create_playbook", {
|
|
2164
|
+
title: "Create Playbook",
|
|
2165
|
+
description: `Create or update a playbook for a customer. Playbooks encode proven tactics for specific deal situations.
|
|
2166
|
+
|
|
2167
|
+
Trigger DSL uses AND-only conditions:
|
|
2168
|
+
deal_stage_<stage> | value > N | value < N | days_stalled > N | health < N | health > N | no_champion | has_champion
|
|
2169
|
+
|
|
2170
|
+
Example: "deal_stage_negotiation AND value > 50000 AND days_stalled > 7"
|
|
2171
|
+
|
|
2172
|
+
Args:
|
|
2173
|
+
slug: Customer ID
|
|
2174
|
+
name: Playbook name (auto-converted to kebab-case)
|
|
2175
|
+
trigger: Trigger DSL string (conditions separated by AND)
|
|
2176
|
+
content: Playbook markdown body (## Situation, ## Steps, ## Warnings, ## Templates)
|
|
2177
|
+
successRate: Historical win rate 0.0–1.0 (default: 0.5)
|
|
2178
|
+
|
|
2179
|
+
Returns: { success: true, playbook: { name, trigger, successRate, path } }`,
|
|
2180
|
+
inputSchema: z.object({
|
|
2181
|
+
slug: z.string().describe("Customer ID"),
|
|
2182
|
+
name: z.string().describe("Playbook name"),
|
|
2183
|
+
trigger: z.string().describe("Trigger DSL string"),
|
|
2184
|
+
content: z.string().describe("Playbook markdown body"),
|
|
2185
|
+
successRate: z.number().min(0).max(1).optional().describe("Historical win rate 0.0–1.0")
|
|
2186
|
+
})
|
|
2187
|
+
}, async ({ slug, name, trigger, content, successRate }) => handleCreatePlaybook({
|
|
2188
|
+
slug,
|
|
2189
|
+
name,
|
|
2190
|
+
trigger,
|
|
2191
|
+
content,
|
|
2192
|
+
...successRate !== void 0 ? { successRate } : {}
|
|
2193
|
+
}, DATA_DIR$33));
|
|
2194
|
+
}
|
|
2195
|
+
//#endregion
|
|
2196
|
+
//#region src/mcp/tools/list-playbooks.ts
|
|
2197
|
+
const DATA_DIR$32 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2198
|
+
async function handleListPlaybooks(input, dataDir = DATA_DIR$32) {
|
|
2199
|
+
try {
|
|
2200
|
+
const playbooks = listPlaybooks(dataDir, input.slug);
|
|
2201
|
+
return { content: [{
|
|
2202
|
+
type: "text",
|
|
2203
|
+
text: JSON.stringify({
|
|
2204
|
+
playbooks: playbooks.map((pb) => ({
|
|
2205
|
+
name: pb.name,
|
|
2206
|
+
trigger: pb.frontmatter.trigger,
|
|
2207
|
+
successRate: pb.frontmatter.successRate,
|
|
2208
|
+
usedCount: pb.frontmatter.usedCount,
|
|
2209
|
+
lastUpdated: pb.frontmatter.lastUpdated
|
|
2210
|
+
})),
|
|
2211
|
+
count: playbooks.length,
|
|
2212
|
+
slug: input.slug
|
|
2213
|
+
}, null, 2)
|
|
2214
|
+
}] };
|
|
2215
|
+
} catch (err) {
|
|
2216
|
+
return { content: [{
|
|
2217
|
+
type: "text",
|
|
2218
|
+
text: JSON.stringify({
|
|
2219
|
+
success: false,
|
|
2220
|
+
error: err.message
|
|
2221
|
+
}, null, 2)
|
|
2222
|
+
}] };
|
|
2223
|
+
}
|
|
2224
|
+
}
|
|
2225
|
+
function registerListPlaybooks(server) {
|
|
2226
|
+
server.registerTool("list_playbooks", {
|
|
2227
|
+
title: "List Playbooks",
|
|
2228
|
+
description: `List all playbooks for a customer (metadata only, no body content).
|
|
2229
|
+
|
|
2230
|
+
Use to discover available playbooks before calling get_playbook with deal context.
|
|
2231
|
+
|
|
2232
|
+
Args:
|
|
2233
|
+
slug: Customer ID
|
|
2234
|
+
|
|
2235
|
+
Returns: { playbooks: [{ name, trigger, successRate, usedCount, lastUpdated }], count, slug }`,
|
|
2236
|
+
inputSchema: z.object({ slug: z.string().describe("Customer ID") })
|
|
2237
|
+
}, async ({ slug }) => handleListPlaybooks({ slug }, DATA_DIR$32));
|
|
2238
|
+
}
|
|
2239
|
+
//#endregion
|
|
2240
|
+
//#region src/mcp/tools/distill-playbook.ts
|
|
2241
|
+
const DATA_DIR$31 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2242
|
+
async function handleDistillPlaybook(input, dataDir = DATA_DIR$31, llmFn = callLlm) {
|
|
2243
|
+
try {
|
|
2244
|
+
const result = await distillPlaybook(dataDir, input.slug, input.dealName, input.outcome, llmFn);
|
|
2245
|
+
if (!result.ok) {
|
|
2246
|
+
const error = result.errorKind === "no_interactions" ? `No interactions.md found for ${input.slug}` : "LLM response could not be parsed as playbook";
|
|
2247
|
+
return { content: [{
|
|
2248
|
+
type: "text",
|
|
2249
|
+
text: JSON.stringify({
|
|
2250
|
+
success: false,
|
|
2251
|
+
error
|
|
2252
|
+
}, null, 2)
|
|
2253
|
+
}] };
|
|
2254
|
+
}
|
|
2255
|
+
return { content: [{
|
|
2256
|
+
type: "text",
|
|
2257
|
+
text: JSON.stringify({
|
|
2258
|
+
success: true,
|
|
2259
|
+
playbook: {
|
|
2260
|
+
name: result.playbook.name,
|
|
2261
|
+
trigger: result.playbook.frontmatter.trigger,
|
|
2262
|
+
successRate: result.playbook.frontmatter.successRate,
|
|
2263
|
+
usedCount: result.playbook.frontmatter.usedCount,
|
|
2264
|
+
path: result.playbook.path
|
|
2265
|
+
},
|
|
2266
|
+
reasoning: result.reasoning
|
|
2267
|
+
}, null, 2)
|
|
2268
|
+
}] };
|
|
2269
|
+
} catch (err) {
|
|
2270
|
+
return { content: [{
|
|
2271
|
+
type: "text",
|
|
2272
|
+
text: JSON.stringify({
|
|
2273
|
+
success: false,
|
|
2274
|
+
error: err.message
|
|
2275
|
+
}, null, 2)
|
|
2276
|
+
}] };
|
|
2277
|
+
}
|
|
2278
|
+
}
|
|
2279
|
+
function registerDistillPlaybook(server) {
|
|
2280
|
+
server.registerTool("distill_playbook", {
|
|
2281
|
+
title: "Distill Playbook",
|
|
2282
|
+
description: `Use LLM to extract a reusable playbook from a won or lost deal's interaction history. Analyzes the customer's interactions.md and identifies the winning/losing pattern as a structured playbook.
|
|
2283
|
+
|
|
2284
|
+
Run after every won or lost deal to build your procedural memory library.
|
|
2285
|
+
|
|
2286
|
+
Args:
|
|
2287
|
+
slug: Customer ID
|
|
2288
|
+
dealName: Name of the deal to analyze
|
|
2289
|
+
outcome: "won" or "lost"
|
|
2290
|
+
|
|
2291
|
+
Returns: { success: true, playbook: { name, trigger, successRate, path }, reasoning }`,
|
|
2292
|
+
inputSchema: z.object({
|
|
2293
|
+
slug: z.string().describe("Customer ID"),
|
|
2294
|
+
dealName: z.string().describe("Deal name to analyze"),
|
|
2295
|
+
outcome: z.enum(["won", "lost"]).describe("Deal outcome")
|
|
2296
|
+
})
|
|
2297
|
+
}, async ({ slug, dealName, outcome }) => handleDistillPlaybook({
|
|
2298
|
+
slug,
|
|
2299
|
+
dealName,
|
|
2300
|
+
outcome
|
|
2301
|
+
}, DATA_DIR$31));
|
|
2302
|
+
}
|
|
2303
|
+
//#endregion
|
|
2304
|
+
//#region src/mcp/tools/pursue-goal.ts
|
|
2305
|
+
const DATA_DIR$30 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2306
|
+
async function handlePursueGoal(input, dataDir = DATA_DIR$30, options = {}) {
|
|
2307
|
+
try {
|
|
2308
|
+
enforceRbac(dataDir, "pursue_goal");
|
|
2309
|
+
const goal = await pursueGoal(dataDir, {
|
|
2310
|
+
description: input.goal,
|
|
2311
|
+
deadline: input.deadline,
|
|
2312
|
+
...input.context ? { context: input.context } : {}
|
|
2313
|
+
}, {
|
|
2314
|
+
actor: getActor(),
|
|
2315
|
+
...options.buildInputFn ? { buildInputFn: options.buildInputFn } : {},
|
|
2316
|
+
...options.llmFn ? { llmFn: options.llmFn } : {}
|
|
2317
|
+
});
|
|
2318
|
+
return { content: [{
|
|
2319
|
+
type: "text",
|
|
2320
|
+
text: JSON.stringify({
|
|
2321
|
+
goalId: goal.id,
|
|
2322
|
+
description: goal.description,
|
|
2323
|
+
target: goal.target,
|
|
2324
|
+
deadline: goal.deadline,
|
|
2325
|
+
type: goal.type,
|
|
2326
|
+
decomposition: {
|
|
2327
|
+
analysis: goal.decomposition.analysis,
|
|
2328
|
+
currentPipeline: goal.decomposition.currentPipeline,
|
|
2329
|
+
gap: goal.decomposition.gap,
|
|
2330
|
+
subGoals: goal.decomposition.subGoals,
|
|
2331
|
+
probabilisticOutcome: goal.decomposition.probabilisticOutcome
|
|
2332
|
+
}
|
|
2333
|
+
}, null, 2)
|
|
2334
|
+
}] };
|
|
2335
|
+
} catch (err) {
|
|
2336
|
+
return { content: [{
|
|
2337
|
+
type: "text",
|
|
2338
|
+
text: JSON.stringify({
|
|
2339
|
+
success: false,
|
|
2340
|
+
error: err.message
|
|
2341
|
+
}, null, 2)
|
|
2342
|
+
}] };
|
|
2343
|
+
}
|
|
2344
|
+
}
|
|
2345
|
+
function registerPursueGoal(server) {
|
|
2346
|
+
server.registerTool("pursue_goal", {
|
|
2347
|
+
title: "Pursue Goal",
|
|
2348
|
+
description: `Set a revenue or pipeline goal and get an AI-decomposed action plan.
|
|
2349
|
+
|
|
2350
|
+
Analyzes current pipeline (P50 forecast) and decomposes the gap into prioritized sub-goals per deal. Persists the goal in .agentic/goals.json for tracking.
|
|
2351
|
+
|
|
2352
|
+
RBAC: manager+
|
|
2353
|
+
|
|
2354
|
+
Args:
|
|
2355
|
+
goal: Natural language goal description (e.g. "Close €500k ARR this quarter")
|
|
2356
|
+
deadline: Target deadline (YYYY-MM-DD)
|
|
2357
|
+
context: Optional constraints (e.g. "Focus on existing pipeline only")
|
|
2358
|
+
|
|
2359
|
+
Returns: { goalId, description, target, deadline, decomposition: { analysis, currentPipeline, gap, subGoals, probabilisticOutcome } }`,
|
|
2360
|
+
inputSchema: z.object({
|
|
2361
|
+
goal: z.string().describe("Natural language goal (e.g. 'Close €500k ARR this quarter')"),
|
|
2362
|
+
deadline: z.string().describe("Target deadline YYYY-MM-DD"),
|
|
2363
|
+
context: z.string().optional().describe("Optional constraints or focus areas")
|
|
2364
|
+
})
|
|
2365
|
+
}, async ({ goal, deadline, context }) => handlePursueGoal({
|
|
2366
|
+
goal,
|
|
2367
|
+
deadline,
|
|
2368
|
+
...context !== void 0 ? { context } : {}
|
|
2369
|
+
}, DATA_DIR$30));
|
|
2370
|
+
}
|
|
2371
|
+
//#endregion
|
|
2372
|
+
//#region src/mcp/tools/get-goal-status.ts
|
|
2373
|
+
const DATA_DIR$29 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2374
|
+
async function handleGetGoalStatus(input, dataDir = DATA_DIR$29) {
|
|
2375
|
+
try {
|
|
2376
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2377
|
+
const allGoals = input.goalId ? readGoals(dataDir).filter((g) => g.id === input.goalId) : getActiveGoals(dataDir);
|
|
2378
|
+
if (input.goalId && allGoals.length === 0) return { content: [{
|
|
2379
|
+
type: "text",
|
|
2380
|
+
text: JSON.stringify({
|
|
2381
|
+
success: false,
|
|
2382
|
+
error: `Goal '${input.goalId}' not found`
|
|
2383
|
+
}, null, 2)
|
|
2384
|
+
}] };
|
|
2385
|
+
const goals = allGoals.map((g) => {
|
|
2386
|
+
const deadlineMs = new Date(g.deadline).getTime();
|
|
2387
|
+
const todayMs = new Date(today).getTime();
|
|
2388
|
+
const daysRemaining = Math.max(0, Math.ceil((deadlineMs - todayMs) / 864e5));
|
|
2389
|
+
return {
|
|
2390
|
+
id: g.id,
|
|
2391
|
+
description: g.description,
|
|
2392
|
+
target: g.target,
|
|
2393
|
+
progress: g.progress,
|
|
2394
|
+
status: g.status,
|
|
2395
|
+
deadline: g.deadline,
|
|
2396
|
+
daysRemaining,
|
|
2397
|
+
subGoals: g.decomposition.subGoals.slice(0, 3),
|
|
2398
|
+
createdAt: g.createdAt
|
|
2399
|
+
};
|
|
2400
|
+
});
|
|
2401
|
+
const active = allGoals.filter((g) => g.status === "active");
|
|
2402
|
+
const completed = allGoals.filter((g) => g.status === "completed");
|
|
2403
|
+
return { content: [{
|
|
2404
|
+
type: "text",
|
|
2405
|
+
text: JSON.stringify({
|
|
2406
|
+
goals,
|
|
2407
|
+
activeCount: active.length,
|
|
2408
|
+
completedCount: completed.length
|
|
2409
|
+
}, null, 2)
|
|
2410
|
+
}] };
|
|
2411
|
+
} catch (err) {
|
|
2412
|
+
return { content: [{
|
|
2413
|
+
type: "text",
|
|
2414
|
+
text: JSON.stringify({
|
|
2415
|
+
success: false,
|
|
2416
|
+
error: err.message
|
|
2417
|
+
}, null, 2)
|
|
2418
|
+
}] };
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
function registerGetGoalStatus(server) {
|
|
2422
|
+
server.registerTool("get_goal_status", {
|
|
2423
|
+
title: "Get Goal Status",
|
|
2424
|
+
description: `Get the status of active goals. Without goalId, returns all active goals. With goalId, returns that specific goal.
|
|
2425
|
+
|
|
2426
|
+
Returns progress, days remaining, and top sub-goals for each goal.
|
|
2427
|
+
|
|
2428
|
+
Args:
|
|
2429
|
+
goalId: (optional) Specific goal ID — if omitted, returns all active goals
|
|
2430
|
+
|
|
2431
|
+
Returns: { goals: [{ id, description, target, progress, status, deadline, daysRemaining, subGoals }], activeCount, completedCount }`,
|
|
2432
|
+
inputSchema: z.object({ goalId: z.string().optional().describe("Specific goal ID (omit for all active goals)") })
|
|
2433
|
+
}, async ({ goalId }) => handleGetGoalStatus({ ...goalId !== void 0 ? { goalId } : {} }, DATA_DIR$29));
|
|
2434
|
+
}
|
|
2435
|
+
//#endregion
|
|
2436
|
+
//#region src/mcp/tools/register-push-subscription.ts
|
|
2437
|
+
const DATA_DIR$28 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2438
|
+
const VALID_PROVIDERS = [
|
|
2439
|
+
"gmail",
|
|
2440
|
+
"microsoft-graph",
|
|
2441
|
+
"slack"
|
|
2442
|
+
];
|
|
2443
|
+
async function handleRegisterPushSubscription(input, dataDir = DATA_DIR$28) {
|
|
2444
|
+
try {
|
|
2445
|
+
if (!VALID_PROVIDERS.includes(input.provider)) return { content: [{
|
|
2446
|
+
type: "text",
|
|
2447
|
+
text: JSON.stringify({
|
|
2448
|
+
success: false,
|
|
2449
|
+
error: `Unknown provider: ${input.provider}`
|
|
2450
|
+
}, null, 2)
|
|
2451
|
+
}] };
|
|
2452
|
+
enforceRbac(dataDir, "register_push_subscription");
|
|
2453
|
+
const providerData = {};
|
|
2454
|
+
if (input.gmailTopicName) providerData["gmailTopicName"] = input.gmailTopicName;
|
|
2455
|
+
if (input.microsoftClientState) providerData["microsoftClientState"] = input.microsoftClientState;
|
|
2456
|
+
if (input.microsoftResource) providerData["microsoftResource"] = input.microsoftResource;
|
|
2457
|
+
if (input.slackTeamId) providerData["slackTeamId"] = input.slackTeamId;
|
|
2458
|
+
if (input.slackChannelId) providerData["slackChannelId"] = input.slackChannelId;
|
|
2459
|
+
const sub = await register(dataDir, input.provider, input.slug, {
|
|
2460
|
+
webhookUrl: input.webhookUrl,
|
|
2461
|
+
providerData
|
|
2462
|
+
});
|
|
2463
|
+
const warning = input.webhookUrl.includes("localhost") ? "Warning: webhookUrl contains 'localhost' — providers cannot reach local endpoints. Use a tunnel (e.g. ngrok http 3847) for development." : void 0;
|
|
2464
|
+
return { content: [{
|
|
2465
|
+
type: "text",
|
|
2466
|
+
text: JSON.stringify({
|
|
2467
|
+
subscriptionId: sub.id,
|
|
2468
|
+
provider: sub.provider,
|
|
2469
|
+
slug: sub.slug,
|
|
2470
|
+
status: sub.status,
|
|
2471
|
+
expiresAt: sub.expiresAt,
|
|
2472
|
+
createdAt: sub.createdAt,
|
|
2473
|
+
...warning ? { warning } : {}
|
|
2474
|
+
}, null, 2)
|
|
2475
|
+
}] };
|
|
2476
|
+
} catch (err) {
|
|
2477
|
+
return { content: [{
|
|
2478
|
+
type: "text",
|
|
2479
|
+
text: JSON.stringify({
|
|
2480
|
+
success: false,
|
|
2481
|
+
error: err.message
|
|
2482
|
+
}, null, 2)
|
|
2483
|
+
}] };
|
|
2484
|
+
}
|
|
2485
|
+
}
|
|
2486
|
+
function registerRegisterPushSubscription(server) {
|
|
2487
|
+
server.registerTool("register_push_subscription", {
|
|
2488
|
+
title: "Register Push Subscription",
|
|
2489
|
+
description: `Register a real-time push subscription for a customer (Gmail Pub/Sub, MS Graph webhook, or Slack Events).
|
|
2490
|
+
|
|
2491
|
+
Instead of polling every 30 minutes, the provider will push new events to dxcrm within seconds.
|
|
2492
|
+
|
|
2493
|
+
RBAC: admin only
|
|
2494
|
+
|
|
2495
|
+
Args:
|
|
2496
|
+
provider: "gmail" | "microsoft-graph" | "slack"
|
|
2497
|
+
slug: Customer slug to receive events for
|
|
2498
|
+
webhookUrl: Public HTTPS URL where the provider will POST events (e.g. https://yourserver.com/webhooks/gmail)
|
|
2499
|
+
gmailTopicName: (Gmail only) Cloud Pub/Sub topic name (e.g. projects/my-project/topics/gmail-push)
|
|
2500
|
+
microsoftClientState: (MS Graph only) Secret for HMAC verification
|
|
2501
|
+
microsoftResource: (MS Graph only) Resource path (e.g. /me/mailFolders/Inbox/messages)
|
|
2502
|
+
slackTeamId: (Slack only) Workspace/team ID (e.g. T12345)
|
|
2503
|
+
slackChannelId: (Slack only) Optional specific channel to monitor
|
|
2504
|
+
|
|
2505
|
+
Returns: { subscriptionId, provider, slug, status, expiresAt, createdAt, warning? }`,
|
|
2506
|
+
inputSchema: z.object({
|
|
2507
|
+
provider: z.enum([
|
|
2508
|
+
"gmail",
|
|
2509
|
+
"microsoft-graph",
|
|
2510
|
+
"slack"
|
|
2511
|
+
]).describe("Push provider"),
|
|
2512
|
+
slug: z.string().describe("Customer slug"),
|
|
2513
|
+
webhookUrl: z.string().describe("Public HTTPS URL for provider callbacks"),
|
|
2514
|
+
gmailTopicName: z.string().optional().describe("Gmail: Cloud Pub/Sub topic name"),
|
|
2515
|
+
microsoftClientState: z.string().optional().describe("MS Graph: secret for verification"),
|
|
2516
|
+
microsoftResource: z.string().optional().describe("MS Graph: resource path"),
|
|
2517
|
+
slackTeamId: z.string().optional().describe("Slack: workspace team ID"),
|
|
2518
|
+
slackChannelId: z.string().optional().describe("Slack: optional channel ID")
|
|
2519
|
+
})
|
|
2520
|
+
}, async ({ provider, slug, webhookUrl, gmailTopicName, microsoftClientState, microsoftResource, slackTeamId, slackChannelId }) => handleRegisterPushSubscription({
|
|
2521
|
+
provider,
|
|
2522
|
+
slug,
|
|
2523
|
+
webhookUrl,
|
|
2524
|
+
...gmailTopicName !== void 0 ? { gmailTopicName } : {},
|
|
2525
|
+
...microsoftClientState !== void 0 ? { microsoftClientState } : {},
|
|
2526
|
+
...microsoftResource !== void 0 ? { microsoftResource } : {},
|
|
2527
|
+
...slackTeamId !== void 0 ? { slackTeamId } : {},
|
|
2528
|
+
...slackChannelId !== void 0 ? { slackChannelId } : {}
|
|
2529
|
+
}, DATA_DIR$28));
|
|
2530
|
+
}
|
|
2531
|
+
//#endregion
|
|
2532
|
+
//#region src/mcp/tools/get-push-status.ts
|
|
2533
|
+
const DATA_DIR$27 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2534
|
+
async function handleGetPushStatus(input, dataDir = DATA_DIR$27) {
|
|
2535
|
+
try {
|
|
2536
|
+
let subs = await readSubscriptions(dataDir);
|
|
2537
|
+
if (input.slug) subs = subs.filter((s) => s.slug === input.slug);
|
|
2538
|
+
if (input.provider) subs = subs.filter((s) => s.provider === input.provider);
|
|
2539
|
+
const now = Date.now();
|
|
2540
|
+
const RENEWAL_THRESHOLD_MS = 1440 * 60 * 1e3;
|
|
2541
|
+
const subscriptions = subs.map((s) => {
|
|
2542
|
+
const expiresInHours = s.expiresAt ? Math.round((new Date(s.expiresAt).getTime() - now) / (3600 * 1e3)) : null;
|
|
2543
|
+
const needsRenewal = s.expiresAt !== null && new Date(s.expiresAt).getTime() - now < RENEWAL_THRESHOLD_MS;
|
|
2544
|
+
return {
|
|
2545
|
+
id: s.id,
|
|
2546
|
+
provider: s.provider,
|
|
2547
|
+
slug: s.slug,
|
|
2548
|
+
status: s.status,
|
|
2549
|
+
expiresAt: s.expiresAt,
|
|
2550
|
+
expiresInHours,
|
|
2551
|
+
needsRenewal,
|
|
2552
|
+
lastEventAt: s.lastEventAt,
|
|
2553
|
+
eventsProcessed: s.eventsProcessed,
|
|
2554
|
+
webhookUrl: s.webhookUrl
|
|
2555
|
+
};
|
|
2556
|
+
});
|
|
2557
|
+
const countByStatus = (status) => subs.filter((s) => s.status === status).length;
|
|
2558
|
+
const summary = {
|
|
2559
|
+
total: subs.length,
|
|
2560
|
+
active: countByStatus("active"),
|
|
2561
|
+
expiringSoon: subscriptions.filter((s) => s.needsRenewal && s.status === "active").length,
|
|
2562
|
+
expired: countByStatus("expired")
|
|
2563
|
+
};
|
|
2564
|
+
return { content: [{
|
|
2565
|
+
type: "text",
|
|
2566
|
+
text: JSON.stringify({
|
|
2567
|
+
subscriptions,
|
|
2568
|
+
summary
|
|
2569
|
+
}, null, 2)
|
|
2570
|
+
}] };
|
|
2571
|
+
} catch (err) {
|
|
2572
|
+
return { content: [{
|
|
2573
|
+
type: "text",
|
|
2574
|
+
text: JSON.stringify({
|
|
2575
|
+
success: false,
|
|
2576
|
+
error: err.message
|
|
2577
|
+
}, null, 2)
|
|
2578
|
+
}] };
|
|
2579
|
+
}
|
|
2580
|
+
}
|
|
2581
|
+
function registerGetPushStatus(server) {
|
|
2582
|
+
server.registerTool("get_push_status", {
|
|
2583
|
+
title: "Get Push Status",
|
|
2584
|
+
description: `Get the status of all active push subscriptions (Gmail Pub/Sub, MS Graph, Slack Events).
|
|
2585
|
+
|
|
2586
|
+
Shows which customers have real-time push enabled, when subscriptions expire, and how many events have been processed.
|
|
2587
|
+
|
|
2588
|
+
RBAC: any
|
|
2589
|
+
|
|
2590
|
+
Args:
|
|
2591
|
+
slug: (optional) Filter by customer slug
|
|
2592
|
+
provider: (optional) Filter by provider — "gmail" | "microsoft-graph" | "slack"
|
|
2593
|
+
|
|
2594
|
+
Returns: { subscriptions: [{ id, provider, slug, status, expiresAt, expiresInHours, needsRenewal, lastEventAt, eventsProcessed }], summary: { total, active, expiringSoon, expired } }`,
|
|
2595
|
+
inputSchema: z.object({
|
|
2596
|
+
slug: z.string().optional().describe("Filter by customer slug"),
|
|
2597
|
+
provider: z.enum([
|
|
2598
|
+
"gmail",
|
|
2599
|
+
"microsoft-graph",
|
|
2600
|
+
"slack"
|
|
2601
|
+
]).optional().describe("Filter by provider")
|
|
2602
|
+
})
|
|
2603
|
+
}, async ({ slug, provider }) => handleGetPushStatus({
|
|
2604
|
+
...slug !== void 0 ? { slug } : {},
|
|
2605
|
+
...provider !== void 0 ? { provider } : {}
|
|
2606
|
+
}, DATA_DIR$27));
|
|
2607
|
+
}
|
|
2608
|
+
//#endregion
|
|
2609
|
+
//#region src/core/org-intelligence.ts
|
|
2610
|
+
function buildStakeholderMap(dataDir, slug, today, dealName) {
|
|
2611
|
+
const graph = readGraph(dataDir, slug);
|
|
2612
|
+
const stakeholders = getStakeholders(graph);
|
|
2613
|
+
const health = readHealth(dataDir, slug);
|
|
2614
|
+
const champIds = new Set(stakeholders.champions.map((n) => n.id));
|
|
2615
|
+
const buyerIds = new Set(stakeholders.economicBuyers.map((n) => n.id));
|
|
2616
|
+
const blockerIds = new Set(stakeholders.blockers.map((n) => n.id));
|
|
2617
|
+
const healthByContactId = new Map((health?.contacts ?? []).map((c) => [c.contactId, c]));
|
|
2618
|
+
const people = graph.nodes.filter((n) => n.type === "person").map((node) => {
|
|
2619
|
+
const contactHealth = healthByContactId.get(node.id);
|
|
2620
|
+
let role = "unknown";
|
|
2621
|
+
if (champIds.has(node.id)) role = "champion";
|
|
2622
|
+
else if (buyerIds.has(node.id)) role = "economic_buyer";
|
|
2623
|
+
else if (blockerIds.has(node.id)) role = "blocker";
|
|
2624
|
+
const edges = graph.edges.filter((e) => e.from === node.id);
|
|
2625
|
+
const contactStrength = edges.length > 0 ? Math.round(Math.max(...edges.map((e) => e.weight)) * 100) / 100 : .5;
|
|
2626
|
+
const profile = {
|
|
2627
|
+
name: node.label,
|
|
2628
|
+
role,
|
|
2629
|
+
healthScore: contactHealth?.score ?? 50,
|
|
2630
|
+
daysSinceContact: contactHealth?.daysSinceContact ?? 999,
|
|
2631
|
+
contactStrength,
|
|
2632
|
+
riskFlags: contactHealth?.riskFlags ?? []
|
|
2633
|
+
};
|
|
2634
|
+
if (node.properties.email) profile.email = node.properties.email;
|
|
2635
|
+
return profile;
|
|
2636
|
+
});
|
|
2637
|
+
const riskAssessment = buildRiskAssessment(people, stakeholders.missingRoles, []);
|
|
2638
|
+
const recommendation = deriveRecommendation(people, stakeholders.missingRoles);
|
|
2639
|
+
return {
|
|
2640
|
+
slug,
|
|
2641
|
+
...dealName ? { dealName } : {},
|
|
2642
|
+
updatedAt: (/* @__PURE__ */ new Date(`${today}T00:00:00Z`)).toISOString(),
|
|
2643
|
+
people,
|
|
2644
|
+
missingRoles: stakeholders.missingRoles,
|
|
2645
|
+
riskAssessment,
|
|
2646
|
+
recommendation
|
|
2647
|
+
};
|
|
2648
|
+
}
|
|
2649
|
+
function buildRiskAssessment(people, missingRoles, signals = []) {
|
|
2650
|
+
const risks = [];
|
|
2651
|
+
if (missingRoles.some((r) => r.role === "champion")) risks.push("No champion identified — deal lacks an internal advocate.");
|
|
2652
|
+
if (missingRoles.some((r) => r.role === "economic_buyer")) risks.push("Economic buyer unknown — decision authority not confirmed.");
|
|
2653
|
+
const coldPeople = people.filter((p) => p.riskFlags.includes("NO_CONTACT_30D"));
|
|
2654
|
+
if (coldPeople.length > 0) risks.push(`Cold contacts (30d+ silence): ${coldPeople.map((p) => p.name).join(", ")}.`);
|
|
2655
|
+
const lowHealth = people.filter((p) => p.healthScore < 40);
|
|
2656
|
+
if (lowHealth.length > 0) risks.push(`Low health score (<40): ${lowHealth.map((p) => p.name).join(", ")}.`);
|
|
2657
|
+
const negativeSignals = signals.filter((s) => s.impact === "negative");
|
|
2658
|
+
for (const sig of negativeSignals.slice(0, 2)) risks.push(`External signal: ${sig.summary}`);
|
|
2659
|
+
return risks.length > 0 ? risks.join(" ") : "No critical risks detected.";
|
|
2660
|
+
}
|
|
2661
|
+
function deriveRecommendation(people, missingRoles) {
|
|
2662
|
+
const critical = missingRoles.find((r) => r.urgency === "critical");
|
|
2663
|
+
if (critical) return critical.suggestion;
|
|
2664
|
+
const coldPeople = people.filter((p) => p.riskFlags.includes("NO_CONTACT_30D"));
|
|
2665
|
+
if (coldPeople.length > 0) return `Re-engage ${coldPeople.map((p) => p.name).join(", ")} — no contact in 30+ days.`;
|
|
2666
|
+
const important = missingRoles.find((r) => r.urgency === "important");
|
|
2667
|
+
if (important) return important.suggestion;
|
|
2668
|
+
return `Relationship health avg ${people.length > 0 ? Math.round(people.reduce((s, p) => s + p.healthScore, 0) / people.length) : 0}/100. Maintain regular contact cadence.`;
|
|
2669
|
+
}
|
|
2670
|
+
//#endregion
|
|
2671
|
+
//#region src/mcp/tools/get-org-intelligence.ts
|
|
2672
|
+
const DATA_DIR$26 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2673
|
+
async function handleGetOrgIntelligence(input, dataDir = DATA_DIR$26) {
|
|
2674
|
+
try {
|
|
2675
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2676
|
+
const map = buildStakeholderMap(dataDir, input.slug, today, input.dealName);
|
|
2677
|
+
return { content: [{
|
|
2678
|
+
type: "text",
|
|
2679
|
+
text: JSON.stringify(map, null, 2)
|
|
2680
|
+
}] };
|
|
2681
|
+
} catch (err) {
|
|
2682
|
+
return { content: [{
|
|
2683
|
+
type: "text",
|
|
2684
|
+
text: JSON.stringify({
|
|
2685
|
+
success: false,
|
|
2686
|
+
error: err.message
|
|
2687
|
+
}, null, 2)
|
|
2688
|
+
}] };
|
|
2689
|
+
}
|
|
2690
|
+
}
|
|
2691
|
+
function registerGetOrgIntelligence(server) {
|
|
2692
|
+
server.registerTool("get_org_intelligence", {
|
|
2693
|
+
title: "Get Org Intelligence",
|
|
2694
|
+
description: `Build a stakeholder map for a customer: champions, economic buyers, blockers, health scores, risk flags, and a prioritised recommendation.
|
|
2695
|
+
|
|
2696
|
+
Returns: { slug, updatedAt, people: [{ name, email, role, healthScore, daysSinceContact, contactStrength, riskFlags }], missingRoles, riskAssessment, recommendation }`,
|
|
2697
|
+
inputSchema: z.object({
|
|
2698
|
+
slug: z.string().describe("Customer slug"),
|
|
2699
|
+
dealName: z.string().optional().describe("Optional deal name to scope the analysis")
|
|
2700
|
+
})
|
|
2701
|
+
}, async ({ slug, dealName }) => handleGetOrgIntelligence(dealName ? {
|
|
2702
|
+
slug,
|
|
2703
|
+
dealName
|
|
2704
|
+
} : { slug }));
|
|
2705
|
+
}
|
|
2706
|
+
//#endregion
|
|
2707
|
+
//#region src/agents/deal-room.ts
|
|
2708
|
+
async function buildDealRoom(dataDir, slug, dealName, today) {
|
|
2709
|
+
const [pipelineDeals, simInput] = await Promise.all([readPipeline(dataDir, slug).catch(() => []), buildSimulationInput(dataDir, "quarter", today).catch(() => ({
|
|
2710
|
+
deals: [],
|
|
2711
|
+
externalSignals: [],
|
|
2712
|
+
iterations: 1e3,
|
|
2713
|
+
horizon: "quarter",
|
|
2714
|
+
today
|
|
2715
|
+
}))]);
|
|
2716
|
+
const stakeholders = buildStakeholderMap(dataDir, slug, today, dealName);
|
|
2717
|
+
const health = readHealth(dataDir, slug);
|
|
2718
|
+
const simResult = runSimulation({
|
|
2719
|
+
...simInput,
|
|
2720
|
+
iterations: 1e3
|
|
2721
|
+
});
|
|
2722
|
+
const todayDate = new Date(today);
|
|
2723
|
+
const dealHealth = pipelineDeals.filter((d) => d.stage !== "won" && d.stage !== "lost").map((deal) => {
|
|
2724
|
+
const updatedDate = deal.updated ? new Date(deal.updated) : todayDate;
|
|
2725
|
+
const daysSinceLastActivity = Math.floor((todayDate.getTime() - updatedDate.getTime()) / 864e5);
|
|
2726
|
+
const daysToClose = deal.close_date ? Math.floor((new Date(deal.close_date).getTime() - todayDate.getTime()) / 864e5) : void 0;
|
|
2727
|
+
const scored = scoreDeal(deal, {
|
|
2728
|
+
daysSinceLastActivity,
|
|
2729
|
+
daysInCurrentStage: daysSinceLastActivity,
|
|
2730
|
+
...daysToClose !== void 0 ? { daysToClose } : {},
|
|
2731
|
+
...deal.probability !== void 0 ? { probability: deal.probability } : {}
|
|
2732
|
+
});
|
|
2733
|
+
return {
|
|
2734
|
+
deal: deal.name,
|
|
2735
|
+
stage: deal.stage,
|
|
2736
|
+
score: scored.score,
|
|
2737
|
+
grade: scored.grade,
|
|
2738
|
+
warnings: scored.warnings
|
|
2739
|
+
};
|
|
2740
|
+
});
|
|
2741
|
+
const firstActiveDeal = pipelineDeals.find((d) => d.stage !== "won" && d.stage !== "lost");
|
|
2742
|
+
let recommendedPlaybook = null;
|
|
2743
|
+
if (firstActiveDeal) {
|
|
2744
|
+
const championEmail = stakeholders.people.find((p) => p.role === "champion")?.email;
|
|
2745
|
+
const daysSinceContact = ((championEmail ? health?.contacts.find((c) => c.email === championEmail || c.contactId === `person:${championEmail}`) : void 0) ?? health?.contacts?.[0])?.daysSinceContact ?? 999;
|
|
2746
|
+
const dealSnapshot = {
|
|
2747
|
+
slug,
|
|
2748
|
+
name: firstActiveDeal.name,
|
|
2749
|
+
stage: firstActiveDeal.stage,
|
|
2750
|
+
value: firstActiveDeal.value ?? 0,
|
|
2751
|
+
probability: firstActiveDeal.probability ?? 50,
|
|
2752
|
+
healthScore: health?.overallHealth ?? 50,
|
|
2753
|
+
daysSinceContact,
|
|
2754
|
+
championPresent: stakeholders.people.some((p) => p.role === "champion")
|
|
2755
|
+
};
|
|
2756
|
+
recommendedPlaybook = matchPlaybooks(listPlaybooks(dataDir, slug), dealSnapshot, daysSinceContact)[0] ?? null;
|
|
2757
|
+
}
|
|
2758
|
+
const riskScore = computeRiskScore(stakeholders.missingRoles, dealHealth, health?.overallHealth ?? 100);
|
|
2759
|
+
const topPriorities = buildTopPriorities(stakeholders, dealHealth);
|
|
2760
|
+
const executiveSummary = buildExecutiveSummary(slug, dealName, stakeholders, health?.overallHealth ?? 100, simResult, riskScore);
|
|
2761
|
+
return {
|
|
2762
|
+
slug,
|
|
2763
|
+
dealName,
|
|
2764
|
+
generatedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
2765
|
+
stakeholders,
|
|
2766
|
+
relationshipHealth: health?.contacts ?? [],
|
|
2767
|
+
dealHealth,
|
|
2768
|
+
revenueSimulation: {
|
|
2769
|
+
p50: simResult.p50,
|
|
2770
|
+
p10: simResult.p10,
|
|
2771
|
+
p90: simResult.p90,
|
|
2772
|
+
expected: simResult.expected,
|
|
2773
|
+
atRiskRevenue: simResult.atRiskRevenue
|
|
2774
|
+
},
|
|
2775
|
+
recommendedPlaybook,
|
|
2776
|
+
executiveSummary,
|
|
2777
|
+
topPriorities,
|
|
2778
|
+
riskScore
|
|
2779
|
+
};
|
|
2780
|
+
}
|
|
2781
|
+
function computeRiskScore(missingRoles, dealHealth, overallHealth) {
|
|
2782
|
+
let risk = 0;
|
|
2783
|
+
for (const mr of missingRoles) risk += mr.urgency === "critical" ? 25 : 10;
|
|
2784
|
+
const avgDealScore = dealHealth.length > 0 ? dealHealth.reduce((s, d) => s + d.score, 0) / dealHealth.length : 100;
|
|
2785
|
+
risk += Math.round((100 - avgDealScore) * .3);
|
|
2786
|
+
risk += Math.round((100 - overallHealth) * .2);
|
|
2787
|
+
return Math.min(100, Math.max(0, risk));
|
|
2788
|
+
}
|
|
2789
|
+
function buildTopPriorities(stakeholders, dealHealth) {
|
|
2790
|
+
const priorities = [];
|
|
2791
|
+
for (const mr of stakeholders.missingRoles) if (mr.urgency === "critical") priorities.push(mr.suggestion);
|
|
2792
|
+
const coldPeople = stakeholders.people.filter((p) => p.riskFlags.includes("NO_CONTACT_30D"));
|
|
2793
|
+
if (coldPeople.length > 0) priorities.push(`Re-engage ${coldPeople.map((p) => p.name).join(", ")} — silent 30+ days.`);
|
|
2794
|
+
const atRiskDeals = dealHealth.filter((d) => d.score < 50);
|
|
2795
|
+
const showCount = Math.min(3, atRiskDeals.length);
|
|
2796
|
+
for (const d of atRiskDeals.slice(0, showCount)) priorities.push(`Rescue deal "${d.deal}" (health ${d.score}/100): ${d.warnings[0] ?? "at risk"}`);
|
|
2797
|
+
if (atRiskDeals.length > showCount) priorities.push(`+${atRiskDeals.length - showCount} more at-risk deal(s) need attention.`);
|
|
2798
|
+
for (const mr of stakeholders.missingRoles) if (mr.urgency === "important") priorities.push(mr.suggestion);
|
|
2799
|
+
if (priorities.length === 0) priorities.push("Maintain current momentum — schedule next check-in.");
|
|
2800
|
+
return priorities;
|
|
2801
|
+
}
|
|
2802
|
+
function buildExecutiveSummary(slug, dealName, stakeholders, overallHealth, sim, riskScore) {
|
|
2803
|
+
const champCount = stakeholders.people.filter((p) => p.role === "champion").length;
|
|
2804
|
+
const missingCritical = stakeholders.missingRoles.filter((r) => r.urgency === "critical").length;
|
|
2805
|
+
const parts = [];
|
|
2806
|
+
parts.push(`${slug}/${dealName}: relationship health ${overallHealth}/100, ${champCount} champion(s) identified.`);
|
|
2807
|
+
if (sim.p50 > 0) parts.push(`Pipeline P50 forecast: €${(sim.p50 / 1e3).toFixed(1)}k.`);
|
|
2808
|
+
if (missingCritical > 0) parts.push(`Critical gaps: ${missingCritical} key role(s) unidentified — risk score ${riskScore}/100.`);
|
|
2809
|
+
else parts.push(`Overall risk score: ${riskScore}/100.`);
|
|
2810
|
+
return parts.join(" ");
|
|
2811
|
+
}
|
|
2812
|
+
//#endregion
|
|
2813
|
+
//#region src/mcp/tools/open-deal-room.ts
|
|
2814
|
+
const DATA_DIR$25 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2815
|
+
async function handleOpenDealRoom(input, dataDir = DATA_DIR$25) {
|
|
2816
|
+
try {
|
|
2817
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
2818
|
+
const brief = await buildDealRoom(dataDir, input.slug, input.dealName, today);
|
|
2819
|
+
return { content: [{
|
|
2820
|
+
type: "text",
|
|
2821
|
+
text: JSON.stringify(brief, null, 2)
|
|
2822
|
+
}] };
|
|
2823
|
+
} catch (err) {
|
|
2824
|
+
return { content: [{
|
|
2825
|
+
type: "text",
|
|
2826
|
+
text: JSON.stringify({
|
|
2827
|
+
success: false,
|
|
2828
|
+
error: err.message
|
|
2829
|
+
}, null, 2)
|
|
2830
|
+
}] };
|
|
2831
|
+
}
|
|
2832
|
+
}
|
|
2833
|
+
function registerOpenDealRoom(server) {
|
|
2834
|
+
server.registerTool("open_deal_room", {
|
|
2835
|
+
title: "Open Deal Room",
|
|
2836
|
+
description: `Multi-agent deal brief: orchestrates relationship graph, health scores, deal health, revenue simulation, and playbook matching into a unified deal-room brief with executive summary, top priorities, and risk score (0–100).
|
|
2837
|
+
|
|
2838
|
+
Returns: { slug, dealName, generatedAt, stakeholders, relationshipHealth, dealHealth, revenueSimulation, recommendedPlaybook, executiveSummary, topPriorities, riskScore }`,
|
|
2839
|
+
inputSchema: z.object({
|
|
2840
|
+
slug: z.string().describe("Customer slug"),
|
|
2841
|
+
dealName: z.string().describe("Name of the deal to analyse")
|
|
2842
|
+
})
|
|
2843
|
+
}, async ({ slug, dealName }) => handleOpenDealRoom({
|
|
2844
|
+
slug,
|
|
2845
|
+
dealName
|
|
2846
|
+
}));
|
|
2847
|
+
}
|
|
2848
|
+
//#endregion
|
|
2849
|
+
//#region src/mcp/tools/get-proactive-briefing.ts
|
|
2850
|
+
const DATA_DIR$24 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2851
|
+
async function handleGetProactiveBriefing(input, dataDir = DATA_DIR$24) {
|
|
2852
|
+
try {
|
|
2853
|
+
const briefing = await buildDailyBriefing(dataDir, input.date ?? (/* @__PURE__ */ new Date()).toISOString().slice(0, 10));
|
|
2854
|
+
return { content: [{
|
|
2855
|
+
type: "text",
|
|
2856
|
+
text: JSON.stringify(briefing, null, 2)
|
|
2857
|
+
}] };
|
|
2858
|
+
} catch (err) {
|
|
2859
|
+
return { content: [{
|
|
2860
|
+
type: "text",
|
|
2861
|
+
text: JSON.stringify({
|
|
2862
|
+
success: false,
|
|
2863
|
+
error: err.message
|
|
2864
|
+
}, null, 2)
|
|
2865
|
+
}] };
|
|
2866
|
+
}
|
|
2867
|
+
}
|
|
2868
|
+
function registerGetProactiveBriefing(server) {
|
|
2869
|
+
server.registerTool("get_proactive_briefing", {
|
|
2870
|
+
title: "Get Proactive Briefing",
|
|
2871
|
+
description: `Generate a proactive daily briefing: urgent alerts (relationship decay, deal risk, overdue close dates), pipeline forecast (P50/P90), and a single top-action recommendation.
|
|
2872
|
+
|
|
2873
|
+
Returns: { date, generatedAt, urgent: string[], opportunities: string[], forecast: string, topAction: string }`,
|
|
2874
|
+
inputSchema: z.object({ date: z.string().optional().describe("ISO date (YYYY-MM-DD). Defaults to today.") })
|
|
2875
|
+
}, async ({ date }) => handleGetProactiveBriefing(date ? { date } : {}));
|
|
2876
|
+
}
|
|
2877
|
+
//#endregion
|
|
2878
|
+
//#region src/mcp/tools/list-email-templates.ts
|
|
2879
|
+
const DATA_DIR$23 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2880
|
+
async function handleListEmailTemplates(input, dataDir = DATA_DIR$23) {
|
|
2881
|
+
const summary = listTemplates(dataDir, input.category ? { category: input.category } : {}).map(({ body: _body, ...meta }) => meta);
|
|
2882
|
+
return { content: [{
|
|
2883
|
+
type: "text",
|
|
2884
|
+
text: JSON.stringify(summary, null, 2)
|
|
2885
|
+
}] };
|
|
2886
|
+
}
|
|
2887
|
+
function registerListEmailTemplates(server, dataDir = DATA_DIR$23) {
|
|
2888
|
+
server.registerTool("list_email_templates", {
|
|
2889
|
+
description: "List available email templates. Optionally filter by category (e.g. 'outreach', 'followup', 'support').",
|
|
2890
|
+
inputSchema: z.object({ category: z.string().optional().describe("Filter by category") })
|
|
2891
|
+
}, ({ category }) => handleListEmailTemplates(category ? { category } : {}, dataDir));
|
|
2892
|
+
}
|
|
2893
|
+
//#endregion
|
|
2894
|
+
//#region src/mcp/tools/get-email-template.ts
|
|
2895
|
+
const DATA_DIR$22 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2896
|
+
async function handleGetEmailTemplate(input, dataDir = DATA_DIR$22) {
|
|
2897
|
+
const tmpl = getTemplate(dataDir, input.id);
|
|
2898
|
+
if (!tmpl) return { content: [{
|
|
2899
|
+
type: "text",
|
|
2900
|
+
text: JSON.stringify({ error: `Template '${input.id}' not found` })
|
|
2901
|
+
}] };
|
|
2902
|
+
const allVars = extractVariables(`${tmpl.subject}\n${tmpl.body}`);
|
|
2903
|
+
const unique = [...new Set(allVars)];
|
|
2904
|
+
return { content: [{
|
|
2905
|
+
type: "text",
|
|
2906
|
+
text: JSON.stringify({
|
|
2907
|
+
...tmpl,
|
|
2908
|
+
detectedVariables: unique
|
|
2909
|
+
}, null, 2)
|
|
2910
|
+
}] };
|
|
2911
|
+
}
|
|
2912
|
+
function registerGetEmailTemplate(server, dataDir = DATA_DIR$22) {
|
|
2913
|
+
server.registerTool("get_email_template", {
|
|
2914
|
+
description: "Get a specific email template by ID, including its body and detected variables.",
|
|
2915
|
+
inputSchema: z.object({ id: z.string().describe("Template ID (e.g. 'enterprise-intro')") })
|
|
2916
|
+
}, ({ id }) => handleGetEmailTemplate({ id }, dataDir));
|
|
2917
|
+
}
|
|
2918
|
+
//#endregion
|
|
2919
|
+
//#region src/mcp/tools/draft-email.ts
|
|
2920
|
+
const DATA_DIR$21 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2921
|
+
async function handleDraftEmail(input, dataDir = DATA_DIR$21) {
|
|
2922
|
+
const tmpl = getTemplate(dataDir, input.templateId);
|
|
2923
|
+
if (!tmpl) return { content: [{
|
|
2924
|
+
type: "text",
|
|
2925
|
+
text: JSON.stringify({ error: `Template '${input.templateId}' not found` })
|
|
2926
|
+
}] };
|
|
2927
|
+
const vars = {
|
|
2928
|
+
...await buildVariablesFromCustomer(dataDir, input.slug),
|
|
2929
|
+
...input.overrides ?? {}
|
|
2930
|
+
};
|
|
2931
|
+
const subject = interpolate(tmpl.subject, vars);
|
|
2932
|
+
const interpolatedBody = interpolate(tmpl.body, vars);
|
|
2933
|
+
let effectiveTone = input.tone;
|
|
2934
|
+
if (!effectiveTone) {
|
|
2935
|
+
const { resolveTone, toneInstruction } = await import("./tone-Bdm5uaht.js");
|
|
2936
|
+
const instr = toneInstruction(resolveTone(dataDir, input.slug));
|
|
2937
|
+
if (instr) effectiveTone = instr;
|
|
2938
|
+
}
|
|
2939
|
+
let body = interpolatedBody;
|
|
2940
|
+
let polished = false;
|
|
2941
|
+
if (effectiveTone) try {
|
|
2942
|
+
const { callLlm } = await import("./llm-DEjWcqmW.js");
|
|
2943
|
+
const refined = await callLlm(`Rewrite the following email in a ${effectiveTone} tone. Keep the same language, preserve all names and facts, and do not invent details. Return ONLY the rewritten email body, no preamble.\n\n---\n${interpolatedBody}`);
|
|
2944
|
+
if (refined && refined.trim()) {
|
|
2945
|
+
const { labelAiContent } = await import("./compliance-CujOqAKk.js");
|
|
2946
|
+
body = labelAiContent(refined.trim());
|
|
2947
|
+
polished = true;
|
|
2948
|
+
}
|
|
2949
|
+
} catch {}
|
|
2950
|
+
const to = (await readMainFacts(dataDir, input.slug).catch(() => null))?.email ?? "";
|
|
2951
|
+
return { content: [{
|
|
2952
|
+
type: "text",
|
|
2953
|
+
text: JSON.stringify({
|
|
2954
|
+
subject,
|
|
2955
|
+
body,
|
|
2956
|
+
to,
|
|
2957
|
+
slug: input.slug,
|
|
2958
|
+
templateId: input.templateId,
|
|
2959
|
+
tone: effectiveTone ?? null,
|
|
2960
|
+
polished,
|
|
2961
|
+
resolvedVariables: vars
|
|
2962
|
+
}, null, 2)
|
|
2963
|
+
}] };
|
|
2964
|
+
}
|
|
2965
|
+
function registerDraftEmail(server, dataDir = DATA_DIR$21) {
|
|
2966
|
+
server.registerTool("draft_email", {
|
|
2967
|
+
description: `Draft a personalized email for a customer using a stored template.
|
|
2968
|
+
Variables are auto-filled from the customer's main_facts.md. Override any variable manually.
|
|
2969
|
+
Optionally pass a tone (e.g. "formal", "friendly", "concise") to LLM-polish the body —
|
|
2970
|
+
falls back to plain template-fill when no ANTHROPIC_API_KEY is configured.
|
|
2971
|
+
Returns: { subject, body, to, tone, polished, resolvedVariables } — does NOT send automatically.`,
|
|
2972
|
+
inputSchema: z.object({
|
|
2973
|
+
slug: z.string().describe("Customer slug"),
|
|
2974
|
+
templateId: z.string().describe("Template ID to use"),
|
|
2975
|
+
overrides: z.record(z.string()).optional().describe("Override any template variable (e.g. {firstName: 'Alice'})"),
|
|
2976
|
+
tone: z.string().optional().describe("Optional tone to LLM-polish the body (e.g. 'formal', 'friendly', 'concise')")
|
|
2977
|
+
})
|
|
2978
|
+
}, ({ slug, templateId, overrides, tone }) => handleDraftEmail({
|
|
2979
|
+
slug,
|
|
2980
|
+
templateId,
|
|
2981
|
+
...overrides !== void 0 ? { overrides } : {},
|
|
2982
|
+
...tone !== void 0 ? { tone } : {}
|
|
2983
|
+
}, dataDir));
|
|
2984
|
+
}
|
|
2985
|
+
//#endregion
|
|
2986
|
+
//#region src/mcp/tools/enroll-in-sequence.ts
|
|
2987
|
+
const DATA_DIR$20 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
2988
|
+
async function handleEnrollInSequence(input, dataDir = DATA_DIR$20) {
|
|
2989
|
+
const sequence = getSequence(dataDir, input.sequenceId);
|
|
2990
|
+
if (!sequence) return { content: [{
|
|
2991
|
+
type: "text",
|
|
2992
|
+
text: JSON.stringify({ error: `Sequence '${input.sequenceId}' not found` })
|
|
2993
|
+
}] };
|
|
2994
|
+
const firstStep = sequence.steps[0];
|
|
2995
|
+
if (!getTemplate(dataDir, firstStep.templateId)) return { content: [{
|
|
2996
|
+
type: "text",
|
|
2997
|
+
text: JSON.stringify({ error: `Template '${firstStep.templateId}' for step 0 not found` })
|
|
2998
|
+
}] };
|
|
2999
|
+
const enrollmentId = `enroll_${Date.now()}_${Math.random().toString(16).slice(2, 8)}`;
|
|
3000
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3001
|
+
await writeEnrollment(dataDir, {
|
|
3002
|
+
id: enrollmentId,
|
|
3003
|
+
sequenceId: input.sequenceId,
|
|
3004
|
+
slug: input.slug,
|
|
3005
|
+
contactEmail: input.contactEmail,
|
|
3006
|
+
enrolledAt: now,
|
|
3007
|
+
status: "active",
|
|
3008
|
+
currentStep: 0,
|
|
3009
|
+
stepsCompleted: []
|
|
3010
|
+
});
|
|
3011
|
+
return { content: [{
|
|
3012
|
+
type: "text",
|
|
3013
|
+
text: JSON.stringify({
|
|
3014
|
+
enrollmentId,
|
|
3015
|
+
sequenceName: sequence.name,
|
|
3016
|
+
totalSteps: sequence.steps.length
|
|
3017
|
+
})
|
|
3018
|
+
}] };
|
|
3019
|
+
}
|
|
3020
|
+
function registerEnrollInSequence(server, dataDir = DATA_DIR$20) {
|
|
3021
|
+
server.registerTool("enroll_in_sequence", {
|
|
3022
|
+
description: `Enroll a contact in an email sequence. Validates that the sequence and its first template exist.
|
|
3023
|
+
Returns: { enrollmentId, sequenceName, totalSteps }`,
|
|
3024
|
+
inputSchema: z.object({
|
|
3025
|
+
slug: z.string().describe("Customer slug"),
|
|
3026
|
+
contactEmail: z.string().email().describe("Email address of the contact to enroll"),
|
|
3027
|
+
sequenceId: z.string().describe("ID of the sequence to enroll in")
|
|
3028
|
+
})
|
|
3029
|
+
}, ({ slug, contactEmail, sequenceId }) => handleEnrollInSequence({
|
|
3030
|
+
slug,
|
|
3031
|
+
contactEmail,
|
|
3032
|
+
sequenceId
|
|
3033
|
+
}, dataDir));
|
|
3034
|
+
}
|
|
3035
|
+
//#endregion
|
|
3036
|
+
//#region src/mcp/tools/list-sequence-enrollments.ts
|
|
3037
|
+
const DATA_DIR$19 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3038
|
+
async function handleListSequenceEnrollments(input, dataDir = DATA_DIR$19) {
|
|
3039
|
+
let enrollments = readEnrollments(dataDir);
|
|
3040
|
+
if (input.slug !== void 0) enrollments = enrollments.filter((e) => e.slug === input.slug);
|
|
3041
|
+
if (input.status !== void 0) enrollments = enrollments.filter((e) => e.status === input.status);
|
|
3042
|
+
return { content: [{
|
|
3043
|
+
type: "text",
|
|
3044
|
+
text: JSON.stringify({ enrollments }, null, 2)
|
|
3045
|
+
}] };
|
|
3046
|
+
}
|
|
3047
|
+
function registerListSequenceEnrollments(server, dataDir = DATA_DIR$19) {
|
|
3048
|
+
server.registerTool("list_sequence_enrollments", {
|
|
3049
|
+
description: `List email sequence enrollments. Filter by customer slug or status.
|
|
3050
|
+
Returns: { enrollments: SequenceEnrollment[] }`,
|
|
3051
|
+
inputSchema: z.object({
|
|
3052
|
+
slug: z.string().optional().describe("Filter by customer slug"),
|
|
3053
|
+
status: z.enum([
|
|
3054
|
+
"active",
|
|
3055
|
+
"paused",
|
|
3056
|
+
"completed"
|
|
3057
|
+
]).optional().describe("Filter by enrollment status")
|
|
3058
|
+
})
|
|
3059
|
+
}, ({ slug, status }) => handleListSequenceEnrollments({
|
|
3060
|
+
...slug !== void 0 ? { slug } : {},
|
|
3061
|
+
...status !== void 0 ? { status } : {}
|
|
3062
|
+
}, dataDir));
|
|
3063
|
+
}
|
|
3064
|
+
//#endregion
|
|
3065
|
+
//#region src/mcp/tools/unenroll-from-sequence.ts
|
|
3066
|
+
const DATA_DIR$18 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3067
|
+
async function handleUnenrollFromSequence(input, dataDir = DATA_DIR$18) {
|
|
3068
|
+
if (!await updateEnrollment(dataDir, input.enrollmentId, { status: "paused" })) return { content: [{
|
|
3069
|
+
type: "text",
|
|
3070
|
+
text: JSON.stringify({
|
|
3071
|
+
success: false,
|
|
3072
|
+
error: `Enrollment '${input.enrollmentId}' not found`
|
|
3073
|
+
})
|
|
3074
|
+
}] };
|
|
3075
|
+
return { content: [{
|
|
3076
|
+
type: "text",
|
|
3077
|
+
text: JSON.stringify({ success: true })
|
|
3078
|
+
}] };
|
|
3079
|
+
}
|
|
3080
|
+
function registerUnenrollFromSequence(server, dataDir = DATA_DIR$18) {
|
|
3081
|
+
server.registerTool("unenroll_from_sequence", {
|
|
3082
|
+
description: `Unenroll (pause) a contact from an email sequence. Sets status to "paused" (soft delete).
|
|
3083
|
+
Returns: { success: boolean }`,
|
|
3084
|
+
inputSchema: z.object({ enrollmentId: z.string().describe("ID of the enrollment to pause") })
|
|
3085
|
+
}, ({ enrollmentId }) => handleUnenrollFromSequence({ enrollmentId }, dataDir));
|
|
3086
|
+
}
|
|
3087
|
+
//#endregion
|
|
3088
|
+
//#region src/mcp/tools/list-sequences.ts
|
|
3089
|
+
const DATA_DIR$17 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3090
|
+
async function handleListSequences(_input, dataDir = DATA_DIR$17) {
|
|
3091
|
+
const sequences = listSequences(dataDir);
|
|
3092
|
+
const enrollments = readEnrollments(dataDir);
|
|
3093
|
+
const result = sequences.map((seq) => ({
|
|
3094
|
+
id: seq.id,
|
|
3095
|
+
name: seq.name,
|
|
3096
|
+
stepCount: seq.steps.length,
|
|
3097
|
+
enrollmentCount: enrollments.filter((e) => e.sequenceId === seq.id).length
|
|
3098
|
+
}));
|
|
3099
|
+
return { content: [{
|
|
3100
|
+
type: "text",
|
|
3101
|
+
text: JSON.stringify({ sequences: result }, null, 2)
|
|
3102
|
+
}] };
|
|
3103
|
+
}
|
|
3104
|
+
function registerListSequences(server, dataDir = DATA_DIR$17) {
|
|
3105
|
+
server.registerTool("list_sequences", {
|
|
3106
|
+
description: `List all email sequences with step count and enrollment count.
|
|
3107
|
+
Returns: { sequences: Array<{ id, name, stepCount, enrollmentCount }> }`,
|
|
3108
|
+
inputSchema: z.object({})
|
|
3109
|
+
}, () => handleListSequences({}, dataDir));
|
|
3110
|
+
}
|
|
3111
|
+
//#endregion
|
|
3112
|
+
//#region src/mcp/tools/generate-quote.ts
|
|
3113
|
+
const DATA_DIR$16 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3114
|
+
async function handleGenerateQuote(input, dataDir = DATA_DIR$16) {
|
|
3115
|
+
try {
|
|
3116
|
+
const quote = await generateQuote(dataDir, input);
|
|
3117
|
+
return { content: [{
|
|
3118
|
+
type: "text",
|
|
3119
|
+
text: JSON.stringify({
|
|
3120
|
+
quoteNumber: quote.quoteNumber,
|
|
3121
|
+
htmlPath: quote.htmlPath,
|
|
3122
|
+
total: quote.total,
|
|
3123
|
+
subtotal: quote.subtotal,
|
|
3124
|
+
vat: quote.vat,
|
|
3125
|
+
vatPercent: quote.vatPercent,
|
|
3126
|
+
currency: quote.currency,
|
|
3127
|
+
validUntil: quote.validUntil,
|
|
3128
|
+
status: quote.status
|
|
3129
|
+
}, null, 2)
|
|
3130
|
+
}] };
|
|
3131
|
+
} catch (err) {
|
|
3132
|
+
return { content: [{
|
|
3133
|
+
type: "text",
|
|
3134
|
+
text: JSON.stringify({ error: err.message })
|
|
3135
|
+
}] };
|
|
3136
|
+
}
|
|
3137
|
+
}
|
|
3138
|
+
function registerGenerateQuote(server, dataDir = DATA_DIR$16) {
|
|
3139
|
+
server.registerTool("generate_quote", {
|
|
3140
|
+
description: `Generate a professional HTML quote/offer for a customer deal.
|
|
3141
|
+
Calculates subtotal, VAT, and total. Saves JSON + HTML to .agentic/quotes/.
|
|
3142
|
+
Returns: { quoteNumber, htmlPath, total, currency, validUntil }`,
|
|
3143
|
+
inputSchema: z.object({
|
|
3144
|
+
slug: z.string().describe("Customer slug"),
|
|
3145
|
+
dealName: z.string().describe("Name of the deal this quote is for"),
|
|
3146
|
+
lineItems: z.array(z.object({
|
|
3147
|
+
description: z.string(),
|
|
3148
|
+
quantity: z.number().positive(),
|
|
3149
|
+
unitPrice: z.number().min(0)
|
|
3150
|
+
})).min(1).describe("Line items for the quote"),
|
|
3151
|
+
vatPercent: z.number().min(0).max(100).optional().describe("VAT percentage (default 19)"),
|
|
3152
|
+
validUntilDays: z.number().int().positive().optional().describe("Quote validity in days (default 30)"),
|
|
3153
|
+
currency: z.string().optional().describe("Currency code (default EUR)")
|
|
3154
|
+
})
|
|
3155
|
+
}, ({ slug, dealName, lineItems, vatPercent, validUntilDays, currency }) => handleGenerateQuote({
|
|
3156
|
+
slug,
|
|
3157
|
+
dealName,
|
|
3158
|
+
lineItems,
|
|
3159
|
+
...vatPercent !== void 0 ? { vatPercent } : {},
|
|
3160
|
+
...validUntilDays !== void 0 ? { validUntilDays } : {},
|
|
3161
|
+
...currency !== void 0 ? { currency } : {}
|
|
3162
|
+
}, dataDir));
|
|
3163
|
+
}
|
|
3164
|
+
//#endregion
|
|
3165
|
+
//#region src/mcp/tools/get-quote-status.ts
|
|
3166
|
+
const DATA_DIR$15 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3167
|
+
async function handleGetQuoteStatus(input, dataDir = DATA_DIR$15) {
|
|
3168
|
+
if (input.quoteNumber) {
|
|
3169
|
+
const quote = readQuote(dataDir, input.quoteNumber);
|
|
3170
|
+
if (!quote) return { content: [{
|
|
3171
|
+
type: "text",
|
|
3172
|
+
text: JSON.stringify({ error: `Quote '${input.quoteNumber}' not found` })
|
|
3173
|
+
}] };
|
|
3174
|
+
return { content: [{
|
|
3175
|
+
type: "text",
|
|
3176
|
+
text: JSON.stringify(quote, null, 2)
|
|
3177
|
+
}] };
|
|
3178
|
+
}
|
|
3179
|
+
const quotes = listQuotes(dataDir, input.slug);
|
|
3180
|
+
return { content: [{
|
|
3181
|
+
type: "text",
|
|
3182
|
+
text: JSON.stringify({ quotes }, null, 2)
|
|
3183
|
+
}] };
|
|
3184
|
+
}
|
|
3185
|
+
function registerGetQuoteStatus(server, dataDir = DATA_DIR$15) {
|
|
3186
|
+
server.registerTool("get_quote_status", {
|
|
3187
|
+
description: `Get quote status and details. Filter by quoteNumber (single quote) or slug (all quotes for a customer).
|
|
3188
|
+
Returns quote with status: draft | sent | viewed | accepted | declined`,
|
|
3189
|
+
inputSchema: z.object({
|
|
3190
|
+
quoteNumber: z.string().optional().describe("Specific quote number (e.g. Q-2026-001)"),
|
|
3191
|
+
slug: z.string().optional().describe("Customer slug to list all quotes for")
|
|
3192
|
+
})
|
|
3193
|
+
}, ({ quoteNumber, slug }) => handleGetQuoteStatus({
|
|
3194
|
+
...quoteNumber !== void 0 ? { quoteNumber } : {},
|
|
3195
|
+
...slug !== void 0 ? { slug } : {}
|
|
3196
|
+
}, dataDir));
|
|
3197
|
+
}
|
|
3198
|
+
//#endregion
|
|
3199
|
+
//#region src/mcp/tools/get-booking-link.ts
|
|
3200
|
+
const DATA_DIR$14 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3201
|
+
function loadCalendlyConfig(dataDir) {
|
|
3202
|
+
const p = path.join(dataDir, ".agentic", "integrations", "calendly.yaml");
|
|
3203
|
+
if (!fs.existsSync(p)) return {};
|
|
3204
|
+
try {
|
|
3205
|
+
return yaml.load(fs.readFileSync(p, "utf-8")) ?? {};
|
|
3206
|
+
} catch {
|
|
3207
|
+
return {};
|
|
3208
|
+
}
|
|
3209
|
+
}
|
|
3210
|
+
function readCustomerFacts(dataDir, slug) {
|
|
3211
|
+
const p = path.join(dataDir, "customers", slug, "main_facts.md");
|
|
3212
|
+
if (!fs.existsSync(p)) return {};
|
|
3213
|
+
const content = fs.readFileSync(p, "utf-8");
|
|
3214
|
+
const nameMatch = /^name:\s*(.+)$/m.exec(content);
|
|
3215
|
+
const emailMatch = /^email:\s*(.+)$/m.exec(content);
|
|
3216
|
+
const name = nameMatch?.[1]?.trim();
|
|
3217
|
+
const email = emailMatch?.[1]?.trim();
|
|
3218
|
+
return {
|
|
3219
|
+
...name ? { name } : {},
|
|
3220
|
+
...email ? { email } : {}
|
|
3221
|
+
};
|
|
3222
|
+
}
|
|
3223
|
+
async function handleGetBookingLink(input, dataDir = DATA_DIR$14) {
|
|
3224
|
+
const config = loadCalendlyConfig(dataDir);
|
|
3225
|
+
const apiKey = config.apiKey ?? process.env["CALENDLY_API_KEY"] ?? "";
|
|
3226
|
+
if (!apiKey) return { content: [{
|
|
3227
|
+
type: "text",
|
|
3228
|
+
text: JSON.stringify({ error: "Calendly API key not configured. Set CALENDLY_API_KEY env var or configure .agentic/integrations/calendly.yaml" })
|
|
3229
|
+
}] };
|
|
3230
|
+
const eventTypeSlug = input.eventType ?? config.defaultEventType ?? "30min";
|
|
3231
|
+
try {
|
|
3232
|
+
const { getSchedulingLink, listEventTypes } = await import("./calendly-Bft_wwji.js");
|
|
3233
|
+
const bookingUrl = await getSchedulingLink(apiKey, eventTypeSlug, input.prefillName ? readCustomerFacts(dataDir, input.slug) : void 0);
|
|
3234
|
+
const eventType = (await listEventTypes(apiKey)).find((et) => et.slug === eventTypeSlug || et.name.toLowerCase().includes(eventTypeSlug.toLowerCase()));
|
|
3235
|
+
return { content: [{
|
|
3236
|
+
type: "text",
|
|
3237
|
+
text: JSON.stringify({
|
|
3238
|
+
bookingUrl,
|
|
3239
|
+
eventType: eventType?.name ?? eventTypeSlug,
|
|
3240
|
+
duration: eventType?.duration ?? 30,
|
|
3241
|
+
slug: input.slug
|
|
3242
|
+
}, null, 2)
|
|
3243
|
+
}] };
|
|
3244
|
+
} catch (err) {
|
|
3245
|
+
return { content: [{
|
|
3246
|
+
type: "text",
|
|
3247
|
+
text: JSON.stringify({ error: err.message })
|
|
3248
|
+
}] };
|
|
3249
|
+
}
|
|
3250
|
+
}
|
|
3251
|
+
function registerGetBookingLink(server, dataDir = DATA_DIR$14) {
|
|
3252
|
+
server.registerTool("get_booking_link", {
|
|
3253
|
+
description: `Get a Calendly booking link for a customer. Optionally pre-fills the customer's name/email.
|
|
3254
|
+
Requires CALENDLY_API_KEY env var or .agentic/integrations/calendly.yaml config.
|
|
3255
|
+
Returns: { bookingUrl, eventType, duration }`,
|
|
3256
|
+
inputSchema: z.object({
|
|
3257
|
+
slug: z.string().describe("Customer slug"),
|
|
3258
|
+
eventType: z.string().optional().describe("Calendly event type slug (e.g. '30min', '60min'). Uses default if not specified."),
|
|
3259
|
+
prefillName: z.boolean().optional().describe("Pre-fill customer name and email in the booking link")
|
|
3260
|
+
})
|
|
3261
|
+
}, ({ slug, eventType, prefillName }) => handleGetBookingLink({
|
|
3262
|
+
slug,
|
|
3263
|
+
...eventType !== void 0 ? { eventType } : {},
|
|
3264
|
+
...prefillName !== void 0 ? { prefillName } : {}
|
|
3265
|
+
}, dataDir));
|
|
3266
|
+
}
|
|
3267
|
+
//#endregion
|
|
3268
|
+
//#region src/mcp/tools/create-ticket.ts
|
|
3269
|
+
const DATA_DIR$13 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3270
|
+
async function handleCreateTicket(input, dataDir = DATA_DIR$13) {
|
|
3271
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3272
|
+
const rules = loadSlaRules(dataDir);
|
|
3273
|
+
const priority = input.priority ?? "normal";
|
|
3274
|
+
const id = nextTicketId(await readTickets(dataDir, input.slug));
|
|
3275
|
+
const slaDue = calcSlaDue(today, priority, rules);
|
|
3276
|
+
const ticket = {
|
|
3277
|
+
id,
|
|
3278
|
+
title: input.title,
|
|
3279
|
+
status: "open",
|
|
3280
|
+
priority,
|
|
3281
|
+
...input.assignee ? { assignee: input.assignee } : {},
|
|
3282
|
+
created: today,
|
|
3283
|
+
slaDue,
|
|
3284
|
+
...input.description ? { description: input.description } : {}
|
|
3285
|
+
};
|
|
3286
|
+
await upsertTicket(dataDir, input.slug, ticket);
|
|
3287
|
+
return { content: [{
|
|
3288
|
+
type: "text",
|
|
3289
|
+
text: JSON.stringify({ ticket }, null, 2)
|
|
3290
|
+
}] };
|
|
3291
|
+
}
|
|
3292
|
+
function registerCreateTicket(server, dataDir = DATA_DIR$13) {
|
|
3293
|
+
server.registerTool("create_ticket", {
|
|
3294
|
+
description: `Create a support ticket for a customer. Auto-calculates SLA due date based on priority.
|
|
3295
|
+
Returns: { ticket } with id T-NNN, status=open, slaDue`,
|
|
3296
|
+
inputSchema: z.object({
|
|
3297
|
+
slug: z.string().describe("Customer slug"),
|
|
3298
|
+
title: z.string().min(1).describe("Ticket title"),
|
|
3299
|
+
description: z.string().optional().describe("Detailed description"),
|
|
3300
|
+
priority: z.enum([
|
|
3301
|
+
"urgent",
|
|
3302
|
+
"high",
|
|
3303
|
+
"normal",
|
|
3304
|
+
"low"
|
|
3305
|
+
]).optional().describe("Priority (default: normal)"),
|
|
3306
|
+
assignee: z.string().optional().describe("Assignee name or email")
|
|
3307
|
+
})
|
|
3308
|
+
}, ({ slug, title, description, priority, assignee }) => handleCreateTicket({
|
|
3309
|
+
slug,
|
|
3310
|
+
title,
|
|
3311
|
+
...description !== void 0 ? { description } : {},
|
|
3312
|
+
...priority !== void 0 ? { priority } : {},
|
|
3313
|
+
...assignee !== void 0 ? { assignee } : {}
|
|
3314
|
+
}, dataDir));
|
|
3315
|
+
}
|
|
3316
|
+
//#endregion
|
|
3317
|
+
//#region src/mcp/tools/update-ticket.ts
|
|
3318
|
+
const DATA_DIR$12 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3319
|
+
async function handleUpdateTicket(input, dataDir = DATA_DIR$12) {
|
|
3320
|
+
const ticket = (await readTickets(dataDir, input.slug)).find((t) => t.id === input.ticketId);
|
|
3321
|
+
if (!ticket) return { content: [{
|
|
3322
|
+
type: "text",
|
|
3323
|
+
text: JSON.stringify({ error: `Ticket '${input.ticketId}' not found for customer '${input.slug}'` })
|
|
3324
|
+
}] };
|
|
3325
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3326
|
+
const updated = {
|
|
3327
|
+
...ticket,
|
|
3328
|
+
...input.status ? { status: input.status } : {},
|
|
3329
|
+
...input.assignee !== void 0 ? { assignee: input.assignee } : {},
|
|
3330
|
+
...input.status === "resolved" && !ticket.resolved ? { resolved: today } : {}
|
|
3331
|
+
};
|
|
3332
|
+
await upsertTicket(dataDir, input.slug, updated);
|
|
3333
|
+
return { content: [{
|
|
3334
|
+
type: "text",
|
|
3335
|
+
text: JSON.stringify({ ticket: updated }, null, 2)
|
|
3336
|
+
}] };
|
|
3337
|
+
}
|
|
3338
|
+
function registerUpdateTicket(server, dataDir = DATA_DIR$12) {
|
|
3339
|
+
server.registerTool("update_ticket", {
|
|
3340
|
+
description: `Update a ticket's status or assignee. Setting status=resolved auto-sets resolved date.
|
|
3341
|
+
Returns: { ticket }`,
|
|
3342
|
+
inputSchema: z.object({
|
|
3343
|
+
slug: z.string().describe("Customer slug"),
|
|
3344
|
+
ticketId: z.string().describe("Ticket ID (e.g. T-001)"),
|
|
3345
|
+
status: z.enum([
|
|
3346
|
+
"open",
|
|
3347
|
+
"in-progress",
|
|
3348
|
+
"waiting",
|
|
3349
|
+
"resolved",
|
|
3350
|
+
"closed"
|
|
3351
|
+
]).optional(),
|
|
3352
|
+
assignee: z.string().optional().describe("New assignee")
|
|
3353
|
+
})
|
|
3354
|
+
}, ({ slug, ticketId, status, assignee }) => handleUpdateTicket({
|
|
3355
|
+
slug,
|
|
3356
|
+
ticketId,
|
|
3357
|
+
...status !== void 0 ? { status } : {},
|
|
3358
|
+
...assignee !== void 0 ? { assignee } : {}
|
|
3359
|
+
}, dataDir));
|
|
3360
|
+
}
|
|
3361
|
+
//#endregion
|
|
3362
|
+
//#region src/mcp/tools/list-tickets.ts
|
|
3363
|
+
const DATA_DIR$11 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3364
|
+
async function handleListTickets(input, dataDir = DATA_DIR$11) {
|
|
3365
|
+
const results = await listAllTickets(dataDir, {
|
|
3366
|
+
...input.slug !== void 0 ? { slug: input.slug } : {},
|
|
3367
|
+
...input.status !== void 0 ? { status: input.status } : {},
|
|
3368
|
+
...input.priority !== void 0 ? { priority: input.priority } : {},
|
|
3369
|
+
...input.assignee !== void 0 ? { assignee: input.assignee } : {}
|
|
3370
|
+
});
|
|
3371
|
+
return { content: [{
|
|
3372
|
+
type: "text",
|
|
3373
|
+
text: JSON.stringify({ tickets: results }, null, 2)
|
|
3374
|
+
}] };
|
|
3375
|
+
}
|
|
3376
|
+
function registerListTickets(server, dataDir = DATA_DIR$11) {
|
|
3377
|
+
server.registerTool("list_tickets", {
|
|
3378
|
+
description: `List support tickets. Filter by customer, status, priority, or assignee. Sorted by priority then date.
|
|
3379
|
+
Returns: { tickets: Array<{ slug, ticket }> }`,
|
|
3380
|
+
inputSchema: z.object({
|
|
3381
|
+
slug: z.string().optional().describe("Filter by customer slug"),
|
|
3382
|
+
status: z.enum([
|
|
3383
|
+
"open",
|
|
3384
|
+
"in-progress",
|
|
3385
|
+
"waiting",
|
|
3386
|
+
"resolved",
|
|
3387
|
+
"closed"
|
|
3388
|
+
]).optional(),
|
|
3389
|
+
priority: z.enum([
|
|
3390
|
+
"urgent",
|
|
3391
|
+
"high",
|
|
3392
|
+
"normal",
|
|
3393
|
+
"low"
|
|
3394
|
+
]).optional(),
|
|
3395
|
+
assignee: z.string().optional().describe("Filter by assignee")
|
|
3396
|
+
})
|
|
3397
|
+
}, ({ slug, status, priority, assignee }) => handleListTickets({
|
|
3398
|
+
...slug !== void 0 ? { slug } : {},
|
|
3399
|
+
...status !== void 0 ? { status } : {},
|
|
3400
|
+
...priority !== void 0 ? { priority } : {},
|
|
3401
|
+
...assignee !== void 0 ? { assignee } : {}
|
|
3402
|
+
}, dataDir));
|
|
3403
|
+
}
|
|
3404
|
+
//#endregion
|
|
3405
|
+
//#region src/mcp/tools/close-ticket.ts
|
|
3406
|
+
const DATA_DIR$10 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3407
|
+
async function handleCloseTicket(input, dataDir = DATA_DIR$10) {
|
|
3408
|
+
const ticket = (await readTickets(dataDir, input.slug)).find((t) => t.id === input.ticketId);
|
|
3409
|
+
if (!ticket) return { content: [{
|
|
3410
|
+
type: "text",
|
|
3411
|
+
text: JSON.stringify({ error: `Ticket '${input.ticketId}' not found for '${input.slug}'` })
|
|
3412
|
+
}] };
|
|
3413
|
+
const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
|
|
3414
|
+
const updated = {
|
|
3415
|
+
...ticket,
|
|
3416
|
+
status: "closed",
|
|
3417
|
+
resolved: ticket.resolved ?? today
|
|
3418
|
+
};
|
|
3419
|
+
await upsertTicket(dataDir, input.slug, updated);
|
|
3420
|
+
if (input.resolution) await appendInteraction(dataDir, input.slug, {
|
|
3421
|
+
date: today,
|
|
3422
|
+
type: "Note",
|
|
3423
|
+
with: input.slug,
|
|
3424
|
+
summary: `Ticket ${input.ticketId} closed: ${input.resolution}`,
|
|
3425
|
+
nextSteps: [],
|
|
3426
|
+
sourceRef: `ticket://${input.ticketId}/close`,
|
|
3427
|
+
synced: (/* @__PURE__ */ new Date()).toISOString()
|
|
3428
|
+
});
|
|
3429
|
+
return { content: [{
|
|
3430
|
+
type: "text",
|
|
3431
|
+
text: JSON.stringify({ ticket: updated }, null, 2)
|
|
3432
|
+
}] };
|
|
3433
|
+
}
|
|
3434
|
+
function registerCloseTicket(server, dataDir = DATA_DIR$10) {
|
|
3435
|
+
server.registerTool("close_ticket", {
|
|
3436
|
+
description: `Close a support ticket. Optionally logs the resolution as an interaction.
|
|
3437
|
+
Returns: { ticket } with status=closed`,
|
|
3438
|
+
inputSchema: z.object({
|
|
3439
|
+
slug: z.string().describe("Customer slug"),
|
|
3440
|
+
ticketId: z.string().describe("Ticket ID (e.g. T-001)"),
|
|
3441
|
+
resolution: z.string().optional().describe("Resolution notes (logged as interaction)")
|
|
3442
|
+
})
|
|
3443
|
+
}, ({ slug, ticketId, resolution }) => handleCloseTicket({
|
|
3444
|
+
slug,
|
|
3445
|
+
ticketId,
|
|
3446
|
+
...resolution !== void 0 ? { resolution } : {}
|
|
3447
|
+
}, dataDir));
|
|
3448
|
+
}
|
|
3449
|
+
//#endregion
|
|
3450
|
+
//#region src/mcp/tools/send-nps-survey.ts
|
|
3451
|
+
const DATA_DIR$9 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3452
|
+
async function handleSendNpsSurvey(input, dataDir = DATA_DIR$9) {
|
|
3453
|
+
const survey = getSurvey(dataDir, input.surveyId);
|
|
3454
|
+
if (!survey) return { content: [{
|
|
3455
|
+
type: "text",
|
|
3456
|
+
text: JSON.stringify({ error: `Survey '${input.surveyId}' not found` })
|
|
3457
|
+
}] };
|
|
3458
|
+
const serverUrl = input.serverUrl ?? process.env["DXCRM_SERVER_URL"] ?? "http://localhost:3456";
|
|
3459
|
+
const token = generateSurveyToken(input.slug, input.contactEmail, input.surveyId);
|
|
3460
|
+
const email = buildSurveyEmail(survey, serverUrl, token);
|
|
3461
|
+
await savePendingSurvey(dataDir, input.surveyId, input.slug, input.contactEmail, token);
|
|
3462
|
+
return { content: [{
|
|
3463
|
+
type: "text",
|
|
3464
|
+
text: JSON.stringify({
|
|
3465
|
+
token,
|
|
3466
|
+
subject: email.subject,
|
|
3467
|
+
body: email.body,
|
|
3468
|
+
surveyUrl: `${serverUrl}/survey/respond?token=${token}`,
|
|
3469
|
+
note: "Email draft ready. Use draft_email or Gmail to send."
|
|
3470
|
+
}, null, 2)
|
|
3471
|
+
}] };
|
|
3472
|
+
}
|
|
3473
|
+
function registerSendNpsSurvey(server, dataDir = DATA_DIR$9) {
|
|
3474
|
+
server.registerTool("send_nps_survey", {
|
|
3475
|
+
description: `Generate an NPS/CSAT survey email for a customer contact. Returns subject, HTML body, and a token-based response URL.
|
|
3476
|
+
Does NOT send automatically — returns draft for review.
|
|
3477
|
+
Returns: { token, subject, body, surveyUrl }`,
|
|
3478
|
+
inputSchema: z.object({
|
|
3479
|
+
slug: z.string().describe("Customer slug"),
|
|
3480
|
+
contactEmail: z.string().email().describe("Contact email to send survey to"),
|
|
3481
|
+
surveyId: z.string().describe("Survey definition ID from .agentic/surveys/"),
|
|
3482
|
+
serverUrl: z.string().optional().describe("Server URL for response links (default: DXCRM_SERVER_URL env var or localhost:3456)")
|
|
3483
|
+
})
|
|
3484
|
+
}, ({ slug, contactEmail, surveyId, serverUrl }) => handleSendNpsSurvey({
|
|
3485
|
+
slug,
|
|
3486
|
+
contactEmail,
|
|
3487
|
+
surveyId,
|
|
3488
|
+
...serverUrl !== void 0 ? { serverUrl } : {}
|
|
3489
|
+
}, dataDir));
|
|
3490
|
+
}
|
|
3491
|
+
//#endregion
|
|
3492
|
+
//#region src/mcp/tools/get-survey-results.ts
|
|
3493
|
+
const DATA_DIR$8 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3494
|
+
async function handleGetSurveyResults(input, dataDir = DATA_DIR$8) {
|
|
3495
|
+
const responses = loadSurveyResponses(dataDir, input.surveyId, input.slug);
|
|
3496
|
+
const nps = calcNpsScore(responses);
|
|
3497
|
+
const promoters = responses.filter((r) => r.score >= 9).length;
|
|
3498
|
+
const passives = responses.filter((r) => r.score >= 7 && r.score <= 8).length;
|
|
3499
|
+
const detractors = responses.filter((r) => r.score <= 6).length;
|
|
3500
|
+
return { content: [{
|
|
3501
|
+
type: "text",
|
|
3502
|
+
text: JSON.stringify({
|
|
3503
|
+
surveyId: input.surveyId,
|
|
3504
|
+
...input.slug ? { slug: input.slug } : {},
|
|
3505
|
+
totalResponses: responses.length,
|
|
3506
|
+
npsScore: nps,
|
|
3507
|
+
promoters,
|
|
3508
|
+
passives,
|
|
3509
|
+
detractors,
|
|
3510
|
+
responses: responses.map((r) => ({
|
|
3511
|
+
slug: r.slug,
|
|
3512
|
+
email: r.contactEmail,
|
|
3513
|
+
score: r.score,
|
|
3514
|
+
...r.comment ? { comment: r.comment } : {},
|
|
3515
|
+
respondedAt: r.respondedAt
|
|
3516
|
+
}))
|
|
3517
|
+
}, null, 2)
|
|
3518
|
+
}] };
|
|
3519
|
+
}
|
|
3520
|
+
function registerGetSurveyResults(server, dataDir = DATA_DIR$8) {
|
|
3521
|
+
server.registerTool("get_survey_results", {
|
|
3522
|
+
description: `Get NPS/CSAT survey results with score breakdown. Calculates Net Promoter Score.
|
|
3523
|
+
Returns: { npsScore, totalResponses, promoters, passives, detractors, responses[] }`,
|
|
3524
|
+
inputSchema: z.object({
|
|
3525
|
+
surveyId: z.string().describe("Survey ID"),
|
|
3526
|
+
slug: z.string().optional().describe("Filter results to a specific customer")
|
|
3527
|
+
})
|
|
3528
|
+
}, ({ surveyId, slug }) => handleGetSurveyResults({
|
|
3529
|
+
surveyId,
|
|
3530
|
+
...slug !== void 0 ? { slug } : {}
|
|
3531
|
+
}, dataDir));
|
|
3532
|
+
}
|
|
3533
|
+
//#endregion
|
|
3534
|
+
//#region src/mcp/tools/search-knowledge-base.ts
|
|
3535
|
+
const DATA_DIR$7 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3536
|
+
async function handleSearchKnowledgeBase(input, dataDir = DATA_DIR$7) {
|
|
3537
|
+
const results = searchKbSimple(dataDir, input.query, { ...input.publicOnly ? { publicOnly: true } : {} });
|
|
3538
|
+
const limited = (input.category ? results.filter((a) => a.category === input.category) : results).slice(0, input.limit ?? 10);
|
|
3539
|
+
return { content: [{
|
|
3540
|
+
type: "text",
|
|
3541
|
+
text: JSON.stringify({
|
|
3542
|
+
query: input.query,
|
|
3543
|
+
count: limited.length,
|
|
3544
|
+
articles: limited.map((a) => ({
|
|
3545
|
+
...getKbMetaForExport(a),
|
|
3546
|
+
excerpt: a.body.slice(0, 300).trim()
|
|
3547
|
+
}))
|
|
3548
|
+
}, null, 2)
|
|
3549
|
+
}] };
|
|
3550
|
+
}
|
|
3551
|
+
function registerSearchKnowledgeBase(server, dataDir = DATA_DIR$7) {
|
|
3552
|
+
server.registerTool("search_knowledge_base", {
|
|
3553
|
+
description: `Search the knowledge base for articles. Text search on title, body, and tags.
|
|
3554
|
+
Returns: { count, articles[] } with excerpts`,
|
|
3555
|
+
inputSchema: z.object({
|
|
3556
|
+
query: z.string().describe("Search query"),
|
|
3557
|
+
category: z.string().optional().describe("Filter by category (e.g. 'troubleshooting', 'howto')"),
|
|
3558
|
+
publicOnly: z.boolean().optional().describe("Only return public articles"),
|
|
3559
|
+
limit: z.number().int().positive().optional().describe("Max results (default 10)")
|
|
3560
|
+
})
|
|
3561
|
+
}, ({ query, category, publicOnly, limit }) => handleSearchKnowledgeBase({
|
|
3562
|
+
query,
|
|
3563
|
+
...category !== void 0 ? { category } : {},
|
|
3564
|
+
...publicOnly !== void 0 ? { publicOnly } : {},
|
|
3565
|
+
...limit !== void 0 ? { limit } : {}
|
|
3566
|
+
}, dataDir));
|
|
3567
|
+
}
|
|
3568
|
+
//#endregion
|
|
3569
|
+
//#region src/mcp/tools/create-kb-article.ts
|
|
3570
|
+
const DATA_DIR$6 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3571
|
+
async function handleCreateKbArticle(input, dataDir = DATA_DIR$6) {
|
|
3572
|
+
if (getKbArticle(dataDir, input.id)) return { content: [{
|
|
3573
|
+
type: "text",
|
|
3574
|
+
text: JSON.stringify({ error: `Article '${input.id}' already exists` })
|
|
3575
|
+
}] };
|
|
3576
|
+
const now = (/* @__PURE__ */ new Date()).toISOString();
|
|
3577
|
+
const article = {
|
|
3578
|
+
id: input.id,
|
|
3579
|
+
title: input.title,
|
|
3580
|
+
body: input.body,
|
|
3581
|
+
category: input.category ?? "general",
|
|
3582
|
+
tags: input.tags ?? [],
|
|
3583
|
+
public: input.public ?? false,
|
|
3584
|
+
createdAt: now,
|
|
3585
|
+
updatedAt: now,
|
|
3586
|
+
...input.sourceTicketId ? { sourceTicketId: input.sourceTicketId } : {}
|
|
3587
|
+
};
|
|
3588
|
+
writeKbArticle(dataDir, article);
|
|
3589
|
+
return { content: [{
|
|
3590
|
+
type: "text",
|
|
3591
|
+
text: JSON.stringify({
|
|
3592
|
+
id: article.id,
|
|
3593
|
+
title: article.title,
|
|
3594
|
+
category: article.category,
|
|
3595
|
+
path: `.agentic/knowledge-base/${article.category}/${article.id}.md`
|
|
3596
|
+
}, null, 2)
|
|
3597
|
+
}] };
|
|
3598
|
+
}
|
|
3599
|
+
function registerCreateKbArticle(server, dataDir = DATA_DIR$6) {
|
|
3600
|
+
server.registerTool("create_kb_article", {
|
|
3601
|
+
description: `Create a new knowledge base article. Articles are stored as Markdown files in .agentic/knowledge-base/.
|
|
3602
|
+
Returns: { id, title, category, path }`,
|
|
3603
|
+
inputSchema: z.object({
|
|
3604
|
+
id: z.string().min(1).describe("Article ID (slug, e.g. 'troubleshoot-api-timeout')"),
|
|
3605
|
+
title: z.string().min(1).describe("Article title"),
|
|
3606
|
+
body: z.string().min(1).describe("Article body in Markdown"),
|
|
3607
|
+
category: z.string().optional().describe("Category (default: 'general')"),
|
|
3608
|
+
tags: z.array(z.string()).optional().describe("Tags for search"),
|
|
3609
|
+
public: z.boolean().optional().describe("Make article publicly accessible (default: false)"),
|
|
3610
|
+
sourceTicketId: z.string().optional().describe("Ticket ID this article was created from")
|
|
3611
|
+
})
|
|
3612
|
+
}, ({ id, title, body, category, tags, public: pub, sourceTicketId }) => handleCreateKbArticle({
|
|
3613
|
+
id,
|
|
3614
|
+
title,
|
|
3615
|
+
body,
|
|
3616
|
+
...category !== void 0 ? { category } : {},
|
|
3617
|
+
...tags !== void 0 ? { tags } : {},
|
|
3618
|
+
...pub !== void 0 ? { public: pub } : {},
|
|
3619
|
+
...sourceTicketId !== void 0 ? { sourceTicketId } : {}
|
|
3620
|
+
}, dataDir));
|
|
3621
|
+
}
|
|
3622
|
+
//#endregion
|
|
3623
|
+
//#region src/mcp/tools/backup-now.ts
|
|
3624
|
+
const DATA_DIR$5 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3625
|
+
async function handleBackupNow(input, dataDir = DATA_DIR$5) {
|
|
3626
|
+
const zipPath = path.join(dataDir, `dxcrm-backup-${(/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-").slice(0, 19)}.zip`);
|
|
3627
|
+
const manifest = await runBackup(zipPath, dataDir, { ...input.remote ? { remote: input.remote } : {} }).catch(() => null);
|
|
3628
|
+
if (!manifest) return { content: [{
|
|
3629
|
+
type: "text",
|
|
3630
|
+
text: "Backup failed. Check disk space and permissions."
|
|
3631
|
+
}] };
|
|
3632
|
+
const sizeMb = fs.existsSync(zipPath) ? (fs.statSync(zipPath).size / 1024 / 1024).toFixed(1) : "?";
|
|
3633
|
+
return { content: [{
|
|
3634
|
+
type: "text",
|
|
3635
|
+
text: JSON.stringify({
|
|
3636
|
+
path: zipPath,
|
|
3637
|
+
createdAt: manifest.createdAt,
|
|
3638
|
+
customerCount: manifest.customerCount,
|
|
3639
|
+
fileCount: manifest.fileCount,
|
|
3640
|
+
sizeMb: `${sizeMb} MB`,
|
|
3641
|
+
directories: manifest.directories,
|
|
3642
|
+
verified: true,
|
|
3643
|
+
...input.remote ? { uploadedTo: input.remote } : {},
|
|
3644
|
+
...input.note ? { note: input.note } : {}
|
|
3645
|
+
}, null, 2)
|
|
3646
|
+
}] };
|
|
3647
|
+
}
|
|
3648
|
+
function registerBackupNow(server) {
|
|
3649
|
+
server.registerTool("backup_now", {
|
|
3650
|
+
description: "Trigger an immediate backup of all CRM data (customers/ + .agentic/). Returns backup path, size, and integrity status. Use before risky operations or on user request.",
|
|
3651
|
+
inputSchema: z.object({
|
|
3652
|
+
remote: z.string().optional().describe("Upload destination: s3://bucket/path/, rsync://user@host:/path/, or local directory"),
|
|
3653
|
+
note: z.string().optional().describe("Optional note to tag this backup")
|
|
3654
|
+
})
|
|
3655
|
+
}, ({ remote, note }) => handleBackupNow({
|
|
3656
|
+
...remote !== void 0 ? { remote } : {},
|
|
3657
|
+
...note !== void 0 ? { note } : {}
|
|
3658
|
+
}));
|
|
3659
|
+
}
|
|
3660
|
+
//#endregion
|
|
3661
|
+
//#region src/mcp/tools/list-backups.ts
|
|
3662
|
+
const DATA_DIR$4 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3663
|
+
async function handleListBackups(input, dataDir = DATA_DIR$4) {
|
|
3664
|
+
const logEntries = readBackupLog(dataDir);
|
|
3665
|
+
const fileEntries = listBackupsInDir(dataDir);
|
|
3666
|
+
const entries = logEntries.length > 0 ? logEntries : fileEntries;
|
|
3667
|
+
const limited = entries.slice(0, input.limit);
|
|
3668
|
+
if (limited.length === 0) return { content: [{
|
|
3669
|
+
type: "text",
|
|
3670
|
+
text: "No backups found. Run backup_now to create one."
|
|
3671
|
+
}] };
|
|
3672
|
+
return { content: [{
|
|
3673
|
+
type: "text",
|
|
3674
|
+
text: JSON.stringify({
|
|
3675
|
+
count: limited.length,
|
|
3676
|
+
totalAvailable: entries.length,
|
|
3677
|
+
backups: limited.map((e) => ({
|
|
3678
|
+
filename: e.filename,
|
|
3679
|
+
createdAt: e.createdAt,
|
|
3680
|
+
sizeMb: e.sizeBytes > 0 ? `${(e.sizeBytes / 1024 / 1024).toFixed(1)} MB` : "unknown",
|
|
3681
|
+
verified: e.verified,
|
|
3682
|
+
encrypted: e.encrypted,
|
|
3683
|
+
customerCount: e.customerCount,
|
|
3684
|
+
fileCount: e.fileCount
|
|
3685
|
+
}))
|
|
3686
|
+
}, null, 2)
|
|
3687
|
+
}] };
|
|
3688
|
+
}
|
|
3689
|
+
function registerListBackups(server) {
|
|
3690
|
+
server.registerTool("list_backups", {
|
|
3691
|
+
description: "List available CRM backups with metadata (date, size, verification status, customer count). Shows log-tracked backups first, falls back to directory scan.",
|
|
3692
|
+
inputSchema: z.object({ limit: z.number().int().min(1).max(50).default(10).describe("Maximum number of backups to return") })
|
|
3693
|
+
}, (input) => handleListBackups(input));
|
|
3694
|
+
}
|
|
3695
|
+
//#endregion
|
|
3696
|
+
//#region src/mcp/tools/trigger-sync.ts
|
|
3697
|
+
const DATA_DIR$3 = process.cwd();
|
|
3698
|
+
async function handleTriggerSync(input, dataDir = DATA_DIR$3) {
|
|
3699
|
+
const auth = getGmailAuth();
|
|
3700
|
+
if (!auth) return { content: [{
|
|
3701
|
+
type: "text",
|
|
3702
|
+
text: JSON.stringify({
|
|
3703
|
+
success: false,
|
|
3704
|
+
error: "Gmail auth not configured. Run `dxcrm sync gmail --init` first."
|
|
3705
|
+
})
|
|
3706
|
+
}] };
|
|
3707
|
+
const customersDir = path.join(dataDir, "customers");
|
|
3708
|
+
if (!fs.existsSync(customersDir)) return { content: [{
|
|
3709
|
+
type: "text",
|
|
3710
|
+
text: JSON.stringify({
|
|
3711
|
+
success: true,
|
|
3712
|
+
synced: 0,
|
|
3713
|
+
skipped: 0,
|
|
3714
|
+
customers: []
|
|
3715
|
+
})
|
|
3716
|
+
}] };
|
|
3717
|
+
const slugs = input.slug ? [input.slug] : fs.readdirSync(customersDir).filter((s) => {
|
|
3718
|
+
try {
|
|
3719
|
+
return fs.statSync(path.join(customersDir, s)).isDirectory();
|
|
3720
|
+
} catch {
|
|
3721
|
+
return false;
|
|
3722
|
+
}
|
|
3723
|
+
});
|
|
3724
|
+
const sinceDate = input.since ? new Date(input.since) : /* @__PURE__ */ new Date(Date.now() - 1440 * 60 * 1e3);
|
|
3725
|
+
const results = [];
|
|
3726
|
+
const errors = [];
|
|
3727
|
+
for (const slug of slugs) {
|
|
3728
|
+
const sourcesPath = path.join(customersDir, slug, "sources.json");
|
|
3729
|
+
if (!fs.existsSync(sourcesPath)) continue;
|
|
3730
|
+
try {
|
|
3731
|
+
const sources = JSON.parse(fs.readFileSync(sourcesPath, "utf-8"));
|
|
3732
|
+
if (!sources.gmail?.enabled || !sources.gmail.query) continue;
|
|
3733
|
+
const { syncGmail } = await import("./gmail-sync-DIaxInDT.js");
|
|
3734
|
+
const result = await syncGmail({
|
|
3735
|
+
slug,
|
|
3736
|
+
dataDir,
|
|
3737
|
+
auth,
|
|
3738
|
+
query: sources.gmail.query,
|
|
3739
|
+
since: sinceDate
|
|
3740
|
+
});
|
|
3741
|
+
updateSlugSyncState(dataDir, slug, { lastGmailSync: (/* @__PURE__ */ new Date()).toISOString() });
|
|
3742
|
+
results.push({
|
|
3743
|
+
slug,
|
|
3744
|
+
...result
|
|
3745
|
+
});
|
|
3746
|
+
} catch (err) {
|
|
3747
|
+
errors.push(`${slug}: ${err.message}`);
|
|
3748
|
+
}
|
|
3749
|
+
}
|
|
3750
|
+
const total = results.reduce((acc, r) => ({
|
|
3751
|
+
synced: acc.synced + r.synced,
|
|
3752
|
+
skipped: acc.skipped + r.skipped
|
|
3753
|
+
}), {
|
|
3754
|
+
synced: 0,
|
|
3755
|
+
skipped: 0
|
|
3756
|
+
});
|
|
3757
|
+
return { content: [{
|
|
3758
|
+
type: "text",
|
|
3759
|
+
text: JSON.stringify({
|
|
3760
|
+
success: true,
|
|
3761
|
+
...total,
|
|
3762
|
+
customers: results,
|
|
3763
|
+
errors
|
|
3764
|
+
}, null, 2)
|
|
3765
|
+
}] };
|
|
3766
|
+
}
|
|
3767
|
+
function registerTriggerSync(server) {
|
|
3768
|
+
server.registerTool("trigger_sync", {
|
|
3769
|
+
title: "Trigger Sync",
|
|
3770
|
+
description: `Immediately trigger a Gmail sync for one or all customers.
|
|
3771
|
+
Use when you need fresh email data before answering a question.
|
|
3772
|
+
The background daemon syncs every 30 minutes automatically — this forces an immediate sync.
|
|
3773
|
+
|
|
3774
|
+
Args:
|
|
3775
|
+
slug: Customer slug to sync (leave empty to sync all customers)
|
|
3776
|
+
since: ISO date string — only fetch emails since this date (default: last 24 hours)
|
|
3777
|
+
|
|
3778
|
+
Returns: { success: boolean, synced: number, skipped: number, customers: [...], errors: [...] }`,
|
|
3779
|
+
inputSchema: z.object({
|
|
3780
|
+
slug: z.string().optional().describe("Customer slug to sync (empty = all customers)"),
|
|
3781
|
+
since: z.string().optional().describe("Sync emails since this ISO date (default: last 24h)")
|
|
3782
|
+
})
|
|
3783
|
+
}, async ({ slug, since }) => {
|
|
3784
|
+
const input = {};
|
|
3785
|
+
if (slug !== void 0) input.slug = slug;
|
|
3786
|
+
if (since !== void 0) input.since = since;
|
|
3787
|
+
return handleTriggerSync(input);
|
|
3788
|
+
});
|
|
3789
|
+
}
|
|
3790
|
+
//#endregion
|
|
3791
|
+
//#region src/mcp/tools/get-audit-log.ts
|
|
3792
|
+
const DATA_DIR$2 = process.cwd();
|
|
3793
|
+
async function handleGetAuditLog(input, dataDir = DATA_DIR$2) {
|
|
3794
|
+
const entries = readAuditLog(dataDir);
|
|
3795
|
+
const filterOpts = { limit: input.limit ?? 50 };
|
|
3796
|
+
if (input.slug !== void 0) filterOpts.slug = input.slug;
|
|
3797
|
+
if (input.actor !== void 0) filterOpts.actor = input.actor;
|
|
3798
|
+
const filtered = filterAuditLog(entries, filterOpts);
|
|
3799
|
+
return { content: [{
|
|
3800
|
+
type: "text",
|
|
3801
|
+
text: JSON.stringify({
|
|
3802
|
+
total: entries.length,
|
|
3803
|
+
returned: filtered.length,
|
|
3804
|
+
entries: filtered
|
|
3805
|
+
}, null, 2)
|
|
3806
|
+
}] };
|
|
3807
|
+
}
|
|
3808
|
+
function registerGetAuditLog(server) {
|
|
3809
|
+
server.registerTool("get_audit_log", {
|
|
3810
|
+
title: "Get Audit Log",
|
|
3811
|
+
description: `Read the CRM audit log — all write operations with timestamp, actor, tool, and customer.
|
|
3812
|
+
Use to answer "what changed recently?", "what did alice do?", or "show me all actions for acme-corp".
|
|
3813
|
+
|
|
3814
|
+
Args:
|
|
3815
|
+
slug: Filter by customer slug (optional)
|
|
3816
|
+
actor: Filter by actor name (optional)
|
|
3817
|
+
limit: Max entries to return (default: 50, most recent)
|
|
3818
|
+
|
|
3819
|
+
Returns: { total: number, returned: number, entries: [{timestamp, actor, tool, slug, summary}] }`,
|
|
3820
|
+
inputSchema: z.object({
|
|
3821
|
+
slug: z.string().optional().describe("Filter by customer slug"),
|
|
3822
|
+
actor: z.string().optional().describe("Filter by actor (user or system)"),
|
|
3823
|
+
limit: z.number().int().min(1).max(500).optional().describe("Max entries (default 50)")
|
|
3824
|
+
})
|
|
3825
|
+
}, async ({ slug, actor, limit }) => {
|
|
3826
|
+
const input = {};
|
|
3827
|
+
if (slug !== void 0) input.slug = slug;
|
|
3828
|
+
if (actor !== void 0) input.actor = actor;
|
|
3829
|
+
if (limit !== void 0) input.limit = limit;
|
|
3830
|
+
return handleGetAuditLog(input);
|
|
3831
|
+
});
|
|
3832
|
+
}
|
|
3833
|
+
//#endregion
|
|
3834
|
+
//#region src/mcp/prompts.ts
|
|
3835
|
+
/**
|
|
3836
|
+
* CRM playbook prompts exposed via MCP `prompts/list` + `prompts/get`.
|
|
3837
|
+
* Each renders an actionable, tool-referencing instruction for the host LLM —
|
|
3838
|
+
* the agent-native equivalent of a Salesforce "playbook".
|
|
3839
|
+
*/
|
|
3840
|
+
const CRM_PROMPTS = [
|
|
3841
|
+
{
|
|
3842
|
+
name: "deal_risk_review",
|
|
3843
|
+
title: "Assess deal risk",
|
|
3844
|
+
description: "Evaluate the health and risk of a customer's open deals and recommend next steps.",
|
|
3845
|
+
build: ({ slug }) => `Assess the deal risk for customer "${slug}".\n1. Call open_deal_room({ slug: "${slug}" }) for a consolidated brief, or get_customer_context + get_deal_health.\n2. Identify stalled deals, approaching close dates, and silent champions (get_relationship_health).\n3. Summarise the top risks and recommend concrete next actions. Do not invent data — cite what you read.`
|
|
3846
|
+
},
|
|
3847
|
+
{
|
|
3848
|
+
name: "draft_follow_up",
|
|
3849
|
+
title: "Draft a follow-up email",
|
|
3850
|
+
description: "Draft a personalized follow-up email for a customer based on recent interactions.",
|
|
3851
|
+
build: ({ slug }) => `Draft a follow-up email for customer "${slug}".\n1. Read recent context with get_customer_context({ slug: "${slug}" }).\n2. Use draft_email({ slug: "${slug}", templateId, tone: "friendly" }) with an appropriate template.\n3. Reference the latest interaction concretely; keep it concise. Return the draft for review — do not send.`
|
|
3852
|
+
},
|
|
3853
|
+
{
|
|
3854
|
+
name: "account_brief",
|
|
3855
|
+
title: "Create an account brief",
|
|
3856
|
+
description: "Produce a concise executive brief for a customer account.",
|
|
3857
|
+
build: ({ slug }) => `Create an executive account brief for "${slug}".\n1. get_customer_context({ slug: "${slug}" }) and get_org_intelligence({ slug: "${slug}" }).\n2. Summarise: who the stakeholders are (champions/buyers/blockers), open pipeline, health, and risks.\n3. End with the single most important next action.`
|
|
3858
|
+
},
|
|
3859
|
+
{
|
|
3860
|
+
name: "pipeline_summary",
|
|
3861
|
+
title: "Summarize the pipeline",
|
|
3862
|
+
description: "Summarize pipeline and forecast, optionally focused on one customer.",
|
|
3863
|
+
build: ({ slug }) => `Summarize the sales pipeline (focus customer: "${slug}").\n1. get_pipeline_forecast() for the weighted total and per-stage breakdown.\n2. simulate_revenue() for P10/P50/P90 if a probabilistic view helps.\n3. Highlight at-risk revenue and the deals that most move the forecast.`
|
|
3864
|
+
}
|
|
3865
|
+
];
|
|
3866
|
+
function registerPrompts(server) {
|
|
3867
|
+
for (const prompt of CRM_PROMPTS) server.registerPrompt(prompt.name, {
|
|
3868
|
+
title: prompt.title,
|
|
3869
|
+
description: prompt.description,
|
|
3870
|
+
argsSchema: { slug: z.string().describe("Customer slug") }
|
|
3871
|
+
}, ({ slug }) => ({ messages: [{
|
|
3872
|
+
role: "user",
|
|
3873
|
+
content: {
|
|
3874
|
+
type: "text",
|
|
3875
|
+
text: prompt.build({ slug })
|
|
3876
|
+
}
|
|
3877
|
+
}] }));
|
|
3878
|
+
}
|
|
3879
|
+
//#endregion
|
|
3880
|
+
//#region src/mcp/resources.ts
|
|
3881
|
+
const DATA_DIR$1 = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3882
|
+
/**
|
|
3883
|
+
* Read-only MCP Resources for CRM entities. Complements the action Tools:
|
|
3884
|
+
* agents can `resources/read` a customer briefing, pipeline or timeline by URI
|
|
3885
|
+
* instead of calling a tool.
|
|
3886
|
+
*/
|
|
3887
|
+
function registerResources(server, dataDir = DATA_DIR$1) {
|
|
3888
|
+
server.registerResource("customers", "crm://customers", {
|
|
3889
|
+
title: "Customers",
|
|
3890
|
+
description: "List of all customer slugs",
|
|
3891
|
+
mimeType: "application/json"
|
|
3892
|
+
}, (uri) => ({ contents: [{
|
|
3893
|
+
uri: uri.href,
|
|
3894
|
+
mimeType: "application/json",
|
|
3895
|
+
text: JSON.stringify(listCustomerSlugs(dataDir), null, 2)
|
|
3896
|
+
}] }));
|
|
3897
|
+
server.registerResource("customer", new ResourceTemplate("crm://customer/{slug}", { list: void 0 }), {
|
|
3898
|
+
title: "Customer context",
|
|
3899
|
+
description: "LLM-ready briefing (main facts, recent interactions, pipeline) for a customer",
|
|
3900
|
+
mimeType: "text/markdown"
|
|
3901
|
+
}, async (uri, variables) => {
|
|
3902
|
+
const { buildContext } = await import("./context-builder-DlrRcqmJ.js");
|
|
3903
|
+
const text = await buildContext(dataDir, String(variables["slug"]));
|
|
3904
|
+
return { contents: [{
|
|
3905
|
+
uri: uri.href,
|
|
3906
|
+
mimeType: "text/markdown",
|
|
3907
|
+
text
|
|
3908
|
+
}] };
|
|
3909
|
+
});
|
|
3910
|
+
server.registerResource("pipeline", new ResourceTemplate("crm://pipeline/{slug}", { list: void 0 }), {
|
|
3911
|
+
title: "Pipeline",
|
|
3912
|
+
description: "Open and closed deals for a customer",
|
|
3913
|
+
mimeType: "application/json"
|
|
3914
|
+
}, async (uri, variables) => {
|
|
3915
|
+
const { readPipeline } = await import("./pipeline-writer-BqBrYrQc.js");
|
|
3916
|
+
const deals = await readPipeline(dataDir, String(variables["slug"]));
|
|
3917
|
+
return { contents: [{
|
|
3918
|
+
uri: uri.href,
|
|
3919
|
+
mimeType: "application/json",
|
|
3920
|
+
text: JSON.stringify(deals, null, 2)
|
|
3921
|
+
}] };
|
|
3922
|
+
});
|
|
3923
|
+
server.registerResource("timeline", new ResourceTemplate("crm://timeline/{slug}", { list: void 0 }), {
|
|
3924
|
+
title: "Interaction timeline",
|
|
3925
|
+
description: "Newest-first interaction history for a customer",
|
|
3926
|
+
mimeType: "text/markdown"
|
|
3927
|
+
}, async (uri, variables) => {
|
|
3928
|
+
const { readInteractions } = await import("./interactions-writer-dSPy1XfO.js");
|
|
3929
|
+
const text = await readInteractions(dataDir, String(variables["slug"]));
|
|
3930
|
+
return { contents: [{
|
|
3931
|
+
uri: uri.href,
|
|
3932
|
+
mimeType: "text/markdown",
|
|
3933
|
+
text
|
|
3934
|
+
}] };
|
|
3935
|
+
});
|
|
3936
|
+
}
|
|
3937
|
+
//#endregion
|
|
3938
|
+
//#region src/mcp/tools/custom-objects.ts
|
|
3939
|
+
const DATA_DIR = process.env["DXCRM_DATA_DIR"] ?? process.cwd();
|
|
3940
|
+
const FIELD_TYPES = [
|
|
3941
|
+
"text",
|
|
3942
|
+
"number",
|
|
3943
|
+
"boolean",
|
|
3944
|
+
"date",
|
|
3945
|
+
"select"
|
|
3946
|
+
];
|
|
3947
|
+
function json(data) {
|
|
3948
|
+
return { content: [{
|
|
3949
|
+
type: "text",
|
|
3950
|
+
text: JSON.stringify(data, null, 2)
|
|
3951
|
+
}] };
|
|
3952
|
+
}
|
|
3953
|
+
function handleDefineCustomObject(input, dataDir = DATA_DIR) {
|
|
3954
|
+
enforceRbac(dataDir, "define_custom_object");
|
|
3955
|
+
const objects = defineCustomObject(dataDir, {
|
|
3956
|
+
name: input.name,
|
|
3957
|
+
...input.label ? { label: input.label } : {},
|
|
3958
|
+
fields: input.fields
|
|
3959
|
+
});
|
|
3960
|
+
return json({
|
|
3961
|
+
defined: input.name,
|
|
3962
|
+
objectCount: objects.length
|
|
3963
|
+
});
|
|
3964
|
+
}
|
|
3965
|
+
function handleCreateRecord(input, dataDir = DATA_DIR) {
|
|
3966
|
+
enforceRbac(dataDir, "create_record");
|
|
3967
|
+
const res = createRecord(dataDir, input.object, input.values);
|
|
3968
|
+
if (!res.ok) return json({ error: (res.errors ?? []).join("; ") });
|
|
3969
|
+
import("./webhooks-7EpA05Qr.js").then(({ emitEvent }) => emitEvent(dataDir, "record.created", {
|
|
3970
|
+
object: input.object,
|
|
3971
|
+
record: res.record
|
|
3972
|
+
}));
|
|
3973
|
+
return json({ record: res.record });
|
|
3974
|
+
}
|
|
3975
|
+
function handleListRecords(input, dataDir = DATA_DIR) {
|
|
3976
|
+
return json({
|
|
3977
|
+
object: input.object,
|
|
3978
|
+
records: listRecords(dataDir, input.object)
|
|
3979
|
+
});
|
|
3980
|
+
}
|
|
3981
|
+
function handleListCustomObjects(dataDir = DATA_DIR) {
|
|
3982
|
+
return json({ objects: loadCustomObjects(dataDir) });
|
|
3983
|
+
}
|
|
3984
|
+
function registerCustomObjectTools(server, dataDir = DATA_DIR) {
|
|
3985
|
+
server.registerTool("define_custom_object", {
|
|
3986
|
+
description: "Define a custom object (runtime entity type) with typed fields — no code migration. admin only.",
|
|
3987
|
+
inputSchema: z.object({
|
|
3988
|
+
name: z.string().describe("Object name (e.g. contract)"),
|
|
3989
|
+
label: z.string().optional(),
|
|
3990
|
+
fields: z.array(z.object({
|
|
3991
|
+
name: z.string(),
|
|
3992
|
+
type: z.enum(FIELD_TYPES),
|
|
3993
|
+
label: z.string().optional(),
|
|
3994
|
+
options: z.array(z.string()).optional()
|
|
3995
|
+
})).describe("Field definitions")
|
|
3996
|
+
})
|
|
3997
|
+
}, ({ name, label, fields }) => handleDefineCustomObject({
|
|
3998
|
+
name,
|
|
3999
|
+
...label ? { label } : {},
|
|
4000
|
+
fields
|
|
4001
|
+
}, dataDir));
|
|
4002
|
+
server.registerTool("create_record", {
|
|
4003
|
+
description: "Create a record of a custom object. Values are validated against the schema. rep+.",
|
|
4004
|
+
inputSchema: z.object({
|
|
4005
|
+
object: z.string().describe("Custom object name"),
|
|
4006
|
+
values: z.record(z.string()).describe("Field values (key=value)")
|
|
4007
|
+
})
|
|
4008
|
+
}, ({ object, values }) => handleCreateRecord({
|
|
4009
|
+
object,
|
|
4010
|
+
values
|
|
4011
|
+
}, dataDir));
|
|
4012
|
+
server.registerTool("list_records", {
|
|
4013
|
+
description: "List records of a custom object.",
|
|
4014
|
+
inputSchema: z.object({ object: z.string().describe("Custom object name") })
|
|
4015
|
+
}, ({ object }) => handleListRecords({ object }, dataDir));
|
|
4016
|
+
server.registerTool("list_custom_objects", {
|
|
4017
|
+
description: "List all defined custom objects and their field schemas.",
|
|
4018
|
+
inputSchema: z.object({})
|
|
4019
|
+
}, () => handleListCustomObjects(dataDir));
|
|
4020
|
+
}
|
|
4021
|
+
//#endregion
|
|
4022
|
+
//#region src/mcp/server.ts
|
|
4023
|
+
function surveyThankYouPage(score, comment) {
|
|
4024
|
+
return `<!DOCTYPE html><html><head><meta charset="utf-8"><title>Thank you</title>
|
|
4025
|
+
<style>body{font-family:sans-serif;max-width:480px;margin:80px auto;text-align:center;padding:0 20px}
|
|
4026
|
+
h1{font-size:2.5em;margin-bottom:.3em}p{color:#555;font-size:1.1em}</style></head>
|
|
4027
|
+
<body><h1>${score >= 9 ? "🎉" : score >= 7 ? "🙂" : "🙏"}</h1><h2>Thank you for your feedback!</h2>
|
|
4028
|
+
<p>You rated us <strong>${score}/10</strong>.${comment ? `<br>Your comment: <em>"${String(comment).slice(0, 200).replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">")}"</em>` : ""}</p>
|
|
4029
|
+
<p style="margin-top:40px;color:#aaa;font-size:.85em">Powered by DatasynxOpenCRM</p>
|
|
4030
|
+
</body></html>`;
|
|
4031
|
+
}
|
|
4032
|
+
function createMcpServer() {
|
|
4033
|
+
const server = new McpServer({
|
|
4034
|
+
name: "datasynx-opencrm",
|
|
4035
|
+
version: "0.1.0"
|
|
4036
|
+
});
|
|
4037
|
+
registerGetCapabilities(server);
|
|
4038
|
+
registerGetActiveSession(server);
|
|
4039
|
+
registerGetCustomerContext(server);
|
|
4040
|
+
registerSearchCustomerKnowledge(server);
|
|
4041
|
+
registerListCustomers(server);
|
|
4042
|
+
registerLogInteraction(server);
|
|
4043
|
+
registerUpdateDeal(server);
|
|
4044
|
+
registerExportCustomer(server);
|
|
4045
|
+
registerUpdateCustomerFacts(server);
|
|
4046
|
+
registerGetDealHealth(server);
|
|
4047
|
+
registerGetPipelineForecast(server);
|
|
4048
|
+
registerSummarizeMeeting(server);
|
|
4049
|
+
registerGetPipelineStages(server);
|
|
4050
|
+
registerGetMarketIntelligence(server);
|
|
4051
|
+
registerGetRelationshipGraph(server);
|
|
4052
|
+
registerGetRelationshipHealth(server);
|
|
4053
|
+
registerRunDealAgent(server);
|
|
4054
|
+
registerApproveAgentAction(server);
|
|
4055
|
+
registerSimulateRevenue(server);
|
|
4056
|
+
registerGetPlaybook(server);
|
|
4057
|
+
registerCreatePlaybook(server);
|
|
4058
|
+
registerListPlaybooks(server);
|
|
4059
|
+
registerDistillPlaybook(server);
|
|
4060
|
+
registerPursueGoal(server);
|
|
4061
|
+
registerGetGoalStatus(server);
|
|
4062
|
+
registerRegisterPushSubscription(server);
|
|
4063
|
+
registerGetPushStatus(server);
|
|
4064
|
+
registerGetOrgIntelligence(server);
|
|
4065
|
+
registerOpenDealRoom(server);
|
|
4066
|
+
registerGetProactiveBriefing(server);
|
|
4067
|
+
registerListEmailTemplates(server);
|
|
4068
|
+
registerGetEmailTemplate(server);
|
|
4069
|
+
registerDraftEmail(server);
|
|
4070
|
+
registerEnrollInSequence(server);
|
|
4071
|
+
registerListSequenceEnrollments(server);
|
|
4072
|
+
registerUnenrollFromSequence(server);
|
|
4073
|
+
registerListSequences(server);
|
|
4074
|
+
registerGenerateQuote(server);
|
|
4075
|
+
registerGetQuoteStatus(server);
|
|
4076
|
+
registerGetBookingLink(server);
|
|
4077
|
+
registerCreateTicket(server);
|
|
4078
|
+
registerUpdateTicket(server);
|
|
4079
|
+
registerListTickets(server);
|
|
4080
|
+
registerCloseTicket(server);
|
|
4081
|
+
registerSendNpsSurvey(server);
|
|
4082
|
+
registerGetSurveyResults(server);
|
|
4083
|
+
registerSearchKnowledgeBase(server);
|
|
4084
|
+
registerCreateKbArticle(server);
|
|
4085
|
+
registerBackupNow(server);
|
|
4086
|
+
registerListBackups(server);
|
|
4087
|
+
registerTriggerSync(server);
|
|
4088
|
+
registerGetAuditLog(server);
|
|
4089
|
+
registerCustomObjectTools(server);
|
|
4090
|
+
registerPrompts(server);
|
|
4091
|
+
registerResources(server);
|
|
4092
|
+
return server;
|
|
4093
|
+
}
|
|
4094
|
+
async function startStdio() {
|
|
4095
|
+
await initOAuthFromDisk(process.cwd());
|
|
4096
|
+
const server = createMcpServer();
|
|
4097
|
+
const transport = new StdioServerTransport();
|
|
4098
|
+
await server.connect(transport);
|
|
4099
|
+
console.error("DatasynxOpenCRM MCP Server running via stdio");
|
|
4100
|
+
}
|
|
4101
|
+
async function startHttp(port = 3847) {
|
|
4102
|
+
await initOAuthFromDisk(process.cwd());
|
|
4103
|
+
const { default: express } = await import("express");
|
|
4104
|
+
const app = express();
|
|
4105
|
+
app.use(express.json());
|
|
4106
|
+
const server = createMcpServer();
|
|
4107
|
+
const dataDir = process.cwd();
|
|
4108
|
+
app.get("/.well-known/oauth-protected-resource", (req, res) => {
|
|
4109
|
+
const base = `${req.protocol}://${req.get("host") ?? "localhost"}`;
|
|
4110
|
+
res.json(protectedResourceMetadata(`${base}/mcp`));
|
|
4111
|
+
});
|
|
4112
|
+
app.post("/mcp", async (req, res) => {
|
|
4113
|
+
if (isAuthRequired(dataDir)) {
|
|
4114
|
+
const auth = verifyBearer(req.headers["authorization"], dataDir);
|
|
4115
|
+
if (!auth.ok) {
|
|
4116
|
+
const base = `${req.protocol}://${req.get("host") ?? "localhost"}`;
|
|
4117
|
+
res.status(401).set("WWW-Authenticate", wwwAuthenticateHeader(`${base}/.well-known/oauth-protected-resource`)).json({ error: "unauthorized" });
|
|
4118
|
+
return;
|
|
4119
|
+
}
|
|
4120
|
+
if (auth.actor) process.env["DXCRM_ACTOR"] = auth.actor;
|
|
4121
|
+
}
|
|
4122
|
+
const transport = new StreamableHTTPServerTransport({ enableJsonResponse: true });
|
|
4123
|
+
transport.onclose = () => {};
|
|
4124
|
+
res.on("close", () => {
|
|
4125
|
+
transport.close();
|
|
4126
|
+
});
|
|
4127
|
+
await server.connect(transport);
|
|
4128
|
+
await transport.handleRequest(req, res, req.body);
|
|
4129
|
+
});
|
|
4130
|
+
app.get("/health", (_req, res) => {
|
|
4131
|
+
res.json({
|
|
4132
|
+
status: "ok",
|
|
4133
|
+
server: "datasynx-opencrm",
|
|
4134
|
+
version: "0.1.0"
|
|
4135
|
+
});
|
|
4136
|
+
});
|
|
4137
|
+
app.get("/sessions", async (_req, res) => {
|
|
4138
|
+
try {
|
|
4139
|
+
const { readAllSessions } = await import("./session-mWHA71Lw.js");
|
|
4140
|
+
const sessions = readAllSessions(dataDir);
|
|
4141
|
+
res.json({ sessions });
|
|
4142
|
+
} catch {
|
|
4143
|
+
res.json({ sessions: [] });
|
|
4144
|
+
}
|
|
4145
|
+
});
|
|
4146
|
+
app.post("/webhooks/gmail", async (req, res) => {
|
|
4147
|
+
const token = process.env["GMAIL_PUBSUB_TOKEN"] ?? "";
|
|
4148
|
+
if (!verifyGmailPubSubSignature(req.headers["authorization"], token)) {
|
|
4149
|
+
res.status(401).json({ error: "unauthorized" });
|
|
4150
|
+
return;
|
|
4151
|
+
}
|
|
4152
|
+
const payload = decodeGmailPubSubPayload(req.body);
|
|
4153
|
+
if (!payload) {
|
|
4154
|
+
res.status(400).json({ error: "invalid_payload" });
|
|
4155
|
+
return;
|
|
4156
|
+
}
|
|
4157
|
+
const result = await handleGmailPushEvent(dataDir, payload, "").catch(() => ({
|
|
4158
|
+
processed: 0,
|
|
4159
|
+
slug: null
|
|
4160
|
+
}));
|
|
4161
|
+
res.json({
|
|
4162
|
+
ok: true,
|
|
4163
|
+
processed: result.processed
|
|
4164
|
+
});
|
|
4165
|
+
});
|
|
4166
|
+
app.all("/webhooks/microsoft", async (req, res) => {
|
|
4167
|
+
const validation = handleMicrosoftValidationRequest(req.query);
|
|
4168
|
+
if (validation.isValidation) {
|
|
4169
|
+
res.setHeader("content-type", "text/plain");
|
|
4170
|
+
res.status(200).send(validation.token);
|
|
4171
|
+
return;
|
|
4172
|
+
}
|
|
4173
|
+
const clientState = process.env["MS_GRAPH_CLIENT_STATE"] ?? "";
|
|
4174
|
+
const body = req.body;
|
|
4175
|
+
if (!verifyMicrosoftGraphSignature(body, clientState)) {
|
|
4176
|
+
res.status(401).json({ error: "unauthorized" });
|
|
4177
|
+
return;
|
|
4178
|
+
}
|
|
4179
|
+
const result = await handleMicrosoftPushEvent(dataDir, body.value ?? [], "").catch(() => ({
|
|
4180
|
+
processed: 0,
|
|
4181
|
+
skipped: 0
|
|
4182
|
+
}));
|
|
4183
|
+
res.json({
|
|
4184
|
+
ok: true,
|
|
4185
|
+
...result
|
|
4186
|
+
});
|
|
4187
|
+
});
|
|
4188
|
+
app.post("/webhooks/slack", express.text({ type: "*/*" }), async (req, res) => {
|
|
4189
|
+
const rawBody = req.body;
|
|
4190
|
+
const signingSecret = process.env["SLACK_SIGNING_SECRET"] ?? "";
|
|
4191
|
+
if (!verifySlackSignature(rawBody, req.headers, signingSecret)) {
|
|
4192
|
+
res.status(401).json({ error: "unauthorized" });
|
|
4193
|
+
return;
|
|
4194
|
+
}
|
|
4195
|
+
let parsed;
|
|
4196
|
+
try {
|
|
4197
|
+
parsed = JSON.parse(rawBody);
|
|
4198
|
+
} catch {
|
|
4199
|
+
res.status(400).json({ error: "invalid_json" });
|
|
4200
|
+
return;
|
|
4201
|
+
}
|
|
4202
|
+
const verification = handleSlackUrlVerification(parsed);
|
|
4203
|
+
if (verification.isVerification) {
|
|
4204
|
+
res.json({ challenge: verification.challenge });
|
|
4205
|
+
return;
|
|
4206
|
+
}
|
|
4207
|
+
if (!parsed.event) {
|
|
4208
|
+
res.json({
|
|
4209
|
+
ok: true,
|
|
4210
|
+
processed: 0
|
|
4211
|
+
});
|
|
4212
|
+
return;
|
|
4213
|
+
}
|
|
4214
|
+
const botToken = process.env["SLACK_BOT_TOKEN"] ?? "";
|
|
4215
|
+
const result = await handleSlackPushEvent(dataDir, parsed.event, botToken, { ...parsed.team_id !== void 0 ? { teamId: parsed.team_id } : {} }).catch(() => ({
|
|
4216
|
+
processed: 0,
|
|
4217
|
+
skipped: 1
|
|
4218
|
+
}));
|
|
4219
|
+
res.json({
|
|
4220
|
+
ok: true,
|
|
4221
|
+
...result
|
|
4222
|
+
});
|
|
4223
|
+
});
|
|
4224
|
+
app.get("/survey/respond", async (req, res) => {
|
|
4225
|
+
const { token, score, comment } = req.query;
|
|
4226
|
+
if (!token) {
|
|
4227
|
+
res.status(400).send("<h2>Invalid survey link.</h2>");
|
|
4228
|
+
return;
|
|
4229
|
+
}
|
|
4230
|
+
if (comment === "true") {
|
|
4231
|
+
res.setHeader("content-type", "text/html");
|
|
4232
|
+
res.send(`<!DOCTYPE html><html><head><meta charset="utf-8"><title>Survey Comment</title>
|
|
4233
|
+
<style>body{font-family:sans-serif;max-width:520px;margin:60px auto;padding:0 20px}
|
|
4234
|
+
textarea{width:100%;padding:10px;font-size:1em;border:1px solid #ccc;border-radius:4px}
|
|
4235
|
+
input[type=number]{width:80px;padding:8px;font-size:1em}
|
|
4236
|
+
button{margin-top:12px;padding:12px 28px;background:#1a1a2e;color:#fff;border:none;border-radius:4px;font-size:1em;cursor:pointer}</style></head>
|
|
4237
|
+
<body><h2>Leave a comment</h2>
|
|
4238
|
+
<form method="POST" action="/survey/respond">
|
|
4239
|
+
<input type="hidden" name="token" value="${String(token).replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<")}">
|
|
4240
|
+
<label>Your score (0–10):<br><input type="number" name="score" min="0" max="10" required></label><br><br>
|
|
4241
|
+
<label>Comment (optional):<br><textarea name="comment" rows="5" placeholder="What can we improve?"></textarea></label><br>
|
|
4242
|
+
<button type="submit">Submit</button>
|
|
4243
|
+
</form></body></html>`);
|
|
4244
|
+
return;
|
|
4245
|
+
}
|
|
4246
|
+
const numScore = score !== void 0 ? parseInt(score, 10) : NaN;
|
|
4247
|
+
if (isNaN(numScore) || numScore < 0 || numScore > 10) {
|
|
4248
|
+
res.status(400).send("<h2>Invalid score. Please use the link from your email.</h2>");
|
|
4249
|
+
return;
|
|
4250
|
+
}
|
|
4251
|
+
const { recordSurveyResponse } = await import("./survey-engine-C06hcQt3.js");
|
|
4252
|
+
await recordSurveyResponse(dataDir, token, numScore).catch(() => null);
|
|
4253
|
+
res.setHeader("content-type", "text/html");
|
|
4254
|
+
res.send(surveyThankYouPage(numScore));
|
|
4255
|
+
});
|
|
4256
|
+
app.post("/survey/respond", express.urlencoded({ extended: false }), async (req, res) => {
|
|
4257
|
+
const { token, score, comment: commentText } = req.body;
|
|
4258
|
+
if (!token) {
|
|
4259
|
+
res.status(400).send("<h2>Invalid survey link.</h2>");
|
|
4260
|
+
return;
|
|
4261
|
+
}
|
|
4262
|
+
const numScore = score !== void 0 ? parseInt(score, 10) : NaN;
|
|
4263
|
+
if (isNaN(numScore) || numScore < 0 || numScore > 10) {
|
|
4264
|
+
res.status(400).send("<h2>Invalid score. Please go back and enter a number between 0 and 10.</h2>");
|
|
4265
|
+
return;
|
|
4266
|
+
}
|
|
4267
|
+
const { recordSurveyResponse } = await import("./survey-engine-C06hcQt3.js");
|
|
4268
|
+
await recordSurveyResponse(dataDir, token, numScore, commentText || void 0).catch(() => null);
|
|
4269
|
+
res.setHeader("content-type", "text/html");
|
|
4270
|
+
res.send(surveyThankYouPage(numScore, commentText));
|
|
4271
|
+
});
|
|
4272
|
+
app.listen(port, () => {
|
|
4273
|
+
console.error(`DatasynxOpenCRM MCP Server running on http://0.0.0.0:${port}/mcp`);
|
|
4274
|
+
});
|
|
4275
|
+
}
|
|
4276
|
+
if ((process.env["DXCRM_MCP_MODE"] ?? "stdio") === "http") startHttp(parseInt(process.env["DXCRM_MCP_PORT"] ?? "3847", 10)).catch((err) => {
|
|
4277
|
+
console.error("MCP Server fatal error:", err.message);
|
|
4278
|
+
process.exit(1);
|
|
4279
|
+
});
|
|
4280
|
+
else startStdio().catch((err) => {
|
|
4281
|
+
console.error("MCP Server fatal error:", err.message);
|
|
4282
|
+
process.exit(1);
|
|
4283
|
+
});
|
|
4284
|
+
//#endregion
|
|
4285
|
+
export { startHttp, startStdio };
|
|
4286
|
+
|
|
4287
|
+
//# sourceMappingURL=server-Dyva03K8.js.map
|