@hasna/contacts 0.5.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/mcp/index.js CHANGED
@@ -557,6 +557,41 @@ var init_database = __esm(() => {
557
557
  created_at TEXT NOT NULL DEFAULT (datetime('now')),
558
558
  updated_at TEXT NOT NULL DEFAULT (datetime('now'))
559
559
  );
560
+ `,
561
+ `
562
+ ALTER TABLE contacts ADD COLUMN sensitivity TEXT NOT NULL DEFAULT 'normal' CHECK(sensitivity IN ('normal','confidential','restricted'));
563
+
564
+ CREATE TABLE IF NOT EXISTS contact_documents (
565
+ id TEXT PRIMARY KEY,
566
+ contact_id TEXT NOT NULL REFERENCES contacts(id) ON DELETE CASCADE,
567
+ doc_type TEXT NOT NULL,
568
+ label TEXT,
569
+ encrypted_value TEXT NOT NULL,
570
+ iv TEXT NOT NULL,
571
+ encrypted_file_path TEXT,
572
+ metadata TEXT NOT NULL DEFAULT '{}',
573
+ expires_at TEXT,
574
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
575
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
576
+ );
577
+
578
+ CREATE TABLE IF NOT EXISTS contact_health (
579
+ id TEXT PRIMARY KEY,
580
+ contact_id TEXT NOT NULL UNIQUE REFERENCES contacts(id) ON DELETE CASCADE,
581
+ blood_type TEXT,
582
+ allergies TEXT NOT NULL DEFAULT '[]',
583
+ medical_conditions TEXT NOT NULL DEFAULT '[]',
584
+ medications TEXT NOT NULL DEFAULT '[]',
585
+ emergency_contacts TEXT NOT NULL DEFAULT '[]',
586
+ health_insurance_provider TEXT,
587
+ health_insurance_id TEXT,
588
+ primary_physician TEXT,
589
+ primary_physician_phone TEXT,
590
+ organ_donor INTEGER NOT NULL DEFAULT 0,
591
+ notes TEXT,
592
+ created_at TEXT NOT NULL DEFAULT (datetime('now')),
593
+ updated_at TEXT NOT NULL DEFAULT (datetime('now'))
594
+ );
560
595
  `
561
596
  ];
562
597
  });
@@ -656,6 +691,7 @@ function rowToContact(row) {
656
691
  follow_up_at: row.follow_up_at ?? null,
657
692
  archived: !!row.archived,
658
693
  project_id: row.project_id ?? null,
694
+ sensitivity: row.sensitivity ?? "normal",
659
695
  do_not_contact: !!row.do_not_contact,
660
696
  priority: row.priority ?? 3,
661
697
  timezone: row.timezone ?? null
@@ -743,8 +779,8 @@ function createContact(input, db) {
743
779
  const firstName = input.first_name ?? "";
744
780
  const lastName = input.last_name ?? "";
745
781
  const displayName = input.display_name ?? (firstName || lastName ? `${firstName} ${lastName}`.trim() : "Unnamed Contact");
746
- 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)
747
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
782
+ 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)
783
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, [
748
784
  id,
749
785
  firstName,
750
786
  lastName,
@@ -763,6 +799,7 @@ function createContact(input, db) {
763
799
  input.status ?? "active",
764
800
  input.follow_up_at ?? null,
765
801
  input.project_id ?? null,
802
+ input.sensitivity ?? "normal",
766
803
  input.do_not_contact ? 1 : 0,
767
804
  input.priority ?? 3,
768
805
  input.timezone ?? null,
@@ -811,6 +848,7 @@ function listContacts(opts = {}, db) {
811
848
  order_by = "display_name",
812
849
  order_dir = "asc",
813
850
  include_dnc = false,
851
+ include_restricted = false,
814
852
  priority_min,
815
853
  updated_since
816
854
  } = opts;
@@ -821,6 +859,9 @@ function listContacts(opts = {}, db) {
821
859
  if (!include_dnc) {
822
860
  conditions.push("c.do_not_contact = 0");
823
861
  }
862
+ if (!include_restricted) {
863
+ conditions.push("c.sensitivity != 'restricted'");
864
+ }
824
865
  if (company_id) {
825
866
  conditions.push("c.company_id = ?");
826
867
  params.push(company_id);
@@ -949,6 +990,10 @@ function updateContact(id, input, db) {
949
990
  setClauses.push("project_id = ?");
950
991
  params.push(input.project_id);
951
992
  }
993
+ if (input.sensitivity !== undefined) {
994
+ setClauses.push("sensitivity = ?");
995
+ params.push(input.sensitivity);
996
+ }
952
997
  if (input.do_not_contact !== undefined) {
953
998
  setClauses.push("do_not_contact = ?");
954
999
  params.push(input.do_not_contact ? 1 : 0);
@@ -996,26 +1041,26 @@ function searchContacts(query, db) {
996
1041
  const ftsRows = d.query(`
997
1042
  SELECT c.* FROM contacts c
998
1043
  JOIN contacts_fts fts ON fts.id = c.id
999
- WHERE contacts_fts MATCH ? AND c.archived = 0
1044
+ WHERE contacts_fts MATCH ? AND c.archived = 0 AND c.sensitivity != 'restricted'
1000
1045
  ORDER BY rank
1001
1046
  LIMIT 50
1002
1047
  `).all(`"${query.replace(/"/g, '""')}"*`);
1003
1048
  const emailRows = d.query(`
1004
1049
  SELECT DISTINCT c.* FROM contacts c
1005
1050
  JOIN emails e ON e.contact_id = c.id
1006
- WHERE e.address LIKE ? AND c.archived = 0
1051
+ WHERE e.address LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
1007
1052
  LIMIT 20
1008
1053
  `).all(`%${query}%`);
1009
1054
  const phoneRows = d.query(`
1010
1055
  SELECT DISTINCT c.* FROM contacts c
1011
1056
  JOIN phones p ON p.contact_id = c.id
1012
- WHERE p.number LIKE ? AND c.archived = 0
1057
+ WHERE p.number LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
1013
1058
  LIMIT 20
1014
1059
  `).all(`%${query}%`);
1015
1060
  const companyRows = d.query(`
1016
1061
  SELECT DISTINCT c.* FROM contacts c
1017
1062
  JOIN companies co ON co.id = c.company_id
1018
- WHERE co.name LIKE ? AND c.archived = 0
1063
+ WHERE co.name LIKE ? AND c.archived = 0 AND c.sensitivity != 'restricted'
1019
1064
  LIMIT 20
1020
1065
  `).all(`%${query}%`);
1021
1066
  const seen = new Set;
@@ -4205,6 +4250,427 @@ function getCoverageGaps(companyId, db) {
4205
4250
  };
4206
4251
  }
4207
4252
 
4253
+ // src/lib/images.ts
4254
+ import { existsSync as existsSync3, mkdirSync as mkdirSync2, copyFileSync, unlinkSync, readdirSync, readFileSync as readFileSync2, writeFileSync } from "fs";
4255
+ import { join as join3, extname, basename } from "path";
4256
+ var IMAGES_DIR = join3(process.env["HOME"] || "~", ".contacts", "images");
4257
+ function ensureImagesDir() {
4258
+ if (!existsSync3(IMAGES_DIR))
4259
+ mkdirSync2(IMAGES_DIR, { recursive: true });
4260
+ }
4261
+ function saveImage(entityId, source, options) {
4262
+ ensureImagesDir();
4263
+ deleteImage(entityId);
4264
+ const base64Match = source.match(/^data:image\/(\w+);base64,(.+)$/s);
4265
+ if (base64Match) {
4266
+ const ext2 = base64Match[1] === "jpeg" ? "jpg" : base64Match[1];
4267
+ const data = Buffer.from(base64Match[2], "base64");
4268
+ const filename2 = `${entityId}.${ext2}`;
4269
+ writeFileSync(join3(IMAGES_DIR, filename2), data);
4270
+ return filename2;
4271
+ }
4272
+ if (!existsSync3(source) && /^[A-Za-z0-9+/=\n\r]+$/.test(source.trim()) && source.length > 100) {
4273
+ const ext2 = options?.format || "jpg";
4274
+ const data = Buffer.from(source.trim(), "base64");
4275
+ const filename2 = `${entityId}.${ext2}`;
4276
+ writeFileSync(join3(IMAGES_DIR, filename2), data);
4277
+ return filename2;
4278
+ }
4279
+ if (!existsSync3(source)) {
4280
+ throw new Error(`Image file not found: ${source}`);
4281
+ }
4282
+ const ext = extname(source).slice(1).toLowerCase() || "jpg";
4283
+ const validExts = ["jpg", "jpeg", "png", "gif", "webp", "svg", "avif", "ico"];
4284
+ if (!validExts.includes(ext)) {
4285
+ throw new Error(`Unsupported image format: ${ext}. Supported: ${validExts.join(", ")}`);
4286
+ }
4287
+ const filename = `${entityId}.${ext === "jpeg" ? "jpg" : ext}`;
4288
+ copyFileSync(source, join3(IMAGES_DIR, filename));
4289
+ return filename;
4290
+ }
4291
+ function getImagePath(entityId) {
4292
+ ensureImagesDir();
4293
+ const files = readdirSync(IMAGES_DIR);
4294
+ const match = files.find((f) => f.startsWith(`${entityId}.`));
4295
+ return match ? join3(IMAGES_DIR, match) : null;
4296
+ }
4297
+ function getImageAsBase64(entityId) {
4298
+ const path = getImagePath(entityId);
4299
+ if (!path)
4300
+ return null;
4301
+ const ext = extname(path).slice(1);
4302
+ const mime = ext === "jpg" ? "image/jpeg" : ext === "svg" ? "image/svg+xml" : `image/${ext}`;
4303
+ const data = readFileSync2(path);
4304
+ return `data:${mime};base64,${data.toString("base64")}`;
4305
+ }
4306
+ function deleteImage(entityId) {
4307
+ ensureImagesDir();
4308
+ const files = readdirSync(IMAGES_DIR);
4309
+ let deleted = false;
4310
+ for (const f of files) {
4311
+ if (f.startsWith(`${entityId}.`)) {
4312
+ unlinkSync(join3(IMAGES_DIR, f));
4313
+ deleted = true;
4314
+ }
4315
+ }
4316
+ return deleted;
4317
+ }
4318
+
4319
+ // src/lib/vault.ts
4320
+ import { existsSync as existsSync4, readFileSync as readFileSync3, writeFileSync as writeFileSync2, mkdirSync as mkdirSync3 } from "fs";
4321
+ import { join as join4 } from "path";
4322
+ import { createCipheriv, createDecipheriv, randomBytes, pbkdf2Sync, createHash } from "crypto";
4323
+ var VAULT_DIR = join4(process.env["HOME"] || "~", ".contacts");
4324
+ var VAULT_CONFIG = join4(VAULT_DIR, "vault.json");
4325
+ var DOCUMENTS_DIR = join4(VAULT_DIR, "documents");
4326
+ var _derivedKey = null;
4327
+ function deriveKey(passphrase, salt) {
4328
+ return pbkdf2Sync(passphrase, salt, 1e5, 32, "sha512");
4329
+ }
4330
+ function initVault(passphrase) {
4331
+ if (!existsSync4(VAULT_DIR))
4332
+ mkdirSync3(VAULT_DIR, { recursive: true });
4333
+ if (!existsSync4(DOCUMENTS_DIR))
4334
+ mkdirSync3(DOCUMENTS_DIR, { recursive: true });
4335
+ const salt = randomBytes(32);
4336
+ const key = deriveKey(passphrase, salt);
4337
+ const keyHash = createHash("sha256").update(key).digest("hex");
4338
+ const config = { salt: salt.toString("hex"), key_hash: keyHash, created_at: new Date().toISOString() };
4339
+ writeFileSync2(VAULT_CONFIG, JSON.stringify(config, null, 2));
4340
+ _derivedKey = key;
4341
+ }
4342
+ function isVaultInitialized() {
4343
+ return existsSync4(VAULT_CONFIG);
4344
+ }
4345
+ function unlockVault(passphrase) {
4346
+ if (!existsSync4(VAULT_CONFIG))
4347
+ throw new Error("Vault not initialized. Run 'contacts vault init' first.");
4348
+ const config = JSON.parse(readFileSync3(VAULT_CONFIG, "utf-8"));
4349
+ const salt = Buffer.from(config.salt, "hex");
4350
+ const key = deriveKey(passphrase, salt);
4351
+ const keyHash = createHash("sha256").update(key).digest("hex");
4352
+ if (keyHash !== config.key_hash)
4353
+ return false;
4354
+ _derivedKey = key;
4355
+ return true;
4356
+ }
4357
+ function lockVault() {
4358
+ _derivedKey = null;
4359
+ }
4360
+ function isVaultUnlocked() {
4361
+ return _derivedKey !== null;
4362
+ }
4363
+ function requireVault() {
4364
+ if (!_derivedKey)
4365
+ throw new Error("Vault is locked. Unlock with 'contacts vault unlock' or vault_unlock MCP tool first.");
4366
+ return _derivedKey;
4367
+ }
4368
+ function encrypt(plaintext) {
4369
+ const key = requireVault();
4370
+ const iv = randomBytes(16);
4371
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
4372
+ let encrypted = cipher.update(plaintext, "utf8", "hex");
4373
+ encrypted += cipher.final("hex");
4374
+ const authTag = cipher.getAuthTag().toString("hex");
4375
+ return { ciphertext: encrypted + ":" + authTag, iv: iv.toString("hex") };
4376
+ }
4377
+ function decrypt(ciphertext, iv) {
4378
+ const key = requireVault();
4379
+ const [encData, authTag] = ciphertext.split(":");
4380
+ if (!encData || !authTag)
4381
+ throw new Error("Invalid ciphertext format");
4382
+ const decipher = createDecipheriv("aes-256-gcm", key, Buffer.from(iv, "hex"));
4383
+ decipher.setAuthTag(Buffer.from(authTag, "hex"));
4384
+ let decrypted = decipher.update(encData, "hex", "utf8");
4385
+ decrypted += decipher.final("utf8");
4386
+ return decrypted;
4387
+ }
4388
+ function encryptFile(sourcePath, entityId) {
4389
+ const key = requireVault();
4390
+ if (!existsSync4(DOCUMENTS_DIR))
4391
+ mkdirSync3(DOCUMENTS_DIR, { recursive: true });
4392
+ const data = readFileSync3(sourcePath);
4393
+ const iv = randomBytes(16);
4394
+ const cipher = createCipheriv("aes-256-gcm", key, iv);
4395
+ const encrypted = Buffer.concat([cipher.update(data), cipher.final()]);
4396
+ const authTag = cipher.getAuthTag();
4397
+ const destPath = join4(DOCUMENTS_DIR, `${entityId}.enc`);
4398
+ const output = Buffer.concat([iv, authTag, encrypted]);
4399
+ writeFileSync2(destPath, output);
4400
+ return destPath;
4401
+ }
4402
+
4403
+ // src/db/documents.ts
4404
+ init_database();
4405
+ import { existsSync as existsSync5, unlinkSync as unlinkSync2 } from "fs";
4406
+ var DOCUMENT_TYPES = [
4407
+ "passport",
4408
+ "national_id",
4409
+ "tax_id",
4410
+ "ssn",
4411
+ "drivers_license",
4412
+ "bank_account",
4413
+ "visa",
4414
+ "insurance",
4415
+ "contract",
4416
+ "certificate",
4417
+ "medical_record",
4418
+ "prescription",
4419
+ "allergy_list",
4420
+ "vaccination",
4421
+ "blood_type",
4422
+ "health_insurance",
4423
+ "medical_condition",
4424
+ "emergency_contact_medical",
4425
+ "other"
4426
+ ];
4427
+ function addDocument(input, db) {
4428
+ requireVault();
4429
+ const _db2 = db || getDatabase();
4430
+ const id = uuid();
4431
+ const { ciphertext, iv } = encrypt(input.value);
4432
+ let encFilePath = null;
4433
+ if (input.file_path) {
4434
+ encFilePath = encryptFile(input.file_path, id);
4435
+ }
4436
+ _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, encFilePath, JSON.stringify(input.metadata || {}), input.expires_at ?? null, now(), now());
4437
+ return getDocument(id, _db2);
4438
+ }
4439
+ function getDocument(id, db) {
4440
+ requireVault();
4441
+ const _db2 = db || getDatabase();
4442
+ const row = _db2.query(`SELECT * FROM contact_documents WHERE id = ?`).get(id);
4443
+ if (!row)
4444
+ throw new Error(`Document not found: ${id}`);
4445
+ return rowToDoc(row);
4446
+ }
4447
+ function listDocuments(contactId, db) {
4448
+ const _db2 = db || getDatabase();
4449
+ 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);
4450
+ return rows.map((r) => ({
4451
+ id: r.id,
4452
+ doc_type: r.doc_type,
4453
+ label: r.label,
4454
+ has_file: !!r.encrypted_file_path,
4455
+ expires_at: r.expires_at,
4456
+ created_at: r.created_at
4457
+ }));
4458
+ }
4459
+ function deleteDocument(id, db) {
4460
+ const _db2 = db || getDatabase();
4461
+ const row = _db2.query(`SELECT encrypted_file_path FROM contact_documents WHERE id = ?`).get(id);
4462
+ if (row?.encrypted_file_path && existsSync5(row.encrypted_file_path)) {
4463
+ try {
4464
+ unlinkSync2(row.encrypted_file_path);
4465
+ } catch {}
4466
+ }
4467
+ _db2.query(`DELETE FROM contact_documents WHERE id = ?`).run(id);
4468
+ }
4469
+ function rowToDoc(row) {
4470
+ return {
4471
+ id: row.id,
4472
+ contact_id: row.contact_id,
4473
+ doc_type: row.doc_type,
4474
+ label: row.label,
4475
+ value: decrypt(row.encrypted_value, row.iv),
4476
+ has_file: !!row.encrypted_file_path,
4477
+ metadata: JSON.parse(row.metadata || "{}"),
4478
+ expires_at: row.expires_at,
4479
+ created_at: row.created_at,
4480
+ updated_at: row.updated_at
4481
+ };
4482
+ }
4483
+
4484
+ // src/db/health.ts
4485
+ init_database();
4486
+ function setHealthData(contactId, input, db) {
4487
+ requireVault();
4488
+ const _db2 = db || getDatabase();
4489
+ const existing = _db2.query(`SELECT id FROM contact_health WHERE contact_id = ?`).get(contactId);
4490
+ if (existing) {
4491
+ const sets = [];
4492
+ const params = [];
4493
+ if (input.blood_type !== undefined) {
4494
+ sets.push("blood_type = ?");
4495
+ params.push(input.blood_type);
4496
+ }
4497
+ if (input.allergies !== undefined) {
4498
+ sets.push("allergies = ?");
4499
+ params.push(JSON.stringify(input.allergies));
4500
+ }
4501
+ if (input.medical_conditions !== undefined) {
4502
+ sets.push("medical_conditions = ?");
4503
+ params.push(JSON.stringify(input.medical_conditions));
4504
+ }
4505
+ if (input.medications !== undefined) {
4506
+ sets.push("medications = ?");
4507
+ params.push(JSON.stringify(input.medications));
4508
+ }
4509
+ if (input.emergency_contacts !== undefined) {
4510
+ sets.push("emergency_contacts = ?");
4511
+ params.push(JSON.stringify(input.emergency_contacts));
4512
+ }
4513
+ if (input.health_insurance_provider !== undefined) {
4514
+ sets.push("health_insurance_provider = ?");
4515
+ params.push(input.health_insurance_provider);
4516
+ }
4517
+ if (input.health_insurance_id !== undefined) {
4518
+ sets.push("health_insurance_id = ?");
4519
+ params.push(input.health_insurance_id);
4520
+ }
4521
+ if (input.primary_physician !== undefined) {
4522
+ sets.push("primary_physician = ?");
4523
+ params.push(input.primary_physician);
4524
+ }
4525
+ if (input.primary_physician_phone !== undefined) {
4526
+ sets.push("primary_physician_phone = ?");
4527
+ params.push(input.primary_physician_phone);
4528
+ }
4529
+ if (input.organ_donor !== undefined) {
4530
+ sets.push("organ_donor = ?");
4531
+ params.push(input.organ_donor ? 1 : 0);
4532
+ }
4533
+ if (input.notes !== undefined) {
4534
+ sets.push("notes = ?");
4535
+ params.push(input.notes);
4536
+ }
4537
+ if (sets.length) {
4538
+ sets.push("updated_at = ?");
4539
+ params.push(now());
4540
+ params.push(contactId);
4541
+ _db2.query(`UPDATE contact_health SET ${sets.join(", ")} WHERE contact_id = ?`).run(...params);
4542
+ }
4543
+ } else {
4544
+ const id = uuid();
4545
+ _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());
4546
+ }
4547
+ return getHealthData(contactId, _db2);
4548
+ }
4549
+ function getHealthData(contactId, db) {
4550
+ requireVault();
4551
+ const _db2 = db || getDatabase();
4552
+ const row = _db2.query(`SELECT * FROM contact_health WHERE contact_id = ?`).get(contactId);
4553
+ if (!row)
4554
+ return null;
4555
+ return {
4556
+ id: row.id,
4557
+ contact_id: row.contact_id,
4558
+ blood_type: row.blood_type,
4559
+ allergies: JSON.parse(row.allergies || "[]"),
4560
+ medical_conditions: JSON.parse(row.medical_conditions || "[]"),
4561
+ medications: JSON.parse(row.medications || "[]"),
4562
+ emergency_contacts: JSON.parse(row.emergency_contacts || "[]"),
4563
+ health_insurance_provider: row.health_insurance_provider,
4564
+ health_insurance_id: row.health_insurance_id,
4565
+ primary_physician: row.primary_physician,
4566
+ primary_physician_phone: row.primary_physician_phone,
4567
+ organ_donor: !!row.organ_donor,
4568
+ notes: row.notes,
4569
+ created_at: row.created_at,
4570
+ updated_at: row.updated_at
4571
+ };
4572
+ }
4573
+ function deleteHealthData(contactId, db) {
4574
+ const _db2 = db || getDatabase();
4575
+ _db2.query(`DELETE FROM contact_health WHERE contact_id = ?`).run(contactId);
4576
+ }
4577
+
4578
+ // src/lib/document-scanner.ts
4579
+ import { readFileSync as readFileSync4, existsSync as existsSync6 } from "fs";
4580
+ import { extname as extname2 } from "path";
4581
+ async function scanDocument(imageSource, docType) {
4582
+ const apiKey = process.env["OPENAI_API_KEY"];
4583
+ if (!apiKey) {
4584
+ throw new Error("OPENAI_API_KEY not set. Set it in ~/.secrets or environment to use document scanning.");
4585
+ }
4586
+ let imageData;
4587
+ if (imageSource.startsWith("data:image/")) {
4588
+ imageData = imageSource;
4589
+ } else if (existsSync6(imageSource)) {
4590
+ const buffer = readFileSync4(imageSource);
4591
+ const ext = extname2(imageSource).slice(1).toLowerCase();
4592
+ const mime = ext === "jpg" || ext === "jpeg" ? "image/jpeg" : ext === "png" ? "image/png" : ext === "webp" ? "image/webp" : ext === "gif" ? "image/gif" : "image/jpeg";
4593
+ imageData = `data:${mime};base64,${buffer.toString("base64")}`;
4594
+ } else if (/^[A-Za-z0-9+/=\n\r]+$/.test(imageSource.trim()) && imageSource.length > 100) {
4595
+ imageData = `data:image/jpeg;base64,${imageSource.trim()}`;
4596
+ } else {
4597
+ throw new Error(`Image source not found or invalid: ${imageSource.slice(0, 50)}...`);
4598
+ }
4599
+ const typeHint = docType ? ` This is a ${docType} document.` : "";
4600
+ const prompt = `Extract all text and structured data from this document image.${typeHint} Return a JSON object with these fields:
4601
+ - document_type: detected type (passport, national_id, drivers_license, tax_document, medical_record, prescription, insurance_card, bank_statement, visa, certificate, contract, other)
4602
+ - full_name: full name as shown
4603
+ - date_of_birth: in YYYY-MM-DD format if visible
4604
+ - document_number: main ID/document number
4605
+ - issuing_country: country code or name
4606
+ - issue_date: in YYYY-MM-DD format if visible
4607
+ - expiry_date: in YYYY-MM-DD format if visible
4608
+ - address: full address if visible
4609
+ - nationality: if visible
4610
+ - gender: if visible
4611
+ - mrz_code: Machine Readable Zone text if this is a passport/ID with MRZ
4612
+ - phone: any phone numbers visible
4613
+ - email: any email addresses visible
4614
+ - additional_fields: object with any other visible structured data
4615
+ - raw_text: all visible text transcribed
4616
+
4617
+ Only include fields that are actually visible in the document. Return valid JSON only.`;
4618
+ const response = await fetch("https://api.openai.com/v1/chat/completions", {
4619
+ method: "POST",
4620
+ headers: {
4621
+ Authorization: `Bearer ${apiKey}`,
4622
+ "Content-Type": "application/json"
4623
+ },
4624
+ body: JSON.stringify({
4625
+ model: "gpt-4o",
4626
+ messages: [
4627
+ {
4628
+ role: "user",
4629
+ content: [
4630
+ { type: "text", text: prompt },
4631
+ { type: "image_url", image_url: { url: imageData, detail: "high" } }
4632
+ ]
4633
+ }
4634
+ ],
4635
+ max_tokens: 2000,
4636
+ temperature: 0
4637
+ })
4638
+ });
4639
+ if (!response.ok) {
4640
+ const err = await response.text();
4641
+ throw new Error(`OpenAI API error: ${response.status} \u2014 ${err}`);
4642
+ }
4643
+ const data = await response.json();
4644
+ const content = data.choices?.[0]?.message?.content || "";
4645
+ const jsonMatch = content.match(/```json\s*([\s\S]*?)```/) || content.match(/\{[\s\S]*\}/);
4646
+ if (!jsonMatch) {
4647
+ return { fields: {}, raw_text: content, document_type: docType || "unknown", confidence: 0.3 };
4648
+ }
4649
+ try {
4650
+ const parsed = JSON.parse(jsonMatch[1] || jsonMatch[0]);
4651
+ const { document_type, raw_text, additional_fields, ...mainFields } = parsed;
4652
+ const fields = {};
4653
+ for (const [k, v] of Object.entries(mainFields)) {
4654
+ if (v && typeof v === "string")
4655
+ fields[k] = v;
4656
+ }
4657
+ if (additional_fields && typeof additional_fields === "object") {
4658
+ for (const [k, v] of Object.entries(additional_fields)) {
4659
+ if (v && typeof v === "string")
4660
+ fields[k] = v;
4661
+ }
4662
+ }
4663
+ return {
4664
+ fields,
4665
+ raw_text: raw_text || content,
4666
+ document_type: document_type || docType || "unknown",
4667
+ confidence: Object.keys(fields).length > 3 ? 0.9 : Object.keys(fields).length > 0 ? 0.7 : 0.3
4668
+ };
4669
+ } catch {
4670
+ return { fields: {}, raw_text: content, document_type: docType || "unknown", confidence: 0.3 };
4671
+ }
4672
+ }
4673
+
4208
4674
  // src/mcp/index.ts
4209
4675
  var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
4210
4676
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -4283,7 +4749,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4283
4749
  }
4284
4750
  },
4285
4751
  tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to assign" },
4286
- source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] }
4752
+ source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
4753
+ sensitivity: { type: "string", enum: ["normal", "confidential", "restricted"], description: "Contact sensitivity level (default: normal)" }
4287
4754
  }
4288
4755
  }
4289
4756
  },
@@ -4319,6 +4786,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4319
4786
  project_id: { type: "string", description: "Primary project ID (single, null to clear)" },
4320
4787
  project_ids: { type: "array", items: { type: "string" }, description: "Replace all project links with this array of todos project IDs" },
4321
4788
  source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
4789
+ sensitivity: { type: "string", enum: ["normal", "confidential", "restricted"] },
4322
4790
  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)" },
4323
4791
  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)" }
4324
4792
  },
@@ -4347,6 +4815,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4347
4815
  status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
4348
4816
  project_id: { type: "string", description: "Filter by project ID" },
4349
4817
  archived: { type: "boolean", description: "Include archived contacts (default false)" },
4818
+ include_restricted: { type: "boolean", description: "Include restricted-sensitivity contacts (default false)" },
4350
4819
  follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
4351
4820
  last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
4352
4821
  last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
@@ -5665,7 +6134,26 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
5665
6134
  { 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"] } },
5666
6135
  { 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"] } },
5667
6136
  { 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"] } },
5668
- { 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" } } } } }
6137
+ { 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" } } } } },
6138
+ { 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 ~/.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"] } },
6139
+ { 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"] } },
6140
+ { name: "delete_contact_photo", description: "Remove a contact's profile photo.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6141
+ { 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 ~/.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"] } },
6142
+ { 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"] } },
6143
+ { name: "delete_company_logo", description: "Remove a company's logo image.", inputSchema: { type: "object", properties: { company_id: { type: "string" } }, required: ["company_id"] } },
6144
+ { 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"] } },
6145
+ { 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"] } },
6146
+ { name: "vault_unlock", description: "Unlock the vault for this session with a passphrase.", inputSchema: { type: "object", properties: { passphrase: { type: "string" } }, required: ["passphrase"] } },
6147
+ { name: "vault_lock", description: "Lock the vault, clearing the encryption key from memory.", inputSchema: { type: "object", properties: {} } },
6148
+ { name: "vault_status", description: "Check vault initialization and lock status.", inputSchema: { type: "object", properties: {} } },
6149
+ { name: "add_document", description: "Store an encrypted document for a contact (passport, tax_id, medical_record, etc.). 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)" }, file_path: { type: "string", description: "Optional file to encrypt and attach" }, metadata: { type: "object" }, expires_at: { type: "string" } }, required: ["contact_id", "doc_type", "value"] } },
6150
+ { name: "list_documents", description: "List documents for a contact (metadata only \u2014 no decryption needed).", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
6151
+ { name: "get_document", description: "Get a document with decrypted value. Vault must be unlocked.", inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] } },
6152
+ { name: "delete_document", description: "Delete a document and its encrypted file.", inputSchema: { type: "object", properties: { document_id: { type: "string" } }, required: ["document_id"] } },
6153
+ { 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"] } },
6154
+ { 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"] } },
6155
+ { 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"] } },
6156
+ { name: "delete_health_data", description: "Delete all health data for a contact.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } }
5669
6157
  ]
5670
6158
  }));
5671
6159
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -5694,7 +6182,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5694
6182
  addresses: a.addresses,
5695
6183
  social_profiles: a.social_profiles,
5696
6184
  tag_ids: a.tag_ids,
5697
- source: a.source
6185
+ source: a.source,
6186
+ sensitivity: a.sensitivity
5698
6187
  };
5699
6188
  const contact = createContact(input);
5700
6189
  if (Array.isArray(a.project_ids) && a.project_ids.length > 0) {
@@ -5729,6 +6218,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5729
6218
  follow_up_at: rest.follow_up_at,
5730
6219
  project_id: rest.project_id,
5731
6220
  source: rest.source,
6221
+ sensitivity: rest.sensitivity,
5732
6222
  emails_add: rest.emails_add,
5733
6223
  phones_add: rest.phones_add
5734
6224
  };
@@ -5752,6 +6242,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5752
6242
  status: a.status,
5753
6243
  project_id: a.project_id,
5754
6244
  archived: a.archived,
6245
+ include_restricted: a.include_restricted,
5755
6246
  follow_up_due: a.follow_up_due,
5756
6247
  last_contacted_after: a.last_contacted_after,
5757
6248
  last_contacted_before: a.last_contacted_before,
@@ -7156,6 +7647,142 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
7156
7647
  const events = db.query(sql).all(...params);
7157
7648
  return { content: [{ type: "text", text: JSON.stringify({ events }, null, 2) }] };
7158
7649
  }
7650
+ case "set_contact_photo": {
7651
+ const { contact_id, image, format } = a;
7652
+ const contact = getContact(contact_id);
7653
+ const filename = saveImage(contact_id, image, { format });
7654
+ updateContact(contact_id, { avatar_url: `~/.contacts/images/${filename}` });
7655
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, contact_id, filename, avatar_url: `~/.contacts/images/${filename}` }) }] };
7656
+ }
7657
+ case "get_contact_photo": {
7658
+ const { contact_id } = a;
7659
+ const dataUri = getImageAsBase64(contact_id);
7660
+ if (!dataUri)
7661
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id, has_photo: false, data: null }) }] };
7662
+ return { content: [{ type: "text", text: JSON.stringify({ contact_id, has_photo: true, data: dataUri }) }] };
7663
+ }
7664
+ case "delete_contact_photo": {
7665
+ const { contact_id } = a;
7666
+ const deleted = deleteImage(contact_id);
7667
+ if (deleted)
7668
+ updateContact(contact_id, { avatar_url: null });
7669
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted }) }] };
7670
+ }
7671
+ case "set_company_logo": {
7672
+ const { company_id, image, format } = a;
7673
+ const co = getCompany(company_id);
7674
+ const filename = saveImage(company_id, image, { format });
7675
+ updateCompany(company_id, { logo_url: `~/.contacts/images/${filename}` });
7676
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, company_id, filename, logo_url: `~/.contacts/images/${filename}` }) }] };
7677
+ }
7678
+ case "get_company_logo": {
7679
+ const { company_id } = a;
7680
+ const dataUri = getImageAsBase64(company_id);
7681
+ if (!dataUri)
7682
+ return { content: [{ type: "text", text: JSON.stringify({ company_id, has_logo: false, data: null }) }] };
7683
+ return { content: [{ type: "text", text: JSON.stringify({ company_id, has_logo: true, data: dataUri }) }] };
7684
+ }
7685
+ case "delete_company_logo": {
7686
+ const { company_id } = a;
7687
+ const deleted = deleteImage(company_id);
7688
+ if (deleted)
7689
+ updateCompany(company_id, { logo_url: null });
7690
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted }) }] };
7691
+ }
7692
+ case "set_sensitivity": {
7693
+ const contact = updateContact(a.contact_id, { sensitivity: a.sensitivity });
7694
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, contact_id: a.contact_id, sensitivity: a.sensitivity }) }] };
7695
+ }
7696
+ case "vault_init": {
7697
+ initVault(a.passphrase);
7698
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, message: "Vault initialized and unlocked" }) }] };
7699
+ }
7700
+ case "vault_unlock": {
7701
+ const ok = unlockVault(a.passphrase);
7702
+ if (!ok)
7703
+ return { content: [{ type: "text", text: "Invalid passphrase" }], isError: true };
7704
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, message: "Vault unlocked" }) }] };
7705
+ }
7706
+ case "vault_lock": {
7707
+ lockVault();
7708
+ return { content: [{ type: "text", text: JSON.stringify({ ok: true, message: "Vault locked" }) }] };
7709
+ }
7710
+ case "vault_status": {
7711
+ const initialized = isVaultInitialized();
7712
+ const unlocked = isVaultUnlocked();
7713
+ const db = getDatabase();
7714
+ let docCount = 0;
7715
+ try {
7716
+ docCount = db.query("SELECT COUNT(*) as n FROM contact_documents").get().n;
7717
+ } catch {}
7718
+ return { content: [{ type: "text", text: JSON.stringify({ initialized, unlocked, document_count: docCount }) }] };
7719
+ }
7720
+ case "add_document": {
7721
+ const doc = addDocument({
7722
+ contact_id: a.contact_id,
7723
+ doc_type: a.doc_type,
7724
+ label: a.label,
7725
+ value: a.value,
7726
+ file_path: a.file_path,
7727
+ metadata: a.metadata,
7728
+ expires_at: a.expires_at
7729
+ });
7730
+ return { content: [{ type: "text", text: JSON.stringify(doc, null, 2) }] };
7731
+ }
7732
+ case "list_documents": {
7733
+ const docs = listDocuments(a.contact_id);
7734
+ return { content: [{ type: "text", text: JSON.stringify(docs, null, 2) }] };
7735
+ }
7736
+ case "get_document": {
7737
+ const doc = getDocument(a.document_id);
7738
+ return { content: [{ type: "text", text: JSON.stringify(doc, null, 2) }] };
7739
+ }
7740
+ case "delete_document": {
7741
+ deleteDocument(a.document_id);
7742
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true }) }] };
7743
+ }
7744
+ case "scan_document": {
7745
+ const result = await scanDocument(a.image, a.doc_type);
7746
+ if (a.auto_save && a.contact_id && isVaultUnlocked()) {
7747
+ try {
7748
+ const doc = addDocument({
7749
+ contact_id: a.contact_id,
7750
+ doc_type: result.document_type || "other",
7751
+ label: `Scanned ${result.document_type}`,
7752
+ value: JSON.stringify(result.fields),
7753
+ metadata: { raw_text: result.raw_text, confidence: result.confidence }
7754
+ });
7755
+ return { content: [{ type: "text", text: JSON.stringify({ scan: result, saved_document: doc }, null, 2) }] };
7756
+ } catch (saveErr) {
7757
+ return { content: [{ type: "text", text: JSON.stringify({ scan: result, save_error: saveErr instanceof Error ? saveErr.message : String(saveErr) }, null, 2) }] };
7758
+ }
7759
+ }
7760
+ return { content: [{ type: "text", text: JSON.stringify(result, null, 2) }] };
7761
+ }
7762
+ case "set_health_data": {
7763
+ const health = setHealthData(a.contact_id, {
7764
+ blood_type: a.blood_type,
7765
+ allergies: a.allergies,
7766
+ medical_conditions: a.medical_conditions,
7767
+ medications: a.medications,
7768
+ emergency_contacts: a.emergency_contacts,
7769
+ health_insurance_provider: a.health_insurance_provider,
7770
+ health_insurance_id: a.health_insurance_id,
7771
+ primary_physician: a.primary_physician,
7772
+ primary_physician_phone: a.primary_physician_phone,
7773
+ organ_donor: a.organ_donor,
7774
+ notes: a.notes
7775
+ });
7776
+ return { content: [{ type: "text", text: JSON.stringify(health, null, 2) }] };
7777
+ }
7778
+ case "get_health_data": {
7779
+ const health = getHealthData(a.contact_id);
7780
+ return { content: [{ type: "text", text: JSON.stringify(health, null, 2) }] };
7781
+ }
7782
+ case "delete_health_data": {
7783
+ deleteHealthData(a.contact_id);
7784
+ return { content: [{ type: "text", text: JSON.stringify({ deleted: true }) }] };
7785
+ }
7159
7786
  default:
7160
7787
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
7161
7788
  }