@adaptic/maestro 1.5.2 → 1.6.1

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.
@@ -30,59 +30,67 @@ const __dirname = dirname(__filename);
30
30
  dotenv.config({ path: resolve(__dirname, "../../.env") });
31
31
 
32
32
  // ── Agent identity + peer agents ──────────────────────────────────────────
33
- // Loaded from config/known-agents.json and AGENT_DIR to determine which
34
- // agent is "me" and which are peers. Prevents cross-agent message
35
- // interception in shared channels (ib-20260418-cross-agent-routing).
33
+ // Primary source: config/agent.json (canonical SOT created by /init-maestro
34
+ // or migration). Falls back to config/known-agents.json registry for the
35
+ // peers list. Prevents cross-agent message interception in shared channels
36
+ // (ib-20260418-cross-agent-routing).
36
37
 
37
38
  const AGENT_DIR = process.env.AGENT_DIR || process.env.AGENT_ROOT || resolve(__dirname, "../..");
38
39
 
39
- let _agentIdentity = null; // { name, slackId, role }
40
+ let _agentIdentity = null; // { name, slackId, role, fullName, title }
40
41
  let _peerAgents = []; // [{ name, slackId, role }] — all agents EXCEPT me
41
42
 
42
43
  function loadAgentRegistry() {
43
44
  if (_agentIdentity) return; // already loaded
44
45
 
45
- // 1. Load known-agents.json (try agent repo first, then maestro scaffold)
46
- let agents = [];
47
- for (const base of [AGENT_DIR, resolve(process.env.HOME || "", "maestro")]) {
48
- try {
49
- const raw = readFileSync(resolve(base, "config/known-agents.json"), "utf-8");
50
- agents = JSON.parse(raw).agents || [];
51
- if (agents.length > 0) break;
52
- } catch { /* try next */ }
53
- }
54
-
55
- // 2. Determine which agent is "me" — match by AGENT_DIR basename
56
- const repoSlug = AGENT_DIR.split("/").pop(); // e.g. "sophie-ai"
57
- const me = agents.find(a => a.repo === repoSlug);
58
-
59
- if (me) {
60
- _agentIdentity = me;
61
- _peerAgents = agents.filter(a => a.slackId !== me.slackId);
62
- } else {
63
- // Fallback: try to extract from agent.ts or CLAUDE.md
46
+ // 1. Primary: load THIS agent's identity from config/agent.json (SOT).
47
+ try {
48
+ const me = JSON.parse(readFileSync(resolve(AGENT_DIR, "config/agent.json"), "utf-8"));
49
+ _agentIdentity = {
50
+ name: me.firstName,
51
+ fullName: me.fullName,
52
+ slackId: me.slackMemberId || "",
53
+ role: me.title || "agent",
54
+ repo: me.repoSlug,
55
+ };
56
+ } catch {
57
+ // Backwards compatibility for repos that haven't migrated yet.
64
58
  try {
65
59
  const agentTs = readFileSync(resolve(AGENT_DIR, "config/agent.ts"), "utf-8");
66
60
  const nameMatch = agentTs.match(/firstName:\s*['"](\w+)['"]/);
67
61
  const slackMatch = agentTs.match(/slackMemberId:\s*['"]([^'"]+)['"]/);
68
62
  _agentIdentity = {
69
- name: nameMatch ? nameMatch[1] : "Unknown",
63
+ name: nameMatch ? nameMatch[1] : "Agent",
70
64
  slackId: slackMatch ? slackMatch[1] : "",
71
65
  role: "agent",
72
66
  };
73
- _peerAgents = agents.filter(a => a.slackId !== (_agentIdentity.slackId || "NONE"));
74
67
  } catch {
75
- _agentIdentity = { name: "Sophie", slackId: "", role: "agent" };
76
- _peerAgents = agents;
68
+ _agentIdentity = { name: "Agent", slackId: "", role: "agent" };
77
69
  }
78
70
  }
79
71
 
72
+ // 2. Load known-agents.json (peers) — try agent repo first, then maestro install.
73
+ let agents = [];
74
+ for (const base of [AGENT_DIR, resolve(process.env.HOME || "", "maestro")]) {
75
+ try {
76
+ const raw = readFileSync(resolve(base, "config/known-agents.json"), "utf-8");
77
+ agents = JSON.parse(raw).agents || [];
78
+ if (agents.length > 0) break;
79
+ } catch { /* try next */ }
80
+ }
81
+
82
+ const mySlackId = _agentIdentity.slackId;
83
+ _peerAgents = mySlackId
84
+ ? agents.filter(a => a.slackId !== mySlackId)
85
+ : agents;
86
+
80
87
  console.log(`[classifier] Agent identity: ${_agentIdentity.name} (${_agentIdentity.slackId || "no slack ID"}), ${_peerAgents.length} peer agents`);
81
88
  }
82
89
 
83
90
  const ANTHROPIC_MODEL = "claude-haiku-4-5-20251001";
84
91
  const OPENAI_MODEL = "gpt-4o-mini";
85
- const CLAUDE_BIN = process.env.CLAUDE_BIN || "/Users/sophie/.local/bin/claude";
92
+ // Default to `claude` from PATH; CLAUDE_BIN env var overrides for non-standard installs.
93
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
86
94
  const CLAUDE_CLI_TIMEOUT_MS = 30_000;
87
95
 
88
96
  // ── System prompt shared by both LLM classifiers ────────────────────────────
@@ -107,7 +115,7 @@ Your job: classify each incoming message and return a JSON object with exactly t
107
115
  "model": "opus" | "sonnet",
108
116
  "summary": "<one-line summary of the message>",
109
117
  "category": "action_required" | "fyi" | "ignore",
110
- "directed_at_sophie": true | false
118
+ "directed_at_agent": true | false
111
119
  }
112
120
  ${peerContext}
113
121
  Classification rules:
@@ -131,11 +139,11 @@ MODEL (which Claude model should handle the response):
131
139
  - "sonnet": Routine responses, scheduling, status updates, simple follow-ups, acknowledgements, standard operational tasks
132
140
 
133
141
  CATEGORY:
134
- - "action_required": Sophie needs to do something
142
+ - "action_required": ${agentName} needs to do something
135
143
  - "fyi": Informational, no action needed
136
144
  - "ignore": No value
137
145
 
138
- DIRECTED_AT_SOPHIE (CRITICAL — determines whether ${agentName} should respond. Default to false in channels/group chats.):
146
+ DIRECTED_AT_AGENT (CRITICAL — determines whether ${agentName} should respond. Default to false in channels/group chats.):
139
147
  - true: The message is a DM to ${agentName} (1:1)
140
148
  - true: The message explicitly mentions ${agentName} by name or @mention
141
149
  - true: The message is from the CEO in a DM (1:1 conversation)
@@ -164,43 +172,43 @@ Context:
164
172
  Examples:
165
173
 
166
174
  Input: CEO DM on Slack: "Can you send the board resolution to Nima?"
167
- Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO requesting board resolution sent to GC Nima","category":"action_required","directed_at_sophie":true}
175
+ Output: {"priority":"critical","action":"respond","model":"opus","summary":"CEO requesting board resolution sent to GC Nima","category":"action_required","directed_at_agent":true}
168
176
 
169
177
  Input: GitHub notification email about a merged PR
170
- Output: {"priority":"ignore","action":"ignore","model":"sonnet","summary":"GitHub PR merge notification","category":"ignore","directed_at_sophie":false}
178
+ Output: {"priority":"ignore","action":"ignore","model":"sonnet","summary":"GitHub PR merge notification","category":"ignore","directed_at_agent":false}
171
179
 
172
180
  Input: Leadership Slack: "We need the DFSA gap analysis updated by Thursday"
173
- Output: {"priority":"high","action":"draft","model":"opus","summary":"Leadership requesting DFSA gap analysis update by Thursday deadline","category":"action_required","directed_at_sophie":true}
181
+ Output: {"priority":"high","action":"draft","model":"opus","summary":"Leadership requesting DFSA gap analysis update by Thursday deadline","category":"action_required","directed_at_agent":true}
174
182
 
175
183
  Input: Team member: "Standup notes from today's sync"
176
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team standup notes shared","category":"fyi","directed_at_sophie":false}
184
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team standup notes shared","category":"fyi","directed_at_agent":false}
177
185
 
178
186
  Input: External email: "Following up on our conversation about the Singapore entity"
179
- Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_sophie":true}
187
+ Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"External follow-up on Singapore entity discussion","category":"action_required","directed_at_agent":true}
180
188
 
181
189
  Input: Channel message from Jacob: "@${agentName} can you check the engine deployment?"
182
- Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_sophie":true}
190
+ Output: {"priority":"normal","action":"respond","model":"sonnet","summary":"Jacob asking ${agentName} to check engine deployment","category":"action_required","directed_at_agent":true}
183
191
 
184
192
  Input: Channel message from CEO: "@AnotherAgent can you review the agentic libraries in this channel?"
185
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_sophie":false}
193
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"CEO asking another agent to review agentic libraries — not directed at ${agentName}","category":"fyi","directed_at_agent":false}
186
194
 
187
195
  Input: Channel message from Hootan to Nima: "Nima, did you file the DIFC response yet?"
188
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Hootan asking Nima about DIFC response filing","category":"fyi","directed_at_sophie":false}
196
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Hootan asking Nima about DIFC response filing","category":"fyi","directed_at_agent":false}
189
197
 
190
198
  Input: Channel thread — Jacob: "pushed the fix" / Hootan: "nice, looks good"
191
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_sophie":false}
199
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Team exchange about code fix","category":"fyi","directed_at_agent":false}
192
200
 
193
201
  Input: Thread where ${agentName} previously replied — Jacob: "yeah that makes sense, I'll handle it" (responding to Hootan, not ${agentName})
194
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Hootan in thread","category":"fyi","directed_at_sophie":false}
202
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Jacob acknowledging Hootan in thread","category":"fyi","directed_at_agent":false}
195
203
 
196
204
  Input: Thread where ${agentName} previously replied — Hootan: "${agentName}, can you send the updated doc?"
197
- Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Hootan asking ${agentName} for updated document","category":"action_required","directed_at_sophie":true}
205
+ Output: {"priority":"high","action":"respond","model":"sonnet","summary":"Hootan asking ${agentName} for updated document","category":"action_required","directed_at_agent":true}
198
206
 
199
207
  Input: Channel message: "FYI — the Cayman entity docs are signed and filed"
200
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_sophie":false}
208
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"FYI: Cayman entity docs signed and filed","category":"fyi","directed_at_agent":false}
201
209
 
202
210
  Input: Group chat — Nima: "Hootan, can we sync on the DFSA gaps tomorrow?"
203
- Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Nima asking Hootan to sync on DFSA gaps","category":"fyi","directed_at_sophie":false}
211
+ Output: {"priority":"normal","action":"archive","model":"sonnet","summary":"Nima asking Hootan to sync on DFSA gaps","category":"fyi","directed_at_agent":false}
204
212
 
205
213
  Return ONLY the JSON object. No explanation, no markdown fencing, no extra text.`;
206
214
  }
@@ -214,14 +222,16 @@ function formatItemPrompt(item) {
214
222
  `Channel: ${item.channel}`,
215
223
  ];
216
224
  // Indicate channel type so the classifier can assess directed-ness
225
+ loadAgentRegistry();
226
+ const agentName = _agentIdentity.name;
217
227
  if (item.is_dm) {
218
- parts.push("Channel type: Direct message (1:1 with Sophie)");
228
+ parts.push(`Channel type: Direct message (1:1 with ${agentName})`);
219
229
  } else if (item.is_group) {
220
230
  parts.push("Channel type: Group chat / multi-person channel");
221
231
  }
222
232
  if (item.subject) parts.push(`Subject: ${item.subject}`);
223
233
  if (item.is_reply) parts.push("This is a reply in an existing thread.");
224
- if (item.sophie_in_thread) parts.push("Sophie has previously replied in this thread.");
234
+ if (item.agent_in_thread) parts.push(`${agentName} has previously replied in this thread.`);
225
235
  if (item.thread_context) {
226
236
  parts.push(`Thread context:\n${item.thread_context}`);
227
237
  }
@@ -261,7 +271,7 @@ function parseClassification(text) {
261
271
  model: validModels.includes(parsed.model) ? parsed.model : "sonnet",
262
272
  summary: typeof parsed.summary === "string" ? parsed.summary : "Unclassified message",
263
273
  category: validCategories.includes(parsed.category) ? parsed.category : "fyi",
264
- directed_at_sophie: typeof parsed.directed_at_sophie === "boolean" ? parsed.directed_at_sophie : false, // default false — don't respond unless we're confident the message is for Sophie
274
+ directed_at_agent: typeof parsed.directed_at_agent === "boolean" ? parsed.directed_at_agent : false, // default false — don't respond unless we're confident the message is for this agent
265
275
  };
266
276
  }
267
277
 
@@ -383,7 +393,7 @@ async function classifyWithOpenAI(item) {
383
393
  * should be skipped — the sender chose a specific agent.
384
394
  *
385
395
  * Added in ib-20260418-cross-agent-routing to fix the recurring issue
386
- * where Lucas's agent intercepted messages tagged for Sophie in #dev-tooling.
396
+ * where peer agents intercepted messages tagged for a different agent in #dev-tooling.
387
397
  */
388
398
  function mentionsOtherAgent(item) {
389
399
  loadAgentRegistry();
@@ -418,9 +428,9 @@ function mentionsOtherAgent(item) {
418
428
  * Used by both the rule-based classifier fallback and as a pre-check
419
429
  * that can override the LLM's assessment in clear-cut cases.
420
430
  *
421
- * Backward-compatible export name: isDirectedAtSophie (used by sophie-daemon.mjs).
431
+ * Used by the daemon as a pre-classification gate.
422
432
  */
423
- function isDirectedAtSophie(item) {
433
+ function isDirectedAtAgent(item) {
424
434
  loadAgentRegistry();
425
435
 
426
436
  // ── Cross-agent gate (ib-20260418-cross-agent-routing) ──────────────────
@@ -437,7 +447,7 @@ function isDirectedAtSophie(item) {
437
447
 
438
448
  // Explicit @mention or name mention in the message content
439
449
  const content = (item.content || "").toLowerCase();
440
- const myName = (_agentIdentity.name || "sophie").toLowerCase();
450
+ const myName = (_agentIdentity.name || "agent").toLowerCase();
441
451
  const mySlackId = (_agentIdentity.slackId || "").toLowerCase();
442
452
 
443
453
  if (content.includes(myName)) return true;
@@ -451,7 +461,7 @@ function isDirectedAtSophie(item) {
451
461
  // if the message also contains a question, request keyword, or is a
452
462
  // direct follow-up to the agent's last message in the thread.
453
463
  const agentNameRegex = new RegExp(`^${myName}:`, "im");
454
- if (item.sophie_in_thread || (item.thread_context && agentNameRegex.test(item.thread_context))) {
464
+ if (item.agent_in_thread || (item.thread_context && agentNameRegex.test(item.thread_context))) {
455
465
  // Check if the agent was the last speaker in the thread
456
466
  if (item.thread_context) {
457
467
  const lines = item.thread_context.trim().split("\n").filter(Boolean);
@@ -471,7 +481,7 @@ function classifyWithRules(item) {
471
481
  const sender = (item.sender || "").toLowerCase();
472
482
  const subject = (item.subject || "").toLowerCase();
473
483
  const combined = `${content} ${sender} ${subject}`;
474
- const directed = isDirectedAtSophie(item);
484
+ const directed = isDirectedAtAgent(item);
475
485
 
476
486
  // CEO → critical, opus (but in channels, respect directed check)
477
487
  if (item.sender_privilege === "ceo") {
@@ -481,7 +491,7 @@ function classifyWithRules(item) {
481
491
  model: "opus",
482
492
  summary: `CEO message from ${item.sender}`,
483
493
  category: directed ? "action_required" : "fyi",
484
- directed_at_sophie: directed,
494
+ directed_at_agent: directed,
485
495
  };
486
496
  }
487
497
 
@@ -493,7 +503,7 @@ function classifyWithRules(item) {
493
503
  model: "opus",
494
504
  summary: `Leadership message from ${item.sender}`,
495
505
  category: directed ? "action_required" : "fyi",
496
- directed_at_sophie: directed,
506
+ directed_at_agent: directed,
497
507
  };
498
508
  }
499
509
 
@@ -507,7 +517,7 @@ function classifyWithRules(item) {
507
517
  model: "sonnet",
508
518
  summary: `Automated notification from ${item.sender}`,
509
519
  category: "ignore",
510
- directed_at_sophie: false,
520
+ directed_at_agent: false,
511
521
  };
512
522
  }
513
523
  }
@@ -521,7 +531,7 @@ function classifyWithRules(item) {
521
531
  model: "sonnet",
522
532
  summary: `Urgent message from ${item.sender}`,
523
533
  category: directed ? "action_required" : "fyi",
524
- directed_at_sophie: directed,
534
+ directed_at_agent: directed,
525
535
  };
526
536
  }
527
537
 
@@ -532,12 +542,12 @@ function classifyWithRules(item) {
532
542
  model: "sonnet",
533
543
  summary: `Message from ${item.sender} via ${item.service}`,
534
544
  category: directed ? "fyi" : "fyi",
535
- directed_at_sophie: directed,
545
+ directed_at_agent: directed,
536
546
  };
537
547
  }
538
548
 
539
549
  // Export for use in daemon's pre-classification gate
540
- export { isDirectedAtSophie, mentionsOtherAgent, loadAgentRegistry };
550
+ export { isDirectedAtAgent, mentionsOtherAgent, loadAgentRegistry };
541
551
 
542
552
  // ── Main export ─────────────────────────────────────────────────────────────
543
553
 
@@ -8,8 +8,8 @@ import { join } from "path";
8
8
  import { releaseLock, releaseThreadLock, releaseRequestClaim, claimItem, releaseItemClaim } from "./session-lock.mjs";
9
9
  import { recordSession } from "./health.mjs";
10
10
 
11
- const SOPHIE_AI_DIR = join(new URL(".", import.meta.url).pathname, "../..");
12
- const CLAUDE_BIN = process.env.CLAUDE_BIN || "/Users/sophie/.local/bin/claude";
11
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
12
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
13
13
  const MAX_CONCURRENT = parseInt(process.env.DAEMON_MAX_CONCURRENT || "10", 10);
14
14
  const RESERVED_INBOX_SLOTS = 3; // Always keep 3 slots free for real-time inbox items
15
15
 
@@ -40,13 +40,13 @@ const backlogRetryCount = new Map(); // backlog item key -> number of times dis
40
40
  const MAX_BACKLOG_RETRIES = 6; // Max retries before skipping (was 3 — too aggressive)
41
41
 
42
42
  function logDir() {
43
- const dir = join(SOPHIE_AI_DIR, "logs", "daemon");
43
+ const dir = join(AGENT_REPO_DIR, "logs", "daemon");
44
44
  mkdirSync(dir, { recursive: true });
45
45
  return dir;
46
46
  }
47
47
 
48
48
  function sessionLogDir() {
49
- const dir = join(SOPHIE_AI_DIR, "logs", "daemon", "sessions");
49
+ const dir = join(AGENT_REPO_DIR, "logs", "daemon", "sessions");
50
50
  mkdirSync(dir, { recursive: true });
51
51
  return dir;
52
52
  }
@@ -60,7 +60,7 @@ function logSession(entry) {
60
60
  appendFileSync(path, JSON.stringify({ timestamp: new Date().toISOString(), ...entry }) + "\n");
61
61
  }
62
62
 
63
- const ACTIVE_PATH = join(SOPHIE_AI_DIR, "state", "sessions", "active.json");
63
+ const ACTIVE_PATH = join(AGENT_REPO_DIR, "state", "sessions", "active.json");
64
64
 
65
65
  /**
66
66
  * Reset active.json on daemon startup.
@@ -282,7 +282,7 @@ function spawnSession(entry) {
282
282
  // A stale ANTHROPIC_API_KEY in the daemon's inherited env will otherwise
283
283
  // override the OAuth token and cause "Invalid API key" failures.
284
284
  const proc = spawn(CLAUDE_BIN, args, {
285
- cwd: SOPHIE_AI_DIR,
285
+ cwd: AGENT_REPO_DIR,
286
286
  env: { ...process.env, ANTHROPIC_API_KEY: "", ANTHROPIC_AUTH_TOKEN: "" },
287
287
  stdio: ["ignore", "pipe", "pipe"],
288
288
  });
@@ -7,7 +7,16 @@ import { readFileSync, readdirSync } from "fs";
7
7
  import { join } from "path";
8
8
  import { compileContext } from "./context-compiler.mjs";
9
9
 
10
- const SOPHIE_AI_DIR = join(new URL(".", import.meta.url).pathname, "../..");
10
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
11
+
12
+ // Load agent identity from canonical SOT for use in prompts and fallback preamble.
13
+ function loadAgent() {
14
+ try {
15
+ return JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
16
+ } catch {
17
+ return { firstName: "Agent", fullName: "Agent", title: "agent", company: "the company", principal: { firstName: "principal", fullName: "the principal", title: "principal" } };
18
+ }
19
+ }
11
20
 
12
21
  // Legacy: Maximum lines of conversation history (used when DAEMON_CONTEXT_COMPILER is off)
13
22
  const MAX_HISTORY_LINES = 30;
@@ -19,7 +28,7 @@ function loadPreamble() {
19
28
  if (cachedPreamble) return cachedPreamble;
20
29
 
21
30
  try {
22
- const raw = readFileSync(join(SOPHIE_AI_DIR, "CLAUDE.md"), "utf-8");
31
+ const raw = readFileSync(join(AGENT_REPO_DIR, "CLAUDE.md"), "utf-8");
23
32
  cachedPreamble = extractPreamble(raw);
24
33
  } catch (err) {
25
34
  console.error(`[prompt-builder] Failed to read CLAUDE.md: ${err.message}`);
@@ -74,11 +83,18 @@ function extractPreamble(raw) {
74
83
  return FALLBACK_PREAMBLE;
75
84
  }
76
85
 
77
- const FALLBACK_PREAMBLE = `You are Sophie Nguyen, Chief of Staff to Mehran Granfar, CEO of Adaptic.ai.
86
+ // Generic preamble derived from config/agent.json. This is only used when
87
+ // CLAUDE.md is unreadable; ordinarily the daemon pulls the full identity
88
+ // block straight from the agent's CLAUDE.md.
89
+ function buildFallbackPreamble() {
90
+ const a = loadAgent();
91
+ const principal = a.principal || {};
92
+ const principalName = principal.fullName || "the principal";
93
+ const principalTitle = principal.title || "principal";
94
+ return `You are ${a.fullName}, ${a.title} to ${principalName}, ${principalTitle} of ${a.company}.
78
95
  You operate as the autonomous executive command layer for the company.
79
96
 
80
- Adaptic Group a global AI-native institutional asset management group.
81
- UBOs: Mehran Granfar (50%) + Bronwyn Leong (50%) via Arrowsphere Holdings Limited.
97
+ ${a.companyDescription || a.company}.
82
98
 
83
99
  Operating Principles:
84
100
  1. Follow-through over brilliance — track every commitment until it closes
@@ -89,14 +105,16 @@ Operating Principles:
89
105
  6. Full autonomy, full accountability
90
106
 
91
107
  Autonomy Model:
92
- - Sophie sends autonomously: all internal messages, external operational messages, candidate comms, follow-ups, calendar coordination
93
- - Sophie escalates only: binding legal/financial obligations, regulatory submissions, acquisition deal terms, public statements
108
+ - ${a.firstName} sends autonomously: all internal messages, external operational messages, candidate comms, follow-ups, calendar coordination
109
+ - ${a.firstName} escalates only: binding legal/financial obligations, regulatory submissions, acquisition deal terms, public statements
94
110
 
95
111
  Document Sharing (CRITICAL):
96
112
  - NEVER reference local file paths in outbound communications
97
113
  - Upload to Google Drive or generate branded PDF for sharing
98
114
  - Inline short content directly in messages
99
115
  - Never attach raw .md or .yaml files`;
116
+ }
117
+ const FALLBACK_PREAMBLE = buildFallbackPreamble();
100
118
 
101
119
  const ACTION_INSTRUCTIONS = {
102
120
  respond: `ACTION: Respond to this message.
@@ -176,10 +194,10 @@ function loadConversationHistory(item) {
176
194
  const senderSlug = item.sender ? item.sender.replace(/\s+/g, "-").toLowerCase() : null;
177
195
 
178
196
  const candidateDirs = [];
179
- if (channelId) candidateDirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", channelId));
197
+ if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", channelId));
180
198
  if (senderSlug) {
181
- candidateDirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
182
- candidateDirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", senderSlug));
199
+ candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
200
+ candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", senderSlug));
183
201
  }
184
202
 
185
203
  const entries = [];
@@ -305,7 +323,7 @@ function buildBacklogContext(queueItem) {
305
323
  lines.push(`Title: ${queueItem.title || "untitled"}`);
306
324
  lines.push(`Status: ${queueItem.status || "open"}`);
307
325
  lines.push(`Priority: ${queueItem.priority || "normal"}`);
308
- lines.push(`Owner: ${queueItem.owner || "sophie"}`);
326
+ lines.push(`Owner: ${queueItem.owner || loadAgent().firstName.toLowerCase()}`);
309
327
  if (queueItem.source) lines.push(`Source: ${queueItem.source}`);
310
328
  if (queueItem.source_ref) lines.push(`Source ref: ${queueItem.source_ref}`);
311
329
  if (queueItem.due) lines.push(`Due: ${queueItem.due}`);
@@ -451,7 +469,7 @@ export async function buildPrompt(item, classResult, options = {}) {
451
469
  parts.push(`When this task is complete:
452
470
  - Update the queue item status to "resolved" in the appropriate state/queues/ file
453
471
  - HISTORY DEDUP RULE (CRITICAL): Before appending a history entry, read the item's existing history and check the LAST entry. If the last entry has substantially the same action text (same status, same priority, same blocked_by, same conclusion — e.g. "confirmed no changes", "still open", "re-verified"), do NOT append a new history entry. Instead, ONLY update the last_updated timestamp on the queue item. Only append a new history entry when something actually changed (status changed, new information, blocker resolved, action taken that differs from the last entry).
454
- - When you DO add a new history entry, include: timestamp, action taken, and by: sophie-daemon
472
+ - When you DO add a new history entry, include: timestamp, action taken, and by: agent-daemon
455
473
  - If the task cannot be completed, set status to "blocked" and record what is blocking it`);
456
474
  parts.push("");
457
475
  }
@@ -23,11 +23,25 @@ import { randomUUID } from "crypto";
23
23
  import { checkRecentlySent, registerSent } from "./session-lock.mjs";
24
24
  import { routingKey as deriveRoutingKey, createRouter } from "./lib/session-router.mjs";
25
25
 
26
- const SOPHIE_AI_DIR = join(new URL(".", import.meta.url).pathname, "../..");
26
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
27
27
  const SONNET_MODEL = "claude-sonnet-4-6";
28
- const CLAUDE_BIN = process.env.CLAUDE_BIN || "/Users/sophie/.local/bin/claude";
28
+ const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
29
29
  const CLAUDE_CLI_TIMEOUT_MS = 60_000;
30
- const SESSION_REGISTRY_PATH = join(SOPHIE_AI_DIR, "state", "daemon", "session-router-registry.json");
30
+ const SESSION_REGISTRY_PATH = join(AGENT_REPO_DIR, "state", "daemon", "session-router-registry.json");
31
+
32
+ // Identity loaded from config/agent.json (canonical SOT). Fall back to a
33
+ // neutral placeholder if the file isn't present — the daemon must still
34
+ // run even on a partially-configured repo.
35
+ let _agent = null;
36
+ function loadAgent() {
37
+ if (_agent) return _agent;
38
+ try {
39
+ _agent = JSON.parse(readFileSync(join(AGENT_REPO_DIR, "config/agent.json"), "utf-8"));
40
+ } catch {
41
+ _agent = { firstName: "Agent", fullName: "Agent", title: "agent", principal: { fullName: "the principal" }, company: "the company" };
42
+ }
43
+ return _agent;
44
+ }
31
45
 
32
46
  // Singleton router — lazily created on first generateResponse() call. The
33
47
  // scaffold's createRouter is async (eager registry read), so we cache the
@@ -213,7 +227,7 @@ function today() {
213
227
  }
214
228
 
215
229
  function logDir() {
216
- const dir = join(SOPHIE_AI_DIR, "logs", "daemon");
230
+ const dir = join(AGENT_REPO_DIR, "logs", "daemon");
217
231
  mkdirSync(dir, { recursive: true });
218
232
  return dir;
219
233
  }
@@ -225,7 +239,7 @@ function logResponse(entry) {
225
239
 
226
240
  /**
227
241
  * Write an interaction record so future sessions can see this exchange.
228
- * Both the incoming message and Sophie's reply are logged.
242
+ * Both the incoming message and the agent's reply are logged.
229
243
  */
230
244
  function logInteraction(item, responseText) {
231
245
  if (!item || !item.sender || item.service !== "slack") return;
@@ -234,8 +248,8 @@ function logInteraction(item, responseText) {
234
248
 
235
249
  // Write to both channel-ID and sender-slug directories
236
250
  const dirs = [];
237
- if (channelId) dirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", channelId));
238
- dirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
251
+ if (channelId) dirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", channelId));
252
+ dirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
239
253
 
240
254
  const incomingEntry = {
241
255
  ts: item.ts || item.timestamp || new Date().toISOString(),
@@ -250,7 +264,7 @@ function logInteraction(item, responseText) {
250
264
 
251
265
  const responseEntry = {
252
266
  ts: new Date().toISOString(),
253
- from: "sophie",
267
+ from: loadAgent().firstName.toLowerCase(),
254
268
  channel: channelId || item.channel,
255
269
  type: "reply",
256
270
  content: responseText,
@@ -277,7 +291,7 @@ function logInteraction(item, responseText) {
277
291
  function loadPreamble() {
278
292
  if (cachedPreamble) return cachedPreamble;
279
293
  try {
280
- const raw = readFileSync(join(SOPHIE_AI_DIR, "CLAUDE.md"), "utf-8");
294
+ const raw = readFileSync(join(AGENT_REPO_DIR, "CLAUDE.md"), "utf-8");
281
295
  // Extract just the identity and communication rules
282
296
  const lines = raw.split("\n");
283
297
  const sections = [];
@@ -296,9 +310,15 @@ function loadPreamble() {
296
310
  return cachedPreamble;
297
311
  }
298
312
 
299
- const FALLBACK_PREAMBLE = `You are Sophie Nguyen, Chief of Staff to Mehran Granfar, CEO of Adaptic.ai.
313
+ function buildFallbackPreamble() {
314
+ const a = loadAgent();
315
+ const principalName = a.principal?.fullName || "the principal";
316
+ const principalTitle = a.principal?.title || "principal";
317
+ return `You are ${a.fullName}, ${a.title} to ${principalName}, ${principalTitle} of ${a.company}.
300
318
  You operate as the autonomous executive command layer. Be concise, direct, and professional.
301
- For CEO messages: prioritise speed and directness. For others: be warm but efficient.`;
319
+ For ${a.principal?.firstName || "principal"} messages: prioritise speed and directness. For others: be warm but efficient.`;
320
+ }
321
+ const FALLBACK_PREAMBLE = buildFallbackPreamble();
302
322
 
303
323
  // ---------------------------------------------------------------------------
304
324
  // Load user profile for context
@@ -307,7 +327,7 @@ For CEO messages: prioritise speed and directness. For others: be warm but effic
307
327
  function loadUserProfile(sender) {
308
328
  try {
309
329
  const profileName = sender.replace(/\s+/g, "-").toLowerCase();
310
- const path = join(SOPHIE_AI_DIR, "memory", "profiles", "users", `${profileName}.yaml`);
330
+ const path = join(AGENT_REPO_DIR, "memory", "profiles", "users", `${profileName}.yaml`);
311
331
  return readFileSync(path, "utf-8");
312
332
  } catch {
313
333
  return null;
@@ -325,8 +345,8 @@ function loadConversationHistory(item) {
325
345
  const channelId = resolveSlackChannel(item);
326
346
 
327
347
  const candidateDirs = [];
328
- if (channelId) candidateDirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", channelId));
329
- candidateDirs.push(join(SOPHIE_AI_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
348
+ if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", channelId));
349
+ candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", `dm-${senderSlug}`));
330
350
 
331
351
  const entries = [];
332
352
  for (const dir of candidateDirs) {
@@ -469,7 +489,7 @@ function validateQuickReply(text) {
469
489
  const issues = [];
470
490
  const lower = text.toLowerCase();
471
491
 
472
- // Rule 1: AI self-disclosure — Sophie must never reveal she is AI
492
+ // Rule 1: AI self-disclosure — the agent must never reveal she/he is AI
473
493
  const aiPatterns = [
474
494
  /\bi am an? ai\b/i, /\bi'?m an? ai\b/i, /\bas an ai\b/i,
475
495
  /\blanguage model\b/i, /\bartificial intelligence\b/i,
@@ -488,7 +508,7 @@ function validateQuickReply(text) {
488
508
  issues.push({ rule: "local_path_reference", severity: "block", detail: "Message references a local file path" });
489
509
  }
490
510
 
491
- // Rule 3: Sophie should not schedule herself for in-person meetings
511
+ // Rule 3: the agent should not schedule themselves for in-person meetings
492
512
  const meetingPatterns = [
493
513
  /\bi(?:'ll| will) (?:come|attend|be there|join (?:you |the meeting )?in person)\b/i,
494
514
  /\blet'?s meet (?:at|in)\b/i,
@@ -496,7 +516,7 @@ function validateQuickReply(text) {
496
516
  ];
497
517
  for (const p of meetingPatterns) {
498
518
  if (p.test(text)) {
499
- issues.push({ rule: "sophie_inperson", severity: "block", detail: `Matched: ${p}` });
519
+ issues.push({ rule: "agent_inperson", severity: "block", detail: `Matched: ${p}` });
500
520
  break;
501
521
  }
502
522
  }
@@ -544,7 +564,7 @@ async function sendSlackMessage(channel, text, threadTs = null) {
544
564
  async function sendGmailResponse(item, text) {
545
565
  const to = item.sender_email || item.sender;
546
566
  const subject = `Re: ${item.subject || "(no subject)"}`;
547
- const sendScript = join(SOPHIE_AI_DIR, "scripts", "send-email-threaded.py");
567
+ const sendScript = join(AGENT_REPO_DIR, "scripts", "send-email-threaded.py");
548
568
 
549
569
  try {
550
570
  const args = [sendScript, to, subject, text];
@@ -553,7 +573,7 @@ async function sendGmailResponse(item, text) {
553
573
  }
554
574
 
555
575
  execFileSync("python3", args, {
556
- cwd: SOPHIE_AI_DIR,
576
+ cwd: AGENT_REPO_DIR,
557
577
  timeout: 30000,
558
578
  encoding: "utf-8",
559
579
  env: { ...process.env },
@@ -563,9 +583,9 @@ async function sendGmailResponse(item, text) {
563
583
  } catch (err) {
564
584
  console.error(`[responder] Gmail send failed for ${to}: ${err.message}`);
565
585
  // Fall back to draft file so the response is not lost
566
- const draftPath = join(SOPHIE_AI_DIR, "outputs", "drafts",
586
+ const draftPath = join(AGENT_REPO_DIR, "outputs", "drafts",
567
587
  `${today()}-quick-reply-${item.sender.replace(/[^a-z0-9]/gi, "-")}.md`);
568
- mkdirSync(join(SOPHIE_AI_DIR, "outputs", "drafts"), { recursive: true });
588
+ mkdirSync(join(AGENT_REPO_DIR, "outputs", "drafts"), { recursive: true });
569
589
  const content = `# Quick Reply Draft (SEND FAILED)\n\nTo: ${to}\nSubject: ${subject}\nGenerated: ${new Date().toISOString()}\nError: ${err.message}\n\n---\n\n${text}\n`;
570
590
  writeFileSync(draftPath, content);
571
591
  return { sent: false, via: "draft_fallback", draft_path: draftPath, error: err.message };
@@ -2,7 +2,7 @@
2
2
  /**
3
3
  * session-lock.mjs — File-based session locks and sent-message registry
4
4
  *
5
- * Replaces the in-memory recentlyProcessed Map in sophie-daemon.mjs with
5
+ * Replaces the in-memory recentlyProcessed Map in the agent daemon with
6
6
  * persistent, atomic file locks that survive daemon restarts and prevent
7
7
  * race conditions between concurrent polls.
8
8
  *
@@ -22,8 +22,8 @@ import {
22
22
  import { join } from "path";
23
23
 
24
24
  // Allow test override of state directory
25
- const SOPHIE_AI_DIR = join(new URL(".", import.meta.url).pathname, "../..");
26
- const SESSION_DIR = process.env.__TEST_SESSION_DIR || join(SOPHIE_AI_DIR, "state", "sessions");
25
+ const AGENT_REPO_DIR = process.env.AGENT_DIR || join(new URL(".", import.meta.url).pathname, "../..");
26
+ const SESSION_DIR = process.env.__TEST_SESSION_DIR || join(AGENT_REPO_DIR, "state", "sessions");
27
27
  const LOCKS_DIR = join(SESSION_DIR, "locks");
28
28
  const REGISTRY_PATH = join(SESSION_DIR, "sent-registry.jsonl");
29
29
 
@@ -525,7 +525,7 @@ export function releaseThreadLock(channel, threadTs) {
525
525
  // outputs/research/2026-04-18-session-coordination-design.md
526
526
  // ---------------------------------------------------------------------------
527
527
 
528
- const ITEM_CLAIM_DIR = join(SOPHIE_AI_DIR, "state", "locks", "item-claims");
528
+ const ITEM_CLAIM_DIR = join(AGENT_REPO_DIR, "state", "locks", "item-claims");
529
529
  const DEFAULT_ITEM_CLAIM_TTL_MIN = 30; // Default TTL in minutes
530
530
 
531
531
  // Ensure directory exists