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 +1 -1
- package/dist/bus/services.js +116 -5
- package/dist/harness/context.js +140 -21
- package/dist/plugins/ai-sdk/runtime.js +74 -2
- package/dist/plugins/memory/index.js +71 -0
- package/dist/registry/plugins.js +2 -0
- package/dist/services/memory.js +152 -0
- package/dist/services/storage.js +6 -0
- package/docs/agents.md +15 -1
- package/package.json +1 -1
- package/src/app/cli.ts +1 -1
- package/src/app/types.ts +61 -1
- package/src/bus/services.ts +126 -6
- package/src/bus/types.ts +13 -0
- package/src/harness/context.ts +162 -29
- package/src/plugins/ai-sdk/runtime.ts +76 -2
- package/src/plugins/memory/index.ts +85 -0
- package/src/registry/plugins.ts +2 -0
- package/src/services/memory.ts +213 -0
- package/src/services/storage.ts +7 -0
package/src/harness/context.ts
CHANGED
|
@@ -1,8 +1,16 @@
|
|
|
1
|
-
import { OpenBotState } from '../app/types.js';
|
|
1
|
+
import { OpenBotEvent, OpenBotState } from '../app/types.js';
|
|
2
2
|
import { Storage } from '../bus/types.js';
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
5
|
* Represents a piece of context that can be used in a prompt.
|
|
6
|
+
*
|
|
7
|
+
* Items flow through the engine in two phases:
|
|
8
|
+
* 1. Each registered `ContextProvider` emits zero or more items.
|
|
9
|
+
* 2. Each registered `ContextProcessor` may transform / drop / re-rank
|
|
10
|
+
* items (e.g. token-budget enforcement).
|
|
11
|
+
*
|
|
12
|
+
* Higher `priority` items appear first in the assembled prompt and are the
|
|
13
|
+
* last to be dropped under budget pressure.
|
|
6
14
|
*/
|
|
7
15
|
export interface ContextItem {
|
|
8
16
|
id: string;
|
|
@@ -12,25 +20,33 @@ export interface ContextItem {
|
|
|
12
20
|
metadata?: Record<string, any>;
|
|
13
21
|
}
|
|
14
22
|
|
|
15
|
-
/**
|
|
16
|
-
* A provider that can fetch or generate context items.
|
|
17
|
-
*/
|
|
18
23
|
export interface ContextProvider {
|
|
19
24
|
name: string;
|
|
20
25
|
provide(state: OpenBotState, storage?: Storage): Promise<ContextItem[]>;
|
|
21
26
|
}
|
|
22
27
|
|
|
23
|
-
/**
|
|
24
|
-
* A processor that can transform or filter context items (e.g., ranking, truncation).
|
|
25
|
-
*/
|
|
26
28
|
export interface ContextProcessor {
|
|
27
29
|
name: string;
|
|
28
30
|
process(items: ContextItem[], state: OpenBotState): Promise<ContextItem[]>;
|
|
29
31
|
}
|
|
30
32
|
|
|
31
33
|
/**
|
|
32
|
-
*
|
|
34
|
+
* Cheap, dependency-free token estimator. Roughly char/4 — fine for budget
|
|
35
|
+
* enforcement; can be swapped for a tokenizer-backed implementation later
|
|
36
|
+
* without touching providers.
|
|
37
|
+
*/
|
|
38
|
+
export const estimateTokens = (text: string): number =>
|
|
39
|
+
Math.ceil((text?.length ?? 0) / 4);
|
|
40
|
+
|
|
41
|
+
/**
|
|
42
|
+
* Hard cap (in characters) on a single context item. Keeps any one provider
|
|
43
|
+
* — typically the recent-events feed — from monopolising the prompt budget.
|
|
33
44
|
*/
|
|
45
|
+
const ITEM_HARD_CHAR_CAP = 6000;
|
|
46
|
+
|
|
47
|
+
const truncate = (text: string, maxChars: number): string =>
|
|
48
|
+
text.length <= maxChars ? text : `${text.slice(0, maxChars)}\n…[truncated]`;
|
|
49
|
+
|
|
34
50
|
export class ContextEngine {
|
|
35
51
|
private providers: ContextProvider[] = [];
|
|
36
52
|
private processors: ContextProcessor[] = [];
|
|
@@ -44,18 +60,18 @@ export class ContextEngine {
|
|
|
44
60
|
}
|
|
45
61
|
|
|
46
62
|
async buildContext(state: OpenBotState, storage?: Storage): Promise<string> {
|
|
47
|
-
// 1. Collect context from all providers
|
|
48
63
|
let items: ContextItem[] = [];
|
|
49
64
|
for (const provider of this.providers) {
|
|
50
65
|
try {
|
|
51
66
|
const providedItems = await provider.provide(state, storage);
|
|
52
|
-
|
|
67
|
+
for (const item of providedItems) {
|
|
68
|
+
items.push({ ...item, content: truncate(item.content, ITEM_HARD_CHAR_CAP) });
|
|
69
|
+
}
|
|
53
70
|
} catch (error) {
|
|
54
71
|
console.warn(`[ContextEngine] Provider ${provider.name} failed:`, error);
|
|
55
72
|
}
|
|
56
73
|
}
|
|
57
74
|
|
|
58
|
-
// 2. Run through processors
|
|
59
75
|
for (const processor of this.processors) {
|
|
60
76
|
try {
|
|
61
77
|
items = await processor.process(items, state);
|
|
@@ -64,26 +80,29 @@ export class ContextEngine {
|
|
|
64
80
|
}
|
|
65
81
|
}
|
|
66
82
|
|
|
67
|
-
// 3. Format items into a single string
|
|
68
83
|
return items
|
|
69
84
|
.sort((a, b) => b.priority - a.priority)
|
|
70
|
-
.map(item => item.content)
|
|
85
|
+
.map((item) => item.content)
|
|
71
86
|
.join('\n\n');
|
|
72
87
|
}
|
|
73
88
|
}
|
|
74
89
|
|
|
75
90
|
/**
|
|
76
|
-
* Default
|
|
91
|
+
* Default context engine. Order of providers is by emit order; final ordering
|
|
92
|
+
* in the prompt is determined by `priority`. The token-budget processor runs
|
|
93
|
+
* last so dropping happens after every provider has contributed.
|
|
77
94
|
*/
|
|
78
95
|
export function createDefaultContextEngine(): ContextEngine {
|
|
79
96
|
const engine = new ContextEngine();
|
|
80
97
|
|
|
81
|
-
// Basic Providers
|
|
82
98
|
engine.registerProvider(new AgentDetailsProvider());
|
|
83
99
|
engine.registerProvider(new ChannelDetailsProvider());
|
|
84
100
|
engine.registerProvider(new ThreadDetailsProvider());
|
|
101
|
+
engine.registerProvider(new MemoryProvider());
|
|
85
102
|
engine.registerProvider(new RecentEventsProvider());
|
|
86
103
|
|
|
104
|
+
engine.registerProcessor(new TokenBudgetProcessor());
|
|
105
|
+
|
|
87
106
|
return engine;
|
|
88
107
|
}
|
|
89
108
|
|
|
@@ -126,35 +145,149 @@ class ThreadDetailsProvider implements ContextProvider {
|
|
|
126
145
|
}
|
|
127
146
|
}
|
|
128
147
|
|
|
148
|
+
/**
|
|
149
|
+
* Fetches relevant memories (global + active agent + active channel) and
|
|
150
|
+
* surfaces them at high priority so the LLM treats them as ground truth
|
|
151
|
+
* rather than chat history.
|
|
152
|
+
*/
|
|
153
|
+
class MemoryProvider implements ContextProvider {
|
|
154
|
+
name = 'memory';
|
|
155
|
+
async provide(state: OpenBotState, storage?: Storage): Promise<ContextItem[]> {
|
|
156
|
+
if (!storage?.listMemories) return [];
|
|
157
|
+
|
|
158
|
+
try {
|
|
159
|
+
const scopes = ['global', `agent:${state.agentId}`];
|
|
160
|
+
if (state.channelId) scopes.push(`channel:${state.channelId}`);
|
|
161
|
+
|
|
162
|
+
const records = await storage.listMemories({ scopes, limit: 50 });
|
|
163
|
+
if (records.length === 0) return [];
|
|
164
|
+
|
|
165
|
+
const formatted = records
|
|
166
|
+
.map((r) => {
|
|
167
|
+
const tags = r.tags?.length ? ` [${r.tags.join(', ')}]` : '';
|
|
168
|
+
const scopeLabel = r.scope === 'global' ? 'global' : r.scope;
|
|
169
|
+
return `- (${scopeLabel}${tags}) ${r.content}`;
|
|
170
|
+
})
|
|
171
|
+
.join('\n');
|
|
172
|
+
|
|
173
|
+
return [
|
|
174
|
+
{
|
|
175
|
+
id: 'memory',
|
|
176
|
+
type: 'memory',
|
|
177
|
+
priority: 95,
|
|
178
|
+
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}`,
|
|
179
|
+
},
|
|
180
|
+
];
|
|
181
|
+
} catch (error) {
|
|
182
|
+
console.warn('[ContextEngine] MemoryProvider failed:', error);
|
|
183
|
+
return [];
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* Event types we omit from the recent-events context block. They duplicate
|
|
190
|
+
* information already in the conversation history, are infrastructural
|
|
191
|
+
* noise, or are too large to be useful as a tail summary.
|
|
192
|
+
*/
|
|
193
|
+
const NOISY_EVENT_PREFIXES = [
|
|
194
|
+
'agent:invoke',
|
|
195
|
+
'agent:output',
|
|
196
|
+
'agent:run',
|
|
197
|
+
'agent:active-runs',
|
|
198
|
+
'client:ui',
|
|
199
|
+
'stream:',
|
|
200
|
+
'action:storage:get-',
|
|
201
|
+
'action:storage:patch-',
|
|
202
|
+
];
|
|
203
|
+
|
|
204
|
+
const MAX_RECENT_EVENTS = 20;
|
|
205
|
+
const MAX_EVENT_DATA_CHARS = 300;
|
|
206
|
+
|
|
207
|
+
const isNoisyEvent = (event: OpenBotEvent): boolean =>
|
|
208
|
+
NOISY_EVENT_PREFIXES.some((prefix) => event.type.startsWith(prefix));
|
|
209
|
+
|
|
210
|
+
const summarizeEvent = (event: OpenBotEvent): string => {
|
|
211
|
+
const data = (event as { data?: unknown }).data;
|
|
212
|
+
if (data === undefined) return `- ${event.type}`;
|
|
213
|
+
let payload: string;
|
|
214
|
+
try {
|
|
215
|
+
payload = typeof data === 'string' ? data : JSON.stringify(data);
|
|
216
|
+
} catch {
|
|
217
|
+
payload = '[unserialisable]';
|
|
218
|
+
}
|
|
219
|
+
if (payload.length > MAX_EVENT_DATA_CHARS) {
|
|
220
|
+
payload = `${payload.slice(0, MAX_EVENT_DATA_CHARS)}…`;
|
|
221
|
+
}
|
|
222
|
+
return `- ${event.type}: ${payload}`;
|
|
223
|
+
};
|
|
224
|
+
|
|
129
225
|
class RecentEventsProvider implements ContextProvider {
|
|
130
226
|
name = 'recent-events';
|
|
131
227
|
async provide(state: OpenBotState, storage?: Storage): Promise<ContextItem[]> {
|
|
132
228
|
if (!storage) return [];
|
|
133
|
-
const items: ContextItem[] = [];
|
|
134
229
|
|
|
135
|
-
// Fetch channel events if no thread, otherwise fetch thread events
|
|
136
230
|
const channelId = state.channelId;
|
|
137
231
|
const threadId = state.threadId;
|
|
138
232
|
|
|
139
233
|
try {
|
|
140
234
|
const events = await storage.getEvents({ channelId, threadId });
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
235
|
+
const filtered = events.filter((e) => !isNoisyEvent(e));
|
|
236
|
+
if (filtered.length === 0) return [];
|
|
237
|
+
|
|
238
|
+
const formatted = filtered.slice(-MAX_RECENT_EVENTS).map(summarizeEvent).join('\n');
|
|
239
|
+
|
|
240
|
+
return [
|
|
241
|
+
{
|
|
148
242
|
id: threadId ? 'thread-events' : 'channel-events',
|
|
149
243
|
type: 'events',
|
|
150
244
|
priority: 70,
|
|
151
|
-
content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${
|
|
152
|
-
}
|
|
153
|
-
|
|
245
|
+
content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formatted}`,
|
|
246
|
+
},
|
|
247
|
+
];
|
|
154
248
|
} catch (error) {
|
|
155
|
-
console.warn(
|
|
249
|
+
console.warn('[ContextEngine] Failed to fetch events:', error);
|
|
250
|
+
return [];
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Drops the lowest-priority items until the assembled prompt fits within the
|
|
257
|
+
* token budget. The first item with priority >= `keepFloor` is always kept,
|
|
258
|
+
* so the agent's own instructions can never be evicted. Stable on ties:
|
|
259
|
+
* later-emitted items go first.
|
|
260
|
+
*/
|
|
261
|
+
export class TokenBudgetProcessor implements ContextProcessor {
|
|
262
|
+
name = 'token-budget';
|
|
263
|
+
/** Soft prompt budget in tokens (matches gpt-4o-mini's reasonable system slice). */
|
|
264
|
+
static DEFAULT_BUDGET = 8000;
|
|
265
|
+
/** Items at or above this priority are never dropped. */
|
|
266
|
+
static KEEP_FLOOR = 100;
|
|
267
|
+
|
|
268
|
+
constructor(
|
|
269
|
+
private budget: number = TokenBudgetProcessor.DEFAULT_BUDGET,
|
|
270
|
+
private keepFloor: number = TokenBudgetProcessor.KEEP_FLOOR,
|
|
271
|
+
) {}
|
|
272
|
+
|
|
273
|
+
async process(items: ContextItem[]): Promise<ContextItem[]> {
|
|
274
|
+
const sorted = [...items].sort((a, b) => b.priority - a.priority);
|
|
275
|
+
const out: ContextItem[] = [];
|
|
276
|
+
let used = 0;
|
|
277
|
+
|
|
278
|
+
for (const item of sorted) {
|
|
279
|
+
const cost = estimateTokens(item.content);
|
|
280
|
+
if (item.priority >= this.keepFloor) {
|
|
281
|
+
out.push(item);
|
|
282
|
+
used += cost;
|
|
283
|
+
continue;
|
|
284
|
+
}
|
|
285
|
+
if (used + cost <= this.budget) {
|
|
286
|
+
out.push(item);
|
|
287
|
+
used += cost;
|
|
288
|
+
}
|
|
156
289
|
}
|
|
157
290
|
|
|
158
|
-
return
|
|
291
|
+
return out;
|
|
159
292
|
}
|
|
160
293
|
}
|
|
@@ -42,6 +42,78 @@ const asRecord = (value: unknown): Record<string, unknown> =>
|
|
|
42
42
|
? (value as Record<string, unknown>)
|
|
43
43
|
: {};
|
|
44
44
|
|
|
45
|
+
/** Per-message hard cap (in characters) on tool-result payloads we feed back
|
|
46
|
+
* to the model. Prevents one huge tool output from eating the context window;
|
|
47
|
+
* the original event remains intact in storage. */
|
|
48
|
+
const TOOL_RESULT_MAX_CHARS = 8000;
|
|
49
|
+
|
|
50
|
+
/** Sliding window: max number of messages we replay to the model on each
|
|
51
|
+
* invocation. Older turns stay on disk but are not sent. Keeps both the
|
|
52
|
+
* recent prompts and the prompt token budget bounded. */
|
|
53
|
+
const MAX_WINDOW_MESSAGES = 80;
|
|
54
|
+
|
|
55
|
+
const truncateToolPayload = (raw: unknown): string => {
|
|
56
|
+
const serialized = typeof raw === 'string' ? raw : JSON.stringify(raw);
|
|
57
|
+
if (serialized.length <= TOOL_RESULT_MAX_CHARS) return serialized;
|
|
58
|
+
const dropped = serialized.length - TOOL_RESULT_MAX_CHARS;
|
|
59
|
+
return `${serialized.slice(0, TOOL_RESULT_MAX_CHARS)}\n…[truncated ${dropped} chars]`;
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Trim the message history to a sliding window while preserving tool-call
|
|
64
|
+
* integrity. Drops any leading orphan `tool` messages whose matching
|
|
65
|
+
* assistant call was sliced off, since most providers reject that.
|
|
66
|
+
*/
|
|
67
|
+
const buildMessageWindow = (messages: ShortTermMessage[]): ShortTermMessage[] => {
|
|
68
|
+
if (messages.length <= MAX_WINDOW_MESSAGES) return messages;
|
|
69
|
+
const tail = messages.slice(-MAX_WINDOW_MESSAGES);
|
|
70
|
+
const knownAssistantCallIds = new Set<string>();
|
|
71
|
+
for (const m of tail) {
|
|
72
|
+
if (m.role === 'assistant' && m.toolCalls) {
|
|
73
|
+
for (const tc of m.toolCalls) knownAssistantCallIds.add(tc.id);
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return tail.filter((m) => m.role !== 'tool' || knownAssistantCallIds.has(m.toolCallId));
|
|
77
|
+
};
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Self-healing pass: every assistant tool_call must have a matching tool
|
|
81
|
+
* result before the next user/assistant turn, or providers (OpenAI in
|
|
82
|
+
* particular) reject the request with "Tool result is missing for tool call".
|
|
83
|
+
*
|
|
84
|
+
* This can happen when a handler emits a `:result` event without `meta`
|
|
85
|
+
* (orphaning the call), the process restarts mid-run, or a tool handler
|
|
86
|
+
* crashes. Rather than refuse to continue, we inject synthetic tool messages
|
|
87
|
+
* with a clear error payload — the LLM can then explain the failure to the
|
|
88
|
+
* user and proceed.
|
|
89
|
+
*/
|
|
90
|
+
const repairOpenToolCalls = (messages: ShortTermMessage[]): ShortTermMessage[] => {
|
|
91
|
+
const fulfilled = new Set<string>();
|
|
92
|
+
for (const m of messages) {
|
|
93
|
+
if (m.role === 'tool') fulfilled.add(m.toolCallId);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const repaired: ShortTermMessage[] = [];
|
|
97
|
+
for (const m of messages) {
|
|
98
|
+
repaired.push(m);
|
|
99
|
+
if (m.role !== 'assistant' || !m.toolCalls) continue;
|
|
100
|
+
for (const tc of m.toolCalls) {
|
|
101
|
+
if (fulfilled.has(tc.id)) continue;
|
|
102
|
+
repaired.push({
|
|
103
|
+
role: 'tool',
|
|
104
|
+
toolCallId: tc.id,
|
|
105
|
+
toolName: tc.function.name,
|
|
106
|
+
content: JSON.stringify({
|
|
107
|
+
success: false,
|
|
108
|
+
error: 'Tool result was lost (handler did not emit a matching :result event).',
|
|
109
|
+
}),
|
|
110
|
+
});
|
|
111
|
+
fulfilled.add(tc.id);
|
|
112
|
+
}
|
|
113
|
+
}
|
|
114
|
+
return repaired;
|
|
115
|
+
};
|
|
116
|
+
|
|
45
117
|
const readPersistedShortTermMessages = (state: OpenBotState): ShortTermMessage[] => {
|
|
46
118
|
const source = state.threadDetails?.state ?? state.channelDetails?.state;
|
|
47
119
|
const record = asRecord(source);
|
|
@@ -161,7 +233,9 @@ export const aiSdkRuntime =
|
|
|
161
233
|
contextEngine,
|
|
162
234
|
);
|
|
163
235
|
|
|
164
|
-
const coreMessages = mapToCoreMessages(
|
|
236
|
+
const coreMessages = mapToCoreMessages(
|
|
237
|
+
buildMessageWindow(repairOpenToolCalls(context.state.shortTermMessages || [])),
|
|
238
|
+
);
|
|
165
239
|
|
|
166
240
|
try {
|
|
167
241
|
const result = await generateText({
|
|
@@ -311,7 +385,7 @@ export const aiSdkRuntime =
|
|
|
311
385
|
|
|
312
386
|
const toolName = event.type.replace(/^action:/, '').replace(/:result$/, '');
|
|
313
387
|
const resultData = (event as { data?: unknown }).data;
|
|
314
|
-
const content =
|
|
388
|
+
const content = truncateToolPayload(resultData);
|
|
315
389
|
|
|
316
390
|
context.state.shortTermMessages = [
|
|
317
391
|
...(context.state.shortTermMessages ?? []),
|
|
@@ -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;
|
package/src/registry/plugins.ts
CHANGED
|
@@ -9,6 +9,7 @@ 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';
|
|
12
13
|
import { DEFAULT_PLUGINS_DIR, DEFAULT_BASE_DIR, loadConfig, resolvePath } from '../app/config.js';
|
|
13
14
|
|
|
14
15
|
let pluginsDir: string | null = null;
|
|
@@ -23,6 +24,7 @@ const BUILT_IN: Record<string, Plugin> = {
|
|
|
23
24
|
[storageToolsPlugin.id]: storageToolsPlugin,
|
|
24
25
|
[uiPlugin.id]: uiPlugin,
|
|
25
26
|
[approvalPlugin.id]: approvalPlugin,
|
|
27
|
+
[memoryPlugin.id]: memoryPlugin,
|
|
26
28
|
};
|
|
27
29
|
|
|
28
30
|
/**
|
|
@@ -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
|
+
};
|