@basicmemory/openclaw-basic-memory 0.2.0 → 0.2.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/context-engine/basic-memory-context-engine.test.ts +398 -0
- package/context-engine/basic-memory-context-engine.ts +330 -0
- package/hooks/capture.ts +18 -6
- package/hooks/recall.ts +28 -12
- package/index.ts +9 -12
- package/package.json +5 -4
- package/scripts/setup-bm.sh +2 -2
- package/tools/build-context.ts +5 -0
- package/tools/delete-note.ts +4 -0
- package/tools/edit-note.ts +5 -0
- package/tools/list-memory-projects.ts +6 -0
- package/tools/list-workspaces.ts +5 -0
- package/tools/memory-provider.ts +23 -0
- package/tools/move-note.ts +5 -0
- package/tools/read-note.ts +5 -0
- package/tools/schema-diff.ts +5 -0
- package/tools/schema-infer.ts +5 -0
- package/tools/schema-validate.ts +6 -0
- package/tools/search-notes.ts +10 -0
- package/tools/write-note.ts +10 -0
- package/types/openclaw.d.ts +0 -24
|
@@ -0,0 +1,398 @@
|
|
|
1
|
+
import { beforeEach, describe, expect, it, jest } from "bun:test"
|
|
2
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core"
|
|
3
|
+
import type { BmClient } from "../bm-client.ts"
|
|
4
|
+
import type { BasicMemoryConfig } from "../config.ts"
|
|
5
|
+
import {
|
|
6
|
+
BasicMemoryContextEngine,
|
|
7
|
+
MAX_ASSEMBLE_RECALL_CHARS,
|
|
8
|
+
} from "./basic-memory-context-engine.ts"
|
|
9
|
+
|
|
10
|
+
function makeConfig(overrides?: Partial<BasicMemoryConfig>): BasicMemoryConfig {
|
|
11
|
+
return {
|
|
12
|
+
project: "test-project",
|
|
13
|
+
bmPath: "bm",
|
|
14
|
+
memoryDir: "memory/",
|
|
15
|
+
memoryFile: "MEMORY.md",
|
|
16
|
+
projectPath: "/tmp/test-project",
|
|
17
|
+
autoCapture: true,
|
|
18
|
+
captureMinChars: 10,
|
|
19
|
+
autoRecall: true,
|
|
20
|
+
recallPrompt:
|
|
21
|
+
"Check for active tasks and recent activity. Summarize anything relevant to the current session.",
|
|
22
|
+
debug: false,
|
|
23
|
+
...overrides,
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function makeMessages(
|
|
28
|
+
messages: Array<Record<string, unknown>>,
|
|
29
|
+
): AgentMessage[] {
|
|
30
|
+
return messages as AgentMessage[]
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("BasicMemoryContextEngine", () => {
|
|
34
|
+
let mockClient: {
|
|
35
|
+
search: jest.Mock
|
|
36
|
+
recentActivity: jest.Mock
|
|
37
|
+
indexConversation: jest.Mock
|
|
38
|
+
writeNote: jest.Mock
|
|
39
|
+
editNote: jest.Mock
|
|
40
|
+
deleteNote: jest.Mock
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
beforeEach(() => {
|
|
44
|
+
mockClient = {
|
|
45
|
+
search: jest.fn().mockResolvedValue([
|
|
46
|
+
{
|
|
47
|
+
title: "Fix auth rollout",
|
|
48
|
+
permalink: "fix-auth-rollout",
|
|
49
|
+
content: "Continue staging verification for auth rollout.",
|
|
50
|
+
file_path: "memory/tasks/fix-auth-rollout.md",
|
|
51
|
+
},
|
|
52
|
+
]),
|
|
53
|
+
recentActivity: jest.fn().mockResolvedValue([
|
|
54
|
+
{
|
|
55
|
+
title: "API review",
|
|
56
|
+
permalink: "api-review",
|
|
57
|
+
file_path: "memory/api-review.md",
|
|
58
|
+
created_at: "2026-03-09T12:00:00Z",
|
|
59
|
+
},
|
|
60
|
+
]),
|
|
61
|
+
indexConversation: jest.fn().mockResolvedValue(undefined),
|
|
62
|
+
writeNote: jest.fn().mockResolvedValue({
|
|
63
|
+
title: "subagent-handoff-agent-test-subagent-child-1",
|
|
64
|
+
permalink:
|
|
65
|
+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
|
|
66
|
+
file_path:
|
|
67
|
+
"memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md",
|
|
68
|
+
content: "",
|
|
69
|
+
}),
|
|
70
|
+
editNote: jest.fn().mockResolvedValue({
|
|
71
|
+
title: "subagent-handoff-agent-test-subagent-child-1",
|
|
72
|
+
permalink:
|
|
73
|
+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
|
|
74
|
+
file_path:
|
|
75
|
+
"memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md",
|
|
76
|
+
operation: "append",
|
|
77
|
+
}),
|
|
78
|
+
deleteNote: jest.fn().mockResolvedValue({
|
|
79
|
+
title: "subagent-handoff-agent-test-subagent-child-1",
|
|
80
|
+
permalink:
|
|
81
|
+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
|
|
82
|
+
file_path:
|
|
83
|
+
"memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md",
|
|
84
|
+
}),
|
|
85
|
+
}
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
it("bootstraps recall state from active tasks and recent activity", async () => {
|
|
89
|
+
const engine = new BasicMemoryContextEngine(
|
|
90
|
+
mockClient as unknown as BmClient,
|
|
91
|
+
makeConfig(),
|
|
92
|
+
)
|
|
93
|
+
|
|
94
|
+
await expect(
|
|
95
|
+
engine.bootstrap({
|
|
96
|
+
sessionId: "session-1",
|
|
97
|
+
sessionFile: "/tmp/session-1.jsonl",
|
|
98
|
+
}),
|
|
99
|
+
).resolves.toEqual({ bootstrapped: true })
|
|
100
|
+
expect(mockClient.search).toHaveBeenCalledWith(undefined, 5, undefined, {
|
|
101
|
+
note_types: ["Task"],
|
|
102
|
+
status: "active",
|
|
103
|
+
})
|
|
104
|
+
expect(mockClient.recentActivity).toHaveBeenCalledWith("1d")
|
|
105
|
+
})
|
|
106
|
+
|
|
107
|
+
it("skips bootstrap when recall is disabled", async () => {
|
|
108
|
+
const engine = new BasicMemoryContextEngine(
|
|
109
|
+
mockClient as unknown as BmClient,
|
|
110
|
+
makeConfig({ autoRecall: false }),
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
await expect(
|
|
114
|
+
engine.bootstrap({
|
|
115
|
+
sessionId: "session-2",
|
|
116
|
+
sessionFile: "/tmp/session-2.jsonl",
|
|
117
|
+
}),
|
|
118
|
+
).resolves.toEqual({
|
|
119
|
+
bootstrapped: false,
|
|
120
|
+
reason: "autoRecall disabled",
|
|
121
|
+
})
|
|
122
|
+
|
|
123
|
+
const result = await engine.assemble({
|
|
124
|
+
sessionId: "session-2",
|
|
125
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
126
|
+
})
|
|
127
|
+
|
|
128
|
+
expect(result).toEqual({
|
|
129
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
130
|
+
estimatedTokens: 0,
|
|
131
|
+
})
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it("injects bounded BM recall during assemble when bootstrap found context", async () => {
|
|
135
|
+
const engine = new BasicMemoryContextEngine(
|
|
136
|
+
mockClient as unknown as BmClient,
|
|
137
|
+
makeConfig(),
|
|
138
|
+
)
|
|
139
|
+
|
|
140
|
+
await engine.bootstrap({
|
|
141
|
+
sessionId: "session-assemble",
|
|
142
|
+
sessionFile: "/tmp/session-assemble.jsonl",
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
const result = await engine.assemble({
|
|
146
|
+
sessionId: "session-assemble",
|
|
147
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
148
|
+
})
|
|
149
|
+
|
|
150
|
+
expect(result.messages).toEqual(
|
|
151
|
+
makeMessages([{ role: "user", content: "hello" }]),
|
|
152
|
+
)
|
|
153
|
+
expect(result.systemPromptAddition).toContain("## Active Tasks")
|
|
154
|
+
expect(result.systemPromptAddition).toContain("Fix auth rollout")
|
|
155
|
+
expect(result.systemPromptAddition).toContain("## Recent Activity")
|
|
156
|
+
expect(result.systemPromptAddition).toContain("API review")
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
it("returns a no-op bootstrap result when there is no recall context", async () => {
|
|
160
|
+
mockClient.search.mockResolvedValue([])
|
|
161
|
+
mockClient.recentActivity.mockResolvedValue([])
|
|
162
|
+
|
|
163
|
+
const engine = new BasicMemoryContextEngine(
|
|
164
|
+
mockClient as unknown as BmClient,
|
|
165
|
+
makeConfig(),
|
|
166
|
+
)
|
|
167
|
+
|
|
168
|
+
await expect(
|
|
169
|
+
engine.bootstrap({
|
|
170
|
+
sessionId: "session-3",
|
|
171
|
+
sessionFile: "/tmp/session-3.jsonl",
|
|
172
|
+
}),
|
|
173
|
+
).resolves.toEqual({
|
|
174
|
+
bootstrapped: false,
|
|
175
|
+
reason: "no recall context found",
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
const result = await engine.assemble({
|
|
179
|
+
sessionId: "session-3",
|
|
180
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
181
|
+
})
|
|
182
|
+
|
|
183
|
+
expect(result).toEqual({
|
|
184
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
185
|
+
estimatedTokens: 0,
|
|
186
|
+
})
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
it("keeps assemble recall stable and within the hard bound", async () => {
|
|
190
|
+
mockClient.search.mockResolvedValue([
|
|
191
|
+
{
|
|
192
|
+
title: "Long task",
|
|
193
|
+
permalink: "long-task",
|
|
194
|
+
content: "A".repeat(4000),
|
|
195
|
+
file_path: "memory/tasks/long-task.md",
|
|
196
|
+
},
|
|
197
|
+
])
|
|
198
|
+
mockClient.recentActivity.mockResolvedValue([
|
|
199
|
+
{
|
|
200
|
+
title: "Long recent item",
|
|
201
|
+
permalink: "long-recent-item",
|
|
202
|
+
file_path: "memory/long-recent-item.md",
|
|
203
|
+
created_at: "2026-03-09T12:00:00Z",
|
|
204
|
+
},
|
|
205
|
+
])
|
|
206
|
+
|
|
207
|
+
const engine = new BasicMemoryContextEngine(
|
|
208
|
+
mockClient as unknown as BmClient,
|
|
209
|
+
makeConfig({
|
|
210
|
+
recallPrompt: "P".repeat(4000),
|
|
211
|
+
}),
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
await engine.bootstrap({
|
|
215
|
+
sessionId: "session-bounded",
|
|
216
|
+
sessionFile: "/tmp/session-bounded.jsonl",
|
|
217
|
+
})
|
|
218
|
+
|
|
219
|
+
const first = await engine.assemble({
|
|
220
|
+
sessionId: "session-bounded",
|
|
221
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
222
|
+
})
|
|
223
|
+
const second = await engine.assemble({
|
|
224
|
+
sessionId: "session-bounded",
|
|
225
|
+
messages: makeMessages([{ role: "user", content: "hello" }]),
|
|
226
|
+
})
|
|
227
|
+
|
|
228
|
+
expect(first.systemPromptAddition).toBeDefined()
|
|
229
|
+
expect(first.systemPromptAddition?.length).toBeLessThanOrEqual(
|
|
230
|
+
MAX_ASSEMBLE_RECALL_CHARS,
|
|
231
|
+
)
|
|
232
|
+
expect(first.systemPromptAddition).toContain(
|
|
233
|
+
"[Basic Memory recall truncated]",
|
|
234
|
+
)
|
|
235
|
+
expect(second.systemPromptAddition).toBe(first.systemPromptAddition)
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
it("creates a parent-to-child BM handoff note on subagent spawn", async () => {
|
|
239
|
+
const engine = new BasicMemoryContextEngine(
|
|
240
|
+
mockClient as unknown as BmClient,
|
|
241
|
+
makeConfig(),
|
|
242
|
+
)
|
|
243
|
+
|
|
244
|
+
await engine.bootstrap({
|
|
245
|
+
sessionId: "parent-session",
|
|
246
|
+
sessionFile: "/tmp/parent-session.jsonl",
|
|
247
|
+
})
|
|
248
|
+
|
|
249
|
+
const preparation = await engine.prepareSubagentSpawn({
|
|
250
|
+
parentSessionKey: "parent-session",
|
|
251
|
+
childSessionKey: "agent:test:subagent:child-1",
|
|
252
|
+
})
|
|
253
|
+
|
|
254
|
+
expect(preparation).toBeDefined()
|
|
255
|
+
expect(mockClient.writeNote).toHaveBeenCalledWith(
|
|
256
|
+
"subagent-handoff-agent-test-subagent-child-1",
|
|
257
|
+
expect.stringContaining("## Parent Basic Memory Context"),
|
|
258
|
+
"agent/subagents",
|
|
259
|
+
)
|
|
260
|
+
})
|
|
261
|
+
|
|
262
|
+
it("rolls back the handoff note when subagent spawn fails after preparation", async () => {
|
|
263
|
+
const engine = new BasicMemoryContextEngine(
|
|
264
|
+
mockClient as unknown as BmClient,
|
|
265
|
+
makeConfig(),
|
|
266
|
+
)
|
|
267
|
+
|
|
268
|
+
const preparation = await engine.prepareSubagentSpawn({
|
|
269
|
+
parentSessionKey: "parent-session",
|
|
270
|
+
childSessionKey: "agent:test:subagent:child-rollback",
|
|
271
|
+
})
|
|
272
|
+
|
|
273
|
+
expect(preparation).toBeDefined()
|
|
274
|
+
await preparation?.rollback()
|
|
275
|
+
|
|
276
|
+
expect(mockClient.deleteNote).toHaveBeenCalledWith(
|
|
277
|
+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
|
|
278
|
+
)
|
|
279
|
+
})
|
|
280
|
+
|
|
281
|
+
it("appends completion details to the handoff note when a child session completes", async () => {
|
|
282
|
+
const engine = new BasicMemoryContextEngine(
|
|
283
|
+
mockClient as unknown as BmClient,
|
|
284
|
+
makeConfig(),
|
|
285
|
+
)
|
|
286
|
+
|
|
287
|
+
await engine.prepareSubagentSpawn({
|
|
288
|
+
parentSessionKey: "parent-session",
|
|
289
|
+
childSessionKey: "agent:test:subagent:child-complete",
|
|
290
|
+
})
|
|
291
|
+
|
|
292
|
+
await engine.onSubagentEnded({
|
|
293
|
+
childSessionKey: "agent:test:subagent:child-complete",
|
|
294
|
+
reason: "completed",
|
|
295
|
+
})
|
|
296
|
+
|
|
297
|
+
expect(mockClient.editNote).toHaveBeenCalledWith(
|
|
298
|
+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
|
|
299
|
+
"append",
|
|
300
|
+
expect.stringContaining("Reason: completed"),
|
|
301
|
+
)
|
|
302
|
+
expect(mockClient.editNote).toHaveBeenCalledWith(
|
|
303
|
+
"agent/subagents/subagent-handoff-agent-test-subagent-child-1",
|
|
304
|
+
"append",
|
|
305
|
+
expect.stringContaining(
|
|
306
|
+
"Durable conversation capture continues through the normal afterTurn path.",
|
|
307
|
+
),
|
|
308
|
+
)
|
|
309
|
+
})
|
|
310
|
+
|
|
311
|
+
it("handles deleted, released, and swept child endings without errors", async () => {
|
|
312
|
+
const reasons = ["deleted", "released", "swept"] as const
|
|
313
|
+
|
|
314
|
+
for (const reason of reasons) {
|
|
315
|
+
const engine = new BasicMemoryContextEngine(
|
|
316
|
+
mockClient as unknown as BmClient,
|
|
317
|
+
makeConfig(),
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
await engine.prepareSubagentSpawn({
|
|
321
|
+
parentSessionKey: "parent-session",
|
|
322
|
+
childSessionKey: `agent:test:subagent:${reason}`,
|
|
323
|
+
})
|
|
324
|
+
|
|
325
|
+
await expect(
|
|
326
|
+
engine.onSubagentEnded({
|
|
327
|
+
childSessionKey: `agent:test:subagent:${reason}`,
|
|
328
|
+
reason,
|
|
329
|
+
}),
|
|
330
|
+
).resolves.toBeUndefined()
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
expect(mockClient.editNote).toHaveBeenCalledTimes(3)
|
|
334
|
+
})
|
|
335
|
+
|
|
336
|
+
it("captures only the current turn after prePromptMessageCount", async () => {
|
|
337
|
+
const engine = new BasicMemoryContextEngine(
|
|
338
|
+
mockClient as unknown as BmClient,
|
|
339
|
+
makeConfig(),
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
await engine.afterTurn({
|
|
343
|
+
sessionId: "session-4",
|
|
344
|
+
sessionFile: "/tmp/session-4.jsonl",
|
|
345
|
+
prePromptMessageCount: 2,
|
|
346
|
+
messages: makeMessages([
|
|
347
|
+
{ role: "user", content: "Old question" },
|
|
348
|
+
{ role: "assistant", content: "Old answer" },
|
|
349
|
+
{ role: "user", content: "Current question with enough detail" },
|
|
350
|
+
{ role: "assistant", content: "Current answer with enough detail" },
|
|
351
|
+
]),
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
expect(mockClient.indexConversation).toHaveBeenCalledWith(
|
|
355
|
+
"Current question with enough detail",
|
|
356
|
+
"Current answer with enough detail",
|
|
357
|
+
)
|
|
358
|
+
})
|
|
359
|
+
|
|
360
|
+
it("respects captureMinChars for afterTurn capture", async () => {
|
|
361
|
+
const engine = new BasicMemoryContextEngine(
|
|
362
|
+
mockClient as unknown as BmClient,
|
|
363
|
+
makeConfig({ captureMinChars: 50 }),
|
|
364
|
+
)
|
|
365
|
+
|
|
366
|
+
await engine.afterTurn({
|
|
367
|
+
sessionId: "session-5",
|
|
368
|
+
sessionFile: "/tmp/session-5.jsonl",
|
|
369
|
+
prePromptMessageCount: 0,
|
|
370
|
+
messages: makeMessages([
|
|
371
|
+
{ role: "user", content: "short" },
|
|
372
|
+
{ role: "assistant", content: "tiny" },
|
|
373
|
+
]),
|
|
374
|
+
})
|
|
375
|
+
|
|
376
|
+
expect(mockClient.indexConversation).not.toHaveBeenCalled()
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it("swallows capture failures in afterTurn", async () => {
|
|
380
|
+
mockClient.indexConversation.mockRejectedValue(new Error("BM down"))
|
|
381
|
+
const engine = new BasicMemoryContextEngine(
|
|
382
|
+
mockClient as unknown as BmClient,
|
|
383
|
+
makeConfig(),
|
|
384
|
+
)
|
|
385
|
+
|
|
386
|
+
await expect(
|
|
387
|
+
engine.afterTurn({
|
|
388
|
+
sessionId: "session-6",
|
|
389
|
+
sessionFile: "/tmp/session-6.jsonl",
|
|
390
|
+
prePromptMessageCount: 0,
|
|
391
|
+
messages: makeMessages([
|
|
392
|
+
{ role: "user", content: "Current question with enough detail" },
|
|
393
|
+
{ role: "assistant", content: "Current answer with enough detail" },
|
|
394
|
+
]),
|
|
395
|
+
}),
|
|
396
|
+
).resolves.toBeUndefined()
|
|
397
|
+
})
|
|
398
|
+
})
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
import { createRequire } from "node:module"
|
|
2
|
+
import { dirname, resolve } from "node:path"
|
|
3
|
+
import { pathToFileURL } from "node:url"
|
|
4
|
+
import type { AgentMessage } from "@mariozechner/pi-agent-core"
|
|
5
|
+
import type {
|
|
6
|
+
AssembleResult,
|
|
7
|
+
BootstrapResult,
|
|
8
|
+
CompactResult,
|
|
9
|
+
ContextEngine,
|
|
10
|
+
} from "openclaw/plugin-sdk"
|
|
11
|
+
import type { BmClient } from "../bm-client.ts"
|
|
12
|
+
import type { BasicMemoryConfig } from "../config.ts"
|
|
13
|
+
import { selectCaptureTurn } from "../hooks/capture.ts"
|
|
14
|
+
import { loadRecallState } from "../hooks/recall.ts"
|
|
15
|
+
import { log } from "../logger.ts"
|
|
16
|
+
|
|
17
|
+
const require = createRequire(import.meta.url)
|
|
18
|
+
export const MAX_ASSEMBLE_RECALL_CHARS = 1200
|
|
19
|
+
const TRUNCATED_RECALL_SUFFIX = "\n\n[Basic Memory recall truncated]"
|
|
20
|
+
const SUBAGENT_HANDOFF_FOLDER = "agent/subagents"
|
|
21
|
+
const MAX_SUBAGENT_RECALL_CHARS = 800
|
|
22
|
+
|
|
23
|
+
interface SessionMemoryState {
|
|
24
|
+
recallContext: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
interface SubagentHandoffState {
|
|
28
|
+
noteIdentifier: string
|
|
29
|
+
noteTitle: string
|
|
30
|
+
parentSessionKey: string
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function boundRecallContext(context: string): string {
|
|
34
|
+
if (context.length <= MAX_ASSEMBLE_RECALL_CHARS) {
|
|
35
|
+
return context
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const trimmed = context
|
|
39
|
+
.slice(
|
|
40
|
+
0,
|
|
41
|
+
Math.max(0, MAX_ASSEMBLE_RECALL_CHARS - TRUNCATED_RECALL_SUFFIX.length),
|
|
42
|
+
)
|
|
43
|
+
.trimEnd()
|
|
44
|
+
|
|
45
|
+
return `${trimmed}${TRUNCATED_RECALL_SUFFIX}`
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
function slugifySessionKey(sessionKey: string): string {
|
|
49
|
+
return sessionKey
|
|
50
|
+
.toLowerCase()
|
|
51
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
52
|
+
.replace(/^-+|-+$/g, "")
|
|
53
|
+
.slice(0, 80)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function buildSubagentNoteTitle(childSessionKey: string): string {
|
|
57
|
+
return `subagent-handoff-${slugifySessionKey(childSessionKey)}`
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
function buildSubagentHandoffContent(params: {
|
|
61
|
+
parentSessionKey: string
|
|
62
|
+
childSessionKey: string
|
|
63
|
+
recallContext?: string
|
|
64
|
+
}): string {
|
|
65
|
+
const sections = [
|
|
66
|
+
"# Subagent Handoff",
|
|
67
|
+
"",
|
|
68
|
+
"## Sessions",
|
|
69
|
+
`- Parent: ${params.parentSessionKey}`,
|
|
70
|
+
`- Child: ${params.childSessionKey}`,
|
|
71
|
+
"",
|
|
72
|
+
"## Lifecycle",
|
|
73
|
+
`- Spawned: ${new Date().toISOString()}`,
|
|
74
|
+
]
|
|
75
|
+
|
|
76
|
+
if (params.recallContext) {
|
|
77
|
+
sections.push(
|
|
78
|
+
"",
|
|
79
|
+
"## Parent Basic Memory Context",
|
|
80
|
+
params.recallContext.slice(0, MAX_SUBAGENT_RECALL_CHARS).trimEnd(),
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return sections.join("\n")
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
function buildSubagentCompletionUpdate(params: {
|
|
88
|
+
childSessionKey: string
|
|
89
|
+
reason: "deleted" | "completed" | "swept" | "released"
|
|
90
|
+
}): string {
|
|
91
|
+
const statusLine =
|
|
92
|
+
params.reason === "completed"
|
|
93
|
+
? "Child run completed. Durable conversation capture continues through the normal afterTurn path."
|
|
94
|
+
: `Child run ended with reason: ${params.reason}.`
|
|
95
|
+
|
|
96
|
+
return [
|
|
97
|
+
"",
|
|
98
|
+
"## Completion",
|
|
99
|
+
`- Child: ${params.childSessionKey}`,
|
|
100
|
+
`- Ended: ${new Date().toISOString()}`,
|
|
101
|
+
`- Reason: ${params.reason}`,
|
|
102
|
+
"",
|
|
103
|
+
statusLine,
|
|
104
|
+
].join("\n")
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
type LegacyContextEngineModule = {
|
|
108
|
+
LegacyContextEngine: new () => {
|
|
109
|
+
compact(params: {
|
|
110
|
+
sessionId: string
|
|
111
|
+
sessionFile: string
|
|
112
|
+
tokenBudget?: number
|
|
113
|
+
force?: boolean
|
|
114
|
+
currentTokenCount?: number
|
|
115
|
+
compactionTarget?: "budget" | "threshold"
|
|
116
|
+
customInstructions?: string
|
|
117
|
+
runtimeContext?: Record<string, unknown>
|
|
118
|
+
}): Promise<CompactResult>
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
async function loadLegacyContextEngine(): Promise<
|
|
123
|
+
LegacyContextEngineModule["LegacyContextEngine"]
|
|
124
|
+
> {
|
|
125
|
+
const pluginSdkPath = require.resolve("openclaw/plugin-sdk")
|
|
126
|
+
const legacyPath = resolve(
|
|
127
|
+
dirname(pluginSdkPath),
|
|
128
|
+
"context-engine",
|
|
129
|
+
"legacy.js",
|
|
130
|
+
)
|
|
131
|
+
const module = (await import(
|
|
132
|
+
pathToFileURL(legacyPath).href
|
|
133
|
+
)) as LegacyContextEngineModule
|
|
134
|
+
return module.LegacyContextEngine
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
export class BasicMemoryContextEngine implements ContextEngine {
|
|
138
|
+
readonly info = {
|
|
139
|
+
id: "openclaw-basic-memory",
|
|
140
|
+
name: "Basic Memory Context Engine",
|
|
141
|
+
version: "0.1.5",
|
|
142
|
+
ownsCompaction: false,
|
|
143
|
+
} as const
|
|
144
|
+
|
|
145
|
+
private readonly sessionState = new Map<string, SessionMemoryState>()
|
|
146
|
+
private readonly subagentState = new Map<string, SubagentHandoffState>()
|
|
147
|
+
private legacyContextEnginePromise: Promise<
|
|
148
|
+
InstanceType<LegacyContextEngineModule["LegacyContextEngine"]>
|
|
149
|
+
> | null = null
|
|
150
|
+
|
|
151
|
+
constructor(
|
|
152
|
+
private readonly client: BmClient,
|
|
153
|
+
private readonly cfg: BasicMemoryConfig,
|
|
154
|
+
) {}
|
|
155
|
+
|
|
156
|
+
async bootstrap(params: {
|
|
157
|
+
sessionId: string
|
|
158
|
+
sessionFile: string
|
|
159
|
+
}): Promise<BootstrapResult> {
|
|
160
|
+
if (!this.cfg.autoRecall) {
|
|
161
|
+
this.sessionState.delete(params.sessionId)
|
|
162
|
+
return { bootstrapped: false, reason: "autoRecall disabled" }
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
try {
|
|
166
|
+
const recall = await loadRecallState(this.client, this.cfg)
|
|
167
|
+
if (!recall) {
|
|
168
|
+
this.sessionState.delete(params.sessionId)
|
|
169
|
+
return { bootstrapped: false, reason: "no recall context found" }
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
this.sessionState.set(params.sessionId, {
|
|
173
|
+
recallContext: boundRecallContext(recall.context),
|
|
174
|
+
})
|
|
175
|
+
|
|
176
|
+
log.debug(
|
|
177
|
+
`context-engine bootstrap: session=${params.sessionId} tasks=${recall.tasks.length} recent=${recall.recent.length}`,
|
|
178
|
+
)
|
|
179
|
+
|
|
180
|
+
return { bootstrapped: true }
|
|
181
|
+
} catch (err) {
|
|
182
|
+
this.sessionState.delete(params.sessionId)
|
|
183
|
+
log.error("context-engine bootstrap failed", err)
|
|
184
|
+
return { bootstrapped: false, reason: "recall failed" }
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async ingest(): Promise<{ ingested: boolean }> {
|
|
189
|
+
return { ingested: false }
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
async assemble(params: {
|
|
193
|
+
sessionId: string
|
|
194
|
+
messages: AgentMessage[]
|
|
195
|
+
tokenBudget?: number
|
|
196
|
+
}): Promise<AssembleResult> {
|
|
197
|
+
const state = this.sessionState.get(params.sessionId)
|
|
198
|
+
|
|
199
|
+
return {
|
|
200
|
+
messages: params.messages,
|
|
201
|
+
estimatedTokens: 0,
|
|
202
|
+
systemPromptAddition: state?.recallContext,
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
async afterTurn(params: {
|
|
207
|
+
sessionId: string
|
|
208
|
+
sessionFile: string
|
|
209
|
+
messages: AgentMessage[]
|
|
210
|
+
prePromptMessageCount: number
|
|
211
|
+
autoCompactionSummary?: string
|
|
212
|
+
isHeartbeat?: boolean
|
|
213
|
+
tokenBudget?: number
|
|
214
|
+
runtimeContext?: Record<string, unknown>
|
|
215
|
+
}): Promise<void> {
|
|
216
|
+
if (!this.cfg.autoCapture) return
|
|
217
|
+
|
|
218
|
+
const newMessages = params.messages.slice(params.prePromptMessageCount)
|
|
219
|
+
const turn =
|
|
220
|
+
selectCaptureTurn(newMessages, this.cfg.captureMinChars) ??
|
|
221
|
+
selectCaptureTurn(params.messages, this.cfg.captureMinChars)
|
|
222
|
+
|
|
223
|
+
if (!turn) return
|
|
224
|
+
|
|
225
|
+
log.debug(
|
|
226
|
+
`context-engine afterTurn: session=${params.sessionId} user=${turn.userText.length} assistant=${turn.assistantText.length}`,
|
|
227
|
+
)
|
|
228
|
+
|
|
229
|
+
try {
|
|
230
|
+
await this.client.indexConversation(turn.userText, turn.assistantText)
|
|
231
|
+
} catch (err) {
|
|
232
|
+
log.error("context-engine capture failed", err)
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
async compact(params: {
|
|
237
|
+
sessionId: string
|
|
238
|
+
sessionFile: string
|
|
239
|
+
tokenBudget?: number
|
|
240
|
+
force?: boolean
|
|
241
|
+
currentTokenCount?: number
|
|
242
|
+
compactionTarget?: "budget" | "threshold"
|
|
243
|
+
customInstructions?: string
|
|
244
|
+
runtimeContext?: Record<string, unknown>
|
|
245
|
+
}): Promise<CompactResult> {
|
|
246
|
+
const legacy = await this.getLegacyContextEngine()
|
|
247
|
+
return legacy.compact(params)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
async prepareSubagentSpawn(params: {
|
|
251
|
+
parentSessionKey: string
|
|
252
|
+
childSessionKey: string
|
|
253
|
+
ttlMs?: number
|
|
254
|
+
}): Promise<{ rollback: () => Promise<void> } | undefined> {
|
|
255
|
+
const parentState = this.sessionState.get(params.parentSessionKey)
|
|
256
|
+
const noteTitle = buildSubagentNoteTitle(params.childSessionKey)
|
|
257
|
+
|
|
258
|
+
try {
|
|
259
|
+
const note = await this.client.writeNote(
|
|
260
|
+
noteTitle,
|
|
261
|
+
buildSubagentHandoffContent({
|
|
262
|
+
parentSessionKey: params.parentSessionKey,
|
|
263
|
+
childSessionKey: params.childSessionKey,
|
|
264
|
+
recallContext: parentState?.recallContext,
|
|
265
|
+
}),
|
|
266
|
+
SUBAGENT_HANDOFF_FOLDER,
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
this.subagentState.set(params.childSessionKey, {
|
|
270
|
+
noteIdentifier: note.permalink,
|
|
271
|
+
noteTitle: note.title,
|
|
272
|
+
parentSessionKey: params.parentSessionKey,
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
return {
|
|
276
|
+
rollback: async () => {
|
|
277
|
+
const handoff = this.subagentState.get(params.childSessionKey)
|
|
278
|
+
this.subagentState.delete(params.childSessionKey)
|
|
279
|
+
if (!handoff) return
|
|
280
|
+
|
|
281
|
+
try {
|
|
282
|
+
await this.client.deleteNote(handoff.noteIdentifier)
|
|
283
|
+
} catch (err) {
|
|
284
|
+
log.error("context-engine subagent rollback failed", err)
|
|
285
|
+
}
|
|
286
|
+
},
|
|
287
|
+
}
|
|
288
|
+
} catch (err) {
|
|
289
|
+
log.error("context-engine prepareSubagentSpawn failed", err)
|
|
290
|
+
return undefined
|
|
291
|
+
}
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
async onSubagentEnded(params: {
|
|
295
|
+
childSessionKey: string
|
|
296
|
+
reason: "deleted" | "completed" | "swept" | "released"
|
|
297
|
+
}): Promise<void> {
|
|
298
|
+
const handoff = this.subagentState.get(params.childSessionKey)
|
|
299
|
+
if (!handoff) return
|
|
300
|
+
|
|
301
|
+
this.subagentState.delete(params.childSessionKey)
|
|
302
|
+
|
|
303
|
+
try {
|
|
304
|
+
await this.client.editNote(
|
|
305
|
+
handoff.noteIdentifier,
|
|
306
|
+
"append",
|
|
307
|
+
buildSubagentCompletionUpdate(params),
|
|
308
|
+
)
|
|
309
|
+
} catch (err) {
|
|
310
|
+
log.error("context-engine onSubagentEnded failed", err)
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
async dispose(): Promise<void> {
|
|
315
|
+
this.sessionState.clear()
|
|
316
|
+
this.subagentState.clear()
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
private async getLegacyContextEngine(): Promise<
|
|
320
|
+
InstanceType<LegacyContextEngineModule["LegacyContextEngine"]>
|
|
321
|
+
> {
|
|
322
|
+
if (!this.legacyContextEnginePromise) {
|
|
323
|
+
this.legacyContextEnginePromise = loadLegacyContextEngine().then(
|
|
324
|
+
(LegacyContextEngine) => new LegacyContextEngine(),
|
|
325
|
+
)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
return this.legacyContextEnginePromise
|
|
329
|
+
}
|
|
330
|
+
}
|
package/hooks/capture.ts
CHANGED
|
@@ -5,7 +5,7 @@ import { log } from "../logger.ts"
|
|
|
5
5
|
/**
|
|
6
6
|
* Extract text content from a message object.
|
|
7
7
|
*/
|
|
8
|
-
function extractText(msg: Record<string, unknown>): string {
|
|
8
|
+
export function extractText(msg: Record<string, unknown>): string {
|
|
9
9
|
const content = msg.content
|
|
10
10
|
if (typeof content === "string") return content
|
|
11
11
|
|
|
@@ -27,7 +27,7 @@ function extractText(msg: Record<string, unknown>): string {
|
|
|
27
27
|
/**
|
|
28
28
|
* Find the last user+assistant turn from the messages array.
|
|
29
29
|
*/
|
|
30
|
-
function getLastTurn(messages: unknown[]): {
|
|
30
|
+
export function getLastTurn(messages: unknown[]): {
|
|
31
31
|
userText: string
|
|
32
32
|
assistantText: string
|
|
33
33
|
} {
|
|
@@ -60,6 +60,19 @@ function getLastTurn(messages: unknown[]): {
|
|
|
60
60
|
return { userText, assistantText }
|
|
61
61
|
}
|
|
62
62
|
|
|
63
|
+
export function selectCaptureTurn(
|
|
64
|
+
messages: unknown[],
|
|
65
|
+
minChars: number,
|
|
66
|
+
): { userText: string; assistantText: string } | null {
|
|
67
|
+
const turn = getLastTurn(messages)
|
|
68
|
+
if (!turn.userText && !turn.assistantText) return null
|
|
69
|
+
if (turn.userText.length < minChars && turn.assistantText.length < minChars) {
|
|
70
|
+
return null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return turn
|
|
74
|
+
}
|
|
75
|
+
|
|
63
76
|
/**
|
|
64
77
|
* Build the post-turn capture handler for Mode B.
|
|
65
78
|
*
|
|
@@ -77,10 +90,9 @@ export function buildCaptureHandler(client: BmClient, cfg: BasicMemoryConfig) {
|
|
|
77
90
|
return
|
|
78
91
|
}
|
|
79
92
|
|
|
80
|
-
const
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
if (userText.length < minChars && assistantText.length < minChars) return
|
|
93
|
+
const turn = selectCaptureTurn(event.messages, minChars)
|
|
94
|
+
if (!turn) return
|
|
95
|
+
const { userText, assistantText } = turn
|
|
84
96
|
|
|
85
97
|
log.debug(
|
|
86
98
|
`capturing conversation: user=${userText.length} chars, assistant=${assistantText.length} chars`,
|
package/hooks/recall.ts
CHANGED
|
@@ -2,6 +2,12 @@ import type { BmClient, RecentResult, SearchResult } from "../bm-client.ts"
|
|
|
2
2
|
import type { BasicMemoryConfig } from "../config.ts"
|
|
3
3
|
import { log } from "../logger.ts"
|
|
4
4
|
|
|
5
|
+
export interface RecallState {
|
|
6
|
+
tasks: SearchResult[]
|
|
7
|
+
recent: RecentResult[]
|
|
8
|
+
context: string
|
|
9
|
+
}
|
|
10
|
+
|
|
5
11
|
/**
|
|
6
12
|
* Format recalled context from active tasks and recent activity.
|
|
7
13
|
* Returns empty string if nothing was found.
|
|
@@ -32,6 +38,24 @@ export function formatRecallContext(
|
|
|
32
38
|
return `${sections.join("\n\n")}\n\n---\n${prompt}`
|
|
33
39
|
}
|
|
34
40
|
|
|
41
|
+
export async function loadRecallState(
|
|
42
|
+
client: BmClient,
|
|
43
|
+
cfg: BasicMemoryConfig,
|
|
44
|
+
): Promise<RecallState | null> {
|
|
45
|
+
const [tasks, recent] = await Promise.all([
|
|
46
|
+
client.search(undefined, 5, undefined, {
|
|
47
|
+
note_types: ["Task"],
|
|
48
|
+
status: "active",
|
|
49
|
+
}),
|
|
50
|
+
client.recentActivity("1d"),
|
|
51
|
+
])
|
|
52
|
+
|
|
53
|
+
const context = formatRecallContext(tasks, recent, cfg.recallPrompt)
|
|
54
|
+
if (!context) return null
|
|
55
|
+
|
|
56
|
+
return { tasks, recent, context }
|
|
57
|
+
}
|
|
58
|
+
|
|
35
59
|
/**
|
|
36
60
|
* Build the pre-turn recall handler.
|
|
37
61
|
*
|
|
@@ -41,22 +65,14 @@ export function formatRecallContext(
|
|
|
41
65
|
export function buildRecallHandler(client: BmClient, cfg: BasicMemoryConfig) {
|
|
42
66
|
return async (_event: Record<string, unknown>) => {
|
|
43
67
|
try {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
-
note_types: ["Task"],
|
|
47
|
-
status: "active",
|
|
48
|
-
}),
|
|
49
|
-
client.recentActivity("1d"),
|
|
50
|
-
])
|
|
51
|
-
|
|
52
|
-
const context = formatRecallContext(tasks, recent, cfg.recallPrompt)
|
|
53
|
-
if (!context) return {}
|
|
68
|
+
const recall = await loadRecallState(client, cfg)
|
|
69
|
+
if (!recall) return {}
|
|
54
70
|
|
|
55
71
|
log.debug(
|
|
56
|
-
`recall: ${tasks.length} active tasks, ${recent.length} recent items`,
|
|
72
|
+
`recall: ${recall.tasks.length} active tasks, ${recall.recent.length} recent items`,
|
|
57
73
|
)
|
|
58
74
|
|
|
59
|
-
return { context }
|
|
75
|
+
return { context: recall.context }
|
|
60
76
|
} catch (err) {
|
|
61
77
|
log.error("recall failed", err)
|
|
62
78
|
return {}
|
package/index.ts
CHANGED
|
@@ -10,9 +10,7 @@ import {
|
|
|
10
10
|
parseConfig,
|
|
11
11
|
resolveProjectPath,
|
|
12
12
|
} from "./config.ts"
|
|
13
|
-
|
|
14
|
-
import { buildCaptureHandler } from "./hooks/capture.ts"
|
|
15
|
-
import { buildRecallHandler } from "./hooks/recall.ts"
|
|
13
|
+
import { BasicMemoryContextEngine } from "./context-engine/basic-memory-context-engine.ts"
|
|
16
14
|
import { initLogger, log } from "./logger.ts"
|
|
17
15
|
import { CONVERSATION_SCHEMA_CONTENT } from "./schema/conversation-schema.ts"
|
|
18
16
|
import { TASK_SCHEMA_CONTENT } from "./schema/task-schema.ts"
|
|
@@ -33,6 +31,8 @@ import { registerSchemaValidateTool } from "./tools/schema-validate.ts"
|
|
|
33
31
|
import { registerSearchTool } from "./tools/search-notes.ts"
|
|
34
32
|
import { registerWriteTool } from "./tools/write-note.ts"
|
|
35
33
|
|
|
34
|
+
const BASIC_MEMORY_RELEASE_TAG = "v0.20.2"
|
|
35
|
+
|
|
36
36
|
export default {
|
|
37
37
|
id: "openclaw-basic-memory",
|
|
38
38
|
name: "Basic Memory",
|
|
@@ -69,14 +69,11 @@ export default {
|
|
|
69
69
|
// --- Composited memory_search + memory_get (always registered) ---
|
|
70
70
|
registerMemoryProvider(api, client, cfg)
|
|
71
71
|
log.info("registered composited memory_search + memory_get")
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
if (cfg.autoRecall) {
|
|
78
|
-
api.on("agent_start", buildRecallHandler(client, cfg))
|
|
79
|
-
}
|
|
72
|
+
api.registerContextEngine(
|
|
73
|
+
"openclaw-basic-memory",
|
|
74
|
+
() => new BasicMemoryContextEngine(client, cfg),
|
|
75
|
+
)
|
|
76
|
+
log.info("registered Basic Memory context engine")
|
|
80
77
|
|
|
81
78
|
// --- Commands ---
|
|
82
79
|
registerCommands(api, client)
|
|
@@ -101,7 +98,7 @@ export default {
|
|
|
101
98
|
"installing basic-memory via uv (this may take a minute)...",
|
|
102
99
|
)
|
|
103
100
|
const result = execSync(
|
|
104
|
-
|
|
101
|
+
`uv tool install "basic-memory @ git+https://github.com/basicmachines-co/basic-memory.git@${BASIC_MEMORY_RELEASE_TAG}" --force`,
|
|
105
102
|
{ encoding: "utf-8", timeout: 120_000, stdio: "pipe" },
|
|
106
103
|
)
|
|
107
104
|
log.info(
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@basicmemory/openclaw-basic-memory",
|
|
3
|
-
"version": "0.2.
|
|
3
|
+
"version": "0.2.1",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Basic Memory plugin for OpenClaw — local-first knowledge graph for agent memory",
|
|
6
6
|
"license": "MIT",
|
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
"files": [
|
|
12
12
|
"index.ts",
|
|
13
13
|
"bm-client.ts",
|
|
14
|
+
"context-engine/",
|
|
14
15
|
"config.ts",
|
|
15
16
|
"logger.ts",
|
|
16
17
|
"commands/cli.ts",
|
|
@@ -31,7 +32,6 @@
|
|
|
31
32
|
"tools/schema-validate.ts",
|
|
32
33
|
"tools/search-notes.ts",
|
|
33
34
|
"tools/write-note.ts",
|
|
34
|
-
"types/openclaw.d.ts",
|
|
35
35
|
"schema/task-schema.ts",
|
|
36
36
|
"schema/conversation-schema.ts",
|
|
37
37
|
"skills/",
|
|
@@ -49,7 +49,7 @@
|
|
|
49
49
|
"@sinclair/typebox": "0.34.47"
|
|
50
50
|
},
|
|
51
51
|
"peerDependencies": {
|
|
52
|
-
"openclaw": ">=2026.
|
|
52
|
+
"openclaw": ">=2026.3.7"
|
|
53
53
|
},
|
|
54
54
|
"scripts": {
|
|
55
55
|
"postinstall": "bash scripts/setup-bm.sh || true",
|
|
@@ -69,8 +69,9 @@
|
|
|
69
69
|
]
|
|
70
70
|
},
|
|
71
71
|
"devDependencies": {
|
|
72
|
-
"@biomejs/biome": "^2.
|
|
72
|
+
"@biomejs/biome": "^2.4.6",
|
|
73
73
|
"@types/node": "^20.0.0",
|
|
74
|
+
"openclaw": "^2026.3.8",
|
|
74
75
|
"typescript": "^5.9.3"
|
|
75
76
|
}
|
|
76
77
|
}
|
package/scripts/setup-bm.sh
CHANGED
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bash
|
|
2
|
-
# Install basic-memory CLI via uv from
|
|
2
|
+
# Install basic-memory CLI via uv from a pinned release tag.
|
|
3
3
|
# Idempotent — safe to re-run. Fails gracefully when uv is absent.
|
|
4
4
|
set -euo pipefail
|
|
5
5
|
|
|
6
6
|
BM_REPO="https://github.com/basicmachines-co/basic-memory.git"
|
|
7
|
-
BM_REF="${BM_REF:-v0.
|
|
7
|
+
BM_REF="${BM_REF:-v0.20.2}"
|
|
8
8
|
|
|
9
9
|
# ── check for uv ──────────────────────────────────────────────────
|
|
10
10
|
if ! command -v uv >/dev/null 2>&1; then
|
package/tools/build-context.ts
CHANGED
package/tools/delete-note.ts
CHANGED
package/tools/edit-note.ts
CHANGED
|
@@ -109,6 +109,11 @@ export function registerEditTool(
|
|
|
109
109
|
text: `Failed to edit note "${params.identifier}". It may not exist.`,
|
|
110
110
|
},
|
|
111
111
|
],
|
|
112
|
+
details: {
|
|
113
|
+
identifier: params.identifier,
|
|
114
|
+
operation: params.operation,
|
|
115
|
+
error: "edit_note_failed",
|
|
116
|
+
},
|
|
112
117
|
}
|
|
113
118
|
}
|
|
114
119
|
},
|
|
@@ -85,6 +85,12 @@ export function registerProjectListTool(
|
|
|
85
85
|
text: "Failed to list Basic Memory projects. Is Basic Memory running? Check logs for details.",
|
|
86
86
|
},
|
|
87
87
|
],
|
|
88
|
+
details: {
|
|
89
|
+
count: 0,
|
|
90
|
+
projects: [],
|
|
91
|
+
workspace: params.workspace ?? null,
|
|
92
|
+
error: "list_memory_projects_failed",
|
|
93
|
+
},
|
|
88
94
|
}
|
|
89
95
|
}
|
|
90
96
|
},
|
package/tools/list-workspaces.ts
CHANGED
package/tools/memory-provider.ts
CHANGED
|
@@ -235,6 +235,13 @@ export function registerMemoryProvider(
|
|
|
235
235
|
text: "No matches found across memory sources.",
|
|
236
236
|
},
|
|
237
237
|
],
|
|
238
|
+
details: {
|
|
239
|
+
query: params.query,
|
|
240
|
+
sectionCount: 0,
|
|
241
|
+
hasMemoryFileMatches: false,
|
|
242
|
+
hasKnowledgeGraphMatches: false,
|
|
243
|
+
hasTaskMatches: false,
|
|
244
|
+
},
|
|
238
245
|
}
|
|
239
246
|
}
|
|
240
247
|
|
|
@@ -245,6 +252,13 @@ export function registerMemoryProvider(
|
|
|
245
252
|
text: sections.join("\n\n"),
|
|
246
253
|
},
|
|
247
254
|
],
|
|
255
|
+
details: {
|
|
256
|
+
query: params.query,
|
|
257
|
+
sectionCount: sections.length,
|
|
258
|
+
hasMemoryFileMatches: memoryMd.length > 0,
|
|
259
|
+
hasKnowledgeGraphMatches: bmResults.length > 0,
|
|
260
|
+
hasTaskMatches: taskResults.length > 0,
|
|
261
|
+
},
|
|
248
262
|
}
|
|
249
263
|
},
|
|
250
264
|
},
|
|
@@ -293,6 +307,11 @@ export function registerMemoryProvider(
|
|
|
293
307
|
text: `# ${note.title}\n\n${note.content}`,
|
|
294
308
|
},
|
|
295
309
|
],
|
|
310
|
+
details: {
|
|
311
|
+
title: note.title,
|
|
312
|
+
permalink: note.permalink,
|
|
313
|
+
file_path: note.file_path,
|
|
314
|
+
},
|
|
296
315
|
}
|
|
297
316
|
} catch (err) {
|
|
298
317
|
log.error("memory_get failed", err)
|
|
@@ -303,6 +322,10 @@ export function registerMemoryProvider(
|
|
|
303
322
|
text: `Could not read "${params.path}". It may not exist in the knowledge graph.`,
|
|
304
323
|
},
|
|
305
324
|
],
|
|
325
|
+
details: {
|
|
326
|
+
path: params.path,
|
|
327
|
+
error: "memory_get_failed",
|
|
328
|
+
},
|
|
306
329
|
}
|
|
307
330
|
}
|
|
308
331
|
},
|
package/tools/move-note.ts
CHANGED
package/tools/read-note.ts
CHANGED
|
@@ -70,6 +70,11 @@ export function registerReadTool(
|
|
|
70
70
|
text: `Could not read note "${params.identifier}". It may not exist yet.`,
|
|
71
71
|
},
|
|
72
72
|
],
|
|
73
|
+
details: {
|
|
74
|
+
identifier: params.identifier,
|
|
75
|
+
include_frontmatter: params.include_frontmatter === true,
|
|
76
|
+
error: "read_note_failed",
|
|
77
|
+
},
|
|
73
78
|
}
|
|
74
79
|
}
|
|
75
80
|
},
|
package/tools/schema-diff.ts
CHANGED
|
@@ -46,6 +46,7 @@ export function registerSchemaDiffTool(
|
|
|
46
46
|
text: `No schema found for type "${params.noteType}". Use schema_infer to generate one.`,
|
|
47
47
|
},
|
|
48
48
|
],
|
|
49
|
+
details: result,
|
|
49
50
|
}
|
|
50
51
|
}
|
|
51
52
|
|
|
@@ -95,6 +96,10 @@ export function registerSchemaDiffTool(
|
|
|
95
96
|
text: "Schema diff failed. Check logs for details.",
|
|
96
97
|
},
|
|
97
98
|
],
|
|
99
|
+
details: {
|
|
100
|
+
noteType: params.noteType,
|
|
101
|
+
error: "schema_diff_failed",
|
|
102
|
+
},
|
|
98
103
|
}
|
|
99
104
|
}
|
|
100
105
|
},
|
package/tools/schema-infer.ts
CHANGED
|
@@ -94,6 +94,11 @@ export function registerSchemaInferTool(
|
|
|
94
94
|
text: "Schema inference failed. Check logs for details.",
|
|
95
95
|
},
|
|
96
96
|
],
|
|
97
|
+
details: {
|
|
98
|
+
noteType: params.noteType,
|
|
99
|
+
threshold: params.threshold ?? 0.25,
|
|
100
|
+
error: "schema_infer_failed",
|
|
101
|
+
},
|
|
97
102
|
}
|
|
98
103
|
}
|
|
99
104
|
},
|
package/tools/schema-validate.ts
CHANGED
|
@@ -53,6 +53,7 @@ export function registerSchemaValidateTool(
|
|
|
53
53
|
if ("error" in result && typeof resultRecord.error === "string") {
|
|
54
54
|
return {
|
|
55
55
|
content: [{ type: "text" as const, text: resultRecord.error }],
|
|
56
|
+
details: result,
|
|
56
57
|
}
|
|
57
58
|
}
|
|
58
59
|
|
|
@@ -91,6 +92,11 @@ export function registerSchemaValidateTool(
|
|
|
91
92
|
text: "Schema validation failed. Check logs for details.",
|
|
92
93
|
},
|
|
93
94
|
],
|
|
95
|
+
details: {
|
|
96
|
+
noteType: params.noteType ?? null,
|
|
97
|
+
identifier: params.identifier ?? null,
|
|
98
|
+
error: "schema_validate_failed",
|
|
99
|
+
},
|
|
94
100
|
}
|
|
95
101
|
}
|
|
96
102
|
},
|
package/tools/search-notes.ts
CHANGED
|
@@ -83,6 +83,10 @@ export function registerSearchTool(
|
|
|
83
83
|
text: "No matching notes found in the knowledge graph.",
|
|
84
84
|
},
|
|
85
85
|
],
|
|
86
|
+
details: {
|
|
87
|
+
count: 0,
|
|
88
|
+
results: [],
|
|
89
|
+
},
|
|
86
90
|
}
|
|
87
91
|
}
|
|
88
92
|
|
|
@@ -121,6 +125,12 @@ export function registerSearchTool(
|
|
|
121
125
|
text: "Search failed. Is Basic Memory running? Check logs for details.",
|
|
122
126
|
},
|
|
123
127
|
],
|
|
128
|
+
details: {
|
|
129
|
+
count: 0,
|
|
130
|
+
results: [],
|
|
131
|
+
query: params.query,
|
|
132
|
+
error: "search_notes_failed",
|
|
133
|
+
},
|
|
124
134
|
}
|
|
125
135
|
}
|
|
126
136
|
},
|
package/tools/write-note.ts
CHANGED
|
@@ -83,6 +83,11 @@ export function registerWriteTool(
|
|
|
83
83
|
|
|
84
84
|
return {
|
|
85
85
|
content: [{ type: "text" as const, text: hint }],
|
|
86
|
+
details: {
|
|
87
|
+
title: params.title,
|
|
88
|
+
permalink: err.permalink,
|
|
89
|
+
error: "note_already_exists",
|
|
90
|
+
},
|
|
86
91
|
}
|
|
87
92
|
}
|
|
88
93
|
|
|
@@ -94,6 +99,11 @@ export function registerWriteTool(
|
|
|
94
99
|
text: "Failed to write note. Is Basic Memory running? Check logs for details.",
|
|
95
100
|
},
|
|
96
101
|
],
|
|
102
|
+
details: {
|
|
103
|
+
title: params.title,
|
|
104
|
+
folder: params.folder,
|
|
105
|
+
error: "write_note_failed",
|
|
106
|
+
},
|
|
97
107
|
}
|
|
98
108
|
}
|
|
99
109
|
},
|
package/types/openclaw.d.ts
DELETED
|
@@ -1,24 +0,0 @@
|
|
|
1
|
-
declare module "openclaw/plugin-sdk" {
|
|
2
|
-
export interface OpenClawPluginApi {
|
|
3
|
-
pluginConfig: unknown
|
|
4
|
-
logger: {
|
|
5
|
-
info: (msg: string) => void
|
|
6
|
-
warn: (msg: string) => void
|
|
7
|
-
error: (msg: string, ...args: unknown[]) => void
|
|
8
|
-
debug: (msg: string) => void
|
|
9
|
-
}
|
|
10
|
-
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
|
11
|
-
registerTool(tool: any, options: any): void
|
|
12
|
-
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
|
13
|
-
registerCommand(command: any): void
|
|
14
|
-
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
|
15
|
-
registerCli(handler: any, options?: any): void
|
|
16
|
-
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
|
17
|
-
registerService(service: any): void
|
|
18
|
-
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
|
19
|
-
on(event: string, handler: (...args: any[]) => any): void
|
|
20
|
-
}
|
|
21
|
-
|
|
22
|
-
// biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
|
|
23
|
-
export function stringEnum(values: readonly string[]): any
|
|
24
|
-
}
|