@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
|
@@ -0,0 +1,327 @@
|
|
|
1
|
+
import { readdir, readFile } from "node:fs/promises"
|
|
2
|
+
import { join, resolve } from "node:path"
|
|
3
|
+
import { Type } from "@sinclair/typebox"
|
|
4
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
5
|
+
import type { BmClient } from "../bm-client.ts"
|
|
6
|
+
import type { BasicMemoryConfig } from "../config.ts"
|
|
7
|
+
import { log } from "../logger.ts"
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Search MEMORY.md for lines matching the query.
|
|
11
|
+
* Returns matching lines with 1 line of surrounding context.
|
|
12
|
+
*/
|
|
13
|
+
async function searchMemoryFile(
|
|
14
|
+
query: string,
|
|
15
|
+
workspaceDir: string,
|
|
16
|
+
memoryFile: string,
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
try {
|
|
19
|
+
const filePath = resolve(workspaceDir, memoryFile)
|
|
20
|
+
const content = await readFile(filePath, "utf-8")
|
|
21
|
+
const lines = content.split("\n")
|
|
22
|
+
const queryLower = query.toLowerCase()
|
|
23
|
+
const terms = queryLower.split(/\s+/).filter((t) => t.length > 0)
|
|
24
|
+
|
|
25
|
+
// Find lines that match any search term
|
|
26
|
+
const matchingIndices = new Set<number>()
|
|
27
|
+
for (let i = 0; i < lines.length; i++) {
|
|
28
|
+
const lineLower = lines[i].toLowerCase()
|
|
29
|
+
if (terms.some((term) => lineLower.includes(term))) {
|
|
30
|
+
// Add the matching line + 1 line before/after for context
|
|
31
|
+
if (i > 0) matchingIndices.add(i - 1)
|
|
32
|
+
matchingIndices.add(i)
|
|
33
|
+
if (i < lines.length - 1) matchingIndices.add(i + 1)
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
if (matchingIndices.size === 0) return ""
|
|
38
|
+
|
|
39
|
+
// Group consecutive lines into snippets
|
|
40
|
+
const sorted = [...matchingIndices].sort((a, b) => a - b)
|
|
41
|
+
const snippets: string[] = []
|
|
42
|
+
let current: string[] = []
|
|
43
|
+
let lastIdx = -2
|
|
44
|
+
|
|
45
|
+
for (const idx of sorted) {
|
|
46
|
+
if (idx !== lastIdx + 1 && current.length > 0) {
|
|
47
|
+
snippets.push(current.join("\n"))
|
|
48
|
+
current = []
|
|
49
|
+
}
|
|
50
|
+
current.push(`- ${lines[idx]}`)
|
|
51
|
+
lastIdx = idx
|
|
52
|
+
}
|
|
53
|
+
if (current.length > 0) snippets.push(current.join("\n"))
|
|
54
|
+
|
|
55
|
+
return snippets.join("\n…\n")
|
|
56
|
+
} catch {
|
|
57
|
+
return ""
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Search for active tasks via BM knowledge graph.
|
|
63
|
+
* Uses structured metadata filtering via search_by_metadata for precise querying.
|
|
64
|
+
* Falls back to filesystem scan if metadata search fails.
|
|
65
|
+
*/
|
|
66
|
+
async function searchActiveTasks(
|
|
67
|
+
query: string,
|
|
68
|
+
client: BmClient,
|
|
69
|
+
workspaceDir: string,
|
|
70
|
+
memoryDir: string,
|
|
71
|
+
): Promise<string> {
|
|
72
|
+
// Try structured metadata search first
|
|
73
|
+
try {
|
|
74
|
+
const filters: Record<string, unknown> = { type: "task", status: "active" }
|
|
75
|
+
const metaResults = await client.searchByMetadata(filters, 10)
|
|
76
|
+
|
|
77
|
+
if (metaResults.results.length > 0) {
|
|
78
|
+
// If the caller provided a query, filter results by keyword match
|
|
79
|
+
const queryLower = query.toLowerCase()
|
|
80
|
+
const terms = queryLower.split(/\s+/).filter((t) => t.length > 0)
|
|
81
|
+
|
|
82
|
+
const filtered =
|
|
83
|
+
terms.length > 0
|
|
84
|
+
? metaResults.results.filter((r) => {
|
|
85
|
+
const text = `${r.title} ${r.content ?? ""}`.toLowerCase()
|
|
86
|
+
return terms.some((term) => text.includes(term))
|
|
87
|
+
})
|
|
88
|
+
: metaResults.results
|
|
89
|
+
|
|
90
|
+
if (filtered.length > 0) {
|
|
91
|
+
const matches: string[] = []
|
|
92
|
+
for (const r of filtered) {
|
|
93
|
+
const score = r.score ? ` (${r.score.toFixed(2)})` : ""
|
|
94
|
+
const preview =
|
|
95
|
+
(r.content ?? "").length > 200
|
|
96
|
+
? `${(r.content ?? "").slice(0, 200)}…`
|
|
97
|
+
: (r.content ?? "")
|
|
98
|
+
matches.push(
|
|
99
|
+
`- **${r.title}**${score} — ${r.file_path}\n > ${preview.replace(/\n/g, "\n > ")}`,
|
|
100
|
+
)
|
|
101
|
+
}
|
|
102
|
+
return matches.join("\n\n")
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
} catch (err) {
|
|
106
|
+
log.debug(
|
|
107
|
+
"BM metadata task search failed, falling back to filesystem scan",
|
|
108
|
+
err,
|
|
109
|
+
)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
// Fallback: filesystem scan for tasks not yet indexed
|
|
113
|
+
try {
|
|
114
|
+
const tasksDir = resolve(workspaceDir, memoryDir, "tasks")
|
|
115
|
+
let entries: import("node:fs").Dirent[]
|
|
116
|
+
try {
|
|
117
|
+
entries = (await readdir(tasksDir, {
|
|
118
|
+
withFileTypes: true,
|
|
119
|
+
})) as unknown as import("node:fs").Dirent[]
|
|
120
|
+
} catch {
|
|
121
|
+
return ""
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const queryLower = query.toLowerCase()
|
|
125
|
+
const terms = queryLower.split(/\s+/).filter((t) => t.length > 0)
|
|
126
|
+
const matches: string[] = []
|
|
127
|
+
|
|
128
|
+
for (const entry of entries) {
|
|
129
|
+
if (!entry.isFile() || !entry.name.endsWith(".md")) continue
|
|
130
|
+
|
|
131
|
+
const filePath = join(entry.parentPath ?? tasksDir, entry.name)
|
|
132
|
+
const content = await readFile(filePath, "utf-8")
|
|
133
|
+
|
|
134
|
+
const statusMatch = content.match(/status:\s*(\S+)/)
|
|
135
|
+
const status = statusMatch?.[1] ?? "unknown"
|
|
136
|
+
if (status === "done") continue
|
|
137
|
+
|
|
138
|
+
const contentLower = content.toLowerCase()
|
|
139
|
+
const matchesQuery =
|
|
140
|
+
terms.length === 0 || terms.some((term) => contentLower.includes(term))
|
|
141
|
+
if (!matchesQuery) continue
|
|
142
|
+
|
|
143
|
+
const titleMatch = content.match(/title:\s*(.+)/)
|
|
144
|
+
const title = titleMatch?.[1] ?? entry.name.replace(/\.md$/, "")
|
|
145
|
+
const stepMatch = content.match(/current_step:\s*(\S+)/)
|
|
146
|
+
const currentStep = stepMatch?.[1] ?? "?"
|
|
147
|
+
|
|
148
|
+
const contextMatch = content.match(
|
|
149
|
+
/## Context\s*\n([\s\S]*?)(?=\n##|\n---|$)/,
|
|
150
|
+
)
|
|
151
|
+
const context = contextMatch?.[1]?.trim().slice(0, 150) ?? ""
|
|
152
|
+
|
|
153
|
+
matches.push(
|
|
154
|
+
`- **${title}** (status: ${status}, step: ${currentStep})\n ${context}`,
|
|
155
|
+
)
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
return matches.join("\n")
|
|
159
|
+
} catch {
|
|
160
|
+
return ""
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
// Store workspace dir for use in memory_search (set during service start)
|
|
165
|
+
let _workspaceDir = ""
|
|
166
|
+
export function setWorkspaceDir(dir: string) {
|
|
167
|
+
_workspaceDir = dir
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/**
|
|
171
|
+
* Register composited memory_search and memory_get tools.
|
|
172
|
+
*
|
|
173
|
+
* memory_search queries 3 sources in parallel:
|
|
174
|
+
* 1. MEMORY.md — grep/text search
|
|
175
|
+
* 2. BM knowledge graph — semantic + FTS search
|
|
176
|
+
* 3. Active tasks — memory/tasks/ files with status != done
|
|
177
|
+
*
|
|
178
|
+
* memory_get reads a specific note by identifier.
|
|
179
|
+
*/
|
|
180
|
+
export function registerMemoryProvider(
|
|
181
|
+
api: OpenClawPluginApi,
|
|
182
|
+
client: BmClient,
|
|
183
|
+
cfg: BasicMemoryConfig,
|
|
184
|
+
): void {
|
|
185
|
+
// --- composited memory_search ---
|
|
186
|
+
api.registerTool(
|
|
187
|
+
{
|
|
188
|
+
name: "memory_search",
|
|
189
|
+
label: "Memory Search",
|
|
190
|
+
description:
|
|
191
|
+
"Search across all memory sources: MEMORY.md (working memory), " +
|
|
192
|
+
"Basic Memory knowledge graph (long-term archive), and active tasks. " +
|
|
193
|
+
"Returns composited results from all sources.",
|
|
194
|
+
parameters: Type.Object({
|
|
195
|
+
query: Type.String({
|
|
196
|
+
description: "Search query — natural language or keywords",
|
|
197
|
+
}),
|
|
198
|
+
}),
|
|
199
|
+
async execute(_toolCallId: string, params: { query: string }) {
|
|
200
|
+
log.debug(`memory_search: query="${params.query}"`)
|
|
201
|
+
|
|
202
|
+
const workspaceDir = _workspaceDir || process.cwd()
|
|
203
|
+
|
|
204
|
+
// Query all 3 sources in parallel
|
|
205
|
+
const [memoryMd, bmResults, taskResults] = await Promise.all([
|
|
206
|
+
searchMemoryFile(params.query, workspaceDir, cfg.memoryFile),
|
|
207
|
+
client
|
|
208
|
+
.search(params.query, 5)
|
|
209
|
+
.then((results) => {
|
|
210
|
+
if (results.length === 0) return ""
|
|
211
|
+
return results
|
|
212
|
+
.map((r) => {
|
|
213
|
+
const score = r.score ? ` (${r.score.toFixed(2)})` : ""
|
|
214
|
+
const preview =
|
|
215
|
+
(r.content ?? "").length > 200
|
|
216
|
+
? `${(r.content ?? "").slice(0, 200)}…`
|
|
217
|
+
: (r.content ?? "")
|
|
218
|
+
const source = r.file_path || r.permalink
|
|
219
|
+
return `- ${source}${score}\n > ${preview.replace(/\n/g, "\n > ")}`
|
|
220
|
+
})
|
|
221
|
+
.join("\n\n")
|
|
222
|
+
})
|
|
223
|
+
.catch((err) => {
|
|
224
|
+
log.error("BM search failed in composited search", err)
|
|
225
|
+
return "(search unavailable)"
|
|
226
|
+
}),
|
|
227
|
+
searchActiveTasks(params.query, client, workspaceDir, cfg.memoryDir),
|
|
228
|
+
])
|
|
229
|
+
|
|
230
|
+
// Build composited result
|
|
231
|
+
const sections: string[] = []
|
|
232
|
+
|
|
233
|
+
if (memoryMd) {
|
|
234
|
+
sections.push(`## ${cfg.memoryFile}\n${memoryMd}`)
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
if (bmResults) {
|
|
238
|
+
sections.push(`## Knowledge Graph (${cfg.memoryDir})\n${bmResults}`)
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (taskResults) {
|
|
242
|
+
sections.push(`## Active Tasks\n${taskResults}`)
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (sections.length === 0) {
|
|
246
|
+
return {
|
|
247
|
+
content: [
|
|
248
|
+
{
|
|
249
|
+
type: "text" as const,
|
|
250
|
+
text: "No matches found across memory sources.",
|
|
251
|
+
},
|
|
252
|
+
],
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
return {
|
|
257
|
+
content: [
|
|
258
|
+
{
|
|
259
|
+
type: "text" as const,
|
|
260
|
+
text: sections.join("\n\n"),
|
|
261
|
+
},
|
|
262
|
+
],
|
|
263
|
+
}
|
|
264
|
+
},
|
|
265
|
+
},
|
|
266
|
+
{ names: ["memory_search"] },
|
|
267
|
+
)
|
|
268
|
+
|
|
269
|
+
// --- memory_get (unchanged) ---
|
|
270
|
+
api.registerTool(
|
|
271
|
+
{
|
|
272
|
+
name: "memory_get",
|
|
273
|
+
label: "Memory Get",
|
|
274
|
+
description:
|
|
275
|
+
"Read a specific note from the knowledge graph by title, permalink, or path. " +
|
|
276
|
+
"Returns the full note content. Use after memory_search to read a specific result in full.",
|
|
277
|
+
parameters: Type.Object({
|
|
278
|
+
path: Type.String({
|
|
279
|
+
description:
|
|
280
|
+
"Note identifier — title, permalink, memory:// URL, or file path",
|
|
281
|
+
}),
|
|
282
|
+
from: Type.Optional(
|
|
283
|
+
Type.Number({
|
|
284
|
+
description:
|
|
285
|
+
"Starting line number (ignored — included for compatibility)",
|
|
286
|
+
}),
|
|
287
|
+
),
|
|
288
|
+
lines: Type.Optional(
|
|
289
|
+
Type.Number({
|
|
290
|
+
description:
|
|
291
|
+
"Number of lines to read (ignored — included for compatibility)",
|
|
292
|
+
}),
|
|
293
|
+
),
|
|
294
|
+
}),
|
|
295
|
+
async execute(
|
|
296
|
+
_toolCallId: string,
|
|
297
|
+
params: { path: string; from?: number; lines?: number },
|
|
298
|
+
) {
|
|
299
|
+
log.debug(`memory_get: path="${params.path}"`)
|
|
300
|
+
|
|
301
|
+
try {
|
|
302
|
+
const note = await client.readNote(params.path)
|
|
303
|
+
|
|
304
|
+
return {
|
|
305
|
+
content: [
|
|
306
|
+
{
|
|
307
|
+
type: "text" as const,
|
|
308
|
+
text: `# ${note.title}\n\n${note.content}`,
|
|
309
|
+
},
|
|
310
|
+
],
|
|
311
|
+
}
|
|
312
|
+
} catch (err) {
|
|
313
|
+
log.error("memory_get failed", err)
|
|
314
|
+
return {
|
|
315
|
+
content: [
|
|
316
|
+
{
|
|
317
|
+
type: "text" as const,
|
|
318
|
+
text: `Could not read "${params.path}". It may not exist in the knowledge graph.`,
|
|
319
|
+
},
|
|
320
|
+
],
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
},
|
|
324
|
+
},
|
|
325
|
+
{ names: ["memory_get"] },
|
|
326
|
+
)
|
|
327
|
+
}
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { BmClient } from "../bm-client.ts"
|
|
4
|
+
import { log } from "../logger.ts"
|
|
5
|
+
|
|
6
|
+
export function registerMoveTool(
|
|
7
|
+
api: OpenClawPluginApi,
|
|
8
|
+
client: BmClient,
|
|
9
|
+
): void {
|
|
10
|
+
api.registerTool(
|
|
11
|
+
{
|
|
12
|
+
name: "move_note",
|
|
13
|
+
label: "Move Note",
|
|
14
|
+
description:
|
|
15
|
+
"Move a note to a different folder in the Basic Memory knowledge graph. " +
|
|
16
|
+
"The note content is preserved; only the location changes.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
identifier: Type.String({
|
|
19
|
+
description: "Note title, permalink, or memory:// URL to move",
|
|
20
|
+
}),
|
|
21
|
+
newFolder: Type.String({
|
|
22
|
+
description:
|
|
23
|
+
"Destination folder (e.g., 'archive', 'projects/completed')",
|
|
24
|
+
}),
|
|
25
|
+
project: Type.Optional(
|
|
26
|
+
Type.String({
|
|
27
|
+
description: "Target project name (defaults to current project)",
|
|
28
|
+
}),
|
|
29
|
+
),
|
|
30
|
+
}),
|
|
31
|
+
async execute(
|
|
32
|
+
_toolCallId: string,
|
|
33
|
+
params: { identifier: string; newFolder: string; project?: string },
|
|
34
|
+
) {
|
|
35
|
+
log.debug(
|
|
36
|
+
`move_note: identifier="${params.identifier}" → folder="${params.newFolder}"`,
|
|
37
|
+
)
|
|
38
|
+
|
|
39
|
+
try {
|
|
40
|
+
const result = await client.moveNote(
|
|
41
|
+
params.identifier,
|
|
42
|
+
params.newFolder,
|
|
43
|
+
params.project,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
content: [
|
|
48
|
+
{
|
|
49
|
+
type: "text" as const,
|
|
50
|
+
text: `Moved: ${result.title} → ${result.file_path}`,
|
|
51
|
+
},
|
|
52
|
+
],
|
|
53
|
+
details: {
|
|
54
|
+
title: result.title,
|
|
55
|
+
permalink: result.permalink,
|
|
56
|
+
file_path: result.file_path,
|
|
57
|
+
},
|
|
58
|
+
}
|
|
59
|
+
} catch (err) {
|
|
60
|
+
log.error("move_note failed", err)
|
|
61
|
+
return {
|
|
62
|
+
content: [
|
|
63
|
+
{
|
|
64
|
+
type: "text" as const,
|
|
65
|
+
text: `Failed to move "${params.identifier}". It may not exist.`,
|
|
66
|
+
},
|
|
67
|
+
],
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
},
|
|
71
|
+
},
|
|
72
|
+
{ name: "move_note" },
|
|
73
|
+
)
|
|
74
|
+
}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { BmClient } from "../bm-client.ts"
|
|
4
|
+
import { log } from "../logger.ts"
|
|
5
|
+
|
|
6
|
+
export function registerReadTool(
|
|
7
|
+
api: OpenClawPluginApi,
|
|
8
|
+
client: BmClient,
|
|
9
|
+
): void {
|
|
10
|
+
api.registerTool(
|
|
11
|
+
{
|
|
12
|
+
name: "read_note",
|
|
13
|
+
label: "Read Note",
|
|
14
|
+
description:
|
|
15
|
+
"Read a specific note from the Basic Memory knowledge graph by title or permalink. " +
|
|
16
|
+
"Returns the full note content including observations and relations.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
identifier: Type.String({
|
|
19
|
+
description: "Note title, permalink, or memory:// URL to read",
|
|
20
|
+
}),
|
|
21
|
+
include_frontmatter: Type.Optional(
|
|
22
|
+
Type.Boolean({
|
|
23
|
+
description:
|
|
24
|
+
"If true, returns raw note content including YAML frontmatter.",
|
|
25
|
+
}),
|
|
26
|
+
),
|
|
27
|
+
project: Type.Optional(
|
|
28
|
+
Type.String({
|
|
29
|
+
description: "Target project name (defaults to current project)",
|
|
30
|
+
}),
|
|
31
|
+
),
|
|
32
|
+
}),
|
|
33
|
+
async execute(
|
|
34
|
+
_toolCallId: string,
|
|
35
|
+
params: {
|
|
36
|
+
identifier: string
|
|
37
|
+
include_frontmatter?: boolean
|
|
38
|
+
project?: string
|
|
39
|
+
},
|
|
40
|
+
) {
|
|
41
|
+
log.debug(`read_note: identifier="${params.identifier}"`)
|
|
42
|
+
|
|
43
|
+
try {
|
|
44
|
+
const note = await client.readNote(
|
|
45
|
+
params.identifier,
|
|
46
|
+
{ includeFrontmatter: params.include_frontmatter === true },
|
|
47
|
+
params.project,
|
|
48
|
+
)
|
|
49
|
+
|
|
50
|
+
return {
|
|
51
|
+
content: [
|
|
52
|
+
{
|
|
53
|
+
type: "text" as const,
|
|
54
|
+
text: note.content,
|
|
55
|
+
},
|
|
56
|
+
],
|
|
57
|
+
details: {
|
|
58
|
+
title: note.title,
|
|
59
|
+
permalink: note.permalink,
|
|
60
|
+
file_path: note.file_path,
|
|
61
|
+
frontmatter: note.frontmatter ?? null,
|
|
62
|
+
},
|
|
63
|
+
}
|
|
64
|
+
} catch (err) {
|
|
65
|
+
log.error("read_note failed", err)
|
|
66
|
+
return {
|
|
67
|
+
content: [
|
|
68
|
+
{
|
|
69
|
+
type: "text" as const,
|
|
70
|
+
text: `Could not read note "${params.identifier}". It may not exist yet.`,
|
|
71
|
+
},
|
|
72
|
+
],
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
},
|
|
76
|
+
},
|
|
77
|
+
{ name: "read_note" },
|
|
78
|
+
)
|
|
79
|
+
}
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { BmClient } from "../bm-client.ts"
|
|
4
|
+
import { log } from "../logger.ts"
|
|
5
|
+
|
|
6
|
+
export function registerSchemaDiffTool(
|
|
7
|
+
api: OpenClawPluginApi,
|
|
8
|
+
client: BmClient,
|
|
9
|
+
): void {
|
|
10
|
+
api.registerTool(
|
|
11
|
+
{
|
|
12
|
+
name: "schema_diff",
|
|
13
|
+
label: "Schema Diff",
|
|
14
|
+
description:
|
|
15
|
+
"Detect drift between a Picoschema definition and actual note usage. " +
|
|
16
|
+
"Identifies new fields in notes not declared in the schema, " +
|
|
17
|
+
"declared fields no longer used, and cardinality changes.",
|
|
18
|
+
parameters: Type.Object({
|
|
19
|
+
noteType: Type.String({
|
|
20
|
+
description:
|
|
21
|
+
'The note type to check for drift (e.g., "person", "meeting")',
|
|
22
|
+
}),
|
|
23
|
+
project: Type.Optional(
|
|
24
|
+
Type.String({
|
|
25
|
+
description: "Target project name (defaults to current project)",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
}),
|
|
29
|
+
async execute(
|
|
30
|
+
_toolCallId: string,
|
|
31
|
+
params: { noteType: string; project?: string },
|
|
32
|
+
) {
|
|
33
|
+
log.debug(`schema_diff: noteType="${params.noteType}"`)
|
|
34
|
+
|
|
35
|
+
try {
|
|
36
|
+
const result = await client.schemaDiff(
|
|
37
|
+
params.noteType,
|
|
38
|
+
params.project,
|
|
39
|
+
)
|
|
40
|
+
|
|
41
|
+
if (!result.schema_found) {
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: "text" as const,
|
|
46
|
+
text: `No schema found for type "${params.noteType}". Use schema_infer to generate one.`,
|
|
47
|
+
},
|
|
48
|
+
],
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const lines: string[] = [`**Type:** ${result.entity_type}`]
|
|
53
|
+
|
|
54
|
+
if (
|
|
55
|
+
result.new_fields.length === 0 &&
|
|
56
|
+
result.dropped_fields.length === 0 &&
|
|
57
|
+
result.cardinality_changes.length === 0
|
|
58
|
+
) {
|
|
59
|
+
lines.push("No drift detected — schema and notes are in sync.")
|
|
60
|
+
} else {
|
|
61
|
+
if (result.new_fields.length > 0) {
|
|
62
|
+
lines.push("", "### New Fields (in notes, not in schema)")
|
|
63
|
+
for (const f of result.new_fields) {
|
|
64
|
+
lines.push(
|
|
65
|
+
`- **${f.name}** — ${(f.percentage * 100).toFixed(0)}% of notes`,
|
|
66
|
+
)
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (result.dropped_fields.length > 0) {
|
|
71
|
+
lines.push("", "### Dropped Fields (in schema, not in notes)")
|
|
72
|
+
for (const f of result.dropped_fields) {
|
|
73
|
+
lines.push(`- **${f.name}** — declared in ${f.declared_in}`)
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (result.cardinality_changes.length > 0) {
|
|
78
|
+
lines.push("", "### Cardinality Changes")
|
|
79
|
+
for (const c of result.cardinality_changes) {
|
|
80
|
+
lines.push(`- ${c}`)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
87
|
+
details: result,
|
|
88
|
+
}
|
|
89
|
+
} catch (err) {
|
|
90
|
+
log.error("schema_diff failed", err)
|
|
91
|
+
return {
|
|
92
|
+
content: [
|
|
93
|
+
{
|
|
94
|
+
type: "text" as const,
|
|
95
|
+
text: "Schema diff failed. Check logs for details.",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
},
|
|
101
|
+
},
|
|
102
|
+
{ name: "schema_diff" },
|
|
103
|
+
)
|
|
104
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { Type } from "@sinclair/typebox"
|
|
2
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk"
|
|
3
|
+
import type { BmClient } from "../bm-client.ts"
|
|
4
|
+
import { log } from "../logger.ts"
|
|
5
|
+
|
|
6
|
+
export function registerSchemaInferTool(
|
|
7
|
+
api: OpenClawPluginApi,
|
|
8
|
+
client: BmClient,
|
|
9
|
+
): void {
|
|
10
|
+
api.registerTool(
|
|
11
|
+
{
|
|
12
|
+
name: "schema_infer",
|
|
13
|
+
label: "Schema Infer",
|
|
14
|
+
description:
|
|
15
|
+
"Analyze existing notes of a given type and suggest a Picoschema definition. " +
|
|
16
|
+
"Examines observation categories and relation types to infer required and optional fields.",
|
|
17
|
+
parameters: Type.Object({
|
|
18
|
+
noteType: Type.String({
|
|
19
|
+
description:
|
|
20
|
+
'The note type to analyze (e.g., "person", "meeting", "Task")',
|
|
21
|
+
}),
|
|
22
|
+
threshold: Type.Optional(
|
|
23
|
+
Type.Number({
|
|
24
|
+
description:
|
|
25
|
+
"Minimum frequency (0-1) for a field to be suggested as optional. Default 0.25.",
|
|
26
|
+
}),
|
|
27
|
+
),
|
|
28
|
+
project: Type.Optional(
|
|
29
|
+
Type.String({
|
|
30
|
+
description: "Target project name (defaults to current project)",
|
|
31
|
+
}),
|
|
32
|
+
),
|
|
33
|
+
}),
|
|
34
|
+
async execute(
|
|
35
|
+
_toolCallId: string,
|
|
36
|
+
params: { noteType: string; threshold?: number; project?: string },
|
|
37
|
+
) {
|
|
38
|
+
log.debug(
|
|
39
|
+
`schema_infer: noteType="${params.noteType}" threshold=${params.threshold ?? 0.25}`,
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
try {
|
|
43
|
+
const result = await client.schemaInfer(
|
|
44
|
+
params.noteType,
|
|
45
|
+
params.threshold,
|
|
46
|
+
params.project,
|
|
47
|
+
)
|
|
48
|
+
|
|
49
|
+
const lines: string[] = [
|
|
50
|
+
`**Type:** ${result.entity_type}`,
|
|
51
|
+
`**Notes analyzed:** ${result.notes_analyzed}`,
|
|
52
|
+
]
|
|
53
|
+
|
|
54
|
+
if (result.suggested_required.length > 0) {
|
|
55
|
+
lines.push(
|
|
56
|
+
`**Required fields:** ${result.suggested_required.join(", ")}`,
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
if (result.suggested_optional.length > 0) {
|
|
60
|
+
lines.push(
|
|
61
|
+
`**Optional fields:** ${result.suggested_optional.join(", ")}`,
|
|
62
|
+
)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
if (result.field_frequencies.length > 0) {
|
|
66
|
+
lines.push("", "### Field Frequencies")
|
|
67
|
+
for (const f of result.field_frequencies) {
|
|
68
|
+
lines.push(
|
|
69
|
+
`- **${f.name}** — ${(f.percentage * 100).toFixed(0)}% (${f.count} notes)`,
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (Object.keys(result.suggested_schema).length > 0) {
|
|
75
|
+
lines.push(
|
|
76
|
+
"",
|
|
77
|
+
"### Suggested Schema",
|
|
78
|
+
"```json",
|
|
79
|
+
JSON.stringify(result.suggested_schema, null, 2),
|
|
80
|
+
"```",
|
|
81
|
+
)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return {
|
|
85
|
+
content: [{ type: "text" as const, text: lines.join("\n") }],
|
|
86
|
+
details: result,
|
|
87
|
+
}
|
|
88
|
+
} catch (err) {
|
|
89
|
+
log.error("schema_infer failed", err)
|
|
90
|
+
return {
|
|
91
|
+
content: [
|
|
92
|
+
{
|
|
93
|
+
type: "text" as const,
|
|
94
|
+
text: "Schema inference failed. Check logs for details.",
|
|
95
|
+
},
|
|
96
|
+
],
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
},
|
|
100
|
+
},
|
|
101
|
+
{ name: "schema_infer" },
|
|
102
|
+
)
|
|
103
|
+
}
|