askshepherd 0.1.31 → 0.1.36
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 +51 -11
- package/bin/shepherd-onboard.js +1425 -65
- package/package.json +1 -1
package/bin/shepherd-onboard.js
CHANGED
|
@@ -1,17 +1,18 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { execFile, execFileSync, spawn } from "node:child_process";
|
|
3
|
-
import {
|
|
4
|
-
import {
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { constants as fsConstants, existsSync, mkdirSync, readFileSync, unlinkSync, watch, writeFileSync } from "node:fs";
|
|
5
|
+
import { access, mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
|
|
5
6
|
import { createServer } from "node:http";
|
|
6
7
|
import { homedir, platform } from "node:os";
|
|
7
|
-
import { dirname, join } from "node:path";
|
|
8
|
+
import { basename, dirname, join } from "node:path";
|
|
8
9
|
import readline from "node:readline";
|
|
9
10
|
import { fileURLToPath } from "node:url";
|
|
10
11
|
|
|
11
12
|
const DEFAULT_API_URL = "https://brain-api-customer-facing.up.railway.app";
|
|
12
13
|
const PACKAGE_NAME = "askshepherd";
|
|
13
14
|
const PACKAGE_SPEC = `${PACKAGE_NAME}@latest`;
|
|
14
|
-
const PACKAGE_VERSION = "0.1.
|
|
15
|
+
const PACKAGE_VERSION = "0.1.36";
|
|
15
16
|
const MCP_SERVER_NAME = "shepherd";
|
|
16
17
|
const PACKAGE_DIR = dirname(dirname(fileURLToPath(import.meta.url)));
|
|
17
18
|
const DEFAULT_AGENT_STATE_PATH = join(homedir(), ".shepherd", "raw-onboarding-agent.json");
|
|
@@ -22,7 +23,8 @@ const MAX_BATCH_SIZE = 50;
|
|
|
22
23
|
const MAX_QUEUE_MESSAGES = 10_000;
|
|
23
24
|
const DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT = 200;
|
|
24
25
|
const INITIAL_MESSAGE_CHAT_ROWS = 20;
|
|
25
|
-
const
|
|
26
|
+
const ALL_MESSAGES_CHATS = "__shepherd_all_messages_chats__";
|
|
27
|
+
const AGENT_MODALITY_ORDER = ["google", "slack", "granola", "messages", "codingSessions"];
|
|
26
28
|
const SHEPHERD_LOGO_PATH = join(PACKAGE_DIR, "assets", "shepherd_G_vector_136033.png");
|
|
27
29
|
const GRANOLA_API_KEYS_PATH = "/settings/integrations/api-keys";
|
|
28
30
|
const GOOGLE_WORKSPACE_DELEGATION_ADMIN_URL = "https://admin.google.com/ac/owl/domainwidedelegation";
|
|
@@ -30,6 +32,15 @@ const MAC_FULL_DISK_ACCESS_URL = "x-apple.systempreferences:com.apple.settings.P
|
|
|
30
32
|
const LEGACY_MAC_FULL_DISK_ACCESS_URL = "x-apple.systempreferences:com.apple.preference.security?Privacy_AllFiles";
|
|
31
33
|
const MESSAGES_CHAT_DB_PATH = join(homedir(), "Library", "Messages", "chat.db");
|
|
32
34
|
const MESSAGES_ATTACHMENTS_DIR = join(homedir(), "Library", "Messages", "Attachments");
|
|
35
|
+
const CODEX_SESSIONS_DIR = join(homedir(), ".codex", "sessions");
|
|
36
|
+
const CODEX_ARCHIVED_SESSIONS_DIR = join(homedir(), ".codex", "archived_sessions");
|
|
37
|
+
const CLAUDE_PROJECTS_DIR = join(homedir(), ".claude", "projects");
|
|
38
|
+
const CONTACTS_WAL_PATH = join(homedir(), "Library", "Application Support", "AddressBook", "AddressBook-v22.abcddb-wal");
|
|
39
|
+
const CONTACT_SYNC_DEBOUNCE_MS = 5_000;
|
|
40
|
+
const CONTACT_SYNC_FALLBACK_MS = 30 * 60_000;
|
|
41
|
+
const SHEPHERD_OWNED_MESSAGE_HANDLES = parseMessageHandleList(
|
|
42
|
+
process.env.SHEPHERD_OWNED_MESSAGE_HANDLES ?? process.env.SENDBLUE_NUMBER ?? "",
|
|
43
|
+
);
|
|
33
44
|
const GOOGLE_WORKSPACE_DELEGATION_APP_NAME = "Shepherd";
|
|
34
45
|
const GOOGLE_WORKSPACE_DELEGATION_SERVICE_ACCOUNT_EMAIL =
|
|
35
46
|
"gigabrain-delegation@shepherd-gigabrain.iam.gserviceaccount.com";
|
|
@@ -120,6 +131,10 @@ async function dispatch() {
|
|
|
120
131
|
await runMessagesChatsCommand();
|
|
121
132
|
} else if (command === "messages-agent") {
|
|
122
133
|
await runMessagesAgent();
|
|
134
|
+
} else if (command === "coding-sessions-agent") {
|
|
135
|
+
await runCodingSessionsAgent();
|
|
136
|
+
} else if (command === "coding-sessions-status") {
|
|
137
|
+
await runCodingSessionsStatus();
|
|
123
138
|
} else {
|
|
124
139
|
throw new Error(`Unknown command: ${command}`);
|
|
125
140
|
}
|
|
@@ -143,12 +158,7 @@ async function runOnboarding() {
|
|
|
143
158
|
const name = stringArg("name") ?? authenticatedName(workosLogin.authenticated) ?? await valueOrPrompt("name", "Full name");
|
|
144
159
|
const organizationName = await valueOrPrompt("org", "Organization name");
|
|
145
160
|
|
|
146
|
-
const sources =
|
|
147
|
-
google: !args["no-google"],
|
|
148
|
-
slack: !args["no-slack"],
|
|
149
|
-
granola: !args["no-granola"],
|
|
150
|
-
messages: !args["no-messages"],
|
|
151
|
-
};
|
|
161
|
+
const sources = selectedSources();
|
|
152
162
|
|
|
153
163
|
const session = await postJson(`${apiUrl}/onboarding/raw/session`, {
|
|
154
164
|
email,
|
|
@@ -237,6 +247,29 @@ async function runOnboarding() {
|
|
|
237
247
|
}
|
|
238
248
|
}
|
|
239
249
|
|
|
250
|
+
if (finalized.connected?.codingSessions?.agentToken) {
|
|
251
|
+
const configPath = await writeCodingSessionsConfig({
|
|
252
|
+
apiUrl,
|
|
253
|
+
userId: session.sessionId,
|
|
254
|
+
agentToken: finalized.connected.codingSessions.agentToken,
|
|
255
|
+
intervalSeconds: Number(args["coding-sessions-interval-seconds"] ?? 60),
|
|
256
|
+
});
|
|
257
|
+
|
|
258
|
+
if (!args["no-install-coding-sessions-agent"]) {
|
|
259
|
+
const install = await installCodingSessionsAgent(configPath, session.sessionId).catch((err) => ({
|
|
260
|
+
error: safeError(err),
|
|
261
|
+
}));
|
|
262
|
+
if ("error" in install) {
|
|
263
|
+
console.log(`\nLocal coding-session credentials saved: ${configPath}`);
|
|
264
|
+
console.log(`Coding-session background sync was not started: ${install.error}`);
|
|
265
|
+
} else {
|
|
266
|
+
console.log(`\nLocal coding-session sync started: ${install.label}`);
|
|
267
|
+
}
|
|
268
|
+
} else {
|
|
269
|
+
console.log(`\nLocal coding-session credentials saved: ${configPath}`);
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
240
273
|
const connected = Object.keys(finalized.connected ?? {});
|
|
241
274
|
console.log(`\nConnected sources: ${connected.length ? connected.join(", ") : "none"}`);
|
|
242
275
|
|
|
@@ -280,6 +313,10 @@ async function runAgentOnboarding() {
|
|
|
280
313
|
|| args["no-slack"]
|
|
281
314
|
|| args["no-granola"]
|
|
282
315
|
|| args["no-messages"]
|
|
316
|
+
|| args["no-coding-sessions"]
|
|
317
|
+
|| args["coding-sessions"]
|
|
318
|
+
|| stringArg("sources")
|
|
319
|
+
|| stringArg("add-sources")
|
|
283
320
|
);
|
|
284
321
|
|
|
285
322
|
if (!wantsStart) {
|
|
@@ -339,7 +376,7 @@ async function runAgentOnboarding() {
|
|
|
339
376
|
currentAction,
|
|
340
377
|
statePath,
|
|
341
378
|
messagesChatsCommand: sources.messages ? `${agentCommand()} messages-chats` : undefined,
|
|
342
|
-
nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
379
|
+
nextCommand: `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"`,
|
|
343
380
|
needsUserAction: agentNeedsUserAction(sources, currentAction),
|
|
344
381
|
}, null, 2));
|
|
345
382
|
return;
|
|
@@ -358,7 +395,7 @@ async function runAgentOnboarding() {
|
|
|
358
395
|
});
|
|
359
396
|
|
|
360
397
|
console.log("\nAfter that modality is complete, run:");
|
|
361
|
-
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
398
|
+
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"`);
|
|
362
399
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
363
400
|
}
|
|
364
401
|
|
|
@@ -869,7 +906,7 @@ async function continueAgentOnboarding() {
|
|
|
869
906
|
if (granolaApiKey) body.granolaApiKey = granolaApiKey;
|
|
870
907
|
if (messagesHandle) body.imessage = { handle: messagesHandle };
|
|
871
908
|
if (state.sources.messages && messagesHandle && selectedMessageChatIds.length === 0) {
|
|
872
|
-
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>".`);
|
|
909
|
+
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats, have the user select chats in the browser page, then rerun --continue with --messages-chat-ids "<id1>,<id2>" or --messages-chat-ids all.`);
|
|
873
910
|
}
|
|
874
911
|
|
|
875
912
|
const finalized = await postJson(
|
|
@@ -880,12 +917,19 @@ async function continueAgentOnboarding() {
|
|
|
880
917
|
state = await updateAgentStateFromOnboardingResponse(state, finalized);
|
|
881
918
|
|
|
882
919
|
if (finalized.connected?.messages?.agentToken) {
|
|
920
|
+
const selectedChats = selectedChatIdsIncludeAll(selectedMessageChatIds)
|
|
921
|
+
? [allMessagesChatsSelection()]
|
|
922
|
+
: await loadSelectedMessageChatsForConfig(selectedMessageChatIds).catch((err) => {
|
|
923
|
+
console.warn(`Could not load selected Messages chat metadata for contact hydration: ${safeError(err)}`);
|
|
924
|
+
return [];
|
|
925
|
+
});
|
|
883
926
|
const configPath = await writeMessagesConfig({
|
|
884
927
|
apiUrl: state.apiUrl,
|
|
885
928
|
userId: state.sessionId,
|
|
886
929
|
agentToken: finalized.connected.messages.agentToken,
|
|
887
930
|
backfillDays: parseBackfillDays(args["messages-backfill-days"], null),
|
|
888
931
|
allowedChatIds: selectedMessageChatIds,
|
|
932
|
+
selectedChats,
|
|
889
933
|
});
|
|
890
934
|
|
|
891
935
|
if (!args["no-install-messages-agent"]) {
|
|
@@ -902,6 +946,29 @@ async function continueAgentOnboarding() {
|
|
|
902
946
|
}
|
|
903
947
|
}
|
|
904
948
|
|
|
949
|
+
if (finalized.connected?.codingSessions?.agentToken) {
|
|
950
|
+
const configPath = await writeCodingSessionsConfig({
|
|
951
|
+
apiUrl: state.apiUrl,
|
|
952
|
+
userId: state.sessionId,
|
|
953
|
+
agentToken: finalized.connected.codingSessions.agentToken,
|
|
954
|
+
intervalSeconds: Number(args["coding-sessions-interval-seconds"] ?? 60),
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
if (!args["no-install-coding-sessions-agent"]) {
|
|
958
|
+
const install = await installCodingSessionsAgent(configPath, state.sessionId).catch((err) => ({
|
|
959
|
+
error: safeError(err),
|
|
960
|
+
}));
|
|
961
|
+
if ("error" in install) {
|
|
962
|
+
console.log(`Coding-session credentials saved: ${configPath}`);
|
|
963
|
+
console.log(`Coding-session background sync was not started: ${install.error}`);
|
|
964
|
+
} else {
|
|
965
|
+
console.log(`Coding-session background sync started: ${install.label}`);
|
|
966
|
+
}
|
|
967
|
+
} else {
|
|
968
|
+
console.log(`Coding-session credentials saved: ${configPath}`);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
905
972
|
const errors = finalized.errors && Object.keys(finalized.errors).length ? finalized.errors : null;
|
|
906
973
|
const currentAction = errors
|
|
907
974
|
? await openNextAgentModality({
|
|
@@ -915,11 +982,17 @@ async function continueAgentOnboarding() {
|
|
|
915
982
|
console.log(JSON.stringify({
|
|
916
983
|
status: errors ? "waiting" : "completed",
|
|
917
984
|
connected: Object.keys(finalized.connected ?? {}),
|
|
985
|
+
alreadyConnected: Object.keys(finalized.alreadyConnected ?? {}),
|
|
918
986
|
processingEnabled: finalized.processingEnabled === true,
|
|
919
987
|
processing: finalized.processing,
|
|
920
988
|
errors: errors ? safeErrorRecord(errors) : undefined,
|
|
921
989
|
currentAction,
|
|
922
|
-
nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
990
|
+
nextCommand: errors ? `${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"` : undefined,
|
|
991
|
+
mcpInstall: errors ? undefined : {
|
|
992
|
+
prompt: "Ask where to install Shepherd MCP for this customer: Codex, Claude Code, Cursor, any subset, or none.",
|
|
993
|
+
targets: MCP_INSTALL_TARGETS,
|
|
994
|
+
command: `${agentCommand()} mcp-login --install "<targets>"`,
|
|
995
|
+
},
|
|
923
996
|
}, null, 2));
|
|
924
997
|
return;
|
|
925
998
|
}
|
|
@@ -935,7 +1008,7 @@ async function continueAgentOnboarding() {
|
|
|
935
1008
|
});
|
|
936
1009
|
|
|
937
1010
|
console.log("\nAfter that modality is complete, rerun:");
|
|
938
|
-
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
1011
|
+
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"`);
|
|
939
1012
|
console.log(" Omit either optional flag if that source is not being connected.");
|
|
940
1013
|
return;
|
|
941
1014
|
}
|
|
@@ -943,6 +1016,8 @@ async function continueAgentOnboarding() {
|
|
|
943
1016
|
console.log("\nShepherd raw onboarding completed.");
|
|
944
1017
|
console.log(`Connected sources: ${Object.keys(finalized.connected ?? {}).join(", ") || "none"}`);
|
|
945
1018
|
printProcessingSummary(finalized);
|
|
1019
|
+
console.log("\nAsk the user where to install Shepherd MCP for this customer: Codex, Claude Code, Cursor, any subset, or none.");
|
|
1020
|
+
console.log(`If they choose any targets, run: ${agentCommand()} mcp-login --install <codex,claude,cursor>`);
|
|
946
1021
|
}
|
|
947
1022
|
|
|
948
1023
|
async function printAgentStatus() {
|
|
@@ -975,8 +1050,10 @@ async function runMessagesChatsCommand() {
|
|
|
975
1050
|
|
|
976
1051
|
if (!args.text && !args.list) {
|
|
977
1052
|
const selected = await selectChatsInBrowser(chats, { noOpen: Boolean(args["no-open"]) });
|
|
978
|
-
const selectedIds = selected.map((chat) => chat.chatId).join(",");
|
|
979
|
-
console.log(
|
|
1053
|
+
const selectedIds = selectedIncludesAllChats(selected) ? "all" : selected.map((chat) => chat.chatId).join(",");
|
|
1054
|
+
console.log(selectedIncludesAllChats(selected)
|
|
1055
|
+
? "\nSelected all current and future Messages chats."
|
|
1056
|
+
: `\nSelected ${selected.length} Messages chat(s).`);
|
|
980
1057
|
console.log(`messages-chat-ids=${selectedIds}`);
|
|
981
1058
|
console.log("\nContinue with:");
|
|
982
1059
|
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "${selectedIds}"`);
|
|
@@ -990,6 +1067,7 @@ async function runMessagesChatsCommand() {
|
|
|
990
1067
|
}
|
|
991
1068
|
console.log("\nPass selected IDs to:");
|
|
992
1069
|
console.log(` ${agentCommand()} agent --continue --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<id1>,<id2>"`);
|
|
1070
|
+
console.log(` Or explicitly sync all current and future chats with: --messages-chat-ids all`);
|
|
993
1071
|
}
|
|
994
1072
|
|
|
995
1073
|
async function runMessagesAgent() {
|
|
@@ -1000,37 +1078,157 @@ async function runMessagesAgent() {
|
|
|
1000
1078
|
const apiUrl = requiredConfigString(config.apiUrl, "apiUrl");
|
|
1001
1079
|
const userId = requiredConfigString(config.userId, "userId");
|
|
1002
1080
|
const agentToken = requiredConfigString(config.agentToken, "agentToken");
|
|
1081
|
+
mergeShepherdOwnedMessageHandles(config.excludedMessageHandles);
|
|
1003
1082
|
const backfillDays = parseBackfillDays(args["backfill-days"] ?? process.env.SHEPHERD_BACKFILL_DAYS ?? config.backfillDays, null);
|
|
1004
1083
|
const allowedChatIds = parseAllowedChatIds(config.allowedChatIds);
|
|
1005
|
-
|
|
1006
|
-
|
|
1084
|
+
const allChats = config.allChats === true || selectedChatIdsIncludeAll(allowedChatIds);
|
|
1085
|
+
if (!allChats && allowedChatIds.length === 0) {
|
|
1086
|
+
throw new Error("Messages config must include selected chat IDs or allChats=true. Re-run onboarding and select chats, or pass --messages-chat-ids all.");
|
|
1007
1087
|
}
|
|
1008
1088
|
|
|
1009
1089
|
const kit = await import("@photon-ai/imessage-kit");
|
|
1010
1090
|
const sdk = new kit.IMessageSDK({ debug: args.debug === true });
|
|
1011
1091
|
const sender = new MessagesBatchSender(apiUrl, agentToken, userId);
|
|
1012
|
-
const contactLookup = buildContactLookup();
|
|
1092
|
+
const contactLookup = createMutableContactLookup(buildContactLookup());
|
|
1013
1093
|
const serializer = createMessageSerializer(kit, contactLookup);
|
|
1094
|
+
const contactSync = startMessagesContactSync(sender, contactLookup, {
|
|
1095
|
+
syncAllContacts: allChats,
|
|
1096
|
+
seedHandles: allChats ? [] : selectedChatContactSeedHandles(config.selectedChats, allowedChatIds),
|
|
1097
|
+
});
|
|
1014
1098
|
|
|
1015
1099
|
console.log("Shepherd Messages raw sync starting");
|
|
1016
|
-
console.log(
|
|
1100
|
+
console.log(allChats
|
|
1101
|
+
? "Messages chat filter: all current and future chats"
|
|
1102
|
+
: `Messages chat filter: ${allowedChatIds.length} selected chat(s)`);
|
|
1017
1103
|
|
|
1018
1104
|
try {
|
|
1105
|
+
await contactSync.syncNow({ forceAll: true, reason: "startup" }).catch((err) => {
|
|
1106
|
+
console.error("Initial Messages contact sync failed:", safeError(err));
|
|
1107
|
+
});
|
|
1019
1108
|
await loadGroupChatNames(sdk, serializer);
|
|
1020
1109
|
loadSelectedChatNames(config.selectedChats, serializer);
|
|
1021
1110
|
|
|
1022
1111
|
if (backfillDays !== 0) {
|
|
1023
|
-
await runMessagesBackfill(sdk, sender, serializer, backfillDays, allowedChatIds);
|
|
1112
|
+
await runMessagesBackfill(sdk, sender, serializer, backfillDays, allChats ? null : allowedChatIds, contactSync);
|
|
1113
|
+
await contactSync.syncNow({ forceAll: true, reason: "post-backfill" }).catch((err) => {
|
|
1114
|
+
console.error("Post-backfill Messages contact sync failed:", safeError(err));
|
|
1115
|
+
});
|
|
1024
1116
|
}
|
|
1025
1117
|
|
|
1026
|
-
await gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds);
|
|
1027
|
-
await
|
|
1118
|
+
await gapFillFromWatermark(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, contactSync);
|
|
1119
|
+
await contactSync.syncNow({ forceAll: true, reason: "post-gap-fill" }).catch((err) => {
|
|
1120
|
+
console.error("Post-gap-fill Messages contact sync failed:", safeError(err));
|
|
1121
|
+
});
|
|
1122
|
+
await watchMessages(sdk, sender, serializer, userId, allChats ? null : allowedChatIds, { contactSync });
|
|
1028
1123
|
} catch (err) {
|
|
1124
|
+
contactSync.stop();
|
|
1029
1125
|
await sdk.close?.().catch(() => undefined);
|
|
1030
1126
|
throw err;
|
|
1031
1127
|
}
|
|
1032
1128
|
}
|
|
1033
1129
|
|
|
1130
|
+
async function runCodingSessionsAgent() {
|
|
1131
|
+
const configPath = stringArg("config");
|
|
1132
|
+
if (!configPath) throw new Error("coding-sessions-agent requires --config <path>");
|
|
1133
|
+
|
|
1134
|
+
const config = JSON.parse(await readFile(configPath, "utf8"));
|
|
1135
|
+
const apiUrl = requiredConfigString(config.apiUrl, "apiUrl");
|
|
1136
|
+
const userId = requiredConfigString(config.userId, "userId");
|
|
1137
|
+
const agentToken = requiredConfigString(config.agentToken, "agentToken");
|
|
1138
|
+
const sender = new CodingSessionsBatchSender(apiUrl, agentToken, userId);
|
|
1139
|
+
const statePath = codingSessionsStateFile(userId);
|
|
1140
|
+
const statusPath = codingSessionsStatusFile(userId);
|
|
1141
|
+
const intervalSeconds = Math.max(15, Math.floor(Number(args["interval-seconds"] ?? config.intervalSeconds ?? 60)));
|
|
1142
|
+
|
|
1143
|
+
console.log("Shepherd Coding Sessions sync starting");
|
|
1144
|
+
while (true) {
|
|
1145
|
+
const startedAt = new Date().toISOString();
|
|
1146
|
+
const previous = readCodingSessionsState(statePath);
|
|
1147
|
+
const scan = await scanCodingSessions(config, previous);
|
|
1148
|
+
const changed = scan.sessions.filter((session) => previous.hashes[session.sourcePathHash] !== session.contentHash);
|
|
1149
|
+
const sendResult = changed.length > 0 ? await sender.send(changed) : { stored: 0, updated: 0, skipped: 0 };
|
|
1150
|
+
const nextState = {
|
|
1151
|
+
hashes: { ...previous.hashes },
|
|
1152
|
+
updatedAt: new Date().toISOString(),
|
|
1153
|
+
};
|
|
1154
|
+
for (const session of changed) {
|
|
1155
|
+
nextState.hashes[session.sourcePathHash] = session.contentHash;
|
|
1156
|
+
}
|
|
1157
|
+
writeFileSync(statePath, JSON.stringify(nextState, null, 2), { mode: 0o600 });
|
|
1158
|
+
const status = {
|
|
1159
|
+
ok: true,
|
|
1160
|
+
startedAt,
|
|
1161
|
+
finishedAt: new Date().toISOString(),
|
|
1162
|
+
scanned: scan.sessions.length,
|
|
1163
|
+
changed: changed.length,
|
|
1164
|
+
sent: sendResult,
|
|
1165
|
+
probes: scan.probes,
|
|
1166
|
+
errors: scan.errors,
|
|
1167
|
+
};
|
|
1168
|
+
writeFileSync(statusPath, JSON.stringify(status, null, 2), { mode: 0o600 });
|
|
1169
|
+
console.log(`Coding session scan: ${scan.sessions.length} scanned, ${changed.length} changed, ${sendResult.stored ?? 0} stored, ${sendResult.updated ?? 0} updated, ${sendResult.skipped ?? 0} skipped`);
|
|
1170
|
+
|
|
1171
|
+
if (args.once) return;
|
|
1172
|
+
await sleep(intervalSeconds * 1000);
|
|
1173
|
+
}
|
|
1174
|
+
}
|
|
1175
|
+
|
|
1176
|
+
async function runCodingSessionsStatus() {
|
|
1177
|
+
const configPath = stringArg("config") ?? await latestCodingSessionsConfigPath();
|
|
1178
|
+
const config = configPath ? JSON.parse(await readFile(configPath, "utf8")) : null;
|
|
1179
|
+
const userId = config?.userId ?? null;
|
|
1180
|
+
const safeId = userId ? safeFileId(userId) : null;
|
|
1181
|
+
const label = safeId ? `ai.shepherd.coding-sessions.${safeId}` : null;
|
|
1182
|
+
const probes = await probeCodingSessionPaths(config ?? {});
|
|
1183
|
+
const lastSync = userId ? readJsonOptional(codingSessionsStatusFile(userId)) : null;
|
|
1184
|
+
const queue = userId ? readJsonOptional(join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-queue.json`)) : null;
|
|
1185
|
+
const launch = label && platform() === "darwin"
|
|
1186
|
+
? {
|
|
1187
|
+
label,
|
|
1188
|
+
state: readLaunchctlPrint(`gui/${process.getuid?.() ?? 501}/${label}`),
|
|
1189
|
+
}
|
|
1190
|
+
: null;
|
|
1191
|
+
const production = await productionOnboardingStatusForCodingSessions(config).catch((err) => ({ error: safeError(err) }));
|
|
1192
|
+
const status = {
|
|
1193
|
+
configPath: configPath ?? null,
|
|
1194
|
+
userId,
|
|
1195
|
+
localFolders: probes,
|
|
1196
|
+
launch,
|
|
1197
|
+
lastSync,
|
|
1198
|
+
queueDepth: Array.isArray(queue) ? queue.length : 0,
|
|
1199
|
+
production,
|
|
1200
|
+
};
|
|
1201
|
+
|
|
1202
|
+
if (args.json) {
|
|
1203
|
+
console.log(JSON.stringify(status, null, 2));
|
|
1204
|
+
return;
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
console.log("\nShepherd coding-session sync status\n");
|
|
1208
|
+
console.log(`Config: ${configPath ?? "not found"}`);
|
|
1209
|
+
if (userId) console.log(`User: ${userId}`);
|
|
1210
|
+
for (const probe of probes) {
|
|
1211
|
+
console.log(`- ${probe.provider}: ${probe.path} ${probe.readable ? "readable" : `not readable (${probe.reason})`}`);
|
|
1212
|
+
}
|
|
1213
|
+
if (launch) {
|
|
1214
|
+
const running = /state = running|job state = running/i.test(launch.state);
|
|
1215
|
+
console.log(`LaunchAgent: ${launch.label} ${running ? "running" : "not running or unknown"}`);
|
|
1216
|
+
} else {
|
|
1217
|
+
console.log("LaunchAgent: not installed or unavailable");
|
|
1218
|
+
}
|
|
1219
|
+
if (lastSync) {
|
|
1220
|
+
console.log(`Last sync: ${lastSync.finishedAt ?? "unknown"} (${lastSync.scanned ?? 0} scanned, ${lastSync.changed ?? 0} changed)`);
|
|
1221
|
+
} else {
|
|
1222
|
+
console.log("Last sync: none recorded");
|
|
1223
|
+
}
|
|
1224
|
+
console.log(`Queued unsent sessions: ${Array.isArray(queue) ? queue.length : 0}`);
|
|
1225
|
+
if (production?.providers?.codingSessions) {
|
|
1226
|
+
console.log(`Production provider: ${production.providers.codingSessions.connected ? "connected" : "not connected"}`);
|
|
1227
|
+
} else if (production?.error) {
|
|
1228
|
+
console.log(`Production provider: unavailable (${production.error})`);
|
|
1229
|
+
}
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1034
1232
|
function parseArgs(argv) {
|
|
1035
1233
|
const parsed = {};
|
|
1036
1234
|
for (let i = 0; i < argv.length; i++) {
|
|
@@ -1063,8 +1261,10 @@ Usage:
|
|
|
1063
1261
|
npx -y ${PACKAGE_NAME}@latest agent
|
|
1064
1262
|
npx -y ${PACKAGE_NAME}@latest agent --login
|
|
1065
1263
|
npx -y ${PACKAGE_NAME}@latest agent --name <name> --org <organization>
|
|
1066
|
-
npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids>
|
|
1264
|
+
npx -y ${PACKAGE_NAME}@latest agent --continue --granola-api-key <key> --messages-handle <value> --messages-chat-ids <ids|all>
|
|
1265
|
+
npx -y ${PACKAGE_NAME}@latest agent --add-sources coding-sessions --name <name> --org <organization>
|
|
1067
1266
|
npx -y ${PACKAGE_NAME}@latest agent --status
|
|
1267
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status
|
|
1068
1268
|
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
1069
1269
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
1070
1270
|
|
|
@@ -1074,6 +1274,40 @@ The bare agent command is intended for coding-agent shells. For direct terminal
|
|
|
1074
1274
|
return;
|
|
1075
1275
|
}
|
|
1076
1276
|
|
|
1277
|
+
if (which === "coding-sessions-agent") {
|
|
1278
|
+
console.log(`Shepherd coding-session sync agent
|
|
1279
|
+
|
|
1280
|
+
Usage:
|
|
1281
|
+
shepherd-onboard coding-sessions-agent --config ~/.shepherd/coding-sessions/<id>.json
|
|
1282
|
+
|
|
1283
|
+
Options:
|
|
1284
|
+
--config <path> Coding-session agent config created by onboarding.
|
|
1285
|
+
--once Run one scan and exit.
|
|
1286
|
+
--interval-seconds <n> Poll interval for live sync. Defaults to config or 60.
|
|
1287
|
+
--debug Print extra collector details.
|
|
1288
|
+
--help Show this help.
|
|
1289
|
+
`);
|
|
1290
|
+
return;
|
|
1291
|
+
}
|
|
1292
|
+
|
|
1293
|
+
if (which === "coding-sessions-status") {
|
|
1294
|
+
console.log(`Shepherd coding-session sync status
|
|
1295
|
+
|
|
1296
|
+
Usage:
|
|
1297
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status
|
|
1298
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status --json
|
|
1299
|
+
|
|
1300
|
+
Shows whether local Codex and Claude Code session folders are readable, whether
|
|
1301
|
+
the background LaunchAgent is installed/running, and the last local sync result.
|
|
1302
|
+
|
|
1303
|
+
Options:
|
|
1304
|
+
--config <path> Config path. Defaults to the latest file in ~/.shepherd/coding-sessions.
|
|
1305
|
+
--json Print machine-readable status.
|
|
1306
|
+
--help Show this help.
|
|
1307
|
+
`);
|
|
1308
|
+
return;
|
|
1309
|
+
}
|
|
1310
|
+
|
|
1077
1311
|
if (which === "messages-agent") {
|
|
1078
1312
|
console.log(`Shepherd Messages raw sync agent
|
|
1079
1313
|
|
|
@@ -1103,6 +1337,7 @@ macOS permission:
|
|
|
1103
1337
|
|
|
1104
1338
|
Options:
|
|
1105
1339
|
--limit <n> Number of recent chats to load for search. Defaults to ${DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT}.
|
|
1340
|
+
--messages-chat-ids all Supported on agent --continue to watch every current and future Messages chat.
|
|
1106
1341
|
--text Print a terminal list instead of opening the selector page.
|
|
1107
1342
|
--no-open Print the local selector URL instead of opening it.
|
|
1108
1343
|
--no-permission-prompt Print macOS permission instructions without waiting for confirmation.
|
|
@@ -1188,6 +1423,7 @@ Usage:
|
|
|
1188
1423
|
npx -y ${PACKAGE_NAME}@latest mcp-login
|
|
1189
1424
|
npx -y ${PACKAGE_NAME}@latest mcp-install
|
|
1190
1425
|
npx -y ${PACKAGE_NAME}@latest messages-chats
|
|
1426
|
+
npx -y ${PACKAGE_NAME}@latest coding-sessions-status
|
|
1191
1427
|
npx -y ${PACKAGE_NAME}@latest granola-api-keys
|
|
1192
1428
|
|
|
1193
1429
|
Options:
|
|
@@ -1196,7 +1432,7 @@ Options:
|
|
|
1196
1432
|
--org <name> Organization name.
|
|
1197
1433
|
--granola-api-key <key> Granola API key.
|
|
1198
1434
|
--messages-handle <value> Messages phone number or Apple ID email.
|
|
1199
|
-
--messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats.
|
|
1435
|
+
--messages-chat-ids <ids> Comma-separated local Messages chat IDs selected from messages-chats, or all to watch every current and future chat.
|
|
1200
1436
|
--messages-backfill-days <days>
|
|
1201
1437
|
Local Messages backfill window. Defaults to all selected chat history.
|
|
1202
1438
|
--no-google Skip Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts).
|
|
@@ -1204,8 +1440,13 @@ Options:
|
|
|
1204
1440
|
--no-granola Skip Granola.
|
|
1205
1441
|
--no-open-granola Do not open the Granola API key screen.
|
|
1206
1442
|
--no-messages Skip local Messages.
|
|
1443
|
+
--coding-sessions Opt in to local Codex/Claude Code session summary sync.
|
|
1444
|
+
--sources <list> Exact sources to connect: google,slack,granola,messages,coding-sessions,all.
|
|
1445
|
+
--add-sources <list> Same as --sources, named for second-time onboarding.
|
|
1207
1446
|
--no-install-messages-agent
|
|
1208
1447
|
Save Messages credentials without starting launchd.
|
|
1448
|
+
--no-install-coding-sessions-agent
|
|
1449
|
+
Save coding-session credentials without starting launchd.
|
|
1209
1450
|
--no-open Print auth URLs instead of opening the browser.
|
|
1210
1451
|
--no-permission-prompt Print macOS permission instructions without waiting for confirmation.
|
|
1211
1452
|
--api <url> Advanced: Shepherd API URL.
|
|
@@ -1248,9 +1489,12 @@ function printAgentContract() {
|
|
|
1248
1489
|
"Tell the user Shepherd verifies existing-org joins from the authenticated WorkOS account and company email domain. The typed org name is not trusted by itself.",
|
|
1249
1490
|
"If Google Workspace is selected, guide the customer's Google Workspace super admin to authorize Shepherd's Client ID and scopes in Google Admin Console.",
|
|
1250
1491
|
"Ask Messages as a selectable choice: Skip Messages, or Provide handle.",
|
|
1492
|
+
"Ask Coding Sessions as a selectable choice: Skip Coding Sessions, or Sync Codex/Claude Code session summaries.",
|
|
1251
1493
|
"If the user chooses Provide handle, ask for the phone number or Apple ID email.",
|
|
1252
1494
|
"If Messages is selected, ask the user to grant or confirm macOS Full Disk Access for the app running onboarding and the Node.js binary used by background sync. Shepherd checks this and keeps prompting until access works in interactive onboarding.",
|
|
1253
|
-
"If Messages is selected, run the recent-chat command. It opens a browser selector with recent chats and search. Never sync all Messages chats by default.",
|
|
1495
|
+
"If Messages is selected, run the recent-chat command. It opens a browser selector with recent chats and search. Never sync all Messages chats by default; use all only when the user explicitly asks for every current and future chat.",
|
|
1496
|
+
"If Coding Sessions is selected, continue onboarding installs a local LaunchAgent that reads Codex and Claude Code session logs, summarizes them, and syncs summaries. It usually does not require Full Disk Access unless macOS denies access to ~/.codex or ~/.claude.",
|
|
1497
|
+
"After raw onboarding completes, ask whether they want Shepherd MCP installed for their signed-in customer account into Codex, Claude Code, Cursor, any subset, or none.",
|
|
1254
1498
|
],
|
|
1255
1499
|
selectionQuestions: [
|
|
1256
1500
|
{
|
|
@@ -1261,7 +1505,7 @@ function printAgentContract() {
|
|
|
1261
1505
|
{
|
|
1262
1506
|
label: "Sources",
|
|
1263
1507
|
prompt: "Which sources should Shepherd connect for raw sync?",
|
|
1264
|
-
options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages"],
|
|
1508
|
+
options: ["Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts)", "Slack", "Granola", "Messages", "Coding Sessions (Codex/Claude Code summaries)"],
|
|
1265
1509
|
multiSelect: true,
|
|
1266
1510
|
},
|
|
1267
1511
|
{
|
|
@@ -1269,6 +1513,12 @@ function printAgentContract() {
|
|
|
1269
1513
|
prompt: "Do you want to connect local Messages?",
|
|
1270
1514
|
options: ["Skip Messages", "Provide handle"],
|
|
1271
1515
|
},
|
|
1516
|
+
{
|
|
1517
|
+
label: "MCP install",
|
|
1518
|
+
prompt: "After onboarding completes, where should Shepherd MCP be installed for you?",
|
|
1519
|
+
options: ["Codex", "Claude Code", "Cursor", "None"],
|
|
1520
|
+
multiSelect: true,
|
|
1521
|
+
},
|
|
1272
1522
|
],
|
|
1273
1523
|
askUserFor: [
|
|
1274
1524
|
"Full name",
|
|
@@ -1276,6 +1526,8 @@ function printAgentContract() {
|
|
|
1276
1526
|
"Messages phone number or Apple ID email, if they want local Messages connected",
|
|
1277
1527
|
"Full Disk Access confirmation, if they want local Messages connected on macOS",
|
|
1278
1528
|
"Selected local Messages chats from the browser selector, if they want local Messages connected",
|
|
1529
|
+
"Coding-session sync consent, if they want Codex and Claude Code session summaries connected",
|
|
1530
|
+
"MCP install targets after onboarding completes: Codex, Claude Code, Cursor, any subset, or none",
|
|
1279
1531
|
],
|
|
1280
1532
|
afterStartCommand: [
|
|
1281
1533
|
"Handle only the current modality opened or printed by the command.",
|
|
@@ -1293,21 +1545,29 @@ function printAgentContract() {
|
|
|
1293
1545
|
],
|
|
1294
1546
|
loginCommand: `${command} agent --login`,
|
|
1295
1547
|
startCommand: `${command} agent --name "<full_name>" --org "<organization>"`,
|
|
1548
|
+
addSourcesCommand: `${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"`,
|
|
1296
1549
|
continueCommand: `${command} agent --continue`,
|
|
1550
|
+
mcpLoginCommand: `${command} mcp-login`,
|
|
1297
1551
|
optionalContinueArgs: [
|
|
1298
1552
|
"--messages-handle \"<phone_or_apple_id>\" if local Messages is being connected",
|
|
1299
|
-
"--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected",
|
|
1553
|
+
"--messages-chat-ids \"<comma_separated_chat_ids>\" if local Messages is being connected, or --messages-chat-ids all when the user explicitly wants every current and future Messages chat watched",
|
|
1300
1554
|
"--granola-api-key \"<granola_key>\" if Granola is being connected",
|
|
1301
1555
|
],
|
|
1302
1556
|
statusCommand: `${command} agent --status`,
|
|
1303
1557
|
messagesChatsCommand: `${command} messages-chats`,
|
|
1304
1558
|
messagesPermissions: {
|
|
1305
|
-
macOS: "Local Messages raw sync needs Full Disk Access for the app running onboarding and for Node.js used by the background LaunchAgent. The Messages selector command validates local chat.db access, opens Full Disk Access settings if needed, and keeps checking until access works in interactive onboarding. Background sync install also checks that launchd can start the Messages agent. Contacts permission may also appear when resolving local contact names.",
|
|
1559
|
+
macOS: "Local Messages raw sync needs Full Disk Access for the app running onboarding and for Node.js used by the background LaunchAgent. The Messages selector command validates local chat.db access, opens Full Disk Access settings if needed, and keeps checking until access works in interactive onboarding. Background sync install also checks that launchd can start the Messages agent. Contacts permission may also appear when resolving local contact names. The background Messages agent reloads Contacts on startup, watches AddressBook changes when available, and runs fallback contact sync so renamed contacts can hydrate prior ingested Messages rows for the token-bound customer account.",
|
|
1306
1560
|
nodeBinary: process.execPath,
|
|
1307
1561
|
},
|
|
1562
|
+
codingSessions: {
|
|
1563
|
+
sourceFlag: "--sources coding-sessions or --add-sources coding-sessions",
|
|
1564
|
+
statusCommand: `${command} coding-sessions-status`,
|
|
1565
|
+
localAgentCommand: `${command} coding-sessions-agent --config ~/.shepherd/coding-sessions/<id>.json`,
|
|
1566
|
+
privacy: "Shepherd uploads bounded summaries and evidence metadata, not full raw Codex or Claude Code transcripts. Secrets, auth headers, private keys, credential URLs, high-entropy tokens, and home paths are redacted.",
|
|
1567
|
+
},
|
|
1308
1568
|
googleWorkspaceDelegation: googleWorkspaceDelegationSetup(),
|
|
1309
1569
|
orgSecurity: "Existing organizations are only reused when Shepherd can verify the authenticated user belongs there, for example by an existing Shepherd account/membership or matching non-personal company email domain. Similar spelling helps match verified orgs, but cannot attach an unverified user to someone else's org.",
|
|
1310
|
-
expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd production environment. Finalize asks production brain services to schedule downstream ingestion batches, wiki ingestion, memory artifacts, and
|
|
1570
|
+
expectedResult: "Cloud sources start raw polling/backfill in the customer-facing Shepherd production environment. Finalize asks production brain services to schedule downstream ingestion batches, wiki ingestion, memory artifacts, document summaries, and coding-session wiki artifacts. Local Messages and Coding Sessions start via macOS LaunchAgents when run on macOS.",
|
|
1311
1571
|
granolaApiKeyCommand: `${command} granola-api-keys`,
|
|
1312
1572
|
granolaApiKeyPath: "Granola desktop app -> Settings -> Connectors -> API keys",
|
|
1313
1573
|
};
|
|
@@ -1327,8 +1587,9 @@ Ask with short interactive prompts, not as one pasted checklist. Do not paste th
|
|
|
1327
1587
|
|
|
1328
1588
|
Start with selection questions to determine intent:
|
|
1329
1589
|
1. Organization: Join existing org, or Create new org.
|
|
1330
|
-
2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages. Allow multi-select if your interface supports it.
|
|
1590
|
+
2. Sources: Google Workspace (Gmail/Drive/Docs/Calendar/Sheets/Slides/Tasks/Contacts), Slack, Granola, Messages, Coding Sessions (Codex/Claude Code summaries). Allow multi-select if your interface supports it.
|
|
1331
1591
|
3. Messages, if selected: Skip Messages, or Provide handle.
|
|
1592
|
+
4. MCP install after onboarding completes: Codex, Claude Code, Cursor, any subset, or none.
|
|
1332
1593
|
|
|
1333
1594
|
When discussing existing orgs, keep it short: Shepherd verifies the join from their Shepherd login and company email domain. The org name they type is not trusted by itself.
|
|
1334
1595
|
|
|
@@ -1352,9 +1613,9 @@ If Messages is selected, run:
|
|
|
1352
1613
|
|
|
1353
1614
|
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:
|
|
1354
1615
|
${payload.messagesPermissions.nodeBinary}
|
|
1355
|
-
Contacts permission may also appear when Shepherd resolves local contact names.
|
|
1616
|
+
Contacts permission may also appear when Shepherd resolves local contact names. The background Messages agent reloads Contacts on startup, watches AddressBook changes when available, and runs fallback contact sync so renamed contacts can hydrate prior ingested Messages rows for the token-bound customer account.
|
|
1356
1617
|
|
|
1357
|
-
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. When the command returns, keep the printed
|
|
1618
|
+
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.
|
|
1358
1619
|
|
|
1359
1620
|
Then run:
|
|
1360
1621
|
${payload.startCommand}
|
|
@@ -1365,6 +1626,9 @@ Add skip flags for sources the user did not select:
|
|
|
1365
1626
|
- --no-granola
|
|
1366
1627
|
- --no-messages
|
|
1367
1628
|
|
|
1629
|
+
Or pass an exact source list, especially for adding sources later:
|
|
1630
|
+
${command} agent --add-sources coding-sessions --name "<full_name>" --org "<organization>"
|
|
1631
|
+
|
|
1368
1632
|
That command creates/reuses the customer user and org, saves local state, and opens at most one source setup surface. It works one modality at a time after account setup: Google Workspace, then Slack, then Granola. If Messages details are still missing, it prints the Messages selector command instead of opening another auth surface. Do not manually open later source setup surfaces until the command tells you that source is the current modality.
|
|
1369
1633
|
|
|
1370
1634
|
If Google Workspace is the current modality, the setup command opens the Admin Console domain-wide delegation page. Show this setup to the user and have their Google Workspace super admin authorize it:
|
|
@@ -1392,13 +1656,23 @@ If Granola is the current modality and did not come forward, run:
|
|
|
1392
1656
|
That command opens Granola and tries to navigate to Settings -> Connectors -> API keys. If your tool cannot click inside Granola, leave Granola open and ask the user to go to that screen.
|
|
1393
1657
|
|
|
1394
1658
|
After the current modality is complete, run:
|
|
1395
|
-
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<
|
|
1659
|
+
${payload.continueCommand} --messages-handle "<phone_or_apple_id>" --messages-chat-ids "<comma_separated_chat_ids_or_all>" --granola-api-key "<granola_key>"
|
|
1396
1660
|
|
|
1397
1661
|
Omit either optional flag if that source is not being connected.
|
|
1398
1662
|
|
|
1663
|
+
If Coding Sessions was selected, the continue step installs local Codex/Claude Code session summary sync. It probes ~/.codex and ~/.claude, redacts sensitive strings, and uploads bounded summaries and work metadata rather than full transcripts. It usually does not need Full Disk Access unless macOS denies access to those folders.
|
|
1664
|
+
|
|
1399
1665
|
Check progress with:
|
|
1400
1666
|
${payload.statusCommand}
|
|
1401
1667
|
|
|
1668
|
+
Check local coding-session sync with:
|
|
1669
|
+
${payload.codingSessions.statusCommand}
|
|
1670
|
+
|
|
1671
|
+
After onboarding completes, ask whether to install Shepherd MCP for the signed-in customer into Codex, Claude Code, Cursor, any subset, or none.
|
|
1672
|
+
If they choose any targets, run:
|
|
1673
|
+
${payload.mcpLoginCommand} --install "<codex,claude,cursor>"
|
|
1674
|
+
Use only the selected target names. If they choose none, skip MCP install for now; they can run the same command later.
|
|
1675
|
+
|
|
1402
1676
|
Do not ask for Railway, Postgres, Redis, service names, or internal credentials.
|
|
1403
1677
|
Do not trigger local wiki generation, daily/weekly memory compilation, or doc summaries.
|
|
1404
1678
|
This flow links sources, starts raw polling/backfill, and lets the customer-facing production brain services run downstream processing separately.
|
|
@@ -1410,12 +1684,74 @@ function hasIdentityArgs() {
|
|
|
1410
1684
|
}
|
|
1411
1685
|
|
|
1412
1686
|
function selectedSources() {
|
|
1413
|
-
|
|
1414
|
-
|
|
1415
|
-
|
|
1416
|
-
|
|
1417
|
-
|
|
1687
|
+
const explicitSources = stringArg("sources") ?? stringArg("add-sources");
|
|
1688
|
+
const selected = explicitSources
|
|
1689
|
+
? sourceSelectionFromList(explicitSources)
|
|
1690
|
+
: {
|
|
1691
|
+
google: !args["no-google"],
|
|
1692
|
+
slack: !args["no-slack"],
|
|
1693
|
+
granola: !args["no-granola"],
|
|
1694
|
+
messages: !args["no-messages"],
|
|
1695
|
+
codingSessions: args["coding-sessions"] === true || args["coding-sessions"] === "true",
|
|
1696
|
+
};
|
|
1697
|
+
|
|
1698
|
+
if (args["no-google"]) selected.google = false;
|
|
1699
|
+
if (args["no-slack"]) selected.slack = false;
|
|
1700
|
+
if (args["no-granola"]) selected.granola = false;
|
|
1701
|
+
if (args["no-messages"]) selected.messages = false;
|
|
1702
|
+
if (args["no-coding-sessions"]) selected.codingSessions = false;
|
|
1703
|
+
if (args["coding-sessions"]) selected.codingSessions = true;
|
|
1704
|
+
|
|
1705
|
+
return selected;
|
|
1706
|
+
}
|
|
1707
|
+
|
|
1708
|
+
function sourceSelectionFromList(value) {
|
|
1709
|
+
const selected = {
|
|
1710
|
+
google: false,
|
|
1711
|
+
slack: false,
|
|
1712
|
+
granola: false,
|
|
1713
|
+
messages: false,
|
|
1714
|
+
codingSessions: false,
|
|
1418
1715
|
};
|
|
1716
|
+
const aliases = new Map([
|
|
1717
|
+
["google", "google"],
|
|
1718
|
+
["gmail", "google"],
|
|
1719
|
+
["drive", "google"],
|
|
1720
|
+
["docs", "google"],
|
|
1721
|
+
["gdocs", "google"],
|
|
1722
|
+
["calendar", "google"],
|
|
1723
|
+
["slack", "slack"],
|
|
1724
|
+
["granola", "granola"],
|
|
1725
|
+
["messages", "messages"],
|
|
1726
|
+
["imessage", "messages"],
|
|
1727
|
+
["imessages", "messages"],
|
|
1728
|
+
["coding-sessions", "codingSessions"],
|
|
1729
|
+
["coding_sessions", "codingSessions"],
|
|
1730
|
+
["codingsessions", "codingSessions"],
|
|
1731
|
+
["sessions", "codingSessions"],
|
|
1732
|
+
["codex", "codingSessions"],
|
|
1733
|
+
["claude", "codingSessions"],
|
|
1734
|
+
["claude-code", "codingSessions"],
|
|
1735
|
+
]);
|
|
1736
|
+
const raw = String(value ?? "").trim().toLowerCase();
|
|
1737
|
+
if (!raw) throw new Error("--sources requires at least one source.");
|
|
1738
|
+
const parts = raw.split(/[,\s]+/).filter(Boolean);
|
|
1739
|
+
for (const part of parts) {
|
|
1740
|
+
if (part === "all") {
|
|
1741
|
+
selected.google = true;
|
|
1742
|
+
selected.slack = true;
|
|
1743
|
+
selected.granola = true;
|
|
1744
|
+
selected.messages = true;
|
|
1745
|
+
selected.codingSessions = true;
|
|
1746
|
+
continue;
|
|
1747
|
+
}
|
|
1748
|
+
const source = aliases.get(part);
|
|
1749
|
+
if (!source) {
|
|
1750
|
+
throw new Error(`Unknown source "${part}". Use google, slack, granola, messages, coding-sessions, or all.`);
|
|
1751
|
+
}
|
|
1752
|
+
selected[source] = true;
|
|
1753
|
+
}
|
|
1754
|
+
return selected;
|
|
1419
1755
|
}
|
|
1420
1756
|
|
|
1421
1757
|
async function writeAgentState(state) {
|
|
@@ -1596,6 +1932,16 @@ async function openNextAgentModality({ sources, authUrls = {}, noOpen = false, p
|
|
|
1596
1932
|
message: "Run the local Messages chat selector and keep the printed chat IDs.",
|
|
1597
1933
|
};
|
|
1598
1934
|
}
|
|
1935
|
+
|
|
1936
|
+
if (source === "codingSessions") {
|
|
1937
|
+
return {
|
|
1938
|
+
source,
|
|
1939
|
+
label: "Coding Sessions",
|
|
1940
|
+
opened: false,
|
|
1941
|
+
command: `${agentCommand()} agent --continue`,
|
|
1942
|
+
message: "Continue onboarding to install local Codex and Claude Code session sync.",
|
|
1943
|
+
};
|
|
1944
|
+
}
|
|
1599
1945
|
}
|
|
1600
1946
|
|
|
1601
1947
|
return null;
|
|
@@ -1643,6 +1989,12 @@ function printAgentCurrentAction(action, opts = {}) {
|
|
|
1643
1989
|
console.log(`Run: ${action.command}`);
|
|
1644
1990
|
console.log("Have the user select specific local Messages chats; do not select all by default.");
|
|
1645
1991
|
console.log("Ask the user to grant or confirm macOS Full Disk Access for the app running onboarding and Node.js before installing background Messages sync.");
|
|
1992
|
+
return;
|
|
1993
|
+
}
|
|
1994
|
+
|
|
1995
|
+
if (action.source === "codingSessions") {
|
|
1996
|
+
console.log(`Run: ${action.command}`);
|
|
1997
|
+
console.log("This installs a local LaunchAgent that summarizes Codex and Claude Code session logs and syncs those summaries to Shepherd.");
|
|
1646
1998
|
}
|
|
1647
1999
|
}
|
|
1648
2000
|
|
|
@@ -1652,6 +2004,7 @@ function agentNeedsUserAction(sources, action) {
|
|
|
1652
2004
|
if (action.source === "slack") return ["Complete Slack browser authorization."];
|
|
1653
2005
|
if (action.source === "granola") return ["Create/copy a Granola API key from the Granola Mac app."];
|
|
1654
2006
|
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."];
|
|
2007
|
+
if (action.source === "codingSessions") return ["Run the continue command to install local Codex and Claude Code session summary sync."];
|
|
1655
2008
|
return [];
|
|
1656
2009
|
}
|
|
1657
2010
|
|
|
@@ -1806,7 +2159,7 @@ async function explainMessagesBackgroundPermissions(opts = {}) {
|
|
|
1806
2159
|
console.log("\nMessages background sync permissions");
|
|
1807
2160
|
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.");
|
|
1808
2161
|
printMessagesPermissionTargets();
|
|
1809
|
-
console.log("Contacts permission may also appear when Shepherd resolves local contact names for
|
|
2162
|
+
console.log("Contacts permission may also appear when Shepherd resolves local contact names. The background agent keeps contact names hydrated for observed Messages conversations.");
|
|
1810
2163
|
await openFullDiskAccessSettings(opts);
|
|
1811
2164
|
|
|
1812
2165
|
if (opts.waitForUser && process.stdin.isTTY && !args["no-permission-prompt"]) {
|
|
@@ -1918,8 +2271,9 @@ async function writeMessagesConfig(input) {
|
|
|
1918
2271
|
await mkdir(dir, { recursive: true });
|
|
1919
2272
|
const path = join(dir, `${input.userId}.json`);
|
|
1920
2273
|
const allowedChatIds = parseAllowedChatIds(input.allowedChatIds);
|
|
1921
|
-
|
|
1922
|
-
|
|
2274
|
+
const allChats = selectedChatIdsIncludeAll(allowedChatIds);
|
|
2275
|
+
if (!allChats && allowedChatIds.length === 0) {
|
|
2276
|
+
throw new Error("Select at least one Messages chat or pass --messages-chat-ids all before installing local Messages sync.");
|
|
1923
2277
|
}
|
|
1924
2278
|
await writeFile(
|
|
1925
2279
|
path,
|
|
@@ -1928,8 +2282,10 @@ async function writeMessagesConfig(input) {
|
|
|
1928
2282
|
userId: input.userId,
|
|
1929
2283
|
agentToken: input.agentToken,
|
|
1930
2284
|
backfillDays: input.backfillDays,
|
|
1931
|
-
|
|
2285
|
+
allChats,
|
|
2286
|
+
allowedChatIds: allChats ? [] : allowedChatIds,
|
|
1932
2287
|
selectedChats: Array.isArray(input.selectedChats) ? input.selectedChats.map(publicMessageChat) : [],
|
|
2288
|
+
excludedMessageHandles: SHEPHERD_OWNED_MESSAGE_HANDLES,
|
|
1933
2289
|
createdAt: new Date().toISOString(),
|
|
1934
2290
|
}, null, 2),
|
|
1935
2291
|
{ mode: 0o600 },
|
|
@@ -2014,6 +2370,110 @@ async function installMessagesAgent(configPath, userId) {
|
|
|
2014
2370
|
return { label, plistPath, stdoutPath, stderrPath };
|
|
2015
2371
|
}
|
|
2016
2372
|
|
|
2373
|
+
async function writeCodingSessionsConfig(input) {
|
|
2374
|
+
const dir = join(homedir(), ".shepherd", "coding-sessions");
|
|
2375
|
+
await mkdir(dir, { recursive: true });
|
|
2376
|
+
const path = join(dir, `${input.userId}.json`);
|
|
2377
|
+
await writeFile(
|
|
2378
|
+
path,
|
|
2379
|
+
JSON.stringify({
|
|
2380
|
+
apiUrl: input.apiUrl,
|
|
2381
|
+
userId: input.userId,
|
|
2382
|
+
agentToken: input.agentToken,
|
|
2383
|
+
intervalSeconds: Math.max(15, Math.floor(Number(input.intervalSeconds) || 60)),
|
|
2384
|
+
codexDirs: [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR],
|
|
2385
|
+
claudeProjectsDir: CLAUDE_PROJECTS_DIR,
|
|
2386
|
+
maxFilesPerProvider: 300,
|
|
2387
|
+
createdAt: new Date().toISOString(),
|
|
2388
|
+
}, null, 2),
|
|
2389
|
+
{ mode: 0o600 },
|
|
2390
|
+
);
|
|
2391
|
+
return path;
|
|
2392
|
+
}
|
|
2393
|
+
|
|
2394
|
+
async function installCodingSessionsAgent(configPath, userId) {
|
|
2395
|
+
if (platform() !== "darwin") {
|
|
2396
|
+
throw new Error("automatic local coding-session sync is only supported on macOS");
|
|
2397
|
+
}
|
|
2398
|
+
|
|
2399
|
+
const safeId = safeFileId(userId);
|
|
2400
|
+
const label = `ai.shepherd.coding-sessions.${safeId}`;
|
|
2401
|
+
const rawDir = join(homedir(), ".shepherd", "coding-sessions");
|
|
2402
|
+
const agentsDir = join(homedir(), "Library", "LaunchAgents");
|
|
2403
|
+
await mkdir(rawDir, { recursive: true });
|
|
2404
|
+
await mkdir(agentsDir, { recursive: true });
|
|
2405
|
+
|
|
2406
|
+
const plistPath = join(agentsDir, `${label}.plist`);
|
|
2407
|
+
const stdoutPath = join(rawDir, `${safeId}.out.log`);
|
|
2408
|
+
const stderrPath = join(rawDir, `${safeId}.err.log`);
|
|
2409
|
+
const launchPath = launchAgentPath();
|
|
2410
|
+
|
|
2411
|
+
const plist = `<?xml version="1.0" encoding="UTF-8"?>
|
|
2412
|
+
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
|
2413
|
+
<plist version="1.0">
|
|
2414
|
+
<dict>
|
|
2415
|
+
<key>Label</key>
|
|
2416
|
+
<string>${xmlEscape(label)}</string>
|
|
2417
|
+
<key>ProgramArguments</key>
|
|
2418
|
+
<array>
|
|
2419
|
+
<string>/usr/bin/env</string>
|
|
2420
|
+
<string>npx</string>
|
|
2421
|
+
<string>-y</string>
|
|
2422
|
+
<string>${PACKAGE_SPEC}</string>
|
|
2423
|
+
<string>coding-sessions-agent</string>
|
|
2424
|
+
<string>--config</string>
|
|
2425
|
+
<string>${xmlEscape(configPath)}</string>
|
|
2426
|
+
</array>
|
|
2427
|
+
<key>KeepAlive</key>
|
|
2428
|
+
<true/>
|
|
2429
|
+
<key>RunAtLoad</key>
|
|
2430
|
+
<true/>
|
|
2431
|
+
<key>StandardOutPath</key>
|
|
2432
|
+
<string>${xmlEscape(stdoutPath)}</string>
|
|
2433
|
+
<key>StandardErrorPath</key>
|
|
2434
|
+
<string>${xmlEscape(stderrPath)}</string>
|
|
2435
|
+
<key>EnvironmentVariables</key>
|
|
2436
|
+
<dict>
|
|
2437
|
+
<key>PATH</key>
|
|
2438
|
+
<string>${xmlEscape(launchPath)}</string>
|
|
2439
|
+
</dict>
|
|
2440
|
+
</dict>
|
|
2441
|
+
</plist>
|
|
2442
|
+
`;
|
|
2443
|
+
|
|
2444
|
+
await writeFile(plistPath, plist, { mode: 0o600 });
|
|
2445
|
+
const stdoutOffset = await fileLength(stdoutPath);
|
|
2446
|
+
const stderrOffset = await fileLength(stderrPath);
|
|
2447
|
+
await execFileQuiet("launchctl", ["unload", plistPath], { ignoreError: true });
|
|
2448
|
+
await execFileQuiet("launchctl", ["load", plistPath]);
|
|
2449
|
+
await execFileQuiet("launchctl", ["start", label], { ignoreError: true });
|
|
2450
|
+
await verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset });
|
|
2451
|
+
return { label, plistPath, stdoutPath, stderrPath };
|
|
2452
|
+
}
|
|
2453
|
+
|
|
2454
|
+
async function verifyCodingSessionsAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
|
|
2455
|
+
const domainLabel = `gui/${process.getuid?.() ?? 501}/${label}`;
|
|
2456
|
+
for (let attempt = 0; attempt < 8; attempt++) {
|
|
2457
|
+
await sleep(1000);
|
|
2458
|
+
const stdout = await readFileFrom(stdoutPath, stdoutOffset);
|
|
2459
|
+
const stderr = await readFileFrom(stderrPath, stderrOffset);
|
|
2460
|
+
const launchState = readLaunchctlPrint(domainLabel);
|
|
2461
|
+
|
|
2462
|
+
if (/Shepherd Coding Sessions sync starting|Coding session scan/i.test(stdout)
|
|
2463
|
+
&& /state = running|job state = running/i.test(launchState)) {
|
|
2464
|
+
return;
|
|
2465
|
+
}
|
|
2466
|
+
if (/EACCES|operation not permitted|permission denied/i.test(stderr)) {
|
|
2467
|
+
throw new Error("Coding-session sync could not read ~/.codex or ~/.claude. Grant the onboarding app access to those folders or run coding-sessions-status for details.");
|
|
2468
|
+
}
|
|
2469
|
+
if (/last exit code = [1-9]|job state = exited|state = spawn scheduled/i.test(launchState) && stderr.trim()) {
|
|
2470
|
+
throw new Error(`Coding-session sync exited during startup: ${firstMeaningfulLine(stderr)}`);
|
|
2471
|
+
}
|
|
2472
|
+
}
|
|
2473
|
+
|
|
2474
|
+
throw new Error("Coding-session sync did not reach a healthy launchd running state. Check logs under ~/.shepherd/coding-sessions and run coding-sessions-status.");
|
|
2475
|
+
}
|
|
2476
|
+
|
|
2017
2477
|
async function verifyMessagesAgentLaunch({ label, stdoutPath, stderrPath, stdoutOffset, stderrOffset }) {
|
|
2018
2478
|
const domainLabel = `gui/${process.getuid?.() ?? 501}/${label}`;
|
|
2019
2479
|
for (let attempt = 0; attempt < 12; attempt++) {
|
|
@@ -2099,7 +2559,7 @@ async function selectRecentMessageChats() {
|
|
|
2099
2559
|
}
|
|
2100
2560
|
|
|
2101
2561
|
if (!process.stdin.isTTY) {
|
|
2102
|
-
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>".`);
|
|
2562
|
+
throw new Error(`Messages sync requires selected chat IDs. Run ${agentCommand()} messages-chats and pass --messages-chat-ids "<id1>,<id2>" or --messages-chat-ids all.`);
|
|
2103
2563
|
}
|
|
2104
2564
|
|
|
2105
2565
|
const chats = await listRecentMessageChats({ limit: clampInt(Number(args.limit ?? DEFAULT_MESSAGE_CHAT_SEARCH_LIMIT), 1, 500) });
|
|
@@ -2113,11 +2573,13 @@ async function selectRecentMessageChats() {
|
|
|
2113
2573
|
|
|
2114
2574
|
console.log(`\nSelect local Messages chats to sync\n`);
|
|
2115
2575
|
console.log("Shepherd will only pull from the chats you select.");
|
|
2576
|
+
console.log("Enter all to sync every current chat and keep watching future new chats.");
|
|
2116
2577
|
for (let i = 0; i < chats.length; i++) {
|
|
2117
2578
|
console.log(`${String(i + 1).padStart(2, " ")}. ${formatMessageChatOption(chats[i])}`);
|
|
2118
2579
|
}
|
|
2119
2580
|
|
|
2120
|
-
const answer = await prompt("\nEnter chat numbers to sync, separated by commas: ");
|
|
2581
|
+
const answer = await prompt("\nEnter chat numbers to sync, separated by commas, or all: ");
|
|
2582
|
+
if (String(answer ?? "").trim().toLowerCase() === "all") return [allMessagesChatsSelection()];
|
|
2121
2583
|
const indexes = parseSelectionIndexes(answer, chats.length);
|
|
2122
2584
|
if (indexes.length === 0) throw new Error("Select at least one Messages chat.");
|
|
2123
2585
|
return indexes.map((idx) => chats[idx]);
|
|
@@ -2174,6 +2636,16 @@ async function selectChatsInBrowser(chats, opts = {}) {
|
|
|
2174
2636
|
sendHtml(res, renderMessagesDonePage("Invalid selection session.", true), 403);
|
|
2175
2637
|
return;
|
|
2176
2638
|
}
|
|
2639
|
+
if (form.get("allChats") === "1") {
|
|
2640
|
+
if (!settled) res.once("finish", finishBrowserSelection);
|
|
2641
|
+
sendHtml(res, renderMessagesDonePage("All current and future chats selected."));
|
|
2642
|
+
if (!settled) {
|
|
2643
|
+
settled = true;
|
|
2644
|
+
clearTimeout(timeout);
|
|
2645
|
+
resolve([allMessagesChatsSelection()]);
|
|
2646
|
+
}
|
|
2647
|
+
return;
|
|
2648
|
+
}
|
|
2177
2649
|
const selectedIds = form.getAll("chatId").filter(Boolean);
|
|
2178
2650
|
const selectedSet = new Set(selectedIds);
|
|
2179
2651
|
const selected = chats.filter((chat) => selectedSet.has(chat.chatId));
|
|
@@ -2363,6 +2835,31 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2363
2835
|
outline-offset: 1px;
|
|
2364
2836
|
}
|
|
2365
2837
|
.search::placeholder { color: var(--faint); }
|
|
2838
|
+
.all-chats {
|
|
2839
|
+
display: grid;
|
|
2840
|
+
grid-template-columns: 20px minmax(0, 1fr);
|
|
2841
|
+
gap: 12px;
|
|
2842
|
+
align-items: start;
|
|
2843
|
+
margin-top: 12px;
|
|
2844
|
+
padding: 12px;
|
|
2845
|
+
border: 1px solid var(--line);
|
|
2846
|
+
border-radius: 8px;
|
|
2847
|
+
cursor: pointer;
|
|
2848
|
+
background: #fbfbfa;
|
|
2849
|
+
}
|
|
2850
|
+
.all-chats:hover { border-color: var(--green); }
|
|
2851
|
+
.all-title {
|
|
2852
|
+
display: block;
|
|
2853
|
+
font-size: 14px;
|
|
2854
|
+
font-weight: 600;
|
|
2855
|
+
}
|
|
2856
|
+
.all-copy {
|
|
2857
|
+
display: block;
|
|
2858
|
+
margin-top: 2px;
|
|
2859
|
+
color: var(--muted);
|
|
2860
|
+
font-size: 12.5px;
|
|
2861
|
+
line-height: 1.35;
|
|
2862
|
+
}
|
|
2366
2863
|
.error {
|
|
2367
2864
|
margin: 0 0 12px;
|
|
2368
2865
|
color: #9B1C1C;
|
|
@@ -2515,6 +3012,14 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2515
3012
|
<div class="panel">
|
|
2516
3013
|
<div class="panel-head">
|
|
2517
3014
|
<input class="search" id="search" type="search" placeholder="Search contacts or groups" autocomplete="off">
|
|
3015
|
+
<label class="all-chats">
|
|
3016
|
+
<input type="checkbox" id="all-chats" name="allChats" value="1">
|
|
3017
|
+
<span class="box" aria-hidden="true"></span>
|
|
3018
|
+
<span>
|
|
3019
|
+
<span class="all-title">Sync all current and future chats</span>
|
|
3020
|
+
<span class="all-copy">Backfill every current Messages chat and keep watching chats that appear later.</span>
|
|
3021
|
+
</span>
|
|
3022
|
+
</label>
|
|
2518
3023
|
</div>
|
|
2519
3024
|
${error ? `<p class="error">${html(error)}</p>` : ""}
|
|
2520
3025
|
<div class="list-head">
|
|
@@ -2540,6 +3045,7 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2540
3045
|
const selected = document.getElementById("selection-count");
|
|
2541
3046
|
const form = document.querySelector("form");
|
|
2542
3047
|
const checks = Array.from(document.querySelectorAll('input[name="chatId"]'));
|
|
3048
|
+
const allChats = document.getElementById("all-chats");
|
|
2543
3049
|
|
|
2544
3050
|
function updateRows() {
|
|
2545
3051
|
const query = search.value.trim().toLowerCase();
|
|
@@ -2555,6 +3061,12 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2555
3061
|
}
|
|
2556
3062
|
|
|
2557
3063
|
function updateSelected() {
|
|
3064
|
+
if (allChats.checked) {
|
|
3065
|
+
selected.textContent = "All current and future chats";
|
|
3066
|
+
for (const check of checks) check.disabled = true;
|
|
3067
|
+
return;
|
|
3068
|
+
}
|
|
3069
|
+
for (const check of checks) check.disabled = false;
|
|
2558
3070
|
const count = checks.filter((check) => check.checked).length;
|
|
2559
3071
|
selected.textContent = count + " selected";
|
|
2560
3072
|
}
|
|
@@ -2565,6 +3077,7 @@ function renderMessagesSelectorPage(chats, token, error = "") {
|
|
|
2565
3077
|
event.preventDefault();
|
|
2566
3078
|
form.requestSubmit();
|
|
2567
3079
|
});
|
|
3080
|
+
allChats.addEventListener("change", updateSelected);
|
|
2568
3081
|
for (const check of checks) check.addEventListener("change", updateSelected);
|
|
2569
3082
|
updateRows();
|
|
2570
3083
|
updateSelected();
|
|
@@ -2735,7 +3248,8 @@ async function listRecentMessageChats({ limit }) {
|
|
|
2735
3248
|
|
|
2736
3249
|
const enriched = [];
|
|
2737
3250
|
for (const chat of visible) {
|
|
2738
|
-
|
|
3251
|
+
const candidate = await enrichMessageChat(sdk, chat, contactLookup);
|
|
3252
|
+
if (!messageChatTouchesShepherdAgent(candidate)) enriched.push(candidate);
|
|
2739
3253
|
}
|
|
2740
3254
|
return enriched;
|
|
2741
3255
|
} finally {
|
|
@@ -2743,6 +3257,41 @@ async function listRecentMessageChats({ limit }) {
|
|
|
2743
3257
|
}
|
|
2744
3258
|
}
|
|
2745
3259
|
|
|
3260
|
+
async function loadSelectedMessageChatsForConfig(chatIds) {
|
|
3261
|
+
const allowedChatIds = parseAllowedChatIds(chatIds);
|
|
3262
|
+
if (allowedChatIds.length === 0 || selectedChatIdsIncludeAll(allowedChatIds)) return [];
|
|
3263
|
+
if (platform() !== "darwin") return [];
|
|
3264
|
+
|
|
3265
|
+
const kit = await import("@photon-ai/imessage-kit");
|
|
3266
|
+
const sdk = new kit.IMessageSDK({ debug: args.debug === true });
|
|
3267
|
+
const contactLookup = buildContactLookup();
|
|
3268
|
+
try {
|
|
3269
|
+
const listedChats = typeof sdk.listChats === "function"
|
|
3270
|
+
? await sdk.listChats({ sortBy: "recent", limit: 5_000 }).catch(() => [])
|
|
3271
|
+
: [];
|
|
3272
|
+
const byId = new Map(listedChats
|
|
3273
|
+
.filter((chat) => typeof chat?.chatId === "string")
|
|
3274
|
+
.map((chat) => [chat.chatId, chat]));
|
|
3275
|
+
|
|
3276
|
+
const selected = [];
|
|
3277
|
+
for (const chatId of allowedChatIds) {
|
|
3278
|
+
const listed = byId.get(chatId);
|
|
3279
|
+
const fallback = {
|
|
3280
|
+
chatId,
|
|
3281
|
+
kind: parseDmHandleFromChatId(chatId) ? "dm" : "group",
|
|
3282
|
+
name: null,
|
|
3283
|
+
service: null,
|
|
3284
|
+
lastMessageAt: null,
|
|
3285
|
+
};
|
|
3286
|
+
const candidate = await enrichMessageChat(sdk, { ...fallback, ...listed, chatId }, contactLookup);
|
|
3287
|
+
if (!messageChatTouchesShepherdAgent(candidate)) selected.push(candidate);
|
|
3288
|
+
}
|
|
3289
|
+
return selected;
|
|
3290
|
+
} finally {
|
|
3291
|
+
await sdk.close?.().catch(() => undefined);
|
|
3292
|
+
}
|
|
3293
|
+
}
|
|
3294
|
+
|
|
2746
3295
|
async function enrichMessageChat(sdk, chat, contactLookup) {
|
|
2747
3296
|
const recentMessages = await sdk.getMessages({ chatId: chat.chatId, limit: 30 }).catch(() => []);
|
|
2748
3297
|
const participants = uniqueParticipants(recentMessages, contactLookup);
|
|
@@ -2773,7 +3322,7 @@ function uniqueParticipants(messages, contactLookup) {
|
|
|
2773
3322
|
const participants = [];
|
|
2774
3323
|
for (const msg of messages) {
|
|
2775
3324
|
const handle = typeof msg.participant === "string" ? msg.participant.trim() : "";
|
|
2776
|
-
if (!handle || contactLookup.isSelfHandle(handle)) continue;
|
|
3325
|
+
if (!handle || contactLookup.isSelfHandle(handle) || isShepherdAgentMessageHandle(handle)) continue;
|
|
2777
3326
|
const normalized = normalizeHandle(handle);
|
|
2778
3327
|
if (seen.has(normalized)) continue;
|
|
2779
3328
|
seen.add(normalized);
|
|
@@ -2803,6 +3352,21 @@ function publicMessageChat(chat) {
|
|
|
2803
3352
|
};
|
|
2804
3353
|
}
|
|
2805
3354
|
|
|
3355
|
+
function allMessagesChatsSelection() {
|
|
3356
|
+
return {
|
|
3357
|
+
chatId: ALL_MESSAGES_CHATS,
|
|
3358
|
+
label: "All current and future chats",
|
|
3359
|
+
kind: "all",
|
|
3360
|
+
service: null,
|
|
3361
|
+
lastMessageAt: null,
|
|
3362
|
+
participants: [],
|
|
3363
|
+
};
|
|
3364
|
+
}
|
|
3365
|
+
|
|
3366
|
+
function selectedIncludesAllChats(chats) {
|
|
3367
|
+
return Array.isArray(chats) && chats.some((chat) => chat?.chatId === ALL_MESSAGES_CHATS);
|
|
3368
|
+
}
|
|
3369
|
+
|
|
2806
3370
|
async function loadGroupChatNames(sdk, serializer) {
|
|
2807
3371
|
if (typeof sdk.listChats !== "function") return;
|
|
2808
3372
|
try {
|
|
@@ -2825,21 +3389,67 @@ function loadSelectedChatNames(selectedChats, serializer) {
|
|
|
2825
3389
|
}
|
|
2826
3390
|
}
|
|
2827
3391
|
|
|
2828
|
-
|
|
2829
|
-
|
|
3392
|
+
function selectedChatContactSeedHandles(selectedChats, allowedChatIds) {
|
|
3393
|
+
const handles = [];
|
|
3394
|
+
for (const chatId of allowedChatIds ?? []) {
|
|
3395
|
+
const dmHandle = parseDmHandleFromChatId(chatId);
|
|
3396
|
+
if (dmHandle && !isShepherdAgentMessageHandle(dmHandle)) handles.push(dmHandle);
|
|
3397
|
+
}
|
|
3398
|
+
for (const chat of Array.isArray(selectedChats) ? selectedChats : []) {
|
|
3399
|
+
for (const participant of Array.isArray(chat?.participants) ? chat.participants : []) {
|
|
3400
|
+
if (
|
|
3401
|
+
typeof participant?.handle === "string"
|
|
3402
|
+
&& participant.handle.trim()
|
|
3403
|
+
&& !isShepherdAgentMessageHandle(participant.handle)
|
|
3404
|
+
) {
|
|
3405
|
+
handles.push(participant.handle);
|
|
3406
|
+
}
|
|
3407
|
+
}
|
|
3408
|
+
}
|
|
3409
|
+
return [...new Set(handles)];
|
|
3410
|
+
}
|
|
3411
|
+
|
|
3412
|
+
async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds, contactSync = null) {
|
|
3413
|
+
const allChats = allowedChatIds == null;
|
|
3414
|
+
console.log(allChats
|
|
3415
|
+
? `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for all current chats`
|
|
3416
|
+
: `Running ${days == null ? "all-history" : `${days}-day`} Messages backfill for ${allowedChatIds.length} selected chat(s)`);
|
|
2830
3417
|
const since = days == null ? null : new Date(Date.now() - days * 24 * 60 * 60 * 1000);
|
|
2831
3418
|
const pageSize = 1000;
|
|
2832
3419
|
let totalMessages = 0;
|
|
2833
3420
|
let totalStored = 0;
|
|
2834
3421
|
|
|
3422
|
+
if (allChats) {
|
|
3423
|
+
let offset = 0;
|
|
3424
|
+
while (true) {
|
|
3425
|
+
const messages = await sdk.getMessages({ ...(since ? { since } : {}), limit: pageSize, offset });
|
|
3426
|
+
if (!messages.length) break;
|
|
3427
|
+
const filtered = messages.filter((msg) => !messageTouchesShepherdAgent(msg));
|
|
3428
|
+
|
|
3429
|
+
contactSync?.observeMessages(filtered);
|
|
3430
|
+
totalMessages += filtered.length;
|
|
3431
|
+
const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
|
|
3432
|
+
totalStored += result.stored;
|
|
3433
|
+
saveMessagesWatermark(sender.userId, maxRowId(messages));
|
|
3434
|
+
|
|
3435
|
+
if (messages.length < pageSize) break;
|
|
3436
|
+
offset += pageSize;
|
|
3437
|
+
}
|
|
3438
|
+
|
|
3439
|
+
console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
|
|
3440
|
+
return;
|
|
3441
|
+
}
|
|
3442
|
+
|
|
2835
3443
|
for (const chatId of allowedChatIds) {
|
|
2836
3444
|
let offset = 0;
|
|
2837
3445
|
while (true) {
|
|
2838
3446
|
const messages = await sdk.getMessages({ chatId, ...(since ? { since } : {}), limit: pageSize, offset });
|
|
2839
3447
|
if (!messages.length) break;
|
|
3448
|
+
const filtered = messages.filter((msg) => !messageTouchesShepherdAgent(msg));
|
|
2840
3449
|
|
|
2841
|
-
|
|
2842
|
-
|
|
3450
|
+
contactSync?.observeMessages(filtered);
|
|
3451
|
+
totalMessages += filtered.length;
|
|
3452
|
+
const result = await sender.send(filtered.map((msg) => serializer.serialize(msg)));
|
|
2843
3453
|
totalStored += result.stored;
|
|
2844
3454
|
saveMessagesWatermark(sender.userId, maxRowId(messages));
|
|
2845
3455
|
|
|
@@ -2851,24 +3461,35 @@ async function runMessagesBackfill(sdk, sender, serializer, days, allowedChatIds
|
|
|
2851
3461
|
console.log(`Messages backfill complete: stored ${totalStored} of ${totalMessages}`);
|
|
2852
3462
|
}
|
|
2853
3463
|
|
|
2854
|
-
async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds) {
|
|
3464
|
+
async function gapFillFromWatermark(sdk, sender, serializer, userId, allowedChatIds, contactSync = null) {
|
|
3465
|
+
const allChats = allowedChatIds == null;
|
|
2855
3466
|
const lastWatermark = loadMessagesWatermark(userId);
|
|
2856
3467
|
if (lastWatermark <= 0) return;
|
|
2857
3468
|
|
|
2858
3469
|
const missed = [];
|
|
2859
|
-
|
|
2860
|
-
missed.push(...await sdk.getMessages({
|
|
3470
|
+
if (allChats) {
|
|
3471
|
+
missed.push(...await sdk.getMessages({ limit: 5000 }));
|
|
3472
|
+
} else {
|
|
3473
|
+
for (const chatId of allowedChatIds) {
|
|
3474
|
+
missed.push(...await sdk.getMessages({ chatId, limit: 1000 }));
|
|
3475
|
+
}
|
|
2861
3476
|
}
|
|
2862
|
-
const newMessages = missed.filter((msg) =>
|
|
3477
|
+
const newMessages = missed.filter((msg) =>
|
|
3478
|
+
Number(msg.rowId) > lastWatermark
|
|
3479
|
+
&& (allChats || allowedChatIds.includes(msg.chatId))
|
|
3480
|
+
&& !messageTouchesShepherdAgent(msg));
|
|
2863
3481
|
if (newMessages.length === 0) return;
|
|
2864
3482
|
|
|
3483
|
+
contactSync?.observeMessages(newMessages);
|
|
2865
3484
|
const result = await sender.send(newMessages.map((msg) => serializer.serialize(msg)));
|
|
2866
3485
|
if (result.stored > 0) saveMessagesWatermark(userId, maxRowId(newMessages));
|
|
2867
3486
|
console.log(`Messages gap-fill complete: stored ${result.stored} of ${newMessages.length}`);
|
|
2868
3487
|
}
|
|
2869
3488
|
|
|
2870
|
-
async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
2871
|
-
const
|
|
3489
|
+
async function watchMessages(sdk, sender, serializer, userId, allowedChatIds, opts = {}) {
|
|
3490
|
+
const allChats = allowedChatIds == null;
|
|
3491
|
+
const allowed = new Set(allowedChatIds ?? []);
|
|
3492
|
+
const contactSync = opts.contactSync ?? null;
|
|
2872
3493
|
let buffer = [];
|
|
2873
3494
|
let timer = null;
|
|
2874
3495
|
|
|
@@ -2888,7 +3509,9 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2888
3509
|
};
|
|
2889
3510
|
|
|
2890
3511
|
const onMessage = (msg) => {
|
|
2891
|
-
if (!msg.chatId || !allowed.has(msg.chatId)) return;
|
|
3512
|
+
if (!msg.chatId || (!allChats && !allowed.has(msg.chatId))) return;
|
|
3513
|
+
if (messageTouchesShepherdAgent(msg)) return;
|
|
3514
|
+
contactSync?.observeMessages([msg]);
|
|
2892
3515
|
buffer.push(msg);
|
|
2893
3516
|
if (buffer.length >= MAX_BATCH_SIZE) {
|
|
2894
3517
|
if (timer) clearTimeout(timer);
|
|
@@ -2905,7 +3528,9 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2905
3528
|
onError: (err) => console.error("Messages watcher error:", safeError(err)),
|
|
2906
3529
|
});
|
|
2907
3530
|
|
|
2908
|
-
console.log(
|
|
3531
|
+
console.log(allChats
|
|
3532
|
+
? "Watching for new Messages in all current and future chats"
|
|
3533
|
+
: "Watching for new Messages in selected chats");
|
|
2909
3534
|
|
|
2910
3535
|
await new Promise((resolve) => {
|
|
2911
3536
|
let stopping = false;
|
|
@@ -2913,6 +3538,7 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2913
3538
|
if (stopping) return;
|
|
2914
3539
|
stopping = true;
|
|
2915
3540
|
if (timer) clearTimeout(timer);
|
|
3541
|
+
contactSync?.stop();
|
|
2916
3542
|
await flush().catch(() => undefined);
|
|
2917
3543
|
await sdk.close?.().catch(() => undefined);
|
|
2918
3544
|
resolve();
|
|
@@ -2923,9 +3549,164 @@ async function watchMessages(sdk, sender, serializer, userId, allowedChatIds) {
|
|
|
2923
3549
|
});
|
|
2924
3550
|
}
|
|
2925
3551
|
|
|
2926
|
-
function
|
|
2927
|
-
|
|
2928
|
-
|
|
3552
|
+
function createMutableContactLookup(initial = emptyContactLookup()) {
|
|
3553
|
+
let current = initial;
|
|
3554
|
+
return {
|
|
3555
|
+
replace(next) {
|
|
3556
|
+
current = next ?? emptyContactLookup();
|
|
3557
|
+
},
|
|
3558
|
+
resolveName(handle) {
|
|
3559
|
+
return current.resolveName(handle);
|
|
3560
|
+
},
|
|
3561
|
+
isSelfHandle(handle) {
|
|
3562
|
+
return current.isSelfHandle(handle);
|
|
3563
|
+
},
|
|
3564
|
+
mappings() {
|
|
3565
|
+
return contactMappingsFromLookup(current);
|
|
3566
|
+
},
|
|
3567
|
+
};
|
|
3568
|
+
}
|
|
3569
|
+
|
|
3570
|
+
function startMessagesContactSync(sender, contactLookup, opts = {}) {
|
|
3571
|
+
const syncAllContacts = opts.syncAllContacts === true;
|
|
3572
|
+
const observedHandles = new Set();
|
|
3573
|
+
for (const handle of opts.seedHandles ?? []) rememberObservedHandle(observedHandles, contactLookup, handle);
|
|
3574
|
+
|
|
3575
|
+
let previousSnapshot = new Map();
|
|
3576
|
+
let syncTimer = null;
|
|
3577
|
+
let contactsWatcher = null;
|
|
3578
|
+
let fallbackInterval = null;
|
|
3579
|
+
let syncing = false;
|
|
3580
|
+
let pendingReason = null;
|
|
3581
|
+
let stopped = false;
|
|
3582
|
+
|
|
3583
|
+
const visibleMappings = () => {
|
|
3584
|
+
const mappings = contactLookup.mappings();
|
|
3585
|
+
if (syncAllContacts) return mappings;
|
|
3586
|
+
return mappings.filter((mapping) => handleCandidates(mapping.handle).some((candidate) => observedHandles.has(candidate)));
|
|
3587
|
+
};
|
|
3588
|
+
|
|
3589
|
+
const syncNow = async ({ forceAll = false, reason = "manual" } = {}) => {
|
|
3590
|
+
if (stopped) return { skipped: true, reason: "stopped" };
|
|
3591
|
+
if (syncing) {
|
|
3592
|
+
pendingReason = reason;
|
|
3593
|
+
return { skipped: true, reason: "already_syncing" };
|
|
3594
|
+
}
|
|
3595
|
+
|
|
3596
|
+
syncing = true;
|
|
3597
|
+
try {
|
|
3598
|
+
const nextLookup = buildContactLookup();
|
|
3599
|
+
contactLookup.replace(nextLookup);
|
|
3600
|
+
const mappings = visibleMappings();
|
|
3601
|
+
const toSync = forceAll
|
|
3602
|
+
? mappings
|
|
3603
|
+
: mappings.filter((mapping) => previousSnapshot.get(mapping.handle) !== mapping.name);
|
|
3604
|
+
|
|
3605
|
+
if (toSync.length === 0) {
|
|
3606
|
+
previousSnapshot = snapshotContactMappings(mappings);
|
|
3607
|
+
return { updated: 0, contactsProcessed: 0 };
|
|
3608
|
+
}
|
|
3609
|
+
|
|
3610
|
+
const result = await sender.syncContacts(toSync);
|
|
3611
|
+
previousSnapshot = snapshotContactMappings(mappings);
|
|
3612
|
+
console.log(`[contacts] Synced ${toSync.length}/${mappings.length} Messages contact mappings (${reason}); updated ${result.updated ?? 0}`);
|
|
3613
|
+
return result;
|
|
3614
|
+
} finally {
|
|
3615
|
+
syncing = false;
|
|
3616
|
+
const queuedReason = pendingReason;
|
|
3617
|
+
pendingReason = null;
|
|
3618
|
+
if (queuedReason && !stopped) scheduleSync(queuedReason);
|
|
3619
|
+
}
|
|
3620
|
+
};
|
|
3621
|
+
|
|
3622
|
+
const scheduleSync = (reason) => {
|
|
3623
|
+
if (stopped) return;
|
|
3624
|
+
if (syncTimer) clearTimeout(syncTimer);
|
|
3625
|
+
syncTimer = setTimeout(() => {
|
|
3626
|
+
syncTimer = null;
|
|
3627
|
+
syncNow({ reason }).catch((err) => console.error("Messages contact sync failed:", safeError(err)));
|
|
3628
|
+
}, CONTACT_SYNC_DEBOUNCE_MS);
|
|
3629
|
+
};
|
|
3630
|
+
|
|
3631
|
+
const observeMessages = (messages) => {
|
|
3632
|
+
let changed = false;
|
|
3633
|
+
for (const msg of messages ?? []) {
|
|
3634
|
+
if (rememberObservedHandle(observedHandles, contactLookup, msg?.participant)) changed = true;
|
|
3635
|
+
if (msg?.affectedParticipant) {
|
|
3636
|
+
if (rememberObservedHandle(observedHandles, contactLookup, msg.affectedParticipant)) changed = true;
|
|
3637
|
+
}
|
|
3638
|
+
}
|
|
3639
|
+
if (changed) scheduleSync("observed-message-handle");
|
|
3640
|
+
};
|
|
3641
|
+
|
|
3642
|
+
const contactWatchPaths = addressBookWalPaths();
|
|
3643
|
+
if (contactWatchPaths.length > 0) {
|
|
3644
|
+
contactsWatcher = [];
|
|
3645
|
+
for (const path of contactWatchPaths) {
|
|
3646
|
+
try {
|
|
3647
|
+
contactsWatcher.push(watch(path, () => scheduleSync("contacts-watch")));
|
|
3648
|
+
} catch (err) {
|
|
3649
|
+
console.warn(`Could not watch Contacts WAL ${path}: ${safeError(err)}`);
|
|
3650
|
+
}
|
|
3651
|
+
}
|
|
3652
|
+
if (contactsWatcher.length > 0) {
|
|
3653
|
+
console.log(`Watching ${contactsWatcher.length} Contacts database WAL file(s) for Messages name changes`);
|
|
3654
|
+
} else {
|
|
3655
|
+
console.warn("Contacts WAL files were found but could not be watched; Messages contact sync will use fallback polling only");
|
|
3656
|
+
}
|
|
3657
|
+
} else if (platform() === "darwin") {
|
|
3658
|
+
console.warn("Contacts WAL not found; Messages contact sync will use fallback polling only");
|
|
3659
|
+
}
|
|
3660
|
+
|
|
3661
|
+
fallbackInterval = setInterval(() => {
|
|
3662
|
+
syncNow({ reason: "fallback" }).catch((err) => console.error("Messages contact fallback sync failed:", safeError(err)));
|
|
3663
|
+
}, CONTACT_SYNC_FALLBACK_MS);
|
|
3664
|
+
|
|
3665
|
+
return {
|
|
3666
|
+
observeMessages,
|
|
3667
|
+
syncNow,
|
|
3668
|
+
stop() {
|
|
3669
|
+
stopped = true;
|
|
3670
|
+
if (syncTimer) clearTimeout(syncTimer);
|
|
3671
|
+
if (fallbackInterval) clearInterval(fallbackInterval);
|
|
3672
|
+
if (contactsWatcher) {
|
|
3673
|
+
for (const watcher of contactsWatcher) watcher.close();
|
|
3674
|
+
}
|
|
3675
|
+
},
|
|
3676
|
+
};
|
|
3677
|
+
}
|
|
3678
|
+
|
|
3679
|
+
function rememberObservedHandle(observedHandles, contactLookup, handle) {
|
|
3680
|
+
const raw = typeof handle === "string" ? handle.trim() : "";
|
|
3681
|
+
if (!raw || contactLookup.isSelfHandle(raw)) return false;
|
|
3682
|
+
let changed = false;
|
|
3683
|
+
for (const candidate of handleCandidates(raw)) {
|
|
3684
|
+
if (!observedHandles.has(candidate)) {
|
|
3685
|
+
observedHandles.add(candidate);
|
|
3686
|
+
changed = true;
|
|
3687
|
+
}
|
|
3688
|
+
}
|
|
3689
|
+
return changed;
|
|
3690
|
+
}
|
|
3691
|
+
|
|
3692
|
+
function contactMappingsFromLookup(lookup) {
|
|
3693
|
+
const mappings = typeof lookup?.mappings === "function" ? lookup.mappings() : lookup?.mappings;
|
|
3694
|
+
if (!Array.isArray(mappings)) return [];
|
|
3695
|
+
return mappings.filter((mapping) =>
|
|
3696
|
+
mapping
|
|
3697
|
+
&& typeof mapping.handle === "string"
|
|
3698
|
+
&& mapping.handle.trim()
|
|
3699
|
+
&& typeof mapping.name === "string"
|
|
3700
|
+
&& mapping.name.trim());
|
|
3701
|
+
}
|
|
3702
|
+
|
|
3703
|
+
function snapshotContactMappings(mappings) {
|
|
3704
|
+
return new Map(mappings.map((mapping) => [mapping.handle, mapping.name]));
|
|
3705
|
+
}
|
|
3706
|
+
|
|
3707
|
+
function createMessageSerializer(kit, contactLookup = emptyContactLookup()) {
|
|
3708
|
+
const chatNames = new Map();
|
|
3709
|
+
const isImageAttachment = kit.isImageAttachment ?? (() => false);
|
|
2929
3710
|
const isVideoAttachment = kit.isVideoAttachment ?? (() => false);
|
|
2930
3711
|
const isAudioAttachment = kit.isAudioAttachment ?? (() => false);
|
|
2931
3712
|
|
|
@@ -3017,6 +3798,9 @@ function buildContactLookup(opts = {}) {
|
|
|
3017
3798
|
isSelfHandle(handle) {
|
|
3018
3799
|
return handleCandidates(handle).some((candidate) => selfHandles.has(candidate));
|
|
3019
3800
|
},
|
|
3801
|
+
mappings() {
|
|
3802
|
+
return [...handleToName.entries()].map(([handle, name]) => ({ handle, name }));
|
|
3803
|
+
},
|
|
3020
3804
|
};
|
|
3021
3805
|
}
|
|
3022
3806
|
|
|
@@ -3028,6 +3812,9 @@ function emptyContactLookup() {
|
|
|
3028
3812
|
isSelfHandle() {
|
|
3029
3813
|
return false;
|
|
3030
3814
|
},
|
|
3815
|
+
mappings() {
|
|
3816
|
+
return [];
|
|
3817
|
+
},
|
|
3031
3818
|
};
|
|
3032
3819
|
}
|
|
3033
3820
|
|
|
@@ -3124,6 +3911,15 @@ function addressBookDatabasePaths() {
|
|
|
3124
3911
|
}
|
|
3125
3912
|
}
|
|
3126
3913
|
|
|
3914
|
+
function addressBookWalPaths() {
|
|
3915
|
+
if (platform() !== "darwin") return [];
|
|
3916
|
+
const paths = new Set([CONTACTS_WAL_PATH]);
|
|
3917
|
+
for (const dbPath of addressBookDatabasePaths()) {
|
|
3918
|
+
paths.add(`${dbPath}-wal`);
|
|
3919
|
+
}
|
|
3920
|
+
return [...paths].filter((path) => existsSync(path));
|
|
3921
|
+
}
|
|
3922
|
+
|
|
3127
3923
|
function loadMyCard() {
|
|
3128
3924
|
if (platform() !== "darwin") return null;
|
|
3129
3925
|
const script = `
|
|
@@ -3238,6 +4034,40 @@ function parseDmHandleFromChatId(chatId) {
|
|
|
3238
4034
|
return null;
|
|
3239
4035
|
}
|
|
3240
4036
|
|
|
4037
|
+
function messageChatTouchesShepherdAgent(chat) {
|
|
4038
|
+
if (isShepherdAgentMessageHandle(parseDmHandleFromChatId(chat?.chatId))) return true;
|
|
4039
|
+
return Array.isArray(chat?.participants)
|
|
4040
|
+
&& chat.participants.some((participant) => isShepherdAgentMessageHandle(participant?.handle));
|
|
4041
|
+
}
|
|
4042
|
+
|
|
4043
|
+
function messageTouchesShepherdAgent(msg) {
|
|
4044
|
+
return isShepherdAgentMessageHandle(msg?.participant)
|
|
4045
|
+
|| isShepherdAgentMessageHandle(msg?.affectedParticipant)
|
|
4046
|
+
|| isShepherdAgentMessageHandle(parseDmHandleFromChatId(msg?.chatId));
|
|
4047
|
+
}
|
|
4048
|
+
|
|
4049
|
+
function isShepherdAgentMessageHandle(handle) {
|
|
4050
|
+
if (!SHEPHERD_OWNED_MESSAGE_HANDLES.length) return false;
|
|
4051
|
+
const blocked = new Set(SHEPHERD_OWNED_MESSAGE_HANDLES.flatMap(handleCandidates));
|
|
4052
|
+
return handleCandidates(handle).some((candidate) => blocked.has(candidate));
|
|
4053
|
+
}
|
|
4054
|
+
|
|
4055
|
+
function parseMessageHandleList(value) {
|
|
4056
|
+
return String(value ?? "")
|
|
4057
|
+
.split(",")
|
|
4058
|
+
.map((handle) => handle.trim())
|
|
4059
|
+
.filter(Boolean);
|
|
4060
|
+
}
|
|
4061
|
+
|
|
4062
|
+
function mergeShepherdOwnedMessageHandles(value) {
|
|
4063
|
+
for (const handle of Array.isArray(value) ? value : parseMessageHandleList(value)) {
|
|
4064
|
+
if (typeof handle !== "string" || !handle.trim()) continue;
|
|
4065
|
+
if (!SHEPHERD_OWNED_MESSAGE_HANDLES.includes(handle.trim())) {
|
|
4066
|
+
SHEPHERD_OWNED_MESSAGE_HANDLES.push(handle.trim());
|
|
4067
|
+
}
|
|
4068
|
+
}
|
|
4069
|
+
}
|
|
4070
|
+
|
|
3241
4071
|
function parseSelectionIndexes(answer, max) {
|
|
3242
4072
|
const indexes = new Set();
|
|
3243
4073
|
for (const part of String(answer ?? "").split(/[,\s]+/).map((value) => value.trim()).filter(Boolean)) {
|
|
@@ -3263,7 +4093,15 @@ function parseMessageChatIdsArg() {
|
|
|
3263
4093
|
function parseAllowedChatIds(value) {
|
|
3264
4094
|
if (!value) return [];
|
|
3265
4095
|
const raw = Array.isArray(value) ? value : String(value).split(",");
|
|
3266
|
-
|
|
4096
|
+
const values = raw.map((chatId) => String(chatId).trim()).filter(Boolean);
|
|
4097
|
+
if (values.some((chatId) => chatId.toLowerCase() === "all" || chatId === ALL_MESSAGES_CHATS)) {
|
|
4098
|
+
return [ALL_MESSAGES_CHATS];
|
|
4099
|
+
}
|
|
4100
|
+
return [...new Set(values)];
|
|
4101
|
+
}
|
|
4102
|
+
|
|
4103
|
+
function selectedChatIdsIncludeAll(chatIds) {
|
|
4104
|
+
return Array.isArray(chatIds) && chatIds.includes(ALL_MESSAGES_CHATS);
|
|
3267
4105
|
}
|
|
3268
4106
|
|
|
3269
4107
|
function html(value) {
|
|
@@ -3277,6 +4115,513 @@ function htmlAttr(value) {
|
|
|
3277
4115
|
return html(value).replace(/"/g, """);
|
|
3278
4116
|
}
|
|
3279
4117
|
|
|
4118
|
+
async function scanCodingSessions(config, previousState = { hashes: {} }) {
|
|
4119
|
+
const probes = await probeCodingSessionPaths(config);
|
|
4120
|
+
const errors = [];
|
|
4121
|
+
const sessions = [];
|
|
4122
|
+
const codexDirs = Array.isArray(config.codexDirs) ? config.codexDirs : [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR];
|
|
4123
|
+
const claudeDir = typeof config.claudeProjectsDir === "string" ? config.claudeProjectsDir : CLAUDE_PROJECTS_DIR;
|
|
4124
|
+
const maxFiles = Math.max(1, Math.floor(Number(config.maxFilesPerProvider ?? 300)));
|
|
4125
|
+
|
|
4126
|
+
for (const dir of codexDirs) {
|
|
4127
|
+
try {
|
|
4128
|
+
const files = await recentJsonlFiles(dir, maxFiles);
|
|
4129
|
+
for (const file of files) {
|
|
4130
|
+
const parsed = await parseCodexSessionFile(file, config).catch((err) => {
|
|
4131
|
+
errors.push({ provider: "codex", pathHash: hashString(file), error: safeError(err) });
|
|
4132
|
+
return null;
|
|
4133
|
+
});
|
|
4134
|
+
if (parsed) sessions.push(parsed);
|
|
4135
|
+
}
|
|
4136
|
+
} catch (err) {
|
|
4137
|
+
if (args.debug) errors.push({ provider: "codex", pathHash: hashString(dir), error: safeError(err) });
|
|
4138
|
+
}
|
|
4139
|
+
}
|
|
4140
|
+
|
|
4141
|
+
try {
|
|
4142
|
+
const files = await recentJsonlFiles(claudeDir, maxFiles);
|
|
4143
|
+
for (const file of files) {
|
|
4144
|
+
const parsed = await parseClaudeSessionFile(file, config).catch((err) => {
|
|
4145
|
+
errors.push({ provider: "claude", pathHash: hashString(file), error: safeError(err) });
|
|
4146
|
+
return null;
|
|
4147
|
+
});
|
|
4148
|
+
if (parsed) sessions.push(parsed);
|
|
4149
|
+
}
|
|
4150
|
+
} catch (err) {
|
|
4151
|
+
if (args.debug) errors.push({ provider: "claude", pathHash: hashString(claudeDir), error: safeError(err) });
|
|
4152
|
+
}
|
|
4153
|
+
|
|
4154
|
+
return {
|
|
4155
|
+
probes,
|
|
4156
|
+
errors,
|
|
4157
|
+
sessions: sessions
|
|
4158
|
+
.sort((a, b) => String(b.lastUpdatedAt ?? "").localeCompare(String(a.lastUpdatedAt ?? "")))
|
|
4159
|
+
.slice(0, maxFiles * 2),
|
|
4160
|
+
previousState,
|
|
4161
|
+
};
|
|
4162
|
+
}
|
|
4163
|
+
|
|
4164
|
+
async function probeCodingSessionPaths(config) {
|
|
4165
|
+
const probes = [];
|
|
4166
|
+
const codexDirs = Array.isArray(config.codexDirs) ? config.codexDirs : [CODEX_SESSIONS_DIR, CODEX_ARCHIVED_SESSIONS_DIR];
|
|
4167
|
+
for (const path of codexDirs) probes.push(await probePath("codex", path));
|
|
4168
|
+
probes.push(await probePath("claude", typeof config.claudeProjectsDir === "string" ? config.claudeProjectsDir : CLAUDE_PROJECTS_DIR));
|
|
4169
|
+
return probes;
|
|
4170
|
+
}
|
|
4171
|
+
|
|
4172
|
+
async function probePath(provider, path) {
|
|
4173
|
+
try {
|
|
4174
|
+
await access(path, fsConstants.R_OK);
|
|
4175
|
+
const info = await stat(path);
|
|
4176
|
+
return { provider, path, readable: true, directory: info.isDirectory() };
|
|
4177
|
+
} catch (err) {
|
|
4178
|
+
return { provider, path, readable: false, reason: err?.code ?? safeError(err) };
|
|
4179
|
+
}
|
|
4180
|
+
}
|
|
4181
|
+
|
|
4182
|
+
async function recentJsonlFiles(root, limit) {
|
|
4183
|
+
const files = [];
|
|
4184
|
+
await walkJsonl(root, files, limit * 8);
|
|
4185
|
+
return files
|
|
4186
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs)
|
|
4187
|
+
.slice(0, limit)
|
|
4188
|
+
.map((file) => file.path);
|
|
4189
|
+
}
|
|
4190
|
+
|
|
4191
|
+
async function walkJsonl(dir, files, cap) {
|
|
4192
|
+
if (files.length >= cap) return;
|
|
4193
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
4194
|
+
for (const entry of entries) {
|
|
4195
|
+
if (files.length >= cap) return;
|
|
4196
|
+
const path = join(dir, entry.name);
|
|
4197
|
+
if (entry.isDirectory()) {
|
|
4198
|
+
await walkJsonl(path, files, cap).catch(() => undefined);
|
|
4199
|
+
} else if (entry.isFile() && entry.name.endsWith(".jsonl")) {
|
|
4200
|
+
const info = await stat(path).catch(() => null);
|
|
4201
|
+
if (info) files.push({ path, mtimeMs: info.mtimeMs });
|
|
4202
|
+
}
|
|
4203
|
+
}
|
|
4204
|
+
}
|
|
4205
|
+
|
|
4206
|
+
async function parseCodexSessionFile(path, config) {
|
|
4207
|
+
const lines = await readJsonlRecords(path);
|
|
4208
|
+
if (lines.length === 0) return null;
|
|
4209
|
+
const payloads = lines.map((line) => line.payload && typeof line.payload === "object" ? line.payload : line);
|
|
4210
|
+
const meta = payloads.find((payload) => payload && typeof payload === "object" && (payload.id || payload.started_at || payload.cwd)) ?? {};
|
|
4211
|
+
const timestamps = lines.map((line) => isoDate(line.timestamp ?? line.payload?.timestamp ?? line.payload?.started_at)).filter(Boolean);
|
|
4212
|
+
const cwd = firstString(payloads, ["cwd"]);
|
|
4213
|
+
const repo = await repoMetadata(cwd);
|
|
4214
|
+
const actor = await actorMetadata(cwd, config);
|
|
4215
|
+
const userTexts = extractTextValues(payloads, ["text", "query"]).filter((text) => text.length > 0);
|
|
4216
|
+
const summaries = extractTextValues(payloads, ["summary", "formatted_output"]).filter((text) => text.length > 0);
|
|
4217
|
+
const commands = extractCodexCommands(payloads);
|
|
4218
|
+
const session = {
|
|
4219
|
+
provider: "codex",
|
|
4220
|
+
sessionId: cleanSessionId(String(meta.id ?? basename(path, ".jsonl"))),
|
|
4221
|
+
startedAt: minIso(timestamps) ?? isoDate(meta.started_at),
|
|
4222
|
+
endedAt: maxIso(timestamps),
|
|
4223
|
+
lastUpdatedAt: maxIso(timestamps) ?? new Date().toISOString(),
|
|
4224
|
+
status: sessionStatus(maxIso(timestamps)),
|
|
4225
|
+
actor,
|
|
4226
|
+
repo,
|
|
4227
|
+
cwdHash: cwd ? hashString(cwd) : null,
|
|
4228
|
+
cwdBasename: cwd ? basename(cwd) : null,
|
|
4229
|
+
title: redactText(firstUseful(userTexts) ?? firstUseful(summaries) ?? basename(path, ".jsonl"), 180),
|
|
4230
|
+
goal: redactText(firstUseful(userTexts), 600),
|
|
4231
|
+
summary: buildLocalSessionSummary("Codex", userTexts, summaries, commands),
|
|
4232
|
+
decisions: [],
|
|
4233
|
+
filesTouched: inferFilesTouched([...userTexts, ...summaries, ...commands.map((command) => command.command)]),
|
|
4234
|
+
commands,
|
|
4235
|
+
verification: inferVerification(commands),
|
|
4236
|
+
errors: inferErrors(commands, summaries),
|
|
4237
|
+
followUps: inferFollowUps([...userTexts, ...summaries]),
|
|
4238
|
+
omittedCounts: { rawLines: lines.length, uploadedRawTranscriptLines: 0 },
|
|
4239
|
+
sourcePathHash: hashString(path),
|
|
4240
|
+
};
|
|
4241
|
+
session.contentHash = hashObject(session);
|
|
4242
|
+
return session;
|
|
4243
|
+
}
|
|
4244
|
+
|
|
4245
|
+
async function parseClaudeSessionFile(path, config) {
|
|
4246
|
+
const lines = await readJsonlRecords(path);
|
|
4247
|
+
if (lines.length === 0) return null;
|
|
4248
|
+
const timestamps = lines.map((line) => isoDate(line.timestamp)).filter(Boolean);
|
|
4249
|
+
const cwd = firstString(lines, ["cwd"]);
|
|
4250
|
+
const repo = await repoMetadata(cwd);
|
|
4251
|
+
const actor = await actorMetadata(cwd, config);
|
|
4252
|
+
const sessionId = firstString(lines, ["sessionId"]) ?? basename(path, ".jsonl");
|
|
4253
|
+
const title = firstString(lines, ["aiTitle", "lastPrompt", "slug"]) ?? firstMessageText(lines);
|
|
4254
|
+
const userTexts = claudeTextValues(lines, "user");
|
|
4255
|
+
const assistantTexts = claudeTextValues(lines, "assistant");
|
|
4256
|
+
const commands = extractClaudeCommands(lines);
|
|
4257
|
+
const session = {
|
|
4258
|
+
provider: "claude",
|
|
4259
|
+
sessionId: cleanSessionId(sessionId),
|
|
4260
|
+
startedAt: minIso(timestamps),
|
|
4261
|
+
endedAt: maxIso(timestamps),
|
|
4262
|
+
lastUpdatedAt: maxIso(timestamps) ?? new Date().toISOString(),
|
|
4263
|
+
status: sessionStatus(maxIso(timestamps)),
|
|
4264
|
+
actor,
|
|
4265
|
+
repo: {
|
|
4266
|
+
...repo,
|
|
4267
|
+
branch: repo.branch ?? firstString(lines, ["gitBranch"]),
|
|
4268
|
+
},
|
|
4269
|
+
cwdHash: cwd ? hashString(cwd) : null,
|
|
4270
|
+
cwdBasename: cwd ? basename(cwd) : null,
|
|
4271
|
+
title: redactText(title, 180),
|
|
4272
|
+
goal: redactText(firstUseful(userTexts), 600),
|
|
4273
|
+
summary: buildLocalSessionSummary("Claude Code", userTexts, assistantTexts, commands),
|
|
4274
|
+
decisions: [],
|
|
4275
|
+
filesTouched: inferFilesTouched([...userTexts, ...assistantTexts, ...commands.map((command) => command.command)]),
|
|
4276
|
+
commands,
|
|
4277
|
+
verification: inferVerification(commands),
|
|
4278
|
+
errors: inferErrors(commands, assistantTexts),
|
|
4279
|
+
followUps: inferFollowUps([...userTexts, ...assistantTexts]),
|
|
4280
|
+
omittedCounts: { rawLines: lines.length, uploadedRawTranscriptLines: 0 },
|
|
4281
|
+
sourcePathHash: hashString(path),
|
|
4282
|
+
};
|
|
4283
|
+
session.contentHash = hashObject(session);
|
|
4284
|
+
return session;
|
|
4285
|
+
}
|
|
4286
|
+
|
|
4287
|
+
async function readJsonlRecords(path) {
|
|
4288
|
+
const raw = await readFile(path, "utf8");
|
|
4289
|
+
return raw
|
|
4290
|
+
.split("\n")
|
|
4291
|
+
.filter((line) => line.trim())
|
|
4292
|
+
.slice(-3000)
|
|
4293
|
+
.flatMap((line) => {
|
|
4294
|
+
try {
|
|
4295
|
+
return [JSON.parse(line)];
|
|
4296
|
+
} catch {
|
|
4297
|
+
return [];
|
|
4298
|
+
}
|
|
4299
|
+
});
|
|
4300
|
+
}
|
|
4301
|
+
|
|
4302
|
+
function extractCodexCommands(payloads) {
|
|
4303
|
+
return payloads.flatMap((payload) => {
|
|
4304
|
+
if (!payload || typeof payload !== "object") return [];
|
|
4305
|
+
const command = payload.command ?? payload.parsed_cmd?.join?.(" ") ?? null;
|
|
4306
|
+
if (typeof command !== "string" || !command.trim()) return [];
|
|
4307
|
+
return [{
|
|
4308
|
+
command: redactText(command, 600),
|
|
4309
|
+
exitCode: typeof payload.exit_code === "number" ? payload.exit_code : null,
|
|
4310
|
+
summary: payload.stdout || payload.stderr
|
|
4311
|
+
? redactText(String(payload.stderr || payload.stdout).split("\n").find(Boolean) ?? "", 240)
|
|
4312
|
+
: null,
|
|
4313
|
+
}];
|
|
4314
|
+
}).slice(-100);
|
|
4315
|
+
}
|
|
4316
|
+
|
|
4317
|
+
function extractClaudeCommands(lines) {
|
|
4318
|
+
const commands = [];
|
|
4319
|
+
for (const line of lines) {
|
|
4320
|
+
const text = JSON.stringify(line.toolUseResult ?? line.message?.content ?? "");
|
|
4321
|
+
const bashMatch = text.match(/(?:"command"|"input")\s*:\s*"([^"]{2,500})"/);
|
|
4322
|
+
if (bashMatch && /\b(?:git|npm|pnpm|yarn|bun|pytest|vitest|cargo|go|python|node|tsc|ruff|eslint|make)\b/.test(bashMatch[1])) {
|
|
4323
|
+
commands.push({ command: redactText(unescapeJsonString(bashMatch[1]), 600), exitCode: null, summary: null });
|
|
4324
|
+
}
|
|
4325
|
+
}
|
|
4326
|
+
return commands.slice(-100);
|
|
4327
|
+
}
|
|
4328
|
+
|
|
4329
|
+
async function repoMetadata(cwd) {
|
|
4330
|
+
const base = { fullName: null, remote: null, branch: null, commit: null };
|
|
4331
|
+
if (!cwd) return base;
|
|
4332
|
+
const remote = await gitValue(cwd, ["config", "--get", "remote.origin.url"]);
|
|
4333
|
+
const branch = await gitValue(cwd, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
4334
|
+
const commit = await gitValue(cwd, ["rev-parse", "--short", "HEAD"]);
|
|
4335
|
+
return {
|
|
4336
|
+
fullName: repoFullNameFromRemote(remote),
|
|
4337
|
+
remote: remote ? redactCredentialUrl(remote) : null,
|
|
4338
|
+
branch,
|
|
4339
|
+
commit,
|
|
4340
|
+
};
|
|
4341
|
+
}
|
|
4342
|
+
|
|
4343
|
+
async function actorMetadata(cwd, config) {
|
|
4344
|
+
const email = config.actorEmail ?? await gitValue(cwd, ["config", "user.email"]) ?? await gitValue(null, ["config", "--global", "user.email"]);
|
|
4345
|
+
const name = config.actorName ?? await gitValue(cwd, ["config", "user.name"]) ?? await gitValue(null, ["config", "--global", "user.name"]);
|
|
4346
|
+
return { email: email ?? null, name: name ?? null };
|
|
4347
|
+
}
|
|
4348
|
+
|
|
4349
|
+
async function gitValue(cwd, argv) {
|
|
4350
|
+
return new Promise((resolve) => {
|
|
4351
|
+
execFile("git", argv, { cwd: cwd || undefined, timeout: 2000, windowsHide: true }, (error, stdout) => {
|
|
4352
|
+
if (error) resolve(null);
|
|
4353
|
+
else resolve(String(stdout).trim() || null);
|
|
4354
|
+
});
|
|
4355
|
+
});
|
|
4356
|
+
}
|
|
4357
|
+
|
|
4358
|
+
function buildLocalSessionSummary(provider, userTexts, assistantTexts, commands) {
|
|
4359
|
+
const primary = firstUseful([...userTexts, ...assistantTexts]);
|
|
4360
|
+
const commandSummary = commands.length ? ` Commands included: ${commands.slice(-5).map((command) => command.command).join("; ")}.` : "";
|
|
4361
|
+
return redactText(`${provider} session${primary ? `: ${primary}` : " synced."}${commandSummary}`, 1200);
|
|
4362
|
+
}
|
|
4363
|
+
|
|
4364
|
+
function firstUseful(values) {
|
|
4365
|
+
return values.find((value) => value && value.trim().length > 8) ?? null;
|
|
4366
|
+
}
|
|
4367
|
+
|
|
4368
|
+
function firstString(records, keys) {
|
|
4369
|
+
for (const record of records) {
|
|
4370
|
+
if (!record || typeof record !== "object") continue;
|
|
4371
|
+
for (const key of keys) {
|
|
4372
|
+
const value = record[key];
|
|
4373
|
+
if (typeof value === "string" && value.trim()) return value.trim();
|
|
4374
|
+
}
|
|
4375
|
+
}
|
|
4376
|
+
return null;
|
|
4377
|
+
}
|
|
4378
|
+
|
|
4379
|
+
function extractTextValues(records, keys) {
|
|
4380
|
+
const values = [];
|
|
4381
|
+
for (const record of records) {
|
|
4382
|
+
if (!record || typeof record !== "object") continue;
|
|
4383
|
+
for (const key of keys) {
|
|
4384
|
+
const value = record[key];
|
|
4385
|
+
if (typeof value === "string" && value.trim()) values.push(redactText(value, 1200));
|
|
4386
|
+
}
|
|
4387
|
+
}
|
|
4388
|
+
return values;
|
|
4389
|
+
}
|
|
4390
|
+
|
|
4391
|
+
function claudeTextValues(records, role) {
|
|
4392
|
+
const values = [];
|
|
4393
|
+
for (const record of records) {
|
|
4394
|
+
const message = record?.message;
|
|
4395
|
+
if (!message || typeof message !== "object" || message.role !== role) continue;
|
|
4396
|
+
const content = message.content;
|
|
4397
|
+
if (typeof content === "string") values.push(redactText(content, 1200));
|
|
4398
|
+
if (Array.isArray(content)) {
|
|
4399
|
+
for (const item of content) {
|
|
4400
|
+
if (item?.type === "text" && typeof item.text === "string") values.push(redactText(item.text, 1200));
|
|
4401
|
+
}
|
|
4402
|
+
}
|
|
4403
|
+
}
|
|
4404
|
+
return values;
|
|
4405
|
+
}
|
|
4406
|
+
|
|
4407
|
+
function firstMessageText(records) {
|
|
4408
|
+
return firstUseful(claudeTextValues(records, "user")) ?? firstUseful(claudeTextValues(records, "assistant"));
|
|
4409
|
+
}
|
|
4410
|
+
|
|
4411
|
+
function inferFilesTouched(values) {
|
|
4412
|
+
const matches = new Set();
|
|
4413
|
+
const fileRe = /\b(?:[A-Za-z0-9_.-]+\/)+[A-Za-z0-9_.-]+\.(?:ts|tsx|js|jsx|py|rs|go|swift|kt|java|json|md|sql|css|html|yml|yaml|toml)\b/g;
|
|
4414
|
+
for (const value of values) {
|
|
4415
|
+
for (const match of String(value ?? "").matchAll(fileRe)) matches.add(match[0]);
|
|
4416
|
+
}
|
|
4417
|
+
return [...matches].slice(0, 200);
|
|
4418
|
+
}
|
|
4419
|
+
|
|
4420
|
+
function inferVerification(commands) {
|
|
4421
|
+
return commands
|
|
4422
|
+
.filter((command) => /\b(?:test|vitest|jest|pytest|tsc|lint|eslint|build|cargo test|go test)\b/i.test(command.command))
|
|
4423
|
+
.map((command) => `${command.command}${command.exitCode == null ? "" : ` exited ${command.exitCode}`}`)
|
|
4424
|
+
.slice(0, 30);
|
|
4425
|
+
}
|
|
4426
|
+
|
|
4427
|
+
function inferErrors(commands, texts) {
|
|
4428
|
+
const errors = [];
|
|
4429
|
+
for (const command of commands) {
|
|
4430
|
+
if (typeof command.exitCode === "number" && command.exitCode !== 0) errors.push(`${command.command} exited ${command.exitCode}`);
|
|
4431
|
+
}
|
|
4432
|
+
for (const text of texts) {
|
|
4433
|
+
if (/\b(?:error|failed|failing|blocked|exception)\b/i.test(text)) errors.push(redactText(text, 280));
|
|
4434
|
+
}
|
|
4435
|
+
return [...new Set(errors)].slice(0, 30);
|
|
4436
|
+
}
|
|
4437
|
+
|
|
4438
|
+
function inferFollowUps(texts) {
|
|
4439
|
+
return texts
|
|
4440
|
+
.filter((text) => /\b(?:todo|follow up|next|later|needs?|remaining)\b/i.test(text))
|
|
4441
|
+
.map((text) => redactText(text, 280))
|
|
4442
|
+
.slice(0, 30);
|
|
4443
|
+
}
|
|
4444
|
+
|
|
4445
|
+
function sessionStatus(lastUpdatedAt) {
|
|
4446
|
+
if (!lastUpdatedAt) return "unknown";
|
|
4447
|
+
return Date.now() - Date.parse(lastUpdatedAt) < 5 * 60_000 ? "active" : "settled";
|
|
4448
|
+
}
|
|
4449
|
+
|
|
4450
|
+
function minIso(values) {
|
|
4451
|
+
return values.length ? values.sort()[0] : null;
|
|
4452
|
+
}
|
|
4453
|
+
|
|
4454
|
+
function maxIso(values) {
|
|
4455
|
+
return values.length ? values.sort().at(-1) : null;
|
|
4456
|
+
}
|
|
4457
|
+
|
|
4458
|
+
function cleanSessionId(value) {
|
|
4459
|
+
return String(value ?? "").replace(/[^a-zA-Z0-9_.:-]/g, "-").slice(0, 200) || hashString(value).slice(0, 16);
|
|
4460
|
+
}
|
|
4461
|
+
|
|
4462
|
+
function repoFullNameFromRemote(remote) {
|
|
4463
|
+
if (!remote) return null;
|
|
4464
|
+
const match = String(remote).match(/[:/]([^/:]+\/[^/.]+)(?:\.git)?$/);
|
|
4465
|
+
return match?.[1] ?? null;
|
|
4466
|
+
}
|
|
4467
|
+
|
|
4468
|
+
function redactCredentialUrl(value) {
|
|
4469
|
+
return String(value).replace(/:\/\/[^/@]+@/g, "://[redacted]@");
|
|
4470
|
+
}
|
|
4471
|
+
|
|
4472
|
+
function redactText(value, maxLength = 1000) {
|
|
4473
|
+
return String(value ?? "")
|
|
4474
|
+
.replace(/-----BEGIN [^-]+PRIVATE KEY-----[\s\S]*?-----END [^-]+PRIVATE KEY-----/g, "[redacted-private-key]")
|
|
4475
|
+
.replace(/\b(?:sk|pk|rk|ghp|github_pat|xox[baprs])_[A-Za-z0-9_=-]{12,}\b/g, "[redacted-token]")
|
|
4476
|
+
.replace(/\b[A-Za-z0-9._%+-]+:[A-Za-z0-9._%+-]+@/g, "[redacted-credentials]@")
|
|
4477
|
+
.replace(/\b(?:authorization|x-api-key|api[_-]?key|token|secret|password)\s*[:=]\s*['\"]?[^'\"\s]+/gi, "$1=[redacted]")
|
|
4478
|
+
.replace(/(OPENAI_API_KEY|FIREWORKS_API_KEY|ANTHROPIC_API_KEY|DATABASE_URL|REDIS_URL)=\S+/g, "$1=[redacted]")
|
|
4479
|
+
.replace(new RegExp(homedir().replace(/[.*+?^${}()|[\]\\]/g, "\\$&"), "g"), "~")
|
|
4480
|
+
.slice(0, maxLength);
|
|
4481
|
+
}
|
|
4482
|
+
|
|
4483
|
+
function unescapeJsonString(value) {
|
|
4484
|
+
try {
|
|
4485
|
+
return JSON.parse(`"${value.replace(/"/g, '\\"')}"`);
|
|
4486
|
+
} catch {
|
|
4487
|
+
return value;
|
|
4488
|
+
}
|
|
4489
|
+
}
|
|
4490
|
+
|
|
4491
|
+
function hashString(value) {
|
|
4492
|
+
return createHash("sha256").update(String(value ?? "")).digest("hex");
|
|
4493
|
+
}
|
|
4494
|
+
|
|
4495
|
+
function hashObject(value) {
|
|
4496
|
+
return hashString(JSON.stringify(value));
|
|
4497
|
+
}
|
|
4498
|
+
|
|
4499
|
+
function readCodingSessionsState(path) {
|
|
4500
|
+
const value = readJsonOptional(path);
|
|
4501
|
+
return value && typeof value === "object" && !Array.isArray(value)
|
|
4502
|
+
? { hashes: value.hashes && typeof value.hashes === "object" ? value.hashes : {} }
|
|
4503
|
+
: { hashes: {} };
|
|
4504
|
+
}
|
|
4505
|
+
|
|
4506
|
+
function codingSessionsStateFile(userId) {
|
|
4507
|
+
const path = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-state.json`);
|
|
4508
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4509
|
+
return path;
|
|
4510
|
+
}
|
|
4511
|
+
|
|
4512
|
+
function codingSessionsStatusFile(userId) {
|
|
4513
|
+
const path = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-status.json`);
|
|
4514
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
4515
|
+
return path;
|
|
4516
|
+
}
|
|
4517
|
+
|
|
4518
|
+
async function latestCodingSessionsConfigPath() {
|
|
4519
|
+
const dir = join(homedir(), ".shepherd", "coding-sessions");
|
|
4520
|
+
try {
|
|
4521
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
4522
|
+
const files = [];
|
|
4523
|
+
for (const entry of entries) {
|
|
4524
|
+
if (!entry.isFile() || !entry.name.endsWith(".json") || entry.name.includes("-state") || entry.name.includes("-status") || entry.name.includes("-queue")) continue;
|
|
4525
|
+
const path = join(dir, entry.name);
|
|
4526
|
+
const info = await stat(path).catch(() => null);
|
|
4527
|
+
if (info) files.push({ path, mtimeMs: info.mtimeMs });
|
|
4528
|
+
}
|
|
4529
|
+
return files.sort((a, b) => b.mtimeMs - a.mtimeMs)[0]?.path ?? null;
|
|
4530
|
+
} catch {
|
|
4531
|
+
return null;
|
|
4532
|
+
}
|
|
4533
|
+
}
|
|
4534
|
+
|
|
4535
|
+
async function productionOnboardingStatusForCodingSessions(config) {
|
|
4536
|
+
if (!config?.userId || !config?.apiUrl) return null;
|
|
4537
|
+
const local = await readOptionalAgentState().catch(() => null);
|
|
4538
|
+
if (!local?.sessionToken || local.sessionId !== config.userId) return null;
|
|
4539
|
+
return getJson(
|
|
4540
|
+
`${trimTrailingSlash(config.apiUrl)}/onboarding/raw/session/${encodeURIComponent(config.userId)}/status`,
|
|
4541
|
+
{ token: local.sessionToken },
|
|
4542
|
+
);
|
|
4543
|
+
}
|
|
4544
|
+
|
|
4545
|
+
function readJsonOptional(path) {
|
|
4546
|
+
try {
|
|
4547
|
+
return JSON.parse(readFileSync(path, "utf8"));
|
|
4548
|
+
} catch {
|
|
4549
|
+
return null;
|
|
4550
|
+
}
|
|
4551
|
+
}
|
|
4552
|
+
|
|
4553
|
+
class CodingSessionsBatchSender {
|
|
4554
|
+
constructor(apiUrl, agentToken, userId) {
|
|
4555
|
+
this.apiUrl = trimTrailingSlash(apiUrl);
|
|
4556
|
+
this.agentToken = agentToken;
|
|
4557
|
+
this.userId = userId;
|
|
4558
|
+
this.queueFile = join(homedir(), ".shepherd", "coding-sessions", `${safeFileId(userId)}-queue.json`);
|
|
4559
|
+
}
|
|
4560
|
+
|
|
4561
|
+
async send(sessions) {
|
|
4562
|
+
const queued = this.loadQueue();
|
|
4563
|
+
const all = [...queued, ...sessions];
|
|
4564
|
+
if (!all.length) return { stored: 0, updated: 0, skipped: 0 };
|
|
4565
|
+
|
|
4566
|
+
let totalStored = 0;
|
|
4567
|
+
let totalUpdated = 0;
|
|
4568
|
+
let totalSkipped = 0;
|
|
4569
|
+
|
|
4570
|
+
for (let i = 0; i < all.length; i += MAX_BATCH_SIZE) {
|
|
4571
|
+
const batch = all.slice(i, i + MAX_BATCH_SIZE);
|
|
4572
|
+
try {
|
|
4573
|
+
const result = await this.postBatch(batch);
|
|
4574
|
+
totalStored += result.stored ?? 0;
|
|
4575
|
+
totalUpdated += result.updated ?? 0;
|
|
4576
|
+
totalSkipped += result.skipped ?? 0;
|
|
4577
|
+
} catch (err) {
|
|
4578
|
+
this.saveQueue(all.slice(i));
|
|
4579
|
+
console.error("Coding sessions batch send failed:", safeError(err));
|
|
4580
|
+
return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped };
|
|
4581
|
+
}
|
|
4582
|
+
}
|
|
4583
|
+
|
|
4584
|
+
this.clearQueue();
|
|
4585
|
+
return { stored: totalStored, updated: totalUpdated, skipped: totalSkipped };
|
|
4586
|
+
}
|
|
4587
|
+
|
|
4588
|
+
async postBatch(sessions) {
|
|
4589
|
+
const res = await fetch(`${this.apiUrl}/api/coding-sessions/ingest`, {
|
|
4590
|
+
method: "POST",
|
|
4591
|
+
headers: {
|
|
4592
|
+
"Content-Type": "application/json",
|
|
4593
|
+
"x-api-key": this.agentToken,
|
|
4594
|
+
},
|
|
4595
|
+
body: JSON.stringify({ userId: this.userId, sessions }),
|
|
4596
|
+
});
|
|
4597
|
+
|
|
4598
|
+
const json = await res.json().catch(() => ({}));
|
|
4599
|
+
if (!res.ok) throw new Error(json.error ?? `Coding sessions ingest failed (${res.status})`);
|
|
4600
|
+
return json;
|
|
4601
|
+
}
|
|
4602
|
+
|
|
4603
|
+
loadQueue() {
|
|
4604
|
+
try {
|
|
4605
|
+
return JSON.parse(readFileSync(this.queueFile, "utf8"));
|
|
4606
|
+
} catch {
|
|
4607
|
+
return [];
|
|
4608
|
+
}
|
|
4609
|
+
}
|
|
4610
|
+
|
|
4611
|
+
saveQueue(sessions) {
|
|
4612
|
+
mkdirSync(dirname(this.queueFile), { recursive: true });
|
|
4613
|
+
writeFileSync(this.queueFile, JSON.stringify(sessions.slice(-MAX_QUEUE_MESSAGES)), { mode: 0o600 });
|
|
4614
|
+
}
|
|
4615
|
+
|
|
4616
|
+
clearQueue() {
|
|
4617
|
+
try {
|
|
4618
|
+
unlinkSync(this.queueFile);
|
|
4619
|
+
} catch {
|
|
4620
|
+
// Queue is already empty.
|
|
4621
|
+
}
|
|
4622
|
+
}
|
|
4623
|
+
}
|
|
4624
|
+
|
|
3280
4625
|
class MessagesBatchSender {
|
|
3281
4626
|
constructor(apiUrl, agentToken, userId) {
|
|
3282
4627
|
this.apiUrl = trimTrailingSlash(apiUrl);
|
|
@@ -3325,6 +4670,21 @@ class MessagesBatchSender {
|
|
|
3325
4670
|
return json;
|
|
3326
4671
|
}
|
|
3327
4672
|
|
|
4673
|
+
async syncContacts(contacts) {
|
|
4674
|
+
const res = await fetch(`${this.apiUrl}/api/imessage/ingest/sync-contacts`, {
|
|
4675
|
+
method: "POST",
|
|
4676
|
+
headers: {
|
|
4677
|
+
"Content-Type": "application/json",
|
|
4678
|
+
"x-api-key": this.agentToken,
|
|
4679
|
+
},
|
|
4680
|
+
body: JSON.stringify({ userId: this.userId, contacts }),
|
|
4681
|
+
});
|
|
4682
|
+
|
|
4683
|
+
const json = await res.json().catch(() => ({}));
|
|
4684
|
+
if (!res.ok) throw new Error(json.error ?? `Messages contact sync failed (${res.status})`);
|
|
4685
|
+
return json;
|
|
4686
|
+
}
|
|
4687
|
+
|
|
3328
4688
|
loadQueue() {
|
|
3329
4689
|
try {
|
|
3330
4690
|
return JSON.parse(readFileSync(this.queueFile, "utf8"));
|