Skip to content
Draft
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
287 changes: 100 additions & 187 deletions native/helpers/whatsapp-go/main.go

Large diffs are not rendered by default.

92 changes: 34 additions & 58 deletions native/helpers/whatsapp-go/main_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -497,44 +497,7 @@ func TestExtractTextUnwrapsFutureProofEditedMessage(t *testing.T) {
}
}

func TestHistoryMessageSnapshotFallbackRetainsReplyForwardAndRevokeMetadata(t *testing.T) {
forwardingScore := uint32(2)
historyMsg := &waHistorySync.HistorySyncMsg{
Message: &waWeb.WebMessageInfo{
Key: &waCommon.MessageKey{
ID: proto.String("wamid-reply"),
RemoteJID: proto.String("15551234567@s.whatsapp.net"),
FromMe: proto.Bool(false),
},
MessageTimestamp: proto.Uint64(1_710_000_000),
Message: &waProto.Message{
ExtendedTextMessage: &waProto.ExtendedTextMessage{
Text: proto.String("reply"),
ContextInfo: &waProto.ContextInfo{
StanzaID: proto.String("wamid-parent"),
Participant: proto.String("15557654321@s.whatsapp.net"),
IsForwarded: proto.Bool(true),
ForwardingScore: proto.Uint32(forwardingScore),
},
},
},
},
}

snapshot, ok := historyMessageSnapshot(nil, types.NewJID("15551234567", types.DefaultUserServer), historyMsg)
if !ok {
t.Fatal("expected fallback history snapshot")
}
if snapshot.ReplyToID == nil || *snapshot.ReplyToID != "wamid-parent" {
t.Fatalf("expected reply id metadata, got %+v", snapshot)
}
if snapshot.ReplyToSender == nil || *snapshot.ReplyToSender != "15557654321@s.whatsapp.net" {
t.Fatalf("expected reply sender metadata, got %+v", snapshot)
}
if !snapshot.IsForwarded || snapshot.ForwardingScore == nil || *snapshot.ForwardingScore != forwardingScore {
t.Fatalf("expected forwarded metadata, got %+v", snapshot)
}

func TestHistoryMessageSnapshotFallbackRetainsRevokeMetadata(t *testing.T) {
revokeType := waProto.ProtocolMessage_REVOKE
revokeSnapshot, ok := historyMessageSnapshot(nil, types.NewJID("15551234567", types.DefaultUserServer), &waHistorySync.HistorySyncMsg{
Message: &waWeb.WebMessageInfo{
Expand Down Expand Up @@ -688,13 +651,17 @@ func TestOpenSyncCacheMigratesLegacyMessageColumns(t *testing.T) {
message_id TEXT NOT NULL,
sender_jid TEXT,
participant_jid TEXT,
reply_to_message_id TEXT,
reply_to_sender_jid TEXT,
from_me INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
text TEXT NOT NULL,
push_name TEXT,
status TEXT,
delivered_at INTEGER,
read_at INTEGER,
is_forwarded INTEGER NOT NULL DEFAULT 0,
forwarding_score INTEGER,
updated_at INTEGER NOT NULL,
PRIMARY KEY (chat_jid, message_id)
)`)
Expand All @@ -717,6 +684,23 @@ func TestOpenSyncCacheMigratesLegacyMessageColumns(t *testing.T) {
if err != nil {
t.Fatalf("insert legacy message: %v", err)
}
_, err = db.Exec(`CREATE TABLE calls (
chat_jid TEXT NOT NULL,
call_id TEXT NOT NULL,
initiator_jid TEXT,
remote_jid TEXT,
from_me INTEGER NOT NULL,
timestamp INTEGER NOT NULL,
duration_seconds INTEGER,
status TEXT NOT NULL,
medium TEXT NOT NULL,
provider_call_type TEXT,
updated_at INTEGER NOT NULL,
PRIMARY KEY (chat_jid, call_id)
)`)
if err != nil {
t.Fatalf("create legacy calls table: %v", err)
}
if err := db.Close(); err != nil {
t.Fatalf("close legacy cache db: %v", err)
}
Expand All @@ -727,17 +711,24 @@ func TestOpenSyncCacheMigratesLegacyMessageColumns(t *testing.T) {
}
defer cache.close()

for _, column := range []string{"revoked"} {
if !sqliteColumnExists(t, cache.db, "messages", column) {
t.Fatalf("expected migrated messages.%s column", column)
}
}
for _, column := range []string{
"reply_to_message_id",
"reply_to_sender_jid",
"is_forwarded",
"forwarding_score",
"revoked",
} {
if !sqliteColumnExists(t, cache.db, "messages", column) {
t.Fatalf("expected migrated messages.%s column", column)
if sqliteColumnExists(t, cache.db, "messages", column) {
t.Fatalf("expected migrated messages.%s column to be removed", column)
}
}
if sqliteColumnExists(t, cache.db, "calls", "provider_call_type") {
t.Fatalf("expected migrated calls.provider_call_type column to be removed")
}

messages, _, _, err := cache.queryMessages(nil, &resyncCursor{SnapshotUpdatedAt: 1_710_000_000_001}, 10)
if err != nil {
Expand All @@ -746,11 +737,8 @@ func TestOpenSyncCacheMigratesLegacyMessageColumns(t *testing.T) {
if len(messages) != 1 {
t.Fatalf("expected legacy message to remain queryable, got %+v", messages)
}
if messages[0].IsForwarded || messages[0].Revoked {
t.Fatalf("expected legacy message defaults to be false, got %+v", messages[0])
}
if messages[0].ForwardingScore != nil || messages[0].ReplyToID != nil || messages[0].ReplyToSender != nil {
t.Fatalf("expected legacy nullable metadata defaults, got %+v", messages[0])
if messages[0].Revoked {
t.Fatalf("expected legacy message revoked default to be false, got %+v", messages[0])
}
}

Expand Down Expand Up @@ -1179,10 +1167,6 @@ func TestApplySnapshotDoesNotRewriteUnchangedMediaFile(t *testing.T) {
"messageID": "message-1",
"attachmentIndex": 0,
},
ProviderMeta: map[string]interface{}{
"kind": "image",
"width": 640,
},
}},
},
{
Expand All @@ -1198,10 +1182,6 @@ func TestApplySnapshotDoesNotRewriteUnchangedMediaFile(t *testing.T) {
"messageID": "message-2",
"attachmentIndex": 0,
},
ProviderMeta: map[string]interface{}{
"kind": "document",
"pageCount": 3,
},
}},
},
},
Expand Down Expand Up @@ -1253,10 +1233,6 @@ func TestApplySnapshotDoesNotRewriteUnchangedMediaFileAfterReload(t *testing.T)
"messageID": "message-1",
"attachmentIndex": 0,
},
ProviderMeta: map[string]interface{}{
"kind": "image",
"width": 640,
},
}},
},
},
Expand Down
13 changes: 0 additions & 13 deletions native/macos/CuedNative/Sources/CuedNative/main.swift
Original file line number Diff line number Diff line change
Expand Up @@ -130,14 +130,11 @@ struct CallHistoryRecord: Encodable {
let remoteAddress: String?
let remoteDisplayName: String?
let provider: String
let providerCallType: String?
let direction: String
let medium: String
let status: String
let startedAt: Int
let endedAt: Int?
let durationSeconds: Int?
let disconnectedCause: String?
let syntheticConversation: Bool
}

Expand Down Expand Up @@ -1020,13 +1017,6 @@ struct Command {
} else {
0
}
let endedAt: Int? =
if let durationSeconds {
startedAt + durationSeconds * 1000
} else {
nil
}

return CallHistoryRecord(
pk: row.pk,
sourceCallKey: nilIfEmpty(row.uniqueID) ?? "callhistory:\(row.pk)",
Expand All @@ -1035,7 +1025,6 @@ struct Command {
remoteAddress: remoteAddress,
remoteDisplayName: nilIfEmpty(row.name),
provider: provider,
providerCallType: row.callType.map(String.init),
direction: direction,
medium: normalizeCallMedium(provider: provider, callType: row.callType),
status: normalizeCallStatus(
Expand All @@ -1045,9 +1034,7 @@ struct Command {
durationSeconds: durationSeconds
),
startedAt: startedAt,
endedAt: endedAt,
durationSeconds: durationSeconds,
disconnectedCause: row.disconnectedCause.map(String.init),
syntheticConversation: chatID == nil
)
}
Expand Down
28 changes: 14 additions & 14 deletions skills/cued/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,14 +36,14 @@ Do not use `sqlite3 ~/.cued/local.db` unless the user explicitly asks to debug r
This is not a full schema reference. Use these tables when CLI commands are too coarse and you need exact counts, joins, ranking, or provenance. Prefer `cued` commands for mutations and attachment fetches.

- `contacts`: canonical people/entities. Key fields: `id`, `name`, `company`, `archived`.
- `contact_handles`: email/phone/platform handles. Key fields: `contact_id`, `type`, `value`, `normalized_value`, `platform`, `is_deterministic`.
- `contact_sources`: where a contact came from. Key fields: `contact_id`, `platform`, `account_key`, `source_entity_key`, `profile_url`, `first_seen_at`, `last_seen_at`.
- `contact_handles`: email/phone/platform handles. Key fields: `contact_id`, `type`, `value`, `normalized_value`, `is_deterministic`.
- `contact_sources`: where a contact came from. Key fields: `contact_id`, `platform`, `account_key`, `source_entity_key`.
- `contact_memories`: durable agent-written context. Query it for current memories with `stale_at IS NULL`; write through `cued contacts memory ...`, not SQL.
- `conversations`: canonical threads. Key fields: `id`, `platform`, `account_key`, `type`, `name`, `participant_names`, `last_message_at`, `last_message_preview`, `unread_count`.
- `conversations`: canonical threads. Key fields: `id`, `platform`, `account_key`, `source_conversation_key`, `type`, `is_active`, `name`, `participant_names`.
- `conversation_participants`: contact membership in threads. Key fields: `conversation_id`, `contact_id`, `participant_name`, `is_self`, `is_active`.
- `messages`: canonical message rows. Key fields: `id`, `platform`, `conversation_id`, `sender_contact_id`, `sender_name`, `sent_at`, `is_from_me`, `content`, `attachment_count`, `reaction_count`, `reply_to_message_id`.
- `message_reactions`: reactions/tapbacks. Key fields: `message_id`, `reactor_contact_id`, `reactor_name`, `emoji`, `is_active`, `created_at`.
- `message_attachments`: attachment metadata. Key fields: `id`, `message_id`, `platform`, `kind`, `mime_type`, `filename`, `title`, `size_bytes`, `text_content`, `access_kind`, `availability_status`.
- `messages`: canonical message rows. Key fields: `id`, `platform`, `conversation_id`, `sender_contact_id`, `sender_source_key`, `sender_name`, `conversation_name`, `sent_at`, `status`, `is_from_me`, `content`, `delivered_at`, `read_at`, `is_deleted`.
- `message_reactions`: reactions/tapbacks. Key fields: `message_id`, `reactor_source_key`, `emoji`, `is_active`, `created_at`.
- `message_attachments`: attachment metadata. Key fields: `id`, `message_id`, `platform`, `kind`, `mime_type`, `filename`, `title`, `size_bytes`, `text_content`, `access_kind`, `access_ref_json`.
- `attachment_content`: extracted text for fetched attachments. Key fields: `attachment_id`, `status`, `text_content`, `mime_type`, `extracted_at`, `last_error`. Prefer `cued attachments search`.
- `messages_fts`: FTS5 over message search fields: `sender_name`, `conversation_name`, `participant_names`, `attachment_text`, `content`.
- `attachment_content_fts`: FTS5 over extracted attachment text. Prefer `cued attachments search <query>`.
Expand All @@ -55,9 +55,9 @@ Useful SQL examples:
```bash
cued sql "select count(*) as messages from messages"
cued sql "select platform, count(*) as messages from messages group by platform order by messages desc"
cued sql "select id, platform, name, participant_names, datetime(last_message_at/1000,'unixepoch','localtime') as last_message from conversations order by last_message_at desc limit 20"
cued sql "select id, sender_name, conversation_name, datetime(sent_at/1000,'unixepoch','localtime') as sent, content, attachment_count from messages where conversation_id = 'conversation-id-here' order by sent_at desc limit 50"
cued sql "select ma.id, ma.kind, ma.mime_type, ma.filename, ma.size_bytes, ma.access_kind, ma.availability_status from message_attachments ma where ma.message_id = 'message-id-here'"
cued sql "select c.id, c.platform, c.name, c.participant_names, datetime(max(m.sent_at)/1000,'unixepoch','localtime') as last_message from conversations c left join messages m on m.conversation_id = c.id group by c.id order by max(m.sent_at) desc limit 20"
cued sql "select m.id, m.sender_name, m.conversation_name, datetime(m.sent_at/1000,'unixepoch','localtime') as sent, m.content, (select count(*) from message_attachments ma where ma.message_id = m.id) as attachments from messages m where m.conversation_id = 'conversation-id-here' order by m.sent_at desc limit 50"
cued sql "select ma.id, ma.kind, ma.mime_type, ma.filename, ma.size_bytes, ma.access_kind from message_attachments ma where ma.message_id = 'message-id-here'"
```

## Attachments
Expand All @@ -82,12 +82,12 @@ cued attachments search "search terms" --limit 20
```

Safety rules:
- Inspect `filename`, `mime_type`, `size_bytes`, `access_kind`, and `availability_status` before fetching.
- Inspect `filename`, `mime_type`, `size_bytes`, and `access_kind` before fetching.
- The default fetch path has a conservative byte ceiling to prevent accidental large downloads.
- Use `--allow-large` only when the user explicitly asks for the file or the task clearly depends on the bytes.
- Avoid fetching video, audio, archives, disk images, and opaque binary files unless the user asks for the file itself; agents usually cannot inspect them usefully.
- Use `--max-bytes` when you intentionally want a stricter ceiling for a one-off fetch.
- Treat `metadata_only`, `none`, or missing fetch coordinates as not currently fetchable.
- Treat `none` or missing fetch coordinates as not currently fetchable.
- Never paste private attachment text into fixtures or broad summaries; summarize only what is needed.

## Relationship Patterns
Expand All @@ -96,7 +96,7 @@ Safety rules:
- **Follow-up needed**: DM where I sent the last message, got no reply or reaction, and it's 3+ days old. If they reacted but didn't reply, it's lower priority.
- **Dormant relationship**: Contact with significant message history (10+ messages) but no messages in 30+ days.
- **Network search**: Use `messages_fts` to find contacts who've discussed a topic, grouped by `sender_contact_id`.
- **Unread triage**: Conversations with `unread_count > 0` where the last message is not from me. Prioritize DMs over groups.
- **Unread triage**: Messages where `read_at IS NULL` and `is_from_me = 0`, grouped by conversation. Prioritize DMs over groups and use the newest unread `sent_at` as the recency signal.

## Contact Management

Expand Down Expand Up @@ -144,15 +144,15 @@ Enrichment is a local-first identity task, not a web search task. Prioritize con

Queue candidates in this order:
- high-recency or high-volume human DMs with missing company/profile context;
- contacts with deterministic handles or profile URLs already in `contact_handles` / `contact_sources`;
- contacts with deterministic handles or profile URLs already in `contact_handles`, or platform identity in `contact_sources`;
- duplicate clusters where exact handles can enrich the canonical record;
- high-value contacts from recent meetings, calls, or messages that still lack public profile context.

Deprioritize or skip service senders, newsletters, stores, bots, OTP/verification senders, one-off contacts, family/private contacts unless explicitly requested, and contacts with only a name and no local relationship evidence.

Use this source order:
1. Local identity evidence: exact email, phone, LinkedIn URL/id, platform user id, profile URL, message history.
2. Existing public profile URL from `contact_sources.profile_url` or `contact_handles`.
2. Existing public profile URL or handle in `contact_handles`, or platform identity in `contact_sources.source_entity_key`.
3. Web search for the exact profile handle or exact full name plus known affiliation.
4. Cross-profile links from a verified source, such as a personal site linking GitHub/X/LinkedIn.

Expand Down
2 changes: 1 addition & 1 deletion skills/cued/evals/contact-memories.md
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,7 @@ handle_counts AS (
SELECT
contact_id,
SUM(CASE WHEN is_deterministic = 1 THEN 1 ELSE 0 END) AS deterministic_handles,
GROUP_CONCAT(DISTINCT type || ':' || COALESCE(platform, '') || ':' || value) AS handles
GROUP_CONCAT(DISTINCT type || ':' || value) AS handles
FROM contact_handles
GROUP BY contact_id
)
Expand Down
14 changes: 7 additions & 7 deletions skills/cued/evals/evals.json
Original file line number Diff line number Diff line change
Expand Up @@ -101,10 +101,10 @@
"expected_output": "A prioritized list of conversations with unread messages, ranked by importance signals like relationship strength and message volume.",
"files": [],
"expectations": [
"Queried conversations where unread_count > 0",
"Filtered to conversations where the last message was NOT sent by the user (is_from_me = 0)",
"Queried unread inbound messages where read_at IS NULL and is_from_me = 0",
"Grouped unread messages by conversation and checked the latest unread sender",
"Ordered by some importance signal (message frequency, recency, or DM vs group)",
"Returned conversation names, platforms, unread counts, and last message previews",
"Returned conversation names, platforms, unread message counts, and latest unread message previews",
"Distinguished between DM and group conversations, prioritizing DMs"
]
},
Expand Down Expand Up @@ -266,7 +266,7 @@
"files": [],
"expectations": [
"Skill was triggered by a generic message check without mentioning 'cued'",
"Queried recent messages or conversations with unread_count > 0",
"Queried recent messages and unread inbound messages using read_at IS NULL",
"Returned conversation names, platforms, and message previews",
"Ordered by recency"
]
Expand Down Expand Up @@ -305,7 +305,7 @@
"Skill was triggered by a contact info lookup without mentioning 'cued'",
"Searched contacts for 'Sarah Chen'",
"Queried contact_handles for type = 'phone' and type = 'linkedin'",
"Checked contact_sources for profile_url containing linkedin",
"Checked contact_handles and contact_sources source_entity_key values for LinkedIn identifiers",
"Returned the handle values"
]
},
Expand Down Expand Up @@ -396,7 +396,7 @@
"Identified a bounded set of contacts or duplicate clusters with missing or sparse profile fields",
"Structured the output as independent per-contact or per-cluster enrichment tasks rather than one monolithic pass",
"Searched contact_handles for email, phone, linkedin, and other relevant handle types",
"Checked contact_sources.profile_url and related metadata for enrichment signals",
"Checked contact_handles and contact_sources source_entity_key values for enrichment signals",
"Pulled enrichment candidates from duplicate records or cross-platform matches in local data",
"Returned only enrichments supported by local evidence",
"Did not claim that data had already been updated unless a real command was run"
Expand All @@ -421,7 +421,7 @@
"expected_output": "A list of contacts with latent enrichment opportunities from existing local metadata, including LinkedIn URLs/handles, company names, and other profile fields that can be surfaced.",
"files": [],
"expectations": [
"Queried contact_sources for profile_url and platform metadata that imply enrichable profile details",
"Queried contact_handles and contact_sources source_entity_key values that imply enrichable profile details",
"Queried contact_handles for linkedin, email, phone, and other relevant handle types",
"Matched local metadata back to the canonical contact",
"Returned concrete field/value enrichments rather than vague suggestions",
Expand Down
25 changes: 0 additions & 25 deletions src/core/types/provider.test.ts

This file was deleted.

Loading
Loading