@hasna/contacts 0.3.2 → 0.4.1

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/dist/mcp/index.js CHANGED
@@ -1,13 +1,32 @@
1
1
  #!/usr/bin/env bun
2
2
  // @bun
3
-
4
- // src/mcp/index.ts
5
- import { Server } from "@modelcontextprotocol/sdk/server/index.js";
6
- import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
7
- import {
8
- CallToolRequestSchema,
9
- ListToolsRequestSchema
10
- } from "@modelcontextprotocol/sdk/types.js";
3
+ var __create = Object.create;
4
+ var __getProtoOf = Object.getPrototypeOf;
5
+ var __defProp = Object.defineProperty;
6
+ var __getOwnPropNames = Object.getOwnPropertyNames;
7
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
8
+ var __toESM = (mod, isNodeMode, target) => {
9
+ target = mod != null ? __create(__getProtoOf(mod)) : {};
10
+ const to = isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", { value: mod, enumerable: true }) : target;
11
+ for (let key of __getOwnPropNames(mod))
12
+ if (!__hasOwnProp.call(to, key))
13
+ __defProp(to, key, {
14
+ get: () => mod[key],
15
+ enumerable: true
16
+ });
17
+ return to;
18
+ };
19
+ var __export = (target, all) => {
20
+ for (var name in all)
21
+ __defProp(target, name, {
22
+ get: all[name],
23
+ enumerable: true,
24
+ configurable: true,
25
+ set: (newValue) => all[name] = () => newValue
26
+ });
27
+ };
28
+ var __esm = (fn, res) => () => (fn && (res = fn(fn = 0)), res);
29
+ var __require = import.meta.require;
11
30
 
12
31
  // src/db/database.ts
13
32
  import { Database } from "bun:sqlite";
@@ -26,8 +45,47 @@ function ensureDir(filePath) {
26
45
  if (!existsSync(dir))
27
46
  mkdirSync(dir, { recursive: true });
28
47
  }
29
- var MIGRATIONS = [
30
- `
48
+ function getDatabase(path) {
49
+ if (_db)
50
+ return _db;
51
+ const dbPath = path || getDbPath();
52
+ ensureDir(dbPath);
53
+ const db = new Database(dbPath, { create: true });
54
+ db.exec("PRAGMA journal_mode=WAL");
55
+ db.exec("PRAGMA foreign_keys=ON");
56
+ runMigrations(db);
57
+ _db = db;
58
+ return db;
59
+ }
60
+ function uuid() {
61
+ return crypto.randomUUID();
62
+ }
63
+ function now() {
64
+ return new Date().toISOString();
65
+ }
66
+ function runMigrations(db) {
67
+ try {
68
+ const row = db.query("SELECT MAX(version) as v FROM _migrations").get();
69
+ const current = row?.v ?? -1;
70
+ for (let i = current + 1;i < MIGRATIONS.length; i++) {
71
+ db.exec(MIGRATIONS[i]);
72
+ db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
73
+ }
74
+ } catch {
75
+ for (const m of MIGRATIONS) {
76
+ try {
77
+ db.exec(m);
78
+ } catch {}
79
+ }
80
+ try {
81
+ db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${MIGRATIONS.length - 1})`);
82
+ } catch {}
83
+ }
84
+ }
85
+ var MIGRATIONS, _db = null;
86
+ var init_database = __esm(() => {
87
+ MIGRATIONS = [
88
+ `
31
89
  CREATE TABLE IF NOT EXISTS companies (
32
90
  id TEXT PRIMARY KEY,
33
91
  name TEXT NOT NULL,
@@ -180,7 +238,7 @@ var MIGRATIONS = [
180
238
 
181
239
  CREATE TABLE IF NOT EXISTS _migrations (version INTEGER PRIMARY KEY);
182
240
  `,
183
- `
241
+ `
184
242
  ALTER TABLE contacts ADD COLUMN last_contacted_at TEXT;
185
243
  ALTER TABLE contacts ADD COLUMN website TEXT;
186
244
  ALTER TABLE contacts ADD COLUMN preferred_contact_method TEXT;
@@ -199,7 +257,7 @@ var MIGRATIONS = [
199
257
  PRIMARY KEY (contact_id, group_id)
200
258
  );
201
259
  `,
202
- `
260
+ `
203
261
  ALTER TABLE contacts ADD COLUMN status TEXT DEFAULT 'active';
204
262
  ALTER TABLE contacts ADD COLUMN follow_up_at TEXT;
205
263
  ALTER TABLE contacts ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
@@ -213,7 +271,7 @@ var MIGRATIONS = [
213
271
  PRIMARY KEY (company_id, group_id)
214
272
  );
215
273
  `,
216
- `
274
+ `
217
275
  CREATE TABLE IF NOT EXISTS company_relationships (
218
276
  id TEXT PRIMARY KEY,
219
277
  contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
@@ -226,7 +284,7 @@ var MIGRATIONS = [
226
284
  CREATE INDEX IF NOT EXISTS idx_company_relationships_contact ON company_relationships(contact_id);
227
285
  CREATE INDEX IF NOT EXISTS idx_company_relationships_company ON company_relationships(company_id);
228
286
  `,
229
- `
287
+ `
230
288
  CREATE TABLE IF NOT EXISTS contact_notes (
231
289
  id TEXT PRIMARY KEY,
232
290
  contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
@@ -237,7 +295,7 @@ var MIGRATIONS = [
237
295
 
238
296
  CREATE INDEX IF NOT EXISTS idx_contact_notes_contact ON contact_notes(contact_id);
239
297
  `,
240
- `
298
+ `
241
299
  ALTER TABLE companies ADD COLUMN is_owned_entity INTEGER NOT NULL DEFAULT 0;
242
300
  ALTER TABLE companies ADD COLUMN entity_type TEXT CHECK(entity_type IN ('operating','holding','dissolved','nonprofit','trust','branch','other'));
243
301
 
@@ -315,75 +373,71 @@ var MIGRATIONS = [
315
373
  );
316
374
 
317
375
  ALTER TABLE contact_notes ADD COLUMN company_id TEXT REFERENCES companies(id) ON DELETE SET NULL;
318
- `
319
- ];
320
- var _db = null;
321
- function getDatabase(path) {
322
- if (_db)
323
- return _db;
324
- const dbPath = path || getDbPath();
325
- ensureDir(dbPath);
326
- const db = new Database(dbPath, { create: true });
327
- db.exec("PRAGMA journal_mode=WAL");
328
- db.exec("PRAGMA foreign_keys=ON");
329
- runMigrations(db);
330
- _db = db;
331
- return db;
332
- }
333
- function uuid() {
334
- return crypto.randomUUID();
335
- }
336
- function now() {
337
- return new Date().toISOString();
338
- }
339
- function runMigrations(db) {
340
- try {
341
- const row = db.query("SELECT MAX(version) as v FROM _migrations").get();
342
- const current = row?.v ?? -1;
343
- for (let i = current + 1;i < MIGRATIONS.length; i++) {
344
- db.exec(MIGRATIONS[i]);
345
- db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${i})`);
346
- }
347
- } catch {
348
- for (const m of MIGRATIONS) {
349
- try {
350
- db.exec(m);
351
- } catch {}
352
- }
353
- try {
354
- db.exec(`INSERT OR REPLACE INTO _migrations(version) VALUES(${MIGRATIONS.length - 1})`);
355
- } catch {}
356
- }
357
- }
358
-
359
- // src/types/index.ts
360
- class ContactNotFoundError extends Error {
361
- constructor(id) {
362
- super(`Contact not found: ${id}`);
363
- this.name = "ContactNotFoundError";
364
- }
365
- }
376
+ `,
377
+ `
378
+ ALTER TABLE contacts ADD COLUMN do_not_contact INTEGER NOT NULL DEFAULT 0;
379
+ ALTER TABLE contacts ADD COLUMN priority INTEGER NOT NULL DEFAULT 3 CHECK(priority BETWEEN 1 AND 5);
380
+ ALTER TABLE contacts ADD COLUMN timezone TEXT;
366
381
 
367
- class CompanyNotFoundError extends Error {
368
- constructor(id) {
369
- super(`Company not found: ${id}`);
370
- this.name = "CompanyNotFoundError";
371
- }
372
- }
382
+ CREATE TABLE IF NOT EXISTS deals (
383
+ id TEXT PRIMARY KEY,
384
+ title TEXT NOT NULL,
385
+ contact_id TEXT REFERENCES contacts(id) ON DELETE SET NULL,
386
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
387
+ stage TEXT NOT NULL DEFAULT 'lead' CHECK(stage IN ('lead','qualified','proposal','negotiation','won','lost','cancelled')),
388
+ value_usd REAL,
389
+ currency TEXT NOT NULL DEFAULT 'USD',
390
+ close_date TEXT,
391
+ notes TEXT,
392
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
393
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
394
+ );
373
395
 
374
- class TagNotFoundError extends Error {
375
- constructor(id) {
376
- super(`Tag not found: ${id}`);
377
- this.name = "TagNotFoundError";
378
- }
379
- }
396
+ CREATE TABLE IF NOT EXISTS events (
397
+ id TEXT PRIMARY KEY,
398
+ title TEXT NOT NULL,
399
+ type TEXT NOT NULL DEFAULT 'meeting' CHECK(type IN ('meeting','call','lunch','email','demo','conference','intro','other')),
400
+ event_date TEXT NOT NULL,
401
+ duration_min INTEGER,
402
+ contact_ids TEXT NOT NULL DEFAULT '[]',
403
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
404
+ notes TEXT,
405
+ outcome TEXT,
406
+ deal_id TEXT REFERENCES deals(id) ON DELETE SET NULL,
407
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
408
+ );
409
+ `
410
+ ];
411
+ });
380
412
 
381
- class DuplicateTagNameError extends Error {
382
- constructor(name) {
383
- super(`Tag with name already exists: ${name}`);
384
- this.name = "DuplicateTagNameError";
385
- }
386
- }
413
+ // src/types/index.ts
414
+ var ContactNotFoundError, CompanyNotFoundError, TagNotFoundError, DuplicateTagNameError;
415
+ var init_types = __esm(() => {
416
+ ContactNotFoundError = class ContactNotFoundError extends Error {
417
+ constructor(id) {
418
+ super(`Contact not found: ${id}`);
419
+ this.name = "ContactNotFoundError";
420
+ }
421
+ };
422
+ CompanyNotFoundError = class CompanyNotFoundError extends Error {
423
+ constructor(id) {
424
+ super(`Company not found: ${id}`);
425
+ this.name = "CompanyNotFoundError";
426
+ }
427
+ };
428
+ TagNotFoundError = class TagNotFoundError extends Error {
429
+ constructor(id) {
430
+ super(`Tag not found: ${id}`);
431
+ this.name = "TagNotFoundError";
432
+ }
433
+ };
434
+ DuplicateTagNameError = class DuplicateTagNameError extends Error {
435
+ constructor(name) {
436
+ super(`Tag with name already exists: ${name}`);
437
+ this.name = "DuplicateTagNameError";
438
+ }
439
+ };
440
+ });
387
441
 
388
442
  // src/db/activity.ts
389
443
  function rowToActivity(row) {
@@ -412,8 +466,29 @@ function listActivity(opts = {}, db) {
412
466
  const rows = d.query(`SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
413
467
  return { entries: rows.map(rowToActivity), total: totalRow.total };
414
468
  }
469
+ var init_activity = __esm(() => {
470
+ init_database();
471
+ });
415
472
 
416
473
  // src/db/contacts.ts
474
+ var exports_contacts = {};
475
+ __export(exports_contacts, {
476
+ updateContact: () => updateContact,
477
+ unarchiveContact: () => unarchiveContact,
478
+ searchContacts: () => searchContacts,
479
+ mergeContacts: () => mergeContacts,
480
+ listRecentContacts: () => listRecentContacts,
481
+ listContacts: () => listContacts,
482
+ listColdContacts: () => listColdContacts,
483
+ getContactByEmail: () => getContactByEmail,
484
+ getContact: () => getContact,
485
+ deleteContact: () => deleteContact,
486
+ createContact: () => createContact,
487
+ autoLinkContactToCompany: () => autoLinkContactToCompany,
488
+ archiveContact: () => archiveContact,
489
+ addPhoneToContact: () => addPhoneToContact,
490
+ addEmailToContact: () => addEmailToContact
491
+ });
417
492
  function rowToContact(row) {
418
493
  return {
419
494
  ...row,
@@ -423,7 +498,10 @@ function rowToContact(row) {
423
498
  status: row.status ?? "active",
424
499
  follow_up_at: row.follow_up_at ?? null,
425
500
  archived: !!row.archived,
426
- project_id: row.project_id ?? null
501
+ project_id: row.project_id ?? null,
502
+ do_not_contact: !!row.do_not_contact,
503
+ priority: row.priority ?? 3,
504
+ timezone: row.timezone ?? null
427
505
  };
428
506
  }
429
507
  function rowToEmail(row) {
@@ -508,8 +586,8 @@ function createContact(input, db) {
508
586
  const firstName = input.first_name ?? "";
509
587
  const lastName = input.last_name ?? "";
510
588
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
511
- d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, status, follow_up_at, project_id, created_at, updated_at)
512
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
589
+ d.run(`INSERT INTO contacts (id, first_name, last_name, display_name, nickname, avatar_url, notes, birthday, company_id, job_title, source, custom_fields, last_contacted_at, website, preferred_contact_method, status, follow_up_at, project_id, do_not_contact, priority, timezone, created_at, updated_at)
590
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
513
591
  id,
514
592
  firstName,
515
593
  lastName,
@@ -528,6 +606,9 @@ function createContact(input, db) {
528
606
  input.status ?? "active",
529
607
  input.follow_up_at ?? null,
530
608
  input.project_id ?? null,
609
+ input.do_not_contact ? 1 : 0,
610
+ input.priority ?? 3,
611
+ input.timezone ?? null,
531
612
  timestamp,
532
613
  timestamp
533
614
  ]);
@@ -571,12 +652,18 @@ function listContacts(opts = {}, db) {
571
652
  last_contacted_after,
572
653
  last_contacted_before,
573
654
  order_by = "display_name",
574
- order_dir = "asc"
655
+ order_dir = "asc",
656
+ include_dnc = false,
657
+ priority_min,
658
+ updated_since
575
659
  } = opts;
576
660
  const conditions = [];
577
661
  const params = [];
578
662
  conditions.push("c.archived = ?");
579
663
  params.push(archived ? 1 : 0);
664
+ if (!include_dnc) {
665
+ conditions.push("c.do_not_contact = 0");
666
+ }
580
667
  if (company_id) {
581
668
  conditions.push("c.company_id = ?");
582
669
  params.push(company_id);
@@ -614,6 +701,14 @@ function listContacts(opts = {}, db) {
614
701
  conditions.push("c.last_contacted_at <= ?");
615
702
  params.push(last_contacted_before);
616
703
  }
704
+ if (priority_min !== undefined) {
705
+ conditions.push("c.priority >= ?");
706
+ params.push(priority_min);
707
+ }
708
+ if (updated_since) {
709
+ conditions.push("c.updated_at >= ?");
710
+ params.push(updated_since);
711
+ }
617
712
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
618
713
  const validOrderBy = ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"].includes(order_by) ? order_by : "display_name";
619
714
  const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
@@ -697,6 +792,18 @@ function updateContact(id, input, db) {
697
792
  setClauses.push("project_id = ?");
698
793
  params.push(input.project_id);
699
794
  }
795
+ if (input.do_not_contact !== undefined) {
796
+ setClauses.push("do_not_contact = ?");
797
+ params.push(input.do_not_contact ? 1 : 0);
798
+ }
799
+ if (input.priority !== undefined) {
800
+ setClauses.push("priority = ?");
801
+ params.push(input.priority ?? 3);
802
+ }
803
+ if (input.timezone !== undefined) {
804
+ setClauses.push("timezone = ?");
805
+ params.push(input.timezone);
806
+ }
700
807
  params.push(id);
701
808
  d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
702
809
  if (input.emails_add?.length) {
@@ -764,6 +871,11 @@ function searchContacts(query, db) {
764
871
  }
765
872
  return allRows.map((row) => loadContactDetails(d, rowToContact(row)));
766
873
  }
874
+ function listRecentContacts(limit, db) {
875
+ const d = db || getDatabase();
876
+ const rows = d.query(`SELECT * FROM contacts ORDER BY updated_at DESC LIMIT ?`).all(limit);
877
+ return rows.map((row) => loadContactDetails(d, rowToContact(row)));
878
+ }
767
879
  function mergeContacts(keepId, mergeId, db) {
768
880
  const d = db || getDatabase();
769
881
  const keepRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
@@ -890,6 +1002,15 @@ function unarchiveContact(id, db) {
890
1002
  const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
891
1003
  return loadContactDetails(d, rowToContact(updated));
892
1004
  }
1005
+ function listColdContacts(days, db) {
1006
+ const d = db || getDatabase();
1007
+ const rows = d.query(`SELECT c.* FROM contacts c
1008
+ WHERE c.archived = 0 AND c.do_not_contact = 0
1009
+ AND (c.last_contacted_at IS NULL OR c.last_contacted_at < datetime('now', ? || ' days'))
1010
+ ORDER BY c.last_contacted_at ASC NULLS FIRST
1011
+ LIMIT 100`).all(`-${days}`);
1012
+ return rows.map((row) => loadContactDetails(d, rowToContact(row)));
1013
+ }
893
1014
  function autoLinkContactToCompany(contactId, db) {
894
1015
  const d = db || getDatabase();
895
1016
  const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
@@ -909,8 +1030,24 @@ function autoLinkContactToCompany(contactId, db) {
909
1030
  const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
910
1031
  return loadContactDetails(d, rowToContact(updated));
911
1032
  }
1033
+ var init_contacts = __esm(() => {
1034
+ init_types();
1035
+ init_database();
1036
+ init_activity();
1037
+ });
1038
+
1039
+ // src/mcp/index.ts
1040
+ init_database();
1041
+ init_contacts();
1042
+ import { Server } from "@modelcontextprotocol/sdk/server/index.js";
1043
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
1044
+ import {
1045
+ CallToolRequestSchema,
1046
+ ListToolsRequestSchema
1047
+ } from "@modelcontextprotocol/sdk/types.js";
912
1048
 
913
1049
  // src/db/groups.ts
1050
+ init_database();
914
1051
  function createGroup(db, input) {
915
1052
  const id = uuid();
916
1053
  db.query(`INSERT INTO groups(id, name, description, created_at, updated_at) VALUES(?,?,?,?,?)`).run(id, input.name, input.description ?? null, now(), now());
@@ -981,6 +1118,8 @@ function listGroupsForCompany(db, companyId) {
981
1118
  }
982
1119
 
983
1120
  // src/db/tags.ts
1121
+ init_types();
1122
+ init_database();
984
1123
  function rowToTag2(row) {
985
1124
  return { ...row };
986
1125
  }
@@ -1039,6 +1178,9 @@ function removeTagFromCompany(companyId, tagId, db) {
1039
1178
  }
1040
1179
 
1041
1180
  // src/db/companies.ts
1181
+ init_types();
1182
+ init_database();
1183
+ init_activity();
1042
1184
  function rowToCompany2(row) {
1043
1185
  return {
1044
1186
  ...row,
@@ -1292,6 +1434,8 @@ function unarchiveCompany(id, db) {
1292
1434
  }
1293
1435
 
1294
1436
  // src/db/relationships.ts
1437
+ init_types();
1438
+ init_database();
1295
1439
  function rowToRelationship(row) {
1296
1440
  return {
1297
1441
  ...row,
@@ -1388,7 +1532,12 @@ function deleteCompanyRelationship(id, db) {
1388
1532
  d.run(`DELETE FROM company_relationships WHERE id = ?`, [id]);
1389
1533
  }
1390
1534
 
1535
+ // src/mcp/index.ts
1536
+ init_activity();
1537
+
1391
1538
  // src/db/notes.ts
1539
+ init_types();
1540
+ init_database();
1392
1541
  function addNote(contactId, body, createdBy, db, companyId) {
1393
1542
  const d = db || getDatabase();
1394
1543
  const contact = d.query(`SELECT id FROM contacts WHERE id = ?`).get(contactId);
@@ -1721,9 +1870,65 @@ function importFromJson(data) {
1721
1870
  };
1722
1871
  });
1723
1872
  }
1873
+ function parseLinkedInCsvLine(line) {
1874
+ return parseCsvLine(line);
1875
+ }
1876
+ function parseLinkedIn(csv) {
1877
+ const lines = csv.split(`
1878
+ `).filter((l) => l.trim());
1879
+ if (!lines.length)
1880
+ return [];
1881
+ const headers = parseLinkedInCsvLine(lines[0]).map((h) => h.replace(/"/g, "").trim());
1882
+ const firstNameIdx = headers.findIndex((h) => h === "First Name");
1883
+ const lastNameIdx = headers.findIndex((h) => h === "Last Name");
1884
+ const emailIdx = headers.findIndex((h) => h === "Email Address");
1885
+ const companyIdx = headers.findIndex((h) => h === "Company");
1886
+ const positionIdx = headers.findIndex((h) => h === "Position");
1887
+ const urlIdx = headers.findIndex((h) => h === "URL");
1888
+ const connectedIdx = headers.findIndex((h) => h === "Connected On");
1889
+ const results = [];
1890
+ for (let i = 1;i < lines.length; i++) {
1891
+ const cols = parseLinkedInCsvLine(lines[i]);
1892
+ const firstName = firstNameIdx >= 0 ? (cols[firstNameIdx] ?? "").trim() : "";
1893
+ const lastName = lastNameIdx >= 0 ? (cols[lastNameIdx] ?? "").trim() : "";
1894
+ if (!firstName && !lastName)
1895
+ continue;
1896
+ const contact = {
1897
+ first_name: firstName,
1898
+ last_name: lastName,
1899
+ display_name: `${firstName} ${lastName}`.trim(),
1900
+ source: "import"
1901
+ };
1902
+ if (emailIdx >= 0 && cols[emailIdx]?.trim()) {
1903
+ contact.emails = [{ address: cols[emailIdx].trim(), type: "work", is_primary: true }];
1904
+ }
1905
+ if (positionIdx >= 0 && cols[positionIdx]?.trim()) {
1906
+ contact.job_title = cols[positionIdx].trim();
1907
+ }
1908
+ if (urlIdx >= 0 && cols[urlIdx]?.trim()) {
1909
+ contact.social_profiles = [{ platform: "linkedin", url: cols[urlIdx].trim(), is_primary: true }];
1910
+ }
1911
+ if (companyIdx >= 0 && cols[companyIdx]?.trim()) {
1912
+ const connectedNote = connectedIdx >= 0 && cols[connectedIdx]?.trim() ? ` Connected on LinkedIn: ${cols[connectedIdx].trim()}` : "";
1913
+ contact.notes = `Company: ${cols[companyIdx].trim()}${connectedNote}`;
1914
+ } else if (connectedIdx >= 0 && cols[connectedIdx]?.trim()) {
1915
+ contact.notes = `Connected on LinkedIn: ${cols[connectedIdx].trim()}`;
1916
+ }
1917
+ results.push(contact);
1918
+ }
1919
+ return results;
1920
+ }
1921
+ function isLinkedInFormat(data) {
1922
+ const firstLine = data.split(`
1923
+ `)[0] ?? "";
1924
+ const lower = firstLine.toLowerCase();
1925
+ return lower.includes("first name") && lower.includes("url") && lower.includes("connected on");
1926
+ }
1724
1927
  async function importContacts(format, data) {
1725
1928
  switch (format) {
1726
1929
  case "csv":
1930
+ if (isLinkedInFormat(data))
1931
+ return parseLinkedIn(data);
1727
1932
  return importFromCsv(data);
1728
1933
  case "vcf":
1729
1934
  return parseVcf(data);
@@ -2241,6 +2446,7 @@ async function pullGoogleContactsAsInputs(opts = {}) {
2241
2446
  }
2242
2447
 
2243
2448
  // src/db/org-members.ts
2449
+ init_database();
2244
2450
  function rowToOrgMember(row) {
2245
2451
  return {
2246
2452
  id: row.id,
@@ -2318,6 +2524,7 @@ function listOrgMembersForContact(contactId, db) {
2318
2524
  }
2319
2525
 
2320
2526
  // src/db/vendor-comms.ts
2527
+ init_database();
2321
2528
  function rowToVendorComm(row) {
2322
2529
  return {
2323
2530
  id: row.id,
@@ -2403,6 +2610,7 @@ function markFollowUpDone(id, db) {
2403
2610
  }
2404
2611
 
2405
2612
  // src/db/contact-tasks.ts
2613
+ init_database();
2406
2614
  function rowToContactTask(row) {
2407
2615
  return {
2408
2616
  id: row.id,
@@ -2542,6 +2750,7 @@ function checkEscalations(db) {
2542
2750
  }
2543
2751
 
2544
2752
  // src/db/applications.ts
2753
+ init_database();
2545
2754
  function rowToApplication(row) {
2546
2755
  return {
2547
2756
  id: row.id,
@@ -2688,81 +2897,499 @@ function listFollowUpDue(db) {
2688
2897
  return rows.map(rowToApplication);
2689
2898
  }
2690
2899
 
2900
+ // src/lib/brief.ts
2901
+ init_database();
2902
+ init_contacts();
2903
+
2904
+ // src/lib/timeline.ts
2905
+ init_database();
2906
+ function getContactTimeline(contactId, limit = 50, db) {
2907
+ const _db2 = db || getDatabase();
2908
+ const items = [];
2909
+ const notes = _db2.query(`SELECT * FROM contact_notes WHERE contact_id = ? ORDER BY created_at DESC LIMIT 50`).all(contactId);
2910
+ for (const n of notes) {
2911
+ items.push({ date: n.created_at, type: "note", title: "Note", body: n.body });
2912
+ }
2913
+ const events = _db2.query(`SELECT * FROM events WHERE contact_ids LIKE ? ORDER BY event_date DESC LIMIT 50`).all(`%${contactId}%`);
2914
+ for (const e of events) {
2915
+ items.push({ date: e.event_date, type: "event", title: `${e.type}: ${e.title}`, body: e.notes ?? undefined, metadata: { outcome: e.outcome, duration_min: e.duration_min } });
2916
+ }
2917
+ const tasks = _db2.query(`SELECT * FROM contact_tasks WHERE contact_id = ? ORDER BY created_at DESC LIMIT 30`).all(contactId);
2918
+ for (const t of tasks) {
2919
+ items.push({ date: t.created_at, type: "task_created", title: `Task created: ${t.title}`, metadata: { deadline: t.deadline, priority: t.priority } });
2920
+ if (t.status === "completed") {
2921
+ items.push({ date: t.updated_at, type: "task_completed", title: `Task completed: ${t.title}` });
2922
+ }
2923
+ }
2924
+ const comms = _db2.query(`SELECT vc.*, co.name as company_name FROM vendor_communications vc JOIN companies co ON vc.company_id = co.id WHERE vc.contact_id = ? ORDER BY vc.comm_date DESC LIMIT 20`).all(contactId);
2925
+ for (const c of comms) {
2926
+ items.push({ date: c.comm_date, type: "vendor_comm", title: `${c.type} \u2014 ${c.company_name}`, body: c.subject ?? undefined });
2927
+ }
2928
+ const activity = _db2.query(`SELECT * FROM activity_log WHERE contact_id = ? ORDER BY created_at DESC LIMIT 30`).all(contactId);
2929
+ for (const a of activity) {
2930
+ items.push({ date: a.created_at, type: "interaction", title: a.action, body: a.details ?? undefined });
2931
+ }
2932
+ return items.sort((a, b) => b.date.localeCompare(a.date)).slice(0, limit);
2933
+ }
2934
+
2935
+ // src/lib/brief.ts
2936
+ function generateBrief(contactId, db) {
2937
+ const _db2 = db || getDatabase();
2938
+ const contact = getContact(contactId, _db2);
2939
+ const notes = listNotes(contactId, _db2);
2940
+ const allTasks = listContactTasks({ contact_id: contactId }, _db2);
2941
+ const tasks = allTasks.filter((t) => !["completed", "cancelled"].includes(t.status));
2942
+ const overdueTasks = allTasks.filter((t) => t.deadline && t.deadline < new Date().toISOString() && !["completed", "cancelled"].includes(t.status));
2943
+ const companyRels = listCompanyRelationships({ contact_id: contactId }, _db2);
2944
+ const recentTimeline = getContactTimeline(contactId, 5, _db2);
2945
+ const daysSince = contact.last_contacted_at ? Math.floor((Date.now() - new Date(contact.last_contacted_at).getTime()) / 86400000) : null;
2946
+ const lines = [];
2947
+ lines.push(`# ${contact.display_name}`);
2948
+ if (contact.job_title)
2949
+ lines.push(`**Role:** ${contact.job_title}${contact.company_id ? ` (linked to company)` : ""}`);
2950
+ if (contact.emails?.length) {
2951
+ const primary = contact.emails.find((e) => e.is_primary) || contact.emails[0];
2952
+ if (primary)
2953
+ lines.push(`**Email:** ${primary.address}`);
2954
+ }
2955
+ if (contact.phones?.length) {
2956
+ const primary = contact.phones.find((p) => p.is_primary) || contact.phones[0];
2957
+ if (primary)
2958
+ lines.push(`**Phone:** ${primary.number}`);
2959
+ }
2960
+ if (contact.preferred_contact_method)
2961
+ lines.push(`**Preferred contact:** ${contact.preferred_contact_method}`);
2962
+ lines.push("");
2963
+ lines.push(`## Status`);
2964
+ lines.push(`- Last contacted: ${daysSince !== null ? `${daysSince} days ago` : "never"}`);
2965
+ lines.push(`- Status: ${contact.status || "active"}`);
2966
+ if (contact.follow_up_at)
2967
+ lines.push(`- Follow-up scheduled: ${contact.follow_up_at}`);
2968
+ if (overdueTasks.length)
2969
+ lines.push(`- OVERDUE TASKS: ${overdueTasks.length}`);
2970
+ if (companyRels.length) {
2971
+ lines.push("");
2972
+ lines.push(`## Entity Relationships`);
2973
+ for (const r of companyRels)
2974
+ lines.push(`- ${r.relationship_type} \u2014 ${r.notes || ""}`);
2975
+ }
2976
+ if (tasks.length) {
2977
+ lines.push("");
2978
+ lines.push(`## Open Tasks`);
2979
+ for (const t of tasks)
2980
+ lines.push(`- [${t.priority}] ${t.title}${t.deadline ? ` (due ${t.deadline})` : ""}`);
2981
+ }
2982
+ if (notes.length) {
2983
+ lines.push("");
2984
+ lines.push(`## Recent Notes`);
2985
+ for (const n of notes.slice(0, 3))
2986
+ lines.push(`**${n.created_at.slice(0, 10)}:** ${n.body}`);
2987
+ }
2988
+ if (recentTimeline.length) {
2989
+ lines.push("");
2990
+ lines.push(`## Recent Activity`);
2991
+ for (const item of recentTimeline)
2992
+ lines.push(`- ${item.date.slice(0, 10)} ${item.title}`);
2993
+ }
2994
+ if (contact.notes) {
2995
+ lines.push("");
2996
+ lines.push(`## Background Notes`);
2997
+ lines.push(contact.notes);
2998
+ }
2999
+ return lines.join(`
3000
+ `);
3001
+ }
3002
+
2691
3003
  // src/mcp/index.ts
2692
- var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
2693
- server.setRequestHandler(ListToolsRequestSchema, async () => ({
2694
- tools: [
2695
- {
2696
- name: "create_contact",
2697
- description: "Create a new contact. Provide at minimum display_name or first_name+last_name. Emails, phones, addresses, and social_profiles are arrays of objects. relationship_type values: colleague|friend|family|reports_to|mentor|investor|partner|client|vendor|other. source values: manual|import|linkedin|github|twitter|email|calendar|crm|other.",
2698
- inputSchema: {
2699
- type: "object",
2700
- properties: {
2701
- first_name: { type: "string" },
2702
- last_name: { type: "string" },
2703
- display_name: { type: "string", description: "Display name (auto-generated from first+last if omitted)" },
2704
- nickname: { type: "string" },
2705
- job_title: { type: "string" },
2706
- company_id: { type: "string" },
2707
- notes: { type: "string" },
2708
- birthday: { type: "string", description: "YYYY-MM-DD" },
2709
- website: { type: "string", description: "Personal or professional website URL" },
2710
- last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
2711
- preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
2712
- status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"], description: "Contact lifecycle status (default: active)" },
2713
- follow_up_at: { type: "string", description: "ISO 8601 datetime to follow up with this contact" },
2714
- project_id: { type: "string", description: "Associate contact with a project ID" },
2715
- emails: {
2716
- type: "array",
2717
- items: {
2718
- type: "object",
2719
- properties: {
2720
- address: { type: "string" },
2721
- type: { type: "string", enum: ["work", "personal", "other"] },
2722
- is_primary: { type: "boolean" }
2723
- },
2724
- required: ["address"]
2725
- }
2726
- },
2727
- phones: {
2728
- type: "array",
2729
- items: {
2730
- type: "object",
2731
- properties: {
2732
- number: { type: "string" },
2733
- type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
2734
- is_primary: { type: "boolean" }
2735
- },
2736
- required: ["number"]
2737
- }
2738
- },
2739
- addresses: {
2740
- type: "array",
2741
- items: {
2742
- type: "object",
2743
- properties: {
2744
- type: { type: "string", enum: ["physical", "mailing", "billing", "virtual", "other"] },
2745
- street: { type: "string" },
2746
- city: { type: "string" },
2747
- state: { type: "string" },
2748
- zip: { type: "string" },
2749
- country: { type: "string" },
2750
- is_primary: { type: "boolean" }
2751
- }
2752
- }
2753
- },
2754
- social_profiles: {
2755
- type: "array",
2756
- items: {
2757
- type: "object",
2758
- properties: {
2759
- platform: { type: "string", enum: ["twitter", "linkedin", "github", "instagram", "telegram", "discord", "youtube", "tiktok", "bluesky", "facebook", "whatsapp", "snapchat", "reddit", "other"] },
2760
- handle: { type: "string" },
2761
- url: { type: "string" },
2762
- is_primary: { type: "boolean" }
2763
- },
2764
- required: ["platform"]
2765
- }
3004
+ init_contacts();
3005
+
3006
+ // src/lib/upcoming.ts
3007
+ init_database();
3008
+ function getUpcomingItems(days = 7, db) {
3009
+ const _db2 = db || getDatabase();
3010
+ const items = [];
3011
+ const now3 = new Date;
3012
+ const future = new Date(now3.getTime() + days * 86400000);
3013
+ const todayStr = now3.toISOString().slice(0, 10);
3014
+ const futureStr = future.toISOString().slice(0, 10);
3015
+ const followUps = _db2.query(`SELECT c.id, c.display_name, c.follow_up_at FROM contacts c WHERE c.follow_up_at IS NOT NULL AND c.follow_up_at <= ? AND c.do_not_contact = 0`).all(futureStr);
3016
+ for (const r of followUps) {
3017
+ items.push({ date: r.follow_up_at, type: "follow_up", contact_id: r.id, contact_name: r.display_name, title: `Follow up with ${r.display_name}`, urgency: r.follow_up_at < todayStr ? "overdue" : r.follow_up_at === todayStr ? "today" : "upcoming" });
3018
+ }
3019
+ const tasks = _db2.query(`SELECT ct.*, c.display_name FROM contact_tasks ct JOIN contacts c ON ct.contact_id = c.id WHERE ct.deadline IS NOT NULL AND ct.deadline <= ? AND ct.status NOT IN ('completed','cancelled')`).all(futureStr);
3020
+ for (const t of tasks) {
3021
+ items.push({ date: t.deadline, type: "task_deadline", contact_id: t.contact_id, contact_name: t.display_name, title: t.title, urgency: t.deadline < todayStr ? "overdue" : t.deadline === todayStr ? "today" : "upcoming" });
3022
+ }
3023
+ const apps = _db2.query(`SELECT a.*, c.display_name as contact_name FROM applications a LEFT JOIN contacts c ON a.primary_contact_id = c.id WHERE a.follow_up_date IS NOT NULL AND a.follow_up_date <= ?`).all(futureStr);
3024
+ for (const a of apps) {
3025
+ items.push({ date: a.follow_up_date, type: "application_followup", contact_name: a.contact_name ?? undefined, title: `Follow up: ${a.program_name}`, urgency: a.follow_up_date < todayStr ? "overdue" : a.follow_up_date === todayStr ? "today" : "upcoming" });
3026
+ }
3027
+ const vendorFU = _db2.query(`SELECT vc.*, co.name as company_name FROM vendor_communications vc JOIN companies co ON vc.company_id = co.id WHERE vc.follow_up_date IS NOT NULL AND vc.follow_up_date <= ? AND vc.follow_up_done = 0`).all(futureStr);
3028
+ for (const v of vendorFU) {
3029
+ items.push({ date: v.follow_up_date, type: "vendor_followup", company_id: v.company_id, company_name: v.company_name, title: `Follow up with ${v.company_name}: ${v.subject || v.type}`, urgency: v.follow_up_date < todayStr ? "overdue" : v.follow_up_date === todayStr ? "today" : "upcoming" });
3030
+ }
3031
+ const contacts = _db2.query(`SELECT id, display_name, birthday FROM contacts WHERE birthday IS NOT NULL AND do_not_contact = 0`).all();
3032
+ for (const c of contacts) {
3033
+ const bday = new Date(c.birthday);
3034
+ const thisYear = new Date(now3.getFullYear(), bday.getMonth(), bday.getDate());
3035
+ const nextBday = thisYear >= now3 ? thisYear : new Date(now3.getFullYear() + 1, bday.getMonth(), bday.getDate());
3036
+ const nextStr = nextBday.toISOString().slice(0, 10);
3037
+ if (nextStr <= futureStr) {
3038
+ items.push({ date: nextStr, type: "birthday", contact_id: c.id, contact_name: c.display_name, title: `Birthday: ${c.display_name}`, urgency: nextStr === todayStr ? "today" : "upcoming" });
3039
+ }
3040
+ }
3041
+ return items.sort((a, b) => a.date.localeCompare(b.date));
3042
+ }
3043
+
3044
+ // src/lib/stats.ts
3045
+ init_database();
3046
+ function getNetworkStats(db) {
3047
+ const _db2 = db || getDatabase();
3048
+ const q = (sql) => _db2.query(sql).get();
3049
+ const today = new Date().toISOString().slice(0, 10);
3050
+ const d30 = new Date(Date.now() - 30 * 86400000).toISOString().slice(0, 10);
3051
+ const d60 = new Date(Date.now() - 60 * 86400000).toISOString().slice(0, 10);
3052
+ return {
3053
+ total_contacts: q(`SELECT COUNT(*) c FROM contacts WHERE archived=0`).c,
3054
+ total_companies: q(`SELECT COUNT(*) c FROM companies WHERE archived=0`).c,
3055
+ owned_entities: q(`SELECT COUNT(*) c FROM companies WHERE is_owned_entity=1`).c,
3056
+ total_tags: q(`SELECT COUNT(*) c FROM tags`).c,
3057
+ total_groups: q(`SELECT COUNT(*) c FROM groups`).c,
3058
+ total_deals: q(`SELECT COUNT(*) c FROM deals WHERE stage NOT IN ('won','lost','cancelled')`).c,
3059
+ total_events: q(`SELECT COUNT(*) c FROM events`).c,
3060
+ cold_30d: q(`SELECT COUNT(*) c FROM contacts WHERE archived=0 AND do_not_contact=0 AND (last_contacted_at IS NULL OR last_contacted_at < '${d30}')`).c,
3061
+ cold_60d: q(`SELECT COUNT(*) c FROM contacts WHERE archived=0 AND do_not_contact=0 AND (last_contacted_at IS NULL OR last_contacted_at < '${d60}')`).c,
3062
+ cold_never: q(`SELECT COUNT(*) c FROM contacts WHERE archived=0 AND do_not_contact=0 AND last_contacted_at IS NULL`).c,
3063
+ contacts_with_email: q(`SELECT COUNT(DISTINCT contact_id) c FROM emails WHERE contact_id IS NOT NULL`).c,
3064
+ contacts_with_phone: q(`SELECT COUNT(DISTINCT contact_id) c FROM phones WHERE contact_id IS NOT NULL`).c,
3065
+ contacts_no_company: q(`SELECT COUNT(*) c FROM contacts WHERE archived=0 AND company_id IS NULL`).c,
3066
+ overdue_tasks: q(`SELECT COUNT(*) c FROM contact_tasks WHERE deadline < '${today}' AND status NOT IN ('completed','cancelled')`).c,
3067
+ pending_applications: q(`SELECT COUNT(*) c FROM applications WHERE status IN ('submitted','pending','follow_up_needed')`).c,
3068
+ missing_invoices: q(`SELECT COUNT(*) c FROM vendor_communications WHERE type='invoice_request' AND status IN ('awaiting_response','no_response')`).c,
3069
+ upcoming_7d: q(`SELECT COUNT(*) c FROM contacts WHERE follow_up_at BETWEEN '${today}' AND date('${today}','+7 days')`).c,
3070
+ notes_count: q(`SELECT COUNT(*) c FROM contact_notes`).c,
3071
+ active_deals_value: q(`SELECT COALESCE(SUM(value_usd),0) c FROM deals WHERE stage NOT IN ('won','lost','cancelled') AND currency='USD'`).c
3072
+ };
3073
+ }
3074
+
3075
+ // src/lib/audit.ts
3076
+ init_database();
3077
+ function auditContact(contact) {
3078
+ const missing = [];
3079
+ const suggestions = [];
3080
+ let score = 0;
3081
+ if (contact.emails?.length)
3082
+ score += 20;
3083
+ else {
3084
+ missing.push("email");
3085
+ suggestions.push("Add an email address");
3086
+ }
3087
+ if (contact.phones?.length)
3088
+ score += 15;
3089
+ else {
3090
+ missing.push("phone");
3091
+ suggestions.push("Add a phone number");
3092
+ }
3093
+ if (contact.company_id)
3094
+ score += 15;
3095
+ else {
3096
+ missing.push("company");
3097
+ suggestions.push("Link to a company");
3098
+ }
3099
+ if (contact.last_contacted_at)
3100
+ score += 20;
3101
+ else {
3102
+ missing.push("last_contacted_at");
3103
+ suggestions.push("Log a contact interaction");
3104
+ }
3105
+ if (contact.tags?.length)
3106
+ score += 10;
3107
+ else {
3108
+ missing.push("tags");
3109
+ suggestions.push("Add at least one tag");
3110
+ }
3111
+ if (contact.notes)
3112
+ score += 10;
3113
+ else {
3114
+ missing.push("notes");
3115
+ suggestions.push("Add notes");
3116
+ }
3117
+ if (contact.job_title)
3118
+ score += 10;
3119
+ else {
3120
+ missing.push("job_title");
3121
+ suggestions.push("Add a job title");
3122
+ }
3123
+ return { contact_id: contact.id, display_name: contact.display_name, score, missing, suggestions };
3124
+ }
3125
+ async function listContactAudit(db) {
3126
+ const _db2 = db || getDatabase();
3127
+ const { listContacts: listContacts2 } = await Promise.resolve().then(() => (init_contacts(), exports_contacts));
3128
+ const { contacts } = listContacts2({ limit: 500, include_dnc: true }, _db2);
3129
+ return contacts.map(auditContact).sort((a, b) => a.score - b.score);
3130
+ }
3131
+
3132
+ // src/db/deals.ts
3133
+ init_database();
3134
+ function rowToDeal(row) {
3135
+ return {
3136
+ id: row.id,
3137
+ title: row.title,
3138
+ contact_id: row.contact_id,
3139
+ company_id: row.company_id,
3140
+ stage: row.stage,
3141
+ value_usd: row.value_usd,
3142
+ currency: row.currency,
3143
+ close_date: row.close_date,
3144
+ notes: row.notes,
3145
+ created_at: row.created_at,
3146
+ updated_at: row.updated_at
3147
+ };
3148
+ }
3149
+ function createDeal(input, db) {
3150
+ const d = db || getDatabase();
3151
+ const id = uuid();
3152
+ const timestamp = now();
3153
+ d.run(`INSERT INTO deals (id, title, contact_id, company_id, stage, value_usd, currency, close_date, notes, created_at, updated_at)
3154
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
3155
+ id,
3156
+ input.title,
3157
+ input.contact_id ?? null,
3158
+ input.company_id ?? null,
3159
+ input.stage ?? "lead",
3160
+ input.value_usd ?? null,
3161
+ input.currency ?? "USD",
3162
+ input.close_date ?? null,
3163
+ input.notes ?? null,
3164
+ timestamp,
3165
+ timestamp
3166
+ ]);
3167
+ return rowToDeal(d.query(`SELECT * FROM deals WHERE id = ?`).get(id));
3168
+ }
3169
+ function getDeal(id, db) {
3170
+ const d = db || getDatabase();
3171
+ const row = d.query(`SELECT * FROM deals WHERE id = ?`).get(id);
3172
+ return row ? rowToDeal(row) : null;
3173
+ }
3174
+ function listDeals(opts = {}, db) {
3175
+ const d = db || getDatabase();
3176
+ const conditions = [];
3177
+ const params = [];
3178
+ if (opts.stage) {
3179
+ conditions.push("stage = ?");
3180
+ params.push(opts.stage);
3181
+ }
3182
+ if (opts.contact_id) {
3183
+ conditions.push("contact_id = ?");
3184
+ params.push(opts.contact_id);
3185
+ }
3186
+ if (opts.company_id) {
3187
+ conditions.push("company_id = ?");
3188
+ params.push(opts.company_id);
3189
+ }
3190
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3191
+ const rows = d.query(`SELECT * FROM deals ${where} ORDER BY created_at DESC`).all(...params);
3192
+ return rows.map(rowToDeal);
3193
+ }
3194
+ function updateDeal(id, input, db) {
3195
+ const d = db || getDatabase();
3196
+ const existing = d.query(`SELECT * FROM deals WHERE id = ?`).get(id);
3197
+ if (!existing)
3198
+ return null;
3199
+ const setClauses = ["updated_at = ?"];
3200
+ const params = [now()];
3201
+ if (input.title !== undefined) {
3202
+ setClauses.push("title = ?");
3203
+ params.push(input.title);
3204
+ }
3205
+ if (input.contact_id !== undefined) {
3206
+ setClauses.push("contact_id = ?");
3207
+ params.push(input.contact_id ?? null);
3208
+ }
3209
+ if (input.company_id !== undefined) {
3210
+ setClauses.push("company_id = ?");
3211
+ params.push(input.company_id ?? null);
3212
+ }
3213
+ if (input.stage !== undefined) {
3214
+ setClauses.push("stage = ?");
3215
+ params.push(input.stage);
3216
+ }
3217
+ if (input.value_usd !== undefined) {
3218
+ setClauses.push("value_usd = ?");
3219
+ params.push(input.value_usd ?? null);
3220
+ }
3221
+ if (input.currency !== undefined) {
3222
+ setClauses.push("currency = ?");
3223
+ params.push(input.currency);
3224
+ }
3225
+ if (input.close_date !== undefined) {
3226
+ setClauses.push("close_date = ?");
3227
+ params.push(input.close_date ?? null);
3228
+ }
3229
+ if (input.notes !== undefined) {
3230
+ setClauses.push("notes = ?");
3231
+ params.push(input.notes ?? null);
3232
+ }
3233
+ params.push(id);
3234
+ d.run(`UPDATE deals SET ${setClauses.join(", ")} WHERE id = ?`, params);
3235
+ return rowToDeal(d.query(`SELECT * FROM deals WHERE id = ?`).get(id));
3236
+ }
3237
+ function deleteDeal(id, db) {
3238
+ const d = db || getDatabase();
3239
+ d.run(`DELETE FROM deals WHERE id = ?`, [id]);
3240
+ }
3241
+
3242
+ // src/db/events.ts
3243
+ init_database();
3244
+ function rowToEvent(row) {
3245
+ let contact_ids = [];
3246
+ try {
3247
+ contact_ids = JSON.parse(row.contact_ids);
3248
+ } catch {
3249
+ contact_ids = [];
3250
+ }
3251
+ return {
3252
+ id: row.id,
3253
+ title: row.title,
3254
+ type: row.type,
3255
+ event_date: row.event_date,
3256
+ duration_min: row.duration_min,
3257
+ contact_ids,
3258
+ company_id: row.company_id,
3259
+ notes: row.notes,
3260
+ outcome: row.outcome,
3261
+ deal_id: row.deal_id,
3262
+ created_at: row.created_at
3263
+ };
3264
+ }
3265
+ function logEvent(input, db) {
3266
+ const d = db || getDatabase();
3267
+ const id = uuid();
3268
+ const timestamp = now();
3269
+ d.run(`INSERT INTO events (id, title, type, event_date, duration_min, contact_ids, company_id, notes, outcome, deal_id, created_at)
3270
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
3271
+ id,
3272
+ input.title,
3273
+ input.type ?? "meeting",
3274
+ input.event_date,
3275
+ input.duration_min ?? null,
3276
+ JSON.stringify(input.contact_ids ?? []),
3277
+ input.company_id ?? null,
3278
+ input.notes ?? null,
3279
+ input.outcome ?? null,
3280
+ input.deal_id ?? null,
3281
+ timestamp
3282
+ ]);
3283
+ return rowToEvent(d.query(`SELECT * FROM events WHERE id = ?`).get(id));
3284
+ }
3285
+ function listEvents(opts = {}, db) {
3286
+ const d = db || getDatabase();
3287
+ const conditions = [];
3288
+ const params = [];
3289
+ if (opts.contact_id) {
3290
+ conditions.push("contact_ids LIKE ?");
3291
+ params.push(`%${opts.contact_id}%`);
3292
+ }
3293
+ if (opts.company_id) {
3294
+ conditions.push("company_id = ?");
3295
+ params.push(opts.company_id);
3296
+ }
3297
+ if (opts.type) {
3298
+ conditions.push("type = ?");
3299
+ params.push(opts.type);
3300
+ }
3301
+ if (opts.date_from) {
3302
+ conditions.push("event_date >= ?");
3303
+ params.push(opts.date_from);
3304
+ }
3305
+ if (opts.date_to) {
3306
+ conditions.push("event_date <= ?");
3307
+ params.push(opts.date_to);
3308
+ }
3309
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3310
+ const rows = d.query(`SELECT * FROM events ${where} ORDER BY event_date DESC`).all(...params);
3311
+ return rows.map(rowToEvent);
3312
+ }
3313
+ function deleteEvent(id, db) {
3314
+ const d = db || getDatabase();
3315
+ d.run(`DELETE FROM events WHERE id = ?`, [id]);
3316
+ }
3317
+
3318
+ // src/mcp/index.ts
3319
+ var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
3320
+ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3321
+ tools: [
3322
+ {
3323
+ name: "create_contact",
3324
+ description: "Create a new contact. Provide at minimum display_name or first_name+last_name. Emails, phones, addresses, and social_profiles are arrays of objects. relationship_type values: colleague|friend|family|reports_to|mentor|investor|partner|client|vendor|other. source values: manual|import|linkedin|github|twitter|email|calendar|crm|other.",
3325
+ inputSchema: {
3326
+ type: "object",
3327
+ properties: {
3328
+ first_name: { type: "string" },
3329
+ last_name: { type: "string" },
3330
+ display_name: { type: "string", description: "Display name (auto-generated from first+last if omitted)" },
3331
+ nickname: { type: "string" },
3332
+ job_title: { type: "string" },
3333
+ company_id: { type: "string" },
3334
+ notes: { type: "string" },
3335
+ birthday: { type: "string", description: "YYYY-MM-DD" },
3336
+ website: { type: "string", description: "Personal or professional website URL" },
3337
+ last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
3338
+ preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
3339
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"], description: "Contact lifecycle status (default: active)" },
3340
+ follow_up_at: { type: "string", description: "ISO 8601 datetime to follow up with this contact" },
3341
+ project_id: { type: "string", description: "Associate contact with a project ID" },
3342
+ emails: {
3343
+ type: "array",
3344
+ items: {
3345
+ type: "object",
3346
+ properties: {
3347
+ address: { type: "string" },
3348
+ type: { type: "string", enum: ["work", "personal", "other"] },
3349
+ is_primary: { type: "boolean" }
3350
+ },
3351
+ required: ["address"]
3352
+ }
3353
+ },
3354
+ phones: {
3355
+ type: "array",
3356
+ items: {
3357
+ type: "object",
3358
+ properties: {
3359
+ number: { type: "string" },
3360
+ type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
3361
+ is_primary: { type: "boolean" }
3362
+ },
3363
+ required: ["number"]
3364
+ }
3365
+ },
3366
+ addresses: {
3367
+ type: "array",
3368
+ items: {
3369
+ type: "object",
3370
+ properties: {
3371
+ type: { type: "string", enum: ["physical", "mailing", "billing", "virtual", "other"] },
3372
+ street: { type: "string" },
3373
+ city: { type: "string" },
3374
+ state: { type: "string" },
3375
+ zip: { type: "string" },
3376
+ country: { type: "string" },
3377
+ is_primary: { type: "boolean" }
3378
+ }
3379
+ }
3380
+ },
3381
+ social_profiles: {
3382
+ type: "array",
3383
+ items: {
3384
+ type: "object",
3385
+ properties: {
3386
+ platform: { type: "string", enum: ["twitter", "linkedin", "github", "instagram", "telegram", "discord", "youtube", "tiktok", "bluesky", "facebook", "whatsapp", "snapchat", "reddit", "other"] },
3387
+ handle: { type: "string" },
3388
+ url: { type: "string" },
3389
+ is_primary: { type: "boolean" }
3390
+ },
3391
+ required: ["platform"]
3392
+ }
2766
3393
  },
2767
3394
  tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to assign" },
2768
3395
  source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
@@ -3041,12 +3668,13 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3041
3668
  },
3042
3669
  {
3043
3670
  name: "export_contacts",
3044
- description: "Export contacts to CSV, vCard (.vcf), or JSON format. Optionally specify contact_ids to export a subset; omit to export all contacts.",
3671
+ description: "Export contacts to CSV, vCard (.vcf), or JSON format. Optionally specify contact_ids to export a subset; omit to export all contacts. Use updated_since to export only contacts updated after a date.",
3045
3672
  inputSchema: {
3046
3673
  type: "object",
3047
3674
  properties: {
3048
3675
  format: { type: "string", enum: ["json", "csv", "vcf"] },
3049
- contact_ids: { type: "array", items: { type: "string" }, description: "Specific contact IDs to export (omit for all)" }
3676
+ contact_ids: { type: "array", items: { type: "string" }, description: "Specific contact IDs to export (omit for all)" },
3677
+ updated_since: { type: "string", description: "ISO 8601 date \u2014 only export contacts updated/contacted after this date" }
3050
3678
  },
3051
3679
  required: ["format"]
3052
3680
  }
@@ -3837,6 +4465,228 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3837
4465
  },
3838
4466
  required: ["company_id"]
3839
4467
  }
4468
+ },
4469
+ {
4470
+ name: "get_contact_brief",
4471
+ description: "Generate a comprehensive pre-meeting briefing for a contact. Returns structured markdown covering: role/company, contact details, status, last contacted, open tasks, overdue items, entity relationships, recent notes, recent activity. Feed this to an AI before a meeting or call.",
4472
+ inputSchema: {
4473
+ type: "object",
4474
+ properties: { contact_id: { type: "string" } },
4475
+ required: ["contact_id"]
4476
+ }
4477
+ },
4478
+ {
4479
+ name: "list_cold_contacts",
4480
+ description: "List contacts you haven't been in touch with for N days (default 30). Sorted by most neglected first. Use to identify who needs re-engagement. 'never' means last_contacted_at was never set.",
4481
+ inputSchema: {
4482
+ type: "object",
4483
+ properties: { days: { type: "number", description: "Days threshold (default 30)" } }
4484
+ }
4485
+ },
4486
+ {
4487
+ name: "get_upcoming",
4488
+ description: "Get a unified calendar of upcoming items: follow-ups due, birthdays, task deadlines, application follow-ups, vendor follow-ups. Default 7-day window. Returns items sorted by date with urgency (overdue/today/upcoming).",
4489
+ inputSchema: {
4490
+ type: "object",
4491
+ properties: { days: { type: "number", description: "Days ahead to show (default 7)" } }
4492
+ }
4493
+ },
4494
+ {
4495
+ name: "get_network_stats",
4496
+ description: "Get comprehensive network health stats: contact counts, cold contacts (30d/60d/never), data completeness, overdue tasks, pending applications, missing invoices, active deal pipeline value. The health dashboard for your network.",
4497
+ inputSchema: { type: "object", properties: {} }
4498
+ },
4499
+ {
4500
+ name: "audit_contacts",
4501
+ description: "Score all contacts for data completeness (0-100). Points: email +20, phone +15, company +15, last_contacted_at +20, tags +10, notes +10, job_title +10. Returns contacts sorted by score ascending (worst first) so you know who to enrich.",
4502
+ inputSchema: {
4503
+ type: "object",
4504
+ properties: { limit: { type: "number", description: "Number to show (default 20)" } }
4505
+ }
4506
+ },
4507
+ {
4508
+ name: "create_deal",
4509
+ description: "Create a new deal or opportunity, optionally linked to a contact and/or company.",
4510
+ inputSchema: {
4511
+ type: "object",
4512
+ properties: {
4513
+ title: { type: "string" },
4514
+ contact_id: { type: "string" },
4515
+ company_id: { type: "string" },
4516
+ stage: { type: "string", enum: ["prospecting", "qualified", "proposal", "negotiation", "won", "lost"], description: "Deal stage (default: prospecting)" },
4517
+ value_usd: { type: "number" },
4518
+ currency: { type: "string" },
4519
+ close_date: { type: "string", description: "Expected close date (YYYY-MM-DD)" },
4520
+ notes: { type: "string" }
4521
+ },
4522
+ required: ["title"]
4523
+ }
4524
+ },
4525
+ {
4526
+ name: "get_deal",
4527
+ description: "Get a deal by ID.",
4528
+ inputSchema: {
4529
+ type: "object",
4530
+ properties: { id: { type: "string" } },
4531
+ required: ["id"]
4532
+ }
4533
+ },
4534
+ {
4535
+ name: "list_deals",
4536
+ description: "List deals, optionally filtered by stage, contact, or company.",
4537
+ inputSchema: {
4538
+ type: "object",
4539
+ properties: {
4540
+ stage: { type: "string", enum: ["prospecting", "qualified", "proposal", "negotiation", "won", "lost"] },
4541
+ contact_id: { type: "string" },
4542
+ company_id: { type: "string" }
4543
+ }
4544
+ }
4545
+ },
4546
+ {
4547
+ name: "update_deal",
4548
+ description: "Update a deal's title, stage, value, close date, or notes.",
4549
+ inputSchema: {
4550
+ type: "object",
4551
+ properties: {
4552
+ id: { type: "string" },
4553
+ title: { type: "string" },
4554
+ stage: { type: "string", enum: ["prospecting", "qualified", "proposal", "negotiation", "won", "lost"] },
4555
+ value_usd: { type: "number" },
4556
+ close_date: { type: "string" },
4557
+ notes: { type: "string" }
4558
+ },
4559
+ required: ["id"]
4560
+ }
4561
+ },
4562
+ {
4563
+ name: "delete_deal",
4564
+ description: "Delete a deal by ID.",
4565
+ inputSchema: {
4566
+ type: "object",
4567
+ properties: { id: { type: "string" } },
4568
+ required: ["id"]
4569
+ }
4570
+ },
4571
+ {
4572
+ name: "log_event",
4573
+ description: "Log a meeting, call, or interaction event with one or more contacts.",
4574
+ inputSchema: {
4575
+ type: "object",
4576
+ properties: {
4577
+ title: { type: "string" },
4578
+ type: { type: "string", enum: ["meeting", "call", "email", "lunch", "conference", "demo", "other"] },
4579
+ event_date: { type: "string", description: "ISO date or datetime of the event" },
4580
+ duration_min: { type: "number", description: "Duration in minutes" },
4581
+ contact_ids: { type: "array", items: { type: "string" }, description: "Contact IDs who attended" },
4582
+ company_id: { type: "string" },
4583
+ notes: { type: "string" },
4584
+ outcome: { type: "string" },
4585
+ deal_id: { type: "string" }
4586
+ },
4587
+ required: ["title", "event_date"]
4588
+ }
4589
+ },
4590
+ {
4591
+ name: "list_events",
4592
+ description: "List events, optionally filtered by contact, company, type, or date range.",
4593
+ inputSchema: {
4594
+ type: "object",
4595
+ properties: {
4596
+ contact_id: { type: "string" },
4597
+ company_id: { type: "string" },
4598
+ type: { type: "string" },
4599
+ date_from: { type: "string" },
4600
+ date_to: { type: "string" }
4601
+ }
4602
+ }
4603
+ },
4604
+ {
4605
+ name: "delete_event",
4606
+ description: "Delete an event by ID.",
4607
+ inputSchema: {
4608
+ type: "object",
4609
+ properties: { id: { type: "string" } },
4610
+ required: ["id"]
4611
+ }
4612
+ },
4613
+ {
4614
+ name: "get_contact_timeline",
4615
+ description: "Get full chronological activity history for a contact: notes, events, tasks, vendor communications, interactions \u2014 all unified in reverse date order. The account history view.",
4616
+ inputSchema: {
4617
+ type: "object",
4618
+ properties: {
4619
+ contact_id: { type: "string" },
4620
+ limit: { type: "number", description: "Max items (default 50)" }
4621
+ },
4622
+ required: ["contact_id"]
4623
+ }
4624
+ },
4625
+ {
4626
+ name: "enrich_contact",
4627
+ description: "Search the web for missing contact data (LinkedIn, Twitter, GitHub, phone, company website) using the contact's name, email, and company. Returns SUGGESTIONS only \u2014 does not auto-apply. Review and apply with update_contact.",
4628
+ inputSchema: {
4629
+ type: "object",
4630
+ properties: { contact_id: { type: "string" } },
4631
+ required: ["contact_id"]
4632
+ }
4633
+ },
4634
+ {
4635
+ name: "get_contacts_for_context",
4636
+ description: "Find contacts relevant to a topic or domain. Searches job titles, notes, specializations, company names, relationship types, tags, and org memberships. Returns ranked results with relevance reason. Essential for agents: 'who do I contact about trademark law?' \u2192 returns attorneys at Revision Legal.",
4637
+ inputSchema: {
4638
+ type: "object",
4639
+ properties: {
4640
+ topic: { type: "string", description: "Topic or domain to search for (e.g. 'trademark law', 'payroll', 'banking')" },
4641
+ limit: { type: "number", description: "Max results (default 10)" }
4642
+ },
4643
+ required: ["topic"]
4644
+ }
4645
+ },
4646
+ {
4647
+ name: "set_reminder",
4648
+ description: "Schedule a follow-up reminder for a contact. Sets follow_up_at to the specified date and adds a note with the reminder text. Will appear in get_upcoming results. Use: set_reminder({contact_id, remind_at: '2026-04-01', note: 'Check on invoice status'})",
4649
+ inputSchema: {
4650
+ type: "object",
4651
+ properties: {
4652
+ contact_id: { type: "string" },
4653
+ remind_at: { type: "string", description: "Reminder date (YYYY-MM-DD)" },
4654
+ note: { type: "string", description: "Optional reminder note" }
4655
+ },
4656
+ required: ["contact_id", "remind_at"]
4657
+ }
4658
+ },
4659
+ {
4660
+ name: "check_and_fire_webhooks",
4661
+ description: "Check all registered webhooks and fire any that match current conditions: contact.stale (last_contacted_at > 30 days), task.overdue (deadline passed), followup.due (follow_up_at <= today). Returns list of fired webhooks.",
4662
+ inputSchema: { type: "object", properties: {} }
4663
+ },
4664
+ {
4665
+ name: "bulk_tag_contacts",
4666
+ description: "Apply or remove a tag from multiple contacts at once. Either pass contact_ids array directly, or pass a search query and all matching contacts will be tagged. Returns count of contacts tagged.",
4667
+ inputSchema: {
4668
+ type: "object",
4669
+ properties: {
4670
+ tag_id_or_name: { type: "string", description: "Tag ID (UUID) or tag name to apply/remove" },
4671
+ action: { type: "string", enum: ["add", "remove"] },
4672
+ contact_ids: { type: "array", items: { type: "string" }, description: "Specific contact IDs (alternative to query)" },
4673
+ query: { type: "string", description: "Search query \u2014 all matching contacts will be tagged" }
4674
+ },
4675
+ required: ["tag_id_or_name", "action"]
4676
+ }
4677
+ },
4678
+ {
4679
+ name: "set_do_not_contact",
4680
+ description: "Mark a contact as do-not-contact (DNC). They will be excluded from list_contacts, cold, and upcoming results unless explicitly requested. Use for GDPR compliance or unsubscribes.",
4681
+ inputSchema: {
4682
+ type: "object",
4683
+ properties: {
4684
+ contact_id: { type: "string" },
4685
+ do_not_contact: { type: "boolean" },
4686
+ reason: { type: "string", description: "Reason for DNC flag (optional)" }
4687
+ },
4688
+ required: ["contact_id", "do_not_contact"]
4689
+ }
3840
4690
  }
3841
4691
  ]
3842
4692
  }));
@@ -4054,18 +4904,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4054
4904
  }]
4055
4905
  };
4056
4906
  }
4057
- case "export_contacts": {
4058
- const format = a.format;
4059
- const contactIds = a.contact_ids;
4060
- let contactList;
4061
- if (contactIds && contactIds.length > 0) {
4062
- contactList = contactIds.map((id) => getContact(id));
4063
- } else {
4064
- contactList = listContacts({ limit: 1e4 }).contacts;
4065
- }
4066
- const output = await exportContacts(format, contactList);
4067
- return { content: [{ type: "text", text: output }] };
4068
- }
4069
4907
  case "get_stats": {
4070
4908
  const db = getDatabase();
4071
4909
  const contactCount = db.prepare("SELECT COUNT(*) as count FROM contacts").get().count;
@@ -4761,6 +5599,258 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4761
5599
  const team = listCompanyRelationships({ company_id: a.company_id }, db);
4762
5600
  return { content: [{ type: "text", text: JSON.stringify({ company, team }, null, 2) }] };
4763
5601
  }
5602
+ case "get_contact_brief": {
5603
+ const db = getDatabase();
5604
+ const brief = generateBrief(a.contact_id, db);
5605
+ return { content: [{ type: "text", text: JSON.stringify({ brief }, null, 2) }] };
5606
+ }
5607
+ case "list_cold_contacts": {
5608
+ const db = getDatabase();
5609
+ const contacts = listColdContacts(a.days ?? 30, db);
5610
+ return { content: [{ type: "text", text: JSON.stringify({ contacts }, null, 2) }] };
5611
+ }
5612
+ case "get_upcoming": {
5613
+ const db = getDatabase();
5614
+ const items = getUpcomingItems(a.days ?? 7, db);
5615
+ return { content: [{ type: "text", text: JSON.stringify({ items }, null, 2) }] };
5616
+ }
5617
+ case "get_network_stats": {
5618
+ const db = getDatabase();
5619
+ const stats = getNetworkStats(db);
5620
+ return { content: [{ type: "text", text: JSON.stringify(stats, null, 2) }] };
5621
+ }
5622
+ case "audit_contacts": {
5623
+ const db = getDatabase();
5624
+ const results = (await listContactAudit(db)).slice(0, a.limit ?? 20);
5625
+ return { content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }] };
5626
+ }
5627
+ case "create_deal": {
5628
+ const db = getDatabase();
5629
+ const deal = createDeal({
5630
+ title: a.title,
5631
+ contact_id: a.contact_id,
5632
+ company_id: a.company_id,
5633
+ stage: a.stage,
5634
+ value_usd: a.value_usd,
5635
+ currency: a.currency,
5636
+ close_date: a.close_date,
5637
+ notes: a.notes
5638
+ }, db);
5639
+ return { content: [{ type: "text", text: JSON.stringify(deal, null, 2) }] };
5640
+ }
5641
+ case "get_deal": {
5642
+ const db = getDatabase();
5643
+ const deal = getDeal(a.id, db);
5644
+ return { content: [{ type: "text", text: JSON.stringify(deal, null, 2) }] };
5645
+ }
5646
+ case "list_deals": {
5647
+ const db = getDatabase();
5648
+ const deals = listDeals({
5649
+ stage: a.stage,
5650
+ contact_id: a.contact_id,
5651
+ company_id: a.company_id
5652
+ }, db);
5653
+ return { content: [{ type: "text", text: JSON.stringify({ deals }, null, 2) }] };
5654
+ }
5655
+ case "update_deal": {
5656
+ const db = getDatabase();
5657
+ const { id: dealId, ...dealRest } = a;
5658
+ const deal = updateDeal(dealId, {
5659
+ title: dealRest.title,
5660
+ stage: dealRest.stage,
5661
+ value_usd: dealRest.value_usd,
5662
+ close_date: dealRest.close_date,
5663
+ notes: dealRest.notes
5664
+ }, db);
5665
+ return { content: [{ type: "text", text: JSON.stringify(deal, null, 2) }] };
5666
+ }
5667
+ case "delete_deal": {
5668
+ const db = getDatabase();
5669
+ deleteDeal(a.id, db);
5670
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true }) }] };
5671
+ }
5672
+ case "log_event": {
5673
+ const db = getDatabase();
5674
+ const event = logEvent({
5675
+ title: a.title,
5676
+ type: a.type,
5677
+ event_date: a.event_date,
5678
+ duration_min: a.duration_min,
5679
+ contact_ids: a.contact_ids,
5680
+ company_id: a.company_id,
5681
+ notes: a.notes,
5682
+ outcome: a.outcome,
5683
+ deal_id: a.deal_id
5684
+ }, db);
5685
+ return { content: [{ type: "text", text: JSON.stringify(event, null, 2) }] };
5686
+ }
5687
+ case "list_events": {
5688
+ const db = getDatabase();
5689
+ const events = listEvents({
5690
+ contact_id: a.contact_id,
5691
+ company_id: a.company_id,
5692
+ type: a.type,
5693
+ date_from: a.date_from,
5694
+ date_to: a.date_to
5695
+ }, db);
5696
+ return { content: [{ type: "text", text: JSON.stringify({ events }, null, 2) }] };
5697
+ }
5698
+ case "delete_event": {
5699
+ const db = getDatabase();
5700
+ deleteEvent(a.id, db);
5701
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true }) }] };
5702
+ }
5703
+ case "get_contact_timeline": {
5704
+ const db = getDatabase();
5705
+ const items = getContactTimeline(a.contact_id, a.limit ?? 50, db);
5706
+ return { content: [{ type: "text", text: JSON.stringify({ items }, null, 2) }] };
5707
+ }
5708
+ case "enrich_contact": {
5709
+ const db = getDatabase();
5710
+ const contact = getContact(a.contact_id);
5711
+ const exaKey = process.env["EXA_API_KEY"];
5712
+ if (!exaKey) {
5713
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Set EXA_API_KEY to use enrichment", contact_id: a.contact_id, suggestions: [] }, null, 2) }] };
5714
+ }
5715
+ const query = `${contact.display_name} ${contact.emails?.[0]?.address ?? ""} site:linkedin.com OR site:twitter.com OR site:github.com`;
5716
+ const res = await fetch("https://api.exa.ai/search", {
5717
+ method: "POST",
5718
+ headers: { "x-api-key": exaKey, "content-type": "application/json" },
5719
+ body: JSON.stringify({ query, num_results: 5 })
5720
+ });
5721
+ const data = await res.json();
5722
+ const suggestions = {};
5723
+ const socialProfiles = contact.social_profiles;
5724
+ for (const r of data.results ?? []) {
5725
+ if (r.url?.includes("linkedin.com") && !socialProfiles?.find((s) => s.platform === "linkedin"))
5726
+ suggestions["linkedin"] = r.url;
5727
+ if (r.url?.includes("twitter.com") && !socialProfiles?.find((s) => s.platform === "twitter"))
5728
+ suggestions["twitter"] = r.url;
5729
+ if (r.url?.includes("github.com") && !socialProfiles?.find((s) => s.platform === "github"))
5730
+ suggestions["github"] = r.url;
5731
+ }
5732
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id: a.contact_id, contact_name: contact.display_name, suggestions, raw_results: data.results?.slice(0, 3) }, null, 2) }] };
5733
+ }
5734
+ case "get_contacts_for_context": {
5735
+ const db = getDatabase();
5736
+ const { topic, limit = 10 } = a;
5737
+ const byTitle = db.query(`SELECT c.id, c.display_name, c.job_title, 'job_title' as reason FROM contacts c WHERE c.job_title LIKE ? AND c.archived=0 LIMIT 20`).all(`%${topic}%`);
5738
+ const byNotes = db.query(`SELECT c.id, c.display_name, c.job_title, 'notes' as reason FROM contacts c WHERE c.notes LIKE ? AND c.archived=0 LIMIT 10`).all(`%${topic}%`);
5739
+ const byCompany = db.query(`SELECT c.id, c.display_name, c.job_title, 'company' as reason FROM contacts c JOIN companies co ON c.company_id = co.id WHERE (co.name LIKE ? OR co.industry LIKE ?) AND c.archived=0 LIMIT 10`).all(`%${topic}%`, `%${topic}%`);
5740
+ const bySpec = db.query(`SELECT c.id, c.display_name, c.job_title, om.specialization as reason FROM contacts c JOIN org_members om ON c.id = om.contact_id WHERE om.specialization LIKE ? LIMIT 10`).all(`%${topic}%`);
5741
+ const seen = new Set;
5742
+ const results = [...byTitle, ...bySpec, ...byCompany, ...byNotes].filter((r) => {
5743
+ if (seen.has(r.id))
5744
+ return false;
5745
+ seen.add(r.id);
5746
+ return true;
5747
+ }).slice(0, limit);
5748
+ return { content: [{ type: "text", text: JSON.stringify({ topic, results }, null, 2) }] };
5749
+ }
5750
+ case "set_reminder": {
5751
+ const db = getDatabase();
5752
+ updateContact(a.contact_id, { follow_up_at: a.remind_at });
5753
+ if (a.note) {
5754
+ addNote(a.contact_id, `Reminder (${a.remind_at}): ${a.note}`, undefined, db);
5755
+ }
5756
+ return { content: [{ type: "text", text: JSON.stringify({ set: true, contact_id: a.contact_id, remind_at: a.remind_at }, null, 2) }] };
5757
+ }
5758
+ case "check_and_fire_webhooks": {
5759
+ const db = getDatabase();
5760
+ let webhooks = [];
5761
+ try {
5762
+ webhooks = db.query(`SELECT id, event_type, url, secret FROM webhooks WHERE active=1`).all();
5763
+ } catch {
5764
+ return { content: [{ type: "text", text: JSON.stringify({ fired: [], message: "webhooks table not available" }, null, 2) }] };
5765
+ }
5766
+ const today = new Date().toISOString().slice(0, 10);
5767
+ const fired = [];
5768
+ for (const wh of webhooks) {
5769
+ let payload = null;
5770
+ if (wh.event_type === "contact.stale") {
5771
+ const stale = db.query(`SELECT id, display_name, last_contacted_at FROM contacts WHERE (last_contacted_at IS NULL OR last_contacted_at < date('now', '-30 days')) AND archived=0 LIMIT 50`).all();
5772
+ if (stale.length > 0)
5773
+ payload = { event: "contact.stale", contacts: stale, fired_at: new Date().toISOString() };
5774
+ } else if (wh.event_type === "task.overdue") {
5775
+ const overdue = listOverdueTasks(db);
5776
+ if (overdue.length > 0)
5777
+ payload = { event: "task.overdue", tasks: overdue, fired_at: new Date().toISOString() };
5778
+ } else if (wh.event_type === "followup.due") {
5779
+ const due = db.query(`SELECT id, display_name, follow_up_at FROM contacts WHERE follow_up_at IS NOT NULL AND follow_up_at <= ? AND archived=0`).all(today);
5780
+ if (due.length > 0)
5781
+ payload = { event: "followup.due", contacts: due, fired_at: new Date().toISOString() };
5782
+ }
5783
+ if (payload) {
5784
+ const headers = { "content-type": "application/json" };
5785
+ if (wh.secret) {
5786
+ const crypto2 = await import("crypto");
5787
+ const sig = crypto2.createHmac("sha256", wh.secret).update(JSON.stringify(payload)).digest("hex");
5788
+ headers["x-contacts-signature"] = `sha256=${sig}`;
5789
+ }
5790
+ try {
5791
+ const resp = await fetch(wh.url, { method: "POST", headers, body: JSON.stringify(payload) });
5792
+ fired.push({ webhook_id: wh.id, event_type: wh.event_type, status: resp.status });
5793
+ } catch {
5794
+ fired.push({ webhook_id: wh.id, event_type: wh.event_type, status: 0 });
5795
+ }
5796
+ }
5797
+ }
5798
+ return { content: [{ type: "text", text: JSON.stringify({ fired }, null, 2) }] };
5799
+ }
5800
+ case "bulk_tag_contacts": {
5801
+ const db = getDatabase();
5802
+ const tagInput = a.tag_id_or_name;
5803
+ const action = a.action;
5804
+ const isUuid = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(tagInput);
5805
+ let tagId = isUuid ? tagInput : null;
5806
+ let tagName = tagInput;
5807
+ if (!tagId) {
5808
+ const tag = getTagByName(tagInput, db);
5809
+ if (!tag)
5810
+ return { content: [{ type: "text", text: `Tag not found: ${tagInput}` }], isError: true };
5811
+ tagId = tag.id;
5812
+ tagName = tag.name;
5813
+ }
5814
+ let contactIds = a.contact_ids ?? [];
5815
+ if (a.query && typeof a.query === "string") {
5816
+ const found = searchContacts(a.query);
5817
+ contactIds = [...contactIds, ...found.map((c) => c.id)];
5818
+ }
5819
+ contactIds = [...new Set(contactIds)];
5820
+ let taggedCount = 0;
5821
+ for (const cid of contactIds) {
5822
+ try {
5823
+ if (action === "add") {
5824
+ addTagToContact(cid, tagId);
5825
+ } else {
5826
+ removeTagFromContact(cid, tagId);
5827
+ }
5828
+ taggedCount++;
5829
+ } catch {}
5830
+ }
5831
+ return { content: [{ type: "text", text: JSON.stringify({ tagged_count: taggedCount, tag_name: tagName, action }, null, 2) }] };
5832
+ }
5833
+ case "set_do_not_contact": {
5834
+ const db = getDatabase();
5835
+ updateContact(a.contact_id, { do_not_contact: a.do_not_contact });
5836
+ if (a.reason && !!a.do_not_contact) {
5837
+ addNote(a.contact_id, `DNC: ${a.reason}`, undefined, db);
5838
+ }
5839
+ return { content: [{ type: "text", text: JSON.stringify({ set: true, contact_id: a.contact_id, do_not_contact: a.do_not_contact }, null, 2) }] };
5840
+ }
5841
+ case "export_contacts": {
5842
+ const format = a.format;
5843
+ const contactIds = a.contact_ids;
5844
+ const updatedSince = a.updated_since;
5845
+ let contactList;
5846
+ if (contactIds && contactIds.length > 0) {
5847
+ contactList = contactIds.map((id) => getContact(id));
5848
+ } else {
5849
+ contactList = listContacts({ limit: 1e4, ...updatedSince ? { last_contacted_after: updatedSince } : {} }).contacts;
5850
+ }
5851
+ const output = await exportContacts(format, contactList);
5852
+ return { content: [{ type: "text", text: output }] };
5853
+ }
4764
5854
  default:
4765
5855
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
4766
5856
  }