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,5 +1,4 @@
1
1
  import { aiSdkRuntime } from './runtime.js';
2
- import { AI_SDK_SYSTEM_PROMPT } from './system-prompt.js';
3
2
  /**
4
3
  * `ai-sdk` — generic LLM runtime plugin built on the Vercel AI SDK.
5
4
  *
@@ -11,7 +10,6 @@ export const aiSdkPlugin = {
11
10
  id: 'ai-sdk',
12
11
  name: 'AI SDK Runtime',
13
12
  description: 'Generic LLM runtime built on the Vercel AI SDK. Consumes tools contributed by other plugins.',
14
- defaultInstructions: AI_SDK_SYSTEM_PROMPT,
15
13
  configSchema: {
16
14
  type: 'object',
17
15
  properties: {
@@ -28,7 +26,6 @@ export const aiSdkPlugin = {
28
26
  : 'openai/gpt-4o-mini';
29
27
  return aiSdkRuntime({
30
28
  model,
31
- system: agentDetails.instructions || AI_SDK_SYSTEM_PROMPT,
32
29
  storage,
33
30
  toolDefinitions: tools,
34
31
  });
@@ -21,6 +21,78 @@ function resolveModel(modelString) {
21
21
  const asRecord = (value) => value && typeof value === 'object' && !Array.isArray(value)
22
22
  ? value
23
23
  : {};
24
+ /** Per-message hard cap (in characters) on tool-result payloads we feed back
25
+ * to the model. Prevents one huge tool output from eating the context window;
26
+ * the original event remains intact in storage. */
27
+ const TOOL_RESULT_MAX_CHARS = 8000;
28
+ /** Sliding window: max number of messages we replay to the model on each
29
+ * invocation. Older turns stay on disk but are not sent. Keeps both the
30
+ * recent prompts and the prompt token budget bounded. */
31
+ const MAX_WINDOW_MESSAGES = 80;
32
+ const truncateToolPayload = (raw) => {
33
+ const serialized = typeof raw === 'string' ? raw : JSON.stringify(raw);
34
+ if (serialized.length <= TOOL_RESULT_MAX_CHARS)
35
+ return serialized;
36
+ const dropped = serialized.length - TOOL_RESULT_MAX_CHARS;
37
+ return `${serialized.slice(0, TOOL_RESULT_MAX_CHARS)}\n…[truncated ${dropped} chars]`;
38
+ };
39
+ /**
40
+ * Trim the message history to a sliding window while preserving tool-call
41
+ * integrity. Drops any leading orphan `tool` messages whose matching
42
+ * assistant call was sliced off, since most providers reject that.
43
+ */
44
+ const buildMessageWindow = (messages) => {
45
+ if (messages.length <= MAX_WINDOW_MESSAGES)
46
+ return messages;
47
+ const tail = messages.slice(-MAX_WINDOW_MESSAGES);
48
+ const knownAssistantCallIds = new Set();
49
+ for (const m of tail) {
50
+ if (m.role === 'assistant' && m.toolCalls) {
51
+ for (const tc of m.toolCalls)
52
+ knownAssistantCallIds.add(tc.id);
53
+ }
54
+ }
55
+ return tail.filter((m) => m.role !== 'tool' || knownAssistantCallIds.has(m.toolCallId));
56
+ };
57
+ /**
58
+ * Self-healing pass: every assistant tool_call must have a matching tool
59
+ * result before the next user/assistant turn, or providers (OpenAI in
60
+ * particular) reject the request with "Tool result is missing for tool call".
61
+ *
62
+ * This can happen when a handler emits a `:result` event without `meta`
63
+ * (orphaning the call), the process restarts mid-run, or a tool handler
64
+ * crashes. Rather than refuse to continue, we inject synthetic tool messages
65
+ * with a clear error payload — the LLM can then explain the failure to the
66
+ * user and proceed.
67
+ */
68
+ const repairOpenToolCalls = (messages) => {
69
+ const fulfilled = new Set();
70
+ for (const m of messages) {
71
+ if (m.role === 'tool')
72
+ fulfilled.add(m.toolCallId);
73
+ }
74
+ const repaired = [];
75
+ for (const m of messages) {
76
+ repaired.push(m);
77
+ if (m.role !== 'assistant' || !m.toolCalls)
78
+ continue;
79
+ for (const tc of m.toolCalls) {
80
+ if (fulfilled.has(tc.id))
81
+ continue;
82
+ repaired.push({
83
+ role: 'tool',
84
+ toolCallId: tc.id,
85
+ toolName: tc.function.name,
86
+ content: JSON.stringify({
87
+ success: false,
88
+ error: 'Tool result was lost (handler did not emit a matching :result event).',
89
+ }),
90
+ });
91
+ fulfilled.add(tc.id);
92
+ }
93
+ }
94
+ return repaired;
95
+ };
24
96
  const readPersistedShortTermMessages = (state) => {
25
97
  const source = state.threadDetails?.state ?? state.channelDetails?.state;
26
98
  const record = asRecord(source);
@@ -44,15 +116,8 @@ const persistShortTermMessages = async (state, storage) => {
44
116
  state: { shortTermMessages },
45
117
  });
46
118
  };
47
- async function buildSystemPrompt(state, system, context, storage, contextEngine) {
48
- const sections = [];
49
- if (system && typeof system === 'string')
50
- sections.push(system);
51
- if (system && typeof system === 'function' && context)
52
- sections.push(await system(context));
53
- if (contextEngine)
54
- sections.push(await contextEngine.buildContext(state, storage));
55
- return sections.join('\n\n');
119
+ async function buildSystemPrompt(state, storage, contextEngine) {
120
+ return contextEngine.buildContext(state, storage);
56
121
  }
57
122
  /**
58
123
  * Generic ai-sdk runtime plugin.
@@ -62,7 +127,7 @@ async function buildSystemPrompt(state, system, context, storage, contextEngine)
62
127
  * loader (merged from every tool plugin attached to the same agent).
63
128
  */
64
129
  export const aiSdkRuntime = (options) => (builder) => {
65
- const { model: modelString = 'openai/gpt-4o-mini', system, storage, contextEngine = createDefaultContextEngine(), toolDefinitions = {}, } = options;
130
+ const { model: modelString = 'openai/gpt-4o-mini', storage, contextEngine = createDefaultContextEngine(), toolDefinitions = {}, } = options;
66
131
  let currentModelString = modelString;
67
132
  let model = resolveModel(currentModelString);
68
133
  const ensureShortTermMessages = (state) => {
@@ -107,8 +172,8 @@ export const aiSdkRuntime = (options) => (builder) => {
107
172
  };
108
173
  const runLLM = async function* (context, threadId) {
109
174
  ensureShortTermMessages(context.state);
110
- const systemPrompt = await buildSystemPrompt(context.state, system, context, storage, contextEngine);
111
- const coreMessages = mapToCoreMessages(context.state.shortTermMessages || []);
175
+ const systemPrompt = await buildSystemPrompt(context.state, storage, contextEngine);
176
+ const coreMessages = mapToCoreMessages(buildMessageWindow(repairOpenToolCalls(context.state.shortTermMessages || [])));
112
177
  try {
113
178
  const result = await generateText({
114
179
  model,
@@ -246,7 +311,7 @@ export const aiSdkRuntime = (options) => (builder) => {
246
311
  ensureShortTermMessages(context.state);
247
312
  const toolName = event.type.replace(/^action:/, '').replace(/:result$/, '');
248
313
  const resultData = event.data;
249
- const content = typeof resultData === 'string' ? resultData : JSON.stringify(resultData);
314
+ const content = truncateToolPayload(resultData);
250
315
  context.state.shortTermMessages = [
251
316
  ...(context.state.shortTermMessages ?? []),
252
317
  { role: 'tool', content, toolCallId, toolName },
@@ -1,3 +1,18 @@
1
- export const AI_SDK_SYSTEM_PROMPT = 'You are a helpful AI assistant on the OpenBot platform. ' +
2
- 'Use the tools available to you to help the user. ' +
3
- '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');
@@ -1,21 +1,14 @@
1
1
  import z from 'zod';
2
- const delegationToolDefinitions = {
2
+ const handoffToolDefinitions = {
3
3
  handoff: {
4
- description: 'Transfer control to another agent. The target agent continues the task and you do not wait for a tool result.',
4
+ description: 'Transfer control to another agent. The target agent continues the task in this thread.',
5
5
  inputSchema: z.object({
6
6
  agentId: z.string().describe('The ID of the target agent.'),
7
7
  content: z.string().describe('The message or task to hand off.'),
8
8
  }),
9
9
  },
10
- delegate: {
11
- description: 'Delegate a subtask to another agent and wait for its result so you can continue.',
12
- inputSchema: z.object({
13
- agentId: z.string().describe('The ID of the target agent.'),
14
- content: z.string().describe('The subtask you want the target agent to execute.'),
15
- }),
16
- },
17
10
  };
18
- const delegationPluginRuntime = () => (builder) => {
11
+ const handoffPluginRuntime = () => (builder) => {
19
12
  builder.on('action:handoff', async function* (event, context) {
20
13
  const { agentId, content } = event.data;
21
14
  yield {
@@ -36,44 +29,12 @@ const delegationPluginRuntime = () => (builder) => {
36
29
  };
37
30
  }
38
31
  });
39
- builder.on('action:delegate', async function* (event, context) {
40
- const { agentId, content } = event.data;
41
- const widgetId = event.meta?.toolCallId
42
- ? `delegate_${event.meta.toolCallId}`
43
- : `delegate_${Date.now()}`;
44
- yield {
45
- type: 'client:ui:widget',
46
- data: {
47
- kind: 'message',
48
- widgetId,
49
- title: `Delegation started: ${agentId}`,
50
- body: `Running delegated task in background.\n\n${content}`,
51
- state: 'open',
52
- metadata: {
53
- type: 'delegation:status',
54
- phase: 'started',
55
- delegatedAgentId: agentId,
56
- },
57
- },
58
- meta: { ...(event.meta || {}), agentId: context.state.agentId },
59
- };
60
- yield {
61
- type: 'delegation:request',
62
- data: { agentId, content },
63
- meta: {
64
- ...(event.meta || {}),
65
- parentAgentId: context.state.agentId,
66
- delegationWidgetId: widgetId,
67
- agentId: context.state.agentId,
68
- },
69
- };
70
- });
71
32
  };
72
33
  export const delegationPlugin = {
73
34
  id: 'delegation',
74
- name: 'Delegation',
75
- description: 'Hand off or delegate sub-tasks to other agents on the bus.',
76
- toolDefinitions: delegationToolDefinitions,
77
- factory: () => delegationPluginRuntime(),
35
+ name: 'Handoff',
36
+ description: 'Hand off tasks to other agents on the bus.',
37
+ toolDefinitions: handoffToolDefinitions,
38
+ factory: () => handoffPluginRuntime(),
78
39
  };
79
40
  export default delegationPlugin;
@@ -0,0 +1,71 @@
1
+ import z from 'zod';
2
+ /**
3
+ * `memory` — exposes the global memory store as agent tools.
4
+ *
5
+ * The actual handlers live in `bus/services.ts` because memory is platform
6
+ * infrastructure (shared across every agent on the bus); this plugin only
7
+ * contributes the tool definitions so a runtime plugin (e.g. `ai-sdk`) can
8
+ * surface them to the LLM.
9
+ *
10
+ * Scopes
11
+ * ------
12
+ * - `global` (default) — visible to every agent and channel.
13
+ * - `agent` — visible only to the agent that wrote it.
14
+ * - `channel` — visible only inside the active channel.
15
+ */
16
+ const memoryToolDefinitions = {
17
+ remember: {
18
+ description: '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.',
19
+ inputSchema: z.object({
20
+ content: z
21
+ .string()
22
+ .min(1)
23
+ .describe('The fact to remember, written so it makes sense out of context (e.g. "User prefers TypeScript over JavaScript.").'),
24
+ scope: z
25
+ .enum(['global', 'agent', 'channel'])
26
+ .optional()
27
+ .describe('Visibility: `global` (default, all agents everywhere), `agent` (only this agent), `channel` (only this channel).'),
28
+ tags: z
29
+ .array(z.string())
30
+ .optional()
31
+ .describe('Optional tags for filtering with `recall`.'),
32
+ }),
33
+ },
34
+ recall: {
35
+ description: '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.',
36
+ inputSchema: z.object({
37
+ query: z
38
+ .string()
39
+ .optional()
40
+ .describe('Case-insensitive substring filter against memory content.'),
41
+ tag: z.string().optional().describe('Only return memories that include this tag.'),
42
+ scope: z
43
+ .enum(['global', 'agent', 'channel', 'all'])
44
+ .optional()
45
+ .describe('Restrict the search to a single scope. Default `all` returns global + this agent + this channel.'),
46
+ limit: z
47
+ .number()
48
+ .int()
49
+ .positive()
50
+ .max(50)
51
+ .optional()
52
+ .describe('Maximum records to return (default 20, max 50).'),
53
+ }),
54
+ },
55
+ forget: {
56
+ description: '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`.',
57
+ inputSchema: z.object({
58
+ id: z.string().describe('The memory record id (returned by `recall`/`remember`).'),
59
+ }),
60
+ },
61
+ };
62
+ export const memoryPlugin = {
63
+ id: 'memory',
64
+ name: 'Memory',
65
+ description: 'Global long-term memory: remember/recall/forget facts across runs and agents.',
66
+ toolDefinitions: memoryToolDefinitions,
67
+ factory: () => () => {
68
+ // Handlers live in bus/services.ts; this plugin only contributes tool definitions.
69
+ },
70
+ };
71
+ export default memoryPlugin;
@@ -42,20 +42,11 @@ const storageToolDefinitions = {
42
42
  .refine((value) => value.state !== undefined || value.spec !== undefined || value.cwd !== undefined, { message: 'Provide at least one of state, spec, or cwd.' }),
43
43
  },
44
44
  patch_thread_details: {
45
- description: 'Patch current thread details (state and/or spec).',
46
- inputSchema: z
47
- .object({
45
+ description: 'Patch current thread details (state).',
46
+ inputSchema: z.object({
48
47
  state: z
49
48
  .record(z.string(), z.unknown())
50
- .optional()
51
49
  .describe('JSON state object for the thread. Use for structured data like `todos` or progress.'),
52
- spec: z
53
- .string()
54
- .optional()
55
- .describe('Markdown content for the thread specification (SPEC.md). Use for plans and goals.'),
56
- })
57
- .refine((value) => value.state !== undefined || value.spec !== undefined, {
58
- message: 'Provide at least one of state or spec.',
59
50
  }),
60
51
  },
61
52
  create_variable: {
@@ -0,0 +1,54 @@
1
+ import z from 'zod';
2
+ /**
3
+ * `todo` — shared, per-thread task list for autonomous multi-agent flows.
4
+ *
5
+ * Todos live in `threadDetails.state.todos` and are owned by the system
6
+ * (handlers in `bus/services.ts`). Any agent in the thread can read the
7
+ * list via context, and propose mutations through these tools. Each item
8
+ * may carry an `assignee` agent id; combine with `handoff` to drive an
9
+ * autonomous, multi-step plan across agents.
10
+ *
11
+ * Keep the surface minimal: two tools (replace-all, patch-one) cover plan
12
+ * authoring, status transitions, and reassignment.
13
+ */
14
+ const todoStatus = z.enum(['pending', 'in_progress', 'done', 'cancelled']);
15
+ const todoToolDefinitions = {
16
+ todo_write: {
17
+ description: '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`.',
18
+ inputSchema: z.object({
19
+ todos: z
20
+ .array(z.object({
21
+ id: z
22
+ .string()
23
+ .optional()
24
+ .describe('Stable id. Reuse existing ids to preserve history; omit to create.'),
25
+ content: z.string().min(1).describe('What needs to be done. One concrete step.'),
26
+ status: todoStatus.optional().describe('Defaults to `pending`.'),
27
+ assignee: z
28
+ .string()
29
+ .optional()
30
+ .describe('Agent id responsible for this step. Pair with `handoff` to delegate.'),
31
+ }))
32
+ .describe('The complete, ordered plan.'),
33
+ }),
34
+ },
35
+ todo_update: {
36
+ description: 'Patch a single todo by id. Use to mark progress (`in_progress`, `done`, `cancelled`), rephrase, or reassign without rewriting the whole list.',
37
+ inputSchema: z.object({
38
+ id: z.string().describe('Todo id from `todo_write` or the rendered list.'),
39
+ status: todoStatus.optional(),
40
+ content: z.string().min(1).optional(),
41
+ assignee: z.string().optional().describe('Use empty string to clear.'),
42
+ }),
43
+ },
44
+ };
45
+ export const todoPlugin = {
46
+ id: 'todo',
47
+ name: 'Todo',
48
+ description: 'Shared per-thread task list for coordinating multi-step, multi-agent work.',
49
+ toolDefinitions: todoToolDefinitions,
50
+ factory: () => () => {
51
+ // Handlers live in bus/services.ts; this plugin only contributes tool definitions.
52
+ },
53
+ };
54
+ export default todoPlugin;
@@ -0,0 +1,65 @@
1
+ import z from 'zod';
2
+ import { setWorkflowPlan, updateWorkflowTodo } from '../../workflow/service.js';
3
+ const workflowToolDefinitions = {
4
+ workflow_set_plan: {
5
+ description: 'Define or replace the multi-agent plan for this thread.',
6
+ inputSchema: z.object({
7
+ title: z.string().optional().describe('Optional title for the plan.'),
8
+ todos: z.array(z.object({
9
+ id: z.string().optional().describe('Optional stable id.'),
10
+ text: z.string().describe('What needs to be done.'),
11
+ })).min(1).describe('Ordered steps.'),
12
+ }),
13
+ },
14
+ workflow_update_todo: {
15
+ description: 'Update a step in the plan with progress or results.',
16
+ inputSchema: z.object({
17
+ todoId: z.string().describe('The id of the todo to update.'),
18
+ status: z.enum(['pending', 'done', 'failed']).optional().describe('New status.'),
19
+ summary: z.string().optional().describe('Concise result or note about this step.'),
20
+ }),
21
+ },
22
+ };
23
+ const workflowPluginRuntime = () => (builder) => {
24
+ builder.on('action:workflow_set_plan', async function* (event, context) {
25
+ const { threadId, channelId } = context.state;
26
+ if (!threadId) {
27
+ yield { type: 'action:workflow_set_plan:result', data: { success: false, error: 'No active thread.' }, meta: event.meta };
28
+ return;
29
+ }
30
+ try {
31
+ const workflow = await setWorkflowPlan({ channelId, threadId, plan: event.data });
32
+ yield { type: 'action:workflow_set_plan:result', data: { success: true, workflowId: workflow.id }, meta: event.meta };
33
+ yield {
34
+ type: 'agent:output',
35
+ data: { content: `Plan updated: **${workflow.title || workflow.id}** (${workflow.todos.length} steps).` },
36
+ meta: { ...(event.meta || {}), agentId: context.state.agentId, threadId },
37
+ };
38
+ }
39
+ catch (error) {
40
+ yield { type: 'action:workflow_set_plan:result', data: { success: false, error: String(error) }, meta: event.meta };
41
+ }
42
+ });
43
+ builder.on('action:workflow_update_todo', async function* (event, context) {
44
+ const { threadId, channelId } = context.state;
45
+ if (!threadId) {
46
+ yield { type: 'action:workflow_update_todo:result', data: { success: false, error: 'No active thread.' }, meta: event.meta };
47
+ return;
48
+ }
49
+ try {
50
+ const workflow = await updateWorkflowTodo({ channelId, threadId, update: event.data });
51
+ yield { type: 'action:workflow_update_todo:result', data: { success: true, todoId: event.data.todoId }, meta: event.meta };
52
+ }
53
+ catch (error) {
54
+ yield { type: 'action:workflow_update_todo:result', data: { success: false, error: String(error) }, meta: event.meta };
55
+ }
56
+ });
57
+ };
58
+ export const workflowPlugin = {
59
+ id: 'workflow',
60
+ name: 'Workflow',
61
+ description: 'Simple thread-scoped planning for multi-agent coordination.',
62
+ toolDefinitions: workflowToolDefinitions,
63
+ factory: () => workflowPluginRuntime(),
64
+ };
65
+ export default workflowPlugin;
@@ -8,6 +8,8 @@ import { delegationPlugin } from '../plugins/delegation/index.js';
8
8
  import { storageToolsPlugin } from '../plugins/storage-tools/index.js';
9
9
  import { uiPlugin } from '../plugins/ui/index.js';
10
10
  import { approvalPlugin } from '../plugins/approval/index.js';
11
+ import { memoryPlugin } from '../plugins/memory/index.js';
12
+ import { todoPlugin } from '../plugins/todo/index.js';
11
13
  import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
12
14
  let pluginsDir = null;
13
15
  const loadedPlugins = new Set();
@@ -20,6 +22,8 @@ const BUILT_IN = {
20
22
  [storageToolsPlugin.id]: storageToolsPlugin,
21
23
  [uiPlugin.id]: uiPlugin,
22
24
  [approvalPlugin.id]: approvalPlugin,
25
+ [memoryPlugin.id]: memoryPlugin,
26
+ [todoPlugin.id]: todoPlugin,
23
27
  };
24
28
  /** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
25
29
  export function parsePluginModule(module) {
@@ -33,14 +37,12 @@ export function parsePluginModule(module) {
33
37
  const name = typeof raw.name === 'string' ? raw.name : '';
34
38
  const description = typeof raw.description === 'string' ? raw.description : '';
35
39
  const image = typeof raw.image === 'string' ? raw.image : undefined;
36
- const defaultInstructions = typeof raw.defaultInstructions === 'string' ? raw.defaultInstructions : undefined;
37
40
  const configSchema = raw.configSchema;
38
41
  const toolDefinitions = raw.toolDefinitions;
39
42
  return {
40
43
  name,
41
44
  description,
42
45
  image,
43
- defaultInstructions,
44
46
  configSchema,
45
47
  toolDefinitions,
46
48
  factory: factory,
@@ -0,0 +1,152 @@
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
+ const DEFAULT_LIMIT = 50;
6
+ const MAX_LIMIT = 500;
7
+ const getMemoryDir = () => {
8
+ const config = loadConfig();
9
+ return path.join(resolvePath(config.baseDir || DEFAULT_BASE_DIR), 'memory');
10
+ };
11
+ const getLogPath = () => path.join(getMemoryDir(), 'log.jsonl');
12
+ const ensureDir = async () => {
13
+ await fs.mkdir(getMemoryDir(), { recursive: true });
14
+ };
15
+ const readLog = async () => {
16
+ try {
17
+ const raw = await fs.readFile(getLogPath(), 'utf-8');
18
+ return raw
19
+ .split(/\r?\n/)
20
+ .map((line) => line.trim())
21
+ .filter(Boolean)
22
+ .map((line) => {
23
+ try {
24
+ return JSON.parse(line);
25
+ }
26
+ catch {
27
+ return null;
28
+ }
29
+ })
30
+ .filter((e) => !!e);
31
+ }
32
+ catch (e) {
33
+ if (e?.code === 'ENOENT')
34
+ return [];
35
+ throw e;
36
+ }
37
+ };
38
+ const replay = (entries) => {
39
+ const out = new Map();
40
+ for (const entry of entries) {
41
+ if (entry.op === 'add') {
42
+ out.set(entry.record.id, entry.record);
43
+ }
44
+ else if (entry.op === 'delete') {
45
+ out.delete(entry.id);
46
+ }
47
+ else if (entry.op === 'update') {
48
+ const existing = out.get(entry.id);
49
+ if (!existing)
50
+ continue;
51
+ out.set(entry.id, {
52
+ ...existing,
53
+ ...entry.patch,
54
+ id: existing.id,
55
+ updatedAt: entry.at,
56
+ });
57
+ }
58
+ }
59
+ return out;
60
+ };
61
+ const appendEntry = async (entry) => {
62
+ await ensureDir();
63
+ await fs.appendFile(getLogPath(), `${JSON.stringify(entry)}\n`, 'utf-8');
64
+ };
65
+ const matchesQuery = (record, query, tag) => {
66
+ if (tag) {
67
+ if (!record.tags || !record.tags.includes(tag))
68
+ return false;
69
+ }
70
+ if (query) {
71
+ const q = query.toLowerCase();
72
+ if (!record.content.toLowerCase().includes(q))
73
+ return false;
74
+ }
75
+ return true;
76
+ };
77
+ export const memoryService = {
78
+ appendMemory: async (args) => {
79
+ const now = new Date().toISOString();
80
+ const record = {
81
+ id: crypto.randomUUID(),
82
+ scope: args.scope,
83
+ content: args.content,
84
+ tags: args.tags?.length ? args.tags : undefined,
85
+ createdAt: now,
86
+ updatedAt: now,
87
+ };
88
+ await appendEntry({ op: 'add', record });
89
+ return record;
90
+ },
91
+ updateMemory: async (args) => {
92
+ const entries = await readLog();
93
+ const map = replay(entries);
94
+ if (!map.has(args.id))
95
+ return false;
96
+ const at = new Date().toISOString();
97
+ const patch = {};
98
+ if (args.content !== undefined)
99
+ patch.content = args.content;
100
+ if (args.tags !== undefined)
101
+ patch.tags = args.tags.length ? args.tags : undefined;
102
+ if (Object.keys(patch).length === 0)
103
+ return true;
104
+ await appendEntry({ op: 'update', id: args.id, patch, at });
105
+ return true;
106
+ },
107
+ deleteMemory: async (args) => {
108
+ const entries = await readLog();
109
+ const map = replay(entries);
110
+ if (!map.has(args.id))
111
+ return false;
112
+ await appendEntry({ op: 'delete', id: args.id, at: new Date().toISOString() });
113
+ return true;
114
+ },
115
+ listMemories: async (args = {}) => {
116
+ const entries = await readLog();
117
+ const map = replay(entries);
118
+ const limit = Math.min(Math.max(args.limit ?? DEFAULT_LIMIT, 1), MAX_LIMIT);
119
+ const scopeSet = (() => {
120
+ if (args.scope)
121
+ return new Set([args.scope]);
122
+ if (args.scopes && args.scopes.length > 0)
123
+ return new Set(args.scopes);
124
+ return null;
125
+ })();
126
+ const filtered = [];
127
+ for (const record of map.values()) {
128
+ if (scopeSet && !scopeSet.has(record.scope))
129
+ continue;
130
+ if (!matchesQuery(record, args.query, args.tag))
131
+ continue;
132
+ filtered.push(record);
133
+ }
134
+ filtered.sort((a, b) => (a.updatedAt < b.updatedAt ? 1 : -1));
135
+ return filtered.slice(0, limit);
136
+ },
137
+ /**
138
+ * Compact the log into a single `add` per surviving record. Cheap to call
139
+ * occasionally; not required for correctness.
140
+ */
141
+ compact: async () => {
142
+ const entries = await readLog();
143
+ const map = replay(entries);
144
+ const surviving = Array.from(map.values());
145
+ await ensureDir();
146
+ const tmp = `${getLogPath()}.tmp`;
147
+ const body = surviving.map((record) => JSON.stringify({ op: 'add', record })).join('\n');
148
+ await fs.writeFile(tmp, body ? `${body}\n` : '', 'utf-8');
149
+ await fs.rename(tmp, getLogPath());
150
+ return surviving.length;
151
+ },
152
+ };