@basicmemory/openclaw-basic-memory 0.1.5 → 0.2.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@basicmemory/openclaw-basic-memory",
3
- "version": "0.1.5",
3
+ "version": "0.2.1",
4
4
  "type": "module",
5
5
  "description": "Basic Memory plugin for OpenClaw — local-first knowledge graph for agent memory",
6
6
  "license": "MIT",
@@ -11,6 +11,7 @@
11
11
  "files": [
12
12
  "index.ts",
13
13
  "bm-client.ts",
14
+ "context-engine/",
14
15
  "config.ts",
15
16
  "logger.ts",
16
17
  "commands/cli.ts",
@@ -31,7 +32,6 @@
31
32
  "tools/schema-validate.ts",
32
33
  "tools/search-notes.ts",
33
34
  "tools/write-note.ts",
34
- "types/openclaw.d.ts",
35
35
  "schema/task-schema.ts",
36
36
  "schema/conversation-schema.ts",
37
37
  "skills/",
@@ -49,7 +49,7 @@
49
49
  "@sinclair/typebox": "0.34.47"
50
50
  },
51
51
  "peerDependencies": {
52
- "openclaw": ">=2026.1.29"
52
+ "openclaw": ">=2026.3.7"
53
53
  },
54
54
  "scripts": {
55
55
  "postinstall": "bash scripts/setup-bm.sh || true",
@@ -69,8 +69,9 @@
69
69
  ]
70
70
  },
71
71
  "devDependencies": {
72
- "@biomejs/biome": "^2.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:-main}"
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
@@ -14,6 +14,11 @@
14
14
  "name": "memory-lifecycle",
15
15
  "description": "Manage entity status transitions in Basic Memory: archive completed work, move notes between status folders, update frontmatter, and handle edge cases. Use when marking items complete, archiving old entities, or managing any folder-based status workflow."
16
16
  },
17
+ {
18
+ "dir": "memory-literary-analysis",
19
+ "name": "memory-literary-analysis",
20
+ "description": "Analyze a complete literary work into a structured Basic Memory knowledge graph. Covers schema design, entity seeding, chapter-by-chapter processing, cross-referencing, validation, and visualization."
21
+ },
17
22
  {
18
23
  "dir": "memory-metadata-search",
19
24
  "name": "memory-metadata-search",