@basicmemory/openclaw-basic-memory 0.23.1 → 0.24.0

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.
Files changed (50) hide show
  1. package/README.md +24 -7
  2. package/bm-client.ts +107 -7
  3. package/dist/bm-client.d.ts +33 -1
  4. package/dist/bm-client.js +77 -6
  5. package/dist/index.js +9 -1
  6. package/dist/tools/edit-note.js +8 -1
  7. package/dist/tools/list-memory-projects.js +23 -5
  8. package/dist/tools/list-workspaces.js +3 -1
  9. package/dist/tools/memory-provider.js +2 -1
  10. package/dist/tools/read-note.js +18 -3
  11. package/dist/tools/search-notes.js +3 -1
  12. package/dist/tools/write-note.js +65 -5
  13. package/index.ts +9 -1
  14. package/openclaw.plugin.json +2 -0
  15. package/package.json +1 -1
  16. package/scripts/fetch-skills.ts +87 -36
  17. package/scripts/setup-bm.sh +4 -1
  18. package/skills/manifest.json +31 -16
  19. package/skills/memory-agents/SKILL.md +170 -0
  20. package/skills/memory-capture/SKILL.md +7 -5
  21. package/skills/memory-comark/SKILL.md +204 -0
  22. package/skills/memory-comark/references/components.md +740 -0
  23. package/skills/memory-continue/SKILL.md +11 -1
  24. package/skills/memory-curate/SKILL.md +32 -39
  25. package/skills/memory-defrag/SKILL.md +106 -57
  26. package/skills/memory-ingest/SKILL.md +14 -3
  27. package/skills/memory-lifecycle/SKILL.md +21 -21
  28. package/skills/memory-literary-analysis/SKILL.md +258 -28
  29. package/skills/memory-metadata-search/SKILL.md +29 -4
  30. package/skills/memory-notes/SKILL.md +69 -17
  31. package/skills/memory-okf/SKILL.md +135 -0
  32. package/skills/memory-onboarding/SKILL.md +2 -2
  33. package/skills/memory-onboarding/references/assistant-setup.md +8 -0
  34. package/skills/memory-onboarding/references/conventions.md +3 -3
  35. package/skills/memory-onboarding/references/domain-playbooks.md +1 -1
  36. package/skills/memory-onboarding/references/schema-guide.md +3 -3
  37. package/skills/memory-quest/SKILL.md +129 -0
  38. package/skills/memory-quest/agents/openai.yaml +9 -0
  39. package/skills/memory-reflect/SKILL.md +125 -44
  40. package/skills/memory-research/SKILL.md +9 -15
  41. package/skills/memory-schema/SKILL.md +29 -21
  42. package/skills/memory-tasks/SKILL.md +36 -10
  43. package/tools/edit-note.ts +15 -1
  44. package/tools/list-memory-projects.ts +24 -5
  45. package/tools/list-workspaces.ts +3 -1
  46. package/tools/memory-provider.ts +2 -1
  47. package/tools/read-note.ts +22 -3
  48. package/tools/search-notes.ts +3 -1
  49. package/tools/write-note.ts +94 -4
  50. package/skills/memory-ci-capture/SKILL.md +0 -56
package/README.md CHANGED
@@ -161,8 +161,8 @@ All tools accept an optional `project` parameter for cross-project operations.
161
161
  | `memory_get` | Read a specific note by title or path |
162
162
  | `search_notes` | Search the knowledge graph directly |
163
163
  | `read_note` | Read a note by title, permalink, or `memory://` URL |
164
- | `write_note` | Create or update a note |
165
- | `edit_note` | Append, prepend, find/replace, or replace a section |
164
+ | `write_note` | Create or overwrite a note with optional tags, note type, and custom frontmatter metadata |
165
+ | `edit_note` | Append, prepend, replace content, and merge custom frontmatter metadata |
166
166
  | `delete_note` | Delete a note |
167
167
  | `move_note` | Move a note to a different folder |
168
168
  | `build_context` | Navigate the knowledge graph — follow relations and connections |
@@ -172,6 +172,22 @@ All tools accept an optional `project` parameter for cross-project operations.
172
172
  | `schema_infer` | Analyze notes and suggest a schema |
173
173
  | `schema_diff` | Detect drift between schema and actual usage |
174
174
 
175
+ Use the dedicated frontmatter parameters when creating structured notes:
176
+
177
+ ```
178
+ write_note(
179
+ title="auth-middleware-rollout",
180
+ folder="tasks",
181
+ note_type="Task",
182
+ tags=["auth", "rollout"],
183
+ metadata={"status": "active", "current_step": 2},
184
+ content="""## Context
185
+ Rolling JWT middleware to all API routes."""
186
+ )
187
+ ```
188
+
189
+ `edit_note` accepts `metadata` alongside any edit operation, so content and frontmatter state can be updated in one call. Metadata keys supplied by the caller are merged into existing frontmatter; unrelated fields remain unchanged.
190
+
175
191
  ## Slash commands
176
192
 
177
193
  | Command | Description |
@@ -196,14 +212,17 @@ openclaw basic-memory status
196
212
 
197
213
  ## Bundled skills
198
214
 
199
- Ten skills ship with the plugin — no installation needed:
215
+ These skills ship with the plugin — no installation needed:
200
216
 
201
- - **memory-defrag** — cleanup and reorganization of memory files
217
+ - **memory-comark** — charts, tables, boards, and live views in notes with `::bm-*` components
218
+ - **memory-defrag** — cleanup and reorganization of a project's notes
202
219
  - **memory-ingest** — import existing material into Basic Memory
203
220
  - **memory-lifecycle** — manage note/project lifecycle workflows
204
221
  - **memory-literary-analysis** — analyze texts and reading notes
205
222
  - **memory-metadata-search** — query notes by frontmatter fields
206
223
  - **memory-notes** — guidance for writing well-structured notes
224
+ - **memory-okf** — make a project ready for OKF export
225
+ - **memory-onboarding** — guided setup of a new knowledge system
207
226
  - **memory-reflect** — periodic consolidation of recent notes into durable memory
208
227
  - **memory-research** — research synthesis into durable notes
209
228
  - **memory-schema** — schema lifecycle (infer, create, validate, diff)
@@ -253,14 +272,12 @@ Cloud extends local-first — still plain Markdown, still yours. [Start a free t
253
272
 
254
273
  ## Troubleshooting
255
274
 
256
- **`bm` not found** — Install uv, then restart the gateway. Or install manually: `uv tool install basic-memory`
275
+ **`bm` not found** — Install uv, then restart the gateway. Or install manually: `uv tool install basic-memory --prerelease=allow` (the flag is required — basic-memory depends on a FastMCP pre-release, and without it uv silently installs an older release)
257
276
 
258
277
  **Search returns nothing** — Check that Basic Memory connected (look for `connected to BM` in logs). Verify files exist in the project directory.
259
278
 
260
279
  **Jiti cache issues** — `rm -rf /tmp/jiti/ "$TMPDIR/jiti/"` then restart the gateway.
261
280
 
262
- **Disable semantic search** — Set `BASIC_MEMORY_SEMANTIC_SEARCH_ENABLED=false` to fall back to full-text only.
263
-
264
281
  ## More
265
282
 
266
283
  - [Memory + Task Flow](./MEMORY_TASK_FLOW.md) — practical runbook
package/bm-client.ts CHANGED
@@ -14,6 +14,40 @@ export class NoteAlreadyExistsError extends Error {
14
14
  }
15
15
  }
16
16
 
17
+ /**
18
+ * write_note refused a conditional overwrite: the note is no longer the
19
+ * revision named by expected_checksum. Nothing was written; currentChecksum is
20
+ * the revision to re-read and retry against (null once no note owns the path).
21
+ */
22
+ export class NoteRevisionConflictError extends Error {
23
+ readonly permalink: string | null
24
+ readonly currentChecksum: string | null
25
+ constructor(
26
+ title: string,
27
+ permalink: string | null,
28
+ currentChecksum: string | null,
29
+ ) {
30
+ super(`Note revision conflict: "${title}" changed since it was read`)
31
+ this.name = "NoteRevisionConflictError"
32
+ this.permalink = permalink
33
+ this.currentChecksum = currentChecksum
34
+ }
35
+ }
36
+
37
+ /** A BM MCP tool returned isError; toolMessage is the tool's own error text. */
38
+ export class BmToolError extends Error {
39
+ readonly toolName: string
40
+ readonly toolMessage: string
41
+ constructor(toolName: string, toolMessage: string) {
42
+ super(
43
+ `BM MCP tool ${toolName} failed${toolMessage ? `: ${toolMessage}` : ""}`,
44
+ )
45
+ this.name = "BmToolError"
46
+ this.toolName = toolName
47
+ this.toolMessage = toolMessage
48
+ }
49
+ }
50
+
17
51
  const REQUIRED_TOOLS = [
18
52
  "search_notes",
19
53
  "read_note",
@@ -59,12 +93,25 @@ export interface EditNoteResult {
59
93
 
60
94
  interface ReadNoteOptions {
61
95
  includeFrontmatter?: boolean
96
+ /** Project external_id; routes to that exact project instead of by name. */
97
+ projectId?: string
98
+ }
99
+
100
+ interface WriteNoteOptions {
101
+ metadata?: Record<string, unknown>
102
+ tags?: string[] | string
103
+ noteType?: string
104
+ /** Project external_id; routes to that exact project instead of by name. */
105
+ projectId?: string
106
+ /** Checksum of the revision read; overwrite only while it is still current. */
107
+ expectedChecksum?: string
62
108
  }
63
109
 
64
110
  interface EditNoteOptions {
65
111
  find_text?: string
66
112
  section?: string
67
113
  expected_replacements?: number
114
+ metadata?: Record<string, unknown>
68
115
  }
69
116
 
70
117
  export interface ContextResult {
@@ -95,6 +142,9 @@ export interface RecentResult {
95
142
  export interface ProjectListResult {
96
143
  name: string
97
144
  path: string
145
+ external_id?: string | null
146
+ source?: string | null
147
+ qualified_name?: string | null
98
148
  display_name?: string | null
99
149
  is_private?: boolean
100
150
  is_default?: boolean
@@ -214,6 +264,19 @@ function asString(value: unknown): string | null {
214
264
  return typeof value === "string" ? value : null
215
265
  }
216
266
 
267
+ // In JSON mode BM reports a refused write as a tool error whose text is the
268
+ // refusal payload itself ({"error": "NOTE_ALREADY_EXISTS", ...}). Any other
269
+ // error text (plain-text failures, older servers) is not a refusal payload.
270
+ function parseRefusalPayload(text: string): Record<string, unknown> | null {
271
+ let parsed: unknown
272
+ try {
273
+ parsed = JSON.parse(text)
274
+ } catch {
275
+ return null
276
+ }
277
+ return isRecord(parsed) && typeof parsed.error === "string" ? parsed : null
278
+ }
279
+
217
280
  function projectMatchesWorkspace(
218
281
  project: ProjectListResult,
219
282
  workspace: string,
@@ -432,10 +495,7 @@ export class BmClient {
432
495
  })
433
496
 
434
497
  if (isRecord(result) && result.isError === true) {
435
- const message = extractTextFromContent(result.content)
436
- throw new Error(
437
- `BM MCP tool ${name} failed${message ? `: ${message}` : ""}`,
438
- )
498
+ throw new BmToolError(name, extractTextFromContent(result.content))
439
499
  }
440
500
 
441
501
  return result
@@ -486,6 +546,17 @@ export class BmClient {
486
546
  return project ?? this.project
487
547
  }
488
548
 
549
+ // project_id names one exact project, so it is sent alone: pairing it with
550
+ // a name (or the plugin's default project) would make routing depend on
551
+ // server-side precedence rather than on what the caller asked for.
552
+ private routing(
553
+ project?: string,
554
+ projectId?: string,
555
+ ): { project: string } | { project_id: string } {
556
+ if (projectId !== undefined) return { project_id: projectId }
557
+ return { project: this.routedProject(project) }
558
+ }
559
+
489
560
  async ensureProject(projectPath: string): Promise<void> {
490
561
  const payload = await this.callTool("create_memory_project", {
491
562
  project_name: this.project,
@@ -572,7 +643,7 @@ export class BmClient {
572
643
  identifier,
573
644
  include_frontmatter: options.includeFrontmatter === true,
574
645
  output_format: "json",
575
- project: this.routedProject(project),
646
+ ...this.routing(project, options.projectId),
576
647
  }
577
648
 
578
649
  const payload = await this.callTool("read_note", args)
@@ -596,6 +667,7 @@ export class BmClient {
596
667
  content,
597
668
  file_path: filePath,
598
669
  frontmatter: isRecord(payload.frontmatter) ? payload.frontmatter : null,
670
+ checksum: asString(payload.checksum),
599
671
  }
600
672
  }
601
673
 
@@ -605,22 +677,49 @@ export class BmClient {
605
677
  folder: string,
606
678
  project?: string,
607
679
  overwrite?: boolean,
680
+ options: WriteNoteOptions = {},
608
681
  ): Promise<NoteResult> {
609
682
  const args: Record<string, unknown> = {
610
683
  title,
611
684
  content,
612
685
  directory: folder,
613
686
  output_format: "json",
614
- project: this.routedProject(project),
687
+ ...this.routing(project, options.projectId),
615
688
  }
616
689
  if (overwrite !== undefined) args.overwrite = overwrite
690
+ if (options.metadata !== undefined) args.metadata = options.metadata
691
+ if (options.tags !== undefined) args.tags = options.tags
692
+ if (options.noteType !== undefined) args.note_type = options.noteType
693
+ if (options.expectedChecksum !== undefined)
694
+ args.expected_checksum = options.expectedChecksum
617
695
 
618
- const payload = await this.callTool("write_note", args)
696
+ let payload: unknown
697
+ try {
698
+ payload = await this.callTool("write_note", args)
699
+ } catch (err) {
700
+ const refusal =
701
+ err instanceof BmToolError ? parseRefusalPayload(err.toolMessage) : null
702
+ if (refusal?.error === "NOTE_ALREADY_EXISTS") {
703
+ throw new NoteAlreadyExistsError(
704
+ asString(refusal.title) ?? title,
705
+ asString(refusal.permalink) ?? "",
706
+ )
707
+ }
708
+ if (refusal?.error === "NOTE_REVISION_CONFLICT") {
709
+ throw new NoteRevisionConflictError(
710
+ asString(refusal.title) ?? title,
711
+ asString(refusal.permalink),
712
+ asString(refusal.currentChecksum),
713
+ )
714
+ }
715
+ throw err
716
+ }
619
717
 
620
718
  if (!isRecord(payload)) {
621
719
  throw new Error("invalid write_note response")
622
720
  }
623
721
 
722
+ // Older servers returned the refusal as a successful payload.
624
723
  if (payload.error === "NOTE_ALREADY_EXISTS") {
625
724
  throw new NoteAlreadyExistsError(
626
725
  asString(payload.title) ?? title,
@@ -707,6 +806,7 @@ export class BmClient {
707
806
  if (options.section) args.section = options.section
708
807
  if (options.expected_replacements != null)
709
808
  args.expected_replacements = options.expected_replacements
809
+ if (options.metadata !== undefined) args.metadata = options.metadata
710
810
 
711
811
  const payload = await this.callTool("edit_note", args)
712
812
 
@@ -2,6 +2,22 @@ export declare class NoteAlreadyExistsError extends Error {
2
2
  readonly permalink: string;
3
3
  constructor(title: string, permalink: string);
4
4
  }
5
+ /**
6
+ * write_note refused a conditional overwrite: the note is no longer the
7
+ * revision named by expected_checksum. Nothing was written; currentChecksum is
8
+ * the revision to re-read and retry against (null once no note owns the path).
9
+ */
10
+ export declare class NoteRevisionConflictError extends Error {
11
+ readonly permalink: string | null;
12
+ readonly currentChecksum: string | null;
13
+ constructor(title: string, permalink: string | null, currentChecksum: string | null);
14
+ }
15
+ /** A BM MCP tool returned isError; toolMessage is the tool's own error text. */
16
+ export declare class BmToolError extends Error {
17
+ readonly toolName: string;
18
+ readonly toolMessage: string;
19
+ constructor(toolName: string, toolMessage: string);
20
+ }
5
21
  export interface SearchResult {
6
22
  title: string;
7
23
  permalink: string;
@@ -27,11 +43,23 @@ export interface EditNoteResult {
27
43
  }
28
44
  interface ReadNoteOptions {
29
45
  includeFrontmatter?: boolean;
46
+ /** Project external_id; routes to that exact project instead of by name. */
47
+ projectId?: string;
48
+ }
49
+ interface WriteNoteOptions {
50
+ metadata?: Record<string, unknown>;
51
+ tags?: string[] | string;
52
+ noteType?: string;
53
+ /** Project external_id; routes to that exact project instead of by name. */
54
+ projectId?: string;
55
+ /** Checksum of the revision read; overwrite only while it is still current. */
56
+ expectedChecksum?: string;
30
57
  }
31
58
  interface EditNoteOptions {
32
59
  find_text?: string;
33
60
  section?: string;
34
61
  expected_replacements?: number;
62
+ metadata?: Record<string, unknown>;
35
63
  }
36
64
  export interface ContextResult {
37
65
  results: Array<{
@@ -59,6 +87,9 @@ export interface RecentResult {
59
87
  export interface ProjectListResult {
60
88
  name: string;
61
89
  path: string;
90
+ external_id?: string | null;
91
+ source?: string | null;
92
+ qualified_name?: string | null;
62
93
  display_name?: string | null;
63
94
  is_private?: boolean;
64
95
  is_default?: boolean;
@@ -149,6 +180,7 @@ export declare class BmClient {
149
180
  private callToolRaw;
150
181
  private callTool;
151
182
  private routedProject;
183
+ private routing;
152
184
  ensureProject(projectPath: string): Promise<void>;
153
185
  listWorkspaces(): Promise<WorkspaceResult[]>;
154
186
  listProjects(workspace?: string): Promise<ProjectListResult[]>;
@@ -160,7 +192,7 @@ export declare class BmClient {
160
192
  entity_types?: string[];
161
193
  }): Promise<SearchResult[]>;
162
194
  readNote(identifier: string, options?: ReadNoteOptions, project?: string): Promise<NoteResult>;
163
- writeNote(title: string, content: string, folder: string, project?: string, overwrite?: boolean): Promise<NoteResult>;
195
+ writeNote(title: string, content: string, folder: string, project?: string, overwrite?: boolean, options?: WriteNoteOptions): Promise<NoteResult>;
164
196
  buildContext(url: string, depth?: number, project?: string): Promise<ContextResult>;
165
197
  recentActivity(timeframe?: string, project?: string): Promise<RecentResult[]>;
166
198
  editNote(identifier: string, operation: "append" | "prepend" | "find_replace" | "replace_section", content: string, options?: EditNoteOptions, project?: string): Promise<EditNoteResult>;
package/dist/bm-client.js CHANGED
@@ -11,6 +11,32 @@ export class NoteAlreadyExistsError extends Error {
11
11
  this.permalink = permalink;
12
12
  }
13
13
  }
14
+ /**
15
+ * write_note refused a conditional overwrite: the note is no longer the
16
+ * revision named by expected_checksum. Nothing was written; currentChecksum is
17
+ * the revision to re-read and retry against (null once no note owns the path).
18
+ */
19
+ export class NoteRevisionConflictError extends Error {
20
+ permalink;
21
+ currentChecksum;
22
+ constructor(title, permalink, currentChecksum) {
23
+ super(`Note revision conflict: "${title}" changed since it was read`);
24
+ this.name = "NoteRevisionConflictError";
25
+ this.permalink = permalink;
26
+ this.currentChecksum = currentChecksum;
27
+ }
28
+ }
29
+ /** A BM MCP tool returned isError; toolMessage is the tool's own error text. */
30
+ export class BmToolError extends Error {
31
+ toolName;
32
+ toolMessage;
33
+ constructor(toolName, toolMessage) {
34
+ super(`BM MCP tool ${toolName} failed${toolMessage ? `: ${toolMessage}` : ""}`);
35
+ this.name = "BmToolError";
36
+ this.toolName = toolName;
37
+ this.toolMessage = toolMessage;
38
+ }
39
+ }
14
40
  const REQUIRED_TOOLS = [
15
41
  "search_notes",
16
42
  "read_note",
@@ -65,6 +91,19 @@ function isNoteNotFoundError(err) {
65
91
  function asString(value) {
66
92
  return typeof value === "string" ? value : null;
67
93
  }
94
+ // In JSON mode BM reports a refused write as a tool error whose text is the
95
+ // refusal payload itself ({"error": "NOTE_ALREADY_EXISTS", ...}). Any other
96
+ // error text (plain-text failures, older servers) is not a refusal payload.
97
+ function parseRefusalPayload(text) {
98
+ let parsed;
99
+ try {
100
+ parsed = JSON.parse(text);
101
+ }
102
+ catch {
103
+ return null;
104
+ }
105
+ return isRecord(parsed) && typeof parsed.error === "string" ? parsed : null;
106
+ }
68
107
  function projectMatchesWorkspace(project, workspace) {
69
108
  const requested = workspace.trim().toLowerCase();
70
109
  if (!requested)
@@ -235,8 +274,7 @@ export class BmClient {
235
274
  arguments: args,
236
275
  });
237
276
  if (isRecord(result) && result.isError === true) {
238
- const message = extractTextFromContent(result.content);
239
- throw new Error(`BM MCP tool ${name} failed${message ? `: ${message}` : ""}`);
277
+ throw new BmToolError(name, extractTextFromContent(result.content));
240
278
  }
241
279
  return result;
242
280
  }
@@ -272,6 +310,14 @@ export class BmClient {
272
310
  routedProject(project) {
273
311
  return project ?? this.project;
274
312
  }
313
+ // project_id names one exact project, so it is sent alone: pairing it with
314
+ // a name (or the plugin's default project) would make routing depend on
315
+ // server-side precedence rather than on what the caller asked for.
316
+ routing(project, projectId) {
317
+ if (projectId !== undefined)
318
+ return { project_id: projectId };
319
+ return { project: this.routedProject(project) };
320
+ }
275
321
  async ensureProject(projectPath) {
276
322
  const payload = await this.callTool("create_memory_project", {
277
323
  project_name: this.project,
@@ -335,7 +381,7 @@ export class BmClient {
335
381
  identifier,
336
382
  include_frontmatter: options.includeFrontmatter === true,
337
383
  output_format: "json",
338
- project: this.routedProject(project),
384
+ ...this.routing(project, options.projectId),
339
385
  };
340
386
  const payload = await this.callTool("read_note", args);
341
387
  if (!isRecord(payload)) {
@@ -354,22 +400,45 @@ export class BmClient {
354
400
  content,
355
401
  file_path: filePath,
356
402
  frontmatter: isRecord(payload.frontmatter) ? payload.frontmatter : null,
403
+ checksum: asString(payload.checksum),
357
404
  };
358
405
  }
359
- async writeNote(title, content, folder, project, overwrite) {
406
+ async writeNote(title, content, folder, project, overwrite, options = {}) {
360
407
  const args = {
361
408
  title,
362
409
  content,
363
410
  directory: folder,
364
411
  output_format: "json",
365
- project: this.routedProject(project),
412
+ ...this.routing(project, options.projectId),
366
413
  };
367
414
  if (overwrite !== undefined)
368
415
  args.overwrite = overwrite;
369
- const payload = await this.callTool("write_note", args);
416
+ if (options.metadata !== undefined)
417
+ args.metadata = options.metadata;
418
+ if (options.tags !== undefined)
419
+ args.tags = options.tags;
420
+ if (options.noteType !== undefined)
421
+ args.note_type = options.noteType;
422
+ if (options.expectedChecksum !== undefined)
423
+ args.expected_checksum = options.expectedChecksum;
424
+ let payload;
425
+ try {
426
+ payload = await this.callTool("write_note", args);
427
+ }
428
+ catch (err) {
429
+ const refusal = err instanceof BmToolError ? parseRefusalPayload(err.toolMessage) : null;
430
+ if (refusal?.error === "NOTE_ALREADY_EXISTS") {
431
+ throw new NoteAlreadyExistsError(asString(refusal.title) ?? title, asString(refusal.permalink) ?? "");
432
+ }
433
+ if (refusal?.error === "NOTE_REVISION_CONFLICT") {
434
+ throw new NoteRevisionConflictError(asString(refusal.title) ?? title, asString(refusal.permalink), asString(refusal.currentChecksum));
435
+ }
436
+ throw err;
437
+ }
370
438
  if (!isRecord(payload)) {
371
439
  throw new Error("invalid write_note response");
372
440
  }
441
+ // Older servers returned the refusal as a successful payload.
373
442
  if (payload.error === "NOTE_ALREADY_EXISTS") {
374
443
  throw new NoteAlreadyExistsError(asString(payload.title) ?? title, asString(payload.permalink) ?? "");
375
444
  }
@@ -429,6 +498,8 @@ export class BmClient {
429
498
  args.section = options.section;
430
499
  if (options.expected_replacements != null)
431
500
  args.expected_replacements = options.expected_replacements;
501
+ if (options.metadata !== undefined)
502
+ args.metadata = options.metadata;
432
503
  const payload = await this.callTool("edit_note", args);
433
504
  if (!isRecord(payload)) {
434
505
  throw new Error("invalid edit_note response");
package/dist/index.js CHANGED
@@ -77,7 +77,15 @@ export default definePluginEntry({
77
77
  throw new Error("uv not found");
78
78
  }
79
79
  log.info("installing basic-memory via uv (this may take a minute)...");
80
- const result = execFileSync("uv", ["tool", "install", "basic-memory", "--force"], {
80
+ // basic-memory pins a FastMCP pre-release; without this flag uv
81
+ // silently installs the last release that had none (#1338).
82
+ const result = execFileSync("uv", [
83
+ "tool",
84
+ "install",
85
+ "basic-memory",
86
+ "--prerelease=allow",
87
+ "--force",
88
+ ], {
81
89
  encoding: "utf-8",
82
90
  timeout: 120_000,
83
91
  stdio: ["ignore", "pipe", "pipe"],
@@ -6,7 +6,9 @@ export function registerEditTool(api, client) {
6
6
  label: "Edit Note",
7
7
  description: "Incrementally edit an existing note in the Basic Memory knowledge graph. " +
8
8
  "Supports append, prepend, find/replace, and section replacement " +
9
- "without rewriting the entire note.",
9
+ "without rewriting the entire note. The result returns the note's " +
10
+ "permalink and file_path: cite those exact values when confirming " +
11
+ "the edit — never report a save that no tool call performed.",
10
12
  parameters: Type.Object({
11
13
  identifier: Type.String({
12
14
  description: "Note title, permalink, or memory:// URL to edit",
@@ -35,6 +37,10 @@ export function registerEditTool(api, client) {
35
37
  project: Type.Optional(Type.String({
36
38
  description: "Target project name (defaults to current project)",
37
39
  })),
40
+ metadata: Type.Optional(Type.Object({}, {
41
+ additionalProperties: true,
42
+ description: "Frontmatter fields to merge independently of the edit operation. Nested objects are supported.",
43
+ })),
38
44
  }),
39
45
  async execute(_toolCallId, params) {
40
46
  log.debug(`edit_note: id="${params.identifier}" op=${params.operation}`);
@@ -43,6 +49,7 @@ export function registerEditTool(api, client) {
43
49
  find_text: params.find_text,
44
50
  section: params.section,
45
51
  expected_replacements: params.expected_replacements,
52
+ metadata: params.metadata,
46
53
  }, params.project);
47
54
  return {
48
55
  content: [
@@ -3,6 +3,9 @@ import { log } from "../logger.js";
3
3
  function normalizeProject(project) {
4
4
  return {
5
5
  name: project.name,
6
+ external_id: project.external_id ?? null,
7
+ qualified_name: project.qualified_name ?? null,
8
+ source: project.source ?? null,
6
9
  path: project.path,
7
10
  display_name: project.display_name ?? null,
8
11
  is_private: project.is_private === true,
@@ -17,7 +20,12 @@ export function registerProjectListTool(api, client) {
17
20
  api.registerTool({
18
21
  name: "list_memory_projects",
19
22
  label: "List Projects",
20
- description: "List all Basic Memory projects accessible to this agent",
23
+ description: "List Basic Memory projects accessible to this agent, optionally filtered by " +
24
+ "workspace. Returns each project's name, external_id, source (local, cloud, " +
25
+ "or local+cloud), path, default flag, workspace, and qualified name. " +
26
+ "Call this before passing `project` to another tool when the user names a " +
27
+ "project that isn't the default. Pass a project's external_id as " +
28
+ "`project_id` to route to that exact project.",
21
29
  parameters: Type.Object({
22
30
  workspace: Type.Optional(Type.String({
23
31
  description: "Filter by workspace name, slug, or tenant_id",
@@ -45,10 +53,20 @@ export function registerProjectListTool(api, client) {
45
53
  const text = normalized
46
54
  .map((project, idx) => {
47
55
  const defaultSuffix = project.is_default ? " (default)" : "";
48
- const displayLine = project.display_name
49
- ? `\n Display Name: ${project.display_name}`
50
- : "";
51
- return `${idx + 1}. **${project.name}**${defaultSuffix}\n Path: ${project.path}\n Private: ${project.is_private}${displayLine}`;
56
+ const lines = [
57
+ `${idx + 1}. **${project.name}**${defaultSuffix}`,
58
+ project.external_id ? ` ID: ${project.external_id}` : null,
59
+ project.source ? ` Source: ${project.source}` : null,
60
+ ` Path: ${project.path}`,
61
+ ` Private: ${project.is_private}`,
62
+ project.display_name
63
+ ? ` Display Name: ${project.display_name}`
64
+ : null,
65
+ project.qualified_name
66
+ ? ` Qualified Name: ${project.qualified_name}`
67
+ : null,
68
+ ];
69
+ return lines.filter((line) => line !== null).join("\n");
52
70
  })
53
71
  .join("\n\n");
54
72
  return {
@@ -9,7 +9,9 @@ export function registerWorkspaceListTool(api, client) {
9
9
  api.registerTool({
10
10
  name: "list_workspaces",
11
11
  label: "List Workspaces",
12
- description: "List all Basic Memory workspaces (personal and organization) accessible to this user",
12
+ description: "List all Basic Memory workspaces (personal and organization) accessible to this user. " +
13
+ "Use it to find the workspace to pass to list_memory_projects when the same " +
14
+ "project name may exist in more than one workspace.",
13
15
  parameters: Type.Object({}),
14
16
  async execute(_toolCallId, _params) {
15
17
  log.debug("list_workspaces");
@@ -140,7 +140,8 @@ export function registerMemoryProvider(api, client, cfg) {
140
140
  label: "Memory Search",
141
141
  description: "Search across all memory sources: MEMORY.md (working memory), " +
142
142
  "Basic Memory knowledge graph (long-term archive), and active tasks. " +
143
- "Returns composited results from all sources.",
143
+ "Returns composited results from all sources. Use it for broad recall; use " +
144
+ "search_notes when you need metadata, tag, or status filters or a specific project.",
144
145
  parameters: Type.Object({
145
146
  query: Type.String({
146
147
  description: "Search query — natural language or keywords",
@@ -5,7 +5,9 @@ export function registerReadTool(api, client) {
5
5
  name: "read_note",
6
6
  label: "Read Note",
7
7
  description: "Read a specific note from the Basic Memory knowledge graph by title or permalink. " +
8
- "Returns the full note content including observations and relations.",
8
+ "Returns the full note content including observations and relations, " +
9
+ "followed by the note's checksum: pass it to write_note as " +
10
+ "expected_checksum (with overwrite=true) to replace only this revision.",
9
11
  parameters: Type.Object({
10
12
  identifier: Type.String({
11
13
  description: "Note title, permalink, or memory:// URL to read",
@@ -16,16 +18,28 @@ export function registerReadTool(api, client) {
16
18
  project: Type.Optional(Type.String({
17
19
  description: "Target project name (defaults to current project)",
18
20
  })),
21
+ project_id: Type.Optional(Type.String({
22
+ description: "Target project external_id from list_memory_projects. Routes to " +
23
+ "that exact project and takes precedence over `project`.",
24
+ })),
19
25
  }),
20
26
  async execute(_toolCallId, params) {
21
27
  log.debug(`read_note: identifier="${params.identifier}"`);
22
28
  try {
23
- const note = await client.readNote(params.identifier, { includeFrontmatter: params.include_frontmatter === true }, params.project);
29
+ const note = await client.readNote(params.identifier, {
30
+ includeFrontmatter: params.include_frontmatter === true,
31
+ projectId: params.project_id,
32
+ }, params.project);
33
+ // The checksum identifies the revision just read; the model sees only
34
+ // `content`, so it has to travel in the text to reach a guarded write.
35
+ const text = note.checksum
36
+ ? `${note.content}\n\nChecksum: ${note.checksum}`
37
+ : note.content;
24
38
  return {
25
39
  content: [
26
40
  {
27
41
  type: "text",
28
- text: note.content,
42
+ text,
29
43
  },
30
44
  ],
31
45
  details: {
@@ -33,6 +47,7 @@ export function registerReadTool(api, client) {
33
47
  permalink: note.permalink,
34
48
  file_path: note.file_path,
35
49
  frontmatter: note.frontmatter ?? null,
50
+ checksum: note.checksum ?? null,
36
51
  },
37
52
  };
38
53
  }
@@ -6,7 +6,9 @@ export function registerSearchTool(api, client) {
6
6
  label: "Knowledge Search",
7
7
  description: "Search the Basic Memory knowledge graph for relevant notes, concepts, and connections. " +
8
8
  "Returns matching notes with titles, content previews, and relevance scores. " +
9
- "Optionally filter by frontmatter metadata fields, tags, or status.",
9
+ "Optionally filter by frontmatter metadata fields, tags, or status, or target " +
10
+ "another project. For a quick cross-source recall that also covers MEMORY.md " +
11
+ "and active tasks, use memory_search.",
10
12
  parameters: Type.Object({
11
13
  query: Type.String({ description: "Search query" }),
12
14
  limit: Type.Optional(Type.Number({ description: "Max results (default: 10)" })),