openbot 0.3.0 → 0.3.2

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 (43) hide show
  1. package/dist/app/cli.js +1 -1
  2. package/dist/app/server.js +1 -4
  3. package/dist/bus/services.js +222 -15
  4. package/dist/harness/context.js +205 -26
  5. package/dist/harness/queue-processor.js +44 -110
  6. package/dist/harness/runtime-factory.js +11 -7
  7. package/dist/harness/todo-advance.js +93 -0
  8. package/dist/plugins/ai-sdk/index.js +0 -3
  9. package/dist/plugins/ai-sdk/runtime.js +78 -13
  10. package/dist/plugins/ai-sdk/system-prompt.js +18 -3
  11. package/dist/plugins/delegation/index.js +7 -46
  12. package/dist/plugins/memory/index.js +71 -0
  13. package/dist/plugins/storage-tools/index.js +2 -11
  14. package/dist/plugins/todo/index.js +54 -0
  15. package/dist/plugins/workflow/index.js +65 -0
  16. package/dist/registry/plugins.js +4 -2
  17. package/dist/services/memory.js +152 -0
  18. package/dist/services/storage.js +9 -31
  19. package/dist/workflow/service.js +106 -0
  20. package/dist/workflow/types.js +3 -0
  21. package/docs/agents.md +15 -1
  22. package/docs/plugins.md +0 -1
  23. package/package.json +1 -1
  24. package/src/app/cli.ts +1 -1
  25. package/src/app/server.ts +3 -4
  26. package/src/app/types.ts +140 -45
  27. package/src/bus/plugin.ts +0 -2
  28. package/src/bus/services.ts +258 -17
  29. package/src/bus/types.ts +13 -4
  30. package/src/harness/context.ts +233 -37
  31. package/src/harness/queue-processor.ts +54 -143
  32. package/src/harness/runtime-factory.ts +11 -7
  33. package/src/harness/todo-advance.ts +128 -0
  34. package/src/plugins/ai-sdk/index.ts +0 -3
  35. package/src/plugins/ai-sdk/runtime.ts +356 -298
  36. package/src/plugins/ai-sdk/system-prompt.ts +18 -4
  37. package/src/plugins/delegation/index.ts +7 -50
  38. package/src/plugins/memory/index.ts +85 -0
  39. package/src/plugins/storage-tools/index.ts +8 -19
  40. package/src/plugins/todo/index.ts +64 -0
  41. package/src/registry/plugins.ts +4 -3
  42. package/src/services/memory.ts +213 -0
  43. package/src/services/storage.ts +9 -49
@@ -1,4 +1,18 @@
1
- export const AI_SDK_SYSTEM_PROMPT =
2
- 'You are a helpful AI assistant on the OpenBot platform. ' +
3
- 'Use the tools available to you to help the user. ' +
4
- 'Be concise unless the user asks for depth.';
1
+ export const AI_SDK_SYSTEM_PROMPT = [
2
+ 'You are a helpful AI assistant on the OpenBot platform.',
3
+ 'Use the tools available to you to help the user.',
4
+ 'Be concise unless the user asks for depth.',
5
+ '',
6
+ '## Planning with todos',
7
+ 'The current thread has a shared todo list (visible under "Shared todo plan" in context).',
8
+ 'It is the single source of truth for multi-step work and is shared across every agent in the thread.',
9
+ '',
10
+ 'When planning:',
11
+ '- For any task that needs more than one step, call `todo_write` ONCE with the full ordered plan, then stop. Do not call any other tool in the same turn.',
12
+ '- The platform dispatches assignees automatically and completes their todo when their run ends. You do NOT need to call `handoff` to start the plan or `todo_update` to finish items.',
13
+ '- Each item must be concrete and atomic (one verb, one outcome). Skip the list entirely for trivial single-step requests.',
14
+ '',
15
+ 'When you are an assignee (you have an `in_progress` todo addressed to you):',
16
+ '- Just do the work and reply. The platform will mark your todo done and dispatch the next one.',
17
+ '- If you genuinely cannot complete it, call `todo_update(id, status: "cancelled")` with a brief reason in your reply.',
18
+ ].join('\n');
@@ -3,26 +3,18 @@ import z from 'zod';
3
3
  import type { Plugin } from '../../bus/plugin.js';
4
4
  import { OpenBotEvent, OpenBotState } from '../../app/types.js';
5
5
 
6
- const delegationToolDefinitions = {
6
+ const handoffToolDefinitions = {
7
7
  handoff: {
8
8
  description:
9
- 'Transfer control to another agent. The target agent continues the task and you do not wait for a tool result.',
9
+ 'Transfer control to another agent. The target agent continues the task in this thread.',
10
10
  inputSchema: z.object({
11
11
  agentId: z.string().describe('The ID of the target agent.'),
12
12
  content: z.string().describe('The message or task to hand off.'),
13
13
  }),
14
14
  },
15
- delegate: {
16
- description:
17
- 'Delegate a subtask to another agent and wait for its result so you can continue.',
18
- inputSchema: z.object({
19
- agentId: z.string().describe('The ID of the target agent.'),
20
- content: z.string().describe('The subtask you want the target agent to execute.'),
21
- }),
22
- },
23
15
  };
24
16
 
25
- const delegationPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> => (builder) => {
17
+ const handoffPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> => (builder) => {
26
18
  builder.on('action:handoff', async function* (event, context) {
27
19
  const { agentId, content } = event.data;
28
20
 
@@ -46,49 +38,14 @@ const delegationPluginRuntime = (): MelonyPlugin<OpenBotState, OpenBotEvent> =>
46
38
  };
47
39
  }
48
40
  });
49
-
50
- builder.on('action:delegate', async function* (event, context) {
51
- const { agentId, content } = event.data;
52
- const widgetId = event.meta?.toolCallId
53
- ? `delegate_${event.meta.toolCallId}`
54
- : `delegate_${Date.now()}`;
55
-
56
- yield {
57
- type: 'client:ui:widget',
58
- data: {
59
- kind: 'message',
60
- widgetId,
61
- title: `Delegation started: ${agentId}`,
62
- body: `Running delegated task in background.\n\n${content}`,
63
- state: 'open',
64
- metadata: {
65
- type: 'delegation:status',
66
- phase: 'started',
67
- delegatedAgentId: agentId,
68
- },
69
- },
70
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
71
- };
72
-
73
- yield {
74
- type: 'delegation:request',
75
- data: { agentId, content },
76
- meta: {
77
- ...(event.meta || {}),
78
- parentAgentId: context.state.agentId,
79
- delegationWidgetId: widgetId,
80
- agentId: context.state.agentId,
81
- },
82
- };
83
- });
84
41
  };
85
42
 
86
43
  export const delegationPlugin: Plugin = {
87
44
  id: 'delegation',
88
- name: 'Delegation',
89
- description: 'Hand off or delegate sub-tasks to other agents on the bus.',
90
- toolDefinitions: delegationToolDefinitions,
91
- factory: () => delegationPluginRuntime(),
45
+ name: 'Handoff',
46
+ description: 'Hand off tasks to other agents on the bus.',
47
+ toolDefinitions: handoffToolDefinitions,
48
+ factory: () => handoffPluginRuntime(),
92
49
  };
93
50
 
94
51
  export default delegationPlugin;
@@ -0,0 +1,85 @@
1
+ import z from 'zod';
2
+ import type { Plugin } from '../../bus/plugin.js';
3
+
4
+ /**
5
+ * `memory` — exposes the global memory store as agent tools.
6
+ *
7
+ * The actual handlers live in `bus/services.ts` because memory is platform
8
+ * infrastructure (shared across every agent on the bus); this plugin only
9
+ * contributes the tool definitions so a runtime plugin (e.g. `ai-sdk`) can
10
+ * surface them to the LLM.
11
+ *
12
+ * Scopes
13
+ * ------
14
+ * - `global` (default) — visible to every agent and channel.
15
+ * - `agent` — visible only to the agent that wrote it.
16
+ * - `channel` — visible only inside the active channel.
17
+ */
18
+ const memoryToolDefinitions = {
19
+ remember: {
20
+ description:
21
+ 'Persist a durable fact, preference, or note to long-term memory so it can be recalled in future turns and runs. Use for stable information (user preferences, project conventions, contact details, decisions); avoid using it for transient chatter or per-step scratch state — that belongs in thread state. Keep entries short and self-contained.',
22
+ inputSchema: z.object({
23
+ content: z
24
+ .string()
25
+ .min(1)
26
+ .describe(
27
+ 'The fact to remember, written so it makes sense out of context (e.g. "User prefers TypeScript over JavaScript.").',
28
+ ),
29
+ scope: z
30
+ .enum(['global', 'agent', 'channel'])
31
+ .optional()
32
+ .describe(
33
+ 'Visibility: `global` (default, all agents everywhere), `agent` (only this agent), `channel` (only this channel).',
34
+ ),
35
+ tags: z
36
+ .array(z.string())
37
+ .optional()
38
+ .describe('Optional tags for filtering with `recall`.'),
39
+ }),
40
+ },
41
+ recall: {
42
+ description:
43
+ 'Search long-term memory for facts you previously stored with `remember`. Returns up to `limit` matching records with their ids so you can `forget` stale ones.',
44
+ inputSchema: z.object({
45
+ query: z
46
+ .string()
47
+ .optional()
48
+ .describe('Case-insensitive substring filter against memory content.'),
49
+ tag: z.string().optional().describe('Only return memories that include this tag.'),
50
+ scope: z
51
+ .enum(['global', 'agent', 'channel', 'all'])
52
+ .optional()
53
+ .describe(
54
+ 'Restrict the search to a single scope. Default `all` returns global + this agent + this channel.',
55
+ ),
56
+ limit: z
57
+ .number()
58
+ .int()
59
+ .positive()
60
+ .max(50)
61
+ .optional()
62
+ .describe('Maximum records to return (default 20, max 50).'),
63
+ }),
64
+ },
65
+ forget: {
66
+ description:
67
+ 'Delete a memory by id. Use after the user asks to forget something or when a previously remembered fact is now wrong. Get ids from `recall`.',
68
+ inputSchema: z.object({
69
+ id: z.string().describe('The memory record id (returned by `recall`/`remember`).'),
70
+ }),
71
+ },
72
+ };
73
+
74
+ export const memoryPlugin: Plugin = {
75
+ id: 'memory',
76
+ name: 'Memory',
77
+ description:
78
+ 'Global long-term memory: remember/recall/forget facts across runs and agents.',
79
+ toolDefinitions: memoryToolDefinitions,
80
+ factory: () => () => {
81
+ // Handlers live in bus/services.ts; this plugin only contributes tool definitions.
82
+ },
83
+ };
84
+
85
+ export default memoryPlugin;
@@ -52,25 +52,14 @@ const storageToolDefinitions = {
52
52
  ),
53
53
  },
54
54
  patch_thread_details: {
55
- description: 'Patch current thread details (state and/or spec).',
56
- inputSchema: z
57
- .object({
58
- state: z
59
- .record(z.string(), z.unknown())
60
- .optional()
61
- .describe(
62
- 'JSON state object for the thread. Use for structured data like `todos` or progress.',
63
- ),
64
- spec: z
65
- .string()
66
- .optional()
67
- .describe(
68
- 'Markdown content for the thread specification (SPEC.md). Use for plans and goals.',
69
- ),
70
- })
71
- .refine((value) => value.state !== undefined || value.spec !== undefined, {
72
- message: 'Provide at least one of state or spec.',
73
- }),
55
+ description: 'Patch current thread details (state).',
56
+ inputSchema: z.object({
57
+ state: z
58
+ .record(z.string(), z.unknown())
59
+ .describe(
60
+ 'JSON state object for the thread. Use for structured data like `todos` or progress.',
61
+ ),
62
+ }),
74
63
  },
75
64
  create_variable: {
76
65
  description: 'Create or update a variable in the workspace storage.',
@@ -0,0 +1,64 @@
1
+ import z from 'zod';
2
+ import type { Plugin } from '../../bus/plugin.js';
3
+
4
+ /**
5
+ * `todo` — shared, per-thread task list for autonomous multi-agent flows.
6
+ *
7
+ * Todos live in `threadDetails.state.todos` and are owned by the system
8
+ * (handlers in `bus/services.ts`). Any agent in the thread can read the
9
+ * list via context, and propose mutations through these tools. Each item
10
+ * may carry an `assignee` agent id; combine with `handoff` to drive an
11
+ * autonomous, multi-step plan across agents.
12
+ *
13
+ * Keep the surface minimal: two tools (replace-all, patch-one) cover plan
14
+ * authoring, status transitions, and reassignment.
15
+ */
16
+ const todoStatus = z.enum(['pending', 'in_progress', 'done', 'cancelled']);
17
+
18
+ const todoToolDefinitions = {
19
+ todo_write: {
20
+ description:
21
+ 'Author or rewrite the shared todo plan for the current thread. Pass the full ordered list — missing items are removed. Use at the start of multi-step work, or whenever the plan changes shape. For status flips, prefer `todo_update`.',
22
+ inputSchema: z.object({
23
+ todos: z
24
+ .array(
25
+ z.object({
26
+ id: z
27
+ .string()
28
+ .optional()
29
+ .describe('Stable id. Reuse existing ids to preserve history; omit to create.'),
30
+ content: z.string().min(1).describe('What needs to be done. One concrete step.'),
31
+ status: todoStatus.optional().describe('Defaults to `pending`.'),
32
+ assignee: z
33
+ .string()
34
+ .optional()
35
+ .describe('Agent id responsible for this step. Pair with `handoff` to delegate.'),
36
+ }),
37
+ )
38
+ .describe('The complete, ordered plan.'),
39
+ }),
40
+ },
41
+ todo_update: {
42
+ description:
43
+ 'Patch a single todo by id. Use to mark progress (`in_progress`, `done`, `cancelled`), rephrase, or reassign without rewriting the whole list.',
44
+ inputSchema: z.object({
45
+ id: z.string().describe('Todo id from `todo_write` or the rendered list.'),
46
+ status: todoStatus.optional(),
47
+ content: z.string().min(1).optional(),
48
+ assignee: z.string().optional().describe('Use empty string to clear.'),
49
+ }),
50
+ },
51
+ };
52
+
53
+ export const todoPlugin: Plugin = {
54
+ id: 'todo',
55
+ name: 'Todo',
56
+ description:
57
+ 'Shared per-thread task list for coordinating multi-step, multi-agent work.',
58
+ toolDefinitions: todoToolDefinitions,
59
+ factory: () => () => {
60
+ // Handlers live in bus/services.ts; this plugin only contributes tool definitions.
61
+ },
62
+ };
63
+
64
+ export default todoPlugin;
@@ -9,6 +9,8 @@ import { delegationPlugin } from '../plugins/delegation/index.js';
9
9
  import { storageToolsPlugin } from '../plugins/storage-tools/index.js';
10
10
  import { uiPlugin } from '../plugins/ui/index.js';
11
11
  import { approvalPlugin } from '../plugins/approval/index.js';
12
+ import { memoryPlugin } from '../plugins/memory/index.js';
13
+ import { todoPlugin } from '../plugins/todo/index.js';
12
14
  import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
13
15
 
14
16
  let pluginsDir: string | null = null;
@@ -23,6 +25,8 @@ const BUILT_IN: Record<string, Plugin> = {
23
25
  [storageToolsPlugin.id]: storageToolsPlugin,
24
26
  [uiPlugin.id]: uiPlugin,
25
27
  [approvalPlugin.id]: approvalPlugin,
28
+ [memoryPlugin.id]: memoryPlugin,
29
+ [todoPlugin.id]: todoPlugin,
26
30
  };
27
31
 
28
32
  /**
@@ -48,8 +52,6 @@ export function parsePluginModule(
48
52
  const name = typeof raw.name === 'string' ? raw.name : '';
49
53
  const description = typeof raw.description === 'string' ? raw.description : '';
50
54
  const image = typeof raw.image === 'string' ? raw.image : undefined;
51
- const defaultInstructions =
52
- typeof raw.defaultInstructions === 'string' ? raw.defaultInstructions : undefined;
53
55
  const configSchema = raw.configSchema as Plugin['configSchema'];
54
56
  const toolDefinitions = raw.toolDefinitions as Plugin['toolDefinitions'];
55
57
 
@@ -57,7 +59,6 @@ export function parsePluginModule(
57
59
  name,
58
60
  description,
59
61
  image,
60
- defaultInstructions,
61
62
  configSchema,
62
63
  toolDefinitions,
63
64
  factory: factory as Plugin['factory'],
@@ -0,0 +1,213 @@
1
+ import fs from 'node:fs/promises';
2
+ import path from 'node:path';
3
+ import crypto from 'node:crypto';
4
+ import { DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
5
+
6
+ /**
7
+ * Global memory service.
8
+ *
9
+ * Persistent, agent-shared knowledge store that lives outside of any single
10
+ * channel/thread conversation. Designed as a stable foundation we can extend
11
+ * later with embeddings, retrieval ranking, TTLs, etc.
12
+ *
13
+ * Storage format
14
+ * --------------
15
+ * `~/.openbot/memory/log.jsonl` — append-only log. Each line is one of:
16
+ *
17
+ * { "op": "add", "record": MemoryRecord }
18
+ * { "op": "delete", "id": string, "at": ISO }
19
+ * { "op": "update", "id": string, "patch": Partial<MemoryRecord>, "at": ISO }
20
+ *
21
+ * Reads replay the log into an in-memory map. The log is append-only so
22
+ * concurrent writers are line-atomic on every POSIX filesystem we target.
23
+ *
24
+ * Scopes
25
+ * ------
26
+ * `global` — visible to every agent everywhere.
27
+ * `agent:<agentId>` — visible only when that agent is running.
28
+ * `channel:<channelId>` — visible only inside that channel.
29
+ *
30
+ * Scope strings are opaque to the store; new scopes can be introduced without
31
+ * a migration.
32
+ */
33
+ export interface MemoryRecord {
34
+ id: string;
35
+ scope: string;
36
+ content: string;
37
+ tags?: string[];
38
+ createdAt: string;
39
+ updatedAt: string;
40
+ }
41
+
42
+ export interface ListMemoriesArgs {
43
+ /** Exact scope match (e.g. `global`, `agent:foo`, `channel:bar`). */
44
+ scope?: string;
45
+ /** Multiple scopes — OR'd together. Useful for "global + agent:X + channel:Y". */
46
+ scopes?: string[];
47
+ /** Substring match (case-insensitive) against `content`. */
48
+ query?: string;
49
+ /** Match if any of these tags is present. */
50
+ tag?: string;
51
+ /** Default 50, hard cap 500. */
52
+ limit?: number;
53
+ }
54
+
55
+ interface AddEntry { op: 'add'; record: MemoryRecord }
56
+ interface DeleteEntry { op: 'delete'; id: string; at: string }
57
+ interface UpdateEntry { op: 'update'; id: string; patch: Partial<MemoryRecord>; at: string }
58
+ type LogEntry = AddEntry | DeleteEntry | UpdateEntry;
59
+
60
+ const DEFAULT_LIMIT = 50;
61
+ const MAX_LIMIT = 500;
62
+
63
+ const getMemoryDir = (): string => {
64
+ const config = loadConfig();
65
+ return path.join(resolvePath(config.baseDir || DEFAULT_BASE_DIR), 'memory');
66
+ };
67
+
68
+ const getLogPath = (): string => path.join(getMemoryDir(), 'log.jsonl');
69
+
70
+ const ensureDir = async (): Promise<void> => {
71
+ await fs.mkdir(getMemoryDir(), { recursive: true });
72
+ };
73
+
74
+ const readLog = async (): Promise<LogEntry[]> => {
75
+ try {
76
+ const raw = await fs.readFile(getLogPath(), 'utf-8');
77
+ return raw
78
+ .split(/\r?\n/)
79
+ .map((line) => line.trim())
80
+ .filter(Boolean)
81
+ .map((line) => {
82
+ try {
83
+ return JSON.parse(line) as LogEntry;
84
+ } catch {
85
+ return null;
86
+ }
87
+ })
88
+ .filter((e): e is LogEntry => !!e);
89
+ } catch (e: unknown) {
90
+ if ((e as { code?: string })?.code === 'ENOENT') return [];
91
+ throw e;
92
+ }
93
+ };
94
+
95
+ const replay = (entries: LogEntry[]): Map<string, MemoryRecord> => {
96
+ const out = new Map<string, MemoryRecord>();
97
+ for (const entry of entries) {
98
+ if (entry.op === 'add') {
99
+ out.set(entry.record.id, entry.record);
100
+ } else if (entry.op === 'delete') {
101
+ out.delete(entry.id);
102
+ } else if (entry.op === 'update') {
103
+ const existing = out.get(entry.id);
104
+ if (!existing) continue;
105
+ out.set(entry.id, {
106
+ ...existing,
107
+ ...entry.patch,
108
+ id: existing.id,
109
+ updatedAt: entry.at,
110
+ });
111
+ }
112
+ }
113
+ return out;
114
+ };
115
+
116
+ const appendEntry = async (entry: LogEntry): Promise<void> => {
117
+ await ensureDir();
118
+ await fs.appendFile(getLogPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
119
+ };
120
+
121
+ const matchesQuery = (record: MemoryRecord, query?: string, tag?: string): boolean => {
122
+ if (tag) {
123
+ if (!record.tags || !record.tags.includes(tag)) return false;
124
+ }
125
+ if (query) {
126
+ const q = query.toLowerCase();
127
+ if (!record.content.toLowerCase().includes(q)) return false;
128
+ }
129
+ return true;
130
+ };
131
+
132
+ export const memoryService = {
133
+ appendMemory: async (args: {
134
+ scope: string;
135
+ content: string;
136
+ tags?: string[];
137
+ }): Promise<MemoryRecord> => {
138
+ const now = new Date().toISOString();
139
+ const record: MemoryRecord = {
140
+ id: crypto.randomUUID(),
141
+ scope: args.scope,
142
+ content: args.content,
143
+ tags: args.tags?.length ? args.tags : undefined,
144
+ createdAt: now,
145
+ updatedAt: now,
146
+ };
147
+ await appendEntry({ op: 'add', record });
148
+ return record;
149
+ },
150
+
151
+ updateMemory: async (args: {
152
+ id: string;
153
+ content?: string;
154
+ tags?: string[];
155
+ }): Promise<boolean> => {
156
+ const entries = await readLog();
157
+ const map = replay(entries);
158
+ if (!map.has(args.id)) return false;
159
+ const at = new Date().toISOString();
160
+ const patch: Partial<MemoryRecord> = {};
161
+ if (args.content !== undefined) patch.content = args.content;
162
+ if (args.tags !== undefined) patch.tags = args.tags.length ? args.tags : undefined;
163
+ if (Object.keys(patch).length === 0) return true;
164
+ await appendEntry({ op: 'update', id: args.id, patch, at });
165
+ return true;
166
+ },
167
+
168
+ deleteMemory: async (args: { id: string }): Promise<boolean> => {
169
+ const entries = await readLog();
170
+ const map = replay(entries);
171
+ if (!map.has(args.id)) return false;
172
+ await appendEntry({ op: 'delete', id: args.id, at: new Date().toISOString() });
173
+ return true;
174
+ },
175
+
176
+ listMemories: async (args: ListMemoriesArgs = {}): Promise<MemoryRecord[]> => {
177
+ const entries = await readLog();
178
+ const map = replay(entries);
179
+ const limit = Math.min(Math.max(args.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
180
+
181
+ const scopeSet = (() => {
182
+ if (args.scope) return new Set([args.scope]);
183
+ if (args.scopes && args.scopes.length > 0) return new Set(args.scopes);
184
+ return null;
185
+ })();
186
+
187
+ const filtered: MemoryRecord[] = [];
188
+ for (const record of map.values()) {
189
+ if (scopeSet && !scopeSet.has(record.scope)) continue;
190
+ if (!matchesQuery(record, args.query, args.tag)) continue;
191
+ filtered.push(record);
192
+ }
193
+
194
+ filtered.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
195
+ return filtered.slice(0, limit);
196
+ },
197
+
198
+ /**
199
+ * Compact the log into a single `add` per surviving record. Cheap to call
200
+ * occasionally; not required for correctness.
201
+ */
202
+ compact: async (): Promise<number> => {
203
+ const entries = await readLog();
204
+ const map = replay(entries);
205
+ const surviving = Array.from(map.values());
206
+ await ensureDir();
207
+ const tmp = `${getLogPath()}.tmp`;
208
+ const body = surviving.map((record) => JSON.stringify({ op: 'add', record })).join('\n');
209
+ await fs.writeFile(tmp, body ? `${body}\n` : '', 'utf-8');
210
+ await fs.rename(tmp, getLogPath());
211
+ return surviving.length;
212
+ },
213
+ };
@@ -27,6 +27,7 @@ import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
27
27
  import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
28
28
  import { OpenBotEvent, OpenBotState } from '../app/types.js';
29
29
  import { processService } from '../harness/process.js';
30
+ import { memoryService } from './memory.js';
30
31
  import { pathToFileURL } from 'node:url';
31
32
 
32
33
  const resolveBaseDir = () => {
@@ -90,9 +91,10 @@ const SYSTEM_DEFAULT_PLUGINS: PluginRef[] = [
90
91
  { id: 'storage-tools' },
91
92
  // { id: 'mcp' },
92
93
  { id: 'shell' },
93
- { id: 'delegation' },
94
+ { id: 'todo' },
94
95
  // { id: 'ui' },
95
96
  { id: 'approval' },
97
+ { id: 'memory' },
96
98
  ];
97
99
 
98
100
  function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails {
@@ -101,7 +103,7 @@ function getSystemAgentDetails(overrides?: Partial<AgentDetails>): AgentDetails
101
103
  name: 'OpenBot',
102
104
  image: undefined,
103
105
  description:
104
- 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff and delegation.',
106
+ 'First-party orchestration agent for OpenBot. Coordinates other agents via handoff.',
105
107
  instructions: AI_SDK_SYSTEM_PROMPT,
106
108
  plugins: SYSTEM_DEFAULT_PLUGINS.map((ref) => ref.id),
107
109
  pluginRefs: SYSTEM_DEFAULT_PLUGINS,
@@ -209,7 +211,6 @@ const listBuiltInPluginDescriptors = async (): Promise<PluginDescriptor[]> => {
209
211
  description: plugin.description,
210
212
  builtIn: true,
211
213
  image: plugin.image,
212
- defaultInstructions: plugin.defaultInstructions,
213
214
  configSchema: plugin.configSchema,
214
215
  createdAt: new Date(),
215
216
  updatedAt: new Date(),
@@ -279,7 +280,6 @@ const listPluginsFromDisk = async (): Promise<PluginDescriptor[]> => {
279
280
  description: parsed.description || '',
280
281
  builtIn: false,
281
282
  image: parsed.image || image,
282
- defaultInstructions: parsed.defaultInstructions,
283
283
  configSchema: parsed.configSchema,
284
284
  createdAt: new Date(),
285
285
  updatedAt: new Date(),
@@ -448,13 +448,11 @@ export const storageService = {
448
448
  channelId,
449
449
  threadId,
450
450
  threadTitle,
451
- spec,
452
451
  initialState,
453
452
  }: {
454
453
  channelId: string;
455
454
  threadId: string;
456
455
  threadTitle?: string;
457
- spec?: string;
458
456
  initialState?: Record<string, unknown>;
459
457
  }): Promise<void> => {
460
458
  const normalizedChannelId = channelId.trim();
@@ -464,7 +462,6 @@ export const storageService = {
464
462
  if (!normalizedThreadId) throw new Error('threadId is required');
465
463
 
466
464
  const threadDir = getConversationDir(normalizedChannelId, normalizedThreadId);
467
- const specPath = `${threadDir}/SPEC.md`;
468
465
  const statePath = `${threadDir}/state.json`;
469
466
 
470
467
  try {
@@ -485,11 +482,6 @@ export const storageService = {
485
482
  }
486
483
 
487
484
  await fs.mkdir(threadDir, { recursive: true });
488
- await fs.writeFile(
489
- specPath,
490
- spec?.trim() ||
491
- `# ${normalizedThreadId}\n\nDefine the goals and plan for this thread here.\n`,
492
- );
493
485
  await fs.writeFile(statePath, JSON.stringify(baseState, null, 2));
494
486
  },
495
487
  getThreads: async ({ channelId }: { channelId: string }): Promise<Thread[]> => {
@@ -548,21 +540,8 @@ export const storageService = {
548
540
  threadId: string;
549
541
  }): Promise<ThreadDetails> => {
550
542
  const threadDir = getConversationDir(channelId, threadId);
551
- const specPath = `${threadDir}/SPEC.md`;
552
543
  const statePath = `${threadDir}/state.json`;
553
544
 
554
- let spec = '';
555
- try {
556
- spec = await fs.readFile(specPath, 'utf-8');
557
- } catch (error: unknown) {
558
- if ((error as NodeJS.ErrnoException)?.code !== 'ENOENT') {
559
- console.error(
560
- `Failed to read thread spec for channel ${channelId} thread ${threadId}`,
561
- error,
562
- );
563
- }
564
- }
565
-
566
545
  let state: unknown = {};
567
546
  try {
568
547
  const stateContent = await fs.readFile(statePath, 'utf-8');
@@ -585,7 +564,6 @@ export const storageService = {
585
564
  id: threadId,
586
565
  name: generatedName || threadId,
587
566
  channelId,
588
- spec,
589
567
  state,
590
568
  };
591
569
  },
@@ -702,29 +680,6 @@ export const storageService = {
702
680
  throw error;
703
681
  }
704
682
  },
705
- patchThreadSpec: async ({
706
- channelId,
707
- threadId,
708
- spec,
709
- }: {
710
- channelId: string;
711
- threadId: string;
712
- spec: string;
713
- }): Promise<void> => {
714
- const threadDir = getConversationDir(channelId, threadId);
715
- const specPath = `${threadDir}/SPEC.md`;
716
-
717
- try {
718
- await fs.mkdir(threadDir, { recursive: true });
719
- await fs.writeFile(specPath, spec);
720
- } catch (error) {
721
- console.error(
722
- `Failed to patch thread spec for channel ${channelId} thread ${threadId}`,
723
- error,
724
- );
725
- throw error;
726
- }
727
- },
728
683
  getAgents: async (): Promise<Agent[]> => {
729
684
  const agentsDir = resolvePath(resolveBaseDir() + '/' + DEFAULT_AGENTS_DIR);
730
685
  try {
@@ -1220,6 +1175,11 @@ export const storageService = {
1220
1175
  return fs.readFile(targetFile, 'utf-8');
1221
1176
  },
1222
1177
 
1178
+ appendMemory: memoryService.appendMemory,
1179
+ listMemories: memoryService.listMemories,
1180
+ deleteMemory: memoryService.deleteMemory,
1181
+ updateMemory: memoryService.updateMemory,
1182
+
1223
1183
  /**
1224
1184
  * Hydrates the full OpenBot state from disk/storage before a run.
1225
1185
  */