openbot 0.2.2 → 0.2.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (49) hide show
  1. package/README.md +1 -1
  2. package/dist/agents/agent-creator.js +58 -19
  3. package/dist/agents/os-agent.js +1 -4
  4. package/dist/agents/planner-agent.js +32 -0
  5. package/dist/agents/topic-agent.js +1 -1
  6. package/dist/architecture/contracts.js +1 -0
  7. package/dist/architecture/execution-engine.js +151 -0
  8. package/dist/architecture/intent-classifier.js +26 -0
  9. package/dist/architecture/planner.js +106 -0
  10. package/dist/automation-worker.js +121 -0
  11. package/dist/automations.js +52 -0
  12. package/dist/cli.js +54 -141
  13. package/dist/config.js +20 -0
  14. package/dist/core/agents.js +41 -0
  15. package/dist/core/delegation.js +124 -0
  16. package/dist/core/manager.js +73 -0
  17. package/dist/core/plugins.js +77 -0
  18. package/dist/core/router.js +40 -0
  19. package/dist/installers.js +170 -0
  20. package/dist/marketplace.js +80 -0
  21. package/dist/open-bot.js +34 -157
  22. package/dist/orchestrator.js +247 -51
  23. package/dist/plugins/approval/index.js +107 -3
  24. package/dist/plugins/brain/index.js +17 -86
  25. package/dist/plugins/brain/memory.js +1 -1
  26. package/dist/plugins/brain/prompt.js +8 -13
  27. package/dist/plugins/brain/types.js +0 -15
  28. package/dist/plugins/file-system/index.js +8 -8
  29. package/dist/plugins/llm/context-shaping.js +177 -0
  30. package/dist/plugins/llm/index.js +223 -49
  31. package/dist/plugins/memory/index.js +220 -0
  32. package/dist/plugins/memory/memory.js +122 -0
  33. package/dist/plugins/memory/prompt.js +55 -0
  34. package/dist/plugins/memory/types.js +45 -0
  35. package/dist/plugins/shell/index.js +3 -3
  36. package/dist/plugins/skills/index.js +9 -9
  37. package/dist/registry/index.js +1 -4
  38. package/dist/registry/plugin-loader.js +339 -56
  39. package/dist/registry/plugin-registry.js +21 -4
  40. package/dist/registry/ts-agent-loader.js +4 -4
  41. package/dist/registry/yaml-agent-loader.js +78 -20
  42. package/dist/runtime/execution-trace.js +41 -0
  43. package/dist/runtime/intent-routing.js +26 -0
  44. package/dist/runtime/openbot-runtime.js +354 -0
  45. package/dist/server.js +549 -31
  46. package/dist/ui/widgets/approval-card.js +22 -2
  47. package/dist/ui/widgets/delegation.js +29 -0
  48. package/dist/version.js +62 -0
  49. package/package.json +8 -7
@@ -1,47 +1,154 @@
1
1
  import { streamText } from "ai";
2
- /**
3
- * Builds a simple history summary from recent messages.
4
- * Keeps the last N messages as simple role/content pairs.
5
- */
6
- function getRecentHistory(messages, maxMessages) {
7
- return messages.slice(-maxMessages);
2
+ async function toModelMessages(messages) {
3
+ return messages.map((message) => {
4
+ if (message.role === "tool") {
5
+ return {
6
+ role: "tool",
7
+ content: Array.isArray(message.content)
8
+ ? message.content.map((c) => {
9
+ const result = c.result ?? c.output?.value ?? c.output;
10
+ return {
11
+ type: "tool-result",
12
+ toolCallId: c.toolCallId,
13
+ toolName: c.toolName,
14
+ output: typeof result === "string"
15
+ ? { type: "text", value: result }
16
+ : { type: "json", value: result },
17
+ };
18
+ })
19
+ : [],
20
+ };
21
+ }
22
+ if (message.role === "assistant") {
23
+ if (Array.isArray(message.content)) {
24
+ return {
25
+ role: "assistant",
26
+ content: message.content.map((c) => {
27
+ if (c.type === "tool-call") {
28
+ return {
29
+ type: "tool-call",
30
+ toolCallId: c.toolCallId,
31
+ toolName: c.toolName,
32
+ input: c.input,
33
+ };
34
+ }
35
+ if (c.type === "text") {
36
+ return c;
37
+ }
38
+ // Fallback for character spread bug fix
39
+ if (typeof c === "string") {
40
+ return { type: "text", text: c };
41
+ }
42
+ return c;
43
+ }),
44
+ };
45
+ }
46
+ return {
47
+ role: "assistant",
48
+ content: message.content,
49
+ };
50
+ }
51
+ if (message.role === "user") {
52
+ if (message.attachments && message.attachments.length > 0) {
53
+ return {
54
+ role: "user",
55
+ content: [
56
+ { type: "text", text: message.content },
57
+ ...message.attachments.map((a) => {
58
+ if (a.mimeType.startsWith("image/")) {
59
+ return {
60
+ type: "image",
61
+ image: a.url,
62
+ };
63
+ }
64
+ return {
65
+ type: "file",
66
+ data: a.url,
67
+ mimeType: a.mimeType,
68
+ };
69
+ }),
70
+ ],
71
+ };
72
+ }
73
+ return {
74
+ role: "user",
75
+ content: message.content,
76
+ };
77
+ }
78
+ return {
79
+ role: message.role,
80
+ content: message.content,
81
+ };
82
+ });
8
83
  }
9
- async function buildMessageContent(message) {
10
- if (!message.attachments?.length)
11
- return message.content;
12
- const parts = [];
13
- const trimmed = message.content.trim();
14
- if (trimmed) {
15
- parts.push({ type: "text", text: trimmed });
16
- }
17
- for (const attachment of message.attachments) {
18
- if (!attachment?.mimeType?.startsWith("image/"))
19
- continue;
20
- if (!attachment.url)
21
- continue;
22
- try {
23
- const response = await fetch(attachment.url);
24
- if (!response.ok)
25
- continue;
26
- const bytes = await response.arrayBuffer();
27
- parts.push({
28
- type: "image",
29
- image: Buffer.from(bytes),
30
- mimeType: attachment.mimeType,
84
+ // Helper to find pending tool calls in history
85
+ function getPendingToolCalls(messages) {
86
+ const toolResults = new Set();
87
+ let lastAssistantWithTools = null;
88
+ for (let i = 0; i < messages.length; i++) {
89
+ const msg = messages[i];
90
+ if (msg.role === "tool" && Array.isArray(msg.content)) {
91
+ msg.content.forEach((c) => {
92
+ if (c.toolCallId)
93
+ toolResults.add(c.toolCallId);
31
94
  });
32
95
  }
33
- catch {
34
- // Best-effort multimodal handling: skip failed image fetches.
96
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
97
+ if (msg.content.some((c) => c.type === "tool-call")) {
98
+ lastAssistantWithTools = msg;
99
+ }
35
100
  }
36
101
  }
37
- return parts.length > 0 ? parts : message.content;
102
+ if (lastAssistantWithTools) {
103
+ return lastAssistantWithTools.content.filter((c) => c.type === "tool-call" && !toolResults.has(c.toolCallId));
104
+ }
105
+ return [];
38
106
  }
39
- async function toModelMessages(messages) {
40
- const built = await Promise.all(messages.map(async (message) => ({
41
- role: message.role,
42
- content: await buildMessageContent(message),
43
- })));
44
- return built;
107
+ // Helper to insert tool result message in correct position (immediately after corresponding assistant msg)
108
+ function insertToolResult(messages, toolResultMsg) {
109
+ if (toolResultMsg.role !== "tool" || !Array.isArray(toolResultMsg.content))
110
+ return;
111
+ const toolCallId = toolResultMsg.content[0]?.toolCallId;
112
+ if (!toolCallId)
113
+ return;
114
+ // Find the assistant message that called this tool
115
+ let assistantIdx = -1;
116
+ for (let i = messages.length - 1; i >= 0; i--) {
117
+ const msg = messages[i];
118
+ if (msg.role === "assistant" && Array.isArray(msg.content)) {
119
+ if (msg.content.some((c) => c.toolCallId === toolCallId)) {
120
+ assistantIdx = i;
121
+ break;
122
+ }
123
+ }
124
+ }
125
+ if (assistantIdx !== -1) {
126
+ // Find if there's already a tool message after this assistant message
127
+ let toolMsgIdx = -1;
128
+ for (let i = assistantIdx + 1; i < messages.length; i++) {
129
+ if (messages[i].role === "tool") {
130
+ toolMsgIdx = i;
131
+ break;
132
+ }
133
+ if (messages[i].role === "assistant")
134
+ break;
135
+ }
136
+ if (toolMsgIdx !== -1) {
137
+ // Merge into existing tool message
138
+ const toolMsg = messages[toolMsgIdx];
139
+ if (Array.isArray(toolMsg.content)) {
140
+ toolMsg.content.push(...toolResultMsg.content);
141
+ }
142
+ }
143
+ else {
144
+ // Insert new tool message after assistant
145
+ messages.splice(assistantIdx + 1, 0, toolResultMsg);
146
+ }
147
+ }
148
+ else {
149
+ // Fallback: push to end
150
+ messages.push(toolResultMsg);
151
+ }
45
152
  }
46
153
  /**
47
154
  * LLM Plugin for Melony.
@@ -49,32 +156,76 @@ async function toModelMessages(messages) {
49
156
  * It can also automatically trigger events based on tool calls.
50
157
  */
51
158
  export const llmPlugin = (options) => (builder) => {
52
- const { model, system, toolDefinitions = {}, actionEventPrefix = "action:", promptInputType = "user:text", actionResultInputType = "action:taskResult", completionEventType, usageEventType = "usage:update", usageScope = "default", modelId, } = options;
159
+ const { model, system, toolDefinitions = {}, actionEventPrefix = "action:", promptInputType = "agent:input", actionResultInputType = "action:result", completionEventType = "agent:output", usageEventType = "usage:update", usageScope = "default", modelId, } = options;
53
160
  async function* routeToLLM(newMessage, context, silent = false) {
54
161
  const state = context.state;
55
162
  if (!state.messages) {
56
163
  state.messages = [];
57
164
  }
58
- // Add new message to history
59
- state.messages.push(newMessage);
165
+ // 1. Add new message to history with correct positioning and unblocking logic.
166
+ if (newMessage) {
167
+ if (newMessage.role === "tool") {
168
+ insertToolResult(state.messages, newMessage);
169
+ }
170
+ else {
171
+ // If a new user message arrives while tools are pending, we unblock by failing the tools.
172
+ if (newMessage.role === "user") {
173
+ const pending = getPendingToolCalls(state.messages);
174
+ if (pending.length > 0) {
175
+ console.warn(`User message received while tools are pending. Failing pending tools to unblock: ${pending
176
+ .map((p) => p.toolName)
177
+ .join(", ")}`);
178
+ for (const p of pending) {
179
+ insertToolResult(state.messages, {
180
+ role: "tool",
181
+ content: [
182
+ {
183
+ type: "tool-result",
184
+ toolCallId: p.toolCallId,
185
+ toolName: p.toolName,
186
+ output: {
187
+ type: "text",
188
+ value: "Tool failed to respond in time (unblocked by user message).",
189
+ },
190
+ },
191
+ ],
192
+ });
193
+ }
194
+ }
195
+ }
196
+ state.messages.push(newMessage);
197
+ }
198
+ }
199
+ // 2. Check for pending tool calls. We MUST have results for all tool calls before continuing.
200
+ const pending = getPendingToolCalls(state.messages);
201
+ if (pending.length > 0) {
202
+ console.log(`Waiting for ${pending.length} pending tool results: ${pending
203
+ .map((p) => p.toolName)
204
+ .join(", ")}`);
205
+ return;
206
+ }
60
207
  // Evaluate dynamic system prompt if it's a function
61
208
  const systemPrompt = typeof system === "function" ? await system(context) : system;
62
- const recentMessages = getRecentHistory(state.messages, 20);
63
- const modelMessages = await toModelMessages(recentMessages);
209
+ const modelMessages = await toModelMessages(state.messages);
64
210
  // Initialize an empty assistant message to be populated as we stream
65
211
  const assistantMessage = { role: "assistant", content: "" };
66
212
  state.messages.push(assistantMessage);
213
+ // console.log("messages:::::", JSON.stringify(state.messages, null, 2));
214
+ // console.log("modelMessages:::::", JSON.stringify(modelMessages, null, 2));
67
215
  const result = streamText({
68
216
  model,
69
217
  system: systemPrompt,
70
218
  messages: modelMessages,
71
219
  tools: toolDefinitions,
220
+ onError: (error) => {
221
+ console.error("streamText error:::::", JSON.stringify(error, null, 2));
222
+ },
72
223
  });
73
224
  for await (const delta of result.textStream) {
74
225
  assistantMessage.content += delta;
75
226
  if (!silent) {
76
227
  yield {
77
- type: "assistant:text-delta",
228
+ type: "agent:output-delta",
78
229
  data: { delta, content: assistantMessage.content },
79
230
  };
80
231
  }
@@ -82,8 +233,21 @@ export const llmPlugin = (options) => (builder) => {
82
233
  const assistantText = assistantMessage.content;
83
234
  // Wait for tool calls to complete
84
235
  const toolCalls = await result.toolCalls;
236
+ if (toolCalls && toolCalls.length > 0) {
237
+ const parts = [];
238
+ if (assistantText) {
239
+ parts.push({ type: "text", text: assistantText });
240
+ }
241
+ parts.push(...toolCalls.map((c) => ({
242
+ type: "tool-call",
243
+ toolCallId: c.toolCallId,
244
+ toolName: c.toolName,
245
+ input: c.input,
246
+ })));
247
+ assistantMessage.content = parts;
248
+ }
85
249
  // Remove the message if it's empty (e.g. only tool calls)
86
- if (!assistantText) {
250
+ if (!assistantText && (!toolCalls || toolCalls.length === 0)) {
87
251
  state.messages = state.messages.filter((m) => m !== assistantMessage);
88
252
  }
89
253
  else {
@@ -141,12 +305,22 @@ export const llmPlugin = (options) => (builder) => {
141
305
  const attachments = Array.isArray(event.data?.attachments) ? event.data.attachments : undefined;
142
306
  yield* routeToLLM({ role: "user", content, attachments }, context);
143
307
  });
144
- // Feed action results back to the LLM as user messages (with a System prefix)
145
- // We use "user" role instead of "system" to avoid errors with providers like Anthropic
146
- // that don't support multiple system messages or system messages after the first turn.
308
+ // Feed action results back as system-role feedback to the model.
147
309
  builder.on(actionResultInputType, async function* (event, context) {
148
- const { action, result } = event.data;
310
+ const { action, result, toolCallId } = event.data;
311
+ const normalizedAction = typeof action === "string" ? action : "unknown";
149
312
  const summary = typeof result === "string" ? result : JSON.stringify(result);
150
- yield* routeToLLM({ role: "user", content: `System: Action "${action}" completed: ${summary}` }, context);
313
+ yield* routeToLLM({
314
+ role: "tool",
315
+ content: [{
316
+ type: 'tool-result',
317
+ toolCallId,
318
+ toolName: normalizedAction,
319
+ output: {
320
+ type: 'text',
321
+ value: summary,
322
+ },
323
+ }],
324
+ }, context);
151
325
  });
152
326
  };
@@ -0,0 +1,220 @@
1
+ import { ui } from "@melony/ui-kit/server";
2
+ import * as fs from "node:fs/promises";
3
+ import * as path from "node:path";
4
+ import { createMemoryModule } from "./memory.js";
5
+ import { buildMemoryPrompt } from "./prompt.js";
6
+ import { statusWidget } from "../../ui/widgets/status.js";
7
+ // Re-exports
8
+ export { memoryToolDefinitions } from "./types.js";
9
+ export { buildMemoryPrompt } from "./prompt.js";
10
+ function expandPath(p) {
11
+ if (p.startsWith("~/")) {
12
+ return path.join(process.env.HOME || "", p.slice(2));
13
+ }
14
+ return p;
15
+ }
16
+ const DEFAULT_AGENT_MD = `# Agent Profile
17
+
18
+ You are the Manager Agent, the central orchestrator of this AI system.
19
+ Your role is to analyze user intent, manage long-term memory, and coordinate specialized agents to solve complex tasks.
20
+
21
+ ## Persona
22
+ - Professional yet approachable
23
+ - Highly organized and efficient
24
+ - Focused on providing clear, actionable results
25
+ `;
26
+ /**
27
+ * Create a prompt-builder function bound to a baseDir.
28
+ * Returns the memory's portion of the system prompt (agent definition + memory).
29
+ */
30
+ export function createMemoryPromptBuilder(baseDir) {
31
+ const expandedBase = expandPath(baseDir);
32
+ const modules = {
33
+ memory: createMemoryModule(expandedBase),
34
+ };
35
+ return async (context) => buildMemoryPrompt(expandedBase, modules, context);
36
+ }
37
+ // --- Plugin ---
38
+ /**
39
+ * Memory Plugin for Melony
40
+ *
41
+ * Provides the bot's "memory": agent definition and long-term memory with recall.
42
+ * Skills are managed by the separate skills plugin.
43
+ */
44
+ export const memoryPlugin = (options) => (builder) => {
45
+ const { baseDir } = options;
46
+ const expandedBase = expandPath(baseDir);
47
+ // Create sub-modules
48
+ const memory = createMemoryModule(expandedBase);
49
+ // ─── Initialization ───────────────────────────────────────────────
50
+ builder.on("init", async function* (_event, _context) {
51
+ yield {
52
+ type: "memory:status",
53
+ data: { message: "Initializing memory..." },
54
+ };
55
+ await fs.mkdir(expandedBase, { recursive: true, mode: 0o700 });
56
+ // Initialize AGENT.md if it doesn't exist
57
+ const agentPath = path.join(expandedBase, "AGENT.md");
58
+ try {
59
+ await fs.access(agentPath);
60
+ }
61
+ catch {
62
+ await fs.writeFile(agentPath, DEFAULT_AGENT_MD, "utf-8");
63
+ }
64
+ await memory.initialize();
65
+ yield {
66
+ type: "memory:status",
67
+ data: { message: "Memory initialized", severity: "success" },
68
+ };
69
+ });
70
+ // ─── Memory: Remember ─────────────────────────────────────────────
71
+ builder.on("action:remember", async function* (event) {
72
+ const { content, tags = [], toolCallId } = event.data;
73
+ try {
74
+ const entry = await memory.store(content, tags);
75
+ yield {
76
+ type: "memory:status",
77
+ data: { message: "Remembered", severity: "success" },
78
+ };
79
+ yield {
80
+ type: "action:result",
81
+ data: {
82
+ action: "remember",
83
+ toolCallId,
84
+ result: {
85
+ success: true,
86
+ memoryId: entry.id,
87
+ message: `Stored in memory with id ${entry.id}`,
88
+ },
89
+ },
90
+ };
91
+ }
92
+ catch (error) {
93
+ yield {
94
+ type: "memory:status",
95
+ data: {
96
+ message: `Failed to remember: ${error.message}`,
97
+ severity: "error",
98
+ },
99
+ };
100
+ yield {
101
+ type: "action:result",
102
+ data: {
103
+ action: "remember",
104
+ toolCallId,
105
+ result: { error: error.message },
106
+ },
107
+ };
108
+ }
109
+ });
110
+ // ─── Memory: Recall ────────────────────────────────────────────────
111
+ builder.on("action:recall", async function* (event) {
112
+ const { query, tags, limit, toolCallId } = event.data;
113
+ try {
114
+ const results = await memory.recall(query, { tags, limit });
115
+ yield {
116
+ type: "action:result",
117
+ data: {
118
+ action: "recall",
119
+ toolCallId,
120
+ result: {
121
+ count: results.length,
122
+ memories: results.map((e) => ({
123
+ id: e.id,
124
+ content: e.content,
125
+ tags: e.tags,
126
+ createdAt: e.createdAt,
127
+ })),
128
+ },
129
+ },
130
+ };
131
+ }
132
+ catch (error) {
133
+ yield {
134
+ type: "action:result",
135
+ data: {
136
+ action: "recall",
137
+ toolCallId,
138
+ result: { error: error.message },
139
+ },
140
+ };
141
+ }
142
+ });
143
+ // ─── Memory: Forget ────────────────────────────────────────────────
144
+ builder.on("action:forget", async function* (event) {
145
+ const { memoryId, toolCallId } = event.data;
146
+ try {
147
+ const removed = await memory.forget(memoryId);
148
+ yield {
149
+ type: "memory:status",
150
+ data: {
151
+ message: removed ? "Memory removed" : "Memory not found",
152
+ severity: removed ? "success" : "error",
153
+ },
154
+ };
155
+ yield {
156
+ type: "action:result",
157
+ data: {
158
+ action: "forget",
159
+ toolCallId,
160
+ result: {
161
+ success: removed,
162
+ message: removed
163
+ ? "Memory removed"
164
+ : `Memory "${memoryId}" not found`,
165
+ },
166
+ },
167
+ };
168
+ }
169
+ catch (error) {
170
+ yield {
171
+ type: "action:result",
172
+ data: {
173
+ action: "forget",
174
+ toolCallId,
175
+ result: { error: error.message },
176
+ },
177
+ };
178
+ }
179
+ });
180
+ // ─── Memory: Journal ───────────────────────────────────────────────
181
+ builder.on("action:journal", async function* (event) {
182
+ const { content, toolCallId } = event.data;
183
+ try {
184
+ await memory.addJournalEntry(content);
185
+ yield {
186
+ type: "memory:status",
187
+ data: { message: "Journal entry added", severity: "success" },
188
+ };
189
+ yield {
190
+ type: "action:result",
191
+ data: {
192
+ action: "journal",
193
+ toolCallId,
194
+ result: { success: true, message: "Journal entry added" },
195
+ },
196
+ };
197
+ }
198
+ catch (error) {
199
+ yield {
200
+ type: "memory:status",
201
+ data: {
202
+ message: `Failed to journal: ${error.message}`,
203
+ severity: "error",
204
+ },
205
+ };
206
+ yield {
207
+ type: "action:result",
208
+ data: {
209
+ action: "journal",
210
+ toolCallId,
211
+ result: { error: error.message },
212
+ },
213
+ };
214
+ }
215
+ });
216
+ builder.on("memory:status", async function* (event) {
217
+ yield ui.event(statusWidget(event.data.message, event.data.severity));
218
+ });
219
+ };
220
+ export default memoryPlugin;
@@ -0,0 +1,122 @@
1
+ import * as fs from "node:fs/promises";
2
+ import * as path from "node:path";
3
+ // --- Factory ---
4
+ export function createMemoryModule(baseDir) {
5
+ const memoryDir = path.join(baseDir, "memory");
6
+ const indexPath = path.join(memoryDir, "index.json");
7
+ const journalDir = path.join(memoryDir, "journal");
8
+ // --- Helpers ---
9
+ async function loadIndex() {
10
+ try {
11
+ const data = await fs.readFile(indexPath, "utf-8");
12
+ return JSON.parse(data);
13
+ }
14
+ catch {
15
+ return { entries: [] };
16
+ }
17
+ }
18
+ async function saveIndex(index) {
19
+ await fs.mkdir(memoryDir, { recursive: true });
20
+ await fs.writeFile(indexPath, JSON.stringify(index, null, 2), "utf-8");
21
+ }
22
+ function generateId() {
23
+ const timestamp = Date.now().toString(36);
24
+ const random = Math.random().toString(36).substring(2, 6);
25
+ return `mem_${timestamp}_${random}`;
26
+ }
27
+ /**
28
+ * Simple keyword-based relevance scoring.
29
+ * Splits query into terms (ignoring short words) and counts how many
30
+ * appear in the entry's content + tags. Returns a 0-1 score.
31
+ */
32
+ function scoreMatch(entry, query) {
33
+ const queryTerms = query
34
+ .toLowerCase()
35
+ .split(/\s+/)
36
+ .filter((t) => t.length > 2);
37
+ if (queryTerms.length === 0)
38
+ return 0;
39
+ const searchable = `${entry.content} ${entry.tags.join(" ")}`.toLowerCase();
40
+ let matched = 0;
41
+ for (const term of queryTerms) {
42
+ if (searchable.includes(term)) {
43
+ matched++;
44
+ }
45
+ }
46
+ return matched / queryTerms.length;
47
+ }
48
+ // --- Module ---
49
+ return {
50
+ async initialize() {
51
+ await fs.mkdir(memoryDir, { recursive: true });
52
+ await fs.mkdir(journalDir, { recursive: true });
53
+ try {
54
+ await fs.access(indexPath);
55
+ }
56
+ catch {
57
+ await saveIndex({ entries: [] });
58
+ }
59
+ },
60
+ async store(content, tags = []) {
61
+ const index = await loadIndex();
62
+ const entry = {
63
+ id: generateId(),
64
+ content,
65
+ tags,
66
+ createdAt: new Date().toISOString(),
67
+ };
68
+ index.entries.push(entry);
69
+ await saveIndex(index);
70
+ return entry;
71
+ },
72
+ async recall(query, options) {
73
+ const { tags, limit = 5 } = options || {};
74
+ const index = await loadIndex();
75
+ let candidates = index.entries;
76
+ // Filter by tags if provided
77
+ if (tags && tags.length > 0) {
78
+ candidates = candidates.filter((entry) => tags.some((tag) => entry.tags.includes(tag)));
79
+ }
80
+ // Score and sort by relevance
81
+ const scored = candidates
82
+ .map((entry) => ({ entry, score: scoreMatch(entry, query) }))
83
+ .filter(({ score }) => score > 0)
84
+ .sort((a, b) => b.score - a.score)
85
+ .slice(0, limit);
86
+ // If no keyword matches found, fall back to most recent entries
87
+ if (scored.length === 0) {
88
+ return candidates.slice(-limit).reverse();
89
+ }
90
+ return scored.map(({ entry }) => entry);
91
+ },
92
+ async forget(memoryId) {
93
+ const index = await loadIndex();
94
+ const before = index.entries.length;
95
+ index.entries = index.entries.filter((e) => e.id !== memoryId);
96
+ if (index.entries.length < before) {
97
+ await saveIndex(index);
98
+ return true;
99
+ }
100
+ return false;
101
+ },
102
+ async addJournalEntry(content) {
103
+ const today = new Date().toISOString().split("T")[0]; // e.g., "2026-02-12"
104
+ const journalPath = path.join(journalDir, `${today}.md`);
105
+ const timestamp = new Date().toLocaleTimeString();
106
+ const entry = `\n## ${timestamp}\n${content}\n`;
107
+ await fs.mkdir(journalDir, { recursive: true });
108
+ try {
109
+ await fs.access(journalPath);
110
+ await fs.appendFile(journalPath, entry, "utf-8");
111
+ }
112
+ catch {
113
+ const header = `# Journal - ${today}\n`;
114
+ await fs.writeFile(journalPath, header + entry, "utf-8");
115
+ }
116
+ },
117
+ async getRecentFacts(limit = 3) {
118
+ const index = await loadIndex();
119
+ return (index?.entries ?? []).slice(-limit);
120
+ },
121
+ };
122
+ }