imsg-mcp 1.3.2 → 1.4.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,19 +1,19 @@
1
1
  #!/usr/bin/env node
2
- import { execFile } from "node:child_process";
3
- import { existsSync, mkdirSync, statSync } from "node:fs";
2
+ import { execFileSync, execFile } from "node:child_process";
3
+ import { existsSync, mkdirSync, readdirSync, readFileSync, writeFileSync, renameSync, chmodSync, statSync, mkdtempSync, rmSync } from "node:fs";
4
4
  import { join, dirname, isAbsolute } from "node:path";
5
5
  import { promisify } from "node:util";
6
6
  import { Server } from "@modelcontextprotocol/sdk/server/index.js";
7
7
  import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
8
8
  import { ListToolsRequestSchema, CallToolRequestSchema, ListResourcesRequestSchema, ListResourceTemplatesRequestSchema, ReadResourceRequestSchema } from "@modelcontextprotocol/sdk/types.js";
9
- import { c as checkLocalAccess, f as formatAccessReport, s as shutdown, g as getImsgDbPath, a as getContactsDbPaths, b as getSlugsDbPath, d as appendLog, e as getFileLogLines, h as getLogDirectory, p as perf, i as checkMessagesAvailable, j as sendToChat, k as sendToChatId, l as sendMessageReliable, m as sendMessageAlt, n as sendAttachment, o as checkImessageAvailability, q as installShutdownHandlers, r as registerCleanup, t as enableStdinEofDetection, u as enableOrphanWatchdog, v as logStartup, w as info, x as startHeapMonitor, y as getLogs, z as setLastSendError, A as logShutdown, B as stopHeapMonitor, C as getLogFilePath, D as getLastSendError } from "./shutdown-pPAPSnk3.js";
10
- import { m as minMessageId, r as resolveRecipient, d as defaultCountryFromEnv, a as rankFuzzy, b as dispatchAnalytic, i as installWatchdog, n as noteActivity, c as readWatchdogState } from "./watchdog-Cbgm050b.js";
11
- import { homedir } from "node:os";
9
+ import { g as getHumansDirPath, s as sanitizeSlugPart, c as checkLocalAccess, f as formatAccessReport, a as shutdown, b as getImsgDbPath, d as getContactsDbPaths, e as getSlugsDbPath, h as appendLog, i as getFileLogLines, j as getLogDirectory, p as perf, k as checkMessagesAvailable, l as sendToChat, m as sendToChatId, n as sendMessageReliable, o as sendMessageAlt, q as sendAttachment, r as checkImessageAvailability, t as installShutdownHandlers, u as registerCleanup, v as enableStdinEofDetection, w as enableOrphanWatchdog, x as logStartup, y as info, z as startHeapMonitor, A as getLogs, B as setLastSendError, C as logShutdown, D as stopHeapMonitor, E as getLogFilePath, F as getLastSendError } from "./shutdown-CqGQKFmr.js";
10
+ import { m as minMessageId, r as resolveRecipient, d as defaultCountryFromEnv, a as rankFuzzy, c as computeRelationshipLeaderboard, b as dispatchAnalytic, i as installWatchdog, n as noteActivity, e as readWatchdogState } from "./watchdog-CizaS0Jv.js";
11
+ import { homedir, tmpdir } from "node:os";
12
12
  import Database from "better-sqlite3";
13
- import { I as IMessageDB, n as normalizedPhoneVariants, h as hasNativeModule } from "./imessage-db-Cz8MIIpG.js";
13
+ import { n as normalizedPhoneVariants, I as IMessageDB, h as hasNativeModule } from "./imessage-db-C8WKHFQA.js";
14
14
  import { streamExport } from "./exportStream-BCoZYUUZ.js";
15
15
  import { z } from "zod";
16
- import { A as APP_VERSION, a as APP_NAME } from "./meta-DzE7CSrZ.js";
16
+ import { A as APP_VERSION, a as APP_NAME } from "./meta-CGz9n7A2.js";
17
17
  import { randomUUID } from "node:crypto";
18
18
  import { parseUserDate } from "./dateParse-DJXMfq3a.js";
19
19
  const DEFAULT_TTL_MS = 24 * 60 * 60 * 1e3;
@@ -88,6 +88,238 @@ function resolveContactSelector(selector) {
88
88
  }
89
89
  return null;
90
90
  }
91
+ function computePersonSlug(name, taken) {
92
+ const base = sanitizeSlugPart(name) || "person";
93
+ if (!taken.has(base)) return base;
94
+ for (let n = 2; ; n++) {
95
+ const candidate = `${base}-${n}`;
96
+ if (!taken.has(candidate)) return candidate;
97
+ }
98
+ }
99
+ function handleKeys$1(handle) {
100
+ const trimmed = handle.trim();
101
+ if (trimmed.includes("@")) return [trimmed.toLowerCase()];
102
+ return normalizedPhoneVariants(trimmed);
103
+ }
104
+ function parseFrontmatterHandles(content) {
105
+ const fm = content.match(/^---\n([\s\S]*?)\n---/);
106
+ if (!fm?.[1]) return [];
107
+ const lines = fm[1].split("\n");
108
+ const handles = [];
109
+ let inHandles = false;
110
+ for (const line of lines) {
111
+ if (/^handles:\s*$/.test(line)) {
112
+ inHandles = true;
113
+ continue;
114
+ }
115
+ if (inHandles) {
116
+ const item = line.match(/^\s+-\s+["']?([^"']+)["']?\s*$/);
117
+ if (item?.[1]) {
118
+ handles.push(item[1].trim());
119
+ continue;
120
+ }
121
+ inHandles = false;
122
+ }
123
+ const flow = line.match(/^handles:\s*\[(.*)\]\s*$/);
124
+ if (flow?.[1]) {
125
+ for (const part of flow[1].split(",")) {
126
+ const v = part.trim().replace(/^["']|["']$/g, "");
127
+ if (v) handles.push(v);
128
+ }
129
+ }
130
+ }
131
+ return handles;
132
+ }
133
+ class HumansScaffold {
134
+ constructor(dir = getHumansDirPath()) {
135
+ this.dir = dir;
136
+ }
137
+ /** All existing files' slugs (filenames minus .md). */
138
+ existingSlugs() {
139
+ if (!existsSync(this.dir)) return /* @__PURE__ */ new Set();
140
+ return new Set(
141
+ readdirSync(this.dir).filter((f) => f.endsWith(".md")).map((f) => f.slice(0, -3))
142
+ );
143
+ }
144
+ /**
145
+ * Find an existing file whose frontmatter handles overlap any of `handles`.
146
+ * Returns its slug, or null. Linear scan — the directory holds tens of
147
+ * files, not thousands.
148
+ */
149
+ findByHandleOverlap(handles) {
150
+ if (!existsSync(this.dir)) return null;
151
+ const targetKeys = new Set(handles.flatMap(handleKeys$1));
152
+ for (const file of readdirSync(this.dir)) {
153
+ if (!file.endsWith(".md")) continue;
154
+ try {
155
+ const content = readFileSync(join(this.dir, file), "utf8");
156
+ const fileHandles = parseFrontmatterHandles(content);
157
+ if (fileHandles.some((h) => handleKeys$1(h).some((k) => targetKeys.has(k)))) {
158
+ return file.slice(0, -3);
159
+ }
160
+ } catch {
161
+ }
162
+ }
163
+ return null;
164
+ }
165
+ /**
166
+ * Create the conventional skeleton. Never overwrites: an existing file for
167
+ * the same person (by handle overlap) or same slug is returned untouched.
168
+ */
169
+ scaffold(init) {
170
+ const existing = this.findByHandleOverlap(init.handles);
171
+ if (existing) {
172
+ return { slug: existing, path: join(this.dir, `${existing}.md`), created: false };
173
+ }
174
+ const base = computePersonSlug(init.name, /* @__PURE__ */ new Set());
175
+ const basePath = join(this.dir, `${base}.md`);
176
+ if (existsSync(basePath)) {
177
+ try {
178
+ if (parseFrontmatterHandles(readFileSync(basePath, "utf8")).length === 0) {
179
+ return { slug: base, path: basePath, created: false };
180
+ }
181
+ } catch {
182
+ }
183
+ }
184
+ const slug = computePersonSlug(init.name, this.existingSlugs());
185
+ const path = join(this.dir, `${slug}.md`);
186
+ if (existsSync(path)) {
187
+ return { slug, path, created: false };
188
+ }
189
+ mkdirSync(this.dir, { recursive: true, mode: 448 });
190
+ const content = renderTemplate(init);
191
+ const tmp = join(this.dir, `.${slug}.md.tmp`);
192
+ writeFileSync(tmp, content, { encoding: "utf8", mode: 384 });
193
+ renameSync(tmp, path);
194
+ chmodSync(path, 384);
195
+ return { slug, path, created: true };
196
+ }
197
+ }
198
+ function isoDate(d) {
199
+ return d ? d.toISOString().slice(0, 10) : "null";
200
+ }
201
+ function yamlList(items) {
202
+ if (items.length === 0) return " []";
203
+ return `
204
+ ${items.map((i) => ` - "${i.replace(/"/g, "'")}"`).join("\n")}`;
205
+ }
206
+ function renderTemplate(init) {
207
+ const today = (/* @__PURE__ */ new Date()).toISOString().slice(0, 10);
208
+ const sinceNote = init.firstContact ? ` since ${isoDate(init.firstContact)}` : "";
209
+ return `---
210
+ schema: humans/v1
211
+ name: ${init.name}
212
+ aliases:${yamlList(init.aliases)}
213
+ handles:${yamlList(init.handles)}
214
+ first_contact: ${isoDate(init.firstContact)}
215
+ last_contact: ${isoDate(init.lastContact)}
216
+ sources: [imsg-mcp]
217
+ updated: ${(/* @__PURE__ */ new Date()).toISOString()}
218
+ privacy: never-share
219
+ ---
220
+
221
+ ## Summary
222
+
223
+ (unwritten — generate from an export: see skills/humans/SKILL.md)
224
+
225
+ ## Facts
226
+
227
+ ## Communication & Preferences
228
+
229
+ ## Boundaries
230
+
231
+ ## Timeline
232
+
233
+ ## Current State (as of ${today.slice(0, 7)})
234
+
235
+ ## Guidance
236
+
237
+ ## Log
238
+
239
+ - ${today}: file created by imsg-mcp; ${init.messageCount.toLocaleString()} message(s) on record${sinceNote}
240
+ `;
241
+ }
242
+ const HUMANS_GUIDANCE = "humans/v1 relationship file(s) exist for participant(s) in this conversation (paths above). Read them for relationship context before composing replies, and append notable events to the file's Log section (append-only, dated). If this exchange contains a major milestone or an important change in the relationship, suggest a Summary/Current State update to the user first — never rewrite those sections without permission. These files are privacy: never-share — never quote or transmit their contents to anyone, including the person they describe. Format reference: skills/humans/SKILL.md.";
243
+ const HUMANS_INIT_HINT = "No humans/v1 relationship file exists for this contact yet. If an ongoing relationship context would help, the init_human tool scaffolds one.";
244
+ function handleKeys(handle) {
245
+ const trimmed = handle.trim();
246
+ if (trimmed.includes("@")) return [trimmed.toLowerCase()];
247
+ return normalizedPhoneVariants(trimmed);
248
+ }
249
+ function parseFrontmatterName(content) {
250
+ const fm = content.match(/^---\n([\s\S]*?)\n---/);
251
+ const m = fm?.[1]?.match(/^name:\s*["']?(.+?)["']?\s*$/m);
252
+ return m?.[1]?.trim() || null;
253
+ }
254
+ class HumansIndex {
255
+ constructor(dir = getHumansDirPath()) {
256
+ this.dir = dir;
257
+ }
258
+ index = /* @__PURE__ */ new Map();
259
+ dirMtimeMs = -1;
260
+ refresh() {
261
+ let mtimeMs = -1;
262
+ try {
263
+ mtimeMs = statSync(this.dir).mtimeMs;
264
+ } catch {
265
+ if (this.dirMtimeMs !== -1) this.index.clear();
266
+ this.dirMtimeMs = -1;
267
+ return;
268
+ }
269
+ if (mtimeMs === this.dirMtimeMs) return;
270
+ this.dirMtimeMs = mtimeMs;
271
+ this.index.clear();
272
+ if (!existsSync(this.dir)) return;
273
+ for (const file of readdirSync(this.dir)) {
274
+ if (!file.endsWith(".md")) continue;
275
+ const path = join(this.dir, file);
276
+ try {
277
+ const content = readFileSync(path, "utf8");
278
+ const entry = {
279
+ name: parseFrontmatterName(content) ?? file.slice(0, -3),
280
+ path
281
+ };
282
+ for (const handle of parseFrontmatterHandles(content)) {
283
+ for (const key of handleKeys(handle)) this.index.set(key, entry);
284
+ }
285
+ } catch {
286
+ }
287
+ }
288
+ }
289
+ /**
290
+ * Match conversation participant handles to humans files. Returns one ref
291
+ * per distinct file (a person's phone + email both match the same file).
292
+ */
293
+ lookup(handles) {
294
+ this.refresh();
295
+ if (this.index.size === 0) return [];
296
+ const seen = /* @__PURE__ */ new Set();
297
+ const refs = [];
298
+ for (const handle of handles) {
299
+ for (const key of handleKeys(handle)) {
300
+ const entry = this.index.get(key);
301
+ if (entry && !seen.has(entry.path)) {
302
+ seen.add(entry.path);
303
+ refs.push({ handle, name: entry.name, path: entry.path });
304
+ }
305
+ }
306
+ }
307
+ return refs;
308
+ }
309
+ /** Full hint object for structured output, or null when nothing matches. */
310
+ hintFor(handles) {
311
+ const files = this.lookup(handles);
312
+ if (files.length === 0) return null;
313
+ return { files, guidance: HUMANS_GUIDANCE };
314
+ }
315
+ }
316
+ function humansHintText(hint) {
317
+ const list = hint.files.map((f) => `${f.name}: ${f.path}`).join("; ");
318
+ return `
319
+
320
+ _Relationship file(s): ${list}_
321
+ _${hint.guidance}_`;
322
+ }
91
323
  const ignoreOverride = /* @__PURE__ */ Symbol("Let zodToJsonSchema decide on which parser to use");
92
324
  const defaultOptions = {
93
325
  name: void 0,
@@ -1323,6 +1555,7 @@ const ReplyContextSchema = z.object({
1323
1555
  replyToText: z.string().nullable().optional()
1324
1556
  });
1325
1557
  const AttachmentSchema = z.object({
1558
+ rowId: z.number().int().optional().describe("Feed to get_attachment to fetch/view the file."),
1326
1559
  filename: z.string(),
1327
1560
  mimeType: z.string().nullable(),
1328
1561
  transferName: z.string().nullable(),
@@ -1340,6 +1573,7 @@ const MessageSchema = z.object({
1340
1573
  dateDelivered: z.string().nullable(),
1341
1574
  isRead: z.boolean(),
1342
1575
  isDelivered: z.boolean(),
1576
+ sendError: z.number().int().optional().describe("Non-zero chat.db error code — this from-me message FAILED to send."),
1343
1577
  chatId: z.string(),
1344
1578
  service: z.enum(["iMessage", "SMS"]),
1345
1579
  isReaction: z.boolean(),
@@ -1365,7 +1599,20 @@ const ConversationSchema = z.object({
1365
1599
  unreadCount: z.number().int(),
1366
1600
  threadSlug: z.string(),
1367
1601
  isGroupChat: z.boolean(),
1368
- serviceType: z.enum(["iMessage", "SMS"])
1602
+ serviceType: z.enum(["iMessage", "SMS"]),
1603
+ humansFiles: z.array(z.string()).optional().describe(
1604
+ "Paths to humans/v1 relationship files for participants of this conversation, when they exist. Read for context; see skills/humans/SKILL.md."
1605
+ )
1606
+ });
1607
+ const HumansHintSchema = z.object({
1608
+ files: z.array(
1609
+ z.object({
1610
+ handle: z.string(),
1611
+ name: z.string(),
1612
+ path: z.string()
1613
+ })
1614
+ ),
1615
+ guidance: z.string()
1369
1616
  });
1370
1617
  const GetMessagesSchema = z.object({
1371
1618
  limit: z.number().int().min(0).default(20).describe(
@@ -1379,7 +1626,8 @@ const GetMessagesOutputSchema = z.object({
1379
1626
  messages: z.array(MessageSchema),
1380
1627
  count: z.number().int(),
1381
1628
  hasMore: z.boolean(),
1382
- oldestMessageId: z.number().int().optional()
1629
+ oldestMessageId: z.number().int().optional(),
1630
+ humans: HumansHintSchema.optional()
1383
1631
  });
1384
1632
  const ExportMessagesSchema = z.object({
1385
1633
  chatIdentifier: nonEmptyString("Phone number, email, or chat ID").optional(),
@@ -1425,6 +1673,9 @@ const SendMessageOutputSchema = z.object({
1425
1673
  timestamp: z.string().nullable().optional(),
1426
1674
  threadSlug: z.string().optional(),
1427
1675
  lastMessageId: z.number().int().optional(),
1676
+ sendConfirmed: z.boolean().optional().describe(
1677
+ "True when the sent message was observed in chat.db before returning — lastMessageId then points exactly at the sent message."
1678
+ ),
1428
1679
  attachments: z.array(
1429
1680
  z.object({
1430
1681
  path: z.string(),
@@ -1438,12 +1689,17 @@ const WaitForReplySchema = z.object({
1438
1689
  threadSlug: nonEmptyString("Thread slug from list_conversations to monitor").optional(),
1439
1690
  timeoutSeconds: z.number().min(10).max(3600).default(300),
1440
1691
  pollIntervalSeconds: z.number().min(5).max(60).default(10),
1441
- afterMessageId: z.number().int().positive().optional()
1692
+ afterMessageId: z.number().int().positive().optional(),
1693
+ includeSelf: z.boolean().default(true).describe(
1694
+ "Also return messages the user sends from their own account (other devices) — they may be addressing the agent. The agent's own just-sent messages are always excluded. false = incoming-only (legacy behavior)."
1695
+ )
1442
1696
  });
1443
1697
  const WaitForReplyOutputSchema = z.object({
1444
1698
  received: z.boolean().optional(),
1445
1699
  messages: z.array(MessageSchema).optional(),
1446
1700
  count: z.number().int().optional(),
1701
+ selfCount: z.number().int().optional(),
1702
+ humans: HumansHintSchema.optional(),
1447
1703
  timedOut: z.boolean().optional(),
1448
1704
  timeoutSeconds: z.number().optional(),
1449
1705
  threadSlug: z.string().optional(),
@@ -1570,7 +1826,11 @@ const GetContactOutputSchema = z.object({
1570
1826
  handle: z.string(),
1571
1827
  threadSlug: z.string().nullable()
1572
1828
  })
1573
- )
1829
+ ),
1830
+ /** Path to this person's humans/v1 relationship file, when one exists. */
1831
+ humansFile: z.string().nullable().optional(),
1832
+ /** Standing instruction for using (or creating) the relationship file. */
1833
+ humansGuidance: z.string().optional()
1574
1834
  });
1575
1835
  const ResolveHandleSchema = z.object({
1576
1836
  handle: nonEmptyString("Phone number or email to resolve to a contact name.")
@@ -1611,6 +1871,30 @@ const SearchAttachmentsOutputSchema = z.object({
1611
1871
  attachments: z.array(AttachmentRecordSchema),
1612
1872
  count: z.number().int()
1613
1873
  });
1874
+ const InitHumanSchema = z.object({
1875
+ contact: nonEmptyString(
1876
+ "Contact name, phone, email, or contact:N selector from a prior search_contacts"
1877
+ ).optional(),
1878
+ threadSlug: nonEmptyString("Thread slug from list_conversations").optional(),
1879
+ top: z.coerce.number().int().min(1).max(25).optional().describe(
1880
+ "Scaffold files for the user's top N relationships (ranked by the relationship_leaderboard analytic over the last year)."
1881
+ )
1882
+ }).refine((d) => [d.contact, d.threadSlug, d.top].filter((v) => v !== void 0).length === 1, {
1883
+ message: "Provide exactly one of: contact, threadSlug, or top."
1884
+ });
1885
+ const InitHumanOutputSchema = z.object({
1886
+ results: z.array(
1887
+ z.object({
1888
+ slug: z.string(),
1889
+ name: z.string(),
1890
+ path: z.string(),
1891
+ created: z.boolean(),
1892
+ messageCount: z.number().int().optional()
1893
+ })
1894
+ ),
1895
+ count: z.number().int(),
1896
+ humansDir: z.string()
1897
+ });
1614
1898
  const ChatAnalyticsSchema = z.object({
1615
1899
  type: z.enum([
1616
1900
  "messaging_streaks",
@@ -1618,7 +1902,8 @@ const ChatAnalyticsSchema = z.object({
1618
1902
  "response_time_stats",
1619
1903
  "daily_heatmap",
1620
1904
  "tapback_summary",
1621
- "year_in_review_wrapped"
1905
+ "year_in_review_wrapped",
1906
+ "relationship_leaderboard"
1622
1907
  ]).describe(
1623
1908
  "Which analytic to compute. Six priority types are implemented; 20 more are reserved for future versions and return a structured 'not_yet_implemented' error."
1624
1909
  ),
@@ -1643,7 +1928,10 @@ const GetAttachmentOutputSchema = z.object({
1643
1928
  totalBytes: z.number().int(),
1644
1929
  inline: z.boolean(),
1645
1930
  base64: z.string().optional(),
1646
- converted: z.string().optional().describe("Set when source was HEIC and we converted to PNG.")
1931
+ converted: z.string().optional().describe("Set when source was HEIC and we converted to PNG."),
1932
+ mediaInfo: z.string().optional().describe("Compact duration/dimensions/codec summary for audio/video (via mdls)."),
1933
+ transcript: z.string().optional().describe("Audio transcript when an optional transcriber (hear/yap/whisper-cli) is installed."),
1934
+ imageBlockIncluded: z.boolean().optional().describe("True when the tool result carries a real MCP image content block.")
1647
1935
  });
1648
1936
  const noArgsSchema = { type: "object", properties: {} };
1649
1937
  const annotations = {
@@ -1680,6 +1968,8 @@ const annotations = {
1680
1968
  };
1681
1969
  const TOOL_TIMEOUTS_MS = {
1682
1970
  wait_for_reply: 0,
1971
+ init_human: 12e4,
1972
+ // top-N path runs a year-window analytic
1683
1973
  run_build: 12e4,
1684
1974
  search_messages: 6e4,
1685
1975
  get_messages: 6e4,
@@ -1708,7 +1998,7 @@ function resolveLimit(limit, defaultValue = 20) {
1708
1998
  const TOOLS = [
1709
1999
  {
1710
2000
  name: "get_messages",
1711
- description: "Get recent iMessages. Optionally filter by conversation. Response footer includes oldestMessageId for beforeMessageId pagination; use export_messages for very large histories.",
2001
+ description: "Get recent iMessages. Optionally filter by conversation. Response footer includes oldestMessageId for beforeMessageId pagination; use export_messages for very large histories. When a humans/v1 relationship file exists for the conversation's participants, the response includes its path and usage guidance (`humans`).",
1712
2002
  annotations: annotations.read,
1713
2003
  inputSchema: {
1714
2004
  type: "object",
@@ -1788,7 +2078,7 @@ const TOOLS = [
1788
2078
  },
1789
2079
  {
1790
2080
  name: "wait_for_reply",
1791
- description: "Wait for a new incoming message in a conversation until timeout or client cancellation.",
2081
+ description: "Wait for new messages in a conversation until timeout or client cancellation. Returns the other party's replies AND (by default) messages the user sends from their own account on other devices — marked isFromMe: true, counted in selfCount — since the user may be interjecting to address the agent. The agent's own just-sent message never echoes back. Pass includeSelf: false for incoming-only. When a humans/v1 relationship file exists for the participants, the response includes its path and usage guidance (`humans`) — consult it before composing a reply.",
1792
2082
  annotations: annotations.read,
1793
2083
  inputSchema: {
1794
2084
  type: "object",
@@ -1805,7 +2095,12 @@ const TOOLS = [
1805
2095
  default: 10,
1806
2096
  description: "Polling interval in seconds, 5-60"
1807
2097
  },
1808
- afterMessageId: { type: "number", description: "Only return messages after this id" }
2098
+ afterMessageId: { type: "number", description: "Only return messages after this id" },
2099
+ includeSelf: {
2100
+ type: "boolean",
2101
+ default: true,
2102
+ description: "Also return the user's own messages sent from other devices (the agent's own sends are always excluded). false = incoming-only."
2103
+ }
1809
2104
  }
1810
2105
  },
1811
2106
  // @ts-expect-error
@@ -1938,7 +2233,7 @@ const TOOLS = [
1938
2233
  },
1939
2234
  {
1940
2235
  name: "get_contact",
1941
- description: "Fetch a single contact by handle (phone/email) or by numeric id, including each handle's thread slug (for send_message/get_messages). Returns null if not found.",
2236
+ description: "Fetch a single contact by handle (phone/email) or by numeric id, including each handle's thread slug (for send_message/get_messages). Returns null if not found. Includes `humansFile` — the path to this person's humans/v1 relationship file if one exists (read it for relationship context; init_human scaffolds one otherwise).",
1942
2237
  annotations: annotations.read,
1943
2238
  inputSchema: {
1944
2239
  type: "object",
@@ -2004,7 +2299,7 @@ const TOOLS = [
2004
2299
  },
2005
2300
  {
2006
2301
  name: "get_attachment",
2007
- description: "Fetch an attachment by ROWID (from search_attachments). Returns base64 inline if ≤ inlineMaxBytes (default 5MB); otherwise returns the resolved path only. HEIC images are auto-converted to PNG via macOS sips.",
2302
+ description: "Fetch an attachment by ROWID (from search_attachments or a message's attachments[].rowId). IMAGES: returned as a real MCP image content block (downscaled ≤1536px, HEIC auto-converted) so the model can SEE it — plus full-size base64 in structuredContent when ≤ inlineMaxBytes (default 5MB). VIDEO: QuickLook poster frame as an image block + duration/resolution metadata + file path. AUDIO (voice memos): metadata + file path, and a transcript when a transcriber (hear, yap, or whisper-cli) is installed on PATH.",
2008
2303
  annotations: annotations.read,
2009
2304
  inputSchema: {
2010
2305
  type: "object",
@@ -2019,7 +2314,7 @@ const TOOLS = [
2019
2314
  },
2020
2315
  {
2021
2316
  name: "chat_analytics",
2022
- description: "Compute analytics over your chat history. Pick a `type`: messaging_streaks, double_texts, response_time_stats, daily_heatmap, tapback_summary, or year_in_review_wrapped. Results are cached at ~/.imsg-mcp/analytics-cache.db keyed on (type, args, MAX(message.rowid)) so subsequent calls without new messages hit cache. 20 additional analytic types are reserved for future versions.",
2317
+ description: "Compute analytics over your chat history. Pick a `type`: messaging_streaks, double_texts, response_time_stats, daily_heatmap, tapback_summary, year_in_review_wrapped, or relationship_leaderboard (top relationships by volume, reciprocity, and recency). Results are cached at ~/.imsg-mcp/analytics-cache.db keyed on (type, args, MAX(message.rowid)) so subsequent calls without new messages hit cache. 20 additional analytic types are reserved for future versions.",
2023
2318
  annotations: annotations.read,
2024
2319
  inputSchema: {
2025
2320
  type: "object",
@@ -2033,7 +2328,8 @@ const TOOLS = [
2033
2328
  "response_time_stats",
2034
2329
  "daily_heatmap",
2035
2330
  "tapback_summary",
2036
- "year_in_review_wrapped"
2331
+ "year_in_review_wrapped",
2332
+ "relationship_leaderboard"
2037
2333
  ]
2038
2334
  },
2039
2335
  windowDays: {
@@ -2045,6 +2341,32 @@ const TOOLS = [
2045
2341
  },
2046
2342
  // @ts-expect-error
2047
2343
  outputSchema: zodToJsonSchema(ChatAnalyticsOutputSchema)
2344
+ },
2345
+ {
2346
+ name: "init_human",
2347
+ description: "Scaffold a humans/v1 relationship file (~/.agents/humans/<person>.md — see the humans skill) for a contact, a thread slug, or the user's top N relationships. Prefills identity (name, aliases, handles) from the Address Book and first/last contact + message counts from chat.db. NEVER overwrites an existing file — returns its path with created: false. The calling agent fills the sections (export_messages → summarize → edit the file directly); the file's contents are privacy: never-share.",
2348
+ annotations: {
2349
+ readOnlyHint: false,
2350
+ destructiveHint: false,
2351
+ idempotentHint: true,
2352
+ openWorldHint: false
2353
+ },
2354
+ inputSchema: {
2355
+ type: "object",
2356
+ properties: {
2357
+ contact: {
2358
+ type: "string",
2359
+ description: "Contact name, phone, email, or contact:N selector."
2360
+ },
2361
+ threadSlug: { type: "string", description: "Thread slug from list_conversations." },
2362
+ top: {
2363
+ type: "number",
2364
+ description: "Scaffold the top N relationships (1-25) by importance."
2365
+ }
2366
+ }
2367
+ },
2368
+ // @ts-expect-error
2369
+ outputSchema: zodToJsonSchema(InitHumanOutputSchema)
2048
2370
  }
2049
2371
  ];
2050
2372
  const DEV_TOOL_NAMES = /* @__PURE__ */ new Set([
@@ -2061,6 +2383,150 @@ function getActiveTools() {
2061
2383
  if (isDevMode()) return TOOLS;
2062
2384
  return TOOLS.filter((t) => !DEV_TOOL_NAMES.has(t.name));
2063
2385
  }
2386
+ const IMAGE_BLOCK_MAX_BASE64 = 1e6;
2387
+ const IMAGE_MAX_EDGE = 1536;
2388
+ function imageBlockFromFile(path) {
2389
+ if (!existsSync(path)) return null;
2390
+ const work = mkdtempSync(join(tmpdir(), "imsg-media-"));
2391
+ try {
2392
+ const out = join(work, "preview.png");
2393
+ execFileSync(
2394
+ "sips",
2395
+ ["-Z", String(IMAGE_MAX_EDGE), "-s", "format", "png", path, "--out", out],
2396
+ {
2397
+ stdio: "ignore",
2398
+ timeout: 3e4
2399
+ }
2400
+ );
2401
+ let base64 = readFileSync(out).toString("base64");
2402
+ let mime = "image/png";
2403
+ if (base64.length > IMAGE_BLOCK_MAX_BASE64) {
2404
+ const jpg = join(work, "preview.jpg");
2405
+ execFileSync(
2406
+ "sips",
2407
+ [
2408
+ "-Z",
2409
+ String(IMAGE_MAX_EDGE),
2410
+ "-s",
2411
+ "format",
2412
+ "jpeg",
2413
+ "-s",
2414
+ "formatOptions",
2415
+ "70",
2416
+ path,
2417
+ "--out",
2418
+ jpg
2419
+ ],
2420
+ { stdio: "ignore", timeout: 3e4 }
2421
+ );
2422
+ base64 = readFileSync(jpg).toString("base64");
2423
+ mime = "image/jpeg";
2424
+ if (base64.length > IMAGE_BLOCK_MAX_BASE64) return null;
2425
+ }
2426
+ return { base64, mimeType: mime, note: `downscaled to ≤${IMAGE_MAX_EDGE}px via sips` };
2427
+ } catch {
2428
+ return null;
2429
+ } finally {
2430
+ rmSync(work, { recursive: true, force: true });
2431
+ }
2432
+ }
2433
+ function videoPosterFrame(path) {
2434
+ if (!existsSync(path)) return null;
2435
+ const work = mkdtempSync(join(tmpdir(), "imsg-poster-"));
2436
+ try {
2437
+ execFileSync("qlmanage", ["-t", "-s", String(IMAGE_MAX_EDGE), "-o", work, path], {
2438
+ stdio: "ignore",
2439
+ timeout: 15e3
2440
+ });
2441
+ const png = readdirSync(work).find((f) => f.endsWith(".png"));
2442
+ if (!png) return null;
2443
+ const block = imageBlockFromFile(join(work, png));
2444
+ if (block) block.note = "video poster frame via QuickLook";
2445
+ return block;
2446
+ } catch {
2447
+ return null;
2448
+ } finally {
2449
+ rmSync(work, { recursive: true, force: true });
2450
+ }
2451
+ }
2452
+ function mediaMetadata(path) {
2453
+ if (!existsSync(path)) return null;
2454
+ try {
2455
+ const out = execFileSync(
2456
+ "mdls",
2457
+ [
2458
+ "-name",
2459
+ "kMDItemDurationSeconds",
2460
+ "-name",
2461
+ "kMDItemPixelWidth",
2462
+ "-name",
2463
+ "kMDItemPixelHeight",
2464
+ "-name",
2465
+ "kMDItemCodecs",
2466
+ "-name",
2467
+ "kMDItemAudioSampleRate",
2468
+ path
2469
+ ],
2470
+ { encoding: "utf8", timeout: 1e4 }
2471
+ );
2472
+ const get = (key) => {
2473
+ const m = out.match(new RegExp(`${key}\\s*=\\s*(.+)`));
2474
+ const v = m?.[1]?.trim();
2475
+ return v && v !== "(null)" ? v : null;
2476
+ };
2477
+ const parts = [];
2478
+ const dur = get("kMDItemDurationSeconds");
2479
+ if (dur) parts.push(`duration ${Number.parseFloat(dur).toFixed(1)}s`);
2480
+ const w = get("kMDItemPixelWidth");
2481
+ const h = get("kMDItemPixelHeight");
2482
+ if (w && h) parts.push(`${w}x${h}`);
2483
+ const codecs = get("kMDItemCodecs");
2484
+ if (codecs) parts.push(`codecs ${codecs.replace(/[()\n]/g, "").trim()}`);
2485
+ const rate = get("kMDItemAudioSampleRate");
2486
+ if (rate) parts.push(`${Number.parseFloat(rate)}Hz`);
2487
+ return parts.length > 0 ? parts.join(" · ") : null;
2488
+ } catch {
2489
+ return null;
2490
+ }
2491
+ }
2492
+ const TRANSCRIBERS = [
2493
+ // sveinbjornt/hear — on-device Apple Speech (macOS 10.15+).
2494
+ { name: "hear", args: (p) => ["-i", p] },
2495
+ // finnvoor/yap — SpeechAnalyzer (macOS 26+).
2496
+ { name: "yap", args: (p) => [p] },
2497
+ // whisper.cpp CLI.
2498
+ { name: "whisper-cli", args: (p) => ["-f", p, "-np", "-nt"] }
2499
+ ];
2500
+ let cachedTranscriber;
2501
+ function detectTranscriber() {
2502
+ if (cachedTranscriber !== void 0) return cachedTranscriber;
2503
+ for (const t of TRANSCRIBERS) {
2504
+ try {
2505
+ execFileSync("which", [t.name], { stdio: "ignore", timeout: 3e3 });
2506
+ cachedTranscriber = t;
2507
+ return t;
2508
+ } catch {
2509
+ }
2510
+ }
2511
+ cachedTranscriber = null;
2512
+ return null;
2513
+ }
2514
+ const TRANSCRIBE_MAX_BYTES = 5e7;
2515
+ function transcribeAudio(path, timeoutMs = 6e4) {
2516
+ const t = detectTranscriber();
2517
+ if (!t || !existsSync(path)) return null;
2518
+ try {
2519
+ const out = execFileSync(t.name, t.args(path), {
2520
+ encoding: "utf8",
2521
+ timeout: timeoutMs,
2522
+ maxBuffer: 4 * 1024 * 1024
2523
+ });
2524
+ const text = out.trim();
2525
+ return text.length > 0 ? text : null;
2526
+ } catch {
2527
+ return null;
2528
+ }
2529
+ }
2064
2530
  const INSTRUCTIONS_UUID = randomUUID();
2065
2531
  function wrapUntrusted(text) {
2066
2532
  if (text == null || text === "") return "";
@@ -2084,6 +2550,69 @@ function sanitizeUserText(text, maxLength = 4096) {
2084
2550
  }
2085
2551
  return sanitized;
2086
2552
  }
2553
+ function normalizeForEcho(text) {
2554
+ return (text ?? "").normalize("NFC").trim().replace(/\s+/g, " ");
2555
+ }
2556
+ class SentEchoRegistry {
2557
+ entries = [];
2558
+ windowMs;
2559
+ skewMs;
2560
+ maxEntries;
2561
+ constructor(opts = {}) {
2562
+ this.windowMs = opts.windowMs ?? 12e4;
2563
+ this.skewMs = opts.skewMs ?? 15e3;
2564
+ this.maxEntries = opts.maxEntries ?? 200;
2565
+ }
2566
+ register(chatKey, text, kind = "text") {
2567
+ const echo = {
2568
+ chatKey,
2569
+ kind,
2570
+ normalizedText: kind === "text" ? normalizeForEcho(text) : "",
2571
+ sentAt: Date.now()
2572
+ };
2573
+ this.entries.push(echo);
2574
+ if (this.entries.length > this.maxEntries) {
2575
+ this.entries.splice(0, this.entries.length - this.maxEntries);
2576
+ }
2577
+ return echo;
2578
+ }
2579
+ /**
2580
+ * Is this from-me message an echo of a registered send? Idempotent: a
2581
+ * message whose id equals an entry's pinned matchedMessageId always
2582
+ * matches. Otherwise matches on (chatKey, equal normalized text, message
2583
+ * date within skew of sentAt, entry still inside the window, entry not
2584
+ * pinned to a different id) — and pins the entry on first match so
2585
+ * concurrent wait_for_reply calls see consistent suppression. Attachment
2586
+ * entries only match rows that actually carry attachments.
2587
+ */
2588
+ consume(chatKey, msg) {
2589
+ this.prune();
2590
+ for (const e of this.entries) {
2591
+ if (e.chatKey === chatKey && e.matchedMessageId === msg.id) return true;
2592
+ }
2593
+ const msgText = normalizeForEcho(msg.text);
2594
+ for (const e of this.entries) {
2595
+ if (e.chatKey !== chatKey || e.matchedMessageId !== void 0) continue;
2596
+ if (msg.date.getTime() < e.sentAt - this.skewMs) continue;
2597
+ if (e.kind === "attachment") {
2598
+ if (!msg.hasAttachments) continue;
2599
+ } else if (e.normalizedText !== msgText) {
2600
+ continue;
2601
+ }
2602
+ e.matchedMessageId = msg.id;
2603
+ return true;
2604
+ }
2605
+ return false;
2606
+ }
2607
+ /** Drop entries older than the suppression window. */
2608
+ prune(now = Date.now()) {
2609
+ this.entries = this.entries.filter((e) => now - e.sentAt <= this.windowMs);
2610
+ }
2611
+ /** Visible for tests. */
2612
+ size() {
2613
+ return this.entries.length;
2614
+ }
2615
+ }
2087
2616
  const execFileAsync = promisify(execFile);
2088
2617
  class ToolTimeoutError extends Error {
2089
2618
  constructor(toolName, timeoutMs) {
@@ -2116,6 +2645,17 @@ function withTimeout(toolName, fn) {
2116
2645
  function round1(n) {
2117
2646
  return Math.round(n * 10) / 10;
2118
2647
  }
2648
+ function analyticTextSummary(type, data) {
2649
+ if (type !== "relationship_leaderboard") return "";
2650
+ const rows = data?.leaderboard ?? [];
2651
+ if (rows.length === 0) return "\n(no ranked relationships in window)";
2652
+ const lines = rows.slice(0, 20).map(
2653
+ (r, i) => `${String(i + 1).padStart(2)}. ${sanitizeUserText(r.contact)} — ${r.total} msgs, ${Math.round(r.reciprocity * 100)}% reciprocity, last ${Math.round(r.daysSinceLast)}d ago (score ${r.score})`
2654
+ );
2655
+ return `
2656
+
2657
+ ${lines.join("\n")}`;
2658
+ }
2119
2659
  function formatDuration(ms) {
2120
2660
  if (ms < 1e3) return `${Math.round(ms)}ms`;
2121
2661
  const sec = Math.floor(ms / 1e3);
@@ -2152,6 +2692,8 @@ function formatMessage(msg, conversationLabel) {
2152
2692
  let status = "";
2153
2693
  if (!msg.isFromMe && !msg.isRead) {
2154
2694
  status = " [UNREAD]";
2695
+ } else if (msg.isFromMe && msg.sendError) {
2696
+ status = ` [NOT DELIVERED — send failed (error ${msg.sendError})]`;
2155
2697
  } else if (msg.isFromMe) {
2156
2698
  if (msg.dateRead) {
2157
2699
  status = ` [Read ${msg.dateRead.toLocaleTimeString("en-US", { hour: "numeric", minute: "2-digit", hour12: true })}]`;
@@ -2242,6 +2784,10 @@ class IMessageMCPServer {
2242
2784
  toolCallCount = 0;
2243
2785
  recentErrorCount = 0;
2244
2786
  lastActivityTs = Date.now();
2787
+ // Fingerprints of this process's own sends so wait_for_reply can return
2788
+ // the user's interjections without echoing the agent's just-sent message.
2789
+ sentEchoes = new SentEchoRegistry();
2790
+ humansIndex = new HumansIndex();
2245
2791
  constructor() {
2246
2792
  this.server = new Server(
2247
2793
  {
@@ -2410,6 +2956,8 @@ class IMessageMCPServer {
2410
2956
  return await this.handleGetAttachment(args);
2411
2957
  case "chat_analytics":
2412
2958
  return await this.handleChatAnalytics(args);
2959
+ case "init_human":
2960
+ return await this.handleInitHuman(args);
2413
2961
  default:
2414
2962
  throw new Error(`Unknown tool: ${name}`);
2415
2963
  }
@@ -2569,6 +3117,7 @@ ${stderr}`);
2569
3117
  const wasCapped = requested > HARD_PAGE_CAP;
2570
3118
  let messages;
2571
3119
  let threadHeader = "";
3120
+ let humansHint = null;
2572
3121
  if (chatIdentifier || threadSlug) {
2573
3122
  let targetIdentifier = chatIdentifier;
2574
3123
  if (threadSlug) {
@@ -2587,6 +3136,7 @@ ${stderr}`);
2587
3136
  threadHeader = `Thread: ${conv.threadSlug} | ${name}${ident} | ${conv.serviceType} | ${kind}
2588
3137
 
2589
3138
  `;
3139
+ humansHint = this.humansIndex.hintFor(conv.participants);
2590
3140
  }
2591
3141
  } else {
2592
3142
  messages = await this.db.getRecentMessages(limit);
@@ -2606,15 +3156,17 @@ ${stderr}`);
2606
3156
  _Pagination: oldestMessageId=${oldestId}, hasMore=${hasMore}${wasCapped ? ` (capped at ${HARD_PAGE_CAP} per call — use beforeMessageId or export_messages)` : ""}_` : "";
2607
3157
  const perfLine = `
2608
3158
  _Engine: ${engineLabel()} | Query: ${durMs.toFixed(0)}ms | Messages: ${messages.length}_`;
3159
+ const humansLine = humansHint ? humansHintText(humansHint) : "";
2609
3160
  return toolText(
2610
3161
  `${threadHeader}Found ${messages.length} message(s):
2611
3162
 
2612
- ${formatted}${paginationLine}${perfLine}`,
3163
+ ${formatted}${paginationLine}${perfLine}${humansLine}`,
2613
3164
  {
2614
3165
  messages: messages.map(messageToStructured),
2615
3166
  count: messages.length,
2616
3167
  hasMore,
2617
- oldestMessageId: chatIdentifier || threadSlug ? oldestId : void 0
3168
+ oldestMessageId: chatIdentifier || threadSlug ? oldestId : void 0,
3169
+ ...humansHint ? { humans: humansHint } : {}
2618
3170
  }
2619
3171
  );
2620
3172
  }
@@ -2766,7 +3318,7 @@ Call again with one of the contact:N labels.`,
2766
3318
  {}
2767
3319
  );
2768
3320
  }
2769
- preferredService = slugRecord.service === "SMS" ? "SMS" : "iMessage";
3321
+ preferredService = this.db.getPreferredSendService(slugRecord.chatIdentifier) ?? (slugRecord.service === "SMS" ? "SMS" : "iMessage");
2770
3322
  if (slugRecord.isGroup) {
2771
3323
  if (slugRecord.displayName && !slugRecord.displayName.startsWith("chat")) {
2772
3324
  result = await sendToChat(slugRecord.displayName, message);
@@ -2783,7 +3335,7 @@ Call again with one of the contact:N labels.`,
2783
3335
  } else {
2784
3336
  const existingChat = await this.db.findChatByHandle(normalizedRecipient);
2785
3337
  if (existingChat && !existingChat.isGroupChat) {
2786
- preferredService = existingChat.serviceType === "SMS" ? "SMS" : "iMessage";
3338
+ preferredService = this.db.getPreferredSendService(existingChat.chatIdentifier) ?? (existingChat.serviceType === "SMS" ? "SMS" : "iMessage");
2787
3339
  }
2788
3340
  result = await sendMessageReliable(normalizedRecipient, message, preferredService);
2789
3341
  if (!result.success) {
@@ -2818,9 +3370,16 @@ Call again with one of the contact:N labels.`,
2818
3370
  threadSlug ? this.db.getSlugRecord(threadSlug)?.chatIdentifier ?? "" : recipient
2819
3371
  );
2820
3372
  let lastMessageId;
3373
+ let sendConfirmed = false;
2821
3374
  if (chat) {
2822
- const lastMsg = await this.db.getLastMessage(chat.chatIdentifier);
2823
- lastMessageId = lastMsg?.id;
3375
+ const chatKey = chat.threadSlug ?? chat.chatIdentifier;
3376
+ const echo = this.sentEchoes.register(chatKey, message);
3377
+ for (const a of attachmentResults) {
3378
+ if (a.success) this.sentEchoes.register(chatKey, "", "attachment");
3379
+ }
3380
+ const confirmed = await this.confirmSendLanded(chat.chatIdentifier, echo);
3381
+ lastMessageId = confirmed.lastMessageId;
3382
+ sendConfirmed = confirmed.confirmed;
2824
3383
  }
2825
3384
  const attSummary = attachmentResults.length > 0 ? `
2826
3385
  Attachments: ${attachmentResults.filter((a) => a.success).length}/${attachmentResults.length} delivered` : "";
@@ -2834,6 +3393,7 @@ Last message ID: ${lastMessageId} (use with wait_for_reply)` : ""}${attSummary}`
2834
3393
  timestamp: result.timestamp?.toISOString() ?? null,
2835
3394
  threadSlug: chat?.threadSlug,
2836
3395
  lastMessageId,
3396
+ sendConfirmed,
2837
3397
  attachments: attachmentResults.length > 0 ? attachmentResults : void 0
2838
3398
  }
2839
3399
  );
@@ -2847,8 +3407,43 @@ Last message ID: ${lastMessageId} (use with wait_for_reply)` : ""}${attSummary}`
2847
3407
  );
2848
3408
  }
2849
3409
  }
3410
+ /**
3411
+ * Bounded poll for the just-sent message's own from-me row in chat.db.
3412
+ * Scans the last few messages (not just the newest — an incoming reply can
3413
+ * land AFTER our send) for a from-me row matching the echo's normalized
3414
+ * text with a plausible date. On hit, pins the echo's ROWID so wait_for_reply
3415
+ * suppression is exact. On timeout, falls back to the last message id
3416
+ * (previous behavior) with confirmed: false.
3417
+ */
3418
+ async confirmSendLanded(chatIdentifier, echo, opts = {}) {
3419
+ const timeoutMs = opts.timeoutMs ?? 5e3;
3420
+ const pollMs = opts.pollMs ?? 500;
3421
+ const deadline = Date.now() + timeoutMs;
3422
+ const skewMs = 15e3;
3423
+ for (; ; ) {
3424
+ const recent = await this.db.getMessagesForChat(chatIdentifier, 5);
3425
+ for (let i = recent.length - 1; i >= 0; i--) {
3426
+ const m = recent[i];
3427
+ if (m.isFromMe && normalizeForEcho(m.text) === echo.normalizedText && m.date.getTime() >= echo.sentAt - skewMs) {
3428
+ echo.matchedMessageId = m.id;
3429
+ return { lastMessageId: m.id, confirmed: true };
3430
+ }
3431
+ }
3432
+ if (Date.now() >= deadline) break;
3433
+ await sleep(pollMs);
3434
+ }
3435
+ const lastMsg = await this.db.getLastMessage(chatIdentifier);
3436
+ return { lastMessageId: lastMsg?.id, confirmed: false };
3437
+ }
2850
3438
  async handleWaitForReply(args, signal) {
2851
- const { chatIdentifier, threadSlug, timeoutSeconds, pollIntervalSeconds, afterMessageId } = WaitForReplySchema.parse(args);
3439
+ const {
3440
+ chatIdentifier,
3441
+ threadSlug,
3442
+ timeoutSeconds,
3443
+ pollIntervalSeconds,
3444
+ afterMessageId,
3445
+ includeSelf
3446
+ } = WaitForReplySchema.parse(args);
2852
3447
  if (!chatIdentifier && !threadSlug) {
2853
3448
  return toolError("Either chatIdentifier or threadSlug is required.");
2854
3449
  }
@@ -2873,23 +3468,36 @@ Last message ID: ${lastMessageId} (use with wait_for_reply)` : ""}${attSummary}`
2873
3468
  const lastMsg = await this.db.getLastMessage(chat.chatIdentifier);
2874
3469
  lastKnownId = lastMsg?.id || 0;
2875
3470
  }
3471
+ const chatKey = chat.threadSlug ?? chat.chatIdentifier;
2876
3472
  while (Date.now() - startTime < timeoutMs) {
2877
3473
  if (signal?.aborted) {
2878
3474
  return toolError(
2879
3475
  `Cancelled by client after ${Math.round((Date.now() - startTime) / 1e3)}s`
2880
3476
  );
2881
3477
  }
2882
- const newMessages = await this.db.getMessagesAfter(chat.chatIdentifier, lastKnownId);
2883
- if (newMessages.length > 0) {
2884
- const formatted = newMessages.map((m) => formatMessage(m)).join("\n");
2885
- return toolText(`Received ${newMessages.length} new message(s):
3478
+ const newMessages = await this.db.getMessagesAfter(chat.chatIdentifier, lastKnownId, {
3479
+ includeSelf
3480
+ });
3481
+ const visible = includeSelf ? newMessages.filter((m) => !m.isFromMe || !this.sentEchoes.consume(chatKey, m)) : newMessages;
3482
+ if (visible.length > 0) {
3483
+ const selfCount = visible.filter((m) => m.isFromMe).length;
3484
+ const header = selfCount > 0 ? `Received ${visible.length} new message(s) — ${selfCount} sent by the user from their own account (another device), ${visible.length - selfCount} from the other party:` : `Received ${visible.length} new message(s):`;
3485
+ const formatted = visible.map((m) => formatMessage(m)).join("\n");
3486
+ const humansHint = this.humansIndex.hintFor(chat.participants);
3487
+ const humansLine = humansHint ? humansHintText(humansHint) : "";
3488
+ return toolText(`${header}
2886
3489
 
2887
- ${formatted}`, {
3490
+ ${formatted}${humansLine}`, {
2888
3491
  received: true,
2889
- messages: newMessages.map(messageToStructured),
2890
- count: newMessages.length
3492
+ messages: visible.map(messageToStructured),
3493
+ count: visible.length,
3494
+ selfCount,
3495
+ ...humansHint ? { humans: humansHint } : {}
2891
3496
  });
2892
3497
  }
3498
+ if (newMessages.length > 0) {
3499
+ lastKnownId = newMessages[newMessages.length - 1].id;
3500
+ }
2893
3501
  await sleep(pollIntervalMs, signal);
2894
3502
  }
2895
3503
  return toolText(
@@ -2944,11 +3552,15 @@ ${formatted}`, {
2944
3552
  return toolText(`Found ${results.length} conversation(s):
2945
3553
 
2946
3554
  ${formatted}`, {
2947
- conversations: results.map((conversation) => ({
2948
- ...conversation,
2949
- lastMessageDate: conversation.lastMessageDate?.toISOString() ?? null,
2950
- lastMessageSnippet: sanitizeUserText(conversation.lastMessageSnippet)
2951
- })),
3555
+ conversations: results.map((conversation) => {
3556
+ const humanFiles = this.humansIndex.lookup(conversation.participants).map((f) => f.path);
3557
+ return {
3558
+ ...conversation,
3559
+ lastMessageDate: conversation.lastMessageDate?.toISOString() ?? null,
3560
+ lastMessageSnippet: sanitizeUserText(conversation.lastMessageSnippet),
3561
+ ...humanFiles.length > 0 ? { humansFiles: humanFiles } : {}
3562
+ };
3563
+ }),
2952
3564
  count: results.length,
2953
3565
  hasMore,
2954
3566
  nextOffset: hasMore ? startAfter : null
@@ -3107,9 +3719,19 @@ Organization: ${contact.organization}` : "";
3107
3719
  const threadLines = withThreads.length > 0 ? `
3108
3720
  Threads:
3109
3721
  ${withThreads.map((t) => ` ${t.handle} → ${t.threadSlug}`).join("\n")}` : "";
3722
+ const humansHint = this.humansIndex.hintFor([...contact.phoneNumbers, ...contact.emails]);
3723
+ const humansFile = humansHint?.files[0]?.path ?? null;
3724
+ const humansLine = humansHint ? humansHintText(humansHint) : `
3725
+
3726
+ _${HUMANS_INIT_HINT}_`;
3110
3727
  return toolText(
3111
- `${sanitizeUserText(contact.displayName)} (id ${contact.id})${phones}${emails}${org}${threadLines}`,
3112
- { contact, threads }
3728
+ `${sanitizeUserText(contact.displayName)} (id ${contact.id})${phones}${emails}${org}${threadLines}${humansLine}`,
3729
+ {
3730
+ contact,
3731
+ threads,
3732
+ humansFile,
3733
+ humansGuidance: humansHint?.guidance ?? HUMANS_INIT_HINT
3734
+ }
3113
3735
  );
3114
3736
  }
3115
3737
  async handleResolveHandle(args) {
@@ -3189,7 +3811,7 @@ ${formatted}`, {
3189
3811
  });
3190
3812
  }
3191
3813
  async handleGetAttachment(args) {
3192
- const { readFileSync } = await import("node:fs");
3814
+ const { readFileSync: readFileSync2 } = await import("node:fs");
3193
3815
  const { rowId, inlineMaxBytes } = GetAttachmentSchema.parse(args);
3194
3816
  const rec = this.db.getAttachmentByRowId(rowId);
3195
3817
  if (!rec) return toolError(`Attachment ROWID ${rowId} not found.`);
@@ -3199,36 +3821,87 @@ ${formatted}`, {
3199
3821
  }
3200
3822
  const stat = statSync(resolvedPath);
3201
3823
  const sizeBytes = stat.size;
3202
- const isHeic = (rec.mimeType ?? "").toLowerCase().includes("heic") || resolvedPath.toLowerCase().endsWith(".heic");
3203
- const inline = sizeBytes <= inlineMaxBytes;
3824
+ const mime = (rec.mimeType ?? "").toLowerCase();
3825
+ const isHeic = mime.includes("heic") || resolvedPath.toLowerCase().endsWith(".heic");
3826
+ const isImage = mime.startsWith("image/") || isHeic;
3827
+ const isVideo = mime.startsWith("video/");
3828
+ const isAudio = mime.startsWith("audio/") || /\.(caf|amr|m4a|mp3|wav|aac)$/i.test(resolvedPath);
3829
+ const inline = isImage && sizeBytes <= inlineMaxBytes;
3204
3830
  let base64;
3205
3831
  let convertedNote;
3206
3832
  let finalMime = rec.mimeType;
3207
3833
  let finalPath = resolvedPath;
3208
- if (inline) {
3209
- if (isHeic) {
3210
- try {
3211
- const { execFileSync } = await import("node:child_process");
3212
- const { tmpdir } = await import("node:os");
3213
- const { join: pjoin } = await import("node:path");
3214
- const out = pjoin(tmpdir(), `imsg-att-${rowId}.png`);
3215
- execFileSync("sips", ["-s", "format", "png", resolvedPath, "--out", out], {
3216
- stdio: "ignore"
3217
- });
3218
- finalPath = out;
3219
- finalMime = "image/png";
3220
- convertedNote = "HEIC → PNG via sips";
3221
- base64 = readFileSync(out).toString("base64");
3222
- } catch (e) {
3223
- return toolError(`HEIC→PNG conversion failed: ${e.message ?? e}`);
3834
+ let mediaInfo;
3835
+ let transcript;
3836
+ let imageBlock = null;
3837
+ if (isImage) {
3838
+ imageBlock = imageBlockFromFile(resolvedPath);
3839
+ if (inline) {
3840
+ if (isHeic) {
3841
+ try {
3842
+ const { execFileSync: execFileSync2 } = await import("node:child_process");
3843
+ const { tmpdir: tmpdir2 } = await import("node:os");
3844
+ const { join: pjoin } = await import("node:path");
3845
+ const out = pjoin(tmpdir2(), `imsg-att-${rowId}.png`);
3846
+ execFileSync2("sips", ["-s", "format", "png", resolvedPath, "--out", out], {
3847
+ stdio: "ignore"
3848
+ });
3849
+ finalPath = out;
3850
+ finalMime = "image/png";
3851
+ convertedNote = "HEIC → PNG via sips";
3852
+ base64 = readFileSync2(out).toString("base64");
3853
+ } catch (e) {
3854
+ return toolError(`HEIC→PNG conversion failed: ${e.message ?? e}`);
3855
+ }
3856
+ } else {
3857
+ base64 = readFileSync2(resolvedPath).toString("base64");
3224
3858
  }
3859
+ }
3860
+ } else if (isVideo) {
3861
+ imageBlock = videoPosterFrame(resolvedPath);
3862
+ mediaInfo = mediaMetadata(resolvedPath) ?? void 0;
3863
+ convertedNote = imageBlock ? "poster frame via QuickLook" : void 0;
3864
+ } else if (isAudio) {
3865
+ mediaInfo = mediaMetadata(resolvedPath) ?? void 0;
3866
+ if (sizeBytes <= TRANSCRIBE_MAX_BYTES) {
3867
+ transcript = transcribeAudio(resolvedPath) ?? void 0;
3868
+ }
3869
+ }
3870
+ const lines = [];
3871
+ if (isVideo) {
3872
+ lines.push(
3873
+ `Video attachment ${rowId} (${sizeBytes}B${mediaInfo ? `, ${mediaInfo}` : ""}).`,
3874
+ imageBlock ? "Poster frame attached as an image; full video at the path below." : "Poster frame unavailable; use the path below.",
3875
+ `Path: ${resolvedPath}`
3876
+ );
3877
+ } else if (isAudio) {
3878
+ lines.push(`Audio attachment ${rowId} (${sizeBytes}B${mediaInfo ? `, ${mediaInfo}` : ""}).`);
3879
+ if (transcript) {
3880
+ lines.push("", "Transcript:", wrapUntrusted(sanitizeUserText(transcript) ?? ""));
3225
3881
  } else {
3226
- base64 = readFileSync(resolvedPath).toString("base64");
3882
+ lines.push(
3883
+ detectTranscriber() ? "Transcription produced no text." : "No transcriber installed — `brew install hear` enables on-device transcription."
3884
+ );
3227
3885
  }
3886
+ lines.push(`Path: ${resolvedPath}`);
3887
+ } else if (inline) {
3888
+ lines.push(`Attachment ${rowId} (${sizeBytes}B, ${finalMime ?? "?"}) returned inline.`);
3889
+ } else if (isImage) {
3890
+ lines.push(
3891
+ `Attachment ${rowId} too large to inline (${sizeBytes}B > ${inlineMaxBytes}B); a downscaled preview is attached${imageBlock ? "" : " (preview failed)"}. Full file: ${resolvedPath}`
3892
+ );
3893
+ } else {
3894
+ lines.push(
3895
+ `Attachment ${rowId} (${sizeBytes}B, ${finalMime ?? "?"}) — not an inlineable type. Path: ${resolvedPath}`
3896
+ );
3228
3897
  }
3229
- return toolText(
3230
- inline ? `Attachment ${rowId} (${sizeBytes}B, ${finalMime ?? "?"}) returned inline.` : `Attachment ${rowId} too large to inline (${sizeBytes}B > ${inlineMaxBytes}B). Use path: ${resolvedPath}`,
3231
- {
3898
+ const content = [{ type: "text", text: lines.join("\n") }];
3899
+ if (imageBlock) {
3900
+ content.push({ type: "image", data: imageBlock.base64, mimeType: imageBlock.mimeType });
3901
+ }
3902
+ return {
3903
+ content,
3904
+ structuredContent: {
3232
3905
  rowId,
3233
3906
  filename: rec.filename,
3234
3907
  resolvedPath: finalPath,
@@ -3236,9 +3909,108 @@ ${formatted}`, {
3236
3909
  totalBytes: sizeBytes,
3237
3910
  inline,
3238
3911
  base64,
3239
- converted: convertedNote
3912
+ converted: convertedNote,
3913
+ mediaInfo,
3914
+ transcript,
3915
+ imageBlockIncluded: imageBlock != null
3916
+ }
3917
+ };
3918
+ }
3919
+ /**
3920
+ * Scaffold humans/v1 relationship file(s). Identity comes from the Address
3921
+ * Book; message stats from chat.db across the person's merged legs. Never
3922
+ * overwrites. The agent does all summarization afterwards (humans skill).
3923
+ */
3924
+ async handleInitHuman(args) {
3925
+ const { contact, threadSlug, top } = InitHumanSchema.parse(args);
3926
+ const scaffold = new HumansScaffold();
3927
+ const targets = [];
3928
+ const targetFromHandle = (handle, fallbackName) => {
3929
+ const lookup = this.db.contacts.lookupContact(handle);
3930
+ const card = lookup ? this.db.contacts.getContact(lookup.contactId) : null;
3931
+ if (card) {
3932
+ return {
3933
+ name: card.displayName,
3934
+ aliases: card.nickname && card.nickname !== card.displayName ? [card.nickname] : [],
3935
+ handles: [...card.phoneNumbers, ...card.emails]
3936
+ };
3937
+ }
3938
+ return { name: fallbackName ?? handle, aliases: [], handles: [handle] };
3939
+ };
3940
+ if (threadSlug) {
3941
+ const slugRecord = this.db.getSlugRecord(threadSlug);
3942
+ if (!slugRecord) {
3943
+ return toolError(`Unknown thread slug: ${threadSlug}`);
3944
+ }
3945
+ if (slugRecord.isGroup) {
3946
+ return toolError("Humans files are per-person — pick a 1:1 thread (or use contact/top).");
3947
+ }
3948
+ targets.push(
3949
+ targetFromHandle(slugRecord.chatIdentifier, slugRecord.displayName ?? void 0)
3950
+ );
3951
+ } else if (contact) {
3952
+ const selectorHit = resolveContactSelector(contact);
3953
+ const resolution = resolveRecipient(selectorHit?.handle ?? contact, {
3954
+ contacts: this.db.contacts,
3955
+ defaultCountry: defaultCountryFromEnv()
3956
+ });
3957
+ if (resolution.kind === "error") {
3958
+ return toolError(resolution.message);
3959
+ }
3960
+ if (resolution.kind === "ambiguous") {
3961
+ const lines2 = resolution.candidates.map((c, i) => ` contact:${i + 1} ${c.displayName} → ${c.handle}`).join("\n");
3962
+ return toolError(
3963
+ `Ambiguous contact "${contact}" — multiple matches:
3964
+ ${lines2}
3965
+
3966
+ Call again with one of the contact:N labels.`,
3967
+ { candidates: resolution.candidates }
3968
+ );
3969
+ }
3970
+ targets.push(targetFromHandle(resolution.handle, contact));
3971
+ } else if (top !== void 0) {
3972
+ const cutoffMs = Date.now() - 365 * 864e5;
3973
+ const messages = await this.db.getMessagesInWindow(cutoffMs);
3974
+ const { leaderboard } = computeRelationshipLeaderboard(messages);
3975
+ for (const entry of leaderboard.slice(0, top)) {
3976
+ targets.push(targetFromHandle(entry.handle, entry.contact));
3977
+ }
3978
+ if (targets.length === 0) {
3979
+ return toolError("No ranked relationships found in the last year.");
3980
+ }
3981
+ }
3982
+ const results = [];
3983
+ for (const t of targets) {
3984
+ let stats = { count: 0, first: null, last: null };
3985
+ for (const h of t.handles) {
3986
+ const candidates = h.includes("@") ? [h] : normalizedPhoneVariants(h);
3987
+ for (const candidate of candidates) {
3988
+ const s = this.db.getChatStats(candidate);
3989
+ if (s.count > stats.count) stats = s;
3990
+ }
3240
3991
  }
3992
+ const r = scaffold.scaffold({
3993
+ name: t.name,
3994
+ aliases: t.aliases,
3995
+ handles: t.handles,
3996
+ firstContact: stats.first,
3997
+ lastContact: stats.last,
3998
+ messageCount: stats.count
3999
+ });
4000
+ results.push({ ...r, name: t.name, messageCount: stats.count });
4001
+ }
4002
+ const lines = results.map(
4003
+ (r) => `${r.created ? "created" : "exists "} ${r.slug} (${r.name}, ${r.messageCount?.toLocaleString() ?? "?"} msgs) ${r.path}`
4004
+ );
4005
+ lines.push(
4006
+ "",
4007
+ "Next: export history (export_messages), summarize it YOURSELF into the file's sections, and append a Log entry — see the humans skill. File contents are privacy: never-share."
3241
4008
  );
4009
+ return toolText(lines.join("\n"), {
4010
+ results,
4011
+ count: results.length,
4012
+ humansDir: getHumansDirPath()
4013
+ });
3242
4014
  }
3243
4015
  async handleChatAnalytics(args) {
3244
4016
  const { type, windowDays } = ChatAnalyticsSchema.parse(args);
@@ -3248,20 +4020,23 @@ ${formatted}`, {
3248
4020
  const cacheArgs = { type, windowDays: effectiveDays };
3249
4021
  const hit = lookupCache(type, cacheArgs, maxRowId);
3250
4022
  if (hit) {
3251
- return toolText(`Cached ${type} (computed at ${new Date(hit.computedAt).toISOString()}).`, {
3252
- type,
3253
- windowDays: effectiveDays,
3254
- computedAtIso: new Date(hit.computedAt).toISOString(),
3255
- fromCache: true,
3256
- data: hit.data
3257
- });
4023
+ return toolText(
4024
+ `Cached ${type} (computed at ${new Date(hit.computedAt).toISOString()}).${analyticTextSummary(type, hit.data)}`,
4025
+ {
4026
+ type,
4027
+ windowDays: effectiveDays,
4028
+ computedAtIso: new Date(hit.computedAt).toISOString(),
4029
+ fromCache: true,
4030
+ data: hit.data
4031
+ }
4032
+ );
3258
4033
  }
3259
4034
  const messages = await this.db.getMessagesInWindow(cutoffMs);
3260
4035
  const result = dispatchAnalytic(type, messages);
3261
4036
  const computedAtIso = (/* @__PURE__ */ new Date()).toISOString();
3262
4037
  storeCache(type, cacheArgs, maxRowId, result.data);
3263
4038
  return toolText(
3264
- `Computed ${type} over ${messages.length} messages in the last ${effectiveDays}d.`,
4039
+ `Computed ${type} over ${messages.length} messages in the last ${effectiveDays}d.${analyticTextSummary(type, result.data)}`,
3265
4040
  {
3266
4041
  type,
3267
4042
  windowDays: effectiveDays,