@hasna/contacts 0.6.5 → 0.6.6

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.
Files changed (59) hide show
  1. package/dist/cli/index.js +3903 -1611
  2. package/dist/db/companies.d.ts.map +1 -1
  3. package/dist/db/contacts.d.ts +4 -0
  4. package/dist/db/contacts.d.ts.map +1 -1
  5. package/dist/db/coordination.d.ts +30 -0
  6. package/dist/db/coordination.d.ts.map +1 -0
  7. package/dist/db/database.d.ts +1 -0
  8. package/dist/db/database.d.ts.map +1 -1
  9. package/dist/db/documents.d.ts +38 -0
  10. package/dist/db/documents.d.ts.map +1 -0
  11. package/dist/db/field-history.d.ts +17 -0
  12. package/dist/db/field-history.d.ts.map +1 -0
  13. package/dist/db/freshness.d.ts +24 -0
  14. package/dist/db/freshness.d.ts.map +1 -0
  15. package/dist/db/graph.d.ts +21 -0
  16. package/dist/db/graph.d.ts.map +1 -0
  17. package/dist/db/health.d.ts +40 -0
  18. package/dist/db/health.d.ts.map +1 -0
  19. package/dist/db/identity.d.ts +32 -0
  20. package/dist/db/identity.d.ts.map +1 -0
  21. package/dist/db/job-history.d.ts +29 -0
  22. package/dist/db/job-history.d.ts.map +1 -0
  23. package/dist/db/learnings.d.ts +43 -0
  24. package/dist/db/learnings.d.ts.map +1 -0
  25. package/dist/db/org-chart.d.ts +37 -0
  26. package/dist/db/org-chart.d.ts.map +1 -0
  27. package/dist/db/signals.d.ts +18 -0
  28. package/dist/db/signals.d.ts.map +1 -0
  29. package/dist/db/tags.d.ts.map +1 -1
  30. package/dist/index.d.ts +33 -1
  31. package/dist/index.d.ts.map +1 -1
  32. package/dist/index.js +1577 -89
  33. package/dist/lib/config.d.ts.map +1 -1
  34. package/dist/lib/context.d.ts +5 -0
  35. package/dist/lib/context.d.ts.map +1 -0
  36. package/dist/lib/document-scanner.d.ts +8 -0
  37. package/dist/lib/document-scanner.d.ts.map +1 -0
  38. package/dist/lib/embeddings.d.ts +9 -0
  39. package/dist/lib/embeddings.d.ts.map +1 -0
  40. package/dist/lib/freshness.d.ts +19 -0
  41. package/dist/lib/freshness.d.ts.map +1 -0
  42. package/dist/lib/images.d.ts +30 -0
  43. package/dist/lib/images.d.ts.map +1 -0
  44. package/dist/lib/learning-maintenance.d.ts +8 -0
  45. package/dist/lib/learning-maintenance.d.ts.map +1 -0
  46. package/dist/lib/meeting-capture.d.ts +15 -0
  47. package/dist/lib/meeting-capture.d.ts.map +1 -0
  48. package/dist/lib/signals.d.ts +9 -0
  49. package/dist/lib/signals.d.ts.map +1 -0
  50. package/dist/lib/signature-parser.d.ts +36 -0
  51. package/dist/lib/signature-parser.d.ts.map +1 -0
  52. package/dist/lib/vault.d.ts +24 -0
  53. package/dist/lib/vault.d.ts.map +1 -0
  54. package/dist/mcp/index.js +1951 -81
  55. package/dist/server/index.js +352 -15
  56. package/dist/server/serve.d.ts.map +1 -1
  57. package/dist/types/index.d.ts +6 -0
  58. package/dist/types/index.d.ts.map +1 -1
  59. package/package.json +2 -1
package/dist/mcp/index.js CHANGED
@@ -19,13 +19,30 @@ var __require = import.meta.require;
19
19
 
20
20
  // src/db/database.ts
21
21
  import { Database } from "bun:sqlite";
22
- import { existsSync, mkdirSync } from "fs";
22
+ import { copyFileSync, existsSync, mkdirSync, readdirSync, statSync } from "fs";
23
23
  import { dirname, join, resolve } from "path";
24
+ function getDataDir() {
25
+ const home = process.env["HOME"] || process.env["USERPROFILE"] || "~";
26
+ const newDir = join(home, ".hasna", "contacts");
27
+ const oldDir = join(home, ".contacts");
28
+ if (existsSync(oldDir) && !existsSync(newDir)) {
29
+ mkdirSync(newDir, { recursive: true });
30
+ for (const file of readdirSync(oldDir)) {
31
+ const oldPath = join(oldDir, file);
32
+ if (statSync(oldPath).isFile()) {
33
+ copyFileSync(oldPath, join(newDir, file));
34
+ }
35
+ }
36
+ }
37
+ mkdirSync(newDir, { recursive: true });
38
+ return newDir;
39
+ }
24
40
  function getDbPath() {
41
+ if (process.env["HASNA_CONTACTS_DB_PATH"])
42
+ return process.env["HASNA_CONTACTS_DB_PATH"];
25
43
  if (process.env["CONTACTS_DB_PATH"])
26
44
  return process.env["CONTACTS_DB_PATH"];
27
- const home = process.env["HOME"] || "~";
28
- return join(home, ".contacts", "contacts.db");
45
+ return join(getDataDir(), "contacts.db");
29
46
  }
30
47
  function ensureDir(filePath) {
31
48
  if (filePath === ":memory:")
@@ -410,6 +427,177 @@ var init_database = __esm(() => {
410
427
  deal_id TEXT REFERENCES deals(id) ON DELETE SET NULL,
411
428
  created_at TEXT NOT NULL DEFAULT (datetime('now'))
412
429
  );
430
+ `,
431
+ `
432
+ -- CON-00069: temporal field history
433
+ CREATE TABLE IF NOT EXISTS contact_field_history (
434
+ id TEXT PRIMARY KEY,
435
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
436
+ field_name TEXT NOT NULL,
437
+ old_value TEXT,
438
+ new_value TEXT,
439
+ valid_from TEXT NOT NULL DEFAULT (datetime('now')),
440
+ source TEXT,
441
+ confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
442
+ created_by TEXT,
443
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
444
+ );
445
+
446
+ -- CON-00070: job history
447
+ CREATE TABLE IF NOT EXISTS job_history (
448
+ id TEXT PRIMARY KEY,
449
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
450
+ company_id TEXT REFERENCES companies(id) ON DELETE SET NULL,
451
+ company_name TEXT NOT NULL,
452
+ title TEXT,
453
+ start_date TEXT,
454
+ end_date TEXT,
455
+ is_current INTEGER NOT NULL DEFAULT 0,
456
+ inferred INTEGER NOT NULL DEFAULT 0,
457
+ source TEXT,
458
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
459
+ );
460
+
461
+ -- CON-00071: learnings
462
+ CREATE TABLE IF NOT EXISTS contact_learnings (
463
+ id TEXT PRIMARY KEY,
464
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
465
+ content TEXT NOT NULL,
466
+ type TEXT NOT NULL DEFAULT 'fact' CHECK(type IN ('preference','fact','inference','warning','signal')),
467
+ confidence INTEGER NOT NULL DEFAULT 70 CHECK(confidence BETWEEN 0 AND 100),
468
+ importance INTEGER NOT NULL DEFAULT 5 CHECK(importance BETWEEN 1 AND 10),
469
+ learned_by TEXT,
470
+ session_id TEXT,
471
+ visibility TEXT NOT NULL DEFAULT 'shared' CHECK(visibility IN ('private','shared','human')),
472
+ tags TEXT NOT NULL DEFAULT '[]',
473
+ confirmed_count INTEGER NOT NULL DEFAULT 0,
474
+ contradicts_id TEXT REFERENCES contact_learnings(id) ON DELETE SET NULL,
475
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
476
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
477
+ );
478
+
479
+ -- CON-00072: coordination
480
+ CREATE TABLE IF NOT EXISTS contact_locks (
481
+ id TEXT PRIMARY KEY,
482
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
483
+ agent_name TEXT NOT NULL,
484
+ reason TEXT,
485
+ acquired_at TEXT NOT NULL DEFAULT (datetime('now')),
486
+ expires_at TEXT NOT NULL,
487
+ session_id TEXT
488
+ );
489
+ CREATE TABLE IF NOT EXISTS contact_agent_activity (
490
+ id TEXT PRIMARY KEY,
491
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
492
+ agent_name TEXT NOT NULL,
493
+ action TEXT NOT NULL,
494
+ details TEXT,
495
+ session_id TEXT,
496
+ created_at TEXT NOT NULL DEFAULT (datetime('now'))
497
+ );
498
+
499
+ -- CON-00073: relationship graph extra columns
500
+ ALTER TABLE contact_relationships ADD COLUMN strength_score INTEGER NOT NULL DEFAULT 50;
501
+ ALTER TABLE contact_relationships ADD COLUMN interaction_count INTEGER NOT NULL DEFAULT 0;
502
+ ALTER TABLE contact_relationships ADD COLUMN last_interaction TEXT;
503
+ ALTER TABLE contact_relationships ADD COLUMN relationship_status TEXT NOT NULL DEFAULT 'stable' CHECK(relationship_status IN ('warming','stable','cooling','ghost'));
504
+
505
+ -- CON-00074: identity resolution
506
+ CREATE TABLE IF NOT EXISTS contact_identities (
507
+ id TEXT PRIMARY KEY,
508
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
509
+ system TEXT NOT NULL,
510
+ external_id TEXT NOT NULL,
511
+ external_url TEXT,
512
+ confidence TEXT NOT NULL DEFAULT 'inferred' CHECK(confidence IN ('verified','inferred')),
513
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
514
+ UNIQUE(system, external_id)
515
+ );
516
+ ALTER TABLE contacts ADD COLUMN canonical_id TEXT;
517
+
518
+ -- CON-00076: relationship signals
519
+ ALTER TABLE contacts ADD COLUMN relationship_health INTEGER NOT NULL DEFAULT 50;
520
+ ALTER TABLE contacts ADD COLUMN avg_response_hours REAL;
521
+ ALTER TABLE contacts ADD COLUMN preferred_channel TEXT;
522
+ ALTER TABLE contacts ADD COLUMN engagement_status TEXT NOT NULL DEFAULT 'new' CHECK(engagement_status IN ('warming','stable','cooling','ghost','new'));
523
+ ALTER TABLE contacts ADD COLUMN interaction_count_30d INTEGER NOT NULL DEFAULT 0;
524
+ ALTER TABLE contacts ADD COLUMN interaction_count_90d INTEGER NOT NULL DEFAULT 0;
525
+
526
+ -- CON-00079: freshness scoring
527
+ CREATE TABLE IF NOT EXISTS contact_field_confidence (
528
+ id TEXT PRIMARY KEY,
529
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
530
+ field_name TEXT NOT NULL,
531
+ confidence TEXT NOT NULL DEFAULT 'imported' CHECK(confidence IN ('verified','inferred','imported','stale')),
532
+ source TEXT,
533
+ last_verified_at TEXT NOT NULL DEFAULT (datetime('now')),
534
+ UNIQUE(contact_id, field_name)
535
+ );
536
+
537
+ -- CON-00080: org chart
538
+ CREATE TABLE IF NOT EXISTS org_chart_edges (
539
+ id TEXT PRIMARY KEY,
540
+ company_id TEXT NOT NULL REFERENCES companies(id) ON DELETE CASCADE,
541
+ contact_a_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
542
+ contact_b_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
543
+ edge_type TEXT NOT NULL CHECK(edge_type IN ('reports_to','manages','collaborates_with','peer')),
544
+ inferred INTEGER NOT NULL DEFAULT 0,
545
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
546
+ UNIQUE(company_id, contact_a_id, contact_b_id, edge_type)
547
+ );
548
+ CREATE TABLE IF NOT EXISTS deal_contact_roles (
549
+ id TEXT PRIMARY KEY,
550
+ deal_id TEXT NOT NULL REFERENCES deals(id) ON DELETE CASCADE,
551
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
552
+ account_role TEXT NOT NULL CHECK(account_role IN ('economic_buyer','technical_evaluator','champion','blocker','influencer','user','sponsor','other')),
553
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
554
+ UNIQUE(deal_id, contact_id)
555
+ );
556
+
557
+ -- CON-00075: embeddings
558
+ CREATE TABLE IF NOT EXISTS contact_embeddings (
559
+ contact_id TEXT PRIMARY KEY REFERENCES contacts(id) ON DELETE CASCADE,
560
+ embedding TEXT NOT NULL,
561
+ model TEXT NOT NULL DEFAULT 'tfidf',
562
+ embedded_text TEXT,
563
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
564
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
565
+ );
566
+ `,
567
+ `
568
+ ALTER TABLE contacts ADD COLUMN sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK(sensitivity IN ('normal','confidential','restricted'));
569
+
570
+ CREATE TABLE IF NOT EXISTS contact_documents (
571
+ id TEXT PRIMARY KEY,
572
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
573
+ doc_type TEXT NOT NULL,
574
+ label TEXT,
575
+ encrypted_value TEXT NOT NULL,
576
+ iv TEXT NOT NULL,
577
+ encrypted_file_path TEXT,
578
+ metadata TEXT NOT NULL DEFAULT '{}',
579
+ expires_at TEXT,
580
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
581
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
582
+ );
583
+
584
+ CREATE TABLE IF NOT EXISTS contact_health (
585
+ id TEXT PRIMARY KEY,
586
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
587
+ blood_type TEXT,
588
+ allergies TEXT NOT NULL DEFAULT '[]',
589
+ medical_conditions TEXT NOT NULL DEFAULT '[]',
590
+ medications TEXT NOT NULL DEFAULT '[]',
591
+ emergency_contacts TEXT NOT NULL DEFAULT '[]',
592
+ health_insurance_provider TEXT,
593
+ health_insurance_id TEXT,
594
+ primary_physician TEXT,
595
+ primary_physician_phone TEXT,
596
+ organ_donor INTEGER NOT NULL DEFAULT 0,
597
+ notes TEXT,
598
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
599
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
600
+ );
413
601
  `
414
602
  ];
415
603
  });
@@ -491,6 +679,7 @@ __export(exports_contacts, {
491
679
  getContactProjectIds: () => getContactProjectIds,
492
680
  getContactByEmail: () => getContactByEmail,
493
681
  getContact: () => getContact,
682
+ findOrCreateContact: () => findOrCreateContact,
494
683
  deleteContact: () => deleteContact,
495
684
  createContact: () => createContact,
496
685
  autoLinkContactToCompany: () => autoLinkContactToCompany,
@@ -508,6 +697,7 @@ function rowToContact(row) {
508
697
  follow_up_at: row.follow_up_at ?? null,
509
698
  archived: !!row.archived,
510
699
  project_id: row.project_id ?? null,
700
+ sensitivity: row.sensitivity ?? "normal",
511
701
  do_not_contact: !!row.do_not_contact,
512
702
  priority: row.priority ?? 3,
513
703
  timezone: row.timezone ?? null
@@ -595,8 +785,8 @@ function createContact(input, db) {
595
785
  const firstName = input.first_name ?? "";
596
786
  const lastName = input.last_name ?? "";
597
787
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
598
- 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)
599
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
788
+ 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, sensitivity, do_not_contact, priority, timezone, created_at, updated_at)
789
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
600
790
  id,
601
791
  firstName,
602
792
  lastName,
@@ -615,6 +805,7 @@ function createContact(input, db) {
615
805
  input.status ?? "active",
616
806
  input.follow_up_at ?? null,
617
807
  input.project_id ?? null,
808
+ input.sensitivity ?? "normal",
618
809
  input.do_not_contact ? 1 : 0,
619
810
  input.priority ?? 3,
620
811
  input.timezone ?? null,
@@ -663,6 +854,7 @@ function listContacts(opts = {}, db) {
663
854
  order_by = "display_name",
664
855
  order_dir = "asc",
665
856
  include_dnc = false,
857
+ include_restricted = false,
666
858
  priority_min,
667
859
  updated_since
668
860
  } = opts;
@@ -673,6 +865,9 @@ function listContacts(opts = {}, db) {
673
865
  if (!include_dnc) {
674
866
  conditions.push("c.do_not_contact = 0");
675
867
  }
868
+ if (!include_restricted) {
869
+ conditions.push("c.sensitivity != 'restricted'");
870
+ }
676
871
  if (company_id) {
677
872
  conditions.push("c.company_id = ?");
678
873
  params.push(company_id);
@@ -801,6 +996,10 @@ function updateContact(id, input, db) {
801
996
  setClauses.push("project_id = ?");
802
997
  params.push(input.project_id);
803
998
  }
999
+ if (input.sensitivity !== undefined) {
1000
+ setClauses.push("sensitivity = ?");
1001
+ params.push(input.sensitivity);
1002
+ }
804
1003
  if (input.do_not_contact !== undefined) {
805
1004
  setClauses.push("do_not_contact = ?");
806
1005
  params.push(input.do_not_contact ? 1 : 0);
@@ -848,26 +1047,26 @@ function searchContacts(query, db) {
848
1047
  const ftsRows = d.query(`
849
1048
  SELECT c.* FROM contacts c
850
1049
  JOIN contacts_fts fts ON fts.id = c.id
851
- WHERE contacts_fts MATCH ? AND c.archived = 0
1050
+ WHERE contacts_fts MATCH ? AND c.archived = 0 AND c.sensitivity != 'restricted'
852
1051
  ORDER BY rank
853
1052
  LIMIT 50
854
1053
  `).all(`"${query.replace(/"/g, '""')}"*`);
855
1054
  const emailRows = d.query(`
856
1055
  SELECT DISTINCT c.* FROM contacts c
857
1056
  JOIN emails e ON e.contact_id = c.id
858
- WHERE e.address LIKE ? AND c.archived = 0
1057
+ WHERE e.address LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
859
1058
  LIMIT 20
860
1059
  `).all(`%${query}%`);
861
1060
  const phoneRows = d.query(`
862
1061
  SELECT DISTINCT c.* FROM contacts c
863
1062
  JOIN phones p ON p.contact_id = c.id
864
- WHERE p.number LIKE ? AND c.archived = 0
1063
+ WHERE p.number LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
865
1064
  LIMIT 20
866
1065
  `).all(`%${query}%`);
867
1066
  const companyRows = d.query(`
868
1067
  SELECT DISTINCT c.* FROM contacts c
869
1068
  JOIN companies co ON co.id = c.company_id
870
- WHERE co.name LIKE ? AND c.archived = 0
1069
+ WHERE co.name LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
871
1070
  LIMIT 20
872
1071
  `).all(`%${query}%`);
873
1072
  const seen = new Set;
@@ -1020,6 +1219,25 @@ function listColdContacts(days, db) {
1020
1219
  LIMIT 100`).all(`-${days}`);
1021
1220
  return rows.map((row) => loadContactDetails(d, rowToContact(row)));
1022
1221
  }
1222
+ async function findOrCreateContact(input, db) {
1223
+ const d = db || getDatabase();
1224
+ const emailAddresses = (input.emails ?? []).map((e) => e.address);
1225
+ for (const addr of emailAddresses) {
1226
+ const emailRow = d.query(`SELECT contact_id FROM emails WHERE LOWER(address) = LOWER(?) AND contact_id IS NOT NULL LIMIT 1`).get(addr);
1227
+ if (emailRow) {
1228
+ return { contact: getContact(emailRow.contact_id, d), created: false };
1229
+ }
1230
+ }
1231
+ const nameQuery = input.display_name ?? (input.first_name || input.last_name ? `${input.first_name ?? ""} ${input.last_name ?? ""}`.trim() : null);
1232
+ if (nameQuery) {
1233
+ const results = searchContacts(nameQuery, d);
1234
+ if (results.length > 0 && results[0]) {
1235
+ return { contact: results[0], created: false };
1236
+ }
1237
+ }
1238
+ const contact = createContact(input, d);
1239
+ return { contact, created: true };
1240
+ }
1023
1241
  function autoLinkContactToCompany(contactId, db) {
1024
1242
  const d = db || getDatabase();
1025
1243
  const row = d.query(`SELECT * FROM contacts WHERE id = ?`).get(contactId);
@@ -1070,6 +1288,96 @@ var init_contacts = __esm(() => {
1070
1288
  init_activity();
1071
1289
  });
1072
1290
 
1291
+ // src/db/events.ts
1292
+ var exports_events = {};
1293
+ __export(exports_events, {
1294
+ logEvent: () => logEvent,
1295
+ listEvents: () => listEvents,
1296
+ getEvent: () => getEvent,
1297
+ deleteEvent: () => deleteEvent
1298
+ });
1299
+ function rowToEvent(row) {
1300
+ let contact_ids = [];
1301
+ try {
1302
+ contact_ids = JSON.parse(row.contact_ids);
1303
+ } catch {
1304
+ contact_ids = [];
1305
+ }
1306
+ return {
1307
+ id: row.id,
1308
+ title: row.title,
1309
+ type: row.type,
1310
+ event_date: row.event_date,
1311
+ duration_min: row.duration_min,
1312
+ contact_ids,
1313
+ company_id: row.company_id,
1314
+ notes: row.notes,
1315
+ outcome: row.outcome,
1316
+ deal_id: row.deal_id,
1317
+ created_at: row.created_at
1318
+ };
1319
+ }
1320
+ function logEvent(input, db) {
1321
+ const d = db || getDatabase();
1322
+ const id = uuid();
1323
+ const timestamp = now();
1324
+ d.run(`INSERT INTO events (id, title, type, event_date, duration_min, contact_ids, company_id, notes, outcome, deal_id, created_at)
1325
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
1326
+ id,
1327
+ input.title,
1328
+ input.type ?? "meeting",
1329
+ input.event_date,
1330
+ input.duration_min ?? null,
1331
+ JSON.stringify(input.contact_ids ?? []),
1332
+ input.company_id ?? null,
1333
+ input.notes ?? null,
1334
+ input.outcome ?? null,
1335
+ input.deal_id ?? null,
1336
+ timestamp
1337
+ ]);
1338
+ return rowToEvent(d.query(`SELECT * FROM events WHERE id = ?`).get(id));
1339
+ }
1340
+ function getEvent(id, db) {
1341
+ const d = db || getDatabase();
1342
+ const row = d.query(`SELECT * FROM events WHERE id = ?`).get(id);
1343
+ return row ? rowToEvent(row) : null;
1344
+ }
1345
+ function listEvents(opts = {}, db) {
1346
+ const d = db || getDatabase();
1347
+ const conditions = [];
1348
+ const params = [];
1349
+ if (opts.contact_id) {
1350
+ conditions.push("contact_ids LIKE ?");
1351
+ params.push(`%${opts.contact_id}%`);
1352
+ }
1353
+ if (opts.company_id) {
1354
+ conditions.push("company_id = ?");
1355
+ params.push(opts.company_id);
1356
+ }
1357
+ if (opts.type) {
1358
+ conditions.push("type = ?");
1359
+ params.push(opts.type);
1360
+ }
1361
+ if (opts.date_from) {
1362
+ conditions.push("event_date >= ?");
1363
+ params.push(opts.date_from);
1364
+ }
1365
+ if (opts.date_to) {
1366
+ conditions.push("event_date <= ?");
1367
+ params.push(opts.date_to);
1368
+ }
1369
+ const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
1370
+ const rows = d.query(`SELECT * FROM events ${where} ORDER BY event_date DESC`).all(...params);
1371
+ return rows.map(rowToEvent);
1372
+ }
1373
+ function deleteEvent(id, db) {
1374
+ const d = db || getDatabase();
1375
+ d.run(`DELETE FROM events WHERE id = ?`, [id]);
1376
+ }
1377
+ var init_events = __esm(() => {
1378
+ init_database();
1379
+ });
1380
+
1073
1381
  // src/mcp/index.ts
1074
1382
  init_database();
1075
1383
  init_contacts();
@@ -3279,84 +3587,1142 @@ function deleteDeal(id, db) {
3279
3587
  d.run(`DELETE FROM deals WHERE id = ?`, [id]);
3280
3588
  }
3281
3589
 
3282
- // src/db/events.ts
3590
+ // src/mcp/index.ts
3591
+ init_events();
3592
+
3593
+ // src/db/field-history.ts
3283
3594
  init_database();
3284
- function rowToEvent(row) {
3285
- let contact_ids = [];
3286
- try {
3287
- contact_ids = JSON.parse(row.contact_ids);
3288
- } catch {
3289
- contact_ids = [];
3595
+ function getFieldHistory(contactId, fieldName, db) {
3596
+ const _db2 = db || getDatabase();
3597
+ if (fieldName) {
3598
+ return _db2.query(`SELECT * FROM contact_field_history WHERE contact_id=? AND field_name=? ORDER BY valid_from DESC`).all(contactId, fieldName);
3290
3599
  }
3291
- return {
3292
- id: row.id,
3293
- title: row.title,
3294
- type: row.type,
3295
- event_date: row.event_date,
3296
- duration_min: row.duration_min,
3297
- contact_ids,
3298
- company_id: row.company_id,
3299
- notes: row.notes,
3300
- outcome: row.outcome,
3301
- deal_id: row.deal_id,
3302
- created_at: row.created_at
3303
- };
3600
+ return _db2.query(`SELECT * FROM contact_field_history WHERE contact_id=? ORDER BY valid_from DESC`).all(contactId);
3304
3601
  }
3305
- function logEvent(input, db) {
3306
- const d = db || getDatabase();
3602
+ function getContactAt(contactId, timestamp, db) {
3603
+ const _db2 = db || getDatabase();
3604
+ const rows = _db2.query(`SELECT field_name, new_value FROM contact_field_history WHERE contact_id=? AND valid_from<=? ORDER BY valid_from ASC`).all(contactId, timestamp);
3605
+ const result = {};
3606
+ for (const r of rows) {
3607
+ if (r.new_value != null)
3608
+ result[r.field_name] = r.new_value;
3609
+ }
3610
+ return result;
3611
+ }
3612
+
3613
+ // src/db/job-history.ts
3614
+ init_database();
3615
+ function rowToJob(r) {
3616
+ return { ...r, is_current: !!r["is_current"], inferred: !!r["inferred"] };
3617
+ }
3618
+ function addJobEntry(contactId, input, db) {
3619
+ const _db2 = db || getDatabase();
3620
+ if (input.is_current) {
3621
+ _db2.query(`UPDATE job_history SET is_current=0, end_date=COALESCE(end_date,?) WHERE contact_id=? AND is_current=1`).run(new Date().toISOString().slice(0, 10), contactId);
3622
+ }
3307
3623
  const id = uuid();
3308
- const timestamp = now();
3309
- d.run(`INSERT INTO events (id, title, type, event_date, duration_min, contact_ids, company_id, notes, outcome, deal_id, created_at)
3310
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
3311
- id,
3312
- input.title,
3313
- input.type ?? "meeting",
3314
- input.event_date,
3315
- input.duration_min ?? null,
3316
- JSON.stringify(input.contact_ids ?? []),
3317
- input.company_id ?? null,
3318
- input.notes ?? null,
3319
- input.outcome ?? null,
3320
- input.deal_id ?? null,
3321
- timestamp
3322
- ]);
3323
- return rowToEvent(d.query(`SELECT * FROM events WHERE id = ?`).get(id));
3624
+ _db2.query(`INSERT INTO job_history(id,contact_id,company_id,company_name,title,start_date,end_date,is_current,inferred,source,created_at) VALUES(?,?,?,?,?,?,?,?,?,?,?)`).run(id, contactId, input.company_id || null, input.company_name, input.title || null, input.start_date || null, input.end_date || null, input.is_current ? 1 : 0, input.inferred ? 1 : 0, input.source || null, now());
3625
+ return rowToJob(_db2.query(`SELECT * FROM job_history WHERE id=?`).get(id));
3324
3626
  }
3325
- function listEvents(opts = {}, db) {
3326
- const d = db || getDatabase();
3327
- const conditions = [];
3328
- const params = [];
3329
- if (opts.contact_id) {
3330
- conditions.push("contact_ids LIKE ?");
3331
- params.push(`%${opts.contact_id}%`);
3627
+ function getJobHistory(contactId, db) {
3628
+ const _db2 = db || getDatabase();
3629
+ return _db2.query(`SELECT * FROM job_history WHERE contact_id=? ORDER BY is_current DESC, start_date DESC`).all(contactId).map(rowToJob);
3630
+ }
3631
+
3632
+ // src/db/learnings.ts
3633
+ init_database();
3634
+ function rowToLearning(r) {
3635
+ return { ...r, tags: JSON.parse(r["tags"] || "[]") };
3636
+ }
3637
+ function saveLearning(contactId, input, db) {
3638
+ const _db2 = db || getDatabase();
3639
+ const id = uuid();
3640
+ _db2.query(`INSERT INTO contact_learnings(id,contact_id,content,type,confidence,importance,learned_by,session_id,visibility,tags,created_at,updated_at) VALUES(?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, contactId, input.content, input.type || "fact", input.confidence ?? 70, input.importance ?? 5, input.learned_by || null, input.session_id || null, input.visibility || "shared", JSON.stringify(input.tags || []), now(), now());
3641
+ return rowToLearning(_db2.query(`SELECT * FROM contact_learnings WHERE id=?`).get(id));
3642
+ }
3643
+ function getLearnings(contactId, opts = {}, db) {
3644
+ const _db2 = db || getDatabase();
3645
+ let sql = `SELECT * FROM contact_learnings WHERE contact_id=?`;
3646
+ const params = [contactId];
3647
+ if (opts.type) {
3648
+ sql += ` AND type=?`;
3649
+ params.push(opts.type);
3332
3650
  }
3333
- if (opts.company_id) {
3334
- conditions.push("company_id = ?");
3335
- params.push(opts.company_id);
3651
+ if (opts.min_importance) {
3652
+ sql += ` AND importance>=?`;
3653
+ params.push(opts.min_importance);
3336
3654
  }
3655
+ if (opts.visibility) {
3656
+ sql += ` AND visibility=?`;
3657
+ params.push(opts.visibility);
3658
+ }
3659
+ sql += ` ORDER BY importance DESC, confidence DESC`;
3660
+ return _db2.query(sql).all(...params).map(rowToLearning);
3661
+ }
3662
+ function searchLearnings(query, opts = {}, db) {
3663
+ const _db2 = db || getDatabase();
3664
+ let sql = `SELECT * FROM contact_learnings WHERE content LIKE ?`;
3665
+ const params = [`%${query}%`];
3337
3666
  if (opts.type) {
3338
- conditions.push("type = ?");
3667
+ sql += ` AND type=?`;
3339
3668
  params.push(opts.type);
3340
3669
  }
3341
- if (opts.date_from) {
3342
- conditions.push("event_date >= ?");
3343
- params.push(opts.date_from);
3344
- }
3345
- if (opts.date_to) {
3346
- conditions.push("event_date <= ?");
3347
- params.push(opts.date_to);
3670
+ if (opts.contact_id) {
3671
+ sql += ` AND contact_id=?`;
3672
+ params.push(opts.contact_id);
3348
3673
  }
3349
- const where = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
3350
- const rows = d.query(`SELECT * FROM events ${where} ORDER BY event_date DESC`).all(...params);
3351
- return rows.map(rowToEvent);
3674
+ sql += ` ORDER BY importance DESC, confidence DESC LIMIT 50`;
3675
+ return _db2.query(sql).all(...params).map(rowToLearning);
3352
3676
  }
3353
- function deleteEvent(id, db) {
3354
- const d = db || getDatabase();
3355
- d.run(`DELETE FROM events WHERE id = ?`, [id]);
3677
+ function confirmLearning(learningId, _agentName, db) {
3678
+ const _db2 = db || getDatabase();
3679
+ _db2.query(`UPDATE contact_learnings SET confirmed_count=confirmed_count+1, confidence=MIN(100,confidence+10), updated_at=? WHERE id=?`).run(now(), learningId);
3680
+ }
3681
+ function decayLearnings(db) {
3682
+ const _db2 = db || getDatabase();
3683
+ const cutoff = new Date(Date.now() - 30 * 86400000).toISOString();
3684
+ const result = _db2.query(`UPDATE contact_learnings SET confidence=MAX(10,confidence-5), updated_at=? WHERE confirmed_count=0 AND created_at<? AND confidence>10`).run(now(), cutoff);
3685
+ return result.changes || 0;
3356
3686
  }
3357
3687
 
3358
- // src/mcp/index.ts
3359
- var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
3688
+ // src/db/coordination.ts
3689
+ init_database();
3690
+ function acquireLock(contactId, agentName, ttlSeconds = 300, reason, sessionId, db) {
3691
+ const _db2 = db || getDatabase();
3692
+ cleanExpiredLocks(_db2);
3693
+ const existing = _db2.query(`SELECT * FROM contact_locks WHERE contact_id=?`).get(contactId);
3694
+ if (existing)
3695
+ return { acquired: false, held_by: existing.agent_name, lock: existing };
3696
+ const id = uuid();
3697
+ const expiresAt = new Date(Date.now() + ttlSeconds * 1000).toISOString();
3698
+ _db2.query(`INSERT INTO contact_locks(id,contact_id,agent_name,reason,acquired_at,expires_at,session_id) VALUES(?,?,?,?,?,?,?)`).run(id, contactId, agentName, reason || null, now(), expiresAt, sessionId || null);
3699
+ return {
3700
+ acquired: true,
3701
+ lock: _db2.query(`SELECT * FROM contact_locks WHERE id=?`).get(id)
3702
+ };
3703
+ }
3704
+ function releaseLock(contactId, agentName, db) {
3705
+ const _db2 = db || getDatabase();
3706
+ const result = _db2.query(`DELETE FROM contact_locks WHERE contact_id=? AND agent_name=?`).run(contactId, agentName);
3707
+ return (result.changes || 0) > 0;
3708
+ }
3709
+ function checkLock(contactId, db) {
3710
+ const _db2 = db || getDatabase();
3711
+ cleanExpiredLocks(_db2);
3712
+ return _db2.query(`SELECT * FROM contact_locks WHERE contact_id=?`).get(contactId);
3713
+ }
3714
+ function cleanExpiredLocks(db) {
3715
+ const _db2 = db || getDatabase();
3716
+ _db2.query(`DELETE FROM contact_locks WHERE expires_at<?`).run(now());
3717
+ }
3718
+ function logAgentActivity(contactId, agentName, action, details, sessionId, db) {
3719
+ const _db2 = db || getDatabase();
3720
+ _db2.query(`INSERT INTO contact_agent_activity(id,contact_id,agent_name,action,details,session_id,created_at) VALUES(?,?,?,?,?,?,?)`).run(uuid(), contactId, agentName, action, details || null, sessionId || null, now());
3721
+ }
3722
+ function getAgentActivity(contactId, limit = 20, db) {
3723
+ const _db2 = db || getDatabase();
3724
+ return _db2.query(`SELECT * FROM contact_agent_activity WHERE contact_id=? ORDER BY created_at DESC LIMIT ?`).all(contactId, limit);
3725
+ }
3726
+
3727
+ // src/db/graph.ts
3728
+ init_database();
3729
+ function computeRelationshipStrength(contactId, db) {
3730
+ const _db2 = db || getDatabase();
3731
+ const contact = _db2.query(`SELECT last_contacted_at, interaction_count_30d, interaction_count_90d FROM contacts WHERE id=?`).get(contactId);
3732
+ if (!contact)
3733
+ return 0;
3734
+ let score = 50;
3735
+ if (contact.last_contacted_at) {
3736
+ const days = Math.floor((Date.now() - new Date(contact.last_contacted_at).getTime()) / 86400000);
3737
+ score += days < 7 ? 30 : days < 30 ? 20 : days < 90 ? 5 : -20;
3738
+ } else {
3739
+ score -= 20;
3740
+ }
3741
+ score += Math.min(20, (contact.interaction_count_30d || 0) * 4);
3742
+ return Math.max(0, Math.min(100, score));
3743
+ }
3744
+ function findWarmPath(fromContactId, toContactId, db) {
3745
+ const _db2 = db || getDatabase();
3746
+ const visited = new Set([fromContactId]);
3747
+ const queue = [{ id: fromContactId, path: [] }];
3748
+ while (queue.length) {
3749
+ const item = queue.shift();
3750
+ const { id, path } = item;
3751
+ if (id === toContactId)
3752
+ return path;
3753
+ if (path.length >= 4)
3754
+ continue;
3755
+ const neighbors = _db2.query(`SELECT cr.*, c.display_name FROM contact_relationships cr JOIN contacts c ON (CASE WHEN cr.contact_a_id=? THEN cr.contact_b_id ELSE cr.contact_a_id END)=c.id WHERE (cr.contact_a_id=? OR cr.contact_b_id=?) LIMIT 20`).all(id, id, id);
3756
+ for (const n of neighbors) {
3757
+ const nextId = n.contact_a_id === id ? n.contact_b_id : n.contact_a_id;
3758
+ if (visited.has(nextId))
3759
+ continue;
3760
+ visited.add(nextId);
3761
+ queue.push({
3762
+ id: nextId,
3763
+ path: [
3764
+ ...path,
3765
+ { contact_id: nextId, display_name: n.display_name, strength: n.strength_score || 50 }
3766
+ ]
3767
+ });
3768
+ }
3769
+ }
3770
+ return [];
3771
+ }
3772
+ function findConnectionsAtCompany(companyId, db) {
3773
+ const _db2 = db || getDatabase();
3774
+ return _db2.query(`SELECT c.id as contact_id, c.display_name, c.job_title, c.relationship_health as strength FROM contacts c WHERE c.company_id=? AND c.archived=0 ORDER BY c.relationship_health DESC`).all(companyId);
3775
+ }
3776
+ function detectCoolingRelationships(db) {
3777
+ const _db2 = db || getDatabase();
3778
+ const cutoff = new Date(Date.now() - 45 * 86400000).toISOString();
3779
+ return _db2.query(`SELECT id as contact_id, display_name, CAST((julianday('now') - julianday(last_contacted_at)) AS INTEGER) as days_since FROM contacts WHERE last_contacted_at IS NOT NULL AND last_contacted_at < ? AND engagement_status != 'ghost' AND archived=0 ORDER BY last_contacted_at ASC LIMIT 50`).all(cutoff);
3780
+ }
3781
+
3782
+ // src/db/identity.ts
3783
+ init_database();
3784
+ function addIdentity(contactId, system, externalId, externalUrl, confidence = "inferred", db) {
3785
+ const _db2 = db || getDatabase();
3786
+ const id = uuid();
3787
+ _db2.query(`INSERT OR REPLACE INTO contact_identities(id,contact_id,system,external_id,external_url,confidence,created_at) VALUES(?,?,?,?,?,?,?)`).run(id, contactId, system, externalId, externalUrl || null, confidence, now());
3788
+ return _db2.query(`SELECT * FROM contact_identities WHERE id=?`).get(id);
3789
+ }
3790
+ function resolveByPartial(partial, db) {
3791
+ const _db2 = db || getDatabase();
3792
+ const matches = new Map;
3793
+ const addMatch = (id, name, title, score, reason) => {
3794
+ const existing = matches.get(id);
3795
+ if (existing) {
3796
+ existing.confidence_score = Math.min(100, existing.confidence_score + score);
3797
+ existing.match_reasons.push(reason);
3798
+ } else {
3799
+ matches.set(id, {
3800
+ contact: { id, display_name: name, job_title: title },
3801
+ confidence_score: score,
3802
+ match_reasons: [reason]
3803
+ });
3804
+ }
3805
+ };
3806
+ if (partial.email) {
3807
+ const rows = _db2.query(`SELECT c.id, c.display_name, c.job_title FROM contacts c JOIN emails e ON c.id=e.contact_id WHERE LOWER(e.address)=LOWER(?)`).all(partial.email);
3808
+ rows.forEach((r) => addMatch(r.id, r.display_name, r.job_title, 90, `email match: ${partial.email}`));
3809
+ }
3810
+ if (partial.linkedin_url) {
3811
+ const rows = _db2.query(`SELECT c.id, c.display_name, c.job_title FROM contacts c JOIN social_profiles sp ON c.id=sp.contact_id WHERE sp.platform='linkedin' AND sp.url LIKE ?`).all(`%${partial.linkedin_url.split("/").pop()}%`);
3812
+ rows.forEach((r) => addMatch(r.id, r.display_name, r.job_title, 85, `linkedin match`));
3813
+ }
3814
+ if (partial.name) {
3815
+ const rows = _db2.query(`SELECT id, display_name, job_title FROM contacts WHERE display_name LIKE ? AND archived=0 LIMIT 10`).all(`%${partial.name}%`);
3816
+ rows.forEach((r) => addMatch(r.id, r.display_name, r.job_title, 40, `name match: ${partial.name}`));
3817
+ }
3818
+ return Array.from(matches.values()).sort((a, b) => b.confidence_score - a.confidence_score);
3819
+ }
3820
+ function getIdentities(contactId, db) {
3821
+ const _db2 = db || getDatabase();
3822
+ return _db2.query(`SELECT * FROM contact_identities WHERE contact_id=? ORDER BY created_at DESC`).all(contactId);
3823
+ }
3824
+
3825
+ // src/lib/embeddings.ts
3826
+ init_database();
3827
+ function tokenize(text) {
3828
+ return text.toLowerCase().replace(/[^a-z0-9\s]/g, " ").split(/\s+/).filter((t) => t.length > 2);
3829
+ }
3830
+ function buildTfIdf(tokens) {
3831
+ const freq = new Map;
3832
+ for (const t of tokens)
3833
+ freq.set(t, (freq.get(t) || 0) + 1);
3834
+ const max = Math.max(...freq.values(), 1);
3835
+ const result = new Map;
3836
+ freq.forEach((v, k) => result.set(k, v / max));
3837
+ return result;
3838
+ }
3839
+ function cosineSimilarity(a, b) {
3840
+ let dot = 0, normA = 0, normB = 0;
3841
+ a.forEach((v, k) => {
3842
+ if (b.has(k))
3843
+ dot += v * b.get(k);
3844
+ normA += v * v;
3845
+ });
3846
+ b.forEach((v) => normB += v * v);
3847
+ return normA && normB ? dot / (Math.sqrt(normA) * Math.sqrt(normB)) : 0;
3848
+ }
3849
+ function buildContactEmbeddingText(contact) {
3850
+ const tags = contact.tags ?? [];
3851
+ const socialProfiles = contact.social_profiles ?? [];
3852
+ const company = contact.company;
3853
+ const parts = [
3854
+ contact.display_name,
3855
+ contact.job_title,
3856
+ contact.notes,
3857
+ company?.name,
3858
+ company?.industry,
3859
+ ...tags.map((t) => t.name),
3860
+ ...socialProfiles.map((s) => s.platform)
3861
+ ].filter(Boolean);
3862
+ return parts.join(" ");
3863
+ }
3864
+ async function embedContact(contactId, db) {
3865
+ const { getContact: getContact2 } = await Promise.resolve().then(() => (init_contacts(), exports_contacts));
3866
+ const _db2 = db || getDatabase();
3867
+ const contact = getContact2(contactId, _db2);
3868
+ const text = buildContactEmbeddingText(contact);
3869
+ const tokens = tokenize(text);
3870
+ const tfidf = buildTfIdf(tokens);
3871
+ const embedding = JSON.stringify(Array.from(tfidf.entries()).sort((a, b) => b[1] - a[1]).slice(0, 100));
3872
+ _db2.query(`INSERT OR REPLACE INTO contact_embeddings(contact_id, embedding, model, embedded_text, created_at, updated_at) VALUES(?,?,'tfidf',?,?,?)`).run(contactId, embedding, text.slice(0, 500), now(), now());
3873
+ }
3874
+ async function embedAllContacts(db) {
3875
+ const _db2 = db || getDatabase();
3876
+ const contacts = _db2.query(`SELECT id FROM contacts WHERE archived=0`).all();
3877
+ for (const c of contacts) {
3878
+ try {
3879
+ await embedContact(c.id, _db2);
3880
+ } catch {}
3881
+ }
3882
+ return contacts.length;
3883
+ }
3884
+ function semanticSearch(query, limit = 10, db) {
3885
+ const _db2 = db || getDatabase();
3886
+ const queryTokens = buildTfIdf(tokenize(query));
3887
+ let embeddings = [];
3888
+ try {
3889
+ embeddings = _db2.query(`SELECT contact_id, embedding FROM contact_embeddings`).all();
3890
+ } catch {
3891
+ return [];
3892
+ }
3893
+ const results = embeddings.map((e) => {
3894
+ try {
3895
+ const emb = new Map(JSON.parse(e.embedding));
3896
+ return { contact_id: e.contact_id, score: cosineSimilarity(queryTokens, emb) };
3897
+ } catch {
3898
+ return { contact_id: e.contact_id, score: 0 };
3899
+ }
3900
+ }).filter((r) => r.score > 0).sort((a, b) => b.score - a.score).slice(0, limit);
3901
+ return results;
3902
+ }
3903
+
3904
+ // src/db/signals.ts
3905
+ init_database();
3906
+ function getRelationshipSignals(contactId, db) {
3907
+ const _db2 = db || getDatabase();
3908
+ const row = _db2.query(`SELECT id as contact_id, display_name, last_contacted_at, interaction_count_30d, engagement_status, relationship_health FROM contacts WHERE id=?`).get(contactId);
3909
+ if (!row)
3910
+ return [];
3911
+ const daysSince = row.last_contacted_at ? Math.floor((Date.now() - new Date(row.last_contacted_at).getTime()) / 86400000) : null;
3912
+ const signals = [];
3913
+ const cnt = row.interaction_count_30d || 0;
3914
+ const health = row.relationship_health ?? 50;
3915
+ if (daysSince === null || daysSince > 180) {
3916
+ signals.push({ ...row, signal_type: "ghost", days_since_contact: daysSince, reason: "No contact in 180+ days or never contacted" });
3917
+ } else if (daysSince > 60 && cnt === 0) {
3918
+ signals.push({ ...row, signal_type: "cooling", days_since_contact: daysSince, reason: `No contact in ${daysSince} days, no recent interactions` });
3919
+ } else if (cnt > 3 && health > 70) {
3920
+ signals.push({ ...row, signal_type: "warming", days_since_contact: daysSince, reason: `${cnt} interactions in last 30 days, health score ${health}` });
3921
+ } else {
3922
+ signals.push({ ...row, signal_type: "healthy", days_since_contact: daysSince, reason: `Last contact ${daysSince}d ago, ${cnt} interactions in 30d` });
3923
+ }
3924
+ return signals;
3925
+ }
3926
+ function getGhostContacts(db) {
3927
+ const _db2 = db || getDatabase();
3928
+ const rows = _db2.query(`SELECT id as contact_id, display_name, last_contacted_at, interaction_count_30d, engagement_status, relationship_health FROM contacts WHERE (last_contacted_at IS NULL OR julianday('now') - julianday(last_contacted_at) > 180) AND archived=0 ORDER BY last_contacted_at ASC LIMIT 50`).all();
3929
+ return rows.map((r) => ({
3930
+ ...r,
3931
+ signal_type: "ghost",
3932
+ days_since_contact: r.last_contacted_at ? Math.floor((Date.now() - new Date(r.last_contacted_at).getTime()) / 86400000) : null,
3933
+ reason: "No contact in 180+ days or never contacted"
3934
+ }));
3935
+ }
3936
+ function getWarmingContacts(db) {
3937
+ const _db2 = db || getDatabase();
3938
+ const rows = _db2.query(`SELECT id as contact_id, display_name, last_contacted_at, interaction_count_30d, engagement_status, relationship_health FROM contacts WHERE interaction_count_30d > 2 AND relationship_health > 60 AND archived=0 ORDER BY relationship_health DESC LIMIT 50`).all();
3939
+ return rows.map((r) => ({
3940
+ ...r,
3941
+ signal_type: "warming",
3942
+ days_since_contact: r.last_contacted_at ? Math.floor((Date.now() - new Date(r.last_contacted_at).getTime()) / 86400000) : null,
3943
+ reason: `${r.interaction_count_30d} interactions in last 30 days`
3944
+ }));
3945
+ }
3946
+ function recomputeAllSignals(db) {
3947
+ const _db2 = db || getDatabase();
3948
+ _db2.query(`
3949
+ UPDATE contacts SET
3950
+ engagement_status = CASE
3951
+ WHEN interaction_count_30d > 3 THEN 'warm'
3952
+ WHEN last_contacted_at IS NULL OR julianday('now') - julianday(last_contacted_at) > 180 THEN 'ghost'
3953
+ WHEN julianday('now') - julianday(last_contacted_at) > 60 THEN 'cooling'
3954
+ ELSE 'active'
3955
+ END,
3956
+ updated_at = datetime('now')
3957
+ WHERE archived = 0
3958
+ `).run();
3959
+ const result = _db2.query(`SELECT changes() as n`).get();
3960
+ return { updated: result?.n ?? 0 };
3961
+ }
3962
+
3963
+ // src/lib/context.ts
3964
+ init_database();
3965
+ init_contacts();
3966
+ function getContactCard(contactId, db) {
3967
+ const _db2 = db || getDatabase();
3968
+ const c = getContact(contactId, _db2);
3969
+ const emails = c.emails;
3970
+ const phones = c.phones;
3971
+ const company = c.company;
3972
+ return {
3973
+ id: c.id,
3974
+ display_name: c.display_name,
3975
+ job_title: c.job_title,
3976
+ company: company?.name,
3977
+ primary_email: emails?.find((e) => e.is_primary)?.address || emails?.[0]?.address,
3978
+ primary_phone: phones?.find((p) => p.is_primary)?.number || phones?.[0]?.number
3979
+ };
3980
+ }
3981
+ function getContactBrief(contactId, taskContext, db) {
3982
+ const _db2 = db || getDatabase();
3983
+ const c = getContact(contactId, _db2);
3984
+ const notes = listNotes(contactId, _db2).slice(0, 3);
3985
+ const learnings = getLearnings(contactId, { min_importance: 7 }, _db2).slice(0, 5);
3986
+ const ctx = (taskContext ?? "").toLowerCase();
3987
+ const lastContactedAt = c.last_contacted_at;
3988
+ const daysSince = lastContactedAt ? Math.floor((Date.now() - new Date(lastContactedAt).getTime()) / 86400000) : null;
3989
+ const company = c.company;
3990
+ const brief = {
3991
+ id: c.id,
3992
+ display_name: c.display_name,
3993
+ job_title: c.job_title,
3994
+ company: company?.name,
3995
+ status: c.status,
3996
+ last_contacted: daysSince !== null ? `${daysSince}d ago` : "never",
3997
+ relationship_health: c.relationship_health,
3998
+ engagement_status: c.engagement_status,
3999
+ preferred_contact: c.preferred_contact_method || c.preferred_channel
4000
+ };
4001
+ if (ctx.includes("meeting") || ctx.includes("call") || ctx.includes("prep")) {
4002
+ brief.recent_notes = notes.map((n) => ({ date: n.created_at?.slice(0, 10), content: n.body }));
4003
+ brief.key_learnings = learnings.map((l) => l.content);
4004
+ }
4005
+ if (ctx.includes("outreach") || ctx.includes("email")) {
4006
+ brief.preferred_channel = c.preferred_channel;
4007
+ brief.follow_up_at = c.follow_up_at;
4008
+ }
4009
+ if (ctx.includes("deal")) {
4010
+ const dealCompany = c.company;
4011
+ brief.company_details = dealCompany ? { name: dealCompany.name, domain: dealCompany.domain } : null;
4012
+ }
4013
+ if (learnings.length)
4014
+ brief.top_learnings = learnings.map((l) => l.content);
4015
+ return brief;
4016
+ }
4017
+ async function assembleContext(contactIds, format = "meeting_prep", db) {
4018
+ const _db2 = db || getDatabase();
4019
+ const briefs = contactIds.map((id) => {
4020
+ try {
4021
+ return getContactBrief(id, format, _db2);
4022
+ } catch {
4023
+ return { id, error: "not found" };
4024
+ }
4025
+ });
4026
+ return { format, contact_count: contactIds.length, assembled_at: new Date().toISOString(), contacts: briefs };
4027
+ }
4028
+
4029
+ // src/lib/signature-parser.ts
4030
+ function parseEmailSignature(text) {
4031
+ const result = {};
4032
+ const phoneMatch = text.match(/(\+?[\d\s\-\(\)]{7,20})/);
4033
+ if (phoneMatch)
4034
+ result.phone = phoneMatch[1]?.trim();
4035
+ const emailMatch = text.match(/[\w.+-]+@[\w-]+\.[a-z]{2,}/i);
4036
+ if (emailMatch)
4037
+ result.email = emailMatch[0];
4038
+ const linkedinMatch = text.match(/(?:linkedin\.com\/in\/)([\w-]+)/i);
4039
+ if (linkedinMatch)
4040
+ result.linkedin = `https://linkedin.com/in/${linkedinMatch[1]}`;
4041
+ const websiteMatch = text.match(/https?:\/\/(?!linkedin)(?!twitter)[\w.-]+\.[a-z]{2,}/i);
4042
+ if (websiteMatch)
4043
+ result.website = websiteMatch[0];
4044
+ const lines = text.split(`
4045
+ `).map((l) => l.trim()).filter((l) => l.length > 2 && l.length < 80);
4046
+ if (lines[0])
4047
+ result.name = lines[0];
4048
+ for (const line of lines.slice(1)) {
4049
+ if (line.match(/\b(CEO|CTO|VP|Director|Manager|Engineer|Partner|Associate|Consultant|Analyst|President|Founder)\b/i)) {
4050
+ result.title = line;
4051
+ } else if (!result.company && line.match(/^[A-Z][A-Za-z\s,\.]+$/) && !line.includes("@")) {
4052
+ result.company = line;
4053
+ }
4054
+ }
4055
+ return result;
4056
+ }
4057
+ function extractContactsFromEmailThread(participants) {
4058
+ return participants.map((p) => {
4059
+ const sig = p.signature ? parseEmailSignature(p.signature) : {};
4060
+ const name = p.name || sig.name || p.email.split("@")[0] || "Unknown";
4061
+ const contact = {
4062
+ display_name: name,
4063
+ emails: [{ address: p.email, type: "work", is_primary: true }],
4064
+ source: "import"
4065
+ };
4066
+ if (sig.title)
4067
+ contact.job_title = sig.title;
4068
+ if (sig.phone)
4069
+ contact.phones = [{ number: sig.phone, type: "work", is_primary: true }];
4070
+ if (sig.linkedin)
4071
+ contact.social_profiles = [{ platform: "linkedin", url: sig.linkedin, is_primary: true }];
4072
+ if (sig.website)
4073
+ contact.website = sig.website;
4074
+ return contact;
4075
+ });
4076
+ }
4077
+
4078
+ // src/lib/meeting-capture.ts
4079
+ init_database();
4080
+ async function ingestMeetingParticipants(event, db) {
4081
+ const { findOrCreateContact: findOrCreateContact2 } = await Promise.resolve().then(() => (init_contacts(), exports_contacts));
4082
+ const { logEvent: logEvent2 } = await Promise.resolve().then(() => (init_events(), exports_events));
4083
+ const _db2 = db || getDatabase();
4084
+ let created = 0;
4085
+ let updated = 0;
4086
+ const ids = [];
4087
+ for (const a of event.attendees) {
4088
+ try {
4089
+ const nameParts = a.name.split(" ");
4090
+ const result = await findOrCreateContact2({
4091
+ display_name: a.name,
4092
+ first_name: nameParts[0],
4093
+ last_name: nameParts.slice(1).join(" ") || undefined,
4094
+ emails: [{ address: a.email, type: "work", is_primary: true }],
4095
+ source: "import"
4096
+ }, _db2);
4097
+ ids.push(result.contact.id);
4098
+ if (result.created)
4099
+ created++;
4100
+ else
4101
+ updated++;
4102
+ } catch {}
4103
+ }
4104
+ if (ids.length) {
4105
+ try {
4106
+ logEvent2({
4107
+ title: event.title,
4108
+ type: "meeting",
4109
+ event_date: event.event_date,
4110
+ contact_ids: ids,
4111
+ notes: event.context
4112
+ }, _db2);
4113
+ } catch {}
4114
+ }
4115
+ return { created, updated, contact_ids: ids };
4116
+ }
4117
+
4118
+ // src/db/freshness.ts
4119
+ init_database();
4120
+ var SCORED_FIELDS = ["display_name", "job_title", "company_id", "emails", "phones", "last_contacted_at"];
4121
+ function getFreshnessScore(contactId, db) {
4122
+ const _db2 = db || getDatabase();
4123
+ const contact = _db2.query(`SELECT * FROM contacts WHERE id=?`).get(contactId);
4124
+ if (!contact)
4125
+ throw new Error(`Contact not found: ${contactId}`);
4126
+ let historyRows = [];
4127
+ try {
4128
+ historyRows = _db2.query(`SELECT field_name, new_value, source, created_at FROM contact_field_history WHERE contact_id=? ORDER BY created_at DESC`).all(contactId);
4129
+ } catch {}
4130
+ let verifiedRows = [];
4131
+ try {
4132
+ verifiedRows = _db2.query(`SELECT field_name, verified_at, source FROM field_verifications WHERE contact_id=?`).all(contactId);
4133
+ } catch {}
4134
+ const verifiedMap = new Map(verifiedRows.map((r) => [r.field_name, r]));
4135
+ const historyMap = new Map;
4136
+ for (const r of historyRows) {
4137
+ if (!historyMap.has(r.field_name))
4138
+ historyMap.set(r.field_name, r);
4139
+ }
4140
+ const fields = SCORED_FIELDS.map((field) => {
4141
+ let value = null;
4142
+ if (field === "emails") {
4143
+ const emailRow = _db2.query(`SELECT address FROM emails WHERE contact_id=? LIMIT 1`).get(contactId);
4144
+ value = emailRow?.address ?? null;
4145
+ } else if (field === "phones") {
4146
+ const phoneRow = _db2.query(`SELECT number FROM phones WHERE contact_id=? LIMIT 1`).get(contactId);
4147
+ value = phoneRow?.number ?? null;
4148
+ } else {
4149
+ value = contact[field] != null ? String(contact[field]) : null;
4150
+ }
4151
+ const verified = verifiedMap.get(field);
4152
+ const history = historyMap.get(field);
4153
+ let confidence = "unknown";
4154
+ let days_old = null;
4155
+ let last_verified_at = null;
4156
+ let source = null;
4157
+ if (verified) {
4158
+ confidence = "verified";
4159
+ last_verified_at = verified.verified_at;
4160
+ source = verified.source;
4161
+ days_old = Math.floor((Date.now() - new Date(verified.verified_at).getTime()) / 86400000);
4162
+ } else if (history) {
4163
+ confidence = history.source === "import" ? "imported" : "inferred";
4164
+ last_verified_at = history.created_at;
4165
+ source = history.source;
4166
+ days_old = Math.floor((Date.now() - new Date(history.created_at).getTime()) / 86400000);
4167
+ if (days_old > 365)
4168
+ confidence = "stale";
4169
+ } else if (value) {
4170
+ confidence = "inferred";
4171
+ }
4172
+ return { field_name: field, value, last_verified_at, source, confidence, days_old };
4173
+ });
4174
+ const fieldScore = fields.reduce((acc, f) => {
4175
+ if (!f.value)
4176
+ return acc;
4177
+ if (f.confidence === "verified")
4178
+ return acc + 20;
4179
+ if (f.confidence === "imported" || f.confidence === "inferred")
4180
+ return acc + 10;
4181
+ return acc + 5;
4182
+ }, 0);
4183
+ const overall_score = Math.min(100, fieldScore);
4184
+ return {
4185
+ contact_id: contactId,
4186
+ overall_score,
4187
+ fields,
4188
+ stale_fields: fields.filter((f) => f.confidence === "stale" || !f.value && f.field_name !== "phones").map((f) => f.field_name),
4189
+ verified_fields: fields.filter((f) => f.confidence === "verified").map((f) => f.field_name)
4190
+ };
4191
+ }
4192
+ function getStaleContacts(threshold = 40, db) {
4193
+ const _db2 = db || getDatabase();
4194
+ const rows = _db2.query(`SELECT * FROM (
4195
+ SELECT c.id as contact_id, c.display_name,
4196
+ (CASE WHEN c.job_title IS NOT NULL THEN 15 ELSE 0 END +
4197
+ CASE WHEN c.company_id IS NOT NULL THEN 15 ELSE 0 END +
4198
+ CASE WHEN c.last_contacted_at IS NOT NULL THEN 20 ELSE 0 END +
4199
+ CASE WHEN EXISTS(SELECT 1 FROM emails WHERE contact_id=c.id) THEN 20 ELSE 0 END +
4200
+ CASE WHEN EXISTS(SELECT 1 FROM phones WHERE contact_id=c.id) THEN 15 ELSE 0 END +
4201
+ CASE WHEN c.notes IS NOT NULL THEN 10 ELSE 0 END +
4202
+ CASE WHEN EXISTS(SELECT 1 FROM contact_tags WHERE contact_id=c.id) THEN 5 ELSE 0 END
4203
+ ) as score
4204
+ FROM contacts c WHERE c.archived=0
4205
+ ) WHERE score < ? ORDER BY score ASC LIMIT 100`).all(threshold);
4206
+ return rows;
4207
+ }
4208
+ function markFieldVerified(contactId, fieldName, source, db) {
4209
+ const _db2 = db || getDatabase();
4210
+ try {
4211
+ _db2.query(`INSERT OR REPLACE INTO field_verifications(contact_id,field_name,verified_at,source) VALUES(?,?,?,?)`).run(contactId, fieldName, now(), source || null);
4212
+ } catch {
4213
+ _db2.query(`INSERT INTO activity_log(id,contact_id,action,details,created_at) VALUES(?,?,?,?,?)`).run(crypto.randomUUID(), contactId, "field.verified", JSON.stringify({ field_name: fieldName, source }), now());
4214
+ }
4215
+ }
4216
+
4217
+ // src/db/org-chart.ts
4218
+ init_database();
4219
+ function addOrgChartEdge(companyId, contactAId, contactBId, edgeType, inferred = false, db) {
4220
+ const _db2 = db || getDatabase();
4221
+ const id = uuid();
4222
+ _db2.query(`INSERT OR IGNORE INTO org_chart_edges(id,company_id,contact_a_id,contact_b_id,edge_type,inferred,created_at) VALUES(?,?,?,?,?,?,?)`).run(id, companyId, contactAId, contactBId, edgeType, inferred ? 1 : 0, now());
4223
+ return _db2.query(`SELECT * FROM org_chart_edges WHERE company_id=? AND contact_a_id=? AND contact_b_id=? AND edge_type=?`).get(companyId, contactAId, contactBId, edgeType);
4224
+ }
4225
+ function listOrgChart(companyId, db) {
4226
+ const _db2 = db || getDatabase();
4227
+ return _db2.query(`SELECT oe.*, ca.display_name as contact_a_name, cb.display_name as contact_b_name FROM org_chart_edges oe JOIN contacts ca ON oe.contact_a_id=ca.id JOIN contacts cb ON oe.contact_b_id=cb.id WHERE oe.company_id=?`).all(companyId);
4228
+ }
4229
+ function setDealContactRole(dealId, contactId, accountRole, db) {
4230
+ const _db2 = db || getDatabase();
4231
+ const id = uuid();
4232
+ _db2.query(`INSERT OR REPLACE INTO deal_contact_roles(id,deal_id,contact_id,account_role,created_at) VALUES(?,?,?,?,?)`).run(id, dealId, contactId, accountRole, now());
4233
+ return _db2.query(`SELECT * FROM deal_contact_roles WHERE deal_id=? AND contact_id=?`).get(dealId, contactId);
4234
+ }
4235
+ function getDealTeam(dealId, db) {
4236
+ const _db2 = db || getDatabase();
4237
+ return _db2.query(`SELECT dr.*, c.display_name, c.job_title FROM deal_contact_roles dr JOIN contacts c ON dr.contact_id=c.id WHERE dr.deal_id=?`).all(dealId);
4238
+ }
4239
+ function getCoverageGaps(companyId, db) {
4240
+ const _db2 = db || getDatabase();
4241
+ const total = _db2.query(`SELECT COUNT(*) c FROM contacts WHERE company_id=? AND archived=0`).get(companyId).c;
4242
+ const hasManager = _db2.query(`SELECT COUNT(*) c FROM org_chart_edges WHERE company_id=? AND edge_type='manages'`).get(companyId).c > 0;
4243
+ const hasEco = _db2.query(`SELECT COUNT(*) c FROM deal_contact_roles dr JOIN deals d ON dr.deal_id=d.id WHERE d.company_id=? AND dr.account_role='economic_buyer'`).get(companyId).c > 0;
4244
+ const hasTech = _db2.query(`SELECT COUNT(*) c FROM deal_contact_roles dr JOIN deals d ON dr.deal_id=d.id WHERE d.company_id=? AND dr.account_role='technical_evaluator'`).get(companyId).c > 0;
4245
+ const missing = [
4246
+ !hasManager && "org chart relationships",
4247
+ !hasEco && "economic buyer",
4248
+ !hasTech && "technical evaluator"
4249
+ ].filter(Boolean);
4250
+ return {
4251
+ total_contacts: total,
4252
+ has_manager: hasManager,
4253
+ has_technical: hasTech,
4254
+ has_economic_buyer: hasEco,
4255
+ suggestion: missing.length ? `Missing: ${missing.join(", ")}` : "Good coverage"
4256
+ };
4257
+ }
4258
+
4259
+ // src/lib/images.ts
4260
+ init_database();
4261
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, copyFileSync as copyFileSync2, unlinkSync, readdirSync as readdirSync2, readFileSync as readFileSync2, writeFileSync } from "fs";
4262
+ import { join as join3, extname, basename } from "path";
4263
+ var IMAGES_DIR = join3(getDataDir(), "images");
4264
+ function ensureImagesDir() {
4265
+ if (!existsSync3(IMAGES_DIR))
4266
+ mkdirSync2(IMAGES_DIR, { recursive: true });
4267
+ }
4268
+ function saveImage(entityId, source, options) {
4269
+ ensureImagesDir();
4270
+ deleteImage(entityId);
4271
+ const base64Match = source.match(/^data:image\/(\w+);base64,(.+)$/s);
4272
+ if (base64Match) {
4273
+ const ext2 = base64Match[1] === "jpeg" ? "jpg" : base64Match[1];
4274
+ const data = Buffer.from(base64Match[2], "base64");
4275
+ const filename2 = `${entityId}.${ext2}`;
4276
+ writeFileSync(join3(IMAGES_DIR, filename2), data);
4277
+ return filename2;
4278
+ }
4279
+ if (!existsSync3(source) && /^[A-Za-z0-9+/=\n\r]+$/.test(source.trim()) && source.length > 100) {
4280
+ const ext2 = options?.format || "jpg";
4281
+ const data = Buffer.from(source.trim(), "base64");
4282
+ const filename2 = `${entityId}.${ext2}`;
4283
+ writeFileSync(join3(IMAGES_DIR, filename2), data);
4284
+ return filename2;
4285
+ }
4286
+ if (!existsSync3(source)) {
4287
+ throw new Error(`Image file not found: ${source}`);
4288
+ }
4289
+ const ext = extname(source).slice(1).toLowerCase() || "jpg";
4290
+ const validExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "ico"];
4291
+ if (!validExts.includes(ext)) {
4292
+ throw new Error(`Unsupported image format: ${ext}. Supported: ${validExts.join(", ")}`);
4293
+ }
4294
+ const filename = `${entityId}.${ext === "jpeg" ? "jpg" : ext}`;
4295
+ copyFileSync2(source, join3(IMAGES_DIR, filename));
4296
+ return filename;
4297
+ }
4298
+ function getImagePath(entityId) {
4299
+ ensureImagesDir();
4300
+ const files = readdirSync2(IMAGES_DIR);
4301
+ const match = files.find((f) => f.startsWith(`${entityId}.`));
4302
+ return match ? join3(IMAGES_DIR, match) : null;
4303
+ }
4304
+ function getImageAsBase64(entityId) {
4305
+ const path = getImagePath(entityId);
4306
+ if (!path)
4307
+ return null;
4308
+ const ext = extname(path).slice(1);
4309
+ const mime = ext === "jpg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : `image/${ext}`;
4310
+ const data = readFileSync2(path);
4311
+ return `data:${mime};base64,${data.toString("base64")}`;
4312
+ }
4313
+ function deleteImage(entityId) {
4314
+ ensureImagesDir();
4315
+ const files = readdirSync2(IMAGES_DIR);
4316
+ let deleted = false;
4317
+ for (const f of files) {
4318
+ if (f.startsWith(`${entityId}.`)) {
4319
+ unlinkSync(join3(IMAGES_DIR, f));
4320
+ deleted = true;
4321
+ }
4322
+ }
4323
+ return deleted;
4324
+ }
4325
+
4326
+ // src/lib/vault.ts
4327
+ init_database();
4328
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3, unlinkSync as unlinkSync2 } from "fs";
4329
+ import { join as join4 } from "path";
4330
+ import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
4331
+ var VAULT_DIR = getDataDir();
4332
+ var VAULT_CONFIG = join4(VAULT_DIR, "vault.json");
4333
+ var VAULT_SESSION = join4(VAULT_DIR, ".vault-session");
4334
+ var DOCUMENTS_DIR = join4(VAULT_DIR, "documents");
4335
+ var SESSION_TTL_MS = 30 * 60 * 1000;
4336
+ var _derivedKey = null;
4337
+ function deriveKey(passphrase, salt) {
4338
+ return pbkdf2Sync(passphrase, salt, 1e5, 32, "sha512");
4339
+ }
4340
+ function saveSession(key) {
4341
+ const session = {
4342
+ key: key.toString("hex"),
4343
+ expires_at: new Date(Date.now() + SESSION_TTL_MS).toISOString()
4344
+ };
4345
+ writeFileSync2(VAULT_SESSION, JSON.stringify(session), { mode: 384 });
4346
+ }
4347
+ function loadSession() {
4348
+ if (!existsSync4(VAULT_SESSION))
4349
+ return null;
4350
+ try {
4351
+ const session = JSON.parse(readFileSync3(VAULT_SESSION, "utf-8"));
4352
+ if (new Date(session.expires_at).getTime() < Date.now()) {
4353
+ try {
4354
+ unlinkSync2(VAULT_SESSION);
4355
+ } catch {}
4356
+ return null;
4357
+ }
4358
+ return Buffer.from(session.key, "hex");
4359
+ } catch {
4360
+ return null;
4361
+ }
4362
+ }
4363
+ function clearSession() {
4364
+ try {
4365
+ if (existsSync4(VAULT_SESSION))
4366
+ unlinkSync2(VAULT_SESSION);
4367
+ } catch {}
4368
+ }
4369
+ function initVault(passphrase) {
4370
+ if (!existsSync4(VAULT_DIR))
4371
+ mkdirSync3(VAULT_DIR, { recursive: true });
4372
+ if (!existsSync4(DOCUMENTS_DIR))
4373
+ mkdirSync3(DOCUMENTS_DIR, { recursive: true });
4374
+ const salt = randomBytes(32);
4375
+ const key = deriveKey(passphrase, salt);
4376
+ const keyHash = createHash("sha256").update(key).digest("hex");
4377
+ const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
4378
+ writeFileSync2(VAULT_CONFIG, JSON.stringify(config, null, 2));
4379
+ _derivedKey = key;
4380
+ saveSession(key);
4381
+ }
4382
+ function isVaultInitialized() {
4383
+ return existsSync4(VAULT_CONFIG);
4384
+ }
4385
+ function unlockVault(passphrase) {
4386
+ if (!existsSync4(VAULT_CONFIG))
4387
+ throw new Error("Vault not initialized. Run 'contacts vault init' first.");
4388
+ const config = JSON.parse(readFileSync3(VAULT_CONFIG, "utf-8"));
4389
+ const salt = Buffer.from(config.salt, "hex");
4390
+ const key = deriveKey(passphrase, salt);
4391
+ const keyHash = createHash("sha256").update(key).digest("hex");
4392
+ if (keyHash !== config.key_hash)
4393
+ return false;
4394
+ _derivedKey = key;
4395
+ saveSession(key);
4396
+ return true;
4397
+ }
4398
+ function lockVault() {
4399
+ _derivedKey = null;
4400
+ clearSession();
4401
+ }
4402
+ function isVaultUnlocked() {
4403
+ if (_derivedKey)
4404
+ return true;
4405
+ const sessionKey = loadSession();
4406
+ if (sessionKey) {
4407
+ _derivedKey = sessionKey;
4408
+ return true;
4409
+ }
4410
+ return false;
4411
+ }
4412
+ function requireVault() {
4413
+ if (_derivedKey)
4414
+ return _derivedKey;
4415
+ const sessionKey = loadSession();
4416
+ if (sessionKey) {
4417
+ _derivedKey = sessionKey;
4418
+ return _derivedKey;
4419
+ }
4420
+ throw new Error("Vault is locked. Unlock with 'contacts vault unlock --passphrase <pass>' or vault_unlock MCP tool first.");
4421
+ }
4422
+ function encrypt(plaintext) {
4423
+ const key = requireVault();
4424
+ const iv = randomBytes(16);
4425
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
4426
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
4427
+ encrypted += cipher.final("hex");
4428
+ const authTag = cipher.getAuthTag().toString("hex");
4429
+ return { ciphertext: encrypted + ":" + authTag, iv: iv.toString("hex") };
4430
+ }
4431
+ function decrypt(ciphertext, iv) {
4432
+ const key = requireVault();
4433
+ const [encData, authTag] = ciphertext.split(":");
4434
+ if (!encData || !authTag)
4435
+ throw new Error("Invalid ciphertext format");
4436
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"));
4437
+ decipher.setAuthTag(Buffer.from(authTag, "hex"));
4438
+ let decrypted = decipher.update(encData, "hex", "utf8");
4439
+ decrypted += decipher.final("utf8");
4440
+ return decrypted;
4441
+ }
4442
+ function storeFile(sourcePath, entityId) {
4443
+ if (!existsSync4(DOCUMENTS_DIR))
4444
+ mkdirSync3(DOCUMENTS_DIR, { recursive: true });
4445
+ const ext = sourcePath.split(".").pop() || "bin";
4446
+ const destPath = join4(DOCUMENTS_DIR, `${entityId}.${ext}`);
4447
+ const data = readFileSync3(sourcePath);
4448
+ writeFileSync2(destPath, data);
4449
+ return destPath;
4450
+ }
4451
+
4452
+ // src/db/documents.ts
4453
+ init_database();
4454
+ import { existsSync as existsSync5, unlinkSync as unlinkSync3 } from "fs";
4455
+ var DOCUMENT_TYPES = [
4456
+ "passport",
4457
+ "national_id",
4458
+ "tax_id",
4459
+ "ssn",
4460
+ "drivers_license",
4461
+ "bank_account",
4462
+ "visa",
4463
+ "insurance",
4464
+ "contract",
4465
+ "certificate",
4466
+ "medical_record",
4467
+ "prescription",
4468
+ "allergy_list",
4469
+ "vaccination",
4470
+ "blood_type",
4471
+ "health_insurance",
4472
+ "medical_condition",
4473
+ "emergency_contact_medical",
4474
+ "other"
4475
+ ];
4476
+ function addDocument(input, db) {
4477
+ requireVault();
4478
+ const _db2 = db || getDatabase();
4479
+ const id = uuid();
4480
+ const { ciphertext, iv } = encrypt(input.value);
4481
+ let filePath = null;
4482
+ if (input.file_path) {
4483
+ filePath = storeFile(input.file_path, id);
4484
+ }
4485
+ _db2.query(`INSERT INTO contact_documents (id, contact_id, doc_type, label, encrypted_value, iv, encrypted_file_path, metadata, expires_at, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?)`).run(id, input.contact_id, input.doc_type, input.label ?? null, ciphertext, iv, filePath, JSON.stringify(input.metadata || {}), input.expires_at ?? null, now(), now());
4486
+ return getDocument(id, _db2);
4487
+ }
4488
+ function getDocument(id, db) {
4489
+ requireVault();
4490
+ const _db2 = db || getDatabase();
4491
+ const row = _db2.query(`SELECT * FROM contact_documents WHERE id = ?`).get(id);
4492
+ if (!row)
4493
+ throw new Error(`Document not found: ${id}`);
4494
+ return rowToDoc(row);
4495
+ }
4496
+ function listDocuments(contactId, db) {
4497
+ const _db2 = db || getDatabase();
4498
+ const rows = _db2.query(`SELECT id, doc_type, label, encrypted_file_path, expires_at, created_at FROM contact_documents WHERE contact_id = ? ORDER BY created_at DESC`).all(contactId);
4499
+ return rows.map((r) => ({
4500
+ id: r.id,
4501
+ doc_type: r.doc_type,
4502
+ label: r.label,
4503
+ has_file: !!r.encrypted_file_path,
4504
+ expires_at: r.expires_at,
4505
+ created_at: r.created_at
4506
+ }));
4507
+ }
4508
+ function deleteDocument(id, db) {
4509
+ const _db2 = db || getDatabase();
4510
+ const row = _db2.query(`SELECT encrypted_file_path FROM contact_documents WHERE id = ?`).get(id);
4511
+ if (row?.encrypted_file_path && existsSync5(row.encrypted_file_path)) {
4512
+ try {
4513
+ unlinkSync3(row.encrypted_file_path);
4514
+ } catch {}
4515
+ }
4516
+ _db2.query(`DELETE FROM contact_documents WHERE id = ?`).run(id);
4517
+ }
4518
+ function rowToDoc(row) {
4519
+ return {
4520
+ id: row.id,
4521
+ contact_id: row.contact_id,
4522
+ doc_type: row.doc_type,
4523
+ label: row.label,
4524
+ value: decrypt(row.encrypted_value, row.iv),
4525
+ has_file: !!row.encrypted_file_path,
4526
+ file_path: row.encrypted_file_path,
4527
+ metadata: JSON.parse(row.metadata || "{}"),
4528
+ expires_at: row.expires_at,
4529
+ created_at: row.created_at,
4530
+ updated_at: row.updated_at
4531
+ };
4532
+ }
4533
+
4534
+ // src/db/health.ts
4535
+ init_database();
4536
+ function setHealthData(contactId, input, db) {
4537
+ requireVault();
4538
+ const _db2 = db || getDatabase();
4539
+ const existing = _db2.query(`SELECT id FROM contact_health WHERE contact_id = ?`).get(contactId);
4540
+ if (existing) {
4541
+ const sets = [];
4542
+ const params = [];
4543
+ if (input.blood_type !== undefined) {
4544
+ sets.push("blood_type = ?");
4545
+ params.push(input.blood_type);
4546
+ }
4547
+ if (input.allergies !== undefined) {
4548
+ sets.push("allergies = ?");
4549
+ params.push(JSON.stringify(input.allergies));
4550
+ }
4551
+ if (input.medical_conditions !== undefined) {
4552
+ sets.push("medical_conditions = ?");
4553
+ params.push(JSON.stringify(input.medical_conditions));
4554
+ }
4555
+ if (input.medications !== undefined) {
4556
+ sets.push("medications = ?");
4557
+ params.push(JSON.stringify(input.medications));
4558
+ }
4559
+ if (input.emergency_contacts !== undefined) {
4560
+ sets.push("emergency_contacts = ?");
4561
+ params.push(JSON.stringify(input.emergency_contacts));
4562
+ }
4563
+ if (input.health_insurance_provider !== undefined) {
4564
+ sets.push("health_insurance_provider = ?");
4565
+ params.push(input.health_insurance_provider);
4566
+ }
4567
+ if (input.health_insurance_id !== undefined) {
4568
+ sets.push("health_insurance_id = ?");
4569
+ params.push(input.health_insurance_id);
4570
+ }
4571
+ if (input.primary_physician !== undefined) {
4572
+ sets.push("primary_physician = ?");
4573
+ params.push(input.primary_physician);
4574
+ }
4575
+ if (input.primary_physician_phone !== undefined) {
4576
+ sets.push("primary_physician_phone = ?");
4577
+ params.push(input.primary_physician_phone);
4578
+ }
4579
+ if (input.organ_donor !== undefined) {
4580
+ sets.push("organ_donor = ?");
4581
+ params.push(input.organ_donor ? 1 : 0);
4582
+ }
4583
+ if (input.notes !== undefined) {
4584
+ sets.push("notes = ?");
4585
+ params.push(input.notes);
4586
+ }
4587
+ if (sets.length) {
4588
+ sets.push("updated_at = ?");
4589
+ params.push(now());
4590
+ params.push(contactId);
4591
+ _db2.query(`UPDATE contact_health SET ${sets.join(", ")} WHERE contact_id = ?`).run(...params);
4592
+ }
4593
+ } else {
4594
+ const id = uuid();
4595
+ _db2.query(`INSERT INTO contact_health (id, contact_id, blood_type, allergies, medical_conditions, medications, emergency_contacts, health_insurance_provider, health_insurance_id, primary_physician, primary_physician_phone, organ_donor, notes, created_at, updated_at) VALUES (?,?,?,?,?,?,?,?,?,?,?,?,?,?,?)`).run(id, contactId, input.blood_type ?? null, JSON.stringify(input.allergies || []), JSON.stringify(input.medical_conditions || []), JSON.stringify(input.medications || []), JSON.stringify(input.emergency_contacts || []), input.health_insurance_provider ?? null, input.health_insurance_id ?? null, input.primary_physician ?? null, input.primary_physician_phone ?? null, input.organ_donor ? 1 : 0, input.notes ?? null, now(), now());
4596
+ }
4597
+ return getHealthData(contactId, _db2);
4598
+ }
4599
+ function getHealthData(contactId, db) {
4600
+ requireVault();
4601
+ const _db2 = db || getDatabase();
4602
+ const row = _db2.query(`SELECT * FROM contact_health WHERE contact_id = ?`).get(contactId);
4603
+ if (!row)
4604
+ return null;
4605
+ return {
4606
+ id: row.id,
4607
+ contact_id: row.contact_id,
4608
+ blood_type: row.blood_type,
4609
+ allergies: JSON.parse(row.allergies || "[]"),
4610
+ medical_conditions: JSON.parse(row.medical_conditions || "[]"),
4611
+ medications: JSON.parse(row.medications || "[]"),
4612
+ emergency_contacts: JSON.parse(row.emergency_contacts || "[]"),
4613
+ health_insurance_provider: row.health_insurance_provider,
4614
+ health_insurance_id: row.health_insurance_id,
4615
+ primary_physician: row.primary_physician,
4616
+ primary_physician_phone: row.primary_physician_phone,
4617
+ organ_donor: !!row.organ_donor,
4618
+ notes: row.notes,
4619
+ created_at: row.created_at,
4620
+ updated_at: row.updated_at
4621
+ };
4622
+ }
4623
+ function deleteHealthData(contactId, db) {
4624
+ const _db2 = db || getDatabase();
4625
+ _db2.query(`DELETE FROM contact_health WHERE contact_id = ?`).run(contactId);
4626
+ }
4627
+
4628
+ // src/lib/document-scanner.ts
4629
+ import { readFileSync as readFileSync4, existsSync as existsSync6 } from "fs";
4630
+ import { extname as extname2 } from "path";
4631
+ async function scanDocument(imageSource, docType) {
4632
+ const apiKey = process.env["OPENAI_API_KEY"];
4633
+ if (!apiKey) {
4634
+ throw new Error("OPENAI_API_KEY not set. Set it in ~/.secrets or environment to use document scanning.");
4635
+ }
4636
+ let imageData;
4637
+ if (imageSource.startsWith("data:image/")) {
4638
+ imageData = imageSource;
4639
+ } else if (existsSync6(imageSource)) {
4640
+ const buffer = readFileSync4(imageSource);
4641
+ const ext = extname2(imageSource).slice(1).toLowerCase();
4642
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/jpeg";
4643
+ imageData = `data:${mime};base64,${buffer.toString("base64")}`;
4644
+ } else if (/^[A-Za-z0-9+/=\n\r]+$/.test(imageSource.trim()) && imageSource.length > 100) {
4645
+ imageData = `data:image/jpeg;base64,${imageSource.trim()}`;
4646
+ } else {
4647
+ throw new Error(`Image source not found or invalid: ${imageSource.slice(0, 50)}...`);
4648
+ }
4649
+ const typeHint = docType ? ` This is a ${docType} document.` : "";
4650
+ const prompt = `Extract all text and structured data from this document image.${typeHint} Return a JSON object with these fields:
4651
+ - document_type: detected type (passport, national_id, drivers_license, tax_document, medical_record, prescription, insurance_card, bank_statement, visa, certificate, contract, other)
4652
+ - full_name: full name as shown
4653
+ - date_of_birth: in YYYY-MM-DD format if visible
4654
+ - document_number: main ID/document number
4655
+ - issuing_country: country code or name
4656
+ - issue_date: in YYYY-MM-DD format if visible
4657
+ - expiry_date: in YYYY-MM-DD format if visible
4658
+ - address: full address if visible
4659
+ - nationality: if visible
4660
+ - gender: if visible
4661
+ - mrz_code: Machine Readable Zone text if this is a passport/ID with MRZ
4662
+ - phone: any phone numbers visible
4663
+ - email: any email addresses visible
4664
+ - additional_fields: object with any other visible structured data
4665
+ - raw_text: all visible text transcribed
4666
+
4667
+ Only include fields that are actually visible in the document. Return valid JSON only.`;
4668
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
4669
+ method: "POST",
4670
+ headers: {
4671
+ Authorization: `Bearer ${apiKey}`,
4672
+ "Content-Type": "application/json"
4673
+ },
4674
+ body: JSON.stringify({
4675
+ model: "gpt-4o",
4676
+ messages: [
4677
+ {
4678
+ role: "user",
4679
+ content: [
4680
+ { type: "text", text: prompt },
4681
+ { type: "image_url", image_url: { url: imageData, detail: "high" } }
4682
+ ]
4683
+ }
4684
+ ],
4685
+ max_tokens: 2000,
4686
+ temperature: 0
4687
+ })
4688
+ });
4689
+ if (!response.ok) {
4690
+ const err = await response.text();
4691
+ throw new Error(`OpenAI API error: ${response.status} \u2014 ${err}`);
4692
+ }
4693
+ const data = await response.json();
4694
+ const content = data.choices?.[0]?.message?.content || "";
4695
+ const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
4696
+ if (!jsonMatch) {
4697
+ return { fields: {}, raw_text: content, document_type: docType || "unknown", confidence: 0.3 };
4698
+ }
4699
+ try {
4700
+ const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
4701
+ const { document_type, raw_text, additional_fields, ...mainFields } = parsed;
4702
+ const fields = {};
4703
+ for (const [k, v] of Object.entries(mainFields)) {
4704
+ if (v && typeof v === "string")
4705
+ fields[k] = v;
4706
+ }
4707
+ if (additional_fields && typeof additional_fields === "object") {
4708
+ for (const [k, v] of Object.entries(additional_fields)) {
4709
+ if (v && typeof v === "string")
4710
+ fields[k] = v;
4711
+ }
4712
+ }
4713
+ return {
4714
+ fields,
4715
+ raw_text: raw_text || content,
4716
+ document_type: document_type || docType || "unknown",
4717
+ confidence: Object.keys(fields).length > 3 ? 0.9 : Object.keys(fields).length > 0 ? 0.7 : 0.3
4718
+ };
4719
+ } catch {
4720
+ return { fields: {}, raw_text: content, document_type: docType || "unknown", confidence: 0.3 };
4721
+ }
4722
+ }
4723
+
4724
+ // src/mcp/index.ts
4725
+ var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
3360
4726
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
3361
4727
  tools: [
3362
4728
  {
@@ -3433,7 +4799,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3433
4799
  }
3434
4800
  },
3435
4801
  tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to assign" },
3436
- source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
4802
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
4803
+ sensitivity: { type: "string", enum: ["normal", "confidential", "restricted"], description: "Contact sensitivity level (default: normal)" }
3437
4804
  }
3438
4805
  }
3439
4806
  },
@@ -3469,6 +4836,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3469
4836
  project_id: { type: "string", description: "Primary project ID (single, null to clear)" },
3470
4837
  project_ids: { type: "array", items: { type: "string" }, description: "Replace all project links with this array of todos project IDs" },
3471
4838
  source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
4839
+ sensitivity: { type: "string", enum: ["normal", "confidential", "restricted"] },
3472
4840
  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)" },
3473
4841
  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)" }
3474
4842
  },
@@ -3497,6 +4865,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
3497
4865
  status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
3498
4866
  project_id: { type: "string", description: "Filter by project ID" },
3499
4867
  archived: { type: "boolean", description: "Include archived contacts (default false)" },
4868
+ include_restricted: { type: "boolean", description: "Include restricted-sensitivity contacts (default false)" },
3500
4869
  follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
3501
4870
  last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
3502
4871
  last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
@@ -4773,7 +6142,69 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4773
6142
  },
4774
6143
  required: ["contact_id", "do_not_contact"]
4775
6144
  }
4776
- }
6145
+ },
6146
+ { name: "get_field_history", description: "Get the change history for one or all fields of a contact (temporal audit trail).", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, field_name: { type: "string", description: "Optional \u2014 filter to a single field" } }, required: ["contact_id"] } },
6147
+ { name: "get_contact_at", description: "Reconstruct a contact's profile as it was at a specific point in time, using field history.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, timestamp: { type: "string", description: "ISO 8601 datetime \u2014 reconstruct profile at this point in time" } }, required: ["contact_id", "timestamp"] } },
6148
+ { name: "get_job_history", description: "Get the employment timeline for a contact \u2014 all past and current job entries in reverse chronological order.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6149
+ { name: "add_job_entry", description: "Add a job history entry to a contact's employment timeline.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, company_name: { type: "string" }, title: { type: "string" }, start_date: { type: "string" }, end_date: { type: "string" }, is_current: { type: "boolean" } }, required: ["contact_id", "company_name"] } },
6150
+ { name: "save_learning", description: "Save a structured learning about a contact \u2014 preferences, facts, inferences, warnings, or signals. Include confidence (0-100) and importance (1-10).", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, content: { type: "string" }, type: { type: "string", enum: ["preference", "fact", "inference", "warning", "signal"] }, confidence: { type: "number" }, importance: { type: "number" }, learned_by: { type: "string" }, visibility: { type: "string", enum: ["private", "shared", "human"] }, tags: { type: "array", items: { type: "string" } } }, required: ["contact_id", "content"] } },
6151
+ { name: "get_learnings", description: "Get all learnings for a contact, optionally filtered by type and minimum importance.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, type: { type: "string", enum: ["preference", "fact", "inference", "warning", "signal"] }, min_importance: { type: "number" } }, required: ["contact_id"] } },
6152
+ { name: "search_learnings", description: "Cross-contact search across all learnings for a keyword or phrase.", inputSchema: { type: "object", properties: { query: { type: "string" }, type: { type: "string" }, contact_id: { type: "string", description: "Optional \u2014 limit to a specific contact" } }, required: ["query"] } },
6153
+ { name: "confirm_learning", description: "Confirm a learning as correct, boosting its confidence score.", inputSchema: { type: "object", properties: { learning_id: { type: "string" }, agent_name: { type: "string" } }, required: ["learning_id", "agent_name"] } },
6154
+ { name: "get_stale_learnings", description: "Find learnings that haven't been confirmed recently and may need review.", inputSchema: { type: "object", properties: { days_old: { type: "number" }, min_confidence: { type: "number" } } } },
6155
+ { name: "run_learning_maintenance", description: "Run decay (reduce confidence on old unconfirmed learnings) and contradiction detection across all learnings.", inputSchema: { type: "object", properties: {} } },
6156
+ { name: "acquire_contact_lock", description: "Acquire a write lock on a contact to prevent conflicts when multiple agents edit the same record. Returns {acquired, lock, held_by}.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, agent_name: { type: "string" }, ttl_seconds: { type: "number" }, reason: { type: "string" }, session_id: { type: "string" } }, required: ["contact_id", "agent_name"] } },
6157
+ { name: "release_contact_lock", description: "Release a contact write lock previously acquired by this agent.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, agent_name: { type: "string" } }, required: ["contact_id", "agent_name"] } },
6158
+ { name: "check_contact_lock", description: "Check if a contact is currently locked by any agent.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6159
+ { name: "log_agent_activity", description: "Log an agent action against a contact for audit/coordination purposes.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, agent_name: { type: "string" }, action: { type: "string" }, details: { type: "string" }, session_id: { type: "string" } }, required: ["contact_id", "agent_name", "action"] } },
6160
+ { name: "get_contact_agent_activity", description: "Get the recent agent activity log for a contact.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, limit: { type: "number" } }, required: ["contact_id"] } },
6161
+ { name: "get_relationship_strength", description: "Compute and return the relationship strength score (0-100) for a contact based on interaction frequency and recency.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6162
+ { name: "find_warm_path", description: "Find the shortest warm introduction path between two contacts through the relationship graph.", inputSchema: { type: "object", properties: { from_contact_id: { type: "string" }, to_contact_id: { type: "string" } }, required: ["from_contact_id", "to_contact_id"] } },
6163
+ { name: "find_connections_at_company", description: "Find all contacts linked to a specific company, with relationship strength scores.", inputSchema: { type: "object", properties: { company_id: { type: "string" } }, required: ["company_id"] } },
6164
+ { name: "get_cooling_relationships", description: "Get all relationships that are cooling (no contact in 45+ days) \u2014 use to prioritize re-engagement outreach.", inputSchema: { type: "object", properties: {} } },
6165
+ { name: "resolve_contact_identity", description: "Resolve a contact's identity from partial signals (email, name, LinkedIn URL, phone, or external system ID). Returns ranked matches with confidence scores.", inputSchema: { type: "object", properties: { email: { type: "string" }, name: { type: "string" }, linkedin_url: { type: "string" }, phone: { type: "string" }, system: { type: "string" }, external_id: { type: "string" } } } },
6166
+ { name: "add_contact_identity", description: "Register an external system identity (e.g. Salesforce ID, LinkedIn URL) for a contact.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, system: { type: "string" }, external_id: { type: "string" }, external_url: { type: "string" }, confidence: { type: "string", enum: ["verified", "inferred"] } }, required: ["contact_id", "system", "external_id"] } },
6167
+ { name: "get_contact_identities", description: "Get all registered external system identities for a contact.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6168
+ { name: "semantic_search_contacts", description: "Search contacts by capability or context using TF-IDF semantic similarity \u2014 finds contacts based on meaning, not just keyword match.", inputSchema: { type: "object", properties: { query: { type: "string" }, limit: { type: "number" } }, required: ["query"] } },
6169
+ { name: "embed_all_contacts", description: "Build TF-IDF embeddings for all contacts in the database \u2014 run once to enable semantic_search_contacts.", inputSchema: { type: "object", properties: {} } },
6170
+ { name: "get_relationship_signals", description: "Get relationship health signals for a contact: warming/cooling/ghost/healthy status with reasons.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6171
+ { name: "get_ghost_contacts", description: "List contacts you haven't been in touch with for 180+ days \u2014 relationships at risk of becoming permanently cold.", inputSchema: { type: "object", properties: {} } },
6172
+ { name: "get_warming_contacts", description: "List contacts with rising interaction frequency \u2014 relationships gaining momentum.", inputSchema: { type: "object", properties: {} } },
6173
+ { name: "recompute_signals", description: "Recompute engagement_status for all contacts based on interaction counts and recency.", inputSchema: { type: "object", properties: {} } },
6174
+ { name: "get_contact_card", description: "Get a minimal ~50-token contact summary: name, title, company, primary email and phone. Ideal for lists and agent context injection.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6175
+ { name: "assemble_context", description: "Assemble a multi-contact context package for meetings, deals, outreach, or research. Returns task-relevant briefs for each contact.", inputSchema: { type: "object", properties: { contact_ids: { type: "array", items: { type: "string" } }, format: { type: "string", enum: ["meeting_prep", "deal_review", "outreach", "research"] } }, required: ["contact_ids"] } },
6176
+ { name: "parse_email_signature", description: "Parse an email signature text to extract contact fields (name, title, company, phone, email, LinkedIn, website). Does NOT create a contact.", inputSchema: { type: "object", properties: { signature_text: { type: "string" } }, required: ["signature_text"] } },
6177
+ { name: "ingest_email_participants", description: "Find or create contacts from email thread participants (with optional signatures). Returns { created, updated, contacts }.", inputSchema: { type: "object", properties: { participants: { type: "array", items: { type: "object", properties: { name: { type: "string" }, email: { type: "string" }, signature: { type: "string" } }, required: ["email"] } }, context: { type: "string" } }, required: ["participants"] } },
6178
+ { name: "ingest_meeting_participants", description: "Ingest meeting attendees: find-or-create contacts and log the meeting as an event. Returns { created, updated, contact_ids }.", inputSchema: { type: "object", properties: { title: { type: "string" }, event_date: { type: "string" }, attendees: { type: "array", items: { type: "object", properties: { name: { type: "string" }, email: { type: "string" } }, required: ["name", "email"] } }, context: { type: "string" } }, required: ["title", "event_date", "attendees"] } },
6179
+ { name: "get_freshness_score", description: "Get a per-field freshness and confidence breakdown for a contact \u2014 shows which fields are verified, stale, or missing.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6180
+ { name: "get_stale_contacts", description: "List contacts with low data completeness scores (below threshold). Default threshold: 40.", inputSchema: { type: "object", properties: { threshold: { type: "number", description: "Score threshold 0-100 (default 40)" } } } },
6181
+ { name: "mark_field_verified", description: "Mark a specific contact field as verified by a human or trusted source.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, field_name: { type: "string" }, source: { type: "string" } }, required: ["contact_id", "field_name"] } },
6182
+ { name: "add_org_chart_edge", description: "Add a relationship edge to the org chart for a company (reports_to, manages, peer, collaborates_with).", inputSchema: { type: "object", properties: { company_id: { type: "string" }, contact_a_id: { type: "string" }, contact_b_id: { type: "string" }, edge_type: { type: "string", enum: ["reports_to", "manages", "collaborates_with", "peer"] } }, required: ["company_id", "contact_a_id", "contact_b_id", "edge_type"] } },
6183
+ { name: "get_org_chart", description: "Get the org chart for a company as a list of directed edges with contact names.", inputSchema: { type: "object", properties: { company_id: { type: "string" } }, required: ["company_id"] } },
6184
+ { name: "set_deal_contact_role", description: "Assign a contact a buying committee role in a deal (economic_buyer, technical_evaluator, champion, blocker, influencer, user, sponsor, other).", inputSchema: { type: "object", properties: { deal_id: { type: "string" }, contact_id: { type: "string" }, account_role: { type: "string", enum: ["economic_buyer", "technical_evaluator", "champion", "blocker", "influencer", "user", "sponsor", "other"] } }, required: ["deal_id", "contact_id", "account_role"] } },
6185
+ { name: "get_deal_team", description: "Get the full buying committee for a deal with contact names and roles.", inputSchema: { type: "object", properties: { deal_id: { type: "string" } }, required: ["deal_id"] } },
6186
+ { name: "get_coverage_gaps", description: "Identify coverage gaps in a company account \u2014 missing economic buyer, technical evaluator, or org chart relationships.", inputSchema: { type: "object", properties: { company_id: { type: "string" } }, required: ["company_id"] } },
6187
+ { name: "get_recent_contact_events", description: "Polling fallback for change events \u2014 returns recent activity log entries, optionally filtered by event type or date.", inputSchema: { type: "object", properties: { since: { type: "string", description: "ISO 8601 datetime \u2014 only events after this date" }, event_types: { type: "array", items: { type: "string" } } } } },
6188
+ { name: "set_contact_photo", description: "Set a contact's profile photo. Provide either a local file path or base64-encoded image data (with or without data URI prefix). Stores image in ~/.hasna/contacts/images/ and updates avatar_url. Supported formats: jpg, png, gif, webp, svg, avif.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, image: { type: "string", description: "File path (e.g. /tmp/photo.jpg) OR base64 data (e.g. data:image/png;base64,...) OR raw base64 string" }, format: { type: "string", description: "Image format hint when using raw base64 (jpg, png, webp). Not needed for file paths or data URIs." } }, required: ["contact_id", "image"] } },
6189
+ { name: "get_contact_photo", description: "Get a contact's profile photo as base64 data URI. Returns null if no photo is set.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6190
+ { name: "delete_contact_photo", description: "Remove a contact's profile photo.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6191
+ { name: "set_company_logo", description: "Set a company's logo image. Provide either a local file path or base64-encoded image data. Stores image in ~/.hasna/contacts/images/ and updates logo_url.", inputSchema: { type: "object", properties: { company_id: { type: "string" }, image: { type: "string", description: "File path or base64 data" }, format: { type: "string", description: "Image format hint for raw base64" } }, required: ["company_id", "image"] } },
6192
+ { name: "get_company_logo", description: "Get a company's logo as base64 data URI.", inputSchema: { type: "object", properties: { company_id: { type: "string" } }, required: ["company_id"] } },
6193
+ { name: "delete_company_logo", description: "Remove a company's logo image.", inputSchema: { type: "object", properties: { company_id: { type: "string" } }, required: ["company_id"] } },
6194
+ { name: "set_sensitivity", description: "Set a contact's sensitivity level (normal, confidential, restricted). Restricted contacts are hidden from list/search unless explicitly requested.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, sensitivity: { type: "string", enum: ["normal", "confidential", "restricted"] } }, required: ["contact_id", "sensitivity"] } },
6195
+ { name: "vault_init", description: "Initialize the encrypted document vault with a passphrase. Must be called before storing documents or health data.", inputSchema: { type: "object", properties: { passphrase: { type: "string" } }, required: ["passphrase"] } },
6196
+ { name: "vault_unlock", description: "Unlock the vault for this session with a passphrase.", inputSchema: { type: "object", properties: { passphrase: { type: "string" } }, required: ["passphrase"] } },
6197
+ { name: "vault_lock", description: "Lock the vault, clearing the encryption key from memory.", inputSchema: { type: "object", properties: {} } },
6198
+ { name: "vault_status", description: "Check vault initialization and lock status.", inputSchema: { type: "object", properties: {} } },
6199
+ { name: "add_document", description: "Store a document for a contact (passport, tax_id, medical_record, etc.). Text values are encrypted; file attachments are stored plain so agents can read them. Vault must be unlocked.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, doc_type: { type: "string", enum: [...DOCUMENT_TYPES] }, label: { type: "string" }, value: { type: "string", description: "Plaintext value (will be encrypted in DB)" }, file_path: { type: "string", description: "File to attach \u2014 stored PLAIN in ~/.hasna/contacts/documents/ for agent access" }, metadata: { type: "object" }, expires_at: { type: "string" } }, required: ["contact_id", "doc_type", "value"] } },
6200
+ { name: "list_documents", description: "List documents for a contact (metadata only \u2014 no decryption needed). Returns file_path for attachments so agents can read them directly.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6201
+ { name: "get_document", description: "Get a document with decrypted value and file_path. Vault must be unlocked for the text value; file is always accessible.", inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] } },
6202
+ { name: "get_document_file", description: "Get the plain file path for a document attachment. Agents can read this file directly \u2014 it is NOT encrypted. Returns null if no file attached.", inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] } },
6203
+ { name: "delete_document", description: "Delete a document and its file attachment.", inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] } },
6204
+ { name: "scan_document", description: "Scan a document image using AI vision (OpenAI GPT-4o) to extract structured data. Optionally auto-save to vault.", inputSchema: { type: "object", properties: { image: { type: "string", description: "File path or base64 image data" }, doc_type: { type: "string", description: "Hint: passport, national_id, drivers_license, etc." }, contact_id: { type: "string", description: "Contact to associate scanned document with" }, auto_save: { type: "boolean", description: "Automatically save extracted data as a vault document" } }, required: ["image"] } },
6205
+ { name: "set_health_data", description: "Set or update health data for a contact. Vault must be unlocked.", inputSchema: { type: "object", properties: { contact_id: { type: "string" }, blood_type: { type: "string" }, allergies: { type: "array", items: { type: "string" } }, medical_conditions: { type: "array", items: { type: "string" } }, medications: { type: "array", items: { type: "string" } }, emergency_contacts: { type: "array", items: { type: "object", properties: { name: { type: "string" }, phone: { type: "string" }, relationship: { type: "string" } }, required: ["name", "phone", "relationship"] } }, health_insurance_provider: { type: "string" }, health_insurance_id: { type: "string" }, primary_physician: { type: "string" }, primary_physician_phone: { type: "string" }, organ_donor: { type: "boolean" }, notes: { type: "string" } }, required: ["contact_id"] } },
6206
+ { name: "get_health_data", description: "Get health data for a contact. Vault must be unlocked.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6207
+ { name: "delete_health_data", description: "Delete all health data for a contact.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } }
4777
6208
  ]
4778
6209
  }));
4779
6210
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -4802,7 +6233,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4802
6233
  addresses: a.addresses,
4803
6234
  social_profiles: a.social_profiles,
4804
6235
  tag_ids: a.tag_ids,
4805
- source: a.source
6236
+ source: a.source,
6237
+ sensitivity: a.sensitivity
4806
6238
  };
4807
6239
  const contact = createContact(input);
4808
6240
  if (Array.isArray(a.project_ids) && a.project_ids.length > 0) {
@@ -4837,6 +6269,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4837
6269
  follow_up_at: rest.follow_up_at,
4838
6270
  project_id: rest.project_id,
4839
6271
  source: rest.source,
6272
+ sensitivity: rest.sensitivity,
4840
6273
  emails_add: rest.emails_add,
4841
6274
  phones_add: rest.phones_add
4842
6275
  };
@@ -4860,6 +6293,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
4860
6293
  status: a.status,
4861
6294
  project_id: a.project_id,
4862
6295
  archived: a.archived,
6296
+ include_restricted: a.include_restricted,
4863
6297
  follow_up_due: a.follow_up_due,
4864
6298
  last_contacted_after: a.last_contacted_after,
4865
6299
  last_contacted_before: a.last_contacted_before,
@@ -5720,11 +7154,6 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5720
7154
  const team = listCompanyRelationships({ company_id: a.company_id }, db);
5721
7155
  return { content: [{ type: "text", text: JSON.stringify({ company, team }, null, 2) }] };
5722
7156
  }
5723
- case "get_contact_brief": {
5724
- const db = getDatabase();
5725
- const brief = generateBrief(a.contact_id, db);
5726
- return { content: [{ type: "text", text: JSON.stringify({ brief }, null, 2) }] };
5727
- }
5728
7157
  case "list_cold_contacts": {
5729
7158
  const db = getDatabase();
5730
7159
  const contacts = listColdContacts(a.days ?? 30, db);
@@ -5972,6 +7401,447 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5972
7401
  const output = await exportContacts(format, contactList);
5973
7402
  return { content: [{ type: "text", text: output }] };
5974
7403
  }
7404
+ case "get_field_history": {
7405
+ const db = getDatabase();
7406
+ const history = getFieldHistory(a.contact_id, a.field_name, db);
7407
+ return { content: [{ type: "text", text: JSON.stringify({ history }, null, 2) }] };
7408
+ }
7409
+ case "get_contact_at": {
7410
+ const db = getDatabase();
7411
+ const snapshot = getContactAt(a.contact_id, a.timestamp, db);
7412
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id: a.contact_id, timestamp: a.timestamp, snapshot }, null, 2) }] };
7413
+ }
7414
+ case "get_job_history": {
7415
+ const db = getDatabase();
7416
+ const history = getJobHistory(a.contact_id, db);
7417
+ return { content: [{ type: "text", text: JSON.stringify({ history }, null, 2) }] };
7418
+ }
7419
+ case "add_job_entry": {
7420
+ const db = getDatabase();
7421
+ const entry = addJobEntry(a.contact_id, {
7422
+ company_name: a.company_name,
7423
+ title: a.title,
7424
+ start_date: a.start_date,
7425
+ end_date: a.end_date,
7426
+ is_current: a.is_current
7427
+ }, db);
7428
+ return { content: [{ type: "text", text: JSON.stringify(entry, null, 2) }] };
7429
+ }
7430
+ case "save_learning": {
7431
+ const db = getDatabase();
7432
+ const input = {
7433
+ content: a.content,
7434
+ type: a.type,
7435
+ confidence: a.confidence,
7436
+ importance: a.importance,
7437
+ learned_by: a.learned_by,
7438
+ visibility: a.visibility,
7439
+ tags: a.tags
7440
+ };
7441
+ const learning = saveLearning(a.contact_id, input, db);
7442
+ return { content: [{ type: "text", text: JSON.stringify(learning, null, 2) }] };
7443
+ }
7444
+ case "get_learnings": {
7445
+ const db = getDatabase();
7446
+ const learnings = getLearnings(a.contact_id, {
7447
+ type: a.type,
7448
+ min_importance: a.min_importance
7449
+ }, db);
7450
+ return { content: [{ type: "text", text: JSON.stringify({ learnings }, null, 2) }] };
7451
+ }
7452
+ case "search_learnings": {
7453
+ const db = getDatabase();
7454
+ const results = searchLearnings(a.query, {
7455
+ type: a.type,
7456
+ contact_id: a.contact_id
7457
+ }, db);
7458
+ return { content: [{ type: "text", text: JSON.stringify({ results }, null, 2) }] };
7459
+ }
7460
+ case "confirm_learning": {
7461
+ const db = getDatabase();
7462
+ confirmLearning(a.learning_id, a.agent_name, db);
7463
+ return { content: [{ type: "text", text: JSON.stringify({ confirmed: true }) }] };
7464
+ }
7465
+ case "get_stale_learnings": {
7466
+ const db = getDatabase();
7467
+ const daysOld = a.days_old ?? 30;
7468
+ const minConf = a.min_confidence ?? 0;
7469
+ const cutoff = new Date(Date.now() - daysOld * 86400000).toISOString();
7470
+ const rows = db.query(`SELECT * FROM contact_learnings WHERE confirmed_count=0 AND created_at<? AND confidence>=? ORDER BY confidence ASC LIMIT 50`).all(cutoff, minConf);
7471
+ return { content: [{ type: "text", text: JSON.stringify({ stale_learnings: rows }, null, 2) }] };
7472
+ }
7473
+ case "run_learning_maintenance": {
7474
+ const db = getDatabase();
7475
+ const decayed = decayLearnings(db);
7476
+ const duplicates = db.query(`SELECT contact_id, COUNT(*) as cnt FROM contact_learnings GROUP BY contact_id, LOWER(SUBSTR(content,1,30)) HAVING cnt > 1`).all();
7477
+ return { content: [{ type: "text", text: JSON.stringify({ decayed_count: decayed, potential_contradictions: duplicates }, null, 2) }] };
7478
+ }
7479
+ case "acquire_contact_lock": {
7480
+ const db = getDatabase();
7481
+ const result = acquireLock(a.contact_id, a.agent_name, a.ttl_seconds, a.reason, a.session_id, db);
7482
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7483
+ }
7484
+ case "release_contact_lock": {
7485
+ const db = getDatabase();
7486
+ const released = releaseLock(a.contact_id, a.agent_name, db);
7487
+ return { content: [{ type: "text", text: JSON.stringify({ released }, null, 2) }] };
7488
+ }
7489
+ case "check_contact_lock": {
7490
+ const db = getDatabase();
7491
+ const lock = checkLock(a.contact_id, db);
7492
+ return { content: [{ type: "text", text: JSON.stringify({ locked: !!lock, lock }, null, 2) }] };
7493
+ }
7494
+ case "log_agent_activity": {
7495
+ const db = getDatabase();
7496
+ logAgentActivity(a.contact_id, a.agent_name, a.action, a.details, a.session_id, db);
7497
+ return { content: [{ type: "text", text: JSON.stringify({ logged: true }) }] };
7498
+ }
7499
+ case "get_contact_agent_activity": {
7500
+ const db = getDatabase();
7501
+ const activity = getAgentActivity(a.contact_id, a.limit ?? 20, db);
7502
+ return { content: [{ type: "text", text: JSON.stringify({ activity }, null, 2) }] };
7503
+ }
7504
+ case "get_relationship_strength": {
7505
+ const db = getDatabase();
7506
+ const score = computeRelationshipStrength(a.contact_id, db);
7507
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id: a.contact_id, strength_score: score }, null, 2) }] };
7508
+ }
7509
+ case "find_warm_path": {
7510
+ const db = getDatabase();
7511
+ const path = findWarmPath(a.from_contact_id, a.to_contact_id, db);
7512
+ return { content: [{ type: "text", text: JSON.stringify({ path, hops: path.length }, null, 2) }] };
7513
+ }
7514
+ case "find_connections_at_company": {
7515
+ const db = getDatabase();
7516
+ const connections = findConnectionsAtCompany(a.company_id, db);
7517
+ return { content: [{ type: "text", text: JSON.stringify({ connections }, null, 2) }] };
7518
+ }
7519
+ case "get_cooling_relationships": {
7520
+ const db = getDatabase();
7521
+ const cooling = detectCoolingRelationships(db);
7522
+ return { content: [{ type: "text", text: JSON.stringify({ cooling }, null, 2) }] };
7523
+ }
7524
+ case "resolve_contact_identity": {
7525
+ const db = getDatabase();
7526
+ const matches = resolveByPartial({
7527
+ email: a.email,
7528
+ name: a.name,
7529
+ linkedin_url: a.linkedin_url,
7530
+ phone: a.phone
7531
+ }, db);
7532
+ return { content: [{ type: "text", text: JSON.stringify({ matches }, null, 2) }] };
7533
+ }
7534
+ case "add_contact_identity": {
7535
+ const db = getDatabase();
7536
+ const identity = addIdentity(a.contact_id, a.system, a.external_id, a.external_url, a.confidence ?? "inferred", db);
7537
+ return { content: [{ type: "text", text: JSON.stringify(identity, null, 2) }] };
7538
+ }
7539
+ case "get_contact_identities": {
7540
+ const db = getDatabase();
7541
+ const identities = getIdentities(a.contact_id, db);
7542
+ return { content: [{ type: "text", text: JSON.stringify({ identities }, null, 2) }] };
7543
+ }
7544
+ case "semantic_search_contacts": {
7545
+ const db = getDatabase();
7546
+ const results = semanticSearch(a.query, a.limit ?? 10, db);
7547
+ const enriched = results.map((r) => {
7548
+ try {
7549
+ return { ...r, contact: getContact(r.contact_id) };
7550
+ } catch {
7551
+ return r;
7552
+ }
7553
+ });
7554
+ return { content: [{ type: "text", text: JSON.stringify({ results: enriched }, null, 2) }] };
7555
+ }
7556
+ case "embed_all_contacts": {
7557
+ const db = getDatabase();
7558
+ const count = await embedAllContacts(db);
7559
+ return { content: [{ type: "text", text: JSON.stringify({ embedded: count }) }] };
7560
+ }
7561
+ case "get_relationship_signals": {
7562
+ const db = getDatabase();
7563
+ const signals = getRelationshipSignals(a.contact_id, db);
7564
+ return { content: [{ type: "text", text: JSON.stringify({ signals }, null, 2) }] };
7565
+ }
7566
+ case "get_ghost_contacts": {
7567
+ const db = getDatabase();
7568
+ const ghosts = getGhostContacts(db);
7569
+ return { content: [{ type: "text", text: JSON.stringify({ ghosts }, null, 2) }] };
7570
+ }
7571
+ case "get_warming_contacts": {
7572
+ const db = getDatabase();
7573
+ const warming = getWarmingContacts(db);
7574
+ return { content: [{ type: "text", text: JSON.stringify({ warming }, null, 2) }] };
7575
+ }
7576
+ case "recompute_signals": {
7577
+ const db = getDatabase();
7578
+ const result = recomputeAllSignals(db);
7579
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7580
+ }
7581
+ case "get_contact_card": {
7582
+ const db = getDatabase();
7583
+ const card = getContactCard(a.contact_id, db);
7584
+ return { content: [{ type: "text", text: JSON.stringify(card, null, 2) }] };
7585
+ }
7586
+ case "get_contact_brief": {
7587
+ const db = getDatabase();
7588
+ const taskContext = a.task_context ?? a.format;
7589
+ if (taskContext) {
7590
+ const brief2 = getContactBrief(a.contact_id, taskContext, db);
7591
+ return { content: [{ type: "text", text: JSON.stringify(brief2, null, 2) }] };
7592
+ }
7593
+ const brief = generateBrief(a.contact_id, db);
7594
+ return { content: [{ type: "text", text: JSON.stringify({ brief }, null, 2) }] };
7595
+ }
7596
+ case "assemble_context": {
7597
+ const db = getDatabase();
7598
+ const ctx = await assembleContext(a.contact_ids, a.format ?? "meeting_prep", db);
7599
+ return { content: [{ type: "text", text: JSON.stringify(ctx, null, 2) }] };
7600
+ }
7601
+ case "parse_email_signature": {
7602
+ const parsed = parseEmailSignature(a.signature_text);
7603
+ return { content: [{ type: "text", text: JSON.stringify(parsed, null, 2) }] };
7604
+ }
7605
+ case "ingest_email_participants": {
7606
+ const db = getDatabase();
7607
+ const participants = a.participants;
7608
+ const extracted = extractContactsFromEmailThread(participants);
7609
+ let created = 0;
7610
+ let updated = 0;
7611
+ const contacts = [];
7612
+ const { findOrCreateContact: findOrCreate } = await Promise.resolve().then(() => (init_contacts(), exports_contacts));
7613
+ for (const ci of extracted) {
7614
+ try {
7615
+ const result = await findOrCreate({
7616
+ display_name: ci.display_name,
7617
+ job_title: ci.job_title,
7618
+ website: ci.website,
7619
+ emails: ci.emails?.map((e) => ({ address: e.address, type: e.type, is_primary: e.is_primary })),
7620
+ phones: ci.phones?.map((p) => ({ number: p.number, type: p.type, is_primary: p.is_primary })),
7621
+ social_profiles: ci.social_profiles?.map((s) => ({ platform: "linkedin", url: s.url, is_primary: s.is_primary })),
7622
+ source: "import"
7623
+ }, db);
7624
+ contacts.push(result.contact);
7625
+ if (result.created)
7626
+ created++;
7627
+ else
7628
+ updated++;
7629
+ } catch {}
7630
+ }
7631
+ return { content: [{ type: "text", text: JSON.stringify({ created, updated, contacts }, null, 2) }] };
7632
+ }
7633
+ case "ingest_meeting_participants": {
7634
+ const db = getDatabase();
7635
+ const result = await ingestMeetingParticipants({
7636
+ title: a.title,
7637
+ event_date: a.event_date,
7638
+ attendees: a.attendees,
7639
+ context: a.context
7640
+ }, db);
7641
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7642
+ }
7643
+ case "get_freshness_score": {
7644
+ const db = getDatabase();
7645
+ const score = getFreshnessScore(a.contact_id, db);
7646
+ return { content: [{ type: "text", text: JSON.stringify(score, null, 2) }] };
7647
+ }
7648
+ case "get_stale_contacts": {
7649
+ const db = getDatabase();
7650
+ const contacts = getStaleContacts(a.threshold ?? 40, db);
7651
+ return { content: [{ type: "text", text: JSON.stringify({ contacts }, null, 2) }] };
7652
+ }
7653
+ case "mark_field_verified": {
7654
+ const db = getDatabase();
7655
+ markFieldVerified(a.contact_id, a.field_name, a.source, db);
7656
+ return { content: [{ type: "text", text: JSON.stringify({ verified: true }) }] };
7657
+ }
7658
+ case "add_org_chart_edge": {
7659
+ const db = getDatabase();
7660
+ const edge = addOrgChartEdge(a.company_id, a.contact_a_id, a.contact_b_id, a.edge_type, false, db);
7661
+ return { content: [{ type: "text", text: JSON.stringify(edge, null, 2) }] };
7662
+ }
7663
+ case "get_org_chart": {
7664
+ const db = getDatabase();
7665
+ const edges = listOrgChart(a.company_id, db);
7666
+ return { content: [{ type: "text", text: JSON.stringify({ company_id: a.company_id, edges }, null, 2) }] };
7667
+ }
7668
+ case "set_deal_contact_role": {
7669
+ const db = getDatabase();
7670
+ const role = setDealContactRole(a.deal_id, a.contact_id, a.account_role, db);
7671
+ return { content: [{ type: "text", text: JSON.stringify(role, null, 2) }] };
7672
+ }
7673
+ case "get_deal_team": {
7674
+ const db = getDatabase();
7675
+ const team = getDealTeam(a.deal_id, db);
7676
+ return { content: [{ type: "text", text: JSON.stringify({ deal_id: a.deal_id, team }, null, 2) }] };
7677
+ }
7678
+ case "get_coverage_gaps": {
7679
+ const db = getDatabase();
7680
+ const gaps = getCoverageGaps(a.company_id, db);
7681
+ return { content: [{ type: "text", text: JSON.stringify(gaps, null, 2) }] };
7682
+ }
7683
+ case "get_recent_contact_events": {
7684
+ const db = getDatabase();
7685
+ const since = a.since;
7686
+ const eventTypes = a.event_types;
7687
+ let sql = `SELECT * FROM activity_log WHERE 1=1`;
7688
+ const params = [];
7689
+ if (since) {
7690
+ sql += ` AND created_at >= ?`;
7691
+ params.push(since);
7692
+ }
7693
+ if (eventTypes?.length) {
7694
+ sql += ` AND action IN (${eventTypes.map(() => "?").join(",")})`;
7695
+ params.push(...eventTypes);
7696
+ }
7697
+ sql += ` ORDER BY created_at DESC LIMIT 100`;
7698
+ const events = db.query(sql).all(...params);
7699
+ return { content: [{ type: "text", text: JSON.stringify({ events }, null, 2) }] };
7700
+ }
7701
+ case "set_contact_photo": {
7702
+ const { contact_id, image, format } = a;
7703
+ const contact = getContact(contact_id);
7704
+ const filename = saveImage(contact_id, image, { format });
7705
+ updateContact(contact_id, { avatar_url: `~/.hasna/contacts/images/${filename}` });
7706
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, contact_id, filename, avatar_url: `~/.hasna/contacts/images/${filename}` }) }] };
7707
+ }
7708
+ case "get_contact_photo": {
7709
+ const { contact_id } = a;
7710
+ const dataUri = getImageAsBase64(contact_id);
7711
+ if (!dataUri)
7712
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id, has_photo: false, data: null }) }] };
7713
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id, has_photo: true, data: dataUri }) }] };
7714
+ }
7715
+ case "delete_contact_photo": {
7716
+ const { contact_id } = a;
7717
+ const deleted = deleteImage(contact_id);
7718
+ if (deleted)
7719
+ updateContact(contact_id, { avatar_url: null });
7720
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted }) }] };
7721
+ }
7722
+ case "set_company_logo": {
7723
+ const { company_id, image, format } = a;
7724
+ const co = getCompany(company_id);
7725
+ const filename = saveImage(company_id, image, { format });
7726
+ updateCompany(company_id, { logo_url: `~/.hasna/contacts/images/${filename}` });
7727
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, company_id, filename, logo_url: `~/.hasna/contacts/images/${filename}` }) }] };
7728
+ }
7729
+ case "get_company_logo": {
7730
+ const { company_id } = a;
7731
+ const dataUri = getImageAsBase64(company_id);
7732
+ if (!dataUri)
7733
+ return { content: [{ type: "text", text: JSON.stringify({ company_id, has_logo: false, data: null }) }] };
7734
+ return { content: [{ type: "text", text: JSON.stringify({ company_id, has_logo: true, data: dataUri }) }] };
7735
+ }
7736
+ case "delete_company_logo": {
7737
+ const { company_id } = a;
7738
+ const deleted = deleteImage(company_id);
7739
+ if (deleted)
7740
+ updateCompany(company_id, { logo_url: null });
7741
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted }) }] };
7742
+ }
7743
+ case "set_sensitivity": {
7744
+ const contact = updateContact(a.contact_id, { sensitivity: a.sensitivity });
7745
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, contact_id: a.contact_id, sensitivity: a.sensitivity }) }] };
7746
+ }
7747
+ case "vault_init": {
7748
+ initVault(a.passphrase);
7749
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, message: "Vault initialized and unlocked" }) }] };
7750
+ }
7751
+ case "vault_unlock": {
7752
+ const ok = unlockVault(a.passphrase);
7753
+ if (!ok)
7754
+ return { content: [{ type: "text", text: "Invalid passphrase" }], isError: true };
7755
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, message: "Vault unlocked" }) }] };
7756
+ }
7757
+ case "vault_lock": {
7758
+ lockVault();
7759
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, message: "Vault locked" }) }] };
7760
+ }
7761
+ case "vault_status": {
7762
+ const initialized = isVaultInitialized();
7763
+ const unlocked = isVaultUnlocked();
7764
+ const db = getDatabase();
7765
+ let docCount = 0;
7766
+ try {
7767
+ docCount = db.query("SELECT COUNT(*) as n FROM contact_documents").get().n;
7768
+ } catch {}
7769
+ return { content: [{ type: "text", text: JSON.stringify({ initialized, unlocked, document_count: docCount }) }] };
7770
+ }
7771
+ case "add_document": {
7772
+ const doc = addDocument({
7773
+ contact_id: a.contact_id,
7774
+ doc_type: a.doc_type,
7775
+ label: a.label,
7776
+ value: a.value,
7777
+ file_path: a.file_path,
7778
+ metadata: a.metadata,
7779
+ expires_at: a.expires_at
7780
+ });
7781
+ return { content: [{ type: "text", text: JSON.stringify(doc, null, 2) }] };
7782
+ }
7783
+ case "list_documents": {
7784
+ const docs = listDocuments(a.contact_id);
7785
+ return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
7786
+ }
7787
+ case "get_document": {
7788
+ const doc = getDocument(a.document_id);
7789
+ return { content: [{ type: "text", text: JSON.stringify(doc, null, 2) }] };
7790
+ }
7791
+ case "get_document_file": {
7792
+ const db = getDatabase();
7793
+ const row = db.query(`SELECT encrypted_file_path FROM contact_documents WHERE id = ?`).get(a.document_id);
7794
+ if (!row)
7795
+ return { content: [{ type: "text", text: JSON.stringify({ error: "Document not found" }) }], isError: true };
7796
+ const filePath = row.encrypted_file_path;
7797
+ return { content: [{ type: "text", text: JSON.stringify({ document_id: a.document_id, file_path: filePath, has_file: !!filePath }) }] };
7798
+ }
7799
+ case "delete_document": {
7800
+ deleteDocument(a.document_id);
7801
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true }) }] };
7802
+ }
7803
+ case "scan_document": {
7804
+ const result = await scanDocument(a.image, a.doc_type);
7805
+ if (a.auto_save && a.contact_id && isVaultUnlocked()) {
7806
+ try {
7807
+ const doc = addDocument({
7808
+ contact_id: a.contact_id,
7809
+ doc_type: result.document_type || "other",
7810
+ label: `Scanned ${result.document_type}`,
7811
+ value: JSON.stringify(result.fields),
7812
+ metadata: { raw_text: result.raw_text, confidence: result.confidence }
7813
+ });
7814
+ return { content: [{ type: "text", text: JSON.stringify({ scan: result, saved_document: doc }, null, 2) }] };
7815
+ } catch (saveErr) {
7816
+ return { content: [{ type: "text", text: JSON.stringify({ scan: result, save_error: saveErr instanceof Error ? saveErr.message : String(saveErr) }, null, 2) }] };
7817
+ }
7818
+ }
7819
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7820
+ }
7821
+ case "set_health_data": {
7822
+ const health = setHealthData(a.contact_id, {
7823
+ blood_type: a.blood_type,
7824
+ allergies: a.allergies,
7825
+ medical_conditions: a.medical_conditions,
7826
+ medications: a.medications,
7827
+ emergency_contacts: a.emergency_contacts,
7828
+ health_insurance_provider: a.health_insurance_provider,
7829
+ health_insurance_id: a.health_insurance_id,
7830
+ primary_physician: a.primary_physician,
7831
+ primary_physician_phone: a.primary_physician_phone,
7832
+ organ_donor: a.organ_donor,
7833
+ notes: a.notes
7834
+ });
7835
+ return { content: [{ type: "text", text: JSON.stringify(health, null, 2) }] };
7836
+ }
7837
+ case "get_health_data": {
7838
+ const health = getHealthData(a.contact_id);
7839
+ return { content: [{ type: "text", text: JSON.stringify(health, null, 2) }] };
7840
+ }
7841
+ case "delete_health_data": {
7842
+ deleteHealthData(a.contact_id);
7843
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true }) }] };
7844
+ }
5975
7845
  default:
5976
7846
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
5977
7847
  }