askshepherd 0.1.40 → 0.1.42

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.
@@ -1,18 +1,21 @@
1
1
  #!/usr/bin/env node
2
2
  import { execFile, execFileSync, spawn } from "node:child_process";
3
3
  import { createHash } from "node:crypto";
4
- import { constants as fsConstants, existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
4
+ import { constants as fsConstants, existsSync, mkdirSync, readdirSync, readFileSync, realpathSync, renameSync, unlinkSync, watch, writeFileSync } from "node:fs";
5
5
  import { access, chmod, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
6
6
  import { createServer } from "node:http";
7
7
  import { homedir, platform } from "node:os";
8
8
  import { basename, dirname, join } from "node:path";
9
9
  import readline from "node:readline";
10
10
  import { fileURLToPath } from "node:url";
11
+ import sourceSelectionHelpers from "../source-selection.cjs";
12
+
13
+ const { sourceSelectionFromSession } = sourceSelectionHelpers;
11
14
 
12
15
  const DEFAULT_API_URL = "https://brain-api-customer-facing.up.railway.app";
13
16
  const PACKAGE_NAME = "askshepherd";
14
17
  const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
15
- const PACKAGE_VERSION = "0.1.40";
18
+ const PACKAGE_VERSION = "0.1.42";
16
19
  const MCP_SERVER_NAME = "shepherd";
17
20
  const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
18
21
  const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
@@ -24,7 +27,7 @@ const MAX_QUEUE_MESSAGES = 10_000;
24
27
  const DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT = 200;
25
28
  const INITIAL_MESSAGE_CHAT_ROWS = 20;
26
29
  const ALL_MESSAGES_CHATS = "__shepherd_all_messages_chats__";
27
- const AGENT_MODALITY_ORDER = ["google", "slack", "granola", "messages", "codingSessions"];
30
+ const AGENT_MODALITY_ORDER = ["google", "slack", "github", "granola", "messages", "codingSessions"];
28
31
  const SHEPHERD_LOGO_PATH = join(PACKAGE_DIR, "assets", "shepherd_G_vector_136033.png");
29
32
  const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
30
33
  const GOOGLE_WORKSPACE_DELEGATION_ADMIN_URL = "https://admin.google.com/ac/owl/domainwidedelegation";
@@ -38,8 +41,22 @@ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
38
41
  const CONTACTS_WAL_PATH = join(homedir(), "Library", "Application Support", "AddressBook", "AddressBook-v22.abcddb-wal");
39
42
  const CONTACT_SYNC_DEBOUNCE_MS = 5_000;
40
43
  const CONTACT_SYNC_FALLBACK_MS = 30 * 60_000;
44
+ const MESSAGES_EDIT_SCAN_INTERVAL_MS = positiveIntFromEnv("SHEPHERD_MESSAGES_EDIT_SCAN_INTERVAL_MS", 60_000);
45
+ const MESSAGES_EDIT_SCAN_WINDOW_MS = positiveIntFromEnv("SHEPHERD_MESSAGES_EDIT_SCAN_WINDOW_MS", 30 * 60_000);
46
+ const MESSAGES_EDIT_SCAN_LIMIT = positiveIntFromEnv("SHEPHERD_MESSAGES_EDIT_SCAN_LIMIT", 500);
47
+ const MESSAGES_MUTATION_RECONCILE_INTERVAL_MS = positiveIntFromEnv("SHEPHERD_MESSAGES_MUTATION_RECONCILE_INTERVAL_MS", 15 * 60_000);
48
+ const MESSAGES_STATE_CACHE_MAX = positiveIntFromEnv("SHEPHERD_MESSAGES_STATE_CACHE_MAX", 5_000);
49
+ const LEGACY_SHEPHERD_OWNED_MESSAGE_HANDLES = [
50
+ "+13054098546",
51
+ "+12054012556",
52
+ ];
41
53
  const SHEPHERD_OWNED_MESSAGE_HANDLES = parseMessageHandleList(
42
- process.env.SHEPHERD_OWNED_MESSAGE_HANDLES ?? process.env.SENDBLUE_NUMBER ?? "",
54
+ [
55
+ ...LEGACY_SHEPHERD_OWNED_MESSAGE_HANDLES,
56
+ process.env.SENDBLUE_NUMBER,
57
+ process.env.SHEPHERD_OWNED_MESSAGE_HANDLES,
58
+ process.env.SHEPHERD_OWNED_IMESSAGE_HANDLES,
59
+ ].filter(Boolean).join(","),
43
60
  );
44
61
  const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
45
62
  const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
@@ -101,18 +118,30 @@ const command = rawArgv[0] && !rawArgv[0].startsWith("--") ? rawArgv[0] : "onboa
101
118
  const args = parseArgs(command === "onboard" ? rawArgv : rawArgv.slice(1));
102
119
  let messagesPermissionNoticePrinted = false;
103
120
 
104
- if (command === "help" || args.help) {
105
- printHelp(command === "help" ? "onboard" : command);
106
- process.exit(0);
121
+ if (isCliEntrypoint()) {
122
+ if (command === "help" || args.help) {
123
+ printHelp(command === "help" ? "onboard" : command);
124
+ process.exit(0);
125
+ }
126
+
127
+ void dispatch().catch((err) => {
128
+ console.error(`\nShepherd onboarding failed: ${safeError(err)}`);
129
+ if (args.debug === true) {
130
+ console.error(rawErrorDetails(err));
131
+ }
132
+ process.exit(1);
133
+ });
107
134
  }
108
135
 
109
- void dispatch().catch((err) => {
110
- console.error(`\nShepherd onboarding failed: ${safeError(err)}`);
111
- if (args.debug === true) {
112
- console.error(rawErrorDetails(err));
136
+ function isCliEntrypoint() {
137
+ if (!process.argv[1]) return false;
138
+ const modulePath = fileURLToPath(import.meta.url);
139
+ try {
140
+ return realpathSync(modulePath) === realpathSync(process.argv[1]);
141
+ } catch {
142
+ return modulePath === process.argv[1];
113
143
  }
114
- process.exit(1);
115
- });
144
+ }
116
145
 
117
146
  async function dispatch() {
118
147
  if (command === "onboard") {
@@ -139,6 +168,10 @@ async function dispatch() {
139
168
  await runWriteMessagesConfig();
140
169
  } else if (command === "install-messages-agent") {
141
170
  await runInstallMessagesAgent();
171
+ } else if (command === "write-coding-sessions-config") {
172
+ await runWriteCodingSessionsConfig();
173
+ } else if (command === "install-coding-sessions-agent") {
174
+ await runInstallCodingSessionsAgent();
142
175
  } else if (command === "coding-sessions-agent") {
143
176
  await runCodingSessionsAgent();
144
177
  } else if (command === "coding-sessions-status") {
@@ -354,15 +387,16 @@ async function runAgentOnboarding() {
354
387
  authSessionToken: workosAuth.authSessionToken,
355
388
  sources,
356
389
  });
390
+ const sessionSources = sourceSelectionFromSession(session, sources);
357
391
 
358
392
  const statePath = await writeAgentState({
359
393
  apiUrl,
360
394
  sessionId: session.sessionId,
361
395
  sessionToken: session.sessionToken,
362
396
  account: session.account,
363
- sources,
397
+ sources: sessionSources,
364
398
  authUrls: session.authUrls ?? {},
365
- googleWorkspaceDelegation: sources.google
399
+ googleWorkspaceDelegation: sessionSources.google && session.googleWorkspaceDelegation
366
400
  ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation)
367
401
  : undefined,
368
402
  workosAuth,
@@ -380,7 +414,8 @@ async function runAgentOnboarding() {
380
414
  status: "auth_required",
381
415
  account: publicAgentAccount(session.account),
382
416
  opened: currentAction?.opened ? [currentAction.source] : [],
383
- googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
417
+ sources: sessionSources,
418
+ googleWorkspaceDelegation: sessionSources.google && session.googleWorkspaceDelegation ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
384
419
  currentAction,
385
420
  statePath,
386
421
  messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
@@ -491,7 +526,7 @@ async function runMcpLogin() {
491
526
  const statePath = await writeMcpStateFromLogin(login);
492
527
  const installTargets = await selectMcpInstallTargets();
493
528
  const installResults = installTargets.length > 0
494
- ? await installMcpClients({ statePath, targets: installTargets })
529
+ ? await installMcpClients({ statePath, targets: installTargets, proxyProgram: parseJsonArrayArg("mcp-program") })
495
530
  : [];
496
531
 
497
532
  if (args.json) {
@@ -680,7 +715,7 @@ async function runMcpInstall() {
680
715
  const ensured = await ensureMcpState({ allowBrowser: process.stdin.isTTY, quiet: args.json === true });
681
716
  const targets = await selectMcpInstallTargets({ defaultTargets: MCP_INSTALL_TARGETS });
682
717
  const installResults = targets.length > 0
683
- ? await installMcpClients({ statePath: ensured.statePath, targets })
718
+ ? await installMcpClients({ statePath: ensured.statePath, targets, proxyProgram: parseJsonArrayArg("mcp-program") })
684
719
  : [];
685
720
 
686
721
  if (args.json) {
@@ -982,16 +1017,17 @@ function parseMcpInstallTargets(value) {
982
1017
  return [...new Set(targets)];
983
1018
  }
984
1019
 
985
- async function installMcpClients({ statePath, targets }) {
1020
+ async function installMcpClients({ statePath, targets, proxyProgram }) {
986
1021
  const results = [];
1022
+ const proxy = mcpProxyCommand(statePath, proxyProgram);
987
1023
  for (const target of targets) {
988
1024
  try {
989
1025
  if (target === "codex") {
990
- await installCodexMcp(statePath);
1026
+ await installCodexMcp(proxy);
991
1027
  } else if (target === "claude") {
992
- await installClaudeMcp(statePath);
1028
+ await installClaudeMcp(proxy);
993
1029
  } else if (target === "cursor") {
994
- await installCursorMcp(statePath);
1030
+ await installCursorMcp(proxy);
995
1031
  }
996
1032
  results.push({ target, status: "installed" });
997
1033
  } catch (err) {
@@ -1001,25 +1037,25 @@ async function installMcpClients({ statePath, targets }) {
1001
1037
  return results;
1002
1038
  }
1003
1039
 
1004
- async function installCodexMcp(statePath) {
1040
+ async function installCodexMcp(proxy) {
1005
1041
  await execFileQuiet("codex", ["mcp", "remove", MCP_SERVER_NAME], { ignoreError: true });
1006
- await execFileQuiet("codex", ["mcp", "add", MCP_SERVER_NAME, "--", "npx", ...mcpProxyArgs(statePath)]);
1042
+ await execFileQuiet("codex", ["mcp", "add", MCP_SERVER_NAME, "--", proxy.command, ...proxy.args]);
1007
1043
  }
1008
1044
 
1009
- async function installClaudeMcp(statePath) {
1045
+ async function installClaudeMcp(proxy) {
1010
1046
  await execFileQuiet("claude", ["mcp", "remove", MCP_SERVER_NAME], { ignoreError: true });
1011
- await execFileQuiet("claude", ["mcp", "add", "--scope", "user", MCP_SERVER_NAME, "--", "npx", ...mcpProxyArgs(statePath)]);
1047
+ await execFileQuiet("claude", ["mcp", "add", "--scope", "user", MCP_SERVER_NAME, "--", proxy.command, ...proxy.args]);
1012
1048
  }
1013
1049
 
1014
- async function installCursorMcp(statePath) {
1050
+ async function installCursorMcp(proxy) {
1015
1051
  const path = join(homedir(), ".cursor", "mcp.json");
1016
1052
  const config = await readJsonObject(path);
1017
1053
  const mcpServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
1018
1054
  ? config.mcpServers
1019
1055
  : {};
1020
1056
  mcpServers[MCP_SERVER_NAME] = {
1021
- command: "npx",
1022
- args: mcpProxyArgs(statePath),
1057
+ command: proxy.command,
1058
+ args: proxy.args,
1023
1059
  };
1024
1060
  config.mcpServers = mcpServers;
1025
1061
  await mkdir(dirname(path), { recursive: true });
@@ -1027,8 +1063,18 @@ async function installCursorMcp(statePath) {
1027
1063
  await execFileQuiet("cursor-agent", ["mcp", "enable", MCP_SERVER_NAME], { ignoreError: true });
1028
1064
  }
1029
1065
 
1066
+ function mcpProxyCommand(statePath, proxyProgram) {
1067
+ const prefix = Array.isArray(proxyProgram) && proxyProgram.length > 0
1068
+ ? proxyProgram
1069
+ : ["npx", "-y", PACKAGE_SPEC];
1070
+ return {
1071
+ command: prefix[0],
1072
+ args: [...prefix.slice(1), "mcp", "--state", statePath],
1073
+ };
1074
+ }
1075
+
1030
1076
  function mcpProxyArgs(statePath) {
1031
- return ["-y", PACKAGE_SPEC, "mcp", "--state", statePath];
1077
+ return mcpProxyCommand(statePath).args;
1032
1078
  }
1033
1079
 
1034
1080
  async function readJsonObject(path) {
@@ -1192,7 +1238,7 @@ async function runStatusCommand() {
1192
1238
 
1193
1239
  async function collectShepherdStatus() {
1194
1240
  const statePath = agentStatePath();
1195
- const state = await readOptionalAgentState();
1241
+ let state = await readOptionalAgentState();
1196
1242
  let production = null;
1197
1243
  let productionError = null;
1198
1244
 
@@ -1202,7 +1248,7 @@ async function collectShepherdStatus() {
1202
1248
  `${trimTrailingSlash(state.apiUrl)}/onboarding/raw/session/${encodeURIComponent(state.sessionId)}/status`,
1203
1249
  { token: state.sessionToken },
1204
1250
  );
1205
- await updateAgentStateFromOnboardingResponse(state, production);
1251
+ state = await updateAgentStateFromOnboardingResponse(state, production);
1206
1252
  } catch (err) {
1207
1253
  productionError = safeError(err);
1208
1254
  }
@@ -1296,8 +1342,11 @@ function statusSourceRows(providers, savedSources = {}) {
1296
1342
  const definitions = [
1297
1343
  ["google", "Google Workspace", "google"],
1298
1344
  ["slack", "Slack", "slack"],
1345
+ ["github", "GitHub", "github"],
1299
1346
  ["granola", "Granola", "granola"],
1300
1347
  ["messages", "Messages", "messages"],
1348
+ ["discord", "Discord", "discord"],
1349
+ ["instagram", "Instagram", "instagram"],
1301
1350
  ["codingSessions", "Coding Sessions", "codingSessions"],
1302
1351
  ];
1303
1352
  return definitions.map(([key, label, sourceKey]) => ({
@@ -1327,6 +1376,9 @@ function renderLocalMessagesStatus(status) {
1327
1376
  lines.push(" LaunchAgent: not installed or unavailable");
1328
1377
  }
1329
1378
  lines.push(` Messages database: ${status.storage.readable ? "readable" : `not readable (${status.storage.reason})`}`);
1379
+ if (status.contacts) {
1380
+ lines.push(` Contacts: ${status.contacts.ok ? "resolved from AddressBook DB" : `degraded (${status.contacts.error ?? "unsupported local Contacts database schema"}; contact founders@askshepherd.ai)`}`);
1381
+ }
1330
1382
  lines.push(` Queued unsent messages: ${status.queueDepth}`);
1331
1383
  return lines;
1332
1384
  }
@@ -1379,6 +1431,7 @@ async function runWriteMessagesConfig() {
1379
1431
  apiUrl: trimTrailingSlash(requiredConfigString(input.apiUrl, "apiUrl")),
1380
1432
  userId: requiredConfigString(input.userId, "userId"),
1381
1433
  agentToken: requiredConfigString(input.agentToken, "agentToken"),
1434
+ handle: optionalString(input.handle),
1382
1435
  backfillDays: parseBackfillDays(input.backfillDays, null),
1383
1436
  allowedChatIds: input.allowedChatIds,
1384
1437
  selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats : [],
@@ -1386,6 +1439,20 @@ async function runWriteMessagesConfig() {
1386
1439
  console.log(JSON.stringify({ configPath }, null, 2));
1387
1440
  }
1388
1441
 
1442
+ async function runWriteCodingSessionsConfig() {
1443
+ const input = await readJsonInput();
1444
+ if (!input || typeof input !== "object" || Array.isArray(input)) {
1445
+ throw new Error("write-coding-sessions-config expects a JSON object on stdin.");
1446
+ }
1447
+ const configPath = await writeCodingSessionsConfig({
1448
+ apiUrl: trimTrailingSlash(requiredConfigString(input.apiUrl, "apiUrl")),
1449
+ userId: requiredConfigString(input.userId, "userId"),
1450
+ agentToken: requiredConfigString(input.agentToken, "agentToken"),
1451
+ intervalSeconds: Number(input.intervalSeconds ?? args["coding-sessions-interval-seconds"] ?? 60),
1452
+ });
1453
+ console.log(JSON.stringify({ configPath }, null, 2));
1454
+ }
1455
+
1389
1456
  async function runInstallMessagesAgent() {
1390
1457
  const configPath = stringArg("config");
1391
1458
  if (!configPath) throw new Error("install-messages-agent requires --config <path>");
@@ -1411,6 +1478,31 @@ async function runInstallMessagesAgent() {
1411
1478
  console.log(JSON.stringify(install, null, 2));
1412
1479
  }
1413
1480
 
1481
+ async function runInstallCodingSessionsAgent() {
1482
+ const configPath = stringArg("config");
1483
+ if (!configPath) throw new Error("install-coding-sessions-agent requires --config <path>");
1484
+ let config;
1485
+ try {
1486
+ config = JSON.parse(await readFile(configPath, "utf8"));
1487
+ } catch (err) {
1488
+ if (err && typeof err === "object" && "code" in err) throw err;
1489
+ throw new Error(`install-coding-sessions-agent: config file at ${configPath} does not contain valid JSON.`);
1490
+ }
1491
+ const userId = stringArg("user-id") ?? requiredConfigString(config.userId, "userId");
1492
+ const overrides = {
1493
+ programArguments: parseJsonArrayArg("program"),
1494
+ environment: parseJsonObjectArg("env"),
1495
+ };
1496
+
1497
+ if (args["dry-run"]) {
1498
+ console.log(JSON.stringify(buildCodingSessionsAgentInstall(configPath, userId, overrides), null, 2));
1499
+ return;
1500
+ }
1501
+
1502
+ const install = await installCodingSessionsAgent(configPath, userId, overrides);
1503
+ console.log(JSON.stringify(install, null, 2));
1504
+ }
1505
+
1414
1506
  async function readJsonInput() {
1415
1507
  const chunks = [];
1416
1508
  for await (const chunk of process.stdin) chunks.push(chunk);
@@ -1492,7 +1584,9 @@ async function runMessagesAgent() {
1492
1584
  const userId = requiredConfigString(config.userId, "userId");
1493
1585
  const agentToken = requiredConfigString(config.agentToken, "agentToken");
1494
1586
  mergeShepherdOwnedMessageHandles(config.excludedMessageHandles);
1495
- const backfillDays = parseBackfillDays(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays, null);
1587
+ const backfillOverride = args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS;
1588
+ const backfillExplicit = backfillOverride !== undefined && backfillOverride !== null && backfillOverride !== "";
1589
+ const backfillDays = parseBackfillDays(backfillExplicit ? backfillOverride : config.backfillDays, null);
1496
1590
  const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
1497
1591
  const allChats = config.allChats === true || selectedChatIdsIncludeAll(allowedChatIds);
1498
1592
  if (!allChats && allowedChatIds.length === 0) {
@@ -1502,12 +1596,15 @@ async function runMessagesAgent() {
1502
1596
  const kit = await import("@photon-ai/imessage-kit");
1503
1597
  const sdk = new kit.IMessageSDK({ debug: args.debug === true });
1504
1598
  const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
1505
- const contactLookup = createMutableContactLookup(buildContactLookup());
1599
+ const contactLookup = createMutableContactLookup(buildContactLookup({ userId }));
1506
1600
  const serializer = createMessageSerializer(kit, contactLookup);
1601
+ const stateCache = createMessageStateCache(userId);
1507
1602
  const contactSync = startMessagesContactSync(sender, contactLookup, {
1603
+ userId,
1508
1604
  syncAllContacts: allChats,
1509
1605
  seedHandles: allChats ? [] : selectedChatContactSeedHandles(config.selectedChats, allowedChatIds),
1510
1606
  });
1607
+ let editDetector = null;
1511
1608
 
1512
1609
  console.log("Shepherd Messages raw sync starting");
1513
1610
  console.log(allChats
@@ -1515,25 +1612,52 @@ async function runMessagesAgent() {
1515
1612
  : `Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
1516
1613
 
1517
1614
  try {
1615
+ await validateMessagesDatabaseAccess(sdk);
1616
+ console.log("Messages local database access validated");
1518
1617
  await contactSync.syncNow({ forceAll: true, reason: "startup" }).catch((err) => {
1519
1618
  console.error("Initial Messages contact sync failed:", safeError(err));
1520
1619
  });
1521
1620
  await loadGroupChatNames(sdk, serializer);
1522
1621
  loadSelectedChatNames(config.selectedChats, serializer);
1523
1622
 
1524
- if (backfillDays !== 0) {
1525
- await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync);
1623
+ const initialWatermark = loadMessagesWatermark(userId);
1624
+ const backfillScope = messagesBackfillScope({ backfillDays, allChats, allowedChatIds });
1625
+ let backfillComplete = loadMessagesBackfillComplete(userId, backfillScope);
1626
+ if (!backfillComplete && !hasAnyMessagesBackfillComplete(userId) && initialWatermark > 0 && !backfillExplicit && backfillDays !== 0) {
1627
+ backfillComplete = saveMessagesBackfillComplete(userId, backfillScope, {
1628
+ legacyAssumedComplete: true,
1629
+ watermark: initialWatermark,
1630
+ });
1631
+ }
1632
+ if (shouldRunMessagesBackfill({ backfillDays, backfillExplicit, backfillComplete })) {
1633
+ const backfillResult = await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync, stateCache);
1634
+ if (backfillResult.complete) {
1635
+ saveMessagesBackfillComplete(userId, backfillScope, backfillResult);
1636
+ } else {
1637
+ console.warn("Messages backfill paused before completion because messages were queued for retry");
1638
+ }
1526
1639
  await contactSync.syncNow({ forceAll: true, reason: "post-backfill" }).catch((err) => {
1527
1640
  console.error("Post-backfill Messages contact sync failed:", safeError(err));
1528
1641
  });
1642
+ } else if (backfillDays !== 0) {
1643
+ console.log(`Skipping configured Messages backfill because this chat scope is already complete; use --backfill-days to force a historical backfill`);
1529
1644
  }
1530
1645
 
1531
- await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync);
1646
+ await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync, stateCache);
1532
1647
  await contactSync.syncNow({ forceAll: true, reason: "post-gap-fill" }).catch((err) => {
1533
1648
  console.error("Post-gap-fill Messages contact sync failed:", safeError(err));
1534
1649
  });
1535
- await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, { contactSync });
1650
+ editDetector = startMessagesEditDetector(sdk, sender, serializer, allChats ? null : allowedChatIds, {
1651
+ contactSync,
1652
+ stateCache,
1653
+ });
1654
+ await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, {
1655
+ contactSync,
1656
+ stateCache,
1657
+ onShutdown: () => editDetector?.stop(),
1658
+ });
1536
1659
  } catch (err) {
1660
+ editDetector?.stop();
1537
1661
  contactSync.stop();
1538
1662
  await sdk.close?.().catch(() => undefined);
1539
1663
  throw err;
@@ -1600,6 +1724,7 @@ async function collectMessagesLocalStatus(preferredUserId = null) {
1600
1724
  allChats: config?.allChats === true,
1601
1725
  selectedChatCount: Array.isArray(config?.allowedChatIds) ? config.allowedChatIds.length : 0,
1602
1726
  storage: await probePath("messages", MESSAGES_CHAT_DB_PATH),
1727
+ contacts: userId ? readJsonOptional(messagesContactStatusFile(userId)) : null,
1603
1728
  launch: localLaunchStatus(label),
1604
1729
  queueDepth: Array.isArray(queue) ? queue.length : 0,
1605
1730
  };
@@ -1827,6 +1952,7 @@ Options:
1827
1952
  --api <url> Advanced: Shepherd API URL.
1828
1953
  --state <path> Token state file. Defaults to ~/.shepherd/mcp.json.
1829
1954
  --onboarding-state <path> Local onboarding state file. Defaults to ~/.shepherd/raw-onboarding-agent.json.
1955
+ --mcp-program <json_array> Advanced: MCP proxy command prefix. The app uses this to install app-binary-backed MCP instead of npm.
1830
1956
  --no-local Skip local onboarding auth and use WorkOS browser login.
1831
1957
  --install <targets> Install MCP after login. Use all, none, codex, claude, cursor, or comma-separated targets.
1832
1958
  --no-install Save the MCP token without installing client config.
@@ -1855,6 +1981,7 @@ Installs the saved Shepherd MCP login into:
1855
1981
  Options:
1856
1982
  --state <path> Token state file. Defaults to ~/.shepherd/mcp.json.
1857
1983
  --onboarding-state <path> Local onboarding state file. Defaults to ~/.shepherd/raw-onboarding-agent.json.
1984
+ --mcp-program <json_array> Advanced: MCP proxy command prefix. The app uses this to install app-binary-backed MCP instead of npm.
1858
1985
  --no-local Skip local onboarding auth refresh.
1859
1986
  --install <targets> Use all, none, codex, claude, cursor, or comma-separated targets.
1860
1987
  --no-install Skip client config writes.
@@ -1864,7 +1991,7 @@ Options:
1864
1991
  return;
1865
1992
  }
1866
1993
 
1867
- if (which === "write-agent-state" || which === "write-messages-config" || which === "install-messages-agent") {
1994
+ if (which === "write-agent-state" || which === "write-messages-config" || which === "install-messages-agent" || which === "write-coding-sessions-config" || which === "install-coding-sessions-agent") {
1868
1995
  console.log(`Shepherd onboarding engine commands
1869
1996
 
1870
1997
  These non-interactive commands are used by GUI onboarding apps (for example the
@@ -1876,9 +2003,12 @@ Usage:
1876
2003
  shepherd-onboard write-messages-config JSON object on stdin ({apiUrl, userId, agentToken, backfillDays?, allowedChatIds, selectedChats?}) is written to ~/.shepherd/raw-messages/<userId>.json. Prints {configPath}.
1877
2004
  shepherd-onboard install-messages-agent --config <path>
1878
2005
  Installs and verifies the Messages launchd agent for an existing config. Prints install metadata.
2006
+ shepherd-onboard write-coding-sessions-config JSON object on stdin ({apiUrl, userId, agentToken, intervalSeconds?}) is written to ~/.shepherd/coding-sessions/<userId>.json. Prints {configPath}.
2007
+ shepherd-onboard install-coding-sessions-agent --config <path>
2008
+ Installs and verifies the Coding Sessions launchd agent for an existing config. Prints install metadata.
1879
2009
 
1880
- install-messages-agent options:
1881
- --config <path> Messages agent config created by onboarding. Required.
2010
+ install agent options:
2011
+ --config <path> Agent config created by onboarding. Required.
1882
2012
  --user-id <id> Override the user ID. Defaults to the config's userId.
1883
2013
  --program <json_array> Replace the default npx launcher with custom ProgramArguments (e.g. a signed app binary). --config <path> is appended.
1884
2014
  --env <json_object> Extra EnvironmentVariables merged into the launchd plist (e.g. ELECTRON_RUN_AS_NODE).
@@ -2064,7 +2194,7 @@ function printAgentContract() {
2064
2194
  agentStatusCommand: `${command} agent --status`,
2065
2195
  messagesChatsCommand: `${command} messages-chats`,
2066
2196
  messagesPermissions: {
2067
- macOS: "Local Messages raw sync needs Full Disk Access for the app running onboarding and for Node.js used by the background LaunchAgent. The Messages selector command validates local chat.db access, opens Full Disk Access settings if needed, and keeps checking until access works in interactive onboarding. Background sync install also checks that launchd can start the Messages agent. Contacts permission may also appear when resolving local contact names. The background Messages agent reloads Contacts on startup, watches AddressBook changes when available, and runs fallback contact sync so renamed contacts can hydrate prior ingested Messages rows for the token-bound customer account.",
2197
+ macOS: "Local Messages raw sync needs Full Disk Access for the app running onboarding and for Node.js used by the background LaunchAgent, so launchd can start the Messages agent after onboarding. The Messages selector command validates local chat.db access, opens Full Disk Access settings if needed, and keeps checking until access works in interactive onboarding. Contacts permission is also needed for contact-name hydration; Shepherd reads contact names from the local macOS AddressBook database, reloads Contacts on startup, watches AddressBook changes, and scopes synced messages to the token-bound customer account. If that schema cannot be read, raw message sync still works but contact names are marked degraded and the user should contact founders@askshepherd.ai.",
2068
2198
  nodeBinary: process.execPath,
2069
2199
  },
2070
2200
  codingSessions: {
@@ -2133,7 +2263,7 @@ If Messages is selected, run:
2133
2263
 
2134
2264
  Before or during this step, ask the user to grant or confirm macOS Full Disk Access for local Messages sync. The command validates access to the local Messages database, opens System Settings -> Privacy & Security -> Full Disk Access if access is missing, and keeps checking until access works in interactive onboarding. The user should enable the app running onboarding, such as Terminal, iTerm, Claude Code, or Codex, and Node.js for background sync:
2135
2265
  ${payload.messagesPermissions.nodeBinary}
2136
- Contacts permission may also appear when Shepherd resolves local contact names. The background Messages agent reloads Contacts on startup, watches AddressBook changes when available, and runs fallback contact sync so renamed contacts can hydrate prior ingested Messages rows for the token-bound customer account.
2266
+ Shepherd reads contact names from the local macOS AddressBook database. If that database schema cannot be read, raw Messages sync still works, but contact names will be marked degraded and the user should contact founders@askshepherd.ai.
2137
2267
 
2138
2268
  This opens a minimal local webpage with recent local Messages chats and search. Have the user select which contacts/groups Shepherd should sync. Do not select all chats by default. If the user explicitly wants everything, use the "Sync all current and future chats" checkbox or pass --messages-chat-ids all. All-chats mode backfills current chats and keeps watching chats that appear later. When the command returns, keep the printed chat IDs or the literal value all.
2139
2269
 
@@ -2230,6 +2360,7 @@ function sourceSelectionFromList(value) {
2230
2360
  const selected = {
2231
2361
  google: false,
2232
2362
  slack: false,
2363
+ github: false,
2233
2364
  granola: false,
2234
2365
  messages: false,
2235
2366
  codingSessions: false,
@@ -2242,6 +2373,7 @@ function sourceSelectionFromList(value) {
2242
2373
  ["gdocs", "google"],
2243
2374
  ["calendar", "google"],
2244
2375
  ["slack", "slack"],
2376
+ ["github", "github"],
2245
2377
  ["granola", "granola"],
2246
2378
  ["messages", "messages"],
2247
2379
  ["imessage", "messages"],
@@ -2261,6 +2393,7 @@ function sourceSelectionFromList(value) {
2261
2393
  if (part === "all") {
2262
2394
  selected.google = true;
2263
2395
  selected.slack = true;
2396
+ selected.github = true;
2264
2397
  selected.granola = true;
2265
2398
  selected.messages = true;
2266
2399
  selected.codingSessions = true;
@@ -2268,7 +2401,7 @@ function sourceSelectionFromList(value) {
2268
2401
  }
2269
2402
  const source = aliases.get(part);
2270
2403
  if (!source) {
2271
- throw new Error(`Unknown source "${part}". Use google, slack, granola, messages, coding-sessions, or all.`);
2404
+ throw new Error(`Unknown source "${part}". Use google, slack, github, granola, messages, coding-sessions, or all.`);
2272
2405
  }
2273
2406
  selected[source] = true;
2274
2407
  }
@@ -2324,7 +2457,9 @@ async function updateAgentStateFromOnboardingResponse(state, response) {
2324
2457
  const hasStatus = typeof response?.status === "string";
2325
2458
  const hasProcessing = typeof response?.processingEnabled === "boolean" || response?.processing;
2326
2459
  const hasProviders = response?.providers && typeof response.providers === "object" && !Array.isArray(response.providers);
2327
- if (!hasAuthUrls && !hasGoogleWorkspaceDelegation && !hasStatus && !hasProcessing && !hasProviders) return state;
2460
+ const responseSources = sourceSelectionFromSession(response, null);
2461
+ const hasSources = responseSources !== null;
2462
+ if (!hasAuthUrls && !hasGoogleWorkspaceDelegation && !hasStatus && !hasProcessing && !hasProviders && !hasSources) return state;
2328
2463
 
2329
2464
  const next = {
2330
2465
  ...state,
@@ -2336,6 +2471,7 @@ async function updateAgentStateFromOnboardingResponse(state, response) {
2336
2471
  ...(typeof response?.processingEnabled === "boolean" ? { processingEnabled: response.processingEnabled } : {}),
2337
2472
  ...(response?.processing ? { processing: response.processing } : {}),
2338
2473
  ...(hasProviders ? { providers: response.providers } : {}),
2474
+ ...(hasSources ? { sources: responseSources } : {}),
2339
2475
  };
2340
2476
  await writeAgentState(next);
2341
2477
  return next;
@@ -2442,6 +2578,20 @@ async function openNextAgentModality({ sources, authUrls = {}, noOpen = false, p
2442
2578
  return { source, label: "Slack", opened: !noOpen, url };
2443
2579
  }
2444
2580
 
2581
+ if (source === "github") {
2582
+ const url = typeof authUrls.github === "string" ? authUrls.github : null;
2583
+ if (!url) {
2584
+ return {
2585
+ source,
2586
+ label: "GitHub",
2587
+ opened: false,
2588
+ message: "GitHub authorization URL was not returned by Shepherd.",
2589
+ };
2590
+ }
2591
+ await openOrPrint(url, { noOpen });
2592
+ return { source, label: "GitHub", opened: !noOpen, url };
2593
+ }
2594
+
2445
2595
  if (source === "granola") {
2446
2596
  const result = await openGranolaApiKeys({ noOpen: noOpen || Boolean(args["no-open-granola"]) });
2447
2597
  return { source, label: "Granola", ...result };
@@ -2503,6 +2653,18 @@ function printAgentCurrentAction(action, opts = {}) {
2503
2653
  return;
2504
2654
  }
2505
2655
 
2656
+ if (action.source === "github") {
2657
+ if (action.opened) {
2658
+ console.log("Opened GitHub authorization in the browser.");
2659
+ } else if (action.url) {
2660
+ console.log(`GitHub authorization URL: ${action.url}`);
2661
+ } else if (action.message) {
2662
+ console.log(action.message);
2663
+ }
2664
+ console.log("Ask the user to complete GitHub authorization before opening another source.");
2665
+ return;
2666
+ }
2667
+
2506
2668
  if (action.source === "granola") {
2507
2669
  if (action.target) console.log(`Granola target: ${action.target}`);
2508
2670
  console.log("Ask the user to create/copy the Granola API key before opening another source.");
@@ -2526,6 +2688,7 @@ function agentNeedsUserAction(sources, action) {
2526
2688
  if (!action) return [];
2527
2689
  if (action.source === "google") return ["Have the customer's Google Workspace super admin authorize Shepherd's domain-wide delegation Client ID and scopes in Google Admin Console."];
2528
2690
  if (action.source === "slack") return ["Complete Slack browser authorization."];
2691
+ if (action.source === "github") return ["Complete GitHub browser authorization."];
2529
2692
  if (action.source === "granola") return ["Create/copy a Granola API key from the Granola Mac app."];
2530
2693
  if (action.source === "messages") return ["Grant or confirm macOS Full Disk Access for the onboarding app and Node.js, run messages-chats, have the user select local Messages contacts/groups in the browser, then pass the printed chat IDs with the Messages handle."];
2531
2694
  if (action.source === "codingSessions") return ["Run the continue command to install local Codex and Claude Code session summary sync."];
@@ -2683,7 +2846,7 @@ async function explainMessagesBackgroundPermissions(opts = {}) {
2683
2846
  console.log("\nMessages background sync permissions");
2684
2847
  console.log("Local Messages raw sync runs as a macOS LaunchAgent using npx/Node.js. For continuous sync, macOS Full Disk Access must include the background Node.js binary, not just the current terminal.");
2685
2848
  printMessagesPermissionTargets();
2686
- console.log("Contacts permission may also appear when Shepherd resolves local contact names. The background agent keeps contact names hydrated for observed Messages conversations.");
2849
+ console.log("Shepherd reads contact names from the local macOS AddressBook database. If that schema cannot be read, raw Messages sync still works but contact names are marked degraded; contact founders@askshepherd.ai.");
2687
2850
  await openFullDiskAccessSettings(opts);
2688
2851
 
2689
2852
  if (opts.waitForUser && process.stdin.isTTY && !args["no-permission-prompt"]) {
@@ -2809,6 +2972,7 @@ async function writeMessagesConfig(input) {
2809
2972
  apiUrl: input.apiUrl,
2810
2973
  userId: input.userId,
2811
2974
  agentToken: input.agentToken,
2975
+ ...(input.handle ? { handle: input.handle } : {}),
2812
2976
  backfillDays: input.backfillDays,
2813
2977
  allChats,
2814
2978
  allowedChatIds: allChats ? [] : allowedChatIds,
@@ -2939,22 +3103,29 @@ async function writeCodingSessionsConfig(input) {
2939
3103
  return path;
2940
3104
  }
2941
3105
 
2942
- async function installCodingSessionsAgent(configPath, userId) {
2943
- if (platform() !== "darwin") {
2944
- throw new Error("automatic local coding-session sync is only supported on macOS");
2945
- }
2946
-
3106
+ function buildCodingSessionsAgentInstall(configPath, userId, overrides = {}) {
2947
3107
  const safeId = safeFileId(userId);
2948
3108
  const label = `ai.shepherd.coding-sessions.${safeId}`;
2949
3109
  const rawDir = join(homedir(), ".shepherd", "coding-sessions");
2950
3110
  const agentsDir = join(homedir(), "Library", "LaunchAgents");
2951
- await mkdir(rawDir, { recursive: true });
2952
- await mkdir(agentsDir, { recursive: true });
2953
-
2954
3111
  const plistPath = join(agentsDir, `${label}.plist`);
2955
3112
  const stdoutPath = join(rawDir, `${safeId}.out.log`);
2956
3113
  const stderrPath = join(rawDir, `${safeId}.err.log`);
2957
- const launchPath = launchAgentPath();
3114
+ const programPrefix = Array.isArray(overrides.programArguments) && overrides.programArguments.length > 0
3115
+ ? overrides.programArguments
3116
+ : ["/usr/bin/env", "npx", "-y", PACKAGE_SPEC, "coding-sessions-agent"];
3117
+ const programArguments = [...programPrefix, "--config", configPath];
3118
+ const environment = {
3119
+ PATH: launchAgentPath(),
3120
+ ...stringRecord(overrides.environment),
3121
+ };
3122
+
3123
+ const programArgumentsXml = programArguments
3124
+ .map((value) => ` <string>${xmlEscape(value)}</string>`)
3125
+ .join("\n");
3126
+ const environmentXml = Object.entries(environment)
3127
+ .map(([key, value]) => ` <key>${xmlEscape(key)}</key>\n <string>${xmlEscape(value)}</string>`)
3128
+ .join("\n");
2958
3129
 
2959
3130
  const plist = `<?xml version="1.0" encoding="UTF-8"?>
2960
3131
  <!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
@@ -2964,39 +3135,50 @@ async function installCodingSessionsAgent(configPath, userId) {
2964
3135
  <string>${xmlEscape(label)}</string>
2965
3136
  <key>ProgramArguments</key>
2966
3137
  <array>
2967
- <string>/usr/bin/env</string>
2968
- <string>npx</string>
2969
- <string>-y</string>
2970
- <string>${PACKAGE_SPEC}</string>
2971
- <string>coding-sessions-agent</string>
2972
- <string>--config</string>
2973
- <string>${xmlEscape(configPath)}</string>
3138
+ ${programArgumentsXml}
2974
3139
  </array>
2975
3140
  <key>KeepAlive</key>
2976
3141
  <true/>
2977
3142
  <key>RunAtLoad</key>
2978
3143
  <true/>
3144
+ <key>ThrottleInterval</key>
3145
+ <integer>10</integer>
3146
+ <key>WorkingDirectory</key>
3147
+ <string>${xmlEscape(rawDir)}</string>
2979
3148
  <key>StandardOutPath</key>
2980
3149
  <string>${xmlEscape(stdoutPath)}</string>
2981
3150
  <key>StandardErrorPath</key>
2982
3151
  <string>${xmlEscape(stderrPath)}</string>
2983
3152
  <key>EnvironmentVariables</key>
2984
3153
  <dict>
2985
- <key>PATH</key>
2986
- <string>${xmlEscape(launchPath)}</string>
3154
+ ${environmentXml}
2987
3155
  </dict>
2988
3156
  </dict>
2989
3157
  </plist>
2990
3158
  `;
2991
3159
 
3160
+ return { label, rawDir, agentsDir, plistPath, stdoutPath, stderrPath, programArguments, environment, plist };
3161
+ }
3162
+
3163
+ async function installCodingSessionsAgent(configPath, userId, overrides = {}) {
3164
+ if (platform() !== "darwin") {
3165
+ throw new Error("automatic local coding-session sync is only supported on macOS");
3166
+ }
3167
+
3168
+ const install = buildCodingSessionsAgentInstall(configPath, userId, overrides);
3169
+ const { label, rawDir, agentsDir, plistPath, stdoutPath, stderrPath, plist } = install;
3170
+ await mkdir(rawDir, { recursive: true });
3171
+ await mkdir(agentsDir, { recursive: true });
3172
+
2992
3173
  await writeFile(plistPath, plist, { mode: 0o600 });
3174
+ await chmod(plistPath, 0o600);
2993
3175
  const stdoutOffset = await fileLength(stdoutPath);
2994
3176
  const stderrOffset = await fileLength(stderrPath);
2995
3177
  await execFileQuiet("launchctl", ["unload", plistPath], { ignoreError: true });
2996
3178
  await execFileQuiet("launchctl", ["load", plistPath]);
2997
3179
  await execFileQuiet("launchctl", ["start", label], { ignoreError: true });
2998
3180
  await verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset });
2999
- return { label, plistPath, stdoutPath, stderrPath };
3181
+ return install;
3000
3182
  }
3001
3183
 
3002
3184
  async function verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
@@ -3035,7 +3217,7 @@ async function verifyMessagesAgentLaunch({ label, stdoutPath, stderrPath, stdout
3035
3217
  throw new Error("Messages background sync could not open local Messages storage. Grant macOS Full Disk Access to the app running onboarding and Node.js, then rerun or continue the Messages step.");
3036
3218
  }
3037
3219
 
3038
- if (/Watching for new Messages|Shepherd Messages raw sync starting|Running .*Messages backfill/i.test(stdout)
3220
+ if (/Messages local database access validated|Watching for new Messages/i.test(stdout)
3039
3221
  && /state = running|job state = running/i.test(launchState)) {
3040
3222
  return;
3041
3223
  }
@@ -3958,7 +4140,27 @@ function selectedChatContactSeedHandles(selectedChats, allowedChatIds) {
3958
4140
  return [...new Set(handles)];
3959
4141
  }
3960
4142
 
3961
- async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null) {
4143
+ async function validateMessagesDatabaseAccess(sdk) {
4144
+ await sdk.getMessages({ limit: 1 });
4145
+ }
4146
+
4147
+ function messagesBackfillScope({ backfillDays, allChats, allowedChatIds }) {
4148
+ return {
4149
+ version: 1,
4150
+ backfillDays: backfillDays == null ? "all" : backfillDays,
4151
+ scope: allChats
4152
+ ? { allChats: true }
4153
+ : { chatIds: [...allowedChatIds].sort() },
4154
+ };
4155
+ }
4156
+
4157
+ function shouldRunMessagesBackfill({ backfillDays, backfillExplicit, backfillComplete }) {
4158
+ if (backfillDays === 0) return false;
4159
+ if (backfillExplicit) return true;
4160
+ return !backfillComplete;
4161
+ }
4162
+
4163
+ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null, stateCache = null) {
3962
4164
  const allChats = allowedChatIds == null;
3963
4165
  console.log(allChats
3964
4166
  ? `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for all current chats`
@@ -3979,6 +4181,8 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
3979
4181
  totalMessages += filtered.length;
3980
4182
  const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
3981
4183
  totalStored += result.stored;
4184
+ if ((result.queued ?? 0) > 0) return { complete: false, totalMessages, totalStored, queued: result.queued };
4185
+ stateCache?.observe(filtered);
3982
4186
  saveMessagesWatermark(sender.userId, maxRowId(messages));
3983
4187
 
3984
4188
  if (messages.length < pageSize) break;
@@ -3986,7 +4190,7 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
3986
4190
  }
3987
4191
 
3988
4192
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
3989
- return;
4193
+ return { complete: true, totalMessages, totalStored, queued: 0, watermark: loadMessagesWatermark(sender.userId) };
3990
4194
  }
3991
4195
 
3992
4196
  for (const chatId of allowedChatIds) {
@@ -4000,6 +4204,8 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
4000
4204
  totalMessages += filtered.length;
4001
4205
  const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
4002
4206
  totalStored += result.stored;
4207
+ if ((result.queued ?? 0) > 0) return { complete: false, totalMessages, totalStored, queued: result.queued };
4208
+ stateCache?.observe(filtered);
4003
4209
  saveMessagesWatermark(sender.userId, maxRowId(messages));
4004
4210
 
4005
4211
  if (messages.length < pageSize) break;
@@ -4008,19 +4214,20 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
4008
4214
  }
4009
4215
 
4010
4216
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
4217
+ return { complete: true, totalMessages, totalStored, queued: 0, watermark: loadMessagesWatermark(sender.userId) };
4011
4218
  }
4012
4219
 
4013
- async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null) {
4220
+ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null, stateCache = null) {
4014
4221
  const allChats = allowedChatIds == null;
4015
4222
  const lastWatermark = loadMessagesWatermark(userId);
4016
4223
  if (lastWatermark <= 0) return;
4017
4224
 
4018
- const missed = [];
4225
+ let missed = [];
4019
4226
  if (allChats) {
4020
- missed.push(...await sdk.getMessages({ limit: 5000 }));
4227
+ missed = await getMessagesAfterWatermark(sdk, { lastWatermark, pageSize: 1000 });
4021
4228
  } else {
4022
4229
  for (const chatId of allowedChatIds) {
4023
- missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
4230
+ missed.push(...await getMessagesAfterWatermark(sdk, { chatId, lastWatermark, pageSize: 1000 }));
4024
4231
  }
4025
4232
  }
4026
4233
  const newMessages = missed.filter((msg) =>
@@ -4029,16 +4236,405 @@ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChat
4029
4236
  && !messageTouchesShepherdAgent(msg));
4030
4237
  if (newMessages.length === 0) return;
4031
4238
 
4239
+ newMessages.sort((a, b) => Number(a.rowId ?? 0) - Number(b.rowId ?? 0));
4032
4240
  contactSync?.observeMessages(newMessages);
4033
4241
  const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
4034
- if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(newMessages));
4242
+ if ((result.queued ?? 0) === 0) {
4243
+ stateCache?.observe(newMessages);
4244
+ saveMessagesWatermark(userId, maxRowId(newMessages));
4245
+ }
4035
4246
  console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
4036
4247
  }
4037
4248
 
4249
+ async function getMessagesAfterWatermark(sdk, { chatId = null, lastWatermark, pageSize }) {
4250
+ const messages = [];
4251
+ let offset = 0;
4252
+ while (true) {
4253
+ const page = await sdk.getMessages({ ...(chatId ? { chatId } : {}), limit: pageSize, offset });
4254
+ if (!page.length) break;
4255
+ messages.push(...page.filter((msg) => Number(msg.rowId) > lastWatermark));
4256
+ if (page.length < pageSize) break;
4257
+ offset += pageSize;
4258
+ }
4259
+ return messages;
4260
+ }
4261
+
4262
+ function createMessageStateCache(userId, maxSize = MESSAGES_STATE_CACHE_MAX) {
4263
+ const stateFile = messagesStateFile(userId);
4264
+ const initial = readJsonOptional(stateFile);
4265
+ const cache = new Map(Object.entries(
4266
+ initial && typeof initial === "object" && !Array.isArray(initial) ? initial : {},
4267
+ ));
4268
+
4269
+ const remember = (msg) => {
4270
+ const key = messageIdentity(msg);
4271
+ if (!key) return;
4272
+ cache.set(key, messageState(msg));
4273
+ trimMessageStateCache(cache, maxSize);
4274
+ };
4275
+
4276
+ const persist = () => saveMessagesState(userId, Object.fromEntries(cache));
4277
+
4278
+ return {
4279
+ observe(messages) {
4280
+ for (const msg of messages ?? []) remember(msg);
4281
+ persist();
4282
+ },
4283
+ changed(messages) {
4284
+ const changed = [];
4285
+ for (const msg of messages ?? []) {
4286
+ const key = messageIdentity(msg);
4287
+ if (!key) continue;
4288
+ const current = messageState(msg);
4289
+ const previous = cache.get(key);
4290
+ if ((previous && !sameMessageState(previous, current))
4291
+ || (!previous && hasMessageEditOrRetraction(msg))) {
4292
+ changed.push(msg);
4293
+ }
4294
+ }
4295
+ return changed;
4296
+ },
4297
+ };
4298
+ }
4299
+
4300
+ function trimMessageStateCache(cache, maxSize) {
4301
+ if (cache.size <= maxSize) return;
4302
+ const excess = cache.size - maxSize;
4303
+ for (const key of [...cache.keys()].slice(0, excess)) cache.delete(key);
4304
+ }
4305
+
4306
+ function hasMessageEditOrRetraction(msg) {
4307
+ return Boolean(msg?.editedAt || msg?.retractedAt);
4308
+ }
4309
+
4310
+ function messageIdentity(msg) {
4311
+ const value = msg?.id ?? msg?.messageId ?? msg?.rowId;
4312
+ return value == null ? null : String(value);
4313
+ }
4314
+
4315
+ function messageState(msg) {
4316
+ const participant = messageParticipant(msg);
4317
+ return {
4318
+ text: msg?.text ?? null,
4319
+ chatId: msg?.chatId ?? null,
4320
+ chatKind: messageChatKind(msg),
4321
+ participant,
4322
+ isFromMe: Boolean(msg?.isFromMe),
4323
+ editedAt: isoDate(msg?.editedAt),
4324
+ retractedAt: isoDate(msg?.retractedAt),
4325
+ replyToMessageId: msg?.replyToMessageId ?? null,
4326
+ threadRootMessageId: msg?.threadRootMessageId ?? null,
4327
+ affectedParticipant: msg?.affectedParticipant ?? null,
4328
+ kind: msg?.kind ?? null,
4329
+ hasAttachments: Boolean(msg?.hasAttachments ?? (Array.isArray(msg?.attachments) && msg.attachments.length > 0)),
4330
+ attachments: messageAttachmentState(msg),
4331
+ };
4332
+ }
4333
+
4334
+ function sameMessageState(a, b) {
4335
+ return a.text === b.text
4336
+ && a.chatId === b.chatId
4337
+ && a.chatKind === b.chatKind
4338
+ && a.participant === b.participant
4339
+ && a.isFromMe === b.isFromMe
4340
+ && a.editedAt === b.editedAt
4341
+ && a.retractedAt === b.retractedAt
4342
+ && a.replyToMessageId === b.replyToMessageId
4343
+ && a.threadRootMessageId === b.threadRootMessageId
4344
+ && a.affectedParticipant === b.affectedParticipant
4345
+ && a.kind === b.kind
4346
+ && a.hasAttachments === b.hasAttachments
4347
+ && stableLocalJson(a.attachments) === stableLocalJson(b.attachments);
4348
+ }
4349
+
4350
+ function messageAttachmentState(msg) {
4351
+ return (Array.isArray(msg?.attachments) ? msg.attachments : [])
4352
+ .map((att) => ({
4353
+ id: String(att?.id ?? ""),
4354
+ fileName: att?.fileName ?? null,
4355
+ mimeType: att?.mimeType ?? null,
4356
+ sizeBytes: Number(att?.sizeBytes ?? 0),
4357
+ transferStatus: att?.transferStatus ?? null,
4358
+ isSticker: Boolean(att?.isSticker),
4359
+ isSensitiveContent: Boolean(att?.isSensitiveContent),
4360
+ }))
4361
+ .sort((a, b) => a.id.localeCompare(b.id) || String(a.fileName ?? "").localeCompare(String(b.fileName ?? "")));
4362
+ }
4363
+
4364
+ function stableLocalJson(value) {
4365
+ if (Array.isArray(value)) return `[${value.map(stableLocalJson).join(",")}]`;
4366
+ if (value && typeof value === "object") {
4367
+ return `{${Object.entries(value)
4368
+ .filter(([, entryValue]) => entryValue !== undefined)
4369
+ .sort(([a], [b]) => a.localeCompare(b))
4370
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableLocalJson(entryValue)}`)
4371
+ .join(",")}}`;
4372
+ }
4373
+ return JSON.stringify(value);
4374
+ }
4375
+
4376
+ function startMessagesEditDetector(sdk, sender, serializer, allowedChatIds, opts = {}) {
4377
+ const contactSync = opts.contactSync ?? null;
4378
+ const stateCache = opts.stateCache ?? createMessageStateCache(sender.userId);
4379
+ const watchers = [];
4380
+ let stopped = false;
4381
+ let timer = null;
4382
+ let scanning = false;
4383
+ let lastMutationReconcileAt = 0;
4384
+
4385
+ const scan = async (reason) => {
4386
+ if (stopped || scanning) return;
4387
+ scanning = true;
4388
+ try {
4389
+ const recentResult = await scanMessagesForEditsAndUnsends(sdk, sender, serializer, allowedChatIds, {
4390
+ contactSync,
4391
+ stateCache,
4392
+ });
4393
+ const shouldReconcileMutations = reason === "startup"
4394
+ || Date.now() - lastMutationReconcileAt >= MESSAGES_MUTATION_RECONCILE_INTERVAL_MS;
4395
+ const mutationResult = shouldReconcileMutations
4396
+ ? await reconcileMessagesMutations(sdk, sender, serializer, allowedChatIds, {
4397
+ contactSync,
4398
+ stateCache,
4399
+ })
4400
+ : { changed: 0, stored: 0, updated: 0, queued: 0 };
4401
+ if (shouldReconcileMutations) lastMutationReconcileAt = Date.now();
4402
+
4403
+ const result = {
4404
+ changed: recentResult.changed + mutationResult.changed,
4405
+ stored: recentResult.stored + mutationResult.stored,
4406
+ updated: recentResult.updated + mutationResult.updated,
4407
+ queued: recentResult.queued + mutationResult.queued,
4408
+ };
4409
+ if (result.changed > 0) {
4410
+ console.log(`Messages edit detector: ${result.changed} edit/unsend change(s) sent (${reason}); stored ${result.stored}, updated ${result.updated}, queued ${result.queued}`);
4411
+ }
4412
+ } catch (err) {
4413
+ console.error("Messages edit detector failed:", safeError(err));
4414
+ } finally {
4415
+ scanning = false;
4416
+ }
4417
+ };
4418
+
4419
+ const schedule = (reason) => {
4420
+ if (stopped) return;
4421
+ if (timer) clearTimeout(timer);
4422
+ timer = setTimeout(() => {
4423
+ timer = null;
4424
+ scan(reason).catch((err) => console.error("Messages edit detector failed:", safeError(err)));
4425
+ }, 500);
4426
+ };
4427
+
4428
+ for (const walPath of messagesWatchPaths()) {
4429
+ try {
4430
+ watchers.push(watch(walPath, () => {
4431
+ schedule("messages-db");
4432
+ }));
4433
+ } catch (err) {
4434
+ console.warn(`Could not watch Messages database file ${walPath}: ${safeError(err)}`);
4435
+ }
4436
+ }
4437
+
4438
+ if (watchers.length > 0) {
4439
+ console.log(`Watching ${watchers.length} Messages database file(s) for edits and unsends`);
4440
+ } else if (platform() === "darwin") {
4441
+ console.warn("Messages database watch files not found; edit/unsend detection will use fallback polling only");
4442
+ }
4443
+
4444
+ const interval = setInterval(() => {
4445
+ scan("fallback").catch((err) => console.error("Messages edit detector failed:", safeError(err)));
4446
+ }, MESSAGES_EDIT_SCAN_INTERVAL_MS);
4447
+ scan("startup").catch((err) => console.error("Messages edit detector failed:", safeError(err)));
4448
+
4449
+ return {
4450
+ stop() {
4451
+ stopped = true;
4452
+ if (timer) clearTimeout(timer);
4453
+ clearInterval(interval);
4454
+ for (const watcher of watchers) watcher.close();
4455
+ },
4456
+ };
4457
+ }
4458
+
4459
+ function messagesWatchPaths() {
4460
+ if (platform() !== "darwin") return [];
4461
+ return [
4462
+ MESSAGES_CHAT_DB_PATH,
4463
+ `${MESSAGES_CHAT_DB_PATH}-wal`,
4464
+ `${MESSAGES_CHAT_DB_PATH}-shm`,
4465
+ ].filter((path) => existsSync(path));
4466
+ }
4467
+
4468
+ async function scanMessagesForEditsAndUnsends(sdk, sender, serializer, allowedChatIds, opts = {}) {
4469
+ const allChats = allowedChatIds == null;
4470
+ const since = new Date(Date.now() - MESSAGES_EDIT_SCAN_WINDOW_MS);
4471
+ const messages = await getScopedMessages(sdk, allowedChatIds, { since });
4472
+
4473
+ const filtered = messages.filter((msg) =>
4474
+ msg?.chatId
4475
+ && (allChats || allowedChatIds.includes(msg.chatId))
4476
+ && !messageTouchesShepherdAgent(msg));
4477
+ opts.contactSync?.observeMessages(filtered);
4478
+
4479
+ const changed = opts.stateCache?.changed(filtered) ?? [];
4480
+ if (changed.length === 0) {
4481
+ opts.stateCache?.observe(filtered);
4482
+ return { changed: 0, stored: 0, updated: 0, queued: 0 };
4483
+ }
4484
+
4485
+ const result = await sender.send(changed.map((msg) => serializer.serialize(msg)));
4486
+ if ((result.queued ?? 0) === 0) opts.stateCache?.observe(filtered);
4487
+ return {
4488
+ changed: changed.length,
4489
+ stored: result.stored ?? 0,
4490
+ updated: result.updated ?? 0,
4491
+ queued: result.queued ?? 0,
4492
+ };
4493
+ }
4494
+
4495
+ async function getScopedMessages(sdk, allowedChatIds, query) {
4496
+ const messages = [];
4497
+ if (allowedChatIds == null) {
4498
+ messages.push(...await getPagedMessages(sdk, query));
4499
+ return messages;
4500
+ }
4501
+
4502
+ for (const chatId of allowedChatIds) {
4503
+ messages.push(...await getPagedMessages(sdk, { ...query, chatId }));
4504
+ }
4505
+ return messages;
4506
+ }
4507
+
4508
+ async function getPagedMessages(sdk, query) {
4509
+ const messages = [];
4510
+ let offset = 0;
4511
+ while (true) {
4512
+ const page = await sdk.getMessages({ ...query, limit: MESSAGES_EDIT_SCAN_LIMIT, offset });
4513
+ if (!page.length) break;
4514
+ messages.push(...page);
4515
+ if (page.length < MESSAGES_EDIT_SCAN_LIMIT) break;
4516
+ offset += MESSAGES_EDIT_SCAN_LIMIT;
4517
+ }
4518
+ return messages;
4519
+ }
4520
+
4521
+ async function reconcileMessagesMutations(sdk, sender, serializer, allowedChatIds, opts = {}) {
4522
+ const allChats = allowedChatIds == null;
4523
+ const rowIds = queryMessagesMutationRowIds(sdk, allowedChatIds);
4524
+ if (rowIds.length === 0) return { changed: 0, stored: 0, updated: 0, queued: 0 };
4525
+
4526
+ const messages = await getMessagesByRowIds(sdk, rowIds);
4527
+ const filtered = messages.filter((msg) =>
4528
+ msg?.chatId
4529
+ && (allChats || allowedChatIds.includes(msg.chatId))
4530
+ && !messageTouchesShepherdAgent(msg));
4531
+ opts.contactSync?.observeMessages(filtered);
4532
+
4533
+ const changed = opts.stateCache?.changed(filtered) ?? filtered.filter(hasMessageEditOrRetraction);
4534
+ if (changed.length === 0) {
4535
+ opts.stateCache?.observe(filtered);
4536
+ return { changed: 0, stored: 0, updated: 0, queued: 0 };
4537
+ }
4538
+
4539
+ const result = await sender.send(changed.map((msg) => serializer.serialize(msg)));
4540
+ if ((result.queued ?? 0) === 0) opts.stateCache?.observe(filtered);
4541
+ return {
4542
+ changed: changed.length,
4543
+ stored: result.stored ?? 0,
4544
+ updated: result.updated ?? 0,
4545
+ queued: result.queued ?? 0,
4546
+ };
4547
+ }
4548
+
4549
+ function queryMessagesMutationRowIds(sdk, allowedChatIds = null) {
4550
+ const db = sdk?.database;
4551
+ if (!db || typeof db.all !== "function") {
4552
+ console.warn("Messages mutation reconciliation unavailable: SDK database reader is not exposed");
4553
+ return [];
4554
+ }
4555
+
4556
+ const columns = messageTableColumns(db);
4557
+ const predicates = [];
4558
+ if (columns.has("date_edited")) predicates.push("COALESCE(message.date_edited, 0) != 0");
4559
+ if (columns.has("date_retracted")) predicates.push("COALESCE(message.date_retracted, 0) != 0");
4560
+ if (!predicates.length) return [];
4561
+
4562
+ const params = [];
4563
+ let chatJoin = "";
4564
+ let chatPredicate = "";
4565
+ if (allowedChatIds != null) {
4566
+ const values = uniqueStrings(allowedChatIds.flatMap(chatIdMatchValues));
4567
+ if (values.length === 0) return [];
4568
+ const placeholders = values.map(() => "?").join(", ");
4569
+ chatJoin = `
4570
+ INNER JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
4571
+ INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id
4572
+ `;
4573
+ chatPredicate = `AND (chat.chat_identifier IN (${placeholders}) OR chat.guid IN (${placeholders}))`;
4574
+ params.push(...values, ...values);
4575
+ }
4576
+
4577
+ try {
4578
+ const rows = db.all(`
4579
+ SELECT DISTINCT message.ROWID AS rowId
4580
+ FROM message
4581
+ ${chatJoin}
4582
+ WHERE (${predicates.join(" OR ")})
4583
+ ${chatPredicate}
4584
+ ORDER BY message.ROWID ASC
4585
+ `, params);
4586
+ return rows
4587
+ .map((row) => Number(row.rowId))
4588
+ .filter((rowId) => Number.isFinite(rowId) && rowId > 0);
4589
+ } catch (err) {
4590
+ console.warn(`Messages mutation reconciliation query failed: ${safeError(err)}`);
4591
+ return [];
4592
+ }
4593
+ }
4594
+
4595
+ function messageTableColumns(db) {
4596
+ try {
4597
+ return new Set(db.all("PRAGMA table_info(message)").map((row) => String(row.name)));
4598
+ } catch (err) {
4599
+ console.warn(`Messages mutation reconciliation unavailable: ${safeError(err)}`);
4600
+ return new Set();
4601
+ }
4602
+ }
4603
+
4604
+ function chatIdMatchValues(chatId) {
4605
+ const raw = String(chatId ?? "").trim();
4606
+ if (!raw) return [];
4607
+ const core = raw.includes(";+;")
4608
+ ? raw.slice(raw.indexOf(";+;") + 3)
4609
+ : raw.includes(";-;")
4610
+ ? raw.slice(raw.indexOf(";-;") + 3)
4611
+ : raw;
4612
+ return raw === core ? [raw] : [raw, core];
4613
+ }
4614
+
4615
+ function uniqueStrings(values) {
4616
+ return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
4617
+ }
4618
+
4619
+ async function getMessagesByRowIds(sdk, rowIds) {
4620
+ const messages = [];
4621
+ for (const rowId of rowIds) {
4622
+ const page = await sdk.getMessages({
4623
+ sinceRowId: rowId - 1,
4624
+ orderByRowIdAsc: true,
4625
+ limit: 1,
4626
+ });
4627
+ const msg = page.find((candidate) => Number(candidate.rowId) === rowId);
4628
+ if (msg) messages.push(msg);
4629
+ }
4630
+ return messages;
4631
+ }
4632
+
4038
4633
  async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, opts = {}) {
4039
4634
  const allChats = allowedChatIds == null;
4040
4635
  const allowed = new Set(allowedChatIds ?? []);
4041
4636
  const contactSync = opts.contactSync ?? null;
4637
+ const stateCache = opts.stateCache ?? null;
4042
4638
  let buffer = [];
4043
4639
  let timer = null;
4044
4640
 
@@ -4046,7 +4642,10 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, op
4046
4642
  if (!buffer.length) return;
4047
4643
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
4048
4644
  const result = await sender.send(batch.map((msg) => serializer.serialize(msg)));
4049
- if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(batch));
4645
+ if ((result.queued ?? 0) === 0) {
4646
+ stateCache?.observe(batch);
4647
+ saveMessagesWatermark(userId, maxRowId(batch));
4648
+ }
4050
4649
  };
4051
4650
 
4052
4651
  const scheduleFlush = () => {
@@ -4088,6 +4687,7 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, op
4088
4687
  stopping = true;
4089
4688
  if (timer) clearTimeout(timer);
4090
4689
  contactSync?.stop();
4690
+ opts.onShutdown?.();
4091
4691
  await flush().catch(() => undefined);
4092
4692
  await sdk.close?.().catch(() => undefined);
4093
4693
  resolve();
@@ -4144,7 +4744,7 @@ function startMessagesContactSync(sender, contactLookup, opts = {}) {
4144
4744
 
4145
4745
  syncing = true;
4146
4746
  try {
4147
- const nextLookup = buildContactLookup();
4747
+ const nextLookup = buildContactLookup({ userId: opts.userId });
4148
4748
  contactLookup.replace(nextLookup);
4149
4749
  const mappings = visibleMappings();
4150
4750
  const toSync = forceAll
@@ -4180,7 +4780,7 @@ function startMessagesContactSync(sender, contactLookup, opts = {}) {
4180
4780
  const observeMessages = (messages) => {
4181
4781
  let changed = false;
4182
4782
  for (const msg of messages ?? []) {
4183
- if (rememberObservedHandle(observedHandles, contactLookup, msg?.participant)) changed = true;
4783
+ if (rememberObservedHandle(observedHandles, contactLookup, messageParticipant(msg))) changed = true;
4184
4784
  if (msg?.affectedParticipant) {
4185
4785
  if (rememberObservedHandle(observedHandles, contactLookup, msg.affectedParticipant)) changed = true;
4186
4786
  }
@@ -4265,6 +4865,8 @@ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
4265
4865
  },
4266
4866
  serialize(msg) {
4267
4867
  const chatId = msg.chatId ?? "unknown";
4868
+ const participant = messageParticipant(msg);
4869
+ const chatKind = messageChatKind(msg);
4268
4870
  const attachments = Array.isArray(msg.attachments) ? msg.attachments : [];
4269
4871
  return {
4270
4872
  messageId: String(msg.id ?? msg.messageId ?? msg.rowId),
@@ -4272,9 +4874,9 @@ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
4272
4874
  text: msg.text ?? null,
4273
4875
  service: msg.service ?? "iMessage",
4274
4876
  chatId,
4275
- chatKind: msg.chatKind ?? "unknown",
4877
+ chatKind,
4276
4878
  chatName: chatNames.get(chatId) ?? null,
4277
- participant: msg.participant ?? null,
4879
+ participant,
4278
4880
  isFromMe: Boolean(msg.isFromMe),
4279
4881
  createdAt: isoDate(msg.createdAt) ?? new Date().toISOString(),
4280
4882
  deliveredAt: isoDate(msg.deliveredAt),
@@ -4287,14 +4889,22 @@ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
4287
4889
  targetMessageId: msg.reaction.targetMessageId ?? null,
4288
4890
  emoji: msg.reaction.emoji ?? null,
4289
4891
  isRemoved: Boolean(msg.reaction.isRemoved),
4892
+ textRange: msg.reaction.textRange ?? null,
4893
+ appBundleId: msg.reaction.appBundleId ?? null,
4290
4894
  }
4291
4895
  : null,
4896
+ hasAttachments: Boolean(msg.hasAttachments ?? attachments.length > 0),
4292
4897
  attachments: attachments.map((att) => ({
4293
4898
  id: String(att.id ?? ""),
4294
4899
  fileName: att.fileName ?? null,
4295
4900
  mimeType: att.mimeType ?? "application/octet-stream",
4901
+ uti: att.uti ?? null,
4296
4902
  sizeBytes: Number(att.sizeBytes ?? 0),
4297
4903
  transferStatus: att.transferStatus ?? "unknown",
4904
+ createdAt: isoDate(att.createdAt),
4905
+ altText: att.altText ?? null,
4906
+ isFromMe: typeof att.isFromMe === "boolean" ? att.isFromMe : null,
4907
+ isSensitiveContent: Boolean(att.isSensitiveContent),
4298
4908
  isSticker: Boolean(att.isSticker),
4299
4909
  isImage: isImageAttachment(att),
4300
4910
  isVideo: isVideoAttachment(att),
@@ -4308,16 +4918,47 @@ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
4308
4918
  isForwarded: Boolean(msg.isForwarded),
4309
4919
  affectedParticipant: msg.affectedParticipant ?? null,
4310
4920
  newGroupName: msg.newGroupName ?? null,
4311
- _resolved_name: msg.participant ? contactLookup.resolveName(msg.participant) : null,
4312
- _is_self_handle: msg.participant ? contactLookup.isSelfHandle(msg.participant) : false,
4921
+ _resolved_name: participant ? contactLookup.resolveName(participant) : null,
4922
+ _is_self_handle: participant ? contactLookup.isSelfHandle(participant) : false,
4313
4923
  };
4314
4924
  },
4315
4925
  };
4316
4926
  }
4317
4927
 
4928
+ function messageParticipant(msg) {
4929
+ const explicit = typeof msg?.participant === "string" ? msg.participant.trim() : "";
4930
+ if (explicit) return explicit;
4931
+ if (!isDirectMessageKind(messageChatKind(msg))) return null;
4932
+ return parseDmHandleFromChatId(msg?.chatId);
4933
+ }
4934
+
4935
+ function messageChatKind(msg) {
4936
+ const explicit = typeof msg?.chatKind === "string" ? msg.chatKind.trim() : "";
4937
+ if (explicit === "direct") return "dm";
4938
+ if (explicit) return explicit;
4939
+ return parseDmHandleFromChatId(msg?.chatId) ? "dm" : "unknown";
4940
+ }
4941
+
4942
+ function isDirectMessageKind(kind) {
4943
+ return kind === "dm";
4944
+ }
4945
+
4318
4946
  function buildContactLookup(opts = {}) {
4319
- const contacts = opts.loadAll === false ? [] : loadContacts();
4320
- const myCard = loadMyCard();
4947
+ const addressBook = platform() === "darwin" ? loadContactsFromAddressBookDb() : { contacts: [], myCard: null };
4948
+ if (opts.userId) {
4949
+ saveMessagesContactStatus(opts.userId, {
4950
+ ok: addressBook.ok === true,
4951
+ updatedAt: new Date().toISOString(),
4952
+ contacts: addressBook.contacts.length,
4953
+ hasMyCard: Boolean(addressBook.myCard),
4954
+ error: addressBook.ok === true ? null : addressBook.error ?? "unsupported local Contacts database schema",
4955
+ });
4956
+ }
4957
+ if (addressBook.ok !== true && platform() === "darwin") {
4958
+ console.warn(`Messages contact resolution degraded: ${addressBook.error ?? "unsupported local Contacts database schema"}. Raw Messages sync will continue; contact founders@askshepherd.ai.`);
4959
+ }
4960
+ const contacts = opts.loadAll === false ? [] : addressBook.contacts;
4961
+ const myCard = addressBook.myCard;
4321
4962
  const handleToName = new Map();
4322
4963
  const selfHandles = new Set();
4323
4964
 
@@ -4367,84 +5008,96 @@ function emptyContactLookup() {
4367
5008
  };
4368
5009
  }
4369
5010
 
4370
- function loadContacts() {
4371
- if (platform() !== "darwin") return [];
4372
- const sqliteContacts = loadContactsFromAddressBookDb();
4373
- if (sqliteContacts.length > 0) return sqliteContacts;
4374
-
4375
- const script = `
4376
- set output to ""
4377
- tell application "Contacts"
4378
- repeat with p in every person
4379
- set pName to name of p
4380
- set phList to ""
4381
- repeat with ph in phones of p
4382
- if phList is not "" then set phList to phList & ","
4383
- set phList to phList & (value of ph)
4384
- end repeat
4385
- set eList to ""
4386
- repeat with e in emails of p
4387
- if eList is not "" then set eList to eList & ","
4388
- set eList to eList & (value of e)
4389
- end repeat
4390
- set output to output & pName & "\\t" & phList & "\\t" & eList & "\\n"
4391
- end repeat
4392
- end tell
4393
- return output`;
4394
-
4395
- try {
4396
- const raw = execFileSync("osascript", ["-e", script], {
4397
- encoding: "utf8",
4398
- timeout: 120_000,
4399
- });
4400
- return parseContacts(raw);
4401
- } catch (err) {
4402
- if (args.debug === true) console.error("Could not load Contacts:", safeError(err));
4403
- return [];
4404
- }
4405
- }
4406
-
4407
5011
  function loadContactsFromAddressBookDb() {
4408
- const contacts = new Map();
5012
+ const contacts = [];
5013
+ let myCard = null;
5014
+ let readSucceeded = false;
5015
+ let firstError = null;
4409
5016
  for (const dbPath of addressBookDatabasePaths()) {
4410
5017
  const query = `
4411
- select r.Z_PK,
4412
- coalesce(nullif(r.ZNAME, ''), nullif(trim(coalesce(r.ZFIRSTNAME, '') || ' ' || coalesce(r.ZLASTNAME, '')), ''), nullif(r.ZORGANIZATION, ''), '') as display_name,
4413
- coalesce(p.ZFULLNUMBER, '') as phone,
4414
- '' as email
4415
- from ZABCDRECORD r
4416
- join ZABCDPHONENUMBER p on p.ZOWNER = r.Z_PK
4417
- where p.ZFULLNUMBER is not null and p.ZFULLNUMBER != ''
4418
- union all
4419
- select r.Z_PK,
4420
- coalesce(nullif(r.ZNAME, ''), nullif(trim(coalesce(r.ZFIRSTNAME, '') || ' ' || coalesce(r.ZLASTNAME, '')), ''), nullif(r.ZORGANIZATION, ''), '') as display_name,
4421
- '' as phone,
4422
- coalesce(e.ZADDRESS, '') as email
4423
- from ZABCDRECORD r
4424
- join ZABCDEMAILADDRESS e on e.ZOWNER = r.Z_PK
4425
- where e.ZADDRESS is not null and e.ZADDRESS != '';`;
5018
+ with records as (
5019
+ select Z_PK as id,
5020
+ coalesce(ZME, 0) as me,
5021
+ coalesce(Z22_ME, 0) as me22,
5022
+ coalesce(
5023
+ nullif(ZNAME, ''),
5024
+ nullif(trim(coalesce(ZFIRSTNAME, '') || ' ' || coalesce(ZMIDDLENAME, '') || ' ' || coalesce(ZLASTNAME, '')), ''),
5025
+ nullif(ZNICKNAME, ''),
5026
+ nullif(ZORGANIZATION, ''),
5027
+ ''
5028
+ ) as display_name
5029
+ from ZABCDRECORD
5030
+ ),
5031
+ phones as (
5032
+ select coalesce(ZOWNER, Z22_OWNER) as owner, ZFULLNUMBER as value
5033
+ from ZABCDPHONENUMBER
5034
+ where nullif(ZFULLNUMBER, '') is not null
5035
+ ),
5036
+ emails as (
5037
+ select coalesce(ZOWNER, Z22_OWNER) as owner, coalesce(ZADDRESSNORMALIZED, ZADDRESS) as value
5038
+ from ZABCDEMAILADDRESS
5039
+ where nullif(coalesce(ZADDRESSNORMALIZED, ZADDRESS), '') is not null
5040
+ )
5041
+ select r.id,
5042
+ r.me,
5043
+ r.me22,
5044
+ r.display_name,
5045
+ coalesce(p.value, '') as phone,
5046
+ coalesce(e.value, '') as email
5047
+ from records r
5048
+ left join phones p on p.owner = r.id
5049
+ left join emails e on e.owner = r.id
5050
+ where r.display_name != ''
5051
+ and (p.value is not null or e.value is not null);`;
4426
5052
 
4427
5053
  try {
4428
5054
  const raw = execFileSync("sqlite3", ["-separator", "\t", dbPath, query], {
4429
5055
  encoding: "utf8",
4430
5056
  timeout: 10_000,
4431
5057
  });
5058
+ readSucceeded = true;
4432
5059
  for (const line of raw.split("\n").filter(Boolean)) {
4433
- const [id, rawName, phone, email] = line.split("\t");
5060
+ const [id, me, me22, rawName, phone, email] = line.split("\t");
4434
5061
  const name = rawName?.trim();
4435
5062
  if (!id || !name) continue;
4436
5063
  const key = `${dbPath}:${id}`;
4437
- const current = contacts.get(key) ?? { name, phones: [], emails: [] };
4438
- if (phone) current.phones.push(phone.trim());
4439
- if (email) current.emails.push(email.trim());
4440
- contacts.set(key, current);
5064
+ let current = contacts.find((contact) => contact._key === key);
5065
+ if (!current) {
5066
+ current = { _key: key, name, phones: [], emails: [] };
5067
+ contacts.push(current);
5068
+ }
5069
+ if (phone?.trim()) current.phones.push(phone.trim());
5070
+ if (email?.trim()) current.emails.push(email.trim());
5071
+ if (Number(me) !== 0 || Number(me22) !== 0) myCard = current;
4441
5072
  }
4442
5073
  } catch (err) {
5074
+ firstError ??= safeError(err);
4443
5075
  if (args.debug === true) console.error(`Could not read Contacts DB ${dbPath}:`, safeError(err));
4444
5076
  }
4445
5077
  }
4446
5078
 
4447
- return [...contacts.values()].filter((contact) => contact.name);
5079
+ const cleanContacts = contacts
5080
+ .map(({ _key, ...contact }) => ({
5081
+ name: contact.name,
5082
+ phones: [...new Set(contact.phones)],
5083
+ emails: [...new Set(contact.emails)],
5084
+ }))
5085
+ .filter((contact) => contact.name && (contact.phones.length > 0 || contact.emails.length > 0));
5086
+
5087
+ const cleanMyCard = myCard
5088
+ ? {
5089
+ name: myCard.name,
5090
+ phones: [...new Set(myCard.phones)],
5091
+ emails: [...new Set(myCard.emails)],
5092
+ }
5093
+ : null;
5094
+
5095
+ return {
5096
+ ok: readSucceeded,
5097
+ contacts: cleanContacts,
5098
+ myCard: cleanMyCard,
5099
+ error: readSucceeded ? null : firstError,
5100
+ };
4448
5101
  }
4449
5102
 
4450
5103
  function addressBookDatabasePaths() {
@@ -4469,51 +5122,6 @@ function addressBookWalPaths() {
4469
5122
  return [...paths].filter((path) => existsSync(path));
4470
5123
  }
4471
5124
 
4472
- function loadMyCard() {
4473
- if (platform() !== "darwin") return null;
4474
- const script = `
4475
- tell application "Contacts"
4476
- set mc to my card
4477
- set pName to name of mc
4478
- set phList to ""
4479
- repeat with ph in phones of mc
4480
- if phList is not "" then set phList to phList & ","
4481
- set phList to phList & (value of ph)
4482
- end repeat
4483
- set eList to ""
4484
- repeat with e in emails of mc
4485
- if eList is not "" then set eList to eList & ","
4486
- set eList to eList & (value of e)
4487
- end repeat
4488
- return pName & "\\t" & phList & "\\t" & eList
4489
- end tell`;
4490
-
4491
- try {
4492
- const raw = execFileSync("osascript", ["-e", script], {
4493
- encoding: "utf8",
4494
- timeout: 10_000,
4495
- });
4496
- return parseContacts(raw)[0] ?? null;
4497
- } catch {
4498
- return null;
4499
- }
4500
- }
4501
-
4502
- function parseContacts(raw) {
4503
- return String(raw)
4504
- .split("\n")
4505
- .filter(Boolean)
4506
- .map((line) => {
4507
- const [name, phones, emails] = line.split("\t");
4508
- return {
4509
- name: name?.trim() ?? "",
4510
- phones: phones ? phones.split(",").map((phone) => phone.trim()).filter(Boolean) : [],
4511
- emails: emails ? emails.split(",").map((email) => email.trim()).filter(Boolean) : [],
4512
- };
4513
- })
4514
- .filter((contact) => contact.name);
4515
- }
4516
-
4517
5125
  function addHandleMapping(map, handle, name) {
4518
5126
  for (const candidate of handleCandidates(handle)) {
4519
5127
  map.set(candidate, name);
@@ -5219,14 +5827,23 @@ class MessagesBatchSender {
5219
5827
  this.agentToken = agentToken;
5220
5828
  this.userId = userId;
5221
5829
  this.queueFile = join(homedir(), ".shepherd", "raw-messages", `${safeFileId(userId)}-queue.json`);
5830
+ this.sendChain = Promise.resolve();
5222
5831
  }
5223
5832
 
5224
5833
  async send(messages) {
5834
+ const run = () => this.sendUnlocked(messages);
5835
+ const next = this.sendChain.then(run, run);
5836
+ this.sendChain = next.catch(() => undefined);
5837
+ return next;
5838
+ }
5839
+
5840
+ async sendUnlocked(messages) {
5225
5841
  const queued = this.loadQueue();
5226
- const all = [...queued, ...messages];
5227
- if (!all.length) return { stored: 0, skipped: 0 };
5842
+ const all = dedupeMessagePayloads([...queued, ...messages]);
5843
+ if (!all.length) return { stored: 0, updated: 0, skipped: 0, queued: 0 };
5228
5844
 
5229
5845
  let totalStored = 0;
5846
+ let totalUpdated = 0;
5230
5847
  let totalSkipped = 0;
5231
5848
 
5232
5849
  for (let i = 0; i < all.length; i += MAX_BATCH_SIZE) {
@@ -5234,16 +5851,18 @@ class MessagesBatchSender {
5234
5851
  try {
5235
5852
  const result = await this.postBatch(batch);
5236
5853
  totalStored += result.stored ?? 0;
5854
+ totalUpdated += result.updated ?? 0;
5237
5855
  totalSkipped += result.skipped ?? 0;
5238
5856
  } catch (err) {
5239
- this.saveQueue(all.slice(i));
5857
+ const remaining = all.slice(i);
5858
+ this.saveQueue(remaining);
5240
5859
  console.error("Messages batch send failed:", safeError(err));
5241
- return { stored: totalStored, skipped: totalSkipped };
5860
+ return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped, queued: remaining.length };
5242
5861
  }
5243
5862
  }
5244
5863
 
5245
5864
  this.clearQueue();
5246
- return { stored: totalStored, skipped: totalSkipped };
5865
+ return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped, queued: 0 };
5247
5866
  }
5248
5867
 
5249
5868
  async postBatch(messages) {
@@ -5286,7 +5905,11 @@ class MessagesBatchSender {
5286
5905
 
5287
5906
  saveQueue(messages) {
5288
5907
  const capped = messages.slice(-MAX_QUEUE_MESSAGES);
5908
+ mkdirSync(dirname(this.queueFile), { recursive: true });
5289
5909
  writeFileSync(this.queueFile, JSON.stringify(capped), { mode: 0o600 });
5910
+ if (capped.length < messages.length) {
5911
+ console.warn(`Messages queue trimmed from ${messages.length} to ${MAX_QUEUE_MESSAGES} messages`);
5912
+ }
5290
5913
  }
5291
5914
 
5292
5915
  clearQueue() {
@@ -5298,6 +5921,37 @@ class MessagesBatchSender {
5298
5921
  }
5299
5922
  }
5300
5923
 
5924
+ function dedupeMessagePayloads(messages) {
5925
+ const entries = [];
5926
+ const keyed = new Map();
5927
+ for (const message of messages) {
5928
+ const key = messagePayloadIdentity(message);
5929
+ if (!key) {
5930
+ entries.push({ message });
5931
+ continue;
5932
+ }
5933
+
5934
+ let entry = keyed.get(key);
5935
+ if (!entry) {
5936
+ entry = { key, message };
5937
+ keyed.set(key, entry);
5938
+ entries.push(entry);
5939
+ } else {
5940
+ entry.message = message;
5941
+ }
5942
+ }
5943
+ return entries.map((entry) => entry.message);
5944
+ }
5945
+
5946
+ function messagePayloadIdentity(message) {
5947
+ const messageId = message?.messageId == null ? null : String(message.messageId);
5948
+ const chatId = message?.chatId == null ? null : String(message.chatId);
5949
+ if (messageId && chatId) return `${chatId}:${messageId}`;
5950
+ if (messageId) return messageId;
5951
+ const rowId = Number(message?.rowId);
5952
+ return Number.isFinite(rowId) && rowId > 0 ? `row:${rowId}` : null;
5953
+ }
5954
+
5301
5955
  function loadMessagesWatermark(userId) {
5302
5956
  try {
5303
5957
  const raw = readFileSync(messagesWatermarkFile(userId), "utf8").trim();
@@ -5308,21 +5962,99 @@ function loadMessagesWatermark(userId) {
5308
5962
  }
5309
5963
  }
5310
5964
 
5965
+ function loadMessagesBackfillComplete(userId, scope) {
5966
+ const record = readJsonOptional(messagesBackfillCompleteFile(userId, scope));
5967
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
5968
+ return record;
5969
+ }
5970
+
5971
+ function hasAnyMessagesBackfillComplete(userId) {
5972
+ const safeId = safeFileId(userId);
5973
+ try {
5974
+ return readdirSync(messagesRawMessagesDir()).some((entry) =>
5975
+ entry.startsWith(`${safeId}-backfill-`) && entry.endsWith(".json"));
5976
+ } catch {
5977
+ return false;
5978
+ }
5979
+ }
5980
+
5981
+ function saveMessagesBackfillComplete(userId, scope, result = {}) {
5982
+ const record = {
5983
+ completedAt: new Date().toISOString(),
5984
+ scope,
5985
+ ...result,
5986
+ };
5987
+ writeJsonAtomic(messagesBackfillCompleteFile(userId, scope), record);
5988
+ return record;
5989
+ }
5990
+
5311
5991
  function saveMessagesWatermark(userId, rowId) {
5992
+ const numericRowId = Number(rowId);
5993
+ if (!Number.isFinite(numericRowId) || numericRowId <= 0) return;
5994
+
5312
5995
  try {
5313
5996
  const path = messagesWatermarkFile(userId);
5314
- writeFileSync(path, String(rowId), { mode: 0o600 });
5997
+ const next = Math.max(loadMessagesWatermark(userId), Math.floor(numericRowId));
5998
+ const tmpPath = `${path}.tmp`;
5999
+ writeFileSync(tmpPath, String(next), { mode: 0o600 });
6000
+ renameSync(tmpPath, path);
5315
6001
  } catch (err) {
5316
6002
  console.error("Could not save Messages watermark:", safeError(err));
5317
6003
  }
5318
6004
  }
5319
6005
 
5320
6006
  function messagesWatermarkFile(userId) {
5321
- const path = join(homedir(), ".shepherd", "raw-messages", `${safeFileId(userId)}-watermark`);
6007
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-watermark`);
6008
+ mkdirSync(dirname(path), { recursive: true });
6009
+ return path;
6010
+ }
6011
+
6012
+ function messagesBackfillCompleteFile(userId, scope) {
6013
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-backfill-${hashObject(scope).slice(0, 24)}.json`);
5322
6014
  mkdirSync(dirname(path), { recursive: true });
5323
6015
  return path;
5324
6016
  }
5325
6017
 
6018
+ function messagesStateFile(userId) {
6019
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-message-state.json`);
6020
+ mkdirSync(dirname(path), { recursive: true });
6021
+ return path;
6022
+ }
6023
+
6024
+ function saveMessagesState(userId, state) {
6025
+ try {
6026
+ writeJsonAtomic(messagesStateFile(userId), state);
6027
+ } catch (err) {
6028
+ console.error("Could not save Messages state:", safeError(err));
6029
+ }
6030
+ }
6031
+
6032
+ function messagesContactStatusFile(userId) {
6033
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-contacts-status.json`);
6034
+ mkdirSync(dirname(path), { recursive: true });
6035
+ return path;
6036
+ }
6037
+
6038
+ function saveMessagesContactStatus(userId, status) {
6039
+ try {
6040
+ writeJsonAtomic(messagesContactStatusFile(userId), status);
6041
+ } catch (err) {
6042
+ console.error("Could not save Messages contact status:", safeError(err));
6043
+ }
6044
+ }
6045
+
6046
+ function messagesRawMessagesDir() {
6047
+ const path = join(homedir(), ".shepherd", "raw-messages");
6048
+ mkdirSync(path, { recursive: true });
6049
+ return path;
6050
+ }
6051
+
6052
+ function writeJsonAtomic(path, value) {
6053
+ const tmpPath = `${path}.tmp`;
6054
+ writeFileSync(tmpPath, JSON.stringify(value), { mode: 0o600 });
6055
+ renameSync(tmpPath, path);
6056
+ }
6057
+
5326
6058
  function maxRowId(messages) {
5327
6059
  return Math.max(0, ...messages.map((msg) => Number(msg.rowId ?? 0)).filter(Number.isFinite));
5328
6060
  }
@@ -5344,6 +6076,10 @@ function requiredConfigString(value, label) {
5344
6076
  return value.trim();
5345
6077
  }
5346
6078
 
6079
+ function optionalString(value) {
6080
+ return typeof value === "string" && value.trim() ? value.trim() : undefined;
6081
+ }
6082
+
5347
6083
  function execFileQuiet(file, argv, opts = {}) {
5348
6084
  return new Promise((resolve, reject) => {
5349
6085
  execFile(file, argv, { windowsHide: true }, (error) => {
@@ -5385,6 +6121,12 @@ function clampInt(value, min, max) {
5385
6121
  return Math.min(Math.max(Math.floor(value), min), max);
5386
6122
  }
5387
6123
 
6124
+ function positiveIntFromEnv(name, defaultValue) {
6125
+ const parsed = Number(process.env[name]);
6126
+ if (!Number.isFinite(parsed) || parsed <= 0) return defaultValue;
6127
+ return Math.floor(parsed);
6128
+ }
6129
+
5388
6130
  function parseBackfillDays(value, defaultValue) {
5389
6131
  if (value === undefined || value === null || value === "") return defaultValue;
5390
6132
  if (typeof value === "string" && value.trim().toLowerCase() === "all") return null;
@@ -5403,3 +6145,10 @@ function rawErrorDetails(err) {
5403
6145
  if (err instanceof Error) return err.stack ?? err.message;
5404
6146
  return String(err);
5405
6147
  }
6148
+
6149
+ export const __test = {
6150
+ messageChatKind,
6151
+ messageParticipant,
6152
+ messageState,
6153
+ sameMessageState,
6154
+ };