diff --git a/skills/cued/SKILL.md b/skills/cued/SKILL.md index 0758d445..99c6bd37 100644 --- a/skills/cued/SKILL.md +++ b/skills/cued/SKILL.md @@ -100,7 +100,7 @@ Safety rules: ## Contact Management -- **Find a person**: Search `contacts` by name (`LIKE '%name%' COLLATE NOCASE`), then check `contact_handles` for email/phone/handle matches. +- **Find a person**: Start with `cued contacts search "name, company, handle, or remembered fact"`. It searches names, companies, handles, and current memories and returns canonical contact ids. Use SQL only when you need a precise join or ranking the command does not expose. - **Cross-platform view**: Join `conversation_participants` → `conversations` for a contact to see all their threads across platforms. - **Duplicate detection**: Match `contact_handles.normalized_value` across different `contact_id`s, or match `contacts.name` case-insensitively. Many contacts have phone numbers as names (e.g. `+1347...`) because they were discovered via iMessage before being linked. - **Merge duplicates**: `cued contacts merge [--reason TEXT]` for one merge, or `cued contacts merge-batch merges.json --apply` for many exact-evidence merges with one rebuild. `merge-batch` dry-runs by default when `--apply` is omitted. diff --git a/src/cli-contacts-memory.test.ts b/src/cli-contacts-memory.test.ts index ace1d835..20868dfb 100644 --- a/src/cli-contacts-memory.test.ts +++ b/src/cli-contacts-memory.test.ts @@ -165,4 +165,84 @@ describe("contacts memory CLI", () => { ], }); }); + + it("searches canonical contacts across names, companies, handles, and current memories", () => { + const home = createHome(); + const db = new CuedDatabase(join(home, "local.db")); + let memoryId: string; + try { + const sqlite = ( + db as unknown as { + sqlite: { + prepare: (sql: string) => { + run: (...params: unknown[]) => void; + }; + }; + } + ).sqlite; + sqlite.prepare("UPDATE contacts SET company = ? WHERE id = ?").run("Acme Labs", "contact-1"); + sqlite + .prepare( + "INSERT INTO contact_handles (id, contact_id, type, value, normalized_value, platform, account_key, is_deterministic, created_at, updated_at) VALUES (?, ?, 'email', ?, ?, 'gmail', 'default', 1, 1, 1)", + ) + .run("handle-1", "contact-1", "ava@example.com", "ava@example.com"); + memoryId = db.addContactMemory({ + contactId: "contact-1", + body: "Interested in applied robotics", + }).id; + } finally { + db.close(); + } + + for (const query of ["Ava Ch", "Acme", "ava@example", "applied robot"]) { + const results = JSON.parse(runCli(home, ["contacts", "search", query])) as Array<{ + id: string; + }>; + expect(results.map((row) => row.id)).toContain("contact-1"); + } + + const writableDb = new CuedDatabase(join(home, "local.db")); + try { + writableDb.markContactMemoryStale(memoryId); + } finally { + writableDb.close(); + } + expect(JSON.parse(runCli(home, ["contacts", "search", "robotics"]))).toEqual([]); + }); + + it("applies the contact search migration on the first search after an upgrade", () => { + const home = createHome(); + const db = new CuedDatabase(join(home, "local.db")); + try { + const sqlite = ( + db as unknown as { + sqlite: { + exec: (sql: string) => void; + }; + } + ).sqlite; + sqlite.exec(` + DROP TRIGGER trg_contacts_inserted_contact_fts; + DROP TRIGGER trg_contacts_updated_contact_fts; + DROP TRIGGER trg_contacts_deleted_contact_fts; + DROP TRIGGER trg_contact_handles_inserted_contact_fts; + DROP TRIGGER trg_contact_handles_updated_contact_fts; + DROP TRIGGER trg_contact_handles_deleted_contact_fts; + DROP TRIGGER trg_contact_memories_inserted_contact_fts; + DROP TRIGGER trg_contact_memories_updated_contact_fts; + DROP TRIGGER trg_contact_memories_deleted_contact_fts; + DROP VIEW contact_fts_source; + DROP TABLE contacts_fts; + DROP INDEX idx_contact_handles_contact_id; + DELETE FROM schema_migrations WHERE id = '0022_contact_fts'; + `); + } finally { + db.close(); + } + + const results = JSON.parse(runCli(home, ["contacts", "search", "Ava"])) as Array<{ + id: string; + }>; + expect(results.map((row) => row.id)).toEqual(["contact-1"]); + }); }); diff --git a/src/cli.ts b/src/cli.ts index d59d59ea..02556ef0 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -155,6 +155,7 @@ Usage: cued integrations disable [account] cued contacts merge [--reason TEXT] cued contacts merge-batch [--apply] + cued contacts search [--limit N] [--include-archived] cued contacts memory add [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID] cued contacts memory list [--limit N] [--include-stale] cued contacts memory stale @@ -1166,6 +1167,27 @@ async function main(): Promise { } throw new Error("Usage: cued sync run [source] | cued sync resume"); case "contacts": + if (subcommand === "search") { + const query = parseFreeTextArgument(rest, 0); + if (!query) { + throw new Error("Usage: cued contacts search [--limit N] [--include-archived]"); + } + // Contact search owns a schema migration, so its first invocation after an + // upgrade must open the canonical writable database before reading it. + const db = openCuedDatabase(); + try { + printJson( + db.searchContacts({ + query, + limit: parseIntegerFlag(rest, "--limit"), + includeArchived: rest.includes("--include-archived"), + }), + ); + } finally { + db.close(); + } + return; + } if (subcommand === "memory" || subcommand === "memories") { const memoryCommand = rest[0]; const args = rest.slice(1); diff --git a/src/db/database.ts b/src/db/database.ts index c4cc5c95..4f649c45 100644 --- a/src/db/database.ts +++ b/src/db/database.ts @@ -165,6 +165,16 @@ export interface ContactMemoryRow { updated_at: number; } +export interface ContactSearchRow { + id: string; + name: string | null; + company: string | null; + archived: number; + handles: string | null; + rank: number; + snippet: string; +} + export interface ContactMergeBatchInput { primaryContactId: string; secondaryContactId: string; @@ -2325,6 +2335,50 @@ export class CuedDatabase { .all(...params) as ContactMemoryRow[]; } + searchContacts(input: { + query: string; + limit?: number; + includeArchived?: boolean; + }): ContactSearchRow[] { + const terms = input.query + .trim() + .split(/\s+/) + .filter(Boolean) + .map((term) => `"${term.replaceAll('"', '""')}"*`); + if (terms.length === 0) { + throw new Error("Contact search query is required."); + } + const limit = + typeof input.limit === "number" && Number.isFinite(input.limit) + ? Math.max(1, Math.min(100, Math.trunc(input.limit))) + : 20; + + return this.sqlite + .prepare( + ` + SELECT + c.id, + c.name, + c.company, + c.archived, + ( + SELECT GROUP_CONCAT(ch.type || ':' || ch.value, ' | ') + FROM contact_handles ch + WHERE ch.contact_id = c.id + ) AS handles, + bm25(contacts_fts, 0.0, 8.0, 4.0, 2.0, 1.0) AS rank, + snippet(contacts_fts, -1, '[', ']', '…', 16) AS snippet + FROM contacts_fts + JOIN contacts c ON c.id = contacts_fts.contact_id + WHERE contacts_fts MATCH ? + AND (? = 1 OR c.archived = 0) + ORDER BY rank ASC, c.name COLLATE NOCASE ASC + LIMIT ? + `, + ) + .all(terms.join(" AND "), input.includeArchived ? 1 : 0, limit) as ContactSearchRow[]; + } + markContactMemoryStale(id: string, staleAt: number | null = null): ContactMemoryRow | null { const timestamp = staleAt ?? now(); this.db diff --git a/src/db/migrations.ts b/src/db/migrations.ts index 388965c0..ea94ba90 100644 --- a/src/db/migrations.ts +++ b/src/db/migrations.ts @@ -2245,4 +2245,124 @@ export const MIGRATIONS: Migration[] = [ `); }, }, + { + id: "0022_contact_fts", + apply: (db) => { + if ( + !tableExists(db, "contacts") || + !tableExists(db, "contact_handles") || + !tableExists(db, "contact_memories") + ) { + return; + } + db.exec(` + CREATE INDEX IF NOT EXISTS idx_contact_handles_contact_id + ON contact_handles(contact_id); + + CREATE VIRTUAL TABLE IF NOT EXISTS contacts_fts USING fts5( + contact_id UNINDEXED, + name, + company, + handles, + memories + ); + + CREATE VIEW IF NOT EXISTS contact_fts_source AS + SELECT + c.rowid AS contact_rowid, + c.id AS contact_id, + COALESCE(c.name, '') AS name, + COALESCE(c.company, '') AS company, + COALESCE(( + SELECT GROUP_CONCAT(TRIM(ch.type || ' ' || ch.value), ' ') + FROM contact_handles ch + WHERE ch.contact_id = c.id + ), '') AS handles, + COALESCE(( + SELECT GROUP_CONCAT(cm.body, ' ') + FROM contact_memories cm + WHERE cm.contact_id = c.id AND cm.stale_at IS NULL + ), '') AS memories + FROM contacts c; + + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source; + + CREATE TRIGGER IF NOT EXISTS trg_contacts_inserted_contact_fts + AFTER INSERT ON contacts BEGIN + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id = NEW.id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contacts_updated_contact_fts + AFTER UPDATE OF name, company ON contacts BEGIN + DELETE FROM contacts_fts WHERE rowid = OLD.rowid; + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id = NEW.id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contacts_deleted_contact_fts + AFTER DELETE ON contacts BEGIN + DELETE FROM contacts_fts WHERE rowid = OLD.rowid; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contact_handles_inserted_contact_fts + AFTER INSERT ON contact_handles BEGIN + DELETE FROM contacts_fts + WHERE rowid = (SELECT rowid FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id = NEW.contact_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contact_handles_updated_contact_fts + AFTER UPDATE OF contact_id, type, value ON contact_handles BEGIN + DELETE FROM contacts_fts + WHERE rowid IN (SELECT rowid FROM contacts WHERE id IN (OLD.contact_id, NEW.contact_id)); + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id IN (OLD.contact_id, NEW.contact_id); + END; + + CREATE TRIGGER IF NOT EXISTS trg_contact_handles_deleted_contact_fts + AFTER DELETE ON contact_handles BEGIN + DELETE FROM contacts_fts + WHERE rowid = (SELECT rowid FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id = OLD.contact_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contact_memories_inserted_contact_fts + AFTER INSERT ON contact_memories BEGIN + DELETE FROM contacts_fts + WHERE rowid = (SELECT rowid FROM contacts WHERE id = NEW.contact_id); + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id = NEW.contact_id; + END; + + CREATE TRIGGER IF NOT EXISTS trg_contact_memories_updated_contact_fts + AFTER UPDATE OF contact_id, body, stale_at ON contact_memories BEGIN + DELETE FROM contacts_fts + WHERE rowid IN (SELECT rowid FROM contacts WHERE id IN (OLD.contact_id, NEW.contact_id)); + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id IN (OLD.contact_id, NEW.contact_id); + END; + + CREATE TRIGGER IF NOT EXISTS trg_contact_memories_deleted_contact_fts + AFTER DELETE ON contact_memories BEGIN + DELETE FROM contacts_fts + WHERE rowid = (SELECT rowid FROM contacts WHERE id = OLD.contact_id); + INSERT INTO contacts_fts (rowid, contact_id, name, company, handles, memories) + SELECT contact_rowid, contact_id, name, company, handles, memories + FROM contact_fts_source WHERE contact_id = OLD.contact_id; + END; + `); + }, + }, ];