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.
- package/dist/app/cli.js +1 -1
- package/dist/app/server.js +1 -4
- package/dist/bus/services.js +222 -15
- package/dist/harness/context.js +205 -26
- package/dist/harness/queue-processor.js +44 -110
- package/dist/harness/runtime-factory.js +11 -7
- package/dist/harness/todo-advance.js +93 -0
- package/dist/plugins/ai-sdk/index.js +0 -3
- package/dist/plugins/ai-sdk/runtime.js +78 -13
- package/dist/plugins/ai-sdk/system-prompt.js +18 -3
- package/dist/plugins/delegation/index.js +7 -46
- package/dist/plugins/memory/index.js +71 -0
- package/dist/plugins/storage-tools/index.js +2 -11
- package/dist/plugins/todo/index.js +54 -0
- package/dist/plugins/workflow/index.js +65 -0
- package/dist/registry/plugins.js +4 -2
- package/dist/services/memory.js +152 -0
- package/dist/services/storage.js +9 -31
- package/dist/workflow/service.js +106 -0
- package/dist/workflow/types.js +3 -0
- package/docs/agents.md +15 -1
- package/docs/plugins.md +0 -1
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/server.ts +3 -4
- package/src/app/types.ts +140 -45
- package/src/bus/plugin.ts +0 -2
- package/src/bus/services.ts +258 -17
- package/src/bus/types.ts +13 -4
- package/src/harness/context.ts +233 -37
- package/src/harness/queue-processor.ts +54 -143
- package/src/harness/runtime-factory.ts +11 -7
- package/src/harness/todo-advance.ts +128 -0
- package/src/plugins/ai-sdk/index.ts +0 -3
- package/src/plugins/ai-sdk/runtime.ts +356 -298
- package/src/plugins/ai-sdk/system-prompt.ts +18 -4
- package/src/plugins/delegation/index.ts +7 -50
- package/src/plugins/memory/index.ts +85 -0
- package/src/plugins/storage-tools/index.ts +8 -19
- package/src/plugins/todo/index.ts +64 -0
- package/src/registry/plugins.ts +4 -3
- package/src/services/memory.ts +213 -0
- 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
|
|
6
|
+
const handoffToolDefinitions = {
|
|
7
7
|
handoff: {
|
|
8
8
|
description:
|
|
9
|
-
'Transfer control to another agent. The target agent continues the task
|
|
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
|
|
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: '
|
|
89
|
-
description: 'Hand off
|
|
90
|
-
toolDefinitions:
|
|
91
|
-
factory: () =>
|
|
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
|
|
56
|
-
inputSchema: z
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
.
|
|
61
|
-
|
|
62
|
-
|
|
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;
|
package/src/registry/plugins.ts
CHANGED
|
@@ -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
|
+
};
|
package/src/services/storage.ts
CHANGED
|
@@ -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: '
|
|
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
|
|
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
|
*/
|