double_texts
Analyze iMessage conversations to identify double-texting patterns and unanswered message sequences, showing frequency, longest bursts, and contact comparisons for messaging behavior insights.
Instructions
Detect double-texting and unanswered message patterns. Finds when you (or a contact) sent multiple consecutive messages without a reply. Shows frequency, longest bursts, and who does it more. Omit contact for a global ranking of who you double-text the most.
Input Schema
TableJSON Schema
| Name | Required | Description | Default |
|---|---|---|---|
| contact | No | Contact handle or name (omit for global double-text ranking) | |
| min_consecutive | No | Minimum consecutive messages to count (default: 2) | |
| date_from | No | Start date (ISO) | |
| date_to | No | End date (ISO) | |
| limit | No | Max burst results (default 20) |
Implementation Reference
- src/tools/patterns.ts:220-386 (handler)Main handler function that implements the double_texts tool logic. Handles two modes: global ranking (showing who the user double-texts most) and contact-specific mode (showing detailed double-text patterns for a specific contact). Uses island-and-gaps SQL technique to detect consecutive message bursts.
async (params) => { const db = getDb(); const limit = clamp(params.limit ?? 20, 1, MAX_LIMIT); const minConsecutive = params.min_consecutive ?? 2; if (!params.contact) { // Global ranking: who does the user double-text most? const conditions = [ "(m.text IS NOT NULL OR m.attributedBody IS NOT NULL)", "m.associated_message_type = 0", ]; const bindings: Record<string, any> = { min_consecutive: minConsecutive, limit }; // Apply spam filter for global queries conditions.push(repliedToCondition()); if (params.date_from) { conditions.push(`${DATE_EXPR} >= @date_from`); bindings.date_from = params.date_from; } if (params.date_to) { conditions.push(`${DATE_EXPR} <= @date_to`); bindings.date_to = params.date_to; } const where = conditions.join(" AND "); const ranking = db.prepare(` WITH msgs AS ( SELECT m.is_from_me, ${DATE_EXPR} as date, h.id as handle, ROW_NUMBER() OVER (PARTITION BY h.id ORDER BY m.date) as rn, ROW_NUMBER() OVER (PARTITION BY h.id, m.is_from_me ORDER BY m.date) as part_rn FROM message m JOIN handle h ON m.handle_id = h.ROWID WHERE ${where} ), runs AS ( SELECT handle, is_from_me, COUNT(*) as consecutive FROM msgs GROUP BY handle, is_from_me, rn - part_rn HAVING consecutive >= @min_consecutive ) SELECT handle, SUM(CASE WHEN is_from_me = 1 THEN 1 ELSE 0 END) as your_double_texts, SUM(CASE WHEN is_from_me = 0 THEN 1 ELSE 0 END) as their_double_texts, MAX(consecutive) as max_burst FROM runs GROUP BY handle ORDER BY your_double_texts DESC LIMIT @limit `).all(bindings) as any[]; const enriched = ranking.map((row: any) => { const c = lookupContact(row.handle); return { handle: row.handle, name: c.name, your_double_texts: row.your_double_texts, their_double_texts: row.their_double_texts, max_burst: row.max_burst, }; }); return { content: [{ type: "text", text: JSON.stringify({ min_consecutive: minConsecutive, ranking: enriched }, null, 2), }], }; } // Contact-specific mode const conditions = [ "h.id LIKE @contact", "(m.text IS NOT NULL OR m.attributedBody IS NOT NULL)", "m.associated_message_type = 0", ]; const bindings: Record<string, any> = { contact: `%${params.contact}%`, min_consecutive: minConsecutive, limit, }; if (params.date_from) { conditions.push(`${DATE_EXPR} >= @date_from`); bindings.date_from = params.date_from; } if (params.date_to) { conditions.push(`${DATE_EXPR} <= @date_to`); bindings.date_to = params.date_to; } const where = conditions.join(" AND "); // Find consecutive message bursts using island-and-gaps const bursts = db.prepare(` WITH msgs AS ( SELECT m.is_from_me, ${DATE_EXPR} as date, ROW_NUMBER() OVER (ORDER BY m.date) as rn, ROW_NUMBER() OVER (PARTITION BY m.is_from_me ORDER BY m.date) as part_rn FROM message m JOIN handle h ON m.handle_id = h.ROWID WHERE ${where} ) SELECT is_from_me, COUNT(*) as consecutive, MIN(date) as first_msg, MAX(date) as last_msg FROM msgs GROUP BY is_from_me, rn - part_rn HAVING consecutive >= @min_consecutive ORDER BY consecutive DESC LIMIT @limit `).all(bindings) as any[]; // Summary stats const summary = db.prepare(` WITH msgs AS ( SELECT m.is_from_me, ${DATE_EXPR} as date, ROW_NUMBER() OVER (ORDER BY m.date) as rn, ROW_NUMBER() OVER (PARTITION BY m.is_from_me ORDER BY m.date) as part_rn FROM message m JOIN handle h ON m.handle_id = h.ROWID WHERE ${where} ), runs AS ( SELECT is_from_me, COUNT(*) as consecutive FROM msgs GROUP BY is_from_me, rn - part_rn HAVING consecutive >= @min_consecutive ) SELECT is_from_me, COUNT(*) as times, ROUND(AVG(consecutive), 1) as avg_burst, MAX(consecutive) as max_burst FROM runs Group BY is_from_me `).all(bindings) as any[]; const contact = lookupContact(params.contact); const formattedSummary: Record<string, any> = {}; for (const s of summary as any[]) { formattedSummary[s.is_from_me ? "you" : contact.name] = { times: s.times, avg_burst: s.avg_burst, max_burst: s.max_burst, }; } return { content: [{ type: "text", text: JSON.stringify({ contact: contact.name, min_consecutive: minConsecutive, summary: formattedSummary, top_bursts: bursts.map((b: any) => ({ from: b.is_from_me ? "you" : contact.name, consecutive_messages: b.consecutive, first_msg: b.first_msg, last_msg: b.last_msg, })), }, null, 2), }], }; }, - src/tools/patterns.ts:208-219 (registration)Tool registration with name, description, input schema (using zod validation), and handler hints for the double_texts tool.
// -- double_texts -- server.tool( "double_texts", "Detect double-texting and unanswered message patterns. Finds when you (or a contact) sent multiple consecutive messages without a reply. Shows frequency, longest bursts, and who does it more. Omit contact for a global ranking of who you double-text the most.", { contact: z.string().optional().describe("Contact handle or name (omit for global double-text ranking)"), min_consecutive: z.number().optional().describe("Minimum consecutive messages to count (default: 2)"), date_from: isoDateSchema.optional().describe("Start date (ISO)"), date_to: isoDateSchema.optional().describe("End date (ISO)"), limit: z.number().optional().describe("Max burst results (default 20)"), }, { readOnlyHint: true, destructiveHint: false, openWorldHint: false }, - src/helpers.ts:5-6 (schema)Zod schema for ISO date strings (YYYY-MM-DD format) used by the double_texts tool for date_from and date_to parameters.
/** 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/index.ts:22-22 (registration)Import of registerPatternTools function which registers the double_texts tool along with other pattern tools (who_initiates, streaks, conversation_gaps, forgotten_contacts).
import { registerPatternTools } from "./tools/patterns.js";