@basicmemory/openclaw-basic-memory 0.1.0-alpha.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/bm-client.ts ADDED
@@ -0,0 +1,879 @@
1
+ import { setTimeout as delay } from "node:timers/promises"
2
+ import { Client } from "@modelcontextprotocol/sdk/client"
3
+ import { StdioClientTransport } from "@modelcontextprotocol/sdk/client/stdio.js"
4
+ import { log } from "./logger.ts"
5
+
6
+ const DEFAULT_RETRY_DELAYS_MS = [500, 1000, 2000]
7
+
8
+ const REQUIRED_TOOLS = [
9
+ "search_notes",
10
+ "search_by_metadata",
11
+ "read_note",
12
+ "write_note",
13
+ "edit_note",
14
+ "build_context",
15
+ "recent_activity",
16
+ "list_memory_projects",
17
+ "list_workspaces",
18
+ "create_memory_project",
19
+ "delete_note",
20
+ "move_note",
21
+ "schema_validate",
22
+ "schema_infer",
23
+ "schema_diff",
24
+ ]
25
+
26
+ export interface SearchResult {
27
+ title: string
28
+ permalink: string
29
+ content: string
30
+ score?: number
31
+ file_path: string
32
+ }
33
+
34
+ export interface NoteResult {
35
+ title: string
36
+ permalink: string
37
+ content: string
38
+ file_path: string
39
+ frontmatter?: Record<string, unknown> | null
40
+ checksum?: string | null
41
+ action?: "created" | "updated"
42
+ }
43
+
44
+ export interface EditNoteResult {
45
+ title: string
46
+ permalink: string
47
+ file_path: string
48
+ operation: "append" | "prepend" | "find_replace" | "replace_section"
49
+ checksum?: string | null
50
+ }
51
+
52
+ interface ReadNoteOptions {
53
+ includeFrontmatter?: boolean
54
+ }
55
+
56
+ interface EditNoteOptions {
57
+ find_text?: string
58
+ section?: string
59
+ expected_replacements?: number
60
+ }
61
+
62
+ export interface ContextResult {
63
+ results: Array<{
64
+ primary_result: NoteResult
65
+ observations: Array<{
66
+ category: string
67
+ content: string
68
+ }>
69
+ related_results: Array<{
70
+ type: "relation" | "entity"
71
+ title?: string
72
+ permalink: string
73
+ relation_type?: string
74
+ from_entity?: string
75
+ to_entity?: string
76
+ }>
77
+ }>
78
+ }
79
+
80
+ export interface RecentResult {
81
+ title: string
82
+ permalink: string
83
+ file_path: string
84
+ created_at: string
85
+ }
86
+
87
+ export interface ProjectListResult {
88
+ name: string
89
+ path: string
90
+ display_name?: string | null
91
+ is_private?: boolean
92
+ is_default?: boolean
93
+ isDefault?: boolean
94
+ workspace_name?: string | null
95
+ workspace_type?: string | null
96
+ workspace_tenant_id?: string | null
97
+ }
98
+
99
+ export interface WorkspaceResult {
100
+ tenant_id: string
101
+ name: string
102
+ workspace_type: string
103
+ role: string
104
+ organization_id?: string | null
105
+ has_active_subscription: boolean
106
+ }
107
+
108
+ export interface SchemaValidationResult {
109
+ entity_type: string | null
110
+ total_notes: number
111
+ total_entities: number
112
+ valid_count: number
113
+ warning_count: number
114
+ error_count: number
115
+ results: Array<{
116
+ identifier: string
117
+ valid: boolean
118
+ warnings: string[]
119
+ errors: string[]
120
+ }>
121
+ }
122
+
123
+ export interface SchemaInferResult {
124
+ entity_type: string
125
+ notes_analyzed: number
126
+ field_frequencies: Array<{
127
+ name: string
128
+ percentage: number
129
+ count: number
130
+ total: number
131
+ source: string
132
+ sample_values?: string[]
133
+ is_array?: boolean
134
+ target_type?: string | null
135
+ }>
136
+ suggested_schema: Record<string, unknown>
137
+ suggested_required: string[]
138
+ suggested_optional: string[]
139
+ excluded: string[]
140
+ }
141
+
142
+ export interface SchemaDiffResult {
143
+ entity_type: string
144
+ schema_found: boolean
145
+ new_fields: Array<{
146
+ name: string
147
+ source: string
148
+ count: number
149
+ total: number
150
+ percentage: number
151
+ }>
152
+ dropped_fields: Array<{ name: string; source: string; declared_in?: string }>
153
+ cardinality_changes: string[]
154
+ }
155
+
156
+ export interface MetadataSearchResult {
157
+ results: SearchResult[]
158
+ current_page: number
159
+ page_size: number
160
+ }
161
+
162
+ function getErrorMessage(err: unknown): string {
163
+ return err instanceof Error ? err.message : String(err)
164
+ }
165
+
166
+ function isRecord(value: unknown): value is Record<string, unknown> {
167
+ return value !== null && typeof value === "object" && !Array.isArray(value)
168
+ }
169
+
170
+ function extractTextFromContent(content: unknown): string {
171
+ if (!Array.isArray(content)) return ""
172
+
173
+ const textBlocks = content
174
+ .filter(
175
+ (block): block is { type: "text"; text: string } =>
176
+ isRecord(block) &&
177
+ block.type === "text" &&
178
+ typeof block.text === "string",
179
+ )
180
+ .map((block) => block.text)
181
+
182
+ return textBlocks.join("\n").trim()
183
+ }
184
+
185
+ function isRecoverableConnectionError(err: unknown): boolean {
186
+ const msg = getErrorMessage(err).toLowerCase()
187
+ return (
188
+ msg.includes("connection closed") ||
189
+ msg.includes("not connected") ||
190
+ msg.includes("transport") ||
191
+ msg.includes("broken pipe") ||
192
+ msg.includes("econnreset") ||
193
+ msg.includes("epipe") ||
194
+ msg.includes("failed to start bm mcp stdio") ||
195
+ msg.includes("client is closed")
196
+ )
197
+ }
198
+
199
+ function isNoteNotFoundError(err: unknown): boolean {
200
+ const msg = getErrorMessage(err).toLowerCase()
201
+ return (
202
+ msg.includes("entity not found") ||
203
+ msg.includes("note not found") ||
204
+ msg.includes("resource not found") ||
205
+ msg.includes("could not find note matching") ||
206
+ msg.includes("404")
207
+ )
208
+ }
209
+
210
+ function asString(value: unknown): string | null {
211
+ return typeof value === "string" ? value : null
212
+ }
213
+
214
+ export class BmClient {
215
+ private bmPath: string
216
+ private project: string
217
+ private cwd?: string
218
+ private env?: Record<string, string>
219
+ private shouldRun = false
220
+
221
+ private client: Client | null = null
222
+ private transport: StdioClientTransport | null = null
223
+ private connectPromise: Promise<void> | null = null
224
+ private retryDelaysMs = [...DEFAULT_RETRY_DELAYS_MS]
225
+
226
+ constructor(bmPath: string, project: string) {
227
+ this.bmPath = bmPath
228
+ this.project = project
229
+ }
230
+
231
+ async start(options?: {
232
+ cwd?: string
233
+ env?: Record<string, string>
234
+ }): Promise<void> {
235
+ this.shouldRun = true
236
+ if (options?.cwd) {
237
+ this.cwd = options.cwd
238
+ }
239
+ if (options?.env) {
240
+ this.env = options.env
241
+ }
242
+
243
+ await this.connectWithRetries()
244
+ }
245
+
246
+ async stop(): Promise<void> {
247
+ this.shouldRun = false
248
+ await this.disconnectCurrent(this.client, this.transport)
249
+ this.client = null
250
+ this.transport = null
251
+ }
252
+
253
+ private async connectWithRetries(): Promise<void> {
254
+ let lastErr: unknown
255
+
256
+ for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt++) {
257
+ try {
258
+ await this.ensureConnected()
259
+ return
260
+ } catch (err) {
261
+ lastErr = err
262
+ await this.disconnectCurrent(this.client, this.transport)
263
+ this.client = null
264
+ this.transport = null
265
+
266
+ if (attempt === this.retryDelaysMs.length) {
267
+ break
268
+ }
269
+
270
+ const waitMs = this.retryDelaysMs[attempt]
271
+ log.warn(
272
+ `BM MCP connect failed (attempt ${attempt + 1}/${this.retryDelaysMs.length + 1}): ${getErrorMessage(err)}; retrying in ${waitMs}ms`,
273
+ )
274
+ await delay(waitMs)
275
+ }
276
+ }
277
+
278
+ throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`)
279
+ }
280
+
281
+ private async ensureConnected(): Promise<Client> {
282
+ if (!this.shouldRun) {
283
+ this.shouldRun = true
284
+ }
285
+
286
+ if (this.client && this.transport) {
287
+ return this.client
288
+ }
289
+
290
+ if (!this.connectPromise) {
291
+ this.connectPromise = this.connectFresh()
292
+ }
293
+
294
+ try {
295
+ await this.connectPromise
296
+ } finally {
297
+ this.connectPromise = null
298
+ }
299
+
300
+ if (!this.client) {
301
+ throw new Error("BM MCP client was not initialized")
302
+ }
303
+
304
+ return this.client
305
+ }
306
+
307
+ private async connectFresh(): Promise<void> {
308
+ const transport = new StdioClientTransport({
309
+ command: this.bmPath,
310
+ args: ["mcp", "--transport", "stdio"],
311
+ cwd: this.cwd,
312
+ env: this.env,
313
+ stderr: "pipe",
314
+ })
315
+
316
+ const client = new Client(
317
+ {
318
+ name: "openclaw-basic-memory",
319
+ version: "0.1.0",
320
+ },
321
+ { capabilities: {} },
322
+ )
323
+
324
+ const stderr = transport.stderr
325
+ if (stderr) {
326
+ stderr.on("data", (data: Buffer) => {
327
+ const msg = data.toString().trim()
328
+ if (msg.length > 0) {
329
+ log.debug(`[bm mcp] ${msg}`)
330
+ }
331
+ })
332
+ }
333
+
334
+ transport.onclose = () => {
335
+ if (this.transport !== transport) return
336
+ log.warn("BM MCP stdio session closed")
337
+ this.client = null
338
+ this.transport = null
339
+ }
340
+
341
+ transport.onerror = (err: unknown) => {
342
+ if (this.transport !== transport) return
343
+ log.warn(`BM MCP transport error: ${getErrorMessage(err)}`)
344
+ }
345
+
346
+ this.client = client
347
+ this.transport = transport
348
+
349
+ try {
350
+ await client.connect(transport)
351
+ const tools = await client.listTools()
352
+ this.assertRequiredTools(tools.tools.map((tool) => tool.name))
353
+
354
+ log.info(
355
+ `connected to BM MCP stdio (project=${this.project}, pid=${transport.pid ?? "unknown"})`,
356
+ )
357
+ } catch (err) {
358
+ await this.disconnectCurrent(client, transport)
359
+ if (this.client === client) {
360
+ this.client = null
361
+ }
362
+ if (this.transport === transport) {
363
+ this.transport = null
364
+ }
365
+
366
+ throw new Error(`failed to start BM MCP stdio: ${getErrorMessage(err)}`)
367
+ }
368
+ }
369
+
370
+ private assertRequiredTools(toolNames: string[]): void {
371
+ const available = new Set(toolNames)
372
+ const missing = REQUIRED_TOOLS.filter((name) => !available.has(name))
373
+ if (missing.length > 0) {
374
+ throw new Error(
375
+ `BM MCP server missing required tools: ${missing.join(", ")}`,
376
+ )
377
+ }
378
+ }
379
+
380
+ private async disconnectCurrent(
381
+ client: Client | null,
382
+ transport: StdioClientTransport | null,
383
+ ): Promise<void> {
384
+ if (client) {
385
+ try {
386
+ await client.close()
387
+ } catch {
388
+ // ignore shutdown errors
389
+ }
390
+ }
391
+
392
+ if (transport) {
393
+ try {
394
+ await transport.close()
395
+ } catch {
396
+ // ignore shutdown errors
397
+ }
398
+ }
399
+ }
400
+
401
+ private async callToolRaw(
402
+ name: string,
403
+ args: Record<string, unknown>,
404
+ ): Promise<unknown> {
405
+ let lastErr: unknown
406
+
407
+ for (let attempt = 0; attempt <= this.retryDelaysMs.length; attempt++) {
408
+ try {
409
+ const client = await this.ensureConnected()
410
+ const result = await client.callTool({
411
+ name,
412
+ arguments: args,
413
+ })
414
+
415
+ if (isRecord(result) && result.isError === true) {
416
+ const message = extractTextFromContent(result.content)
417
+ throw new Error(
418
+ `BM MCP tool ${name} failed${message ? `: ${message}` : ""}`,
419
+ )
420
+ }
421
+
422
+ return result
423
+ } catch (err) {
424
+ if (!isRecoverableConnectionError(err)) {
425
+ throw err
426
+ }
427
+
428
+ lastErr = err
429
+ await this.disconnectCurrent(this.client, this.transport)
430
+ this.client = null
431
+ this.transport = null
432
+
433
+ if (attempt === this.retryDelaysMs.length) {
434
+ break
435
+ }
436
+
437
+ const waitMs = this.retryDelaysMs[attempt]
438
+ log.warn(
439
+ `BM MCP call ${name} failed (attempt ${attempt + 1}/${this.retryDelaysMs.length + 1}): ${getErrorMessage(err)}; retrying in ${waitMs}ms`,
440
+ )
441
+ await delay(waitMs)
442
+ }
443
+ }
444
+
445
+ throw new Error(`BM MCP unavailable: ${getErrorMessage(lastErr)}`)
446
+ }
447
+
448
+ private async callTool(
449
+ name: string,
450
+ args: Record<string, unknown>,
451
+ ): Promise<unknown> {
452
+ const result = await this.callToolRaw(name, args)
453
+
454
+ if (!isRecord(result) || result.structuredContent === undefined) {
455
+ throw new Error(`BM MCP tool ${name} returned no structured payload`)
456
+ }
457
+
458
+ const structuredPayload = result.structuredContent
459
+ if (isRecord(structuredPayload) && structuredPayload.result !== undefined) {
460
+ return structuredPayload.result
461
+ }
462
+
463
+ return structuredPayload
464
+ }
465
+
466
+ async ensureProject(projectPath: string): Promise<void> {
467
+ const payload = await this.callTool("create_memory_project", {
468
+ project_name: this.project,
469
+ project_path: projectPath,
470
+ set_default: true,
471
+ output_format: "json",
472
+ })
473
+
474
+ if (!isRecord(payload)) {
475
+ throw new Error("invalid create_memory_project response")
476
+ }
477
+ }
478
+
479
+ async listWorkspaces(): Promise<WorkspaceResult[]> {
480
+ const payload = await this.callTool("list_workspaces", {
481
+ output_format: "json",
482
+ })
483
+
484
+ if (isRecord(payload) && Array.isArray(payload.workspaces)) {
485
+ return payload.workspaces as WorkspaceResult[]
486
+ }
487
+
488
+ throw new Error("invalid list_workspaces response")
489
+ }
490
+
491
+ async listProjects(workspace?: string): Promise<ProjectListResult[]> {
492
+ const args: Record<string, unknown> = { output_format: "json" }
493
+ if (workspace) args.workspace = workspace
494
+
495
+ const payload = await this.callTool("list_memory_projects", args)
496
+
497
+ if (isRecord(payload) && Array.isArray(payload.projects)) {
498
+ return payload.projects as ProjectListResult[]
499
+ }
500
+
501
+ throw new Error("invalid list_memory_projects response")
502
+ }
503
+
504
+ async search(
505
+ query: string,
506
+ limit = 10,
507
+ project?: string,
508
+ metadata?: {
509
+ filters?: Record<string, unknown>
510
+ tags?: string[]
511
+ status?: string
512
+ },
513
+ ): Promise<SearchResult[]> {
514
+ const args: Record<string, unknown> = {
515
+ query,
516
+ page: 1,
517
+ page_size: limit,
518
+ output_format: "json",
519
+ }
520
+ if (project) args.project = project
521
+ if (metadata?.filters) args.metadata_filters = metadata.filters
522
+ if (metadata?.tags) args.tags = metadata.tags
523
+ if (metadata?.status) args.status = metadata.status
524
+
525
+ const payload = await this.callTool("search_notes", args)
526
+
527
+ if (!isRecord(payload) || !Array.isArray(payload.results)) {
528
+ throw new Error("invalid search_notes response")
529
+ }
530
+
531
+ return payload.results as SearchResult[]
532
+ }
533
+
534
+ async readNote(
535
+ identifier: string,
536
+ options: ReadNoteOptions = {},
537
+ project?: string,
538
+ ): Promise<NoteResult> {
539
+ const args: Record<string, unknown> = {
540
+ identifier,
541
+ include_frontmatter: options.includeFrontmatter === true,
542
+ output_format: "json",
543
+ }
544
+ if (project) args.project = project
545
+
546
+ const payload = await this.callTool("read_note", args)
547
+
548
+ if (!isRecord(payload)) {
549
+ throw new Error("invalid read_note response")
550
+ }
551
+
552
+ const title = asString(payload.title)
553
+ const permalink = asString(payload.permalink)
554
+ const content = asString(payload.content)
555
+ const filePath = asString(payload.file_path)
556
+
557
+ if (!title || !permalink || content === null || !filePath) {
558
+ throw new Error("invalid read_note payload")
559
+ }
560
+
561
+ return {
562
+ title,
563
+ permalink,
564
+ content,
565
+ file_path: filePath,
566
+ frontmatter: isRecord(payload.frontmatter) ? payload.frontmatter : null,
567
+ }
568
+ }
569
+
570
+ async writeNote(
571
+ title: string,
572
+ content: string,
573
+ folder: string,
574
+ project?: string,
575
+ ): Promise<NoteResult> {
576
+ const args: Record<string, unknown> = {
577
+ title,
578
+ content,
579
+ directory: folder,
580
+ output_format: "json",
581
+ }
582
+ if (project) args.project = project
583
+
584
+ const payload = await this.callTool("write_note", args)
585
+
586
+ if (!isRecord(payload)) {
587
+ throw new Error("invalid write_note response")
588
+ }
589
+
590
+ const resultTitle = asString(payload.title)
591
+ const permalink = asString(payload.permalink)
592
+ const filePath = asString(payload.file_path)
593
+
594
+ if (!resultTitle || !permalink || !filePath) {
595
+ throw new Error("invalid write_note payload")
596
+ }
597
+
598
+ return {
599
+ title: resultTitle,
600
+ permalink,
601
+ content,
602
+ file_path: filePath,
603
+ checksum: asString(payload.checksum),
604
+ action:
605
+ payload.action === "created" || payload.action === "updated"
606
+ ? payload.action
607
+ : undefined,
608
+ }
609
+ }
610
+
611
+ async buildContext(
612
+ url: string,
613
+ depth = 1,
614
+ project?: string,
615
+ ): Promise<ContextResult> {
616
+ const args: Record<string, unknown> = {
617
+ url,
618
+ depth,
619
+ output_format: "json",
620
+ }
621
+ if (project) args.project = project
622
+
623
+ const payload = await this.callTool("build_context", args)
624
+
625
+ if (!isRecord(payload) || !Array.isArray(payload.results)) {
626
+ throw new Error("invalid build_context response")
627
+ }
628
+
629
+ return payload as unknown as ContextResult
630
+ }
631
+
632
+ async recentActivity(
633
+ timeframe = "24h",
634
+ project?: string,
635
+ ): Promise<RecentResult[]> {
636
+ const args: Record<string, unknown> = {
637
+ timeframe,
638
+ output_format: "json",
639
+ }
640
+ if (project) args.project = project
641
+
642
+ const payload = await this.callTool("recent_activity", args)
643
+
644
+ if (Array.isArray(payload)) {
645
+ return payload as RecentResult[]
646
+ }
647
+
648
+ throw new Error("invalid recent_activity response")
649
+ }
650
+
651
+ async editNote(
652
+ identifier: string,
653
+ operation: "append" | "prepend" | "find_replace" | "replace_section",
654
+ content: string,
655
+ options: EditNoteOptions = {},
656
+ project?: string,
657
+ ): Promise<EditNoteResult> {
658
+ const args: Record<string, unknown> = {
659
+ identifier,
660
+ operation,
661
+ content,
662
+ find_text: options.find_text,
663
+ section: options.section,
664
+ expected_replacements: options.expected_replacements,
665
+ output_format: "json",
666
+ }
667
+ if (project) args.project = project
668
+
669
+ const payload = await this.callTool("edit_note", args)
670
+
671
+ if (!isRecord(payload)) {
672
+ throw new Error("invalid edit_note response")
673
+ }
674
+
675
+ const title = asString(payload.title)
676
+ const permalink = asString(payload.permalink)
677
+ const filePath = asString(payload.file_path)
678
+
679
+ if (!title || !permalink || !filePath) {
680
+ throw new Error("invalid edit_note payload")
681
+ }
682
+
683
+ return {
684
+ title,
685
+ permalink,
686
+ file_path: filePath,
687
+ operation,
688
+ checksum: asString(payload.checksum),
689
+ }
690
+ }
691
+
692
+ async deleteNote(
693
+ identifier: string,
694
+ project?: string,
695
+ ): Promise<{ title: string; permalink: string; file_path: string }> {
696
+ const args: Record<string, unknown> = {
697
+ identifier,
698
+ output_format: "json",
699
+ }
700
+ if (project) args.project = project
701
+
702
+ const payload = await this.callTool("delete_note", args)
703
+
704
+ if (!isRecord(payload)) {
705
+ throw new Error("invalid delete_note response")
706
+ }
707
+
708
+ if (payload.deleted !== true) {
709
+ throw new Error(`delete_note did not delete "${identifier}"`)
710
+ }
711
+
712
+ return {
713
+ title: asString(payload.title) ?? identifier,
714
+ permalink: asString(payload.permalink) ?? identifier,
715
+ file_path: asString(payload.file_path) ?? identifier,
716
+ }
717
+ }
718
+
719
+ async moveNote(
720
+ identifier: string,
721
+ newFolder: string,
722
+ project?: string,
723
+ ): Promise<NoteResult> {
724
+ const args: Record<string, unknown> = {
725
+ identifier,
726
+ destination_folder: newFolder,
727
+ output_format: "json",
728
+ }
729
+ if (project) args.project = project
730
+
731
+ const payload = await this.callTool("move_note", args)
732
+
733
+ if (!isRecord(payload)) {
734
+ throw new Error("invalid move_note response")
735
+ }
736
+
737
+ if (payload.moved !== true) {
738
+ throw new Error(
739
+ asString(payload.error) ??
740
+ `move_note did not move "${identifier}" to "${newFolder}"`,
741
+ )
742
+ }
743
+
744
+ return {
745
+ title: asString(payload.title) ?? identifier,
746
+ permalink: asString(payload.permalink) ?? identifier,
747
+ content: "",
748
+ file_path: asString(payload.file_path) ?? "",
749
+ }
750
+ }
751
+
752
+ async schemaValidate(
753
+ noteType?: string,
754
+ identifier?: string,
755
+ project?: string,
756
+ ): Promise<SchemaValidationResult> {
757
+ const args: Record<string, unknown> = { output_format: "json" }
758
+ if (noteType) args.note_type = noteType
759
+ if (identifier) args.identifier = identifier
760
+ if (project) args.project = project
761
+
762
+ const payload = await this.callTool("schema_validate", args)
763
+
764
+ if (!isRecord(payload)) {
765
+ throw new Error("invalid schema_validate response")
766
+ }
767
+
768
+ return payload as unknown as SchemaValidationResult
769
+ }
770
+
771
+ async schemaInfer(
772
+ noteType: string,
773
+ threshold = 0.25,
774
+ project?: string,
775
+ ): Promise<SchemaInferResult> {
776
+ const args: Record<string, unknown> = {
777
+ note_type: noteType,
778
+ threshold,
779
+ output_format: "json",
780
+ }
781
+ if (project) args.project = project
782
+
783
+ const payload = await this.callTool("schema_infer", args)
784
+
785
+ if (!isRecord(payload)) {
786
+ throw new Error("invalid schema_infer response")
787
+ }
788
+
789
+ return payload as unknown as SchemaInferResult
790
+ }
791
+
792
+ async schemaDiff(
793
+ noteType: string,
794
+ project?: string,
795
+ ): Promise<SchemaDiffResult> {
796
+ const args: Record<string, unknown> = {
797
+ note_type: noteType,
798
+ output_format: "json",
799
+ }
800
+ if (project) args.project = project
801
+
802
+ const payload = await this.callTool("schema_diff", args)
803
+
804
+ if (!isRecord(payload)) {
805
+ throw new Error("invalid schema_diff response")
806
+ }
807
+
808
+ return payload as unknown as SchemaDiffResult
809
+ }
810
+
811
+ async searchByMetadata(
812
+ filters: Record<string, unknown>,
813
+ limit = 20,
814
+ project?: string,
815
+ ): Promise<MetadataSearchResult> {
816
+ const args: Record<string, unknown> = {
817
+ filters,
818
+ limit,
819
+ output_format: "json",
820
+ }
821
+ if (project) args.project = project
822
+
823
+ const payload = await this.callTool("search_by_metadata", args)
824
+
825
+ if (!isRecord(payload) || !Array.isArray(payload.results)) {
826
+ throw new Error("invalid search_by_metadata response")
827
+ }
828
+
829
+ return payload as unknown as MetadataSearchResult
830
+ }
831
+
832
+ private isNoteNotFoundError(err: unknown): boolean {
833
+ return isNoteNotFoundError(err)
834
+ }
835
+
836
+ async indexConversation(
837
+ userMessage: string,
838
+ assistantResponse: string,
839
+ ): Promise<void> {
840
+ const now = new Date()
841
+ const dateStr = now.toISOString().split("T")[0]
842
+ const timeStr = now.toTimeString().slice(0, 5)
843
+ const title = `conversations-${dateStr}`
844
+
845
+ const entry = [
846
+ `### ${timeStr}`,
847
+ "",
848
+ "**User:**",
849
+ userMessage,
850
+ "",
851
+ "**Assistant:**",
852
+ assistantResponse,
853
+ "",
854
+ "---",
855
+ ].join("\n")
856
+
857
+ try {
858
+ await this.editNote(title, "append", entry)
859
+ log.debug(`appended conversation to: ${title}`)
860
+ } catch (err) {
861
+ if (!this.isNoteNotFoundError(err)) {
862
+ log.error("conversation append failed", err)
863
+ return
864
+ }
865
+
866
+ const content = [`# Conversations ${dateStr}`, "", entry].join("\n")
867
+ try {
868
+ await this.writeNote(title, content, "conversations")
869
+ log.debug(`created conversation note: ${title}`)
870
+ } catch (createErr) {
871
+ log.error("conversation index failed", createErr)
872
+ }
873
+ }
874
+ }
875
+
876
+ getProject(): string {
877
+ return this.project
878
+ }
879
+ }