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.
Files changed (115) hide show
  1. package/README.md +666 -0
  2. package/SOUL.md +104 -0
  3. package/config/hooks.json +14 -0
  4. package/config/mcp.json +145 -0
  5. package/package.json +86 -0
  6. package/skills/.gitkeep +0 -0
  7. package/skills/apple-notes.md +193 -0
  8. package/skills/apple-reminders.md +189 -0
  9. package/skills/camsnap.md +162 -0
  10. package/skills/coding.md +14 -0
  11. package/skills/documents.md +13 -0
  12. package/skills/email.md +13 -0
  13. package/skills/gif-search.md +196 -0
  14. package/skills/healthcheck.md +225 -0
  15. package/skills/image-gen.md +147 -0
  16. package/skills/model-usage.md +182 -0
  17. package/skills/obsidian.md +207 -0
  18. package/skills/pdf.md +211 -0
  19. package/skills/research.md +13 -0
  20. package/skills/skill-creator.md +142 -0
  21. package/skills/spotify.md +149 -0
  22. package/skills/summarize.md +230 -0
  23. package/skills/things.md +199 -0
  24. package/skills/tmux.md +204 -0
  25. package/skills/trello.md +183 -0
  26. package/skills/video-frames.md +202 -0
  27. package/skills/weather.md +127 -0
  28. package/src/a2a/A2AClient.js +136 -0
  29. package/src/a2a/A2AServer.js +316 -0
  30. package/src/a2a/AgentCard.js +79 -0
  31. package/src/agents/SubAgentManager.js +369 -0
  32. package/src/agents/Supervisor.js +192 -0
  33. package/src/channels/BaseChannel.js +104 -0
  34. package/src/channels/DiscordChannel.js +288 -0
  35. package/src/channels/EmailChannel.js +172 -0
  36. package/src/channels/GoogleChatChannel.js +316 -0
  37. package/src/channels/HttpChannel.js +26 -0
  38. package/src/channels/LineChannel.js +168 -0
  39. package/src/channels/SignalChannel.js +186 -0
  40. package/src/channels/SlackChannel.js +329 -0
  41. package/src/channels/TeamsChannel.js +272 -0
  42. package/src/channels/TelegramChannel.js +347 -0
  43. package/src/channels/WhatsAppChannel.js +219 -0
  44. package/src/channels/index.js +198 -0
  45. package/src/cli.js +1267 -0
  46. package/src/config/agentProfiles.js +120 -0
  47. package/src/config/channels.js +32 -0
  48. package/src/config/default.js +206 -0
  49. package/src/config/models.js +123 -0
  50. package/src/config/permissions.js +167 -0
  51. package/src/core/AgentLoop.js +446 -0
  52. package/src/core/Compaction.js +143 -0
  53. package/src/core/CostTracker.js +116 -0
  54. package/src/core/EventBus.js +46 -0
  55. package/src/core/Task.js +67 -0
  56. package/src/core/TaskQueue.js +206 -0
  57. package/src/core/TaskRunner.js +226 -0
  58. package/src/daemon/DaemonManager.js +301 -0
  59. package/src/hooks/HookRunner.js +230 -0
  60. package/src/index.js +482 -0
  61. package/src/mcp/MCPAgentRunner.js +112 -0
  62. package/src/mcp/MCPClient.js +186 -0
  63. package/src/mcp/MCPManager.js +412 -0
  64. package/src/models/ModelRouter.js +180 -0
  65. package/src/safety/AuditLog.js +135 -0
  66. package/src/safety/CircuitBreaker.js +126 -0
  67. package/src/safety/FilesystemGuard.js +169 -0
  68. package/src/safety/GitRollback.js +139 -0
  69. package/src/safety/HumanApproval.js +156 -0
  70. package/src/safety/InputSanitizer.js +72 -0
  71. package/src/safety/PermissionGuard.js +83 -0
  72. package/src/safety/Sandbox.js +70 -0
  73. package/src/safety/SecretScanner.js +100 -0
  74. package/src/safety/SecretVault.js +250 -0
  75. package/src/scheduler/Heartbeat.js +115 -0
  76. package/src/scheduler/Scheduler.js +228 -0
  77. package/src/services/models/outputSchema.js +15 -0
  78. package/src/services/openai.js +25 -0
  79. package/src/services/sessions.js +65 -0
  80. package/src/setup/theme.js +110 -0
  81. package/src/setup/wizard.js +788 -0
  82. package/src/skills/SkillLoader.js +168 -0
  83. package/src/storage/TaskStore.js +69 -0
  84. package/src/systemPrompt.js +526 -0
  85. package/src/tenants/TenantContext.js +19 -0
  86. package/src/tenants/TenantManager.js +379 -0
  87. package/src/tools/ToolRegistry.js +141 -0
  88. package/src/tools/applyPatch.js +144 -0
  89. package/src/tools/browserAutomation.js +223 -0
  90. package/src/tools/createDocument.js +265 -0
  91. package/src/tools/cronTool.js +105 -0
  92. package/src/tools/editFile.js +139 -0
  93. package/src/tools/executeCommand.js +123 -0
  94. package/src/tools/glob.js +67 -0
  95. package/src/tools/grep.js +121 -0
  96. package/src/tools/imageAnalysis.js +120 -0
  97. package/src/tools/index.js +173 -0
  98. package/src/tools/listDirectory.js +47 -0
  99. package/src/tools/manageAgents.js +47 -0
  100. package/src/tools/manageMCP.js +159 -0
  101. package/src/tools/memory.js +478 -0
  102. package/src/tools/messageChannel.js +45 -0
  103. package/src/tools/projectTracker.js +259 -0
  104. package/src/tools/readFile.js +52 -0
  105. package/src/tools/screenCapture.js +112 -0
  106. package/src/tools/searchContent.js +76 -0
  107. package/src/tools/searchFiles.js +75 -0
  108. package/src/tools/sendEmail.js +118 -0
  109. package/src/tools/sendFile.js +63 -0
  110. package/src/tools/textToSpeech.js +161 -0
  111. package/src/tools/transcribeAudio.js +82 -0
  112. package/src/tools/useMCP.js +29 -0
  113. package/src/tools/webFetch.js +150 -0
  114. package/src/tools/webSearch.js +134 -0
  115. package/src/tools/writeFile.js +26 -0
@@ -0,0 +1,379 @@
1
+ import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { randomBytes, createCipheriv, createDecipheriv, scryptSync } from "node:crypto";
4
+ import { config } from "../config/default.js";
5
+
6
+ /**
7
+ * TenantManager — per-user configuration and isolation for multi-tenant deployments.
8
+ *
9
+ * A tenant is any unique user identified by their channel + userId:
10
+ * tenantId = "telegram:123456789"
11
+ * tenantId = "slack:U012AB3CD"
12
+ * tenantId = "email:user@example.com"
13
+ *
14
+ * Each tenant can have:
15
+ * model — override the default model (e.g. cheaper model for free tier)
16
+ * allowedPaths — filesystem paths this tenant's tasks can access
17
+ * blockedPaths — paths always blocked for this tenant
18
+ * maxCostPerTask — per-task spend limit (overrides global)
19
+ * maxDailyCost — per-tenant daily budget
20
+ * tools — tool allowlist (empty = all tools allowed by permission tier)
21
+ * mcpServers — MCP server allowlist: ["github","linear"] | null (null = all allowed)
22
+ * modelRoutes — task-type model overrides: { coder: "anthropic:...", researcher: "google:..." }
23
+ * encryptedApiKeys — AES-256-GCM encrypted per-tenant API keys (managed via setApiKey/deleteApiKey)
24
+ * suspended — block all tasks from this tenant
25
+ * plan — "free" | "pro" | "admin" (for display / future rate limiting)
26
+ * createdAt — ISO timestamp of first message
27
+ * lastSeenAt — ISO timestamp of last message
28
+ * totalCost — lifetime spend for this tenant
29
+ * taskCount — total tasks submitted
30
+ * notes — free-text operator notes
31
+ *
32
+ * Storage: data/tenants/tenants.json (flat JSON map of tenantId → config)
33
+ * Workspaces: data/tenants/{tenantId}/workspace/ (isolated per-tenant directory)
34
+ */
35
+
36
+ const TENANTS_PATH = join(config.dataDir, "tenants", "tenants.json");
37
+ const TENANTS_DIR = join(config.dataDir, "tenants");
38
+
39
+ // ── Per-tenant API key encryption (AES-256-GCM) ───────────────────────────────
40
+ // Keys are stored as "iv:authTag:ciphertext" in tenant.encryptedApiKeys.
41
+ // The master key is derived from DAEMORA_TENANT_KEY env var via scrypt.
42
+ // If DAEMORA_TENANT_KEY is not set, an insecure dev fallback is used (warns via daemora doctor).
43
+
44
+ const _TENANT_CIPHER = "aes-256-gcm";
45
+ const _TENANT_SALT = "daemora-tenant-keys-v1";
46
+
47
+ function _getTenantKey() {
48
+ const master = process.env.DAEMORA_TENANT_KEY || "daemora-dev-insecure-fallback";
49
+ return scryptSync(master, _TENANT_SALT, 32);
50
+ }
51
+
52
+ function _encryptTenantValue(plaintext) {
53
+ const key = _getTenantKey();
54
+ const iv = randomBytes(16);
55
+ const cipher = createCipheriv(_TENANT_CIPHER, key, iv);
56
+ const enc = Buffer.concat([cipher.update(plaintext, "utf8"), cipher.final()]);
57
+ const tag = cipher.getAuthTag();
58
+ return `${iv.toString("hex")}:${tag.toString("hex")}:${enc.toString("hex")}`;
59
+ }
60
+
61
+ function _decryptTenantValue(str) {
62
+ try {
63
+ const parts = str.split(":");
64
+ if (parts.length < 3) return null;
65
+ const [ivHex, tagHex, ...cipherParts] = parts;
66
+ const cipherHex = cipherParts.join(":");
67
+ const key = _getTenantKey();
68
+ const iv = Buffer.from(ivHex, "hex");
69
+ const tag = Buffer.from(tagHex, "hex");
70
+ const enc = Buffer.from(cipherHex, "hex");
71
+ const decipher = createDecipheriv(_TENANT_CIPHER, key, iv);
72
+ decipher.setAuthTag(tag);
73
+ return decipher.update(enc).toString("utf8") + decipher.final("utf8");
74
+ } catch {
75
+ return null;
76
+ }
77
+ }
78
+
79
+ // ─────────────────────────────────────────────────────────────────────────────
80
+
81
+ class TenantManager {
82
+ constructor() {
83
+ this._cache = null; // in-memory cache, invalidated on write
84
+ }
85
+
86
+ // ── CRUD ──────────────────────────────────────────────────────────────────
87
+
88
+ /**
89
+ * Get or auto-create a tenant record.
90
+ * Called on every incoming message to apply per-tenant config.
91
+ */
92
+ getOrCreate(channel, userId) {
93
+ const id = _makeId(channel, userId);
94
+ const tenants = this._load();
95
+
96
+ if (!tenants[id]) {
97
+ if (!config.multiTenant?.autoRegister) return null;
98
+ tenants[id] = _defaultTenant(id);
99
+ this._save(tenants);
100
+ } else {
101
+ // Update lastSeenAt on every access
102
+ tenants[id].lastSeenAt = new Date().toISOString();
103
+ this._save(tenants);
104
+ }
105
+
106
+ return { id, ...tenants[id] };
107
+ }
108
+
109
+ /**
110
+ * Get tenant by ID. Returns null if not found.
111
+ */
112
+ get(tenantId) {
113
+ const tenants = this._load();
114
+ if (!tenants[tenantId]) return null;
115
+ return { id: tenantId, ...tenants[tenantId] };
116
+ }
117
+
118
+ /**
119
+ * List all tenants.
120
+ */
121
+ list() {
122
+ const tenants = this._load();
123
+ return Object.entries(tenants).map(([id, t]) => ({ id, ...t }));
124
+ }
125
+
126
+ /**
127
+ * Update tenant config (partial update — only provided keys are changed).
128
+ */
129
+ set(tenantId, updates) {
130
+ const tenants = this._load();
131
+ if (!tenants[tenantId]) {
132
+ tenants[tenantId] = _defaultTenant(tenantId);
133
+ }
134
+ const allowed = [
135
+ "model", "allowedPaths", "blockedPaths", "maxCostPerTask",
136
+ "maxDailyCost", "tools", "suspended", "plan", "notes",
137
+ "modelRoutes", "mcpServers",
138
+ ];
139
+ for (const key of allowed) {
140
+ if (updates[key] !== undefined) tenants[tenantId][key] = updates[key];
141
+ }
142
+ tenants[tenantId].updatedAt = new Date().toISOString();
143
+ this._save(tenants);
144
+ return { id: tenantId, ...tenants[tenantId] };
145
+ }
146
+
147
+ /**
148
+ * Record task cost against this tenant's lifetime totals.
149
+ */
150
+ recordCost(tenantId, cost) {
151
+ if (!tenantId || !cost) return;
152
+ const tenants = this._load();
153
+ if (!tenants[tenantId]) return;
154
+ tenants[tenantId].totalCost = (tenants[tenantId].totalCost || 0) + cost;
155
+ tenants[tenantId].taskCount = (tenants[tenantId].taskCount || 0) + 1;
156
+ this._save(tenants);
157
+ }
158
+
159
+ /**
160
+ * Suspend a tenant (all their tasks will be rejected).
161
+ */
162
+ suspend(tenantId, reason = "") {
163
+ return this.set(tenantId, { suspended: true, suspendReason: reason });
164
+ }
165
+
166
+ /**
167
+ * Unsuspend a tenant.
168
+ */
169
+ unsuspend(tenantId) {
170
+ return this.set(tenantId, { suspended: false, suspendReason: "" });
171
+ }
172
+
173
+ /**
174
+ * Reset a tenant's config back to defaults (keep cost history).
175
+ */
176
+ reset(tenantId) {
177
+ const tenants = this._load();
178
+ if (!tenants[tenantId]) return null;
179
+ const preserved = {
180
+ totalCost: tenants[tenantId].totalCost || 0,
181
+ taskCount: tenants[tenantId].taskCount || 0,
182
+ createdAt: tenants[tenantId].createdAt,
183
+ };
184
+ tenants[tenantId] = { ..._defaultTenant(tenantId), ...preserved };
185
+ this._save(tenants);
186
+ return { id: tenantId, ...tenants[tenantId] };
187
+ }
188
+
189
+ /**
190
+ * Delete a tenant record entirely.
191
+ */
192
+ delete(tenantId) {
193
+ const tenants = this._load();
194
+ if (!tenants[tenantId]) return false;
195
+ delete tenants[tenantId];
196
+ this._save(tenants);
197
+ return true;
198
+ }
199
+
200
+ // ── Per-Tenant API Key Management ─────────────────────────────────────────
201
+
202
+ /**
203
+ * Store a per-tenant API key, encrypted with AES-256-GCM.
204
+ * The key is stored in tenant.encryptedApiKeys[keyName].
205
+ *
206
+ * @param {string} tenantId - e.g. "telegram:123"
207
+ * @param {string} keyName - e.g. "OPENAI_API_KEY"
208
+ * @param {string} keyValue - plaintext key value
209
+ */
210
+ setApiKey(tenantId, keyName, keyValue) {
211
+ const tenants = this._load();
212
+ if (!tenants[tenantId]) tenants[tenantId] = _defaultTenant(tenantId);
213
+ tenants[tenantId].encryptedApiKeys = tenants[tenantId].encryptedApiKeys || {};
214
+ tenants[tenantId].encryptedApiKeys[keyName] = _encryptTenantValue(keyValue);
215
+ tenants[tenantId].updatedAt = new Date().toISOString();
216
+ this._save(tenants);
217
+ return true;
218
+ }
219
+
220
+ /**
221
+ * Delete a per-tenant API key.
222
+ *
223
+ * @param {string} tenantId
224
+ * @param {string} keyName
225
+ * @returns {boolean} true if deleted, false if not found
226
+ */
227
+ deleteApiKey(tenantId, keyName) {
228
+ const tenants = this._load();
229
+ if (!tenants[tenantId]?.encryptedApiKeys?.[keyName]) return false;
230
+ delete tenants[tenantId].encryptedApiKeys[keyName];
231
+ tenants[tenantId].updatedAt = new Date().toISOString();
232
+ this._save(tenants);
233
+ return true;
234
+ }
235
+
236
+ /**
237
+ * List the names (not values) of stored API keys for a tenant.
238
+ *
239
+ * @param {string} tenantId
240
+ * @returns {string[]} key names
241
+ */
242
+ listApiKeyNames(tenantId) {
243
+ const tenants = this._load();
244
+ return Object.keys(tenants[tenantId]?.encryptedApiKeys || {});
245
+ }
246
+
247
+ /**
248
+ * Decrypt and return all stored API keys for a tenant.
249
+ * Returns {} if none stored or decryption fails.
250
+ * These are passed through the call stack (NOT via process.env) to prevent cross-tenant bleed.
251
+ *
252
+ * @param {string} tenantId
253
+ * @returns {object} e.g. { OPENAI_API_KEY: "sk-...", ANTHROPIC_API_KEY: "sk-ant-..." }
254
+ */
255
+ getDecryptedApiKeys(tenantId) {
256
+ const tenants = this._load();
257
+ const encrypted = tenants[tenantId]?.encryptedApiKeys || {};
258
+ const result = {};
259
+ for (const [key, val] of Object.entries(encrypted)) {
260
+ const decrypted = _decryptTenantValue(val);
261
+ if (decrypted !== null) result[key] = decrypted;
262
+ }
263
+ return result;
264
+ }
265
+
266
+ // ── Workspace ─────────────────────────────────────────────────────────────
267
+
268
+ /**
269
+ * Get (and create if missing) the isolated workspace directory for a tenant.
270
+ * This is the default allowed path when sandbox mode is active.
271
+ */
272
+ getWorkspace(tenantId) {
273
+ const safe = tenantId.replace(/[^a-zA-Z0-9_-]/g, "_");
274
+ const dir = join(TENANTS_DIR, safe, "workspace");
275
+ mkdirSync(dir, { recursive: true });
276
+ return dir;
277
+ }
278
+
279
+ // ── Effective config resolution ───────────────────────────────────────────
280
+
281
+ /**
282
+ * Resolve the effective config for a task, merging:
283
+ * tenant config > channel config > global config > defaults
284
+ *
285
+ * Returns the values TaskRunner should use for this specific task.
286
+ */
287
+ resolveTaskConfig(tenant, channelModel) {
288
+ const sandboxEnabled = config.sandbox?.mode === "docker" || config.multiTenant?.isolateFilesystem;
289
+
290
+ // Filesystem: tenant paths > global paths > (if sandbox) tenant workspace only
291
+ let allowedPaths = tenant?.allowedPaths?.length
292
+ ? tenant.allowedPaths
293
+ : config.filesystem?.allowedPaths || [];
294
+
295
+ // If sandbox mode and tenant has no custom paths, default to their workspace
296
+ if (sandboxEnabled && allowedPaths.length === 0 && tenant?.id) {
297
+ allowedPaths = [this.getWorkspace(tenant.id)];
298
+ }
299
+
300
+ const blockedPaths = tenant?.blockedPaths?.length
301
+ ? tenant.blockedPaths
302
+ : config.filesystem?.blockedPaths || [];
303
+
304
+ return {
305
+ model: tenant?.model || channelModel || config.defaultModel,
306
+ allowedPaths,
307
+ blockedPaths,
308
+ restrictCommands: config.filesystem?.restrictCommands || false,
309
+ maxCostPerTask: tenant?.maxCostPerTask ?? config.maxCostPerTask,
310
+ maxDailyCost: tenant?.maxDailyCost ?? config.maxDailyCost,
311
+ tools: tenant?.tools || null, // null = all tools allowed
312
+ sandbox: config.sandbox?.mode || "process",
313
+ mcpServers: tenant?.mcpServers ?? null, // null = all MCP servers allowed
314
+ modelRoutes: tenant?.modelRoutes || null, // null = use global env vars
315
+ apiKeys: tenant?.id ? this.getDecryptedApiKeys(tenant.id) : {}, // per-tenant API keys
316
+ };
317
+ }
318
+
319
+ // ── Internal ──────────────────────────────────────────────────────────────
320
+
321
+ _load() {
322
+ if (this._cache) return this._cache;
323
+ mkdirSync(TENANTS_DIR, { recursive: true });
324
+ if (!existsSync(TENANTS_PATH)) return {};
325
+ try {
326
+ this._cache = JSON.parse(readFileSync(TENANTS_PATH, "utf-8"));
327
+ return this._cache;
328
+ } catch {
329
+ return {};
330
+ }
331
+ }
332
+
333
+ _save(tenants) {
334
+ mkdirSync(TENANTS_DIR, { recursive: true });
335
+ writeFileSync(TENANTS_PATH, JSON.stringify(tenants, null, 2), "utf-8");
336
+ this._cache = tenants;
337
+ }
338
+
339
+ stats() {
340
+ const tenants = this._load();
341
+ const list = Object.values(tenants);
342
+ return {
343
+ total: list.length,
344
+ suspended: list.filter(t => t.suspended).length,
345
+ totalCost: list.reduce((s, t) => s + (t.totalCost || 0), 0).toFixed(4),
346
+ totalTasks: list.reduce((s, t) => s + (t.taskCount || 0), 0),
347
+ };
348
+ }
349
+ }
350
+
351
+ function _makeId(channel, userId) {
352
+ return `${channel}:${userId}`;
353
+ }
354
+
355
+ function _defaultTenant(id) {
356
+ return {
357
+ model: null,
358
+ allowedPaths: [],
359
+ blockedPaths: [],
360
+ maxCostPerTask: null,
361
+ maxDailyCost: null,
362
+ tools: null,
363
+ mcpServers: null, // null = all MCP servers allowed; ["github","linear"] = allowlist
364
+ modelRoutes: null, // null = use global env vars; { coder: "anthropic:..." }
365
+ encryptedApiKeys: {}, // AES-256-GCM encrypted per-tenant API keys
366
+ suspended: false,
367
+ suspendReason: "",
368
+ plan: "free",
369
+ notes: "",
370
+ totalCost: 0,
371
+ taskCount: 0,
372
+ createdAt: new Date().toISOString(),
373
+ lastSeenAt: new Date().toISOString(),
374
+ updatedAt: new Date().toISOString(),
375
+ };
376
+ }
377
+
378
+ const tenantManager = new TenantManager();
379
+ export default tenantManager;
@@ -0,0 +1,141 @@
1
+ /**
2
+ * Structured tool registry.
3
+ * Each tool registers with metadata: name, category, params, permissionTier, isWrite, fn.
4
+ * Replaces loose imports + string descriptions with a single source of truth.
5
+ */
6
+ class ToolRegistry {
7
+ constructor() {
8
+ this.tools = new Map();
9
+ }
10
+
11
+ /**
12
+ * Register a tool with structured metadata.
13
+ */
14
+ register({ name, category, description, params, permissionTier, isWrite, fn }) {
15
+ if (!name || !fn) throw new Error(`Tool registration requires name and fn`);
16
+ this.tools.set(name, {
17
+ name,
18
+ category: category || "other",
19
+ description: description || "",
20
+ params: params || [],
21
+ permissionTier: permissionTier || "full",
22
+ isWrite: isWrite ?? false,
23
+ fn,
24
+ });
25
+ }
26
+
27
+ /**
28
+ * Get tool function map { name: fn } — backward compatible with current toolFunctions export.
29
+ */
30
+ getToolFunctions() {
31
+ const fns = {};
32
+ for (const [name, tool] of this.tools) {
33
+ fns[name] = tool.fn;
34
+ }
35
+ return fns;
36
+ }
37
+
38
+ /**
39
+ * Get tool description strings — backward compatible with current toolDescriptions export.
40
+ */
41
+ getToolDescriptions() {
42
+ const descs = [];
43
+ for (const [, tool] of this.tools) {
44
+ const paramStr = tool.params
45
+ .map((p) => `${p.name}${p.required ? "" : "?"}: ${p.type}`)
46
+ .join(", ");
47
+ descs.push(`${tool.name}(${paramStr}) - ${tool.description}`);
48
+ }
49
+ return descs;
50
+ }
51
+
52
+ /**
53
+ * Build tool docs for system prompt — grouped by category.
54
+ */
55
+ buildToolDocs() {
56
+ const categories = {};
57
+ for (const [, tool] of this.tools) {
58
+ if (!categories[tool.category]) categories[tool.category] = [];
59
+ categories[tool.category].push(tool);
60
+ }
61
+
62
+ const categoryLabels = {
63
+ filesystem: "File Operations",
64
+ search: "Search",
65
+ system: "System",
66
+ web: "Web & Browser",
67
+ communication: "Communication",
68
+ documents: "Documents",
69
+ memory: "Memory",
70
+ agents: "Agents",
71
+ automation: "Automation",
72
+ other: "Other",
73
+ };
74
+
75
+ let doc = "# Available Tools\n\nAll tool params are STRINGS. Pass them as an array of strings.\n";
76
+
77
+ for (const [cat, tools] of Object.entries(categories)) {
78
+ doc += `\n## ${categoryLabels[cat] || cat}\n\n`;
79
+ for (const tool of tools) {
80
+ const paramStr = tool.params
81
+ .map((p) => `${p.name}${p.required ? "" : "?"}`)
82
+ .join(", ");
83
+ doc += `### ${tool.name}(${paramStr})\n`;
84
+ doc += `${tool.description}\n`;
85
+ if (tool.params.length > 0) {
86
+ for (const p of tool.params) {
87
+ doc += `- ${p.name}${p.required ? " (required)" : " (optional)"}: ${p.description || p.type}\n`;
88
+ }
89
+ }
90
+ doc += "\n";
91
+ }
92
+ }
93
+
94
+ return doc;
95
+ }
96
+
97
+ /**
98
+ * Get tools allowed for a specific permission tier.
99
+ */
100
+ getToolsForTier(tier) {
101
+ const tierOrder = { minimal: 0, standard: 1, full: 2 };
102
+ const tierLevel = tierOrder[tier] ?? 2;
103
+ const allowed = [];
104
+ for (const [name, tool] of this.tools) {
105
+ const toolLevel = tierOrder[tool.permissionTier] ?? 2;
106
+ if (toolLevel <= tierLevel) {
107
+ allowed.push(name);
108
+ }
109
+ }
110
+ return allowed;
111
+ }
112
+
113
+ /**
114
+ * Get write tool names (for SAFEGUARD 2 in AgentLoop).
115
+ */
116
+ getWriteTools() {
117
+ const names = [];
118
+ for (const [name, tool] of this.tools) {
119
+ if (tool.isWrite) names.push(name);
120
+ }
121
+ return new Set(names);
122
+ }
123
+
124
+ /**
125
+ * Get tool metadata by name.
126
+ */
127
+ get(name) {
128
+ return this.tools.get(name);
129
+ }
130
+
131
+ /**
132
+ * Get all tool names.
133
+ */
134
+ getNames() {
135
+ return [...this.tools.keys()];
136
+ }
137
+ }
138
+
139
+ // Singleton
140
+ const registry = new ToolRegistry();
141
+ export default registry;
@@ -0,0 +1,144 @@
1
+ /**
2
+ * applyPatch(filePath, patch) — Apply a unified diff patch to a file.
3
+ * Handles multi-hunk edits that editFile's single find-replace can't do.
4
+ * Inspired by OpenClaw's apply_patch tool.
5
+ */
6
+ import { readFileSync, writeFileSync } from "node:fs";
7
+ import { existsSync } from "node:fs";
8
+
9
+ /**
10
+ * Parse a unified diff string into an array of hunks.
11
+ * Each hunk: { origStart, origCount, newStart, newCount, lines }
12
+ */
13
+ function parseUnifiedDiff(patch) {
14
+ const lines = patch.split("\n");
15
+ const hunks = [];
16
+ let i = 0;
17
+
18
+ // Skip file headers (--- +++)
19
+ while (i < lines.length && (lines[i].startsWith("---") || lines[i].startsWith("+++"))) {
20
+ i++;
21
+ }
22
+
23
+ while (i < lines.length) {
24
+ const hunkHeader = lines[i].match(/^@@\s+-(\d+)(?:,(\d+))?\s+\+(\d+)(?:,(\d+))?\s+@@/);
25
+ if (!hunkHeader) { i++; continue; }
26
+
27
+ const origStart = parseInt(hunkHeader[1]) - 1; // 0-indexed
28
+ const origCount = hunkHeader[2] !== undefined ? parseInt(hunkHeader[2]) : 1;
29
+ const hunkLines = [];
30
+ i++;
31
+
32
+ while (i < lines.length && !lines[i].startsWith("@@")) {
33
+ hunkLines.push(lines[i]);
34
+ i++;
35
+ }
36
+
37
+ hunks.push({ origStart, origCount, lines: hunkLines });
38
+ }
39
+
40
+ return hunks;
41
+ }
42
+
43
+ /**
44
+ * Apply a single hunk to file lines. Returns new lines array or null on failure.
45
+ * Supports fuzzy matching: tries exact position first, then scans ±10 lines.
46
+ */
47
+ function applyHunk(fileLines, hunk, offset) {
48
+ const contextLines = hunk.lines.filter((l) => l.startsWith(" ") || l === "");
49
+ const removals = hunk.lines.filter((l) => l.startsWith("-")).map((l) => l.slice(1));
50
+
51
+ const tryAt = (pos) => {
52
+ // Verify context and removals match at this position
53
+ let ri = pos;
54
+ for (const hunkLine of hunk.lines) {
55
+ if (hunkLine.startsWith(" ") || hunkLine === "") {
56
+ // context line — must match
57
+ if (ri >= fileLines.length) return null;
58
+ if (fileLines[ri].trimEnd() !== hunkLine.slice(1).trimEnd()) return null;
59
+ ri++;
60
+ } else if (hunkLine.startsWith("-")) {
61
+ if (ri >= fileLines.length) return null;
62
+ if (fileLines[ri].trimEnd() !== hunkLine.slice(1).trimEnd()) return null;
63
+ ri++;
64
+ }
65
+ // "+" lines don't consume file lines
66
+ }
67
+ return ri; // end position
68
+ };
69
+
70
+ // Try intended position first, then fuzzy
71
+ const intendedPos = Math.max(0, hunk.origStart + offset);
72
+ const positions = [intendedPos];
73
+ for (let delta = 1; delta <= 10; delta++) {
74
+ if (intendedPos + delta < fileLines.length) positions.push(intendedPos + delta);
75
+ if (intendedPos - delta >= 0) positions.push(intendedPos - delta);
76
+ }
77
+
78
+ for (const pos of positions) {
79
+ const endPos = tryAt(pos);
80
+ if (endPos === null) continue;
81
+
82
+ // Build new file lines: everything before hunk + new lines + everything after
83
+ const before = fileLines.slice(0, pos);
84
+ const after = fileLines.slice(endPos);
85
+ const added = hunk.lines
86
+ .filter((l) => l.startsWith("+"))
87
+ .map((l) => l.slice(1));
88
+ const context = hunk.lines
89
+ .filter((l) => l.startsWith(" ") || l === "")
90
+ .map((l) => (l === "" ? "" : l.slice(1)));
91
+
92
+ // Reconstruct: before, context+additions interleaved, after
93
+ const middle = [];
94
+ for (const hl of hunk.lines) {
95
+ if (hl.startsWith(" ") || hl === "") middle.push(hl === "" ? "" : hl.slice(1));
96
+ else if (hl.startsWith("+")) middle.push(hl.slice(1));
97
+ // "-" lines are removed, not added
98
+ }
99
+
100
+ return { newLines: [...before, ...middle, ...after], newOffset: offset + (endPos - pos) };
101
+ }
102
+
103
+ return null;
104
+ }
105
+
106
+ export function applyPatch(filePath, patch) {
107
+ try {
108
+ if (!existsSync(filePath)) {
109
+ return `Error: File not found: ${filePath}`;
110
+ }
111
+ if (!patch || typeof patch !== "string") {
112
+ return "Error: patch must be a unified diff string";
113
+ }
114
+
115
+ const content = readFileSync(filePath, "utf-8");
116
+ let fileLines = content.split("\n");
117
+ const hunks = parseUnifiedDiff(patch);
118
+
119
+ if (hunks.length === 0) {
120
+ return "Error: No valid hunks found in patch. Ensure it is in unified diff format (with @@ headers).";
121
+ }
122
+
123
+ let offset = 0;
124
+ let appliedCount = 0;
125
+
126
+ for (let h = 0; h < hunks.length; h++) {
127
+ const result = applyHunk(fileLines, hunks[h], offset);
128
+ if (!result) {
129
+ return `Error: Hunk ${h + 1} of ${hunks.length} failed to apply. Context lines did not match file content. Re-read the file and regenerate the patch.`;
130
+ }
131
+ fileLines = result.newLines;
132
+ offset = result.newOffset;
133
+ appliedCount++;
134
+ }
135
+
136
+ writeFileSync(filePath, fileLines.join("\n"), "utf-8");
137
+ return `Applied ${appliedCount} hunk(s) to ${filePath} successfully.`;
138
+ } catch (error) {
139
+ return `Error applying patch: ${error.message}`;
140
+ }
141
+ }
142
+
143
+ export const applyPatchDescription =
144
+ 'applyPatch(filePath: string, patch: string) - Apply a unified diff patch to a file. Handles multi-hunk edits. patch must be in standard unified diff format starting with @@ hunk headers.';