@hasna/contacts 0.5.3 → 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;
@@ -4271,6 +4316,361 @@ function deleteImage(entityId) {
4271
4316
  return deleted;
4272
4317
  }
4273
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
+
4274
4674
  // src/mcp/index.ts
4275
4675
  var server = new Server({ name: "contacts", version: "0.1.0" }, { capabilities: { tools: {} } });
4276
4676
  server.setRequestHandler(ListToolsRequestSchema, async () => ({
@@ -4349,7 +4749,8 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4349
4749
  }
4350
4750
  },
4351
4751
  tag_ids: { type: "array", items: { type: "string" }, description: "Tag IDs to assign" },
4352
- 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)" }
4353
4754
  }
4354
4755
  }
4355
4756
  },
@@ -4385,6 +4786,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4385
4786
  project_id: { type: "string", description: "Primary project ID (single, null to clear)" },
4386
4787
  project_ids: { type: "array", items: { type: "string" }, description: "Replace all project links with this array of todos project IDs" },
4387
4788
  source: { type: "string", enum: ["manual", "import", "linkedin", "github", "twitter", "email", "calendar", "crm", "other"] },
4789
+ sensitivity: { type: "string", enum: ["normal", "confidential", "restricted"] },
4388
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)" },
4389
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)" }
4390
4792
  },
@@ -4413,6 +4815,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
4413
4815
  status: { type: "string", enum: ["active", "pending_reply", "converted", "closed", "other"] },
4414
4816
  project_id: { type: "string", description: "Filter by project ID" },
4415
4817
  archived: { type: "boolean", description: "Include archived contacts (default false)" },
4818
+ include_restricted: { type: "boolean", description: "Include restricted-sensitivity contacts (default false)" },
4416
4819
  follow_up_due: { type: "boolean", description: "Only return contacts whose follow_up_at is in the past" },
4417
4820
  last_contacted_after: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted after this date" },
4418
4821
  last_contacted_before: { type: "string", description: "ISO 8601 date \u2014 only contacts last contacted before this date" },
@@ -5737,7 +6140,20 @@ server.setRequestHandler(ListToolsRequestSchema, async () => ({
5737
6140
  { name: "delete_contact_photo", description: "Remove a contact's profile photo.", inputSchema: { type: "object", properties: { contact_id: { type: "string" } }, required: ["contact_id"] } },
5738
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"] } },
5739
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"] } },
5740
- { name: "delete_company_logo", description: "Remove a company's logo image.", 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"] } }
5741
6157
  ]
5742
6158
  }));
5743
6159
  server.setRequestHandler(CallToolRequestSchema, async (request) => {
@@ -5766,7 +6182,8 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5766
6182
  addresses: a.addresses,
5767
6183
  social_profiles: a.social_profiles,
5768
6184
  tag_ids: a.tag_ids,
5769
- source: a.source
6185
+ source: a.source,
6186
+ sensitivity: a.sensitivity
5770
6187
  };
5771
6188
  const contact = createContact(input);
5772
6189
  if (Array.isArray(a.project_ids) && a.project_ids.length > 0) {
@@ -5801,6 +6218,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5801
6218
  follow_up_at: rest.follow_up_at,
5802
6219
  project_id: rest.project_id,
5803
6220
  source: rest.source,
6221
+ sensitivity: rest.sensitivity,
5804
6222
  emails_add: rest.emails_add,
5805
6223
  phones_add: rest.phones_add
5806
6224
  };
@@ -5824,6 +6242,7 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
5824
6242
  status: a.status,
5825
6243
  project_id: a.project_id,
5826
6244
  archived: a.archived,
6245
+ include_restricted: a.include_restricted,
5827
6246
  follow_up_due: a.follow_up_due,
5828
6247
  last_contacted_after: a.last_contacted_after,
5829
6248
  last_contacted_before: a.last_contacted_before,
@@ -7270,6 +7689,100 @@ server.setRequestHandler(CallToolRequestSchema, async (request) => {
7270
7689
  updateCompany(company_id, { logo_url: null });
7271
7690
  return { content: [{ type: "text", text: JSON.stringify({ ok: true, deleted }) }] };
7272
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
+ }
7273
7786
  default:
7274
7787
  return { content: [{ type: "text", text: `Unknown tool: ${name}` }], isError: true };
7275
7788
  }