@e9n/pi-personal-crm 0.2.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/src/tool.ts ADDED
@@ -0,0 +1,680 @@
1
+ /**
2
+ * CRM Pi Tool — contact lookup, interaction logging, search.
3
+ *
4
+ * Conversational CRM operations accessible from Pi agent prompts.
5
+ */
6
+
7
+ import { Type } from "@sinclair/typebox";
8
+ import { StringEnum } from "@mariozechner/pi-ai";
9
+ import { getCrmStore } from "./store.ts";
10
+
11
+ /** Sanitize a URL: only allow http(s). Returns cleaned URL or undefined. */
12
+ function sanitizeUrl(value: unknown): string | undefined {
13
+ if (value == null || value === "") return undefined;
14
+ const s = String(value).trim();
15
+ if (!s) return undefined;
16
+ if (/^https?:\/\//i.test(s)) return s;
17
+ if (!s.includes("://")) return `https://${s}`;
18
+ throw new Error("Invalid URL protocol — only http and https are allowed");
19
+ }
20
+
21
+ // Note: ExtensionAPI is from @mariozechner/pi-coding-agent
22
+ // We define minimal interface here to avoid hard dependency
23
+ interface ExtensionAPI {
24
+ registerTool(tool: any): void;
25
+ on(event: string, handler: (...args: any[]) => any): void;
26
+ }
27
+
28
+ /**
29
+ * Register the CRM tool with Pi.
30
+ * @param pi ExtensionAPI from Pi coding agent
31
+ */
32
+ export function registerCrmTool(pi: ExtensionAPI): void {
33
+ // ── System prompt injection ───────────────────────────────
34
+
35
+ pi.on("before_agent_start", async (event: any) => {
36
+ return {
37
+ systemPrompt:
38
+ event.systemPrompt +
39
+ "\n\n---\n\n" +
40
+ "## CRM Tool\n\n" +
41
+ "You have access to a personal CRM system via the `crm` tool.\n\n" +
42
+ "**Common workflows:**\n" +
43
+ "- \"Tell me about John Doe\" → crm.contact with name=\"John Doe\"\n" +
44
+ "- \"Who works at Acme?\" → crm.search with query=\"Acme\"\n" +
45
+ "- \"Log a call with John\" → crm.log_interaction\n" +
46
+ "- \"What's coming up this week?\" → crm.upcoming\n" +
47
+ "- \"Add Sarah's birthday\" → crm.add_reminder\n\n" +
48
+ "**Actions:**\n" +
49
+ "- search — Full-text search across contacts and companies\n" +
50
+ "- contact — Get full contact details with interactions, relationships, reminders, groups\n" +
51
+ "- add_contact — Create a new contact\n" +
52
+ "- update_contact — Update contact fields\n" +
53
+ "- delete_contact — Delete a contact\n" +
54
+ "- log_interaction — Log a call, meeting, email, note, or gift\n" +
55
+ "- add_reminder — Set a birthday, anniversary, or custom reminder\n" +
56
+ "- upcoming — Show upcoming birthdays and reminders\n" +
57
+ "- add_relationship — Link two contacts (spouse, colleague, etc.)\n" +
58
+ "- list_companies — List all companies\n" +
59
+ "- add_company — Create a new company\n" +
60
+ "- list_groups — List all groups\n" +
61
+ "- add_to_group — Add a contact to a group (creates group if needed)\n" +
62
+ "- remove_from_group — Remove a contact from a group\n" +
63
+ "- export_csv — Export all contacts as CSV\n" +
64
+ "- import_csv — Import contacts from CSV (with duplicate detection)\n\n" +
65
+ "**Interaction types:** call, meeting, email, note, gift, message\n\n" +
66
+ "When creating/updating contacts, capture: name, email, phone, company, birthday, tags, notes.\n" +
67
+ "Duplicate detection runs automatically on add_contact and import_csv (matches by email or name).",
68
+ };
69
+ });
70
+
71
+ // Helper to return text response
72
+ const text = (s: string) => ({ content: [{ type: "text" as const, text: s }], details: {} });
73
+
74
+ // ── search ──────────────────────────────────────────────────
75
+
76
+ pi.registerTool({
77
+ name: "crm",
78
+ label: "CRM",
79
+ description: "Search and manage contacts, companies, and interactions in the personal CRM.",
80
+ parameters: Type.Object({
81
+ action: StringEnum(
82
+ ["search", "contact", "add_contact", "update_contact", "log_interaction", "add_reminder", "upcoming", "list_companies", "add_company", "add_relationship", "list_groups", "add_to_group", "remove_from_group", "delete_contact", "export_csv", "import_csv"] as const,
83
+ { description: "CRM action to perform" },
84
+ ),
85
+
86
+ // Search
87
+ query: Type.Optional(Type.String({ description: "Search query (for search action)" })),
88
+
89
+ // Contact lookup
90
+ contact_id: Type.Optional(Type.Number({ description: "Contact ID (for contact, update_contact, log_interaction, add_reminder)" })),
91
+ name: Type.Optional(Type.String({ description: "Contact name to search (for contact action as alternative to ID)" })),
92
+
93
+ // Add/update contact
94
+ first_name: Type.Optional(Type.String({ description: "First name (required for add_contact)" })),
95
+ last_name: Type.Optional(Type.String({ description: "Last name" })),
96
+ email: Type.Optional(Type.String({ description: "Primary email address" })),
97
+ phone: Type.Optional(Type.String({ description: "Primary phone number" })),
98
+ emails: Type.Optional(Type.Array(Type.Object({
99
+ value: Type.String({ description: "Email address" }),
100
+ label: Type.Optional(Type.String({ description: "Label, e.g. 'Work', 'Personal'" })),
101
+ }), { description: "Multiple emails with optional labels" })),
102
+ phones: Type.Optional(Type.Array(Type.Object({
103
+ value: Type.String({ description: "Phone number" }),
104
+ label: Type.Optional(Type.String({ description: "Label, e.g. 'Mobile', 'Work', 'Home'" })),
105
+ }), { description: "Multiple phones with optional labels" })),
106
+ company_id: Type.Optional(Type.Number({ description: "Company ID" })),
107
+ company_name: Type.Optional(Type.String({ description: "Company name (will create if doesn't exist)" })),
108
+ birthday: Type.Optional(Type.String({ description: "Birthday in YYYY-MM-DD format" })),
109
+ anniversary: Type.Optional(Type.String({ description: "Anniversary in YYYY-MM-DD format" })),
110
+ tags: Type.Optional(Type.String({ description: "Comma-separated tags" })),
111
+ notes: Type.Optional(Type.String({ description: "Notes about the contact" })),
112
+
113
+ // Log interaction
114
+ interaction_type: Type.Optional(
115
+ StringEnum(["call", "meeting", "email", "note", "gift", "message"] as const, {
116
+ description: "Type of interaction",
117
+ }),
118
+ ),
119
+ summary: Type.Optional(Type.String({ description: "Interaction summary (required for log_interaction)" })),
120
+ interaction_notes: Type.Optional(Type.String({ description: "Detailed notes about the interaction" })),
121
+ happened_at: Type.Optional(Type.String({ description: "When it happened (ISO timestamp, defaults to now)" })),
122
+
123
+ // Add reminder
124
+ reminder_type: Type.Optional(
125
+ StringEnum(["birthday", "anniversary", "custom"] as const, {
126
+ description: "Type of reminder",
127
+ }),
128
+ ),
129
+ reminder_date: Type.Optional(Type.String({ description: "Reminder date in YYYY-MM-DD format" })),
130
+ reminder_message: Type.Optional(Type.String({ description: "Custom reminder message" })),
131
+
132
+ // Upcoming
133
+ days: Type.Optional(Type.Number({ description: "Number of days ahead to look (default: 7)" })),
134
+
135
+ // Company
136
+ industry: Type.Optional(Type.String({ description: "Company industry" })),
137
+ website: Type.Optional(Type.String({ description: "Company website URL" })),
138
+
139
+ // Relationship
140
+ related_contact_id: Type.Optional(Type.Number({ description: "Related contact ID (for add_relationship)" })),
141
+ relationship_type: Type.Optional(Type.String({ description: "Relationship type: spouse, child, parent, colleague, friend, etc." })),
142
+
143
+ // Group
144
+ group_id: Type.Optional(Type.Number({ description: "Group ID (for add_to_group, remove_from_group)" })),
145
+ group_name: Type.Optional(Type.String({ description: "Group name (for list_groups with new group creation, or add_to_group by name)" })),
146
+ group_description: Type.Optional(Type.String({ description: "Group description (when creating a new group)" })),
147
+
148
+ // Import
149
+ csv_data: Type.Optional(Type.String({ description: "CSV text to import (for import_csv)" })),
150
+ }),
151
+
152
+ async execute(_toolCallId: string, params: any, _signal: any, _onUpdate: any, _ctx: any) {
153
+ let crm: ReturnType<typeof getCrmStore>;
154
+ try {
155
+ crm = getCrmStore();
156
+ } catch {
157
+ return text("❌ CRM not available (extension not loaded)");
158
+ }
159
+
160
+ try {
161
+ // ── search ──────────────────────────────────────────
162
+
163
+ if (params.action === "search") {
164
+ if (!params.query) {
165
+ return text("❌ query is required for search");
166
+ }
167
+
168
+ const contacts = await crm.searchContacts(params.query, 20);
169
+ const companies = await crm.searchCompanies(params.query, 10);
170
+
171
+ if (contacts.length === 0 && companies.length === 0) {
172
+ return text(`🔍 No results found for "${params.query}"`);
173
+ }
174
+
175
+ let result = `🔍 Search results for "${params.query}":\n\n`;
176
+
177
+ if (contacts.length > 0) {
178
+ result += `**Contacts (${contacts.length}):**\n`;
179
+ for (const c of contacts) {
180
+ const company = c.company_name ? ` @ ${c.company_name}` : "";
181
+ const email = c.email ? ` <${c.email}>` : "";
182
+ result += `- ${c.first_name} ${c.last_name || ""}${company}${email} (ID: ${c.id})\n`;
183
+ }
184
+ }
185
+
186
+ if (companies.length > 0) {
187
+ result += `\n**Companies (${companies.length}):**\n`;
188
+ for (const co of companies) {
189
+ const website = co.website ? ` — ${co.website}` : "";
190
+ result += `- ${co.name}${website} (ID: ${co.id})\n`;
191
+ }
192
+ }
193
+
194
+ return text(result.trim());
195
+ }
196
+
197
+ // ── contact ─────────────────────────────────────────
198
+
199
+ if (params.action === "contact") {
200
+ let contact = null;
201
+
202
+ if (params.contact_id) {
203
+ contact = await crm.getContact(params.contact_id);
204
+ } else if (params.name) {
205
+ // Search by name
206
+ const results = await crm.searchContacts(params.name, 5);
207
+ if (results.length === 0) {
208
+ return text(`❌ No contact found matching "${params.name}"`);
209
+ }
210
+ if (results.length > 1) {
211
+ let list = `🔍 Multiple contacts found for "${params.name}":\n\n`;
212
+ for (const c of results) {
213
+ list += `- ${c.first_name} ${c.last_name || ""} (${c.email || "no email"}) — ID: ${c.id}\n`;
214
+ }
215
+ list += `\nPlease specify contact_id.`;
216
+ return text(list);
217
+ }
218
+ contact = results[0];
219
+ } else {
220
+ return text("❌ Either contact_id or name is required");
221
+ }
222
+
223
+ if (!contact) {
224
+ return text(`❌ Contact not found`);
225
+ }
226
+
227
+ // Build contact card
228
+ let card = `👤 **${contact.first_name} ${contact.last_name || ""}**\n\n`;
229
+
230
+ if (contact.emails && contact.emails.length > 0) {
231
+ for (const e of contact.emails) {
232
+ card += `📧 ${e.value}${e.label ? ` (${e.label})` : ""}\n`;
233
+ }
234
+ } else if (contact.email) {
235
+ card += `📧 ${contact.email}\n`;
236
+ }
237
+ if (contact.phones && contact.phones.length > 0) {
238
+ for (const p of contact.phones) {
239
+ card += `📞 ${p.value}${p.label ? ` (${p.label})` : ""}\n`;
240
+ }
241
+ } else if (contact.phone) {
242
+ card += `📞 ${contact.phone}\n`;
243
+ }
244
+ if (contact.company_name) card += `🏢 ${contact.company_name}\n`;
245
+ if (contact.birthday) card += `🎂 Birthday: ${contact.birthday}\n`;
246
+ if (contact.anniversary) card += `💍 Anniversary: ${contact.anniversary}\n`;
247
+ if (contact.tags) card += `🏷️ Tags: ${contact.tags}\n`;
248
+ if (contact.notes) card += `\n📝 **Notes:**\n${contact.notes}\n`;
249
+
250
+ // Recent interactions
251
+ const interactions = await crm.getInteractions(contact.id);
252
+ if (interactions.length > 0) {
253
+ card += `\n**Recent Interactions (${interactions.length}):**\n`;
254
+ const recent = interactions.slice(0, 5);
255
+ for (const i of recent) {
256
+ const date = new Date(i.happened_at).toLocaleDateString();
257
+ card += `- ${i.interaction_type} (${date}): ${i.summary}\n`;
258
+ if (i.notes) card += ` ${i.notes}\n`;
259
+ }
260
+ }
261
+
262
+ // Relationships
263
+ const relationships = await crm.getRelationships(contact.id);
264
+ if (relationships.length > 0) {
265
+ card += `\n**Relationships:**\n`;
266
+ for (const r of relationships) {
267
+ card += `- ${r.relationship_type}: ${r.first_name} ${r.last_name}\n`;
268
+ }
269
+ }
270
+
271
+ // Reminders
272
+ const reminders = await crm.getReminders(contact.id);
273
+ if (reminders.length > 0) {
274
+ card += `\n**Reminders:**\n`;
275
+ for (const r of reminders) {
276
+ card += `- ${r.reminder_type}: ${r.reminder_date}`;
277
+ if (r.message) card += ` — ${r.message}`;
278
+ card += `\n`;
279
+ }
280
+ }
281
+
282
+ // Groups
283
+ const groups = await crm.getContactGroups(contact.id);
284
+ if (groups.length > 0) {
285
+ card += `\n**Groups:**\n`;
286
+ for (const g of groups) {
287
+ card += `- ${g.name}`;
288
+ if (g.description) card += ` — ${g.description}`;
289
+ card += `\n`;
290
+ }
291
+ }
292
+
293
+ card += `\n_Contact ID: ${contact.id}_`;
294
+
295
+ return text(card.trim());
296
+ }
297
+
298
+ // ── add_contact ─────────────────────────────────────
299
+
300
+ if (params.action === "add_contact") {
301
+ if (!params.first_name) {
302
+ return text("❌ first_name is required");
303
+ }
304
+
305
+ // Check for duplicates
306
+ const dupes = await crm.findDuplicates({
307
+ email: params.email,
308
+ first_name: params.first_name,
309
+ last_name: params.last_name,
310
+ });
311
+ if (dupes.length > 0) {
312
+ const dupeList = dupes
313
+ .map(d => `- ${d.first_name} ${d.last_name || ""} (${d.email || "no email"}, ID: ${d.id})`)
314
+ .join("\n");
315
+ return text(
316
+ `⚠️ Possible duplicate(s) found:\n${dupeList}\n\n` +
317
+ `Use update_contact to modify an existing contact, or add with a distinguishing detail.`,
318
+ );
319
+ }
320
+
321
+ // Handle company by name
322
+ let company_id = params.company_id;
323
+ if (params.company_name && !company_id) {
324
+ const companies = await crm.searchCompanies(params.company_name, 1);
325
+ if (companies.length > 0) {
326
+ company_id = companies[0].id;
327
+ } else {
328
+ // Create company
329
+ const newCompany = await crm.createCompany({ name: params.company_name });
330
+ company_id = newCompany.id;
331
+ }
332
+ }
333
+
334
+ const contact = await crm.createContact({
335
+ first_name: params.first_name,
336
+ last_name: params.last_name,
337
+ email: params.email,
338
+ phone: params.phone,
339
+ emails: params.emails,
340
+ phones: params.phones,
341
+ company_id,
342
+ birthday: params.birthday,
343
+ anniversary: params.anniversary,
344
+ tags: params.tags,
345
+ notes: params.notes,
346
+ });
347
+
348
+ return text(
349
+ `✅ Created contact: ${contact.first_name} ${contact.last_name || ""} (ID: ${contact.id})`,
350
+ );
351
+ }
352
+
353
+ // ── update_contact ──────────────────────────────────
354
+
355
+ if (params.action === "update_contact") {
356
+ if (!params.contact_id) {
357
+ return text("❌ contact_id is required");
358
+ }
359
+
360
+ const updated = await crm.updateContact(params.contact_id, {
361
+ first_name: params.first_name,
362
+ last_name: params.last_name,
363
+ email: params.email,
364
+ phone: params.phone,
365
+ emails: params.emails,
366
+ phones: params.phones,
367
+ company_id: params.company_id,
368
+ birthday: params.birthday,
369
+ anniversary: params.anniversary,
370
+ tags: params.tags,
371
+ notes: params.notes,
372
+ });
373
+
374
+ if (!updated) {
375
+ return text(`❌ Contact ${params.contact_id} not found`);
376
+ }
377
+
378
+ return text(`✅ Updated contact: ${updated.first_name} ${updated.last_name || ""}`);
379
+ }
380
+
381
+ // ── log_interaction ─────────────────────────────────
382
+
383
+ if (params.action === "log_interaction") {
384
+ if (!params.contact_id) {
385
+ return text("❌ contact_id is required");
386
+ }
387
+ if (!params.summary) {
388
+ return text("❌ summary is required");
389
+ }
390
+ if (!params.interaction_type) {
391
+ return text("❌ interaction_type is required (call, meeting, email, note, gift, message)");
392
+ }
393
+
394
+ const interaction = await crm.createInteraction({
395
+ contact_id: params.contact_id,
396
+ interaction_type: params.interaction_type,
397
+ summary: params.summary,
398
+ notes: params.interaction_notes,
399
+ happened_at: params.happened_at,
400
+ });
401
+
402
+ const contact = await crm.getContact(params.contact_id);
403
+ const contactName = contact ? `${contact.first_name} ${contact.last_name || ""}` : `ID ${params.contact_id}`;
404
+
405
+ return text(
406
+ `✅ Logged ${params.interaction_type} with ${contactName}: ${params.summary}`,
407
+ );
408
+ }
409
+
410
+ // ── add_reminder ────────────────────────────────────
411
+
412
+ if (params.action === "add_reminder") {
413
+ if (!params.contact_id) {
414
+ return text("❌ contact_id is required");
415
+ }
416
+ if (!params.reminder_type) {
417
+ return text("❌ reminder_type is required (birthday, anniversary, custom)");
418
+ }
419
+ if (!params.reminder_date) {
420
+ return text("❌ reminder_date is required (YYYY-MM-DD)");
421
+ }
422
+
423
+ const reminder = await crm.createReminder({
424
+ contact_id: params.contact_id,
425
+ reminder_type: params.reminder_type,
426
+ reminder_date: params.reminder_date,
427
+ message: params.reminder_message,
428
+ });
429
+
430
+ const contact = await crm.getContact(params.contact_id);
431
+ const contactName = contact ? `${contact.first_name} ${contact.last_name || ""}` : `ID ${params.contact_id}`;
432
+
433
+ return text(
434
+ `✅ Added ${params.reminder_type} reminder for ${contactName} on ${params.reminder_date}`,
435
+ );
436
+ }
437
+
438
+ // ── upcoming ────────────────────────────────────────
439
+
440
+ if (params.action === "upcoming") {
441
+ const days = params.days ?? 7;
442
+ const reminders = await crm.getUpcomingReminders(days);
443
+
444
+ if (reminders.length === 0) {
445
+ return text(`📅 No upcoming reminders in the next ${days} days`);
446
+ }
447
+
448
+ let result = `📅 Upcoming reminders (next ${days} days):\n\n`;
449
+ for (const r of reminders) {
450
+ const name = `${r.first_name} ${r.last_name || ""}`;
451
+ result += `- ${r.reminder_date}: ${r.reminder_type} — ${name}`;
452
+ if (r.message) result += ` (${r.message})`;
453
+ result += `\n`;
454
+ }
455
+
456
+ return text(result.trim());
457
+ }
458
+
459
+ // ── list_companies ──────────────────────────────────
460
+
461
+ if (params.action === "list_companies") {
462
+ const companies = await crm.getCompanies();
463
+
464
+ if (companies.length === 0) {
465
+ return text("🏢 No companies in CRM");
466
+ }
467
+
468
+ let result = `🏢 Companies (${companies.length}):\n\n`;
469
+ for (const co of companies) {
470
+ const website = co.website ? ` — ${co.website}` : "";
471
+ const industry = co.industry ? ` [${co.industry}]` : "";
472
+ result += `- ${co.name}${industry}${website} (ID: ${co.id})\n`;
473
+ }
474
+
475
+ return text(result.trim());
476
+ }
477
+
478
+ // ── add_company ─────────────────────────────────────
479
+
480
+ if (params.action === "add_company") {
481
+ if (!params.company_name) {
482
+ return text("❌ company_name is required");
483
+ }
484
+
485
+ let website: string | undefined;
486
+ try { website = sanitizeUrl(params.website); }
487
+ catch (e: any) { return text(`❌ ${e.message}`); }
488
+
489
+ const company = await crm.createCompany({
490
+ name: params.company_name,
491
+ website,
492
+ industry: params.industry,
493
+ notes: params.notes,
494
+ });
495
+
496
+ return text(`✅ Created company: ${company.name} (ID: ${company.id})`);
497
+ }
498
+
499
+ // ── delete_contact ──────────────────────────────────
500
+
501
+ if (params.action === "delete_contact") {
502
+ if (!params.contact_id) {
503
+ return text("❌ contact_id is required");
504
+ }
505
+
506
+ const contact = await crm.getContact(params.contact_id);
507
+ if (!contact) {
508
+ return text(`❌ Contact ${params.contact_id} not found`);
509
+ }
510
+
511
+ const name = `${contact.first_name} ${contact.last_name || ""}`.trim();
512
+ await crm.deleteContact(params.contact_id);
513
+ return text(`✅ Deleted contact: ${name} (ID: ${params.contact_id})`);
514
+ }
515
+
516
+ // ── add_relationship ────────────────────────────────
517
+
518
+ if (params.action === "add_relationship") {
519
+ if (!params.contact_id) {
520
+ return text("❌ contact_id is required");
521
+ }
522
+ if (!params.related_contact_id) {
523
+ return text("❌ related_contact_id is required");
524
+ }
525
+ if (!params.relationship_type) {
526
+ return text("❌ relationship_type is required (e.g. spouse, colleague, friend, parent, child)");
527
+ }
528
+
529
+ const relationship = await crm.createRelationship({
530
+ contact_id: params.contact_id,
531
+ related_contact_id: params.related_contact_id,
532
+ relationship_type: params.relationship_type,
533
+ notes: params.notes,
534
+ });
535
+
536
+ const c1 = await crm.getContact(params.contact_id);
537
+ const c2 = await crm.getContact(params.related_contact_id);
538
+ const name1 = c1 ? `${c1.first_name} ${c1.last_name || ""}`.trim() : `ID ${params.contact_id}`;
539
+ const name2 = c2 ? `${c2.first_name} ${c2.last_name || ""}`.trim() : `ID ${params.related_contact_id}`;
540
+
541
+ return text(`✅ Added relationship: ${name1} ↔ ${name2} (${params.relationship_type})`);
542
+ }
543
+
544
+ // ── list_groups ─────────────────────────────────────
545
+
546
+ if (params.action === "list_groups") {
547
+ const groups = await crm.getGroups();
548
+
549
+ if (groups.length === 0) {
550
+ return text("📂 No groups in CRM");
551
+ }
552
+
553
+ let result = `📂 Groups (${groups.length}):\n\n`;
554
+ for (const g of groups) {
555
+ const members = await crm.getGroupMembers(g.id);
556
+ const desc = g.description ? ` — ${g.description}` : "";
557
+ result += `- ${g.name}${desc} (${members.length} members, ID: ${g.id})\n`;
558
+ }
559
+
560
+ return text(result.trim());
561
+ }
562
+
563
+ // ── add_to_group ────────────────────────────────────
564
+
565
+ if (params.action === "add_to_group") {
566
+ if (!params.contact_id) {
567
+ return text("❌ contact_id is required");
568
+ }
569
+
570
+ let groupId = params.group_id;
571
+
572
+ // Resolve group by name, create if needed
573
+ if (!groupId && params.group_name) {
574
+ const groups = await crm.getGroups();
575
+ const existing = groups.find(g => g.name.toLowerCase() === params.group_name.toLowerCase());
576
+ if (existing) {
577
+ groupId = existing.id;
578
+ } else {
579
+ const newGroup = await crm.createGroup({
580
+ name: params.group_name,
581
+ description: params.group_description,
582
+ });
583
+ groupId = newGroup.id;
584
+ }
585
+ }
586
+
587
+ if (!groupId) {
588
+ return text("❌ group_id or group_name is required");
589
+ }
590
+
591
+ await crm.addGroupMember(groupId, params.contact_id);
592
+
593
+ const contact = await crm.getContact(params.contact_id);
594
+ const contactName = contact ? `${contact.first_name} ${contact.last_name || ""}`.trim() : `ID ${params.contact_id}`;
595
+ const groups = await crm.getGroups();
596
+ const group = groups.find(g => g.id === groupId);
597
+ const groupName = group ? group.name : `ID ${groupId}`;
598
+
599
+ return text(`✅ Added ${contactName} to group "${groupName}"`);
600
+ }
601
+
602
+ // ── remove_from_group ───────────────────────────────
603
+
604
+ if (params.action === "remove_from_group") {
605
+ if (!params.contact_id) {
606
+ return text("❌ contact_id is required");
607
+ }
608
+
609
+ let groupId = params.group_id;
610
+
611
+ // Resolve group by name
612
+ if (!groupId && params.group_name) {
613
+ const groups = await crm.getGroups();
614
+ const existing = groups.find(g => g.name.toLowerCase() === params.group_name.toLowerCase());
615
+ if (existing) {
616
+ groupId = existing.id;
617
+ }
618
+ }
619
+
620
+ if (!groupId) {
621
+ return text("❌ group_id or group_name is required");
622
+ }
623
+
624
+ const ok = await crm.removeGroupMember(groupId, params.contact_id);
625
+
626
+ if (!ok) {
627
+ return text("❌ Contact is not in that group");
628
+ }
629
+
630
+ const contact = await crm.getContact(params.contact_id);
631
+ const contactName = contact ? `${contact.first_name} ${contact.last_name || ""}`.trim() : `ID ${params.contact_id}`;
632
+ const groups = await crm.getGroups();
633
+ const group = groups.find(g => g.id === groupId);
634
+ const groupName = group ? group.name : `ID ${groupId}`;
635
+
636
+ return text(`✅ Removed ${contactName} from group "${groupName}"`);
637
+ }
638
+
639
+ // ── export_csv ──────────────────────────────────────
640
+
641
+ if (params.action === "export_csv") {
642
+ const csv = await crm.exportContactsCsv();
643
+ const lines = csv.split("\n");
644
+ return text(
645
+ `📊 Exported ${lines.length - 1} contact(s) as CSV:\n\n\`\`\`csv\n${csv}\n\`\`\``,
646
+ );
647
+ }
648
+
649
+ // ── import_csv ──────────────────────────────────────
650
+
651
+ if (params.action === "import_csv") {
652
+ if (!params.csv_data) {
653
+ return text("❌ csv_data is required (CSV text with header row)");
654
+ }
655
+
656
+ const result = await crm.importContactsCsv(params.csv_data);
657
+
658
+ let msg = `📊 Import complete:\n✅ Created: ${result.created}\n⏭ Skipped: ${result.skipped}`;
659
+
660
+ if (result.duplicates.length > 0) {
661
+ msg += `\n\n⚠️ Duplicates found (skipped):`;
662
+ for (const d of result.duplicates) {
663
+ msg += `\n- Row ${d.row}: "${d.incoming}" matches ${d.existing.first_name} ${d.existing.last_name || ""} (ID: ${d.existing.id})`;
664
+ }
665
+ }
666
+
667
+ if (result.errors.length > 0) {
668
+ msg += `\n\n❌ Errors:\n${result.errors.map(e => `- ${e}`).join("\n")}`;
669
+ }
670
+
671
+ return text(msg);
672
+ }
673
+
674
+ return text(`❌ Unknown action: ${params.action}`);
675
+ } catch (error: any) {
676
+ return text(`❌ CRM error: ${error.message}`);
677
+ }
678
+ },
679
+ });
680
+ }