From 230e8c631e1a6bce1b199d457bf1026ead1ba7f5 Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 9 Mar 2026 15:29:26 -0500 Subject: [PATCH 1/5] feat(context-engine): move recall and capture into native lifecycle --- .../basic-memory-context-engine.test.ts | 187 ++++++++++++++++++ context-engine/basic-memory-context-engine.ts | 177 +++++++++++++++++ hooks/capture.ts | 27 ++- hooks/recall.ts | 40 ++-- index.test.ts | 6 + index.ts | 17 +- package.json | 5 +- tools/build-context.test.ts | 5 + tools/build-context.ts | 5 + tools/delete-note.ts | 4 + tools/edit-note.test.ts | 5 + tools/edit-note.ts | 5 + tools/list-memory-projects.test.ts | 6 + tools/list-memory-projects.ts | 6 + tools/list-workspaces.test.ts | 5 + tools/list-workspaces.ts | 5 + tools/memory-provider.ts | 23 +++ tools/move-note.ts | 5 + tools/read-note.test.ts | 5 + tools/read-note.ts | 5 + tools/schema-diff.ts | 5 + tools/schema-infer.ts | 5 + tools/schema-validate.ts | 6 + tools/search-notes.test.ts | 10 + tools/search-notes.ts | 10 + tools/write-note.test.ts | 5 + tools/write-note.ts | 10 + types/openclaw.d.ts | 24 --- 28 files changed, 563 insertions(+), 55 deletions(-) create mode 100644 context-engine/basic-memory-context-engine.test.ts create mode 100644 context-engine/basic-memory-context-engine.ts delete mode 100644 types/openclaw.d.ts diff --git a/context-engine/basic-memory-context-engine.test.ts b/context-engine/basic-memory-context-engine.test.ts new file mode 100644 index 0000000..12e6318 --- /dev/null +++ b/context-engine/basic-memory-context-engine.test.ts @@ -0,0 +1,187 @@ +import { beforeEach, describe, expect, it, jest } from "bun:test" +import type { AgentMessage } from "@mariozechner/pi-agent-core" +import type { BmClient } from "../bm-client.ts" +import type { BasicMemoryConfig } from "../config.ts" +import { BasicMemoryContextEngine } from "./basic-memory-context-engine.ts" + +function makeConfig( + overrides?: Partial, +): BasicMemoryConfig { + return { + project: "test-project", + bmPath: "bm", + memoryDir: "memory/", + memoryFile: "MEMORY.md", + projectPath: "/tmp/test-project", + autoCapture: true, + captureMinChars: 10, + autoRecall: true, + recallPrompt: + "Check for active tasks and recent activity. Summarize anything relevant to the current session.", + debug: false, + ...overrides, + } +} + +function makeMessages(messages: Array>): AgentMessage[] { + return messages as AgentMessage[] +} + +describe("BasicMemoryContextEngine", () => { + let mockClient: { + search: jest.Mock + recentActivity: jest.Mock + indexConversation: jest.Mock + } + + beforeEach(() => { + mockClient = { + search: jest.fn().mockResolvedValue([ + { + title: "Fix auth rollout", + permalink: "fix-auth-rollout", + content: "Continue staging verification for auth rollout.", + file_path: "memory/tasks/fix-auth-rollout.md", + }, + ]), + recentActivity: jest.fn().mockResolvedValue([ + { + title: "API review", + permalink: "api-review", + file_path: "memory/api-review.md", + created_at: "2026-03-09T12:00:00Z", + }, + ]), + indexConversation: jest.fn().mockResolvedValue(undefined), + } + }) + + it("bootstraps recall state from active tasks and recent activity", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await expect( + engine.bootstrap({ + sessionId: "session-1", + sessionFile: "/tmp/session-1.jsonl", + }), + ).resolves.toEqual({ bootstrapped: true }) + expect(mockClient.search).toHaveBeenCalledWith(undefined, 5, undefined, { + note_types: ["Task"], + status: "active", + }) + expect(mockClient.recentActivity).toHaveBeenCalledWith("1d") + }) + + it("skips bootstrap when recall is disabled", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig({ autoRecall: false }), + ) + + await expect( + engine.bootstrap({ + sessionId: "session-2", + sessionFile: "/tmp/session-2.jsonl", + }), + ).resolves.toEqual({ + bootstrapped: false, + reason: "autoRecall disabled", + }) + + const result = await engine.assemble({ + sessionId: "session-2", + messages: makeMessages([{ role: "user", content: "hello" }]), + }) + + expect(result).toEqual({ + messages: makeMessages([{ role: "user", content: "hello" }]), + estimatedTokens: 0, + }) + }) + + it("returns a no-op bootstrap result when there is no recall context", async () => { + mockClient.search.mockResolvedValue([]) + mockClient.recentActivity.mockResolvedValue([]) + + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await expect( + engine.bootstrap({ + sessionId: "session-3", + sessionFile: "/tmp/session-3.jsonl", + }), + ).resolves.toEqual({ + bootstrapped: false, + reason: "no recall context found", + }) + }) + + it("captures only the current turn after prePromptMessageCount", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await engine.afterTurn({ + sessionId: "session-4", + sessionFile: "/tmp/session-4.jsonl", + prePromptMessageCount: 2, + messages: makeMessages([ + { role: "user", content: "Old question" }, + { role: "assistant", content: "Old answer" }, + { role: "user", content: "Current question with enough detail" }, + { role: "assistant", content: "Current answer with enough detail" }, + ]), + }) + + expect(mockClient.indexConversation).toHaveBeenCalledWith( + "Current question with enough detail", + "Current answer with enough detail", + ) + }) + + it("respects captureMinChars for afterTurn capture", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig({ captureMinChars: 50 }), + ) + + await engine.afterTurn({ + sessionId: "session-5", + sessionFile: "/tmp/session-5.jsonl", + prePromptMessageCount: 0, + messages: makeMessages([ + { role: "user", content: "short" }, + { role: "assistant", content: "tiny" }, + ]), + }) + + expect(mockClient.indexConversation).not.toHaveBeenCalled() + }) + + it("swallows capture failures in afterTurn", async () => { + mockClient.indexConversation.mockRejectedValue(new Error("BM down")) + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await expect( + engine.afterTurn({ + sessionId: "session-6", + sessionFile: "/tmp/session-6.jsonl", + prePromptMessageCount: 0, + messages: makeMessages([ + { role: "user", content: "Current question with enough detail" }, + { role: "assistant", content: "Current answer with enough detail" }, + ]), + }), + ).resolves.toBeUndefined() + }) +}) diff --git a/context-engine/basic-memory-context-engine.ts b/context-engine/basic-memory-context-engine.ts new file mode 100644 index 0000000..565c3d2 --- /dev/null +++ b/context-engine/basic-memory-context-engine.ts @@ -0,0 +1,177 @@ +import { createRequire } from "node:module" +import { dirname, resolve } from "node:path" +import { pathToFileURL } from "node:url" +import type { AgentMessage } from "@mariozechner/pi-agent-core" +import type { + AssembleResult, + BootstrapResult, + CompactResult, + ContextEngine, +} from "openclaw/plugin-sdk" +import type { BmClient } from "../bm-client.ts" +import type { BasicMemoryConfig } from "../config.ts" +import { selectCaptureTurn } from "../hooks/capture.ts" +import { loadRecallState } from "../hooks/recall.ts" +import { log } from "../logger.ts" + +const require = createRequire(import.meta.url) + +interface SessionMemoryState { + recallContext: string +} + +type LegacyContextEngineModule = { + LegacyContextEngine: new () => { + compact(params: { + sessionId: string + sessionFile: string + tokenBudget?: number + force?: boolean + currentTokenCount?: number + compactionTarget?: "budget" | "threshold" + customInstructions?: string + runtimeContext?: Record + }): Promise + } +} + +async function loadLegacyContextEngine(): Promise< + LegacyContextEngineModule["LegacyContextEngine"] +> { + const pluginSdkPath = require.resolve("openclaw/plugin-sdk") + const legacyPath = resolve( + dirname(pluginSdkPath), + "context-engine", + "legacy.js", + ) + const module = (await import( + pathToFileURL(legacyPath).href + )) as LegacyContextEngineModule + return module.LegacyContextEngine +} + +export class BasicMemoryContextEngine implements ContextEngine { + readonly info = { + id: "openclaw-basic-memory", + name: "Basic Memory Context Engine", + version: "0.1.5", + ownsCompaction: false, + } as const + + private readonly sessionState = new Map() + private legacyContextEnginePromise: + | Promise> + | null = null + + constructor( + private readonly client: BmClient, + private readonly cfg: BasicMemoryConfig, + ) {} + + async bootstrap(params: { + sessionId: string + sessionFile: string + }): Promise { + if (!this.cfg.autoRecall) { + this.sessionState.delete(params.sessionId) + return { bootstrapped: false, reason: "autoRecall disabled" } + } + + try { + const recall = await loadRecallState(this.client, this.cfg) + if (!recall) { + this.sessionState.delete(params.sessionId) + return { bootstrapped: false, reason: "no recall context found" } + } + + this.sessionState.set(params.sessionId, { + recallContext: recall.context, + }) + + log.debug( + `context-engine bootstrap: session=${params.sessionId} tasks=${recall.tasks.length} recent=${recall.recent.length}`, + ) + + return { bootstrapped: true } + } catch (err) { + this.sessionState.delete(params.sessionId) + log.error("context-engine bootstrap failed", err) + return { bootstrapped: false, reason: "recall failed" } + } + } + + async ingest(): Promise<{ ingested: boolean }> { + return { ingested: false } + } + + async assemble(params: { + sessionId: string + messages: AgentMessage[] + tokenBudget?: number + }): Promise { + return { + messages: params.messages, + estimatedTokens: 0, + } + } + + async afterTurn(params: { + sessionId: string + sessionFile: string + messages: AgentMessage[] + prePromptMessageCount: number + autoCompactionSummary?: string + isHeartbeat?: boolean + tokenBudget?: number + runtimeContext?: Record + }): Promise { + if (!this.cfg.autoCapture) return + + const newMessages = params.messages.slice(params.prePromptMessageCount) + const turn = + selectCaptureTurn(newMessages, this.cfg.captureMinChars) ?? + selectCaptureTurn(params.messages, this.cfg.captureMinChars) + + if (!turn) return + + log.debug( + `context-engine afterTurn: session=${params.sessionId} user=${turn.userText.length} assistant=${turn.assistantText.length}`, + ) + + try { + await this.client.indexConversation(turn.userText, turn.assistantText) + } catch (err) { + log.error("context-engine capture failed", err) + } + } + + async compact(params: { + sessionId: string + sessionFile: string + tokenBudget?: number + force?: boolean + currentTokenCount?: number + compactionTarget?: "budget" | "threshold" + customInstructions?: string + runtimeContext?: Record + }): Promise { + const legacy = await this.getLegacyContextEngine() + return legacy.compact(params) + } + + async dispose(): Promise { + this.sessionState.clear() + } + + private async getLegacyContextEngine(): Promise< + InstanceType + > { + if (!this.legacyContextEnginePromise) { + this.legacyContextEnginePromise = loadLegacyContextEngine().then( + (LegacyContextEngine) => new LegacyContextEngine(), + ) + } + + return this.legacyContextEnginePromise + } +} diff --git a/hooks/capture.ts b/hooks/capture.ts index 5c8c2ee..55a6d5a 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -5,7 +5,7 @@ import { log } from "../logger.ts" /** * Extract text content from a message object. */ -function extractText(msg: Record): string { +export function extractText(msg: Record): string { const content = msg.content if (typeof content === "string") return content @@ -27,7 +27,7 @@ function extractText(msg: Record): string { /** * Find the last user+assistant turn from the messages array. */ -function getLastTurn(messages: unknown[]): { +export function getLastTurn(messages: unknown[]): { userText: string assistantText: string } { @@ -60,6 +60,22 @@ function getLastTurn(messages: unknown[]): { return { userText, assistantText } } +export function selectCaptureTurn( + messages: unknown[], + minChars: number, +): { userText: string; assistantText: string } | null { + const turn = getLastTurn(messages) + if (!turn.userText && !turn.assistantText) return null + if ( + turn.userText.length < minChars && + turn.assistantText.length < minChars + ) { + return null + } + + return turn +} + /** * Build the post-turn capture handler for Mode B. * @@ -77,10 +93,9 @@ export function buildCaptureHandler(client: BmClient, cfg: BasicMemoryConfig) { return } - const { userText, assistantText } = getLastTurn(event.messages) - - if (!userText && !assistantText) return - if (userText.length < minChars && assistantText.length < minChars) return + const turn = selectCaptureTurn(event.messages, minChars) + if (!turn) return + const { userText, assistantText } = turn log.debug( `capturing conversation: user=${userText.length} chars, assistant=${assistantText.length} chars`, diff --git a/hooks/recall.ts b/hooks/recall.ts index dc303e1..edcb18b 100644 --- a/hooks/recall.ts +++ b/hooks/recall.ts @@ -2,6 +2,12 @@ import type { BmClient, RecentResult, SearchResult } from "../bm-client.ts" import type { BasicMemoryConfig } from "../config.ts" import { log } from "../logger.ts" +export interface RecallState { + tasks: SearchResult[] + recent: RecentResult[] + context: string +} + /** * Format recalled context from active tasks and recent activity. * Returns empty string if nothing was found. @@ -32,6 +38,24 @@ export function formatRecallContext( return `${sections.join("\n\n")}\n\n---\n${prompt}` } +export async function loadRecallState( + client: BmClient, + cfg: BasicMemoryConfig, +): Promise { + const [tasks, recent] = await Promise.all([ + client.search(undefined, 5, undefined, { + note_types: ["Task"], + status: "active", + }), + client.recentActivity("1d"), + ]) + + const context = formatRecallContext(tasks, recent, cfg.recallPrompt) + if (!context) return null + + return { tasks, recent, context } +} + /** * Build the pre-turn recall handler. * @@ -41,22 +65,14 @@ export function formatRecallContext( export function buildRecallHandler(client: BmClient, cfg: BasicMemoryConfig) { return async (_event: Record) => { try { - const [tasks, recent] = await Promise.all([ - client.search(undefined, 5, undefined, { - note_types: ["Task"], - status: "active", - }), - client.recentActivity("1d"), - ]) - - const context = formatRecallContext(tasks, recent, cfg.recallPrompt) - if (!context) return {} + const recall = await loadRecallState(client, cfg) + if (!recall) return {} log.debug( - `recall: ${tasks.length} active tasks, ${recent.length} recent items`, + `recall: ${recall.tasks.length} active tasks, ${recall.recent.length} recent items`, ) - return { context } + return { context: recall.context } } catch (err) { log.error("recall failed", err) return {} diff --git a/index.test.ts b/index.test.ts index a63e56d..2cbd765 100644 --- a/index.test.ts +++ b/index.test.ts @@ -44,6 +44,7 @@ describe("plugin service lifecycle", () => { registerTool: jest.fn(), registerCommand: jest.fn(), registerCli: jest.fn(), + registerContextEngine: jest.fn(), registerService: jest.fn((service: any) => { services.push(service) }), @@ -53,6 +54,11 @@ describe("plugin service lifecycle", () => { plugin.register(api as any) expect(services).toHaveLength(1) + expect(api.registerContextEngine).toHaveBeenCalledWith( + "openclaw-basic-memory", + expect.any(Function), + ) + expect(api.on).not.toHaveBeenCalled() await services[0].start({ workspaceDir: "/tmp/workspace" }) diff --git a/index.ts b/index.ts index 053373f..6418d0d 100644 --- a/index.ts +++ b/index.ts @@ -10,9 +10,7 @@ import { parseConfig, resolveProjectPath, } from "./config.ts" - -import { buildCaptureHandler } from "./hooks/capture.ts" -import { buildRecallHandler } from "./hooks/recall.ts" +import { BasicMemoryContextEngine } from "./context-engine/basic-memory-context-engine.ts" import { initLogger, log } from "./logger.ts" import { CONVERSATION_SCHEMA_CONTENT } from "./schema/conversation-schema.ts" import { TASK_SCHEMA_CONTENT } from "./schema/task-schema.ts" @@ -69,14 +67,11 @@ export default { // --- Composited memory_search + memory_get (always registered) --- registerMemoryProvider(api, client, cfg) log.info("registered composited memory_search + memory_get") - - if (cfg.autoCapture) { - api.on("agent_end", buildCaptureHandler(client, cfg)) - } - - if (cfg.autoRecall) { - api.on("agent_start", buildRecallHandler(client, cfg)) - } + api.registerContextEngine( + "openclaw-basic-memory", + () => new BasicMemoryContextEngine(client, cfg), + ) + log.info("registered Basic Memory context engine") // --- Commands --- registerCommands(api, client) diff --git a/package.json b/package.json index bc9f21b..5b6d153 100644 --- a/package.json +++ b/package.json @@ -11,6 +11,7 @@ "files": [ "index.ts", "bm-client.ts", + "context-engine/", "config.ts", "logger.ts", "commands/cli.ts", @@ -31,7 +32,6 @@ "tools/schema-validate.ts", "tools/search-notes.ts", "tools/write-note.ts", - "types/openclaw.d.ts", "schema/task-schema.ts", "schema/conversation-schema.ts", "skills/", @@ -49,7 +49,7 @@ "@sinclair/typebox": "0.34.47" }, "peerDependencies": { - "openclaw": ">=2026.1.29" + "openclaw": ">=2026.3.7" }, "scripts": { "postinstall": "bash scripts/setup-bm.sh || true", @@ -71,6 +71,7 @@ "devDependencies": { "@biomejs/biome": "^2.3.8", "@types/node": "^20.0.0", + "openclaw": "^2026.3.8", "typescript": "^5.9.3" } } diff --git a/tools/build-context.test.ts b/tools/build-context.test.ts index 1cbe8d8..5e6bf35 100644 --- a/tools/build-context.test.ts +++ b/tools/build-context.test.ts @@ -429,6 +429,11 @@ describe("context tool", () => { text: 'Failed to build context for "memory://error/test". Check logs for details.', }, ], + details: { + url: "memory://error/test", + depth: 1, + error: "build_context_failed", + }, }) }) diff --git a/tools/build-context.ts b/tools/build-context.ts index db16a49..c7d177d 100644 --- a/tools/build-context.ts +++ b/tools/build-context.ts @@ -114,6 +114,11 @@ export function registerContextTool( text: `Failed to build context for "${params.url}". Check logs for details.`, }, ], + details: { + url: params.url, + depth, + error: "build_context_failed", + }, } } }, diff --git a/tools/delete-note.ts b/tools/delete-note.ts index 09d0745..17fa9f2 100644 --- a/tools/delete-note.ts +++ b/tools/delete-note.ts @@ -58,6 +58,10 @@ export function registerDeleteTool( text: `Failed to delete "${params.identifier}". It may not exist.`, }, ], + details: { + identifier: params.identifier, + error: "delete_note_failed", + }, } } }, diff --git a/tools/edit-note.test.ts b/tools/edit-note.test.ts index 6275671..7231458 100644 --- a/tools/edit-note.test.ts +++ b/tools/edit-note.test.ts @@ -194,6 +194,11 @@ describe("edit tool", () => { text: 'Failed to edit note "missing-note". It may not exist.', }, ], + details: { + identifier: "missing-note", + operation: "append", + error: "edit_note_failed", + }, }) }) }) diff --git a/tools/edit-note.ts b/tools/edit-note.ts index b8719d4..bf1332d 100644 --- a/tools/edit-note.ts +++ b/tools/edit-note.ts @@ -109,6 +109,11 @@ export function registerEditTool( text: `Failed to edit note "${params.identifier}". It may not exist.`, }, ], + details: { + identifier: params.identifier, + operation: params.operation, + error: "edit_note_failed", + }, } } }, diff --git a/tools/list-memory-projects.test.ts b/tools/list-memory-projects.test.ts index 7be6a2d..f5a5ce7 100644 --- a/tools/list-memory-projects.test.ts +++ b/tools/list-memory-projects.test.ts @@ -148,6 +148,12 @@ describe("project list tool", () => { text: "Failed to list Basic Memory projects. Is Basic Memory running? Check logs for details.", }, ], + details: { + count: 0, + projects: [], + workspace: null, + error: "list_memory_projects_failed", + }, }) }) }) diff --git a/tools/list-memory-projects.ts b/tools/list-memory-projects.ts index 3e8f27d..37cf32f 100644 --- a/tools/list-memory-projects.ts +++ b/tools/list-memory-projects.ts @@ -85,6 +85,12 @@ export function registerProjectListTool( text: "Failed to list Basic Memory projects. Is Basic Memory running? Check logs for details.", }, ], + details: { + count: 0, + projects: [], + workspace: params.workspace ?? null, + error: "list_memory_projects_failed", + }, } } }, diff --git a/tools/list-workspaces.test.ts b/tools/list-workspaces.test.ts index c669edc..2ffbb83 100644 --- a/tools/list-workspaces.test.ts +++ b/tools/list-workspaces.test.ts @@ -135,6 +135,11 @@ describe("workspace list tool", () => { text: "Failed to list workspaces. Is Basic Memory running? Check logs for details.", }, ], + details: { + count: 0, + workspaces: [], + error: "list_workspaces_failed", + }, }) }) }) diff --git a/tools/list-workspaces.ts b/tools/list-workspaces.ts index 2ff21cc..7566fb4 100644 --- a/tools/list-workspaces.ts +++ b/tools/list-workspaces.ts @@ -66,6 +66,11 @@ export function registerWorkspaceListTool( text: "Failed to list workspaces. Is Basic Memory running? Check logs for details.", }, ], + details: { + count: 0, + workspaces: [], + error: "list_workspaces_failed", + }, } } }, diff --git a/tools/memory-provider.ts b/tools/memory-provider.ts index 167f02f..8394d74 100644 --- a/tools/memory-provider.ts +++ b/tools/memory-provider.ts @@ -235,6 +235,13 @@ export function registerMemoryProvider( text: "No matches found across memory sources.", }, ], + details: { + query: params.query, + sectionCount: 0, + hasMemoryFileMatches: false, + hasKnowledgeGraphMatches: false, + hasTaskMatches: false, + }, } } @@ -245,6 +252,13 @@ export function registerMemoryProvider( text: sections.join("\n\n"), }, ], + details: { + query: params.query, + sectionCount: sections.length, + hasMemoryFileMatches: memoryMd.length > 0, + hasKnowledgeGraphMatches: bmResults.length > 0, + hasTaskMatches: taskResults.length > 0, + }, } }, }, @@ -293,6 +307,11 @@ export function registerMemoryProvider( text: `# ${note.title}\n\n${note.content}`, }, ], + details: { + title: note.title, + permalink: note.permalink, + file_path: note.file_path, + }, } } catch (err) { log.error("memory_get failed", err) @@ -303,6 +322,10 @@ export function registerMemoryProvider( text: `Could not read "${params.path}". It may not exist in the knowledge graph.`, }, ], + details: { + path: params.path, + error: "memory_get_failed", + }, } } }, diff --git a/tools/move-note.ts b/tools/move-note.ts index c76e18f..e781b59 100644 --- a/tools/move-note.ts +++ b/tools/move-note.ts @@ -65,6 +65,11 @@ export function registerMoveTool( text: `Failed to move "${params.identifier}". It may not exist.`, }, ], + details: { + identifier: params.identifier, + newFolder: params.newFolder, + error: "move_note_failed", + }, } } }, diff --git a/tools/read-note.test.ts b/tools/read-note.test.ts index 6154f9b..3b5cf3a 100644 --- a/tools/read-note.test.ts +++ b/tools/read-note.test.ts @@ -170,6 +170,11 @@ describe("read tool", () => { text: 'Could not read note "missing-note". It may not exist yet.', }, ], + details: { + identifier: "missing-note", + include_frontmatter: false, + error: "read_note_failed", + }, }) }) }) diff --git a/tools/read-note.ts b/tools/read-note.ts index 3113c51..85c651c 100644 --- a/tools/read-note.ts +++ b/tools/read-note.ts @@ -70,6 +70,11 @@ export function registerReadTool( text: `Could not read note "${params.identifier}". It may not exist yet.`, }, ], + details: { + identifier: params.identifier, + include_frontmatter: params.include_frontmatter === true, + error: "read_note_failed", + }, } } }, diff --git a/tools/schema-diff.ts b/tools/schema-diff.ts index 9c23272..d0ccce3 100644 --- a/tools/schema-diff.ts +++ b/tools/schema-diff.ts @@ -46,6 +46,7 @@ export function registerSchemaDiffTool( text: `No schema found for type "${params.noteType}". Use schema_infer to generate one.`, }, ], + details: result, } } @@ -95,6 +96,10 @@ export function registerSchemaDiffTool( text: "Schema diff failed. Check logs for details.", }, ], + details: { + noteType: params.noteType, + error: "schema_diff_failed", + }, } } }, diff --git a/tools/schema-infer.ts b/tools/schema-infer.ts index 7d20891..2432dba 100644 --- a/tools/schema-infer.ts +++ b/tools/schema-infer.ts @@ -94,6 +94,11 @@ export function registerSchemaInferTool( text: "Schema inference failed. Check logs for details.", }, ], + details: { + noteType: params.noteType, + threshold: params.threshold ?? 0.25, + error: "schema_infer_failed", + }, } } }, diff --git a/tools/schema-validate.ts b/tools/schema-validate.ts index 5b21f01..30e0773 100644 --- a/tools/schema-validate.ts +++ b/tools/schema-validate.ts @@ -53,6 +53,7 @@ export function registerSchemaValidateTool( if ("error" in result && typeof resultRecord.error === "string") { return { content: [{ type: "text" as const, text: resultRecord.error }], + details: result, } } @@ -91,6 +92,11 @@ export function registerSchemaValidateTool( text: "Schema validation failed. Check logs for details.", }, ], + details: { + noteType: params.noteType ?? null, + identifier: params.identifier ?? null, + error: "schema_validate_failed", + }, } } }, diff --git a/tools/search-notes.test.ts b/tools/search-notes.test.ts index 2c407fc..a69259d 100644 --- a/tools/search-notes.test.ts +++ b/tools/search-notes.test.ts @@ -242,6 +242,10 @@ describe("search tool", () => { text: "No matching notes found in the knowledge graph.", }, ], + details: { + count: 0, + results: [], + }, }) }) @@ -365,6 +369,12 @@ describe("search tool", () => { text: "Search failed. Is Basic Memory running? Check logs for details.", }, ], + details: { + count: 0, + results: [], + query: "test", + error: "search_notes_failed", + }, }) }) diff --git a/tools/search-notes.ts b/tools/search-notes.ts index 350dd4e..a735dd8 100644 --- a/tools/search-notes.ts +++ b/tools/search-notes.ts @@ -83,6 +83,10 @@ export function registerSearchTool( text: "No matching notes found in the knowledge graph.", }, ], + details: { + count: 0, + results: [], + }, } } @@ -121,6 +125,12 @@ export function registerSearchTool( text: "Search failed. Is Basic Memory running? Check logs for details.", }, ], + details: { + count: 0, + results: [], + query: params.query, + error: "search_notes_failed", + }, } } }, diff --git a/tools/write-note.test.ts b/tools/write-note.test.ts index 4bcb7d0..0dca058 100644 --- a/tools/write-note.test.ts +++ b/tools/write-note.test.ts @@ -410,6 +410,11 @@ const code = "example"; text: "Failed to write note. Is Basic Memory running? Check logs for details.", }, ], + details: { + title: "Failed Note", + folder: "notes", + error: "write_note_failed", + }, }) }) diff --git a/tools/write-note.ts b/tools/write-note.ts index b6a32f4..2ee0f14 100644 --- a/tools/write-note.ts +++ b/tools/write-note.ts @@ -83,6 +83,11 @@ export function registerWriteTool( return { content: [{ type: "text" as const, text: hint }], + details: { + title: params.title, + permalink: err.permalink, + error: "note_already_exists", + }, } } @@ -94,6 +99,11 @@ export function registerWriteTool( text: "Failed to write note. Is Basic Memory running? Check logs for details.", }, ], + details: { + title: params.title, + folder: params.folder, + error: "write_note_failed", + }, } } }, diff --git a/types/openclaw.d.ts b/types/openclaw.d.ts deleted file mode 100644 index d6ca1f4..0000000 --- a/types/openclaw.d.ts +++ /dev/null @@ -1,24 +0,0 @@ -declare module "openclaw/plugin-sdk" { - export interface OpenClawPluginApi { - pluginConfig: unknown - logger: { - info: (msg: string) => void - warn: (msg: string) => void - error: (msg: string, ...args: unknown[]) => void - debug: (msg: string) => void - } - // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types - registerTool(tool: any, options: any): void - // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types - registerCommand(command: any): void - // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types - registerCli(handler: any, options?: any): void - // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types - registerService(service: any): void - // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types - on(event: string, handler: (...args: any[]) => any): void - } - - // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types - export function stringEnum(values: readonly string[]): any -} From df3905fccb52c33e6d9840cffcdb2cc4a35d4701 Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 9 Mar 2026 15:30:36 -0500 Subject: [PATCH 2/5] feat(context-engine): add bounded assemble-time BM recall --- .../basic-memory-context-engine.test.ts | 87 ++++++++++++++++++- context-engine/basic-memory-context-engine.ts | 22 ++++- 2 files changed, 107 insertions(+), 2 deletions(-) diff --git a/context-engine/basic-memory-context-engine.test.ts b/context-engine/basic-memory-context-engine.test.ts index 12e6318..a35aaee 100644 --- a/context-engine/basic-memory-context-engine.test.ts +++ b/context-engine/basic-memory-context-engine.test.ts @@ -2,7 +2,10 @@ import { beforeEach, describe, expect, it, jest } from "bun:test" import type { AgentMessage } from "@mariozechner/pi-agent-core" import type { BmClient } from "../bm-client.ts" import type { BasicMemoryConfig } from "../config.ts" -import { BasicMemoryContextEngine } from "./basic-memory-context-engine.ts" +import { + BasicMemoryContextEngine, + MAX_ASSEMBLE_RECALL_CHARS, +} from "./basic-memory-context-engine.ts" function makeConfig( overrides?: Partial, @@ -102,6 +105,31 @@ describe("BasicMemoryContextEngine", () => { }) }) + it("injects bounded BM recall during assemble when bootstrap found context", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await engine.bootstrap({ + sessionId: "session-assemble", + sessionFile: "/tmp/session-assemble.jsonl", + }) + + const result = await engine.assemble({ + sessionId: "session-assemble", + messages: makeMessages([{ role: "user", content: "hello" }]), + }) + + expect(result.messages).toEqual( + makeMessages([{ role: "user", content: "hello" }]), + ) + expect(result.systemPromptAddition).toContain("## Active Tasks") + expect(result.systemPromptAddition).toContain("Fix auth rollout") + expect(result.systemPromptAddition).toContain("## Recent Activity") + expect(result.systemPromptAddition).toContain("API review") + }) + it("returns a no-op bootstrap result when there is no recall context", async () => { mockClient.search.mockResolvedValue([]) mockClient.recentActivity.mockResolvedValue([]) @@ -120,6 +148,63 @@ describe("BasicMemoryContextEngine", () => { bootstrapped: false, reason: "no recall context found", }) + + const result = await engine.assemble({ + sessionId: "session-3", + messages: makeMessages([{ role: "user", content: "hello" }]), + }) + + expect(result).toEqual({ + messages: makeMessages([{ role: "user", content: "hello" }]), + estimatedTokens: 0, + }) + }) + + it("keeps assemble recall stable and within the hard bound", async () => { + mockClient.search.mockResolvedValue([ + { + title: "Long task", + permalink: "long-task", + content: "A".repeat(4000), + file_path: "memory/tasks/long-task.md", + }, + ]) + mockClient.recentActivity.mockResolvedValue([ + { + title: "Long recent item", + permalink: "long-recent-item", + file_path: "memory/long-recent-item.md", + created_at: "2026-03-09T12:00:00Z", + }, + ]) + + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig({ + recallPrompt: "P".repeat(4000), + }), + ) + + await engine.bootstrap({ + sessionId: "session-bounded", + sessionFile: "/tmp/session-bounded.jsonl", + }) + + const first = await engine.assemble({ + sessionId: "session-bounded", + messages: makeMessages([{ role: "user", content: "hello" }]), + }) + const second = await engine.assemble({ + sessionId: "session-bounded", + messages: makeMessages([{ role: "user", content: "hello" }]), + }) + + expect(first.systemPromptAddition).toBeDefined() + expect(first.systemPromptAddition?.length).toBeLessThanOrEqual( + MAX_ASSEMBLE_RECALL_CHARS, + ) + expect(first.systemPromptAddition).toContain("[Basic Memory recall truncated]") + expect(second.systemPromptAddition).toBe(first.systemPromptAddition) }) it("captures only the current turn after prePromptMessageCount", async () => { diff --git a/context-engine/basic-memory-context-engine.ts b/context-engine/basic-memory-context-engine.ts index 565c3d2..2f39c11 100644 --- a/context-engine/basic-memory-context-engine.ts +++ b/context-engine/basic-memory-context-engine.ts @@ -15,11 +15,28 @@ import { loadRecallState } from "../hooks/recall.ts" import { log } from "../logger.ts" const require = createRequire(import.meta.url) +export const MAX_ASSEMBLE_RECALL_CHARS = 1200 +const TRUNCATED_RECALL_SUFFIX = "\n\n[Basic Memory recall truncated]" interface SessionMemoryState { recallContext: string } +function boundRecallContext(context: string): string { + if (context.length <= MAX_ASSEMBLE_RECALL_CHARS) { + return context + } + + const trimmed = context + .slice( + 0, + Math.max(0, MAX_ASSEMBLE_RECALL_CHARS - TRUNCATED_RECALL_SUFFIX.length), + ) + .trimEnd() + + return `${trimmed}${TRUNCATED_RECALL_SUFFIX}` +} + type LegacyContextEngineModule = { LegacyContextEngine: new () => { compact(params: { @@ -85,7 +102,7 @@ export class BasicMemoryContextEngine implements ContextEngine { } this.sessionState.set(params.sessionId, { - recallContext: recall.context, + recallContext: boundRecallContext(recall.context), }) log.debug( @@ -109,9 +126,12 @@ export class BasicMemoryContextEngine implements ContextEngine { messages: AgentMessage[] tokenBudget?: number }): Promise { + const state = this.sessionState.get(params.sessionId) + return { messages: params.messages, estimatedTokens: 0, + systemPromptAddition: state?.recallContext, } } From 48aaa90731cba7876c03699e896d4d954907358d Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 9 Mar 2026 15:34:28 -0500 Subject: [PATCH 3/5] feat(context-engine): add subagent memory handoff --- .../basic-memory-context-engine.test.ts | 119 ++++++++++++++++ context-engine/basic-memory-context-engine.ts | 133 ++++++++++++++++++ 2 files changed, 252 insertions(+) diff --git a/context-engine/basic-memory-context-engine.test.ts b/context-engine/basic-memory-context-engine.test.ts index a35aaee..af78320 100644 --- a/context-engine/basic-memory-context-engine.test.ts +++ b/context-engine/basic-memory-context-engine.test.ts @@ -35,6 +35,9 @@ describe("BasicMemoryContextEngine", () => { search: jest.Mock recentActivity: jest.Mock indexConversation: jest.Mock + writeNote: jest.Mock + editNote: jest.Mock + deleteNote: jest.Mock } beforeEach(() => { @@ -56,6 +59,26 @@ describe("BasicMemoryContextEngine", () => { }, ]), indexConversation: jest.fn().mockResolvedValue(undefined), + writeNote: jest.fn().mockResolvedValue({ + title: "subagent-handoff-agent-test-subagent-child-1", + permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + file_path: + "memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md", + content: "", + }), + editNote: jest.fn().mockResolvedValue({ + title: "subagent-handoff-agent-test-subagent-child-1", + permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + file_path: + "memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md", + operation: "append", + }), + deleteNote: jest.fn().mockResolvedValue({ + title: "subagent-handoff-agent-test-subagent-child-1", + permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + file_path: + "memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md", + }), } }) @@ -207,6 +230,102 @@ describe("BasicMemoryContextEngine", () => { expect(second.systemPromptAddition).toBe(first.systemPromptAddition) }) + it("creates a parent-to-child BM handoff note on subagent spawn", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await engine.bootstrap({ + sessionId: "parent-session", + sessionFile: "/tmp/parent-session.jsonl", + }) + + const preparation = await engine.prepareSubagentSpawn({ + parentSessionKey: "parent-session", + childSessionKey: "agent:test:subagent:child-1", + }) + + expect(preparation).toBeDefined() + expect(mockClient.writeNote).toHaveBeenCalledWith( + "subagent-handoff-agent-test-subagent-child-1", + expect.stringContaining("## Parent Basic Memory Context"), + "agent/subagents", + ) + }) + + it("rolls back the handoff note when subagent spawn fails after preparation", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + const preparation = await engine.prepareSubagentSpawn({ + parentSessionKey: "parent-session", + childSessionKey: "agent:test:subagent:child-rollback", + }) + + expect(preparation).toBeDefined() + await preparation?.rollback() + + expect(mockClient.deleteNote).toHaveBeenCalledWith( + "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + ) + }) + + it("appends completion details to the handoff note when a child session completes", async () => { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await engine.prepareSubagentSpawn({ + parentSessionKey: "parent-session", + childSessionKey: "agent:test:subagent:child-complete", + }) + + await engine.onSubagentEnded({ + childSessionKey: "agent:test:subagent:child-complete", + reason: "completed", + }) + + expect(mockClient.editNote).toHaveBeenCalledWith( + "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + "append", + expect.stringContaining("Reason: completed"), + ) + expect(mockClient.editNote).toHaveBeenCalledWith( + "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + "append", + expect.stringContaining("Durable conversation capture continues through the normal afterTurn path."), + ) + }) + + it("handles deleted, released, and swept child endings without errors", async () => { + const reasons = ["deleted", "released", "swept"] as const + + for (const reason of reasons) { + const engine = new BasicMemoryContextEngine( + mockClient as unknown as BmClient, + makeConfig(), + ) + + await engine.prepareSubagentSpawn({ + parentSessionKey: "parent-session", + childSessionKey: `agent:test:subagent:${reason}`, + }) + + await expect( + engine.onSubagentEnded({ + childSessionKey: `agent:test:subagent:${reason}`, + reason, + }), + ).resolves.toBeUndefined() + } + + expect(mockClient.editNote).toHaveBeenCalledTimes(3) + }) + it("captures only the current turn after prePromptMessageCount", async () => { const engine = new BasicMemoryContextEngine( mockClient as unknown as BmClient, diff --git a/context-engine/basic-memory-context-engine.ts b/context-engine/basic-memory-context-engine.ts index 2f39c11..43aaa71 100644 --- a/context-engine/basic-memory-context-engine.ts +++ b/context-engine/basic-memory-context-engine.ts @@ -17,11 +17,19 @@ import { log } from "../logger.ts" const require = createRequire(import.meta.url) export const MAX_ASSEMBLE_RECALL_CHARS = 1200 const TRUNCATED_RECALL_SUFFIX = "\n\n[Basic Memory recall truncated]" +const SUBAGENT_HANDOFF_FOLDER = "agent/subagents" +const MAX_SUBAGENT_RECALL_CHARS = 800 interface SessionMemoryState { recallContext: string } +interface SubagentHandoffState { + noteIdentifier: string + noteTitle: string + parentSessionKey: string +} + function boundRecallContext(context: string): string { if (context.length <= MAX_ASSEMBLE_RECALL_CHARS) { return context @@ -37,6 +45,65 @@ function boundRecallContext(context: string): string { return `${trimmed}${TRUNCATED_RECALL_SUFFIX}` } +function slugifySessionKey(sessionKey: string): string { + return sessionKey + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, "") + .slice(0, 80) +} + +function buildSubagentNoteTitle(childSessionKey: string): string { + return `subagent-handoff-${slugifySessionKey(childSessionKey)}` +} + +function buildSubagentHandoffContent(params: { + parentSessionKey: string + childSessionKey: string + recallContext?: string +}): string { + const sections = [ + "# Subagent Handoff", + "", + "## Sessions", + `- Parent: ${params.parentSessionKey}`, + `- Child: ${params.childSessionKey}`, + "", + "## Lifecycle", + `- Spawned: ${new Date().toISOString()}`, + ] + + if (params.recallContext) { + sections.push( + "", + "## Parent Basic Memory Context", + params.recallContext.slice(0, MAX_SUBAGENT_RECALL_CHARS).trimEnd(), + ) + } + + return sections.join("\n") +} + +function buildSubagentCompletionUpdate(params: { + childSessionKey: string + reason: "deleted" | "completed" | "swept" | "released" +}): string { + const statusLine = + params.reason === "completed" + ? "Child run completed. Durable conversation capture continues through the normal afterTurn path." + : `Child run ended with reason: ${params.reason}.` + + return [ + "", + "## Completion", + `- Child: ${params.childSessionKey}`, + `- Ended: ${new Date().toISOString()}`, + `- Reason: ${params.reason}`, + "", + statusLine, + ].join("\n") +} + type LegacyContextEngineModule = { LegacyContextEngine: new () => { compact(params: { @@ -76,6 +143,7 @@ export class BasicMemoryContextEngine implements ContextEngine { } as const private readonly sessionState = new Map() + private readonly subagentState = new Map() private legacyContextEnginePromise: | Promise> | null = null @@ -179,8 +247,73 @@ export class BasicMemoryContextEngine implements ContextEngine { return legacy.compact(params) } + async prepareSubagentSpawn(params: { + parentSessionKey: string + childSessionKey: string + ttlMs?: number + }): Promise<{ rollback: () => Promise } | undefined> { + const parentState = this.sessionState.get(params.parentSessionKey) + const noteTitle = buildSubagentNoteTitle(params.childSessionKey) + + try { + const note = await this.client.writeNote( + noteTitle, + buildSubagentHandoffContent({ + parentSessionKey: params.parentSessionKey, + childSessionKey: params.childSessionKey, + recallContext: parentState?.recallContext, + }), + SUBAGENT_HANDOFF_FOLDER, + ) + + this.subagentState.set(params.childSessionKey, { + noteIdentifier: note.permalink, + noteTitle: note.title, + parentSessionKey: params.parentSessionKey, + }) + + return { + rollback: async () => { + const handoff = this.subagentState.get(params.childSessionKey) + this.subagentState.delete(params.childSessionKey) + if (!handoff) return + + try { + await this.client.deleteNote(handoff.noteIdentifier) + } catch (err) { + log.error("context-engine subagent rollback failed", err) + } + }, + } + } catch (err) { + log.error("context-engine prepareSubagentSpawn failed", err) + return undefined + } + } + + async onSubagentEnded(params: { + childSessionKey: string + reason: "deleted" | "completed" | "swept" | "released" + }): Promise { + const handoff = this.subagentState.get(params.childSessionKey) + if (!handoff) return + + this.subagentState.delete(params.childSessionKey) + + try { + await this.client.editNote( + handoff.noteIdentifier, + "append", + buildSubagentCompletionUpdate(params), + ) + } catch (err) { + log.error("context-engine onSubagentEnded failed", err) + } + } + async dispose(): Promise { this.sessionState.clear() + this.subagentState.clear() } private async getLegacyContextEngine(): Promise< From e851df796d359a2fcee164abb5a52b1f2c8f8943 Mon Sep 17 00:00:00 2001 From: phernandez Date: Mon, 9 Mar 2026 16:24:36 -0500 Subject: [PATCH 4/5] chore(ci): align local lint checks with CI --- .githooks/pre-commit | 11 ++++++++ biome.json | 2 +- .../basic-memory-context-engine.test.ts | 25 ++++++++++++------- context-engine/basic-memory-context-engine.ts | 6 ++--- hooks/capture.ts | 5 +--- justfile | 9 ++++--- package.json | 2 +- 7 files changed, 38 insertions(+), 22 deletions(-) create mode 100755 .githooks/pre-commit diff --git a/.githooks/pre-commit b/.githooks/pre-commit new file mode 100755 index 0000000..53d47c7 --- /dev/null +++ b/.githooks/pre-commit @@ -0,0 +1,11 @@ +#!/usr/bin/env bash +set -euo pipefail + +if ! command -v bun >/dev/null 2>&1; then + echo "bun is required to run pre-commit checks." >&2 + exit 1 +fi + +echo "Running pre-commit checks..." +bun run lint +bun run check-types diff --git a/biome.json b/biome.json index ded29bc..6ff280c 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.3.14/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.6/schema.json", "assist": { "actions": { "source": { diff --git a/context-engine/basic-memory-context-engine.test.ts b/context-engine/basic-memory-context-engine.test.ts index af78320..3e2fd66 100644 --- a/context-engine/basic-memory-context-engine.test.ts +++ b/context-engine/basic-memory-context-engine.test.ts @@ -7,9 +7,7 @@ import { MAX_ASSEMBLE_RECALL_CHARS, } from "./basic-memory-context-engine.ts" -function makeConfig( - overrides?: Partial, -): BasicMemoryConfig { +function makeConfig(overrides?: Partial): BasicMemoryConfig { return { project: "test-project", bmPath: "bm", @@ -26,7 +24,9 @@ function makeConfig( } } -function makeMessages(messages: Array>): AgentMessage[] { +function makeMessages( + messages: Array>, +): AgentMessage[] { return messages as AgentMessage[] } @@ -61,21 +61,24 @@ describe("BasicMemoryContextEngine", () => { indexConversation: jest.fn().mockResolvedValue(undefined), writeNote: jest.fn().mockResolvedValue({ title: "subagent-handoff-agent-test-subagent-child-1", - permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + permalink: + "agent/subagents/subagent-handoff-agent-test-subagent-child-1", file_path: "memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md", content: "", }), editNote: jest.fn().mockResolvedValue({ title: "subagent-handoff-agent-test-subagent-child-1", - permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + permalink: + "agent/subagents/subagent-handoff-agent-test-subagent-child-1", file_path: "memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md", operation: "append", }), deleteNote: jest.fn().mockResolvedValue({ title: "subagent-handoff-agent-test-subagent-child-1", - permalink: "agent/subagents/subagent-handoff-agent-test-subagent-child-1", + permalink: + "agent/subagents/subagent-handoff-agent-test-subagent-child-1", file_path: "memory/agent/subagents/subagent-handoff-agent-test-subagent-child-1.md", }), @@ -226,7 +229,9 @@ describe("BasicMemoryContextEngine", () => { expect(first.systemPromptAddition?.length).toBeLessThanOrEqual( MAX_ASSEMBLE_RECALL_CHARS, ) - expect(first.systemPromptAddition).toContain("[Basic Memory recall truncated]") + expect(first.systemPromptAddition).toContain( + "[Basic Memory recall truncated]", + ) expect(second.systemPromptAddition).toBe(first.systemPromptAddition) }) @@ -297,7 +302,9 @@ describe("BasicMemoryContextEngine", () => { expect(mockClient.editNote).toHaveBeenCalledWith( "agent/subagents/subagent-handoff-agent-test-subagent-child-1", "append", - expect.stringContaining("Durable conversation capture continues through the normal afterTurn path."), + expect.stringContaining( + "Durable conversation capture continues through the normal afterTurn path.", + ), ) }) diff --git a/context-engine/basic-memory-context-engine.ts b/context-engine/basic-memory-context-engine.ts index 43aaa71..10dcd2c 100644 --- a/context-engine/basic-memory-context-engine.ts +++ b/context-engine/basic-memory-context-engine.ts @@ -144,9 +144,9 @@ export class BasicMemoryContextEngine implements ContextEngine { private readonly sessionState = new Map() private readonly subagentState = new Map() - private legacyContextEnginePromise: - | Promise> - | null = null + private legacyContextEnginePromise: Promise< + InstanceType + > | null = null constructor( private readonly client: BmClient, diff --git a/hooks/capture.ts b/hooks/capture.ts index 55a6d5a..64bf75d 100644 --- a/hooks/capture.ts +++ b/hooks/capture.ts @@ -66,10 +66,7 @@ export function selectCaptureTurn( ): { userText: string; assistantText: string } | null { const turn = getLastTurn(messages) if (!turn.userText && !turn.assistantText) return null - if ( - turn.userText.length < minChars && - turn.assistantText.length < minChars - ) { + if (turn.userText.length < minChars && turn.assistantText.length < minChars) { return null } diff --git a/justfile b/justfile index b440ad1..6cc06e3 100644 --- a/justfile +++ b/justfile @@ -9,6 +9,7 @@ install: bun install bash scripts/setup-bm.sh bun scripts/fetch-skills.ts + git config core.hooksPath .githooks # Setup Basic Memory project setup: @@ -16,19 +17,19 @@ setup: # Type check check-types: - npx tsc --noEmit + bun run check-types # Lint lint: - npx @biomejs/biome check . + bun run lint # Lint fix (safe + unsafe) lint-fix: - npx @biomejs/biome check --write --unsafe . + bun run lint:fix # Format and fix fix: - npx @biomejs/biome check --write --unsafe . + bun run lint:fix # Run tests test: diff --git a/package.json b/package.json index 5b6d153..149d800 100644 --- a/package.json +++ b/package.json @@ -69,7 +69,7 @@ ] }, "devDependencies": { - "@biomejs/biome": "^2.3.8", + "@biomejs/biome": "^2.4.6", "@types/node": "^20.0.0", "openclaw": "^2026.3.8", "typescript": "^5.9.3" From 492c91bd0748678c5e5a1064f8899ab165132ee4 Mon Sep 17 00:00:00 2001 From: phernandez Date: Wed, 11 Mar 2026 13:46:31 -0500 Subject: [PATCH 5/5] docs: add context engine implementation plan --- CONTEXT_ENGINE_PLAN.md | 275 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 275 insertions(+) create mode 100644 CONTEXT_ENGINE_PLAN.md diff --git a/CONTEXT_ENGINE_PLAN.md b/CONTEXT_ENGINE_PLAN.md new file mode 100644 index 0000000..80359f0 --- /dev/null +++ b/CONTEXT_ENGINE_PLAN.md @@ -0,0 +1,275 @@ +# Basic Memory ContextEngine Plan + +## Goal + +Complete the Basic Memory integration with OpenClaw's native memory lifecycle so BM works as a decorator around the default OpenClaw flow instead of relying on `agent_start` / `agent_end` shims. + +The target model is: + +- OpenClaw owns session state, context assembly pipeline, and compaction. +- Basic Memory owns durable knowledge, cross-session recall, and long-term capture. +- This plugin enriches the default flow without replacing it. + +## Scope + +This plan is for [issue #34](https://github.com/basicmachines-co/openclaw-basic-memory/issues/34), updated to match the "complement, don't replace" direction discussed there. + +We will use the new OpenClaw `ContextEngine` lifecycle introduced in OpenClaw `2026.3.7` on March 6, 2026, but we will not implement a custom compaction strategy. + +## Non-Goals + +- Do not replace or override OpenClaw compaction behavior. +- Do not compete with lossless-claw or other alternate context engines. +- Do not turn BM into the canonical source of current-session state. +- Do not remove existing BM tools such as `memory_search`, `memory_get`, `search_notes`, `read_note`, and note CRUD tools. +- Do not add aggressive semantic retrieval on every turn. + +## Design Principles + +### Decorator, not replacement + +The plugin should behave like a wrapper around the default OpenClaw memory model: + +- OpenClaw tracks the live conversation. +- BM stores durable notes, tasks, decisions, and cross-session context. +- The plugin bridges the two systems at official lifecycle boundaries. + +### Keep the baseline flow intact + +Where the ContextEngine API requires behavior that OpenClaw already provides well, we should pass through to the default behavior instead of re-implementing it. + +### Add value only where BM is strongest + +BM should improve: + +- session bootstrap recall +- durable post-turn capture +- subagent memory inheritance +- cross-session continuity through notes and graph search + +BM should not try to improve: + +- session-local compaction +- low-level pruning logic +- runtime token budgeting heuristics + +## Current State + +Today the plugin uses: + +- `api.on("agent_start", ...)` for recall +- `api.on("agent_end", ...)` for capture +- composited `memory_search` / `memory_get` tools for explicit retrieval + +This works, but it lives beside OpenClaw's memory lifecycle instead of inside it. + +Relevant current files: + +- `index.ts` +- `hooks/recall.ts` +- `hooks/capture.ts` +- `tools/memory-provider.ts` +- `types/openclaw.d.ts` + +Current dependency constraint: + +- `package.json` currently pins `openclaw` peer support to `>=2026.1.29` +- the local installed dependency is `openclaw@2026.2.6` +- ContextEngine work requires moving to the `2026.3.7+` SDK surface + +## Target Architecture + +Add a `BasicMemoryContextEngine` that composes with the default OpenClaw flow. + +Expected lifecycle usage: + +- `bootstrap` + - initialize BM session-side recall state + - gather small, high-signal context such as active tasks and recent activity +- `assemble` + - pass through OpenClaw messages + - optionally add a compact BM recall block when useful +- `afterTurn` + - persist durable takeaways from the completed turn into BM +- `prepareSubagentSpawn` + - prepare a minimal BM handoff for a child session +- `onSubagentEnded` + - capture child results back into BM +- `compact` + - do not customize + - use legacy/default pass-through behavior only if the interface requires it + +## Phase Plan + +## Phase 1 + +### Commit goal + +`feat(context-engine): move recall and capture into native lifecycle` + +### Deliverables + +- bump OpenClaw compatibility to `2026.3.7+` +- replace the local SDK shim with the real ContextEngine-capable SDK types where possible +- add a `BasicMemoryContextEngine` +- register the engine through `api.registerContextEngine(...)` +- migrate recall behavior from `agent_start` into `bootstrap` +- migrate capture behavior from `agent_end` into `afterTurn` +- keep existing BM tools and service startup behavior intact +- keep compaction fully default + +### Expected behavior + +- session startup still recalls active tasks and recent activity +- turns still get captured into BM +- plugin behavior is functionally similar to today, but now uses official lifecycle hooks + +### Test coverage + +- engine registration works +- `bootstrap` returns expected initialized state when recall finds data +- `bootstrap` is a no-op when recall finds nothing +- `afterTurn` captures only valid turn content +- `afterTurn` handles failures without breaking the run +- existing service startup and BM client lifecycle tests still pass + +## Phase 2 + +### Commit goal + +`feat(context-engine): add bounded assemble-time BM recall` + +### Deliverables + +- implement a minimal `assemble` hook +- preserve incoming OpenClaw messages in order +- add an optional BM recall block only when there is useful context +- bound the size of injected BM context so it stays cheap and predictable +- avoid per-turn graph-heavy retrieval unless explicitly configured later + +### Expected behavior + +- the model sees a small BM memory summary automatically when helpful +- explicit `memory_search` and `memory_get` remain available for deeper retrieval +- OpenClaw remains in charge of the actual context pipeline and compaction + +### Test coverage + +- `assemble` returns original messages unchanged when no recall block exists +- `assemble` adds a BM block when recall content exists +- injected content is size-bounded +- assembly remains stable across repeated turns when recall content is unchanged + +## Phase 3 + +### Commit goal + +`feat(context-engine): add subagent memory handoff` + +### Deliverables + +- implement `prepareSubagentSpawn` +- implement `onSubagentEnded` +- create a small BM handoff model for parent to child context transfer +- capture child outputs or summaries back into the parent knowledge base +- keep subagent integration lightweight and failure-tolerant + +### Expected behavior + +- subagents start with relevant BM context instead of a cold memory start +- useful child outputs become durable BM knowledge after completion +- failures in handoff/capture do not break subagent execution + +### Test coverage + +- child handoff is created for subagent sessions +- rollback path works if spawn fails after preparation +- child completion writes back expected BM artifacts +- delete/release/sweep paths are handled safely + +## Implementation Notes + +### Engine shape + +Prefer a small, explicit implementation instead of pushing logic back into `index.ts`. + +Likely new files: + +- `context-engine/basic-memory-context-engine.ts` +- `context-engine/basic-memory-context-engine.test.ts` +- optional small helper modules for recall/capture formatting + +### Hook migration + +After Phase 1 lands, the old event-hook path in `index.ts` should be removed or disabled so we do not double-capture or double-recall. + +### Tool preservation + +The BM tool surface remains part of the product even after lifecycle integration: + +- composited `memory_search` and `memory_get` +- graph CRUD tools +- schema tools +- slash commands and CLI commands + +Lifecycle integration complements explicit retrieval; it does not replace it. + +### Compatibility posture + +This work should be shipped as the canonical BM integration path for OpenClaw `2026.3.7+`. + +If we need a temporary compatibility story for older OpenClaw versions, keep it shallow and time-boxed. The long-term target should be one code path based on the native lifecycle. + +## Risks + +### Single-slot context engine model + +OpenClaw currently resolves one `contextEngine` slot, not a middleware stack. + +Implication: + +- our engine must behave like "default behavior plus BM enrichment" +- we should not assume we can stack with other context engines automatically + +### Over-injection + +If `assemble` injects too much, BM could bloat prompt cost and work against the default system. + +Mitigation: + +- keep Phase 2 narrow +- bound injected size +- prefer summaries over raw note dumps + +### Double-processing during migration + +If old hooks and new lifecycle paths run together, recall and capture may happen twice. + +Mitigation: + +- Phase 1 should explicitly remove or disable the legacy hook wiring +- add tests that assert only one capture path is active + +## Success Criteria + +This feature is complete when: + +- recall and capture happen through ContextEngine lifecycle hooks, not event shims +- BM enriches default session context without taking over compaction +- subagents inherit and return useful durable memory +- explicit BM tools remain intact +- the architecture clearly reflects "BM decorates OpenClaw memory" + +## Commit Sequence + +1. `feat(context-engine): move recall and capture into native lifecycle` +2. `feat(context-engine): add bounded assemble-time BM recall` +3. `feat(context-engine): add subagent memory handoff` + +## Out of Scope for This Stack + +- custom `compact` logic +- BM-driven token budgeting +- replacing post-compaction context reinjection +- new retrieval heuristics beyond a compact recall block +- multi-engine composition support inside OpenClaw core