@meetopenbot/openbot 0.1.1
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/cloud-mode.d.ts +5 -0
- package/dist/cloud-mode.js +4 -0
- package/dist/context.d.ts +11 -0
- package/dist/context.js +120 -0
- package/dist/history.d.ts +9 -0
- package/dist/history.js +145 -0
- package/dist/index.d.ts +80 -0
- package/dist/index.js +77 -0
- package/dist/model.d.ts +2 -0
- package/dist/model.js +20 -0
- package/dist/runtime.d.ts +25 -0
- package/dist/runtime.js +401 -0
- package/dist/system-prompt.d.ts +1 -0
- package/dist/system-prompt.js +57 -0
- package/dist/tools/approval.d.ts +3 -0
- package/dist/tools/approval.js +130 -0
- package/dist/tools/bash.d.ts +3 -0
- package/dist/tools/bash.js +425 -0
- package/dist/tools/delegation.d.ts +3 -0
- package/dist/tools/delegation.js +130 -0
- package/dist/tools/memory.d.ts +3 -0
- package/dist/tools/memory.js +163 -0
- package/dist/tools/preview.d.ts +4 -0
- package/dist/tools/preview.js +269 -0
- package/dist/tools/storage.d.ts +57 -0
- package/dist/tools/storage.js +335 -0
- package/dist/tools/todo-service.d.ts +28 -0
- package/dist/tools/todo-service.js +93 -0
- package/dist/tools/todo.d.ts +3 -0
- package/dist/tools/todo.js +146 -0
- package/dist/tools/ui.d.ts +9 -0
- package/dist/tools/ui.js +120 -0
- package/dist/types.d.ts +80 -0
- package/dist/types.js +12 -0
- package/dist/utils/paths.d.ts +3 -0
- package/dist/utils/paths.js +12 -0
- package/dist/utils/workspace-url.d.ts +5 -0
- package/dist/utils/workspace-url.js +6 -0
- package/package.json +32 -0
|
@@ -0,0 +1,335 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { asActionBuilder } from '../types.js';
|
|
3
|
+
import { buildWorkspaceFileUrl } from '../utils/workspace-url.js';
|
|
4
|
+
const storageToolDefinitions = {
|
|
5
|
+
create_channel: {
|
|
6
|
+
description: 'Create a new channel. Use when the user intent is clearly different from the current channel and should be split. Always confirm before creating. Skip for simple Q&A.',
|
|
7
|
+
inputSchema: z.object({
|
|
8
|
+
channelId: z
|
|
9
|
+
.string()
|
|
10
|
+
.describe('Unique channel ID (e.g. product-launch, backend-platform, channel_roadmap).'),
|
|
11
|
+
spec: z
|
|
12
|
+
.string()
|
|
13
|
+
.optional()
|
|
14
|
+
.describe('Optional initial markdown content for the channel spec.'),
|
|
15
|
+
initialState: z
|
|
16
|
+
.record(z.string(), z.unknown())
|
|
17
|
+
.optional()
|
|
18
|
+
.describe('Optional initial state object for the channel.'),
|
|
19
|
+
cwd: z
|
|
20
|
+
.string()
|
|
21
|
+
.optional()
|
|
22
|
+
.describe('Optional initial current working directory for the channel. Defaults to an absolute path under ~/openbot/{channelId}.'),
|
|
23
|
+
}),
|
|
24
|
+
},
|
|
25
|
+
patch_channel_details: {
|
|
26
|
+
description: 'Patch current channel details (state, spec, cwd).',
|
|
27
|
+
inputSchema: z
|
|
28
|
+
.object({
|
|
29
|
+
state: z
|
|
30
|
+
.record(z.string(), z.unknown())
|
|
31
|
+
.optional()
|
|
32
|
+
.describe('JSON state object for the channel.'),
|
|
33
|
+
spec: z
|
|
34
|
+
.string()
|
|
35
|
+
.optional()
|
|
36
|
+
.describe('Markdown content for the channel specification (SPEC.md). Use for goals and rules.'),
|
|
37
|
+
cwd: z.string().optional().describe('Current working directory for the channel.'),
|
|
38
|
+
})
|
|
39
|
+
.refine((value) => value.state !== undefined ||
|
|
40
|
+
value.spec !== undefined ||
|
|
41
|
+
value.cwd !== undefined, { message: 'Provide at least one of state, spec, or cwd.' }),
|
|
42
|
+
},
|
|
43
|
+
patch_thread_details: {
|
|
44
|
+
description: 'Patch current thread details (state). Use for thread metadata such as `name` or `isSmartNamed`. For multi-step task tracking, use `todo_write` instead.',
|
|
45
|
+
inputSchema: z.object({
|
|
46
|
+
state: z
|
|
47
|
+
.record(z.string(), z.unknown())
|
|
48
|
+
.describe('JSON state object for the thread. Merges with existing state.'),
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
create_variable: {
|
|
52
|
+
description: 'Create or update a variable in the workspace storage.',
|
|
53
|
+
inputSchema: z.object({
|
|
54
|
+
key: z.string().describe('The key of the variable.'),
|
|
55
|
+
value: z.string().describe('The value of the variable.'),
|
|
56
|
+
secret: z.boolean().optional().describe('Whether the variable is a secret.'),
|
|
57
|
+
}),
|
|
58
|
+
},
|
|
59
|
+
delete_variable: {
|
|
60
|
+
description: 'Delete a variable from the workspace storage.',
|
|
61
|
+
inputSchema: z.object({
|
|
62
|
+
key: z.string().describe('The key of the variable to delete.'),
|
|
63
|
+
}),
|
|
64
|
+
},
|
|
65
|
+
delete_channel: {
|
|
66
|
+
description: 'Permanently delete a channel and all its threads and events. Always confirm with the user before deleting.',
|
|
67
|
+
inputSchema: z.object({
|
|
68
|
+
channelId: z.string().describe('The channel ID to delete.'),
|
|
69
|
+
}),
|
|
70
|
+
},
|
|
71
|
+
get_workspace_file_url: {
|
|
72
|
+
description: 'Get a fetchable HTTP URL for a file in the current channel workspace (images, video, audio, documents).',
|
|
73
|
+
inputSchema: z.object({
|
|
74
|
+
path: z
|
|
75
|
+
.string()
|
|
76
|
+
.describe('Path relative to the channel working directory, e.g. "uploads/clip.mp4".'),
|
|
77
|
+
}),
|
|
78
|
+
},
|
|
79
|
+
};
|
|
80
|
+
export function registerStorageTools(context) {
|
|
81
|
+
const storage = context.storage;
|
|
82
|
+
const { publicBaseUrl } = context;
|
|
83
|
+
const resolvePublicBaseUrl = () => publicBaseUrl;
|
|
84
|
+
return (builder) => {
|
|
85
|
+
const actions = asActionBuilder(builder);
|
|
86
|
+
actions.on('create_channel', async function* (event, context) {
|
|
87
|
+
const { channelId, spec, initialState, cwd } = event.data;
|
|
88
|
+
const rawChannelId = (channelId || '').trim();
|
|
89
|
+
const channelSpec = typeof spec === 'string' ? spec : '';
|
|
90
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
91
|
+
if (!rawChannelId) {
|
|
92
|
+
yield {
|
|
93
|
+
type: 'action:create_channel:result',
|
|
94
|
+
data: { success: false, channelId: '', channelUrl: '' },
|
|
95
|
+
meta: resultMeta,
|
|
96
|
+
};
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const channelUrl = `/channels/${rawChannelId}`;
|
|
100
|
+
const mergedInitial = { ...(initialState || {}) };
|
|
101
|
+
try {
|
|
102
|
+
await storage.createChannel({
|
|
103
|
+
channelId: rawChannelId,
|
|
104
|
+
spec: channelSpec,
|
|
105
|
+
initialState: mergedInitial,
|
|
106
|
+
cwd,
|
|
107
|
+
});
|
|
108
|
+
yield {
|
|
109
|
+
type: 'action:create_channel:result',
|
|
110
|
+
data: { success: true, channelId: rawChannelId, channelUrl },
|
|
111
|
+
meta: resultMeta,
|
|
112
|
+
};
|
|
113
|
+
yield {
|
|
114
|
+
type: 'agent:output',
|
|
115
|
+
data: { content: `Created channel \`${rawChannelId}\`.` },
|
|
116
|
+
meta: resultMeta,
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
yield {
|
|
121
|
+
type: 'action:create_channel:result',
|
|
122
|
+
data: { success: false, channelId: rawChannelId, channelUrl },
|
|
123
|
+
meta: resultMeta,
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
});
|
|
127
|
+
actions.on('delete_channel', async function* (event, context) {
|
|
128
|
+
const rawChannelId = (event.data?.channelId || '').trim();
|
|
129
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
130
|
+
if (!rawChannelId) {
|
|
131
|
+
yield {
|
|
132
|
+
type: 'action:delete_channel:result',
|
|
133
|
+
data: { success: false, channelId: '', error: 'channelId is required' },
|
|
134
|
+
meta: resultMeta,
|
|
135
|
+
};
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
try {
|
|
139
|
+
await storage.deleteChannel({ channelId: rawChannelId });
|
|
140
|
+
yield {
|
|
141
|
+
type: 'action:delete_channel:result',
|
|
142
|
+
data: { success: true, channelId: rawChannelId },
|
|
143
|
+
meta: resultMeta,
|
|
144
|
+
};
|
|
145
|
+
yield {
|
|
146
|
+
type: 'agent:output',
|
|
147
|
+
data: { content: `Deleted channel \`${rawChannelId}\`.` },
|
|
148
|
+
meta: resultMeta,
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
catch (error) {
|
|
152
|
+
yield {
|
|
153
|
+
type: 'action:delete_channel:result',
|
|
154
|
+
data: {
|
|
155
|
+
success: false,
|
|
156
|
+
channelId: rawChannelId,
|
|
157
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
158
|
+
},
|
|
159
|
+
meta: resultMeta,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
162
|
+
});
|
|
163
|
+
actions.on('update_channel', async function* (event, context) {
|
|
164
|
+
const data = event.data;
|
|
165
|
+
const targetChannelId = (data.channelId || context.state.channelId || '').trim();
|
|
166
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
167
|
+
if (!targetChannelId) {
|
|
168
|
+
yield {
|
|
169
|
+
type: 'action:update_channel:result',
|
|
170
|
+
data: { success: false, channelId: '', updatedFields: [] },
|
|
171
|
+
meta: resultMeta,
|
|
172
|
+
};
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
const patch = {};
|
|
176
|
+
const updatedFields = [];
|
|
177
|
+
if (typeof data.name === 'string' && data.name.trim()) {
|
|
178
|
+
patch.name = data.name.trim();
|
|
179
|
+
updatedFields.push('name');
|
|
180
|
+
}
|
|
181
|
+
if (typeof data.cwd === 'string' && data.cwd.trim()) {
|
|
182
|
+
patch.cwd = data.cwd.trim();
|
|
183
|
+
updatedFields.push('cwd');
|
|
184
|
+
}
|
|
185
|
+
try {
|
|
186
|
+
if (updatedFields.length > 0) {
|
|
187
|
+
await storage.patchChannelState({ channelId: targetChannelId, state: patch });
|
|
188
|
+
}
|
|
189
|
+
if (targetChannelId === context.state.channelId) {
|
|
190
|
+
context.state.channelDetails = await storage.getChannelDetails({
|
|
191
|
+
channelId: context.state.channelId,
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
yield {
|
|
195
|
+
type: 'action:update_channel:result',
|
|
196
|
+
data: { success: true, channelId: targetChannelId, updatedFields },
|
|
197
|
+
meta: resultMeta,
|
|
198
|
+
};
|
|
199
|
+
}
|
|
200
|
+
catch {
|
|
201
|
+
yield {
|
|
202
|
+
type: 'action:update_channel:result',
|
|
203
|
+
data: { success: false, channelId: targetChannelId, updatedFields },
|
|
204
|
+
meta: resultMeta,
|
|
205
|
+
};
|
|
206
|
+
}
|
|
207
|
+
});
|
|
208
|
+
actions.on('patch_channel_details', async function* (event, context) {
|
|
209
|
+
const updatedFields = [];
|
|
210
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
211
|
+
const data = event.data;
|
|
212
|
+
try {
|
|
213
|
+
if (data.state !== undefined) {
|
|
214
|
+
await storage.patchChannelState({
|
|
215
|
+
channelId: context.state.channelId,
|
|
216
|
+
state: data.state,
|
|
217
|
+
});
|
|
218
|
+
updatedFields.push('state');
|
|
219
|
+
}
|
|
220
|
+
if (typeof data.spec === 'string') {
|
|
221
|
+
await storage.patchChannelSpec({
|
|
222
|
+
channelId: context.state.channelId,
|
|
223
|
+
spec: data.spec,
|
|
224
|
+
});
|
|
225
|
+
updatedFields.push('spec');
|
|
226
|
+
}
|
|
227
|
+
if (typeof data.cwd === 'string') {
|
|
228
|
+
await storage.patchChannelState({
|
|
229
|
+
channelId: context.state.channelId,
|
|
230
|
+
state: { cwd: data.cwd },
|
|
231
|
+
});
|
|
232
|
+
updatedFields.push('cwd');
|
|
233
|
+
}
|
|
234
|
+
context.state.channelDetails = await storage.getChannelDetails({
|
|
235
|
+
channelId: context.state.channelId,
|
|
236
|
+
});
|
|
237
|
+
yield {
|
|
238
|
+
type: "client:ui:widget",
|
|
239
|
+
data: {
|
|
240
|
+
widgetId: "patch-channel-details-result" + Date.now(),
|
|
241
|
+
kind: "message",
|
|
242
|
+
title: "Channel details updated.",
|
|
243
|
+
body: `The channel details have been updated. ${updatedFields.join(', ')}`,
|
|
244
|
+
display: "collapsed",
|
|
245
|
+
},
|
|
246
|
+
meta: resultMeta,
|
|
247
|
+
};
|
|
248
|
+
yield {
|
|
249
|
+
type: 'action:patch_channel_details:result',
|
|
250
|
+
data: { success: true, updatedFields },
|
|
251
|
+
meta: resultMeta,
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
catch {
|
|
255
|
+
yield {
|
|
256
|
+
type: 'action:patch_channel_details:result',
|
|
257
|
+
data: { success: false, updatedFields },
|
|
258
|
+
meta: resultMeta,
|
|
259
|
+
};
|
|
260
|
+
}
|
|
261
|
+
});
|
|
262
|
+
actions.on('patch_thread_details', async function* (event, context) {
|
|
263
|
+
const updatedFields = [];
|
|
264
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
265
|
+
try {
|
|
266
|
+
if (!context.state.threadId) {
|
|
267
|
+
throw new Error('Missing threadId in state for patch_thread_details');
|
|
268
|
+
}
|
|
269
|
+
if (event.data?.state !== undefined) {
|
|
270
|
+
await storage.patchThreadState({
|
|
271
|
+
channelId: context.state.channelId,
|
|
272
|
+
threadId: context.state.threadId,
|
|
273
|
+
state: event.data.state,
|
|
274
|
+
});
|
|
275
|
+
updatedFields.push('state');
|
|
276
|
+
}
|
|
277
|
+
context.state.threadDetails = await storage.getThreadDetails({
|
|
278
|
+
channelId: context.state.channelId,
|
|
279
|
+
threadId: context.state.threadId,
|
|
280
|
+
});
|
|
281
|
+
yield {
|
|
282
|
+
type: 'action:patch_thread_details:result',
|
|
283
|
+
data: { success: true, updatedFields },
|
|
284
|
+
meta: resultMeta,
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
catch {
|
|
288
|
+
yield {
|
|
289
|
+
type: 'action:patch_thread_details:result',
|
|
290
|
+
data: { success: false, updatedFields },
|
|
291
|
+
meta: resultMeta,
|
|
292
|
+
};
|
|
293
|
+
}
|
|
294
|
+
});
|
|
295
|
+
actions.on('get_workspace_file_url', async function* (event, context) {
|
|
296
|
+
const channelId = context.state.channelId;
|
|
297
|
+
const filePath = event.data?.path;
|
|
298
|
+
const toolCallId = event.meta?.toolCallId;
|
|
299
|
+
if (!filePath) {
|
|
300
|
+
yield {
|
|
301
|
+
type: 'action:get_workspace_file_url:result',
|
|
302
|
+
data: { success: false, path: '', error: 'Path is required', output: 'Path is required' },
|
|
303
|
+
meta: { ...(event.meta || {}), toolCallId },
|
|
304
|
+
};
|
|
305
|
+
return;
|
|
306
|
+
}
|
|
307
|
+
try {
|
|
308
|
+
const { size, mimeType } = await storage.getChannelFileStat({ channelId, path: filePath });
|
|
309
|
+
const url = buildWorkspaceFileUrl({
|
|
310
|
+
baseUrl: resolvePublicBaseUrl(),
|
|
311
|
+
channelId,
|
|
312
|
+
filePath,
|
|
313
|
+
});
|
|
314
|
+
const output = JSON.stringify({ path: filePath, url, mimeType, size });
|
|
315
|
+
yield {
|
|
316
|
+
type: 'action:get_workspace_file_url:result',
|
|
317
|
+
data: { success: true, path: filePath, url, mimeType, size, output },
|
|
318
|
+
meta: { ...(event.meta || {}), toolCallId },
|
|
319
|
+
};
|
|
320
|
+
}
|
|
321
|
+
catch (error) {
|
|
322
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
323
|
+
yield {
|
|
324
|
+
type: 'action:get_workspace_file_url:result',
|
|
325
|
+
data: { success: false, path: filePath, error: message, output: message },
|
|
326
|
+
meta: { ...(event.meta || {}), toolCallId },
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
});
|
|
330
|
+
};
|
|
331
|
+
}
|
|
332
|
+
export const storageToolPlugin = {
|
|
333
|
+
toolDefinitions: storageToolDefinitions,
|
|
334
|
+
register: registerStorageTools,
|
|
335
|
+
};
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
export type TodoStatus = 'pending' | 'in_progress' | 'completed' | 'cancelled';
|
|
2
|
+
export type TodoItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
content: string;
|
|
5
|
+
status: TodoStatus;
|
|
6
|
+
};
|
|
7
|
+
export type TodoList = {
|
|
8
|
+
items: TodoItem[];
|
|
9
|
+
updatedAt: string;
|
|
10
|
+
};
|
|
11
|
+
export declare const MAX_TODO_ITEMS = 20;
|
|
12
|
+
/**
|
|
13
|
+
* Validate and normalize a todo list write. Throws on invalid input.
|
|
14
|
+
* Enforces: non-empty content, known statuses, unique ids, max size,
|
|
15
|
+
* and at most one `in_progress` item.
|
|
16
|
+
*/
|
|
17
|
+
export declare function validateTodoItems(items: TodoItem[]): TodoItem[];
|
|
18
|
+
export declare const todoService: {
|
|
19
|
+
getTodos: (args: {
|
|
20
|
+
channelId: string;
|
|
21
|
+
threadId: string;
|
|
22
|
+
}) => Promise<TodoList>;
|
|
23
|
+
writeTodos: (args: {
|
|
24
|
+
channelId: string;
|
|
25
|
+
threadId: string;
|
|
26
|
+
items: TodoItem[];
|
|
27
|
+
}) => Promise<TodoList>;
|
|
28
|
+
};
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import fs from 'node:fs/promises';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
import { DEFAULT_CHANNELS_DIR, getBaseDir, resolvePath } from '../utils/paths.js';
|
|
4
|
+
export const MAX_TODO_ITEMS = 20;
|
|
5
|
+
const TODO_STATUSES = new Set([
|
|
6
|
+
'pending',
|
|
7
|
+
'in_progress',
|
|
8
|
+
'completed',
|
|
9
|
+
'cancelled',
|
|
10
|
+
]);
|
|
11
|
+
const getThreadDir = (channelId, threadId) => {
|
|
12
|
+
const base = resolvePath(path.join(getBaseDir(), DEFAULT_CHANNELS_DIR, channelId));
|
|
13
|
+
return path.join(base, 'threads', threadId);
|
|
14
|
+
};
|
|
15
|
+
const getTodosPath = (channelId, threadId) => path.join(getThreadDir(channelId, threadId), 'todos.json');
|
|
16
|
+
const emptyList = () => ({
|
|
17
|
+
items: [],
|
|
18
|
+
updatedAt: new Date().toISOString(),
|
|
19
|
+
});
|
|
20
|
+
/**
|
|
21
|
+
* Validate and normalize a todo list write. Throws on invalid input.
|
|
22
|
+
* Enforces: non-empty content, known statuses, unique ids, max size,
|
|
23
|
+
* and at most one `in_progress` item.
|
|
24
|
+
*/
|
|
25
|
+
export function validateTodoItems(items) {
|
|
26
|
+
if (!Array.isArray(items)) {
|
|
27
|
+
throw new Error('todos must be an array');
|
|
28
|
+
}
|
|
29
|
+
if (items.length > MAX_TODO_ITEMS) {
|
|
30
|
+
throw new Error(`At most ${MAX_TODO_ITEMS} todos allowed`);
|
|
31
|
+
}
|
|
32
|
+
const seen = new Set();
|
|
33
|
+
let inProgressCount = 0;
|
|
34
|
+
const normalized = [];
|
|
35
|
+
for (const raw of items) {
|
|
36
|
+
if (!raw || typeof raw !== 'object') {
|
|
37
|
+
throw new Error('Each todo must be an object');
|
|
38
|
+
}
|
|
39
|
+
const id = typeof raw.id === 'string' ? raw.id.trim() : '';
|
|
40
|
+
const content = typeof raw.content === 'string' ? raw.content.trim() : '';
|
|
41
|
+
const status = raw.status;
|
|
42
|
+
if (!id)
|
|
43
|
+
throw new Error('Each todo requires a non-empty id');
|
|
44
|
+
if (!content)
|
|
45
|
+
throw new Error(`Todo "${id}" requires non-empty content`);
|
|
46
|
+
if (!TODO_STATUSES.has(status)) {
|
|
47
|
+
throw new Error(`Todo "${id}" has invalid status "${String(status)}"; expected pending|in_progress|completed|cancelled`);
|
|
48
|
+
}
|
|
49
|
+
if (seen.has(id))
|
|
50
|
+
throw new Error(`Duplicate todo id "${id}"`);
|
|
51
|
+
seen.add(id);
|
|
52
|
+
if (status === 'in_progress')
|
|
53
|
+
inProgressCount += 1;
|
|
54
|
+
normalized.push({ id, content, status: status });
|
|
55
|
+
}
|
|
56
|
+
if (inProgressCount > 1) {
|
|
57
|
+
throw new Error('At most one todo may be in_progress');
|
|
58
|
+
}
|
|
59
|
+
return normalized;
|
|
60
|
+
}
|
|
61
|
+
export const todoService = {
|
|
62
|
+
getTodos: async (args) => {
|
|
63
|
+
const filePath = getTodosPath(args.channelId, args.threadId);
|
|
64
|
+
try {
|
|
65
|
+
const raw = await fs.readFile(filePath, 'utf-8');
|
|
66
|
+
const parsed = JSON.parse(raw);
|
|
67
|
+
const items = Array.isArray(parsed.items) ? parsed.items : [];
|
|
68
|
+
return {
|
|
69
|
+
items,
|
|
70
|
+
updatedAt: typeof parsed.updatedAt === 'string' ? parsed.updatedAt : new Date().toISOString(),
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
catch (e) {
|
|
74
|
+
if (e?.code === 'ENOENT')
|
|
75
|
+
return emptyList();
|
|
76
|
+
throw e;
|
|
77
|
+
}
|
|
78
|
+
},
|
|
79
|
+
writeTodos: async (args) => {
|
|
80
|
+
const items = validateTodoItems(args.items);
|
|
81
|
+
const list = {
|
|
82
|
+
items,
|
|
83
|
+
updatedAt: new Date().toISOString(),
|
|
84
|
+
};
|
|
85
|
+
const threadDir = getThreadDir(args.channelId, args.threadId);
|
|
86
|
+
await fs.mkdir(threadDir, { recursive: true });
|
|
87
|
+
const filePath = getTodosPath(args.channelId, args.threadId);
|
|
88
|
+
const tmp = `${filePath}.tmp`;
|
|
89
|
+
await fs.writeFile(tmp, `${JSON.stringify(list, null, 2)}\n`, 'utf-8');
|
|
90
|
+
await fs.rename(tmp, filePath);
|
|
91
|
+
return list;
|
|
92
|
+
},
|
|
93
|
+
};
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import z from 'zod';
|
|
2
|
+
import { todoService } from './todo-service.js';
|
|
3
|
+
/**
|
|
4
|
+
* `todo` — thread-scoped task checklist for multi-step agent work.
|
|
5
|
+
*
|
|
6
|
+
* Persisted at `~/.openbot/channels/<channelId>/threads/<threadId>/todos.json`.
|
|
7
|
+
* The agent replaces the full list via `todo_write`; the runtime injects the
|
|
8
|
+
* current list into context each turn.
|
|
9
|
+
*/
|
|
10
|
+
/** Map todo statuses onto UI list-item status label + variant. */
|
|
11
|
+
function toWidgetItemStatus(status) {
|
|
12
|
+
switch (status) {
|
|
13
|
+
case 'pending':
|
|
14
|
+
return { status: 'Pending', statusVariant: 'default' };
|
|
15
|
+
case 'in_progress':
|
|
16
|
+
return { status: 'In progress', statusVariant: 'info' };
|
|
17
|
+
case 'completed':
|
|
18
|
+
return { status: 'Done', statusVariant: 'success' };
|
|
19
|
+
case 'cancelled':
|
|
20
|
+
return { status: 'Cancelled', statusVariant: 'danger' };
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
function formatTodoOutput(list) {
|
|
24
|
+
if (list.items.length === 0)
|
|
25
|
+
return 'Todo list is empty.';
|
|
26
|
+
const lines = list.items.map((t) => `- [${t.status}] (${t.id}) ${t.content}`);
|
|
27
|
+
return `Todos (${list.items.length}):\n${lines.join('\n')}`;
|
|
28
|
+
}
|
|
29
|
+
function todoListWidget(args) {
|
|
30
|
+
const items = args.list.items.map((t) => ({
|
|
31
|
+
id: t.id,
|
|
32
|
+
label: t.content,
|
|
33
|
+
...toWidgetItemStatus(t.status),
|
|
34
|
+
}));
|
|
35
|
+
return {
|
|
36
|
+
type: 'client:ui:widget',
|
|
37
|
+
data: {
|
|
38
|
+
// One widget per agent run — repeated todo_write calls update in place.
|
|
39
|
+
widgetId: `todos:${args.runId}`,
|
|
40
|
+
kind: 'list',
|
|
41
|
+
title: 'Todos',
|
|
42
|
+
description: args.list.items.length === 0
|
|
43
|
+
? 'No todos'
|
|
44
|
+
: `${args.list.items.filter((t) => t.status === 'completed').length}/${args.list.items.length} completed`,
|
|
45
|
+
items,
|
|
46
|
+
display: 'expanded',
|
|
47
|
+
state: 'open',
|
|
48
|
+
},
|
|
49
|
+
meta: {},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
52
|
+
const todoItemSchema = z.object({
|
|
53
|
+
id: z.string().min(1).describe('Stable todo id (e.g. "1", "setup-repo").'),
|
|
54
|
+
content: z.string().min(1).describe('Short imperative description of the step.'),
|
|
55
|
+
status: z
|
|
56
|
+
.enum(['pending', 'in_progress', 'completed', 'cancelled'])
|
|
57
|
+
.describe('Todo status. At most one item may be in_progress.'),
|
|
58
|
+
});
|
|
59
|
+
const todoToolDefinitions = {
|
|
60
|
+
todo_write: {
|
|
61
|
+
description: 'Replace the current thread todo list with the provided items. Use for multi-step tasks: write the plan first, keep exactly one item in_progress while working, mark items completed immediately after finishing, and when done leave all items as completed (do not clear the list). Pass the full intended list each call (not a partial patch).',
|
|
62
|
+
inputSchema: z.object({
|
|
63
|
+
items: z
|
|
64
|
+
.array(todoItemSchema)
|
|
65
|
+
.max(20)
|
|
66
|
+
.describe('Full todo list for this thread (max 20). Replaces any previous list.'),
|
|
67
|
+
}),
|
|
68
|
+
},
|
|
69
|
+
todo_read: {
|
|
70
|
+
description: 'Read the current thread todo list. Usually unnecessary — the list is already injected into context each turn. Use only if you need an explicit refresh after an external change.',
|
|
71
|
+
inputSchema: z.object({}),
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
export const todoPlugin = {
|
|
75
|
+
id: 'todo',
|
|
76
|
+
name: 'Todo',
|
|
77
|
+
description: 'Thread-scoped todo checklist for multi-step task tracking (todo_write / todo_read).',
|
|
78
|
+
toolDefinitions: todoToolDefinitions,
|
|
79
|
+
factory: () => (builder) => {
|
|
80
|
+
builder.on('action:todo_write', async function* (event, context) {
|
|
81
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
82
|
+
try {
|
|
83
|
+
const channelId = context.state.channelId;
|
|
84
|
+
const threadId = context.state.threadId;
|
|
85
|
+
if (!channelId || !threadId) {
|
|
86
|
+
throw new Error('Missing channelId or threadId for todo_write');
|
|
87
|
+
}
|
|
88
|
+
const items = event.data.items;
|
|
89
|
+
const list = await todoService.writeTodos({ channelId, threadId, items });
|
|
90
|
+
const runId = context.state.runId;
|
|
91
|
+
if (!runId) {
|
|
92
|
+
throw new Error('Missing runId for todo_write widget');
|
|
93
|
+
}
|
|
94
|
+
const widget = todoListWidget({ runId, list });
|
|
95
|
+
widget.meta = { ...resultMeta, threadId, runId };
|
|
96
|
+
yield widget;
|
|
97
|
+
yield {
|
|
98
|
+
type: 'action:todo_write:result',
|
|
99
|
+
data: { success: true, list, output: formatTodoOutput(list) },
|
|
100
|
+
meta: resultMeta,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
catch (error) {
|
|
104
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
105
|
+
yield {
|
|
106
|
+
type: 'action:todo_write:result',
|
|
107
|
+
data: {
|
|
108
|
+
success: false,
|
|
109
|
+
error: message,
|
|
110
|
+
output: message,
|
|
111
|
+
},
|
|
112
|
+
meta: resultMeta,
|
|
113
|
+
};
|
|
114
|
+
}
|
|
115
|
+
});
|
|
116
|
+
builder.on('action:todo_read', async function* (event, context) {
|
|
117
|
+
const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
|
|
118
|
+
try {
|
|
119
|
+
const channelId = context.state.channelId;
|
|
120
|
+
const threadId = context.state.threadId;
|
|
121
|
+
if (!channelId || !threadId) {
|
|
122
|
+
throw new Error('Missing channelId or threadId for todo_read');
|
|
123
|
+
}
|
|
124
|
+
const list = await todoService.getTodos({ channelId, threadId });
|
|
125
|
+
yield {
|
|
126
|
+
type: 'action:todo_read:result',
|
|
127
|
+
data: { success: true, list, output: formatTodoOutput(list) },
|
|
128
|
+
meta: resultMeta,
|
|
129
|
+
};
|
|
130
|
+
}
|
|
131
|
+
catch (error) {
|
|
132
|
+
const message = error instanceof Error ? error.message : 'Unknown error';
|
|
133
|
+
yield {
|
|
134
|
+
type: 'action:todo_read:result',
|
|
135
|
+
data: {
|
|
136
|
+
success: false,
|
|
137
|
+
error: message,
|
|
138
|
+
output: message,
|
|
139
|
+
},
|
|
140
|
+
meta: resultMeta,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
});
|
|
144
|
+
},
|
|
145
|
+
};
|
|
146
|
+
export default todoPlugin;
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { Plugin } from '../types.js';
|
|
2
|
+
/**
|
|
3
|
+
* `ui` — provides a tool for the agent to render interactive UI widgets.
|
|
4
|
+
*
|
|
5
|
+
* The model can choose which widget to render (form, choice, list, message)
|
|
6
|
+
* depending on the situation.
|
|
7
|
+
*/
|
|
8
|
+
export declare const uiPlugin: Plugin;
|
|
9
|
+
export default uiPlugin;
|