@adaptic/maestro 1.1.7 → 1.4.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/.claude/commands/init-maestro.md +502 -260
- package/README.md +47 -2
- package/bin/maestro.mjs +1 -1
- package/docs/guides/agents-observe-setup.md +64 -0
- package/docs/guides/ccxray-diagnostics.md +65 -0
- package/docs/guides/claude-mem-setup.md +79 -0
- package/docs/guides/claude-pace-setup.md +56 -0
- package/docs/guides/claudraband-sessions.md +98 -0
- package/docs/guides/clawteam-swarm.md +116 -0
- package/docs/guides/code-review-graph-setup.md +86 -0
- package/docs/guides/email-setup.md +399 -0
- package/docs/guides/media-generation-setup.md +349 -0
- package/docs/guides/outbound-governance-setup.md +438 -0
- package/docs/guides/pdf-generation-setup.md +315 -0
- package/docs/guides/poller-daemon-setup.md +550 -0
- package/docs/guides/rag-context-setup.md +459 -0
- package/docs/guides/self-optimization-pattern.md +82 -0
- package/docs/guides/slack-setup.md +350 -0
- package/docs/guides/twilio-subaccounts-setup.md +223 -0
- package/docs/guides/voice-sms-setup.md +698 -0
- package/docs/guides/webhook-relay-setup.md +349 -0
- package/docs/guides/whatsapp-setup.md +282 -0
- package/docs/runbooks/mac-mini-bootstrap.md +21 -0
- package/package.json +2 -1
- package/plugins/maestro-skills/plugin.json +16 -0
- package/plugins/maestro-skills/skills/agents-observe.md +110 -0
- package/plugins/maestro-skills/skills/ccxray-diagnostics.md +91 -0
- package/plugins/maestro-skills/skills/claude-pace.md +61 -0
- package/plugins/maestro-skills/skills/code-review-graph.md +99 -0
- package/scaffold/CLAUDE.md +64 -0
- package/scaffold/config/agent.ts.example +2 -1
- package/scaffold/config/caller-id-map.yaml +46 -0
- package/scaffold/config/known-agents.json +35 -0
- package/scripts/daemon/classifier.mjs +264 -50
- package/scripts/daemon/dispatcher.mjs +109 -5
- package/scripts/daemon/launchd-wrapper-generic.sh +96 -0
- package/scripts/daemon/launchd-wrapper-slack-events.sh +37 -0
- package/scripts/daemon/launchd-wrapper.sh +91 -0
- package/scripts/daemon/lib/session-router.mjs +274 -0
- package/scripts/daemon/lib/session-router.test.mjs +295 -0
- package/scripts/daemon/prompt-builder.mjs +51 -11
- package/scripts/daemon/responder.mjs +234 -19
- package/scripts/daemon/session-lock.mjs +194 -0
- package/scripts/daemon/sophie-daemon.mjs +16 -2
- package/scripts/email-signature.html +20 -4
- package/scripts/local-triggers/generate-plists.sh +62 -10
- package/scripts/media-generation/README.md +2 -0
- package/scripts/pdf-generation/README.md +2 -0
- package/scripts/poller/imap-client.mjs +4 -2
- package/scripts/poller/slack-poller.mjs +126 -59
- package/scripts/poller/trigger.mjs +12 -1
- package/scripts/setup/init-agent.sh +91 -1
- package/scripts/setup/install-dev-tools.sh +150 -0
- package/scripts/spawn-session.sh +21 -6
- package/workflows/continuous/backlog-executor.yaml +141 -0
- package/workflows/daily/evening-wrap.yaml +41 -1
- package/workflows/daily/morning-brief.yaml +17 -0
- package/workflows/event-driven/agent-failure-investigation.yaml +137 -0
- package/workflows/event-driven/pr-review.yaml +104 -0
- package/workflows/weekly/engineering-health.yaml +154 -0
|
@@ -111,13 +111,48 @@ PLIST_SCHED
|
|
|
111
111
|
PLIST_INTERVAL
|
|
112
112
|
fi
|
|
113
113
|
|
|
114
|
+
# ── EnvironmentVariables block ──────────────────────────────────────────
|
|
115
|
+
# Exports for every spawned process:
|
|
116
|
+
# PATH — homebrew + standard paths so node/python/etc are findable
|
|
117
|
+
# CLAUDE_CODE_TMPDIR — redirect Claude Code per-cwd temp to external SSD if mounted
|
|
118
|
+
# AGENT_ROOT — used by maestro's singleton lock and other helpers
|
|
119
|
+
cat >> "$FILE" << PLIST_ENV
|
|
120
|
+
|
|
121
|
+
<key>EnvironmentVariables</key>
|
|
122
|
+
<dict>
|
|
123
|
+
<key>PATH</key>
|
|
124
|
+
<string>/opt/homebrew/bin:/opt/homebrew/sbin:/usr/local/bin:/usr/bin:/bin:/usr/sbin:/sbin</string>
|
|
125
|
+
<key>HOME</key>
|
|
126
|
+
<string>$HOME</string>
|
|
127
|
+
<key>USER</key>
|
|
128
|
+
<string>$AGENT_USER</string>
|
|
129
|
+
<key>AGENT_ROOT</key>
|
|
130
|
+
<string>$AGENT_DIR</string>
|
|
131
|
+
PLIST_ENV
|
|
132
|
+
|
|
133
|
+
if [ -d "/Volumes/4TB-SSD" ]; then
|
|
134
|
+
cat >> "$FILE" << PLIST_SSD
|
|
135
|
+
<key>CLAUDE_CODE_TMPDIR</key>
|
|
136
|
+
<string>/Volumes/4TB-SSD/maestro/${AGENT_FIRST}/claude-tmp</string>
|
|
137
|
+
PLIST_SSD
|
|
138
|
+
fi
|
|
139
|
+
|
|
140
|
+
cat >> "$FILE" << 'PLIST_ENV_END'
|
|
141
|
+
</dict>
|
|
142
|
+
PLIST_ENV_END
|
|
143
|
+
|
|
144
|
+
# Resolve symlinked log path to a real path so launchd can write to it.
|
|
145
|
+
# If logs/ is a symlink (e.g. when redirected to an external SSD), launchd's
|
|
146
|
+
# StandardErrorPath/StandardOutPath sometimes fail with EX_CONFIG (78).
|
|
147
|
+
LOG_REAL=$(cd -P "$AGENT_DIR/logs/daemon" 2>/dev/null && pwd -P || echo "$AGENT_DIR/logs/daemon")
|
|
148
|
+
|
|
114
149
|
cat >> "$FILE" << PLIST_FOOTER
|
|
115
150
|
|
|
116
151
|
<key>StandardOutPath</key>
|
|
117
|
-
<string>$
|
|
152
|
+
<string>$LOG_REAL/launchd-stdout.log</string>
|
|
118
153
|
|
|
119
154
|
<key>StandardErrorPath</key>
|
|
120
|
-
<string>$
|
|
155
|
+
<string>$LOG_REAL/launchd-stderr.log</string>
|
|
121
156
|
</dict>
|
|
122
157
|
</plist>
|
|
123
158
|
PLIST_FOOTER
|
|
@@ -126,6 +161,15 @@ PLIST_FOOTER
|
|
|
126
161
|
}
|
|
127
162
|
|
|
128
163
|
# ── Helper: trigger plist (runs claude with a trigger prompt) ────────────────
|
|
164
|
+
#
|
|
165
|
+
# Routes through scripts/daemon/launchd-wrapper-generic.sh so the trigger's
|
|
166
|
+
# stdout/stderr lands on the SSD (when /Volumes/{name}-SSD is mounted) and
|
|
167
|
+
# CLAUDE_CODE_TMPDIR is set for any Claude Code session it spawns.
|
|
168
|
+
#
|
|
169
|
+
# IMPORTANT: run-trigger.sh expects the trigger NAME (e.g. "meeting-prep"),
|
|
170
|
+
# not the full path to the .md file. The script constructs the full path
|
|
171
|
+
# itself. Passing the full path causes a doubled path bug:
|
|
172
|
+
# /agent/schedules/triggers//agent/schedules/triggers/meeting-prep.md.md
|
|
129
173
|
|
|
130
174
|
generate_trigger_plist() {
|
|
131
175
|
local TRIGGER_NAME="$1"
|
|
@@ -133,11 +177,11 @@ generate_trigger_plist() {
|
|
|
133
177
|
local INTERVAL="$3"
|
|
134
178
|
|
|
135
179
|
local LABEL="ai.adaptic.${AGENT_FIRST}-${TRIGGER_NAME}"
|
|
136
|
-
local TRIGGER_FILE="$AGENT_DIR/schedules/triggers/${TRIGGER_NAME}.md"
|
|
137
180
|
local RUN_TRIGGER="$AGENT_DIR/scripts/local-triggers/run-trigger.sh"
|
|
181
|
+
local WRAPPER="$AGENT_DIR/scripts/daemon/launchd-wrapper-generic.sh"
|
|
138
182
|
|
|
139
183
|
generate_plist "$LABEL" \
|
|
140
|
-
"$RUN_TRIGGER|$
|
|
184
|
+
"$WRAPPER|$RUN_TRIGGER|$TRIGGER_NAME" \
|
|
141
185
|
"$SCHEDULE" \
|
|
142
186
|
"$INTERVAL" \
|
|
143
187
|
""
|
|
@@ -148,15 +192,23 @@ generate_trigger_plist() {
|
|
|
148
192
|
echo ""
|
|
149
193
|
echo "Generating launchd plist files..."
|
|
150
194
|
|
|
151
|
-
# 1. Main daemon (KeepAlive)
|
|
195
|
+
# 1. Main daemon (KeepAlive) — uses wrapper to bootstrap env (HOME, PATH, etc).
|
|
196
|
+
# Wrapper is at scripts/daemon/launchd-wrapper.sh. This avoids passing
|
|
197
|
+
# EnvironmentVariables in the plist, which has been observed to cause
|
|
198
|
+
# EX_CONFIG (78) failures on macOS when the agent's logs/state dirs are
|
|
199
|
+
# symlinked to an external SSD.
|
|
152
200
|
generate_plist "ai.adaptic.${AGENT_FIRST}-daemon" \
|
|
153
|
-
"${
|
|
201
|
+
"${AGENT_DIR}/scripts/daemon/launchd-wrapper.sh" \
|
|
154
202
|
"" "" "true"
|
|
155
203
|
|
|
156
|
-
# 2. Slack events
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
204
|
+
# 2. Slack events relay polling job (every 5 sec).
|
|
205
|
+
# The local Mac mini polls the Railway-hosted webhook relay for buffered
|
|
206
|
+
# Slack events. This replaces the older local slack-events-server.mjs which
|
|
207
|
+
# needed an inbound tunnel from the Mac. See docs/guides/webhook-relay-setup.md.
|
|
208
|
+
# Routed through the generic wrapper so its output goes to the SSD.
|
|
209
|
+
generate_plist "ai.adaptic.${AGENT_FIRST}-poll-relay" \
|
|
210
|
+
"${AGENT_DIR}/scripts/daemon/launchd-wrapper-generic.sh|${AGENT_DIR}/scripts/poll-slack-events.sh" \
|
|
211
|
+
"" "5" ""
|
|
160
212
|
|
|
161
213
|
# 3. Inbox processor (every 5 minutes)
|
|
162
214
|
generate_trigger_plist "inbox-processor" "" "300"
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# Media Generation Pipeline
|
|
2
2
|
|
|
3
|
+
> **Full setup guide**: See [docs/guides/media-generation-setup.md](../../docs/guides/media-generation-setup.md) for complete API setup, prompt spec authoring, brand alignment, testing, and troubleshooting.
|
|
4
|
+
|
|
3
5
|
Generates branded illustrations, diagrams, and video assets using Google Gemini and Veo APIs.
|
|
4
6
|
Ported from `~/adapticai/app/scripts/media-generation/` and `~/ai-born/`.
|
|
5
7
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# PDF Generation Pipeline
|
|
2
2
|
|
|
3
|
+
> **Full setup guide**: See [docs/guides/pdf-generation-setup.md](../../docs/guides/pdf-generation-setup.md) for complete installation, template customisation, brand integration, testing, and troubleshooting.
|
|
4
|
+
|
|
3
5
|
Generates professional PDF documents from Markdown using Pandoc + XeLaTeX.
|
|
4
6
|
Ported from the `~/ai-born` book generation pipeline, adapted for corporate documents.
|
|
5
7
|
|
|
@@ -157,6 +157,7 @@ export async function pollImapInbox({
|
|
|
157
157
|
const now = new Date().toISOString();
|
|
158
158
|
let newCount = 0;
|
|
159
159
|
let skipped = 0;
|
|
160
|
+
let unseenCount = 0;
|
|
160
161
|
let lastMid = cursor.last_message_id;
|
|
161
162
|
let totalProcessed = cursor.messages_processed;
|
|
162
163
|
|
|
@@ -185,7 +186,8 @@ export async function pollImapInbox({
|
|
|
185
186
|
return { newCount: 0, errors };
|
|
186
187
|
}
|
|
187
188
|
|
|
188
|
-
|
|
189
|
+
unseenCount = unseen.length;
|
|
190
|
+
console.log(`${logPrefix} Found ${unseenCount} unseen messages`);
|
|
189
191
|
|
|
190
192
|
// Rate limit: process most recent N
|
|
191
193
|
const toFetch = unseen.length > MAX_EMAILS_PER_CYCLE
|
|
@@ -273,7 +275,7 @@ export async function pollImapInbox({
|
|
|
273
275
|
await client.logout();
|
|
274
276
|
|
|
275
277
|
console.log(
|
|
276
|
-
`${logPrefix} Poll complete: ${newCount} new, ${skipped} skipped, ${
|
|
278
|
+
`${logPrefix} Poll complete: ${newCount} new, ${skipped} skipped, ${unseenCount} unseen total`,
|
|
277
279
|
);
|
|
278
280
|
} catch (err) {
|
|
279
281
|
errors.push(`${logPrefix} IMAP error: ${err.message}`);
|
|
@@ -82,25 +82,42 @@ async function getMonitoredChannels() {
|
|
|
82
82
|
const CEO_USER_ID = "U097N5R0M7U";
|
|
83
83
|
const SOPHIE_USER_ID = "U099N1JFPRQ";
|
|
84
84
|
|
|
85
|
-
// Rate-limit-aware delay
|
|
86
|
-
//
|
|
87
|
-
//
|
|
88
|
-
//
|
|
89
|
-
|
|
85
|
+
// Rate-limit-aware delay with random jitter to reduce Slack API rate limiting.
|
|
86
|
+
// Base delay 400ms + random 0-600ms jitter = 400-1000ms between requests.
|
|
87
|
+
// With broader-fetch elimination and DM/thread rotation, total calls per cycle
|
|
88
|
+
// are ~20-30 (down from 90+), so this timing keeps us well under both the
|
|
89
|
+
// Slack Tier 3 limit (~50 req/min) and the 50s cycle budget.
|
|
90
|
+
// Previous: fixed 1500ms with redundant broader fetches caused 25-30 min cycles.
|
|
91
|
+
const INTER_REQUEST_BASE_MS = 400;
|
|
92
|
+
const INTER_REQUEST_JITTER_MS = 600;
|
|
90
93
|
const MAX_RETRIES = 3;
|
|
91
94
|
|
|
95
|
+
// Cycle time budget — abort non-critical work if approaching this limit.
|
|
96
|
+
// Launchd fires every 60s; we must finish well before the next invocation.
|
|
97
|
+
const CYCLE_TIME_BUDGET_MS = 55_000;
|
|
98
|
+
let cycleStartTime = 0;
|
|
99
|
+
|
|
92
100
|
function sleep(ms) {
|
|
93
101
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
94
102
|
}
|
|
95
103
|
|
|
96
104
|
let lastRequestTime = 0;
|
|
97
105
|
|
|
106
|
+
/**
|
|
107
|
+
* Check whether the cycle time budget has been exceeded.
|
|
108
|
+
* Returns true if we should skip non-critical work to finish on time.
|
|
109
|
+
*/
|
|
110
|
+
function isCycleBudgetExceeded() {
|
|
111
|
+
return cycleStartTime > 0 && (Date.now() - cycleStartTime) > CYCLE_TIME_BUDGET_MS;
|
|
112
|
+
}
|
|
113
|
+
|
|
98
114
|
async function slackApi(method, params = {}) {
|
|
99
|
-
// Enforce minimum spacing between requests
|
|
115
|
+
// Enforce minimum spacing between requests with random jitter
|
|
100
116
|
const now = Date.now();
|
|
117
|
+
const jitteredDelay = INTER_REQUEST_BASE_MS + Math.floor(Math.random() * INTER_REQUEST_JITTER_MS);
|
|
101
118
|
const elapsed = now - lastRequestTime;
|
|
102
|
-
if (elapsed <
|
|
103
|
-
await sleep(
|
|
119
|
+
if (elapsed < jitteredDelay) {
|
|
120
|
+
await sleep(jitteredDelay - elapsed);
|
|
104
121
|
}
|
|
105
122
|
lastRequestTime = Date.now();
|
|
106
123
|
|
|
@@ -219,6 +236,8 @@ function getChannelsForCycle(allChannels) {
|
|
|
219
236
|
}
|
|
220
237
|
|
|
221
238
|
export async function pollSlack() {
|
|
239
|
+
cycleStartTime = Date.now();
|
|
240
|
+
|
|
222
241
|
if (!SLACK_TOKEN) {
|
|
223
242
|
return { items: [], errors: ["SLACK_TOKEN not set"] };
|
|
224
243
|
}
|
|
@@ -243,6 +262,12 @@ export async function pollSlack() {
|
|
|
243
262
|
}
|
|
244
263
|
|
|
245
264
|
for (const channel of channelsThisCycle) {
|
|
265
|
+
// Check cycle time budget — skip remaining channels if running long
|
|
266
|
+
if (isCycleBudgetExceeded()) {
|
|
267
|
+
errors.push("Cycle time budget exceeded — skipping remaining channels");
|
|
268
|
+
break;
|
|
269
|
+
}
|
|
270
|
+
|
|
246
271
|
try {
|
|
247
272
|
const result = await slackApi("conversations.history", {
|
|
248
273
|
channel: channel.id,
|
|
@@ -256,31 +281,14 @@ export async function pollSlack() {
|
|
|
256
281
|
}
|
|
257
282
|
|
|
258
283
|
// Track channel threads with replies so the thread scanner picks them up.
|
|
259
|
-
//
|
|
260
|
-
//
|
|
261
|
-
//
|
|
284
|
+
// Thread discovery relies on persisted activeThreads state (which survives
|
|
285
|
+
// across cycles) plus any threaded messages found in the current history
|
|
286
|
+
// response. The previous "broader" second fetch (without oldest filter)
|
|
287
|
+
// has been removed — it doubled API calls per channel and was the primary
|
|
288
|
+
// cause of rate-limiting. Active threads with old parent messages are
|
|
289
|
+
// already tracked in activeThreads from prior cycles.
|
|
262
290
|
const channelThreads = new Set(activeThreads[channel.id] || []);
|
|
263
291
|
|
|
264
|
-
// Second pass: fetch recent messages WITHOUT oldest filter to find threads
|
|
265
|
-
// with new replies. Parent messages older than cursor are invisible to the
|
|
266
|
-
// first pass, but their threads can have new replies we need to scan.
|
|
267
|
-
// Always run this — it's the only way to discover active threads in channels.
|
|
268
|
-
try {
|
|
269
|
-
const broader = await slackApi("conversations.history", {
|
|
270
|
-
channel: channel.id,
|
|
271
|
-
limit: 15,
|
|
272
|
-
});
|
|
273
|
-
if (broader.ok) {
|
|
274
|
-
for (const m of broader.messages || []) {
|
|
275
|
-
if (m.reply_count > 0 && m.ts) {
|
|
276
|
-
// Track ALL threads with replies — the thread scanner will
|
|
277
|
-
// filter by cursor timestamp when fetching individual replies
|
|
278
|
-
channelThreads.add(m.ts);
|
|
279
|
-
}
|
|
280
|
-
}
|
|
281
|
-
}
|
|
282
|
-
} catch { /* non-critical — thread discovery is best-effort */ }
|
|
283
|
-
|
|
284
292
|
for (const msg of result.messages || []) {
|
|
285
293
|
// Register any message with replies as an active thread (even Sophie's)
|
|
286
294
|
if (msg.reply_count > 0 && msg.ts) {
|
|
@@ -357,14 +365,33 @@ export async function pollSlack() {
|
|
|
357
365
|
}
|
|
358
366
|
}
|
|
359
367
|
|
|
360
|
-
// Also check DMs
|
|
368
|
+
// Also check DMs — with rotation.
|
|
369
|
+
// CEO DMs are polled every cycle (critical). Other DMs rotate in halves
|
|
370
|
+
// to reduce API calls. DM thread discovery uses persisted activeThreads
|
|
371
|
+
// state instead of a redundant broader fetch (same fix as channels above).
|
|
372
|
+
// Cache CEO DM channel IDs for thread scanning priority later.
|
|
373
|
+
const ceoDMChannelIds = new Set();
|
|
361
374
|
try {
|
|
362
375
|
const convos = await slackApi("conversations.list", {
|
|
363
376
|
types: "im",
|
|
364
377
|
limit: 50,
|
|
365
378
|
});
|
|
366
379
|
if (convos.ok) {
|
|
367
|
-
|
|
380
|
+
const allDMs = convos.channels || [];
|
|
381
|
+
// Separate CEO DMs (always poll) from other DMs (rotate in halves)
|
|
382
|
+
const ceoDMs = allDMs.filter((im) => im.user === CEO_USER_ID);
|
|
383
|
+
for (const dm of ceoDMs) ceoDMChannelIds.add(dm.id);
|
|
384
|
+
const otherDMs = allDMs.filter((im) => im.user !== CEO_USER_ID);
|
|
385
|
+
const rotatedOtherDMs = otherDMs.filter((_, i) => i % 2 === pollCycleCount % 2);
|
|
386
|
+
const dmsThisCycle = [...ceoDMs, ...rotatedOtherDMs];
|
|
387
|
+
|
|
388
|
+
for (const im of dmsThisCycle) {
|
|
389
|
+
// Check cycle time budget — skip remaining DMs if running long
|
|
390
|
+
if (isCycleBudgetExceeded()) {
|
|
391
|
+
errors.push("Cycle time budget exceeded — skipping remaining DMs");
|
|
392
|
+
break;
|
|
393
|
+
}
|
|
394
|
+
|
|
368
395
|
const history = await slackApi("conversations.history", {
|
|
369
396
|
channel: im.id,
|
|
370
397
|
oldest,
|
|
@@ -372,26 +399,10 @@ export async function pollSlack() {
|
|
|
372
399
|
});
|
|
373
400
|
if (!history.ok) continue;
|
|
374
401
|
|
|
375
|
-
// Track threads discovered from DM history
|
|
402
|
+
// Track threads discovered from DM history — uses persisted state
|
|
403
|
+
// from prior cycles instead of a redundant broader API call.
|
|
376
404
|
const channelThreads = new Set(activeThreads[im.id] || []);
|
|
377
405
|
|
|
378
|
-
// Broader thread discovery for DMs — same pattern as channels.
|
|
379
|
-
// Fetch recent messages WITHOUT oldest filter to find DM threads
|
|
380
|
-
// whose parent message predates the cursor but have new replies.
|
|
381
|
-
try {
|
|
382
|
-
const broader = await slackApi("conversations.history", {
|
|
383
|
-
channel: im.id,
|
|
384
|
-
limit: 10,
|
|
385
|
-
});
|
|
386
|
-
if (broader.ok) {
|
|
387
|
-
for (const m of broader.messages || []) {
|
|
388
|
-
if (m.reply_count > 0 && m.ts) {
|
|
389
|
-
channelThreads.add(m.ts);
|
|
390
|
-
}
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
} catch { /* non-critical — DM thread discovery is best-effort */ }
|
|
394
|
-
|
|
395
406
|
for (const msg of history.messages || []) {
|
|
396
407
|
// Register any message with replies as an active thread
|
|
397
408
|
if (msg.reply_count > 0 && msg.ts) {
|
|
@@ -474,6 +485,11 @@ export async function pollSlack() {
|
|
|
474
485
|
// This catches thread replies that conversations.history misses.
|
|
475
486
|
// Use a 30-minute lookback window instead of the exact cursor to avoid
|
|
476
487
|
// missing replies that arrived between poll cycles or during processing.
|
|
488
|
+
//
|
|
489
|
+
// Thread scanning rotation: threads in high-priority channels (CEO DMs,
|
|
490
|
+
// critical/high channels) are scanned every cycle. Threads in normal
|
|
491
|
+
// channels rotate in halves to reduce API calls. If the cycle time
|
|
492
|
+
// budget is exceeded, remaining thread scans are skipped.
|
|
477
493
|
const THREAD_LOOKBACK_MS = 30 * 60 * 1000;
|
|
478
494
|
const threadOldest = String((Date.now() - THREAD_LOOKBACK_MS) / 1000);
|
|
479
495
|
const threadOldestTs = parseFloat(threadOldest);
|
|
@@ -490,9 +506,37 @@ export async function pollSlack() {
|
|
|
490
506
|
existingInboxFiles = readdirSync(SLACK_INBOX);
|
|
491
507
|
} catch { /* inbox dir may not exist yet */ }
|
|
492
508
|
|
|
493
|
-
|
|
509
|
+
// Determine which channels are high-priority for thread scanning.
|
|
510
|
+
// ceoDMChannelIds was populated during DM polling above — no extra API call needed.
|
|
511
|
+
const highPriorityChannelIds = new Set(
|
|
512
|
+
allChannels
|
|
513
|
+
.filter((c) => c.priority === "critical" || c.priority === "high")
|
|
514
|
+
.map((c) => c.id),
|
|
515
|
+
);
|
|
516
|
+
|
|
517
|
+
const threadEntries = Object.entries(activeThreads);
|
|
518
|
+
// Sort: high-priority channels first, then rotate others
|
|
519
|
+
const priorityThreadEntries = threadEntries.filter(
|
|
520
|
+
([chId]) => highPriorityChannelIds.has(chId) || ceoDMChannelIds.has(chId),
|
|
521
|
+
);
|
|
522
|
+
const normalThreadEntries = threadEntries.filter(
|
|
523
|
+
([chId]) => !highPriorityChannelIds.has(chId) && !ceoDMChannelIds.has(chId),
|
|
524
|
+
);
|
|
525
|
+
// Rotate normal thread entries in halves
|
|
526
|
+
const rotatedNormalThreadEntries = normalThreadEntries.filter(
|
|
527
|
+
(_, i) => i % 2 === pollCycleCount % 2,
|
|
528
|
+
);
|
|
529
|
+
const threadEntriesToScan = [...priorityThreadEntries, ...rotatedNormalThreadEntries];
|
|
530
|
+
|
|
531
|
+
for (const [channelId, threadTsList] of threadEntriesToScan) {
|
|
494
532
|
if (!threadTsList || threadTsList.length === 0) continue;
|
|
495
533
|
|
|
534
|
+
// Check cycle time budget before scanning this channel's threads
|
|
535
|
+
if (isCycleBudgetExceeded()) {
|
|
536
|
+
errors.push("Cycle time budget exceeded — skipping remaining thread scans");
|
|
537
|
+
break;
|
|
538
|
+
}
|
|
539
|
+
|
|
496
540
|
for (const threadTs of threadTsList) {
|
|
497
541
|
try {
|
|
498
542
|
const repliesResult = await slackApi("conversations.replies", {
|
|
@@ -643,8 +687,11 @@ export async function pollSlack() {
|
|
|
643
687
|
const data = JSON.parse(raw);
|
|
644
688
|
|
|
645
689
|
// Dedup: skip if we already have this item from API polling
|
|
646
|
-
|
|
647
|
-
const
|
|
690
|
+
// Events-server JSON uses `slack_event` key; also check `event` for compatibility
|
|
691
|
+
const evtPre = data.slack_event || data.event || {};
|
|
692
|
+
const eventTs = data.ts || data.event_ts || data.received_at || evtPre.ts || "";
|
|
693
|
+
const evtChannel = data.channel_id || data.channel || evtPre.channel || "";
|
|
694
|
+
const eventRef = data.raw_ref || `slack:${evtChannel}:${eventTs}`;
|
|
648
695
|
const alreadyHave = items.some((i) => i.raw_ref === eventRef);
|
|
649
696
|
if (alreadyHave) {
|
|
650
697
|
// Mark as processed since the API poll already got it
|
|
@@ -653,20 +700,22 @@ export async function pollSlack() {
|
|
|
653
700
|
}
|
|
654
701
|
|
|
655
702
|
// Convert events-server JSON to daemon item format
|
|
656
|
-
|
|
703
|
+
// Events-server JSON uses `slack_event` key; also check `event` for compatibility
|
|
704
|
+
const evt = data.slack_event || data.event || {};
|
|
705
|
+
const sender = data.sender || resolveName(data.user || data.user_id || evt.user || "unknown");
|
|
657
706
|
|
|
658
707
|
// Skip Sophie's own messages — the events server should filter these,
|
|
659
708
|
// but if any slip through (e.g. message_changed edge cases), catch here
|
|
660
|
-
const userId = data.user || data.user_id || "";
|
|
709
|
+
const userId = data.user || data.user_id || evt.user || "";
|
|
661
710
|
if (userId === SOPHIE_USER_ID || sender === "sophie-nguyen") {
|
|
662
711
|
try { renameSync(join(SLACK_INBOX_DIR, file), join(SLACK_INBOX_DIR, file + ".processed")); } catch {}
|
|
663
712
|
continue;
|
|
664
713
|
}
|
|
665
714
|
|
|
666
715
|
const privilege = data.sender_privilege || resolvePrivilege(userId);
|
|
667
|
-
const content = data.content || data.text ||
|
|
668
|
-
const channelId = data.channel_id || data.channel ||
|
|
669
|
-
const threadTs = data.thread_id || data.thread_ts ||
|
|
716
|
+
const content = data.content || data.text || evt.text || "";
|
|
717
|
+
const channelId = data.channel_id || data.channel || evt.channel || "";
|
|
718
|
+
const threadTs = data.thread_id || data.thread_ts || evt.thread_ts || "";
|
|
670
719
|
|
|
671
720
|
// Fetch thread context if this is a thread reply
|
|
672
721
|
let threadContext = data.thread_context || null;
|
|
@@ -674,6 +723,15 @@ export async function pollSlack() {
|
|
|
674
723
|
threadContext = await fetchThreadContext(channelId, threadTs, eventTs);
|
|
675
724
|
}
|
|
676
725
|
|
|
726
|
+
// Extract and download attachments from events-server items
|
|
727
|
+
const evtFiles = evt.files || data.files || [];
|
|
728
|
+
const evtAttachments = extractSlackAttachments(evtFiles);
|
|
729
|
+
for (const att of evtAttachments) {
|
|
730
|
+
const fileObj = evtFiles.find((f) => f.id === att.id);
|
|
731
|
+
const localPath = await downloadSlackAttachment(fileObj, SLACK_TOKEN);
|
|
732
|
+
if (localPath) att.local_path = localPath;
|
|
733
|
+
}
|
|
734
|
+
|
|
677
735
|
items.push({
|
|
678
736
|
id: data.id || eventTs.replace(".", "-") || file.replace(".json", ""),
|
|
679
737
|
service: "slack",
|
|
@@ -687,6 +745,7 @@ export async function pollSlack() {
|
|
|
687
745
|
thread_id: threadTs,
|
|
688
746
|
thread_context: threadContext,
|
|
689
747
|
is_reply: data.is_reply || (!!threadTs && threadTs !== eventTs),
|
|
748
|
+
attachments: evtAttachments.length > 0 ? evtAttachments : undefined,
|
|
690
749
|
priority_signals: data.priority_signals || {
|
|
691
750
|
from_ceo: privilege === "ceo",
|
|
692
751
|
tagged_urgent: /\b(urgent|emergency|asap|blocker|critical)\b/i.test(content),
|
|
@@ -712,5 +771,13 @@ export async function pollSlack() {
|
|
|
712
771
|
}
|
|
713
772
|
|
|
714
773
|
writeCursor("slack", new Date().toISOString());
|
|
774
|
+
|
|
775
|
+
const cycleDurationMs = Date.now() - cycleStartTime;
|
|
776
|
+
const cycleDurationSec = (cycleDurationMs / 1000).toFixed(1);
|
|
777
|
+
console.log(
|
|
778
|
+
`[slack-poller] Cycle ${pollCycleCount} completed in ${cycleDurationSec}s ` +
|
|
779
|
+
`(${channelsThisCycle.length} channels, ${items.length} items, ${errors.length} errors)`,
|
|
780
|
+
);
|
|
781
|
+
|
|
715
782
|
return { items, errors };
|
|
716
783
|
}
|
|
@@ -28,7 +28,7 @@ export function triggerSophie(item) {
|
|
|
28
28
|
const dir = join(SOPHIE_AI_DIR, "state", "inbox", "internal");
|
|
29
29
|
mkdirSync(dir, { recursive: true });
|
|
30
30
|
const taskFile = join(dir, `priority-${Date.now()}.yaml`);
|
|
31
|
-
|
|
31
|
+
let content = `type: priority_trigger
|
|
32
32
|
reason: "${reason}"
|
|
33
33
|
source_item: "${item.raw_ref}"
|
|
34
34
|
timestamp: "${new Date().toISOString()}"
|
|
@@ -37,6 +37,17 @@ channel: "${item.channel}"
|
|
|
37
37
|
content: |
|
|
38
38
|
${(item.content || "").replace(/\n/g, "\n ")}
|
|
39
39
|
`;
|
|
40
|
+
// Include attachment metadata so downstream sessions can view files
|
|
41
|
+
if (item.attachments && item.attachments.length > 0) {
|
|
42
|
+
content += `attachments:\n`;
|
|
43
|
+
for (const att of item.attachments) {
|
|
44
|
+
content += ` - name: "${att.name || "unnamed"}"\n`;
|
|
45
|
+
content += ` mimetype: "${att.mimetype || "unknown"}"\n`;
|
|
46
|
+
content += ` size: ${att.size || 0}\n`;
|
|
47
|
+
if (att.local_path) content += ` local_path: "${att.local_path}"\n`;
|
|
48
|
+
if (att.url_private) content += ` url_private: "${att.url_private}"\n`;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
40
51
|
writeFileSync(taskFile, content);
|
|
41
52
|
console.log(`[trigger] Priority task written to ${taskFile}`);
|
|
42
53
|
return true;
|
|
@@ -109,6 +109,94 @@ fi
|
|
|
109
109
|
|
|
110
110
|
echo ""
|
|
111
111
|
|
|
112
|
+
# ---------------------------------------------------------------------------
|
|
113
|
+
# Step 1b: Claude-Mem (persistent session memory plugin)
|
|
114
|
+
# ---------------------------------------------------------------------------
|
|
115
|
+
|
|
116
|
+
log "Setting up Claude-Mem (persistent session memory)..."
|
|
117
|
+
|
|
118
|
+
if command -v claude &>/dev/null; then
|
|
119
|
+
# Install via Claude Code plugin system
|
|
120
|
+
if npx claude-mem install --non-interactive 2>/dev/null; then
|
|
121
|
+
ok "Claude-Mem installed and configured"
|
|
122
|
+
else
|
|
123
|
+
warn "Claude-Mem auto-install failed — install manually: npx claude-mem install"
|
|
124
|
+
fi
|
|
125
|
+
else
|
|
126
|
+
warn "Claude CLI required for Claude-Mem — install Claude CLI first, then run: npx claude-mem install"
|
|
127
|
+
fi
|
|
128
|
+
|
|
129
|
+
echo ""
|
|
130
|
+
|
|
131
|
+
# ---------------------------------------------------------------------------
|
|
132
|
+
# Step 1c: Claude-Pace (real-time rate limit tracker)
|
|
133
|
+
# ---------------------------------------------------------------------------
|
|
134
|
+
|
|
135
|
+
log "Setting up Claude-Pace (rate limit tracker)..."
|
|
136
|
+
|
|
137
|
+
if command -v claude &>/dev/null; then
|
|
138
|
+
# Install via Claude Code plugin system
|
|
139
|
+
if claude plugin marketplace add Astro-Han/claude-pace 2>/dev/null && \
|
|
140
|
+
claude plugin install claude-pace 2>/dev/null; then
|
|
141
|
+
ok "Claude-Pace installed"
|
|
142
|
+
else
|
|
143
|
+
warn "Claude-Pace auto-install failed — install manually:"
|
|
144
|
+
warn " claude plugin marketplace add Astro-Han/claude-pace"
|
|
145
|
+
warn " claude plugin install claude-pace"
|
|
146
|
+
fi
|
|
147
|
+
else
|
|
148
|
+
warn "Claude CLI required for Claude-Pace — install Claude CLI first"
|
|
149
|
+
fi
|
|
150
|
+
|
|
151
|
+
echo ""
|
|
152
|
+
|
|
153
|
+
# ---------------------------------------------------------------------------
|
|
154
|
+
# Step 1d: Claudraband (persistent sessions + daemon mode) [optional]
|
|
155
|
+
# ---------------------------------------------------------------------------
|
|
156
|
+
|
|
157
|
+
log "Checking claudraband availability (persistent session backend)..."
|
|
158
|
+
|
|
159
|
+
if npx @halfwhey/claudraband --version &>/dev/null 2>&1; then
|
|
160
|
+
ok "Claudraband available (npx @halfwhey/claudraband)"
|
|
161
|
+
else
|
|
162
|
+
warn "Claudraband not cached — will be fetched on first use via npx"
|
|
163
|
+
warn " Pre-cache: npx @halfwhey/claudraband --version"
|
|
164
|
+
fi
|
|
165
|
+
|
|
166
|
+
echo ""
|
|
167
|
+
|
|
168
|
+
# ---------------------------------------------------------------------------
|
|
169
|
+
# Step 1e: ClawTeam swarm orchestrator [optional]
|
|
170
|
+
# ---------------------------------------------------------------------------
|
|
171
|
+
|
|
172
|
+
if [ "${MAESTRO_ENABLE_SWARM:-0}" = "1" ]; then
|
|
173
|
+
log "Setting up ClawTeam swarm orchestrator..."
|
|
174
|
+
|
|
175
|
+
CLAWTEAM_DIR="$HOME/ClawTeam-OpenClaw"
|
|
176
|
+
if [ -d "$CLAWTEAM_DIR" ]; then
|
|
177
|
+
ok "ClawTeam already cloned at $CLAWTEAM_DIR"
|
|
178
|
+
(cd "$CLAWTEAM_DIR" && git pull --ff-only 2>/dev/null) && ok "ClawTeam updated" || warn "ClawTeam update failed — check manually"
|
|
179
|
+
else
|
|
180
|
+
if git clone https://github.com/win4r/ClawTeam-OpenClaw.git "$CLAWTEAM_DIR" 2>/dev/null; then
|
|
181
|
+
ok "ClawTeam cloned to $CLAWTEAM_DIR"
|
|
182
|
+
else
|
|
183
|
+
warn "ClawTeam clone failed — install manually: git clone https://github.com/win4r/ClawTeam-OpenClaw.git ~/ClawTeam-OpenClaw"
|
|
184
|
+
fi
|
|
185
|
+
fi
|
|
186
|
+
|
|
187
|
+
# Ensure tmux is available (required by ClawTeam)
|
|
188
|
+
if ! command -v tmux &>/dev/null; then
|
|
189
|
+
warn "tmux not found — ClawTeam requires tmux. Install via: brew install tmux"
|
|
190
|
+
else
|
|
191
|
+
ok "tmux $(tmux -V 2>/dev/null)"
|
|
192
|
+
fi
|
|
193
|
+
|
|
194
|
+
echo ""
|
|
195
|
+
else
|
|
196
|
+
log "Skipping ClawTeam (set MAESTRO_ENABLE_SWARM=1 to enable)"
|
|
197
|
+
echo ""
|
|
198
|
+
fi
|
|
199
|
+
|
|
112
200
|
# ---------------------------------------------------------------------------
|
|
113
201
|
# Step 2: npm install
|
|
114
202
|
# ---------------------------------------------------------------------------
|
|
@@ -351,7 +439,9 @@ cat > "$CLAUDE_SETTINGS" << 'SETTINGS_EOF'
|
|
|
351
439
|
"mcp-server-dev@claude-plugins-official": true,
|
|
352
440
|
"zapier@claude-plugins-official": true,
|
|
353
441
|
"explanatory-output-style@claude-plugins-official": true,
|
|
354
|
-
"learning-output-style@claude-plugins-official": true
|
|
442
|
+
"learning-output-style@claude-plugins-official": true,
|
|
443
|
+
"claude-mem": true,
|
|
444
|
+
"claude-pace": true
|
|
355
445
|
},
|
|
356
446
|
"effortLevel": "high",
|
|
357
447
|
"skipDangerousModePermissionPrompt": true
|