openbot 0.2.3 → 0.2.6
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/README.md +1 -1
- package/dist/agents/agent-creator.js +58 -19
- package/dist/agents/os-agent.js +1 -4
- package/dist/agents/planner-agent.js +32 -0
- package/dist/agents/topic-agent.js +1 -1
- package/dist/architecture/contracts.js +1 -0
- package/dist/architecture/execution-engine.js +151 -0
- package/dist/architecture/intent-classifier.js +26 -0
- package/dist/architecture/planner.js +106 -0
- package/dist/automation-worker.js +121 -0
- package/dist/automations.js +52 -0
- package/dist/cli.js +116 -146
- package/dist/config.js +20 -0
- package/dist/core/agents.js +41 -0
- package/dist/core/delegation.js +124 -0
- package/dist/core/manager.js +73 -0
- package/dist/core/plugins.js +77 -0
- package/dist/core/router.js +40 -0
- package/dist/installers.js +156 -0
- package/dist/marketplace.js +80 -0
- package/dist/open-bot.js +34 -157
- package/dist/orchestrator.js +247 -51
- package/dist/plugins/approval/index.js +107 -3
- package/dist/plugins/brain/index.js +17 -86
- package/dist/plugins/brain/memory.js +1 -1
- package/dist/plugins/brain/prompt.js +8 -13
- package/dist/plugins/brain/types.js +0 -15
- package/dist/plugins/file-system/index.js +8 -8
- package/dist/plugins/llm/context-shaping.js +177 -0
- package/dist/plugins/llm/index.js +223 -49
- package/dist/plugins/memory/index.js +220 -0
- package/dist/plugins/memory/memory.js +122 -0
- package/dist/plugins/memory/prompt.js +55 -0
- package/dist/plugins/memory/types.js +45 -0
- package/dist/plugins/shell/index.js +3 -3
- package/dist/plugins/skills/index.js +9 -9
- package/dist/registry/index.js +1 -4
- package/dist/registry/plugin-loader.js +361 -56
- package/dist/registry/plugin-registry.js +21 -4
- package/dist/registry/ts-agent-loader.js +4 -4
- package/dist/registry/yaml-agent-loader.js +78 -20
- package/dist/runtime/execution-trace.js +41 -0
- package/dist/runtime/intent-routing.js +26 -0
- package/dist/runtime/openbot-runtime.js +354 -0
- package/dist/server.js +513 -41
- package/dist/ui/widgets/approval-card.js +22 -2
- package/dist/ui/widgets/delegation.js +29 -0
- package/dist/version.js +62 -0
- package/package.json +4 -1
|
@@ -1,47 +1,154 @@
|
|
|
1
1
|
import { streamText } from "ai";
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
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
|
-
|
|
34
|
-
|
|
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
|
-
|
|
102
|
+
if (lastAssistantWithTools) {
|
|
103
|
+
return lastAssistantWithTools.content.filter((c) => c.type === "tool-call" && !toolResults.has(c.toolCallId));
|
|
104
|
+
}
|
|
105
|
+
return [];
|
|
38
106
|
}
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
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 = "
|
|
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
|
-
|
|
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
|
|
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: "
|
|
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
|
|
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({
|
|
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
|
+
}
|