@epic-cloudcontrol/daemon 0.2.0

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,146 @@
1
+ import { ClaudeAdapter } from "./models/claude.js";
2
+ import { OllamaAdapter } from "./models/ollama.js";
3
+ import { CliAdapter, KNOWN_CLIS, parseCliModels } from "./models/cli-adapter.js";
4
+ import { execSync } from "child_process";
5
+ function isCommandInstalled(command) {
6
+ try {
7
+ execSync(`which ${command}`, { stdio: "ignore" });
8
+ return true;
9
+ }
10
+ catch {
11
+ return false;
12
+ }
13
+ }
14
+ /**
15
+ * ModelRouter manages a roster of available AI models and selects
16
+ * the best one for each task based on the task's modelHint.
17
+ *
18
+ * Models are detected from:
19
+ * 1. ANTHROPIC_API_KEY → Claude API (sonnet + haiku)
20
+ * 2. Installed CLIs → auto-detected from KNOWN_CLIS + CLOUDCONTROL_CLI_MODELS env
21
+ * 3. CLOUDCONTROL_OLLAMA_MODELS → Ollama local models
22
+ */
23
+ export class ModelRouter {
24
+ models = [];
25
+ defaultModel;
26
+ constructor() {
27
+ this.defaultModel = "";
28
+ this.detectAvailableModels();
29
+ }
30
+ detectAvailableModels() {
31
+ // 1. Claude API models (require ANTHROPIC_API_KEY)
32
+ const apiKey = process.env.ANTHROPIC_API_KEY;
33
+ const hasApiKey = apiKey && apiKey !== "your-anthropic-api-key-here" && apiKey.length > 0;
34
+ if (hasApiKey) {
35
+ this.models.push({
36
+ name: "claude-sonnet",
37
+ adapter: new ClaudeAdapter("claude-sonnet-4-20250514"),
38
+ traits: ["smartest", "code", "vision"],
39
+ available: true,
40
+ });
41
+ this.models.push({
42
+ name: "claude-haiku",
43
+ adapter: new ClaudeAdapter("claude-haiku-4-5-20251001"),
44
+ traits: ["cheapest", "fastest"],
45
+ available: true,
46
+ });
47
+ }
48
+ // 2. Auto-detect installed CLIs from KNOWN_CLIS
49
+ const cliTraits = {
50
+ "claude-code": ["smartest", "code", "local"],
51
+ gemini: ["smartest", "code", "local"],
52
+ codex: ["code", "local"],
53
+ aider: ["code", "local"],
54
+ goose: ["code", "local"],
55
+ };
56
+ for (const [name, config] of Object.entries(KNOWN_CLIS)) {
57
+ if (isCommandInstalled(config.command)) {
58
+ this.models.push({
59
+ name,
60
+ adapter: new CliAdapter({ name, ...config }),
61
+ traits: cliTraits[name] || ["code", "local"],
62
+ available: true,
63
+ });
64
+ }
65
+ }
66
+ // 3. Custom CLI models from env var
67
+ // Format: "name:command:args;name2:command2:args"
68
+ // Or just names from KNOWN_CLIS: "gemini;aider"
69
+ const customClis = process.env.CLOUDCONTROL_CLI_MODELS;
70
+ if (customClis) {
71
+ const configs = parseCliModels(customClis);
72
+ for (const config of configs) {
73
+ // Skip if already registered (auto-detected above)
74
+ if (this.models.some((m) => m.name === config.name))
75
+ continue;
76
+ this.models.push({
77
+ name: config.name,
78
+ adapter: new CliAdapter(config),
79
+ traits: ["code", "local"],
80
+ available: true,
81
+ });
82
+ }
83
+ }
84
+ // 4. Ollama models
85
+ const ollamaUrl = process.env.OLLAMA_URL || "http://localhost:11434";
86
+ const ollamaModels = process.env.CLOUDCONTROL_OLLAMA_MODELS?.split(",") || [];
87
+ for (const model of ollamaModels) {
88
+ const name = model.trim();
89
+ if (!name)
90
+ continue;
91
+ this.models.push({
92
+ name: `ollama/${name}`,
93
+ adapter: new OllamaAdapter(name, ollamaUrl),
94
+ traits: ["cheapest", "local"],
95
+ available: true,
96
+ });
97
+ }
98
+ // Set default: first available local model, or cheapest API model
99
+ const localModel = this.models.find((m) => m.traits.includes("local"));
100
+ const cheapApi = this.models.find((m) => m.name === "claude-haiku");
101
+ this.defaultModel = localModel?.name || cheapApi?.name || this.models[0]?.name || "";
102
+ console.log(`[router] Available models: ${this.models.map((m) => m.name).join(", ") || "none"}`);
103
+ console.log(`[router] Default model: ${this.defaultModel || "none"}`);
104
+ }
105
+ /**
106
+ * Select the best model for a task based on its modelHint.
107
+ */
108
+ select(modelHint) {
109
+ if (!modelHint || modelHint === "auto") {
110
+ return this.getByName(this.defaultModel);
111
+ }
112
+ // Specific model name — exact match
113
+ const exact = this.models.find((m) => m.name === modelHint && m.available);
114
+ if (exact) {
115
+ return { adapter: exact.adapter, name: exact.name };
116
+ }
117
+ // Trait-based hint — find best match
118
+ const trait = modelHint;
119
+ const candidates = this.models.filter((m) => m.available && m.traits.includes(trait));
120
+ if (candidates.length > 0) {
121
+ return { adapter: candidates[0].adapter, name: candidates[0].name };
122
+ }
123
+ console.log(`[router] No model matches hint "${modelHint}", using default "${this.defaultModel}"`);
124
+ return this.getByName(this.defaultModel);
125
+ }
126
+ getByName(name) {
127
+ const model = this.models.find((m) => m.name === name && m.available);
128
+ if (model)
129
+ return { adapter: model.adapter, name: model.name };
130
+ const fallback = this.models.find((m) => m.available);
131
+ if (fallback) {
132
+ console.log(`[router] Default "${name}" not available, falling back to "${fallback.name}"`);
133
+ return { adapter: fallback.adapter, name: fallback.name };
134
+ }
135
+ throw new Error("No AI models available. Install a CLI (claude, gemini, etc.), set ANTHROPIC_API_KEY, or configure Ollama.");
136
+ }
137
+ getDefault() {
138
+ return this.defaultModel || "none";
139
+ }
140
+ listModels() {
141
+ return this.models
142
+ .filter((m) => m.available)
143
+ .map((m) => ({ name: m.name, traits: m.traits }));
144
+ }
145
+ }
146
+ //# sourceMappingURL=model-router.js.map
@@ -0,0 +1,15 @@
1
+ import type { ExecutionResult } from "./claude.js";
2
+ import { type SandboxConfig } from "../sandbox.js";
3
+ export declare class ClaudeCodeAdapter {
4
+ private sandboxConfig;
5
+ constructor(sandboxConfig?: SandboxConfig);
6
+ execute(task: {
7
+ title: string;
8
+ description?: string | null;
9
+ taskType?: string | null;
10
+ context?: Record<string, unknown> | null;
11
+ processHint?: string | null;
12
+ humanContext?: string | null;
13
+ }): Promise<ExecutionResult>;
14
+ private runClaude;
15
+ }
@@ -0,0 +1,140 @@
1
+ import { spawn } from "child_process";
2
+ import { buildSandboxPrompt, getDefaultSandboxConfig, filterEnvironment, truncateOutput, } from "../sandbox.js";
3
+ export class ClaudeCodeAdapter {
4
+ sandboxConfig;
5
+ constructor(sandboxConfig) {
6
+ this.sandboxConfig = sandboxConfig || getDefaultSandboxConfig();
7
+ }
8
+ async execute(task) {
9
+ const dialogue = [];
10
+ const startTime = Date.now();
11
+ // Build prompt with sandbox restrictions
12
+ const sandboxRules = buildSandboxPrompt(this.sandboxConfig);
13
+ let prompt = `## Task: ${task.title}\n`;
14
+ if (task.description)
15
+ prompt += `\n${task.description}\n`;
16
+ if (task.taskType)
17
+ prompt += `\nTask type: ${task.taskType}\n`;
18
+ if (task.processHint)
19
+ prompt += `\nProcess hint: ${task.processHint}\n`;
20
+ if (task.context) {
21
+ prompt += `\nContext:\n\`\`\`json\n${JSON.stringify(task.context, null, 2)}\n\`\`\`\n`;
22
+ }
23
+ if (task.humanContext) {
24
+ prompt += `\nHuman feedback from previous attempt:\n${task.humanContext}\n`;
25
+ }
26
+ prompt += `\nWhen you encounter something you cannot complete (CAPTCHA, phone verification, account creation, ambiguous decision), say: [HUMAN_REQUIRED]: <reason>\n`;
27
+ prompt += `\nWhen done, end with: [RESULT]: <brief summary>\n`;
28
+ prompt += sandboxRules;
29
+ dialogue.push({
30
+ role: "user",
31
+ content: prompt,
32
+ timestamp: new Date().toISOString(),
33
+ });
34
+ try {
35
+ const output = await this.runClaude(prompt);
36
+ dialogue.push({
37
+ role: "assistant",
38
+ content: output,
39
+ timestamp: new Date().toISOString(),
40
+ });
41
+ // Check for human required
42
+ const humanMatch = output.match(/\[HUMAN_REQUIRED\]:\s*(.*)/s);
43
+ if (humanMatch) {
44
+ return {
45
+ success: false,
46
+ dialogue,
47
+ result: { raw_response: output },
48
+ metadata: {
49
+ model: "claude-code",
50
+ tokens_used: 0,
51
+ duration_ms: Date.now() - startTime,
52
+ },
53
+ humanRequired: {
54
+ reason: humanMatch[1].trim(),
55
+ context: output,
56
+ },
57
+ };
58
+ }
59
+ const resultMatch = output.match(/\[RESULT\]:\s*(.*)/s);
60
+ const resultSummary = resultMatch
61
+ ? resultMatch[1].trim()
62
+ : output.slice(0, 500);
63
+ return {
64
+ success: true,
65
+ dialogue,
66
+ result: { summary: resultSummary, raw_response: output },
67
+ metadata: {
68
+ model: "claude-code",
69
+ tokens_used: 0,
70
+ duration_ms: Date.now() - startTime,
71
+ },
72
+ };
73
+ }
74
+ catch (error) {
75
+ const errMsg = error instanceof Error ? error.message : "Unknown error";
76
+ dialogue.push({
77
+ role: "system",
78
+ content: `Execution error: ${errMsg}`,
79
+ timestamp: new Date().toISOString(),
80
+ });
81
+ return {
82
+ success: false,
83
+ dialogue,
84
+ result: { error: errMsg },
85
+ metadata: {
86
+ model: "claude-code",
87
+ tokens_used: 0,
88
+ duration_ms: Date.now() - startTime,
89
+ },
90
+ };
91
+ }
92
+ }
93
+ runClaude(prompt) {
94
+ return new Promise((resolve, reject) => {
95
+ // Filter environment — only pass allowed vars, strip secrets
96
+ const filteredEnv = filterEnvironment(process.env, this.sandboxConfig.allowedEnvVars);
97
+ const proc = spawn("claude", ["-p", prompt], {
98
+ stdio: ["ignore", "pipe", "pipe"],
99
+ env: filteredEnv,
100
+ });
101
+ let stdout = "";
102
+ let stderr = "";
103
+ const maxOutput = this.sandboxConfig.maxOutputBytes;
104
+ proc.stdout.on("data", (data) => {
105
+ stdout += data.toString();
106
+ // Kill process if output exceeds limit
107
+ if (Buffer.byteLength(stdout, "utf-8") > maxOutput * 1.1) {
108
+ proc.kill();
109
+ reject(new Error("Output exceeded maximum size limit"));
110
+ }
111
+ });
112
+ proc.stderr.on("data", (data) => {
113
+ stderr += data.toString();
114
+ });
115
+ proc.on("close", (code) => {
116
+ const truncatedOutput = truncateOutput(stdout.trim(), maxOutput);
117
+ if (code === 0) {
118
+ resolve(truncatedOutput);
119
+ }
120
+ else {
121
+ reject(new Error(`claude exited with code ${code}: ${stderr.slice(0, 500)}`));
122
+ }
123
+ });
124
+ proc.on("error", (err) => {
125
+ if (err.code === "ENOENT") {
126
+ reject(new Error("Claude Code CLI not found. Install it or set ANTHROPIC_API_KEY to use the API adapter."));
127
+ }
128
+ else {
129
+ reject(err);
130
+ }
131
+ });
132
+ // 5 minute timeout
133
+ setTimeout(() => {
134
+ proc.kill();
135
+ reject(new Error("Claude Code execution timed out (5m)"));
136
+ }, 5 * 60 * 1000);
137
+ });
138
+ }
139
+ }
140
+ //# sourceMappingURL=claude-code.js.map
@@ -0,0 +1,34 @@
1
+ import { type SandboxConfig } from "../sandbox.js";
2
+ export interface ChatMessage {
3
+ role: "user" | "assistant" | "system";
4
+ content: string;
5
+ timestamp: string;
6
+ }
7
+ export interface ExecutionResult {
8
+ success: boolean;
9
+ dialogue: ChatMessage[];
10
+ result: Record<string, unknown>;
11
+ metadata: {
12
+ model: string;
13
+ tokens_used: number;
14
+ duration_ms: number;
15
+ };
16
+ humanRequired?: {
17
+ reason: string;
18
+ context: string;
19
+ };
20
+ }
21
+ export declare class ClaudeAdapter {
22
+ private client;
23
+ private model;
24
+ private sandboxConfig;
25
+ constructor(model?: string, sandboxConfig?: SandboxConfig);
26
+ execute(task: {
27
+ title: string;
28
+ description?: string | null;
29
+ taskType?: string | null;
30
+ context?: Record<string, unknown> | null;
31
+ processHint?: string | null;
32
+ humanContext?: string | null;
33
+ }): Promise<ExecutionResult>;
34
+ }
@@ -0,0 +1,121 @@
1
+ import Anthropic from "@anthropic-ai/sdk";
2
+ import { buildSandboxPrompt, getDefaultSandboxConfig } from "../sandbox.js";
3
+ export class ClaudeAdapter {
4
+ client;
5
+ model;
6
+ sandboxConfig;
7
+ constructor(model = "claude-sonnet-4-20250514", sandboxConfig) {
8
+ this.client = new Anthropic();
9
+ this.model = model;
10
+ this.sandboxConfig = sandboxConfig || getDefaultSandboxConfig();
11
+ }
12
+ async execute(task) {
13
+ const dialogue = [];
14
+ const startTime = Date.now();
15
+ let totalTokens = 0;
16
+ // Build system prompt with sandbox restrictions
17
+ const sandboxRules = buildSandboxPrompt(this.sandboxConfig);
18
+ const systemPrompt = `You are a CloudControl task executor. You complete tasks efficiently and report results clearly.
19
+
20
+ When you encounter something you cannot complete (CAPTCHA, phone verification, account creation, ambiguous decision), respond with:
21
+ [HUMAN_REQUIRED]: <reason>
22
+
23
+ Always end your final response with:
24
+ [RESULT]: <brief summary of what was accomplished>
25
+ ${sandboxRules}`;
26
+ // Build user message with full context
27
+ let userMessage = `## Task: ${task.title}\n`;
28
+ if (task.description)
29
+ userMessage += `\n${task.description}\n`;
30
+ if (task.taskType)
31
+ userMessage += `\nTask type: ${task.taskType}\n`;
32
+ if (task.processHint)
33
+ userMessage += `\nProcess hint: ${task.processHint}\n`;
34
+ if (task.context) {
35
+ userMessage += `\nContext:\n\`\`\`json\n${JSON.stringify(task.context, null, 2)}\n\`\`\`\n`;
36
+ }
37
+ if (task.humanContext) {
38
+ userMessage += `\nHuman feedback from previous attempt:\n${task.humanContext}\n`;
39
+ }
40
+ dialogue.push({
41
+ role: "user",
42
+ content: userMessage,
43
+ timestamp: new Date().toISOString(),
44
+ });
45
+ try {
46
+ const response = await this.client.messages.create({
47
+ model: this.model,
48
+ max_tokens: 4096,
49
+ system: systemPrompt,
50
+ messages: [{ role: "user", content: userMessage }],
51
+ });
52
+ const assistantContent = response.content
53
+ .filter((c) => c.type === "text")
54
+ .map((c) => c.text)
55
+ .join("\n");
56
+ totalTokens =
57
+ (response.usage?.input_tokens || 0) +
58
+ (response.usage?.output_tokens || 0);
59
+ dialogue.push({
60
+ role: "assistant",
61
+ content: assistantContent,
62
+ timestamp: new Date().toISOString(),
63
+ });
64
+ // Check for human required
65
+ const humanMatch = assistantContent.match(/\[HUMAN_REQUIRED\]:\s*(.*)/s);
66
+ if (humanMatch) {
67
+ return {
68
+ success: false,
69
+ dialogue,
70
+ result: { raw_response: assistantContent },
71
+ metadata: {
72
+ model: this.model,
73
+ tokens_used: totalTokens,
74
+ duration_ms: Date.now() - startTime,
75
+ },
76
+ humanRequired: {
77
+ reason: humanMatch[1].trim(),
78
+ context: assistantContent,
79
+ },
80
+ };
81
+ }
82
+ // Extract result
83
+ const resultMatch = assistantContent.match(/\[RESULT\]:\s*(.*)/s);
84
+ const resultSummary = resultMatch
85
+ ? resultMatch[1].trim()
86
+ : assistantContent.slice(0, 500);
87
+ return {
88
+ success: true,
89
+ dialogue,
90
+ result: {
91
+ summary: resultSummary,
92
+ raw_response: assistantContent,
93
+ },
94
+ metadata: {
95
+ model: this.model,
96
+ tokens_used: totalTokens,
97
+ duration_ms: Date.now() - startTime,
98
+ },
99
+ };
100
+ }
101
+ catch (error) {
102
+ const errMsg = error instanceof Error ? error.message : "Unknown error";
103
+ dialogue.push({
104
+ role: "system",
105
+ content: `Execution error: ${errMsg}`,
106
+ timestamp: new Date().toISOString(),
107
+ });
108
+ return {
109
+ success: false,
110
+ dialogue,
111
+ result: { error: errMsg },
112
+ metadata: {
113
+ model: this.model,
114
+ tokens_used: totalTokens,
115
+ duration_ms: Date.now() - startTime,
116
+ },
117
+ };
118
+ }
119
+ }
120
+ }
121
+ //# sourceMappingURL=claude.js.map
@@ -0,0 +1,48 @@
1
+ import type { ExecutionResult } from "./claude.js";
2
+ import { type SandboxConfig } from "../sandbox.js";
3
+ export interface CliModelConfig {
4
+ /** Display name (e.g., "gemini", "claude-code", "aider") */
5
+ name: string;
6
+ /** Binary to execute (e.g., "claude", "gemini", "aider") */
7
+ command: string;
8
+ /** How to pass the prompt. Use {prompt} as placeholder. */
9
+ args: string[];
10
+ /** Environment variables to set for this CLI (merged with filtered env) */
11
+ env?: Record<string, string>;
12
+ /** Timeout in ms (default: 5 minutes) */
13
+ timeoutMs?: number;
14
+ }
15
+ /**
16
+ * Predefined CLI configurations for known tools.
17
+ * Add new CLIs here — no other code changes needed.
18
+ */
19
+ export declare const KNOWN_CLIS: Record<string, Omit<CliModelConfig, "name">>;
20
+ /**
21
+ * Generic CLI adapter — works with any AI CLI tool that accepts a prompt
22
+ * and writes output to stdout. Configure via CliModelConfig or use a
23
+ * predefined config from KNOWN_CLIS.
24
+ */
25
+ export declare class CliAdapter {
26
+ private config;
27
+ private sandboxConfig;
28
+ constructor(config: CliModelConfig, sandboxConfig?: SandboxConfig);
29
+ execute(task: {
30
+ title: string;
31
+ description?: string | null;
32
+ taskType?: string | null;
33
+ context?: Record<string, unknown> | null;
34
+ processHint?: string | null;
35
+ humanContext?: string | null;
36
+ }): Promise<ExecutionResult>;
37
+ private taskId?;
38
+ setTaskId(taskId: string): void;
39
+ private runCli;
40
+ }
41
+ /**
42
+ * Parse CLI model definitions from an env var string.
43
+ * Format: "name:command:arg1,arg2;name2:command2:arg1,arg2"
44
+ * Example: "gemini:gemini:-p;aider:aider:--message,--yes-always,--no-git"
45
+ *
46
+ * If only a name is given (e.g., "gemini"), looks it up in KNOWN_CLIS.
47
+ */
48
+ export declare function parseCliModels(envValue: string): CliModelConfig[];