@basicmemory/openclaw-basic-memory 0.24.0 → 1.0.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 (87) hide show
  1. package/README.md +24 -12
  2. package/bm-client.ts +302 -112
  3. package/commands/cli.ts +5 -6
  4. package/commands/slash.ts +4 -4
  5. package/context-engine/basic-memory-context-engine.ts +74 -9
  6. package/context-engine/types.ts +21 -0
  7. package/dist/bm-client.d.ts +61 -12
  8. package/dist/bm-client.js +250 -101
  9. package/dist/commands/cli.d.ts +1 -1
  10. package/dist/commands/cli.js +5 -5
  11. package/dist/commands/slash.d.ts +1 -1
  12. package/dist/commands/slash.js +4 -3
  13. package/dist/context-engine/basic-memory-context-engine.d.ts +20 -1
  14. package/dist/context-engine/basic-memory-context-engine.js +50 -2
  15. package/dist/context-engine/types.d.ts +9 -0
  16. package/dist/context-engine/types.js +1 -0
  17. package/dist/index.d.ts +10 -4
  18. package/dist/index.js +79 -69
  19. package/dist/memory/basic-memory-capability.d.ts +19 -0
  20. package/dist/memory/basic-memory-capability.js +190 -0
  21. package/dist/tools/build-context.d.ts +1 -1
  22. package/dist/tools/build-context.js +12 -4
  23. package/dist/tools/delete-note.d.ts +1 -1
  24. package/dist/tools/delete-note.js +2 -1
  25. package/dist/tools/edit-note.d.ts +1 -1
  26. package/dist/tools/edit-note.js +22 -12
  27. package/dist/tools/list-memory-projects.d.ts +1 -1
  28. package/dist/tools/list-memory-projects.js +2 -1
  29. package/dist/tools/list-workspaces.d.ts +1 -1
  30. package/dist/tools/list-workspaces.js +4 -4
  31. package/dist/tools/move-note.d.ts +1 -1
  32. package/dist/tools/move-note.js +2 -1
  33. package/dist/tools/read-note.d.ts +1 -1
  34. package/dist/tools/read-note.js +2 -1
  35. package/dist/tools/schema-diff.d.ts +1 -1
  36. package/dist/tools/schema-diff.js +2 -1
  37. package/dist/tools/schema-infer.d.ts +1 -1
  38. package/dist/tools/schema-infer.js +2 -1
  39. package/dist/tools/schema-validate.d.ts +1 -1
  40. package/dist/tools/schema-validate.js +2 -1
  41. package/dist/tools/search-notes.d.ts +1 -1
  42. package/dist/tools/search-notes.js +13 -6
  43. package/dist/tools/write-note.d.ts +1 -1
  44. package/dist/tools/write-note.js +11 -12
  45. package/index.ts +93 -84
  46. package/memory/basic-memory-capability.ts +253 -0
  47. package/openclaw.plugin.json +0 -2
  48. package/package.json +6 -5
  49. package/skills/manifest.json +5 -0
  50. package/skills/memory-agents/SKILL.md +23 -10
  51. package/skills/memory-capture/SKILL.md +29 -18
  52. package/skills/memory-comark/SKILL.md +17 -6
  53. package/skills/memory-continue/SKILL.md +47 -27
  54. package/skills/memory-curate/SKILL.md +40 -24
  55. package/skills/memory-defrag/SKILL.md +18 -7
  56. package/skills/memory-doctor/SKILL.md +93 -0
  57. package/skills/memory-ingest/SKILL.md +33 -17
  58. package/skills/memory-lifecycle/SKILL.md +35 -22
  59. package/skills/memory-literary-analysis/SKILL.md +112 -75
  60. package/skills/memory-metadata-search/SKILL.md +39 -28
  61. package/skills/memory-notes/SKILL.md +75 -50
  62. package/skills/memory-okf/SKILL.md +27 -15
  63. package/skills/memory-onboarding/SKILL.md +20 -9
  64. package/skills/memory-onboarding/references/assistant-setup.md +1 -1
  65. package/skills/memory-onboarding/references/conventions.md +4 -4
  66. package/skills/memory-onboarding/references/domain-playbooks.md +1 -1
  67. package/skills/memory-onboarding/references/schema-guide.md +9 -8
  68. package/skills/memory-quest/SKILL.md +18 -7
  69. package/skills/memory-reflect/SKILL.md +25 -13
  70. package/skills/memory-research/SKILL.md +28 -13
  71. package/skills/memory-schema/SKILL.md +36 -24
  72. package/skills/memory-tasks/SKILL.md +34 -22
  73. package/tools/build-context.ts +14 -5
  74. package/tools/delete-note.ts +2 -2
  75. package/tools/edit-note.ts +27 -14
  76. package/tools/list-memory-projects.ts +6 -2
  77. package/tools/list-workspaces.ts +8 -5
  78. package/tools/move-note.ts +2 -2
  79. package/tools/read-note.ts +2 -2
  80. package/tools/schema-diff.ts +2 -2
  81. package/tools/schema-infer.ts +2 -2
  82. package/tools/schema-validate.ts +2 -2
  83. package/tools/search-notes.ts +18 -7
  84. package/tools/write-note.ts +12 -12
  85. package/dist/tools/memory-provider.d.ts +0 -15
  86. package/dist/tools/memory-provider.js +0 -275
  87. package/tools/memory-provider.ts +0 -336
package/dist/bm-client.js CHANGED
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import { setTimeout as delay } from "node:timers/promises";
2
3
  import { Client } from "@modelcontextprotocol/sdk/client";
3
4
  import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js";
@@ -5,22 +6,23 @@ import { log } from "./logger.js";
5
6
  const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000];
6
7
  export class NoteAlreadyExistsError extends Error {
7
8
  permalink;
8
- constructor(title, permalink) {
9
- super(`Note already exists: "${title}" (${permalink})`);
9
+ constructor(title, permalink, message) {
10
+ super(message ?? `Note already exists: "${title}" (${permalink})`);
10
11
  this.name = "NoteAlreadyExistsError";
11
12
  this.permalink = permalink;
12
13
  }
13
14
  }
14
15
  /**
15
- * write_note refused a conditional overwrite: the note is no longer the
16
+ * bm_write refused a conditional overwrite: the note is no longer the
16
17
  * 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
+ * available revision when supplied by a server. A null value means unknown;
19
+ * 1.0 puts revision details in the refusal message, not a checksum field.
18
20
  */
19
21
  export class NoteRevisionConflictError extends Error {
20
22
  permalink;
21
23
  currentChecksum;
22
- constructor(title, permalink, currentChecksum) {
23
- super(`Note revision conflict: "${title}" changed since it was read`);
24
+ constructor(title, permalink, currentChecksum, message) {
25
+ super(message ?? `Note revision conflict: "${title}" changed since it was read`);
24
26
  this.name = "NoteRevisionConflictError";
25
27
  this.permalink = permalink;
26
28
  this.currentChecksum = currentChecksum;
@@ -38,21 +40,24 @@ export class BmToolError extends Error {
38
40
  }
39
41
  }
40
42
  const REQUIRED_TOOLS = [
41
- "search_notes",
42
- "read_note",
43
- "write_note",
44
- "edit_note",
45
- "build_context",
46
- "recent_activity",
47
- "list_memory_projects",
48
- "list_workspaces",
49
- "create_memory_project",
50
- "delete_note",
51
- "move_note",
52
- "schema_validate",
53
- "schema_infer",
54
- "schema_diff",
43
+ "bm_search",
44
+ "bm_read",
45
+ "bm_write",
46
+ "bm_edit",
47
+ "bm_context",
48
+ "bm_project",
49
+ "bm_delete",
50
+ "bm_move",
51
+ "bm_schema",
55
52
  ];
53
+ /**
54
+ * The text to preview for a search hit. `content` is the start of the note,
55
+ * so for a long note (a day of captured conversation) the matching part can
56
+ * sit far below any preview cut-off; the matched chunk is the part that hit.
57
+ */
58
+ export function searchResultText(result) {
59
+ return result.matched_chunk?.trim() || result.content || "";
60
+ }
56
61
  function getErrorMessage(err) {
57
62
  return err instanceof Error ? err.message : String(err);
58
63
  }
@@ -80,19 +85,24 @@ function isRecoverableConnectionError(err) {
80
85
  msg.includes("failed to start bm mcp stdio") ||
81
86
  msg.includes("client is closed"));
82
87
  }
83
- function isNoteNotFoundError(err) {
84
- const msg = getErrorMessage(err).toLowerCase();
85
- return (msg.includes("entity not found") ||
86
- msg.includes("note not found") ||
87
- msg.includes("resource not found") ||
88
- msg.includes("could not find note matching") ||
89
- msg.includes("404"));
88
+ export function isNoteNotFoundError(err) {
89
+ return (err instanceof BmToolError &&
90
+ parseRefusalPayload(err.toolMessage)?.error === "NOT_FOUND");
91
+ }
92
+ function conversationDate(date) {
93
+ return date.toISOString().split("T")[0];
94
+ }
95
+ // Host advancement keys are opaque and may contain characters that would end
96
+ // an HTML comment, so the note stores a fixed-length digest instead.
97
+ function conversationTurnMarker(turnKey) {
98
+ const digest = createHash("sha256").update(turnKey).digest("hex").slice(0, 32);
99
+ return `<!-- openclaw-turn:${digest} -->`;
90
100
  }
91
101
  function asString(value) {
92
102
  return typeof value === "string" ? value : null;
93
103
  }
94
104
  // 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
105
+ // refusal payload itself ({"error": "ALREADY_EXISTS", ...}). Any other
96
106
  // error text (plain-text failures, older servers) is not a refusal payload.
97
107
  function parseRefusalPayload(text) {
98
108
  let parsed;
@@ -104,6 +114,24 @@ function parseRefusalPayload(text) {
104
114
  }
105
115
  return isRecord(parsed) && typeof parsed.error === "string" ? parsed : null;
106
116
  }
117
+ /**
118
+ * The server's own explanation of a failed tool call, for showing to an agent.
119
+ * A refusal payload becomes "CODE: message"; other tool text passes through.
120
+ */
121
+ export function describeToolError(err) {
122
+ if (!(err instanceof BmToolError))
123
+ return getErrorMessage(err);
124
+ const payload = parseRefusalPayload(err.toolMessage);
125
+ if (!payload)
126
+ return err.toolMessage || err.message;
127
+ const message = asString(payload.message);
128
+ return message ? `${payload.error}: ${message}` : String(payload.error);
129
+ }
130
+ /** describeToolError, but saying plainly when the note does not exist. */
131
+ export function describeNoteError(err) {
132
+ const reason = describeToolError(err);
133
+ return isNoteNotFoundError(err) ? `it does not exist. ${reason}` : reason;
134
+ }
107
135
  function projectMatchesWorkspace(project, workspace) {
108
136
  const requested = workspace.trim().toLowerCase();
109
137
  if (!requested)
@@ -120,26 +148,93 @@ export class BmClient {
120
148
  cwd;
121
149
  env;
122
150
  shouldRun = false;
151
+ // True only between start() and stop(), i.e. while the plugin service owns
152
+ // the BM MCP session. A lazy connection opened by a tool or memory call in a
153
+ // one-shot CLI process leaves this false.
154
+ serviceRunning = false;
155
+ // Connect plus project creation, started by start(). Tool calls wait for
156
+ // it so none can reach a project that does not exist yet. Null when the
157
+ // client was never started (tests, one-shot CLI readers).
158
+ startup = null;
159
+ startupRetry = null;
160
+ projectPath;
161
+ prepare;
123
162
  client = null;
124
163
  transport = null;
125
164
  connectPromise = null;
126
165
  retryDelaysMs = [...DEFAULT_RETRY_DELAYS_MS];
166
+ // Conversation captures run one at a time so a turn-key duplicate check
167
+ // and the append that follows it cannot interleave with another capture.
168
+ conversationWrites = Promise.resolve();
127
169
  constructor(bmPath, project) {
128
170
  this.bmPath = bmPath;
129
171
  this.project = project;
130
172
  }
131
- async start(options) {
173
+ /**
174
+ * Connect to BM and create the plugin's project.
175
+ *
176
+ * Returns the startup promise; callers that must not block (the plugin
177
+ * service, which OpenClaw gives only a few seconds) can leave it running.
178
+ * Tool calls made meanwhile wait for it. If it fails, the next tool call
179
+ * runs it again, so a transient failure does not leave the plugin dead.
180
+ */
181
+ start(options) {
132
182
  this.shouldRun = true;
183
+ this.serviceRunning = true;
133
184
  if (options?.cwd) {
134
185
  this.cwd = options.cwd;
135
186
  }
136
187
  if (options?.env) {
137
188
  this.env = options.env;
138
189
  }
190
+ if (options?.projectPath) {
191
+ this.projectPath = options.projectPath;
192
+ }
193
+ if (options?.prepare) {
194
+ this.prepare = options.prepare;
195
+ }
196
+ this.startup = this.runStartup();
197
+ return this.startup;
198
+ }
199
+ async runStartup() {
200
+ await this.prepare?.();
139
201
  await this.connectWithRetries();
202
+ if (this.projectPath)
203
+ await this.createProject(this.projectPath);
204
+ }
205
+ async waitForStartup() {
206
+ if (!this.startup)
207
+ return;
208
+ try {
209
+ await this.startup;
210
+ }
211
+ catch (err) {
212
+ // Trigger: the startup this call was waiting on failed.
213
+ // Why: the service no longer fails on a slow or failed start, so the
214
+ // first tool call afterwards is the place to recover.
215
+ // Outcome: one fresh attempt, shared by concurrent callers; its error
216
+ // reaches the tool if it fails too.
217
+ if (!this.serviceRunning)
218
+ throw err;
219
+ if (this.startupRetry === null) {
220
+ this.startupRetry = this.runStartup().finally(() => {
221
+ this.startupRetry = null;
222
+ });
223
+ this.startup = this.startupRetry;
224
+ }
225
+ await this.startupRetry;
226
+ }
227
+ }
228
+ isServiceRunning() {
229
+ return this.serviceRunning;
140
230
  }
141
231
  async stop() {
142
232
  this.shouldRun = false;
233
+ this.serviceRunning = false;
234
+ // Let an in-flight startup settle first, so it cannot reconnect after
235
+ // the disconnect below.
236
+ await this.startup?.catch(() => undefined);
237
+ this.startup = null;
143
238
  await this.disconnectCurrent(this.client, this.transport);
144
239
  this.client = null;
145
240
  this.transport = null;
@@ -297,6 +392,11 @@ export class BmClient {
297
392
  throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`);
298
393
  }
299
394
  async callTool(name, args) {
395
+ await this.waitForStartup();
396
+ return this.callStructured(name, args);
397
+ }
398
+ // Ungated: startup itself calls this to create the project.
399
+ async callStructured(name, args) {
300
400
  const result = await this.callToolRaw(name, args);
301
401
  if (!isRecord(result) || result.structuredContent === undefined) {
302
402
  throw new Error(`BM MCP tool ${name} returned no structured payload`);
@@ -310,36 +410,45 @@ export class BmClient {
310
410
  routedProject(project) {
311
411
  return project ?? this.project;
312
412
  }
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.
413
+ // The 1.0 project parameter accepts either a name or an exact project UUID.
316
414
  routing(project, projectId) {
317
415
  if (projectId !== undefined)
318
- return { project_id: projectId };
416
+ return { project: projectId };
319
417
  return { project: this.routedProject(project) };
320
418
  }
321
419
  async ensureProject(projectPath) {
322
- const payload = await this.callTool("create_memory_project", {
323
- project_name: this.project,
324
- project_path: projectPath,
420
+ await this.waitForStartup();
421
+ await this.createProject(projectPath);
422
+ }
423
+ async createProject(projectPath) {
424
+ const payload = await this.callStructured("bm_project", {
425
+ command: "create",
426
+ name: this.project,
427
+ path: projectPath,
325
428
  set_default: true,
326
429
  output_format: "json",
327
430
  });
328
431
  if (!isRecord(payload)) {
329
- throw new Error("invalid create_memory_project response");
432
+ throw new Error("invalid bm_project response");
330
433
  }
331
434
  }
332
435
  async listWorkspaces() {
333
- const payload = await this.callTool("list_workspaces", {
334
- output_format: "json",
335
- });
336
- if (isRecord(payload) && Array.isArray(payload.workspaces)) {
337
- return payload.workspaces;
436
+ const projects = await this.listProjects();
437
+ const workspaces = new Map();
438
+ for (const project of projects) {
439
+ if (project.workspace_tenant_id && project.workspace_name) {
440
+ workspaces.set(project.workspace_tenant_id, {
441
+ tenant_id: project.workspace_tenant_id,
442
+ name: project.workspace_name,
443
+ workspace_type: project.workspace_type ?? null,
444
+ });
445
+ }
338
446
  }
339
- throw new Error("invalid list_workspaces response");
447
+ return [...workspaces.values()];
340
448
  }
341
449
  async listProjects(workspace) {
342
- const payload = await this.callTool("list_memory_projects", {
450
+ const payload = await this.callTool("bm_project", {
451
+ command: "list",
343
452
  output_format: "json",
344
453
  });
345
454
  if (isRecord(payload) && Array.isArray(payload.projects)) {
@@ -349,7 +458,7 @@ export class BmClient {
349
458
  }
350
459
  return projects;
351
460
  }
352
- throw new Error("invalid list_memory_projects response");
461
+ throw new Error("invalid bm_project response");
353
462
  }
354
463
  async search(query, limit = 10, project, metadata) {
355
464
  const args = {
@@ -360,19 +469,21 @@ export class BmClient {
360
469
  };
361
470
  if (query)
362
471
  args.query = query;
363
- if (metadata?.filters)
364
- args.metadata_filters = metadata.filters;
365
- if (metadata?.tags)
366
- args.tags = metadata.tags;
367
- if (metadata?.status)
368
- args.status = metadata.status;
472
+ const filters = { ...metadata?.filters };
473
+ // Explicit predicates take precedence over convenience shortcuts.
474
+ if (metadata?.tags?.length && !("tags" in filters))
475
+ filters.tags = { $contains: metadata.tags };
476
+ if (metadata?.status && !("status" in filters))
477
+ filters.status = metadata.status;
478
+ if (Object.keys(filters).length)
479
+ args.filters = filters;
369
480
  if (metadata?.note_types)
370
481
  args.note_types = metadata.note_types;
371
482
  if (metadata?.entity_types)
372
- args.entity_types = metadata.entity_types;
373
- const payload = await this.callTool("search_notes", args);
483
+ args.items = metadata.entity_types.map((item) => item === "entity" ? "note" : item);
484
+ const payload = await this.callTool("bm_search", args);
374
485
  if (!isRecord(payload) || !Array.isArray(payload.results)) {
375
- throw new Error("invalid search_notes response");
486
+ throw new Error("invalid bm_search response");
376
487
  }
377
488
  return payload.results;
378
489
  }
@@ -383,16 +494,16 @@ export class BmClient {
383
494
  output_format: "json",
384
495
  ...this.routing(project, options.projectId),
385
496
  };
386
- const payload = await this.callTool("read_note", args);
497
+ const payload = await this.callTool("bm_read", args);
387
498
  if (!isRecord(payload)) {
388
- throw new Error("invalid read_note response");
499
+ throw new Error("invalid bm_read response");
389
500
  }
390
501
  const title = asString(payload.title);
391
502
  const permalink = asString(payload.permalink);
392
503
  const content = asString(payload.content);
393
504
  const filePath = asString(payload.file_path);
394
505
  if (!title || !permalink || content === null || !filePath) {
395
- throw new Error("invalid read_note payload");
506
+ throw new Error("invalid bm_read payload");
396
507
  }
397
508
  return {
398
509
  title,
@@ -423,30 +534,26 @@ export class BmClient {
423
534
  args.expected_checksum = options.expectedChecksum;
424
535
  let payload;
425
536
  try {
426
- payload = await this.callTool("write_note", args);
537
+ payload = await this.callTool("bm_write", args);
427
538
  }
428
539
  catch (err) {
429
540
  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) ?? "");
541
+ if (refusal?.error === "ALREADY_EXISTS") {
542
+ throw new NoteAlreadyExistsError(asString(refusal.title) ?? title, asString(refusal.permalink) ?? "", asString(refusal.message) ?? undefined);
432
543
  }
433
- if (refusal?.error === "NOTE_REVISION_CONFLICT") {
434
- throw new NoteRevisionConflictError(asString(refusal.title) ?? title, asString(refusal.permalink), asString(refusal.currentChecksum));
544
+ if (refusal?.error === "REVISION_CONFLICT") {
545
+ throw new NoteRevisionConflictError(asString(refusal.title) ?? title, asString(refusal.permalink), asString(refusal.currentChecksum), asString(refusal.message) ?? undefined);
435
546
  }
436
547
  throw err;
437
548
  }
438
549
  if (!isRecord(payload)) {
439
- throw new Error("invalid write_note response");
440
- }
441
- // Older servers returned the refusal as a successful payload.
442
- if (payload.error === "NOTE_ALREADY_EXISTS") {
443
- throw new NoteAlreadyExistsError(asString(payload.title) ?? title, asString(payload.permalink) ?? "");
550
+ throw new Error("invalid bm_write response");
444
551
  }
445
552
  const resultTitle = asString(payload.title);
446
553
  const permalink = asString(payload.permalink);
447
554
  const filePath = asString(payload.file_path);
448
555
  if (!resultTitle || !permalink || !filePath) {
449
- throw new Error("invalid write_note payload");
556
+ throw new Error("invalid bm_write payload");
450
557
  }
451
558
  return {
452
559
  title: resultTitle,
@@ -461,33 +568,34 @@ export class BmClient {
461
568
  }
462
569
  async buildContext(url, depth = 1, project) {
463
570
  const args = {
464
- url,
571
+ identifier: url,
465
572
  depth,
466
573
  output_format: "json",
467
574
  project: this.routedProject(project),
468
575
  };
469
- const payload = await this.callTool("build_context", args);
576
+ const payload = await this.callTool("bm_context", args);
470
577
  if (!isRecord(payload) || !Array.isArray(payload.results)) {
471
- throw new Error("invalid build_context response");
578
+ throw new Error("invalid bm_context response");
472
579
  }
473
580
  return payload;
474
581
  }
475
582
  async recentActivity(timeframe = "24h", project) {
476
583
  const args = {
477
- timeframe,
584
+ command: "recent",
585
+ since: timeframe,
478
586
  output_format: "json",
479
587
  project: this.routedProject(project),
480
588
  };
481
- const payload = await this.callTool("recent_activity", args);
482
- if (Array.isArray(payload)) {
483
- return payload;
589
+ const payload = await this.callTool("bm_project", args);
590
+ if (isRecord(payload) && Array.isArray(payload.results)) {
591
+ return payload.results;
484
592
  }
485
- throw new Error("invalid recent_activity response");
593
+ throw new Error("invalid bm_project response");
486
594
  }
487
595
  async editNote(identifier, operation, content, options = {}, project) {
488
596
  const args = {
489
597
  identifier,
490
- operation,
598
+ command: operation,
491
599
  content,
492
600
  output_format: "json",
493
601
  project: this.routedProject(project),
@@ -500,15 +608,15 @@ export class BmClient {
500
608
  args.expected_replacements = options.expected_replacements;
501
609
  if (options.metadata !== undefined)
502
610
  args.metadata = options.metadata;
503
- const payload = await this.callTool("edit_note", args);
611
+ const payload = await this.callTool("bm_edit", args);
504
612
  if (!isRecord(payload)) {
505
- throw new Error("invalid edit_note response");
613
+ throw new Error("invalid bm_edit response");
506
614
  }
507
615
  const title = asString(payload.title);
508
616
  const permalink = asString(payload.permalink);
509
617
  const filePath = asString(payload.file_path);
510
618
  if (!title || !permalink || !filePath) {
511
- throw new Error("invalid edit_note payload");
619
+ throw new Error("invalid bm_edit payload");
512
620
  }
513
621
  return {
514
622
  title,
@@ -524,12 +632,12 @@ export class BmClient {
524
632
  output_format: "json",
525
633
  project: this.routedProject(project),
526
634
  };
527
- const payload = await this.callTool("delete_note", args);
635
+ const payload = await this.callTool("bm_delete", args);
528
636
  if (!isRecord(payload)) {
529
- throw new Error("invalid delete_note response");
637
+ throw new Error("invalid bm_delete response");
530
638
  }
531
- if (payload.deleted !== true) {
532
- throw new Error(`delete_note did not delete "${identifier}"`);
639
+ if (payload.action !== "deleted") {
640
+ throw new Error(`bm_delete did not delete "${identifier}"`);
533
641
  }
534
642
  return {
535
643
  title: asString(payload.title) ?? identifier,
@@ -540,17 +648,17 @@ export class BmClient {
540
648
  async moveNote(identifier, newFolder, project) {
541
649
  const args = {
542
650
  identifier,
543
- destination_folder: newFolder,
651
+ destination: `${newFolder.replace(/\/+$/, "")}/`,
544
652
  output_format: "json",
545
653
  project: this.routedProject(project),
546
654
  };
547
- const payload = await this.callTool("move_note", args);
655
+ const payload = await this.callTool("bm_move", args);
548
656
  if (!isRecord(payload)) {
549
- throw new Error("invalid move_note response");
657
+ throw new Error("invalid bm_move response");
550
658
  }
551
- if (payload.moved !== true) {
659
+ if (payload.action !== "moved") {
552
660
  throw new Error(asString(payload.error) ??
553
- `move_note did not move "${identifier}" to "${newFolder}"`);
661
+ `bm_move did not move "${identifier}" to "${newFolder}"`);
554
662
  }
555
663
  return {
556
664
  title: asString(payload.title) ?? identifier,
@@ -561,6 +669,7 @@ export class BmClient {
561
669
  }
562
670
  async schemaValidate(noteType, identifier, project) {
563
671
  const args = {
672
+ command: "validate",
564
673
  output_format: "json",
565
674
  project: this.routedProject(project),
566
675
  };
@@ -568,44 +677,77 @@ export class BmClient {
568
677
  args.note_type = noteType;
569
678
  if (identifier)
570
679
  args.identifier = identifier;
571
- const payload = await this.callTool("schema_validate", args);
680
+ const payload = await this.callTool("bm_schema", args);
572
681
  if (!isRecord(payload)) {
573
- throw new Error("invalid schema_validate response");
682
+ throw new Error("invalid bm_schema response");
574
683
  }
575
684
  return payload;
576
685
  }
577
686
  async schemaInfer(noteType, threshold = 0.25, project) {
578
687
  const args = {
688
+ command: "infer",
579
689
  note_type: noteType,
580
690
  threshold,
581
691
  output_format: "json",
582
692
  project: this.routedProject(project),
583
693
  };
584
- const payload = await this.callTool("schema_infer", args);
694
+ const payload = await this.callTool("bm_schema", args);
585
695
  if (!isRecord(payload)) {
586
- throw new Error("invalid schema_infer response");
696
+ throw new Error("invalid bm_schema response");
587
697
  }
588
698
  return payload;
589
699
  }
590
700
  async schemaDiff(noteType, project) {
591
701
  const args = {
702
+ command: "diff",
592
703
  note_type: noteType,
593
704
  output_format: "json",
594
705
  project: this.routedProject(project),
595
706
  };
596
- const payload = await this.callTool("schema_diff", args);
707
+ const payload = await this.callTool("bm_schema", args);
597
708
  if (!isRecord(payload)) {
598
- throw new Error("invalid schema_diff response");
709
+ throw new Error("invalid bm_schema response");
599
710
  }
600
711
  return payload;
601
712
  }
602
- async indexConversation(userMessage, assistantResponse) {
713
+ /**
714
+ * Append one user/assistant exchange to today's conversation note.
715
+ *
716
+ * With a turnKey the capture is idempotent: the entry carries a marker
717
+ * derived from the key, and a key already present in today's or
718
+ * yesterday's note is reported as a duplicate instead of written again.
719
+ * Yesterday is checked because a host retry can cross midnight.
720
+ */
721
+ async indexConversation(userMessage, assistantResponse, options = {}) {
722
+ const write = this.conversationWrites.then(() => this.writeConversationEntry(userMessage, assistantResponse, options.turnKey));
723
+ this.conversationWrites = write.catch(() => undefined);
724
+ return write;
725
+ }
726
+ async writeConversationEntry(userMessage, assistantResponse, turnKey) {
603
727
  const now = new Date();
604
- const dateStr = now.toISOString().split("T")[0];
728
+ const dateStr = conversationDate(now);
605
729
  const timeStr = now.toTimeString().slice(0, 5);
606
730
  const title = `conversations-${dateStr}`;
731
+ const marker = turnKey ? conversationTurnMarker(turnKey) : null;
732
+ // Trigger: the host supplied an advancement key for this turn.
733
+ // Why: hosts retry commits after a crash, and a second append would
734
+ // duplicate the exchange in the note.
735
+ // Outcome: an already-recorded key short-circuits before any write.
736
+ if (marker) {
737
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000);
738
+ for (const candidate of [
739
+ title,
740
+ `conversations-${conversationDate(yesterday)}`,
741
+ ]) {
742
+ if (await this.noteContains(candidate, marker)) {
743
+ log.debug(`conversation turn already recorded in: ${candidate}`);
744
+ return "duplicate";
745
+ }
746
+ }
747
+ }
607
748
  const entry = [
608
749
  `### ${timeStr}`,
750
+ ...(marker ? [marker] : []),
609
751
  "",
610
752
  "**User:**",
611
753
  userMessage,
@@ -618,7 +760,7 @@ export class BmClient {
618
760
  try {
619
761
  await this.editNote(title, "append", entry);
620
762
  log.debug(`appended conversation to: ${title}`);
621
- return;
763
+ return "committed";
622
764
  }
623
765
  catch (err) {
624
766
  if (!isNoteNotFoundError(err)) {
@@ -639,12 +781,19 @@ export class BmClient {
639
781
  "",
640
782
  entry,
641
783
  ].join("\n");
784
+ await this.writeNote(title, content, "conversations");
785
+ log.debug(`created conversation note: ${title}`);
786
+ return "committed";
787
+ }
788
+ async noteContains(identifier, text) {
642
789
  try {
643
- await this.writeNote(title, content, "conversations");
644
- log.debug(`created conversation note: ${title}`);
790
+ const note = await this.readNote(identifier);
791
+ return note.content.includes(text);
645
792
  }
646
793
  catch (err) {
647
- log.error("conversation index failed", err);
794
+ if (isNoteNotFoundError(err))
795
+ return false;
796
+ throw err;
648
797
  }
649
798
  }
650
799
  getProject() {
@@ -1,4 +1,4 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
- import type { BmClient } from "../bm-client.ts";
2
+ import { type BmClient } from "../bm-client.ts";
3
3
  import type { BasicMemoryConfig } from "../config.ts";
4
4
  export declare function registerCli(api: OpenClawPluginApi, client: BmClient, cfg: BasicMemoryConfig): void;
@@ -1,3 +1,4 @@
1
+ import { searchResultText } from "../bm-client.js";
1
2
  import { log } from "../logger.js";
2
3
  export function registerCli(api, client, cfg) {
3
4
  api.registerCli(
@@ -19,12 +20,11 @@ export function registerCli(api, client, cfg) {
19
20
  return;
20
21
  }
21
22
  for (const r of results) {
22
- const score = r.score ? ` (${(r.score * 100).toFixed(0)}%)` : "";
23
+ const score = r.score ? ` (score ${r.score.toFixed(2)})` : "";
23
24
  console.log(`- ${r.title}${score}`);
24
- if (r.content) {
25
- const preview = r.content.length > 100
26
- ? `${r.content.slice(0, 100)}...`
27
- : r.content;
25
+ const text = searchResultText(r);
26
+ if (text) {
27
+ const preview = text.length > 100 ? `${text.slice(0, 100)}...` : text;
28
28
  console.log(` ${preview}`);
29
29
  }
30
30
  }
@@ -1,3 +1,3 @@
1
1
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk/plugin-entry";
2
- import type { BmClient } from "../bm-client.ts";
2
+ import { type BmClient } from "../bm-client.ts";
3
3
  export declare function registerCommands(api: OpenClawPluginApi, client: BmClient): void;