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.
- package/README.md +88 -0
- package/bm25-recall.ts +71 -0
- package/capture-state.ts +206 -0
- package/categories.ts +106 -0
- package/config.ts +570 -0
- package/db.ts +877 -0
- package/embed-chunks.ts +63 -0
- package/embedding-backend.ts +186 -0
- package/index.ts +1638 -0
- package/openclaw.plugin.json +228 -0
- package/package.json +51 -0
- package/prompt-strip.ts +141 -0
- package/prompts.ts +117 -0
- package/web/memory-routes.ts +433 -0
- package/web/memory-ui.ts +2121 -0
package/db.ts
ADDED
|
@@ -0,0 +1,877 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import type * as LanceDB from "@lancedb/lancedb";
|
|
3
|
+
import type { MemoryCategory } from "./config.js";
|
|
4
|
+
import {
|
|
5
|
+
FULL_CONTEXT_MEMORY,
|
|
6
|
+
FULL_CONTEXT_SOURCE_CATEGORIES,
|
|
7
|
+
SELF_IMPROVING_CATEGORIES,
|
|
8
|
+
USER_MEMORY_FACT,
|
|
9
|
+
USER_MEMORY_CATEGORIES,
|
|
10
|
+
} from "./categories.js";
|
|
11
|
+
|
|
12
|
+
export type MemoryEntry = {
|
|
13
|
+
id: string;
|
|
14
|
+
agentId: string;
|
|
15
|
+
sessionId?: string;
|
|
16
|
+
text: string;
|
|
17
|
+
importance: number;
|
|
18
|
+
category: MemoryCategory;
|
|
19
|
+
createdAt: number;
|
|
20
|
+
isDeleted?: number;
|
|
21
|
+
/** agent_end batch grouping for full-context rows */
|
|
22
|
+
batchId?: string;
|
|
23
|
+
seqInBatch?: number;
|
|
24
|
+
/** 同一逻辑记忆多向量行时的段序号(0..n-1);与 seqInBatch 配合排序 */
|
|
25
|
+
chunkIndex?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type MemorySearchResult = {
|
|
29
|
+
entry: MemoryEntry;
|
|
30
|
+
score: number;
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
|
34
|
+
|
|
35
|
+
/** Fixed LanceDB table name (coexists with official `memories` in the same db directory). */
|
|
36
|
+
export const LANCEDB_TABLE_NAME = "openclaw_memories_alibaba_local";
|
|
37
|
+
|
|
38
|
+
/** Sentinel row used only during initial table creation to train scalar indexes; then hard-deleted. */
|
|
39
|
+
export const BOOTSTRAP_ROW_ID = "00000000-0000-4000-8000-000000000001";
|
|
40
|
+
export const BOOTSTRAP_AGENT_ID = "__memory_index_bootstrap__";
|
|
41
|
+
export const BOOTSTRAP_SESSION_ID = "__memory_index_bootstrap__";
|
|
42
|
+
|
|
43
|
+
/** Max rows matching filters before list API rejects (sorting is in-memory). */
|
|
44
|
+
export const ADMIN_LIST_MAX_MATCHING = 50_000;
|
|
45
|
+
|
|
46
|
+
let lancedbImportPromise: Promise<typeof import("@lancedb/lancedb")> | null = null;
|
|
47
|
+
|
|
48
|
+
async function loadLanceDB(): Promise<typeof import("@lancedb/lancedb")> {
|
|
49
|
+
if (!lancedbImportPromise) {
|
|
50
|
+
lancedbImportPromise = import("@lancedb/lancedb");
|
|
51
|
+
}
|
|
52
|
+
try {
|
|
53
|
+
return await lancedbImportPromise;
|
|
54
|
+
} catch (err) {
|
|
55
|
+
throw new Error(`openclaw-memory-alibaba-local: failed to load LanceDB. ${String(err)}`, {
|
|
56
|
+
cause: err,
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Escape string for LanceDB SQL-style filter predicates. */
|
|
62
|
+
export function sqlEscapeLiteral(value: string): string {
|
|
63
|
+
return value.replace(/\\/g, "\\\\").replace(/'/g, "''");
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function normUserId(v: string | null | undefined): string {
|
|
67
|
+
return v == null || v === "" ? "" : v;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function normSessionId(v: string | null | undefined): string {
|
|
71
|
+
return v == null || v === "" ? "" : v;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function rowToEntry(row: Record<string, unknown>, fallbackAgentId?: string): MemoryEntry {
|
|
75
|
+
const isDel = row.isDeleted;
|
|
76
|
+
const aid = String(row.agentId ?? fallbackAgentId ?? "");
|
|
77
|
+
const batchId = row.batchId != null && String(row.batchId).length > 0 ? String(row.batchId) : undefined;
|
|
78
|
+
const seqRaw = row.seqInBatch;
|
|
79
|
+
const seqInBatch =
|
|
80
|
+
typeof seqRaw === "number" && Number.isFinite(seqRaw) ? Math.floor(seqRaw) : undefined;
|
|
81
|
+
const ciRaw = row.chunkIndex;
|
|
82
|
+
const chunkIndex =
|
|
83
|
+
typeof ciRaw === "number" && Number.isFinite(ciRaw) ? Math.floor(ciRaw) : undefined;
|
|
84
|
+
return {
|
|
85
|
+
id: String(row.id ?? ""),
|
|
86
|
+
agentId: aid,
|
|
87
|
+
sessionId: String(row.sessionId ?? ""),
|
|
88
|
+
text: String(row.text ?? ""),
|
|
89
|
+
importance: Number(row.importance ?? 0),
|
|
90
|
+
category: ((row.category as MemoryCategory) || USER_MEMORY_FACT) as MemoryCategory,
|
|
91
|
+
createdAt: Number(row.createdAt ?? 0),
|
|
92
|
+
isDeleted: isDel !== undefined && isDel !== null ? Number(isDel) : undefined,
|
|
93
|
+
batchId,
|
|
94
|
+
seqInBatch,
|
|
95
|
+
chunkIndex,
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export type AdminListFilters = {
|
|
100
|
+
categories: MemoryCategory[];
|
|
101
|
+
agentId?: string;
|
|
102
|
+
sessionId?: string;
|
|
103
|
+
timeFromMs?: number;
|
|
104
|
+
timeToMs?: number;
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** 管理端「记忆大盘」聚合(与列表共用时间 / Agent / 会话条件;不按 Tab 类别过滤)。 */
|
|
108
|
+
export type MemoryDashboardAggregate = {
|
|
109
|
+
total: number;
|
|
110
|
+
timeFromMs: number;
|
|
111
|
+
timeToMs: number;
|
|
112
|
+
byKind: { user: number; self: number; full: number; other: number };
|
|
113
|
+
byCategory: Record<string, number>;
|
|
114
|
+
byBucket: Array<{ key: string; label: string; count: number }>;
|
|
115
|
+
topAgents: Array<{ agentId: string; count: number }>;
|
|
116
|
+
topSessions: Array<{ sessionId: string; count: number }>;
|
|
117
|
+
importance: { low: number; mid: number; high: number; avg: number };
|
|
118
|
+
uniqueAgents: number;
|
|
119
|
+
uniqueSessions: number;
|
|
120
|
+
};
|
|
121
|
+
|
|
122
|
+
const USER_CAT_SET = new Set<string>(USER_MEMORY_CATEGORIES);
|
|
123
|
+
const SELF_CAT_SET = new Set<string>(SELF_IMPROVING_CATEGORIES);
|
|
124
|
+
const FULL_CAT_SET = new Set<string>([FULL_CONTEXT_MEMORY, ...FULL_CONTEXT_SOURCE_CATEGORIES]);
|
|
125
|
+
|
|
126
|
+
function memoryCategoryKind(category: string): "user" | "self" | "full" | "other" {
|
|
127
|
+
if (USER_CAT_SET.has(category)) {
|
|
128
|
+
return "user";
|
|
129
|
+
}
|
|
130
|
+
if (SELF_CAT_SET.has(category)) {
|
|
131
|
+
return "self";
|
|
132
|
+
}
|
|
133
|
+
if (FULL_CAT_SET.has(category)) {
|
|
134
|
+
return "full";
|
|
135
|
+
}
|
|
136
|
+
return "other";
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pad2(n: number): string {
|
|
140
|
+
return n < 10 ? `0${n}` : String(n);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** 与写入趋势按日桶一致,使用本地时间的年月键。 */
|
|
144
|
+
function localYearMonthKeyFromMs(ms: number): string {
|
|
145
|
+
const d = new Date(ms);
|
|
146
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** 覆盖 [fromMs, toMs] 内涉及的每个自然月(本地),顺序从早到晚。 */
|
|
150
|
+
function localMonthsOverlappingRange(fromMs: number, toMs: number): Array<{ key: string; label: string }> {
|
|
151
|
+
const start = new Date(fromMs);
|
|
152
|
+
const end = new Date(toMs);
|
|
153
|
+
let y = start.getFullYear();
|
|
154
|
+
let m = start.getMonth();
|
|
155
|
+
const endY = end.getFullYear();
|
|
156
|
+
const endM = end.getMonth();
|
|
157
|
+
const out: Array<{ key: string; label: string }> = [];
|
|
158
|
+
while (y < endY || (y === endY && m <= endM)) {
|
|
159
|
+
const key = `${y}-${pad2(m + 1)}`;
|
|
160
|
+
out.push({ key, label: key });
|
|
161
|
+
m += 1;
|
|
162
|
+
if (m > 11) {
|
|
163
|
+
m = 0;
|
|
164
|
+
y += 1;
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
return out;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
/** 写入趋势:按日桶时跨度上限(天);超过则改为按月桶,避免一年只有 60 天有数据。 */
|
|
171
|
+
const DASHBOARD_MAX_DAILY_SPAN_MS = 60 * 86400000;
|
|
172
|
+
|
|
173
|
+
function buildAdminWhereClause(f: AdminListFilters): string {
|
|
174
|
+
const parts: string[] = [];
|
|
175
|
+
parts.push(`id != '${sqlEscapeLiteral(BOOTSTRAP_ROW_ID)}'`);
|
|
176
|
+
if (f.categories.length > 0) {
|
|
177
|
+
const list = f.categories.map((c) => `'${sqlEscapeLiteral(String(c))}'`).join(", ");
|
|
178
|
+
parts.push(`category IN (${list})`);
|
|
179
|
+
}
|
|
180
|
+
parts.push(`isDeleted = 0`);
|
|
181
|
+
if (typeof f.timeFromMs === "number" && Number.isFinite(f.timeFromMs)) {
|
|
182
|
+
parts.push(`createdAt >= ${Math.floor(f.timeFromMs)}`);
|
|
183
|
+
}
|
|
184
|
+
if (typeof f.timeToMs === "number" && Number.isFinite(f.timeToMs)) {
|
|
185
|
+
parts.push(`createdAt <= ${Math.floor(f.timeToMs)}`);
|
|
186
|
+
}
|
|
187
|
+
const aid = (f.agentId ?? "").trim();
|
|
188
|
+
const sid = (f.sessionId ?? "").trim();
|
|
189
|
+
if (aid) {
|
|
190
|
+
parts.push(`agentId = '${sqlEscapeLiteral(aid)}'`);
|
|
191
|
+
}
|
|
192
|
+
if (sid) {
|
|
193
|
+
parts.push(`sessionId = '${sqlEscapeLiteral(sid)}'`);
|
|
194
|
+
}
|
|
195
|
+
return parts.join(" AND ");
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function compareSeqInBatchThenChunk(a: MemoryEntry, b: MemoryEntry): number {
|
|
199
|
+
const sb = (a.seqInBatch ?? 0) - (b.seqInBatch ?? 0);
|
|
200
|
+
if (sb !== 0) {
|
|
201
|
+
return sb;
|
|
202
|
+
}
|
|
203
|
+
return (a.chunkIndex ?? 0) - (b.chunkIndex ?? 0);
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
/**
|
|
207
|
+
* 全文记忆管理端:按 batchId 分组;轮次之间按 batch 时间 sortDesc,组内始终时间/seq/chunk 正序。
|
|
208
|
+
*/
|
|
209
|
+
function sortFullContextAdminGrouped(entries: MemoryEntry[], batchOrderNewestFirst: boolean): void {
|
|
210
|
+
const withBatch = new Map<string, MemoryEntry[]>();
|
|
211
|
+
const noBatch: MemoryEntry[] = [];
|
|
212
|
+
for (const e of entries) {
|
|
213
|
+
const bid = e.batchId != null && String(e.batchId).trim() ? String(e.batchId).trim() : "";
|
|
214
|
+
if (!bid) {
|
|
215
|
+
noBatch.push(e);
|
|
216
|
+
continue;
|
|
217
|
+
}
|
|
218
|
+
let arr = withBatch.get(bid);
|
|
219
|
+
if (!arr) {
|
|
220
|
+
arr = [];
|
|
221
|
+
withBatch.set(bid, arr);
|
|
222
|
+
}
|
|
223
|
+
arr.push(e);
|
|
224
|
+
}
|
|
225
|
+
for (const arr of withBatch.values()) {
|
|
226
|
+
arr.sort((a, b) => {
|
|
227
|
+
if (a.createdAt !== b.createdAt) {
|
|
228
|
+
return a.createdAt - b.createdAt;
|
|
229
|
+
}
|
|
230
|
+
return compareSeqInBatchThenChunk(a, b);
|
|
231
|
+
});
|
|
232
|
+
}
|
|
233
|
+
const batchKeys = [...withBatch.keys()];
|
|
234
|
+
batchKeys.sort((ka, kb) => {
|
|
235
|
+
const ta = withBatch.get(ka)![0]!.createdAt;
|
|
236
|
+
const tb = withBatch.get(kb)![0]!.createdAt;
|
|
237
|
+
return batchOrderNewestFirst ? tb - ta : ta - tb;
|
|
238
|
+
});
|
|
239
|
+
entries.length = 0;
|
|
240
|
+
for (const k of batchKeys) {
|
|
241
|
+
entries.push(...withBatch.get(k)!);
|
|
242
|
+
}
|
|
243
|
+
noBatch.sort((a, b) => {
|
|
244
|
+
return batchOrderNewestFirst ? b.createdAt - a.createdAt : a.createdAt - b.createdAt;
|
|
245
|
+
});
|
|
246
|
+
entries.push(...noBatch);
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
async function createScalarIndexesWithBootstrap(
|
|
250
|
+
table: LanceDB.Table,
|
|
251
|
+
vectorDim: number,
|
|
252
|
+
): Promise<void> {
|
|
253
|
+
const vector = Array.from({ length: vectorDim }).fill(0) as number[];
|
|
254
|
+
await table.add([
|
|
255
|
+
{
|
|
256
|
+
id: BOOTSTRAP_ROW_ID,
|
|
257
|
+
agentId: BOOTSTRAP_AGENT_ID,
|
|
258
|
+
userId: "",
|
|
259
|
+
sessionId: BOOTSTRAP_SESSION_ID,
|
|
260
|
+
text: "",
|
|
261
|
+
vector,
|
|
262
|
+
importance: 0,
|
|
263
|
+
category: USER_MEMORY_FACT,
|
|
264
|
+
createdAt: 0,
|
|
265
|
+
isDeleted: 1,
|
|
266
|
+
batchId: "",
|
|
267
|
+
seqInBatch: 0,
|
|
268
|
+
contentHash: "",
|
|
269
|
+
chunkIndex: 0,
|
|
270
|
+
},
|
|
271
|
+
]);
|
|
272
|
+
try {
|
|
273
|
+
await table.createIndex("agentId");
|
|
274
|
+
} catch (e) {
|
|
275
|
+
console.warn("[openclaw-memory-alibaba-local] createIndex(agentId):", String(e));
|
|
276
|
+
}
|
|
277
|
+
try {
|
|
278
|
+
await table.createIndex("sessionId");
|
|
279
|
+
} catch (e) {
|
|
280
|
+
console.warn("[openclaw-memory-alibaba-local] createIndex(sessionId):", String(e));
|
|
281
|
+
}
|
|
282
|
+
await table.delete(`id = '${BOOTSTRAP_ROW_ID}'`);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
/** Same score mapping as OpenClaw memory-lancedb (L2 distance). */
|
|
286
|
+
function scoreFromL2Distance(distance: number): number {
|
|
287
|
+
return 1 / (1 + distance);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
export class MemoryDB {
|
|
291
|
+
private db: LanceDB.Connection | null = null;
|
|
292
|
+
private table: LanceDB.Table | null = null;
|
|
293
|
+
private initPromise: Promise<void> | null = null;
|
|
294
|
+
|
|
295
|
+
constructor(
|
|
296
|
+
private readonly dbPath: string,
|
|
297
|
+
private readonly vectorDim: number,
|
|
298
|
+
) {}
|
|
299
|
+
|
|
300
|
+
/** LanceDB `vector` column width; used for full_context rows stored without real embeddings (zero placeholder). */
|
|
301
|
+
getEmbeddingVectorDim(): number {
|
|
302
|
+
return this.vectorDim;
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
private async ensureInitialized(): Promise<void> {
|
|
306
|
+
if (this.table) {
|
|
307
|
+
return;
|
|
308
|
+
}
|
|
309
|
+
if (this.initPromise) {
|
|
310
|
+
return this.initPromise;
|
|
311
|
+
}
|
|
312
|
+
this.initPromise = this.doInitialize();
|
|
313
|
+
return this.initPromise;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
/** Await LanceDB open (for HTTP routes before first query). */
|
|
317
|
+
async ensureReady(): Promise<void> {
|
|
318
|
+
await this.ensureInitialized();
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
private async doInitialize(): Promise<void> {
|
|
322
|
+
const lancedb = await loadLanceDB();
|
|
323
|
+
this.db = await lancedb.connect(this.dbPath);
|
|
324
|
+
const tables = await this.db.tableNames();
|
|
325
|
+
|
|
326
|
+
if (tables.includes(LANCEDB_TABLE_NAME)) {
|
|
327
|
+
this.table = await this.db.openTable(LANCEDB_TABLE_NAME);
|
|
328
|
+
} else {
|
|
329
|
+
const seed = {
|
|
330
|
+
id: "__schema__",
|
|
331
|
+
agentId: "",
|
|
332
|
+
userId: "",
|
|
333
|
+
sessionId: "",
|
|
334
|
+
text: "",
|
|
335
|
+
vector: Array.from({ length: this.vectorDim }).fill(0) as number[],
|
|
336
|
+
importance: 0,
|
|
337
|
+
category: USER_MEMORY_FACT,
|
|
338
|
+
createdAt: 0,
|
|
339
|
+
isDeleted: 1,
|
|
340
|
+
batchId: "",
|
|
341
|
+
seqInBatch: 0,
|
|
342
|
+
contentHash: "",
|
|
343
|
+
chunkIndex: 0,
|
|
344
|
+
};
|
|
345
|
+
this.table = await this.db.createTable(LANCEDB_TABLE_NAME, [seed]);
|
|
346
|
+
await this.table.delete('id = "__schema__"');
|
|
347
|
+
await createScalarIndexesWithBootstrap(this.table, this.vectorDim);
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Count rows matching admin UI filters (same predicate as list). */
|
|
352
|
+
async countAdminFiltered(f: AdminListFilters): Promise<number> {
|
|
353
|
+
await this.ensureInitialized();
|
|
354
|
+
const where = buildAdminWhereClause(f);
|
|
355
|
+
return this.table!.countRows(where);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/**
|
|
359
|
+
* 记忆大盘:在时间与可选 Agent/会话下扫描全部类别,内存聚合。
|
|
360
|
+
* 与列表相同行数上限,避免一次加载过多。
|
|
361
|
+
*/
|
|
362
|
+
async getAdminDashboardAggregates(
|
|
363
|
+
timeFromMs: number,
|
|
364
|
+
timeToMs: number,
|
|
365
|
+
agentId?: string,
|
|
366
|
+
sessionId?: string,
|
|
367
|
+
): Promise<MemoryDashboardAggregate> {
|
|
368
|
+
const filters: AdminListFilters = {
|
|
369
|
+
categories: [],
|
|
370
|
+
timeFromMs,
|
|
371
|
+
timeToMs,
|
|
372
|
+
};
|
|
373
|
+
const aid = (agentId ?? "").trim();
|
|
374
|
+
const sid = (sessionId ?? "").trim();
|
|
375
|
+
if (aid) {
|
|
376
|
+
filters.agentId = aid;
|
|
377
|
+
}
|
|
378
|
+
if (sid) {
|
|
379
|
+
filters.sessionId = sid;
|
|
380
|
+
}
|
|
381
|
+
await this.ensureInitialized();
|
|
382
|
+
const where = buildAdminWhereClause(filters);
|
|
383
|
+
const total = await this.table!.countRows(where);
|
|
384
|
+
if (total > ADMIN_LIST_MAX_MATCHING) {
|
|
385
|
+
throw new Error(
|
|
386
|
+
`Too many matching rows (${total}). Narrow the time range or filters (max ${ADMIN_LIST_MAX_MATCHING}).`,
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
const rows = await this.table!
|
|
390
|
+
.query()
|
|
391
|
+
.where(where)
|
|
392
|
+
.select(["category", "createdAt", "agentId", "sessionId", "importance"])
|
|
393
|
+
.toArray();
|
|
394
|
+
|
|
395
|
+
const byKind = { user: 0, self: 0, full: 0, other: 0 };
|
|
396
|
+
const byCategory: Record<string, number> = {};
|
|
397
|
+
const agentCounts = new Map<string, number>();
|
|
398
|
+
const sessionCounts = new Map<string, number>();
|
|
399
|
+
const agentsSeen = new Set<string>();
|
|
400
|
+
const sessionsSeen = new Set<string>();
|
|
401
|
+
let impSum = 0;
|
|
402
|
+
let impN = 0;
|
|
403
|
+
const impB = { low: 0, mid: 0, high: 0 };
|
|
404
|
+
|
|
405
|
+
const fromMs = Math.floor(timeFromMs);
|
|
406
|
+
const toMs = Math.floor(timeToMs);
|
|
407
|
+
const span = Math.max(1, toMs - fromMs);
|
|
408
|
+
const HOUR = 3600000;
|
|
409
|
+
const DAY = 86400000;
|
|
410
|
+
const useHourly = span <= 48 * HOUR;
|
|
411
|
+
const wantMonthly = !useHourly && span > DASHBOARD_MAX_DAILY_SPAN_MS;
|
|
412
|
+
const monthSlots = wantMonthly ? localMonthsOverlappingRange(fromMs, toMs) : null;
|
|
413
|
+
const monthIndex = wantMonthly
|
|
414
|
+
? new Map(monthSlots!.map((s, i) => [s.key, i] as const))
|
|
415
|
+
: null;
|
|
416
|
+
|
|
417
|
+
let bucketSize = DAY;
|
|
418
|
+
let nBuckets = 1;
|
|
419
|
+
|
|
420
|
+
if (useHourly) {
|
|
421
|
+
bucketSize = HOUR;
|
|
422
|
+
nBuckets = Math.min(60, Math.max(1, Math.ceil(span / HOUR)));
|
|
423
|
+
} else if (wantMonthly) {
|
|
424
|
+
nBuckets = monthSlots!.length;
|
|
425
|
+
} else {
|
|
426
|
+
bucketSize = DAY;
|
|
427
|
+
nBuckets = Math.max(1, Math.ceil(span / DAY));
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
const buckets = new Array<number>(nBuckets).fill(0);
|
|
431
|
+
|
|
432
|
+
for (const raw of rows as Array<Record<string, unknown>>) {
|
|
433
|
+
const cat = String(raw.category ?? "");
|
|
434
|
+
const createdAt = Number(raw.createdAt ?? 0);
|
|
435
|
+
const importance = Number(raw.importance ?? 0);
|
|
436
|
+
const ag = String(raw.agentId ?? "").trim();
|
|
437
|
+
const se = String(raw.sessionId ?? "").trim();
|
|
438
|
+
|
|
439
|
+
const k = memoryCategoryKind(cat);
|
|
440
|
+
byKind[k]++;
|
|
441
|
+
|
|
442
|
+
byCategory[cat] = (byCategory[cat] ?? 0) + 1;
|
|
443
|
+
|
|
444
|
+
if (ag) {
|
|
445
|
+
agentCounts.set(ag, (agentCounts.get(ag) ?? 0) + 1);
|
|
446
|
+
agentsSeen.add(ag);
|
|
447
|
+
}
|
|
448
|
+
if (se) {
|
|
449
|
+
sessionCounts.set(se, (sessionCounts.get(se) ?? 0) + 1);
|
|
450
|
+
sessionsSeen.add(se);
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
if (Number.isFinite(importance)) {
|
|
454
|
+
impSum += importance;
|
|
455
|
+
impN++;
|
|
456
|
+
if (importance < 0.34) {
|
|
457
|
+
impB.low++;
|
|
458
|
+
} else if (importance < 0.67) {
|
|
459
|
+
impB.mid++;
|
|
460
|
+
} else {
|
|
461
|
+
impB.high++;
|
|
462
|
+
}
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
if (wantMonthly) {
|
|
466
|
+
const bi = monthIndex!.get(localYearMonthKeyFromMs(createdAt));
|
|
467
|
+
if (bi !== undefined) {
|
|
468
|
+
buckets[bi]++;
|
|
469
|
+
}
|
|
470
|
+
} else {
|
|
471
|
+
const rel = createdAt - fromMs;
|
|
472
|
+
const bi = Math.floor(rel / bucketSize);
|
|
473
|
+
if (bi >= 0 && bi < nBuckets) {
|
|
474
|
+
buckets[bi]++;
|
|
475
|
+
}
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
|
|
479
|
+
const toBucketLabel = (ts: number): string => {
|
|
480
|
+
const d = new Date(ts);
|
|
481
|
+
if (useHourly) {
|
|
482
|
+
return `${pad2(d.getMonth() + 1)}-${pad2(d.getDate())} ${pad2(d.getHours())}:00`;
|
|
483
|
+
}
|
|
484
|
+
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
const byBucket = wantMonthly
|
|
488
|
+
? monthSlots!.map((s, i) => ({
|
|
489
|
+
key: s.key,
|
|
490
|
+
label: s.label,
|
|
491
|
+
count: buckets[i] ?? 0,
|
|
492
|
+
}))
|
|
493
|
+
: buckets.map((count, i) => ({
|
|
494
|
+
key: String(i),
|
|
495
|
+
label: toBucketLabel(fromMs + i * bucketSize),
|
|
496
|
+
count,
|
|
497
|
+
}));
|
|
498
|
+
|
|
499
|
+
const topFromMap = (m: Map<string, number>, n: number) =>
|
|
500
|
+
[...m.entries()]
|
|
501
|
+
.sort((a, b) => b[1] - a[1])
|
|
502
|
+
.slice(0, n)
|
|
503
|
+
.map(([agentId, count]) => ({ agentId, count }));
|
|
504
|
+
|
|
505
|
+
const topSessionsFromMap = (m: Map<string, number>, n: number) =>
|
|
506
|
+
[...m.entries()]
|
|
507
|
+
.sort((a, b) => b[1] - a[1])
|
|
508
|
+
.slice(0, n)
|
|
509
|
+
.map(([sessionId, count]) => ({ sessionId, count }));
|
|
510
|
+
|
|
511
|
+
return {
|
|
512
|
+
total: rows.length,
|
|
513
|
+
timeFromMs: fromMs,
|
|
514
|
+
timeToMs: toMs,
|
|
515
|
+
byKind,
|
|
516
|
+
byCategory,
|
|
517
|
+
byBucket,
|
|
518
|
+
topAgents: topFromMap(agentCounts, 10),
|
|
519
|
+
topSessions: topSessionsFromMap(sessionCounts, 10),
|
|
520
|
+
importance: {
|
|
521
|
+
low: impB.low,
|
|
522
|
+
mid: impB.mid,
|
|
523
|
+
high: impB.high,
|
|
524
|
+
avg: impN > 0 ? impSum / impN : 0,
|
|
525
|
+
},
|
|
526
|
+
uniqueAgents: agentsSeen.size,
|
|
527
|
+
uniqueSessions: sessionsSeen.size,
|
|
528
|
+
};
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* List rows for admin UI, paginated. Throws if match count > ADMIN_LIST_MAX_MATCHING.
|
|
533
|
+
* - user/self: sort by createdAt; sortDesc=true → 新→旧。
|
|
534
|
+
* - full: 按 batchId 分组;轮次间按 batch 时间 sortDesc;组内始终正序。
|
|
535
|
+
*/
|
|
536
|
+
async listAdminFiltered(
|
|
537
|
+
f: AdminListFilters,
|
|
538
|
+
page: number,
|
|
539
|
+
pageSize: number,
|
|
540
|
+
options?: { adminTab?: "user" | "self" | "full"; sortDesc?: boolean },
|
|
541
|
+
): Promise<{ total: number; items: MemoryEntry[] }> {
|
|
542
|
+
await this.ensureInitialized();
|
|
543
|
+
const where = buildAdminWhereClause(f);
|
|
544
|
+
const total = await this.table!.countRows(where);
|
|
545
|
+
if (total > ADMIN_LIST_MAX_MATCHING) {
|
|
546
|
+
throw new Error(
|
|
547
|
+
`Too many matching rows (${total}). Narrow the time range or filters (max ${ADMIN_LIST_MAX_MATCHING}).`,
|
|
548
|
+
);
|
|
549
|
+
}
|
|
550
|
+
const rows = await this.table!
|
|
551
|
+
.query()
|
|
552
|
+
.where(where)
|
|
553
|
+
.select([
|
|
554
|
+
"id",
|
|
555
|
+
"agentId",
|
|
556
|
+
"sessionId",
|
|
557
|
+
"category",
|
|
558
|
+
"createdAt",
|
|
559
|
+
"isDeleted",
|
|
560
|
+
"importance",
|
|
561
|
+
"text",
|
|
562
|
+
"batchId",
|
|
563
|
+
"seqInBatch",
|
|
564
|
+
"chunkIndex",
|
|
565
|
+
])
|
|
566
|
+
.toArray();
|
|
567
|
+
const entries = (rows as Array<Record<string, unknown>>).map((r) => rowToEntry(r));
|
|
568
|
+
const tab = options?.adminTab ?? "user";
|
|
569
|
+
const sortDesc = options?.sortDesc !== false;
|
|
570
|
+
if (tab === "full") {
|
|
571
|
+
sortFullContextAdminGrouped(entries, sortDesc);
|
|
572
|
+
} else if (sortDesc) {
|
|
573
|
+
entries.sort((a, b) => b.createdAt - a.createdAt);
|
|
574
|
+
} else {
|
|
575
|
+
entries.sort((a, b) => a.createdAt - b.createdAt);
|
|
576
|
+
}
|
|
577
|
+
const p = Math.max(1, page);
|
|
578
|
+
const ps = Math.max(1, Math.min(500, pageSize));
|
|
579
|
+
const offset = (p - 1) * ps;
|
|
580
|
+
return { total, items: entries.slice(offset, offset + ps) };
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
/**
|
|
584
|
+
* Distinct agentId / sessionId for admin dropdowns (no agent/session filter).
|
|
585
|
+
* Pass **categories = []** to scan all non-deleted rows (recommended for facets: legacy `full_context_memory`, unknown categories).
|
|
586
|
+
* Non-empty categories adds `category IN (...)`.
|
|
587
|
+
*/
|
|
588
|
+
/**
|
|
589
|
+
* 拉取可供 BM25 打分的行(用户/自进化逻辑记忆),条数上限避免全表扫描过大。
|
|
590
|
+
* 按 createdAt 新→旧排序。
|
|
591
|
+
*/
|
|
592
|
+
async listRowsForBm25Recall(
|
|
593
|
+
agentId: string,
|
|
594
|
+
categories: MemoryCategory[],
|
|
595
|
+
maxRows: number,
|
|
596
|
+
): Promise<MemoryEntry[]> {
|
|
597
|
+
if (categories.length === 0) {
|
|
598
|
+
return [];
|
|
599
|
+
}
|
|
600
|
+
await this.ensureInitialized();
|
|
601
|
+
const a = sqlEscapeLiteral(agentId);
|
|
602
|
+
const list = categories.map((c) => `'${sqlEscapeLiteral(String(c))}'`).join(", ");
|
|
603
|
+
const where = `id != '${sqlEscapeLiteral(BOOTSTRAP_ROW_ID)}' AND agentId = '${a}' AND isDeleted = 0 AND category IN (${list})`;
|
|
604
|
+
const cap = Math.max(1, Math.min(maxRows, 50_000));
|
|
605
|
+
const rows = await this.table!.query().where(where).limit(cap).toArray();
|
|
606
|
+
const entries = (rows as Array<Record<string, unknown>>).map((r) => rowToEntry(r, agentId));
|
|
607
|
+
entries.sort((a, b) => b.createdAt - a.createdAt);
|
|
608
|
+
return entries;
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
async listAdminFacets(
|
|
612
|
+
categories: MemoryCategory[],
|
|
613
|
+
timeFromMs?: number,
|
|
614
|
+
timeToMs?: number,
|
|
615
|
+
): Promise<{ agents: string[]; sessions: string[] }> {
|
|
616
|
+
const f: AdminListFilters = {
|
|
617
|
+
categories,
|
|
618
|
+
timeFromMs,
|
|
619
|
+
timeToMs,
|
|
620
|
+
};
|
|
621
|
+
await this.ensureInitialized();
|
|
622
|
+
const where = buildAdminWhereClause(f);
|
|
623
|
+
const rows = await this.table!
|
|
624
|
+
.query()
|
|
625
|
+
.where(where)
|
|
626
|
+
.select(["agentId", "sessionId"])
|
|
627
|
+
.limit(25_000)
|
|
628
|
+
.toArray();
|
|
629
|
+
const agents = new Set<string>();
|
|
630
|
+
const sessions = new Set<string>();
|
|
631
|
+
for (const row of rows as Array<Record<string, unknown>>) {
|
|
632
|
+
const a = String(row.agentId ?? "").trim();
|
|
633
|
+
const s = String(row.sessionId ?? "").trim();
|
|
634
|
+
if (a) {
|
|
635
|
+
agents.add(a);
|
|
636
|
+
}
|
|
637
|
+
if (s) {
|
|
638
|
+
sessions.add(s);
|
|
639
|
+
}
|
|
640
|
+
}
|
|
641
|
+
return {
|
|
642
|
+
agents: [...agents].sort(),
|
|
643
|
+
sessions: [...sessions].sort(),
|
|
644
|
+
};
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
async store(
|
|
648
|
+
agentId: string,
|
|
649
|
+
entry: {
|
|
650
|
+
text: string;
|
|
651
|
+
vector: number[];
|
|
652
|
+
importance: number;
|
|
653
|
+
category: MemoryCategory;
|
|
654
|
+
userId?: string | null;
|
|
655
|
+
sessionId?: string | null;
|
|
656
|
+
batchId?: string | null;
|
|
657
|
+
seqInBatch?: number | null;
|
|
658
|
+
chunkIndex?: number | null;
|
|
659
|
+
},
|
|
660
|
+
): Promise<MemoryEntry> {
|
|
661
|
+
const [first] = await this.storeMany(agentId, [entry]);
|
|
662
|
+
return first;
|
|
663
|
+
}
|
|
664
|
+
|
|
665
|
+
/**
|
|
666
|
+
* Insert multiple vector rows (e.g. paragraph chunks). Each row gets a new id.
|
|
667
|
+
* `text` should be the full logical memory text on every row for recall/LLM context.
|
|
668
|
+
*/
|
|
669
|
+
async storeMany(
|
|
670
|
+
agentId: string,
|
|
671
|
+
entries: ReadonlyArray<{
|
|
672
|
+
text: string;
|
|
673
|
+
vector: number[];
|
|
674
|
+
importance: number;
|
|
675
|
+
category: MemoryCategory;
|
|
676
|
+
userId?: string | null;
|
|
677
|
+
sessionId?: string | null;
|
|
678
|
+
batchId?: string | null;
|
|
679
|
+
seqInBatch?: number | null;
|
|
680
|
+
chunkIndex?: number | null;
|
|
681
|
+
}>,
|
|
682
|
+
): Promise<MemoryEntry[]> {
|
|
683
|
+
await this.ensureInitialized();
|
|
684
|
+
if (entries.length === 0) {
|
|
685
|
+
return [];
|
|
686
|
+
}
|
|
687
|
+
const createdAt = Date.now();
|
|
688
|
+
const rows: Array<Record<string, unknown>> = [];
|
|
689
|
+
const out: MemoryEntry[] = [];
|
|
690
|
+
for (let i = 0; i < entries.length; i++) {
|
|
691
|
+
const entry = entries[i]!;
|
|
692
|
+
const id = randomUUID();
|
|
693
|
+
const batchId =
|
|
694
|
+
entry.batchId != null && String(entry.batchId).trim() ? String(entry.batchId).trim() : "";
|
|
695
|
+
const seqInBatch =
|
|
696
|
+
typeof entry.seqInBatch === "number" && Number.isFinite(entry.seqInBatch)
|
|
697
|
+
? Math.floor(entry.seqInBatch)
|
|
698
|
+
: 0;
|
|
699
|
+
const chunkIndex =
|
|
700
|
+
typeof entry.chunkIndex === "number" && Number.isFinite(entry.chunkIndex)
|
|
701
|
+
? Math.floor(entry.chunkIndex)
|
|
702
|
+
: i;
|
|
703
|
+
rows.push({
|
|
704
|
+
id,
|
|
705
|
+
agentId,
|
|
706
|
+
userId: normUserId(entry.userId),
|
|
707
|
+
sessionId: normSessionId(entry.sessionId),
|
|
708
|
+
text: entry.text,
|
|
709
|
+
vector: entry.vector,
|
|
710
|
+
importance: entry.importance,
|
|
711
|
+
category: entry.category,
|
|
712
|
+
createdAt,
|
|
713
|
+
isDeleted: 0,
|
|
714
|
+
batchId,
|
|
715
|
+
seqInBatch,
|
|
716
|
+
contentHash: "",
|
|
717
|
+
chunkIndex,
|
|
718
|
+
});
|
|
719
|
+
out.push({
|
|
720
|
+
id,
|
|
721
|
+
agentId,
|
|
722
|
+
text: entry.text,
|
|
723
|
+
importance: entry.importance,
|
|
724
|
+
category: entry.category,
|
|
725
|
+
createdAt,
|
|
726
|
+
batchId: batchId || undefined,
|
|
727
|
+
seqInBatch,
|
|
728
|
+
chunkIndex,
|
|
729
|
+
});
|
|
730
|
+
}
|
|
731
|
+
await this.table!.add(rows);
|
|
732
|
+
return out;
|
|
733
|
+
}
|
|
734
|
+
|
|
735
|
+
/** 是否已有相同 agent + session + category + 全文 的非删除行(用于自动捕获前跳过完全重复)。 */
|
|
736
|
+
async existsSemanticDuplicate(
|
|
737
|
+
agentId: string,
|
|
738
|
+
sessionId: string,
|
|
739
|
+
category: MemoryCategory,
|
|
740
|
+
text: string,
|
|
741
|
+
): Promise<boolean> {
|
|
742
|
+
const t = (text ?? "").trim();
|
|
743
|
+
if (!t) {
|
|
744
|
+
return false;
|
|
745
|
+
}
|
|
746
|
+
await this.ensureInitialized();
|
|
747
|
+
const a = sqlEscapeLiteral(agentId);
|
|
748
|
+
const s = sqlEscapeLiteral(normSessionId(sessionId));
|
|
749
|
+
const c = sqlEscapeLiteral(String(category));
|
|
750
|
+
const tt = sqlEscapeLiteral(t);
|
|
751
|
+
const where = `agentId = '${a}' AND sessionId = '${s}' AND category = '${c}' AND text = '${tt}' AND isDeleted = 0`;
|
|
752
|
+
const rows = await this.table!.query().where(where).limit(1).toArray();
|
|
753
|
+
return rows.length > 0;
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
/**
|
|
757
|
+
* Vector search with multiple query embeddings; merge by category + text (chunk 行共享同一逻辑正文), keep max score.
|
|
758
|
+
*/
|
|
759
|
+
async searchMerged(
|
|
760
|
+
agentId: string,
|
|
761
|
+
vectors: number[][],
|
|
762
|
+
limit = 5,
|
|
763
|
+
minScore = 0.5,
|
|
764
|
+
categories?: MemoryCategory[],
|
|
765
|
+
): Promise<MemorySearchResult[]> {
|
|
766
|
+
if (vectors.length === 0) {
|
|
767
|
+
return [];
|
|
768
|
+
}
|
|
769
|
+
const perVec = Math.max(1, limit * 2);
|
|
770
|
+
const merged = new Map<string, MemorySearchResult>();
|
|
771
|
+
for (const v of vectors) {
|
|
772
|
+
const hits = await this.search(agentId, v, perVec, minScore, categories);
|
|
773
|
+
for (const h of hits) {
|
|
774
|
+
const key = `${String(h.entry.category)}\0${h.entry.text}`;
|
|
775
|
+
const prev = merged.get(key);
|
|
776
|
+
if (!prev || h.score > prev.score) {
|
|
777
|
+
merged.set(key, h);
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
}
|
|
781
|
+
return [...merged.values()].sort((a, b) => b.score - a.score).slice(0, limit);
|
|
782
|
+
}
|
|
783
|
+
|
|
784
|
+
async search(
|
|
785
|
+
agentId: string,
|
|
786
|
+
vector: number[],
|
|
787
|
+
limit = 5,
|
|
788
|
+
minScore = 0.5,
|
|
789
|
+
categories?: MemoryCategory[],
|
|
790
|
+
): Promise<MemorySearchResult[]> {
|
|
791
|
+
await this.ensureInitialized();
|
|
792
|
+
const a = sqlEscapeLiteral(agentId);
|
|
793
|
+
const parts = [`agentId = '${a}'`, `isDeleted = 0`];
|
|
794
|
+
if (Array.isArray(categories) && categories.length > 0) {
|
|
795
|
+
const list = categories.map((cat) => `'${sqlEscapeLiteral(String(cat))}'`).join(", ");
|
|
796
|
+
parts.push(`category IN (${list})`);
|
|
797
|
+
}
|
|
798
|
+
const whereClause = parts.join(" AND ");
|
|
799
|
+
|
|
800
|
+
const rows = await this.table!
|
|
801
|
+
.vectorSearch(vector)
|
|
802
|
+
.where(whereClause)
|
|
803
|
+
.limit(Math.max(1, limit))
|
|
804
|
+
.toArray();
|
|
805
|
+
|
|
806
|
+
const results: MemorySearchResult[] = [];
|
|
807
|
+
for (const row of rows as Array<Record<string, unknown>>) {
|
|
808
|
+
const distance = Number(row._distance ?? 0);
|
|
809
|
+
const score = scoreFromL2Distance(distance);
|
|
810
|
+
if (score < minScore) {
|
|
811
|
+
continue;
|
|
812
|
+
}
|
|
813
|
+
results.push({
|
|
814
|
+
entry: rowToEntry(row, agentId),
|
|
815
|
+
score,
|
|
816
|
+
});
|
|
817
|
+
}
|
|
818
|
+
return results;
|
|
819
|
+
}
|
|
820
|
+
|
|
821
|
+
async deleteMany(items: ReadonlyArray<{ agentId: string; id: string }>): Promise<number> {
|
|
822
|
+
let n = 0;
|
|
823
|
+
for (const it of items) {
|
|
824
|
+
if (await this.delete(it.agentId, it.id)) {
|
|
825
|
+
n++;
|
|
826
|
+
}
|
|
827
|
+
}
|
|
828
|
+
return n;
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/** 删除同一逻辑记忆的所有 chunk 行(agent + session + category + 正文完全一致)。 */
|
|
832
|
+
async deleteByAgentSessionCategoryText(
|
|
833
|
+
agentId: string,
|
|
834
|
+
sessionId: string | null | undefined,
|
|
835
|
+
category: MemoryCategory,
|
|
836
|
+
text: string,
|
|
837
|
+
): Promise<number> {
|
|
838
|
+
const t = (text ?? "").trim();
|
|
839
|
+
if (!t) {
|
|
840
|
+
return 0;
|
|
841
|
+
}
|
|
842
|
+
await this.ensureInitialized();
|
|
843
|
+
const a = sqlEscapeLiteral(agentId);
|
|
844
|
+
const s = sqlEscapeLiteral(normSessionId(sessionId));
|
|
845
|
+
const c = sqlEscapeLiteral(String(category));
|
|
846
|
+
const tt = sqlEscapeLiteral(t);
|
|
847
|
+
const pred = `agentId = '${a}' AND sessionId = '${s}' AND category = '${c}' AND text = '${tt}'`;
|
|
848
|
+
const res = await this.table!.delete(pred);
|
|
849
|
+
return res.numDeletedRows ?? 0;
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
async delete(agentId: string, id: string): Promise<boolean> {
|
|
853
|
+
if (!UUID_RE.test(id)) {
|
|
854
|
+
throw new Error(`Invalid memory ID format: ${id}`);
|
|
855
|
+
}
|
|
856
|
+
await this.ensureInitialized();
|
|
857
|
+
const pred = `id = '${sqlEscapeLiteral(id)}' AND agentId = '${sqlEscapeLiteral(agentId)}'`;
|
|
858
|
+
const res = await this.table!.delete(pred);
|
|
859
|
+
return (res.numDeletedRows ?? 0) > 0;
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
async close(): Promise<void> {
|
|
863
|
+
try {
|
|
864
|
+
this.table?.close();
|
|
865
|
+
} catch {
|
|
866
|
+
// ignore
|
|
867
|
+
}
|
|
868
|
+
try {
|
|
869
|
+
this.db?.close();
|
|
870
|
+
} catch {
|
|
871
|
+
// ignore
|
|
872
|
+
}
|
|
873
|
+
this.table = null;
|
|
874
|
+
this.db = null;
|
|
875
|
+
this.initPromise = null;
|
|
876
|
+
}
|
|
877
|
+
}
|