openbot 0.3.0 → 0.3.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/app/cli.js CHANGED
@@ -16,7 +16,7 @@ function checkNodeVersion() {
16
16
  }
17
17
  }
18
18
  checkNodeVersion();
19
- program.name('openbot').description('OpenBot CLI').version('0.2.14');
19
+ program.name('openbot').description('OpenBot CLI').version('0.3.1');
20
20
  program
21
21
  .command('start')
22
22
  .description('Start the OpenBot harness')
@@ -1,6 +1,30 @@
1
1
  import { DEFAULT_MARKETPLACE_REGISTRY_URL, loadConfig } from '../app/config.js';
2
2
  import { storageService } from '../services/storage.js';
3
3
  import { pluginService } from '../services/plugins.js';
4
+ /**
5
+ * Resolve a scope alias to a concrete scope string. Aliases let tools accept
6
+ * `agent`/`channel`/`global` without knowing the active ids; the bus rewrites
7
+ * them using `context.state`.
8
+ */
9
+ function resolveMemoryScope(alias, state) {
10
+ switch (alias) {
11
+ case 'agent':
12
+ return `agent:${state.agentId}`;
13
+ case 'channel':
14
+ return `channel:${state.channelId}`;
15
+ case 'global':
16
+ case undefined:
17
+ return 'global';
18
+ default:
19
+ return 'global';
20
+ }
21
+ }
22
+ function resolveMemoryScopeFilter(alias, state) {
23
+ if (alias === 'all' || alias === undefined) {
24
+ return ['global', `agent:${state.agentId}`, `channel:${state.channelId}`];
25
+ }
26
+ return [resolveMemoryScope(alias, state)];
27
+ }
4
28
  const DEFAULT_MARKETPLACE_AGENTS = [
5
29
  {
6
30
  id: 'researcher',
@@ -117,17 +141,19 @@ export const busServicesPlugin = (options) => (builder) => {
117
141
  yield {
118
142
  type: 'action:create_thread:result',
119
143
  data: { success: true, threadId, threadTitle },
120
- meta: { threadId },
144
+ meta: { ...(event.meta || {}), threadId, agentId: context.state.agentId },
121
145
  };
122
146
  });
123
147
  builder.on('action:create_channel', async function* (event, context) {
124
148
  const { channelId, spec, initialState, cwd } = event.data;
125
149
  const rawChannelId = (channelId || '').trim();
126
150
  const channelSpec = typeof spec === 'string' ? spec : '';
151
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
127
152
  if (!rawChannelId) {
128
153
  yield {
129
154
  type: 'action:create_channel:result',
130
155
  data: { success: false, channelId: '', channelUrl: '' },
156
+ meta: resultMeta,
131
157
  };
132
158
  return;
133
159
  }
@@ -142,30 +168,31 @@ export const busServicesPlugin = (options) => (builder) => {
142
168
  yield {
143
169
  type: 'action:create_channel:result',
144
170
  data: { success: true, channelId: rawChannelId, channelUrl },
171
+ meta: resultMeta,
145
172
  };
146
173
  yield {
147
174
  type: 'agent:output',
148
175
  data: { content: `Created channel \`${rawChannelId}\`.` },
149
- meta: {
150
- ...(event.meta || {}),
151
- agentId: context.state.agentId,
152
- },
176
+ meta: resultMeta,
153
177
  };
154
178
  }
155
179
  catch {
156
180
  yield {
157
181
  type: 'action:create_channel:result',
158
182
  data: { success: false, channelId: rawChannelId, channelUrl },
183
+ meta: resultMeta,
159
184
  };
160
185
  }
161
186
  });
162
187
  builder.on('action:update_channel', async function* (event, context) {
163
188
  const data = (event.data || {});
164
189
  const targetChannelId = (data.channelId || context.state.channelId || '').trim();
190
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
165
191
  if (!targetChannelId) {
166
192
  yield {
167
193
  type: 'action:update_channel:result',
168
194
  data: { success: false, channelId: '', updatedFields: [] },
195
+ meta: resultMeta,
169
196
  };
170
197
  return;
171
198
  }
@@ -191,17 +218,20 @@ export const busServicesPlugin = (options) => (builder) => {
191
218
  yield {
192
219
  type: 'action:update_channel:result',
193
220
  data: { success: true, channelId: targetChannelId, updatedFields },
221
+ meta: resultMeta,
194
222
  };
195
223
  }
196
224
  catch {
197
225
  yield {
198
226
  type: 'action:update_channel:result',
199
227
  data: { success: false, channelId: targetChannelId, updatedFields },
228
+ meta: resultMeta,
200
229
  };
201
230
  }
202
231
  });
203
232
  builder.on('action:patch_channel_details', async function* (event, context) {
204
233
  const updatedFields = [];
234
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
205
235
  try {
206
236
  if (event.data.state !== undefined) {
207
237
  await storage.patchChannelState({
@@ -230,17 +260,20 @@ export const busServicesPlugin = (options) => (builder) => {
230
260
  yield {
231
261
  type: 'action:patch_channel_details:result',
232
262
  data: { success: true, updatedFields },
263
+ meta: resultMeta,
233
264
  };
234
265
  }
235
266
  catch {
236
267
  yield {
237
268
  type: 'action:patch_channel_details:result',
238
269
  data: { success: false, updatedFields },
270
+ meta: resultMeta,
239
271
  };
240
272
  }
241
273
  });
242
274
  builder.on('action:patch_thread_details', async function* (event, context) {
243
275
  const updatedFields = [];
276
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
244
277
  try {
245
278
  if (!context.state.threadId) {
246
279
  throw new Error('Missing threadId in state for patch_thread_details');
@@ -268,12 +301,14 @@ export const busServicesPlugin = (options) => (builder) => {
268
301
  yield {
269
302
  type: 'action:patch_thread_details:result',
270
303
  data: { success: true, updatedFields },
304
+ meta: resultMeta,
271
305
  };
272
306
  }
273
307
  catch {
274
308
  yield {
275
309
  type: 'action:patch_thread_details:result',
276
310
  data: { success: false, updatedFields },
311
+ meta: resultMeta,
277
312
  };
278
313
  }
279
314
  });
@@ -549,6 +584,82 @@ export const busServicesPlugin = (options) => (builder) => {
549
584
  data: { success: true, agents },
550
585
  };
551
586
  });
587
+ builder.on('action:remember', async function* (event, context) {
588
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
589
+ try {
590
+ const { content, scope, tags } = event.data;
591
+ const record = await storage.appendMemory({
592
+ scope: resolveMemoryScope(scope, context.state),
593
+ content,
594
+ tags,
595
+ });
596
+ yield {
597
+ type: 'action:remember:result',
598
+ data: { success: true, record },
599
+ meta: resultMeta,
600
+ };
601
+ }
602
+ catch (error) {
603
+ yield {
604
+ type: 'action:remember:result',
605
+ data: {
606
+ success: false,
607
+ error: error instanceof Error ? error.message : 'Unknown error',
608
+ },
609
+ meta: resultMeta,
610
+ };
611
+ }
612
+ });
613
+ builder.on('action:recall', async function* (event, context) {
614
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
615
+ try {
616
+ const { query, tag, scope, limit } = event.data;
617
+ const records = await storage.listMemories({
618
+ scopes: resolveMemoryScopeFilter(scope, context.state),
619
+ query,
620
+ tag,
621
+ limit,
622
+ });
623
+ yield {
624
+ type: 'action:recall:result',
625
+ data: { success: true, records },
626
+ meta: resultMeta,
627
+ };
628
+ }
629
+ catch (error) {
630
+ yield {
631
+ type: 'action:recall:result',
632
+ data: {
633
+ success: false,
634
+ records: [],
635
+ error: error instanceof Error ? error.message : 'Unknown error',
636
+ },
637
+ meta: resultMeta,
638
+ };
639
+ }
640
+ });
641
+ builder.on('action:forget', async function* (event, context) {
642
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
643
+ try {
644
+ const deleted = await storage.deleteMemory({ id: event.data.id });
645
+ yield {
646
+ type: 'action:forget:result',
647
+ data: { success: true, deleted },
648
+ meta: resultMeta,
649
+ };
650
+ }
651
+ catch (error) {
652
+ yield {
653
+ type: 'action:forget:result',
654
+ data: {
655
+ success: false,
656
+ deleted: false,
657
+ error: error instanceof Error ? error.message : 'Unknown error',
658
+ },
659
+ meta: resultMeta,
660
+ };
661
+ }
662
+ });
552
663
  builder.on('action:agent:install', async function* (event) {
553
664
  try {
554
665
  const { agentId, name, description, instructions, plugins } = event.data;
@@ -1,6 +1,15 @@
1
1
  /**
2
- * The core engine that orchestrates context building.
2
+ * Cheap, dependency-free token estimator. Roughly char/4 — fine for budget
3
+ * enforcement; can be swapped for a tokenizer-backed implementation later
4
+ * without touching providers.
3
5
  */
6
+ export const estimateTokens = (text) => Math.ceil((text?.length ?? 0) / 4);
7
+ /**
8
+ * Hard cap (in characters) on a single context item. Keeps any one provider
9
+ * — typically the recent-events feed — from monopolising the prompt budget.
10
+ */
11
+ const ITEM_HARD_CHAR_CAP = 6000;
12
+ const truncate = (text, maxChars) => text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n…[truncated]`;
4
13
  export class ContextEngine {
5
14
  constructor() {
6
15
  this.providers = [];
@@ -13,18 +22,18 @@ export class ContextEngine {
13
22
  this.processors.push(processor);
14
23
  }
15
24
  async buildContext(state, storage) {
16
- // 1. Collect context from all providers
17
25
  let items = [];
18
26
  for (const provider of this.providers) {
19
27
  try {
20
28
  const providedItems = await provider.provide(state, storage);
21
- items.push(...providedItems);
29
+ for (const item of providedItems) {
30
+ items.push({ ...item, content: truncate(item.content, ITEM_HARD_CHAR_CAP) });
31
+ }
22
32
  }
23
33
  catch (error) {
24
34
  console.warn(`[ContextEngine] Provider ${provider.name} failed:`, error);
25
35
  }
26
36
  }
27
- // 2. Run through processors
28
37
  for (const processor of this.processors) {
29
38
  try {
30
39
  items = await processor.process(items, state);
@@ -33,23 +42,25 @@ export class ContextEngine {
33
42
  console.warn(`[ContextEngine] Processor ${processor.name} failed:`, error);
34
43
  }
35
44
  }
36
- // 3. Format items into a single string
37
45
  return items
38
46
  .sort((a, b) => b.priority - a.priority)
39
- .map(item => item.content)
47
+ .map((item) => item.content)
40
48
  .join('\n\n');
41
49
  }
42
50
  }
43
51
  /**
44
- * Default implementation of a Context Engine with basic providers.
52
+ * Default context engine. Order of providers is by emit order; final ordering
53
+ * in the prompt is determined by `priority`. The token-budget processor runs
54
+ * last so dropping happens after every provider has contributed.
45
55
  */
46
56
  export function createDefaultContextEngine() {
47
57
  const engine = new ContextEngine();
48
- // Basic Providers
49
58
  engine.registerProvider(new AgentDetailsProvider());
50
59
  engine.registerProvider(new ChannelDetailsProvider());
51
60
  engine.registerProvider(new ThreadDetailsProvider());
61
+ engine.registerProvider(new MemoryProvider());
52
62
  engine.registerProvider(new RecentEventsProvider());
63
+ engine.registerProcessor(new TokenBudgetProcessor());
53
64
  return engine;
54
65
  }
55
66
  class AgentDetailsProvider {
@@ -97,6 +108,81 @@ class ThreadDetailsProvider {
97
108
  }];
98
109
  }
99
110
  }
111
+ /**
112
+ * Fetches relevant memories (global + active agent + active channel) and
113
+ * surfaces them at high priority so the LLM treats them as ground truth
114
+ * rather than chat history.
115
+ */
116
+ class MemoryProvider {
117
+ constructor() {
118
+ this.name = 'memory';
119
+ }
120
+ async provide(state, storage) {
121
+ if (!storage?.listMemories)
122
+ return [];
123
+ try {
124
+ const scopes = ['global', `agent:${state.agentId}`];
125
+ if (state.channelId)
126
+ scopes.push(`channel:${state.channelId}`);
127
+ const records = await storage.listMemories({ scopes, limit: 50 });
128
+ if (records.length === 0)
129
+ return [];
130
+ const formatted = records
131
+ .map((r) => {
132
+ const tags = r.tags?.length ? ` [${r.tags.join(', ')}]` : '';
133
+ const scopeLabel = r.scope === 'global' ? 'global' : r.scope;
134
+ return `- (${scopeLabel}${tags}) ${r.content}`;
135
+ })
136
+ .join('\n');
137
+ return [
138
+ {
139
+ id: 'memory',
140
+ type: 'memory',
141
+ priority: 95,
142
+ content: `## REMEMBERED FACTS\nThese are durable facts you previously stored with the \`remember\` tool. Trust them unless contradicted by the user. Use \`forget\` to remove ones that are stale.\n\n${formatted}`,
143
+ },
144
+ ];
145
+ }
146
+ catch (error) {
147
+ console.warn('[ContextEngine] MemoryProvider failed:', error);
148
+ return [];
149
+ }
150
+ }
151
+ }
152
+ /**
153
+ * Event types we omit from the recent-events context block. They duplicate
154
+ * information already in the conversation history, are infrastructural
155
+ * noise, or are too large to be useful as a tail summary.
156
+ */
157
+ const NOISY_EVENT_PREFIXES = [
158
+ 'agent:invoke',
159
+ 'agent:output',
160
+ 'agent:run',
161
+ 'agent:active-runs',
162
+ 'client:ui',
163
+ 'stream:',
164
+ 'action:storage:get-',
165
+ 'action:storage:patch-',
166
+ ];
167
+ const MAX_RECENT_EVENTS = 20;
168
+ const MAX_EVENT_DATA_CHARS = 300;
169
+ const isNoisyEvent = (event) => NOISY_EVENT_PREFIXES.some((prefix) => event.type.startsWith(prefix));
170
+ const summarizeEvent = (event) => {
171
+ const data = event.data;
172
+ if (data === undefined)
173
+ return `- ${event.type}`;
174
+ let payload;
175
+ try {
176
+ payload = typeof data === 'string' ? data : JSON.stringify(data);
177
+ }
178
+ catch {
179
+ payload = '[unserialisable]';
180
+ }
181
+ if (payload.length > MAX_EVENT_DATA_CHARS) {
182
+ payload = `${payload.slice(0, MAX_EVENT_DATA_CHARS)}…`;
183
+ }
184
+ return `- ${event.type}: ${payload}`;
185
+ };
100
186
  class RecentEventsProvider {
101
187
  constructor() {
102
188
  this.name = 'recent-events';
@@ -104,28 +190,61 @@ class RecentEventsProvider {
104
190
  async provide(state, storage) {
105
191
  if (!storage)
106
192
  return [];
107
- const items = [];
108
- // Fetch channel events if no thread, otherwise fetch thread events
109
193
  const channelId = state.channelId;
110
194
  const threadId = state.threadId;
111
195
  try {
112
196
  const events = await storage.getEvents({ channelId, threadId });
113
- if (events.length > 0) {
114
- const formattedEvents = events
115
- .slice(-20)
116
- .map((e) => `- ${e.type}: ${JSON.stringify(e.data || {})}`)
117
- .join('\n');
118
- items.push({
197
+ const filtered = events.filter((e) => !isNoisyEvent(e));
198
+ if (filtered.length === 0)
199
+ return [];
200
+ const formatted = filtered.slice(-MAX_RECENT_EVENTS).map(summarizeEvent).join('\n');
201
+ return [
202
+ {
119
203
  id: threadId ? 'thread-events' : 'channel-events',
120
204
  type: 'events',
121
205
  priority: 70,
122
- content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formattedEvents}`
123
- });
124
- }
206
+ content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formatted}`,
207
+ },
208
+ ];
125
209
  }
126
210
  catch (error) {
127
- console.warn(`[ContextEngine] Failed to fetch events:`, error);
211
+ console.warn('[ContextEngine] Failed to fetch events:', error);
212
+ return [];
213
+ }
214
+ }
215
+ }
216
+ /**
217
+ * Drops the lowest-priority items until the assembled prompt fits within the
218
+ * token budget. The first item with priority >= `keepFloor` is always kept,
219
+ * so the agent's own instructions can never be evicted. Stable on ties:
220
+ * later-emitted items go first.
221
+ */
222
+ export class TokenBudgetProcessor {
223
+ constructor(budget = TokenBudgetProcessor.DEFAULT_BUDGET, keepFloor = TokenBudgetProcessor.KEEP_FLOOR) {
224
+ this.budget = budget;
225
+ this.keepFloor = keepFloor;
226
+ this.name = 'token-budget';
227
+ }
228
+ async process(items) {
229
+ const sorted = [...items].sort((a, b) => b.priority - a.priority);
230
+ const out = [];
231
+ let used = 0;
232
+ for (const item of sorted) {
233
+ const cost = estimateTokens(item.content);
234
+ if (item.priority >= this.keepFloor) {
235
+ out.push(item);
236
+ used += cost;
237
+ continue;
238
+ }
239
+ if (used + cost <= this.budget) {
240
+ out.push(item);
241
+ used += cost;
242
+ }
128
243
  }
129
- return items;
244
+ return out;
130
245
  }
131
246
  }
247
+ /** Soft prompt budget in tokens (matches gpt-4o-mini's reasonable system slice). */
248
+ TokenBudgetProcessor.DEFAULT_BUDGET = 8000;
249
+ /** Items at or above this priority are never dropped. */
250
+ TokenBudgetProcessor.KEEP_FLOOR = 100;
@@ -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);
@@ -108,7 +180,7 @@ export const aiSdkRuntime = (options) => (builder) => {
108
180
  const runLLM = async function* (context, threadId) {
109
181
  ensureShortTermMessages(context.state);
110
182
  const systemPrompt = await buildSystemPrompt(context.state, system, context, storage, contextEngine);
111
- const coreMessages = mapToCoreMessages(context.state.shortTermMessages || []);
183
+ const coreMessages = mapToCoreMessages(buildMessageWindow(repairOpenToolCalls(context.state.shortTermMessages || [])));
112
184
  try {
113
185
  const result = await generateText({
114
186
  model,
@@ -246,7 +318,7 @@ export const aiSdkRuntime = (options) => (builder) => {
246
318
  ensureShortTermMessages(context.state);
247
319
  const toolName = event.type.replace(/^action:/, '').replace(/:result$/, '');
248
320
  const resultData = event.data;
249
- const content = typeof resultData === 'string' ? resultData : JSON.stringify(resultData);
321
+ const content = truncateToolPayload(resultData);
250
322
  context.state.shortTermMessages = [
251
323
  ...(context.state.shortTermMessages ?? []),
252
324
  { role: 'tool', content, toolCallId, toolName },
@@ -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;
@@ -8,6 +8,7 @@ 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';
11
12
  import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
12
13
  let pluginsDir = null;
13
14
  const loadedPlugins = new Set();
@@ -20,6 +21,7 @@ const BUILT_IN = {
20
21
  [storageToolsPlugin.id]: storageToolsPlugin,
21
22
  [uiPlugin.id]: uiPlugin,
22
23
  [approvalPlugin.id]: approvalPlugin,
24
+ [memoryPlugin.id]: memoryPlugin,
23
25
  };
24
26
  /** Normalize a dynamically imported plugin module. Supports `plugin`, `default`. */
25
27
  export function parsePluginModule(module) {