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