@basicmemory/openclaw-basic-memory 0.2.0 → 0.2.2

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.
@@ -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 { userText, assistantText } = getLastTurn(event.messages)
81
-
82
- if (!userText && !assistantText) return
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 [tasks, recent] = await Promise.all([
45
- client.search(undefined, 5, undefined, {
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
- if (cfg.autoCapture) {
74
- api.on("agent_end", buildCaptureHandler(client, cfg))
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
- 'uv tool install "basic-memory @ git+https://github.com/basicmachines-co/basic-memory.git@main" --force',
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(
@@ -1,6 +1,24 @@
1
1
  {
2
2
  "id": "openclaw-basic-memory",
3
3
  "kind": "memory",
4
+ "contracts": {
5
+ "tools": [
6
+ "build_context",
7
+ "delete_note",
8
+ "edit_note",
9
+ "list_memory_projects",
10
+ "list_workspaces",
11
+ "memory_get",
12
+ "memory_search",
13
+ "move_note",
14
+ "read_note",
15
+ "schema_diff",
16
+ "schema_infer",
17
+ "schema_validate",
18
+ "search_notes",
19
+ "write_note"
20
+ ]
21
+ },
4
22
  "skills": [
5
23
  "skills/memory-defrag",
6
24
  "skills/memory-ingest",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basicmemory/openclaw-basic-memory",
3
- "version": "0.2.0",
3
+ "version": "0.2.2",
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.1.29"
52
+ "openclaw": ">=2026.5.2"
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.3.8",
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
  }
@@ -1,10 +1,10 @@
1
1
  #!/usr/bin/env bash
2
- # Install basic-memory CLI via uv from the latest main branch.
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.19.0}"
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
@@ -114,6 +114,11 @@ export function registerContextTool(
114
114
  text: `Failed to build context for "${params.url}". Check logs for details.`,
115
115
  },
116
116
  ],
117
+ details: {
118
+ url: params.url,
119
+ depth,
120
+ error: "build_context_failed",
121
+ },
117
122
  }
118
123
  }
119
124
  },
@@ -58,6 +58,10 @@ export function registerDeleteTool(
58
58
  text: `Failed to delete "${params.identifier}". It may not exist.`,
59
59
  },
60
60
  ],
61
+ details: {
62
+ identifier: params.identifier,
63
+ error: "delete_note_failed",
64
+ },
61
65
  }
62
66
  }
63
67
  },
@@ -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
  },
@@ -66,6 +66,11 @@ export function registerWorkspaceListTool(
66
66
  text: "Failed to list workspaces. Is Basic Memory running? Check logs for details.",
67
67
  },
68
68
  ],
69
+ details: {
70
+ count: 0,
71
+ workspaces: [],
72
+ error: "list_workspaces_failed",
73
+ },
69
74
  }
70
75
  }
71
76
  },
@@ -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
  },
@@ -65,6 +65,11 @@ export function registerMoveTool(
65
65
  text: `Failed to move "${params.identifier}". It may not exist.`,
66
66
  },
67
67
  ],
68
+ details: {
69
+ identifier: params.identifier,
70
+ newFolder: params.newFolder,
71
+ error: "move_note_failed",
72
+ },
68
73
  }
69
74
  }
70
75
  },
@@ -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
  },
@@ -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
  },
@@ -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
  },
@@ -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
  },
@@ -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
  },
@@ -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
  },
@@ -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
- }