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.
@@ -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
- * The core engine that orchestrates context building.
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
- items.push(...providedItems);
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 implementation of a Context Engine with basic providers.
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
- if (events.length > 0) {
142
- const formattedEvents = events
143
- .slice(-20)
144
- .map((e) => `- ${e.type}: ${JSON.stringify((e as any).data || {})}`)
145
- .join('\n');
146
-
147
- items.push({
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${formattedEvents}`
152
- });
153
- }
245
+ content: `## ${threadId ? 'THREAD' : 'CHANNEL'} RECENT ACTIVITIES (events)\n${formatted}`,
246
+ },
247
+ ];
154
248
  } catch (error) {
155
- console.warn(`[ContextEngine] Failed to fetch events:`, error);
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 items;
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(context.state.shortTermMessages || []);
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 = typeof resultData === 'string' ? resultData : JSON.stringify(resultData);
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;
@@ -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
+ };