@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/bm-client.ts 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"
@@ -7,17 +8,18 @@ const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000]
7
8
 
8
9
  export class NoteAlreadyExistsError extends Error {
9
10
  readonly permalink: string
10
- constructor(title: string, permalink: string) {
11
- super(`Note already exists: "${title}" (${permalink})`)
11
+ constructor(title: string, permalink: string, message?: string) {
12
+ super(message ?? `Note already exists: "${title}" (${permalink})`)
12
13
  this.name = "NoteAlreadyExistsError"
13
14
  this.permalink = permalink
14
15
  }
15
16
  }
16
17
 
17
18
  /**
18
- * write_note refused a conditional overwrite: the note is no longer the
19
+ * bm_write refused a conditional overwrite: the note is no longer the
19
20
  * 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
+ * available revision when supplied by a server. A null value means unknown;
22
+ * 1.0 puts revision details in the refusal message, not a checksum field.
21
23
  */
22
24
  export class NoteRevisionConflictError extends Error {
23
25
  readonly permalink: string | null
@@ -26,8 +28,11 @@ export class NoteRevisionConflictError extends Error {
26
28
  title: string,
27
29
  permalink: string | null,
28
30
  currentChecksum: string | null,
31
+ message?: string,
29
32
  ) {
30
- super(`Note revision conflict: "${title}" changed since it was read`)
33
+ super(
34
+ message ?? `Note revision conflict: "${title}" changed since it was read`,
35
+ )
31
36
  this.name = "NoteRevisionConflictError"
32
37
  this.permalink = permalink
33
38
  this.currentChecksum = currentChecksum
@@ -49,20 +54,15 @@ export class BmToolError extends Error {
49
54
  }
50
55
 
51
56
  const REQUIRED_TOOLS = [
52
- "search_notes",
53
- "read_note",
54
- "write_note",
55
- "edit_note",
56
- "build_context",
57
- "recent_activity",
58
- "list_memory_projects",
59
- "list_workspaces",
60
- "create_memory_project",
61
- "delete_note",
62
- "move_note",
63
- "schema_validate",
64
- "schema_infer",
65
- "schema_diff",
57
+ "bm_search",
58
+ "bm_read",
59
+ "bm_write",
60
+ "bm_edit",
61
+ "bm_context",
62
+ "bm_project",
63
+ "bm_delete",
64
+ "bm_move",
65
+ "bm_schema",
66
66
  ]
67
67
 
68
68
  export interface SearchResult {
@@ -71,6 +71,17 @@ export interface SearchResult {
71
71
  content: string
72
72
  score?: number
73
73
  file_path: string
74
+ // The chunk of the note that matched, when the server ranked by chunk.
75
+ matched_chunk?: string | null
76
+ }
77
+
78
+ /**
79
+ * The text to preview for a search hit. `content` is the start of the note,
80
+ * so for a long note (a day of captured conversation) the matching part can
81
+ * sit far below any preview cut-off; the matched chunk is the part that hit.
82
+ */
83
+ export function searchResultText(result: SearchResult): string {
84
+ return result.matched_chunk?.trim() || result.content || ""
74
85
  }
75
86
 
76
87
  export interface NoteResult {
@@ -87,7 +98,7 @@ export interface EditNoteResult {
87
98
  title: string
88
99
  permalink: string
89
100
  file_path: string
90
- operation: "append" | "prepend" | "find_replace" | "replace_section"
101
+ operation: EditOperation
91
102
  checksum?: string | null
92
103
  }
93
104
 
@@ -107,6 +118,16 @@ interface WriteNoteOptions {
107
118
  expectedChecksum?: string
108
119
  }
109
120
 
121
+ // bm_edit commands the plugin exposes. set_metadata is left out: edit_note
122
+ // already merges `metadata` alongside any other operation.
123
+ export type EditOperation =
124
+ | "append"
125
+ | "prepend"
126
+ | "find_replace"
127
+ | "replace_section"
128
+ | "insert_before_section"
129
+ | "insert_after_section"
130
+
110
131
  interface EditNoteOptions {
111
132
  find_text?: string
112
133
  section?: string
@@ -127,7 +148,10 @@ export interface ContextResult {
127
148
  permalink: string
128
149
  relation_type?: string
129
150
  from_entity?: string
130
- to_entity?: string
151
+ // Null for a forward reference whose target note does not exist yet;
152
+ // to_name then carries the link text.
153
+ to_entity?: string | null
154
+ to_name?: string | null
131
155
  }>
132
156
  }>
133
157
  }
@@ -158,10 +182,7 @@ export interface ProjectListResult {
158
182
  export interface WorkspaceResult {
159
183
  tenant_id: string
160
184
  name: string
161
- workspace_type: string
162
- role: string
163
- organization_id?: string | null
164
- has_active_subscription: boolean
185
+ workspace_type: string | null
165
186
  }
166
187
 
167
188
  export interface SchemaValidationResult {
@@ -249,23 +270,32 @@ function isRecoverableConnectionError(err: unknown): boolean {
249
270
  )
250
271
  }
251
272
 
252
- function isNoteNotFoundError(err: unknown): boolean {
253
- const msg = getErrorMessage(err).toLowerCase()
273
+ export function isNoteNotFoundError(err: unknown): boolean {
254
274
  return (
255
- msg.includes("entity not found") ||
256
- msg.includes("note not found") ||
257
- msg.includes("resource not found") ||
258
- msg.includes("could not find note matching") ||
259
- msg.includes("404")
275
+ err instanceof BmToolError &&
276
+ parseRefusalPayload(err.toolMessage)?.error === "NOT_FOUND"
260
277
  )
261
278
  }
262
279
 
280
+ export type ConversationCaptureStatus = "committed" | "duplicate"
281
+
282
+ function conversationDate(date: Date): string {
283
+ return date.toISOString().split("T")[0]
284
+ }
285
+
286
+ // Host advancement keys are opaque and may contain characters that would end
287
+ // an HTML comment, so the note stores a fixed-length digest instead.
288
+ function conversationTurnMarker(turnKey: string): string {
289
+ const digest = createHash("sha256").update(turnKey).digest("hex").slice(0, 32)
290
+ return `<!-- openclaw-turn:${digest} -->`
291
+ }
292
+
263
293
  function asString(value: unknown): string | null {
264
294
  return typeof value === "string" ? value : null
265
295
  }
266
296
 
267
297
  // 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
298
+ // refusal payload itself ({"error": "ALREADY_EXISTS", ...}). Any other
269
299
  // error text (plain-text failures, older servers) is not a refusal payload.
270
300
  function parseRefusalPayload(text: string): Record<string, unknown> | null {
271
301
  let parsed: unknown
@@ -277,6 +307,24 @@ function parseRefusalPayload(text: string): Record<string, unknown> | null {
277
307
  return isRecord(parsed) && typeof parsed.error === "string" ? parsed : null
278
308
  }
279
309
 
310
+ /**
311
+ * The server's own explanation of a failed tool call, for showing to an agent.
312
+ * A refusal payload becomes "CODE: message"; other tool text passes through.
313
+ */
314
+ export function describeToolError(err: unknown): string {
315
+ if (!(err instanceof BmToolError)) return getErrorMessage(err)
316
+ const payload = parseRefusalPayload(err.toolMessage)
317
+ if (!payload) return err.toolMessage || err.message
318
+ const message = asString(payload.message)
319
+ return message ? `${payload.error}: ${message}` : String(payload.error)
320
+ }
321
+
322
+ /** describeToolError, but saying plainly when the note does not exist. */
323
+ export function describeNoteError(err: unknown): string {
324
+ const reason = describeToolError(err)
325
+ return isNoteNotFoundError(err) ? `it does not exist. ${reason}` : reason
326
+ }
327
+
280
328
  function projectMatchesWorkspace(
281
329
  project: ProjectListResult,
282
330
  workspace: string,
@@ -299,34 +347,103 @@ export class BmClient {
299
347
  private cwd?: string
300
348
  private env?: Record<string, string>
301
349
  private shouldRun = false
350
+ // True only between start() and stop(), i.e. while the plugin service owns
351
+ // the BM MCP session. A lazy connection opened by a tool or memory call in a
352
+ // one-shot CLI process leaves this false.
353
+ private serviceRunning = false
354
+ // Connect plus project creation, started by start(). Tool calls wait for
355
+ // it so none can reach a project that does not exist yet. Null when the
356
+ // client was never started (tests, one-shot CLI readers).
357
+ private startup: Promise<void> | null = null
358
+ private startupRetry: Promise<void> | null = null
359
+ private projectPath?: string
360
+ private prepare?: () => Promise<void>
302
361
 
303
362
  private client: Client | null = null
304
363
  private transport: StdioClientTransport | null = null
305
364
  private connectPromise: Promise<void> | null = null
306
365
  private retryDelaysMs = [...DEFAULT_RETRY_DELAYS_MS]
366
+ // Conversation captures run one at a time so a turn-key duplicate check
367
+ // and the append that follows it cannot interleave with another capture.
368
+ private conversationWrites: Promise<unknown> = Promise.resolve()
307
369
 
308
370
  constructor(bmPath: string, project: string) {
309
371
  this.bmPath = bmPath
310
372
  this.project = project
311
373
  }
312
374
 
313
- async start(options?: {
375
+ /**
376
+ * Connect to BM and create the plugin's project.
377
+ *
378
+ * Returns the startup promise; callers that must not block (the plugin
379
+ * service, which OpenClaw gives only a few seconds) can leave it running.
380
+ * Tool calls made meanwhile wait for it. If it fails, the next tool call
381
+ * runs it again, so a transient failure does not leave the plugin dead.
382
+ */
383
+ start(options?: {
314
384
  cwd?: string
315
385
  env?: Record<string, string>
386
+ projectPath?: string
387
+ // Runs before each connect attempt, e.g. installing the bm CLI.
388
+ prepare?: () => Promise<void>
316
389
  }): Promise<void> {
317
390
  this.shouldRun = true
391
+ this.serviceRunning = true
318
392
  if (options?.cwd) {
319
393
  this.cwd = options.cwd
320
394
  }
321
395
  if (options?.env) {
322
396
  this.env = options.env
323
397
  }
398
+ if (options?.projectPath) {
399
+ this.projectPath = options.projectPath
400
+ }
401
+ if (options?.prepare) {
402
+ this.prepare = options.prepare
403
+ }
404
+
405
+ this.startup = this.runStartup()
406
+ return this.startup
407
+ }
324
408
 
409
+ private async runStartup(): Promise<void> {
410
+ await this.prepare?.()
325
411
  await this.connectWithRetries()
412
+ if (this.projectPath) await this.createProject(this.projectPath)
413
+ }
414
+
415
+ private async waitForStartup(): Promise<void> {
416
+ if (!this.startup) return
417
+ try {
418
+ await this.startup
419
+ } catch (err) {
420
+ // Trigger: the startup this call was waiting on failed.
421
+ // Why: the service no longer fails on a slow or failed start, so the
422
+ // first tool call afterwards is the place to recover.
423
+ // Outcome: one fresh attempt, shared by concurrent callers; its error
424
+ // reaches the tool if it fails too.
425
+ if (!this.serviceRunning) throw err
426
+ if (this.startupRetry === null) {
427
+ this.startupRetry = this.runStartup().finally(() => {
428
+ this.startupRetry = null
429
+ })
430
+ this.startup = this.startupRetry
431
+ }
432
+ await this.startupRetry
433
+ }
434
+ }
435
+
436
+ isServiceRunning(): boolean {
437
+ return this.serviceRunning
326
438
  }
327
439
 
328
440
  async stop(): Promise<void> {
329
441
  this.shouldRun = false
442
+ this.serviceRunning = false
443
+ // Let an in-flight startup settle first, so it cannot reconnect after
444
+ // the disconnect below.
445
+ await this.startup?.catch(() => undefined)
446
+ this.startup = null
330
447
  await this.disconnectCurrent(this.client, this.transport)
331
448
  this.client = null
332
449
  this.transport = null
@@ -527,6 +644,15 @@ export class BmClient {
527
644
  private async callTool(
528
645
  name: string,
529
646
  args: Record<string, unknown>,
647
+ ): Promise<unknown> {
648
+ await this.waitForStartup()
649
+ return this.callStructured(name, args)
650
+ }
651
+
652
+ // Ungated: startup itself calls this to create the project.
653
+ private async callStructured(
654
+ name: string,
655
+ args: Record<string, unknown>,
530
656
  ): Promise<unknown> {
531
657
  const result = await this.callToolRaw(name, args)
532
658
 
@@ -546,44 +672,49 @@ export class BmClient {
546
672
  return project ?? this.project
547
673
  }
548
674
 
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 }
675
+ // The 1.0 project parameter accepts either a name or an exact project UUID.
676
+ private routing(project?: string, projectId?: string): { project: string } {
677
+ if (projectId !== undefined) return { project: projectId }
557
678
  return { project: this.routedProject(project) }
558
679
  }
559
680
 
560
681
  async ensureProject(projectPath: string): Promise<void> {
561
- const payload = await this.callTool("create_memory_project", {
562
- project_name: this.project,
563
- project_path: projectPath,
682
+ await this.waitForStartup()
683
+ await this.createProject(projectPath)
684
+ }
685
+
686
+ private async createProject(projectPath: string): Promise<void> {
687
+ const payload = await this.callStructured("bm_project", {
688
+ command: "create",
689
+ name: this.project,
690
+ path: projectPath,
564
691
  set_default: true,
565
692
  output_format: "json",
566
693
  })
567
694
 
568
695
  if (!isRecord(payload)) {
569
- throw new Error("invalid create_memory_project response")
696
+ throw new Error("invalid bm_project response")
570
697
  }
571
698
  }
572
699
 
573
700
  async listWorkspaces(): Promise<WorkspaceResult[]> {
574
- const payload = await this.callTool("list_workspaces", {
575
- output_format: "json",
576
- })
577
-
578
- if (isRecord(payload) && Array.isArray(payload.workspaces)) {
579
- return payload.workspaces as WorkspaceResult[]
701
+ const projects = await this.listProjects()
702
+ const workspaces = new Map<string, WorkspaceResult>()
703
+ for (const project of projects) {
704
+ if (project.workspace_tenant_id && project.workspace_name) {
705
+ workspaces.set(project.workspace_tenant_id, {
706
+ tenant_id: project.workspace_tenant_id,
707
+ name: project.workspace_name,
708
+ workspace_type: project.workspace_type ?? null,
709
+ })
710
+ }
580
711
  }
581
-
582
- throw new Error("invalid list_workspaces response")
712
+ return [...workspaces.values()]
583
713
  }
584
714
 
585
715
  async listProjects(workspace?: string): Promise<ProjectListResult[]> {
586
- const payload = await this.callTool("list_memory_projects", {
716
+ const payload = await this.callTool("bm_project", {
717
+ command: "list",
587
718
  output_format: "json",
588
719
  })
589
720
 
@@ -597,7 +728,7 @@ export class BmClient {
597
728
  return projects
598
729
  }
599
730
 
600
- throw new Error("invalid list_memory_projects response")
731
+ throw new Error("invalid bm_project response")
601
732
  }
602
733
 
603
734
  async search(
@@ -619,16 +750,23 @@ export class BmClient {
619
750
  project: this.routedProject(project),
620
751
  }
621
752
  if (query) args.query = query
622
- if (metadata?.filters) args.metadata_filters = metadata.filters
623
- if (metadata?.tags) args.tags = metadata.tags
624
- if (metadata?.status) args.status = metadata.status
753
+ const filters = { ...metadata?.filters }
754
+ // Explicit predicates take precedence over convenience shortcuts.
755
+ if (metadata?.tags?.length && !("tags" in filters))
756
+ filters.tags = { $contains: metadata.tags }
757
+ if (metadata?.status && !("status" in filters))
758
+ filters.status = metadata.status
759
+ if (Object.keys(filters).length) args.filters = filters
625
760
  if (metadata?.note_types) args.note_types = metadata.note_types
626
- if (metadata?.entity_types) args.entity_types = metadata.entity_types
761
+ if (metadata?.entity_types)
762
+ args.items = metadata.entity_types.map((item) =>
763
+ item === "entity" ? "note" : item,
764
+ )
627
765
 
628
- const payload = await this.callTool("search_notes", args)
766
+ const payload = await this.callTool("bm_search", args)
629
767
 
630
768
  if (!isRecord(payload) || !Array.isArray(payload.results)) {
631
- throw new Error("invalid search_notes response")
769
+ throw new Error("invalid bm_search response")
632
770
  }
633
771
 
634
772
  return payload.results as SearchResult[]
@@ -646,10 +784,10 @@ export class BmClient {
646
784
  ...this.routing(project, options.projectId),
647
785
  }
648
786
 
649
- const payload = await this.callTool("read_note", args)
787
+ const payload = await this.callTool("bm_read", args)
650
788
 
651
789
  if (!isRecord(payload)) {
652
- throw new Error("invalid read_note response")
790
+ throw new Error("invalid bm_read response")
653
791
  }
654
792
 
655
793
  const title = asString(payload.title)
@@ -658,7 +796,7 @@ export class BmClient {
658
796
  const filePath = asString(payload.file_path)
659
797
 
660
798
  if (!title || !permalink || content === null || !filePath) {
661
- throw new Error("invalid read_note payload")
799
+ throw new Error("invalid bm_read payload")
662
800
  }
663
801
 
664
802
  return {
@@ -695,36 +833,30 @@ export class BmClient {
695
833
 
696
834
  let payload: unknown
697
835
  try {
698
- payload = await this.callTool("write_note", args)
836
+ payload = await this.callTool("bm_write", args)
699
837
  } catch (err) {
700
838
  const refusal =
701
839
  err instanceof BmToolError ? parseRefusalPayload(err.toolMessage) : null
702
- if (refusal?.error === "NOTE_ALREADY_EXISTS") {
840
+ if (refusal?.error === "ALREADY_EXISTS") {
703
841
  throw new NoteAlreadyExistsError(
704
842
  asString(refusal.title) ?? title,
705
843
  asString(refusal.permalink) ?? "",
844
+ asString(refusal.message) ?? undefined,
706
845
  )
707
846
  }
708
- if (refusal?.error === "NOTE_REVISION_CONFLICT") {
847
+ if (refusal?.error === "REVISION_CONFLICT") {
709
848
  throw new NoteRevisionConflictError(
710
849
  asString(refusal.title) ?? title,
711
850
  asString(refusal.permalink),
712
851
  asString(refusal.currentChecksum),
852
+ asString(refusal.message) ?? undefined,
713
853
  )
714
854
  }
715
855
  throw err
716
856
  }
717
857
 
718
858
  if (!isRecord(payload)) {
719
- throw new Error("invalid write_note response")
720
- }
721
-
722
- // Older servers returned the refusal as a successful payload.
723
- if (payload.error === "NOTE_ALREADY_EXISTS") {
724
- throw new NoteAlreadyExistsError(
725
- asString(payload.title) ?? title,
726
- asString(payload.permalink) ?? "",
727
- )
859
+ throw new Error("invalid bm_write response")
728
860
  }
729
861
 
730
862
  const resultTitle = asString(payload.title)
@@ -732,7 +864,7 @@ export class BmClient {
732
864
  const filePath = asString(payload.file_path)
733
865
 
734
866
  if (!resultTitle || !permalink || !filePath) {
735
- throw new Error("invalid write_note payload")
867
+ throw new Error("invalid bm_write payload")
736
868
  }
737
869
 
738
870
  return {
@@ -754,16 +886,16 @@ export class BmClient {
754
886
  project?: string,
755
887
  ): Promise<ContextResult> {
756
888
  const args: Record<string, unknown> = {
757
- url,
889
+ identifier: url,
758
890
  depth,
759
891
  output_format: "json",
760
892
  project: this.routedProject(project),
761
893
  }
762
894
 
763
- const payload = await this.callTool("build_context", args)
895
+ const payload = await this.callTool("bm_context", args)
764
896
 
765
897
  if (!isRecord(payload) || !Array.isArray(payload.results)) {
766
- throw new Error("invalid build_context response")
898
+ throw new Error("invalid bm_context response")
767
899
  }
768
900
 
769
901
  return payload as unknown as ContextResult
@@ -774,30 +906,31 @@ export class BmClient {
774
906
  project?: string,
775
907
  ): Promise<RecentResult[]> {
776
908
  const args: Record<string, unknown> = {
777
- timeframe,
909
+ command: "recent",
910
+ since: timeframe,
778
911
  output_format: "json",
779
912
  project: this.routedProject(project),
780
913
  }
781
914
 
782
- const payload = await this.callTool("recent_activity", args)
915
+ const payload = await this.callTool("bm_project", args)
783
916
 
784
- if (Array.isArray(payload)) {
785
- return payload as RecentResult[]
917
+ if (isRecord(payload) && Array.isArray(payload.results)) {
918
+ return payload.results as RecentResult[]
786
919
  }
787
920
 
788
- throw new Error("invalid recent_activity response")
921
+ throw new Error("invalid bm_project response")
789
922
  }
790
923
 
791
924
  async editNote(
792
925
  identifier: string,
793
- operation: "append" | "prepend" | "find_replace" | "replace_section",
926
+ operation: EditOperation,
794
927
  content: string,
795
928
  options: EditNoteOptions = {},
796
929
  project?: string,
797
930
  ): Promise<EditNoteResult> {
798
931
  const args: Record<string, unknown> = {
799
932
  identifier,
800
- operation,
933
+ command: operation,
801
934
  content,
802
935
  output_format: "json",
803
936
  project: this.routedProject(project),
@@ -808,10 +941,10 @@ export class BmClient {
808
941
  args.expected_replacements = options.expected_replacements
809
942
  if (options.metadata !== undefined) args.metadata = options.metadata
810
943
 
811
- const payload = await this.callTool("edit_note", args)
944
+ const payload = await this.callTool("bm_edit", args)
812
945
 
813
946
  if (!isRecord(payload)) {
814
- throw new Error("invalid edit_note response")
947
+ throw new Error("invalid bm_edit response")
815
948
  }
816
949
 
817
950
  const title = asString(payload.title)
@@ -819,7 +952,7 @@ export class BmClient {
819
952
  const filePath = asString(payload.file_path)
820
953
 
821
954
  if (!title || !permalink || !filePath) {
822
- throw new Error("invalid edit_note payload")
955
+ throw new Error("invalid bm_edit payload")
823
956
  }
824
957
 
825
958
  return {
@@ -841,14 +974,14 @@ export class BmClient {
841
974
  project: this.routedProject(project),
842
975
  }
843
976
 
844
- const payload = await this.callTool("delete_note", args)
977
+ const payload = await this.callTool("bm_delete", args)
845
978
 
846
979
  if (!isRecord(payload)) {
847
- throw new Error("invalid delete_note response")
980
+ throw new Error("invalid bm_delete response")
848
981
  }
849
982
 
850
- if (payload.deleted !== true) {
851
- throw new Error(`delete_note did not delete "${identifier}"`)
983
+ if (payload.action !== "deleted") {
984
+ throw new Error(`bm_delete did not delete "${identifier}"`)
852
985
  }
853
986
 
854
987
  return {
@@ -865,21 +998,21 @@ export class BmClient {
865
998
  ): Promise<NoteResult> {
866
999
  const args: Record<string, unknown> = {
867
1000
  identifier,
868
- destination_folder: newFolder,
1001
+ destination: `${newFolder.replace(/\/+$/, "")}/`,
869
1002
  output_format: "json",
870
1003
  project: this.routedProject(project),
871
1004
  }
872
1005
 
873
- const payload = await this.callTool("move_note", args)
1006
+ const payload = await this.callTool("bm_move", args)
874
1007
 
875
1008
  if (!isRecord(payload)) {
876
- throw new Error("invalid move_note response")
1009
+ throw new Error("invalid bm_move response")
877
1010
  }
878
1011
 
879
- if (payload.moved !== true) {
1012
+ if (payload.action !== "moved") {
880
1013
  throw new Error(
881
1014
  asString(payload.error) ??
882
- `move_note did not move "${identifier}" to "${newFolder}"`,
1015
+ `bm_move did not move "${identifier}" to "${newFolder}"`,
883
1016
  )
884
1017
  }
885
1018
 
@@ -897,16 +1030,17 @@ export class BmClient {
897
1030
  project?: string,
898
1031
  ): Promise<SchemaValidationResult> {
899
1032
  const args: Record<string, unknown> = {
1033
+ command: "validate",
900
1034
  output_format: "json",
901
1035
  project: this.routedProject(project),
902
1036
  }
903
1037
  if (noteType) args.note_type = noteType
904
1038
  if (identifier) args.identifier = identifier
905
1039
 
906
- const payload = await this.callTool("schema_validate", args)
1040
+ const payload = await this.callTool("bm_schema", args)
907
1041
 
908
1042
  if (!isRecord(payload)) {
909
- throw new Error("invalid schema_validate response")
1043
+ throw new Error("invalid bm_schema response")
910
1044
  }
911
1045
 
912
1046
  return payload as unknown as SchemaValidationResult
@@ -918,16 +1052,17 @@ export class BmClient {
918
1052
  project?: string,
919
1053
  ): Promise<SchemaInferResult> {
920
1054
  const args: Record<string, unknown> = {
1055
+ command: "infer",
921
1056
  note_type: noteType,
922
1057
  threshold,
923
1058
  output_format: "json",
924
1059
  project: this.routedProject(project),
925
1060
  }
926
1061
 
927
- const payload = await this.callTool("schema_infer", args)
1062
+ const payload = await this.callTool("bm_schema", args)
928
1063
 
929
1064
  if (!isRecord(payload)) {
930
- throw new Error("invalid schema_infer response")
1065
+ throw new Error("invalid bm_schema response")
931
1066
  }
932
1067
 
933
1068
  return payload as unknown as SchemaInferResult
@@ -938,31 +1073,76 @@ export class BmClient {
938
1073
  project?: string,
939
1074
  ): Promise<SchemaDiffResult> {
940
1075
  const args: Record<string, unknown> = {
1076
+ command: "diff",
941
1077
  note_type: noteType,
942
1078
  output_format: "json",
943
1079
  project: this.routedProject(project),
944
1080
  }
945
1081
 
946
- const payload = await this.callTool("schema_diff", args)
1082
+ const payload = await this.callTool("bm_schema", args)
947
1083
 
948
1084
  if (!isRecord(payload)) {
949
- throw new Error("invalid schema_diff response")
1085
+ throw new Error("invalid bm_schema response")
950
1086
  }
951
1087
 
952
1088
  return payload as unknown as SchemaDiffResult
953
1089
  }
954
1090
 
1091
+ /**
1092
+ * Append one user/assistant exchange to today's conversation note.
1093
+ *
1094
+ * With a turnKey the capture is idempotent: the entry carries a marker
1095
+ * derived from the key, and a key already present in today's or
1096
+ * yesterday's note is reported as a duplicate instead of written again.
1097
+ * Yesterday is checked because a host retry can cross midnight.
1098
+ */
955
1099
  async indexConversation(
956
1100
  userMessage: string,
957
1101
  assistantResponse: string,
958
- ): Promise<void> {
1102
+ options: { turnKey?: string } = {},
1103
+ ): Promise<ConversationCaptureStatus> {
1104
+ const write = this.conversationWrites.then(() =>
1105
+ this.writeConversationEntry(
1106
+ userMessage,
1107
+ assistantResponse,
1108
+ options.turnKey,
1109
+ ),
1110
+ )
1111
+ this.conversationWrites = write.catch(() => undefined)
1112
+ return write
1113
+ }
1114
+
1115
+ private async writeConversationEntry(
1116
+ userMessage: string,
1117
+ assistantResponse: string,
1118
+ turnKey: string | undefined,
1119
+ ): Promise<ConversationCaptureStatus> {
959
1120
  const now = new Date()
960
- const dateStr = now.toISOString().split("T")[0]
1121
+ const dateStr = conversationDate(now)
961
1122
  const timeStr = now.toTimeString().slice(0, 5)
962
1123
  const title = `conversations-${dateStr}`
1124
+ const marker = turnKey ? conversationTurnMarker(turnKey) : null
1125
+
1126
+ // Trigger: the host supplied an advancement key for this turn.
1127
+ // Why: hosts retry commits after a crash, and a second append would
1128
+ // duplicate the exchange in the note.
1129
+ // Outcome: an already-recorded key short-circuits before any write.
1130
+ if (marker) {
1131
+ const yesterday = new Date(now.getTime() - 24 * 60 * 60 * 1000)
1132
+ for (const candidate of [
1133
+ title,
1134
+ `conversations-${conversationDate(yesterday)}`,
1135
+ ]) {
1136
+ if (await this.noteContains(candidate, marker)) {
1137
+ log.debug(`conversation turn already recorded in: ${candidate}`)
1138
+ return "duplicate"
1139
+ }
1140
+ }
1141
+ }
963
1142
 
964
1143
  const entry = [
965
1144
  `### ${timeStr}`,
1145
+ ...(marker ? [marker] : []),
966
1146
  "",
967
1147
  "**User:**",
968
1148
  userMessage,
@@ -976,7 +1156,7 @@ export class BmClient {
976
1156
  try {
977
1157
  await this.editNote(title, "append", entry)
978
1158
  log.debug(`appended conversation to: ${title}`)
979
- return
1159
+ return "committed"
980
1160
  } catch (err) {
981
1161
  if (!isNoteNotFoundError(err)) {
982
1162
  log.error(`conversation append failed: ${getErrorMessage(err)}`, err)
@@ -1001,11 +1181,21 @@ export class BmClient {
1001
1181
  entry,
1002
1182
  ].join("\n")
1003
1183
 
1184
+ await this.writeNote(title, content, "conversations")
1185
+ log.debug(`created conversation note: ${title}`)
1186
+ return "committed"
1187
+ }
1188
+
1189
+ private async noteContains(
1190
+ identifier: string,
1191
+ text: string,
1192
+ ): Promise<boolean> {
1004
1193
  try {
1005
- await this.writeNote(title, content, "conversations")
1006
- log.debug(`created conversation note: ${title}`)
1194
+ const note = await this.readNote(identifier)
1195
+ return note.content.includes(text)
1007
1196
  } catch (err) {
1008
- log.error("conversation index failed", err)
1197
+ if (isNoteNotFoundError(err)) return false
1198
+ throw err
1009
1199
  }
1010
1200
  }
1011
1201