memorylake-openclaw 1.0.2-beta.6 → 1.1.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.
package/index.ts CHANGED
@@ -4,791 +4,24 @@
4
4
  * Long-term memory via MemoryLake platform.
5
5
  *
6
6
  * Features:
7
- * - 9 tools: memory_search, memory_list, memory_store, memory_get, memory_forget, document_search, document_download, advanced_web_search, open_data_search
8
- * - Auto-recall: injects relevant memories and document excerpts before each agent turn
7
+ * - 7 tools: retrieve_context, memory_store, memory_list, memory_forget, document_download, advanced_web_search, open_data_search
8
+ * - Auto-recall: injects retrieve_context instructions (+ open-data categories) into system prompt and per-turn context
9
9
  * - Auto-capture: stores key facts scoped to the current session after each agent turn
10
+ * - Auto-upload: uploads inbound files to MemoryLake as project documents
10
11
  * - CLI: openclaw memorylake search, openclaw memorylake stats, openclaw memorylake upload
11
12
  */
12
13
 
13
- import fs from "node:fs";
14
- import fsPromises from "node:fs/promises";
15
- import os from "node:os";
16
- import path from "node:path";
17
- import got from "got";
18
- import { pipeline } from "node:stream/promises";
19
- import { Type } from "@sinclair/typebox";
20
14
  import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
21
- import { loadCoreAgentDeps } from "./core-bridge";
22
-
23
- // ============================================================================
24
- // Types
25
- // ============================================================================
26
- const DEFAULT_USER_ID = "default";
27
-
28
- type MemoryLakeConfig = {
29
- host: string;
30
- apiKey: string;
31
- projectId: string;
32
- userId: string;
33
- autoCapture: boolean;
34
- autoRecall: boolean;
35
- autoUpload: boolean;
36
- searchThreshold: number;
37
- topK: number;
38
- rerank: boolean;
39
- webSearchIncludeDomains?: string[];
40
- webSearchExcludeDomains?: string[];
41
- webSearchCountry?: string;
42
- webSearchTimezone?: string;
43
- };
44
-
45
- // V2 API option types
46
- interface AddOptions {
47
- user_id: string;
48
- chat_session_id?: string;
49
- metadata?: Record<string, unknown>;
50
- infer?: boolean;
51
- }
52
-
53
- interface SearchOptions {
54
- user_id: string;
55
- top_k?: number;
56
- threshold?: number;
57
- rerank?: boolean;
58
- }
59
-
60
- interface ListOptions {
61
- user_id?: string;
62
- page?: number;
63
- size?: number;
64
- }
65
-
66
- interface MemoryItem {
67
- id: string;
68
- content: string;
69
- user_id?: string;
70
- created_at?: string;
71
- updated_at?: string;
72
- has_unresolved_conflict?: boolean;
73
- }
74
-
75
- interface ConflictMemorySnapshot {
76
- memory_id: string;
77
- memory_history_id?: string;
78
- memory_text: string;
79
- }
80
-
81
- interface ConflictFileChunk {
82
- chunk: { type?: string; text: string; range?: string };
83
- document_id?: string;
84
- document_name?: string;
85
- }
86
-
87
- interface ConflictResolve {
88
- id: string;
89
- strategy: string;
90
- keep_memory_id?: string;
91
- forgotten_memory_ids?: string[];
92
- resolved_by?: string;
93
- created_at?: string;
94
- }
95
-
96
- interface ConflictItem {
97
- id: string;
98
- name: string;
99
- description: string;
100
- category: "m2m" | "m2d";
101
- conflict_type: "logical" | "knowledge";
102
- memory_ids: string[];
103
- memory_snapshots: ConflictMemorySnapshot[];
104
- file_chunks: ConflictFileChunk[];
105
- resolved: boolean;
106
- resolve?: ConflictResolve;
107
- stale?: boolean;
108
- event_id?: string;
109
- created_at?: string;
110
- updated_at?: string;
111
- }
112
-
113
- interface ConflictListResponse {
114
- items: ConflictItem[];
115
- page: number;
116
- total: number;
117
- page_size: number;
118
- }
119
-
120
- interface AddResultItem {
121
- event_id: string;
122
- status: string;
123
- message: string;
124
- }
125
-
126
- interface AddResult {
127
- results: AddResultItem[];
128
- }
129
-
130
- interface DocumentSearchResult {
131
- type: "table" | "paragraph" | "figure";
132
- document_id?: string;
133
- document_name?: string;
134
- source_document?: { file_name?: string };
135
- highlight?: {
136
- chunks?: Array<{ text?: string; range?: string }>;
137
- inner_tables?: Array<{
138
- id?: string;
139
- columns?: Array<{ name?: string; data_type?: string }>;
140
- num_rows?: number;
141
- }>;
142
- figure?: {
143
- text?: string;
144
- caption?: string;
145
- summary_text?: string;
146
- };
147
- };
148
- title?: string;
149
- footnote?: string;
150
- sheet_name?: string;
151
- figure_id?: number;
152
- }
153
-
154
- interface DocumentSearchResponse {
155
- count: number;
156
- results: DocumentSearchResult[];
157
- }
158
-
159
- /**
160
- * Allowed values for web search domain (aligned with zootopia unified_search Domain).
161
- * Declared as enum in schema; at runtime accept string and fall back to "auto" if invalid.
162
- */
163
- const WebSearchDomainValues = [
164
- "web",
165
- "academic",
166
- "news",
167
- "people",
168
- "company",
169
- "financial",
170
- "markets",
171
- "code",
172
- "legal",
173
- "government",
174
- "poi",
175
- "auto",
176
- ] as const;
177
- type WebSearchDomain = (typeof WebSearchDomainValues)[number];
178
-
179
- const WEB_SEARCH_DOMAIN_SET = new Set<string>(WebSearchDomainValues);
180
-
181
- /** Normalize domain: accept string at runtime; if not a valid enum value, return "auto". */
182
- function normalizeWebSearchDomain(value: unknown): WebSearchDomain {
183
- if (value == null) return "auto";
184
- const s = typeof value === "string" ? value.toLowerCase().trim() : "";
185
- return (WEB_SEARCH_DOMAIN_SET.has(s) ? s : "auto") as WebSearchDomain;
186
- }
187
-
188
- interface WebSearchUserLocation {
189
- country?: string;
190
- timezone?: string;
191
- }
192
-
193
- interface WebSearchOptions {
194
- /** Declared as enum in schema; at runtime accept string, normalized with fallback to "auto". */
195
- domain?: WebSearchDomain | string;
196
- max_results?: number;
197
- start_date?: string;
198
- end_date?: string;
199
- include_domains?: string[];
200
- exclude_domains?: string[];
201
- user_location?: WebSearchUserLocation;
202
- }
203
-
204
- interface WebSearchResult {
205
- url?: string;
206
- title?: string;
207
- summary?: string;
208
- content?: string;
209
- source?: string;
210
- published_date?: string;
211
- author?: string;
212
- score?: number;
213
- highlights?: string[];
214
- }
215
-
216
- interface WebSearchResponse {
217
- results: WebSearchResult[];
218
- total_results: number;
219
- }
220
-
221
- /**
222
- * Allowed values for open data search category (aligned with opendata endpoint).
223
- * Maps to proprietary data sources per category.
224
- */
225
- const OpenDataCategoryValues = [
226
- "research/academic",
227
- "clinical/trials",
228
- "drug/database",
229
- "financial/markets",
230
- "company/fundamentals",
231
- "economic/data",
232
- "patents/ip",
233
- ] as const;
234
- type OpenDataCategory = (typeof OpenDataCategoryValues)[number];
235
-
236
- const OPEN_DATA_CATEGORY_SET = new Set<string>(OpenDataCategoryValues);
237
-
238
- /** Normalize category: accept string at runtime; return undefined if not a valid enum value. */
239
- function normalizeOpenDataCategory(value: unknown): OpenDataCategory | undefined {
240
- if (value == null) return undefined;
241
- const s = typeof value === "string" ? value.toLowerCase().trim() : "";
242
- return OPEN_DATA_CATEGORY_SET.has(s) ? (s as OpenDataCategory) : undefined;
243
- }
244
-
245
- interface OpenDataIndustry {
246
- id: string;
247
- name: string;
248
- description?: string;
249
- }
250
-
251
- interface ProjectInfo {
252
- id: string;
253
- name: string;
254
- description?: string;
255
- industries: OpenDataIndustry[];
256
- }
257
-
258
- interface OpenDataSearchOptions {
259
- dataset?: OpenDataCategory | string;
260
- max_results?: number;
261
- start_date?: string;
262
- end_date?: string;
263
- }
264
-
265
- interface OpenDataSearchResult {
266
- title?: string;
267
- url?: string;
268
- summary?: string;
269
- content?: string;
270
- source?: string;
271
- category?: string;
272
- published_date?: string;
273
- author?: string;
274
- score?: number;
275
- metadata?: Record<string, unknown>;
276
- }
277
-
278
- interface OpenDataSearchResponse {
279
- results: OpenDataSearchResult[];
280
- total_results: number;
281
- }
282
-
283
- // ============================================================================
284
- // Unified Provider Interface
285
- // ============================================================================
286
-
287
- interface MemoryLakeProvider {
288
- add(
289
- messages: Array<{ role: string; content: string }>,
290
- options: AddOptions,
291
- ): Promise<AddResult>;
292
- search(query: string, options: SearchOptions): Promise<MemoryItem[]>;
293
- get(memoryId: string): Promise<MemoryItem>;
294
- getAll(options: ListOptions): Promise<MemoryItem[]>;
295
- delete(memoryId: string): Promise<void>;
296
- searchDocuments(query: string, topN: number): Promise<DocumentSearchResponse>;
297
- getDocumentDownloadUrl(documentId: string): Promise<string>;
298
- searchWeb(query: string, options: WebSearchOptions): Promise<WebSearchResponse>;
299
- searchOpenData(query: string, options: OpenDataSearchOptions): Promise<OpenDataSearchResponse>;
300
- getProject(): Promise<ProjectInfo>;
301
- listConflicts(memoryIds: string[], userId: string): Promise<ConflictItem[]>;
302
- }
303
-
304
- // ============================================================================
305
- // Platform Provider (MemoryLake Cloud)
306
- // ============================================================================
307
-
308
- interface ApiResponse<T = unknown> {
309
- success: boolean;
310
- message?: string;
311
- data?: T;
312
- error_code?: string;
313
- }
314
-
315
- class PlatformProvider implements MemoryLakeProvider {
316
- private readonly http: ReturnType<typeof got.extend>;
317
- private readonly basePath: string;
318
- private readonly docSearchPath: string;
319
- private readonly webSearchPath: string;
320
- private readonly openDataSearchPath: string;
321
- private readonly projectPath: string;
322
- private readonly conflictsPath: string;
323
- private readonly projectId: string;
324
-
325
- constructor(host: string, apiKey: string, projectId: string) {
326
- this.projectId = projectId;
327
- this.basePath = `openapi/memorylake/api/v2/projects/${projectId}/memories`;
328
- this.docSearchPath = `openapi/memorylake/api/v1/projects/${projectId}/documents/search`;
329
- this.webSearchPath = "openapi/memorylake/api/v1/search";
330
- this.openDataSearchPath = "openapi/memorylake/api/v1/search/opendata";
331
- this.projectPath = `openapi/memorylake/api/v1/projects/${projectId}`;
332
- this.conflictsPath = `openapi/memorylake/api/v2/projects/${projectId}/memories/conflicts`;
333
- this.http = got.extend({
334
- prefixUrl: host,
335
- headers: {
336
- Authorization: `Bearer ${apiKey}`,
337
- },
338
- responseType: "json",
339
- });
340
- }
341
-
342
- async add(
343
- messages: Array<{ role: string; content: string }>,
344
- options: AddOptions,
345
- ): Promise<AddResult> {
346
- const body: Record<string, unknown> = {
347
- messages,
348
- user_id: options.user_id,
349
- infer: options.infer ?? true,
350
- };
351
- if (options.chat_session_id) body.chat_session_id = options.chat_session_id;
352
- if (options.metadata) body.metadata = options.metadata;
353
-
354
- const resp = await this.http
355
- .post(this.basePath, { json: body })
356
- .json<ApiResponse>();
357
- if (!resp.success) throw new Error(resp.message ?? "add failed");
358
- return normalizeAddResult(resp.data);
359
- }
360
-
361
- async search(query: string, options: SearchOptions): Promise<MemoryItem[]> {
362
- const body: Record<string, unknown> = {
363
- query,
364
- user_id: options.user_id,
365
- with_conflicts: true,
366
- };
367
- if (options.top_k != null) body.top_k = options.top_k;
368
- if (options.threshold != null) body.threshold = options.threshold;
369
- if (options.rerank != null) body.rerank = options.rerank;
370
-
371
-
372
- const resp = await this.http
373
- .post(`${this.basePath}/search`, { json: body })
374
- .json<ApiResponse>();
375
- if (!resp.success) throw new Error(resp.message ?? "search failed");
376
-
377
- return normalizeSearchResults(resp.data);
378
- }
379
-
380
- async get(memoryId: string): Promise<MemoryItem> {
381
- const resp = await this.http
382
- .get(`${this.basePath}/${memoryId}`)
383
- .json<ApiResponse>();
384
- if (!resp.success) throw new Error(resp.message ?? "get failed");
385
- return normalizeMemoryItem(resp.data);
386
- }
387
-
388
- async getAll(options: ListOptions): Promise<MemoryItem[]> {
389
- const searchParams: Record<string, string | number> = {};
390
- if (options.user_id) searchParams.user_id = options.user_id;
391
- if (options.page != null) searchParams.page = options.page;
392
- if (options.size != null) searchParams.size = options.size;
393
-
394
- const resp = await this.http
395
- .get(this.basePath, { searchParams })
396
- .json<ApiResponse>();
397
- if (!resp.success) throw new Error(resp.message ?? "getAll failed");
398
- const data = resp.data as any;
399
- if (data?.items && Array.isArray(data.items))
400
- return data.items.map(normalizeMemoryItem);
401
- return [];
402
- }
403
-
404
- async delete(memoryId: string): Promise<void> {
405
- const resp = await this.http
406
- .delete(`${this.basePath}/${memoryId}`)
407
- .json<ApiResponse>();
408
- if (!resp.success) throw new Error(resp.message ?? "delete failed");
409
- }
410
-
411
- async searchDocuments(query: string, topN: number): Promise<DocumentSearchResponse> {
412
- const resp = await this.http
413
- .post(this.docSearchPath, { json: { query, top_N: topN } })
414
- .json<ApiResponse>();
415
- if (!resp.success) throw new Error(resp.message ?? "document search failed");
416
- const data = resp.data as any;
417
- return {
418
- count: data?.count ?? 0,
419
- results: Array.isArray(data?.results) ? data.results : [],
420
- };
421
- }
422
-
423
- async getDocumentDownloadUrl(documentId: string): Promise<string> {
424
- const downloadPath = `openapi/memorylake/api/v1/projects/${this.projectId}/documents/${documentId}/download`;
425
- const resp = await this.http.get(downloadPath, {
426
- followRedirect: false,
427
- responseType: "text" as any,
428
- throwHttpErrors: false,
429
- });
430
- if (resp.statusCode === 303 || resp.statusCode === 302) {
431
- const location = resp.headers.location;
432
- if (!location) throw new Error("Download redirect missing Location header");
433
- return location;
434
- }
435
- if (resp.statusCode === 404) {
436
- throw new Error(`Document not found: ${documentId}`);
437
- }
438
- throw new Error(`Unexpected download response status: ${resp.statusCode}`);
439
- }
440
-
441
- async searchWeb(query: string, options: WebSearchOptions): Promise<WebSearchResponse> {
442
- const domain = options.domain != null ? normalizeWebSearchDomain(options.domain) : "web";
443
- const body: Record<string, unknown> = {
444
- query,
445
- domain,
446
- };
447
- if (options.max_results != null) body.max_results = options.max_results;
448
- if (options.start_date) body.start_date = options.start_date;
449
- if (options.end_date) body.end_date = options.end_date;
450
- if (options.include_domains?.length) body.include_domains = options.include_domains;
451
- if (options.exclude_domains?.length) body.exclude_domains = options.exclude_domains;
452
- if (options.user_location) body.user_location = options.user_location;
453
-
454
- const resp = await this.http
455
- .post(this.webSearchPath, { json: body })
456
- .json<WebSearchResponse>();
457
- return normalizeWebSearchResponse(resp);
458
- }
459
-
460
- async searchOpenData(query: string, options: OpenDataSearchOptions): Promise<OpenDataSearchResponse> {
461
- const body: Record<string, unknown> = { query };
462
- if (options.dataset != null) {
463
- const ds = normalizeOpenDataCategory(options.dataset);
464
- if (!ds) throw new Error(`Invalid open data dataset: "${options.dataset}"`);
465
- body.dataset = ds;
466
- }
467
- if (options.max_results != null) body.max_results = options.max_results;
468
- if (options.start_date) body.start_date = options.start_date;
469
- if (options.end_date) body.end_date = options.end_date;
470
-
471
- const resp = await this.http
472
- .post(this.openDataSearchPath, { json: body })
473
- .json<OpenDataSearchResponse>();
474
- return normalizeOpenDataSearchResponse(resp);
475
- }
476
-
477
- async getProject(): Promise<ProjectInfo> {
478
- const resp = await this.http
479
- .get(this.projectPath)
480
- .json<ApiResponse<{ id?: string; name?: string; description?: string; industries?: Array<{ id?: string; name?: string; description?: string }> }>>();
481
- if (!resp.success) throw new Error(resp.message ?? "get project failed");
482
- const data = resp.data;
483
- const info: ProjectInfo = {
484
- id: data?.id ?? "",
485
- name: data?.name ?? "",
486
- description: data?.description,
487
- industries: Array.isArray(data?.industries)
488
- ? data.industries.map((ind) => ({
489
- id: ind.id ?? "",
490
- name: ind.name ?? "",
491
- description: ind.description,
492
- }))
493
- : [],
494
- };
495
- return info;
496
- }
497
-
498
- async listConflicts(memoryIds: string[], userId: string): Promise<ConflictItem[]> {
499
- if (memoryIds.length === 0) return [];
500
- const searchParams: Record<string, string> = {
501
- resolved: "false",
502
- memory_ids: memoryIds.join(","),
503
- };
504
- const resp = await this.http
505
- .get(this.conflictsPath, {
506
- searchParams
507
- })
508
- .json<ApiResponse<ConflictListResponse>>();
509
- if (!resp.success) throw new Error(resp.message ?? "list conflicts failed");
510
- const data = resp.data;
511
- return Array.isArray(data?.items) ? data.items : [];
512
- }
513
-
514
- }
515
-
516
- // ============================================================================
517
- // Result Normalizers
518
- // ============================================================================
519
-
520
- function normalizeMemoryItem(raw: any): MemoryItem {
521
- return {
522
- id: raw.id ?? "",
523
- content: raw.content ?? "",
524
- user_id: raw.user_id,
525
- created_at: raw.created_at,
526
- updated_at: raw.updated_at,
527
- has_unresolved_conflict: raw.has_unresolved_conflict ?? false,
528
- };
529
- }
530
-
531
- function normalizeSearchResults(raw: any): MemoryItem[] {
532
- if (raw?.results && Array.isArray(raw.results))
533
- return raw.results.map(normalizeMemoryItem);
534
- if (Array.isArray(raw)) return raw.map(normalizeMemoryItem);
535
- return [];
536
- }
537
-
538
- function normalizeAddResult(raw: any): AddResult {
539
- const items = raw?.results ?? (Array.isArray(raw) ? raw : []);
540
- return {
541
- results: items.map((r: any) => ({
542
- event_id: r.event_id ?? "",
543
- status: r.status ?? "",
544
- message: r.message ?? "",
545
- })),
546
- };
547
- }
548
-
549
- function normalizeWebSearchResponse(raw: any): WebSearchResponse {
550
- return {
551
- results: Array.isArray(raw?.results) ? raw.results : [],
552
- total_results: typeof raw?.total_results === "number" ? raw.total_results : 0,
553
- };
554
- }
555
-
556
- function normalizeOpenDataResult(raw: any): OpenDataSearchResult {
557
- return {
558
- title: typeof raw?.title === "string" ? raw.title : undefined,
559
- url: typeof raw?.url === "string" ? raw.url : undefined,
560
- summary: typeof raw?.summary === "string" ? raw.summary : undefined,
561
- content: typeof raw?.content === "string" ? raw.content : undefined,
562
- source: typeof raw?.source === "string" ? raw.source : undefined,
563
- category: typeof raw?.category === "string" ? raw.category : undefined,
564
- published_date: typeof raw?.published_date === "string" ? raw.published_date : undefined,
565
- author: typeof raw?.author === "string" ? raw.author : undefined,
566
- score: typeof raw?.score === "number" ? raw.score : undefined,
567
- metadata: raw?.metadata && typeof raw.metadata === "object" && !Array.isArray(raw.metadata)
568
- ? raw.metadata as Record<string, unknown>
569
- : undefined,
570
- };
571
- }
572
-
573
- function normalizeOpenDataSearchResponse(raw: any): OpenDataSearchResponse {
574
- return {
575
- results: Array.isArray(raw?.results) ? raw.results.map(normalizeOpenDataResult) : [],
576
- total_results: typeof raw?.total_results === "number" ? raw.total_results : 0,
577
- };
578
- }
579
-
580
- // ============================================================================
581
- // Document Context Builder
582
- // ============================================================================
583
-
584
- function buildDocumentContext(
585
- results: DocumentSearchResult[],
586
- maxChunkLength = 10000,
587
- ): string {
588
- const parts: string[] = [];
589
-
590
- for (const result of results) {
591
- const source = result.document_name ?? result.source_document?.file_name ?? "unknown";
592
- const docId = result.document_id ?? "unknown";
593
- const highlight = result.highlight;
594
-
595
- if (result.type === "table") {
596
- const title = result.title || "Untitled Table";
597
- parts.push(`### Table: ${title} (from ${source}, doc_id: ${docId})`);
598
- if (result.footnote) parts.push(`Note: ${result.footnote}`);
599
-
600
- for (const innerTable of highlight?.inner_tables ?? []) {
601
- const colDesc = (innerTable.columns ?? [])
602
- .map((c) => `${c.name}(${c.data_type})`)
603
- .join(", ");
604
- if (colDesc) parts.push(`Columns: ${colDesc}`);
605
- if (innerTable.num_rows != null) parts.push(`Rows: ${innerTable.num_rows}`);
606
- }
607
- for (const chunk of highlight?.chunks ?? []) {
608
- if (chunk.text) parts.push(chunk.text.slice(0, maxChunkLength));
609
- }
610
- } else if (result.type === "paragraph") {
611
- parts.push(`### Paragraph (from ${source}, doc_id: ${docId}):`);
612
- for (const chunk of highlight?.chunks ?? []) {
613
- if (chunk.text) parts.push(chunk.text.slice(0, maxChunkLength));
614
- }
615
- } else if (result.type === "figure") {
616
- const figure = highlight?.figure;
617
- if (figure) {
618
- parts.push(`### Figure (from ${source}, doc_id: ${docId}):`);
619
- if (figure.caption) parts.push(`Caption: ${figure.caption}`);
620
- const text = figure.text || figure.summary_text || "";
621
- if (text) parts.push(text);
622
- }
623
- }
624
- }
625
-
626
- return parts.join("\n\n");
627
- }
628
-
629
- function buildWebSearchContext(results: WebSearchResult[]): string {
630
- return results
631
- .map((result, index) => {
632
- const parts = [`${index + 1}. ${result.title ?? result.url ?? "Untitled result"}`];
633
- if (result.url) parts.push(`URL: ${result.url}`);
634
- if (result.summary) parts.push(`Summary: ${result.summary}`);
635
- if (result.source) parts.push(`Source: ${result.source}`);
636
- if (result.published_date) parts.push(`Published: ${result.published_date}`);
637
- return parts.join("\n");
638
- })
639
- .join("\n\n");
640
- }
641
-
642
- function buildConflictContext(conflicts: ConflictItem[], maxChunkLength = 200): string {
643
- return conflicts
644
- .map((c) => {
645
- const parts: string[] = [
646
- `- [${c.conflict_type}] ${c.description}`,
647
- ];
648
- for (const snap of c.memory_snapshots ?? []) {
649
- parts.push(` Memory(${snap.memory_id}): ${snap.memory_text.slice(0, maxChunkLength)}`);
650
- }
651
- for (const fc of c.file_chunks ?? []) {
652
- const docLabel = fc.document_name ?? fc.document_id ?? "unknown";
653
- parts.push(` Document(${docLabel}): ${fc.chunk.text.slice(0, maxChunkLength)}`);
654
- }
655
- return parts.join("\n");
656
- })
657
- .join("\n");
658
- }
659
-
660
- function buildOpenDataContext(results: OpenDataSearchResult[]): string {
661
- const filtered = results.map((r) => {
662
- const item: Record<string, unknown> = {};
663
- if (r.title != null) item.title = r.title;
664
- if (r.url != null) item.url = r.url;
665
- if (r.content != null) item.content = r.content;
666
- if (r.published_date != null) item.published_date = r.published_date;
667
- if (r.category != null) item.category = r.category;
668
- return item;
669
- });
670
- return JSON.stringify(filtered, null, 2);
671
- }
672
-
673
- // ============================================================================
674
- // Config Parser
675
- // ============================================================================
676
-
677
- // ============================================================================
678
- // Config Schema
679
- // ============================================================================
680
-
681
- const ALLOWED_KEYS = [
682
- "host",
683
- "apiKey",
684
- "projectId",
685
- "userId",
686
- "autoCapture",
687
- "autoRecall",
688
- "autoUpload",
689
- "searchThreshold",
690
- "topK",
691
- "rerank",
692
- "webSearchIncludeDomains",
693
- "webSearchExcludeDomains",
694
- "webSearchCountry",
695
- "webSearchTimezone",
696
- ];
697
-
698
- function assertAllowedKeys(
699
- value: Record<string, unknown>,
700
- allowed: string[],
701
- label: string,
702
- ) {
703
- const unknown = Object.keys(value).filter((key) => !allowed.includes(key));
704
- if (unknown.length === 0) return;
705
- throw new Error(`${label} has unknown keys: ${unknown.join(", ")}`);
706
- }
707
-
708
- function parseOptionalStringArray(
709
- value: unknown,
710
- label: string,
711
- ): string[] | undefined {
712
- if (value == null) return undefined;
713
- if (!Array.isArray(value) || !value.every((item) => typeof item === "string")) {
714
- throw new Error(`${label} must be an array of strings`);
715
- }
716
- return value;
717
- }
718
-
719
- function parseOptionalString(
720
- value: unknown,
721
- label: string,
722
- ): string | undefined {
723
- if (value == null) return undefined;
724
- if (typeof value !== "string") {
725
- throw new Error(`${label} must be a string`);
726
- }
727
- return value;
728
- }
729
-
730
- const memoryLakeConfigSchema = {
731
- parse(value: unknown): MemoryLakeConfig {
732
- if (!value || typeof value !== "object" || Array.isArray(value)) {
733
- throw new Error("memorylake-openclaw config required");
734
- }
735
- const cfg = value as Record<string, unknown>;
736
- assertAllowedKeys(cfg, ALLOWED_KEYS, "memorylake-openclaw config");
737
-
738
- if (typeof cfg.apiKey !== "string" || !cfg.apiKey) {
739
- throw new Error("apiKey is required");
740
- }
741
- if (typeof cfg.projectId !== "string" || !cfg.projectId) {
742
- throw new Error("projectId is required");
743
- }
744
-
745
- return {
746
- host:
747
- typeof cfg.host === "string" && cfg.host
748
- ? cfg.host
749
- : "https://app.memorylake.ai",
750
- apiKey: cfg.apiKey as string,
751
- projectId: cfg.projectId as string,
752
- userId: DEFAULT_USER_ID,
753
- autoCapture: cfg.autoCapture !== false,
754
- autoRecall: cfg.autoRecall !== false,
755
- autoUpload: cfg.autoUpload !== false,
756
- searchThreshold:
757
- typeof cfg.searchThreshold === "number" ? cfg.searchThreshold : 0.3,
758
- topK: typeof cfg.topK === "number" ? cfg.topK : 5,
759
- rerank: cfg.rerank !== false,
760
- webSearchIncludeDomains: parseOptionalStringArray(
761
- cfg.webSearchIncludeDomains,
762
- "webSearchIncludeDomains",
763
- ),
764
- webSearchExcludeDomains: parseOptionalStringArray(
765
- cfg.webSearchExcludeDomains,
766
- "webSearchExcludeDomains",
767
- ),
768
- webSearchCountry: parseOptionalString(
769
- cfg.webSearchCountry,
770
- "webSearchCountry",
771
- ),
772
- webSearchTimezone: parseOptionalString(
773
- cfg.webSearchTimezone,
774
- "webSearchTimezone",
775
- ),
776
- };
777
- },
778
- };
779
-
780
- // ============================================================================
781
- // Plugin Definition
782
- // ============================================================================
783
-
784
- /** Shared type for the upload / uploadAuto function signature */
785
- type UploadFn = (opts: {
786
- host: string;
787
- apiKey: string;
788
- projectId: string;
789
- filePath: string;
790
- fileName: string;
791
- }) => Promise<unknown>;
15
+ import { memoryLakeConfigSchema } from "./lib/config";
16
+ import { createPluginContext } from "./lib/plugin-context";
17
+ import { registerMemoryPromptSection } from "./lib/prompt/register-prompt";
18
+ import { registerMemoryTools } from "./lib/tools/memory-tools";
19
+ import { registerDocumentTools } from "./lib/tools/document-tools";
20
+ import { registerSearchTools } from "./lib/tools/search-tools";
21
+ import { registerCli } from "./lib/cli/register-cli";
22
+ import { registerAutoUpload } from "./lib/hooks/auto-upload";
23
+ import { registerAutoRecall } from "./lib/hooks/auto-recall";
24
+ import { registerAutoCapture } from "./lib/hooks/auto-capture";
792
25
 
793
26
  const memoryPlugin = {
794
27
  id: "memorylake-openclaw",
@@ -799,1434 +32,21 @@ const memoryPlugin = {
799
32
 
800
33
  register(api: OpenClawPluginApi) {
801
34
  const cfg = memoryLakeConfigSchema.parse(api.pluginConfig);
802
- const provider: MemoryLakeProvider = new PlatformProvider(cfg.host, cfg.apiKey, cfg.projectId);
803
-
804
- // Provider cache: avoids re-creating providers for the same host+apiKey+projectId
805
- const providerCache = new Map<string, MemoryLakeProvider>();
806
- const globalProviderKey = `${cfg.host}|${cfg.apiKey}|${cfg.projectId}`;
807
- providerCache.set(globalProviderKey, provider);
808
-
809
- function getProvider(effectiveCfg: MemoryLakeConfig): MemoryLakeProvider {
810
- const key = `${effectiveCfg.host}|${effectiveCfg.apiKey}|${effectiveCfg.projectId}`;
811
- let p = providerCache.get(key);
812
- if (!p) {
813
- p = new PlatformProvider(effectiveCfg.host, effectiveCfg.apiKey, effectiveCfg.projectId);
814
- providerCache.set(key, p);
815
- }
816
- return p;
817
- }
818
-
819
- function resolveConfig(ctx: any): MemoryLakeConfig {
820
- const workspaceDir = ctx?.workspaceDir;
821
- if (!workspaceDir) return cfg;
822
-
823
- const localPath = path.join(workspaceDir, ".memorylake", "config.json");
824
- if (!fs.existsSync(localPath)) return cfg;
825
-
826
- try {
827
- const raw = JSON.parse(fs.readFileSync(localPath, "utf-8"));
828
- if (!raw || typeof raw !== "object" || Array.isArray(raw)) {
829
- api.logger.warn(
830
- `memorylake-openclaw: workspace config exists but is not a JSON object; falling back to global config (path: ${localPath})`,
831
- );
832
- return cfg;
833
- }
834
-
835
- const allowed = new Set(ALLOWED_KEYS);
836
- const overrides: Record<string, unknown> = {};
837
- for (const [key, value] of Object.entries(raw)) {
838
- if (allowed.has(key)) overrides[key] = value;
839
- }
840
-
841
- return { ...cfg, ...overrides } as MemoryLakeConfig;
842
- } catch (err) {
843
- api.logger.warn(
844
- `memorylake-openclaw: failed to parse workspace config JSON; falling back to global config (path: ${localPath}): ${String(err)}`,
845
- );
846
- return cfg;
847
- }
848
- }
849
-
850
- // Cache project industries per session — fetched once, reused on subsequent prompts
851
- const sessionIndustriesCache = new Map<string, OpenDataIndustry[]>();
35
+ const pctx = createPluginContext(api, cfg);
852
36
 
853
- // ========================================================================
854
- // System Prompt: Memory Section (injected into every system prompt)
855
- // ========================================================================
856
- api.registerMemoryPromptSection((params) => {
857
- const lines: string[] = [
858
- "## Memory (MANDATORY — MemoryLake)",
859
- "",
860
- "You have access to MemoryLake, a long-term memory system that stores the user's preferences, history, personal context, and uploaded documents across sessions.",
861
- "",
862
- ];
863
-
864
- if (params.availableTools.has("memory_search")) {
865
- lines.push(
866
- "### memory_search — MUST be called BEFORE every response",
867
- "",
868
- "**RULE: Your FIRST action for EVERY user message MUST be calling `memory_search`.** No exceptions.",
869
- "",
870
- "This tool searches BOTH memories AND documents in one call. It returns personal context (preferences, history, decisions) and relevant document excerpts (uploaded files, tables, figures).",
871
- "",
872
- "This applies to ALL types of questions, not just questions about memory or recall:",
873
- "- Greetings -> search for who the user is",
874
- "- Recommendations (books, activities, food) -> search for preferences and interests",
875
- "- Advice (what to wear, what to eat) -> search for habits, location, preferences",
876
- "- Tasks (write an email, self-introduction) -> search for user name, role, background",
877
- "- Document questions -> search for relevant uploaded document content",
878
- "- General chat -> search for recent context and ongoing topics",
879
- "",
880
- "Derive a broad query from the user's message. For example:",
881
- "- User asks for a book recommendation -> search: reading preferences favorite books",
882
- "- User asks about weekend plans -> search: hobbies interests weekend activities",
883
- "- User says hello -> search: user name background recent context",
884
- "",
885
- "**If you respond without calling memory_search first, your response is WRONG.**",
886
- "",
887
- );
888
- }
889
-
890
- if (params.availableTools.has("memory_list")) {
891
- lines.push(
892
- "### memory_list",
893
- "- When the user asks what you remember or wants to see all stored memories, call `memory_list`.",
894
- "",
895
- );
896
- }
897
-
898
- if (params.availableTools.has("memory_forget")) {
899
- lines.push(
900
- "### memory_forget",
901
- "- When the user explicitly asks to delete or forget a specific memory, call `memory_forget` with the memory ID.",
902
- "",
903
- );
904
- }
905
-
906
- return lines;
907
- });
37
+ registerMemoryPromptSection(pctx, cfg);
908
38
 
909
39
  api.logger.info(
910
40
  `memorylake-openclaw: registered (user: ${cfg.userId}, autoRecall: ${cfg.autoRecall}, autoCapture: ${cfg.autoCapture}, autoUpload: ${cfg.autoUpload})`,
911
41
  );
912
42
 
913
- // Helper: build add options
914
- function buildAddOptions(effectiveCfg: MemoryLakeConfig, userIdOverride?: string, sessionId?: string): AddOptions {
915
- const opts: AddOptions = {
916
- user_id: userIdOverride || effectiveCfg.userId,
917
- infer: true,
918
- metadata: { source: "OPENCLAW" },
919
- };
920
- if (sessionId) opts.chat_session_id = sessionId;
921
- return opts;
922
- }
923
-
924
- // Helper: build search options
925
- function buildSearchOptions(
926
- effectiveCfg: MemoryLakeConfig,
927
- userIdOverride?: string,
928
- limit?: number,
929
- ): SearchOptions {
930
- return {
931
- user_id: userIdOverride || effectiveCfg.userId,
932
- top_k: limit ?? effectiveCfg.topK,
933
- threshold: effectiveCfg.searchThreshold,
934
- rerank: effectiveCfg.rerank,
935
- };
936
- }
937
-
938
- // ========================================================================
939
- // Tools
940
- // ========================================================================
941
-
942
- api.registerTool(
943
- (ctx) => ({
944
- name: "memory_search",
945
- label: "Memory Search",
946
- description:
947
- "MANDATORY: Search through long-term memories AND uploaded documents stored in MemoryLake. You MUST call this tool at the start of every conversation to recall the user's context, preferences, past decisions, previously discussed topics, and relevant document content. Always search before answering.",
948
- parameters: Type.Object({
949
- query: Type.String({ description: "Search query" }),
950
- limit: Type.Optional(
951
- Type.Number({
952
- description: `Max results (default: ${cfg.topK})`,
953
- }),
954
- ),
955
- userId: Type.Optional(
956
- Type.String({
957
- description:
958
- "User ID to scope search (default: configured userId)",
959
- }),
960
- ),
961
- scope: Type.Optional(
962
- Type.Union([
963
- Type.Literal("session"),
964
- Type.Literal("long-term"),
965
- Type.Literal("all"),
966
- ], {
967
- description:
968
- 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
969
- }),
970
- ),
971
- }),
972
- async execute(_toolCallId, params) {
973
- const effectiveCfg = resolveConfig(ctx);
974
- const effectiveProvider = getProvider(effectiveCfg);
975
- const { query, limit, userId, scope = "all" } = params as {
976
- query: string;
977
- limit?: number;
978
- userId?: string;
979
- scope?: "session" | "long-term" | "all";
980
- };
981
-
982
- const [memoryResult, docResult] = await Promise.allSettled([
983
- effectiveProvider.search(
984
- query,
985
- buildSearchOptions(effectiveCfg, userId, limit),
986
- ),
987
- effectiveProvider.searchDocuments(query, effectiveCfg.topK),
988
- ]);
989
-
990
- const sections: string[] = [];
991
- let memoryCount = 0;
992
- let docCount = 0;
993
- let sanitizedMemories: { id: string; content: string; created_at: string }[] = [];
994
-
995
- if (memoryResult.status === "fulfilled" && memoryResult.value.length > 0) {
996
- const results = memoryResult.value;
997
- memoryCount = results.length;
998
- const text = results
999
- .map((r, i) => `${i + 1}. ${r.content} (id: ${r.id})`)
1000
- .join("\n");
1001
- sections.push(`## Memories\nFound ${results.length} memories:\n\n${text}`);
1002
- sanitizedMemories = results.map((r) => ({
1003
- id: r.id,
1004
- content: r.content,
1005
- created_at: r.created_at,
1006
- }));
1007
- } else if (memoryResult.status === "rejected") {
1008
- sections.push(`## Memories\nMemory search failed: ${String(memoryResult.reason)}`);
1009
- }
1010
-
1011
- if (docResult.status === "fulfilled" && docResult.value.results.length > 0) {
1012
- docCount = docResult.value.results.length;
1013
- const context = buildDocumentContext(docResult.value.results);
1014
- sections.push(`## Documents\nFound ${docCount} document results:\n\n${context}`);
1015
- } else if (docResult.status === "rejected") {
1016
- sections.push(`## Documents\nDocument search failed: ${String(docResult.reason)}`);
1017
- }
1018
-
1019
- if (memoryCount === 0 && docCount === 0) {
1020
- return {
1021
- content: [
1022
- { type: "text", text: "No relevant memories or documents found." },
1023
- ],
1024
- details: { count: 0 },
1025
- };
1026
- }
1027
-
1028
- return {
1029
- content: [
1030
- {
1031
- type: "text",
1032
- text: sections.join("\n\n"),
1033
- },
1034
- ],
1035
- details: {
1036
- memoryCount,
1037
- documentCount: docCount,
1038
- memories: sanitizedMemories,
1039
- },
1040
- };
1041
- },
1042
- }),
1043
- { name: "memory_search" },
1044
- );
1045
-
1046
- api.registerTool(
1047
- (ctx) => ({
1048
- name: "memory_store",
1049
- label: "Memory Store",
1050
- description:
1051
- "Save important information in long-term memory via MemoryLake. Use for preferences, facts, decisions, and anything worth remembering.",
1052
- parameters: Type.Object({
1053
- text: Type.String({ description: "Information to remember" }),
1054
- userId: Type.Optional(
1055
- Type.String({
1056
- description: "User ID to scope this memory",
1057
- }),
1058
- ),
1059
- metadata: Type.Optional(
1060
- Type.Record(Type.String(), Type.Unknown(), {
1061
- description: "Optional metadata to attach to this memory",
1062
- }),
1063
- ),
1064
- }),
1065
- async execute(_toolCallId, params) {
1066
- const effectiveCfg = resolveConfig(ctx);
1067
- const effectiveProvider = getProvider(effectiveCfg);
1068
- const { text, userId } = params as {
1069
- text: string;
1070
- userId?: string;
1071
- metadata?: Record<string, unknown>;
1072
- };
1073
-
1074
- try {
1075
- const result = await effectiveProvider.add(
1076
- [{ role: "user", content: text }],
1077
- buildAddOptions(effectiveCfg, userId, (ctx as any)?.sessionId),
1078
- );
1079
-
1080
- const count = result.results?.length ?? 0;
1081
-
1082
- return {
1083
- content: [
1084
- {
1085
- type: "text",
1086
- text: count > 0
1087
- ? `Submitted ${count} memory task(s) for processing. ${result.results.map((r) => `[${r.status}] ${r.message}`).join("; ")}`
1088
- : "No memories extracted.",
1089
- },
1090
- ],
1091
- details: {
1092
- action: "stored",
1093
- results: result.results,
1094
- },
1095
- };
1096
- } catch (err) {
1097
- return {
1098
- content: [
1099
- {
1100
- type: "text",
1101
- text: `Memory store failed: ${String(err)}`,
1102
- },
1103
- ],
1104
- details: { error: String(err) },
1105
- };
1106
- }
1107
- },
1108
- }),
1109
- { name: "memory_store" },
1110
- );
1111
-
1112
- api.registerTool(
1113
- (ctx) => ({
1114
- name: "memory_get",
1115
- label: "Memory Get",
1116
- description: "Retrieve a specific memory by its ID from MemoryLake.",
1117
- parameters: Type.Object({
1118
- memoryId: Type.String({ description: "The memory ID to retrieve" }),
1119
- }),
1120
- async execute(_toolCallId, params) {
1121
- const effectiveCfg = resolveConfig(ctx);
1122
- const effectiveProvider = getProvider(effectiveCfg);
1123
- const { memoryId } = params as { memoryId: string };
1124
-
1125
- try {
1126
- const memory = await effectiveProvider.get(memoryId);
1127
-
1128
- return {
1129
- content: [
1130
- {
1131
- type: "text",
1132
- text: `Memory ${memory.id}:\n${memory.content}\n\nCreated: ${memory.created_at ?? "unknown"}\nUpdated: ${memory.updated_at ?? "unknown"}`,
1133
- },
1134
- ],
1135
- details: { memory },
1136
- };
1137
- } catch (err) {
1138
- return {
1139
- content: [
1140
- {
1141
- type: "text",
1142
- text: `Memory get failed: ${String(err)}`,
1143
- },
1144
- ],
1145
- details: { error: String(err) },
1146
- };
1147
- }
1148
- },
1149
- }),
1150
- { name: "memory_get" },
1151
- );
1152
-
1153
- api.registerTool(
1154
- (ctx) => ({
1155
- name: "memory_list",
1156
- label: "Memory List",
1157
- description:
1158
- "List all stored memories for a user. Use this when you want to see everything that's been remembered, rather than searching for something specific.",
1159
- parameters: Type.Object({
1160
- userId: Type.Optional(
1161
- Type.String({
1162
- description:
1163
- "User ID to list memories for (default: configured userId)",
1164
- }),
1165
- ),
1166
- scope: Type.Optional(
1167
- Type.Union([
1168
- Type.Literal("session"),
1169
- Type.Literal("long-term"),
1170
- Type.Literal("all"),
1171
- ], {
1172
- description:
1173
- 'Memory scope: "session" (current session only), "long-term" (user-scoped only), or "all" (both). Default: "all"',
1174
- }),
1175
- ),
1176
- }),
1177
- async execute(_toolCallId, params) {
1178
- const effectiveCfg = resolveConfig(ctx);
1179
- const effectiveProvider = getProvider(effectiveCfg);
1180
- const { userId, scope = "all" } = params as { userId?: string; scope?: "session" | "long-term" | "all" };
1181
-
1182
- try {
1183
- const uid = userId || effectiveCfg.userId;
1184
- const memories = await effectiveProvider.getAll({ user_id: uid });
1185
-
1186
- if (!memories || memories.length === 0) {
1187
- return {
1188
- content: [
1189
- { type: "text", text: "No memories stored yet." },
1190
- ],
1191
- details: { count: 0 },
1192
- };
1193
- }
1194
-
1195
- const text = memories
1196
- .map(
1197
- (r, i) =>
1198
- `${i + 1}. ${r.content} (id: ${r.id})`,
1199
- )
1200
- .join("\n");
1201
-
1202
- const sanitized = memories.map((r) => ({
1203
- id: r.id,
1204
- content: r.content,
1205
- created_at: r.created_at,
1206
- }));
1207
-
1208
- return {
1209
- content: [
1210
- {
1211
- type: "text",
1212
- text: `${memories.length} memories:\n\n${text}`,
1213
- },
1214
- ],
1215
- details: { count: memories.length, memories: sanitized },
1216
- };
1217
- } catch (err) {
1218
- return {
1219
- content: [
1220
- {
1221
- type: "text",
1222
- text: `Memory list failed: ${String(err)}`,
1223
- },
1224
- ],
1225
- details: { error: String(err) },
1226
- };
1227
- }
1228
- },
1229
- }),
1230
- { name: "memory_list" },
1231
- );
1232
-
1233
- api.registerTool(
1234
- (ctx) => ({
1235
- name: "memory_forget",
1236
- label: "Memory Forget",
1237
- description:
1238
- "Delete a specific memory by ID from MemoryLake.",
1239
- parameters: Type.Object({
1240
- memoryId: Type.String({ description: "Memory ID to delete" }),
1241
- }),
1242
- async execute(_toolCallId, params) {
1243
- const effectiveCfg = resolveConfig(ctx);
1244
- const effectiveProvider = getProvider(effectiveCfg);
1245
- const { memoryId } = params as { memoryId: string };
1246
-
1247
- try {
1248
- await effectiveProvider.delete(memoryId);
1249
- return {
1250
- content: [
1251
- { type: "text", text: `Memory ${memoryId} forgotten.` },
1252
- ],
1253
- details: { action: "deleted", id: memoryId },
1254
- };
1255
- } catch (err) {
1256
- return {
1257
- content: [
1258
- {
1259
- type: "text",
1260
- text: `Memory forget failed: ${String(err)}`,
1261
- },
1262
- ],
1263
- details: { error: String(err) },
1264
- };
1265
- }
1266
- },
1267
- }),
1268
- { name: "memory_forget" },
1269
- );
1270
-
1271
- api.registerTool(
1272
- (ctx) => ({
1273
- name: "document_search",
1274
- label: "Document Search",
1275
- description:
1276
- "Search through documents stored in MemoryLake project. Returns relevant paragraphs, tables, and figures from uploaded documents.",
1277
- parameters: Type.Object({
1278
- query: Type.String({ description: "Search query" }),
1279
- topN: Type.Optional(
1280
- Type.Number({
1281
- description: `Max results (default: ${cfg.topK})`,
1282
- minimum: 1,
1283
- }),
1284
- ),
1285
- }),
1286
- async execute(_toolCallId, params) {
1287
- const effectiveCfg = resolveConfig(ctx);
1288
- const effectiveProvider = getProvider(effectiveCfg);
1289
- const { query, topN } = params as { query: string; topN?: number };
1290
-
1291
- try {
1292
- const response = await effectiveProvider.searchDocuments(
1293
- query,
1294
- topN ?? effectiveCfg.topK,
1295
- );
1296
-
1297
- if (!response.results || response.results.length === 0) {
1298
- return {
1299
- content: [
1300
- { type: "text", text: "No relevant documents found." },
1301
- ],
1302
- details: { count: 0 },
1303
- };
1304
- }
1305
-
1306
- const context = buildDocumentContext(response.results);
1307
-
1308
- return {
1309
- content: [
1310
- {
1311
- type: "text",
1312
- text: `Found ${response.results.length} document results:\n\n${context}`,
1313
- },
1314
- ],
1315
- details: { count: response.results.length },
1316
- };
1317
- } catch (err) {
1318
- return {
1319
- content: [
1320
- {
1321
- type: "text",
1322
- text: `Document search failed: ${String(err)}`,
1323
- },
1324
- ],
1325
- details: { error: String(err) },
1326
- };
1327
- }
1328
- },
1329
- }),
1330
- { name: "document_search" },
1331
- );
1332
-
1333
- /**
1334
- * Try to extract a usable filename from a pre-signed download URL.
1335
- * Returns null if the URL doesn't contain a recognizable filename.
1336
- */
1337
- function fileNameFromUrl(urlStr: string): string | null {
1338
- try {
1339
- const p = new URL(urlStr).pathname;
1340
- const base = path.posix.basename(p);
1341
- if (base && /\.\w{1,10}$/.test(base)) return decodeURIComponent(base);
1342
- } catch { /* ignore */ }
1343
- return null;
1344
- }
1345
-
1346
- api.registerTool(
1347
- (ctx) => ({
1348
- name: "document_download",
1349
- label: "Document Download",
1350
- description:
1351
- "Download a document (image, PDF, etc.) from MemoryLake to local disk. After calling this tool, you MUST call the `message` tool with action='send' and media=<the returned local file path> to deliver the file to the user.",
1352
- parameters: Type.Object({
1353
- documentId: Type.String({
1354
- description:
1355
- "The document ID to download (from document_search results or document listing)",
1356
- }),
1357
- fileName: Type.Optional(
1358
- Type.String({
1359
- description:
1360
- "Original file name for saving locally (e.g. 'report.pdf'). Falls back to the name in the download URL or the document ID.",
1361
- }),
1362
- ),
1363
- }),
1364
- async execute(_toolCallId, params) {
1365
- const effectiveCfg = resolveConfig(ctx);
1366
- const effectiveProvider = getProvider(effectiveCfg);
1367
- const { documentId, fileName } = params as {
1368
- documentId: string;
1369
- fileName?: string;
1370
- };
1371
-
1372
- try {
1373
- // 1. Get pre-signed download URL
1374
- const downloadUrl =
1375
- await effectiveProvider.getDocumentDownloadUrl(documentId);
1376
-
1377
- // 2. Determine local save directory (cross-platform)
1378
- const workspaceDir = (ctx as any)?.workspaceDir;
1379
- const downloadDir = workspaceDir
1380
- ? path.join(workspaceDir, ".memorylake", "downloads")
1381
- : path.join(os.tmpdir(), "memorylake-downloads");
1382
- fs.mkdirSync(downloadDir, { recursive: true });
1383
-
1384
- // 3. Determine filename: explicit param > URL-derived > documentId
1385
- const finalName =
1386
- fileName || fileNameFromUrl(downloadUrl) || documentId;
1387
- const localPath = path.join(downloadDir, finalName);
1388
-
1389
- // 4. Stream download to local file
1390
- await pipeline(
1391
- got.stream(downloadUrl),
1392
- fs.createWriteStream(localPath),
1393
- );
1394
-
1395
- return {
1396
- content: [
1397
- {
1398
- type: "text",
1399
- text: `Document ${documentId} downloaded to:\n${localPath}\n\nYou MUST now call the message tool with action="send" and media set to this local path to deliver the file to the user.`,
1400
- },
1401
- ],
1402
- details: { documentId, localPath },
1403
- };
1404
- } catch (err) {
1405
- return {
1406
- content: [
1407
- {
1408
- type: "text",
1409
- text: `Document download failed: ${String(err)}`,
1410
- },
1411
- ],
1412
- details: { error: String(err) },
1413
- };
1414
- }
1415
- },
1416
- }),
1417
- { name: "document_download" },
1418
- );
1419
-
1420
- api.registerTool(
1421
- (ctx) => ({
1422
- name: "advanced_web_search",
1423
- label: "Advanced Web Search",
1424
- description:
1425
- "Search the web using the unified search API with plugin-level domain and location constraints. Use this for recent information, public web pages, or web research that should respect configured allowed domains, blocked domains, and user locale.",
1426
- parameters: Type.Object({
1427
- query: Type.String({
1428
- description:
1429
- "The web search query to send to the unified search endpoint.",
1430
- }),
1431
- domain: Type.Optional(
1432
- Type.Union(
1433
- [
1434
- Type.Literal("web"),
1435
- Type.Literal("academic"),
1436
- Type.Literal("news"),
1437
- Type.Literal("people"),
1438
- Type.Literal("company"),
1439
- Type.Literal("financial"),
1440
- Type.Literal("markets"),
1441
- Type.Literal("code"),
1442
- Type.Literal("legal"),
1443
- Type.Literal("government"),
1444
- Type.Literal("poi"),
1445
- Type.Literal("auto"),
1446
- ],
1447
- {
1448
- description:
1449
- "Search domain. Default: web. Invalid or unknown values are treated as auto.",
1450
- },
1451
- ),
1452
- ),
1453
- maxResults: Type.Optional(
1454
- Type.Number({
1455
- description: `Maximum number of web results to return (default: ${cfg.topK}).`,
1456
- minimum: 1,
1457
- }),
1458
- ),
1459
- startDate: Type.Optional(
1460
- Type.String({
1461
- description:
1462
- "Only include results published on or after this date (YYYY-MM-DD).",
1463
- }),
1464
- ),
1465
- endDate: Type.Optional(
1466
- Type.String({
1467
- description:
1468
- "Only include results published on or before this date (YYYY-MM-DD).",
1469
- }),
1470
- ),
1471
- }),
1472
- async execute(_toolCallId, params) {
1473
- const effectiveCfg = resolveConfig(ctx);
1474
- const effectiveProvider = getProvider(effectiveCfg);
1475
- const {
1476
- query,
1477
- domain: rawDomain,
1478
- maxResults,
1479
- startDate,
1480
- endDate,
1481
- } = params as {
1482
- query: string;
1483
- domain?: string;
1484
- maxResults?: number;
1485
- startDate?: string;
1486
- endDate?: string;
1487
- };
1488
- const domain: WebSearchDomain =
1489
- rawDomain === undefined || rawDomain === null
1490
- ? "web"
1491
- : normalizeWebSearchDomain(rawDomain);
1492
-
1493
- try {
1494
- const response = await effectiveProvider.searchWeb(query, {
1495
- domain,
1496
- max_results: maxResults ?? effectiveCfg.topK,
1497
- start_date: startDate,
1498
- end_date: endDate,
1499
- include_domains: effectiveCfg.webSearchIncludeDomains,
1500
- exclude_domains: effectiveCfg.webSearchExcludeDomains,
1501
- user_location:
1502
- effectiveCfg.webSearchCountry || effectiveCfg.webSearchTimezone
1503
- ? {
1504
- country: effectiveCfg.webSearchCountry,
1505
- timezone: effectiveCfg.webSearchTimezone,
1506
- }
1507
- : undefined,
1508
- });
1509
-
1510
- if (!response.results || response.results.length === 0) {
1511
- return {
1512
- content: [
1513
- { type: "text", text: "No relevant web results found." },
1514
- ],
1515
- details: { count: 0, total_results: response.total_results },
1516
- };
1517
- }
1518
-
1519
- const context = buildWebSearchContext(response.results);
1520
-
1521
- return {
1522
- content: [
1523
- {
1524
- type: "text",
1525
- text: `Found ${response.results.length} web results:\n\n${context}`,
1526
- },
1527
- ],
1528
- details: {
1529
- count: response.results.length,
1530
- total_results: response.total_results,
1531
- results: response.results,
1532
- },
1533
- };
1534
- } catch (err) {
1535
- return {
1536
- content: [
1537
- {
1538
- type: "text",
1539
- text: `Web search failed: ${String(err)}`,
1540
- },
1541
- ],
1542
- details: { error: String(err) },
1543
- };
1544
- }
1545
- },
1546
- }),
1547
- { optional: true },
1548
- );
1549
-
1550
- api.registerTool(
1551
- (ctx) => ({
1552
- name: "open_data_search",
1553
- label: "Open Data Search",
1554
- description:
1555
- "Search across open datasets routed to the appropriate proprietary data source based on the dataset:\n- research/academic: arXiv, PubMed, bioRxiv, medRxiv\n- clinical/trials: Clinical trial registries\n- drug/database: ChEMBL, DrugBank, PubChem, etc.\n- financial/markets: Stocks, crypto, forex, funds, commodities\n- company/fundamentals: SEC filings, earnings, balance sheets, etc.\n- economic/data: FRED, BLS, World Bank, etc.\n- patents/ip: USPTO patents",
1556
- parameters: Type.Object({
1557
- query: Type.String({
1558
- description: "The search query to send to the open data endpoint.",
1559
- }),
1560
- dataset: Type.Union(
1561
- [
1562
- Type.Literal("research/academic"),
1563
- Type.Literal("clinical/trials"),
1564
- Type.Literal("drug/database"),
1565
- Type.Literal("financial/markets"),
1566
- Type.Literal("company/fundamentals"),
1567
- Type.Literal("economic/data"),
1568
- Type.Literal("patents/ip"),
1569
- ],
1570
- {
1571
- description:
1572
- "Dataset category to search. Must be one of the project's enabled categories.",
1573
- },
1574
- ),
1575
- maxResults: Type.Optional(
1576
- Type.Number({
1577
- description: `Maximum number of results to return (default: ${cfg.topK}). The server enforces a hard cap.`,
1578
- minimum: 1,
1579
- }),
1580
- ),
1581
- startDate: Type.Optional(
1582
- Type.String({
1583
- description: "Only include results published on or after this date (YYYY-MM-DD).",
1584
- }),
1585
- ),
1586
- endDate: Type.Optional(
1587
- Type.String({
1588
- description: "Only include results published on or before this date (YYYY-MM-DD).",
1589
- }),
1590
- ),
1591
- }),
1592
- async execute(_toolCallId, params) {
1593
- const effectiveCfg = resolveConfig(ctx);
1594
- const effectiveProvider = getProvider(effectiveCfg);
1595
- const {
1596
- query,
1597
- dataset: rawDataset,
1598
- maxResults,
1599
- startDate,
1600
- endDate,
1601
- } = params as {
1602
- query: string;
1603
- dataset: string;
1604
- maxResults?: number;
1605
- startDate?: string;
1606
- endDate?: string;
1607
- };
1608
-
1609
- // Normalize once; use throughout to avoid casing bugs
1610
- const dataset = normalizeOpenDataCategory(rawDataset);
1611
-
1612
- if (!dataset) {
1613
- return {
1614
- content: [
1615
- {
1616
- type: "text",
1617
- text: `Unsupported dataset: "${rawDataset}". Supported values are: ${OpenDataCategoryValues.join(", ")}`,
1618
- },
1619
- ],
1620
- details: { error: "unsupported_dataset", dataset: rawDataset },
1621
- };
1622
- }
1623
-
1624
- try {
1625
- // Validate dataset against project's allowed industries
1626
- const projectInfo = await effectiveProvider.getProject();
1627
- if (projectInfo.industries.length > 0) {
1628
- const allowedIds = projectInfo.industries.map((ind) => ind.id);
1629
- if (!allowedIds.includes(dataset)) {
1630
- const allowed = projectInfo.industries
1631
- .map((ind) => `${ind.id} (${ind.name})`)
1632
- .join(", ");
1633
- return {
1634
- content: [
1635
- {
1636
- type: "text",
1637
- text: `Dataset "${dataset}" is not enabled for this project. Allowed datasets: ${allowed}`,
1638
- },
1639
- ],
1640
- details: {
1641
- error: "dataset_not_allowed",
1642
- dataset,
1643
- allowed_datasets: allowedIds,
1644
- },
1645
- };
1646
- }
1647
- }
1648
-
1649
- const response = await effectiveProvider.searchOpenData(query, {
1650
- dataset,
1651
- max_results: maxResults ?? effectiveCfg.topK,
1652
- start_date: startDate,
1653
- end_date: endDate,
1654
- });
1655
-
1656
- if (!response.results || response.results.length === 0) {
1657
- return {
1658
- content: [
1659
- { type: "text", text: "No relevant open data results found." },
1660
- ],
1661
- details: { count: 0, total_results: response.total_results },
1662
- };
1663
- }
1664
-
1665
- const context = buildOpenDataContext(response.results);
1666
-
1667
- return {
1668
- content: [
1669
- {
1670
- type: "text",
1671
- text: `Found ${response.results.length} open data results:\n\n${context}`,
1672
- },
1673
- ],
1674
- details: {
1675
- count: response.results.length,
1676
- total_results: response.total_results,
1677
- results: response.results,
1678
- },
1679
- };
1680
- } catch (err) {
1681
- return {
1682
- content: [
1683
- {
1684
- type: "text",
1685
- text: `Open data search failed: ${String(err)}`,
1686
- },
1687
- ],
1688
- details: { error: String(err) },
1689
- };
1690
- }
1691
- },
1692
- }),
1693
- );
1694
-
1695
- // ========================================================================
1696
- // CLI Commands
1697
- // ========================================================================
1698
-
1699
- api.registerCli(
1700
- ({ program }) => {
1701
- const memorylake = program
1702
- .command("memorylake")
1703
- .description("MemoryLake memory plugin commands");
1704
-
1705
- memorylake
1706
- .command("search")
1707
- .description("Search memories in MemoryLake")
1708
- .argument("<query>", "Search query")
1709
- .option("--limit <n>", "Max results", String(cfg.topK))
1710
- .action(async (query: string, opts: { limit: string }) => {
1711
- try {
1712
- const limit = parseInt(opts.limit, 10);
1713
- const results = await provider.search(
1714
- query,
1715
- buildSearchOptions(cfg, undefined, limit),
1716
- );
1717
-
1718
- if (!results.length) {
1719
- console.log("No memories found.");
1720
- return;
1721
- }
1722
-
1723
- const output = results.map((r) => ({
1724
- id: r.id,
1725
- content: r.content,
1726
- user_id: r.user_id,
1727
- created_at: r.created_at,
1728
- }));
1729
- console.log(JSON.stringify(output, null, 2));
1730
- } catch (err) {
1731
- console.error(`Search failed: ${String(err)}`);
1732
- }
1733
- });
1734
-
1735
- memorylake
1736
- .command("upload")
1737
- .description("Upload files or directories to MemoryLake")
1738
- .argument("<path>", "File or directory path to upload")
1739
- .option("--agent <id>", "Agent ID (resolves workspace and per-agent projectId)")
1740
- .option("--project-id <id>", "Override project ID (takes precedence over --agent)")
1741
- .action(async (targetPath: string, opts: { agent?: string; projectId?: string }) => {
1742
- // Resolve effective config: --project-id > agent workspace config > global config
1743
- let effectiveCfg = cfg;
1744
- if (opts.agent) {
1745
- try {
1746
- const openclawPath = path.join(os.homedir(), ".openclaw", "openclaw.json");
1747
- const openclaw = JSON.parse(fs.readFileSync(openclawPath, "utf-8"));
1748
- const agents = openclaw?.agents;
1749
- const agentEntry = agents?.list?.find((a: any) => a.id === opts.agent);
1750
- const workspace = agentEntry?.workspace || agents?.defaults?.workspace;
1751
- if (workspace) {
1752
- effectiveCfg = resolveConfig({ workspaceDir: workspace });
1753
- } else {
1754
- console.warn(`Warning: no workspace found for agent "${opts.agent}", using global config.`);
1755
- }
1756
- } catch (err) {
1757
- console.warn(`Warning: failed to resolve agent config: ${String(err)}, using global config.`);
1758
- }
1759
- }
1760
- const effectiveProjectId = opts.projectId || effectiveCfg.projectId;
1761
- if (!effectiveProjectId) {
1762
- console.error("No project ID configured. Use --project-id or set up agent/workspace config.");
1763
- return;
1764
- }
1765
- if (!effectiveCfg.host || !effectiveCfg.apiKey) {
1766
- console.error("Missing host or apiKey in config. Check your MemoryLake configuration.");
1767
- return;
1768
- }
1769
-
1770
- // Lazy import upload.mjs (use uploadAuto to support archives)
1771
- let uploadFn: UploadFn;
1772
- try {
1773
- const uploadModule = await import(
1774
- /* webpackIgnore: true */
1775
- new URL("./skills/memorylake-upload/scripts/upload.mjs", import.meta.url).href
1776
- );
1777
- uploadFn = uploadModule.uploadAuto;
1778
- } catch (err) {
1779
- console.error(`Failed to load upload module: ${String(err)}`);
1780
- return;
1781
- }
1782
-
1783
- const absPath = path.resolve(targetPath);
1784
-
1785
- try {
1786
- await uploadFn({
1787
- host: effectiveCfg.host,
1788
- apiKey: effectiveCfg.apiKey,
1789
- projectId: effectiveProjectId,
1790
- filePath: absPath,
1791
- fileName: path.basename(absPath),
1792
- });
1793
- } catch (err) {
1794
- console.error(`Upload failed: ${String(err)}`);
1795
- }
1796
- });
1797
-
1798
- memorylake
1799
- .command("stats")
1800
- .description("Show memory statistics from MemoryLake")
1801
- .action(async () => {
1802
- try {
1803
- const memories = await provider.getAll({
1804
- user_id: cfg.userId,
1805
- });
1806
- console.log(`User: ${cfg.userId}`);
1807
- console.log(
1808
- `Total memories: ${Array.isArray(memories) ? memories.length : "unknown"}`,
1809
- );
1810
- console.log(
1811
- `Auto-recall: ${cfg.autoRecall}, Auto-capture: ${cfg.autoCapture}`,
1812
- );
1813
- } catch (err) {
1814
- console.error(`Stats failed: ${String(err)}`);
1815
- }
1816
- });
1817
- },
1818
- { commands: ["memorylake"] },
1819
- );
1820
-
1821
- // ========================================================================
1822
- // Lifecycle Hooks
1823
- // ========================================================================
1824
-
1825
- // --- Auto-upload helpers ---
1826
- const UPLOADED_RECORD_FILE = "uploaded.json";
1827
-
1828
- type UploadedRecord = Record<string, { mtimeMs: number }>;
1829
-
1830
- function getUploadedRecord(workspaceDir: string): UploadedRecord {
1831
- const filePath = path.join(workspaceDir, ".memorylake", UPLOADED_RECORD_FILE);
1832
- try {
1833
- if (fs.existsSync(filePath)) {
1834
- const data = JSON.parse(fs.readFileSync(filePath, "utf-8"));
1835
- return data && typeof data === "object" && !Array.isArray(data) ? data : {};
1836
- }
1837
- } catch (err) {
1838
- api.logger.warn(`memorylake-openclaw: failed to read uploaded record: ${String(err)}`);
1839
- }
1840
- return {};
1841
- }
1842
-
1843
- function saveUploadedRecord(workspaceDir: string, record: UploadedRecord): void {
1844
- const dirPath = path.join(workspaceDir, ".memorylake");
1845
- if (!fs.existsSync(dirPath)) fs.mkdirSync(dirPath, { recursive: true });
1846
- fs.writeFileSync(
1847
- path.join(dirPath, UPLOADED_RECORD_FILE),
1848
- JSON.stringify(record, null, 2),
1849
- );
1850
- }
1851
-
1852
- function needsUpload(record: UploadedRecord, filePath: string): fs.Stats | null {
1853
- if (!fs.existsSync(filePath)) return null;
1854
- const stat = fs.statSync(filePath);
1855
- const prev = record[filePath];
1856
- return (!prev || prev.mtimeMs !== stat.mtimeMs) ? stat : null;
1857
- }
1858
-
1859
- function extractInboundPaths(prompt: string): string[] {
1860
- // Path must contain /media/inbound/ (or \media\inbound\)
1861
- // Filename must end with .<ext>, ext = alphanumeric, 1-6 chars
1862
- const sep = '[/\\\\]';
1863
- const regex = new RegExp(
1864
- `(?:[A-Za-z]:${sep}|/)\\S*?media${sep}inbound${sep}.+?\\.[a-zA-Z0-9]{1,6}(?=[^a-zA-Z0-9]|$)`,
1865
- "g",
1866
- );
1867
- const matches = prompt.match(regex) || [];
1868
- return [...new Set(matches)];
1869
- }
1870
-
1871
- // Auto-upload: upload inbound files to MemoryLake before prompt build
1872
- if (cfg.autoUpload) {
1873
- // Lazy-load upload function from upload.mjs
1874
- let uploadAutoFn: UploadFn | undefined;
1875
-
1876
- api.on("before_prompt_build", (event, ctx) => {
1877
- if ((ctx as any)?.trigger !== "user") {
1878
- api.logger.info(`memorylake-openclaw: auto-upload skipped, trigger=${(ctx as any)?.trigger ?? "undefined"}`);
1879
- return;
1880
- }
1881
- const workspaceDir = (ctx as any)?.workspaceDir;
1882
- if (!workspaceDir || !event.prompt) return;
1883
-
1884
- const effectiveCfg = resolveConfig(ctx);
1885
- const paths = extractInboundPaths(event.prompt);
1886
- if (paths.length === 0) return;
1887
-
1888
- const record = getUploadedRecord(workspaceDir);
1889
- const filesToUpload: { filePath: string; stat: fs.Stats }[] = [];
1890
- for (const p of paths) {
1891
- const stat = needsUpload(record, p);
1892
- if (stat) filesToUpload.push({ filePath: p, stat });
1893
- }
1894
- if (filesToUpload.length === 0) return;
1895
-
1896
- // Fire-and-forget: upload asynchronously without blocking
1897
- (async () => {
1898
- // Lazy import upload.mjs
1899
- if (!uploadAutoFn) {
1900
- const uploadModule = await import(
1901
- /* webpackIgnore: true */
1902
- new URL("./skills/memorylake-upload/scripts/upload.mjs", import.meta.url).href
1903
- );
1904
- uploadAutoFn = uploadModule.uploadAuto;
1905
- }
1906
-
1907
- for (const { filePath, stat } of filesToUpload) {
1908
- try {
1909
- await uploadAutoFn!({
1910
- host: effectiveCfg.host,
1911
- apiKey: effectiveCfg.apiKey,
1912
- projectId: effectiveCfg.projectId,
1913
- filePath,
1914
- fileName: path.basename(filePath),
1915
- });
1916
- // Save record only after successful upload to avoid race on crash
1917
- const current = getUploadedRecord(workspaceDir);
1918
- current[filePath] = { mtimeMs: stat.mtimeMs };
1919
- saveUploadedRecord(workspaceDir, current);
1920
- api.logger.info(
1921
- `memorylake-openclaw: auto-uploaded ${path.basename(filePath)}`,
1922
- );
1923
- } catch (err) {
1924
- api.logger.warn(
1925
- `memorylake-openclaw: auto-upload failed for ${filePath}: ${String(err)}`,
1926
- );
1927
- }
1928
- }
1929
- })().catch((err) => {
1930
- api.logger.warn(
1931
- `memorylake-openclaw: auto-upload unexpected error: ${String(err)}`,
1932
- );
1933
- });
1934
- });
1935
- }
1936
-
1937
- // ------------------------------------------------------------------
1938
- // LLM Query Rewrite Helpers
1939
- // ------------------------------------------------------------------
1940
-
1941
- /**
1942
- * Summarize recent session messages into a compact text block for the rewrite prompt.
1943
- * Messages are unknown[] from the hook event — we extract role+content from each.
1944
- */
1945
- function summarizeMessages(messages: unknown[], maxMessages = 10): string {
1946
- if (!messages || messages.length === 0) return "";
1947
- const recent = messages.slice(-maxMessages);
1948
- return recent
1949
- .map((m: any) => {
1950
- const role = m?.role ?? "user";
1951
- const content =
1952
- typeof m?.content === "string"
1953
- ? m.content
1954
- : JSON.stringify(m?.content ?? "");
1955
- return `[${role}]: ${content}`;
1956
- })
1957
- .join("\n");
1958
- }
1959
-
1960
- // (loadCoreAgentDeps is defined at module scope above)
1961
-
1962
- /**
1963
- * Resolve provider/model from config. Returns undefined for both if not found
1964
- * (openclaw will use its own defaults).
1965
- */
1966
- function resolveProviderModel(): { provider: string | undefined; model: string | undefined } {
1967
- const modelPrimary = (api.config as any)?.agents?.defaults?.model?.primary as string | undefined;
1968
- if (modelPrimary) {
1969
- const slashIdx = modelPrimary.indexOf("/");
1970
- if (slashIdx >= 0) {
1971
- return { provider: modelPrimary.slice(0, slashIdx), model: modelPrimary.slice(slashIdx + 1) };
1972
- }
1973
- return { provider: undefined, model: modelPrimary };
1974
- }
1975
- return { provider: undefined, model: undefined };
1976
- }
1977
-
1978
- /**
1979
- * Rewrite the user's prompt into a search-optimized query using
1980
- * openclaw's runEmbeddedPiAgent, considering conversation history.
1981
- *
1982
- * Priority: api.runtime.agent.runEmbeddedPiAgent → loadCoreAgentDeps()
1983
- */
1984
- async function rewriteQueryForSearch(
1985
- originalPrompt: string,
1986
- messages: unknown[],
1987
- ctx: { workspaceDir?: string },
1988
- ): Promise<string> {
1989
- if (!ctx.workspaceDir) {
1990
- api.logger.warn("memorylake-openclaw: no workspaceDir, skipping query rewrite");
1991
- return originalPrompt;
1992
- }
1993
-
1994
- const conversationHistory = summarizeMessages(messages);
1995
- const systemPrompt =
1996
- "You are a search query optimizer. Extract the key search intent and produce a concise, search-optimized query. Output ONLY the rewritten query, nothing else. Preserve important entities, names, dates, and technical terms.";
1997
- const userContent = conversationHistory
1998
- ? `Conversation history:\n${conversationHistory}\n\nUser's latest message:\n${originalPrompt}`
1999
- : originalPrompt;
2000
- const fullPrompt = `${systemPrompt}\n\n${userContent}`;
2001
-
2002
- const { provider, model } = resolveProviderModel();
2003
- api.logger.info(`memorylake-openclaw: rewriting query via runEmbeddedPiAgent (provider=${provider}, model=${model})`);
2004
-
2005
- let tempSessionFile: string | null = null;
2006
- try {
2007
- const tempDir = await fsPromises.mkdtemp(path.join(os.tmpdir(), "memorylake-rewrite-"));
2008
- tempSessionFile = path.join(tempDir, "session.jsonl");
2009
-
2010
- const nowMs = Date.now();
2011
- const callParams = {
2012
- sessionId: `memorylake-rewrite-${nowMs}`,
2013
- sessionKey: `temp:memorylake-rewrite`,
2014
- sessionFile: tempSessionFile,
2015
- workspaceDir: ctx.workspaceDir,
2016
- config: api.config,
2017
- prompt: fullPrompt,
2018
- provider,
2019
- model,
2020
- disableTools: true,
2021
- timeoutMs: 15_000,
2022
- runId: `memorylake-rewrite-${nowMs}`,
2023
- lane: `memorylake-rewrite`,
2024
- trigger: "memory",
2025
- };
2026
-
2027
- // Priority 1: try api.runtime.agent.runEmbeddedPiAgent
2028
- let runEmbeddedPiAgent: ((p: typeof callParams) => Promise<any>) | undefined =
2029
- (api.runtime as any)?.agent?.runEmbeddedPiAgent;
2030
-
2031
- if (typeof runEmbeddedPiAgent !== "function") {
2032
- api.logger.info("memorylake-openclaw: api.runtime.agent.runEmbeddedPiAgent not available, using loadCoreAgentDeps fallback");
2033
- const deps = await loadCoreAgentDeps();
2034
- runEmbeddedPiAgent = deps.runEmbeddedPiAgent;
2035
- }
2036
-
2037
- const result = await runEmbeddedPiAgent(callParams);
2038
-
2039
- const rewritten = result?.payloads?.[0]?.text?.trim();
2040
- if (rewritten && rewritten.length > 0) {
2041
- api.logger.info(`memorylake-openclaw: rewritten query: "${rewritten}"`);
2042
- return rewritten;
2043
- }
2044
- api.logger.warn("memorylake-openclaw: rewrite returned empty, using original");
2045
- } catch (err) {
2046
- api.logger.warn(`memorylake-openclaw: query rewrite failed, using original: ${String(err)}`);
2047
- } finally {
2048
- if (tempSessionFile) {
2049
- try {
2050
- await fsPromises.rm(path.dirname(tempSessionFile), { recursive: true, force: true });
2051
- } catch (cleanupErr) {
2052
- api.logger.warn(`memorylake-openclaw: temp session cleanup failed: ${String(cleanupErr)}`);
2053
- }
2054
- }
2055
- }
2056
- return originalPrompt;
2057
- }
2058
-
2059
- // ------------------------------------------------------------------
2060
- // Auto-recall: inject system-level memory instructions and open data
2061
- // categories before prompt build. Memory content is NOT pre-fetched;
2062
- // the model is instructed to call memory_search itself.
2063
- // ------------------------------------------------------------------
2064
- if (cfg.autoRecall) {
2065
- api.on("before_prompt_build", async (event, ctx) => {
2066
- if ((ctx as any)?.trigger !== "user") {
2067
- api.logger.info(`memorylake-openclaw: auto-recall skipped, trigger=${(ctx as any)?.trigger ?? "undefined"}`);
2068
- return;
2069
- }
2070
- if (!event.prompt) return;
2071
-
2072
- const effectiveCfg = resolveConfig(ctx);
2073
- const effectiveProvider = getProvider(effectiveCfg);
2074
-
2075
- const sessionId = (ctx as any)?.sessionId ?? undefined;
2076
-
2077
- // Fetch industries once per session, then cache
2078
- let industries: OpenDataIndustry[] | undefined;
2079
- if (sessionId && sessionIndustriesCache.has(sessionId)) {
2080
- industries = sessionIndustriesCache.get(sessionId);
2081
- } else {
2082
- try {
2083
- const projectInfo = await effectiveProvider.getProject();
2084
- industries = projectInfo.industries;
2085
- if (sessionId) {
2086
- sessionIndustriesCache.set(sessionId, industries);
2087
- }
2088
- } catch (err) {
2089
- api.logger.warn(`memorylake-openclaw: project info fetch failed: ${String(err)}`);
2090
- }
2091
- }
2092
-
2093
- const result: { prependSystemContext?: string; appendSystemContext?: string } = {};
2094
-
2095
- const memoryDirectiveParts: string[] = [
2096
- `<memory-instructions>`,
2097
- `You have access to the user's long-term memory AND documents via MemoryLake (memory_search tool).`,
2098
- `The memory_search tool searches both memories and uploaded documents in a single call.`,
2099
- ``,
2100
- `MANDATORY PROCEDURE for EVERY user message:`,
2101
- `1. Read the user's message.`,
2102
- `2. Call memory_search with a query derived from the message — this step is NOT optional.`,
2103
- `3. Use the search results (memories + documents) to personalize your response.`,
2104
- `4. Respond to the user.`,
2105
- ``,
2106
- `This applies to ALL messages — greetings, recommendations, advice, casual chat, tasks, questions.`,
2107
- `The user's preferences, history, name, hobbies, context, and documents are stored in MemoryLake.`,
2108
- `A response without memory_search is INCORRECT because it ignores personalized context.`,
2109
- `</memory-instructions>`,
2110
- ];
2111
- result.prependSystemContext = memoryDirectiveParts.join("\n");
2112
-
2113
- const appendParts: string[] = [];
2114
- if (industries && industries.length > 0) {
2115
- const categoryList = industries
2116
- .map((ind) => `- ${ind.id}: ${ind.name}${ind.description ? ` — ${ind.description}` : ""}`)
2117
- .join("\n");
2118
- appendParts.push(
2119
- `<open-data-categories>\nThis project has access to the following open data categories via the open_data_search tool:\n${categoryList}\nWhen the user's question relates to any of these categories, use the open_data_search tool to retrieve relevant data.\n</open-data-categories>`,
2120
- );
2121
- api.logger.info(
2122
- `memorylake-openclaw: injecting ${industries.length} open data categories into system context`,
2123
- );
2124
- }
2125
-
2126
- if (appendParts.length > 0) {
2127
- result.appendSystemContext = appendParts.join("\n\n");
2128
- }
2129
-
2130
- return result;
2131
- });
2132
- }
2133
-
2134
- // Auto-capture: store conversation context after agent ends
2135
- if (cfg.autoCapture) {
2136
- api.on("agent_end", async (event, ctx) => {
2137
- if ((ctx as any)?.trigger !== "user") {
2138
- api.logger.info(`memorylake-openclaw: auto-capture skipped, trigger=${(ctx as any)?.trigger ?? "undefined"}`);
2139
- return;
2140
- }
2141
- if (!event.success || !event.messages || event.messages.length === 0) {
2142
- return;
2143
- }
2144
-
2145
- // Resolve per-workspace config override
2146
- const effectiveCfg = resolveConfig(ctx);
2147
- const effectiveProvider = getProvider(effectiveCfg);
2148
-
2149
- // Track session ID
2150
- const sessionId = (ctx as any)?.sessionId ?? undefined;
2151
-
2152
- try {
2153
- // Extract messages, limiting to last 10
2154
- const recentMessages = event.messages.slice(-10);
2155
- const formattedMessages: Array<{
2156
- role: string;
2157
- content: string;
2158
- }> = [];
2159
-
2160
- for (const msg of recentMessages) {
2161
- if (!msg || typeof msg !== "object") continue;
2162
- const msgObj = msg as Record<string, unknown>;
2163
-
2164
- const role = msgObj.role;
2165
- if (role !== "user" && role !== "assistant") continue;
2166
-
2167
- let textContent = "";
2168
- const content = msgObj.content;
2169
-
2170
- if (typeof content === "string") {
2171
- textContent = content;
2172
- } else if (Array.isArray(content)) {
2173
- for (const block of content) {
2174
- if (
2175
- block &&
2176
- typeof block === "object" &&
2177
- "text" in block &&
2178
- typeof (block as Record<string, unknown>).text === "string"
2179
- ) {
2180
- textContent +=
2181
- (textContent ? "\n" : "") +
2182
- ((block as Record<string, unknown>).text as string);
2183
- }
2184
- }
2185
- }
2186
-
2187
- if (!textContent) continue;
2188
- // Strip injected context, keep the actual user text
2189
- if (textContent.includes("<relevant-memories>")) {
2190
- textContent = textContent.replace(/<relevant-memories>[\s\S]*?<\/relevant-memories>\s*/g, "").trim();
2191
- }
2192
- if (textContent.includes("<memory-conflicts>")) {
2193
- textContent = textContent.replace(/<memory-conflicts>[\s\S]*?<\/memory-conflicts>\s*/g, "").trim();
2194
- }
2195
- if (textContent.includes("<relevant-documents>")) {
2196
- textContent = textContent.replace(/<relevant-documents>[\s\S]*?<\/relevant-documents>\s*/g, "").trim();
2197
- }
2198
-
2199
- if (!textContent) continue;
2200
-
2201
- formattedMessages.push({
2202
- role: role as string,
2203
- content: textContent,
2204
- });
2205
- }
2206
-
2207
- if (formattedMessages.length === 0) return;
2208
-
2209
- const addOpts = buildAddOptions(effectiveCfg, undefined, sessionId);
2210
- const result = await effectiveProvider.add(
2211
- formattedMessages,
2212
- addOpts,
2213
- );
2214
-
2215
- const capturedCount = result.results?.length ?? 0;
2216
- if (capturedCount > 0) {
2217
- api.logger.info(
2218
- `memorylake-openclaw: auto-captured ${capturedCount} memories`,
2219
- );
2220
- }
2221
- } catch (err) {
2222
- api.logger.warn(`memorylake-openclaw: capture failed: ${String(err)}`);
2223
- }
2224
- });
2225
- }
2226
-
2227
- // ========================================================================
2228
- // Service
2229
- // ========================================================================
43
+ registerMemoryTools(pctx, cfg);
44
+ registerDocumentTools(pctx);
45
+ registerSearchTools(pctx, cfg);
46
+ registerCli(pctx, cfg);
47
+ if (cfg.autoUpload) registerAutoUpload(pctx);
48
+ if (cfg.autoRecall) registerAutoRecall(pctx);
49
+ if (cfg.autoCapture) registerAutoCapture(pctx);
2230
50
 
2231
51
  api.registerService({
2232
52
  id: "memorylake-openclaw",