@memtensor/memos-local-openclaw-plugin 0.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.
Files changed (162) hide show
  1. package/.env.example +11 -0
  2. package/README.md +251 -0
  3. package/SKILL.md +43 -0
  4. package/dist/capture/index.d.ts +16 -0
  5. package/dist/capture/index.d.ts.map +1 -0
  6. package/dist/capture/index.js +80 -0
  7. package/dist/capture/index.js.map +1 -0
  8. package/dist/config.d.ts +4 -0
  9. package/dist/config.d.ts.map +1 -0
  10. package/dist/config.js +96 -0
  11. package/dist/config.js.map +1 -0
  12. package/dist/embedding/index.d.ts +12 -0
  13. package/dist/embedding/index.d.ts.map +1 -0
  14. package/dist/embedding/index.js +75 -0
  15. package/dist/embedding/index.js.map +1 -0
  16. package/dist/embedding/local.d.ts +3 -0
  17. package/dist/embedding/local.d.ts.map +1 -0
  18. package/dist/embedding/local.js +65 -0
  19. package/dist/embedding/local.js.map +1 -0
  20. package/dist/embedding/providers/cohere.d.ts +4 -0
  21. package/dist/embedding/providers/cohere.d.ts.map +1 -0
  22. package/dist/embedding/providers/cohere.js +57 -0
  23. package/dist/embedding/providers/cohere.js.map +1 -0
  24. package/dist/embedding/providers/gemini.d.ts +3 -0
  25. package/dist/embedding/providers/gemini.d.ts.map +1 -0
  26. package/dist/embedding/providers/gemini.js +31 -0
  27. package/dist/embedding/providers/gemini.js.map +1 -0
  28. package/dist/embedding/providers/mistral.d.ts +3 -0
  29. package/dist/embedding/providers/mistral.d.ts.map +1 -0
  30. package/dist/embedding/providers/mistral.js +25 -0
  31. package/dist/embedding/providers/mistral.js.map +1 -0
  32. package/dist/embedding/providers/openai.d.ts +3 -0
  33. package/dist/embedding/providers/openai.d.ts.map +1 -0
  34. package/dist/embedding/providers/openai.js +35 -0
  35. package/dist/embedding/providers/openai.js.map +1 -0
  36. package/dist/embedding/providers/voyage.d.ts +3 -0
  37. package/dist/embedding/providers/voyage.d.ts.map +1 -0
  38. package/dist/embedding/providers/voyage.js +25 -0
  39. package/dist/embedding/providers/voyage.js.map +1 -0
  40. package/dist/index.d.ts +44 -0
  41. package/dist/index.d.ts.map +1 -0
  42. package/dist/index.js +75 -0
  43. package/dist/index.js.map +1 -0
  44. package/dist/ingest/chunker.d.ts +15 -0
  45. package/dist/ingest/chunker.d.ts.map +1 -0
  46. package/dist/ingest/chunker.js +193 -0
  47. package/dist/ingest/chunker.js.map +1 -0
  48. package/dist/ingest/dedup.d.ts +11 -0
  49. package/dist/ingest/dedup.d.ts.map +1 -0
  50. package/dist/ingest/dedup.js +29 -0
  51. package/dist/ingest/dedup.js.map +1 -0
  52. package/dist/ingest/providers/anthropic.d.ts +3 -0
  53. package/dist/ingest/providers/anthropic.d.ts.map +1 -0
  54. package/dist/ingest/providers/anthropic.js +33 -0
  55. package/dist/ingest/providers/anthropic.js.map +1 -0
  56. package/dist/ingest/providers/bedrock.d.ts +8 -0
  57. package/dist/ingest/providers/bedrock.d.ts.map +1 -0
  58. package/dist/ingest/providers/bedrock.js +41 -0
  59. package/dist/ingest/providers/bedrock.js.map +1 -0
  60. package/dist/ingest/providers/gemini.d.ts +3 -0
  61. package/dist/ingest/providers/gemini.d.ts.map +1 -0
  62. package/dist/ingest/providers/gemini.js +31 -0
  63. package/dist/ingest/providers/gemini.js.map +1 -0
  64. package/dist/ingest/providers/index.d.ts +9 -0
  65. package/dist/ingest/providers/index.d.ts.map +1 -0
  66. package/dist/ingest/providers/index.js +68 -0
  67. package/dist/ingest/providers/index.js.map +1 -0
  68. package/dist/ingest/providers/openai.d.ts +3 -0
  69. package/dist/ingest/providers/openai.d.ts.map +1 -0
  70. package/dist/ingest/providers/openai.js +41 -0
  71. package/dist/ingest/providers/openai.js.map +1 -0
  72. package/dist/ingest/worker.d.ts +21 -0
  73. package/dist/ingest/worker.d.ts.map +1 -0
  74. package/dist/ingest/worker.js +111 -0
  75. package/dist/ingest/worker.js.map +1 -0
  76. package/dist/recall/engine.d.ts +23 -0
  77. package/dist/recall/engine.d.ts.map +1 -0
  78. package/dist/recall/engine.js +153 -0
  79. package/dist/recall/engine.js.map +1 -0
  80. package/dist/recall/mmr.d.ts +17 -0
  81. package/dist/recall/mmr.d.ts.map +1 -0
  82. package/dist/recall/mmr.js +51 -0
  83. package/dist/recall/mmr.js.map +1 -0
  84. package/dist/recall/recency.d.ts +20 -0
  85. package/dist/recall/recency.d.ts.map +1 -0
  86. package/dist/recall/recency.js +26 -0
  87. package/dist/recall/recency.js.map +1 -0
  88. package/dist/recall/rrf.d.ts +16 -0
  89. package/dist/recall/rrf.d.ts.map +1 -0
  90. package/dist/recall/rrf.js +15 -0
  91. package/dist/recall/rrf.js.map +1 -0
  92. package/dist/storage/sqlite.d.ts +34 -0
  93. package/dist/storage/sqlite.d.ts.map +1 -0
  94. package/dist/storage/sqlite.js +274 -0
  95. package/dist/storage/sqlite.js.map +1 -0
  96. package/dist/storage/vector.d.ts +13 -0
  97. package/dist/storage/vector.d.ts.map +1 -0
  98. package/dist/storage/vector.js +33 -0
  99. package/dist/storage/vector.js.map +1 -0
  100. package/dist/tools/index.d.ts +4 -0
  101. package/dist/tools/index.d.ts.map +1 -0
  102. package/dist/tools/index.js +10 -0
  103. package/dist/tools/index.js.map +1 -0
  104. package/dist/tools/memory-get.d.ts +4 -0
  105. package/dist/tools/memory-get.d.ts.map +1 -0
  106. package/dist/tools/memory-get.js +59 -0
  107. package/dist/tools/memory-get.js.map +1 -0
  108. package/dist/tools/memory-search.d.ts +4 -0
  109. package/dist/tools/memory-search.d.ts.map +1 -0
  110. package/dist/tools/memory-search.js +36 -0
  111. package/dist/tools/memory-search.js.map +1 -0
  112. package/dist/tools/memory-timeline.d.ts +4 -0
  113. package/dist/tools/memory-timeline.d.ts.map +1 -0
  114. package/dist/tools/memory-timeline.js +64 -0
  115. package/dist/tools/memory-timeline.js.map +1 -0
  116. package/dist/types.d.ts +158 -0
  117. package/dist/types.d.ts.map +1 -0
  118. package/dist/types.js +25 -0
  119. package/dist/types.js.map +1 -0
  120. package/dist/viewer/html.d.ts +2 -0
  121. package/dist/viewer/html.d.ts.map +1 -0
  122. package/dist/viewer/html.js +686 -0
  123. package/dist/viewer/html.js.map +1 -0
  124. package/dist/viewer/server.d.ts +48 -0
  125. package/dist/viewer/server.d.ts.map +1 -0
  126. package/dist/viewer/server.js +470 -0
  127. package/dist/viewer/server.js.map +1 -0
  128. package/index.ts +357 -0
  129. package/openclaw.plugin.json +57 -0
  130. package/package.json +57 -0
  131. package/src/capture/index.ts +92 -0
  132. package/src/config.ts +67 -0
  133. package/src/embedding/index.ts +76 -0
  134. package/src/embedding/local.ts +35 -0
  135. package/src/embedding/providers/cohere.ts +69 -0
  136. package/src/embedding/providers/gemini.ts +41 -0
  137. package/src/embedding/providers/mistral.ts +32 -0
  138. package/src/embedding/providers/openai.ts +42 -0
  139. package/src/embedding/providers/voyage.ts +32 -0
  140. package/src/index.ts +106 -0
  141. package/src/ingest/chunker.ts +217 -0
  142. package/src/ingest/dedup.ts +37 -0
  143. package/src/ingest/providers/anthropic.ts +41 -0
  144. package/src/ingest/providers/bedrock.ts +50 -0
  145. package/src/ingest/providers/gemini.ts +41 -0
  146. package/src/ingest/providers/index.ts +67 -0
  147. package/src/ingest/providers/openai.ts +48 -0
  148. package/src/ingest/worker.ts +130 -0
  149. package/src/recall/engine.ts +182 -0
  150. package/src/recall/mmr.ts +60 -0
  151. package/src/recall/recency.ts +27 -0
  152. package/src/recall/rrf.ts +31 -0
  153. package/src/storage/sqlite.ts +305 -0
  154. package/src/storage/vector.ts +39 -0
  155. package/src/tools/index.ts +3 -0
  156. package/src/tools/memory-get.ts +68 -0
  157. package/src/tools/memory-search.ts +36 -0
  158. package/src/tools/memory-timeline.ts +73 -0
  159. package/src/types.ts +214 -0
  160. package/src/viewer/html.ts +682 -0
  161. package/src/viewer/server.ts +464 -0
  162. package/www/index.html +606 -0
@@ -0,0 +1,464 @@
1
+ import http from "node:http";
2
+ import crypto from "node:crypto";
3
+ import fs from "node:fs";
4
+ import path from "node:path";
5
+ import type { SqliteStore } from "../storage/sqlite";
6
+ import type { Embedder } from "../embedding";
7
+ import { vectorSearch } from "../storage/vector";
8
+ import type { Logger } from "../types";
9
+ import { viewerHTML } from "./html";
10
+
11
+ export interface ViewerServerOptions {
12
+ store: SqliteStore;
13
+ embedder: Embedder;
14
+ port: number;
15
+ log: Logger;
16
+ dataDir: string;
17
+ }
18
+
19
+ interface AuthState {
20
+ passwordHash: string | null;
21
+ sessions: Map<string, number>;
22
+ }
23
+
24
+ export class ViewerServer {
25
+ private server: http.Server | null = null;
26
+ private readonly store: SqliteStore;
27
+ private readonly embedder: Embedder;
28
+ private readonly port: number;
29
+ private readonly log: Logger;
30
+ private readonly authFile: string;
31
+ private readonly auth: AuthState;
32
+
33
+ private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
34
+ private resetToken: string;
35
+
36
+ constructor(opts: ViewerServerOptions) {
37
+ this.store = opts.store;
38
+ this.embedder = opts.embedder;
39
+ this.port = opts.port;
40
+ this.log = opts.log;
41
+ this.authFile = path.join(opts.dataDir, "viewer-auth.json");
42
+ this.auth = { passwordHash: null, sessions: new Map() };
43
+ this.resetToken = crypto.randomBytes(16).toString("hex");
44
+ this.loadAuth();
45
+ }
46
+
47
+ start(): Promise<string> {
48
+ return new Promise((resolve, reject) => {
49
+ this.server = http.createServer((req, res) => this.handleRequest(req, res));
50
+ this.server.on("error", (err: NodeJS.ErrnoException) => {
51
+ if (err.code === "EADDRINUSE") {
52
+ this.log.warn(`Viewer port ${this.port} in use, trying ${this.port + 1}`);
53
+ this.server!.listen(this.port + 1, "127.0.0.1");
54
+ } else {
55
+ reject(err);
56
+ }
57
+ });
58
+ this.server.listen(this.port, "127.0.0.1", () => {
59
+ const addr = this.server!.address();
60
+ const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
61
+ resolve(`http://127.0.0.1:${actualPort}`);
62
+ });
63
+ });
64
+ }
65
+
66
+ stop(): void {
67
+ this.server?.close();
68
+ this.server = null;
69
+ }
70
+
71
+ getResetToken(): string {
72
+ return this.resetToken;
73
+ }
74
+
75
+ // ─── Auth helpers ───
76
+
77
+ private loadAuth(): void {
78
+ try {
79
+ if (fs.existsSync(this.authFile)) {
80
+ const data = JSON.parse(fs.readFileSync(this.authFile, "utf-8"));
81
+ this.auth.passwordHash = data.passwordHash ?? null;
82
+ }
83
+ } catch {
84
+ this.log.warn("Failed to load viewer auth file, starting fresh");
85
+ }
86
+ }
87
+
88
+ private saveAuth(): void {
89
+ try {
90
+ fs.mkdirSync(path.dirname(this.authFile), { recursive: true });
91
+ fs.writeFileSync(this.authFile, JSON.stringify({ passwordHash: this.auth.passwordHash }));
92
+ } catch (e) {
93
+ this.log.warn(`Failed to save viewer auth: ${e}`);
94
+ }
95
+ }
96
+
97
+ private hashPassword(pw: string): string {
98
+ return crypto.createHash("sha256").update(pw + "memos-local-salt-2026").digest("hex");
99
+ }
100
+
101
+ private createSession(): string {
102
+ const token = crypto.randomBytes(32).toString("hex");
103
+ this.auth.sessions.set(token, Date.now() + ViewerServer.SESSION_TTL);
104
+ return token;
105
+ }
106
+
107
+ private isValidSession(req: http.IncomingMessage): boolean {
108
+ const cookie = req.headers.cookie ?? "";
109
+ const match = cookie.match(/memos_token=([a-f0-9]+)/);
110
+ if (!match) return false;
111
+ const expiry = this.auth.sessions.get(match[1]);
112
+ if (!expiry) return false;
113
+ if (Date.now() > expiry) { this.auth.sessions.delete(match[1]); return false; }
114
+ return true;
115
+ }
116
+
117
+ private get needsSetup(): boolean {
118
+ return this.auth.passwordHash === null;
119
+ }
120
+
121
+ // ─── Request routing ───
122
+
123
+ private handleRequest(req: http.IncomingMessage, res: http.ServerResponse): void {
124
+ const url = new URL(req.url ?? "/", `http://${req.headers.host}`);
125
+ const p = url.pathname;
126
+
127
+ res.setHeader("Access-Control-Allow-Origin", "*");
128
+ res.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, DELETE, OPTIONS");
129
+ res.setHeader("Access-Control-Allow-Headers", "Content-Type");
130
+
131
+ if (req.method === "OPTIONS") { res.writeHead(204); res.end(); return; }
132
+
133
+ try {
134
+ if (p === "/api/auth/status") {
135
+ return this.jsonResponse(res, { needsSetup: this.needsSetup, loggedIn: this.isValidSession(req) });
136
+ }
137
+ if (p === "/api/auth/setup" && req.method === "POST") {
138
+ return this.handleSetup(req, res);
139
+ }
140
+ if (p === "/api/auth/login" && req.method === "POST") {
141
+ return this.handleLogin(req, res);
142
+ }
143
+ if (p === "/api/auth/reset" && req.method === "POST") {
144
+ return this.handlePasswordReset(req, res);
145
+ }
146
+ if (p === "/" || p === "/viewer") {
147
+ return this.serveViewer(res);
148
+ }
149
+
150
+ if (!this.isValidSession(req)) {
151
+ res.writeHead(401, { "Content-Type": "application/json" });
152
+ res.end(JSON.stringify({ error: "unauthorized" }));
153
+ return;
154
+ }
155
+
156
+ if (p === "/api/memories" && req.method === "GET") this.serveMemories(res, url);
157
+ else if (p === "/api/stats") this.serveStats(res);
158
+ else if (p === "/api/search") this.serveSearch(req, res, url);
159
+ else if (p === "/api/memory" && req.method === "POST") this.handleCreate(req, res);
160
+ else if (p.startsWith("/api/memory/") && req.method === "PUT") this.handleUpdate(req, res, p);
161
+ else if (p.startsWith("/api/memory/") && req.method === "DELETE") this.handleDelete(res, p);
162
+ else if (p === "/api/session" && req.method === "DELETE") this.handleDeleteSession(res, url);
163
+ else if (p === "/api/memories" && req.method === "DELETE") this.handleDeleteAll(res);
164
+ else if (p === "/api/auth/logout" && req.method === "POST") this.handleLogout(req, res);
165
+ else {
166
+ res.writeHead(404, { "Content-Type": "application/json" });
167
+ res.end(JSON.stringify({ error: "not found" }));
168
+ }
169
+ } catch (err) {
170
+ this.log.error(`Viewer request error: ${err}`);
171
+ res.writeHead(500, { "Content-Type": "application/json" });
172
+ res.end(JSON.stringify({ error: String(err) }));
173
+ }
174
+ }
175
+
176
+ // ─── Auth endpoints ───
177
+
178
+ private handleSetup(req: http.IncomingMessage, res: http.ServerResponse): void {
179
+ if (!this.needsSetup) {
180
+ res.writeHead(400, { "Content-Type": "application/json" });
181
+ res.end(JSON.stringify({ error: "Password already set" }));
182
+ return;
183
+ }
184
+ this.readBody(req, (body) => {
185
+ try {
186
+ const { password } = JSON.parse(body);
187
+ if (!password || password.length < 4) {
188
+ res.writeHead(400, { "Content-Type": "application/json" });
189
+ res.end(JSON.stringify({ error: "Password must be at least 4 characters" }));
190
+ return;
191
+ }
192
+ this.auth.passwordHash = this.hashPassword(password);
193
+ this.saveAuth();
194
+ const token = this.createSession();
195
+ res.writeHead(200, {
196
+ "Content-Type": "application/json",
197
+ "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
198
+ });
199
+ res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
200
+ } catch (err) {
201
+ res.writeHead(400, { "Content-Type": "application/json" });
202
+ res.end(JSON.stringify({ error: String(err) }));
203
+ }
204
+ });
205
+ }
206
+
207
+ private handleLogin(req: http.IncomingMessage, res: http.ServerResponse): void {
208
+ this.readBody(req, (body) => {
209
+ try {
210
+ const { password } = JSON.parse(body);
211
+ if (this.needsSetup || this.hashPassword(password) !== this.auth.passwordHash) {
212
+ res.writeHead(401, { "Content-Type": "application/json" });
213
+ res.end(JSON.stringify({ error: "Invalid password" }));
214
+ return;
215
+ }
216
+ const token = this.createSession();
217
+ res.writeHead(200, {
218
+ "Content-Type": "application/json",
219
+ "Set-Cookie": `memos_token=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
220
+ });
221
+ res.end(JSON.stringify({ ok: true }));
222
+ } catch (err) {
223
+ res.writeHead(401, { "Content-Type": "application/json" });
224
+ res.end(JSON.stringify({ error: String(err) }));
225
+ }
226
+ });
227
+ }
228
+
229
+ private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
230
+ const cookie = req.headers.cookie ?? "";
231
+ const match = cookie.match(/memos_token=([a-f0-9]+)/);
232
+ if (match) this.auth.sessions.delete(match[1]);
233
+ res.writeHead(200, {
234
+ "Content-Type": "application/json",
235
+ "Set-Cookie": "memos_token=; Path=/; HttpOnly; Max-Age=0",
236
+ });
237
+ res.end(JSON.stringify({ ok: true }));
238
+ }
239
+
240
+ private handlePasswordReset(req: http.IncomingMessage, res: http.ServerResponse): void {
241
+ this.readBody(req, (body) => {
242
+ try {
243
+ const { token, newPassword } = JSON.parse(body);
244
+ if (token !== this.resetToken) {
245
+ res.writeHead(403, { "Content-Type": "application/json" });
246
+ res.end(JSON.stringify({ error: "Invalid reset token" }));
247
+ return;
248
+ }
249
+ if (!newPassword || newPassword.length < 4) {
250
+ res.writeHead(400, { "Content-Type": "application/json" });
251
+ res.end(JSON.stringify({ error: "Password must be at least 4 characters" }));
252
+ return;
253
+ }
254
+ this.auth.passwordHash = this.hashPassword(newPassword);
255
+ this.auth.sessions.clear();
256
+ this.saveAuth();
257
+ this.resetToken = crypto.randomBytes(16).toString("hex");
258
+ this.log.info(`memos-local: password has been reset. New reset token: ${this.resetToken}`);
259
+ const sessionToken = this.createSession();
260
+ res.writeHead(200, {
261
+ "Content-Type": "application/json",
262
+ "Set-Cookie": `memos_token=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
263
+ });
264
+ res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
265
+ } catch (err) {
266
+ res.writeHead(400, { "Content-Type": "application/json" });
267
+ res.end(JSON.stringify({ error: String(err) }));
268
+ }
269
+ });
270
+ }
271
+
272
+ // ─── Pages ───
273
+
274
+ private serveViewer(res: http.ServerResponse): void {
275
+ res.writeHead(200, { "Content-Type": "text/html; charset=utf-8", "Cache-Control": "no-cache" });
276
+ res.end(viewerHTML);
277
+ }
278
+
279
+ // ─── Data APIs ───
280
+
281
+ private serveMemories(res: http.ServerResponse, url: URL): void {
282
+ const limit = Math.min(Number(url.searchParams.get("limit")) || 30, 200);
283
+ const page = Math.max(1, Number(url.searchParams.get("page")) || 1);
284
+ const offset = (page - 1) * limit;
285
+ const session = url.searchParams.get("session") ?? undefined;
286
+ const role = url.searchParams.get("role") ?? undefined;
287
+ const kind = url.searchParams.get("kind") ?? undefined;
288
+ const dateFrom = url.searchParams.get("dateFrom") ?? undefined;
289
+ const dateTo = url.searchParams.get("dateTo") ?? undefined;
290
+ const sortBy = url.searchParams.get("sort") === "oldest" ? "ASC" : "DESC";
291
+
292
+ const db = (this.store as any).db;
293
+ const conditions: string[] = [];
294
+ const params: any[] = [];
295
+ if (session) { conditions.push("session_key = ?"); params.push(session); }
296
+ if (role) { conditions.push("role = ?"); params.push(role); }
297
+ if (kind) { conditions.push("kind = ?"); params.push(kind); }
298
+ if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
299
+ if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
300
+
301
+ const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
302
+ const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
303
+ const memories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
304
+
305
+ this.jsonResponse(res, {
306
+ memories, page, limit, total: totalRow.count,
307
+ totalPages: Math.ceil(totalRow.count / limit),
308
+ });
309
+ }
310
+
311
+ private serveStats(res: http.ServerResponse): void {
312
+ const db = (this.store as any).db;
313
+ const total = db.prepare("SELECT COUNT(*) as count FROM chunks").get() as any;
314
+ const sessions = db.prepare("SELECT COUNT(DISTINCT session_key) as count FROM chunks").get() as any;
315
+ const roles = db.prepare("SELECT role, COUNT(*) as count FROM chunks GROUP BY role").all() as any[];
316
+ const timeRange = db.prepare("SELECT MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks").get() as any;
317
+ const embeddings = db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any;
318
+ const kinds = db.prepare("SELECT kind, COUNT(*) as count FROM chunks GROUP BY kind").all() as any[];
319
+ const sessionList = db.prepare(
320
+ "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC",
321
+ ).all() as any[];
322
+
323
+ this.jsonResponse(res, {
324
+ totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embeddings.count,
325
+ embeddingProvider: this.embedder.provider,
326
+ roleBreakdown: Object.fromEntries(roles.map((r: any) => [r.role, r.count])),
327
+ kindBreakdown: Object.fromEntries(kinds.map((k: any) => [k.kind, k.count])),
328
+ timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
329
+ sessions: sessionList,
330
+ });
331
+ }
332
+
333
+ private async serveSearch(_req: http.IncomingMessage, res: http.ServerResponse, url: URL): Promise<void> {
334
+ const q = url.searchParams.get("q") ?? "";
335
+ if (!q.trim()) { this.jsonResponse(res, { results: [], query: q }); return; }
336
+
337
+ const role = url.searchParams.get("role") ?? undefined;
338
+ const kind = url.searchParams.get("kind") ?? undefined;
339
+ const dateFrom = url.searchParams.get("dateFrom") ?? undefined;
340
+ const dateTo = url.searchParams.get("dateTo") ?? undefined;
341
+
342
+ const passesFilter = (r: any): boolean => {
343
+ if (role && r.role !== role) return false;
344
+ if (kind && r.kind !== kind) return false;
345
+ if (dateFrom && r.created_at < new Date(dateFrom).getTime()) return false;
346
+ if (dateTo && r.created_at > new Date(dateTo).getTime()) return false;
347
+ return true;
348
+ };
349
+
350
+ const db = (this.store as any).db;
351
+ let ftsResults: any[] = [];
352
+ try {
353
+ ftsResults = db.prepare(
354
+ "SELECT c.* FROM chunks_fts f JOIN chunks c ON f.rowid = c.rowid WHERE chunks_fts MATCH ? ORDER BY rank LIMIT 100",
355
+ ).all(q).filter(passesFilter);
356
+ } catch {
357
+ ftsResults = db.prepare(
358
+ "SELECT * FROM chunks WHERE content LIKE ? OR summary LIKE ? ORDER BY created_at DESC LIMIT 100",
359
+ ).all(`%${q}%`, `%${q}%`).filter(passesFilter);
360
+ }
361
+
362
+ let vectorResults: any[] = [];
363
+ try {
364
+ const queryVec = await this.embedder.embedQuery(q);
365
+ const hits = vectorSearch(this.store, queryVec, 40);
366
+ const hitIds = new Set(hits.filter(h => h.score > 0.3).map(h => h.chunkId));
367
+ if (hitIds.size > 0) {
368
+ const placeholders = [...hitIds].map(() => "?").join(",");
369
+ const rows = db.prepare(`SELECT * FROM chunks WHERE id IN (${placeholders})`).all(...hitIds).filter(passesFilter);
370
+ const scoreMap = new Map(hits.map(h => [h.chunkId, h.score]));
371
+ rows.forEach((r: any) => { r._vscore = scoreMap.get(r.id) ?? 0; });
372
+ rows.sort((a: any, b: any) => (b._vscore ?? 0) - (a._vscore ?? 0));
373
+ vectorResults = rows;
374
+ }
375
+ } catch (err) {
376
+ this.log.warn(`Vector search failed (falling back to FTS only): ${err}`);
377
+ }
378
+
379
+ const seenIds = new Set<string>();
380
+ const merged: any[] = [];
381
+ for (const r of vectorResults) {
382
+ if (!seenIds.has(r.id)) { seenIds.add(r.id); merged.push(r); }
383
+ }
384
+ for (const r of ftsResults) {
385
+ if (!seenIds.has(r.id)) { seenIds.add(r.id); merged.push(r); }
386
+ }
387
+
388
+ this.jsonResponse(res, {
389
+ results: merged,
390
+ query: q,
391
+ vectorCount: vectorResults.length,
392
+ ftsCount: ftsResults.length,
393
+ total: merged.length,
394
+ });
395
+ }
396
+
397
+ // ─── CRUD ───
398
+
399
+ private handleCreate(req: http.IncomingMessage, res: http.ServerResponse): void {
400
+ this.readBody(req, (body) => {
401
+ try {
402
+ const data = JSON.parse(body);
403
+ const { v4: uuidv4 } = require("uuid");
404
+ const id = uuidv4();
405
+ const now = Date.now();
406
+ this.store.insertChunk({
407
+ id, sessionKey: data.session_key || "manual", turnId: `manual-${now}`, seq: 0,
408
+ role: data.role || "user", content: data.content || "", kind: data.kind || "paragraph",
409
+ summary: data.summary || data.content?.slice(0, 100) || "",
410
+ createdAt: now, updatedAt: now, embedding: null,
411
+ });
412
+ this.jsonResponse(res, { ok: true, id, message: "Memory created" });
413
+ } catch (err) {
414
+ res.writeHead(400, { "Content-Type": "application/json" });
415
+ res.end(JSON.stringify({ error: String(err) }));
416
+ }
417
+ });
418
+ }
419
+
420
+ private handleUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
421
+ const chunkId = urlPath.replace("/api/memory/", "");
422
+ this.readBody(req, (body) => {
423
+ try {
424
+ const data = JSON.parse(body);
425
+ const ok = this.store.updateChunk(chunkId, { summary: data.summary, content: data.content, role: data.role, kind: data.kind });
426
+ if (ok) this.jsonResponse(res, { ok: true, message: "Memory updated" });
427
+ else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
428
+ } catch (err) {
429
+ res.writeHead(400, { "Content-Type": "application/json" });
430
+ res.end(JSON.stringify({ error: String(err) }));
431
+ }
432
+ });
433
+ }
434
+
435
+ private handleDelete(res: http.ServerResponse, urlPath: string): void {
436
+ const chunkId = urlPath.replace("/api/memory/", "");
437
+ if (this.store.deleteChunk(chunkId)) this.jsonResponse(res, { ok: true });
438
+ else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
439
+ }
440
+
441
+ private handleDeleteSession(res: http.ServerResponse, url: URL): void {
442
+ const key = url.searchParams.get("key");
443
+ if (!key) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing key" })); return; }
444
+ const count = this.store.deleteSession(key);
445
+ this.jsonResponse(res, { ok: true, deleted: count });
446
+ }
447
+
448
+ private handleDeleteAll(res: http.ServerResponse): void {
449
+ this.jsonResponse(res, { ok: true, deleted: this.store.deleteAll() });
450
+ }
451
+
452
+ // ─── Helpers ───
453
+
454
+ private readBody(req: http.IncomingMessage, cb: (body: string) => void): void {
455
+ let body = "";
456
+ req.on("data", (chunk: Buffer) => { body += chunk.toString(); });
457
+ req.on("end", () => cb(body));
458
+ }
459
+
460
+ private jsonResponse(res: http.ServerResponse, data: unknown): void {
461
+ res.writeHead(200, { "Content-Type": "application/json; charset=utf-8" });
462
+ res.end(JSON.stringify(data));
463
+ }
464
+ }