@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/cli/index.js +1042 -28
- package/dist/db/companies.d.ts.map +1 -1
- package/dist/db/contacts.d.ts.map +1 -1
- package/dist/db/database.d.ts.map +1 -1
- package/dist/db/documents.d.ts +37 -0
- package/dist/db/documents.d.ts.map +1 -0
- package/dist/db/health.d.ts +40 -0
- package/dist/db/health.d.ts.map +1 -0
- package/dist/db/tags.d.ts.map +1 -1
- package/dist/index.d.ts +9 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +524 -6
- package/dist/lib/document-scanner.d.ts +8 -0
- package/dist/lib/document-scanner.d.ts.map +1 -0
- package/dist/lib/images.d.ts +30 -0
- package/dist/lib/images.d.ts.map +1 -0
- package/dist/lib/vault.d.ts +15 -0
- package/dist/lib/vault.d.ts.map +1 -0
- package/dist/mcp/index.js +636 -9
- package/dist/server/index.js +190 -12
- package/dist/server/serve.d.ts.map +1 -1
- package/dist/types/index.d.ts +6 -0
- package/dist/types/index.d.ts.map +1 -1
- package/package.json +1 -1
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
|
}
|