openclaw-memory-alibaba-local 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.
@@ -0,0 +1,433 @@
1
+ /**
2
+ * Memory admin panel — /plugins/memory (HTML + JSON API).
3
+ * Auth aligned with openclaw-observability: gateway.auth.token → ?token= or Bearer.
4
+ */
5
+
6
+ import * as fs from "node:fs";
7
+ import * as os from "node:os";
8
+ import * as path from "node:path";
9
+ import type { IncomingMessage, ServerResponse } from "node:http";
10
+ import type { MemoryCategory, MemoryConfig } from "../config.js";
11
+ import {
12
+ FULL_CONTEXT_ASSISTANT,
13
+ FULL_CONTEXT_MEMORY,
14
+ FULL_CONTEXT_OTHERS,
15
+ FULL_CONTEXT_SOURCE_CATEGORIES,
16
+ FULL_CONTEXT_SYSTEM,
17
+ FULL_CONTEXT_TOOL,
18
+ FULL_CONTEXT_TOOL_RESULT,
19
+ FULL_CONTEXT_USER,
20
+ MANUAL_INSERT_SESSION,
21
+ MEMORY_CATEGORY_LABEL_ZH,
22
+ SELF_IMPROVING_CATEGORIES,
23
+ USER_MEMORY_CATEGORIES,
24
+ isFullContextSourceCategory,
25
+ } from "../categories.js";
26
+ import type { MemoryDB } from "../db.js";
27
+ import type { AdminListFilters } from "../db.js";
28
+ import { getMemoryPanelHtml } from "./memory-ui.js";
29
+
30
+ const MANUAL_ADD_MAX_CHARS = 8000;
31
+
32
+ export type MemoryPanelRoutesOpts = {
33
+ /** Required for POST /api/add when category needs real embeddings (user / self-improving). */
34
+ encodeForStorage?: (text: string) => Promise<{ chunks: string[]; vectors: number[][] }>;
35
+ /** LanceDB vector width; used for full_context_* manual add (zero placeholder, no embed). */
36
+ vectorDim?: number;
37
+ };
38
+
39
+ function categoryUsesRealEmbedding(category: MemoryCategory): boolean {
40
+ return category !== FULL_CONTEXT_MEMORY && !isFullContextSourceCategory(category);
41
+ }
42
+
43
+ function writableCategoriesForPanel(cfg: MemoryConfig): MemoryCategory[] {
44
+ const out: MemoryCategory[] = [...USER_MEMORY_CATEGORIES];
45
+ if (cfg.enableFullContextMemory) {
46
+ out.push(
47
+ FULL_CONTEXT_USER,
48
+ FULL_CONTEXT_ASSISTANT,
49
+ FULL_CONTEXT_SYSTEM,
50
+ FULL_CONTEXT_TOOL,
51
+ FULL_CONTEXT_TOOL_RESULT,
52
+ FULL_CONTEXT_OTHERS,
53
+ );
54
+ }
55
+ if (cfg.enableSelfImprovingMemory) {
56
+ out.push(...SELF_IMPROVING_CATEGORIES);
57
+ }
58
+ return out;
59
+ }
60
+
61
+ function buildPanelConfigPayload(cfg: MemoryConfig) {
62
+ return {
63
+ enableFullContextMemory: cfg.enableFullContextMemory,
64
+ enableSelfImprovingMemory: cfg.enableSelfImprovingMemory,
65
+ categoryLabelsZh: { ...MEMORY_CATEGORY_LABEL_ZH },
66
+ tabCategories: {
67
+ user: [...USER_MEMORY_CATEGORIES],
68
+ self: cfg.enableSelfImprovingMemory ? [...SELF_IMPROVING_CATEGORIES] : [],
69
+ full: cfg.enableFullContextMemory ? [...FULL_CONTEXT_SOURCE_CATEGORIES] : [],
70
+ },
71
+ /** 各 Tab 列表「记忆类型」筛选项(来自插件配置 + 默认中文名) */
72
+ memoryTypeFilterOptions: cfg.adminPanelMemoryTypeOptions,
73
+ };
74
+ }
75
+
76
+ export type RegisterHttpRoute = (params: {
77
+ path: string;
78
+ handler: (req: IncomingMessage, res: ServerResponse) => Promise<boolean | void> | boolean | void;
79
+ auth: "gateway" | "plugin";
80
+ match?: "exact" | "prefix";
81
+ replaceExisting?: boolean;
82
+ }) => void;
83
+
84
+ type PluginLogger = { info: (m: string) => void; warn: (m: string) => void };
85
+
86
+ function resolveGatewayToken(): string | undefined {
87
+ const stateDir = process.env.OPENCLAW_STATE_DIR ?? path.join(os.homedir(), ".openclaw");
88
+ const openclawConfigPath = process.env.OPENCLAW_CONFIG_PATH ?? path.join(stateDir, "openclaw.json");
89
+ try {
90
+ if (fs.existsSync(openclawConfigPath)) {
91
+ const raw = fs.readFileSync(openclawConfigPath, "utf8");
92
+ let t: string | undefined;
93
+ try {
94
+ const parsed = JSON.parse(raw) as { gateway?: { auth?: { token?: string } } };
95
+ t = parsed?.gateway?.auth?.token;
96
+ } catch {
97
+ const m = raw.match(
98
+ /"gateway"\s*:\s*\{[\s\S]*?"auth"\s*:\s*\{[\s\S]*?"token"\s*:\s*"((?:[^"\\]|\\.)*)"/,
99
+ );
100
+ t = m?.[1]?.replace(/\\(.)/g, "$1");
101
+ }
102
+ if (typeof t === "string" && t.length > 0) {
103
+ return t;
104
+ }
105
+ }
106
+ } catch {
107
+ // ignore
108
+ }
109
+ return undefined;
110
+ }
111
+
112
+ function tabToCategories(tab: string, cfg: MemoryConfig): MemoryCategory[] {
113
+ if (tab === "full") {
114
+ return cfg.enableFullContextMemory ? [...FULL_CONTEXT_SOURCE_CATEGORIES] : [];
115
+ }
116
+ if (tab === "self") {
117
+ return cfg.enableSelfImprovingMemory ? [...SELF_IMPROVING_CATEGORIES] : [];
118
+ }
119
+ return [...USER_MEMORY_CATEGORIES];
120
+ }
121
+
122
+ function parseUrl(req: IncomingMessage): URL {
123
+ return new URL(req.url || "/", "http://" + (req.headers.host || "localhost"));
124
+ }
125
+
126
+ function sendJson(res: ServerResponse, status: number, data: unknown): void {
127
+ res.writeHead(status, {
128
+ "Content-Type": "application/json; charset=utf-8",
129
+ "Cache-Control": "no-cache",
130
+ });
131
+ res.end(JSON.stringify(data));
132
+ }
133
+
134
+ function sendHtml(res: ServerResponse, html: string): void {
135
+ res.writeHead(200, {
136
+ "Content-Type": "text/html; charset=utf-8",
137
+ "Cache-Control": "no-store, no-cache, must-revalidate",
138
+ Pragma: "no-cache",
139
+ });
140
+ res.end(html);
141
+ }
142
+
143
+ function readBody(req: IncomingMessage, maxBytes = 1024 * 64): Promise<string> {
144
+ return new Promise((resolve, reject) => {
145
+ let data = "";
146
+ let bytes = 0;
147
+ req.on("data", (chunk: Buffer) => {
148
+ bytes += chunk.length;
149
+ if (bytes > maxBytes) {
150
+ req.destroy();
151
+ reject(new Error("body too large"));
152
+ return;
153
+ }
154
+ data += chunk.toString();
155
+ });
156
+ req.on("end", () => resolve(data));
157
+ req.on("error", reject);
158
+ });
159
+ }
160
+
161
+ export function registerMemoryPanelRoutes(
162
+ registerHttpRoute: RegisterHttpRoute,
163
+ db: MemoryDB,
164
+ cfg: MemoryConfig,
165
+ logger: PluginLogger,
166
+ opts?: MemoryPanelRoutesOpts | null,
167
+ ): void {
168
+ const requiredToken = resolveGatewayToken();
169
+ const token = typeof requiredToken === "string" && requiredToken.length > 0 ? requiredToken : undefined;
170
+
171
+ registerHttpRoute({
172
+ path: "/plugins/memory",
173
+ auth: "plugin",
174
+ match: "prefix",
175
+ handler: async (req, res) => {
176
+ try {
177
+ const url = parseUrl(req);
178
+ const p = url.pathname;
179
+ const isMemoryApi = p.startsWith("/plugins/memory/api/");
180
+
181
+ // HTML shell is public so the browser can load the panel; JSON APIs stay token-protected.
182
+ if (token && isMemoryApi) {
183
+ const queryToken = (url.searchParams.get("token") ?? "").trim();
184
+ const authHeader = (req.headers.authorization ?? req.headers.Authorization ?? "") as string;
185
+ const bearer = authHeader.startsWith("Bearer ") ? authHeader.slice(7).trim() : "";
186
+ if (queryToken !== token && bearer !== token) {
187
+ sendJson(res, 401, { error: { message: "Unauthorized", type: "unauthorized" } });
188
+ return true;
189
+ }
190
+ }
191
+
192
+ if (!isMemoryApi) {
193
+ sendHtml(res, getMemoryPanelHtml());
194
+ return true;
195
+ }
196
+
197
+ const tryDb = async (): Promise<boolean> => {
198
+ try {
199
+ await db.ensureReady();
200
+ return true;
201
+ } catch (e) {
202
+ sendJson(res, 503, { error: "Database unavailable", detail: String(e) });
203
+ return false;
204
+ }
205
+ };
206
+ if (!(await tryDb())) {
207
+ return true;
208
+ }
209
+
210
+ if (p === "/plugins/memory/api/config" && req.method === "GET") {
211
+ sendJson(res, 200, buildPanelConfigPayload(cfg));
212
+ return true;
213
+ }
214
+
215
+ if (p === "/plugins/memory/api/facets" && req.method === "GET") {
216
+ try {
217
+ // No category filter: include legacy `full_context_memory`, manual categories, and any future values.
218
+ // List API still filters by tab category; dropdowns only need distinct agentId/sessionId from real rows.
219
+ const facets = await db.listAdminFacets([], undefined, undefined);
220
+ sendJson(res, 200, facets);
221
+ } catch (e) {
222
+ sendJson(res, 400, { error: String(e) });
223
+ }
224
+ return true;
225
+ }
226
+
227
+ if (p === "/plugins/memory/api/dashboard" && req.method === "GET") {
228
+ const timeFromRaw = url.searchParams.get("timeFrom");
229
+ const timeToRaw = url.searchParams.get("timeTo");
230
+ const timeFromMs = parseOptionalTimeMs(timeFromRaw);
231
+ const timeToMs = parseOptionalTimeMs(timeToRaw);
232
+ if (timeFromMs === undefined || timeToMs === undefined) {
233
+ sendJson(res, 400, { error: "timeFrom and timeTo are required (ISO 8601)" });
234
+ return true;
235
+ }
236
+ const agentId = (url.searchParams.get("agentId") ?? "").trim();
237
+ const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
238
+ if (!agentId) {
239
+ sendJson(res, 400, { error: "缺少 agentId:请先选择 Agent" });
240
+ return true;
241
+ }
242
+ try {
243
+ const agg = await db.getAdminDashboardAggregates(timeFromMs, timeToMs, agentId, sessionId);
244
+ sendJson(res, 200, agg);
245
+ } catch (e) {
246
+ sendJson(res, 400, { error: String(e) });
247
+ }
248
+ return true;
249
+ }
250
+
251
+ if (p === "/plugins/memory/api/list" && req.method === "GET") {
252
+ const tab = url.searchParams.get("tab") || "user";
253
+ const agentId = (url.searchParams.get("agentId") ?? "").trim();
254
+ const sessionId = (url.searchParams.get("sessionId") ?? "").trim();
255
+ if (!agentId) {
256
+ sendJson(res, 400, { error: "缺少 agentId:请先选择 Agent" });
257
+ return true;
258
+ }
259
+ const baseCats = tabToCategories(tab, cfg);
260
+ if (baseCats.length === 0) {
261
+ sendJson(res, 200, { items: [], total: 0, page: 1, pageSize: 100 });
262
+ return true;
263
+ }
264
+ const timeFromMs = parseOptionalTimeMs(url.searchParams.get("timeFrom"));
265
+ const timeToMs = parseOptionalTimeMs(url.searchParams.get("timeTo"));
266
+ const page = Math.max(1, parseInt(url.searchParams.get("page") || "1", 10) || 1);
267
+ const pageSize = Math.min(500, Math.max(1, parseInt(url.searchParams.get("limit") || "100", 10) || 100));
268
+
269
+ const categoryOne = (url.searchParams.get("category") ?? "").trim();
270
+ let filterCats = baseCats;
271
+ if (categoryOne) {
272
+ if (!baseCats.includes(categoryOne as MemoryCategory)) {
273
+ sendJson(res, 400, { error: "category 与当前 Tab 不匹配" });
274
+ return true;
275
+ }
276
+ const listTab = tab === "full" ? "full" : tab === "self" ? "self" : "user";
277
+ const optList = cfg.adminPanelMemoryTypeOptions[listTab];
278
+ if (!optList.some((o) => o.category === categoryOne)) {
279
+ sendJson(res, 400, { error: "category 不在插件配置的记忆类型筛选项中" });
280
+ return true;
281
+ }
282
+ filterCats = [categoryOne as MemoryCategory];
283
+ }
284
+
285
+ const filters: AdminListFilters = {
286
+ categories: filterCats,
287
+ timeFromMs,
288
+ timeToMs,
289
+ };
290
+ filters.agentId = agentId;
291
+ if (sessionId) {
292
+ filters.sessionId = sessionId;
293
+ }
294
+
295
+ try {
296
+ const sortDesc = url.searchParams.get("sortDesc") !== "false";
297
+ const adminTab = tab === "full" ? "full" : tab === "self" ? "self" : "user";
298
+ const { total, items } = await db.listAdminFiltered(filters, page, pageSize, {
299
+ adminTab,
300
+ sortDesc,
301
+ });
302
+ sendJson(res, 200, {
303
+ items,
304
+ total,
305
+ page,
306
+ pageSize,
307
+ });
308
+ } catch (e) {
309
+ sendJson(res, 400, { error: String(e) });
310
+ }
311
+ return true;
312
+ }
313
+
314
+ if (p === "/plugins/memory/api/delete" && req.method === "POST") {
315
+ const raw = await readBody(req);
316
+ let body: { items?: Array<{ agentId?: string; id?: string }> };
317
+ try {
318
+ body = JSON.parse(raw || "{}") as typeof body;
319
+ } catch {
320
+ sendJson(res, 400, { error: "Invalid JSON" });
321
+ return true;
322
+ }
323
+ const items = Array.isArray(body.items) ? body.items : [];
324
+ const normalized: { agentId: string; id: string }[] = [];
325
+ for (const it of items) {
326
+ if (it?.agentId && it?.id) {
327
+ normalized.push({ agentId: String(it.agentId), id: String(it.id) });
328
+ }
329
+ }
330
+ if (normalized.length === 0) {
331
+ sendJson(res, 400, { error: "items required" });
332
+ return true;
333
+ }
334
+ const n = await db.deleteMany(normalized);
335
+ sendJson(res, 200, { deleted: n });
336
+ return true;
337
+ }
338
+
339
+ // Manual insert: embed for user/self rows; full_context_* uses zero-vector placeholder (no embed).
340
+ if (p === "/plugins/memory/api/add" && req.method === "POST") {
341
+ const enc = opts?.encodeForStorage;
342
+ const vectorDim = typeof opts?.vectorDim === "number" && opts.vectorDim > 0 ? opts.vectorDim : 768;
343
+ const raw = await readBody(req);
344
+ let body: { agentId?: string; text?: string; category?: string };
345
+ try {
346
+ body = JSON.parse(raw || "{}") as typeof body;
347
+ } catch {
348
+ sendJson(res, 400, { error: "Invalid JSON" });
349
+ return true;
350
+ }
351
+ const agentId = (body.agentId ?? "").trim();
352
+ const textRaw = body.text == null ? "" : String(body.text);
353
+ const category = (body.category ?? "").trim() as MemoryCategory;
354
+ if (!agentId) {
355
+ sendJson(res, 400, { error: "agentId required" });
356
+ return true;
357
+ }
358
+ const text = textRaw.trim();
359
+ if (!text.length) {
360
+ sendJson(res, 400, { error: "text required" });
361
+ return true;
362
+ }
363
+ const allowed = new Set(writableCategoriesForPanel(cfg));
364
+ if (!allowed.has(category)) {
365
+ sendJson(res, 400, { error: "invalid or disabled category" });
366
+ return true;
367
+ }
368
+ const textForEmbed = text.length > MANUAL_ADD_MAX_CHARS ? text.slice(0, MANUAL_ADD_MAX_CHARS) : text;
369
+ const needsRealEmbed = categoryUsesRealEmbedding(category);
370
+ if (needsRealEmbed && !enc) {
371
+ sendJson(res, 503, { error: "Embedding not configured; plugin needs embedding in config." });
372
+ return true;
373
+ }
374
+ let vectors: number[][];
375
+ if (needsRealEmbed) {
376
+ try {
377
+ const out = await enc!(textForEmbed);
378
+ vectors = out.vectors;
379
+ } catch (e) {
380
+ sendJson(res, 502, { error: `embed failed: ${String(e)}` });
381
+ return true;
382
+ }
383
+ if (vectors.length === 0) {
384
+ sendJson(res, 400, { error: "nothing to embed (empty after chunking)" });
385
+ return true;
386
+ }
387
+ } else {
388
+ vectors = [Array.from({ length: vectorDim }, () => 0)];
389
+ }
390
+ const stored = await db.storeMany(
391
+ agentId,
392
+ vectors.map((vector, idx) => ({
393
+ text: textForEmbed,
394
+ vector,
395
+ importance: 1,
396
+ category,
397
+ userId: "",
398
+ sessionId: MANUAL_INSERT_SESSION,
399
+ seqInBatch: 0,
400
+ chunkIndex: idx,
401
+ })),
402
+ );
403
+ sendJson(res, 200, {
404
+ id: stored[0]!.id,
405
+ createdAt: stored[0]!.createdAt,
406
+ chunkRows: stored.length,
407
+ });
408
+ return true;
409
+ }
410
+
411
+ sendJson(res, 404, { error: "Not found" });
412
+ return true;
413
+ } catch (err) {
414
+ logger.warn(`openclaw-memory-alibaba-local memory panel: ${String(err)}`);
415
+ sendJson(res, 500, { error: "Internal error" });
416
+ return true;
417
+ }
418
+ },
419
+ });
420
+
421
+ logger.info("[openclaw-memory-alibaba-local] Memory admin UI at /plugins/memory/");
422
+ }
423
+
424
+ function parseOptionalTimeMs(iso: string | null): number | undefined {
425
+ if (!iso) {
426
+ return undefined;
427
+ }
428
+ const t = Date.parse(iso);
429
+ if (Number.isNaN(t)) {
430
+ return undefined;
431
+ }
432
+ return t;
433
+ }