@adaptic/maestro 1.5.1 → 1.6.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.
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  // =============================================================================
3
- // Sophie Daemon — Reactive event-driven message processor
3
+ // Agent Daemon — Reactive event-driven message processor
4
4
  // =============================================================================
5
5
  //
6
6
  // Persistent Node.js process that:
@@ -12,7 +12,7 @@
12
12
  // Replaces: poller + inbox-processor + backlog-executor (3-stage pipeline)
13
13
  // Target: CEO DM → response in under 2 minutes
14
14
  //
15
- // Run: node scripts/daemon/sophie-daemon.mjs
15
+ // Run: node scripts/daemon/<agent>-daemon.mjs (e.g. ravi-daemon.mjs)
16
16
  // Install: launchd plist with KeepAlive: true
17
17
  // =============================================================================
18
18
 
@@ -21,15 +21,28 @@ import { resolve, join } from "path";
21
21
  import { readdirSync, readFileSync, renameSync, mkdirSync, appendFileSync } from "fs";
22
22
 
23
23
  // Load .env before anything else
24
- const SOPHIE_AI_DIR = resolve(new URL(".", import.meta.url).pathname, "../..");
25
- config({ path: join(SOPHIE_AI_DIR, ".env") });
24
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || resolve(new URL(".", import.meta.url).pathname, "../..");
25
+ config({ path: join(AGENT_REPO_DIR, ".env") });
26
+
27
+ // Load agent identity (canonical SOT) so filters can match the running
28
+ // agent's own name/slack-id rather than a hardcoded one.
29
+ let _agent = null;
30
+ function loadAgent() {
31
+ if (_agent) return _agent;
32
+ try {
33
+ _agent = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
34
+ } catch {
35
+ _agent = { firstName: "Agent", lastName: "", slackMemberId: "" };
36
+ }
37
+ return _agent;
38
+ }
26
39
 
27
40
  import { pollSlack } from "../poller/slack-poller.mjs";
28
41
  import { pollGmail } from "../poller/gmail-poller.mjs";
29
42
  import { pollCalendar } from "../poller/calendar-poller.mjs";
30
43
  import { pollMehranGmail } from "../poller/mehran-gmail-poller.mjs";
31
44
  import { isPriorityItem } from "../poller/utils.mjs";
32
- import { classifyItem, isDirectedAtSophie } from "./classifier.mjs";
45
+ import { classifyItem, isDirectedAtAgent } from "./classifier.mjs";
33
46
  import { dispatch, getStatus, availableSlots, canDispatchBacklog, resetActiveSessions } from "./dispatcher.mjs";
34
47
  import { buildPrompt } from "./prompt-builder.mjs";
35
48
  import { sendQuickResponse, sendHoldingMessage, isQuickReply } from "./responder.mjs";
@@ -50,7 +63,7 @@ const HEALTH_INTERVAL = 60000; // 1 min
50
63
  // ---------------------------------------------------------------------------
51
64
 
52
65
  function logDir() {
53
- const dir = join(SOPHIE_AI_DIR, "logs", "daemon");
66
+ const dir = join(AGENT_REPO_DIR, "logs", "daemon");
54
67
  mkdirSync(dir, { recursive: true });
55
68
  return dir;
56
69
  }
@@ -91,9 +104,12 @@ async function poll() {
91
104
  // a single message is only processed once regardless of path.
92
105
  const seenRefs = new Set();
93
106
  const newItems = result.items.filter((item) => {
94
- // Skip Sophie's own messages — defensive check in case intake filters miss them
107
+ // Skip the agent's own messages — defensive check in case intake filters miss them
95
108
  const sender = (item.sender || "").toLowerCase();
96
- if (sender === "sophie-nguyen" || sender === "sophie") return false;
109
+ const me = loadAgent();
110
+ const myFirst = (me.firstName || "").toLowerCase();
111
+ const myFull = (me.fullName || "").toLowerCase().replace(/\s+/g, "-");
112
+ if (myFirst && (sender === myFirst || sender === myFull)) return false;
97
113
 
98
114
  const lockKey = item.raw_ref || item.id || `${svc.name}-${Date.now()}`;
99
115
  if (seenRefs.has(lockKey)) return false;
@@ -137,9 +153,11 @@ async function processItem(item, service) {
137
153
  const channelStr = (item.channel || "").toLowerCase();
138
154
  const channelId = item.channel_id || "";
139
155
  const isDm = channelStr.startsWith("dm/") || channelId.startsWith("D");
140
- const sophieInThread = !!(item.thread_context && /^Sophie:/m.test(item.thread_context));
156
+ const myFirstName = loadAgent().firstName || "Agent";
157
+ const agentThreadRegex = new RegExp(`^${myFirstName}:`, "m");
158
+ const agentInThread = !!(item.thread_context && agentThreadRegex.test(item.thread_context));
141
159
  item.is_dm = isDm;
142
- item.sophie_in_thread = sophieInThread;
160
+ item.agent_in_thread = agentInThread;
143
161
 
144
162
  // Classify via Haiku API
145
163
  const classResult = await classifyItem({
@@ -153,7 +171,7 @@ async function processItem(item, service) {
153
171
  subject: item.subject || "",
154
172
  is_dm: isDm,
155
173
  is_group: !isDm && service === "slack",
156
- sophie_in_thread: sophieInThread,
174
+ agent_in_thread: agentInThread,
157
175
  });
158
176
 
159
177
  recordClassification(true);
@@ -174,8 +192,8 @@ async function processItem(item, service) {
174
192
  }
175
193
 
176
194
  // DIRECTED-MESSAGE GATE: In channels and group chats, only respond to
177
- // messages that are clearly directed at Sophie. DMs always pass.
178
- // This prevents Sophie from inserting herself into every conversation.
195
+ // messages that are clearly directed at the agent. DMs always pass.
196
+ // This prevents the agent from inserting itself into every conversation.
179
197
  //
180
198
  // Two-layer check:
181
199
  // 1. If LLM says NOT directed → verify with rules (catch missed @mentions, CEO, DMs)
@@ -183,10 +201,10 @@ async function processItem(item, service) {
183
201
  if (service === "slack") {
184
202
  const isDm = item.is_dm || (item.channel || "").startsWith("dm/") || (item.channel_id || "").startsWith("D");
185
203
 
186
- if (!classResult.directed_at_sophie) {
204
+ if (!classResult.directed_at_agent) {
187
205
  // LLM says not directed — double-check with rule-based heuristics
188
206
  // to catch clear signals the LLM may have missed (DM, CEO, @mention)
189
- const ruleCheck = isDirectedAtSophie(item);
207
+ const ruleCheck = isDirectedAtAgent(item);
190
208
  if (!ruleCheck) {
191
209
  console.log(`[daemon] Directed-message filter: skipping non-directed message from ${item.sender} in ${item.channel}`);
192
210
  logEvent("classifications", {
@@ -194,7 +212,7 @@ async function processItem(item, service) {
194
212
  sender: item.sender,
195
213
  service,
196
214
  skipped: true,
197
- reason: "not_directed_at_sophie",
215
+ reason: "not_directed_at_agent",
198
216
  classifier_directed: false,
199
217
  rule_directed: false,
200
218
  summary: classResult.summary,
@@ -207,11 +225,16 @@ async function processItem(item, service) {
207
225
  } else if (!isDm) {
208
226
  // LLM says directed in a channel/group — sanity-check with rules.
209
227
  // If rule-based also agrees, proceed. If rules say no AND the message
210
- // doesn't contain Sophie's name, the LLM was probably over-eager.
211
- const ruleCheck = isDirectedAtSophie(item);
228
+ // doesn't contain the agent's name, the LLM was probably over-eager.
229
+ const ruleCheck = isDirectedAtAgent(item);
212
230
  const content = (item.content || "").toLowerCase();
213
- const mentionsSophie = content.includes("sophie") || content.includes("<@U09");
214
- if (!ruleCheck && !mentionsSophie) {
231
+ const me = loadAgent();
232
+ const myFirst = (me.firstName || "").toLowerCase();
233
+ const mySlackPrefix = (me.slackMemberId || "").slice(0, 3); // e.g. "U09"
234
+ const mentionsAgent =
235
+ (myFirst && content.includes(myFirst)) ||
236
+ (mySlackPrefix && content.includes(`<@${mySlackPrefix}`));
237
+ if (!ruleCheck && !mentionsAgent) {
215
238
  console.log(`[daemon] Directed-message filter (LLM override): LLM said directed but rules disagree for ${item.sender} in ${item.channel} — skipping`);
216
239
  logEvent("classifications", {
217
240
  item_id: itemId,
@@ -330,7 +353,7 @@ function markProcessed(item, service) {
330
353
  // The pollers write files to state/inbox/{service}/
331
354
  // We mark them by renaming to .processed
332
355
  try {
333
- const inboxDir = join(SOPHIE_AI_DIR, "state", "inbox", service);
356
+ const inboxDir = join(AGENT_REPO_DIR, "state", "inbox", service);
334
357
  const files = readdirSync(inboxDir).filter(
335
358
  (f) => !f.endsWith(".processed") && (f.includes(item.id) || f.includes(item.raw_ref))
336
359
  );
@@ -353,7 +376,7 @@ async function sweepBacklog() {
353
376
  if (slots <= 0) return; // No capacity
354
377
 
355
378
  try {
356
- const queueDir = join(SOPHIE_AI_DIR, "state", "queues");
379
+ const queueDir = join(AGENT_REPO_DIR, "state", "queues");
357
380
  const files = readdirSync(queueDir).filter((f) => f.endsWith(".yaml"));
358
381
  const actionableItems = [];
359
382
 
@@ -450,9 +473,9 @@ async function sweepBacklog() {
450
473
 
451
474
  async function main() {
452
475
  console.log("╔══════════════════════════════════════════════════════════╗");
453
- console.log("║ Sophie Daemon — Reactive Event Processor ║");
476
+ console.log(`║ ${(loadAgent().firstName || "Agent").padEnd(8)} Daemon — Reactive Event Processor ║`);
454
477
  console.log("╠══════════════════════════════════════════════════════════╣");
455
- console.log(`║ Directory: ${SOPHIE_AI_DIR}`);
478
+ console.log(`║ Directory: ${AGENT_REPO_DIR}`);
456
479
  console.log(`║ Poll: every ${POLL_INTERVAL / 1000}s`);
457
480
  console.log(`║ Backlog: every ${BACKLOG_INTERVAL / 1000}s`);
458
481
  console.log(`║ Concurrency: up to ${process.env.DAEMON_MAX_CONCURRENT || 10} parallel sessions`);
@@ -460,7 +483,7 @@ async function main() {
460
483
 
461
484
  // Check for emergency stop
462
485
  try {
463
- readFileSync(join(SOPHIE_AI_DIR, ".emergency-stop"));
486
+ readFileSync(join(AGENT_REPO_DIR, ".emergency-stop"));
464
487
  console.error("[daemon] Emergency stop active — exiting");
465
488
  process.exit(0);
466
489
  } catch {
@@ -481,7 +504,7 @@ async function main() {
481
504
  setInterval(async () => {
482
505
  try {
483
506
  // Check emergency stop
484
- try { readFileSync(join(SOPHIE_AI_DIR, ".emergency-stop")); process.exit(0); } catch {}
507
+ try { readFileSync(join(AGENT_REPO_DIR, ".emergency-stop")); process.exit(0); } catch {}
485
508
  await poll();
486
509
  } catch (err) {
487
510
  console.error("[daemon] Poll loop error:", err.message);
@@ -8,16 +8,16 @@
8
8
  set -e
9
9
 
10
10
  TRIGGER_NAME="${1:?Trigger name required}"
11
- SOPHIE_AI_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
- PROMPT_FILE="$SOPHIE_AI_DIR/schedules/triggers/$TRIGGER_NAME.md"
13
- CLAUDE_BIN="/Users/sophie/.local/bin/claude"
14
- LOG_DIR="$SOPHIE_AI_DIR/logs/workflows"
11
+ AGENT_REPO_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
12
+ PROMPT_FILE="$AGENT_REPO_DIR/schedules/triggers/$TRIGGER_NAME.md"
13
+ CLAUDE_BIN="${CLAUDE_BIN:-claude}"
14
+ LOG_DIR="$AGENT_REPO_DIR/logs/workflows"
15
15
  TIMESTAMP=$(date -u +"%Y-%m-%dT%H:%M:%SZ")
16
16
  DATE=$(date +%Y-%m-%d)
17
17
  LOG_FILE="$LOG_DIR/$DATE-$TRIGGER_NAME.jsonl"
18
18
 
19
19
  # Check emergency stop
20
- if [ -f "$SOPHIE_AI_DIR/.emergency-stop" ]; then
20
+ if [ -f "$AGENT_REPO_DIR/.emergency-stop" ]; then
21
21
  echo "{\"timestamp\":\"$TIMESTAMP\",\"trigger\":\"$TRIGGER_NAME\",\"status\":\"blocked\",\"reason\":\"emergency-stop\"}" >> "$LOG_FILE"
22
22
  exit 0
23
23
  fi
@@ -36,7 +36,7 @@ mkdir -p "$LOG_DIR"
36
36
  echo "{\"timestamp\":\"$TIMESTAMP\",\"trigger\":\"$TRIGGER_NAME\",\"status\":\"started\"}" >> "$LOG_FILE"
37
37
 
38
38
  # Run Claude Code in non-interactive mode
39
- cd "$SOPHIE_AI_DIR"
39
+ cd "$AGENT_REPO_DIR"
40
40
  "$CLAUDE_BIN" --print --dangerously-skip-permissions "$PROMPT" >> "$LOG_DIR/$DATE-$TRIGGER_NAME.log" 2>&1
41
41
 
42
42
  EXIT_CODE=$?
@@ -17,19 +17,36 @@
17
17
  */
18
18
 
19
19
  import { execaCommand } from "execa";
20
- import { existsSync } from "fs";
20
+ import { existsSync, readFileSync } from "fs";
21
21
  import fs from "fs/promises";
22
22
  import path from "path";
23
23
  import { fileURLToPath } from "url";
24
24
 
25
25
  const __filename = fileURLToPath(import.meta.url);
26
26
  const __dirname = path.dirname(__filename);
27
- const ROOT = path.resolve(__dirname, "../..");
27
+ const ROOT = process.env.AGENT_DIR || path.resolve(__dirname, "../..");
28
+
29
+ // Load agent identity once so --author defaults to the running agent.
30
+ let _agent = null;
31
+ function loadAgent() {
32
+ if (_agent) return _agent;
33
+ try {
34
+ _agent = JSON.parse(readFileSync(path.join(ROOT, "config/agent.json"), "utf-8"));
35
+ } catch {
36
+ _agent = { fullName: "Agent", title: "" };
37
+ }
38
+ return _agent;
39
+ }
40
+ function defaultAuthor() {
41
+ const a = loadAgent();
42
+ return a.title ? `${a.fullName}, ${a.title}` : a.fullName;
43
+ }
28
44
 
29
45
  const TEMPLATES_DIR = path.join(__dirname, "templates");
30
- const XELATEX_PATH =
31
- process.env.XELATEX_PATH ||
32
- "/Users/sophie/Library/TinyTeX/bin/universal-darwin/xelatex";
46
+ // xelatex location: env override, otherwise let PATH resolve.
47
+ // (Hardcoded per-user TinyTeX paths broke portability — agents now rely on
48
+ // `which xelatex` resolution at exec time, with XELATEX_PATH as escape hatch.)
49
+ const XELATEX_PATH = process.env.XELATEX_PATH || "xelatex";
33
50
 
34
51
  // Template configurations
35
52
  const TEMPLATES = {
@@ -58,7 +75,7 @@ function parseArgs() {
58
75
  template: "memo",
59
76
  output: null,
60
77
  title: null,
61
- author: "Sophie Nguyen, Chief of Staff",
78
+ author: defaultAuthor(),
62
79
  date: new Date().toISOString().split("T")[0],
63
80
  };
64
81
 
@@ -107,7 +124,7 @@ Options:
107
124
  --template, -t Template name (default: memo)
108
125
  --output, -o Output PDF path (default: same dir as input)
109
126
  --title Document title (overrides markdown title)
110
- --author Author name (default: Sophie Nguyen, Chief of Staff)
127
+ --author Author name (default: from config/agent.json)
111
128
  --date Document date (default: today)
112
129
  --help, -h Show this help
113
130
 
@@ -1,25 +1,37 @@
1
- // Gmail Poller — polls Sophie's inbox via native Node.js IMAP.
2
- // Uses imapflow + mailparser to fetch unseen emails from sophie@adaptic.ai
3
- // and write them as .json files to state/inbox/gmail/.
1
+ // Gmail Poller — polls the agent's inbox via native Node.js IMAP.
2
+ // Uses imapflow + mailparser to fetch unseen emails from the agent's
3
+ // configured email address (config/agent.json#/email) and write them as
4
+ // .json files to state/inbox/gmail/.
4
5
  //
5
- // Previously depended on scripts/sophie-inbox-poller.py (now retired).
6
- // All IMAP logic is now in imap-client.mjs, shared with Mehran's poller.
6
+ // All IMAP logic is in imap-client.mjs, shared with the principal poller.
7
7
 
8
8
  import { readFileSync, readdirSync, renameSync } from "fs";
9
9
  import { join } from "path";
10
- import { readCursor, writeCursor, SOPHIE_AI_DIR } from "./utils.mjs";
10
+ import { readCursor, writeCursor, AGENT_REPO_DIR } from "./utils.mjs";
11
11
  import { pollImapInbox } from "./imap-client.mjs";
12
12
 
13
- const GMAIL_INBOX_DIR = join(SOPHIE_AI_DIR, "state", "inbox", "gmail");
13
+ const GMAIL_INBOX_DIR = join(AGENT_REPO_DIR, "state", "inbox", "gmail");
14
14
  const MAX_ITEMS_PER_POLL = 10;
15
15
 
16
+ // Load agent identity once. Email + first-name used as account labels.
17
+ let _agent = null;
18
+ function loadAgent() {
19
+ if (_agent) return _agent;
20
+ try {
21
+ _agent = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
22
+ } catch {
23
+ _agent = { firstName: "agent", email: "" };
24
+ }
25
+ return _agent;
26
+ }
27
+
16
28
  /**
17
- * Load Sophie's Gmail app password from env or .env file.
29
+ * Load the agent's Gmail app password from env or the agent's .env file.
18
30
  */
19
- function loadSophiePassword() {
31
+ function loadGmailPassword() {
20
32
  if (process.env.GMAIL_APP_PASSWORD) return process.env.GMAIL_APP_PASSWORD;
21
33
  try {
22
- const envFile = readFileSync(join(SOPHIE_AI_DIR, ".env"), "utf-8");
34
+ const envFile = readFileSync(join(AGENT_REPO_DIR, ".env"), "utf-8");
23
35
  for (const line of envFile.split("\n")) {
24
36
  if (line.startsWith("GMAIL_APP_PASSWORD=")) {
25
37
  let val = line.split("=", 2)[1].trim();
@@ -63,21 +75,23 @@ export async function pollGmail() {
63
75
  const items = [];
64
76
  const errors = [];
65
77
 
66
- // Run native IMAP poll for sophie@adaptic.ai
78
+ // Run native IMAP poll for the agent's configured email address.
79
+ const me = loadAgent();
80
+ const agentSlug = (me.firstName || "agent").toLowerCase();
67
81
  try {
68
82
  const result = await pollImapInbox({
69
- email: "sophie@adaptic.ai",
70
- password: loadSophiePassword(),
71
- account: "sophie",
83
+ email: me.email,
84
+ password: loadGmailPassword(),
85
+ account: agentSlug,
72
86
  fileSuffix: "email",
73
- eventType: "sophie_email",
74
- logPrefix: "[sophie-gmail]",
87
+ eventType: `${agentSlug}_email`,
88
+ logPrefix: `[${agentSlug}-gmail]`,
75
89
  });
76
90
  if (result.errors.length > 0) {
77
91
  errors.push(...result.errors);
78
92
  }
79
93
  } catch (err) {
80
- const msg = `Sophie Gmail IMAP poll failed: ${err.message}`;
94
+ const msg = `Agent Gmail IMAP poll failed: ${err.message}`;
81
95
  console.error(`[gmail] ${msg}`);
82
96
  errors.push(msg);
83
97
  }
@@ -126,7 +140,7 @@ export async function pollGmail() {
126
140
  from_ceo: privilege === "ceo",
127
141
  tagged_urgent: /urgent|asap|critical/i.test(email.subject || ""),
128
142
  contains_deadline: /deadline|due|by\s+(monday|tuesday|wednesday|thursday|friday|tomorrow|end of)/i.test(email.subject || ""),
129
- mentions_sophie: /sophie/i.test(email.subject || ""),
143
+ mentions_agent: new RegExp(agentSlug, "i").test(email.subject || ""),
130
144
  },
131
145
  raw_ref: `gmail:${id}`,
132
146
  _inbox_file: file,
@@ -3,11 +3,15 @@ import { join, dirname, extname } from "path";
3
3
  import { fileURLToPath } from "url";
4
4
 
5
5
  const __dirname = dirname(fileURLToPath(import.meta.url));
6
- export const SOPHIE_AI_DIR = join(__dirname, "../..");
7
- export const ATTACHMENTS_DIR = join(SOPHIE_AI_DIR, "state", "inbox", "attachments");
6
+ export const AGENT_REPO_DIR = process.env.AGENT_DIR || join(__dirname, "../..");
7
+
8
+ // Re-export under legacy name for any external script still importing
9
+ // SOPHIE_AI_DIR from this module. New code should use AGENT_REPO_DIR.
10
+ export const SOPHIE_AI_DIR = AGENT_REPO_DIR;
11
+ export const ATTACHMENTS_DIR = join(AGENT_REPO_DIR, "state", "inbox", "attachments");
8
12
 
9
13
  export function writeInboxItem(service, item) {
10
- const dir = join(SOPHIE_AI_DIR, "state", "inbox", service);
14
+ const dir = join(AGENT_REPO_DIR, "state", "inbox", service);
11
15
  mkdirSync(dir, { recursive: true });
12
16
  const ts = item.timestamp.replace(/[:.]/g, "-");
13
17
  const filename = `${ts}-${item.id}.yaml`;
@@ -42,7 +46,7 @@ priority_signals:
42
46
  from_ceo: ${item.priority_signals?.from_ceo || false}
43
47
  tagged_urgent: ${item.priority_signals?.tagged_urgent || false}
44
48
  contains_deadline: ${item.priority_signals?.contains_deadline || false}
45
- mentions_sophie: ${item.priority_signals?.mentions_sophie || false}
49
+ mentions_agent: ${item.priority_signals?.mentions_agent || false}
46
50
  raw_ref: "${item.raw_ref || ""}"
47
51
  `;
48
52
 
@@ -154,7 +158,7 @@ export function extractSlackAttachments(files) {
154
158
 
155
159
  /**
156
160
  * Add an emoji reaction to a Slack message.
157
- * Used by Sophie to acknowledge, approve, or signal status on messages.
161
+ * Used by the agent to acknowledge, approve, or signal status on messages.
158
162
  *
159
163
  * @param {string} channel - Slack channel ID
160
164
  * @param {string} timestamp - Message timestamp (ts)
@@ -188,7 +192,7 @@ export async function addSlackReaction(channel, timestamp, emoji, token) {
188
192
 
189
193
  export function readCursor(service) {
190
194
  const path = join(
191
- SOPHIE_AI_DIR,
195
+ AGENT_REPO_DIR,
192
196
  "state",
193
197
  "polling",
194
198
  `${service}-cursor.yaml`,
@@ -205,7 +209,7 @@ export function readCursor(service) {
205
209
 
206
210
  export function writeCursor(service, timestamp) {
207
211
  const path = join(
208
- SOPHIE_AI_DIR,
212
+ AGENT_REPO_DIR,
209
213
  "state",
210
214
  "polling",
211
215
  `${service}-cursor.yaml`,
@@ -222,7 +226,7 @@ stats:
222
226
  }
223
227
 
224
228
  export function appendLog(service, entry) {
225
- const dir = join(SOPHIE_AI_DIR, "logs", "polling");
229
+ const dir = join(AGENT_REPO_DIR, "logs", "polling");
226
230
  mkdirSync(dir, { recursive: true });
227
231
  const today = new Date().toISOString().split("T")[0];
228
232
  const path = join(dir, `${today}-${service}.jsonl`);
@@ -234,15 +238,31 @@ export function isPriorityItem(item) {
234
238
  return (
235
239
  item.priority_signals?.from_ceo ||
236
240
  item.priority_signals?.tagged_urgent ||
237
- item.priority_signals?.mentions_sophie
241
+ item.priority_signals?.mentions_agent
238
242
  );
239
243
  }
240
244
 
241
- // Known user IDs for privilege resolution
242
- const KNOWN_USERS = {
243
- U097N5R0M7U: { name: "mehran-granfar", privilege: "ceo" },
244
- U099N1JFPRQ: { name: "sophie-nguyen", privilege: "system" },
245
- };
245
+ // Known user IDs for privilege resolution — derived from config/agent.json so
246
+ // the agent's own Slack ID gets "system" privilege and the principal's gets "ceo".
247
+ const KNOWN_USERS = (() => {
248
+ const map = {};
249
+ try {
250
+ const a = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
251
+ if (a.principal?.slackMemberId) {
252
+ map[a.principal.slackMemberId] = {
253
+ name: (a.principal.fullName || "").toLowerCase().replace(/\s+/g, "-"),
254
+ privilege: "ceo",
255
+ };
256
+ }
257
+ if (a.slackMemberId) {
258
+ map[a.slackMemberId] = {
259
+ name: (a.fullName || "").toLowerCase().replace(/\s+/g, "-"),
260
+ privilege: "system",
261
+ };
262
+ }
263
+ } catch { /* no agent.json yet — empty registry */ }
264
+ return map;
265
+ })();
246
266
 
247
267
  export function resolvePrivilege(userId) {
248
268
  return KNOWN_USERS[userId]?.privilege || "unknown";
@@ -65,9 +65,23 @@ try:
65
65
  except ImportError:
66
66
  _PRE_DRAFT_AVAILABLE = False
67
67
 
68
- USER = "sophie@adaptic.ai"
69
- PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "nqinfcgnhzmjucho")
70
- LOGO_URL = "https://ci3.googleusercontent.com/mail-sig/AIorK4xNDnPN6a0MjSHV5urb9g11u2CzHIzwdJbwN21quGjzQFdbOdegCq1Wp6lq3NCqsARbi-ooqPfE9E1D"
68
+ # ── Agent identity from config/agent.json (canonical SOT) ──────────────
69
+ # Loaded once at module load so every call picks up the running agent's
70
+ # email, full name, title, and phone without hardcoded values.
71
+ import json
72
+ from pathlib import Path
73
+ _AGENT_REPO_DIR = Path(os.environ.get("AGENT_DIR", str(Path(__file__).resolve().parent.parent)))
74
+ try:
75
+ _AGENT = json.loads((_AGENT_REPO_DIR / "config" / "agent.json").read_text())
76
+ except Exception:
77
+ _AGENT = {"firstName": "Agent", "fullName": "Agent", "email": "agent@example.com", "title": "Agent", "phone": ""}
78
+
79
+ USER = _AGENT.get("email", "agent@example.com")
80
+ PASSWORD = os.environ.get("GMAIL_APP_PASSWORD", "")
81
+ LOGO_URL = os.environ.get(
82
+ "EMAIL_SIGNATURE_LOGO_URL",
83
+ "https://ci3.googleusercontent.com/mail-sig/AIorK4xNDnPN6a0MjSHV5urb9g11u2CzHIzwdJbwN21quGjzQFdbOdegCq1Wp6lq3NCqsARbi-ooqPfE9E1D",
84
+ )
71
85
 
72
86
  def get_thread_message_ids(subject_search):
73
87
  """Fetch real Message-IDs from Gmail via IMAP for threading."""
@@ -104,15 +118,21 @@ def mark_as_read(sender):
104
118
  mail.logout()
105
119
 
106
120
  def build_signature_html():
121
+ full_name = _AGENT.get("fullName", "Agent")
122
+ title = _AGENT.get("title", "")
123
+ phone = _AGENT.get("phone", "")
124
+ phone_line = f'<div style="font-size:x-small;color:#000;">{phone}</div>\n<div><br></div>\n' if phone else ""
125
+ address = os.environ.get(
126
+ "EMAIL_SIGNATURE_ADDRESS",
127
+ "Level 1, Innovation One, Dubai International Financial Centre, Dubai, UAE",
128
+ )
107
129
  return f"""<div>
108
- <div style="font-size:small;"><b>Sophie Nguyen</b></div>
109
- <div style="font-size:small;">Chief of Staff</div>
130
+ <div style="font-size:small;"><b>{full_name}</b></div>
131
+ <div style="font-size:small;">{title}</div>
110
132
  <div><br></div>
111
133
  <div><img src="{LOGO_URL}" width="125"></div>
112
134
  <div><br></div>
113
- <div style="font-size:x-small;color:#000;">+1 628 265 6712 (USA)</div>
114
- <div><br></div>
115
- <div style="font-size:x-small;color:#000;">Level 1, Innovation One, Dubai International Financial Centre, Dubai, UAE</div>
135
+ {phone_line}<div style="font-size:x-small;color:#000;">{address}</div>
116
136
  <div style="font-size:x-small;color:#666;">_________________________________________</div>
117
137
  <div style="font-size:x-small;color:#999;">This email and any files transmitted with it are confidential and intended solely for the use of the individual or entity to whom they are addressed. If you have received this email in error please notify the sender. This message contains confidential information and is intended only for the individual named. If you are not the named addressee you should not disseminate, distribute or copy this email. Please notify the sender immediately by email if you have received this email by mistake and delete this email from your system. If you are not the intended recipient you are notified that disclosing, copying, distributing, or taking any action in reliance on the contents of this information is strictly prohibited.</div>
118
138
  </div>"""
@@ -247,7 +267,7 @@ def send_email(to, subject, body, reply_subject=None, attachments=None, cc=None,
247
267
  else:
248
268
  msg = MIMEMultipart('alternative')
249
269
 
250
- msg['From'] = 'Sophie Nguyen <sophie@adaptic.ai>'
270
+ msg['From'] = f"{_AGENT.get('fullName', 'Agent')} <{USER}>"
251
271
  msg['To'] = to
252
272
  if cc:
253
273
  msg['Cc'] = cc
@@ -319,7 +339,7 @@ if __name__ == '__main__':
319
339
  sys.exit(1)
320
340
 
321
341
  import argparse
322
- parser = argparse.ArgumentParser(description="Send threaded HTML email as Sophie Nguyen via Gmail SMTP")
342
+ parser = argparse.ArgumentParser(description=f"Send threaded HTML email as {_AGENT.get('fullName', 'the agent')} via Gmail SMTP")
323
343
  parser.add_argument("to", help="Recipient email address")
324
344
  parser.add_argument("subject", help="Email subject line")
325
345
  parser.add_argument("body", help="Email body text (newlines become <br> in HTML)")
@@ -3,7 +3,7 @@
3
3
 
4
4
  Usage: python3 send-email-with-attachment.py <to> <subject> <body> [--cc email] [--attachment path] [--reply-to-subject "original subject"] [--in-reply-to "<message-id>"]
5
5
 
6
- Sends as Sophie Nguyen (sophie@adaptic.ai) with branded signature block.
6
+ Sends as the running agent (identity from config/agent.json) with branded signature block.
7
7
  Supports PNG, PDF, and other file attachments via MIME.
8
8
  """
9
9
  import sys
@@ -43,8 +43,19 @@ try:
43
43
  except ImportError:
44
44
  _PRE_DRAFT_AVAILABLE = False
45
45
 
46
- USER = "sophie@adaptic.ai"
47
- LOGO_URL = "https://ci3.googleusercontent.com/mail-sig/AIorK4xNDnPN6a0MjSHV5urb9g11u2CzHIzwdJbwN21quGjzQFdbOdegCq1Wp6lq3NCqsARbi-ooqPfE9E1D"
46
+ import json
47
+ from pathlib import Path
48
+ _AGENT_REPO_DIR = Path(os.environ.get("AGENT_DIR", str(Path(__file__).resolve().parent.parent)))
49
+ try:
50
+ _AGENT = json.loads((_AGENT_REPO_DIR / "config" / "agent.json").read_text())
51
+ except Exception:
52
+ _AGENT = {"firstName": "Agent", "fullName": "Agent", "email": "agent@example.com", "title": "Agent", "phone": ""}
53
+
54
+ USER = _AGENT.get("email", "agent@example.com")
55
+ LOGO_URL = os.environ.get(
56
+ "EMAIL_SIGNATURE_LOGO_URL",
57
+ "https://ci3.googleusercontent.com/mail-sig/AIorK4xNDnPN6a0MjSHV5urb9g11u2CzHIzwdJbwN21quGjzQFdbOdegCq1Wp6lq3NCqsARbi-ooqPfE9E1D",
58
+ )
48
59
 
49
60
 
50
61
  def load_password():
@@ -124,15 +135,21 @@ def mark_as_read(sender):
124
135
 
125
136
 
126
137
  def build_signature_html():
138
+ full_name = _AGENT.get("fullName", "Agent")
139
+ title = _AGENT.get("title", "")
140
+ phone = _AGENT.get("phone", "")
141
+ phone_line = f'<div style="font-size:x-small;color:#000;">{phone}</div>\n<div><br></div>\n' if phone else ""
142
+ address = os.environ.get(
143
+ "EMAIL_SIGNATURE_ADDRESS",
144
+ "Level 1, Innovation One, Dubai International Financial Centre, Dubai, UAE",
145
+ )
127
146
  return f"""<div>
128
- <div style="font-size:small;"><b>Sophie Nguyen</b></div>
129
- <div style="font-size:small;">Chief of Staff</div>
147
+ <div style="font-size:small;"><b>{full_name}</b></div>
148
+ <div style="font-size:small;">{title}</div>
130
149
  <div><br></div>
131
150
  <div><img src="{LOGO_URL}" width="125"></div>
132
151
  <div><br></div>
133
- <div style="font-size:x-small;color:#000;">+1 628 265 6712 (USA)</div>
134
- <div><br></div>
135
- <div style="font-size:x-small;color:#000;">Level 1, Innovation One, Dubai International Financial Centre, Dubai, UAE</div>
152
+ {phone_line}<div style="font-size:x-small;color:#000;">{address}</div>
136
153
  <div style="font-size:x-small;color:#666;">_________________________________________</div>
137
154
  <div style="font-size:x-small;color:#999;">This email and any files transmitted with it are confidential and intended solely for the use of the individual or entity to whom they are addressed. If you have received this email in error please notify the sender. This message contains confidential information and is intended only for the individual named. If you are not the named addressee you should not disseminate, distribute or copy this email. Please notify the sender immediately by email if you have received this email by mistake and delete this email from your system. If you are not the intended recipient you are notified that disclosing, copying, distributing, or taking any action in reliance on the contents of this information is strictly prohibited.</div>
138
155
  </div>"""
@@ -238,7 +255,7 @@ def send_email(to, subject, body, cc=None, attachment=None, reply_subject=None,
238
255
  pass # fail-open
239
256
 
240
257
  msg = MIMEMultipart('mixed')
241
- msg['From'] = 'Sophie Nguyen <sophie@adaptic.ai>'
258
+ msg['From'] = f"{_AGENT.get('fullName', 'Agent')} <{USER}>"
242
259
  msg['To'] = to
243
260
  msg['Subject'] = subject
244
261
  msg['Date'] = formatdate(localtime=True)