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.
- package/README.md +5 -3
- package/bin/shepherd-onboard.js +793 -156
- package/package.json +1 -1
package/bin/shepherd-onboard.js
CHANGED
|
@@ -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
|
-
|
|
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:
|
|
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
|
-
|
|
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(
|
|
1007
|
+
await installCodexMcp(proxy);
|
|
991
1008
|
} else if (target === "claude") {
|
|
992
|
-
await installClaudeMcp(
|
|
1009
|
+
await installClaudeMcp(proxy);
|
|
993
1010
|
} else if (target === "cursor") {
|
|
994
|
-
await installCursorMcp(
|
|
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(
|
|
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, "--",
|
|
1023
|
+
await execFileQuiet("codex", ["mcp", "add", MCP_SERVER_NAME, "--", proxy.command, ...proxy.args]);
|
|
1007
1024
|
}
|
|
1008
1025
|
|
|
1009
|
-
async function installClaudeMcp(
|
|
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, "--",
|
|
1028
|
+
await execFileQuiet("claude", ["mcp", "add", "--scope", "user", MCP_SERVER_NAME, "--", proxy.command, ...proxy.args]);
|
|
1012
1029
|
}
|
|
1013
1030
|
|
|
1014
|
-
async function installCursorMcp(
|
|
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:
|
|
1022
|
-
args:
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
1525
|
-
|
|
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
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
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 (/
|
|
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
|
|
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
|
-
|
|
4161
|
+
let missed = [];
|
|
4019
4162
|
if (allChats) {
|
|
4020
|
-
missed
|
|
4163
|
+
missed = await getMessagesAfterWatermark(sdk, { lastWatermark, pageSize: 1000 });
|
|
4021
4164
|
} else {
|
|
4022
4165
|
for (const chatId of allowedChatIds) {
|
|
4023
|
-
missed.push(...await sdk
|
|
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.
|
|
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.
|
|
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
|
|
4320
|
-
|
|
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 =
|
|
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
|
-
|
|
4412
|
-
|
|
4413
|
-
|
|
4414
|
-
|
|
4415
|
-
|
|
4416
|
-
|
|
4417
|
-
|
|
4418
|
-
|
|
4419
|
-
|
|
4420
|
-
|
|
4421
|
-
|
|
4422
|
-
|
|
4423
|
-
|
|
4424
|
-
|
|
4425
|
-
|
|
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
|
-
|
|
4438
|
-
if (
|
|
4439
|
-
|
|
4440
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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;
|