@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.
- package/LICENSE +21 -0
- package/README.md +576 -0
- package/bm-client.ts +879 -0
- package/commands/cli.ts +176 -0
- package/commands/skills.ts +52 -0
- package/commands/slash.ts +73 -0
- package/config.ts +152 -0
- package/hooks/capture.ts +95 -0
- package/hooks/recall.ts +66 -0
- package/index.ts +120 -0
- package/logger.ts +47 -0
- package/openclaw.plugin.json +83 -0
- package/package.json +68 -0
- package/schema/task-schema.ts +34 -0
- package/scripts/setup-bm.sh +32 -0
- package/skills/memory-defrag/SKILL.md +87 -0
- package/skills/memory-metadata-search/SKILL.md +208 -0
- package/skills/memory-notes/SKILL.md +250 -0
- package/skills/memory-reflect/SKILL.md +63 -0
- package/skills/memory-schema/SKILL.md +237 -0
- package/skills/memory-tasks/SKILL.md +162 -0
- package/tools/build-context.ts +123 -0
- package/tools/delete-note.ts +67 -0
- package/tools/edit-note.ts +118 -0
- package/tools/list-memory-projects.ts +94 -0
- package/tools/list-workspaces.ts +75 -0
- package/tools/memory-provider.ts +327 -0
- package/tools/move-note.ts +74 -0
- package/tools/read-note.ts +79 -0
- package/tools/schema-diff.ts +104 -0
- package/tools/schema-infer.ts +103 -0
- package/tools/schema-validate.ts +100 -0
- package/tools/search-notes.ts +130 -0
- package/tools/write-note.ts +78 -0
- package/types/openclaw.d.ts +24 -0
package/commands/cli.ts
ADDED
|
@@ -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
|
+
}
|
package/hooks/capture.ts
ADDED
|
@@ -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
|
+
}
|
package/hooks/recall.ts
ADDED
|
@@ -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
|
+
}
|