daemora 1.0.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.
- package/README.md +666 -0
- package/SOUL.md +104 -0
- package/config/hooks.json +14 -0
- package/config/mcp.json +145 -0
- package/package.json +86 -0
- package/skills/.gitkeep +0 -0
- package/skills/apple-notes.md +193 -0
- package/skills/apple-reminders.md +189 -0
- package/skills/camsnap.md +162 -0
- package/skills/coding.md +14 -0
- package/skills/documents.md +13 -0
- package/skills/email.md +13 -0
- package/skills/gif-search.md +196 -0
- package/skills/healthcheck.md +225 -0
- package/skills/image-gen.md +147 -0
- package/skills/model-usage.md +182 -0
- package/skills/obsidian.md +207 -0
- package/skills/pdf.md +211 -0
- package/skills/research.md +13 -0
- package/skills/skill-creator.md +142 -0
- package/skills/spotify.md +149 -0
- package/skills/summarize.md +230 -0
- package/skills/things.md +199 -0
- package/skills/tmux.md +204 -0
- package/skills/trello.md +183 -0
- package/skills/video-frames.md +202 -0
- package/skills/weather.md +127 -0
- package/src/a2a/A2AClient.js +136 -0
- package/src/a2a/A2AServer.js +316 -0
- package/src/a2a/AgentCard.js +79 -0
- package/src/agents/SubAgentManager.js +369 -0
- package/src/agents/Supervisor.js +192 -0
- package/src/channels/BaseChannel.js +104 -0
- package/src/channels/DiscordChannel.js +288 -0
- package/src/channels/EmailChannel.js +172 -0
- package/src/channels/GoogleChatChannel.js +316 -0
- package/src/channels/HttpChannel.js +26 -0
- package/src/channels/LineChannel.js +168 -0
- package/src/channels/SignalChannel.js +186 -0
- package/src/channels/SlackChannel.js +329 -0
- package/src/channels/TeamsChannel.js +272 -0
- package/src/channels/TelegramChannel.js +347 -0
- package/src/channels/WhatsAppChannel.js +219 -0
- package/src/channels/index.js +198 -0
- package/src/cli.js +1267 -0
- package/src/config/agentProfiles.js +120 -0
- package/src/config/channels.js +32 -0
- package/src/config/default.js +206 -0
- package/src/config/models.js +123 -0
- package/src/config/permissions.js +167 -0
- package/src/core/AgentLoop.js +446 -0
- package/src/core/Compaction.js +143 -0
- package/src/core/CostTracker.js +116 -0
- package/src/core/EventBus.js +46 -0
- package/src/core/Task.js +67 -0
- package/src/core/TaskQueue.js +206 -0
- package/src/core/TaskRunner.js +226 -0
- package/src/daemon/DaemonManager.js +301 -0
- package/src/hooks/HookRunner.js +230 -0
- package/src/index.js +482 -0
- package/src/mcp/MCPAgentRunner.js +112 -0
- package/src/mcp/MCPClient.js +186 -0
- package/src/mcp/MCPManager.js +412 -0
- package/src/models/ModelRouter.js +180 -0
- package/src/safety/AuditLog.js +135 -0
- package/src/safety/CircuitBreaker.js +126 -0
- package/src/safety/FilesystemGuard.js +169 -0
- package/src/safety/GitRollback.js +139 -0
- package/src/safety/HumanApproval.js +156 -0
- package/src/safety/InputSanitizer.js +72 -0
- package/src/safety/PermissionGuard.js +83 -0
- package/src/safety/Sandbox.js +70 -0
- package/src/safety/SecretScanner.js +100 -0
- package/src/safety/SecretVault.js +250 -0
- package/src/scheduler/Heartbeat.js +115 -0
- package/src/scheduler/Scheduler.js +228 -0
- package/src/services/models/outputSchema.js +15 -0
- package/src/services/openai.js +25 -0
- package/src/services/sessions.js +65 -0
- package/src/setup/theme.js +110 -0
- package/src/setup/wizard.js +788 -0
- package/src/skills/SkillLoader.js +168 -0
- package/src/storage/TaskStore.js +69 -0
- package/src/systemPrompt.js +526 -0
- package/src/tenants/TenantContext.js +19 -0
- package/src/tenants/TenantManager.js +379 -0
- package/src/tools/ToolRegistry.js +141 -0
- package/src/tools/applyPatch.js +144 -0
- package/src/tools/browserAutomation.js +223 -0
- package/src/tools/createDocument.js +265 -0
- package/src/tools/cronTool.js +105 -0
- package/src/tools/editFile.js +139 -0
- package/src/tools/executeCommand.js +123 -0
- package/src/tools/glob.js +67 -0
- package/src/tools/grep.js +121 -0
- package/src/tools/imageAnalysis.js +120 -0
- package/src/tools/index.js +173 -0
- package/src/tools/listDirectory.js +47 -0
- package/src/tools/manageAgents.js +47 -0
- package/src/tools/manageMCP.js +159 -0
- package/src/tools/memory.js +478 -0
- package/src/tools/messageChannel.js +45 -0
- package/src/tools/projectTracker.js +259 -0
- package/src/tools/readFile.js +52 -0
- package/src/tools/screenCapture.js +112 -0
- package/src/tools/searchContent.js +76 -0
- package/src/tools/searchFiles.js +75 -0
- package/src/tools/sendEmail.js +118 -0
- package/src/tools/sendFile.js +63 -0
- package/src/tools/textToSpeech.js +161 -0
- package/src/tools/transcribeAudio.js +82 -0
- package/src/tools/useMCP.js +29 -0
- package/src/tools/webFetch.js +150 -0
- package/src/tools/webSearch.js +134 -0
- package/src/tools/writeFile.js +26 -0
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
import { readFileSync, existsSync, appendFileSync, mkdirSync } from "fs";
|
|
2
|
+
import { config } from "../config/default.js";
|
|
3
|
+
import { models } from "../config/models.js";
|
|
4
|
+
import eventBus from "./EventBus.js";
|
|
5
|
+
import tenantContext from "../tenants/TenantContext.js";
|
|
6
|
+
|
|
7
|
+
const COSTS_DIR = config.costsDir;
|
|
8
|
+
mkdirSync(COSTS_DIR, { recursive: true });
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Get today's cost log file path.
|
|
12
|
+
*/
|
|
13
|
+
function getTodayLogPath() {
|
|
14
|
+
const today = new Date().toISOString().split("T")[0];
|
|
15
|
+
return `${COSTS_DIR}/${today}.jsonl`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Log a cost entry.
|
|
20
|
+
* tenantId is automatically added from TenantContext when available.
|
|
21
|
+
*/
|
|
22
|
+
export function logCost({ taskId, modelId, inputTokens, outputTokens, estimatedCost, tenantId = null }) {
|
|
23
|
+
const entry = {
|
|
24
|
+
timestamp: new Date().toISOString(),
|
|
25
|
+
taskId,
|
|
26
|
+
modelId,
|
|
27
|
+
inputTokens,
|
|
28
|
+
outputTokens,
|
|
29
|
+
estimatedCost,
|
|
30
|
+
tenantId,
|
|
31
|
+
};
|
|
32
|
+
appendFileSync(getTodayLogPath(), JSON.stringify(entry) + "\n");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Get total cost spent today (global — all tenants combined).
|
|
37
|
+
*/
|
|
38
|
+
export function getTodayCost() {
|
|
39
|
+
const logPath = getTodayLogPath();
|
|
40
|
+
if (!existsSync(logPath)) return 0;
|
|
41
|
+
|
|
42
|
+
const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
|
|
43
|
+
return lines.reduce((sum, line) => {
|
|
44
|
+
try {
|
|
45
|
+
const entry = JSON.parse(line);
|
|
46
|
+
return sum + (entry.estimatedCost || 0);
|
|
47
|
+
} catch {
|
|
48
|
+
return sum;
|
|
49
|
+
}
|
|
50
|
+
}, 0);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get total cost spent today for a specific tenant.
|
|
55
|
+
*
|
|
56
|
+
* @param {string} tenantId
|
|
57
|
+
* @returns {number}
|
|
58
|
+
*/
|
|
59
|
+
export function getTenantTodayCost(tenantId) {
|
|
60
|
+
if (!tenantId) return 0;
|
|
61
|
+
const logPath = getTodayLogPath();
|
|
62
|
+
if (!existsSync(logPath)) return 0;
|
|
63
|
+
|
|
64
|
+
const lines = readFileSync(logPath, "utf-8").trim().split("\n").filter(Boolean);
|
|
65
|
+
return lines.reduce((sum, line) => {
|
|
66
|
+
try {
|
|
67
|
+
const entry = JSON.parse(line);
|
|
68
|
+
if (entry.tenantId !== tenantId) return sum;
|
|
69
|
+
return sum + (entry.estimatedCost || 0);
|
|
70
|
+
} catch {
|
|
71
|
+
return sum;
|
|
72
|
+
}
|
|
73
|
+
}, 0);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Check if global daily budget is exceeded.
|
|
78
|
+
*/
|
|
79
|
+
export function isDailyBudgetExceeded() {
|
|
80
|
+
return getTodayCost() >= config.maxDailyCost;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Check if a specific tenant's daily budget is exceeded.
|
|
85
|
+
*
|
|
86
|
+
* @param {string} tenantId
|
|
87
|
+
* @param {number} maxDailyCost
|
|
88
|
+
* @returns {boolean}
|
|
89
|
+
*/
|
|
90
|
+
export function isTenantDailyBudgetExceeded(tenantId, maxDailyCost) {
|
|
91
|
+
if (!tenantId || !maxDailyCost) return false;
|
|
92
|
+
return getTenantTodayCost(tenantId) >= maxDailyCost;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Estimate cost for a model call.
|
|
97
|
+
*/
|
|
98
|
+
export function estimateCost(modelId, inputTokens, outputTokens) {
|
|
99
|
+
const meta = models[modelId];
|
|
100
|
+
if (!meta) return 0;
|
|
101
|
+
return (inputTokens / 1000) * meta.costPer1kInput + (outputTokens / 1000) * meta.costPer1kOutput;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// Auto-log costs from EventBus — includes tenantId from TenantContext
|
|
105
|
+
eventBus.on("model:called", (data) => {
|
|
106
|
+
const tenantId = tenantContext.getStore()?.tenant?.id || null;
|
|
107
|
+
const cost = estimateCost(data.modelId, data.inputTokens || 0, data.outputTokens || 0);
|
|
108
|
+
logCost({
|
|
109
|
+
taskId: data.taskId || "unknown",
|
|
110
|
+
modelId: data.modelId,
|
|
111
|
+
inputTokens: data.inputTokens || 0,
|
|
112
|
+
outputTokens: data.outputTokens || 0,
|
|
113
|
+
estimatedCost: cost,
|
|
114
|
+
tenantId,
|
|
115
|
+
});
|
|
116
|
+
});
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { EventEmitter } from "events";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Global event bus for inter-module communication.
|
|
5
|
+
*
|
|
6
|
+
* Events:
|
|
7
|
+
* task:created — new task enqueued
|
|
8
|
+
* task:started — task picked up by runner
|
|
9
|
+
* task:completed — task finished successfully
|
|
10
|
+
* task:failed — task failed
|
|
11
|
+
* tool:before — about to execute a tool (PreToolUse hook point)
|
|
12
|
+
* tool:after — tool execution finished (PostToolUse hook point)
|
|
13
|
+
* tool:blocked — tool call was blocked by safety
|
|
14
|
+
* agent:spawned — sub-agent created
|
|
15
|
+
* agent:finished — sub-agent completed
|
|
16
|
+
* agent:killed — sub-agent terminated by supervisor
|
|
17
|
+
* model:called — LLM API call made (for cost tracking)
|
|
18
|
+
* compact:triggered — context compaction started
|
|
19
|
+
* memory:written — memory entry added
|
|
20
|
+
* secret:detected — secret found and redacted
|
|
21
|
+
* audit:event — generic audit event
|
|
22
|
+
*/
|
|
23
|
+
class AgentEventBus extends EventEmitter {
|
|
24
|
+
constructor() {
|
|
25
|
+
super();
|
|
26
|
+
this.setMaxListeners(50);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Emit and log an event for audit trail.
|
|
31
|
+
*/
|
|
32
|
+
emitEvent(event, data = {}) {
|
|
33
|
+
const payload = {
|
|
34
|
+
event,
|
|
35
|
+
timestamp: new Date().toISOString(),
|
|
36
|
+
...data,
|
|
37
|
+
};
|
|
38
|
+
this.emit(event, payload);
|
|
39
|
+
this.emit("audit:event", payload);
|
|
40
|
+
return payload;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
// Singleton
|
|
45
|
+
const eventBus = new AgentEventBus();
|
|
46
|
+
export default eventBus;
|
package/src/core/Task.js
ADDED
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
import { v4 as uuidv4 } from "uuid";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Task data model.
|
|
5
|
+
*
|
|
6
|
+
* States: pending → running → completed | failed
|
|
7
|
+
*
|
|
8
|
+
* Every incoming request (from any channel) becomes a Task.
|
|
9
|
+
*/
|
|
10
|
+
export function createTask({
|
|
11
|
+
input,
|
|
12
|
+
channel = "http",
|
|
13
|
+
channelMeta = {},
|
|
14
|
+
sessionId = null,
|
|
15
|
+
priority = 5,
|
|
16
|
+
model = null,
|
|
17
|
+
maxCost = null,
|
|
18
|
+
approvalMode = "auto",
|
|
19
|
+
}) {
|
|
20
|
+
return {
|
|
21
|
+
id: uuidv4(),
|
|
22
|
+
status: "pending",
|
|
23
|
+
input, // user's message text
|
|
24
|
+
channel, // http | telegram | whatsapp | email | a2a
|
|
25
|
+
channelMeta, // channel-specific metadata (chat_id, phone, email, etc.)
|
|
26
|
+
sessionId, // link to conversation session
|
|
27
|
+
priority, // 1 (highest) - 10 (lowest)
|
|
28
|
+
model, // explicit model override or null (use default)
|
|
29
|
+
maxCost, // per-task cost budget or null (use global)
|
|
30
|
+
approvalMode, // auto | dangerous-only | every-tool | milestones
|
|
31
|
+
result: null, // final response text
|
|
32
|
+
error: null, // error message if failed
|
|
33
|
+
cost: {
|
|
34
|
+
inputTokens: 0,
|
|
35
|
+
outputTokens: 0,
|
|
36
|
+
estimatedCost: 0,
|
|
37
|
+
modelCalls: 0,
|
|
38
|
+
},
|
|
39
|
+
toolCalls: [], // log of tool calls: { tool, params, duration, output_preview }
|
|
40
|
+
createdAt: new Date().toISOString(),
|
|
41
|
+
startedAt: null,
|
|
42
|
+
completedAt: null,
|
|
43
|
+
};
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Task state transitions.
|
|
48
|
+
*/
|
|
49
|
+
export function startTask(task) {
|
|
50
|
+
task.status = "running";
|
|
51
|
+
task.startedAt = new Date().toISOString();
|
|
52
|
+
return task;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function completeTask(task, result) {
|
|
56
|
+
task.status = "completed";
|
|
57
|
+
task.result = result;
|
|
58
|
+
task.completedAt = new Date().toISOString();
|
|
59
|
+
return task;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export function failTask(task, error) {
|
|
63
|
+
task.status = "failed";
|
|
64
|
+
task.error = error;
|
|
65
|
+
task.completedAt = new Date().toISOString();
|
|
66
|
+
return task;
|
|
67
|
+
}
|
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
import { createTask, startTask, completeTask, failTask } from "./Task.js";
|
|
2
|
+
import { saveTask, loadTask, recoverStaleTasks } from "../storage/TaskStore.js";
|
|
3
|
+
import eventBus from "./EventBus.js";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* In-memory priority task queue with file persistence.
|
|
7
|
+
*
|
|
8
|
+
* Tasks are:
|
|
9
|
+
* 1. Enqueued (from any channel) → status: pending
|
|
10
|
+
* 2. Dequeued by TaskRunner → status: running
|
|
11
|
+
* 3. Completed or Failed → status: completed/failed
|
|
12
|
+
*
|
|
13
|
+
* File persistence: every state change saves to data/tasks/.
|
|
14
|
+
* On crash recovery: stale "running" tasks are reset to "pending".
|
|
15
|
+
*/
|
|
16
|
+
class TaskQueue {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.queue = []; // sorted by priority (lower number = higher priority)
|
|
19
|
+
this.active = new Map(); // taskId → task (currently running)
|
|
20
|
+
this.waiters = new Map(); // taskId → { resolve, reject } for sync HTTP callers
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Initialize queue — recover any stale tasks from previous crash.
|
|
25
|
+
*/
|
|
26
|
+
init() {
|
|
27
|
+
recoverStaleTasks();
|
|
28
|
+
console.log(`[TaskQueue] Initialized`);
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* Add a new task to the queue.
|
|
33
|
+
* @returns {object} The created task
|
|
34
|
+
*/
|
|
35
|
+
enqueue(taskInput) {
|
|
36
|
+
const task = createTask(taskInput);
|
|
37
|
+
saveTask(task);
|
|
38
|
+
|
|
39
|
+
// Insert into queue sorted by priority
|
|
40
|
+
const insertIdx = this.queue.findIndex((t) => t.priority > task.priority);
|
|
41
|
+
if (insertIdx === -1) {
|
|
42
|
+
this.queue.push(task);
|
|
43
|
+
} else {
|
|
44
|
+
this.queue.splice(insertIdx, 0, task);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
eventBus.emitEvent("task:created", { taskId: task.id, channel: task.channel, priority: task.priority });
|
|
48
|
+
console.log(`[TaskQueue] Enqueued task ${task.id} (priority: ${task.priority}, queue size: ${this.queue.length})`);
|
|
49
|
+
|
|
50
|
+
return task;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Get the next task to process.
|
|
55
|
+
* @param {Set<string>} skipSessions - Session IDs currently being processed (skip tasks from these sessions)
|
|
56
|
+
* @returns {object|null} Next task or null if queue is empty or all tasks are session-blocked
|
|
57
|
+
*/
|
|
58
|
+
dequeue(skipSessions = null) {
|
|
59
|
+
if (this.queue.length === 0) return null;
|
|
60
|
+
|
|
61
|
+
// Find first task not belonging to an already-active session.
|
|
62
|
+
// This prevents two messages from the same user running concurrently,
|
|
63
|
+
// which would cause history corruption (last-write-wins on setMessages).
|
|
64
|
+
let idx = 0;
|
|
65
|
+
if (skipSessions && skipSessions.size > 0) {
|
|
66
|
+
idx = this.queue.findIndex(
|
|
67
|
+
(t) => !t.sessionId || !skipSessions.has(t.sessionId)
|
|
68
|
+
);
|
|
69
|
+
if (idx === -1) return null; // all pending tasks are session-blocked
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
const [task] = this.queue.splice(idx, 1);
|
|
73
|
+
startTask(task);
|
|
74
|
+
saveTask(task);
|
|
75
|
+
this.active.set(task.id, task);
|
|
76
|
+
|
|
77
|
+
eventBus.emitEvent("task:started", { taskId: task.id });
|
|
78
|
+
return task;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Mark a task as completed.
|
|
83
|
+
*/
|
|
84
|
+
complete(taskId, result) {
|
|
85
|
+
const task = this.active.get(taskId);
|
|
86
|
+
if (!task) return;
|
|
87
|
+
|
|
88
|
+
completeTask(task, result);
|
|
89
|
+
saveTask(task);
|
|
90
|
+
this.active.delete(taskId);
|
|
91
|
+
|
|
92
|
+
eventBus.emitEvent("task:completed", { taskId: task.id, cost: task.cost });
|
|
93
|
+
|
|
94
|
+
// Resolve any sync waiters
|
|
95
|
+
const waiter = this.waiters.get(taskId);
|
|
96
|
+
if (waiter) {
|
|
97
|
+
waiter.resolve(task);
|
|
98
|
+
this.waiters.delete(taskId);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return task;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Mark a task as failed.
|
|
106
|
+
*/
|
|
107
|
+
fail(taskId, error) {
|
|
108
|
+
const task = this.active.get(taskId);
|
|
109
|
+
if (!task) return;
|
|
110
|
+
|
|
111
|
+
failTask(task, error);
|
|
112
|
+
saveTask(task);
|
|
113
|
+
this.active.delete(taskId);
|
|
114
|
+
|
|
115
|
+
eventBus.emitEvent("task:failed", { taskId: task.id, error });
|
|
116
|
+
|
|
117
|
+
// Reject any sync waiters
|
|
118
|
+
const waiter = this.waiters.get(taskId);
|
|
119
|
+
if (waiter) {
|
|
120
|
+
waiter.resolve(task); // resolve not reject — caller handles error state
|
|
121
|
+
this.waiters.delete(taskId);
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return task;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Wait for a task to complete (used by sync HTTP callers).
|
|
129
|
+
* @returns {Promise<object>} The completed task
|
|
130
|
+
*/
|
|
131
|
+
waitForCompletion(taskId, timeoutMs = 300000) {
|
|
132
|
+
return new Promise((resolve, reject) => {
|
|
133
|
+
// Check if already done
|
|
134
|
+
const existing = loadTask(taskId);
|
|
135
|
+
if (existing && (existing.status === "completed" || existing.status === "failed")) {
|
|
136
|
+
resolve(existing);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// Timeout guard — prevent hanging forever (default 5 min)
|
|
141
|
+
const timer = setTimeout(() => {
|
|
142
|
+
this.waiters.delete(taskId);
|
|
143
|
+
resolve({
|
|
144
|
+
id: taskId,
|
|
145
|
+
status: "failed",
|
|
146
|
+
result: "Task timed out after " + (timeoutMs / 1000) + " seconds. The task may still be running in the background.",
|
|
147
|
+
});
|
|
148
|
+
}, timeoutMs);
|
|
149
|
+
|
|
150
|
+
this.waiters.set(taskId, {
|
|
151
|
+
resolve: (task) => { clearTimeout(timer); resolve(task); },
|
|
152
|
+
reject: (err) => { clearTimeout(timer); reject(err); },
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Get queue stats.
|
|
159
|
+
*/
|
|
160
|
+
stats() {
|
|
161
|
+
return {
|
|
162
|
+
pending: this.queue.length,
|
|
163
|
+
active: this.active.size,
|
|
164
|
+
waiters: this.waiters.size,
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/**
|
|
169
|
+
* Peek at the next task without removing it.
|
|
170
|
+
* @returns {object|null}
|
|
171
|
+
*/
|
|
172
|
+
peek() {
|
|
173
|
+
return this.queue[0] || null;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/**
|
|
177
|
+
* Silently absorb a task into the already-running session.
|
|
178
|
+
* Marks it completed with merged=true so channels skip sending a reply.
|
|
179
|
+
*/
|
|
180
|
+
merge(taskId) {
|
|
181
|
+
const task = this.active.get(taskId);
|
|
182
|
+
if (!task) return;
|
|
183
|
+
completeTask(task, "");
|
|
184
|
+
task.merged = true;
|
|
185
|
+
saveTask(task);
|
|
186
|
+
this.active.delete(taskId);
|
|
187
|
+
eventBus.emitEvent("task:completed", { taskId: task.id, merged: true });
|
|
188
|
+
const waiter = this.waiters.get(taskId);
|
|
189
|
+
if (waiter) {
|
|
190
|
+
waiter.resolve(task);
|
|
191
|
+
this.waiters.delete(taskId);
|
|
192
|
+
}
|
|
193
|
+
return task;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* Check if there are tasks to process.
|
|
198
|
+
*/
|
|
199
|
+
hasWork() {
|
|
200
|
+
return this.queue.length > 0;
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// Singleton
|
|
205
|
+
const taskQueue = new TaskQueue();
|
|
206
|
+
export default taskQueue;
|
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
import { runAgentLoop } from "./AgentLoop.js";
|
|
2
|
+
import { buildSystemPrompt } from "../systemPrompt.js";
|
|
3
|
+
import { toolFunctions } from "../tools/index.js";
|
|
4
|
+
import { createSession, getSession, setMessages } from "../services/sessions.js";
|
|
5
|
+
import taskQueue from "./TaskQueue.js";
|
|
6
|
+
import { isDailyBudgetExceeded, isTenantDailyBudgetExceeded } from "./CostTracker.js";
|
|
7
|
+
import { config } from "../config/default.js";
|
|
8
|
+
import tenantManager from "../tenants/TenantManager.js";
|
|
9
|
+
import tenantContext from "../tenants/TenantContext.js";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Task runner — worker loop that picks tasks from the queue and executes them.
|
|
13
|
+
*
|
|
14
|
+
* Configurable concurrency (default: 2 parallel tasks).
|
|
15
|
+
*/
|
|
16
|
+
class TaskRunner {
|
|
17
|
+
constructor() {
|
|
18
|
+
this.running = false;
|
|
19
|
+
this.concurrency = 2;
|
|
20
|
+
this.activeCount = 0;
|
|
21
|
+
this.activeSessions = new Set(); // session IDs currently being processed
|
|
22
|
+
this.sessionSteerQueues = new Map(); // sessionId → steerQueue[] for inject-on-concurrent
|
|
23
|
+
this.pollInterval = null;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* Start the runner.
|
|
28
|
+
*/
|
|
29
|
+
start() {
|
|
30
|
+
if (this.running) return;
|
|
31
|
+
this.running = true;
|
|
32
|
+
|
|
33
|
+
// Poll for new tasks every 500ms
|
|
34
|
+
this.pollInterval = setInterval(() => this.tick(), 500);
|
|
35
|
+
console.log(`[TaskRunner] Started (concurrency: ${this.concurrency})`);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Stop the runner.
|
|
40
|
+
*/
|
|
41
|
+
stop() {
|
|
42
|
+
this.running = false;
|
|
43
|
+
if (this.pollInterval) {
|
|
44
|
+
clearInterval(this.pollInterval);
|
|
45
|
+
this.pollInterval = null;
|
|
46
|
+
}
|
|
47
|
+
console.log(`[TaskRunner] Stopped`);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Poll tick — pick up work if available and under concurrency limit.
|
|
52
|
+
*/
|
|
53
|
+
tick() {
|
|
54
|
+
if (!this.running) return;
|
|
55
|
+
if (!taskQueue.hasWork()) return;
|
|
56
|
+
|
|
57
|
+
// ── Steer/inject: if next task belongs to an already-running session,
|
|
58
|
+
// append its message into the live agent loop rather than spawning a
|
|
59
|
+
// second loop. The agent picks it up between tool calls (like Claude Code).
|
|
60
|
+
// This runs regardless of concurrency — no extra slot needed.
|
|
61
|
+
const nextTask = taskQueue.peek();
|
|
62
|
+
if (nextTask?.sessionId && this.sessionSteerQueues.has(nextTask.sessionId)) {
|
|
63
|
+
const steerTask = taskQueue.dequeue(); // consume from queue
|
|
64
|
+
const steerQueue = this.sessionSteerQueues.get(steerTask.sessionId);
|
|
65
|
+
steerQueue.push({ type: "user", content: steerTask.input }); // inject into live loop
|
|
66
|
+
taskQueue.merge(steerTask.id); // complete silently — no duplicate reply
|
|
67
|
+
console.log(`[TaskRunner] Follow-up "${steerTask.input.slice(0, 60)}" injected into running session ${steerTask.sessionId}`);
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// For starting a fresh agent loop, check concurrency + budget limits
|
|
72
|
+
if (this.activeCount >= this.concurrency) return;
|
|
73
|
+
|
|
74
|
+
if (isDailyBudgetExceeded()) {
|
|
75
|
+
console.log(`[TaskRunner] Daily budget exceeded ($${config.maxDailyCost}). Pausing.`);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// Normal path: start a fresh agent loop for this task
|
|
80
|
+
const task = taskQueue.dequeue(this.activeSessions);
|
|
81
|
+
if (!task) return;
|
|
82
|
+
|
|
83
|
+
// Set up steerQueue synchronously before processTask (no async gap → no race)
|
|
84
|
+
const steerQueue = [];
|
|
85
|
+
if (task.sessionId) {
|
|
86
|
+
this.activeSessions.add(task.sessionId);
|
|
87
|
+
this.sessionSteerQueues.set(task.sessionId, steerQueue);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
this.activeCount++;
|
|
91
|
+
this.processTask(task, steerQueue).finally(() => {
|
|
92
|
+
this.activeCount--;
|
|
93
|
+
if (task.sessionId) {
|
|
94
|
+
this.activeSessions.delete(task.sessionId);
|
|
95
|
+
this.sessionSteerQueues.delete(task.sessionId);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
/**
|
|
101
|
+
* Process a single task.
|
|
102
|
+
* @param {object} task
|
|
103
|
+
* @param {Array} steerQueue - Shared array; follow-up messages from the same session are
|
|
104
|
+
* pushed here and picked up by AgentLoop between tool calls.
|
|
105
|
+
*/
|
|
106
|
+
async processTask(task, steerQueue = []) {
|
|
107
|
+
console.log(`\n[TaskRunner] Processing task ${task.id} from ${task.channel}`);
|
|
108
|
+
|
|
109
|
+
// ── Multi-tenant: resolve tenant and effective config ──────────────────────
|
|
110
|
+
// Derive userId from sessionId: sessionId = "${channel}-${userId}"
|
|
111
|
+
let tenant = null;
|
|
112
|
+
if (task.channel && task.sessionId) {
|
|
113
|
+
const userId = task.sessionId.slice(task.channel.length + 1);
|
|
114
|
+
if (userId) {
|
|
115
|
+
tenant = tenantManager.getOrCreate(task.channel, userId);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// Block suspended tenants immediately
|
|
120
|
+
if (tenant?.suspended) {
|
|
121
|
+
const reason = tenant.suspendReason
|
|
122
|
+
? `Account suspended: ${tenant.suspendReason}`
|
|
123
|
+
: "Account suspended. Contact the operator.";
|
|
124
|
+
console.log(`[TaskRunner] Task ${task.id} rejected — tenant ${tenant.id} is suspended`);
|
|
125
|
+
taskQueue.fail(task.id, reason);
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Resolve effective config: tenant config > channel config > global config
|
|
130
|
+
const resolvedConfig = tenantManager.resolveTaskConfig(tenant, task.channelModel || null);
|
|
131
|
+
|
|
132
|
+
// Per-tenant daily budget check (separate from global budget)
|
|
133
|
+
if (tenant && resolvedConfig.maxDailyCost) {
|
|
134
|
+
if (isTenantDailyBudgetExceeded(tenant.id, resolvedConfig.maxDailyCost)) {
|
|
135
|
+
console.log(`[TaskRunner] Task ${task.id} rejected — tenant ${tenant.id} daily budget ($${resolvedConfig.maxDailyCost}) reached`);
|
|
136
|
+
taskQueue.fail(task.id, `Daily budget of $${resolvedConfig.maxDailyCost} reached. Tasks resume tomorrow.`);
|
|
137
|
+
return;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
// Narrow tool list if tenant has a tool allowlist
|
|
142
|
+
let tools = resolvedConfig.tools?.length
|
|
143
|
+
? Object.fromEntries(Object.entries(toolFunctions).filter(([k]) => resolvedConfig.tools.includes(k)))
|
|
144
|
+
: { ...toolFunctions };
|
|
145
|
+
|
|
146
|
+
// Filter MCP tools by per-tenant mcpServers allowlist (null = all allowed)
|
|
147
|
+
const allowedMcpServers = resolvedConfig.mcpServers; // null = all
|
|
148
|
+
if (allowedMcpServers !== null) {
|
|
149
|
+
tools = Object.fromEntries(
|
|
150
|
+
Object.entries(tools).filter(([name]) => {
|
|
151
|
+
if (!name.startsWith("mcp__")) return true;
|
|
152
|
+
const serverName = name.split("__")[1];
|
|
153
|
+
return allowedMcpServers.includes(serverName);
|
|
154
|
+
})
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Resolved model for this task (used by sub-agents to inherit parent model)
|
|
159
|
+
const resolvedModel = resolvedConfig.model || task.model || config.defaultModel;
|
|
160
|
+
const apiKeys = resolvedConfig.apiKeys || {};
|
|
161
|
+
|
|
162
|
+
try {
|
|
163
|
+
// Wrap entire task execution in tenant context (AsyncLocalStorage).
|
|
164
|
+
// This allows FilesystemGuard, memory tools, and other tools to read per-tenant config
|
|
165
|
+
// without any race conditions across concurrent tasks.
|
|
166
|
+
await tenantContext.run({ tenant, resolvedConfig, resolvedModel, apiKeys }, async () => {
|
|
167
|
+
// Get or create session
|
|
168
|
+
let session = task.sessionId ? getSession(task.sessionId) : null;
|
|
169
|
+
if (!session) {
|
|
170
|
+
session = createSession(task.sessionId || null);
|
|
171
|
+
task.sessionId = session.sessionId;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Build system prompt (SOUL.md + MEMORY.md + semantic recall + daily log + matched skills)
|
|
175
|
+
const systemPrompt = await buildSystemPrompt(task.input);
|
|
176
|
+
|
|
177
|
+
// Build message history
|
|
178
|
+
const previousMessages = session.messages.map((m) => ({
|
|
179
|
+
role: m.role,
|
|
180
|
+
content: m.content,
|
|
181
|
+
}));
|
|
182
|
+
const messages = [...previousMessages, { role: "user", content: task.input }];
|
|
183
|
+
|
|
184
|
+
// Run agent loop with resolved model, cost limits, and per-tenant API keys.
|
|
185
|
+
// steerQueue lets follow-up messages from the same user be injected live
|
|
186
|
+
// between tool calls instead of spawning a competing agent loop.
|
|
187
|
+
const result = await runAgentLoop({
|
|
188
|
+
messages,
|
|
189
|
+
systemPrompt,
|
|
190
|
+
tools,
|
|
191
|
+
modelId: resolvedModel,
|
|
192
|
+
taskId: task.id,
|
|
193
|
+
approvalMode: task.approvalMode || "auto",
|
|
194
|
+
channelMeta: task.channelMeta || null,
|
|
195
|
+
maxCostPerTask: resolvedConfig.maxCostPerTask,
|
|
196
|
+
apiKeys,
|
|
197
|
+
steerQueue,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
// Update session with conversation
|
|
201
|
+
setMessages(session.sessionId, result.messages);
|
|
202
|
+
|
|
203
|
+
// Update task cost info
|
|
204
|
+
task.cost = result.cost;
|
|
205
|
+
|
|
206
|
+
// Record cost against tenant lifetime totals
|
|
207
|
+
if (tenant) {
|
|
208
|
+
tenantManager.recordCost(tenant.id, result.cost || 0);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
// Complete the task
|
|
212
|
+
taskQueue.complete(task.id, result.text);
|
|
213
|
+
const costStr = result.cost ? ` cost: $${result.cost.toFixed(4)}` : "";
|
|
214
|
+
const tenantStr = tenant ? ` tenant: ${tenant.id}` : "";
|
|
215
|
+
console.log(`[TaskRunner] Task ${task.id} completed (${costStr}${tenantStr})`);
|
|
216
|
+
});
|
|
217
|
+
} catch (error) {
|
|
218
|
+
console.error(`[TaskRunner] Task ${task.id} failed:`, error.message);
|
|
219
|
+
taskQueue.fail(task.id, error.message);
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
// Singleton
|
|
225
|
+
const taskRunner = new TaskRunner();
|
|
226
|
+
export default taskRunner;
|