@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
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
// =============================================================================
|
|
3
|
-
//
|
|
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
|
|
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
|
|
25
|
-
config({ path: join(
|
|
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,
|
|
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(
|
|
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
|
|
107
|
+
// Skip the agent's own messages — defensive check in case intake filters miss them
|
|
95
108
|
const sender = (item.sender || "").toLowerCase();
|
|
96
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
178
|
-
// This prevents
|
|
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.
|
|
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 =
|
|
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: "
|
|
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
|
|
211
|
-
const ruleCheck =
|
|
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
|
|
214
|
-
|
|
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(
|
|
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(
|
|
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(
|
|
476
|
+
console.log(`║ ${(loadAgent().firstName || "Agent").padEnd(8)} Daemon — Reactive Event Processor ║`);
|
|
454
477
|
console.log("╠══════════════════════════════════════════════════════════╣");
|
|
455
|
-
console.log(`║ Directory: ${
|
|
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(
|
|
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(
|
|
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
|
-
|
|
12
|
-
PROMPT_FILE="$
|
|
13
|
-
CLAUDE_BIN="
|
|
14
|
-
LOG_DIR="$
|
|
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 "$
|
|
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 "$
|
|
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
|
-
|
|
31
|
-
|
|
32
|
-
|
|
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:
|
|
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:
|
|
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
|
|
2
|
-
// Uses imapflow + mailparser to fetch unseen emails from
|
|
3
|
-
//
|
|
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
|
-
//
|
|
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,
|
|
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(
|
|
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
|
|
29
|
+
* Load the agent's Gmail app password from env or the agent's .env file.
|
|
18
30
|
*/
|
|
19
|
-
function
|
|
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(
|
|
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
|
|
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:
|
|
70
|
-
password:
|
|
71
|
-
account:
|
|
83
|
+
email: me.email,
|
|
84
|
+
password: loadGmailPassword(),
|
|
85
|
+
account: agentSlug,
|
|
72
86
|
fileSuffix: "email",
|
|
73
|
-
eventType:
|
|
74
|
-
logPrefix:
|
|
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 = `
|
|
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
|
-
|
|
143
|
+
mentions_agent: new RegExp(agentSlug, "i").test(email.subject || ""),
|
|
130
144
|
},
|
|
131
145
|
raw_ref: `gmail:${id}`,
|
|
132
146
|
_inbox_file: file,
|
package/scripts/poller/utils.mjs
CHANGED
|
@@ -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
|
|
7
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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(
|
|
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?.
|
|
241
|
+
item.priority_signals?.mentions_agent
|
|
238
242
|
);
|
|
239
243
|
}
|
|
240
244
|
|
|
241
|
-
// Known user IDs for privilege resolution
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
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
|
-
|
|
69
|
-
|
|
70
|
-
|
|
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>
|
|
109
|
-
<div style="font-size:small;">
|
|
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;"
|
|
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'] = '
|
|
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
|
|
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
|
|
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
|
-
|
|
47
|
-
|
|
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>
|
|
129
|
-
<div style="font-size:small;">
|
|
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;"
|
|
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'] = '
|
|
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)
|