@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.
- package/bin/maestro.mjs +17 -0
- package/package.json +1 -1
- package/scaffold/config/agent.json +52 -0
- package/scaffold/config/agent.ts.example +62 -192
- package/scripts/archive-email.sh +16 -2
- package/scripts/daemon/classifier.mjs +69 -59
- package/scripts/daemon/dispatcher.mjs +6 -6
- package/scripts/daemon/prompt-builder.mjs +30 -12
- package/scripts/daemon/responder.mjs +41 -21
- package/scripts/daemon/session-lock.mjs +4 -4
- package/scripts/daemon/sophie-daemon.mjs +49 -26
- package/scripts/local-triggers/run-trigger.sh +6 -6
- package/scripts/pdf-generation/build-document.mjs +24 -7
- package/scripts/poller/gmail-poller.mjs +32 -18
- package/scripts/poller/utils.mjs +34 -14
- package/scripts/send-email-threaded.py +30 -10
- package/scripts/send-email-with-attachment.py +26 -9
- package/scripts/send-email.sh +23 -10
- package/scripts/setup/generate-agent-env.mjs +92 -0
- package/scripts/setup/migrate-agent-to-sot.mjs +192 -0
- package/scripts/slack-events-server.mjs +78 -44
- package/scripts/slack-responded.sh +5 -2
- package/scripts/slack-send.sh +25 -9
- package/scripts/sms-handler.mjs +32 -18
- package/scripts/whatsapp-handler.mjs +27 -14
|
@@ -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
|
-
//
|
|
34
|
-
//
|
|
35
|
-
// interception in shared channels
|
|
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.
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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] : "
|
|
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: "
|
|
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
|
-
|
|
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
|
-
"
|
|
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":
|
|
142
|
+
- "action_required": ${agentName} needs to do something
|
|
135
143
|
- "fyi": Informational, no action needed
|
|
136
144
|
- "ignore": No value
|
|
137
145
|
|
|
138
|
-
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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","
|
|
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(
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
-
*
|
|
431
|
+
* Used by the daemon as a pre-classification gate.
|
|
422
432
|
*/
|
|
423
|
-
function
|
|
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 || "
|
|
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.
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
545
|
+
directed_at_agent: directed,
|
|
536
546
|
};
|
|
537
547
|
}
|
|
538
548
|
|
|
539
549
|
// Export for use in daemon's pre-classification gate
|
|
540
|
-
export {
|
|
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
|
|
12
|
-
const CLAUDE_BIN = process.env.CLAUDE_BIN || "
|
|
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(
|
|
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(
|
|
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(
|
|
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:
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
-
|
|
93
|
-
-
|
|
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(
|
|
197
|
+
if (channelId) candidateDirs.push(join(AGENT_REPO_DIR, "memory", "interactions", "slack", channelId));
|
|
180
198
|
if (senderSlug) {
|
|
181
|
-
candidateDirs.push(join(
|
|
182
|
-
candidateDirs.push(join(
|
|
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 ||
|
|
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:
|
|
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
|
|
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 || "
|
|
28
|
+
const CLAUDE_BIN = process.env.CLAUDE_BIN || "claude";
|
|
29
29
|
const CLAUDE_CLI_TIMEOUT_MS = 60_000;
|
|
30
|
-
const SESSION_REGISTRY_PATH = join(
|
|
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(
|
|
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
|
|
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(
|
|
238
|
-
dirs.push(join(
|
|
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:
|
|
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(
|
|
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
|
-
|
|
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
|
|
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(
|
|
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(
|
|
329
|
-
candidateDirs.push(join(
|
|
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 —
|
|
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:
|
|
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: "
|
|
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(
|
|
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:
|
|
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(
|
|
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(
|
|
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
|
|
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
|
|
26
|
-
const SESSION_DIR = process.env.__TEST_SESSION_DIR || join(
|
|
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(
|
|
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
|