@adaptic/maestro 1.1.0 → 1.1.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.
@@ -0,0 +1,225 @@
1
+ /**
2
+ * Maestro — Action Executor
3
+ *
4
+ * Executes tool calls from the Claude API. Write tools run agent-local
5
+ * scripts; lookup/RAG tools run targeted `claude --print` queries that
6
+ * leverage MCP servers (Gmail, Calendar, Slack, filesystem, etc.)
7
+ * via the agent's Max subscription.
8
+ *
9
+ * Usage:
10
+ * import { executeAction } from "@adaptic/maestro/lib/action-executor.js";
11
+ * const result = await executeAction("search_email", { query: "..." }, callerInfo, sessionId);
12
+ *
13
+ * @module action-executor
14
+ */
15
+
16
+ import { execFile } from "node:child_process";
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { getToolNamesForAccessLevel } from "./tool-definitions.js";
20
+
21
+ const AGENT_ROOT = process.env.AGENT_ROOT || process.cwd();
22
+
23
+ // ---------------------------------------------------------------------------
24
+ // Access Control
25
+ // ---------------------------------------------------------------------------
26
+
27
+ function isAuthorized(toolName, accessLevel) {
28
+ return getToolNamesForAccessLevel(accessLevel).includes(toolName);
29
+ }
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Execute a tool call.
37
+ * @param {string} toolName
38
+ * @param {object} input
39
+ * @param {object} callerInfo - { slug, name, accessLevel }
40
+ * @param {string} sessionId
41
+ * @param {object} [options] - { agentRoot, scripts }
42
+ * @returns {Promise<{success: boolean, result: string}>}
43
+ */
44
+ export async function executeAction(toolName, input, callerInfo, sessionId, options = {}) {
45
+ const agentRoot = options.agentRoot || AGENT_ROOT;
46
+
47
+ if (!isAuthorized(toolName, callerInfo.accessLevel)) {
48
+ return { success: false, result: "You do not have permission to perform this action." };
49
+ }
50
+
51
+ try {
52
+ // Lookup tools — RAG via Claude Code CLI
53
+ if (LOOKUP_PROMPTS[toolName]) {
54
+ return await executeLookup(toolName, input, agentRoot);
55
+ }
56
+
57
+ // Write tools — delegate to agent-local scripts
58
+ switch (toolName) {
59
+ case "slack_send":
60
+ return await executeScript(agentRoot, "slack-send.sh", [input.recipient, input.message]);
61
+ case "draft_email":
62
+ return await executeDraft(agentRoot, input);
63
+ case "whatsapp_send":
64
+ return await executeScript(agentRoot, "send-whatsapp.sh", [input.recipient, input.message]);
65
+ case "sms_send":
66
+ return await executeScript(agentRoot, "send-sms.sh", [input.recipient, input.message]);
67
+ case "queue_update":
68
+ return await executeQueueUpdate(agentRoot, input);
69
+ case "create_action_item":
70
+ return await executeCreateItem(agentRoot, input);
71
+ case "schedule_meeting":
72
+ return await executeLookup("schedule_meeting", input, agentRoot);
73
+ case "create_reminder":
74
+ return await executeLookup("create_reminder", input, agentRoot);
75
+ case "generate_memo":
76
+ return await executeGenerateMemo(agentRoot, input);
77
+ case "generate_report":
78
+ return await executeLookup("generate_report", input, agentRoot);
79
+ default:
80
+ return { success: false, result: `Unknown tool: ${toolName}` };
81
+ }
82
+ } catch (err) {
83
+ return { success: false, result: `Action failed: ${err.message}` };
84
+ }
85
+ }
86
+
87
+ // ---------------------------------------------------------------------------
88
+ // Lookup / RAG via Claude Code CLI
89
+ // ---------------------------------------------------------------------------
90
+
91
+ const LOOKUP_PROMPTS = {
92
+ search_email: (i) =>
93
+ `Search Gmail for: "${i.query}". Return top ${i.max_results || 5} results with sender, subject, date, 1-sentence summary. Be concise — spoken aloud.`,
94
+ search_calendar: (i) =>
95
+ `Look up calendar: "${i.query}" for ${i.time_range || "this week"}. List events with title, time, attendees. Be concise.`,
96
+ search_files: (i) =>
97
+ `Search operational files for: "${i.query}"${i.scope && i.scope !== "all" ? ` in ${i.scope}/` : ""}. Summarise in 2-4 sentences.`,
98
+ search_meetings: (i) =>
99
+ `Search Granola meeting notes for: "${i.query}"${i.date_range ? ` from ${i.date_range}` : ""}. Summarise key discussions and decisions in 2-4 sentences.`,
100
+ search_slack: (i) =>
101
+ `Search Slack for: "${i.query}"${i.channel ? ` in ${i.channel}` : ""}. Summarise who said what, when. 2-4 sentences.`,
102
+ search_web: (i) =>
103
+ `Web search: "${i.query}". Summarise top ${i.max_results || 5} results concisely.`,
104
+ search_documents: (i) =>
105
+ `Search shared documents for: "${i.query}"${i.doc_type && i.doc_type !== "all" ? ` (${i.doc_type} only)` : ""}. List titles and brief summaries.`,
106
+ search_decisions: (i) =>
107
+ `Search the decision archive for: "${i.query}". Include rationale, who decided, and outcome. 2-4 sentences.`,
108
+ lookup_person: (i) =>
109
+ `Look up information about ${i.name}: ${i.info_type || "all available info"} — role, contact, recent activity. Be concise.`,
110
+ search_regulatory: (i) =>
111
+ `Search regulatory/compliance info for: "${i.query}"${i.jurisdiction ? ` in ${i.jurisdiction}` : ""}. Status, deadlines, requirements. Be concise.`,
112
+ search_strategy: (i) =>
113
+ `Search strategic context for: "${i.query}". Priorities, OKRs, initiative status. 2-4 sentences.`,
114
+ search_precedents: (i) =>
115
+ `Search operational precedents for: "${i.query}". How was this handled before? Templates? 2-4 sentences.`,
116
+ search_financial: (i) =>
117
+ `Search financial data for: "${i.query}". Runway, burn, capital readiness. Be concise.`,
118
+ search_pipeline: (i) =>
119
+ `Search pipeline for: "${i.query}"${i.stage && i.stage !== "all" ? ` (stage: ${i.stage})` : ""}. Deal status, partner conversations. Be concise.`,
120
+ search_engineering: (i) =>
121
+ `Search engineering health for: "${i.query}"${i.repo ? ` in ${i.repo}` : ""}. PRs, delivery confidence, blockers. Be concise.`,
122
+ search_hiring: (i) =>
123
+ `Search hiring pipeline for: "${i.query}"${i.status && i.status !== "all" ? ` (status: ${i.status})` : ""}. Roles, candidates, offers. Be concise.`,
124
+ search_risk: (i) =>
125
+ `Search risk register for: "${i.query}"${i.severity && i.severity !== "all" ? ` (severity: ${i.severity})` : ""}. Risks, mitigations, owners. Be concise.`,
126
+ schedule_meeting: (i) =>
127
+ `Schedule a meeting: "${i.title}" with ${i.attendees} on ${i.date}${i.time ? ` at ${i.time}` : ""}${i.duration ? ` for ${i.duration}` : ""}. Check availability and create the event. Confirm what was scheduled.`,
128
+ create_reminder: (i) =>
129
+ `Create a reminder: "${i.message}" for ${i.for_whom || "the principal"} at ${i.when}. Confirm when it will fire.`,
130
+ generate_report: (i) =>
131
+ `Generate a ${i.report_type} report${i.custom_query ? `: ${i.custom_query}` : ""}${i.time_range ? ` for ${i.time_range}` : ""}. Aggregate data from dashboards and queues. Format as a concise executive summary.`,
132
+ };
133
+
134
+ async function executeLookup(toolName, input, agentRoot) {
135
+ const promptBuilder = LOOKUP_PROMPTS[toolName];
136
+ if (!promptBuilder) return { success: false, result: `No prompt for: ${toolName}` };
137
+
138
+ const prompt = promptBuilder(input);
139
+
140
+ try {
141
+ const { stdout } = await new Promise((resolve, reject) => {
142
+ execFile("claude", ["--print", "--model", "haiku", "-p", prompt], {
143
+ timeout: 25000,
144
+ maxBuffer: 1024 * 1024,
145
+ env: { ...process.env, TERM: "dumb" },
146
+ cwd: agentRoot,
147
+ }, (err, stdout, stderr) => {
148
+ if (err) reject(err);
149
+ else resolve({ stdout, stderr });
150
+ });
151
+ });
152
+
153
+ return { success: true, result: (stdout || "").trim() || "No results found." };
154
+ } catch (err) {
155
+ if (err.killed) return { success: false, result: "Search timed out. Try a more specific query." };
156
+ return { success: false, result: `Search failed: ${err.message}` };
157
+ }
158
+ }
159
+
160
+ // ---------------------------------------------------------------------------
161
+ // Write tool executors (generic implementations — agents can override)
162
+ // ---------------------------------------------------------------------------
163
+
164
+ async function executeScript(agentRoot, scriptName, args) {
165
+ const scriptPath = path.join(agentRoot, "scripts", scriptName);
166
+ if (!fs.existsSync(scriptPath)) {
167
+ return { success: false, result: `Script not found: ${scriptName}` };
168
+ }
169
+
170
+ return new Promise((resolve) => {
171
+ execFile("bash", [scriptPath, ...args.filter(Boolean)], {
172
+ timeout: 15000,
173
+ cwd: agentRoot,
174
+ env: { ...process.env },
175
+ }, (err, stdout, stderr) => {
176
+ if (err) resolve({ success: false, result: stderr || err.message });
177
+ else resolve({ success: true, result: stdout.trim() || "Done." });
178
+ });
179
+ });
180
+ }
181
+
182
+ async function executeDraft(agentRoot, input) {
183
+ const draftsDir = path.join(agentRoot, "outputs/drafts");
184
+ fs.mkdirSync(draftsDir, { recursive: true });
185
+
186
+ const date = new Date().toISOString().split("T")[0];
187
+ const slug = input.to.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase();
188
+ const filePath = path.join(draftsDir, `${date}-draft-${slug}.md`);
189
+
190
+ const content = `---\nto: ${input.to}\nsubject: ${input.subject}\nvoice: ${input.voice || "agent"}\nurgency: ${input.urgency || "routine"}\ndate: ${new Date().toISOString()}\n---\n\n${input.body}\n`;
191
+ fs.writeFileSync(filePath, content);
192
+
193
+ if (input.urgency === "immediate") {
194
+ return executeScript(agentRoot, "send-email.sh", [input.to, input.subject, input.body]);
195
+ }
196
+
197
+ return { success: true, result: `Draft saved: ${filePath}` };
198
+ }
199
+
200
+ async function executeQueueUpdate(agentRoot, input) {
201
+ // Delegate to claude CLI for intelligent queue searching and updating
202
+ return executeLookup("queue_update_internal", input, agentRoot);
203
+ }
204
+
205
+ // Add queue_update_internal to LOOKUP_PROMPTS
206
+ LOOKUP_PROMPTS.queue_update_internal = (i) =>
207
+ `In the state/queues/ directory, find the item matching "${i.search_term}" and update its status to "${i.new_status}"${i.new_priority ? ` and priority to "${i.new_priority}"` : ""}${i.note ? `. Add note: "${i.note}"` : ""}. Confirm what was updated.`;
208
+
209
+ async function executeCreateItem(agentRoot, input) {
210
+ return executeLookup("create_item_internal", input, agentRoot);
211
+ }
212
+
213
+ LOOKUP_PROMPTS.create_item_internal = (i) =>
214
+ `Create a new item in state/queues/${i.queue || "action-stack"}.yaml with title "${i.title}", priority "${i.priority || "high"}"${i.due_date ? `, due ${i.due_date}` : ""}${i.owner ? `, owner: ${i.owner}` : ""}${i.context ? `, context: "${i.context}"` : ""}. Use the existing YAML format. Confirm what was created.`;
215
+
216
+ async function executeGenerateMemo(agentRoot, input) {
217
+ const draftsDir = path.join(agentRoot, "outputs/drafts");
218
+ fs.mkdirSync(draftsDir, { recursive: true });
219
+
220
+ const slug = input.title.replace(/[^a-zA-Z0-9]/g, "-").toLowerCase().slice(0, 40);
221
+ const filePath = path.join(draftsDir, `${new Date().toISOString().split("T")[0]}-${slug}.md`);
222
+ fs.writeFileSync(filePath, `# ${input.title}\n\n${input.content}\n`);
223
+
224
+ return { success: true, result: `Memo saved to ${filePath}` };
225
+ }
package/lib/index.js ADDED
@@ -0,0 +1,16 @@
1
+ /**
2
+ * @adaptic/maestro — Main entry point
3
+ *
4
+ * Re-exports all public modules for convenient consumption:
5
+ * import { getToolsForAccessLevel, executeAction } from "@adaptic/maestro";
6
+ */
7
+
8
+ export {
9
+ getToolsForAccessLevel,
10
+ getToolNamesForAccessLevel,
11
+ getAllToolsByCategory,
12
+ } from "./tool-definitions.js";
13
+
14
+ export { executeAction } from "./action-executor.js";
15
+
16
+ export { acquireLock, releaseLock, checkLock } from "./singleton.js";
@@ -0,0 +1,116 @@
1
+ /**
2
+ * Maestro — Process Singleton Guard
3
+ *
4
+ * Prevents duplicate instances of long-running services (daemon, events
5
+ * server, huddle server, etc.). Uses PID files with stale detection.
6
+ *
7
+ * Usage:
8
+ * import { acquireLock, releaseLock } from "@adaptic/maestro/lib/singleton.js";
9
+ *
10
+ * // At startup:
11
+ * if (!acquireLock("slack-events")) {
12
+ * console.log("Already running — exiting");
13
+ * process.exit(0);
14
+ * }
15
+ *
16
+ * // On shutdown:
17
+ * process.on("SIGTERM", () => { releaseLock("slack-events"); process.exit(0); });
18
+ *
19
+ * @module singleton
20
+ */
21
+
22
+ import { writeFileSync, readFileSync, unlinkSync, existsSync, mkdirSync } from "node:fs";
23
+ import { join } from "node:path";
24
+ import { execSync } from "node:child_process";
25
+
26
+ const AGENT_ROOT = process.env.AGENT_ROOT || process.cwd();
27
+ const LOCKS_DIR = join(AGENT_ROOT, "state", "locks", "process");
28
+
29
+ /**
30
+ * Attempt to acquire a singleton lock for a named service.
31
+ * Returns true if the lock was acquired (no other instance running).
32
+ * Returns false if another instance is already running.
33
+ *
34
+ * Handles stale PID files (process died without cleanup).
35
+ *
36
+ * @param {string} name - Service name (e.g., "slack-events", "daemon", "huddle-server")
37
+ * @returns {boolean}
38
+ */
39
+ export function acquireLock(name) {
40
+ mkdirSync(LOCKS_DIR, { recursive: true });
41
+ const pidFile = join(LOCKS_DIR, `${name}.pid`);
42
+
43
+ // Check for existing lock
44
+ if (existsSync(pidFile)) {
45
+ try {
46
+ const existingPid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
47
+ if (existingPid && isProcessRunning(existingPid)) {
48
+ // Another instance is genuinely running
49
+ return false;
50
+ }
51
+ // Stale PID file — process is dead, clean up
52
+ unlinkSync(pidFile);
53
+ } catch {
54
+ // Corrupt PID file — remove it
55
+ try { unlinkSync(pidFile); } catch { /* ignore */ }
56
+ }
57
+ }
58
+
59
+ // Write our PID
60
+ writeFileSync(pidFile, String(process.pid));
61
+
62
+ // Register cleanup on exit
63
+ const cleanup = () => {
64
+ try { unlinkSync(pidFile); } catch { /* ignore */ }
65
+ };
66
+ process.on("exit", cleanup);
67
+ process.on("SIGINT", () => { cleanup(); process.exit(0); });
68
+ process.on("SIGTERM", () => { cleanup(); process.exit(0); });
69
+
70
+ return true;
71
+ }
72
+
73
+ /**
74
+ * Manually release a lock.
75
+ * @param {string} name
76
+ */
77
+ export function releaseLock(name) {
78
+ const pidFile = join(LOCKS_DIR, `${name}.pid`);
79
+ try { unlinkSync(pidFile); } catch { /* ignore */ }
80
+ }
81
+
82
+ /**
83
+ * Check if a service is currently running.
84
+ * @param {string} name
85
+ * @returns {{ running: boolean, pid: number|null }}
86
+ */
87
+ export function checkLock(name) {
88
+ const pidFile = join(LOCKS_DIR, `${name}.pid`);
89
+ if (!existsSync(pidFile)) return { running: false, pid: null };
90
+
91
+ try {
92
+ const pid = parseInt(readFileSync(pidFile, "utf8").trim(), 10);
93
+ if (pid && isProcessRunning(pid)) {
94
+ return { running: true, pid };
95
+ }
96
+ // Stale — clean up
97
+ try { unlinkSync(pidFile); } catch { /* ignore */ }
98
+ return { running: false, pid: null };
99
+ } catch {
100
+ return { running: false, pid: null };
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Check if a PID is still alive.
106
+ * @param {number} pid
107
+ * @returns {boolean}
108
+ */
109
+ function isProcessRunning(pid) {
110
+ try {
111
+ process.kill(pid, 0); // Signal 0 = existence check, no actual signal
112
+ return true;
113
+ } catch {
114
+ return false;
115
+ }
116
+ }