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: 2 additions & 0 deletions skills/cued/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,8 @@ Whenever new evidence could change an existing memory, reconcile before writing:

Never create a second memory that restates a current one. Never invalidate a memory merely because the latest search did not mention it, and never revive an invalidated memory without new supporting evidence.

For complete agent loops built on this contract, use `references/self-driving-crm.md`. Its relationship radar, pre-meeting brief, enrichment reconciliation, and X-mutual workflows keep derived recommendations out of durable memories and require an auditable decision for every candidate.

Before writing a memory, verify at least one of:
- deterministic handle evidence: LinkedIn profile URL/id, email, phone, or platform id;
- strong DM history with the contact;
Expand Down
28 changes: 28 additions & 0 deletions skills/cued/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -455,6 +455,34 @@
"Explained how local and external evidence should aggregate into one canonical contact record",
"Did not skip the local-first pass before proposing external enrichment"
]
},
{
"id": 36,
"prompt": "Run the self-driving CRM relationship radar and tell me who needs attention. Update memories if useful while you work.",
"expected_output": "A bounded, evidence-backed list of relationships needing attention, with durable memory writes only where genuinely new contact context was discovered.",
"files": [],
"expectations": [
"Selected a bounded candidate set using latest DM direction, reply age, and reactions",
"Counted only the user's own active reaction as an acknowledgement",
"Loaded each selected contact's full memory history before any memory mutation",
"Did not persist transient follow-up status such as needs a reply as a contact memory",
"Classified each candidate as no_change, add, supersede, or invalidate",
"Included contact ids and evidence for any mutation actually performed"
]
},
{
"id": 37,
"prompt": "Find my most important X mutuals from my latest imported archive, use our DMs to explain who matters, and save whatever should be remembered.",
"expected_output": "A ranked list of X mutuals grounded in contact-source relationship metadata and local DM history, with only durable, non-duplicative memories written.",
"files": [],
"expectations": [
"Read mutual status from X contact_sources metadata rather than guessing from messages",
"Matched contact-source archiveGeneration to the latest completed X sync proof",
"Joined X conversations and messages through the canonical contact",
"Did not duplicate deterministic mutual or follower state into a prose memory",
"Loaded all memories before reconciling any durable context found in DMs",
"Used no_change when the evidence added nothing durable"
]
}
]
}
141 changes: 141 additions & 0 deletions skills/cued/references/self-driving-crm.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
# Self-driving CRM on Cued

A self-driving CRM should be an agent loop over durable local state, not a second application runtime. Cued owns identity, messages, memories, provenance, and search in SQLite; the agent owns the policy for what to inspect, research, remember, invalidate, and surface to the user.

Every loop follows the same contract:

1. Select a small candidate set with `cued sql`; use `cued contacts search` when the contact-FTS command is installed.
2. Resolve one canonical contact and load `cued contacts memory show <contact-id> --all`.
3. Gather only the evidence needed for this candidate.
4. Make one of four decisions: `no_change`, `add`, `supersede`, or `invalidate`.
5. Write through `cued contacts memory ...`; never mutate the encrypted database directly.
6. Return an audit record containing the contact id, evidence, decision, and command result.

The safe default is `no_change`. A run is successful when it finds nothing worth changing.

## Implementation 1: relationship radar

Use message history to find relationships that need attention, then use memories to explain why each person matters. This query finds DMs where the other person sent the latest message and the user has not replied or reacted:

```sql
WITH latest AS (
SELECT m.*, ROW_NUMBER() OVER (PARTITION BY m.conversation_id ORDER BY m.sent_at DESC, m.id DESC) AS rn
FROM messages m
JOIN conversations c ON c.id = m.conversation_id
WHERE c.type = 'dm' AND c.is_active = 1 AND m.is_deleted = 0
)
SELECT
cp.contact_id,
c.name,
l.platform,
l.content,
l.sent_at
FROM latest l
JOIN conversation_participants cp
ON cp.conversation_id = l.conversation_id AND cp.is_self = 0 AND cp.is_active = 1
JOIN contacts c ON c.id = cp.contact_id
WHERE l.rn = 1
AND c.archived = 0
AND l.is_from_me = 0
AND l.sent_at < unixepoch('now', '-2 days') * 1000
AND NOT EXISTS (
SELECT 1
FROM message_reactions r
WHERE r.message_id = l.id AND r.is_active = 1 AND r.reactor_source_key IS NULL
)
ORDER BY l.sent_at DESC
LIMIT 25;
```

For each result, load current memories and recent messages, then rank it. Do not write a memory saying somebody needs a reply; that is derived state and will go stale. Write only durable context discovered while preparing the recommendation.

## Implementation 2: pre-meeting brief

Resolve the person locally, load all current memories, then retrieve their recent cross-platform messages. Contact FTS is the preferred resolver when available; the base skill can resolve an exact or partial name with:

```sql
SELECT c.id, c.name, c.company, GROUP_CONCAT(h.type || ':' || h.value) AS handles
FROM contacts c
LEFT JOIN contact_handles h ON h.contact_id = c.id
WHERE c.name LIKE '%person name%' COLLATE NOCASE
GROUP BY c.id
LIMIT 20;
```

```sql
SELECT
m.id AS message_id,
m.conversation_id,
m.platform,
m.sent_at,
m.is_from_me,
m.sender_name,
m.content
FROM conversation_participants cp
JOIN messages m ON m.conversation_id = cp.conversation_id
WHERE cp.contact_id = 'contact-id-here' AND m.is_deleted = 0
ORDER BY m.sent_at DESC
LIMIT 100;
```

The brief should contain the last interaction, open commitments, durable context from current memories, and the provenance for each claim. Reading a brief is not evidence that a memory changed, so the default action is no write.

## Implementation 3: enrichment and reconciliation

Start with contacts that have meaningful local interaction and incomplete fields. Process one person at a time; broad web search happens only after deterministic handles, profile URLs, and local message evidence have been collected.

Before any write, load the full memory history with `--all`. If new evidence corrects a current memory, write the replacement with `--supersedes`; if it disproves a memory without a replacement, invalidate it. Absence from a newer page is not contradictory evidence.

```bash
cued contacts memory add CONTACT_ID \
"Now works at ExampleCo on developer tools." \
--source web_profile \
--confidence 95 \
--evidence '{"urls":["https://example.com/team/person"]}' \
--supersedes OLD_MEMORY_ID
```

## Implementation 4: X mutual and DM discovery

After Cued imports an X archive, relationship state is stored with the X contact source. This query finds mutuals from the most recently completed archive generation with a local DM relationship:

```sql
WITH current_x_generation AS (
SELECT account_key, json_extract(coverage_json, '$.generation') AS generation
FROM sync_proofs
WHERE platform = 'x' AND proof_kind = 'messages' AND status = 'complete'
ORDER BY last_observed_at DESC
LIMIT 1
)
SELECT
c.id,
c.name,
json_extract(cs.metadata_json, '$.followersCount') AS followers,
COUNT(DISTINCT m.id) AS dm_messages,
MAX(m.sent_at) AS last_dm_at
FROM contact_sources cs
JOIN current_x_generation xg
ON xg.account_key = cs.account_key
AND xg.generation = json_extract(cs.metadata_json, '$.archiveGeneration')
JOIN contacts c ON c.id = cs.contact_id
JOIN conversation_participants cp
ON cp.contact_id = c.id AND cp.is_self = 0 AND cp.is_active = 1
JOIN conversations conv
ON conv.id = cp.conversation_id
AND conv.platform = 'x'
AND conv.account_key = cs.account_key
AND conv.type = 'dm'
AND conv.is_active = 1
JOIN messages m ON m.conversation_id = conv.id AND m.is_deleted = 0
WHERE cs.platform = 'x'
AND json_extract(cs.metadata_json, '$.mutual') = 1
GROUP BY c.id, c.name
ORDER BY dm_messages DESC, last_dm_at DESC
LIMIT 50;
```

Mutual status is source metadata, not a memory: a newer archive generation can refresh it deterministically. Agents may create a memory from the DM content or verified public context, but should not duplicate `mutual = true` into prose.

## What the agent owns

The agent decides which candidates deserve work, which sources are sufficient, and which memories conflict. Cued supplies deterministic identity resolution, exhaustive memory history, evidence-bearing writes, invalidation, FTS, and provider provenance. Keeping that boundary means different agents can implement different CRM policies over the same auditable person graph without each shipping another contact database, sync daemon, or dashboard.
36 changes: 35 additions & 1 deletion src/skills/install.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,12 @@
import { chmodSync, mkdirSync, mkdtempSync, rmSync, writeFileSync } from "node:fs";
import {
chmodSync,
existsSync,
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, describe, expect, it, vi } from "vitest";
Expand Down Expand Up @@ -191,4 +199,30 @@ describe("cued skill installer", () => {
join(homeDir, ".nvm", "versions", "node", "v20.11.0", "bin", "npx"),
);
});

it("keeps the self-driving CRM reference and eval contracts in the Cued skill source", () => {
delete process.env.CUED_APP_PATH;
const skillRoot = resolveCuedSkillSourcePath();
expect(skillRoot).not.toBeNull();
if (!skillRoot) {
throw new Error("Cued skill source was not found");
}
const skill = readFileSync(join(skillRoot, "SKILL.md"), "utf8");
const referencePath = join(skillRoot, "references", "self-driving-crm.md");

expect(skill).toContain("references/self-driving-crm.md");
expect(existsSync(referencePath)).toBe(true);

const reference = readFileSync(referencePath, "utf8");
expect(reference).toContain("cued contacts memory show <contact-id> --all");
expect(reference).toContain("current_x_generation");
expect(reference).toContain("no_change");

const evals = JSON.parse(readFileSync(join(skillRoot, "evals", "evals.json"), "utf8")) as {
evals: Array<{ id: number; prompt: string }>;
};
expect(new Set(evals.evals.map((entry) => entry.id)).size).toBe(evals.evals.length);
expect(evals.evals.find((entry) => entry.id === 36)?.prompt).toContain("relationship radar");
expect(evals.evals.find((entry) => entry.id === 37)?.prompt).toContain("X mutuals");
});
});