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.
@@ -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
+ };
@@ -7,6 +7,7 @@ import { aiSdkPlugin } from '../plugins/ai-sdk/index.js';
7
7
  import { AI_SDK_SYSTEM_PROMPT } from '../plugins/ai-sdk/system-prompt.js';
8
8
  import { listBuiltInPlugins, parsePluginModule } from '../registry/plugins.js';
9
9
  import { processService } from '../harness/process.js';
10
+ import { memoryService } from './memory.js';
10
11
  import { pathToFileURL } from 'node:url';
11
12
  const resolveBaseDir = () => {
12
13
  const config = loadConfig();
@@ -65,6 +66,7 @@ const SYSTEM_DEFAULT_PLUGINS = [
65
66
  { id: 'delegation' },
66
67
  // { id: 'ui' },
67
68
  { id: 'approval' },
69
+ { id: 'memory' },
68
70
  ];
69
71
  function getSystemAgentDetails(overrides) {
70
72
  const defaults = {
@@ -935,6 +937,10 @@ export const storageService = {
935
937
  }
936
938
  return fs.readFile(targetFile, 'utf-8');
937
939
  },
940
+ appendMemory: memoryService.appendMemory,
941
+ listMemories: memoryService.listMemories,
942
+ deleteMemory: memoryService.deleteMemory,
943
+ updateMemory: memoryService.updateMemory,
938
944
  /**
939
945
  * Hydrates the full OpenBot state from disk/storage before a run.
940
946
  */
package/docs/agents.md CHANGED
@@ -47,7 +47,21 @@ plugins like `shell` or `mcp` to them has no effect. Pair tool plugins with
47
47
 
48
48
  OpenBot ships a built-in `system` agent (the orchestrator) with the
49
49
  `ai-sdk` runtime plus the standard tool plugins (storage, shell, mcp,
50
- delegation, ui, approval). It cannot be deleted.
50
+ delegation, ui, approval, memory). It cannot be deleted.
51
+
52
+ ## Memory
53
+
54
+ The `memory` plugin gives every agent three tools — `remember`, `recall`,
55
+ `forget` — backed by an append-only JSONL log at `~/.openbot/memory/log.jsonl`.
56
+ Memories are scoped:
57
+
58
+ - `global` (default) — visible to every agent everywhere.
59
+ - `agent` — visible only to the agent that wrote it.
60
+ - `channel` — visible only inside the active channel.
61
+
62
+ On every LLM turn the runtime injects matching memories into the system prompt
63
+ via the `MemoryProvider` in the context engine, so the model treats remembered
64
+ facts as ground truth without needing to call `recall` first.
51
65
 
52
66
  ## Installing community agents
53
67
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openbot",
3
- "version": "0.3.0",
3
+ "version": "0.3.1",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "engines": {
package/src/app/cli.ts CHANGED
@@ -25,7 +25,7 @@ function checkNodeVersion() {
25
25
 
26
26
  checkNodeVersion();
27
27
 
28
- program.name('openbot').description('OpenBot CLI').version('0.3.0');
28
+ program.name('openbot').description('OpenBot CLI').version('0.3.1');
29
29
 
30
30
  program
31
31
  .command('start')
package/src/app/types.ts CHANGED
@@ -8,6 +8,7 @@ import {
8
8
  ThreadDetails,
9
9
  } from '../bus/types.js';
10
10
  import type { PluginRef } from '../bus/plugin.js';
11
+ import type { MemoryRecord } from '../services/memory.js';
11
12
 
12
13
  export interface OpenBotState {
13
14
  agentId: string;
@@ -766,6 +767,59 @@ export type InstallAgentResultEvent = BaseEvent & {
766
767
  };
767
768
  };
768
769
 
770
+ export type MemoryScopeAlias = 'global' | 'agent' | 'channel';
771
+
772
+ export type RememberEvent = BaseEvent & {
773
+ type: 'action:remember';
774
+ data: {
775
+ content: string;
776
+ scope?: MemoryScopeAlias;
777
+ tags?: string[];
778
+ };
779
+ };
780
+
781
+ export type RememberResultEvent = BaseEvent & {
782
+ type: 'action:remember:result';
783
+ data: {
784
+ success: boolean;
785
+ record?: MemoryRecord;
786
+ error?: string;
787
+ };
788
+ };
789
+
790
+ export type RecallEvent = BaseEvent & {
791
+ type: 'action:recall';
792
+ data: {
793
+ query?: string;
794
+ tag?: string;
795
+ scope?: MemoryScopeAlias | 'all';
796
+ limit?: number;
797
+ };
798
+ };
799
+
800
+ export type RecallResultEvent = BaseEvent & {
801
+ type: 'action:recall:result';
802
+ data: {
803
+ success: boolean;
804
+ records: MemoryRecord[];
805
+ error?: string;
806
+ };
807
+ };
808
+
809
+ export type ForgetEvent = BaseEvent & {
810
+ type: 'action:forget';
811
+ data: { id: string };
812
+ };
813
+
814
+ export type ForgetResultEvent = BaseEvent & {
815
+ type: 'action:forget:result';
816
+ data: {
817
+ success: boolean;
818
+ deleted: boolean;
819
+ error?: string;
820
+ };
821
+ };
822
+
769
823
  export type OpenBotEvent =
770
824
  | UserInputEvent
771
825
  | AgentInvokeEvent
@@ -842,4 +896,10 @@ export type OpenBotEvent =
842
896
  | ListMarketplaceAgentsEvent
843
897
  | ListMarketplaceAgentsResultEvent
844
898
  | InstallAgentEvent
845
- | InstallAgentResultEvent;
899
+ | InstallAgentResultEvent
900
+ | RememberEvent
901
+ | RememberResultEvent
902
+ | RecallEvent
903
+ | RecallResultEvent
904
+ | ForgetEvent
905
+ | ForgetResultEvent;
@@ -1,11 +1,43 @@
1
1
  import { MelonyPlugin } from 'melony';
2
2
  import { DEFAULT_MARKETPLACE_REGISTRY_URL, loadConfig } from '../app/config.js';
3
- import { OpenBotEvent, OpenBotState } from '../app/types.js';
3
+ import { OpenBotEvent, OpenBotState, MemoryScopeAlias } from '../app/types.js';
4
4
  import type { PluginRef } from './plugin.js';
5
5
  import { Storage } from './types.js';
6
6
  import { storageService } from '../services/storage.js';
7
7
  import { pluginService } from '../services/plugins.js';
8
8
 
9
+ /**
10
+ * Resolve a scope alias to a concrete scope string. Aliases let tools accept
11
+ * `agent`/`channel`/`global` without knowing the active ids; the bus rewrites
12
+ * them using `context.state`.
13
+ */
14
+ function resolveMemoryScope(
15
+ alias: MemoryScopeAlias | undefined,
16
+ state: OpenBotState,
17
+ ): string {
18
+ switch (alias) {
19
+ case 'agent':
20
+ return `agent:${state.agentId}`;
21
+ case 'channel':
22
+ return `channel:${state.channelId}`;
23
+ case 'global':
24
+ case undefined:
25
+ return 'global';
26
+ default:
27
+ return 'global';
28
+ }
29
+ }
30
+
31
+ function resolveMemoryScopeFilter(
32
+ alias: MemoryScopeAlias | 'all' | undefined,
33
+ state: OpenBotState,
34
+ ): string[] | undefined {
35
+ if (alias === 'all' || alias === undefined) {
36
+ return ['global', `agent:${state.agentId}`, `channel:${state.channelId}`];
37
+ }
38
+ return [resolveMemoryScope(alias, state)];
39
+ }
40
+
9
41
  /** One marketplace entry; matches `action:marketplace:list:result` agent shape. */
10
42
  export type MarketplaceAgentListing = {
11
43
  id: string;
@@ -156,7 +188,7 @@ export const busServicesPlugin =
156
188
  yield {
157
189
  type: 'action:create_thread:result',
158
190
  data: { success: true, threadId, threadTitle },
159
- meta: { threadId },
191
+ meta: { ...(event.meta || {}), threadId, agentId: context.state.agentId },
160
192
  } as OpenBotEvent;
161
193
  });
162
194
 
@@ -165,10 +197,13 @@ export const busServicesPlugin =
165
197
  const rawChannelId = (channelId || '').trim();
166
198
  const channelSpec = typeof spec === 'string' ? spec : '';
167
199
 
200
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
201
+
168
202
  if (!rawChannelId) {
169
203
  yield {
170
204
  type: 'action:create_channel:result',
171
205
  data: { success: false, channelId: '', channelUrl: '' },
206
+ meta: resultMeta,
172
207
  } as OpenBotEvent;
173
208
  return;
174
209
  }
@@ -186,20 +221,19 @@ export const busServicesPlugin =
186
221
  yield {
187
222
  type: 'action:create_channel:result',
188
223
  data: { success: true, channelId: rawChannelId, channelUrl },
224
+ meta: resultMeta,
189
225
  } as OpenBotEvent;
190
226
 
191
227
  yield {
192
228
  type: 'agent:output',
193
229
  data: { content: `Created channel \`${rawChannelId}\`.` },
194
- meta: {
195
- ...(event.meta || {}),
196
- agentId: context.state.agentId,
197
- },
230
+ meta: resultMeta,
198
231
  } as OpenBotEvent;
199
232
  } catch {
200
233
  yield {
201
234
  type: 'action:create_channel:result',
202
235
  data: { success: false, channelId: rawChannelId, channelUrl },
236
+ meta: resultMeta,
203
237
  } as OpenBotEvent;
204
238
  }
205
239
  });
@@ -207,11 +241,13 @@ export const busServicesPlugin =
207
241
  builder.on('action:update_channel', async function* (event, context) {
208
242
  const data = (event.data || {}) as { channelId?: string; name?: string; cwd?: string };
209
243
  const targetChannelId = (data.channelId || context.state.channelId || '').trim();
244
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
210
245
 
211
246
  if (!targetChannelId) {
212
247
  yield {
213
248
  type: 'action:update_channel:result',
214
249
  data: { success: false, channelId: '', updatedFields: [] as string[] },
250
+ meta: resultMeta,
215
251
  } as OpenBotEvent;
216
252
  return;
217
253
  }
@@ -242,17 +278,20 @@ export const busServicesPlugin =
242
278
  yield {
243
279
  type: 'action:update_channel:result',
244
280
  data: { success: true, channelId: targetChannelId, updatedFields },
281
+ meta: resultMeta,
245
282
  } as OpenBotEvent;
246
283
  } catch {
247
284
  yield {
248
285
  type: 'action:update_channel:result',
249
286
  data: { success: false, channelId: targetChannelId, updatedFields },
287
+ meta: resultMeta,
250
288
  } as OpenBotEvent;
251
289
  }
252
290
  });
253
291
 
254
292
  builder.on('action:patch_channel_details', async function* (event, context) {
255
293
  const updatedFields: ('state' | 'spec' | 'cwd')[] = [];
294
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
256
295
  try {
257
296
  if ((event.data as any).state !== undefined) {
258
297
  await storage.patchChannelState({
@@ -283,17 +322,20 @@ export const busServicesPlugin =
283
322
  yield {
284
323
  type: 'action:patch_channel_details:result',
285
324
  data: { success: true, updatedFields },
325
+ meta: resultMeta,
286
326
  };
287
327
  } catch {
288
328
  yield {
289
329
  type: 'action:patch_channel_details:result',
290
330
  data: { success: false, updatedFields },
331
+ meta: resultMeta,
291
332
  };
292
333
  }
293
334
  });
294
335
 
295
336
  builder.on('action:patch_thread_details', async function* (event, context) {
296
337
  const updatedFields: ('state' | 'spec')[] = [];
338
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
297
339
  try {
298
340
  if (!context.state.threadId) {
299
341
  throw new Error('Missing threadId in state for patch_thread_details');
@@ -323,11 +365,13 @@ export const busServicesPlugin =
323
365
  yield {
324
366
  type: 'action:patch_thread_details:result',
325
367
  data: { success: true, updatedFields },
368
+ meta: resultMeta,
326
369
  };
327
370
  } catch {
328
371
  yield {
329
372
  type: 'action:patch_thread_details:result',
330
373
  data: { success: false, updatedFields },
374
+ meta: resultMeta,
331
375
  };
332
376
  }
333
377
  });
@@ -615,6 +659,82 @@ export const busServicesPlugin =
615
659
  } as OpenBotEvent;
616
660
  });
617
661
 
662
+ builder.on('action:remember', async function* (event, context) {
663
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
664
+ try {
665
+ const { content, scope, tags } = event.data;
666
+ const record = await storage.appendMemory({
667
+ scope: resolveMemoryScope(scope, context.state),
668
+ content,
669
+ tags,
670
+ });
671
+ yield {
672
+ type: 'action:remember:result',
673
+ data: { success: true, record },
674
+ meta: resultMeta,
675
+ } as OpenBotEvent;
676
+ } catch (error) {
677
+ yield {
678
+ type: 'action:remember:result',
679
+ data: {
680
+ success: false,
681
+ error: error instanceof Error ? error.message : 'Unknown error',
682
+ },
683
+ meta: resultMeta,
684
+ } as OpenBotEvent;
685
+ }
686
+ });
687
+
688
+ builder.on('action:recall', async function* (event, context) {
689
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
690
+ try {
691
+ const { query, tag, scope, limit } = event.data;
692
+ const records = await storage.listMemories({
693
+ scopes: resolveMemoryScopeFilter(scope, context.state),
694
+ query,
695
+ tag,
696
+ limit,
697
+ });
698
+ yield {
699
+ type: 'action:recall:result',
700
+ data: { success: true, records },
701
+ meta: resultMeta,
702
+ } as OpenBotEvent;
703
+ } catch (error) {
704
+ yield {
705
+ type: 'action:recall:result',
706
+ data: {
707
+ success: false,
708
+ records: [],
709
+ error: error instanceof Error ? error.message : 'Unknown error',
710
+ },
711
+ meta: resultMeta,
712
+ } as OpenBotEvent;
713
+ }
714
+ });
715
+
716
+ builder.on('action:forget', async function* (event, context) {
717
+ const resultMeta = { ...(event.meta || {}), agentId: context.state.agentId };
718
+ try {
719
+ const deleted = await storage.deleteMemory({ id: event.data.id });
720
+ yield {
721
+ type: 'action:forget:result',
722
+ data: { success: true, deleted },
723
+ meta: resultMeta,
724
+ } as OpenBotEvent;
725
+ } catch (error) {
726
+ yield {
727
+ type: 'action:forget:result',
728
+ data: {
729
+ success: false,
730
+ deleted: false,
731
+ error: error instanceof Error ? error.message : 'Unknown error',
732
+ },
733
+ meta: resultMeta,
734
+ } as OpenBotEvent;
735
+ }
736
+ });
737
+
618
738
  builder.on('action:agent:install', async function* (event) {
619
739
  try {
620
740
  const { agentId, name, description, instructions, plugins } = event.data;
package/src/bus/types.ts CHANGED
@@ -1,5 +1,6 @@
1
1
  import type { OpenBotEvent } from '../app/types.js';
2
2
  import type { PluginRef } from './plugin.js';
3
+ import type { MemoryRecord, ListMemoriesArgs } from '../services/memory.js';
3
4
 
4
5
  /**
5
6
  * Public data types exposed by the OpenBot bus.
@@ -144,4 +145,16 @@ export interface Storage {
144
145
  path?: string;
145
146
  }) => Promise<Array<{ name: string; isDirectory: boolean }>>;
146
147
  readFile: (args: { channelId: string; path: string }) => Promise<string>;
148
+ /** Persist a memory record into the global memory log. */
149
+ appendMemory: (args: {
150
+ scope: string;
151
+ content: string;
152
+ tags?: string[];
153
+ }) => Promise<MemoryRecord>;
154
+ /** Read memories matching the given filter. */
155
+ listMemories: (args?: ListMemoriesArgs) => Promise<MemoryRecord[]>;
156
+ /** Soft-delete a memory by id. Returns true if a record was deleted. */
157
+ deleteMemory: (args: { id: string }) => Promise<boolean>;
158
+ /** Update a memory's content/tags by id. Returns true if a record was updated. */
159
+ updateMemory: (args: { id: string; content?: string; tags?: string[] }) => Promise<boolean>;
147
160
  }