@basicmemory/openclaw-basic-memory 0.1.0-alpha.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,176 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
+ import type { BmClient } from "../bm-client.ts"
3
+ import type { BasicMemoryConfig } from "../config.ts"
4
+ import { log } from "../logger.ts"
5
+
6
+ export function registerCli(
7
+ api: OpenClawPluginApi,
8
+ client: BmClient,
9
+ cfg: BasicMemoryConfig,
10
+ ): void {
11
+ api.registerCli(
12
+ // biome-ignore lint/suspicious/noExplicitAny: openclaw SDK does not ship types
13
+ ({ program }: { program: any }) => {
14
+ const cmd = program
15
+ .command("basic-memory")
16
+ .description("Basic Memory knowledge graph commands")
17
+
18
+ cmd
19
+ .command("search")
20
+ .argument("<query>", "Search query")
21
+ .option("--limit <n>", "Max results", "10")
22
+ .action(async (query: string, opts: { limit: string }) => {
23
+ const limit = Number.parseInt(opts.limit, 10) || 10
24
+ log.debug(`cli search: query="${query}" limit=${limit}`)
25
+
26
+ const results = await client.search(query, limit)
27
+
28
+ if (results.length === 0) {
29
+ console.log("No notes found.")
30
+ return
31
+ }
32
+
33
+ for (const r of results) {
34
+ const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : ""
35
+ console.log(`- ${r.title}${score}`)
36
+ if (r.content) {
37
+ const preview =
38
+ r.content.length > 100
39
+ ? `${r.content.slice(0, 100)}...`
40
+ : r.content
41
+ console.log(` ${preview}`)
42
+ }
43
+ }
44
+ })
45
+
46
+ cmd
47
+ .command("read")
48
+ .argument("<identifier>", "Note title, permalink, or memory:// URL")
49
+ .option("--raw", "Return raw markdown including frontmatter", false)
50
+ .action(async (identifier: string, opts: { raw?: boolean }) => {
51
+ log.debug(`cli read: identifier="${identifier}"`)
52
+
53
+ const note = await client.readNote(identifier, {
54
+ includeFrontmatter: opts.raw === true,
55
+ })
56
+ console.log(`# ${note.title}`)
57
+ console.log(`permalink: ${note.permalink}`)
58
+ console.log(`file: ${note.file_path}`)
59
+ console.log("")
60
+ console.log(note.content)
61
+ })
62
+
63
+ cmd
64
+ .command("edit")
65
+ .argument("<identifier>", "Note title, permalink, or memory:// URL")
66
+ .requiredOption(
67
+ "--operation <operation>",
68
+ "Edit operation: append|prepend|find_replace|replace_section",
69
+ )
70
+ .requiredOption("--content <content>", "Edit content")
71
+ .option("--find-text <text>", "Text to find for find_replace")
72
+ .option("--section <heading>", "Section heading for replace_section")
73
+ .option(
74
+ "--expected-replacements <n>",
75
+ "Expected replacement count for find_replace",
76
+ "1",
77
+ )
78
+ .action(
79
+ async (
80
+ identifier: string,
81
+ opts: {
82
+ operation:
83
+ | "append"
84
+ | "prepend"
85
+ | "find_replace"
86
+ | "replace_section"
87
+ content: string
88
+ findText?: string
89
+ section?: string
90
+ expectedReplacements: string
91
+ },
92
+ ) => {
93
+ const expectedReplacements =
94
+ Number.parseInt(opts.expectedReplacements, 10) || 1
95
+ log.debug(
96
+ `cli edit: identifier="${identifier}" op=${opts.operation} expected_replacements=${expectedReplacements}`,
97
+ )
98
+
99
+ const result = await client.editNote(
100
+ identifier,
101
+ opts.operation,
102
+ opts.content,
103
+ {
104
+ find_text: opts.findText,
105
+ section: opts.section,
106
+ expected_replacements: expectedReplacements,
107
+ },
108
+ )
109
+
110
+ console.log(`Edited: ${result.title}`)
111
+ console.log(`permalink: ${result.permalink}`)
112
+ console.log(`file: ${result.file_path}`)
113
+ console.log(`operation: ${result.operation}`)
114
+ if (result.checksum) {
115
+ console.log(`checksum: ${result.checksum}`)
116
+ }
117
+ },
118
+ )
119
+
120
+ cmd
121
+ .command("context")
122
+ .argument("<url>", "Memory URL to navigate")
123
+ .option("--depth <n>", "Relation hops to follow", "1")
124
+ .action(async (url: string, opts: { depth: string }) => {
125
+ const depth = Number.parseInt(opts.depth, 10) || 1
126
+ log.debug(`cli context: url="${url}" depth=${depth}`)
127
+
128
+ const ctx = await client.buildContext(url, depth)
129
+
130
+ if (!ctx.results || ctx.results.length === 0) {
131
+ console.log(`No context found for "${url}".`)
132
+ return
133
+ }
134
+
135
+ for (const result of ctx.results) {
136
+ console.log(`## ${result.primary_result.title}`)
137
+ console.log(result.primary_result.content)
138
+ console.log("")
139
+ }
140
+ })
141
+
142
+ cmd
143
+ .command("recent")
144
+ .option("--timeframe <t>", "Timeframe (e.g. 24h, 7d)", "24h")
145
+ .action(async (opts: { timeframe: string }) => {
146
+ log.debug(`cli recent: timeframe="${opts.timeframe}"`)
147
+
148
+ const results = await client.recentActivity(opts.timeframe)
149
+
150
+ if (results.length === 0) {
151
+ console.log("No recent activity.")
152
+ return
153
+ }
154
+
155
+ for (const r of results) {
156
+ console.log(`- ${r.title} (${r.permalink})`)
157
+ }
158
+ })
159
+
160
+ cmd
161
+ .command("status")
162
+ .description("Show plugin status")
163
+ .action(() => {
164
+ console.log(`Project: ${cfg.project}`)
165
+ console.log(`Project path: ${cfg.projectPath}`)
166
+ console.log(`BM CLI: ${cfg.bmPath}`)
167
+ console.log(`Memory dir: ${cfg.memoryDir}`)
168
+ console.log(`Memory file: ${cfg.memoryFile}`)
169
+ console.log(`Auto-capture: ${cfg.autoCapture}`)
170
+ console.log(`Cloud: ${cfg.cloud ? cfg.cloud.url : "disabled"}`)
171
+ console.log(`Debug: ${cfg.debug}`)
172
+ })
173
+ },
174
+ { commands: ["basic-memory"] },
175
+ )
176
+ }
@@ -0,0 +1,52 @@
1
+ import { readFileSync } from "node:fs"
2
+ import { dirname, resolve } from "node:path"
3
+ import { fileURLToPath } from "node:url"
4
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
5
+
6
+ const __dirname = dirname(fileURLToPath(import.meta.url))
7
+ const SKILLS_DIR = resolve(__dirname, "..", "skills")
8
+
9
+ function loadSkill(dir: string): string {
10
+ return readFileSync(resolve(SKILLS_DIR, dir, "SKILL.md"), "utf-8")
11
+ }
12
+
13
+ const SKILLS = [
14
+ { name: "tasks", dir: "memory-tasks", desc: "Task management workflow" },
15
+ {
16
+ name: "reflect",
17
+ dir: "memory-reflect",
18
+ desc: "Memory reflection workflow",
19
+ },
20
+ { name: "defrag", dir: "memory-defrag", desc: "Memory defrag workflow" },
21
+ { name: "schema", dir: "memory-schema", desc: "Schema management workflow" },
22
+ {
23
+ name: "notes",
24
+ dir: "memory-notes",
25
+ desc: "How to write well-structured notes",
26
+ },
27
+ {
28
+ name: "metadata-search",
29
+ dir: "memory-metadata-search",
30
+ desc: "Structured metadata search workflow",
31
+ },
32
+ ] as const
33
+
34
+ export function registerSkillCommands(api: OpenClawPluginApi): void {
35
+ for (const skill of SKILLS) {
36
+ const content = loadSkill(skill.dir)
37
+
38
+ api.registerCommand({
39
+ name: skill.name,
40
+ description: skill.desc,
41
+ acceptsArgs: true,
42
+ requireAuth: true,
43
+ handler: async (ctx: { args?: string }) => {
44
+ const args = ctx.args?.trim()
45
+ const prefix = args
46
+ ? `User request: ${args}\n\nFollow this workflow:\n\n`
47
+ : "Follow this workflow:\n\n"
48
+ return { text: prefix + content }
49
+ },
50
+ })
51
+ }
52
+ }
@@ -0,0 +1,73 @@
1
+ import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
2
+ import type { BmClient } from "../bm-client.ts"
3
+ import { log } from "../logger.ts"
4
+
5
+ export function registerCommands(
6
+ api: OpenClawPluginApi,
7
+ client: BmClient,
8
+ ): void {
9
+ api.registerCommand({
10
+ name: "remember",
11
+ description: "Save something to the Basic Memory knowledge graph",
12
+ acceptsArgs: true,
13
+ requireAuth: true,
14
+ handler: async (ctx: { args?: string }) => {
15
+ const text = ctx.args?.trim()
16
+ if (!text) {
17
+ return { text: "Usage: /remember <text to save as a note>" }
18
+ }
19
+
20
+ log.debug(`/remember: "${text.slice(0, 50)}"`)
21
+
22
+ try {
23
+ const title = text.length > 60 ? text.slice(0, 60) : text
24
+ await client.writeNote(title, text, "agent/memories")
25
+
26
+ const preview = text.length > 60 ? `${text.slice(0, 60)}...` : text
27
+ return { text: `Remembered: "${preview}"` }
28
+ } catch (err) {
29
+ log.error("/remember failed", err)
30
+ return {
31
+ text: "Failed to save memory. Is Basic Memory running?",
32
+ }
33
+ }
34
+ },
35
+ })
36
+
37
+ api.registerCommand({
38
+ name: "recall",
39
+ description: "Search the Basic Memory knowledge graph",
40
+ acceptsArgs: true,
41
+ requireAuth: true,
42
+ handler: async (ctx: { args?: string }) => {
43
+ const query = ctx.args?.trim()
44
+ if (!query) {
45
+ return { text: "Usage: /recall <search query>" }
46
+ }
47
+
48
+ log.debug(`/recall: "${query}"`)
49
+
50
+ try {
51
+ const results = await client.search(query, 5)
52
+
53
+ if (results.length === 0) {
54
+ return { text: `No notes found for: "${query}"` }
55
+ }
56
+
57
+ const lines = results.map((r, i) => {
58
+ const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : ""
59
+ return `${i + 1}. ${r.title}${score}`
60
+ })
61
+
62
+ return {
63
+ text: `Found ${results.length} notes:\n\n${lines.join("\n")}`,
64
+ }
65
+ } catch (err) {
66
+ log.error("/recall failed", err)
67
+ return {
68
+ text: "Failed to search. Is Basic Memory running?",
69
+ }
70
+ }
71
+ },
72
+ })
73
+ }
package/config.ts ADDED
@@ -0,0 +1,152 @@
1
+ import { homedir, hostname } from "node:os"
2
+ import { isAbsolute, resolve } from "node:path"
3
+
4
+ export type CloudConfig = {
5
+ url: string
6
+ api_key: string
7
+ }
8
+
9
+ export type BasicMemoryConfig = {
10
+ project: string
11
+ bmPath: string
12
+ memoryDir: string
13
+ memoryFile: string
14
+ projectPath: string
15
+ autoCapture: boolean
16
+ captureMinChars: number
17
+ autoRecall: boolean
18
+ recallPrompt: string
19
+ debug: boolean
20
+ cloud?: CloudConfig
21
+ }
22
+
23
+ const ALLOWED_KEYS = [
24
+ "project",
25
+ "bmPath",
26
+ "memoryDir",
27
+ "memory_dir",
28
+ "memoryFile",
29
+ "memory_file",
30
+ "projectPath",
31
+ "autoCapture",
32
+ "captureMinChars",
33
+ "capture_min_chars",
34
+ "autoRecall",
35
+ "auto_recall",
36
+ "recallPrompt",
37
+ "recall_prompt",
38
+ "debug",
39
+ "cloud",
40
+ ]
41
+
42
+ function assertAllowedKeys(
43
+ value: Record<string, unknown>,
44
+ allowed: string[],
45
+ label: string,
46
+ ): void {
47
+ const unknown = Object.keys(value).filter((k) => !allowed.includes(k))
48
+ if (unknown.length > 0) {
49
+ throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`)
50
+ }
51
+ }
52
+
53
+ function defaultProject(): string {
54
+ return `openclaw-${hostname()
55
+ .replace(/[^a-zA-Z0-9-]/g, "-")
56
+ .toLowerCase()}`
57
+ }
58
+
59
+ function expandUserPath(path: string): string {
60
+ if (path === "~") return homedir()
61
+ if (path.startsWith("~/")) return `${homedir()}/${path.slice(2)}`
62
+ return path
63
+ }
64
+
65
+ export function resolveProjectPath(
66
+ projectPath: string,
67
+ workspaceDir: string,
68
+ ): string {
69
+ const expanded = expandUserPath(projectPath)
70
+ if (isAbsolute(expanded)) return expanded
71
+ return resolve(workspaceDir, expanded)
72
+ }
73
+
74
+ export function parseConfig(raw: unknown): BasicMemoryConfig {
75
+ const cfg =
76
+ raw && typeof raw === "object" && !Array.isArray(raw)
77
+ ? (raw as Record<string, unknown>)
78
+ : {}
79
+
80
+ if (Object.keys(cfg).length > 0) {
81
+ assertAllowedKeys(cfg, ALLOWED_KEYS, "basic-memory config")
82
+ }
83
+
84
+ // Support both camelCase and snake_case for memory_dir / memory_file
85
+ const memoryDir =
86
+ typeof cfg.memoryDir === "string" && cfg.memoryDir.length > 0
87
+ ? cfg.memoryDir
88
+ : typeof cfg.memory_dir === "string" &&
89
+ (cfg.memory_dir as string).length > 0
90
+ ? (cfg.memory_dir as string)
91
+ : "memory/"
92
+
93
+ const memoryFile =
94
+ typeof cfg.memoryFile === "string" && cfg.memoryFile.length > 0
95
+ ? cfg.memoryFile
96
+ : typeof cfg.memory_file === "string" &&
97
+ (cfg.memory_file as string).length > 0
98
+ ? (cfg.memory_file as string)
99
+ : "MEMORY.md"
100
+
101
+ let cloud: CloudConfig | undefined
102
+ if (cfg.cloud && typeof cfg.cloud === "object" && !Array.isArray(cfg.cloud)) {
103
+ const c = cfg.cloud as Record<string, unknown>
104
+ if (typeof c.url === "string" && typeof c.api_key === "string") {
105
+ cloud = { url: c.url, api_key: c.api_key }
106
+ }
107
+ }
108
+
109
+ return {
110
+ project:
111
+ typeof cfg.project === "string" && cfg.project.length > 0
112
+ ? cfg.project
113
+ : defaultProject(),
114
+ projectPath:
115
+ typeof cfg.projectPath === "string" && cfg.projectPath.length > 0
116
+ ? cfg.projectPath
117
+ : memoryDir,
118
+ bmPath:
119
+ typeof cfg.bmPath === "string" && cfg.bmPath.length > 0
120
+ ? cfg.bmPath
121
+ : "bm",
122
+ memoryDir,
123
+ memoryFile,
124
+ autoCapture: typeof cfg.autoCapture === "boolean" ? cfg.autoCapture : true,
125
+ captureMinChars:
126
+ typeof cfg.captureMinChars === "number" && cfg.captureMinChars >= 0
127
+ ? cfg.captureMinChars
128
+ : typeof cfg.capture_min_chars === "number" &&
129
+ (cfg.capture_min_chars as number) >= 0
130
+ ? (cfg.capture_min_chars as number)
131
+ : 10,
132
+ autoRecall:
133
+ typeof cfg.autoRecall === "boolean"
134
+ ? cfg.autoRecall
135
+ : typeof cfg.auto_recall === "boolean"
136
+ ? (cfg.auto_recall as boolean)
137
+ : true,
138
+ recallPrompt:
139
+ typeof cfg.recallPrompt === "string" && cfg.recallPrompt.length > 0
140
+ ? cfg.recallPrompt
141
+ : typeof cfg.recall_prompt === "string" &&
142
+ (cfg.recall_prompt as string).length > 0
143
+ ? (cfg.recall_prompt as string)
144
+ : "Check for active tasks and recent activity. Summarize anything relevant to the current session.",
145
+ debug: typeof cfg.debug === "boolean" ? cfg.debug : false,
146
+ cloud,
147
+ }
148
+ }
149
+
150
+ export const basicMemoryConfigSchema = {
151
+ parse: parseConfig,
152
+ }
@@ -0,0 +1,95 @@
1
+ import type { BmClient } from "../bm-client.ts"
2
+ import type { BasicMemoryConfig } from "../config.ts"
3
+ import { log } from "../logger.ts"
4
+
5
+ /**
6
+ * Extract text content from a message object.
7
+ */
8
+ function extractText(msg: Record<string, unknown>): string {
9
+ const content = msg.content
10
+ if (typeof content === "string") return content
11
+
12
+ if (Array.isArray(content)) {
13
+ const parts: string[] = []
14
+ for (const block of content) {
15
+ if (!block || typeof block !== "object") continue
16
+ const b = block as Record<string, unknown>
17
+ if (b.type === "text" && typeof b.text === "string") {
18
+ parts.push(b.text)
19
+ }
20
+ }
21
+ return parts.join("\n")
22
+ }
23
+
24
+ return ""
25
+ }
26
+
27
+ /**
28
+ * Find the last user+assistant turn from the messages array.
29
+ */
30
+ function getLastTurn(messages: unknown[]): {
31
+ userText: string
32
+ assistantText: string
33
+ } {
34
+ let lastUserIdx = -1
35
+ for (let i = messages.length - 1; i >= 0; i--) {
36
+ const msg = messages[i]
37
+ if (
38
+ msg &&
39
+ typeof msg === "object" &&
40
+ (msg as Record<string, unknown>).role === "user"
41
+ ) {
42
+ lastUserIdx = i
43
+ break
44
+ }
45
+ }
46
+
47
+ if (lastUserIdx < 0) return { userText: "", assistantText: "" }
48
+
49
+ let userText = ""
50
+ let assistantText = ""
51
+
52
+ for (let i = lastUserIdx; i < messages.length; i++) {
53
+ const msg = messages[i] as Record<string, unknown>
54
+ if (!msg?.role) continue
55
+ const text = extractText(msg)
56
+ if (msg.role === "user") userText = text
57
+ else if (msg.role === "assistant") assistantText = text
58
+ }
59
+
60
+ return { userText, assistantText }
61
+ }
62
+
63
+ /**
64
+ * Build the post-turn capture handler for Mode B.
65
+ *
66
+ * After each agent turn, extracts the conversation content and indexes it
67
+ * into the Basic Memory knowledge graph as a conversation note.
68
+ */
69
+ export function buildCaptureHandler(client: BmClient, cfg: BasicMemoryConfig) {
70
+ const minChars = cfg.captureMinChars
71
+ return async (event: Record<string, unknown>) => {
72
+ if (
73
+ !event.success ||
74
+ !Array.isArray(event.messages) ||
75
+ event.messages.length === 0
76
+ ) {
77
+ return
78
+ }
79
+
80
+ const { userText, assistantText } = getLastTurn(event.messages)
81
+
82
+ if (!userText && !assistantText) return
83
+ if (userText.length < minChars && assistantText.length < minChars) return
84
+
85
+ log.debug(
86
+ `capturing conversation: user=${userText.length} chars, assistant=${assistantText.length} chars`,
87
+ )
88
+
89
+ try {
90
+ await client.indexConversation(userText, assistantText)
91
+ } catch (err) {
92
+ log.error("capture failed", err)
93
+ }
94
+ }
95
+ }
@@ -0,0 +1,66 @@
1
+ import type {
2
+ BmClient,
3
+ MetadataSearchResult,
4
+ RecentResult,
5
+ } from "../bm-client.ts"
6
+ import type { BasicMemoryConfig } from "../config.ts"
7
+ import { log } from "../logger.ts"
8
+
9
+ /**
10
+ * Format recalled context from active tasks and recent activity.
11
+ * Returns empty string if nothing was found.
12
+ */
13
+ export function formatRecallContext(
14
+ tasks: MetadataSearchResult,
15
+ recent: RecentResult[],
16
+ prompt: string,
17
+ ): string {
18
+ const sections: string[] = []
19
+
20
+ if (tasks.results.length > 0) {
21
+ const taskLines = tasks.results.map((t) => {
22
+ const preview =
23
+ t.content.length > 120 ? `${t.content.slice(0, 120)}...` : t.content
24
+ return `- **${t.title}** — ${preview}`
25
+ })
26
+ sections.push(`## Active Tasks\n${taskLines.join("\n")}`)
27
+ }
28
+
29
+ if (recent.length > 0) {
30
+ const recentLines = recent.map((r) => `- ${r.title} (${r.file_path})`)
31
+ sections.push(`## Recent Activity\n${recentLines.join("\n")}`)
32
+ }
33
+
34
+ if (sections.length === 0) return ""
35
+
36
+ return `${sections.join("\n\n")}\n\n---\n${prompt}`
37
+ }
38
+
39
+ /**
40
+ * Build the pre-turn recall handler.
41
+ *
42
+ * On agent_start, queries active tasks and recent activity from Basic Memory,
43
+ * then returns formatted context for injection into the conversation.
44
+ */
45
+ export function buildRecallHandler(client: BmClient, cfg: BasicMemoryConfig) {
46
+ return async (_event: Record<string, unknown>) => {
47
+ try {
48
+ const [tasks, recent] = await Promise.all([
49
+ client.searchByMetadata({ entity_type: "Task", status: "active" }, 5),
50
+ client.recentActivity("1d"),
51
+ ])
52
+
53
+ const context = formatRecallContext(tasks, recent, cfg.recallPrompt)
54
+ if (!context) return {}
55
+
56
+ log.debug(
57
+ `recall: ${tasks.results.length} active tasks, ${recent.length} recent items`,
58
+ )
59
+
60
+ return { context }
61
+ } catch (err) {
62
+ log.error("recall failed", err)
63
+ return {}
64
+ }
65
+ }
66
+ }