@adaptic/maestro 1.6.1 → 1.7.0

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.
Files changed (33) hide show
  1. package/package.json +1 -1
  2. package/scripts/continuous-monitor.sh +11 -6
  3. package/scripts/daemon/context-compiler.mjs +8 -8
  4. package/scripts/daemon/health.mjs +2 -2
  5. package/scripts/daemon/maestro-daemon.mjs +4 -3
  6. package/scripts/email_thread_dedup.py +4 -3
  7. package/scripts/huddle/huddle-server.mjs +50 -29
  8. package/scripts/llm_email_dedup.py +23 -15
  9. package/scripts/local-triggers/generate-plists.sh +2 -1
  10. package/scripts/media-generation/README.md +1 -1
  11. package/scripts/outbound-dedup-cleanup.sh +4 -4
  12. package/scripts/outbound-dedup.sh +4 -3
  13. package/scripts/pdf-generation/README.md +1 -1
  14. package/scripts/pdf-generation/templates/memo.latex +1 -1
  15. package/scripts/poll-slack-events.sh +4 -2
  16. package/scripts/poller/imap-client.mjs +11 -10
  17. package/scripts/poller/index.mjs +6 -6
  18. package/scripts/poller/intra-session-check.mjs +35 -18
  19. package/scripts/poller/mehran-gmail-poller.mjs +63 -29
  20. package/scripts/poller/slack-poller.mjs +45 -31
  21. package/scripts/poller/trigger.mjs +22 -5
  22. package/scripts/pre-draft-context.py +2 -2
  23. package/scripts/rag-indexer.py +3 -3
  24. package/scripts/send-sms.sh +7 -7
  25. package/scripts/send-whatsapp.sh +11 -11
  26. package/scripts/setup/configure-macos.sh +4 -2
  27. package/scripts/setup/init-agent.sh +1 -1
  28. package/scripts/slack-react.mjs +1 -1
  29. package/scripts/slack-typing.mjs +3 -3
  30. package/scripts/system-verify.sh +28 -15
  31. package/scripts/user-context-search.py +4 -4
  32. package/scripts/validate-outbound.py +29 -18
  33. package/scripts/sophie-inbox-poller.py +0 -406
@@ -1,21 +1,21 @@
1
1
  #!/usr/bin/env node
2
- // Sophie Poller — Lightweight event detection daemon
2
+ // Agent Poller — Lightweight event detection daemon
3
3
  // Runs every 60 seconds via macOS launchd
4
4
  // Polls Slack, Gmail, Calendar for new items
5
- // Writes to state/inbox/ for Sophie to process
5
+ // Writes to state/inbox/ for the agent daemon to process
6
6
 
7
7
  import { pollSlack } from "./slack-poller.mjs";
8
8
  import { pollGmail } from "./gmail-poller.mjs";
9
9
  import { pollCalendar } from "./calendar-poller.mjs";
10
10
  import { pollMehranGmail } from "./mehran-gmail-poller.mjs";
11
- import { triggerSophie } from "./trigger.mjs";
12
- import { isPriorityItem, SOPHIE_AI_DIR } from "./utils.mjs";
11
+ import { triggerAgent } from "./trigger.mjs";
12
+ import { isPriorityItem, AGENT_REPO_DIR } from "./utils.mjs";
13
13
  import { appendFileSync, mkdirSync } from "fs";
14
14
  import { join } from "path";
15
15
 
16
16
  async function main() {
17
17
  const timestamp = new Date().toISOString();
18
- const logDir = join(SOPHIE_AI_DIR, "logs", "polling");
18
+ const logDir = join(AGENT_REPO_DIR, "logs", "polling");
19
19
  mkdirSync(logDir, { recursive: true });
20
20
  const logFile = join(logDir, `${timestamp.split("T")[0]}-poller.jsonl`);
21
21
 
@@ -38,7 +38,7 @@ async function main() {
38
38
 
39
39
  for (const item of result.items) {
40
40
  if (isPriorityItem(item) && !priorityTriggered) {
41
- triggerSophie(item);
41
+ triggerAgent(item);
42
42
  priorityTriggered = true;
43
43
  }
44
44
  }
@@ -7,22 +7,36 @@
7
7
  // 3. Unprocessed inbox items
8
8
  // 4. Writing priority triggers when CEO messages detected
9
9
  //
10
- // This complements the 60s launchd poller — it ensures Sophie doesn't miss
11
- // messages while deep in tasks within a session.
10
+ // This complements the 60s launchd poller — it ensures the agent doesn't
11
+ // miss messages while deep in tasks within a session.
12
12
 
13
- import { readdirSync, writeFileSync, mkdirSync, appendFileSync } from "fs";
13
+ import { readdirSync, writeFileSync, mkdirSync, appendFileSync, readFileSync } from "fs";
14
14
  import { join, dirname } from "path";
15
15
  import { fileURLToPath } from "url";
16
16
 
17
17
  const __dirname = dirname(fileURLToPath(import.meta.url));
18
- const SOPHIE_AI_DIR = join(__dirname, "../..");
18
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(__dirname, "../..");
19
19
 
20
20
  const SLACK_TOKEN = process.env.SLACK_USER_TOKEN || process.env.SLACK_TOKEN;
21
21
 
22
- // CEO DM channel highest priority
23
- const CEO_DM_CHANNEL = "D099N1JGKRQ";
24
- const CEO_USER_ID = "U097N5R0M7U";
25
- const SOPHIE_USER_ID = "U099N1JFPRQ";
22
+ // Identity loaded from canonical SOT so principal/agent user IDs aren't
23
+ // hardcoded. The CEO_DM_CHANNEL is detected dynamically (channels starting
24
+ // with "D" are IMs); the previous hardcoded value worked only for Sophie.
25
+ function loadAgentIdentity() {
26
+ try {
27
+ return JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
28
+ } catch {
29
+ return { slackMemberId: "", firstName: "Agent", principal: {} };
30
+ }
31
+ }
32
+ const _agent = loadAgentIdentity();
33
+ const AGENT_USER_ID = _agent.slackMemberId || "";
34
+ const CEO_USER_ID = _agent.principal?.slackMemberId || "";
35
+ // CEO_DM_CHANNEL is agent-specific (Slack assigns IM channel IDs dynamically).
36
+ // Provide it via env var, otherwise the CEO-DM hot path is skipped on this
37
+ // poll cycle and we rely on the normal channel scan + Slack Events API to
38
+ // catch principal messages.
39
+ const CEO_DM_CHANNEL = process.env.CEO_DM_CHANNEL || "";
26
40
 
27
41
  // Critical channels to check during intra-session polls
28
42
  const CRITICAL_CHANNELS = [
@@ -31,11 +45,11 @@ const CRITICAL_CHANNELS = [
31
45
  { id: "C097N5R8HUJ", name: "all-adaptic" },
32
46
  ];
33
47
 
34
- const TRIGGER_DIR = join(SOPHIE_AI_DIR, "state", "triggers", "priority");
48
+ const TRIGGER_DIR = join(AGENT_REPO_DIR, "state", "triggers", "priority");
35
49
  const INBOX_DIRS = ["slack", "gmail", "calendar", "sms", "internal"].map((s) =>
36
- join(SOPHIE_AI_DIR, "state", "inbox", s),
50
+ join(AGENT_REPO_DIR, "state", "inbox", s),
37
51
  );
38
- const LOG_DIR = join(SOPHIE_AI_DIR, "logs", "polling");
52
+ const LOG_DIR = join(AGENT_REPO_DIR, "logs", "polling");
39
53
 
40
54
  async function slackApi(method, params = {}) {
41
55
  const url = new URL(`https://slack.com/api/${method}`);
@@ -76,6 +90,7 @@ content: |
76
90
 
77
91
  async function checkCeoDm() {
78
92
  if (!SLACK_TOKEN) return { found: false, error: "SLACK_TOKEN not set" };
93
+ if (!CEO_DM_CHANNEL) return { found: false, error: "CEO_DM_CHANNEL not configured" };
79
94
 
80
95
  // Check last 5 minutes of CEO DM channel
81
96
  const oldest = String((Date.now() - 5 * 60 * 1000) / 1000);
@@ -96,10 +111,11 @@ async function checkCeoDm() {
96
111
 
97
112
  if (ceoMessages.length > 0) {
98
113
  const latest = ceoMessages[0];
114
+ const principalSlug = (_agent.principal?.fullName || "principal").toLowerCase().replace(/\s+/g, "-");
99
115
  const triggerFile = writePriorityTrigger(
100
116
  `slack:${CEO_DM_CHANNEL}:${latest.ts}`,
101
- "mehran-granfar",
102
- `dm/mehran-granfar`,
117
+ principalSlug,
118
+ `dm/${principalSlug}`,
103
119
  latest.text,
104
120
  "CEO DM detected during intra-session poll",
105
121
  );
@@ -134,9 +150,10 @@ async function checkCriticalChannels() {
134
150
  if (!result.ok) continue;
135
151
 
136
152
  const relevant = (result.messages || []).filter(
137
- (m) => !m.bot_id && m.user !== SOPHIE_USER_ID,
153
+ (m) => !m.bot_id && m.user !== AGENT_USER_ID,
138
154
  );
139
155
 
156
+ const agentNameLower = (_agent.firstName || "").toLowerCase();
140
157
  if (relevant.length > 0) {
141
158
  newMessages.push({
142
159
  channel: ch.name,
@@ -145,9 +162,9 @@ async function checkCriticalChannels() {
145
162
  hasUrgent: relevant.some((m) =>
146
163
  /\b(urgent|emergency|asap|blocker|critical)\b/i.test(m.text || ""),
147
164
  ),
148
- hasSophieMention: relevant.some((m) =>
149
- (m.text || "").toLowerCase().includes("sophie"),
150
- ),
165
+ hasAgentMention: agentNameLower
166
+ ? relevant.some((m) => (m.text || "").toLowerCase().includes(agentNameLower))
167
+ : false,
151
168
  });
152
169
 
153
170
  // If CEO posted in a critical channel, also create a trigger
@@ -231,7 +248,7 @@ async function main() {
231
248
  const flags = [];
232
249
  if (c.hasCeo) flags.push("CEO");
233
250
  if (c.hasUrgent) flags.push("URGENT");
234
- if (c.hasSophieMention) flags.push("@Sophie");
251
+ if (c.hasAgentMention) flags.push(`@${_agent.firstName || "Agent"}`);
235
252
  const flagStr = flags.length > 0 ? ` [${flags.join(",")}]` : "";
236
253
  return `#${c.channel}(${c.count})${flagStr}`;
237
254
  })
@@ -1,25 +1,43 @@
1
- // Mehran Gmail Poller — polls mehran@adaptic.ai via native Node.js IMAP.
2
- // Uses the shared imap-client.mjs module (same as Sophie's poller).
1
+ // Principal Gmail Poller — polls the principal's inbox via native Node.js IMAP.
2
+ // Uses the shared imap-client.mjs module.
3
3
  //
4
- // Previously depended on scripts/mehran-inbox-poller.py (now retired).
4
+ // ACCESS BOUNDARY: This poller is only enabled for agents whose archetype
5
+ // explicitly grants principal-Gmail access (typically Chief-of-Staff /
6
+ // executive-operator archetypes). For other agents, leave
7
+ // PRINCIPAL_GMAIL_APP_PASSWORD unset and this module will no-op.
8
+ //
9
+ // Backward compatibility: this file was previously named `mehran-gmail-poller.mjs`
10
+ // with `pollMehranGmail` as the exported function. The new export name is
11
+ // `pollPrincipalGmail`; the old name is re-exported as an alias.
5
12
 
6
13
  import { readFileSync, readdirSync } from "fs";
7
14
  import { join } from "path";
8
- import { SOPHIE_AI_DIR, writeCursor } from "./utils.mjs";
15
+ import { AGENT_REPO_DIR, writeCursor } from "./utils.mjs";
9
16
  import { pollImapInbox } from "./imap-client.mjs";
10
17
 
11
- const INBOX_DIR = join(SOPHIE_AI_DIR, "state", "inbox", "gmail");
18
+ const INBOX_DIR = join(AGENT_REPO_DIR, "state", "inbox", "gmail");
19
+
20
+ // Load principal identity from agent.json — email comes from there, password
21
+ // from env (PRINCIPAL_GMAIL_APP_PASSWORD with MEHRAN_GMAIL_APP_PASSWORD fallback
22
+ // for repos still on the legacy var name).
23
+ function loadPrincipal() {
24
+ try {
25
+ const agent = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
26
+ return agent.principal || {};
27
+ } catch {
28
+ return {};
29
+ }
30
+ }
12
31
 
13
- /**
14
- * Load Mehran's Gmail app password from env or .env file.
15
- */
16
- function loadMehranPassword() {
32
+ function loadPrincipalPassword() {
33
+ if (process.env.PRINCIPAL_GMAIL_APP_PASSWORD) return process.env.PRINCIPAL_GMAIL_APP_PASSWORD;
17
34
  if (process.env.MEHRAN_GMAIL_APP_PASSWORD) return process.env.MEHRAN_GMAIL_APP_PASSWORD;
18
35
  try {
19
- const envFile = readFileSync(join(SOPHIE_AI_DIR, ".env"), "utf-8");
36
+ const envFile = readFileSync(join(AGENT_REPO_DIR, ".env"), "utf-8");
20
37
  for (const line of envFile.split("\n")) {
21
- if (line.startsWith("MEHRAN_GMAIL_APP_PASSWORD=")) {
22
- let val = line.split("=", 2)[1].trim();
38
+ const m = line.match(/^(PRINCIPAL_GMAIL_APP_PASSWORD|MEHRAN_GMAIL_APP_PASSWORD)=(.*)$/);
39
+ if (m) {
40
+ let val = m[2].trim();
23
41
  if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
24
42
  val = val.slice(1, -1);
25
43
  }
@@ -30,33 +48,46 @@ function loadMehranPassword() {
30
48
  return "";
31
49
  }
32
50
 
33
- export async function pollMehranGmail() {
51
+ export async function pollPrincipalGmail() {
34
52
  const items = [];
35
53
  const errors = [];
36
54
 
37
- // Run native IMAP poll for mehran@adaptic.ai
55
+ const principal = loadPrincipal();
56
+ const principalEmail = principal.email;
57
+ const password = loadPrincipalPassword();
58
+
59
+ // No-op if not configured — agents without principal-Gmail access just
60
+ // skip this entirely instead of failing every poll cycle.
61
+ if (!principalEmail || !password) {
62
+ return { items, errors };
63
+ }
64
+
65
+ const accountSlug = (principal.firstName || "principal").toLowerCase();
66
+ const fileSuffix = `${accountSlug}-email`;
67
+
38
68
  try {
39
69
  const result = await pollImapInbox({
40
- email: "mehran@adaptic.ai",
41
- password: loadMehranPassword(),
42
- account: "mehran",
43
- fileSuffix: "mehran-email",
44
- eventType: "mehran_email",
45
- logPrefix: "[mehran-gmail]",
70
+ email: principalEmail,
71
+ password,
72
+ account: accountSlug,
73
+ fileSuffix,
74
+ eventType: `${accountSlug}_email`,
75
+ logPrefix: `[${accountSlug}-gmail]`,
46
76
  });
47
77
  if (result.errors.length > 0) {
48
78
  errors.push(...result.errors);
49
79
  }
50
80
  } catch (err) {
51
- const msg = `Mehran Gmail poll failed: ${err.message}`;
52
- console.error(`[mehran-gmail] ${msg}`);
81
+ const msg = `Principal Gmail poll failed: ${err.message}`;
82
+ console.error(`[${accountSlug}-gmail] ${msg}`);
53
83
  errors.push(msg);
54
84
  }
55
85
 
56
86
  // Read resulting JSON files
57
87
  try {
88
+ const fileGlob = `-${fileSuffix}.json`;
58
89
  const files = readdirSync(INBOX_DIR).filter(
59
- (f) => f.endsWith("-mehran-email.json") && !f.endsWith(".processed"),
90
+ (f) => f.endsWith(fileGlob) && !f.endsWith(".processed"),
60
91
  );
61
92
 
62
93
  for (const file of files) {
@@ -65,9 +96,9 @@ export async function pollMehranGmail() {
65
96
  const record = JSON.parse(content);
66
97
 
67
98
  items.push({
68
- id: file.replace("-mehran-email.json", ""),
69
- service: "mehran-gmail",
70
- channel: "mehran-inbox",
99
+ id: file.replace(fileGlob, ""),
100
+ service: `${accountSlug}-gmail`,
101
+ channel: `${accountSlug}-inbox`,
71
102
  sender: record.email?.from || "unknown",
72
103
  sender_privilege: "unknown",
73
104
  timestamp: record.received_at || new Date().toISOString(),
@@ -79,9 +110,9 @@ export async function pollMehranGmail() {
79
110
  from_ceo: false,
80
111
  tagged_urgent: false,
81
112
  contains_deadline: false,
82
- mentions_sophie: false,
113
+ mentions_agent: false,
83
114
  },
84
- raw_ref: `mehran-gmail:${record.email?.message_id || ""}`,
115
+ raw_ref: `${accountSlug}-gmail:${record.email?.message_id || ""}`,
85
116
  });
86
117
  } catch (parseErr) {
87
118
  errors.push(`Failed to parse ${file}: ${parseErr.message}`);
@@ -93,6 +124,9 @@ export async function pollMehranGmail() {
93
124
  }
94
125
  }
95
126
 
96
- writeCursor("mehran-gmail", new Date().toISOString());
127
+ writeCursor(`${accountSlug}-gmail`, new Date().toISOString());
97
128
  return { items, errors };
98
129
  }
130
+
131
+ // Back-compat: old export name preserved so existing imports continue to work.
132
+ export const pollMehranGmail = pollPrincipalGmail;
@@ -9,7 +9,7 @@ import {
9
9
  writeCursor,
10
10
  resolvePrivilege,
11
11
  resolveName,
12
- SOPHIE_AI_DIR,
12
+ AGENT_REPO_DIR,
13
13
  extractSlackAttachments,
14
14
  downloadSlackAttachment,
15
15
  } from "./utils.mjs";
@@ -21,7 +21,7 @@ import {
21
21
  } from "../parse-voice-transcript.mjs";
22
22
 
23
23
  const ACTIVE_THREADS_FILE = join(
24
- SOPHIE_AI_DIR,
24
+ AGENT_REPO_DIR,
25
25
  "state",
26
26
  "slack-active-threads.json",
27
27
  );
@@ -29,6 +29,18 @@ const MAX_THREADS_PER_CHANNEL = 10;
29
29
 
30
30
  const SLACK_TOKEN = process.env.SLACK_USER_TOKEN || process.env.SLACK_TOKEN;
31
31
 
32
+ // Load agent identity from canonical SOT for self-filtering + display names.
33
+ let _agent = null;
34
+ function loadAgent() {
35
+ if (_agent) return _agent;
36
+ try {
37
+ _agent = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
38
+ } catch {
39
+ _agent = { firstName: "Agent", fullName: "Agent", slackMemberId: "" };
40
+ }
41
+ return _agent;
42
+ }
43
+
32
44
  // Priority overrides for known channels — everything else defaults to "normal"
33
45
  const CHANNEL_PRIORITY_OVERRIDES = {
34
46
  "ceo-office": "critical",
@@ -46,7 +58,7 @@ let channelsCachedAt = 0;
46
58
  const CHANNEL_CACHE_TTL = 10 * 60 * 1000; // 10 min
47
59
 
48
60
  /**
49
- * Fetch all channels Sophie is a member of.
61
+ * Fetch all channels the agent is a member of.
50
62
  * Replaces the static MONITORED_CHANNELS list so new channels are auto-discovered.
51
63
  * Per CEO standing instruction si-001: "Monitor ALL Slack channels."
52
64
  */
@@ -80,7 +92,7 @@ async function getMonitoredChannels() {
80
92
  }
81
93
 
82
94
  const CEO_USER_ID = "U097N5R0M7U";
83
- const SOPHIE_USER_ID = "U099N1JFPRQ";
95
+ const AGENT_USER_ID = "U099N1JFPRQ";
84
96
 
85
97
  // Rate-limit-aware delay with random jitter to reduce Slack API rate limiting.
86
98
  // Base delay 400ms + random 0-600ms jitter = 400-1000ms between requests.
@@ -165,7 +177,7 @@ const CONTEXT_MESSAGES_LIMIT = 8; // Last 8 messages from the conversation
165
177
 
166
178
  /**
167
179
  * Fetch recent conversation context for a DM message.
168
- * Returns the last N messages (including Sophie's) formatted as readable text.
180
+ * Returns the last N messages (including the agent's) formatted as readable text.
169
181
  * This gives the session awareness of the conversation flow.
170
182
  */
171
183
  async function fetchDMContext(channelId, beforeTs) {
@@ -181,7 +193,7 @@ async function fetchDMContext(channelId, beforeTs) {
181
193
  // Messages come newest-first from Slack, reverse for chronological order
182
194
  const msgs = result.messages.reverse();
183
195
  const lines = msgs.map((m) => {
184
- const who = m.user === SOPHIE_USER_ID ? "Sophie" : resolveName(m.user);
196
+ const who = m.user === AGENT_USER_ID ? (loadAgent().firstName || "Agent") : resolveName(m.user);
185
197
  const text = (m.text || "").substring(0, 500);
186
198
  return `${who}: ${text}`;
187
199
  });
@@ -211,7 +223,7 @@ async function fetchThreadContext(channelId, threadTs, beforeTs) {
211
223
  if (preceding.length === 0) return null;
212
224
 
213
225
  const lines = preceding.map((m) => {
214
- const who = m.user === SOPHIE_USER_ID ? "Sophie" : resolveName(m.user);
226
+ const who = m.user === AGENT_USER_ID ? (loadAgent().firstName || "Agent") : resolveName(m.user);
215
227
  const text = (m.text || "").substring(0, 500);
216
228
  return `${who}: ${text}`;
217
229
  });
@@ -290,12 +302,12 @@ export async function pollSlack() {
290
302
  const channelThreads = new Set(activeThreads[channel.id] || []);
291
303
 
292
304
  for (const msg of result.messages || []) {
293
- // Register any message with replies as an active thread (even Sophie's)
305
+ // Register any message with replies as an active thread (even the agent's)
294
306
  if (msg.reply_count > 0 && msg.ts) {
295
307
  channelThreads.add(msg.ts);
296
308
  }
297
309
 
298
- if (msg.bot_id || msg.user === SOPHIE_USER_ID) continue;
310
+ if (msg.bot_id || msg.user === AGENT_USER_ID) continue;
299
311
 
300
312
  const msgText = msg.text || "";
301
313
  const isCeo = msg.user === CEO_USER_ID;
@@ -313,7 +325,8 @@ export async function pollSlack() {
313
325
  continue;
314
326
  }
315
327
 
316
- const mentionsSophie = msgText.toLowerCase().includes("sophie");
328
+ const agentNameLower = (loadAgent().firstName || "").toLowerCase();
329
+ const mentionsAgent = !!agentNameLower && msgText.toLowerCase().includes(agentNameLower);
317
330
  const isUrgent = /\b(urgent|emergency|asap|blocker|critical)\b/i.test(
318
331
  msgText,
319
332
  );
@@ -350,7 +363,7 @@ export async function pollSlack() {
350
363
  from_ceo: isCeo,
351
364
  tagged_urgent: isUrgent,
352
365
  contains_deadline: false,
353
- mentions_sophie: mentionsSophie,
366
+ mentions_agent: mentionsAgent,
354
367
  },
355
368
  raw_ref: `slack:${channel.id}:${msg.ts}`,
356
369
  });
@@ -409,7 +422,7 @@ export async function pollSlack() {
409
422
  channelThreads.add(msg.ts);
410
423
  }
411
424
 
412
- if (msg.bot_id || msg.user === SOPHIE_USER_ID) continue;
425
+ if (msg.bot_id || msg.user === AGENT_USER_ID) continue;
413
426
 
414
427
  const msgText = msg.text || "";
415
428
  const isCeo = msg.user === CEO_USER_ID;
@@ -465,7 +478,7 @@ export async function pollSlack() {
465
478
  tagged_urgent:
466
479
  /\b(urgent|emergency|asap|blocker|critical)\b/i.test(msgText),
467
480
  contains_deadline: false,
468
- mentions_sophie: true,
481
+ mentions_agent: true,
469
482
  },
470
483
  raw_ref: `slack:${im.id}:${msg.ts}`,
471
484
  });
@@ -500,7 +513,7 @@ export async function pollSlack() {
500
513
  // previous poll cycles; without this check the daemon re-classifies and
501
514
  // re-dispatches the same thread reply every 60 seconds until the reply
502
515
  // ages past the lookback window (the original "thread scanning bug").
503
- const SLACK_INBOX = join(SOPHIE_AI_DIR, "state", "inbox", "slack");
516
+ const SLACK_INBOX = join(AGENT_REPO_DIR, "state", "inbox", "slack");
504
517
  let existingInboxFiles = [];
505
518
  try {
506
519
  existingInboxFiles = readdirSync(SLACK_INBOX);
@@ -551,34 +564,34 @@ export async function pollSlack() {
551
564
  const replies = repliesResult.messages || [];
552
565
 
553
566
  // ── Thread-level cooldown ──────────────────────────────────
554
- // If Sophie posted ANY reply in this thread within the last
567
+ // If the agent posted ANY reply in this thread within the last
555
568
  // THREAD_COOLDOWN_MS, skip the entire thread. This prevents
556
569
  // re-ingesting human messages that a running session is already
557
570
  // handling, which previously caused cascading holding notes.
558
571
  const THREAD_COOLDOWN_MS = 10 * 60 * 1000; // 10 minutes
559
572
  const cooldownTs = (Date.now() - THREAD_COOLDOWN_MS) / 1000;
560
- const sophieRepliedRecently = replies.some(
573
+ const agentRepliedRecently = replies.some(
561
574
  (r) =>
562
- r.user === SOPHIE_USER_ID &&
575
+ r.user === AGENT_USER_ID &&
563
576
  parseFloat(r.ts) > cooldownTs,
564
577
  );
565
- if (sophieRepliedRecently) continue; // skip entire thread
578
+ if (agentRepliedRecently) continue; // skip entire thread
566
579
 
567
580
  for (const reply of replies) {
568
581
  // Skip the parent message itself (ts === thread_ts)
569
582
  if (reply.ts === threadTs) continue;
570
- // Skip bot messages and Sophie's own messages
571
- if (reply.bot_id || reply.user === SOPHIE_USER_ID) continue;
583
+ // Skip bot messages and the agent's own messages
584
+ if (reply.bot_id || reply.user === AGENT_USER_ID) continue;
572
585
  // Only process replies within our lookback window
573
586
  if (parseFloat(reply.ts) <= threadOldestTs) continue;
574
587
 
575
- // Skip messages that Sophie already replied to in this thread.
576
- // If Sophie posted a message AFTER this human message, a prior
588
+ // Skip messages that the agent already replied to in this thread.
589
+ // If the agent posted a message AFTER this human message, a prior
577
590
  // session already handled it — don't re-ingest and duplicate.
578
- const sophieRepliedAfter = replies.some(
579
- (r) => r.user === SOPHIE_USER_ID && parseFloat(r.ts) > parseFloat(reply.ts)
591
+ const agentRepliedAfter = replies.some(
592
+ (r) => r.user === AGENT_USER_ID && parseFloat(r.ts) > parseFloat(reply.ts)
580
593
  );
581
- if (sophieRepliedAfter) continue;
594
+ if (agentRepliedAfter) continue;
582
595
 
583
596
  const replyText = reply.text || "";
584
597
  const isCeo = reply.user === CEO_USER_ID;
@@ -604,7 +617,7 @@ export async function pollSlack() {
604
617
  const precedingInThread = replies
605
618
  .filter((m) => parseFloat(m.ts) < parseFloat(reply.ts))
606
619
  .map((m) => {
607
- const who = m.user === SOPHIE_USER_ID ? "Sophie" : resolveName(m.user);
620
+ const who = m.user === AGENT_USER_ID ? (loadAgent().firstName || "Agent") : resolveName(m.user);
608
621
  return `${who}: ${(m.text || "").substring(0, 500)}`;
609
622
  })
610
623
  .join("\n");
@@ -649,7 +662,7 @@ export async function pollSlack() {
649
662
  tagged_urgent:
650
663
  /\b(urgent|emergency|asap|blocker|critical)\b/i.test(replyText),
651
664
  contains_deadline: false,
652
- mentions_sophie: isDmThread || /sophie/i.test(replyText),
665
+ mentions_agent: isDmThread || (loadAgent().firstName && new RegExp(loadAgent().firstName, "i").test(replyText)),
653
666
  },
654
667
  raw_ref: `slack:${channelId}:${reply.ts}`,
655
668
  });
@@ -675,7 +688,7 @@ export async function pollSlack() {
675
688
  // These include @mentions, channel thread replies, and reactions that
676
689
  // the conversations.history API doesn't return. Scan for unprocessed
677
690
  // .json files and convert them into items for the daemon pipeline.
678
- const SLACK_INBOX_DIR = join(SOPHIE_AI_DIR, "state", "inbox", "slack");
691
+ const SLACK_INBOX_DIR = join(AGENT_REPO_DIR, "state", "inbox", "slack");
679
692
  try {
680
693
  const inboxFiles = readdirSync(SLACK_INBOX_DIR)
681
694
  .filter((f) => f.endsWith(".json") && !f.endsWith(".processed"))
@@ -704,10 +717,11 @@ export async function pollSlack() {
704
717
  const evt = data.slack_event || data.event || {};
705
718
  const sender = data.sender || resolveName(data.user || data.user_id || evt.user || "unknown");
706
719
 
707
- // Skip Sophie's own messages — the events server should filter these,
720
+ // Skip the agent's own messages — the events server should filter these,
708
721
  // but if any slip through (e.g. message_changed edge cases), catch here
709
722
  const userId = data.user || data.user_id || evt.user || "";
710
- if (userId === SOPHIE_USER_ID || sender === "sophie-nguyen") {
723
+ const myFullSlug = (loadAgent().fullName || "").toLowerCase().replace(/\s+/g, "-");
724
+ if (userId === AGENT_USER_ID || (myFullSlug && sender === myFullSlug)) {
711
725
  try { renameSync(join(SLACK_INBOX_DIR, file), join(SLACK_INBOX_DIR, file + ".processed")); } catch {}
712
726
  continue;
713
727
  }
@@ -750,7 +764,7 @@ export async function pollSlack() {
750
764
  from_ceo: privilege === "ceo",
751
765
  tagged_urgent: /\b(urgent|emergency|asap|blocker|critical)\b/i.test(content),
752
766
  contains_deadline: false,
753
- mentions_sophie: /sophie/i.test(content),
767
+ mentions_agent: loadAgent().firstName ? new RegExp(loadAgent().firstName, "i").test(content) : false,
754
768
  },
755
769
  raw_ref: eventRef,
756
770
  _inbox_file: file,
@@ -1,12 +1,27 @@
1
1
  // Priority Trigger — writes a priority task file when urgent items detected
2
- import { writeFileSync, mkdirSync } from "fs";
2
+ import { writeFileSync, mkdirSync, readFileSync } from "fs";
3
3
  import { join } from "path";
4
- import { SOPHIE_AI_DIR } from "./utils.mjs";
4
+ import { AGENT_REPO_DIR } from "./utils.mjs";
5
5
 
6
6
  const COOLDOWN_MS = 30_000;
7
7
  let lastTrigger = 0;
8
8
 
9
- export function triggerSophie(item) {
9
+ // Cached agent identity — used to label the "mentions agent" reason
10
+ // without hardcoding any one agent's name in the reason string.
11
+ let _agent = null;
12
+ function loadAgent() {
13
+ if (_agent) return _agent;
14
+ try {
15
+ _agent = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
16
+ } catch {
17
+ _agent = { firstName: "Agent" };
18
+ }
19
+ return _agent;
20
+ }
21
+
22
+ // Back-compat export: callers used to invoke `triggerSophie`. The function
23
+ // is fully agent-neutral now; both names map to the same implementation.
24
+ export function triggerAgent(item) {
10
25
  const now = Date.now();
11
26
  if (now - lastTrigger < COOLDOWN_MS) {
12
27
  console.log(
@@ -19,13 +34,13 @@ export function triggerSophie(item) {
19
34
  const reasons = [];
20
35
  if (item.priority_signals?.from_ceo) reasons.push("Message from CEO");
21
36
  if (item.priority_signals?.tagged_urgent) reasons.push("Tagged urgent");
22
- if (item.priority_signals?.mentions_sophie) reasons.push("Mentions Sophie");
37
+ if (item.priority_signals?.mentions_agent) reasons.push(`Mentions ${loadAgent().firstName}`);
23
38
  const reason = reasons.join(", ") || "Priority item";
24
39
 
25
40
  console.log(`[trigger] Priority event detected: ${reason}`);
26
41
 
27
42
  try {
28
- const dir = join(SOPHIE_AI_DIR, "state", "inbox", "internal");
43
+ const dir = join(AGENT_REPO_DIR, "state", "inbox", "internal");
29
44
  mkdirSync(dir, { recursive: true });
30
45
  const taskFile = join(dir, `priority-${Date.now()}.yaml`);
31
46
  let content = `type: priority_trigger
@@ -56,3 +71,5 @@ content: |
56
71
  return false;
57
72
  }
58
73
  }
74
+
75
+ export const triggerSophie = triggerAgent;
@@ -2,8 +2,8 @@
2
2
  """Pre-draft fact retrieval module — Memory Enhancement Phase 4.
3
3
 
4
4
  Pulls relevant entity context BEFORE composing any outbound message.
5
- Closes the gap where Sophie drafts messages without first checking what
6
- she knows about the recipient and related entities.
5
+ Closes the gap where the agent drafts messages without first checking what
6
+ they know about the recipient and related entities.
7
7
 
8
8
  Integration points — call this script before any of these send scripts:
9
9
  - scripts/send-email-threaded.py (email composition)
@@ -27,7 +27,7 @@ Usage:
27
27
  python3 scripts/rag-indexer.py --stats # show index statistics
28
28
  python3 scripts/rag-indexer.py --db /path/to/db # custom DB path
29
29
 
30
- Author: Sophie Nguyen, Chief of Staff
30
+ Author: Adaptic.ai
31
31
  Date: 2026-04-03
32
32
  """
33
33
 
@@ -43,7 +43,7 @@ from pathlib import Path
43
43
 
44
44
  # ── Constants ──────────────────────────────────────────────────────────────────
45
45
 
46
- BASE_DIR = Path(__file__).resolve().parent.parent # sophie-ai root
46
+ BASE_DIR = Path(os.environ.get("AGENT_DIR", str(Path(__file__).resolve().parent.parent))) # agent repo root
47
47
  DEFAULT_DB_PATH = BASE_DIR / "state" / "rag" / "search.db"
48
48
  INDEX_STATE_PATH = BASE_DIR / "state" / "rag" / "index-state.json"
49
49
 
@@ -590,7 +590,7 @@ def show_stats(db_path):
590
590
  def main():
591
591
  parser = argparse.ArgumentParser(
592
592
  description="SQLite FTS5 indexer for per-user ring-fenced RAG (Phase 2). "
593
- "Indexes sophie-ai content sources into a full-text search database.",
593
+ "Indexes the agent repo's content sources into a full-text search database.",
594
594
  epilog="Examples:\n"
595
595
  " %(prog)s # incremental index\n"
596
596
  " %(prog)s --full # full re-index\n"