contact_stats
Analyze iMessage conversation patterns for a specific contact by tracking message volumes, response times, and yearly trends to understand communication dynamics.
Instructions
Deep per-contact analytics: message volumes, response time estimates, conversation patterns, and yearly trends.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| contact | Yes | Contact handle or name fragment | |
| date_from | No | Start date | |
| date_to | No | End date |
Implementation Reference
- src/tools/analytics.ts:99-194 (handler)Complete implementation of the contact_stats tool - registers the tool with MCP server and defines the handler function that retrieves per-contact analytics including message volumes, response time estimates, conversation patterns, monthly trends, and hourly distributions.
server.tool( "contact_stats", "Deep per-contact analytics: message volumes, response time estimates, conversation patterns, and yearly trends.", { contact: z.string().describe("Contact handle or name fragment"), date_from: isoDateSchema.optional().describe("Start date"), date_to: isoDateSchema.optional().describe("End date"), }, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, async (params) => { const db = getDb(); const dateFilter = []; const bindings: Record<string, any> = {}; bindings.contact = `%${params.contact}%`; if (params.date_from) { dateFilter.push(`${DATE_EXPR} >= @date_from`); bindings.date_from = params.date_from; } if (params.date_to) { dateFilter.push(`${DATE_EXPR} <= @date_to`); bindings.date_to = params.date_to; } const extraWhere = dateFilter.length > 0 ? "AND " + dateFilter.join(" AND ") : ""; // Basic stats const stats = db.prepare(` SELECT h.id as handle, COUNT(*) as total, SUM(CASE WHEN m.is_from_me = 1 THEN 1 ELSE 0 END) as sent, SUM(CASE WHEN m.is_from_me = 0 THEN 1 ELSE 0 END) as received, ROUND(AVG(LENGTH(m.text)), 1) as avg_length, ROUND(AVG(CASE WHEN m.is_from_me = 1 THEN LENGTH(m.text) END), 1) as avg_sent_length, ROUND(AVG(CASE WHEN m.is_from_me = 0 THEN LENGTH(m.text) END), 1) as avg_received_length, MIN(${DATE_EXPR}) as first_message, MAX(${DATE_EXPR}) as last_message, SUM(m.cache_has_attachments) as attachment_count FROM message m JOIN handle h ON m.handle_id = h.ROWID WHERE h.id LIKE @contact AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL) ${MSG_FILTER} ${extraWhere} GROUP BY h.id ORDER BY total DESC `).all(bindings) as any[]; if (stats.length === 0) { return { content: [{ type: "text", text: `No messages found for "${params.contact}"` }] }; } // Monthly trend for top handle const topHandle = stats[0].handle; const monthly = db.prepare(` SELECT strftime('%Y-%m', ${DATE_EXPR}) as month, COUNT(*) as total, SUM(CASE WHEN m.is_from_me = 1 THEN 1 ELSE 0 END) as sent, SUM(CASE WHEN m.is_from_me = 0 THEN 1 ELSE 0 END) as received FROM message m JOIN handle h ON m.handle_id = h.ROWID WHERE h.id = @handle AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL) ${MSG_FILTER} ${extraWhere} GROUP BY month ORDER BY month `).all({ ...bindings, handle: topHandle }); // Hour-of-day distribution const hourly = db.prepare(` SELECT CAST(strftime('%H', ${DATE_EXPR}) AS INTEGER) as hour, COUNT(*) as total, SUM(CASE WHEN m.is_from_me = 1 THEN 1 ELSE 0 END) as sent, SUM(CASE WHEN m.is_from_me = 0 THEN 1 ELSE 0 END) as received FROM message m JOIN handle h ON m.handle_id = h.ROWID WHERE h.id = @handle AND (m.text IS NOT NULL OR m.attributedBody IS NOT NULL) ${MSG_FILTER} ${extraWhere} GROUP BY hour ORDER BY hour `).all({ ...bindings, handle: topHandle }); const contact = lookupContact(topHandle); return { content: [{ type: "text", text: JSON.stringify({ contact: { handle: topHandle, name: contact.name, tier: contact.tier }, stats: stats[0], monthly_trend: monthly, hourly_distribution: hourly, all_matching_handles: stats.length > 1 ? stats : undefined, }, null, 2), }], }; }, ); - src/tools/analytics.ts:102-106 (schema)Input schema definition for contact_stats tool - uses Zod to validate contact (string), date_from (optional ISO date), and date_to (optional ISO date) parameters.
{ contact: z.string().describe("Contact handle or name fragment"), date_from: isoDateSchema.optional().describe("Start date"), date_to: isoDateSchema.optional().describe("End date"), }, - src/helpers.ts:5-6 (schema)isoDateSchema definition - a Zod schema that validates ISO date strings in YYYY-MM-DD format, used for date filtering in contact_stats and other tools.
/** Zod schema for ISO date strings (YYYY-MM-DD) */ export const isoDateSchema = z.string().regex(/^\d{4}-\d{2}-\d{2}$/, "Must be ISO format: YYYY-MM-DD"); - src/contacts.ts:146-157 (helper)lookupContact helper function - resolves a handle (phone/email) to contact name using macOS AddressBook, falling back to raw handle. Used by contact_stats to enrich results with contact names and tiers.
export function lookupContact(handle: string): Contact { if (!handle) return { id: "", name: "(unknown)", tier: "unknown" }; const cleaned = handle.trim(); // Resolve from macOS AddressBook const name = resolveFromAddressBook(cleaned); if (name) { return { id: cleaned, name, tier: "known" }; } return { id: cleaned, name: cleaned, tier: "unknown" }; } - src/db.ts:87-94 (helper)getDb function - provides singleton access to the read-only SQLite database connection for iMessage chat.db, used by contact_stats to execute queries.
export function getDb(): Database.Database { if (!_db) { _db = new Database(CHAT_DB, { readonly: true, fileMustExist: true }); _db.pragma("journal_mode = WAL"); _db.pragma("query_only = ON"); } return _db; }