askshepherd 0.1.40 → 0.1.41

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,7 +1,7 @@
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, 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";
@@ -24,7 +24,7 @@ const MAX_QUEUE_MESSAGES = 10_000;
24
24
  const DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT = 200;
25
25
  const INITIAL_MESSAGE_CHAT_ROWS = 20;
26
26
  const ALL_MESSAGES_CHATS = "__shepherd_all_messages_chats__";
27
- const AGENT_MODALITY_ORDER = ["google", "slack", "granola", "messages", "codingSessions"];
27
+ const AGENT_MODALITY_ORDER = ["google", "slack", "github", "granola", "messages", "codingSessions"];
28
28
  const SHEPHERD_LOGO_PATH = join(PACKAGE_DIR, "assets", "shepherd_G_vector_136033.png");
29
29
  const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
30
30
  const GOOGLE_WORKSPACE_DELEGATION_ADMIN_URL = "https://admin.google.com/ac/owl/domainwidedelegation";
@@ -38,8 +38,22 @@ const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
38
38
  const CONTACTS_WAL_PATH = join(homedir(), "Library", "Application Support", "AddressBook", "AddressBook-v22.abcddb-wal");
39
39
  const CONTACT_SYNC_DEBOUNCE_MS = 5_000;
40
40
  const CONTACT_SYNC_FALLBACK_MS = 30 * 60_000;
41
+ const MESSAGES_EDIT_SCAN_INTERVAL_MS = positiveIntFromEnv("SHEPHERD_MESSAGES_EDIT_SCAN_INTERVAL_MS", 60_000);
42
+ const MESSAGES_EDIT_SCAN_WINDOW_MS = positiveIntFromEnv("SHEPHERD_MESSAGES_EDIT_SCAN_WINDOW_MS", 30 * 60_000);
43
+ const MESSAGES_EDIT_SCAN_LIMIT = positiveIntFromEnv("SHEPHERD_MESSAGES_EDIT_SCAN_LIMIT", 500);
44
+ const MESSAGES_MUTATION_RECONCILE_INTERVAL_MS = positiveIntFromEnv("SHEPHERD_MESSAGES_MUTATION_RECONCILE_INTERVAL_MS", 15 * 60_000);
45
+ const MESSAGES_STATE_CACHE_MAX = positiveIntFromEnv("SHEPHERD_MESSAGES_STATE_CACHE_MAX", 5_000);
46
+ const LEGACY_SHEPHERD_OWNED_MESSAGE_HANDLES = [
47
+ "+13054098546",
48
+ "+12054012556",
49
+ ];
41
50
  const SHEPHERD_OWNED_MESSAGE_HANDLES = parseMessageHandleList(
42
- process.env.SHEPHERD_OWNED_MESSAGE_HANDLES ?? process.env.SENDBLUE_NUMBER ?? "",
51
+ [
52
+ ...LEGACY_SHEPHERD_OWNED_MESSAGE_HANDLES,
53
+ process.env.SENDBLUE_NUMBER,
54
+ process.env.SHEPHERD_OWNED_MESSAGE_HANDLES,
55
+ process.env.SHEPHERD_OWNED_IMESSAGE_HANDLES,
56
+ ].filter(Boolean).join(","),
43
57
  );
44
58
  const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
45
59
  const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
@@ -354,15 +368,16 @@ async function runAgentOnboarding() {
354
368
  authSessionToken: workosAuth.authSessionToken,
355
369
  sources,
356
370
  });
371
+ const sessionSources = sourceSelectionFromSession(session, sources);
357
372
 
358
373
  const statePath = await writeAgentState({
359
374
  apiUrl,
360
375
  sessionId: session.sessionId,
361
376
  sessionToken: session.sessionToken,
362
377
  account: session.account,
363
- sources,
378
+ sources: sessionSources,
364
379
  authUrls: session.authUrls ?? {},
365
- googleWorkspaceDelegation: sources.google
380
+ googleWorkspaceDelegation: sessionSources.google && session.googleWorkspaceDelegation
366
381
  ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation)
367
382
  : undefined,
368
383
  workosAuth,
@@ -380,7 +395,8 @@ async function runAgentOnboarding() {
380
395
  status: "auth_required",
381
396
  account: publicAgentAccount(session.account),
382
397
  opened: currentAction?.opened ? [currentAction.source] : [],
383
- googleWorkspaceDelegation: sources.google ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
398
+ sources: sessionSources,
399
+ googleWorkspaceDelegation: sessionSources.google && session.googleWorkspaceDelegation ? googleWorkspaceDelegationSetup(session.googleWorkspaceDelegation) : undefined,
384
400
  currentAction,
385
401
  statePath,
386
402
  messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
@@ -491,7 +507,7 @@ async function runMcpLogin() {
491
507
  const statePath = await writeMcpStateFromLogin(login);
492
508
  const installTargets = await selectMcpInstallTargets();
493
509
  const installResults = installTargets.length > 0
494
- ? await installMcpClients({ statePath, targets: installTargets })
510
+ ? await installMcpClients({ statePath, targets: installTargets, proxyProgram: parseJsonArrayArg("mcp-program") })
495
511
  : [];
496
512
 
497
513
  if (args.json) {
@@ -680,7 +696,7 @@ async function runMcpInstall() {
680
696
  const ensured = await ensureMcpState({ allowBrowser: process.stdin.isTTY, quiet: args.json === true });
681
697
  const targets = await selectMcpInstallTargets({ defaultTargets: MCP_INSTALL_TARGETS });
682
698
  const installResults = targets.length > 0
683
- ? await installMcpClients({ statePath: ensured.statePath, targets })
699
+ ? await installMcpClients({ statePath: ensured.statePath, targets, proxyProgram: parseJsonArrayArg("mcp-program") })
684
700
  : [];
685
701
 
686
702
  if (args.json) {
@@ -982,16 +998,17 @@ function parseMcpInstallTargets(value) {
982
998
  return [...new Set(targets)];
983
999
  }
984
1000
 
985
- async function installMcpClients({ statePath, targets }) {
1001
+ async function installMcpClients({ statePath, targets, proxyProgram }) {
986
1002
  const results = [];
1003
+ const proxy = mcpProxyCommand(statePath, proxyProgram);
987
1004
  for (const target of targets) {
988
1005
  try {
989
1006
  if (target === "codex") {
990
- await installCodexMcp(statePath);
1007
+ await installCodexMcp(proxy);
991
1008
  } else if (target === "claude") {
992
- await installClaudeMcp(statePath);
1009
+ await installClaudeMcp(proxy);
993
1010
  } else if (target === "cursor") {
994
- await installCursorMcp(statePath);
1011
+ await installCursorMcp(proxy);
995
1012
  }
996
1013
  results.push({ target, status: "installed" });
997
1014
  } catch (err) {
@@ -1001,25 +1018,25 @@ async function installMcpClients({ statePath, targets }) {
1001
1018
  return results;
1002
1019
  }
1003
1020
 
1004
- async function installCodexMcp(statePath) {
1021
+ async function installCodexMcp(proxy) {
1005
1022
  await execFileQuiet("codex", ["mcp", "remove", MCP_SERVER_NAME], { ignoreError: true });
1006
- await execFileQuiet("codex", ["mcp", "add", MCP_SERVER_NAME, "--", "npx", ...mcpProxyArgs(statePath)]);
1023
+ await execFileQuiet("codex", ["mcp", "add", MCP_SERVER_NAME, "--", proxy.command, ...proxy.args]);
1007
1024
  }
1008
1025
 
1009
- async function installClaudeMcp(statePath) {
1026
+ async function installClaudeMcp(proxy) {
1010
1027
  await execFileQuiet("claude", ["mcp", "remove", MCP_SERVER_NAME], { ignoreError: true });
1011
- await execFileQuiet("claude", ["mcp", "add", "--scope", "user", MCP_SERVER_NAME, "--", "npx", ...mcpProxyArgs(statePath)]);
1028
+ await execFileQuiet("claude", ["mcp", "add", "--scope", "user", MCP_SERVER_NAME, "--", proxy.command, ...proxy.args]);
1012
1029
  }
1013
1030
 
1014
- async function installCursorMcp(statePath) {
1031
+ async function installCursorMcp(proxy) {
1015
1032
  const path = join(homedir(), ".cursor", "mcp.json");
1016
1033
  const config = await readJsonObject(path);
1017
1034
  const mcpServers = config.mcpServers && typeof config.mcpServers === "object" && !Array.isArray(config.mcpServers)
1018
1035
  ? config.mcpServers
1019
1036
  : {};
1020
1037
  mcpServers[MCP_SERVER_NAME] = {
1021
- command: "npx",
1022
- args: mcpProxyArgs(statePath),
1038
+ command: proxy.command,
1039
+ args: proxy.args,
1023
1040
  };
1024
1041
  config.mcpServers = mcpServers;
1025
1042
  await mkdir(dirname(path), { recursive: true });
@@ -1027,8 +1044,18 @@ async function installCursorMcp(statePath) {
1027
1044
  await execFileQuiet("cursor-agent", ["mcp", "enable", MCP_SERVER_NAME], { ignoreError: true });
1028
1045
  }
1029
1046
 
1047
+ function mcpProxyCommand(statePath, proxyProgram) {
1048
+ const prefix = Array.isArray(proxyProgram) && proxyProgram.length > 0
1049
+ ? proxyProgram
1050
+ : ["npx", "-y", PACKAGE_SPEC];
1051
+ return {
1052
+ command: prefix[0],
1053
+ args: [...prefix.slice(1), "mcp", "--state", statePath],
1054
+ };
1055
+ }
1056
+
1030
1057
  function mcpProxyArgs(statePath) {
1031
- return ["-y", PACKAGE_SPEC, "mcp", "--state", statePath];
1058
+ return mcpProxyCommand(statePath).args;
1032
1059
  }
1033
1060
 
1034
1061
  async function readJsonObject(path) {
@@ -1192,7 +1219,7 @@ async function runStatusCommand() {
1192
1219
 
1193
1220
  async function collectShepherdStatus() {
1194
1221
  const statePath = agentStatePath();
1195
- const state = await readOptionalAgentState();
1222
+ let state = await readOptionalAgentState();
1196
1223
  let production = null;
1197
1224
  let productionError = null;
1198
1225
 
@@ -1202,7 +1229,7 @@ async function collectShepherdStatus() {
1202
1229
  `${trimTrailingSlash(state.apiUrl)}/onboarding/raw/session/${encodeURIComponent(state.sessionId)}/status`,
1203
1230
  { token: state.sessionToken },
1204
1231
  );
1205
- await updateAgentStateFromOnboardingResponse(state, production);
1232
+ state = await updateAgentStateFromOnboardingResponse(state, production);
1206
1233
  } catch (err) {
1207
1234
  productionError = safeError(err);
1208
1235
  }
@@ -1296,8 +1323,11 @@ function statusSourceRows(providers, savedSources = {}) {
1296
1323
  const definitions = [
1297
1324
  ["google", "Google Workspace", "google"],
1298
1325
  ["slack", "Slack", "slack"],
1326
+ ["github", "GitHub", "github"],
1299
1327
  ["granola", "Granola", "granola"],
1300
1328
  ["messages", "Messages", "messages"],
1329
+ ["discord", "Discord", "discord"],
1330
+ ["instagram", "Instagram", "instagram"],
1301
1331
  ["codingSessions", "Coding Sessions", "codingSessions"],
1302
1332
  ];
1303
1333
  return definitions.map(([key, label, sourceKey]) => ({
@@ -1327,6 +1357,9 @@ function renderLocalMessagesStatus(status) {
1327
1357
  lines.push(" LaunchAgent: not installed or unavailable");
1328
1358
  }
1329
1359
  lines.push(` Messages database: ${status.storage.readable ? "readable" : `not readable (${status.storage.reason})`}`);
1360
+ if (status.contacts) {
1361
+ lines.push(` Contacts: ${status.contacts.ok ? "resolved from AddressBook DB" : `degraded (${status.contacts.error ?? "unsupported local Contacts database schema"}; contact founders@askshepherd.ai)`}`);
1362
+ }
1330
1363
  lines.push(` Queued unsent messages: ${status.queueDepth}`);
1331
1364
  return lines;
1332
1365
  }
@@ -1492,7 +1525,9 @@ async function runMessagesAgent() {
1492
1525
  const userId = requiredConfigString(config.userId, "userId");
1493
1526
  const agentToken = requiredConfigString(config.agentToken, "agentToken");
1494
1527
  mergeShepherdOwnedMessageHandles(config.excludedMessageHandles);
1495
- const backfillDays = parseBackfillDays(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays, null);
1528
+ const backfillOverride = args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS;
1529
+ const backfillExplicit = backfillOverride !== undefined && backfillOverride !== null && backfillOverride !== "";
1530
+ const backfillDays = parseBackfillDays(backfillExplicit ? backfillOverride : config.backfillDays, null);
1496
1531
  const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
1497
1532
  const allChats = config.allChats === true || selectedChatIdsIncludeAll(allowedChatIds);
1498
1533
  if (!allChats && allowedChatIds.length === 0) {
@@ -1502,12 +1537,15 @@ async function runMessagesAgent() {
1502
1537
  const kit = await import("@photon-ai/imessage-kit");
1503
1538
  const sdk = new kit.IMessageSDK({ debug: args.debug === true });
1504
1539
  const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
1505
- const contactLookup = createMutableContactLookup(buildContactLookup());
1540
+ const contactLookup = createMutableContactLookup(buildContactLookup({ userId }));
1506
1541
  const serializer = createMessageSerializer(kit, contactLookup);
1542
+ const stateCache = createMessageStateCache(userId);
1507
1543
  const contactSync = startMessagesContactSync(sender, contactLookup, {
1544
+ userId,
1508
1545
  syncAllContacts: allChats,
1509
1546
  seedHandles: allChats ? [] : selectedChatContactSeedHandles(config.selectedChats, allowedChatIds),
1510
1547
  });
1548
+ let editDetector = null;
1511
1549
 
1512
1550
  console.log("Shepherd Messages raw sync starting");
1513
1551
  console.log(allChats
@@ -1515,25 +1553,52 @@ async function runMessagesAgent() {
1515
1553
  : `Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
1516
1554
 
1517
1555
  try {
1556
+ await validateMessagesDatabaseAccess(sdk);
1557
+ console.log("Messages local database access validated");
1518
1558
  await contactSync.syncNow({ forceAll: true, reason: "startup" }).catch((err) => {
1519
1559
  console.error("Initial Messages contact sync failed:", safeError(err));
1520
1560
  });
1521
1561
  await loadGroupChatNames(sdk, serializer);
1522
1562
  loadSelectedChatNames(config.selectedChats, serializer);
1523
1563
 
1524
- if (backfillDays !== 0) {
1525
- await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync);
1564
+ const initialWatermark = loadMessagesWatermark(userId);
1565
+ const backfillScope = messagesBackfillScope({ backfillDays, allChats, allowedChatIds });
1566
+ let backfillComplete = loadMessagesBackfillComplete(userId, backfillScope);
1567
+ if (!backfillComplete && !hasAnyMessagesBackfillComplete(userId) && initialWatermark > 0 && !backfillExplicit && backfillDays !== 0) {
1568
+ backfillComplete = saveMessagesBackfillComplete(userId, backfillScope, {
1569
+ legacyAssumedComplete: true,
1570
+ watermark: initialWatermark,
1571
+ });
1572
+ }
1573
+ if (shouldRunMessagesBackfill({ backfillDays, backfillExplicit, backfillComplete })) {
1574
+ const backfillResult = await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync, stateCache);
1575
+ if (backfillResult.complete) {
1576
+ saveMessagesBackfillComplete(userId, backfillScope, backfillResult);
1577
+ } else {
1578
+ console.warn("Messages backfill paused before completion because messages were queued for retry");
1579
+ }
1526
1580
  await contactSync.syncNow({ forceAll: true, reason: "post-backfill" }).catch((err) => {
1527
1581
  console.error("Post-backfill Messages contact sync failed:", safeError(err));
1528
1582
  });
1583
+ } else if (backfillDays !== 0) {
1584
+ console.log(`Skipping configured Messages backfill because this chat scope is already complete; use --backfill-days to force a historical backfill`);
1529
1585
  }
1530
1586
 
1531
- await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync);
1587
+ await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync, stateCache);
1532
1588
  await contactSync.syncNow({ forceAll: true, reason: "post-gap-fill" }).catch((err) => {
1533
1589
  console.error("Post-gap-fill Messages contact sync failed:", safeError(err));
1534
1590
  });
1535
- await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, { contactSync });
1591
+ editDetector = startMessagesEditDetector(sdk, sender, serializer, allChats ? null : allowedChatIds, {
1592
+ contactSync,
1593
+ stateCache,
1594
+ });
1595
+ await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, {
1596
+ contactSync,
1597
+ stateCache,
1598
+ onShutdown: () => editDetector?.stop(),
1599
+ });
1536
1600
  } catch (err) {
1601
+ editDetector?.stop();
1537
1602
  contactSync.stop();
1538
1603
  await sdk.close?.().catch(() => undefined);
1539
1604
  throw err;
@@ -1600,6 +1665,7 @@ async function collectMessagesLocalStatus(preferredUserId = null) {
1600
1665
  allChats: config?.allChats === true,
1601
1666
  selectedChatCount: Array.isArray(config?.allowedChatIds) ? config.allowedChatIds.length : 0,
1602
1667
  storage: await probePath("messages", MESSAGES_CHAT_DB_PATH),
1668
+ contacts: userId ? readJsonOptional(messagesContactStatusFile(userId)) : null,
1603
1669
  launch: localLaunchStatus(label),
1604
1670
  queueDepth: Array.isArray(queue) ? queue.length : 0,
1605
1671
  };
@@ -1827,6 +1893,7 @@ Options:
1827
1893
  --api <url> Advanced: Shepherd API URL.
1828
1894
  --state <path> Token state file. Defaults to ~/.shepherd/mcp.json.
1829
1895
  --onboarding-state <path> Local onboarding state file. Defaults to ~/.shepherd/raw-onboarding-agent.json.
1896
+ --mcp-program <json_array> Advanced: MCP proxy command prefix. The app uses this to install app-binary-backed MCP instead of npm.
1830
1897
  --no-local Skip local onboarding auth and use WorkOS browser login.
1831
1898
  --install <targets> Install MCP after login. Use all, none, codex, claude, cursor, or comma-separated targets.
1832
1899
  --no-install Save the MCP token without installing client config.
@@ -1855,6 +1922,7 @@ Installs the saved Shepherd MCP login into:
1855
1922
  Options:
1856
1923
  --state <path> Token state file. Defaults to ~/.shepherd/mcp.json.
1857
1924
  --onboarding-state <path> Local onboarding state file. Defaults to ~/.shepherd/raw-onboarding-agent.json.
1925
+ --mcp-program <json_array> Advanced: MCP proxy command prefix. The app uses this to install app-binary-backed MCP instead of npm.
1858
1926
  --no-local Skip local onboarding auth refresh.
1859
1927
  --install <targets> Use all, none, codex, claude, cursor, or comma-separated targets.
1860
1928
  --no-install Skip client config writes.
@@ -2064,7 +2132,7 @@ function printAgentContract() {
2064
2132
  agentStatusCommand: `${command} agent --status`,
2065
2133
  messagesChatsCommand: `${command} messages-chats`,
2066
2134
  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.",
2135
+ 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
2136
  nodeBinary: process.execPath,
2069
2137
  },
2070
2138
  codingSessions: {
@@ -2133,7 +2201,7 @@ If Messages is selected, run:
2133
2201
 
2134
2202
  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
2203
  ${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.
2204
+ 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
2205
 
2138
2206
  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
2207
 
@@ -2226,10 +2294,28 @@ function selectedSources() {
2226
2294
  return selected;
2227
2295
  }
2228
2296
 
2297
+ function sourceSelectionFromSession(response, fallback) {
2298
+ const raw = response?.sources;
2299
+ if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
2300
+ return fallback ? { ...fallback } : null;
2301
+ }
2302
+ return {
2303
+ google: raw.google === true,
2304
+ slack: raw.slack === true,
2305
+ github: raw.github === true,
2306
+ granola: raw.granola === true,
2307
+ messages: raw.messages === true,
2308
+ discord: raw.discord === true,
2309
+ instagram: raw.instagram === true,
2310
+ codingSessions: raw.codingSessions === true,
2311
+ };
2312
+ }
2313
+
2229
2314
  function sourceSelectionFromList(value) {
2230
2315
  const selected = {
2231
2316
  google: false,
2232
2317
  slack: false,
2318
+ github: false,
2233
2319
  granola: false,
2234
2320
  messages: false,
2235
2321
  codingSessions: false,
@@ -2242,6 +2328,7 @@ function sourceSelectionFromList(value) {
2242
2328
  ["gdocs", "google"],
2243
2329
  ["calendar", "google"],
2244
2330
  ["slack", "slack"],
2331
+ ["github", "github"],
2245
2332
  ["granola", "granola"],
2246
2333
  ["messages", "messages"],
2247
2334
  ["imessage", "messages"],
@@ -2261,6 +2348,7 @@ function sourceSelectionFromList(value) {
2261
2348
  if (part === "all") {
2262
2349
  selected.google = true;
2263
2350
  selected.slack = true;
2351
+ selected.github = true;
2264
2352
  selected.granola = true;
2265
2353
  selected.messages = true;
2266
2354
  selected.codingSessions = true;
@@ -2268,7 +2356,7 @@ function sourceSelectionFromList(value) {
2268
2356
  }
2269
2357
  const source = aliases.get(part);
2270
2358
  if (!source) {
2271
- throw new Error(`Unknown source "${part}". Use google, slack, granola, messages, coding-sessions, or all.`);
2359
+ throw new Error(`Unknown source "${part}". Use google, slack, github, granola, messages, coding-sessions, or all.`);
2272
2360
  }
2273
2361
  selected[source] = true;
2274
2362
  }
@@ -2324,7 +2412,9 @@ async function updateAgentStateFromOnboardingResponse(state, response) {
2324
2412
  const hasStatus = typeof response?.status === "string";
2325
2413
  const hasProcessing = typeof response?.processingEnabled === "boolean" || response?.processing;
2326
2414
  const hasProviders = response?.providers && typeof response.providers === "object" && !Array.isArray(response.providers);
2327
- if (!hasAuthUrls && !hasGoogleWorkspaceDelegation && !hasStatus && !hasProcessing && !hasProviders) return state;
2415
+ const responseSources = sourceSelectionFromSession(response, null);
2416
+ const hasSources = responseSources !== null;
2417
+ if (!hasAuthUrls && !hasGoogleWorkspaceDelegation && !hasStatus && !hasProcessing && !hasProviders && !hasSources) return state;
2328
2418
 
2329
2419
  const next = {
2330
2420
  ...state,
@@ -2336,6 +2426,7 @@ async function updateAgentStateFromOnboardingResponse(state, response) {
2336
2426
  ...(typeof response?.processingEnabled === "boolean" ? { processingEnabled: response.processingEnabled } : {}),
2337
2427
  ...(response?.processing ? { processing: response.processing } : {}),
2338
2428
  ...(hasProviders ? { providers: response.providers } : {}),
2429
+ ...(hasSources ? { sources: responseSources } : {}),
2339
2430
  };
2340
2431
  await writeAgentState(next);
2341
2432
  return next;
@@ -2442,6 +2533,20 @@ async function openNextAgentModality({ sources, authUrls = {}, noOpen = false, p
2442
2533
  return { source, label: "Slack", opened: !noOpen, url };
2443
2534
  }
2444
2535
 
2536
+ if (source === "github") {
2537
+ const url = typeof authUrls.github === "string" ? authUrls.github : null;
2538
+ if (!url) {
2539
+ return {
2540
+ source,
2541
+ label: "GitHub",
2542
+ opened: false,
2543
+ message: "GitHub authorization URL was not returned by Shepherd.",
2544
+ };
2545
+ }
2546
+ await openOrPrint(url, { noOpen });
2547
+ return { source, label: "GitHub", opened: !noOpen, url };
2548
+ }
2549
+
2445
2550
  if (source === "granola") {
2446
2551
  const result = await openGranolaApiKeys({ noOpen: noOpen || Boolean(args["no-open-granola"]) });
2447
2552
  return { source, label: "Granola", ...result };
@@ -2503,6 +2608,18 @@ function printAgentCurrentAction(action, opts = {}) {
2503
2608
  return;
2504
2609
  }
2505
2610
 
2611
+ if (action.source === "github") {
2612
+ if (action.opened) {
2613
+ console.log("Opened GitHub authorization in the browser.");
2614
+ } else if (action.url) {
2615
+ console.log(`GitHub authorization URL: ${action.url}`);
2616
+ } else if (action.message) {
2617
+ console.log(action.message);
2618
+ }
2619
+ console.log("Ask the user to complete GitHub authorization before opening another source.");
2620
+ return;
2621
+ }
2622
+
2506
2623
  if (action.source === "granola") {
2507
2624
  if (action.target) console.log(`Granola target: ${action.target}`);
2508
2625
  console.log("Ask the user to create/copy the Granola API key before opening another source.");
@@ -2526,6 +2643,7 @@ function agentNeedsUserAction(sources, action) {
2526
2643
  if (!action) return [];
2527
2644
  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
2645
  if (action.source === "slack") return ["Complete Slack browser authorization."];
2646
+ if (action.source === "github") return ["Complete GitHub browser authorization."];
2529
2647
  if (action.source === "granola") return ["Create/copy a Granola API key from the Granola Mac app."];
2530
2648
  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
2649
  if (action.source === "codingSessions") return ["Run the continue command to install local Codex and Claude Code session summary sync."];
@@ -2683,7 +2801,7 @@ async function explainMessagesBackgroundPermissions(opts = {}) {
2683
2801
  console.log("\nMessages background sync permissions");
2684
2802
  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
2803
  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.");
2804
+ 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
2805
  await openFullDiskAccessSettings(opts);
2688
2806
 
2689
2807
  if (opts.waitForUser && process.stdin.isTTY && !args["no-permission-prompt"]) {
@@ -3035,7 +3153,7 @@ async function verifyMessagesAgentLaunch({ label, stdoutPath, stderrPath, stdout
3035
3153
  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
3154
  }
3037
3155
 
3038
- if (/Watching for new Messages|Shepherd Messages raw sync starting|Running .*Messages backfill/i.test(stdout)
3156
+ if (/Messages local database access validated|Watching for new Messages/i.test(stdout)
3039
3157
  && /state = running|job state = running/i.test(launchState)) {
3040
3158
  return;
3041
3159
  }
@@ -3958,7 +4076,27 @@ function selectedChatContactSeedHandles(selectedChats, allowedChatIds) {
3958
4076
  return [...new Set(handles)];
3959
4077
  }
3960
4078
 
3961
- async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null) {
4079
+ async function validateMessagesDatabaseAccess(sdk) {
4080
+ await sdk.getMessages({ limit: 1 });
4081
+ }
4082
+
4083
+ function messagesBackfillScope({ backfillDays, allChats, allowedChatIds }) {
4084
+ return {
4085
+ version: 1,
4086
+ backfillDays: backfillDays == null ? "all" : backfillDays,
4087
+ scope: allChats
4088
+ ? { allChats: true }
4089
+ : { chatIds: [...allowedChatIds].sort() },
4090
+ };
4091
+ }
4092
+
4093
+ function shouldRunMessagesBackfill({ backfillDays, backfillExplicit, backfillComplete }) {
4094
+ if (backfillDays === 0) return false;
4095
+ if (backfillExplicit) return true;
4096
+ return !backfillComplete;
4097
+ }
4098
+
4099
+ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null, stateCache = null) {
3962
4100
  const allChats = allowedChatIds == null;
3963
4101
  console.log(allChats
3964
4102
  ? `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for all current chats`
@@ -3979,6 +4117,8 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
3979
4117
  totalMessages += filtered.length;
3980
4118
  const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
3981
4119
  totalStored += result.stored;
4120
+ if ((result.queued ?? 0) > 0) return { complete: false, totalMessages, totalStored, queued: result.queued };
4121
+ stateCache?.observe(filtered);
3982
4122
  saveMessagesWatermark(sender.userId, maxRowId(messages));
3983
4123
 
3984
4124
  if (messages.length < pageSize) break;
@@ -3986,7 +4126,7 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
3986
4126
  }
3987
4127
 
3988
4128
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
3989
- return;
4129
+ return { complete: true, totalMessages, totalStored, queued: 0, watermark: loadMessagesWatermark(sender.userId) };
3990
4130
  }
3991
4131
 
3992
4132
  for (const chatId of allowedChatIds) {
@@ -4000,6 +4140,8 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
4000
4140
  totalMessages += filtered.length;
4001
4141
  const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
4002
4142
  totalStored += result.stored;
4143
+ if ((result.queued ?? 0) > 0) return { complete: false, totalMessages, totalStored, queued: result.queued };
4144
+ stateCache?.observe(filtered);
4003
4145
  saveMessagesWatermark(sender.userId, maxRowId(messages));
4004
4146
 
4005
4147
  if (messages.length < pageSize) break;
@@ -4008,19 +4150,20 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
4008
4150
  }
4009
4151
 
4010
4152
  console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
4153
+ return { complete: true, totalMessages, totalStored, queued: 0, watermark: loadMessagesWatermark(sender.userId) };
4011
4154
  }
4012
4155
 
4013
- async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null) {
4156
+ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null, stateCache = null) {
4014
4157
  const allChats = allowedChatIds == null;
4015
4158
  const lastWatermark = loadMessagesWatermark(userId);
4016
4159
  if (lastWatermark <= 0) return;
4017
4160
 
4018
- const missed = [];
4161
+ let missed = [];
4019
4162
  if (allChats) {
4020
- missed.push(...await sdk.getMessages({ limit: 5000 }));
4163
+ missed = await getMessagesAfterWatermark(sdk, { lastWatermark, pageSize: 1000 });
4021
4164
  } else {
4022
4165
  for (const chatId of allowedChatIds) {
4023
- missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
4166
+ missed.push(...await getMessagesAfterWatermark(sdk, { chatId, lastWatermark, pageSize: 1000 }));
4024
4167
  }
4025
4168
  }
4026
4169
  const newMessages = missed.filter((msg) =>
@@ -4029,16 +4172,388 @@ async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChat
4029
4172
  && !messageTouchesShepherdAgent(msg));
4030
4173
  if (newMessages.length === 0) return;
4031
4174
 
4175
+ newMessages.sort((a, b) => Number(a.rowId ?? 0) - Number(b.rowId ?? 0));
4032
4176
  contactSync?.observeMessages(newMessages);
4033
4177
  const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
4034
- if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(newMessages));
4178
+ if ((result.queued ?? 0) === 0) {
4179
+ stateCache?.observe(newMessages);
4180
+ saveMessagesWatermark(userId, maxRowId(newMessages));
4181
+ }
4035
4182
  console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
4036
4183
  }
4037
4184
 
4185
+ async function getMessagesAfterWatermark(sdk, { chatId = null, lastWatermark, pageSize }) {
4186
+ const messages = [];
4187
+ let offset = 0;
4188
+ while (true) {
4189
+ const page = await sdk.getMessages({ ...(chatId ? { chatId } : {}), limit: pageSize, offset });
4190
+ if (!page.length) break;
4191
+ messages.push(...page.filter((msg) => Number(msg.rowId) > lastWatermark));
4192
+ if (page.length < pageSize) break;
4193
+ offset += pageSize;
4194
+ }
4195
+ return messages;
4196
+ }
4197
+
4198
+ function createMessageStateCache(userId, maxSize = MESSAGES_STATE_CACHE_MAX) {
4199
+ const stateFile = messagesStateFile(userId);
4200
+ const initial = readJsonOptional(stateFile);
4201
+ const cache = new Map(Object.entries(
4202
+ initial && typeof initial === "object" && !Array.isArray(initial) ? initial : {},
4203
+ ));
4204
+
4205
+ const remember = (msg) => {
4206
+ const key = messageIdentity(msg);
4207
+ if (!key) return;
4208
+ cache.set(key, messageState(msg));
4209
+ trimMessageStateCache(cache, maxSize);
4210
+ };
4211
+
4212
+ const persist = () => saveMessagesState(userId, Object.fromEntries(cache));
4213
+
4214
+ return {
4215
+ observe(messages) {
4216
+ for (const msg of messages ?? []) remember(msg);
4217
+ persist();
4218
+ },
4219
+ changed(messages) {
4220
+ const changed = [];
4221
+ for (const msg of messages ?? []) {
4222
+ const key = messageIdentity(msg);
4223
+ if (!key) continue;
4224
+ const current = messageState(msg);
4225
+ const previous = cache.get(key);
4226
+ if ((previous && !sameMessageState(previous, current))
4227
+ || (!previous && hasMessageEditOrRetraction(msg))) {
4228
+ changed.push(msg);
4229
+ }
4230
+ }
4231
+ return changed;
4232
+ },
4233
+ };
4234
+ }
4235
+
4236
+ function trimMessageStateCache(cache, maxSize) {
4237
+ if (cache.size <= maxSize) return;
4238
+ const excess = cache.size - maxSize;
4239
+ for (const key of [...cache.keys()].slice(0, excess)) cache.delete(key);
4240
+ }
4241
+
4242
+ function hasMessageEditOrRetraction(msg) {
4243
+ return Boolean(msg?.editedAt || msg?.retractedAt);
4244
+ }
4245
+
4246
+ function messageIdentity(msg) {
4247
+ const value = msg?.id ?? msg?.messageId ?? msg?.rowId;
4248
+ return value == null ? null : String(value);
4249
+ }
4250
+
4251
+ function messageState(msg) {
4252
+ return {
4253
+ text: msg?.text ?? null,
4254
+ editedAt: isoDate(msg?.editedAt),
4255
+ retractedAt: isoDate(msg?.retractedAt),
4256
+ hasAttachments: Boolean(msg?.hasAttachments ?? (Array.isArray(msg?.attachments) && msg.attachments.length > 0)),
4257
+ attachments: messageAttachmentState(msg),
4258
+ };
4259
+ }
4260
+
4261
+ function sameMessageState(a, b) {
4262
+ return a.text === b.text
4263
+ && a.editedAt === b.editedAt
4264
+ && a.retractedAt === b.retractedAt
4265
+ && a.hasAttachments === b.hasAttachments
4266
+ && stableLocalJson(a.attachments) === stableLocalJson(b.attachments);
4267
+ }
4268
+
4269
+ function messageAttachmentState(msg) {
4270
+ return (Array.isArray(msg?.attachments) ? msg.attachments : [])
4271
+ .map((att) => ({
4272
+ id: String(att?.id ?? ""),
4273
+ fileName: att?.fileName ?? null,
4274
+ mimeType: att?.mimeType ?? null,
4275
+ sizeBytes: Number(att?.sizeBytes ?? 0),
4276
+ transferStatus: att?.transferStatus ?? null,
4277
+ isSticker: Boolean(att?.isSticker),
4278
+ isSensitiveContent: Boolean(att?.isSensitiveContent),
4279
+ }))
4280
+ .sort((a, b) => a.id.localeCompare(b.id) || String(a.fileName ?? "").localeCompare(String(b.fileName ?? "")));
4281
+ }
4282
+
4283
+ function stableLocalJson(value) {
4284
+ if (Array.isArray(value)) return `[${value.map(stableLocalJson).join(",")}]`;
4285
+ if (value && typeof value === "object") {
4286
+ return `{${Object.entries(value)
4287
+ .filter(([, entryValue]) => entryValue !== undefined)
4288
+ .sort(([a], [b]) => a.localeCompare(b))
4289
+ .map(([key, entryValue]) => `${JSON.stringify(key)}:${stableLocalJson(entryValue)}`)
4290
+ .join(",")}}`;
4291
+ }
4292
+ return JSON.stringify(value);
4293
+ }
4294
+
4295
+ function startMessagesEditDetector(sdk, sender, serializer, allowedChatIds, opts = {}) {
4296
+ const contactSync = opts.contactSync ?? null;
4297
+ const stateCache = opts.stateCache ?? createMessageStateCache(sender.userId);
4298
+ const watchers = [];
4299
+ let stopped = false;
4300
+ let timer = null;
4301
+ let scanning = false;
4302
+ let lastMutationReconcileAt = 0;
4303
+
4304
+ const scan = async (reason) => {
4305
+ if (stopped || scanning) return;
4306
+ scanning = true;
4307
+ try {
4308
+ const recentResult = await scanMessagesForEditsAndUnsends(sdk, sender, serializer, allowedChatIds, {
4309
+ contactSync,
4310
+ stateCache,
4311
+ });
4312
+ const shouldReconcileMutations = reason === "startup"
4313
+ || Date.now() - lastMutationReconcileAt >= MESSAGES_MUTATION_RECONCILE_INTERVAL_MS;
4314
+ const mutationResult = shouldReconcileMutations
4315
+ ? await reconcileMessagesMutations(sdk, sender, serializer, allowedChatIds, {
4316
+ contactSync,
4317
+ stateCache,
4318
+ })
4319
+ : { changed: 0, stored: 0, updated: 0, queued: 0 };
4320
+ if (shouldReconcileMutations) lastMutationReconcileAt = Date.now();
4321
+
4322
+ const result = {
4323
+ changed: recentResult.changed + mutationResult.changed,
4324
+ stored: recentResult.stored + mutationResult.stored,
4325
+ updated: recentResult.updated + mutationResult.updated,
4326
+ queued: recentResult.queued + mutationResult.queued,
4327
+ };
4328
+ if (result.changed > 0) {
4329
+ console.log(`Messages edit detector: ${result.changed} edit/unsend change(s) sent (${reason}); stored ${result.stored}, updated ${result.updated}, queued ${result.queued}`);
4330
+ }
4331
+ } catch (err) {
4332
+ console.error("Messages edit detector failed:", safeError(err));
4333
+ } finally {
4334
+ scanning = false;
4335
+ }
4336
+ };
4337
+
4338
+ const schedule = (reason) => {
4339
+ if (stopped) return;
4340
+ if (timer) clearTimeout(timer);
4341
+ timer = setTimeout(() => {
4342
+ timer = null;
4343
+ scan(reason).catch((err) => console.error("Messages edit detector failed:", safeError(err)));
4344
+ }, 500);
4345
+ };
4346
+
4347
+ for (const walPath of messagesWatchPaths()) {
4348
+ try {
4349
+ watchers.push(watch(walPath, () => {
4350
+ schedule("messages-db");
4351
+ }));
4352
+ } catch (err) {
4353
+ console.warn(`Could not watch Messages database file ${walPath}: ${safeError(err)}`);
4354
+ }
4355
+ }
4356
+
4357
+ if (watchers.length > 0) {
4358
+ console.log(`Watching ${watchers.length} Messages database file(s) for edits and unsends`);
4359
+ } else if (platform() === "darwin") {
4360
+ console.warn("Messages database watch files not found; edit/unsend detection will use fallback polling only");
4361
+ }
4362
+
4363
+ const interval = setInterval(() => {
4364
+ scan("fallback").catch((err) => console.error("Messages edit detector failed:", safeError(err)));
4365
+ }, MESSAGES_EDIT_SCAN_INTERVAL_MS);
4366
+ scan("startup").catch((err) => console.error("Messages edit detector failed:", safeError(err)));
4367
+
4368
+ return {
4369
+ stop() {
4370
+ stopped = true;
4371
+ if (timer) clearTimeout(timer);
4372
+ clearInterval(interval);
4373
+ for (const watcher of watchers) watcher.close();
4374
+ },
4375
+ };
4376
+ }
4377
+
4378
+ function messagesWatchPaths() {
4379
+ if (platform() !== "darwin") return [];
4380
+ return [
4381
+ MESSAGES_CHAT_DB_PATH,
4382
+ `${MESSAGES_CHAT_DB_PATH}-wal`,
4383
+ `${MESSAGES_CHAT_DB_PATH}-shm`,
4384
+ ].filter((path) => existsSync(path));
4385
+ }
4386
+
4387
+ async function scanMessagesForEditsAndUnsends(sdk, sender, serializer, allowedChatIds, opts = {}) {
4388
+ const allChats = allowedChatIds == null;
4389
+ const since = new Date(Date.now() - MESSAGES_EDIT_SCAN_WINDOW_MS);
4390
+ const messages = await getScopedMessages(sdk, allowedChatIds, { since });
4391
+
4392
+ const filtered = messages.filter((msg) =>
4393
+ msg?.chatId
4394
+ && (allChats || allowedChatIds.includes(msg.chatId))
4395
+ && !messageTouchesShepherdAgent(msg));
4396
+ opts.contactSync?.observeMessages(filtered);
4397
+
4398
+ const changed = opts.stateCache?.changed(filtered) ?? [];
4399
+ if (changed.length === 0) {
4400
+ opts.stateCache?.observe(filtered);
4401
+ return { changed: 0, stored: 0, updated: 0, queued: 0 };
4402
+ }
4403
+
4404
+ const result = await sender.send(changed.map((msg) => serializer.serialize(msg)));
4405
+ if ((result.queued ?? 0) === 0) opts.stateCache?.observe(filtered);
4406
+ return {
4407
+ changed: changed.length,
4408
+ stored: result.stored ?? 0,
4409
+ updated: result.updated ?? 0,
4410
+ queued: result.queued ?? 0,
4411
+ };
4412
+ }
4413
+
4414
+ async function getScopedMessages(sdk, allowedChatIds, query) {
4415
+ const messages = [];
4416
+ if (allowedChatIds == null) {
4417
+ messages.push(...await getPagedMessages(sdk, query));
4418
+ return messages;
4419
+ }
4420
+
4421
+ for (const chatId of allowedChatIds) {
4422
+ messages.push(...await getPagedMessages(sdk, { ...query, chatId }));
4423
+ }
4424
+ return messages;
4425
+ }
4426
+
4427
+ async function getPagedMessages(sdk, query) {
4428
+ const messages = [];
4429
+ let offset = 0;
4430
+ while (true) {
4431
+ const page = await sdk.getMessages({ ...query, limit: MESSAGES_EDIT_SCAN_LIMIT, offset });
4432
+ if (!page.length) break;
4433
+ messages.push(...page);
4434
+ if (page.length < MESSAGES_EDIT_SCAN_LIMIT) break;
4435
+ offset += MESSAGES_EDIT_SCAN_LIMIT;
4436
+ }
4437
+ return messages;
4438
+ }
4439
+
4440
+ async function reconcileMessagesMutations(sdk, sender, serializer, allowedChatIds, opts = {}) {
4441
+ const allChats = allowedChatIds == null;
4442
+ const rowIds = queryMessagesMutationRowIds(sdk, allowedChatIds);
4443
+ if (rowIds.length === 0) return { changed: 0, stored: 0, updated: 0, queued: 0 };
4444
+
4445
+ const messages = await getMessagesByRowIds(sdk, rowIds);
4446
+ const filtered = messages.filter((msg) =>
4447
+ msg?.chatId
4448
+ && (allChats || allowedChatIds.includes(msg.chatId))
4449
+ && !messageTouchesShepherdAgent(msg));
4450
+ opts.contactSync?.observeMessages(filtered);
4451
+
4452
+ const changed = opts.stateCache?.changed(filtered) ?? filtered.filter(hasMessageEditOrRetraction);
4453
+ if (changed.length === 0) {
4454
+ opts.stateCache?.observe(filtered);
4455
+ return { changed: 0, stored: 0, updated: 0, queued: 0 };
4456
+ }
4457
+
4458
+ const result = await sender.send(changed.map((msg) => serializer.serialize(msg)));
4459
+ if ((result.queued ?? 0) === 0) opts.stateCache?.observe(filtered);
4460
+ return {
4461
+ changed: changed.length,
4462
+ stored: result.stored ?? 0,
4463
+ updated: result.updated ?? 0,
4464
+ queued: result.queued ?? 0,
4465
+ };
4466
+ }
4467
+
4468
+ function queryMessagesMutationRowIds(sdk, allowedChatIds = null) {
4469
+ const db = sdk?.database;
4470
+ if (!db || typeof db.all !== "function") {
4471
+ console.warn("Messages mutation reconciliation unavailable: SDK database reader is not exposed");
4472
+ return [];
4473
+ }
4474
+
4475
+ const columns = messageTableColumns(db);
4476
+ const predicates = [];
4477
+ if (columns.has("date_edited")) predicates.push("COALESCE(message.date_edited, 0) != 0");
4478
+ if (columns.has("date_retracted")) predicates.push("COALESCE(message.date_retracted, 0) != 0");
4479
+ if (!predicates.length) return [];
4480
+
4481
+ const params = [];
4482
+ let chatJoin = "";
4483
+ let chatPredicate = "";
4484
+ if (allowedChatIds != null) {
4485
+ const values = uniqueStrings(allowedChatIds.flatMap(chatIdMatchValues));
4486
+ if (values.length === 0) return [];
4487
+ const placeholders = values.map(() => "?").join(", ");
4488
+ chatJoin = `
4489
+ INNER JOIN chat_message_join ON chat_message_join.message_id = message.ROWID
4490
+ INNER JOIN chat ON chat.ROWID = chat_message_join.chat_id
4491
+ `;
4492
+ chatPredicate = `AND (chat.chat_identifier IN (${placeholders}) OR chat.guid IN (${placeholders}))`;
4493
+ params.push(...values, ...values);
4494
+ }
4495
+
4496
+ try {
4497
+ const rows = db.all(`
4498
+ SELECT DISTINCT message.ROWID AS rowId
4499
+ FROM message
4500
+ ${chatJoin}
4501
+ WHERE (${predicates.join(" OR ")})
4502
+ ${chatPredicate}
4503
+ ORDER BY message.ROWID ASC
4504
+ `, params);
4505
+ return rows
4506
+ .map((row) => Number(row.rowId))
4507
+ .filter((rowId) => Number.isFinite(rowId) && rowId > 0);
4508
+ } catch (err) {
4509
+ console.warn(`Messages mutation reconciliation query failed: ${safeError(err)}`);
4510
+ return [];
4511
+ }
4512
+ }
4513
+
4514
+ function messageTableColumns(db) {
4515
+ try {
4516
+ return new Set(db.all("PRAGMA table_info(message)").map((row) => String(row.name)));
4517
+ } catch (err) {
4518
+ console.warn(`Messages mutation reconciliation unavailable: ${safeError(err)}`);
4519
+ return new Set();
4520
+ }
4521
+ }
4522
+
4523
+ function chatIdMatchValues(chatId) {
4524
+ const raw = String(chatId ?? "").trim();
4525
+ if (!raw) return [];
4526
+ const core = raw.includes(";+;")
4527
+ ? raw.slice(raw.indexOf(";+;") + 3)
4528
+ : raw.includes(";-;")
4529
+ ? raw.slice(raw.indexOf(";-;") + 3)
4530
+ : raw;
4531
+ return raw === core ? [raw] : [raw, core];
4532
+ }
4533
+
4534
+ function uniqueStrings(values) {
4535
+ return [...new Set(values.map((value) => String(value ?? "").trim()).filter(Boolean))];
4536
+ }
4537
+
4538
+ async function getMessagesByRowIds(sdk, rowIds) {
4539
+ const messages = [];
4540
+ for (const rowId of rowIds) {
4541
+ const page = await sdk.getMessages({
4542
+ sinceRowId: rowId - 1,
4543
+ orderByRowIdAsc: true,
4544
+ limit: 1,
4545
+ });
4546
+ const msg = page.find((candidate) => Number(candidate.rowId) === rowId);
4547
+ if (msg) messages.push(msg);
4548
+ }
4549
+ return messages;
4550
+ }
4551
+
4038
4552
  async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, opts = {}) {
4039
4553
  const allChats = allowedChatIds == null;
4040
4554
  const allowed = new Set(allowedChatIds ?? []);
4041
4555
  const contactSync = opts.contactSync ?? null;
4556
+ const stateCache = opts.stateCache ?? null;
4042
4557
  let buffer = [];
4043
4558
  let timer = null;
4044
4559
 
@@ -4046,7 +4561,10 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, op
4046
4561
  if (!buffer.length) return;
4047
4562
  const batch = buffer.splice(0, MAX_BATCH_SIZE);
4048
4563
  const result = await sender.send(batch.map((msg) => serializer.serialize(msg)));
4049
- if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(batch));
4564
+ if ((result.queued ?? 0) === 0) {
4565
+ stateCache?.observe(batch);
4566
+ saveMessagesWatermark(userId, maxRowId(batch));
4567
+ }
4050
4568
  };
4051
4569
 
4052
4570
  const scheduleFlush = () => {
@@ -4088,6 +4606,7 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, op
4088
4606
  stopping = true;
4089
4607
  if (timer) clearTimeout(timer);
4090
4608
  contactSync?.stop();
4609
+ opts.onShutdown?.();
4091
4610
  await flush().catch(() => undefined);
4092
4611
  await sdk.close?.().catch(() => undefined);
4093
4612
  resolve();
@@ -4144,7 +4663,7 @@ function startMessagesContactSync(sender, contactLookup, opts = {}) {
4144
4663
 
4145
4664
  syncing = true;
4146
4665
  try {
4147
- const nextLookup = buildContactLookup();
4666
+ const nextLookup = buildContactLookup({ userId: opts.userId });
4148
4667
  contactLookup.replace(nextLookup);
4149
4668
  const mappings = visibleMappings();
4150
4669
  const toSync = forceAll
@@ -4287,14 +4806,22 @@ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
4287
4806
  targetMessageId: msg.reaction.targetMessageId ?? null,
4288
4807
  emoji: msg.reaction.emoji ?? null,
4289
4808
  isRemoved: Boolean(msg.reaction.isRemoved),
4809
+ textRange: msg.reaction.textRange ?? null,
4810
+ appBundleId: msg.reaction.appBundleId ?? null,
4290
4811
  }
4291
4812
  : null,
4813
+ hasAttachments: Boolean(msg.hasAttachments ?? attachments.length > 0),
4292
4814
  attachments: attachments.map((att) => ({
4293
4815
  id: String(att.id ?? ""),
4294
4816
  fileName: att.fileName ?? null,
4295
4817
  mimeType: att.mimeType ?? "application/octet-stream",
4818
+ uti: att.uti ?? null,
4296
4819
  sizeBytes: Number(att.sizeBytes ?? 0),
4297
4820
  transferStatus: att.transferStatus ?? "unknown",
4821
+ createdAt: isoDate(att.createdAt),
4822
+ altText: att.altText ?? null,
4823
+ isFromMe: typeof att.isFromMe === "boolean" ? att.isFromMe : null,
4824
+ isSensitiveContent: Boolean(att.isSensitiveContent),
4298
4825
  isSticker: Boolean(att.isSticker),
4299
4826
  isImage: isImageAttachment(att),
4300
4827
  isVideo: isVideoAttachment(att),
@@ -4316,8 +4843,21 @@ function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
4316
4843
  }
4317
4844
 
4318
4845
  function buildContactLookup(opts = {}) {
4319
- const contacts = opts.loadAll === false ? [] : loadContacts();
4320
- const myCard = loadMyCard();
4846
+ const addressBook = platform() === "darwin" ? loadContactsFromAddressBookDb() : { contacts: [], myCard: null };
4847
+ if (opts.userId) {
4848
+ saveMessagesContactStatus(opts.userId, {
4849
+ ok: addressBook.ok === true,
4850
+ updatedAt: new Date().toISOString(),
4851
+ contacts: addressBook.contacts.length,
4852
+ hasMyCard: Boolean(addressBook.myCard),
4853
+ error: addressBook.ok === true ? null : addressBook.error ?? "unsupported local Contacts database schema",
4854
+ });
4855
+ }
4856
+ if (addressBook.ok !== true && platform() === "darwin") {
4857
+ console.warn(`Messages contact resolution degraded: ${addressBook.error ?? "unsupported local Contacts database schema"}. Raw Messages sync will continue; contact founders@askshepherd.ai.`);
4858
+ }
4859
+ const contacts = opts.loadAll === false ? [] : addressBook.contacts;
4860
+ const myCard = addressBook.myCard;
4321
4861
  const handleToName = new Map();
4322
4862
  const selfHandles = new Set();
4323
4863
 
@@ -4367,84 +4907,96 @@ function emptyContactLookup() {
4367
4907
  };
4368
4908
  }
4369
4909
 
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
4910
  function loadContactsFromAddressBookDb() {
4408
- const contacts = new Map();
4911
+ const contacts = [];
4912
+ let myCard = null;
4913
+ let readSucceeded = false;
4914
+ let firstError = null;
4409
4915
  for (const dbPath of addressBookDatabasePaths()) {
4410
4916
  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 != '';`;
4917
+ with records as (
4918
+ select Z_PK as id,
4919
+ coalesce(ZME, 0) as me,
4920
+ coalesce(Z22_ME, 0) as me22,
4921
+ coalesce(
4922
+ nullif(ZNAME, ''),
4923
+ nullif(trim(coalesce(ZFIRSTNAME, '') || ' ' || coalesce(ZMIDDLENAME, '') || ' ' || coalesce(ZLASTNAME, '')), ''),
4924
+ nullif(ZNICKNAME, ''),
4925
+ nullif(ZORGANIZATION, ''),
4926
+ ''
4927
+ ) as display_name
4928
+ from ZABCDRECORD
4929
+ ),
4930
+ phones as (
4931
+ select coalesce(ZOWNER, Z22_OWNER) as owner, ZFULLNUMBER as value
4932
+ from ZABCDPHONENUMBER
4933
+ where nullif(ZFULLNUMBER, '') is not null
4934
+ ),
4935
+ emails as (
4936
+ select coalesce(ZOWNER, Z22_OWNER) as owner, coalesce(ZADDRESSNORMALIZED, ZADDRESS) as value
4937
+ from ZABCDEMAILADDRESS
4938
+ where nullif(coalesce(ZADDRESSNORMALIZED, ZADDRESS), '') is not null
4939
+ )
4940
+ select r.id,
4941
+ r.me,
4942
+ r.me22,
4943
+ r.display_name,
4944
+ coalesce(p.value, '') as phone,
4945
+ coalesce(e.value, '') as email
4946
+ from records r
4947
+ left join phones p on p.owner = r.id
4948
+ left join emails e on e.owner = r.id
4949
+ where r.display_name != ''
4950
+ and (p.value is not null or e.value is not null);`;
4426
4951
 
4427
4952
  try {
4428
4953
  const raw = execFileSync("sqlite3", ["-separator", "\t", dbPath, query], {
4429
4954
  encoding: "utf8",
4430
4955
  timeout: 10_000,
4431
4956
  });
4957
+ readSucceeded = true;
4432
4958
  for (const line of raw.split("\n").filter(Boolean)) {
4433
- const [id, rawName, phone, email] = line.split("\t");
4959
+ const [id, me, me22, rawName, phone, email] = line.split("\t");
4434
4960
  const name = rawName?.trim();
4435
4961
  if (!id || !name) continue;
4436
4962
  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);
4963
+ let current = contacts.find((contact) => contact._key === key);
4964
+ if (!current) {
4965
+ current = { _key: key, name, phones: [], emails: [] };
4966
+ contacts.push(current);
4967
+ }
4968
+ if (phone?.trim()) current.phones.push(phone.trim());
4969
+ if (email?.trim()) current.emails.push(email.trim());
4970
+ if (Number(me) !== 0 || Number(me22) !== 0) myCard = current;
4441
4971
  }
4442
4972
  } catch (err) {
4973
+ firstError ??= safeError(err);
4443
4974
  if (args.debug === true) console.error(`Could not read Contacts DB ${dbPath}:`, safeError(err));
4444
4975
  }
4445
4976
  }
4446
4977
 
4447
- return [...contacts.values()].filter((contact) => contact.name);
4978
+ const cleanContacts = contacts
4979
+ .map(({ _key, ...contact }) => ({
4980
+ name: contact.name,
4981
+ phones: [...new Set(contact.phones)],
4982
+ emails: [...new Set(contact.emails)],
4983
+ }))
4984
+ .filter((contact) => contact.name && (contact.phones.length > 0 || contact.emails.length > 0));
4985
+
4986
+ const cleanMyCard = myCard
4987
+ ? {
4988
+ name: myCard.name,
4989
+ phones: [...new Set(myCard.phones)],
4990
+ emails: [...new Set(myCard.emails)],
4991
+ }
4992
+ : null;
4993
+
4994
+ return {
4995
+ ok: readSucceeded,
4996
+ contacts: cleanContacts,
4997
+ myCard: cleanMyCard,
4998
+ error: readSucceeded ? null : firstError,
4999
+ };
4448
5000
  }
4449
5001
 
4450
5002
  function addressBookDatabasePaths() {
@@ -4469,51 +5021,6 @@ function addressBookWalPaths() {
4469
5021
  return [...paths].filter((path) => existsSync(path));
4470
5022
  }
4471
5023
 
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
5024
  function addHandleMapping(map, handle, name) {
4518
5025
  for (const candidate of handleCandidates(handle)) {
4519
5026
  map.set(candidate, name);
@@ -5219,14 +5726,23 @@ class MessagesBatchSender {
5219
5726
  this.agentToken = agentToken;
5220
5727
  this.userId = userId;
5221
5728
  this.queueFile = join(homedir(), ".shepherd", "raw-messages", `${safeFileId(userId)}-queue.json`);
5729
+ this.sendChain = Promise.resolve();
5222
5730
  }
5223
5731
 
5224
5732
  async send(messages) {
5733
+ const run = () => this.sendUnlocked(messages);
5734
+ const next = this.sendChain.then(run, run);
5735
+ this.sendChain = next.catch(() => undefined);
5736
+ return next;
5737
+ }
5738
+
5739
+ async sendUnlocked(messages) {
5225
5740
  const queued = this.loadQueue();
5226
- const all = [...queued, ...messages];
5227
- if (!all.length) return { stored: 0, skipped: 0 };
5741
+ const all = dedupeMessagePayloads([...queued, ...messages]);
5742
+ if (!all.length) return { stored: 0, updated: 0, skipped: 0, queued: 0 };
5228
5743
 
5229
5744
  let totalStored = 0;
5745
+ let totalUpdated = 0;
5230
5746
  let totalSkipped = 0;
5231
5747
 
5232
5748
  for (let i = 0; i < all.length; i += MAX_BATCH_SIZE) {
@@ -5234,16 +5750,18 @@ class MessagesBatchSender {
5234
5750
  try {
5235
5751
  const result = await this.postBatch(batch);
5236
5752
  totalStored += result.stored ?? 0;
5753
+ totalUpdated += result.updated ?? 0;
5237
5754
  totalSkipped += result.skipped ?? 0;
5238
5755
  } catch (err) {
5239
- this.saveQueue(all.slice(i));
5756
+ const remaining = all.slice(i);
5757
+ this.saveQueue(remaining);
5240
5758
  console.error("Messages batch send failed:", safeError(err));
5241
- return { stored: totalStored, skipped: totalSkipped };
5759
+ return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped, queued: remaining.length };
5242
5760
  }
5243
5761
  }
5244
5762
 
5245
5763
  this.clearQueue();
5246
- return { stored: totalStored, skipped: totalSkipped };
5764
+ return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped, queued: 0 };
5247
5765
  }
5248
5766
 
5249
5767
  async postBatch(messages) {
@@ -5286,7 +5804,11 @@ class MessagesBatchSender {
5286
5804
 
5287
5805
  saveQueue(messages) {
5288
5806
  const capped = messages.slice(-MAX_QUEUE_MESSAGES);
5807
+ mkdirSync(dirname(this.queueFile), { recursive: true });
5289
5808
  writeFileSync(this.queueFile, JSON.stringify(capped), { mode: 0o600 });
5809
+ if (capped.length < messages.length) {
5810
+ console.warn(`Messages queue trimmed from ${messages.length} to ${MAX_QUEUE_MESSAGES} messages`);
5811
+ }
5290
5812
  }
5291
5813
 
5292
5814
  clearQueue() {
@@ -5298,6 +5820,37 @@ class MessagesBatchSender {
5298
5820
  }
5299
5821
  }
5300
5822
 
5823
+ function dedupeMessagePayloads(messages) {
5824
+ const entries = [];
5825
+ const keyed = new Map();
5826
+ for (const message of messages) {
5827
+ const key = messagePayloadIdentity(message);
5828
+ if (!key) {
5829
+ entries.push({ message });
5830
+ continue;
5831
+ }
5832
+
5833
+ let entry = keyed.get(key);
5834
+ if (!entry) {
5835
+ entry = { key, message };
5836
+ keyed.set(key, entry);
5837
+ entries.push(entry);
5838
+ } else {
5839
+ entry.message = message;
5840
+ }
5841
+ }
5842
+ return entries.map((entry) => entry.message);
5843
+ }
5844
+
5845
+ function messagePayloadIdentity(message) {
5846
+ const messageId = message?.messageId == null ? null : String(message.messageId);
5847
+ const chatId = message?.chatId == null ? null : String(message.chatId);
5848
+ if (messageId && chatId) return `${chatId}:${messageId}`;
5849
+ if (messageId) return messageId;
5850
+ const rowId = Number(message?.rowId);
5851
+ return Number.isFinite(rowId) && rowId > 0 ? `row:${rowId}` : null;
5852
+ }
5853
+
5301
5854
  function loadMessagesWatermark(userId) {
5302
5855
  try {
5303
5856
  const raw = readFileSync(messagesWatermarkFile(userId), "utf8").trim();
@@ -5308,21 +5861,99 @@ function loadMessagesWatermark(userId) {
5308
5861
  }
5309
5862
  }
5310
5863
 
5864
+ function loadMessagesBackfillComplete(userId, scope) {
5865
+ const record = readJsonOptional(messagesBackfillCompleteFile(userId, scope));
5866
+ if (!record || typeof record !== "object" || Array.isArray(record)) return null;
5867
+ return record;
5868
+ }
5869
+
5870
+ function hasAnyMessagesBackfillComplete(userId) {
5871
+ const safeId = safeFileId(userId);
5872
+ try {
5873
+ return readdirSync(messagesRawMessagesDir()).some((entry) =>
5874
+ entry.startsWith(`${safeId}-backfill-`) && entry.endsWith(".json"));
5875
+ } catch {
5876
+ return false;
5877
+ }
5878
+ }
5879
+
5880
+ function saveMessagesBackfillComplete(userId, scope, result = {}) {
5881
+ const record = {
5882
+ completedAt: new Date().toISOString(),
5883
+ scope,
5884
+ ...result,
5885
+ };
5886
+ writeJsonAtomic(messagesBackfillCompleteFile(userId, scope), record);
5887
+ return record;
5888
+ }
5889
+
5311
5890
  function saveMessagesWatermark(userId, rowId) {
5891
+ const numericRowId = Number(rowId);
5892
+ if (!Number.isFinite(numericRowId) || numericRowId <= 0) return;
5893
+
5312
5894
  try {
5313
5895
  const path = messagesWatermarkFile(userId);
5314
- writeFileSync(path, String(rowId), { mode: 0o600 });
5896
+ const next = Math.max(loadMessagesWatermark(userId), Math.floor(numericRowId));
5897
+ const tmpPath = `${path}.tmp`;
5898
+ writeFileSync(tmpPath, String(next), { mode: 0o600 });
5899
+ renameSync(tmpPath, path);
5315
5900
  } catch (err) {
5316
5901
  console.error("Could not save Messages watermark:", safeError(err));
5317
5902
  }
5318
5903
  }
5319
5904
 
5320
5905
  function messagesWatermarkFile(userId) {
5321
- const path = join(homedir(), ".shepherd", "raw-messages", `${safeFileId(userId)}-watermark`);
5906
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-watermark`);
5322
5907
  mkdirSync(dirname(path), { recursive: true });
5323
5908
  return path;
5324
5909
  }
5325
5910
 
5911
+ function messagesBackfillCompleteFile(userId, scope) {
5912
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-backfill-${hashObject(scope).slice(0, 24)}.json`);
5913
+ mkdirSync(dirname(path), { recursive: true });
5914
+ return path;
5915
+ }
5916
+
5917
+ function messagesStateFile(userId) {
5918
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-message-state.json`);
5919
+ mkdirSync(dirname(path), { recursive: true });
5920
+ return path;
5921
+ }
5922
+
5923
+ function saveMessagesState(userId, state) {
5924
+ try {
5925
+ writeJsonAtomic(messagesStateFile(userId), state);
5926
+ } catch (err) {
5927
+ console.error("Could not save Messages state:", safeError(err));
5928
+ }
5929
+ }
5930
+
5931
+ function messagesContactStatusFile(userId) {
5932
+ const path = join(messagesRawMessagesDir(), `${safeFileId(userId)}-contacts-status.json`);
5933
+ mkdirSync(dirname(path), { recursive: true });
5934
+ return path;
5935
+ }
5936
+
5937
+ function saveMessagesContactStatus(userId, status) {
5938
+ try {
5939
+ writeJsonAtomic(messagesContactStatusFile(userId), status);
5940
+ } catch (err) {
5941
+ console.error("Could not save Messages contact status:", safeError(err));
5942
+ }
5943
+ }
5944
+
5945
+ function messagesRawMessagesDir() {
5946
+ const path = join(homedir(), ".shepherd", "raw-messages");
5947
+ mkdirSync(path, { recursive: true });
5948
+ return path;
5949
+ }
5950
+
5951
+ function writeJsonAtomic(path, value) {
5952
+ const tmpPath = `${path}.tmp`;
5953
+ writeFileSync(tmpPath, JSON.stringify(value), { mode: 0o600 });
5954
+ renameSync(tmpPath, path);
5955
+ }
5956
+
5326
5957
  function maxRowId(messages) {
5327
5958
  return Math.max(0, ...messages.map((msg) => Number(msg.rowId ?? 0)).filter(Number.isFinite));
5328
5959
  }
@@ -5385,6 +6016,12 @@ function clampInt(value, min, max) {
5385
6016
  return Math.min(Math.max(Math.floor(value), min), max);
5386
6017
  }
5387
6018
 
6019
+ function positiveIntFromEnv(name, defaultValue) {
6020
+ const parsed = Number(process.env[name]);
6021
+ if (!Number.isFinite(parsed) || parsed <= 0) return defaultValue;
6022
+ return Math.floor(parsed);
6023
+ }
6024
+
5388
6025
  function parseBackfillDays(value, defaultValue) {
5389
6026
  if (value === undefined || value === null || value === "") return defaultValue;
5390
6027
  if (typeof value === "string" && value.trim().toLowerCase() === "all") return null;