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/CHANGELOG.md +25 -0
- package/README.md +3 -2
- package/dist/cli.js +47 -5
- package/dist/cli.js.map +1 -1
- package/dist/{imessage-db-Cz8MIIpG.js → imessage-db-C8WKHFQA.js} +102 -15
- package/dist/imessage-db-C8WKHFQA.js.map +1 -0
- package/dist/index.js +848 -73
- package/dist/index.js.map +1 -1
- package/dist/{meta-DzE7CSrZ.js → meta-CGz9n7A2.js} +2 -2
- package/dist/{meta-DzE7CSrZ.js.map → meta-CGz9n7A2.js.map} +1 -1
- package/dist/{setup-BuXuKAHA.js → setup-BDnz3OuH.js} +2 -2
- package/dist/{setup-BuXuKAHA.js.map → setup-BDnz3OuH.js.map} +1 -1
- package/dist/{shutdown-pPAPSnk3.js → shutdown-CqGQKFmr.js} +73 -47
- package/dist/shutdown-CqGQKFmr.js.map +1 -0
- package/dist/tui.js +216 -60
- package/dist/tui.js.map +1 -1
- package/dist/{watchdog-Cbgm050b.js → watchdog-CizaS0Jv.js} +80 -4
- package/dist/watchdog-CizaS0Jv.js.map +1 -0
- package/native/imsg-native.darwin-arm64.node +0 -0
- package/package.json +1 -1
- package/skills/humans/SKILL.md +136 -0
- package/skills/imsg-mcp/SKILL.md +8 -0
- package/dist/imessage-db-Cz8MIIpG.js.map +0 -1
- package/dist/shutdown-pPAPSnk3.js.map +0 -1
- package/dist/watchdog-Cbgm050b.js.map +0 -1
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,
|
|
10
|
-
import { m as minMessageId, r as resolveRecipient, d as defaultCountryFromEnv, a as rankFuzzy, b as dispatchAnalytic, i as installWatchdog, n as noteActivity,
|
|
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 {
|
|
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-
|
|
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
|
|
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).
|
|
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
|
|
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
|
|
2823
|
-
|
|
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 {
|
|
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
|
-
|
|
2884
|
-
|
|
2885
|
-
|
|
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:
|
|
2890
|
-
count:
|
|
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
|
-
|
|
2949
|
-
|
|
2950
|
-
|
|
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
|
-
{
|
|
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
|
|
3203
|
-
const
|
|
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
|
-
|
|
3209
|
-
|
|
3210
|
-
|
|
3211
|
-
|
|
3212
|
-
|
|
3213
|
-
|
|
3214
|
-
|
|
3215
|
-
|
|
3216
|
-
|
|
3217
|
-
|
|
3218
|
-
|
|
3219
|
-
|
|
3220
|
-
|
|
3221
|
-
|
|
3222
|
-
|
|
3223
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3230
|
-
|
|
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(
|
|
3252
|
-
type,
|
|
3253
|
-
|
|
3254
|
-
|
|
3255
|
-
|
|
3256
|
-
|
|
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,
|