@hasna/contacts 0.2.2 → 0.2.4

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
@@ -198,6 +198,20 @@ var MIGRATIONS = [
198
198
  group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
199
199
  PRIMARY KEY (contact_id, group_id)
200
200
  );
201
+ `,
202
+ `
203
+ ALTER TABLE contacts ADD COLUMN status TEXT DEFAULT 'active';
204
+ ALTER TABLE contacts ADD COLUMN follow_up_at TEXT;
205
+ ALTER TABLE contacts ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
206
+ ALTER TABLE contacts ADD COLUMN project_id TEXT;
207
+ ALTER TABLE companies ADD COLUMN archived INTEGER NOT NULL DEFAULT 0;
208
+ ALTER TABLE companies ADD COLUMN project_id TEXT;
209
+
210
+ CREATE TABLE IF NOT EXISTS company_groups (
211
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
212
+ group_id TEXT NOT NULL REFERENCES groups(id) ON DELETE CASCADE,
213
+ PRIMARY KEY (company_id, group_id)
214
+ );
201
215
  `
202
216
  ];
203
217
  var _db = null;
@@ -269,11 +283,32 @@ class DuplicateTagNameError extends Error {
269
283
  }
270
284
 
271
285
  // src/db/activity.ts
286
+ function rowToActivity(row) {
287
+ return { ...row };
288
+ }
272
289
  function logActivity(db, input) {
273
290
  const id = uuid();
274
291
  db.run(`INSERT INTO activity_log (id, contact_id, company_id, action, details) VALUES (?, ?, ?, ?, ?)`, [id, input.contact_id ?? null, input.company_id ?? null, input.action, input.details ?? null]);
275
292
  return db.query(`SELECT * FROM activity_log WHERE id = ?`).get(id);
276
293
  }
294
+ function listActivity(opts = {}, db) {
295
+ const d = db || getDatabase();
296
+ const { limit = 50, offset = 0, contact_id, company_id } = opts;
297
+ const conditions = [];
298
+ const params = [];
299
+ if (contact_id) {
300
+ conditions.push("contact_id = ?");
301
+ params.push(contact_id);
302
+ }
303
+ if (company_id) {
304
+ conditions.push("company_id = ?");
305
+ params.push(company_id);
306
+ }
307
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
308
+ const totalRow = d.query(`SELECT COUNT(*) as total FROM activity_log ${where}`).get(...params);
309
+ const rows = d.query(`SELECT * FROM activity_log ${where} ORDER BY created_at DESC LIMIT ? OFFSET ?`).all(...params, limit, offset);
310
+ return { entries: rows.map(rowToActivity), total: totalRow.total };
311
+ }
277
312
 
278
313
  // src/db/contacts.ts
279
314
  function rowToContact(row) {
@@ -281,7 +316,11 @@ function rowToContact(row) {
281
316
  ...row,
282
317
  source: row.source,
283
318
  custom_fields: JSON.parse(row.custom_fields || "{}"),
284
- preferred_contact_method: row.preferred_contact_method ?? null
319
+ preferred_contact_method: row.preferred_contact_method ?? null,
320
+ status: row.status ?? "active",
321
+ follow_up_at: row.follow_up_at ?? null,
322
+ archived: !!row.archived,
323
+ project_id: row.project_id ?? null
285
324
  };
286
325
  }
287
326
  function rowToEmail(row) {
@@ -318,7 +357,9 @@ function rowToTag(row) {
318
357
  function rowToCompany(row) {
319
358
  return {
320
359
  ...row,
321
- custom_fields: JSON.parse(row.custom_fields || "{}")
360
+ custom_fields: JSON.parse(row.custom_fields || "{}"),
361
+ archived: !!row.archived,
362
+ project_id: row.project_id ?? null
322
363
  };
323
364
  }
324
365
  function insertEmails(db, contactId, companyId, emails) {
@@ -362,8 +403,8 @@ function createContact(input, db) {
362
403
  const firstName = input.first_name ?? "";
363
404
  const lastName = input.last_name ?? "";
364
405
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
365
- 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, created_at, updated_at)
366
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
406
+ 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)
407
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
367
408
  id,
368
409
  firstName,
369
410
  lastName,
@@ -379,6 +420,9 @@ function createContact(input, db) {
379
420
  input.last_contacted_at ?? null,
380
421
  input.website ?? null,
381
422
  input.preferred_contact_method ?? null,
423
+ input.status ?? "active",
424
+ input.follow_up_at ?? null,
425
+ input.project_id ?? null,
382
426
  timestamp,
383
427
  timestamp
384
428
  ]);
@@ -413,12 +457,21 @@ function listContacts(opts = {}, db) {
413
457
  offset = 0,
414
458
  company_id,
415
459
  tag_id,
460
+ tag_ids,
416
461
  source,
462
+ status,
463
+ project_id,
464
+ archived = false,
465
+ follow_up_due,
466
+ last_contacted_after,
467
+ last_contacted_before,
417
468
  order_by = "display_name",
418
469
  order_dir = "asc"
419
470
  } = opts;
420
471
  const conditions = [];
421
472
  const params = [];
473
+ conditions.push("c.archived = ?");
474
+ params.push(archived ? 1 : 0);
422
475
  if (company_id) {
423
476
  conditions.push("c.company_id = ?");
424
477
  params.push(company_id);
@@ -427,12 +480,37 @@ function listContacts(opts = {}, db) {
427
480
  conditions.push("c.source = ?");
428
481
  params.push(source);
429
482
  }
483
+ if (status) {
484
+ conditions.push("c.status = ?");
485
+ params.push(status);
486
+ }
487
+ if (project_id) {
488
+ conditions.push("c.project_id = ?");
489
+ params.push(project_id);
490
+ }
430
491
  if (tag_id) {
431
492
  conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
432
493
  params.push(tag_id);
433
494
  }
495
+ if (tag_ids?.length) {
496
+ for (const tid of tag_ids) {
497
+ conditions.push("EXISTS (SELECT 1 FROM contact_tags ct WHERE ct.contact_id = c.id AND ct.tag_id = ?)");
498
+ params.push(tid);
499
+ }
500
+ }
501
+ if (follow_up_due) {
502
+ conditions.push("c.follow_up_at IS NOT NULL AND c.follow_up_at <= datetime('now')");
503
+ }
504
+ if (last_contacted_after) {
505
+ conditions.push("c.last_contacted_at >= ?");
506
+ params.push(last_contacted_after);
507
+ }
508
+ if (last_contacted_before) {
509
+ conditions.push("c.last_contacted_at <= ?");
510
+ params.push(last_contacted_before);
511
+ }
434
512
  const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
435
- const validOrderBy = ["display_name", "created_at", "updated_at"].includes(order_by) ? order_by : "display_name";
513
+ const validOrderBy = ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"].includes(order_by) ? order_by : "display_name";
436
514
  const validOrderDir = order_dir === "desc" ? "DESC" : "ASC";
437
515
  const totalRow = d.query(`SELECT COUNT(*) as total FROM contacts c ${where}`).get(...params);
438
516
  const rows = d.query(`SELECT c.* FROM contacts c ${where} ORDER BY c.${validOrderBy} ${validOrderDir} LIMIT ? OFFSET ?`).all(...params, limit, offset);
@@ -502,8 +580,36 @@ function updateContact(id, input, db) {
502
580
  setClauses.push("preferred_contact_method = ?");
503
581
  params.push(input.preferred_contact_method);
504
582
  }
583
+ if (input.status !== undefined) {
584
+ setClauses.push("status = ?");
585
+ params.push(input.status);
586
+ }
587
+ if (input.follow_up_at !== undefined) {
588
+ setClauses.push("follow_up_at = ?");
589
+ params.push(input.follow_up_at);
590
+ }
591
+ if (input.project_id !== undefined) {
592
+ setClauses.push("project_id = ?");
593
+ params.push(input.project_id);
594
+ }
505
595
  params.push(id);
506
596
  d.run(`UPDATE contacts SET ${setClauses.join(", ")} WHERE id = ?`, params);
597
+ if (input.emails_add?.length) {
598
+ for (const e of input.emails_add) {
599
+ const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(id, e.address);
600
+ if (!exists) {
601
+ d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), id, e.address, e.type ?? "work", e.is_primary ? 1 : 0]);
602
+ }
603
+ }
604
+ }
605
+ if (input.phones_add?.length) {
606
+ for (const p of input.phones_add) {
607
+ const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(id, p.number);
608
+ if (!exists) {
609
+ d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), id, p.number, p.country_code ?? null, p.type ?? "mobile", p.is_primary ? 1 : 0]);
610
+ }
611
+ }
612
+ }
507
613
  logActivity(d, { contact_id: id, action: "contact.updated", details: `Updated contact: ${existing.display_name}` });
508
614
  const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
509
615
  return loadContactDetails(d, rowToContact(row));
@@ -521,25 +627,31 @@ function searchContacts(query, db) {
521
627
  const ftsRows = d.query(`
522
628
  SELECT c.* FROM contacts c
523
629
  JOIN contacts_fts fts ON fts.id = c.id
524
- WHERE contacts_fts MATCH ?
630
+ WHERE contacts_fts MATCH ? AND c.archived = 0
525
631
  ORDER BY rank
526
632
  LIMIT 50
527
633
  `).all(`"${query.replace(/"/g, '""')}"*`);
528
634
  const emailRows = d.query(`
529
635
  SELECT DISTINCT c.* FROM contacts c
530
636
  JOIN emails e ON e.contact_id = c.id
531
- WHERE e.address LIKE ?
637
+ WHERE e.address LIKE ? AND c.archived = 0
532
638
  LIMIT 20
533
639
  `).all(`%${query}%`);
534
640
  const phoneRows = d.query(`
535
641
  SELECT DISTINCT c.* FROM contacts c
536
642
  JOIN phones p ON p.contact_id = c.id
537
- WHERE p.number LIKE ?
643
+ WHERE p.number LIKE ? AND c.archived = 0
644
+ LIMIT 20
645
+ `).all(`%${query}%`);
646
+ const companyRows = d.query(`
647
+ SELECT DISTINCT c.* FROM contacts c
648
+ JOIN companies co ON co.id = c.company_id
649
+ WHERE co.name LIKE ? AND c.archived = 0
538
650
  LIMIT 20
539
651
  `).all(`%${query}%`);
540
652
  const seen = new Set;
541
653
  const allRows = [];
542
- for (const row of [...ftsRows, ...emailRows, ...phoneRows]) {
654
+ for (const row of [...ftsRows, ...emailRows, ...phoneRows, ...companyRows]) {
543
655
  if (!seen.has(row.id)) {
544
656
  seen.add(row.id);
545
657
  allRows.push(row);
@@ -555,8 +667,24 @@ function mergeContacts(keepId, mergeId, db) {
555
667
  const mergeRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(mergeId);
556
668
  if (!mergeRow)
557
669
  throw new ContactNotFoundError(mergeId);
558
- d.run(`UPDATE emails SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
559
- d.run(`UPDATE phones SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
670
+ const mergeEmailRows = d.query(`SELECT * FROM emails WHERE contact_id = ?`).all(mergeId);
671
+ for (const e of mergeEmailRows) {
672
+ const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(keepId, e.address);
673
+ if (exists) {
674
+ d.run(`DELETE FROM emails WHERE id = ?`, [e.id]);
675
+ } else {
676
+ d.run(`UPDATE emails SET contact_id = ? WHERE id = ?`, [keepId, e.id]);
677
+ }
678
+ }
679
+ const mergePhoneRows = d.query(`SELECT * FROM phones WHERE contact_id = ?`).all(mergeId);
680
+ for (const p of mergePhoneRows) {
681
+ const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(keepId, p.number);
682
+ if (exists) {
683
+ d.run(`DELETE FROM phones WHERE id = ?`, [p.id]);
684
+ } else {
685
+ d.run(`UPDATE phones SET contact_id = ? WHERE id = ?`, [keepId, p.id]);
686
+ }
687
+ }
560
688
  d.run(`UPDATE addresses SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
561
689
  d.run(`UPDATE social_profiles SET contact_id = ? WHERE contact_id = ?`, [keepId, mergeId]);
562
690
  const mergeTags = d.query(`SELECT tag_id FROM contact_tags WHERE contact_id = ?`).all(mergeId);
@@ -603,6 +731,79 @@ function mergeContacts(keepId, mergeId, db) {
603
731
  const finalRow = d.query(`SELECT * FROM contacts WHERE id = ?`).get(keepId);
604
732
  return loadContactDetails(d, rowToContact(finalRow));
605
733
  }
734
+ function getContactByEmail(email, db) {
735
+ const d = db || getDatabase();
736
+ const emailRow = d.query(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(email);
737
+ if (!emailRow)
738
+ return null;
739
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(emailRow.contact_id);
740
+ if (!row)
741
+ return null;
742
+ return loadContactDetails(d, rowToContact(row));
743
+ }
744
+ function addEmailToContact(contactId, email, db) {
745
+ const d = db || getDatabase();
746
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
747
+ if (!row)
748
+ throw new ContactNotFoundError(contactId);
749
+ const exists = d.query(`SELECT id FROM emails WHERE contact_id = ? AND LOWER(address) = LOWER(?)`).get(contactId, email.address);
750
+ if (!exists) {
751
+ d.run(`INSERT INTO emails (id, contact_id, company_id, address, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?)`, [uuid(), contactId, email.address, email.type ?? "work", email.is_primary ? 1 : 0]);
752
+ }
753
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
754
+ return loadContactDetails(d, rowToContact(updated));
755
+ }
756
+ function addPhoneToContact(contactId, phone, db) {
757
+ const d = db || getDatabase();
758
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
759
+ if (!row)
760
+ throw new ContactNotFoundError(contactId);
761
+ const exists = d.query(`SELECT id FROM phones WHERE contact_id = ? AND number = ?`).get(contactId, phone.number);
762
+ if (!exists) {
763
+ d.run(`INSERT INTO phones (id, contact_id, company_id, number, country_code, type, is_primary) VALUES (?, ?, NULL, ?, ?, ?, ?)`, [uuid(), contactId, phone.number, phone.country_code ?? null, phone.type ?? "mobile", phone.is_primary ? 1 : 0]);
764
+ }
765
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
766
+ return loadContactDetails(d, rowToContact(updated));
767
+ }
768
+ function archiveContact(id, db) {
769
+ const d = db || getDatabase();
770
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
771
+ if (!row)
772
+ throw new ContactNotFoundError(id);
773
+ d.run(`UPDATE contacts SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
774
+ logActivity(d, { contact_id: id, action: "contact.archived", details: `Archived contact: ${row.display_name}` });
775
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
776
+ return loadContactDetails(d, rowToContact(updated));
777
+ }
778
+ function unarchiveContact(id, db) {
779
+ const d = db || getDatabase();
780
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
781
+ if (!row)
782
+ throw new ContactNotFoundError(id);
783
+ d.run(`UPDATE contacts SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
784
+ logActivity(d, { contact_id: id, action: "contact.unarchived", details: `Unarchived contact: ${row.display_name}` });
785
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(id);
786
+ return loadContactDetails(d, rowToContact(updated));
787
+ }
788
+ function autoLinkContactToCompany(contactId, db) {
789
+ const d = db || getDatabase();
790
+ const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
791
+ if (!row || row.company_id)
792
+ return null;
793
+ const emailRow = d.query(`SELECT address FROM emails WHERE contact_id = ? AND contact_id IS NOT NULL LIMIT 1`).get(contactId);
794
+ if (!emailRow)
795
+ return null;
796
+ const domain = emailRow.address.split("@")[1];
797
+ if (!domain)
798
+ return null;
799
+ const companyRow = d.query(`SELECT id FROM companies WHERE domain = ? LIMIT 1`).get(domain);
800
+ if (!companyRow)
801
+ return null;
802
+ d.run(`UPDATE contacts SET company_id = ?, updated_at = ? WHERE id = ?`, [companyRow.id, now(), contactId]);
803
+ logActivity(d, { contact_id: contactId, action: "contact.auto_linked", details: `Auto-linked to company via email domain: ${domain}` });
804
+ const updated = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
805
+ return loadContactDetails(d, rowToContact(updated));
806
+ }
606
807
 
607
808
  // src/db/groups.ts
608
809
  function createGroup(db, input) {
@@ -614,7 +815,10 @@ function getGroup(db, id) {
614
815
  return db.query(`SELECT * FROM groups WHERE id = ?`).get(id);
615
816
  }
616
817
  function listGroups(db) {
617
- return db.query(`SELECT g.*, COUNT(cg.contact_id) as member_count FROM groups g LEFT JOIN contact_groups cg ON g.id = cg.group_id GROUP BY g.id ORDER BY g.name`).all();
818
+ return db.query(`SELECT g.*,
819
+ (SELECT COUNT(*) FROM contact_groups cg WHERE cg.group_id = g.id) as member_count,
820
+ (SELECT COUNT(*) FROM company_groups cog WHERE cog.group_id = g.id) as company_count
821
+ FROM groups g ORDER BY g.name`).all();
618
822
  }
619
823
  function updateGroup(db, id, input) {
620
824
  const fields = [];
@@ -637,7 +841,11 @@ function deleteGroup(db, id) {
637
841
  db.query(`DELETE FROM groups WHERE id = ?`).run(id);
638
842
  }
639
843
  function addContactToGroup(db, contactId, groupId) {
640
- db.query(`INSERT OR IGNORE INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
844
+ const existing = db.query(`SELECT 1 FROM contact_groups WHERE contact_id = ? AND group_id = ?`).get(contactId, groupId);
845
+ if (existing)
846
+ return { added: false, already_member: true };
847
+ db.query(`INSERT INTO contact_groups(contact_id, group_id) VALUES(?,?)`).run(contactId, groupId);
848
+ return { added: true, already_member: false };
641
849
  }
642
850
  function removeContactFromGroup(db, contactId, groupId) {
643
851
  db.query(`DELETE FROM contact_groups WHERE contact_id = ? AND group_id = ?`).run(contactId, groupId);
@@ -649,6 +857,23 @@ function listContactsInGroup(db, groupId) {
649
857
  function listGroupsForContact(db, contactId) {
650
858
  return db.query(`SELECT g.* FROM groups g JOIN contact_groups cg ON g.id = cg.group_id WHERE cg.contact_id = ? ORDER BY g.name`).all(contactId);
651
859
  }
860
+ function addCompanyToGroup(db, companyId, groupId) {
861
+ const existing = db.query(`SELECT 1 FROM company_groups WHERE company_id = ? AND group_id = ?`).get(companyId, groupId);
862
+ if (existing)
863
+ return { added: false, already_member: true };
864
+ db.query(`INSERT INTO company_groups(company_id, group_id) VALUES(?,?)`).run(companyId, groupId);
865
+ return { added: true, already_member: false };
866
+ }
867
+ function removeCompanyFromGroup(db, companyId, groupId) {
868
+ db.query(`DELETE FROM company_groups WHERE company_id = ? AND group_id = ?`).run(companyId, groupId);
869
+ }
870
+ function listCompaniesInGroup(db, groupId) {
871
+ const rows = db.query(`SELECT company_id FROM company_groups WHERE group_id = ?`).all(groupId);
872
+ return rows.map((r) => r.company_id);
873
+ }
874
+ function listGroupsForCompany(db, companyId) {
875
+ return db.query(`SELECT g.* FROM groups g JOIN company_groups cog ON g.id = cog.group_id WHERE cog.company_id = ? ORDER BY g.name`).all(companyId);
876
+ }
652
877
 
653
878
  // src/db/tags.ts
654
879
  function rowToTag2(row) {
@@ -693,12 +918,28 @@ function removeTagFromContact(contactId, tagId, db) {
693
918
  const d = db || getDatabase();
694
919
  d.run(`DELETE FROM contact_tags WHERE contact_id = ? AND tag_id = ?`, [contactId, tagId]);
695
920
  }
921
+ function addTagToCompany(companyId, tagId, db) {
922
+ const d = db || getDatabase();
923
+ const company = d.query(`SELECT id FROM companies WHERE id = ?`).get(companyId);
924
+ if (!company)
925
+ throw new CompanyNotFoundError(companyId);
926
+ const tag = d.query(`SELECT id FROM tags WHERE id = ?`).get(tagId);
927
+ if (!tag)
928
+ throw new TagNotFoundError(tagId);
929
+ d.run(`INSERT OR IGNORE INTO company_tags (company_id, tag_id) VALUES (?, ?)`, [companyId, tagId]);
930
+ }
931
+ function removeTagFromCompany(companyId, tagId, db) {
932
+ const d = db || getDatabase();
933
+ d.run(`DELETE FROM company_tags WHERE company_id = ? AND tag_id = ?`, [companyId, tagId]);
934
+ }
696
935
 
697
936
  // src/db/companies.ts
698
937
  function rowToCompany2(row) {
699
938
  return {
700
939
  ...row,
701
- custom_fields: JSON.parse(row.custom_fields || "{}")
940
+ custom_fields: JSON.parse(row.custom_fields || "{}"),
941
+ archived: !!row.archived,
942
+ project_id: row.project_id ?? null
702
943
  };
703
944
  }
704
945
  function insertEmails2(db, companyId, emails) {
@@ -808,15 +1049,23 @@ function listCompanies(opts = {}, db) {
808
1049
  offset = 0,
809
1050
  industry,
810
1051
  tag_id,
1052
+ project_id,
1053
+ archived = false,
811
1054
  order_by = "name",
812
1055
  order_dir = "asc"
813
1056
  } = opts;
814
1057
  const conditions = [];
815
1058
  const params = [];
1059
+ conditions.push("co.archived = ?");
1060
+ params.push(archived ? 1 : 0);
816
1061
  if (industry) {
817
1062
  conditions.push("co.industry = ?");
818
1063
  params.push(industry);
819
1064
  }
1065
+ if (project_id) {
1066
+ conditions.push("co.project_id = ?");
1067
+ params.push(project_id);
1068
+ }
820
1069
  if (tag_id) {
821
1070
  conditions.push("EXISTS (SELECT 1 FROM company_tags ct WHERE ct.company_id = co.id AND ct.tag_id = ?)");
822
1071
  params.push(tag_id);
@@ -872,6 +1121,10 @@ function updateCompany(id, input, db) {
872
1121
  setClauses.push("custom_fields = ?");
873
1122
  params.push(JSON.stringify(input.custom_fields));
874
1123
  }
1124
+ if ("project_id" in input && input.project_id !== undefined) {
1125
+ setClauses.push("project_id = ?");
1126
+ params.push(input.project_id);
1127
+ }
875
1128
  params.push(id);
876
1129
  d.run(`UPDATE companies SET ${setClauses.join(", ")} WHERE id = ?`, params);
877
1130
  logActivity(d, { company_id: id, action: "company.updated", details: `Updated company: ${existing.name}` });
@@ -895,6 +1148,26 @@ function searchCompanies(query, db) {
895
1148
  `).all(`%${query}%`, `%${query}%`, `%${query}%`, `%${query}%`);
896
1149
  return rows.map((row) => loadCompanyDetails(d, rowToCompany2(row)));
897
1150
  }
1151
+ function archiveCompany(id, db) {
1152
+ const d = db || getDatabase();
1153
+ const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1154
+ if (!row)
1155
+ throw new CompanyNotFoundError(id);
1156
+ d.run(`UPDATE companies SET archived = 1, updated_at = ? WHERE id = ?`, [now(), id]);
1157
+ logActivity(d, { company_id: id, action: "company.archived", details: `Archived company: ${row.name}` });
1158
+ const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1159
+ return loadCompanyDetails(d, rowToCompany2(updated));
1160
+ }
1161
+ function unarchiveCompany(id, db) {
1162
+ const d = db || getDatabase();
1163
+ const row = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1164
+ if (!row)
1165
+ throw new CompanyNotFoundError(id);
1166
+ d.run(`UPDATE companies SET archived = 0, updated_at = ? WHERE id = ?`, [now(), id]);
1167
+ logActivity(d, { company_id: id, action: "company.unarchived", details: `Unarchived company: ${row.name}` });
1168
+ const updated = d.query(`SELECT * FROM companies WHERE id = ?`).get(id);
1169
+ return loadCompanyDetails(d, rowToCompany2(updated));
1170
+ }
898
1171
 
899
1172
  // src/db/relationships.ts
900
1173
  function rowToRelationship(row) {
@@ -937,6 +1210,39 @@ function deleteRelationship(id, db) {
937
1210
  d.run(`DELETE FROM contact_relationships WHERE id = ?`, [id]);
938
1211
  }
939
1212
 
1213
+ // src/lib/dedup.ts
1214
+ function findEmailDuplicates(db) {
1215
+ const rows = db.query(`
1216
+ SELECT e.address as email, GROUP_CONCAT(e.contact_id) as ids
1217
+ FROM emails e
1218
+ WHERE e.contact_id IS NOT NULL
1219
+ GROUP BY LOWER(e.address)
1220
+ HAVING COUNT(*) > 1
1221
+ `).all();
1222
+ return rows.map((r) => ({ email: r.email, contact_ids: r.ids.split(",") }));
1223
+ }
1224
+ function levenshtein(a, b) {
1225
+ const m = a.length, n = b.length;
1226
+ const dp = Array.from({ length: m + 1 }, (_, i) => Array.from({ length: n + 1 }, (_2, j) => i === 0 ? j : j === 0 ? i : 0));
1227
+ for (let i = 1;i <= m; i++)
1228
+ for (let j = 1;j <= n; j++)
1229
+ dp[i][j] = a[i - 1] === b[j - 1] ? dp[i - 1][j - 1] : 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
1230
+ return dp[m][n];
1231
+ }
1232
+ function findNameDuplicates(db) {
1233
+ const contacts = db.query(`SELECT id, display_name FROM contacts`).all();
1234
+ const pairs = [];
1235
+ for (let i = 0;i < contacts.length; i++) {
1236
+ for (let j = i + 1;j < contacts.length; j++) {
1237
+ const dist = levenshtein(contacts[i].display_name.toLowerCase(), contacts[j].display_name.toLowerCase());
1238
+ if (dist <= 2 && dist > 0) {
1239
+ pairs.push({ contact_ids: [contacts[i].id, contacts[j].id], similarity: dist });
1240
+ }
1241
+ }
1242
+ }
1243
+ return pairs;
1244
+ }
1245
+
940
1246
  // src/lib/import.ts
941
1247
  function parseCsv(text) {
942
1248
  const lines = text.split(/\r?\n/);
@@ -1382,6 +1688,152 @@ async function exportContacts(format, contacts) {
1382
1688
  }
1383
1689
  }
1384
1690
 
1691
+ // src/lib/gmail-import.ts
1692
+ import { existsSync as existsSync2, readFileSync } from "fs";
1693
+ import { homedir } from "os";
1694
+ import { join as join2 } from "path";
1695
+ function loadGmailToken(profile = "default") {
1696
+ const bases = [
1697
+ join2(homedir(), ".connectors", "connect-gmail"),
1698
+ join2(homedir(), ".connect", "connect-gmail")
1699
+ ];
1700
+ for (const base of bases) {
1701
+ const tokenPath = join2(base, "profiles", profile, "tokens.json");
1702
+ if (existsSync2(tokenPath)) {
1703
+ try {
1704
+ const raw = readFileSync(tokenPath, "utf-8");
1705
+ const tokens = JSON.parse(raw);
1706
+ if (tokens.accessToken)
1707
+ return tokens.accessToken;
1708
+ } catch {}
1709
+ }
1710
+ }
1711
+ throw new Error("Gmail not authenticated. Run `connect-gmail auth login` first.");
1712
+ }
1713
+ function parseAddressHeader(header) {
1714
+ const results = [];
1715
+ const parts = header.split(/,(?![^<>]*>)(?![^"]*")/);
1716
+ for (const part of parts) {
1717
+ const trimmed = part.trim();
1718
+ if (!trimmed)
1719
+ continue;
1720
+ const angleMatch = trimmed.match(/^(.*?)\s*<([^>]+)>\s*$/);
1721
+ if (angleMatch) {
1722
+ const name = angleMatch[1].trim().replace(/^"|"$/g, "");
1723
+ const email = angleMatch[2].trim().toLowerCase();
1724
+ if (email.includes("@"))
1725
+ results.push({ name, email });
1726
+ } else if (trimmed.includes("@")) {
1727
+ results.push({ name: "", email: trimmed.toLowerCase() });
1728
+ }
1729
+ }
1730
+ return results;
1731
+ }
1732
+ function domainToCompany(email) {
1733
+ const domain = email.split("@")[1];
1734
+ if (!domain)
1735
+ return null;
1736
+ const genericDomains = new Set([
1737
+ "gmail.com",
1738
+ "googlemail.com",
1739
+ "yahoo.com",
1740
+ "yahoo.co.uk",
1741
+ "hotmail.com",
1742
+ "hotmail.co.uk",
1743
+ "outlook.com",
1744
+ "live.com",
1745
+ "icloud.com",
1746
+ "me.com",
1747
+ "mac.com",
1748
+ "protonmail.com",
1749
+ "pm.me",
1750
+ "fastmail.com",
1751
+ "hey.com",
1752
+ "aol.com",
1753
+ "msn.com"
1754
+ ]);
1755
+ if (genericDomains.has(domain.toLowerCase()))
1756
+ return null;
1757
+ const parts = domain.split(".");
1758
+ const name = parts.length > 2 ? parts[parts.length - 2] : parts[0];
1759
+ return name.charAt(0).toUpperCase() + name.slice(1);
1760
+ }
1761
+ function parseName(displayName) {
1762
+ const name = displayName.trim();
1763
+ if (!name)
1764
+ return {};
1765
+ const parts = name.split(/\s+/);
1766
+ if (parts.length === 1)
1767
+ return { first_name: parts[0] };
1768
+ const last = parts.pop();
1769
+ return { first_name: parts.join(" "), last_name: last };
1770
+ }
1771
+ async function extractContactsFromGmail(opts) {
1772
+ const maxMessages = Math.min(opts.max_messages ?? 200, 500);
1773
+ const profile = opts.gmail_profile ?? "default";
1774
+ const token = loadGmailToken(profile);
1775
+ const listUrl = new URL("https://gmail.googleapis.com/gmail/v1/users/me/messages");
1776
+ listUrl.searchParams.set("q", opts.query);
1777
+ listUrl.searchParams.set("maxResults", String(maxMessages));
1778
+ const listResp = await fetch(listUrl.toString(), {
1779
+ headers: { Authorization: `Bearer ${token}` }
1780
+ });
1781
+ if (!listResp.ok) {
1782
+ const body = await listResp.text();
1783
+ if (listResp.status === 401) {
1784
+ throw new Error("Gmail token expired. Run `connect-gmail auth login` to re-authenticate.");
1785
+ }
1786
+ throw new Error(`Gmail API error ${listResp.status}: ${body}`);
1787
+ }
1788
+ const listData = await listResp.json();
1789
+ const messageRefs = listData.messages ?? [];
1790
+ if (messageRefs.length === 0) {
1791
+ return [];
1792
+ }
1793
+ const seen = new Map;
1794
+ const batchSize = 20;
1795
+ for (let i = 0;i < messageRefs.length; i += batchSize) {
1796
+ const batch = messageRefs.slice(i, i + batchSize);
1797
+ const fetches = batch.map((ref) => {
1798
+ const url = new URL(`https://gmail.googleapis.com/gmail/v1/users/me/messages/${ref.id}`);
1799
+ url.searchParams.set("format", "metadata");
1800
+ url.searchParams.set("metadataHeaders", "From");
1801
+ url.searchParams.set("metadataHeaders", "To");
1802
+ url.searchParams.set("metadataHeaders", "Cc");
1803
+ return fetch(url.toString(), {
1804
+ headers: { Authorization: `Bearer ${token}` }
1805
+ }).then((r) => r.ok ? r.json() : null);
1806
+ });
1807
+ const results = await Promise.all(fetches);
1808
+ for (const msg of results) {
1809
+ if (!msg?.payload?.headers)
1810
+ continue;
1811
+ const headers = msg.payload.headers;
1812
+ const getHeader = (name) => headers.find((h) => h.name.toLowerCase() === name.toLowerCase())?.value ?? "";
1813
+ const addresses = [
1814
+ ...parseAddressHeader(getHeader("From")),
1815
+ ...parseAddressHeader(getHeader("To")),
1816
+ ...parseAddressHeader(getHeader("Cc"))
1817
+ ];
1818
+ for (const { name, email } of addresses) {
1819
+ if (seen.has(email))
1820
+ continue;
1821
+ const company_hint = domainToCompany(email);
1822
+ const nameParts = parseName(name);
1823
+ const contact_input = {
1824
+ ...nameParts,
1825
+ display_name: name || email.split("@")[0],
1826
+ emails: [{ address: email, type: "work", is_primary: true }],
1827
+ ...opts.tag_ids?.length ? { tag_ids: opts.tag_ids } : {},
1828
+ source: "email"
1829
+ };
1830
+ seen.set(email, { email, name, company_hint, contact_input });
1831
+ }
1832
+ }
1833
+ }
1834
+ return Array.from(seen.values());
1835
+ }
1836
+
1385
1837
  // src/mcp/index.ts
1386
1838
  var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
1387
1839
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -1403,6 +1855,9 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1403
1855
  website: { type: "string", description: "Personal or professional website URL" },
1404
1856
  last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1405
1857
  preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1858
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"], description: "Contact lifecycle status (default: active)" },
1859
+ follow_up_at: { type: "string", description: "ISO 8601 datetime to follow up with this contact" },
1860
+ project_id: { type: "string", description: "Associate contact with a project ID" },
1406
1861
  emails: {
1407
1862
  type: "array",
1408
1863
  items: {
@@ -1471,7 +1926,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1471
1926
  },
1472
1927
  {
1473
1928
  name: "update_contact",
1474
- description: "Update an existing contact's fields. Only provided fields are changed; omitted fields remain unchanged. Supports all contact fields including last_contacted_at, website, preferred_contact_method.",
1929
+ description: "Update an existing contact's fields. Only provided fields are changed. Supports all contact fields including status, follow_up_at, project_id. Use emails_add/phones_add to append new contact methods without replacing existing ones.",
1475
1930
  inputSchema: {
1476
1931
  type: "object",
1477
1932
  properties: {
@@ -1487,7 +1942,12 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1487
1942
  website: { type: "string" },
1488
1943
  last_contacted_at: { type: "string", description: "ISO 8601 datetime of last contact" },
1489
1944
  preferred_contact_method: { type: "string", enum: ["email", "phone", "telegram", "whatsapp", "linkedin", "twitter", "other"] },
1490
- source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
1945
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
1946
+ follow_up_at: { type: "string", description: "ISO 8601 datetime for follow-up reminder (null to clear)" },
1947
+ project_id: { type: "string", description: "Project ID to associate this contact with (null to clear)" },
1948
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
1949
+ emails_add: { type: "array", items: { type: "object", properties: { address: { type: "string" }, type: { type: "string" }, is_primary: { type: "boolean" } }, required: ["address"] }, description: "New email addresses to append (duplicates are skipped)" },
1950
+ phones_add: { type: "array", items: { type: "object", properties: { number: { type: "string" }, type: { type: "string" }, country_code: { type: "string" }, is_primary: { type: "boolean" } }, required: ["number"] }, description: "New phone numbers to append (duplicates are skipped)" }
1491
1951
  },
1492
1952
  required: ["id"]
1493
1953
  }
@@ -1503,15 +1963,23 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1503
1963
  },
1504
1964
  {
1505
1965
  name: "list_contacts",
1506
- description: "List contacts with optional filters. Supports filtering by company, tag, or source. Returns paginated results with total count.",
1966
+ description: "List contacts with optional filters. Supports filtering by company, tag(s), source, status, project, follow-up due, last_contacted date range, and archived state. Returns paginated results with total count.",
1507
1967
  inputSchema: {
1508
1968
  type: "object",
1509
1969
  properties: {
1510
1970
  company_id: { type: "string" },
1511
- tag_id: { type: "string", description: "Filter by tag ID" },
1971
+ tag_id: { type: "string", description: "Filter by a single tag ID" },
1972
+ tag_ids: { type: "array", items: { type: "string" }, description: "Filter by multiple tag IDs (AND logic \u2014 contact must have all tags)" },
1973
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
1974
+ status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
1975
+ project_id: { type: "string", description: "Filter by project ID" },
1976
+ archived: { type: "boolean", description: "Include archived contacts (default false)" },
1977
+ follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
1978
+ last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
1979
+ last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
1512
1980
  limit: { type: "number", description: "Max results (default 50)" },
1513
1981
  offset: { type: "number" },
1514
- order_by: { type: "string", enum: ["display_name", "created_at", "updated_at"] },
1982
+ order_by: { type: "string", enum: ["display_name", "created_at", "updated_at", "last_contacted_at", "follow_up_at"] },
1515
1983
  order_dir: { type: "string", enum: ["asc", "desc"] }
1516
1984
  }
1517
1985
  }
@@ -1585,12 +2053,14 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1585
2053
  },
1586
2054
  {
1587
2055
  name: "list_companies",
1588
- description: "List companies with optional filters by industry or tag. Returns paginated results with total count.",
2056
+ description: "List companies with optional filters by industry, tag, project, or archived state. Returns paginated results with total count.",
1589
2057
  inputSchema: {
1590
2058
  type: "object",
1591
2059
  properties: {
1592
2060
  tag_id: { type: "string" },
1593
2061
  industry: { type: "string" },
2062
+ project_id: { type: "string", description: "Filter by project ID" },
2063
+ archived: { type: "boolean", description: "Include archived companies (default false)" },
1594
2064
  limit: { type: "number" },
1595
2065
  offset: { type: "number" }
1596
2066
  }
@@ -1925,6 +2395,204 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
1925
2395
  properties: { contact_id: { type: "string" } },
1926
2396
  required: ["contact_id"]
1927
2397
  }
2398
+ },
2399
+ {
2400
+ name: "get_contact_by_email",
2401
+ description: "Fast lookup of a contact by exact email address. Returns null if not found. Unlike search_contacts, this is a precise read-only lookup with no side effects.",
2402
+ inputSchema: {
2403
+ type: "object",
2404
+ properties: { email: { type: "string", description: "Exact email address to look up" } },
2405
+ required: ["email"]
2406
+ }
2407
+ },
2408
+ {
2409
+ name: "add_email_to_contact",
2410
+ description: "Append a new email address to a contact. Idempotent \u2014 silently skips if the email already exists on this contact.",
2411
+ inputSchema: {
2412
+ type: "object",
2413
+ properties: {
2414
+ contact_id: { type: "string" },
2415
+ address: { type: "string" },
2416
+ type: { type: "string", enum: ["work", "personal", "other"] },
2417
+ is_primary: { type: "boolean" }
2418
+ },
2419
+ required: ["contact_id", "address"]
2420
+ }
2421
+ },
2422
+ {
2423
+ name: "add_phone_to_contact",
2424
+ description: "Append a new phone number to a contact. Idempotent \u2014 silently skips if the number already exists on this contact.",
2425
+ inputSchema: {
2426
+ type: "object",
2427
+ properties: {
2428
+ contact_id: { type: "string" },
2429
+ number: { type: "string" },
2430
+ type: { type: "string", enum: ["mobile", "work", "home", "fax", "whatsapp", "other"] },
2431
+ country_code: { type: "string" },
2432
+ is_primary: { type: "boolean" }
2433
+ },
2434
+ required: ["contact_id", "number"]
2435
+ }
2436
+ },
2437
+ {
2438
+ name: "archive_contact",
2439
+ description: "Soft-delete a contact by setting archived=true. Archived contacts are excluded from list_contacts and search_contacts by default. Use unarchive_contact to restore.",
2440
+ inputSchema: {
2441
+ type: "object",
2442
+ properties: { id: { type: "string" } },
2443
+ required: ["id"]
2444
+ }
2445
+ },
2446
+ {
2447
+ name: "unarchive_contact",
2448
+ description: "Restore an archived contact. Sets archived=false so the contact reappears in lists and searches.",
2449
+ inputSchema: {
2450
+ type: "object",
2451
+ properties: { id: { type: "string" } },
2452
+ required: ["id"]
2453
+ }
2454
+ },
2455
+ {
2456
+ name: "archive_company",
2457
+ description: "Soft-delete a company by setting archived=true. Archived companies are excluded from list_companies by default.",
2458
+ inputSchema: {
2459
+ type: "object",
2460
+ properties: { id: { type: "string" } },
2461
+ required: ["id"]
2462
+ }
2463
+ },
2464
+ {
2465
+ name: "unarchive_company",
2466
+ description: "Restore an archived company. Sets archived=false.",
2467
+ inputSchema: {
2468
+ type: "object",
2469
+ properties: { id: { type: "string" } },
2470
+ required: ["id"]
2471
+ }
2472
+ },
2473
+ {
2474
+ name: "find_duplicates",
2475
+ description: "Scan contacts for potential duplicates \u2014 by shared email address (exact) or by similar display name (Levenshtein distance \u2264 2). Returns groups of contact IDs that may be the same person.",
2476
+ inputSchema: { type: "object", properties: {} }
2477
+ },
2478
+ {
2479
+ name: "list_interactions",
2480
+ description: "List activity log entries for a contact or company. Shows all logged interactions, creates, updates, and merges in reverse chronological order.",
2481
+ inputSchema: {
2482
+ type: "object",
2483
+ properties: {
2484
+ contact_id: { type: "string" },
2485
+ company_id: { type: "string" },
2486
+ limit: { type: "number", description: "Max results (default 50)" },
2487
+ offset: { type: "number" }
2488
+ }
2489
+ }
2490
+ },
2491
+ {
2492
+ name: "add_tag_to_company",
2493
+ description: "Apply an existing tag to a company. Use list_tags to find tag IDs.",
2494
+ inputSchema: {
2495
+ type: "object",
2496
+ properties: {
2497
+ company_id: { type: "string" },
2498
+ tag_id: { type: "string" }
2499
+ },
2500
+ required: ["company_id", "tag_id"]
2501
+ }
2502
+ },
2503
+ {
2504
+ name: "remove_tag_from_company",
2505
+ description: "Remove a tag from a company. The tag itself is not deleted.",
2506
+ inputSchema: {
2507
+ type: "object",
2508
+ properties: {
2509
+ company_id: { type: "string" },
2510
+ tag_id: { type: "string" }
2511
+ },
2512
+ required: ["company_id", "tag_id"]
2513
+ }
2514
+ },
2515
+ {
2516
+ name: "add_company_to_group",
2517
+ description: "Add a company to a group. Returns { added, already_member } \u2014 idempotent.",
2518
+ inputSchema: {
2519
+ type: "object",
2520
+ properties: {
2521
+ company_id: { type: "string" },
2522
+ group_id: { type: "string" }
2523
+ },
2524
+ required: ["company_id", "group_id"]
2525
+ }
2526
+ },
2527
+ {
2528
+ name: "remove_company_from_group",
2529
+ description: "Remove a company from a group.",
2530
+ inputSchema: {
2531
+ type: "object",
2532
+ properties: {
2533
+ company_id: { type: "string" },
2534
+ group_id: { type: "string" }
2535
+ },
2536
+ required: ["company_id", "group_id"]
2537
+ }
2538
+ },
2539
+ {
2540
+ name: "list_companies_in_group",
2541
+ description: "List all company IDs in a group.",
2542
+ inputSchema: {
2543
+ type: "object",
2544
+ properties: { group_id: { type: "string" } },
2545
+ required: ["group_id"]
2546
+ }
2547
+ },
2548
+ {
2549
+ name: "list_groups_for_company",
2550
+ description: "List all groups that a company belongs to.",
2551
+ inputSchema: {
2552
+ type: "object",
2553
+ properties: { company_id: { type: "string" } },
2554
+ required: ["company_id"]
2555
+ }
2556
+ },
2557
+ {
2558
+ name: "bulk_create_contacts",
2559
+ description: "Create multiple contacts in one call. Each item in the contacts array follows the same schema as create_contact. Returns { created, errors }.",
2560
+ inputSchema: {
2561
+ type: "object",
2562
+ properties: {
2563
+ contacts: {
2564
+ type: "array",
2565
+ items: { type: "object" },
2566
+ description: "Array of contact input objects (same schema as create_contact)"
2567
+ }
2568
+ },
2569
+ required: ["contacts"]
2570
+ }
2571
+ },
2572
+ {
2573
+ name: "auto_link_to_company",
2574
+ description: "Auto-link a contact to a company by matching the contact's email domain against known company domains. Only sets company_id if the contact has no company yet and a matching company exists. Returns the updated contact, or null if no match.",
2575
+ inputSchema: {
2576
+ type: "object",
2577
+ properties: { contact_id: { type: "string" } },
2578
+ required: ["contact_id"]
2579
+ }
2580
+ },
2581
+ {
2582
+ name: "import_contacts_from_gmail",
2583
+ description: "Extract unique contacts from Gmail messages matching a search query and batch-upsert them. Requires connect-gmail auth login first. Returns { imported, skipped, errors }.",
2584
+ inputSchema: {
2585
+ type: "object",
2586
+ properties: {
2587
+ query: { type: "string", description: "Gmail search query, e.g. 'from:(company.com) newer_than:30d'" },
2588
+ max_messages: { type: "number", description: "Max messages to scan (default 200, max 500)" },
2589
+ gmail_profile: { type: "string", description: "connect-gmail profile to use (default: 'default')" },
2590
+ tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to apply to all imported contacts" },
2591
+ group_id: { type: "string", description: "Group ID to add all imported contacts to" },
2592
+ dry_run: { type: "boolean", description: "If true, extract contacts but do not save to database" }
2593
+ },
2594
+ required: ["query"]
2595
+ }
1928
2596
  }
1929
2597
  ]
1930
2598
  }));
@@ -1946,6 +2614,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1946
2614
  website: a.website,
1947
2615
  last_contacted_at: a.last_contacted_at,
1948
2616
  preferred_contact_method: a.preferred_contact_method,
2617
+ status: a.status,
2618
+ follow_up_at: a.follow_up_at,
2619
+ project_id: a.project_id,
1949
2620
  emails: a.emails,
1950
2621
  phones: a.phones,
1951
2622
  addresses: a.addresses,
@@ -1974,7 +2645,12 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1974
2645
  website: rest.website,
1975
2646
  last_contacted_at: rest.last_contacted_at,
1976
2647
  preferred_contact_method: rest.preferred_contact_method,
1977
- source: rest.source
2648
+ status: rest.status,
2649
+ follow_up_at: rest.follow_up_at,
2650
+ project_id: rest.project_id,
2651
+ source: rest.source,
2652
+ emails_add: rest.emails_add,
2653
+ phones_add: rest.phones_add
1978
2654
  };
1979
2655
  const contact = updateContact(id, input);
1980
2656
  return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
@@ -1987,6 +2663,14 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1987
2663
  const result = listContacts({
1988
2664
  company_id: a.company_id,
1989
2665
  tag_id: a.tag_id,
2666
+ tag_ids: a.tag_ids,
2667
+ source: a.source,
2668
+ status: a.status,
2669
+ project_id: a.project_id,
2670
+ archived: a.archived,
2671
+ follow_up_due: a.follow_up_due,
2672
+ last_contacted_after: a.last_contacted_after,
2673
+ last_contacted_before: a.last_contacted_before,
1990
2674
  limit: a.limit,
1991
2675
  offset: a.offset,
1992
2676
  order_by: a.order_by,
@@ -1999,6 +2683,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
1999
2683
  return { content: [{ type: "text", text: JSON.stringify(contacts, null, 2) }] };
2000
2684
  }
2001
2685
  case "create_company": {
2686
+ const rawTagIds = a.tag_ids;
2687
+ const tagIds = typeof rawTagIds === "string" ? JSON.parse(rawTagIds) : rawTagIds;
2002
2688
  const input = {
2003
2689
  name: a.name,
2004
2690
  domain: a.domain,
@@ -2011,7 +2697,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2011
2697
  phones: a.phones,
2012
2698
  addresses: a.addresses,
2013
2699
  social_profiles: a.social_profiles,
2014
- tag_ids: a.tag_ids
2700
+ tag_ids: tagIds
2015
2701
  };
2016
2702
  const company = createCompany(input);
2017
2703
  return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
@@ -2045,6 +2731,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2045
2731
  const result = listCompanies({
2046
2732
  tag_id: a.tag_id,
2047
2733
  industry: a.industry,
2734
+ project_id: a.project_id,
2735
+ archived: a.archived,
2048
2736
  limit: a.limit,
2049
2737
  offset: a.offset
2050
2738
  });
@@ -2168,21 +2856,24 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2168
2856
  const emailAddresses = a.emails?.map((e) => e.address) ?? [];
2169
2857
  let found = null;
2170
2858
  for (const addr of emailAddresses) {
2171
- const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE address = ? AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2859
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(addr);
2172
2860
  if (emailRow) {
2173
2861
  found = getContact(emailRow.contact_id);
2174
2862
  break;
2175
2863
  }
2176
2864
  }
2177
- if (!found && a.display_name) {
2178
- const results = searchContacts(a.display_name);
2179
- if (results.length > 0)
2180
- found = results[0];
2865
+ if (!found) {
2866
+ const nameQuery = a.display_name ?? (a.first_name || a.last_name ? `${a.first_name ?? ""} ${a.last_name ?? ""}`.trim() : null);
2867
+ if (nameQuery) {
2868
+ const results = searchContacts(nameQuery);
2869
+ if (results.length > 0)
2870
+ found = results[0];
2871
+ }
2181
2872
  }
2182
2873
  if (found) {
2183
2874
  return { content: [{ type: "text", text: JSON.stringify({ contact: found, found: true, created: false }, null, 2) }] };
2184
2875
  }
2185
- const input = {
2876
+ const focInput = {
2186
2877
  first_name: a.first_name,
2187
2878
  last_name: a.last_name,
2188
2879
  display_name: a.display_name,
@@ -2194,6 +2885,9 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2194
2885
  website: a.website,
2195
2886
  last_contacted_at: a.last_contacted_at,
2196
2887
  preferred_contact_method: a.preferred_contact_method,
2888
+ status: a.status,
2889
+ follow_up_at: a.follow_up_at,
2890
+ project_id: a.project_id,
2197
2891
  emails: a.emails,
2198
2892
  phones: a.phones,
2199
2893
  addresses: a.addresses,
@@ -2201,7 +2895,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2201
2895
  tag_ids: a.tag_ids,
2202
2896
  source: a.source
2203
2897
  };
2204
- const contact = createContact(input);
2898
+ const contact = createContact(focInput);
2205
2899
  return { content: [{ type: "text", text: JSON.stringify({ contact, found: false, created: true }, null, 2) }] };
2206
2900
  }
2207
2901
  case "upsert_contact": {
@@ -2325,8 +3019,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2325
3019
  }
2326
3020
  case "add_contact_to_group": {
2327
3021
  const db = getDatabase();
2328
- addContactToGroup(db, a.contact_id, a.group_id);
2329
- return { content: [{ type: "text", text: `Contact ${a.contact_id} added to group ${a.group_id}` }] };
3022
+ const result = addContactToGroup(db, a.contact_id, a.group_id);
3023
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
2330
3024
  }
2331
3025
  case "remove_contact_from_group": {
2332
3026
  const db = getDatabase();
@@ -2343,6 +3037,156 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
2343
3037
  const groups = listGroupsForContact(db, a.contact_id);
2344
3038
  return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
2345
3039
  }
3040
+ case "get_contact_by_email": {
3041
+ const contact = getContactByEmail(a.email);
3042
+ if (!contact)
3043
+ return { content: [{ type: "text", text: "null" }] };
3044
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
3045
+ }
3046
+ case "add_email_to_contact": {
3047
+ const contact = addEmailToContact(a.contact_id, {
3048
+ address: a.address,
3049
+ type: a.type,
3050
+ is_primary: a.is_primary
3051
+ });
3052
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
3053
+ }
3054
+ case "add_phone_to_contact": {
3055
+ const contact = addPhoneToContact(a.contact_id, {
3056
+ number: a.number,
3057
+ type: a.type,
3058
+ country_code: a.country_code,
3059
+ is_primary: a.is_primary
3060
+ });
3061
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
3062
+ }
3063
+ case "archive_contact": {
3064
+ const contact = archiveContact(a.id);
3065
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
3066
+ }
3067
+ case "unarchive_contact": {
3068
+ const contact = unarchiveContact(a.id);
3069
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
3070
+ }
3071
+ case "archive_company": {
3072
+ const company = archiveCompany(a.id);
3073
+ return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
3074
+ }
3075
+ case "unarchive_company": {
3076
+ const company = unarchiveCompany(a.id);
3077
+ return { content: [{ type: "text", text: JSON.stringify(company, null, 2) }] };
3078
+ }
3079
+ case "find_duplicates": {
3080
+ const db = getDatabase();
3081
+ const byEmail = findEmailDuplicates(db);
3082
+ const byName = findNameDuplicates(db);
3083
+ return { content: [{ type: "text", text: JSON.stringify({ by_email: byEmail, by_name: byName }, null, 2) }] };
3084
+ }
3085
+ case "list_interactions": {
3086
+ const result = listActivity({
3087
+ contact_id: a.contact_id,
3088
+ company_id: a.company_id,
3089
+ limit: a.limit,
3090
+ offset: a.offset
3091
+ });
3092
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3093
+ }
3094
+ case "add_tag_to_company": {
3095
+ addTagToCompany(a.company_id, a.tag_id);
3096
+ return { content: [{ type: "text", text: `Tag ${a.tag_id} added to company ${a.company_id}` }] };
3097
+ }
3098
+ case "remove_tag_from_company": {
3099
+ removeTagFromCompany(a.company_id, a.tag_id);
3100
+ return { content: [{ type: "text", text: `Tag ${a.tag_id} removed from company ${a.company_id}` }] };
3101
+ }
3102
+ case "add_company_to_group": {
3103
+ const db = getDatabase();
3104
+ const result = addCompanyToGroup(db, a.company_id, a.group_id);
3105
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
3106
+ }
3107
+ case "remove_company_from_group": {
3108
+ const db = getDatabase();
3109
+ removeCompanyFromGroup(db, a.company_id, a.group_id);
3110
+ return { content: [{ type: "text", text: `Company ${a.company_id} removed from group ${a.group_id}` }] };
3111
+ }
3112
+ case "list_companies_in_group": {
3113
+ const db = getDatabase();
3114
+ const companyIds = listCompaniesInGroup(db, a.group_id);
3115
+ return { content: [{ type: "text", text: JSON.stringify(companyIds, null, 2) }] };
3116
+ }
3117
+ case "list_groups_for_company": {
3118
+ const db = getDatabase();
3119
+ const groups = listGroupsForCompany(db, a.company_id);
3120
+ return { content: [{ type: "text", text: JSON.stringify(groups, null, 2) }] };
3121
+ }
3122
+ case "bulk_create_contacts": {
3123
+ const contacts = a.contacts;
3124
+ let created = 0;
3125
+ const errors = [];
3126
+ for (const item of contacts) {
3127
+ try {
3128
+ createContact(item);
3129
+ created++;
3130
+ } catch (err) {
3131
+ errors.push(err instanceof Error ? err.message : String(err));
3132
+ }
3133
+ }
3134
+ return { content: [{ type: "text", text: JSON.stringify({ created, errors: errors.length, error_details: errors }, null, 2) }] };
3135
+ }
3136
+ case "auto_link_to_company": {
3137
+ const contact = autoLinkContactToCompany(a.contact_id);
3138
+ if (!contact)
3139
+ return { content: [{ type: "text", text: "null" }] };
3140
+ return { content: [{ type: "text", text: JSON.stringify(contact, null, 2) }] };
3141
+ }
3142
+ case "import_contacts_from_gmail": {
3143
+ const db = getDatabase();
3144
+ const extracted = await extractContactsFromGmail({
3145
+ query: a.query,
3146
+ max_messages: a.max_messages,
3147
+ gmail_profile: a.gmail_profile,
3148
+ tag_ids: a.tag_ids,
3149
+ group_id: a.group_id
3150
+ });
3151
+ if (a.dry_run) {
3152
+ return {
3153
+ content: [{
3154
+ type: "text",
3155
+ text: JSON.stringify({ dry_run: true, would_import: extracted.length, contacts: extracted }, null, 2)
3156
+ }]
3157
+ };
3158
+ }
3159
+ let imported = 0;
3160
+ let skipped = 0;
3161
+ const errors = [];
3162
+ for (const { email, contact_input, company_hint } of extracted) {
3163
+ try {
3164
+ const emailRow = db.prepare(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(email);
3165
+ if (emailRow) {
3166
+ skipped++;
3167
+ continue;
3168
+ }
3169
+ const contact = createContact(contact_input);
3170
+ if (a.group_id && typeof a.group_id === "string") {
3171
+ try {
3172
+ db.prepare(`INSERT OR IGNORE INTO contact_groups (contact_id, group_id) VALUES (?, ?)`).run(contact.id, a.group_id);
3173
+ } catch {}
3174
+ }
3175
+ if (company_hint) {
3176
+ autoLinkContactToCompany(contact.id);
3177
+ }
3178
+ imported++;
3179
+ } catch (err) {
3180
+ errors.push(`${email}: ${err instanceof Error ? err.message : String(err)}`);
3181
+ }
3182
+ }
3183
+ return {
3184
+ content: [{
3185
+ type: "text",
3186
+ text: JSON.stringify({ imported, skipped, errors: errors.length, error_details: errors }, null, 2)
3187
+ }]
3188
+ };
3189
+ }
2346
3190
  default:
2347
3191
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
2348
3192
  }