@memtensor/memos-local-openclaw-plugin 0.1.2 → 0.1.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (77) hide show
  1. package/.env.example +13 -5
  2. package/README.md +180 -68
  3. package/dist/capture/index.d.ts +5 -7
  4. package/dist/capture/index.d.ts.map +1 -1
  5. package/dist/capture/index.js +72 -43
  6. package/dist/capture/index.js.map +1 -1
  7. package/dist/ingest/providers/anthropic.d.ts +2 -0
  8. package/dist/ingest/providers/anthropic.d.ts.map +1 -1
  9. package/dist/ingest/providers/anthropic.js +110 -1
  10. package/dist/ingest/providers/anthropic.js.map +1 -1
  11. package/dist/ingest/providers/bedrock.d.ts +2 -5
  12. package/dist/ingest/providers/bedrock.d.ts.map +1 -1
  13. package/dist/ingest/providers/bedrock.js +110 -6
  14. package/dist/ingest/providers/bedrock.js.map +1 -1
  15. package/dist/ingest/providers/gemini.d.ts +2 -0
  16. package/dist/ingest/providers/gemini.d.ts.map +1 -1
  17. package/dist/ingest/providers/gemini.js +106 -1
  18. package/dist/ingest/providers/gemini.js.map +1 -1
  19. package/dist/ingest/providers/index.d.ts +9 -0
  20. package/dist/ingest/providers/index.d.ts.map +1 -1
  21. package/dist/ingest/providers/index.js +66 -4
  22. package/dist/ingest/providers/index.js.map +1 -1
  23. package/dist/ingest/providers/openai.d.ts +2 -0
  24. package/dist/ingest/providers/openai.d.ts.map +1 -1
  25. package/dist/ingest/providers/openai.js +112 -1
  26. package/dist/ingest/providers/openai.js.map +1 -1
  27. package/dist/ingest/task-processor.d.ts +63 -0
  28. package/dist/ingest/task-processor.d.ts.map +1 -0
  29. package/dist/ingest/task-processor.js +339 -0
  30. package/dist/ingest/task-processor.js.map +1 -0
  31. package/dist/ingest/worker.d.ts +1 -1
  32. package/dist/ingest/worker.d.ts.map +1 -1
  33. package/dist/ingest/worker.js +18 -13
  34. package/dist/ingest/worker.js.map +1 -1
  35. package/dist/recall/engine.d.ts +1 -0
  36. package/dist/recall/engine.d.ts.map +1 -1
  37. package/dist/recall/engine.js +21 -11
  38. package/dist/recall/engine.js.map +1 -1
  39. package/dist/recall/mmr.d.ts.map +1 -1
  40. package/dist/recall/mmr.js +3 -1
  41. package/dist/recall/mmr.js.map +1 -1
  42. package/dist/storage/sqlite.d.ts +67 -1
  43. package/dist/storage/sqlite.d.ts.map +1 -1
  44. package/dist/storage/sqlite.js +251 -5
  45. package/dist/storage/sqlite.js.map +1 -1
  46. package/dist/types.d.ts +15 -0
  47. package/dist/types.d.ts.map +1 -1
  48. package/dist/types.js +2 -0
  49. package/dist/types.js.map +1 -1
  50. package/dist/viewer/html.d.ts +1 -1
  51. package/dist/viewer/html.d.ts.map +1 -1
  52. package/dist/viewer/html.js +955 -115
  53. package/dist/viewer/html.js.map +1 -1
  54. package/dist/viewer/server.d.ts +3 -0
  55. package/dist/viewer/server.d.ts.map +1 -1
  56. package/dist/viewer/server.js +59 -1
  57. package/dist/viewer/server.js.map +1 -1
  58. package/index.ts +221 -45
  59. package/openclaw.plugin.json +20 -45
  60. package/package.json +3 -4
  61. package/skill/SKILL.md +59 -0
  62. package/src/capture/index.ts +85 -45
  63. package/src/ingest/providers/anthropic.ts +128 -1
  64. package/src/ingest/providers/bedrock.ts +130 -6
  65. package/src/ingest/providers/gemini.ts +128 -1
  66. package/src/ingest/providers/index.ts +74 -8
  67. package/src/ingest/providers/openai.ts +130 -1
  68. package/src/ingest/task-processor.ts +380 -0
  69. package/src/ingest/worker.ts +21 -15
  70. package/src/recall/engine.ts +22 -12
  71. package/src/recall/mmr.ts +3 -1
  72. package/src/storage/sqlite.ts +298 -5
  73. package/src/types.ts +19 -0
  74. package/src/viewer/html.ts +955 -115
  75. package/src/viewer/server.ts +63 -1
  76. package/SKILL.md +0 -43
  77. package/www/index.html +0 -606
@@ -1,7 +1,8 @@
1
1
  import Database from "better-sqlite3";
2
+ import { createHash } from "crypto";
2
3
  import * as fs from "fs";
3
4
  import * as path from "path";
4
- import type { Chunk, ChunkRef, Logger } from "../types";
5
+ import type { Chunk, ChunkRef, Task, TaskStatus, Logger } from "../types";
5
6
 
6
7
  export class SqliteStore {
7
8
  private db: Database.Database;
@@ -69,16 +70,140 @@ export class SqliteStore {
69
70
  dimensions INTEGER NOT NULL,
70
71
  updated_at INTEGER NOT NULL
71
72
  );
73
+
74
+ CREATE TABLE IF NOT EXISTS viewer_events (
75
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
76
+ event_type TEXT NOT NULL,
77
+ created_at INTEGER NOT NULL
78
+ );
79
+ CREATE INDEX IF NOT EXISTS idx_viewer_events_created ON viewer_events(created_at);
80
+ CREATE INDEX IF NOT EXISTS idx_viewer_events_type ON viewer_events(event_type);
81
+
82
+ CREATE TABLE IF NOT EXISTS tasks (
83
+ id TEXT PRIMARY KEY,
84
+ session_key TEXT NOT NULL,
85
+ title TEXT NOT NULL DEFAULT '',
86
+ summary TEXT NOT NULL DEFAULT '',
87
+ status TEXT NOT NULL DEFAULT 'active',
88
+ started_at INTEGER NOT NULL,
89
+ ended_at INTEGER,
90
+ updated_at INTEGER NOT NULL
91
+ );
92
+ CREATE INDEX IF NOT EXISTS idx_tasks_session ON tasks(session_key);
93
+ CREATE INDEX IF NOT EXISTS idx_tasks_status ON tasks(status);
72
94
  `);
95
+
96
+ this.migrateTaskId();
97
+ this.migrateContentHash();
73
98
  this.log.debug("Database schema initialized");
74
99
  }
75
100
 
101
+ private migrateTaskId(): void {
102
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
103
+ if (!cols.some((c) => c.name === "task_id")) {
104
+ this.db.exec("ALTER TABLE chunks ADD COLUMN task_id TEXT REFERENCES tasks(id)");
105
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_task ON chunks(task_id)");
106
+ this.log.info("Migrated: added task_id column to chunks");
107
+ }
108
+ }
109
+
110
+ private migrateContentHash(): void {
111
+ const cols = this.db.prepare("PRAGMA table_info(chunks)").all() as Array<{ name: string }>;
112
+ if (!cols.some((c) => c.name === "content_hash")) {
113
+ this.db.exec("ALTER TABLE chunks ADD COLUMN content_hash TEXT");
114
+ this.db.exec("CREATE INDEX IF NOT EXISTS idx_chunks_dedup ON chunks(session_key, role, content_hash)");
115
+
116
+ // Backfill existing rows
117
+ const rows = this.db.prepare("SELECT id, content FROM chunks WHERE content_hash IS NULL").all() as Array<{ id: string; content: string }>;
118
+ const updateStmt = this.db.prepare("UPDATE chunks SET content_hash = ? WHERE id = ?");
119
+ for (const r of rows) {
120
+ updateStmt.run(contentHash(r.content), r.id);
121
+ }
122
+ if (rows.length > 0) {
123
+ this.log.info(`Migrated: backfilled content_hash for ${rows.length} chunks`);
124
+ }
125
+ }
126
+ }
127
+
128
+ /** Record a viewer API call for analytics (list, search, etc.). */
129
+ recordViewerEvent(eventType: string): void {
130
+ this.db.prepare("INSERT INTO viewer_events (event_type, created_at) VALUES (?, ?)").run(eventType, Date.now());
131
+ }
132
+
133
+ /**
134
+ * Return metrics for the last N days: writes per day (from chunks), viewer calls per day.
135
+ */
136
+ getMetrics(days: number): {
137
+ writesPerDay: Array<{ date: string; count: number }>;
138
+ viewerCallsPerDay: Array<{ date: string; list: number; search: number; total: number }>;
139
+ roleBreakdown: Record<string, number>;
140
+ kindBreakdown: Record<string, number>;
141
+ totals: { memories: number; sessions: number; embeddings: number; todayWrites: number; todayViewerCalls: number };
142
+ } {
143
+ const since = Date.now() - days * 86400 * 1000;
144
+ const now = new Date();
145
+ const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
146
+
147
+ const writesRows = this.db
148
+ .prepare(
149
+ `SELECT date(created_at/1000, 'unixepoch', 'localtime') as d, COUNT(*) as c
150
+ FROM chunks WHERE created_at >= ? GROUP BY d ORDER BY d`,
151
+ )
152
+ .all(since) as Array<{ d: string; c: number }>;
153
+ const writesPerDay = writesRows.map((r) => ({ date: r.d, count: r.c }));
154
+
155
+ const eventsRows = this.db
156
+ .prepare(
157
+ `SELECT date(created_at/1000, 'unixepoch', 'localtime') as d, event_type, COUNT(*) as c
158
+ FROM viewer_events WHERE created_at >= ? GROUP BY d, event_type ORDER BY d`,
159
+ )
160
+ .all(since) as Array<{ d: string; event_type: string; c: number }>;
161
+ const byDate = new Map<string, { list: number; search: number }>();
162
+ for (const r of eventsRows) {
163
+ let row = byDate.get(r.d);
164
+ if (!row) {
165
+ row = { list: 0, search: 0 };
166
+ byDate.set(r.d, row);
167
+ }
168
+ if (r.event_type === "list") row.list += r.c;
169
+ else if (r.event_type === "search") row.search += r.c;
170
+ }
171
+ const viewerCallsPerDay = Array.from(byDate.entries())
172
+ .sort((a, b) => a[0].localeCompare(b[0]))
173
+ .map(([date, v]) => ({ date, list: v.list, search: v.search, total: v.list + v.search }));
174
+
175
+ const roles = this.db.prepare("SELECT role, COUNT(*) as count FROM chunks GROUP BY role").all() as Array<{ role: string; count: number }>;
176
+ const kinds = this.db.prepare("SELECT kind, COUNT(*) as count FROM chunks GROUP BY kind").all() as Array<{ kind: string; count: number }>;
177
+ const roleBreakdown = Object.fromEntries(roles.map((r) => [r.role, r.count]));
178
+ const kindBreakdown = Object.fromEntries(kinds.map((k) => [k.kind, k.count]));
179
+
180
+ const totalChunks = (this.db.prepare("SELECT COUNT(*) as c FROM chunks").get() as { c: number }).c;
181
+ const totalSessions = (this.db.prepare("SELECT COUNT(DISTINCT session_key) as c FROM chunks").get() as { c: number }).c;
182
+ const totalEmbeddings = (this.db.prepare("SELECT COUNT(*) as c FROM embeddings").get() as { c: number }).c;
183
+ const todayWrites = (this.db.prepare("SELECT COUNT(*) as c FROM chunks WHERE created_at >= ?").get(todayStart) as { c: number }).c;
184
+ const todayViewerCalls = (this.db.prepare("SELECT COUNT(*) as c FROM viewer_events WHERE created_at >= ?").get(todayStart) as { c: number }).c;
185
+
186
+ return {
187
+ writesPerDay,
188
+ viewerCallsPerDay,
189
+ roleBreakdown,
190
+ kindBreakdown,
191
+ totals: {
192
+ memories: totalChunks,
193
+ sessions: totalSessions,
194
+ embeddings: totalEmbeddings,
195
+ todayWrites,
196
+ todayViewerCalls,
197
+ },
198
+ };
199
+ }
200
+
76
201
  // ─── Write ───
77
202
 
78
203
  insertChunk(chunk: Chunk): void {
79
204
  const stmt = this.db.prepare(`
80
- INSERT OR REPLACE INTO chunks (id, session_key, turn_id, seq, role, content, kind, summary, created_at, updated_at)
81
- VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
205
+ INSERT OR REPLACE INTO chunks (id, session_key, turn_id, seq, role, content, kind, summary, task_id, content_hash, created_at, updated_at)
206
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
82
207
  `);
83
208
  stmt.run(
84
209
  chunk.id,
@@ -89,6 +214,8 @@ export class SqliteStore {
89
214
  chunk.content,
90
215
  chunk.kind,
91
216
  chunk.summary,
217
+ chunk.taskId,
218
+ contentHash(chunk.content),
92
219
  chunk.createdAt,
93
220
  chunk.updatedAt,
94
221
  );
@@ -167,6 +294,39 @@ export class SqliteStore {
167
294
  }
168
295
  }
169
296
 
297
+ // ─── Pattern Search (LIKE-based, for CJK text where FTS tokenization is weak) ───
298
+
299
+ patternSearch(patterns: string[], opts: { role?: string; limit?: number } = {}): Array<{ chunkId: string; content: string; role: string; createdAt: number }> {
300
+ if (patterns.length === 0) return [];
301
+ const limit = opts.limit ?? 10;
302
+
303
+ const conditions = patterns.map(() => "c.content LIKE ?");
304
+ const whereClause = conditions.join(" OR ");
305
+ const roleClause = opts.role ? " AND c.role = ?" : "";
306
+ const params: (string | number)[] = patterns.map(p => `%${p}%`);
307
+ if (opts.role) params.push(opts.role);
308
+ params.push(limit);
309
+
310
+ try {
311
+ const rows = this.db.prepare(`
312
+ SELECT c.id as chunk_id, c.content, c.role, c.created_at
313
+ FROM chunks c
314
+ WHERE (${whereClause})${roleClause}
315
+ ORDER BY c.created_at DESC
316
+ LIMIT ?
317
+ `).all(...params) as Array<{ chunk_id: string; content: string; role: string; created_at: number }>;
318
+
319
+ return rows.map(r => ({
320
+ chunkId: r.chunk_id,
321
+ content: r.content,
322
+ role: r.role,
323
+ createdAt: r.created_at,
324
+ }));
325
+ } catch {
326
+ return [];
327
+ }
328
+ }
329
+
170
330
  // ─── Vector Search ───
171
331
 
172
332
  getAllEmbeddings(): Array<{ chunkId: string; vector: number[] }> {
@@ -235,8 +395,106 @@ export class SqliteStore {
235
395
  }
236
396
 
237
397
  deleteAll(): number {
238
- const result = this.db.prepare("DELETE FROM chunks").run();
239
- return result.changes;
398
+ this.db.prepare("DELETE FROM chunks").run();
399
+ this.db.prepare("DELETE FROM tasks").run();
400
+ this.db.prepare("DELETE FROM viewer_events").run();
401
+ const remaining = this.countChunks();
402
+ return remaining === 0 ? 1 : 0;
403
+ }
404
+
405
+ // ─── Task CRUD ───
406
+
407
+ insertTask(task: Task): void {
408
+ this.db.prepare(`
409
+ INSERT OR REPLACE INTO tasks (id, session_key, title, summary, status, started_at, ended_at, updated_at)
410
+ VALUES (?, ?, ?, ?, ?, ?, ?, ?)
411
+ `).run(task.id, task.sessionKey, task.title, task.summary, task.status, task.startedAt, task.endedAt, task.updatedAt);
412
+ }
413
+
414
+ getTask(taskId: string): Task | null {
415
+ const row = this.db.prepare("SELECT * FROM tasks WHERE id = ?").get(taskId) as TaskRow | undefined;
416
+ return row ? rowToTask(row) : null;
417
+ }
418
+
419
+ getActiveTask(sessionKey: string): Task | null {
420
+ const row = this.db.prepare(
421
+ "SELECT * FROM tasks WHERE session_key = ? AND status = 'active' ORDER BY started_at DESC LIMIT 1",
422
+ ).get(sessionKey) as TaskRow | undefined;
423
+ return row ? rowToTask(row) : null;
424
+ }
425
+
426
+ getAllActiveTasks(): Task[] {
427
+ const rows = this.db.prepare(
428
+ "SELECT * FROM tasks WHERE status = 'active' ORDER BY started_at DESC",
429
+ ).all() as TaskRow[];
430
+ return rows.map(rowToTask);
431
+ }
432
+
433
+ updateTask(taskId: string, fields: { title?: string; summary?: string; status?: TaskStatus; endedAt?: number }): boolean {
434
+ const sets: string[] = [];
435
+ const params: unknown[] = [];
436
+ if (fields.title !== undefined) { sets.push("title = ?"); params.push(fields.title); }
437
+ if (fields.summary !== undefined) { sets.push("summary = ?"); params.push(fields.summary); }
438
+ if (fields.status !== undefined) { sets.push("status = ?"); params.push(fields.status); }
439
+ if (fields.endedAt !== undefined) { sets.push("ended_at = ?"); params.push(fields.endedAt); }
440
+ if (sets.length === 0) return false;
441
+ sets.push("updated_at = ?");
442
+ params.push(Date.now());
443
+ params.push(taskId);
444
+ const result = this.db.prepare(`UPDATE tasks SET ${sets.join(", ")} WHERE id = ?`).run(...params);
445
+ return result.changes > 0;
446
+ }
447
+
448
+ getChunksByTask(taskId: string): Chunk[] {
449
+ const rows = this.db.prepare("SELECT * FROM chunks WHERE task_id = ? ORDER BY created_at, seq").all(taskId) as ChunkRow[];
450
+ return rows.map(rowToChunk);
451
+ }
452
+
453
+ listTasks(opts: { status?: string; limit?: number; offset?: number } = {}): { tasks: Task[]; total: number } {
454
+ const conditions: string[] = [];
455
+ const params: unknown[] = [];
456
+ if (opts.status) { conditions.push("status = ?"); params.push(opts.status); }
457
+ const whereClause = conditions.length > 0 ? `WHERE ${conditions.join(" AND ")}` : "";
458
+
459
+ const countRow = this.db.prepare(`SELECT COUNT(*) as c FROM tasks ${whereClause}`).get(...params) as { c: number };
460
+ const total = countRow.c;
461
+
462
+ const limit = opts.limit ?? 50;
463
+ const offset = opts.offset ?? 0;
464
+ const rows = this.db.prepare(
465
+ `SELECT * FROM tasks ${whereClause} ORDER BY started_at DESC LIMIT ? OFFSET ?`,
466
+ ).all(...params, limit, offset) as TaskRow[];
467
+
468
+ return { tasks: rows.map(rowToTask), total };
469
+ }
470
+
471
+ countChunksByTask(taskId: string): number {
472
+ const row = this.db.prepare("SELECT COUNT(*) as c FROM chunks WHERE task_id = ?").get(taskId) as { c: number };
473
+ return row.c;
474
+ }
475
+
476
+ setChunkTaskId(chunkId: string, taskId: string): void {
477
+ this.db.prepare("UPDATE chunks SET task_id = ?, updated_at = ? WHERE id = ?").run(taskId, Date.now(), chunkId);
478
+ }
479
+
480
+ getUnassignedChunks(sessionKey: string): Chunk[] {
481
+ const rows = this.db.prepare(
482
+ "SELECT * FROM chunks WHERE session_key = ? AND task_id IS NULL ORDER BY created_at, seq",
483
+ ).all(sessionKey) as ChunkRow[];
484
+ return rows.map(rowToChunk);
485
+ }
486
+
487
+ /**
488
+ * Check if a chunk with the same (session_key, role, content_hash) already exists.
489
+ * Uses indexed content_hash for O(1) lookup to prevent duplicate ingestion
490
+ * when agent_end sends the full conversation history every turn.
491
+ */
492
+ chunkExistsByContent(sessionKey: string, role: string, content: string): boolean {
493
+ const hash = contentHash(content);
494
+ const row = this.db.prepare(
495
+ "SELECT 1 FROM chunks WHERE session_key = ? AND role = ? AND content_hash = ? LIMIT 1",
496
+ ).get(sessionKey, role, hash);
497
+ return !!row;
240
498
  }
241
499
 
242
500
  // ─── Util ───
@@ -248,6 +506,11 @@ export class SqliteStore {
248
506
  return rows.map((r) => r.id);
249
507
  }
250
508
 
509
+ countChunks(): number {
510
+ const row = this.db.prepare("SELECT COUNT(*) AS cnt FROM chunks").get() as { cnt: number };
511
+ return row.cnt;
512
+ }
513
+
251
514
  close(): void {
252
515
  this.db.close();
253
516
  }
@@ -284,6 +547,7 @@ interface ChunkRow {
284
547
  content: string;
285
548
  kind: string;
286
549
  summary: string;
550
+ task_id: string | null;
287
551
  created_at: number;
288
552
  updated_at: number;
289
553
  }
@@ -299,7 +563,36 @@ function rowToChunk(row: ChunkRow): Chunk {
299
563
  kind: row.kind as Chunk["kind"],
300
564
  summary: row.summary,
301
565
  embedding: null,
566
+ taskId: row.task_id,
302
567
  createdAt: row.created_at,
303
568
  updatedAt: row.updated_at,
304
569
  };
305
570
  }
571
+
572
+ interface TaskRow {
573
+ id: string;
574
+ session_key: string;
575
+ title: string;
576
+ summary: string;
577
+ status: string;
578
+ started_at: number;
579
+ ended_at: number | null;
580
+ updated_at: number;
581
+ }
582
+
583
+ function rowToTask(row: TaskRow): Task {
584
+ return {
585
+ id: row.id,
586
+ sessionKey: row.session_key,
587
+ title: row.title,
588
+ summary: row.summary,
589
+ status: row.status as Task["status"],
590
+ startedAt: row.started_at,
591
+ endedAt: row.ended_at,
592
+ updatedAt: row.updated_at,
593
+ };
594
+ }
595
+
596
+ function contentHash(content: string): string {
597
+ return createHash("sha256").update(content).digest("hex").slice(0, 16);
598
+ }
package/src/types.ts CHANGED
@@ -23,10 +23,26 @@ export interface Chunk {
23
23
  kind: ChunkKind;
24
24
  summary: string;
25
25
  embedding: number[] | null;
26
+ taskId: string | null;
26
27
  createdAt: number;
27
28
  updatedAt: number;
28
29
  }
29
30
 
31
+ // ─── Task ───
32
+
33
+ export type TaskStatus = "active" | "completed" | "skipped";
34
+
35
+ export interface Task {
36
+ id: string;
37
+ sessionKey: string;
38
+ title: string;
39
+ summary: string;
40
+ status: TaskStatus;
41
+ startedAt: number;
42
+ endedAt: number | null;
43
+ updatedAt: number;
44
+ }
45
+
30
46
  export type ChunkKind =
31
47
  | "paragraph"
32
48
  | "code_block"
@@ -50,6 +66,7 @@ export interface SearchHit {
50
66
  original_excerpt: string;
51
67
  ref: ChunkRef;
52
68
  score: number;
69
+ taskId: string | null;
53
70
  source: {
54
71
  ts: number;
55
72
  role: Role;
@@ -188,6 +205,8 @@ export const DEFAULTS = {
188
205
  localEmbeddingModel: "Xenova/all-MiniLM-L6-v2",
189
206
  localEmbeddingDimensions: 384,
190
207
  toolResultMaxChars: 2000,
208
+ taskIdleTimeoutMs: 2 * 60 * 60 * 1000, // 2 hour gap → new task
209
+ taskSummaryMaxTokens: 2000,
191
210
  } as const;
192
211
 
193
212
  // ─── Plugin Hooks (OpenClaw integration) ───