openbot 0.2.14 → 0.3.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/dist/agents/openbot/index.js +76 -0
- package/dist/agents/openbot/middleware/approval.js +132 -0
- package/dist/agents/openbot/runtime.js +289 -0
- package/dist/agents/openbot/system-prompt.js +32 -0
- package/dist/agents/openbot/tools/delegation.js +78 -0
- package/dist/agents/openbot/tools/mcp.js +99 -0
- package/dist/agents/openbot/tools/shell.js +91 -0
- package/dist/agents/openbot/tools/storage.js +75 -0
- package/dist/agents/openbot/tools/ui.js +176 -0
- package/dist/agents/system.js +20 -93
- package/dist/app/cli.js +0 -0
- package/dist/app/config.js +4 -1
- package/dist/app/server.js +15 -8
- package/dist/bus/agent-package.js +1 -0
- package/dist/bus/plugin.js +1 -0
- package/dist/bus/services.js +600 -0
- package/dist/bus/types.js +1 -0
- package/dist/harness/context.js +131 -0
- package/dist/harness/event-normalizer.js +59 -0
- package/dist/harness/orchestrator.js +27 -227
- package/dist/harness/process.js +25 -3
- package/dist/harness/queue-processor.js +227 -0
- package/dist/harness/runtime-factory.js +103 -0
- package/dist/plugins/ai-sdk/index.js +37 -0
- package/dist/plugins/ai-sdk/runtime.js +330 -0
- package/dist/plugins/ai-sdk/system-prompt.js +3 -0
- package/dist/plugins/ai-sdk.js +277 -87
- package/dist/plugins/approval/index.js +159 -0
- package/dist/plugins/approval.js +163 -0
- package/dist/plugins/delegation/index.js +79 -0
- package/dist/plugins/delegation.js +67 -11
- package/dist/plugins/mcp/index.js +108 -0
- package/dist/plugins/shell/index.js +99 -0
- package/dist/plugins/shell.js +123 -0
- package/dist/plugins/storage-tools/index.js +85 -0
- package/dist/plugins/storage.js +240 -5
- package/dist/plugins/ui/index.js +184 -0
- package/dist/plugins/ui.js +185 -21
- package/dist/registry/agents.js +138 -0
- package/dist/registry/plugins.js +91 -50
- package/dist/services/agent-packages.js +103 -0
- package/dist/services/plugins.js +98 -0
- package/dist/services/storage.js +360 -94
- package/docs/agents.md +39 -66
- package/docs/architecture.md +1 -1
- package/docs/plugins.md +70 -58
- package/docs/templates/AGENT.example.md +57 -0
- package/package.json +8 -7
- package/src/app/cli.ts +1 -1
- package/src/app/config.ts +14 -4
- package/src/app/server.ts +23 -10
- package/src/app/types.ts +385 -16
- package/src/assets/icon.svg +4 -1
- package/src/bus/plugin.ts +67 -0
- package/src/bus/services.ts +666 -0
- package/src/bus/types.ts +147 -0
- package/src/harness/context.ts +160 -0
- package/src/harness/event-normalizer.ts +82 -0
- package/src/harness/orchestrator.ts +35 -273
- package/src/harness/process.ts +28 -4
- package/src/harness/queue-processor.ts +309 -0
- package/src/harness/runtime-factory.ts +125 -0
- package/src/plugins/ai-sdk/index.ts +44 -0
- package/src/plugins/ai-sdk/runtime.ts +410 -0
- package/src/plugins/ai-sdk/system-prompt.ts +4 -0
- package/src/plugins/approval/index.ts +228 -0
- package/src/plugins/delegation/index.ts +94 -0
- package/src/plugins/mcp/index.ts +128 -0
- package/src/plugins/shell/index.ts +123 -0
- package/src/plugins/storage-tools/index.ts +101 -0
- package/src/plugins/ui/index.ts +227 -0
- package/src/registry/plugins.ts +106 -55
- package/src/services/plugins.ts +133 -0
- package/src/services/storage.ts +465 -137
- package/src/agents/system.ts +0 -112
- package/src/plugins/ai-sdk.ts +0 -197
- package/src/plugins/delegation.ts +0 -60
- package/src/plugins/mcp.ts +0 -154
- package/src/plugins/storage.ts +0 -725
- package/src/plugins/ui.ts +0 -57
package/dist/services/storage.js
CHANGED
|
@@ -1,19 +1,13 @@
|
|
|
1
|
-
import { DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR,
|
|
1
|
+
import { DEFAULT_PLUGINS_DIR, DEFAULT_AGENTS_DIR, DEFAULT_BASE_DIR, DEFAULT_CHANNELS_DIR, loadConfig, resolvePath, VARIABLES_FILE, } from '../app/config.js';
|
|
2
2
|
import fs from 'node:fs/promises';
|
|
3
3
|
import path from 'node:path';
|
|
4
4
|
import crypto from 'node:crypto';
|
|
5
5
|
import matter from 'gray-matter';
|
|
6
|
-
import {
|
|
6
|
+
import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
|
|
7
|
+
import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
|
|
8
|
+
import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
|
|
9
|
+
import { processService } from '../harness/process.js';
|
|
7
10
|
import { pathToFileURL } from 'node:url';
|
|
8
|
-
const mapNameToPlugin = (name, description, kind = 'tool', image) => ({
|
|
9
|
-
id: name,
|
|
10
|
-
name,
|
|
11
|
-
description,
|
|
12
|
-
kind,
|
|
13
|
-
image,
|
|
14
|
-
createdAt: new Date(),
|
|
15
|
-
updatedAt: new Date(),
|
|
16
|
-
});
|
|
17
11
|
const resolveBaseDir = () => {
|
|
18
12
|
const config = loadConfig();
|
|
19
13
|
return resolvePath(config.baseDir || DEFAULT_BASE_DIR);
|
|
@@ -32,15 +26,6 @@ const tryReadSvgDataUrl = async (filePath) => {
|
|
|
32
26
|
return null;
|
|
33
27
|
}
|
|
34
28
|
};
|
|
35
|
-
/**
|
|
36
|
-
* Auto-discovers an entity SVG avatar and returns it as a data URL.
|
|
37
|
-
*
|
|
38
|
-
* Search order:
|
|
39
|
-
* 1) <entity>/assets/avatar.svg|icon.svg|image.svg|logo.svg
|
|
40
|
-
* 2) <entity>/avatar.svg|icon.svg|image.svg|logo.svg
|
|
41
|
-
* 3) first *.svg in <entity>/assets
|
|
42
|
-
* 4) first *.svg in <entity>
|
|
43
|
-
*/
|
|
44
29
|
const resolveEntityImageDataUrl = async (entityDir) => {
|
|
45
30
|
const preferredDirs = [path.join(entityDir, 'assets'), entityDir];
|
|
46
31
|
for (const dir of preferredDirs) {
|
|
@@ -61,7 +46,7 @@ const resolveEntityImageDataUrl = async (entityDir) => {
|
|
|
61
46
|
return dataUrl;
|
|
62
47
|
}
|
|
63
48
|
catch {
|
|
64
|
-
// ignore
|
|
49
|
+
// ignore
|
|
65
50
|
}
|
|
66
51
|
}
|
|
67
52
|
return undefined;
|
|
@@ -70,6 +55,59 @@ const getConversationDir = (channelId, threadId) => {
|
|
|
70
55
|
const base = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId);
|
|
71
56
|
return threadId ? `${base}/threads/${threadId}` : base;
|
|
72
57
|
};
|
|
58
|
+
/** Built-in orchestrator agent id. Not creatable as a normal disk agent. */
|
|
59
|
+
const SYSTEM_AGENT_ID = 'system';
|
|
60
|
+
const SYSTEM_DEFAULT_PLUGINS = [
|
|
61
|
+
{ id: 'ai-sdk', config: { model: 'openai/gpt-5.4-nano' } },
|
|
62
|
+
{ id: 'storage-tools' },
|
|
63
|
+
// { id: 'mcp' },
|
|
64
|
+
{ id: 'shell' },
|
|
65
|
+
{ id: 'delegation' },
|
|
66
|
+
// { id: 'ui' },
|
|
67
|
+
{ id: 'approval' },
|
|
68
|
+
];
|
|
69
|
+
function getSystemAgentDetails(overrides) {
|
|
70
|
+
const defaults = {
|
|
71
|
+
id: SYSTEM_AGENT_ID,
|
|
72
|
+
name: 'OpenBot',
|
|
73
|
+
image: undefined,
|
|
74
|
+
description: 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff and delegation.',
|
|
75
|
+
instructions: AI_SDK_SYSTEM_PROMPT,
|
|
76
|
+
plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
|
|
77
|
+
pluginRefs: SYSTEM_DEFAULT_PLUGINS,
|
|
78
|
+
createdAt: new Date(),
|
|
79
|
+
updatedAt: new Date(),
|
|
80
|
+
};
|
|
81
|
+
if (!overrides)
|
|
82
|
+
return defaults;
|
|
83
|
+
const refs = overrides.pluginRefs && overrides.pluginRefs.length > 0
|
|
84
|
+
? overrides.pluginRefs
|
|
85
|
+
: defaults.pluginRefs;
|
|
86
|
+
return {
|
|
87
|
+
...defaults,
|
|
88
|
+
...overrides,
|
|
89
|
+
id: SYSTEM_AGENT_ID,
|
|
90
|
+
image: overrides.image || defaults.image,
|
|
91
|
+
plugins: refs.map((ref) => ref.id),
|
|
92
|
+
pluginRefs: refs,
|
|
93
|
+
updatedAt: new Date(),
|
|
94
|
+
};
|
|
95
|
+
}
|
|
96
|
+
// Suppress unused warning until system agent customization re-uses aiSdkPlugin metadata.
|
|
97
|
+
void aiSdkPlugin;
|
|
98
|
+
const RESERVED_DISK_AGENT_IDS = new Set([SYSTEM_AGENT_ID]);
|
|
99
|
+
const assertValidDiskAgentId = (agentId) => {
|
|
100
|
+
if (!agentId || typeof agentId !== 'string') {
|
|
101
|
+
throw new Error('agentId is required');
|
|
102
|
+
}
|
|
103
|
+
if (RESERVED_DISK_AGENT_IDS.has(agentId)) {
|
|
104
|
+
throw new Error(`Agent id "${agentId}" is reserved`);
|
|
105
|
+
}
|
|
106
|
+
if (!/^[a-zA-Z0-9_-]+$/.test(agentId)) {
|
|
107
|
+
throw new Error('agentId must contain only letters, digits, underscores, and hyphens');
|
|
108
|
+
}
|
|
109
|
+
};
|
|
110
|
+
const getAgentsRootDir = () => path.join(resolveBaseDir(), DEFAULT_AGENTS_DIR);
|
|
73
111
|
const getLastReadFilePath = () => path.join(resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR), '_meta', 'last-read.json');
|
|
74
112
|
const THREAD_TITLE_MAX_LENGTH = 80;
|
|
75
113
|
const buildThreadTitleFromEvent = (event) => {
|
|
@@ -104,25 +142,67 @@ const toVariablesRecord = (raw) => {
|
|
|
104
142
|
if (!raw || typeof raw !== 'object') {
|
|
105
143
|
return {};
|
|
106
144
|
}
|
|
107
|
-
// Current format: { version: number, variables: StoredVariable[] }
|
|
108
145
|
if ('variables' in raw && Array.isArray(raw.variables)) {
|
|
109
146
|
const entries = raw.variables
|
|
110
147
|
.filter((variable) => typeof variable?.key === 'string')
|
|
111
148
|
.map((variable) => [variable.key, String(variable.value ?? '')]);
|
|
112
149
|
return Object.fromEntries(entries);
|
|
113
150
|
}
|
|
114
|
-
// Legacy format: { [key: string]: string }
|
|
115
151
|
return Object.fromEntries(Object.entries(raw).map(([key, value]) => [
|
|
116
152
|
key,
|
|
117
153
|
String(value ?? ''),
|
|
118
154
|
]));
|
|
119
155
|
};
|
|
120
|
-
const
|
|
121
|
-
return
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
156
|
+
const listBuiltInPluginDescriptors = async () => {
|
|
157
|
+
return listBuiltInPlugins().map((plugin) => ({
|
|
158
|
+
id: plugin.id,
|
|
159
|
+
name: plugin.name,
|
|
160
|
+
description: plugin.description,
|
|
161
|
+
builtIn: true,
|
|
162
|
+
image: plugin.image,
|
|
163
|
+
defaultInstructions: plugin.defaultInstructions,
|
|
164
|
+
configSchema: plugin.configSchema,
|
|
165
|
+
createdAt: new Date(),
|
|
166
|
+
updatedAt: new Date(),
|
|
167
|
+
}));
|
|
168
|
+
};
|
|
169
|
+
/**
|
|
170
|
+
* Walk `plugins/` and yield candidate plugin ids (npm names). Includes scoped
|
|
171
|
+
* packages by recursing one level into directories starting with `@`.
|
|
172
|
+
*/
|
|
173
|
+
const listInstalledPluginIds = async (pluginsDir) => {
|
|
174
|
+
const out = [];
|
|
175
|
+
let topEntries;
|
|
176
|
+
try {
|
|
177
|
+
topEntries = await fs.readdir(pluginsDir, { withFileTypes: true });
|
|
178
|
+
}
|
|
179
|
+
catch {
|
|
180
|
+
return out;
|
|
181
|
+
}
|
|
182
|
+
for (const entry of topEntries) {
|
|
183
|
+
if (entry.name.startsWith('.'))
|
|
184
|
+
continue;
|
|
185
|
+
if (!entry.isDirectory() && !entry.isSymbolicLink())
|
|
186
|
+
continue;
|
|
187
|
+
if (entry.name.startsWith('@')) {
|
|
188
|
+
try {
|
|
189
|
+
const inner = await fs.readdir(path.join(pluginsDir, entry.name), { withFileTypes: true });
|
|
190
|
+
for (const sub of inner) {
|
|
191
|
+
if (sub.name.startsWith('.'))
|
|
192
|
+
continue;
|
|
193
|
+
if (sub.isDirectory() || sub.isSymbolicLink()) {
|
|
194
|
+
out.push(`${entry.name}/${sub.name}`);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
// ignore
|
|
200
|
+
}
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
out.push(entry.name);
|
|
204
|
+
}
|
|
205
|
+
return out;
|
|
126
206
|
};
|
|
127
207
|
const listPluginsFromDisk = async () => {
|
|
128
208
|
const pluginsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_PLUGINS_DIR);
|
|
@@ -132,17 +212,57 @@ const listPluginsFromDisk = async () => {
|
|
|
132
212
|
catch {
|
|
133
213
|
await fs.mkdir(pluginsDir, { recursive: true });
|
|
134
214
|
}
|
|
135
|
-
const
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
215
|
+
const ids = await listInstalledPluginIds(pluginsDir);
|
|
216
|
+
const descriptors = await Promise.all(ids.map(async (id) => {
|
|
217
|
+
try {
|
|
218
|
+
const pluginDir = path.join(pluginsDir, id);
|
|
219
|
+
const distPath = path.join(pluginDir, 'dist', 'index.js');
|
|
220
|
+
const module = await import(pathToFileURL(distPath).href);
|
|
221
|
+
const parsed = parsePluginModule(module);
|
|
222
|
+
if (!parsed)
|
|
223
|
+
return null;
|
|
224
|
+
const image = await resolveEntityImageDataUrl(pluginDir);
|
|
225
|
+
return {
|
|
226
|
+
id,
|
|
227
|
+
name: parsed.name || id,
|
|
228
|
+
description: parsed.description || '',
|
|
229
|
+
builtIn: false,
|
|
230
|
+
image: parsed.image || image,
|
|
231
|
+
defaultInstructions: parsed.defaultInstructions,
|
|
232
|
+
configSchema: parsed.configSchema,
|
|
233
|
+
createdAt: new Date(),
|
|
234
|
+
updatedAt: new Date(),
|
|
235
|
+
};
|
|
236
|
+
}
|
|
237
|
+
catch (error) {
|
|
238
|
+
console.warn(`[storage] Failed to load plugin ${id}:`, error);
|
|
239
|
+
return null;
|
|
240
|
+
}
|
|
241
|
+
}));
|
|
242
|
+
return descriptors.filter((d) => d !== null);
|
|
243
|
+
};
|
|
244
|
+
const isRecord = (value) => !!value && typeof value === 'object' && !Array.isArray(value);
|
|
245
|
+
/**
|
|
246
|
+
* Parse the `plugins:` array from AGENT.md frontmatter. Each entry must have an
|
|
247
|
+
* `id`; `config` is optional. Strings are accepted as a shorthand for `{ id }`.
|
|
248
|
+
*/
|
|
249
|
+
const parsePluginRefs = (raw) => {
|
|
250
|
+
if (!Array.isArray(raw))
|
|
251
|
+
return [];
|
|
252
|
+
const refs = [];
|
|
253
|
+
for (const entry of raw) {
|
|
254
|
+
if (typeof entry === 'string' && entry.trim()) {
|
|
255
|
+
refs.push({ id: entry.trim() });
|
|
256
|
+
continue;
|
|
257
|
+
}
|
|
258
|
+
if (isRecord(entry) && typeof entry.id === 'string' && entry.id.trim()) {
|
|
259
|
+
const config = isRecord(entry.config) ? entry.config : undefined;
|
|
260
|
+
refs.push({ id: entry.id.trim(), ...(config ? { config } : {}) });
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
return refs;
|
|
145
264
|
};
|
|
265
|
+
const serializePluginRefs = (refs) => refs.map((ref) => (ref.config ? { id: ref.id, config: ref.config } : { id: ref.id }));
|
|
146
266
|
export const storageService = {
|
|
147
267
|
getLastReadByChannel: async () => {
|
|
148
268
|
return readJsonFile(getLastReadFilePath(), {});
|
|
@@ -174,7 +294,7 @@ export const storageService = {
|
|
|
174
294
|
cwd = typeof state.cwd === 'string' ? state.cwd : undefined;
|
|
175
295
|
}
|
|
176
296
|
catch {
|
|
177
|
-
//
|
|
297
|
+
// ignore
|
|
178
298
|
}
|
|
179
299
|
const channel = {
|
|
180
300
|
id: name,
|
|
@@ -194,7 +314,6 @@ export const storageService = {
|
|
|
194
314
|
channel.hasUnseenMessages = false;
|
|
195
315
|
}
|
|
196
316
|
try {
|
|
197
|
-
// Fetch up to 5 most recent threads for the sidebar
|
|
198
317
|
const allThreads = await storageService.getThreads({ channelId: name });
|
|
199
318
|
channel.recentThreads = allThreads
|
|
200
319
|
.sort((a, b) => b.updatedAt.getTime() - a.updatedAt.getTime())
|
|
@@ -220,7 +339,8 @@ export const storageService = {
|
|
|
220
339
|
throw new Error(`Channel "${normalizedChannelId}" already exists`);
|
|
221
340
|
}
|
|
222
341
|
catch (error) {
|
|
223
|
-
|
|
342
|
+
const code = error?.code;
|
|
343
|
+
if (code !== 'ENOENT') {
|
|
224
344
|
throw error;
|
|
225
345
|
}
|
|
226
346
|
}
|
|
@@ -238,12 +358,10 @@ export const storageService = {
|
|
|
238
358
|
createThread: async ({ channelId, threadId, threadTitle, spec, initialState, }) => {
|
|
239
359
|
const normalizedChannelId = channelId.trim();
|
|
240
360
|
const normalizedThreadId = threadId.trim();
|
|
241
|
-
if (!normalizedChannelId)
|
|
361
|
+
if (!normalizedChannelId)
|
|
242
362
|
throw new Error('channelId is required');
|
|
243
|
-
|
|
244
|
-
if (!normalizedThreadId) {
|
|
363
|
+
if (!normalizedThreadId)
|
|
245
364
|
throw new Error('threadId is required');
|
|
246
|
-
}
|
|
247
365
|
const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
|
|
248
366
|
const specPath = `${threadDir}/SPEC.md`;
|
|
249
367
|
const statePath = `${threadDir}/state.json`;
|
|
@@ -252,7 +370,8 @@ export const storageService = {
|
|
|
252
370
|
throw new Error(`Thread "${normalizedThreadId}" already exists in channel "${normalizedChannelId}"`);
|
|
253
371
|
}
|
|
254
372
|
catch (error) {
|
|
255
|
-
|
|
373
|
+
const code = error?.code;
|
|
374
|
+
if (code !== 'ENOENT') {
|
|
256
375
|
throw error;
|
|
257
376
|
}
|
|
258
377
|
}
|
|
@@ -288,7 +407,7 @@ export const storageService = {
|
|
|
288
407
|
}
|
|
289
408
|
}
|
|
290
409
|
catch (error) {
|
|
291
|
-
if (error
|
|
410
|
+
if (error?.code !== 'ENOENT') {
|
|
292
411
|
console.error(`Failed to read thread state for channel ${channelId} thread ${name}`, error);
|
|
293
412
|
}
|
|
294
413
|
}
|
|
@@ -311,7 +430,7 @@ export const storageService = {
|
|
|
311
430
|
spec = await fs.readFile(specPath, 'utf-8');
|
|
312
431
|
}
|
|
313
432
|
catch (error) {
|
|
314
|
-
if (error
|
|
433
|
+
if (error?.code !== 'ENOENT') {
|
|
315
434
|
console.error(`Failed to read thread spec for channel ${channelId} thread ${threadId}`, error);
|
|
316
435
|
}
|
|
317
436
|
}
|
|
@@ -321,11 +440,11 @@ export const storageService = {
|
|
|
321
440
|
state = JSON.parse(stateContent);
|
|
322
441
|
}
|
|
323
442
|
catch (error) {
|
|
324
|
-
if (error
|
|
443
|
+
if (error?.code !== 'ENOENT') {
|
|
325
444
|
console.error(`Failed to read thread state for channel ${channelId} thread ${threadId}`, error);
|
|
326
445
|
}
|
|
327
446
|
}
|
|
328
|
-
const generatedName = typeof state.generatedName === 'string'
|
|
447
|
+
const generatedName = isRecord(state) && typeof state.generatedName === 'string'
|
|
329
448
|
? state.generatedName.trim()
|
|
330
449
|
: '';
|
|
331
450
|
return {
|
|
@@ -345,7 +464,7 @@ export const storageService = {
|
|
|
345
464
|
spec = await fs.readFile(specPath, 'utf-8');
|
|
346
465
|
}
|
|
347
466
|
catch (error) {
|
|
348
|
-
if (error
|
|
467
|
+
if (error?.code !== 'ENOENT') {
|
|
349
468
|
console.error(`Failed to read spec file for channel ${channelId}`, error);
|
|
350
469
|
}
|
|
351
470
|
}
|
|
@@ -355,16 +474,17 @@ export const storageService = {
|
|
|
355
474
|
state = JSON.parse(stateContent);
|
|
356
475
|
}
|
|
357
476
|
catch (error) {
|
|
358
|
-
if (error
|
|
477
|
+
if (error?.code !== 'ENOENT') {
|
|
359
478
|
console.error(`Failed to read state file for channel ${channelId}`, error);
|
|
360
479
|
}
|
|
361
480
|
}
|
|
481
|
+
const cwd = isRecord(state) && typeof state.cwd === 'string' ? state.cwd : undefined;
|
|
362
482
|
const details = {
|
|
363
483
|
id: channelId,
|
|
364
484
|
name: channelId,
|
|
365
485
|
spec,
|
|
366
486
|
state,
|
|
367
|
-
cwd
|
|
487
|
+
cwd,
|
|
368
488
|
};
|
|
369
489
|
details.threads = await storageService.getThreads({ channelId });
|
|
370
490
|
return details;
|
|
@@ -373,15 +493,12 @@ export const storageService = {
|
|
|
373
493
|
const channelDir = getConversationDir(channelId);
|
|
374
494
|
const statePath = `${channelDir}/state.json`;
|
|
375
495
|
try {
|
|
376
|
-
// 1. Fetch current details to get the existing state
|
|
377
496
|
const currentDetails = await storageService.getChannelDetails({ channelId });
|
|
378
497
|
const currentState = currentDetails.state || {};
|
|
379
|
-
// 2. Perform a shallow merge (patch)
|
|
380
498
|
const newState = {
|
|
381
499
|
...currentState,
|
|
382
500
|
...patch,
|
|
383
501
|
};
|
|
384
|
-
// 3. Write back the merged state
|
|
385
502
|
await fs.mkdir(channelDir, { recursive: true });
|
|
386
503
|
await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
|
|
387
504
|
}
|
|
@@ -394,15 +511,12 @@ export const storageService = {
|
|
|
394
511
|
const threadDir = getConversationDir(channelId, threadId);
|
|
395
512
|
const statePath = `${threadDir}/state.json`;
|
|
396
513
|
try {
|
|
397
|
-
// 1. Fetch current details to get the existing state
|
|
398
514
|
const currentDetails = await storageService.getThreadDetails({ channelId, threadId });
|
|
399
515
|
const currentState = currentDetails.state || {};
|
|
400
|
-
// 2. Perform a shallow merge (patch)
|
|
401
516
|
const newState = {
|
|
402
517
|
...currentState,
|
|
403
518
|
...patch,
|
|
404
519
|
};
|
|
405
|
-
// 3. Write back the merged state
|
|
406
520
|
await fs.mkdir(threadDir, { recursive: true });
|
|
407
521
|
await fs.writeFile(statePath, JSON.stringify(newState, null, 2));
|
|
408
522
|
}
|
|
@@ -452,7 +566,7 @@ export const storageService = {
|
|
|
452
566
|
name: details.name || id,
|
|
453
567
|
description: details.description || '',
|
|
454
568
|
image: details.image,
|
|
455
|
-
|
|
569
|
+
plugins: details.plugins,
|
|
456
570
|
createdAt: details.createdAt,
|
|
457
571
|
updatedAt: details.updatedAt,
|
|
458
572
|
};
|
|
@@ -462,18 +576,19 @@ export const storageService = {
|
|
|
462
576
|
id,
|
|
463
577
|
name: id,
|
|
464
578
|
description: '',
|
|
579
|
+
plugins: [],
|
|
465
580
|
createdAt: new Date(),
|
|
466
581
|
updatedAt: new Date(),
|
|
467
582
|
};
|
|
468
583
|
}
|
|
469
584
|
}));
|
|
470
|
-
const system = await storageService.getAgentDetails({ agentId:
|
|
585
|
+
const system = await storageService.getAgentDetails({ agentId: SYSTEM_AGENT_ID });
|
|
471
586
|
const builtInSystemAgent = {
|
|
472
587
|
id: system.id,
|
|
473
588
|
name: system.name,
|
|
474
589
|
description: system.description || '',
|
|
475
590
|
image: system.image,
|
|
476
|
-
|
|
591
|
+
plugins: system.plugins,
|
|
477
592
|
createdAt: system.createdAt,
|
|
478
593
|
updatedAt: system.updatedAt,
|
|
479
594
|
};
|
|
@@ -486,11 +601,11 @@ export const storageService = {
|
|
|
486
601
|
return Array.from(deduped.values());
|
|
487
602
|
},
|
|
488
603
|
getPlugins: async () => {
|
|
489
|
-
const [
|
|
490
|
-
|
|
604
|
+
const [builtIn, fromDisk] = await Promise.all([
|
|
605
|
+
listBuiltInPluginDescriptors(),
|
|
491
606
|
listPluginsFromDisk(),
|
|
492
607
|
]);
|
|
493
|
-
const merged = [...
|
|
608
|
+
const merged = [...builtIn, ...fromDisk];
|
|
494
609
|
const deduped = new Map();
|
|
495
610
|
for (const plugin of merged) {
|
|
496
611
|
if (!deduped.has(plugin.id)) {
|
|
@@ -508,26 +623,30 @@ export const storageService = {
|
|
|
508
623
|
const agentMd = await fs.readFile(agentMdPath, 'utf-8');
|
|
509
624
|
const { data, content: instructions } = matter(agentMd);
|
|
510
625
|
const discoveredImage = await resolveEntityImageDataUrl(agentDir);
|
|
626
|
+
const stats = await fs.stat(agentMdPath);
|
|
627
|
+
const pluginRefs = parsePluginRefs(data.plugins);
|
|
511
628
|
diskDetails = {
|
|
512
629
|
id: agentId,
|
|
513
|
-
name: data.name
|
|
630
|
+
name: typeof data.name === 'string' ? data.name : agentId,
|
|
514
631
|
instructions: instructions.trim(),
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
description: data.description
|
|
632
|
+
plugins: pluginRefs.map((ref) => ref.id),
|
|
633
|
+
pluginRefs,
|
|
634
|
+
description: typeof data.description === 'string' ? data.description : '',
|
|
518
635
|
image: discoveredImage || undefined,
|
|
519
|
-
createdAt:
|
|
520
|
-
updatedAt:
|
|
636
|
+
createdAt: stats.birthtime,
|
|
637
|
+
updatedAt: stats.mtime,
|
|
521
638
|
};
|
|
522
639
|
}
|
|
523
640
|
catch (error) {
|
|
524
|
-
if (agentId !==
|
|
641
|
+
if (agentId !== SYSTEM_AGENT_ID) {
|
|
525
642
|
const err = new Error(`Agent "${agentId}" does not exist.`);
|
|
526
643
|
err.code = 'AGENT_NOT_FOUND';
|
|
527
644
|
throw err;
|
|
528
645
|
}
|
|
646
|
+
// swallow: system agent has on-disk overrides optional
|
|
647
|
+
void error;
|
|
529
648
|
}
|
|
530
|
-
if (agentId ===
|
|
649
|
+
if (agentId === SYSTEM_AGENT_ID) {
|
|
531
650
|
return getSystemAgentDetails(diskDetails);
|
|
532
651
|
}
|
|
533
652
|
if (!diskDetails) {
|
|
@@ -537,6 +656,103 @@ export const storageService = {
|
|
|
537
656
|
}
|
|
538
657
|
return diskDetails;
|
|
539
658
|
},
|
|
659
|
+
createAgent: async ({ agentId, name, description = '', instructions, plugins, }) => {
|
|
660
|
+
assertValidDiskAgentId(agentId);
|
|
661
|
+
const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
|
|
662
|
+
const agentMdPath = path.join(agentDir, 'AGENT.md');
|
|
663
|
+
try {
|
|
664
|
+
await fs.access(agentMdPath);
|
|
665
|
+
throw new Error(`Agent "${agentId}" already exists`);
|
|
666
|
+
}
|
|
667
|
+
catch (error) {
|
|
668
|
+
const code = error?.code;
|
|
669
|
+
if (code === 'ENOENT') {
|
|
670
|
+
// proceed
|
|
671
|
+
}
|
|
672
|
+
else if (error instanceof Error && error.message.includes('already exists')) {
|
|
673
|
+
throw error;
|
|
674
|
+
}
|
|
675
|
+
else {
|
|
676
|
+
throw error;
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
await fs.mkdir(agentDir, { recursive: true });
|
|
680
|
+
const data = {
|
|
681
|
+
name,
|
|
682
|
+
description,
|
|
683
|
+
plugins: serializePluginRefs(plugins),
|
|
684
|
+
};
|
|
685
|
+
const body = matter.stringify(`${instructions.trim()}\n`, data);
|
|
686
|
+
await fs.writeFile(agentMdPath, body, 'utf-8');
|
|
687
|
+
},
|
|
688
|
+
updateAgent: async ({ agentId, name, description, instructions, plugins, }) => {
|
|
689
|
+
assertValidDiskAgentId(agentId);
|
|
690
|
+
const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
|
|
691
|
+
const agentMdPath = path.join(agentDir, 'AGENT.md');
|
|
692
|
+
let raw;
|
|
693
|
+
try {
|
|
694
|
+
raw = await fs.readFile(agentMdPath, 'utf-8');
|
|
695
|
+
}
|
|
696
|
+
catch (error) {
|
|
697
|
+
if (error?.code === 'ENOENT') {
|
|
698
|
+
const err = new Error(`Agent "${agentId}" does not exist.`);
|
|
699
|
+
err.code = 'AGENT_NOT_FOUND';
|
|
700
|
+
throw err;
|
|
701
|
+
}
|
|
702
|
+
throw error;
|
|
703
|
+
}
|
|
704
|
+
const parsed = matter(raw);
|
|
705
|
+
const nextData = { ...parsed.data };
|
|
706
|
+
if (name !== undefined)
|
|
707
|
+
nextData.name = name;
|
|
708
|
+
if (description !== undefined)
|
|
709
|
+
nextData.description = description;
|
|
710
|
+
if (plugins !== undefined)
|
|
711
|
+
nextData.plugins = serializePluginRefs(plugins);
|
|
712
|
+
const nextContent = instructions !== undefined ? instructions : parsed.content;
|
|
713
|
+
const body = matter.stringify(`${String(nextContent).trim()}\n`, nextData);
|
|
714
|
+
await fs.writeFile(agentMdPath, body, 'utf-8');
|
|
715
|
+
},
|
|
716
|
+
deleteAgent: async ({ agentId }) => {
|
|
717
|
+
assertValidDiskAgentId(agentId);
|
|
718
|
+
const agentDir = resolvePath(path.join(getAgentsRootDir(), agentId));
|
|
719
|
+
const agentMdPath = path.join(agentDir, 'AGENT.md');
|
|
720
|
+
const packageJsonPath = path.join(agentDir, 'package.json');
|
|
721
|
+
try {
|
|
722
|
+
await fs.access(agentDir);
|
|
723
|
+
}
|
|
724
|
+
catch (error) {
|
|
725
|
+
if (error?.code === 'ENOENT') {
|
|
726
|
+
const err = new Error(`Agent "${agentId}" does not exist.`);
|
|
727
|
+
err.code = 'AGENT_NOT_FOUND';
|
|
728
|
+
throw err;
|
|
729
|
+
}
|
|
730
|
+
throw error;
|
|
731
|
+
}
|
|
732
|
+
let hasPackage = false;
|
|
733
|
+
let hasAgentMd = false;
|
|
734
|
+
try {
|
|
735
|
+
await fs.access(packageJsonPath);
|
|
736
|
+
hasPackage = true;
|
|
737
|
+
}
|
|
738
|
+
catch {
|
|
739
|
+
// ignore
|
|
740
|
+
}
|
|
741
|
+
try {
|
|
742
|
+
await fs.access(agentMdPath);
|
|
743
|
+
hasAgentMd = true;
|
|
744
|
+
}
|
|
745
|
+
catch {
|
|
746
|
+
// ignore
|
|
747
|
+
}
|
|
748
|
+
if (hasPackage && !hasAgentMd) {
|
|
749
|
+
throw new Error(`Cannot delete TypeScript agent package "${agentId}" through this action; remove the folder manually.`);
|
|
750
|
+
}
|
|
751
|
+
if (!hasAgentMd) {
|
|
752
|
+
throw new Error(`Agent "${agentId}" has no AGENT.md and cannot be deleted through this action.`);
|
|
753
|
+
}
|
|
754
|
+
await fs.rm(agentDir, { recursive: true, force: true });
|
|
755
|
+
},
|
|
540
756
|
getEvents: async ({ channelId, threadId, }) => {
|
|
541
757
|
try {
|
|
542
758
|
const threadDir = getConversationDir(channelId, threadId);
|
|
@@ -554,22 +770,18 @@ export const storageService = {
|
|
|
554
770
|
}
|
|
555
771
|
return event;
|
|
556
772
|
});
|
|
557
|
-
// If we are at the channel level (no threadId), check which events have threads
|
|
558
773
|
if (!threadId) {
|
|
559
774
|
const threadsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_CHANNELS_DIR + '/' + channelId + '/threads');
|
|
560
775
|
try {
|
|
561
776
|
const threadDirs = await fs.readdir(threadsDir);
|
|
562
777
|
const threadSet = new Set(threadDirs);
|
|
563
778
|
return events.map((event) => {
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
const threadId = event.id;
|
|
567
|
-
// If an explicit threadId exists and has a directory, use it
|
|
568
|
-
if (threadId && threadSet.has(threadId)) {
|
|
779
|
+
const eventThreadId = event.id;
|
|
780
|
+
if (eventThreadId && threadSet.has(eventThreadId)) {
|
|
569
781
|
return {
|
|
570
782
|
...event,
|
|
571
783
|
meta: {
|
|
572
|
-
...event
|
|
784
|
+
...(event.meta || {}),
|
|
573
785
|
hasThread: true,
|
|
574
786
|
},
|
|
575
787
|
};
|
|
@@ -578,14 +790,13 @@ export const storageService = {
|
|
|
578
790
|
});
|
|
579
791
|
}
|
|
580
792
|
catch {
|
|
581
|
-
// No threads folder or other error, just return events as is
|
|
582
793
|
return events;
|
|
583
794
|
}
|
|
584
795
|
}
|
|
585
796
|
return events;
|
|
586
797
|
}
|
|
587
798
|
catch (error) {
|
|
588
|
-
if (error
|
|
799
|
+
if (error?.code !== 'ENOENT') {
|
|
589
800
|
console.error(`Failed to get events for channel ${channelId} thread ${threadId}`, error);
|
|
590
801
|
}
|
|
591
802
|
return [];
|
|
@@ -599,7 +810,7 @@ export const storageService = {
|
|
|
599
810
|
await fs.access(threadDir);
|
|
600
811
|
}
|
|
601
812
|
catch (error) {
|
|
602
|
-
if (error
|
|
813
|
+
if (error?.code === 'ENOENT') {
|
|
603
814
|
const threadTitle = buildThreadTitleFromEvent(event);
|
|
604
815
|
await storageService.createThread({
|
|
605
816
|
channelId,
|
|
@@ -615,7 +826,6 @@ export const storageService = {
|
|
|
615
826
|
else {
|
|
616
827
|
await fs.mkdir(threadDir, { recursive: true });
|
|
617
828
|
}
|
|
618
|
-
// Ensure the event has a unique ID
|
|
619
829
|
if (!event.id) {
|
|
620
830
|
event.id = crypto.randomUUID();
|
|
621
831
|
}
|
|
@@ -629,15 +839,70 @@ export const storageService = {
|
|
|
629
839
|
getVariables: async () => {
|
|
630
840
|
const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
|
|
631
841
|
const raw = await readJsonFile(variablesFilePath, {});
|
|
632
|
-
if (raw &&
|
|
633
|
-
|
|
842
|
+
if (raw &&
|
|
843
|
+
typeof raw === 'object' &&
|
|
844
|
+
'variables' in raw &&
|
|
845
|
+
Array.isArray(raw.variables)) {
|
|
846
|
+
const entries = (raw.variables)
|
|
634
847
|
.filter((v) => typeof v?.key === 'string')
|
|
635
848
|
.map((v) => [v.key, { value: String(v.value ?? ''), secret: !!v.secret }]);
|
|
636
849
|
return Object.fromEntries(entries);
|
|
637
850
|
}
|
|
638
|
-
// Legacy or simple format
|
|
639
851
|
return toVariablesRecord(raw);
|
|
640
852
|
},
|
|
853
|
+
createVariable: async ({ key, value, secret = false, }) => {
|
|
854
|
+
const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
|
|
855
|
+
const raw = await readJsonFile(variablesFilePath, { version: 1, variables: [] });
|
|
856
|
+
let variables = [];
|
|
857
|
+
if (raw &&
|
|
858
|
+
typeof raw === 'object' &&
|
|
859
|
+
'variables' in raw &&
|
|
860
|
+
Array.isArray(raw.variables)) {
|
|
861
|
+
variables = raw.variables;
|
|
862
|
+
}
|
|
863
|
+
else {
|
|
864
|
+
variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
|
|
865
|
+
key: k,
|
|
866
|
+
value: v,
|
|
867
|
+
secret: false,
|
|
868
|
+
}));
|
|
869
|
+
}
|
|
870
|
+
const existingIndex = variables.findIndex((v) => v.key === key);
|
|
871
|
+
if (existingIndex !== -1) {
|
|
872
|
+
variables[existingIndex] = { key, value, secret };
|
|
873
|
+
}
|
|
874
|
+
else {
|
|
875
|
+
variables.push({ key, value, secret });
|
|
876
|
+
}
|
|
877
|
+
await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
|
|
878
|
+
await fs.writeFile(variablesFilePath, JSON.stringify({ version: 1, variables }, null, 2), 'utf-8');
|
|
879
|
+
processService.syncWorkspaceVariablesToProcessEnv();
|
|
880
|
+
},
|
|
881
|
+
deleteVariable: async ({ key }) => {
|
|
882
|
+
const variablesFilePath = resolvePath(resolveBaseDir() + '/' + VARIABLES_FILE);
|
|
883
|
+
const raw = await readJsonFile(variablesFilePath, { version: 1, variables: [] });
|
|
884
|
+
let variables = [];
|
|
885
|
+
if (raw &&
|
|
886
|
+
typeof raw === 'object' &&
|
|
887
|
+
'variables' in raw &&
|
|
888
|
+
Array.isArray(raw.variables)) {
|
|
889
|
+
variables = raw.variables;
|
|
890
|
+
}
|
|
891
|
+
else {
|
|
892
|
+
variables = Object.entries(toVariablesRecord(raw)).map(([k, v]) => ({
|
|
893
|
+
key: k,
|
|
894
|
+
value: v,
|
|
895
|
+
secret: false,
|
|
896
|
+
}));
|
|
897
|
+
}
|
|
898
|
+
const newVariables = variables.filter((v) => v.key !== key);
|
|
899
|
+
if (newVariables.length === variables.length) {
|
|
900
|
+
return;
|
|
901
|
+
}
|
|
902
|
+
await fs.mkdir(path.dirname(variablesFilePath), { recursive: true });
|
|
903
|
+
await fs.writeFile(variablesFilePath, JSON.stringify({ version: 1, variables: newVariables }, null, 2), 'utf-8');
|
|
904
|
+
processService.syncWorkspaceVariablesToProcessEnv();
|
|
905
|
+
},
|
|
641
906
|
listFiles: async ({ channelId, path: subPath = '', }) => {
|
|
642
907
|
const details = await storageService.getChannelDetails({ channelId });
|
|
643
908
|
const baseCwd = details.cwd;
|
|
@@ -646,19 +911,18 @@ export const storageService = {
|
|
|
646
911
|
}
|
|
647
912
|
const resolvedBase = path.resolve(baseCwd);
|
|
648
913
|
const targetDir = path.resolve(resolvedBase, subPath);
|
|
649
|
-
// Security check: ensure target is within baseCwd
|
|
650
914
|
if (!targetDir.startsWith(resolvedBase)) {
|
|
651
915
|
throw new Error('Access denied: directory escape');
|
|
652
916
|
}
|
|
653
917
|
const entries = await fs.readdir(targetDir, { withFileTypes: true });
|
|
654
918
|
return entries
|
|
655
|
-
.filter((e) => !e.name.startsWith('.'))
|
|
919
|
+
.filter((e) => !e.name.startsWith('.'))
|
|
656
920
|
.map((e) => ({
|
|
657
921
|
name: e.name,
|
|
658
922
|
isDirectory: e.isDirectory(),
|
|
659
923
|
}));
|
|
660
924
|
},
|
|
661
|
-
readFile: async ({ channelId, path: filePath }) => {
|
|
925
|
+
readFile: async ({ channelId, path: filePath, }) => {
|
|
662
926
|
const details = await storageService.getChannelDetails({ channelId });
|
|
663
927
|
const baseCwd = details.cwd;
|
|
664
928
|
if (!baseCwd) {
|
|
@@ -666,7 +930,6 @@ export const storageService = {
|
|
|
666
930
|
}
|
|
667
931
|
const resolvedBase = path.resolve(baseCwd);
|
|
668
932
|
const targetFile = path.resolve(resolvedBase, filePath);
|
|
669
|
-
// Security check: ensure target is within baseCwd
|
|
670
933
|
if (!targetFile.startsWith(resolvedBase)) {
|
|
671
934
|
throw new Error('Access denied: directory escape');
|
|
672
935
|
}
|
|
@@ -686,7 +949,7 @@ export const storageService = {
|
|
|
686
949
|
throw error;
|
|
687
950
|
}
|
|
688
951
|
let channelDetails;
|
|
689
|
-
if (channelId
|
|
952
|
+
if (channelId) {
|
|
690
953
|
try {
|
|
691
954
|
channelDetails = await storageService.getChannelDetails({ channelId });
|
|
692
955
|
}
|
|
@@ -713,9 +976,12 @@ export const storageService = {
|
|
|
713
976
|
id: agentDetails.id,
|
|
714
977
|
name: agentDetails.name,
|
|
715
978
|
description: agentDetails.description || '',
|
|
979
|
+
image: agentDetails.image,
|
|
716
980
|
instructions: agentDetails.instructions || '',
|
|
717
|
-
runtime: agentDetails.runtime,
|
|
718
981
|
plugins: agentDetails.plugins,
|
|
982
|
+
pluginRefs: agentDetails.pluginRefs,
|
|
983
|
+
createdAt: agentDetails.createdAt,
|
|
984
|
+
updatedAt: agentDetails.updatedAt,
|
|
719
985
|
},
|
|
720
986
|
channelDetails: channelDetails,
|
|
721
987
|
threadDetails: threadDetails,
|