openbot 0.2.13 → 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 +1 -1
- 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 +3 -2
- 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
|
@@ -0,0 +1,227 @@
|
|
|
1
|
+
import { MelonyPlugin } from 'melony';
|
|
2
|
+
import z from 'zod';
|
|
3
|
+
import type { Plugin } from '../../bus/plugin.js';
|
|
4
|
+
import {
|
|
5
|
+
OpenBotEvent,
|
|
6
|
+
OpenBotState,
|
|
7
|
+
RenderUIWidgetData,
|
|
8
|
+
UIWidgetField,
|
|
9
|
+
UIWidgetListItem,
|
|
10
|
+
UIWidgetSpec,
|
|
11
|
+
} from '../../app/types.js';
|
|
12
|
+
|
|
13
|
+
const actionSchema = z.object({
|
|
14
|
+
id: z.string().describe('Stable action ID returned by client:ui:widget:response.'),
|
|
15
|
+
label: z.string().describe('Human-readable button label.'),
|
|
16
|
+
value: z.unknown().optional().describe('Optional machine-readable value for this action.'),
|
|
17
|
+
variant: z.enum(['primary', 'secondary', 'danger']).optional(),
|
|
18
|
+
disabled: z.boolean().optional(),
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
const optionSchema = z.object({
|
|
22
|
+
label: z.string(),
|
|
23
|
+
value: z.string(),
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
const fieldSchema = z.object({
|
|
27
|
+
id: z.string().describe('Stable field ID used as the submitted value key.'),
|
|
28
|
+
label: z.string(),
|
|
29
|
+
type: z.enum(['text', 'textarea', 'number', 'boolean', 'select', 'multiselect', 'date']),
|
|
30
|
+
description: z.string().optional(),
|
|
31
|
+
placeholder: z.string().optional(),
|
|
32
|
+
required: z.boolean().optional(),
|
|
33
|
+
options: z.array(optionSchema).optional(),
|
|
34
|
+
defaultValue: z.unknown().optional(),
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
const listItemSchema = z.object({
|
|
38
|
+
id: z.string(),
|
|
39
|
+
label: z.string(),
|
|
40
|
+
description: z.string().optional(),
|
|
41
|
+
status: z.enum(['pending', 'in_progress', 'done', 'error', 'cancelled']).optional(),
|
|
42
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
const widgetBaseSchema = {
|
|
46
|
+
widgetId: z.string().optional().describe('Stable widget ID. Defaults from toolCallId.'),
|
|
47
|
+
title: z.string().optional(),
|
|
48
|
+
description: z.string().optional(),
|
|
49
|
+
body: z.string().optional(),
|
|
50
|
+
state: z.enum(['open', 'submitted', 'cancelled', 'error']).optional(),
|
|
51
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
const renderWidgetSchema = z.union([
|
|
55
|
+
z.object({
|
|
56
|
+
...widgetBaseSchema,
|
|
57
|
+
kind: z.literal('message'),
|
|
58
|
+
actions: z.array(actionSchema).optional(),
|
|
59
|
+
}),
|
|
60
|
+
z.object({
|
|
61
|
+
...widgetBaseSchema,
|
|
62
|
+
kind: z.literal('choice'),
|
|
63
|
+
actions: z.array(actionSchema).min(1),
|
|
64
|
+
}),
|
|
65
|
+
z.object({
|
|
66
|
+
...widgetBaseSchema,
|
|
67
|
+
kind: z.literal('form'),
|
|
68
|
+
fields: z.array(fieldSchema).optional(),
|
|
69
|
+
submitLabel: z.string().optional(),
|
|
70
|
+
actions: z.array(actionSchema).optional(),
|
|
71
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
72
|
+
}),
|
|
73
|
+
z.object({
|
|
74
|
+
...widgetBaseSchema,
|
|
75
|
+
kind: z.literal('list'),
|
|
76
|
+
items: z.array(listItemSchema).optional(),
|
|
77
|
+
actions: z.array(actionSchema).optional(),
|
|
78
|
+
}),
|
|
79
|
+
z.object({
|
|
80
|
+
kind: z.enum(['approval', 'todo_list']).describe('Legacy preset. Prefer choice or list.'),
|
|
81
|
+
widgetId: z.string().optional(),
|
|
82
|
+
title: z.string().optional(),
|
|
83
|
+
props: z.record(z.string(), z.unknown()).optional(),
|
|
84
|
+
metadata: z.record(z.string(), z.unknown()).optional(),
|
|
85
|
+
}),
|
|
86
|
+
]);
|
|
87
|
+
|
|
88
|
+
const isRecord = (value: unknown): value is Record<string, unknown> =>
|
|
89
|
+
!!value && typeof value === 'object' && !Array.isArray(value);
|
|
90
|
+
|
|
91
|
+
const readString = (value: unknown): string | undefined =>
|
|
92
|
+
typeof value === 'string' && value.trim() ? value : undefined;
|
|
93
|
+
|
|
94
|
+
const asFields = (value: unknown): UIWidgetField[] | undefined =>
|
|
95
|
+
Array.isArray(value) ? (value as UIWidgetField[]) : undefined;
|
|
96
|
+
|
|
97
|
+
const asListItems = (value: unknown): UIWidgetListItem[] | undefined =>
|
|
98
|
+
Array.isArray(value) ? (value as UIWidgetListItem[]) : undefined;
|
|
99
|
+
|
|
100
|
+
const todoToListItem = (todo: unknown, index: number): UIWidgetListItem => {
|
|
101
|
+
if (!isRecord(todo)) {
|
|
102
|
+
return { id: `todo_${index + 1}`, label: String(todo) };
|
|
103
|
+
}
|
|
104
|
+
return {
|
|
105
|
+
id: readString(todo.id) || `todo_${index + 1}`,
|
|
106
|
+
label:
|
|
107
|
+
readString(todo.label) ||
|
|
108
|
+
readString(todo.task) ||
|
|
109
|
+
readString(todo.title) ||
|
|
110
|
+
`Todo ${index + 1}`,
|
|
111
|
+
description: readString(todo.description),
|
|
112
|
+
status: readString(todo.status) as UIWidgetListItem['status'],
|
|
113
|
+
metadata: todo,
|
|
114
|
+
};
|
|
115
|
+
};
|
|
116
|
+
|
|
117
|
+
const createWidgetId = (data: RenderUIWidgetData, toolCallId?: string): string => {
|
|
118
|
+
if ('widgetId' in data && data.widgetId) return data.widgetId;
|
|
119
|
+
if (toolCallId) return `widget_${toolCallId}`;
|
|
120
|
+
return `widget_${Date.now()}`;
|
|
121
|
+
};
|
|
122
|
+
|
|
123
|
+
const normalizeWidget = (
|
|
124
|
+
data: RenderUIWidgetData,
|
|
125
|
+
state: OpenBotState,
|
|
126
|
+
toolCallId?: string,
|
|
127
|
+
): UIWidgetSpec => {
|
|
128
|
+
const widgetId = createWidgetId(data, toolCallId);
|
|
129
|
+
|
|
130
|
+
if (data.kind === 'approval') {
|
|
131
|
+
const props = data.props || {};
|
|
132
|
+
return {
|
|
133
|
+
widgetId,
|
|
134
|
+
kind: 'choice',
|
|
135
|
+
title: data.title || 'Approval Required',
|
|
136
|
+
body:
|
|
137
|
+
readString(props.message) ||
|
|
138
|
+
readString(props.summary) ||
|
|
139
|
+
'Please approve or deny this action.',
|
|
140
|
+
metadata: {
|
|
141
|
+
...(data.metadata || {}),
|
|
142
|
+
legacyKind: 'approval',
|
|
143
|
+
actionId: props.actionId,
|
|
144
|
+
},
|
|
145
|
+
actions: [
|
|
146
|
+
{ id: 'approve', label: 'Approve', value: props.actionId || 'approve', variant: 'primary' },
|
|
147
|
+
{ id: 'deny', label: 'Deny', value: props.actionId || 'deny', variant: 'danger' },
|
|
148
|
+
],
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (data.kind === 'todo_list') {
|
|
153
|
+
const props = data.props || {};
|
|
154
|
+
const stateTodos = isRecord(state.threadDetails?.state)
|
|
155
|
+
? (state.threadDetails.state as Record<string, unknown>).todos
|
|
156
|
+
: undefined;
|
|
157
|
+
const todos = asListItems(props.todos) || asListItems(stateTodos) || [];
|
|
158
|
+
return {
|
|
159
|
+
widgetId,
|
|
160
|
+
kind: 'list',
|
|
161
|
+
title: data.title || readString(props.title) || 'Task List',
|
|
162
|
+
description: readString(props.description),
|
|
163
|
+
metadata: { ...(data.metadata || {}), legacyKind: 'todo_list' },
|
|
164
|
+
items: todos.map(todoToListItem),
|
|
165
|
+
};
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
if (data.kind === 'form') {
|
|
169
|
+
const propsSource = (data as unknown as { props?: unknown }).props;
|
|
170
|
+
const props = isRecord(propsSource) ? propsSource : {};
|
|
171
|
+
return {
|
|
172
|
+
widgetId,
|
|
173
|
+
kind: 'form',
|
|
174
|
+
title: data.title || 'Details Required',
|
|
175
|
+
description: data.description,
|
|
176
|
+
body: data.body,
|
|
177
|
+
state: data.state,
|
|
178
|
+
metadata: data.metadata,
|
|
179
|
+
fields: data.fields || asFields(props.schema) || [],
|
|
180
|
+
submitLabel: data.submitLabel || readString(props.submitLabel),
|
|
181
|
+
actions: data.actions,
|
|
182
|
+
};
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
if (data.kind === 'list') {
|
|
186
|
+
return { ...data, widgetId, title: data.title || 'Task List', items: data.items || [] };
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (data.kind === 'choice') {
|
|
190
|
+
return { ...data, widgetId, title: data.title || 'Choose an Option' };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
if (data.kind === 'message') {
|
|
194
|
+
return { ...data, widgetId, title: data.title || 'Message' };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
throw new Error(`Unsupported UI widget kind: ${(data as { kind?: string }).kind || 'unknown'}`);
|
|
198
|
+
};
|
|
199
|
+
|
|
200
|
+
const uiToolDefinitions = {
|
|
201
|
+
render_ui_widget: {
|
|
202
|
+
description:
|
|
203
|
+
'Render a small server-driven UI widget in the conversation. Prefer primitive kinds: message, choice, form, or list. Legacy presets approval and todo_list are accepted.',
|
|
204
|
+
inputSchema: renderWidgetSchema,
|
|
205
|
+
},
|
|
206
|
+
};
|
|
207
|
+
|
|
208
|
+
const uiPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> => (builder) => {
|
|
209
|
+
builder.on('action:render_ui_widget', async function* (event, context) {
|
|
210
|
+
const widget = normalizeWidget(event.data, context.state, event.meta?.toolCallId);
|
|
211
|
+
yield {
|
|
212
|
+
type: 'client:ui:widget',
|
|
213
|
+
data: widget,
|
|
214
|
+
meta: event.meta,
|
|
215
|
+
};
|
|
216
|
+
});
|
|
217
|
+
};
|
|
218
|
+
|
|
219
|
+
export const uiPlugin: Plugin = {
|
|
220
|
+
id: 'ui',
|
|
221
|
+
name: 'UI Widgets',
|
|
222
|
+
description: 'Render server-driven UI widgets (messages, choices, forms, lists) in the conversation.',
|
|
223
|
+
toolDefinitions: uiToolDefinitions,
|
|
224
|
+
factory: () => uiPluginRuntime(),
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
export default uiPlugin;
|
package/src/registry/plugins.ts
CHANGED
|
@@ -1,85 +1,136 @@
|
|
|
1
1
|
import fs from 'node:fs';
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
import { pathToFileURL } from 'node:url';
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
import {
|
|
9
|
-
import {
|
|
10
|
-
import {
|
|
11
|
-
import {
|
|
12
|
-
import {
|
|
13
|
-
import { orchestratorService } from '../harness/orchestrator.js';
|
|
4
|
+
import type { Plugin } from '../bus/plugin.js';
|
|
5
|
+
import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
|
|
6
|
+
import { shellPlugin } from '../plugins/shell/index.js';
|
|
7
|
+
import { mcpPlugin } from '../plugins/mcp/index.js';
|
|
8
|
+
import { delegationPlugin } from '../plugins/delegation/index.js';
|
|
9
|
+
import { storageToolsPlugin } from '../plugins/storage-tools/index.js';
|
|
10
|
+
import { uiPlugin } from '../plugins/ui/index.js';
|
|
11
|
+
import { approvalPlugin } from '../plugins/approval/index.js';
|
|
12
|
+
import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
14
13
|
|
|
15
14
|
let pluginsDir: string | null = null;
|
|
16
15
|
const loadedPlugins = new Set<string>();
|
|
16
|
+
const cache = new Map<string, Plugin>();
|
|
17
|
+
|
|
18
|
+
const BUILT_IN: Record<string, Plugin> = {
|
|
19
|
+
[aiSdkPlugin.id]: aiSdkPlugin,
|
|
20
|
+
[shellPlugin.id]: shellPlugin,
|
|
21
|
+
[mcpPlugin.id]: mcpPlugin,
|
|
22
|
+
[delegationPlugin.id]: delegationPlugin,
|
|
23
|
+
[storageToolsPlugin.id]: storageToolsPlugin,
|
|
24
|
+
[uiPlugin.id]: uiPlugin,
|
|
25
|
+
[approvalPlugin.id]: approvalPlugin,
|
|
26
|
+
};
|
|
17
27
|
|
|
18
28
|
/**
|
|
19
|
-
*
|
|
29
|
+
* Parsed shape of a community plugin module. The `id` is intentionally omitted:
|
|
30
|
+
* the canonical id is the npm package name (== folder under `plugins/`),
|
|
31
|
+
* assigned by the caller.
|
|
20
32
|
*/
|
|
33
|
+
export type ParsedPluginModule = Omit<Plugin, 'id'>;
|
|
34
|
+
|
|
35
|
+
/** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
|
|
36
|
+
export function parsePluginModule(
|
|
37
|
+
module: Record<string, unknown>,
|
|
38
|
+
): ParsedPluginModule | null {
|
|
39
|
+
const raw =
|
|
40
|
+
(module.plugin as Record<string, unknown> | undefined) ??
|
|
41
|
+
(module.default as Record<string, unknown> | undefined);
|
|
42
|
+
|
|
43
|
+
if (!raw || typeof raw !== 'object') return null;
|
|
44
|
+
|
|
45
|
+
const factory = raw.factory;
|
|
46
|
+
if (typeof factory !== 'function') return null;
|
|
47
|
+
|
|
48
|
+
const name = typeof raw.name === 'string' ? raw.name : '';
|
|
49
|
+
const description = typeof raw.description === 'string' ? raw.description : '';
|
|
50
|
+
const image = typeof raw.image === 'string' ? raw.image : undefined;
|
|
51
|
+
const defaultInstructions =
|
|
52
|
+
typeof raw.defaultInstructions === 'string' ? raw.defaultInstructions : undefined;
|
|
53
|
+
const configSchema = raw.configSchema as Plugin['configSchema'];
|
|
54
|
+
const toolDefinitions = raw.toolDefinitions as Plugin['toolDefinitions'];
|
|
55
|
+
|
|
56
|
+
return {
|
|
57
|
+
name,
|
|
58
|
+
description,
|
|
59
|
+
image,
|
|
60
|
+
defaultInstructions,
|
|
61
|
+
configSchema,
|
|
62
|
+
toolDefinitions,
|
|
63
|
+
factory: factory as Plugin['factory'],
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** Initialize the on-disk plugins directory (defaults to ~/.openbot/plugins). */
|
|
21
68
|
export function initPlugins(dir?: string) {
|
|
22
69
|
if (dir) {
|
|
23
70
|
pluginsDir = dir;
|
|
24
71
|
} else {
|
|
25
72
|
const config = loadConfig();
|
|
26
73
|
const baseDir = config.baseDir || DEFAULT_BASE_DIR;
|
|
27
|
-
pluginsDir = path.join(resolvePath(baseDir),
|
|
74
|
+
pluginsDir = path.join(resolvePath(baseDir), DEFAULT_PLUGINS_DIR);
|
|
28
75
|
}
|
|
29
76
|
}
|
|
30
77
|
|
|
31
78
|
/**
|
|
32
|
-
*
|
|
79
|
+
* Resolve a Plugin by id. The id is either:
|
|
80
|
+
* - a built-in id (e.g. "ai-sdk", "shell"), or
|
|
81
|
+
* - an npm package name (e.g. "openbot-plugin-foo" or "@scope/foo"),
|
|
82
|
+
* in which case the folder layout is `plugins/<id>/dist/index.js`.
|
|
33
83
|
*/
|
|
34
|
-
export async function resolvePlugin(
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
switch (pluginName) {
|
|
40
|
-
case 'storage':
|
|
41
|
-
return storagePlugin({ storage: storageService, ...config });
|
|
42
|
-
case 'ai-sdk':
|
|
43
|
-
return aiSdkPlugin({
|
|
44
|
-
storage: storageService,
|
|
45
|
-
...config,
|
|
46
|
-
});
|
|
47
|
-
case 'delegation':
|
|
48
|
-
return delegationPlugin();
|
|
49
|
-
case 'mcp':
|
|
50
|
-
return mcpPlugin();
|
|
51
|
-
case 'ui':
|
|
52
|
-
return uiPlugin();
|
|
84
|
+
export async function resolvePlugin(id: string): Promise<Plugin | null> {
|
|
85
|
+
if (cache.has(id)) return cache.get(id)!;
|
|
86
|
+
if (BUILT_IN[id]) {
|
|
87
|
+
cache.set(id, BUILT_IN[id]);
|
|
88
|
+
return BUILT_IN[id];
|
|
53
89
|
}
|
|
54
90
|
|
|
55
|
-
// 2. Search for external plugins in the initialized plugins directory
|
|
56
91
|
if (!pluginsDir) {
|
|
57
92
|
initPlugins();
|
|
58
93
|
}
|
|
94
|
+
if (!pluginsDir) return null;
|
|
95
|
+
|
|
96
|
+
const distPath = path.join(pluginsDir, id, 'dist', 'index.js');
|
|
59
97
|
|
|
60
|
-
if (
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
if (!loadedPlugins.has(pluginName)) {
|
|
72
|
-
console.log(`[plugins] Loaded community plugin "${pluginName}" from ${distPath}`);
|
|
73
|
-
loadedPlugins.add(pluginName);
|
|
74
|
-
}
|
|
75
|
-
return factory(config);
|
|
76
|
-
}
|
|
77
|
-
} catch (e) {
|
|
78
|
-
console.warn(`[plugins] Failed to load plugin "${pluginName}" from ${distPath}:`, e);
|
|
79
|
-
}
|
|
98
|
+
if (!fs.existsSync(distPath)) {
|
|
99
|
+
console.warn(`[plugins] Plugin "${id}" not found at ${distPath}.`);
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
try {
|
|
104
|
+
const module = await import(pathToFileURL(distPath).href);
|
|
105
|
+
const parsed = parsePluginModule(module as Record<string, unknown>);
|
|
106
|
+
if (!parsed) {
|
|
107
|
+
console.warn(`[plugins] Plugin "${id}" at ${distPath} has no recognizable export.`);
|
|
108
|
+
return null;
|
|
80
109
|
}
|
|
110
|
+
const plugin: Plugin = { id, ...parsed, name: parsed.name || id };
|
|
111
|
+
cache.set(id, plugin);
|
|
112
|
+
if (!loadedPlugins.has(id)) {
|
|
113
|
+
console.log(`[plugins] Loaded community plugin "${id}" from ${distPath}`);
|
|
114
|
+
loadedPlugins.add(id);
|
|
115
|
+
}
|
|
116
|
+
return plugin;
|
|
117
|
+
} catch (e) {
|
|
118
|
+
console.warn(`[plugins] Failed to load plugin "${id}" from ${distPath}:`, e);
|
|
119
|
+
return null;
|
|
81
120
|
}
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Drop a single id from the in-memory cache (e.g. after fresh install). */
|
|
124
|
+
export function invalidatePlugin(id: string): void {
|
|
125
|
+
cache.delete(id);
|
|
126
|
+
loadedPlugins.delete(id);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** List built-in plugins (for marketplace/registry views). */
|
|
130
|
+
export function listBuiltInPlugins(): Plugin[] {
|
|
131
|
+
return Object.values(BUILT_IN);
|
|
132
|
+
}
|
|
82
133
|
|
|
83
|
-
|
|
84
|
-
return
|
|
134
|
+
export function getPluginsDir(): string | null {
|
|
135
|
+
return pluginsDir;
|
|
85
136
|
}
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import { existsSync } from 'node:fs';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
import { exec } from 'node:child_process';
|
|
5
|
+
import { promisify } from 'node:util';
|
|
6
|
+
import {
|
|
7
|
+
DEFAULT_PLUGINS_DIR,
|
|
8
|
+
DEFAULT_BASE_DIR,
|
|
9
|
+
loadConfig,
|
|
10
|
+
resolvePath,
|
|
11
|
+
} from '../app/config.js';
|
|
12
|
+
import { invalidatePlugin } from '../registry/plugins.js';
|
|
13
|
+
|
|
14
|
+
const execAsync = promisify(exec);
|
|
15
|
+
|
|
16
|
+
export interface InstallOptions {
|
|
17
|
+
packageName: string;
|
|
18
|
+
version?: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export interface InstalledPlugin {
|
|
22
|
+
/** npm package name; doubles as the plugin id used everywhere else. */
|
|
23
|
+
name: string;
|
|
24
|
+
version: string;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const getPluginsDir = (): string => {
|
|
28
|
+
const config = loadConfig();
|
|
29
|
+
const baseDir = resolvePath(config.baseDir || DEFAULT_BASE_DIR);
|
|
30
|
+
return path.join(baseDir, DEFAULT_PLUGINS_DIR);
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* Lifecycle for community-built plugins distributed via npm.
|
|
35
|
+
* Each plugin is installed to `<plugins>/<npm-name>/` and is identified
|
|
36
|
+
* everywhere (AGENT.md `plugins[].id`, registry, runtime resolution) by its
|
|
37
|
+
* npm name. Scoped packages (`@scope/foo`) live under `<plugins>/@scope/foo/`.
|
|
38
|
+
*/
|
|
39
|
+
export const pluginService = {
|
|
40
|
+
isInstalled: async (packageName: string): Promise<boolean> => {
|
|
41
|
+
const finalPath = path.join(getPluginsDir(), packageName);
|
|
42
|
+
return existsSync(path.join(finalPath, 'dist', 'index.js'));
|
|
43
|
+
},
|
|
44
|
+
|
|
45
|
+
install: async ({ packageName, version }: InstallOptions): Promise<InstalledPlugin> => {
|
|
46
|
+
const pluginsDir = getPluginsDir();
|
|
47
|
+
await fs.mkdir(pluginsDir, { recursive: true });
|
|
48
|
+
|
|
49
|
+
const finalPath = path.join(pluginsDir, packageName);
|
|
50
|
+
|
|
51
|
+
if (existsSync(path.join(finalPath, 'package.json'))) {
|
|
52
|
+
try {
|
|
53
|
+
const pkgJson = JSON.parse(
|
|
54
|
+
await fs.readFile(path.join(finalPath, 'package.json'), 'utf-8'),
|
|
55
|
+
);
|
|
56
|
+
if (!version || pkgJson.version === version) {
|
|
57
|
+
console.log(
|
|
58
|
+
`[plugins] ${packageName}${version ? `@${version}` : ''} is already installed.`,
|
|
59
|
+
);
|
|
60
|
+
return { name: pkgJson.name, version: pkgJson.version };
|
|
61
|
+
}
|
|
62
|
+
} catch {
|
|
63
|
+
// corrupted; reinstall below
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const target = version ? `${packageName}@${version}` : packageName;
|
|
68
|
+
console.log(`[plugins] Installing ${target} to ${pluginsDir}...`);
|
|
69
|
+
|
|
70
|
+
const tempDir = path.join(pluginsDir, '.tmp_' + Date.now());
|
|
71
|
+
try {
|
|
72
|
+
await fs.mkdir(tempDir, { recursive: true });
|
|
73
|
+
await execAsync(`npm install ${target} --no-save --prefix "${tempDir}"`);
|
|
74
|
+
|
|
75
|
+
const installedPath = path.join(tempDir, 'node_modules', packageName);
|
|
76
|
+
if (!existsSync(installedPath)) {
|
|
77
|
+
throw new Error(`npm did not produce ${installedPath}`);
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
await fs.mkdir(path.dirname(finalPath), { recursive: true });
|
|
81
|
+
await fs.rm(finalPath, { recursive: true, force: true });
|
|
82
|
+
await fs.rename(installedPath, finalPath);
|
|
83
|
+
|
|
84
|
+
console.log(`[plugins] Running npm install in ${finalPath}...`);
|
|
85
|
+
try {
|
|
86
|
+
await execAsync(`npm install`, { cwd: finalPath });
|
|
87
|
+
console.log(`[plugins] npm install completed in ${finalPath}`);
|
|
88
|
+
} catch (e) {
|
|
89
|
+
console.warn(`[plugins] Failed to run npm install in ${finalPath}:`, e);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
const pkgJson = JSON.parse(
|
|
93
|
+
await fs.readFile(path.join(finalPath, 'package.json'), 'utf-8'),
|
|
94
|
+
);
|
|
95
|
+
|
|
96
|
+
invalidatePlugin(packageName);
|
|
97
|
+
return { name: pkgJson.name, version: pkgJson.version };
|
|
98
|
+
} catch (error) {
|
|
99
|
+
console.error(`[plugins] Failed to install ${packageName}:`, error);
|
|
100
|
+
throw new Error(
|
|
101
|
+
`Failed to install plugin ${packageName}: ${(error as Error).message}`,
|
|
102
|
+
);
|
|
103
|
+
} finally {
|
|
104
|
+
await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {});
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
|
|
108
|
+
uninstall: async (packageName: string): Promise<void> => {
|
|
109
|
+
const pluginsDir = getPluginsDir();
|
|
110
|
+
const pluginPath = path.join(pluginsDir, packageName);
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
await fs.rm(pluginPath, { recursive: true, force: true });
|
|
114
|
+
invalidatePlugin(packageName);
|
|
115
|
+
console.log(`[plugins] Uninstalled plugin ${packageName}`);
|
|
116
|
+
|
|
117
|
+
if (packageName.startsWith('@')) {
|
|
118
|
+
const scopeDir = path.dirname(pluginPath);
|
|
119
|
+
try {
|
|
120
|
+
const remaining = await fs.readdir(scopeDir);
|
|
121
|
+
if (remaining.length === 0) await fs.rmdir(scopeDir);
|
|
122
|
+
} catch {
|
|
123
|
+
// ignore
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
} catch (error) {
|
|
127
|
+
console.error(`[plugins] Failed to uninstall ${packageName}:`, error);
|
|
128
|
+
throw new Error(
|
|
129
|
+
`Failed to uninstall plugin ${packageName}: ${(error as Error).message}`,
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
},
|
|
133
|
+
};
|