Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion skills/cued/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 <primary-id> <secondary-id> [--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.
Expand Down
80 changes: 80 additions & 0 deletions src/cli-contacts-memory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"]);
});
});
22 changes: 22 additions & 0 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -155,6 +155,7 @@ Usage:
cued integrations disable <platform> [account]
cued contacts merge <primary-contact-id> <secondary-contact-id> [--reason TEXT]
cued contacts merge-batch <merges.json> [--apply]
cued contacts search <query> [--limit N] [--include-archived]
cued contacts memory add <contact-id> <memory> [--source SOURCE] [--confidence 0-100] [--evidence JSON] [--supersedes MEMORY_ID]
cued contacts memory list <contact-id> [--limit N] [--include-stale]
cued contacts memory stale <memory-id>
Expand Down Expand Up @@ -1166,6 +1167,27 @@ async function main(): Promise<void> {
}
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 <query> [--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);
Expand Down
54 changes: 54 additions & 0 deletions src/db/database.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand Down
120 changes: 120 additions & 0 deletions src/db/migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
`);
},
},
];
Loading