@ppagent/memory 0.1.4 → 0.4.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/dist/cli.js ADDED
@@ -0,0 +1,1187 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/cli.ts
4
+ import * as fs from "node:fs/promises";
5
+ import * as os from "node:os";
6
+ import * as path2 from "node:path";
7
+
8
+ // src/config.ts
9
+ import * as path from "node:path";
10
+ function resolveConfig(config) {
11
+ let embeddingBaseUrl = config.embeddingBaseUrl;
12
+ let embeddingApiKey = config.embeddingApiKey;
13
+ if (!embeddingBaseUrl) {
14
+ console.warn(
15
+ "[MemoryConfig] embeddingBaseUrl \u672A\u914D\u7F6E\uFF0C\u81EA\u52A8\u56DE\u9000\u4F7F\u7528 llmBaseUrl\uFF1A" + config.llmBaseUrl
16
+ );
17
+ embeddingBaseUrl = config.llmBaseUrl;
18
+ }
19
+ if (!embeddingApiKey) {
20
+ console.warn("[MemoryConfig] embeddingApiKey \u672A\u914D\u7F6E\uFF0C\u81EA\u52A8\u56DE\u9000\u4F7F\u7528 llmApiKey\u3002");
21
+ embeddingApiKey = config.llmApiKey;
22
+ }
23
+ const compressedContextTokenLimit = positiveInt(
24
+ config.compressedContextTokenLimit ?? config.historyWindowTokenLimit ?? 16 * 1024,
25
+ "compressedContextTokenLimit"
26
+ );
27
+ if (compressedContextTokenLimit > 32 * 1024) {
28
+ console.warn(
29
+ `[MemoryConfig] compressedContextTokenLimit=${compressedContextTokenLimit} \u8D85\u8FC7 32K\uFF0C\u4F1A\u5360\u7528\u8F83\u591A\u6A21\u578B\u4E0A\u4E0B\u6587\u5E76\u589E\u52A0\u5E38\u9A7B\u5185\u5B58\u3002`
30
+ );
31
+ }
32
+ return {
33
+ ...config,
34
+ embeddingBaseUrl,
35
+ embeddingApiKey,
36
+ provider: config.provider ?? "auto",
37
+ sqlitePath: config.sqlitePath ?? path.join(path.dirname(config.lancedbPath), "memory.sqlite3"),
38
+ sessionTokenLimit: config.sessionTokenLimit ?? 16386,
39
+ historyWindowTokenLimit: config.historyWindowTokenLimit ?? compressedContextTokenLimit,
40
+ topicRatio: config.topicRatio ?? [1, 5, 20],
41
+ detailMaxTokens: config.detailMaxTokens ?? 2048,
42
+ summaryMaxTokens: config.summaryMaxTokens ?? 512,
43
+ conciseMaxTokens: config.conciseMaxTokens ?? 128,
44
+ compressedContextTokenLimit,
45
+ contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
46
+ precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
47
+ compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.5, "compressionBatchRatio"),
48
+ compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
49
+ topicSummaryMaxTokens: positiveInt(
50
+ config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
51
+ "topicSummaryMaxTokens"
52
+ ),
53
+ defaultModelContextTokens: positiveInt(
54
+ config.defaultModelContextTokens ?? 256 * 1024,
55
+ "defaultModelContextTokens"
56
+ ),
57
+ maxHistoryAgeMs: Math.max(0, Math.floor(config.maxHistoryAgeMs ?? 0)),
58
+ sessionIdleTtlMs: Math.floor(config.sessionIdleTtlMs ?? 30 * 6e4),
59
+ sessionSweepIntervalMs: positiveInt(
60
+ config.sessionSweepIntervalMs ?? 6e4,
61
+ "sessionSweepIntervalMs"
62
+ ),
63
+ httpTimeoutMs: config.httpTimeoutMs ?? 6e4,
64
+ httpMaxRetries: config.httpMaxRetries ?? 2,
65
+ embeddingBatchSize: config.embeddingBatchSize ?? 20,
66
+ embeddingConcurrency: config.embeddingConcurrency ?? 2,
67
+ maxConcurrentCompressions: config.maxConcurrentCompressions ?? 3,
68
+ entitySimilarityThreshold: config.entitySimilarityThreshold ?? 0.92,
69
+ defaultSearchLimit: config.defaultSearchLimit ?? 10,
70
+ recallBoostMs: config.recallBoostMs ?? 36e5,
71
+ chunkStrategy: config.chunkStrategy ?? "markdown-heading",
72
+ chunkMaxTokens: config.chunkMaxTokens ?? 800,
73
+ chunkOverlap: config.chunkOverlap ?? 0,
74
+ knowledgeTopK: config.knowledgeTopK ?? 8,
75
+ docCoarseTopK: config.docCoarseTopK ?? 5,
76
+ buildGraphDefault: config.buildGraphDefault ?? "auto",
77
+ chunkRedundantIds: config.chunkRedundantIds ?? true,
78
+ knowledgeGraphTriggerScore: config.knowledgeGraphTriggerScore ?? 0.78,
79
+ knowledgeGraphEntityTopK: config.knowledgeGraphEntityTopK ?? 10,
80
+ knowledgeGraphAnchorTopK: config.knowledgeGraphAnchorTopK ?? 3,
81
+ knowledgeGraphHopLimit: config.knowledgeGraphHopLimit ?? 10,
82
+ graphExtractConcurrency: config.graphExtractConcurrency ?? 2,
83
+ graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4,
84
+ autoOptimizeOnInit: config.autoOptimizeOnInit ?? true,
85
+ autoOptimizeIntervalMs: config.autoOptimizeIntervalMs ?? 6 * 36e5,
86
+ optimizeVersionRetentionMs: config.optimizeVersionRetentionMs ?? 0,
87
+ restoreConcurrency: config.restoreConcurrency ?? 8
88
+ };
89
+ }
90
+ function ratio(value, name) {
91
+ if (!Number.isFinite(value) || value <= 0 || value > 1) {
92
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u5728 (0, 1] \u8303\u56F4\u5185`);
93
+ }
94
+ return value;
95
+ }
96
+ function positiveInt(value, name) {
97
+ if (!Number.isFinite(value) || value <= 0) {
98
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u6B63\u6570`);
99
+ }
100
+ return Math.floor(value);
101
+ }
102
+
103
+ // src/db/store.types.ts
104
+ function eq(field, value) {
105
+ return { op: "eq", field, value };
106
+ }
107
+
108
+ // src/page.util.ts
109
+ function cmpStr(a, b) {
110
+ return a < b ? -1 : a > b ? 1 : 0;
111
+ }
112
+
113
+ // src/llm/embed.service.ts
114
+ import { get_encoding } from "@dqbd/tiktoken";
115
+ var encoder = null;
116
+ function countTokens(text) {
117
+ if (!encoder) {
118
+ encoder = get_encoding("cl100k_base");
119
+ }
120
+ return encoder.encode(text).length;
121
+ }
122
+
123
+ // src/db/memory.store.ts
124
+ var MESSAGES_TABLE = "messages";
125
+ var TOPICS_TABLE = "topics";
126
+ var FACTS_TABLE = "facts";
127
+ var SESSIONS_TABLE = "sessions";
128
+ var DOCUMENTS_TABLE = "documents";
129
+ var CHUNKS_TABLE = "chunks";
130
+ var RRF_K = 60;
131
+ var HYBRID_OVERFETCH = 2;
132
+ function tableDefs(dim) {
133
+ return [
134
+ {
135
+ name: MESSAGES_TABLE,
136
+ vectorDimension: dim,
137
+ columns: [
138
+ { name: "message_id", type: "text" },
139
+ { name: "talker_id", type: "text" },
140
+ { name: "chat_id", type: "text" },
141
+ { name: "user_id", type: "text" },
142
+ { name: "session_id", type: "text" },
143
+ { name: "type", type: "text" },
144
+ { name: "content", type: "text" },
145
+ { name: "parts", type: "text", nullable: true },
146
+ // 兼容存量数据
147
+ { name: "payload", type: "text", nullable: true },
148
+ { name: "vector", type: "vector" },
149
+ { name: "usage", type: "int" },
150
+ { name: "metadata", type: "text" },
151
+ { name: "created_at", type: "long" }
152
+ ],
153
+ indexes: [
154
+ { column: "message_id", kind: "scalar" },
155
+ { column: "session_id", kind: "scalar" },
156
+ { column: "content", kind: "fts" },
157
+ { column: "metadata", kind: "fts" }
158
+ ]
159
+ },
160
+ {
161
+ name: TOPICS_TABLE,
162
+ vectorDimension: dim,
163
+ columns: [
164
+ { name: "summary_id", type: "text" },
165
+ { name: "session_id", type: "text" },
166
+ { name: "user_id", type: "text" },
167
+ { name: "chat_id", type: "text" },
168
+ { name: "title", type: "text", nullable: true },
169
+ // 兼容存量数据
170
+ { name: "detail", type: "text", nullable: true },
171
+ { name: "summary", type: "text" },
172
+ { name: "concise", type: "text", nullable: true },
173
+ { name: "tokens", type: "int", nullable: true },
174
+ { name: "start_message_id", type: "text", nullable: true },
175
+ { name: "end_message_id", type: "text", nullable: true },
176
+ { name: "vector", type: "vector" },
177
+ { name: "start_time", type: "long" },
178
+ { name: "end_time", type: "long" },
179
+ { name: "created_at", type: "long" },
180
+ { name: "updated_at", type: "long" },
181
+ { name: "recall_count", type: "int" }
182
+ ],
183
+ indexes: [
184
+ { column: "chat_id", kind: "scalar" },
185
+ { column: "summary", kind: "fts" }
186
+ ]
187
+ },
188
+ {
189
+ name: FACTS_TABLE,
190
+ columns: [
191
+ { name: "fact_id", type: "text" },
192
+ { name: "level", type: "text" },
193
+ { name: "chat_id", type: "text" },
194
+ { name: "session_id", type: "text" },
195
+ { name: "user_id", type: "text" },
196
+ { name: "fact_key", type: "text", nullable: true },
197
+ { name: "content", type: "text" },
198
+ { name: "created_at", type: "long" },
199
+ { name: "updated_at", type: "long", nullable: true }
200
+ ],
201
+ indexes: []
202
+ },
203
+ {
204
+ name: SESSIONS_TABLE,
205
+ columns: [
206
+ { name: "session_id", type: "text" },
207
+ { name: "chat_id", type: "text" },
208
+ { name: "user_id", type: "text" },
209
+ { name: "title", type: "text" },
210
+ { name: "metadata", type: "text" },
211
+ { name: "created_at", type: "long" },
212
+ { name: "updated_at", type: "long" }
213
+ ],
214
+ indexes: [{ column: "session_id", kind: "scalar" }]
215
+ },
216
+ {
217
+ name: DOCUMENTS_TABLE,
218
+ vectorDimension: dim,
219
+ columns: [
220
+ { name: "doc_id", type: "text" },
221
+ { name: "user_id", type: "text" },
222
+ { name: "chat_id", type: "text" },
223
+ { name: "session_id", type: "text" },
224
+ { name: "title", type: "text" },
225
+ { name: "source_name", type: "text" },
226
+ { name: "full_content", type: "text" },
227
+ { name: "content_hash", type: "text" },
228
+ { name: "summary", type: "text" },
229
+ // vector 列存储文档摘要向量(summaryVector),用于文档级粗召回
230
+ { name: "vector", type: "vector" },
231
+ { name: "chunk_count", type: "int" },
232
+ { name: "has_graph", type: "int" },
233
+ // 0/1 充当布尔
234
+ { name: "metadata", type: "text" },
235
+ { name: "created_at", type: "long" },
236
+ { name: "updated_at", type: "long" }
237
+ ],
238
+ indexes: [{ column: "doc_id", kind: "scalar" }]
239
+ },
240
+ {
241
+ name: CHUNKS_TABLE,
242
+ vectorDimension: dim,
243
+ columns: [
244
+ { name: "chunk_id", type: "text" },
245
+ { name: "doc_id", type: "text" },
246
+ // user_id/chat_id/session_id:chunkRedundantIds=false 时存空串,仅经 documents join 过滤
247
+ { name: "user_id", type: "text" },
248
+ { name: "chat_id", type: "text" },
249
+ { name: "session_id", type: "text" },
250
+ { name: "content", type: "text" },
251
+ { name: "vector", type: "vector" },
252
+ { name: "heading_path", type: "text" },
253
+ { name: "ordinal", type: "int" },
254
+ { name: "tokens", type: "int" },
255
+ { name: "metadata", type: "text" },
256
+ { name: "created_at", type: "long" }
257
+ ],
258
+ indexes: [
259
+ { column: "doc_id", kind: "scalar" },
260
+ { column: "content", kind: "fts" }
261
+ ]
262
+ }
263
+ ];
264
+ }
265
+ function toVector(v) {
266
+ return Array.isArray(v) ? v : Array.from(v);
267
+ }
268
+ function messageToRow(m) {
269
+ return {
270
+ message_id: m.messageId,
271
+ talker_id: m.talkerId,
272
+ chat_id: m.chatId,
273
+ user_id: m.userId,
274
+ session_id: m.sessionId,
275
+ type: m.type,
276
+ content: m.content,
277
+ parts: m.parts ?? "[]",
278
+ payload: m.payload ?? null,
279
+ vector: m.vector,
280
+ usage: m.usage,
281
+ metadata: m.metadata,
282
+ created_at: m.createdAt
283
+ };
284
+ }
285
+ function rowToMessage(r) {
286
+ return {
287
+ messageId: r.message_id,
288
+ talkerId: r.talker_id,
289
+ chatId: r.chat_id,
290
+ userId: r.user_id,
291
+ sessionId: r.session_id,
292
+ type: r.type,
293
+ content: r.content,
294
+ parts: r.parts ?? "[]",
295
+ // 旧数据无此列时安全降级
296
+ payload: r.payload ?? void 0,
297
+ vector: toVector(r.vector),
298
+ usage: positiveNumber(r.usage),
299
+ metadata: r.metadata,
300
+ createdAt: Number(r.created_at)
301
+ };
302
+ }
303
+ function topicToRow(t) {
304
+ return {
305
+ summary_id: t.summaryId,
306
+ session_id: t.sessionId,
307
+ user_id: t.userId,
308
+ chat_id: t.chatId,
309
+ title: t.title ?? "",
310
+ // 旧库的 detail/concise 可能仍是 NOT NULL;兼容写入但业务只读取 summary。
311
+ detail: t.detail ?? t.summary,
312
+ summary: t.summary,
313
+ concise: t.concise ?? t.summary,
314
+ tokens: t.tokens,
315
+ start_message_id: t.startMessageId ?? null,
316
+ end_message_id: t.endMessageId ?? null,
317
+ vector: t.vector,
318
+ start_time: t.startTime,
319
+ end_time: t.endTime,
320
+ created_at: t.createdAt,
321
+ updated_at: t.updatedAt,
322
+ recall_count: t.recallCount
323
+ };
324
+ }
325
+ function rowToTopic(r) {
326
+ return {
327
+ summaryId: r.summary_id,
328
+ sessionId: r.session_id,
329
+ userId: r.user_id,
330
+ chatId: r.chat_id,
331
+ title: r.title ?? "",
332
+ summary: r.summary || r.detail || r.concise || "",
333
+ tokens: positiveNumber(r.tokens),
334
+ startMessageId: r.start_message_id ?? void 0,
335
+ endMessageId: r.end_message_id ?? void 0,
336
+ detail: r.detail ?? void 0,
337
+ concise: r.concise ?? void 0,
338
+ vector: toVector(r.vector),
339
+ startTime: Number(r.start_time),
340
+ endTime: Number(r.end_time),
341
+ createdAt: Number(r.created_at),
342
+ updatedAt: Number(r.updated_at),
343
+ recallCount: Number(r.recall_count)
344
+ };
345
+ }
346
+ function factToRow(f) {
347
+ return {
348
+ fact_id: f.factId,
349
+ level: f.level,
350
+ chat_id: f.chatId,
351
+ session_id: f.sessionId,
352
+ user_id: f.userId,
353
+ fact_key: f.key ?? null,
354
+ content: f.content,
355
+ created_at: f.createdAt,
356
+ updated_at: f.updatedAt ?? f.createdAt
357
+ };
358
+ }
359
+ function rowToFact(r) {
360
+ return {
361
+ factId: r.fact_id,
362
+ level: r.level,
363
+ chatId: r.chat_id,
364
+ sessionId: r.session_id,
365
+ userId: r.user_id,
366
+ key: r.fact_key ?? void 0,
367
+ content: r.content,
368
+ createdAt: Number(r.created_at),
369
+ updatedAt: Number(r.updated_at ?? r.created_at)
370
+ };
371
+ }
372
+ function sessionToRow(s) {
373
+ return {
374
+ session_id: s.sessionId,
375
+ chat_id: s.chatId,
376
+ user_id: s.userId,
377
+ title: s.title,
378
+ metadata: s.metadata,
379
+ created_at: s.createdAt,
380
+ updated_at: s.updatedAt
381
+ };
382
+ }
383
+ function rowToSession(r) {
384
+ return {
385
+ sessionId: r.session_id,
386
+ chatId: r.chat_id,
387
+ userId: r.user_id,
388
+ title: r.title,
389
+ metadata: r.metadata,
390
+ createdAt: Number(r.created_at),
391
+ updatedAt: Number(r.updated_at)
392
+ };
393
+ }
394
+ function documentToRow(d) {
395
+ return {
396
+ doc_id: d.docId,
397
+ user_id: d.userId,
398
+ chat_id: d.chatId,
399
+ session_id: d.sessionId,
400
+ title: d.title,
401
+ source_name: d.sourceName,
402
+ full_content: d.fullContent,
403
+ content_hash: d.contentHash,
404
+ summary: d.summary,
405
+ vector: d.summaryVector,
406
+ chunk_count: d.chunkCount,
407
+ has_graph: d.hasGraph ? 1 : 0,
408
+ metadata: JSON.stringify(d.metadata ?? {}),
409
+ created_at: d.createdAt,
410
+ updated_at: d.updatedAt
411
+ };
412
+ }
413
+ function rowToDocument(r) {
414
+ return {
415
+ docId: r.doc_id,
416
+ userId: r.user_id,
417
+ chatId: r.chat_id,
418
+ sessionId: r.session_id,
419
+ title: r.title,
420
+ sourceName: r.source_name,
421
+ fullContent: r.full_content,
422
+ contentHash: r.content_hash,
423
+ summary: r.summary,
424
+ summaryVector: toVector(r.vector),
425
+ chunkCount: Number(r.chunk_count),
426
+ hasGraph: Number(r.has_graph) === 1,
427
+ metadata: safeParseObject(r.metadata),
428
+ createdAt: Number(r.created_at),
429
+ updatedAt: Number(r.updated_at)
430
+ };
431
+ }
432
+ function chunkToRow(c) {
433
+ return {
434
+ chunk_id: c.chunkId,
435
+ doc_id: c.docId,
436
+ user_id: c.userId,
437
+ chat_id: c.chatId,
438
+ session_id: c.sessionId,
439
+ content: c.content,
440
+ vector: c.vector,
441
+ heading_path: c.headingPath,
442
+ ordinal: c.ordinal,
443
+ tokens: c.tokens,
444
+ metadata: JSON.stringify(c.metadata ?? {}),
445
+ created_at: c.createdAt
446
+ };
447
+ }
448
+ function rowToChunk(r) {
449
+ return {
450
+ chunkId: r.chunk_id,
451
+ docId: r.doc_id,
452
+ userId: r.user_id,
453
+ chatId: r.chat_id,
454
+ sessionId: r.session_id,
455
+ content: r.content,
456
+ vector: toVector(r.vector),
457
+ headingPath: r.heading_path,
458
+ ordinal: Number(r.ordinal),
459
+ tokens: Number(r.tokens),
460
+ metadata: safeParseObject(r.metadata),
461
+ createdAt: Number(r.created_at)
462
+ };
463
+ }
464
+ function safeParseObject(s) {
465
+ try {
466
+ return JSON.parse(s);
467
+ } catch {
468
+ return {};
469
+ }
470
+ }
471
+ function positiveNumber(value) {
472
+ const parsed = Number(value);
473
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
474
+ }
475
+ function migrationValueEquals(current, next) {
476
+ if (current == null && next == null) return true;
477
+ if (typeof next === "number") return Number(current) === next;
478
+ return String(current) === String(next);
479
+ }
480
+ function rrfFuse(lists, keyOf) {
481
+ const acc = /* @__PURE__ */ new Map();
482
+ for (const list of lists) {
483
+ list.forEach((row, idx) => {
484
+ const id = keyOf(row);
485
+ const inc = 1 / (RRF_K + idx + 1);
486
+ const entry = acc.get(id);
487
+ if (entry) entry.score += inc;
488
+ else acc.set(id, { row, score: inc });
489
+ });
490
+ }
491
+ return [...acc.values()].sort((a, b) => b.score - a.score).map((e) => e.row);
492
+ }
493
+ var MemoryStore = class {
494
+ config;
495
+ provider;
496
+ providerOverride;
497
+ /** provider 省略时在 init() 阶段经 provider.resolver 自动探测创建(测试可显式注入) */
498
+ constructor(config, provider) {
499
+ this.config = config;
500
+ this.providerOverride = provider;
501
+ }
502
+ /** 当前后端 provider 标识(日志/诊断用) */
503
+ get providerKind() {
504
+ return this.provider?.kind ?? "uninitialized";
505
+ }
506
+ async init() {
507
+ if (this.providerOverride) {
508
+ this.provider = this.providerOverride;
509
+ } else {
510
+ const { createProvider } = await import("./provider.resolver-2KS2YYNV.js");
511
+ this.provider = await createProvider(this.config);
512
+ console.info(`[memory] \u5411\u91CF\u5B58\u50A8\u540E\u7AEF\uFF1A${this.provider.kind}`);
513
+ }
514
+ await this.provider.init(tableDefs(this.config.embeddingDimension));
515
+ }
516
+ async close() {
517
+ await this.provider.close();
518
+ }
519
+ /**
520
+ * 存储压实(碎片合并 + 历史版本清理)。
521
+ * LanceDB 后端长期运行必须定期执行;SQLite 后端为可选的空间回收。
522
+ */
523
+ async optimizeStorage(retentionMs = 0) {
524
+ return this.provider.optimize(retentionMs);
525
+ }
526
+ // ── messages ───────────────────────────────────────────────────────────────
527
+ async addMessages(messages) {
528
+ if (messages.length === 0) return;
529
+ await this.provider.add(MESSAGES_TABLE, messages.map(messageToRow));
530
+ }
531
+ /** 0.3.x → 0.4.x 数据回填;幂等,不调用 LLM、不删除原始消息。 */
532
+ async migrateLegacyData() {
533
+ const report = {
534
+ provider: this.providerKind,
535
+ topicsScanned: 0,
536
+ topicsUpdated: 0,
537
+ messagesScanned: 0,
538
+ messagesUpdated: 0,
539
+ factsScanned: 0,
540
+ factsUpdated: 0
541
+ };
542
+ const topicRows = await this.provider.query(TOPICS_TABLE);
543
+ report.topicsScanned = topicRows.length;
544
+ const messagesBySession = /* @__PURE__ */ new Map();
545
+ for (const row of topicRows) {
546
+ const sessionId = String(row.session_id);
547
+ let messages = messagesBySession.get(sessionId);
548
+ if (!messages) {
549
+ messages = await this.provider.query(MESSAGES_TABLE, {
550
+ filter: [eq("session_id", sessionId)],
551
+ orderBy: [
552
+ { column: "created_at", ascending: true },
553
+ { column: "message_id", ascending: true }
554
+ ]
555
+ });
556
+ messagesBySession.set(sessionId, messages);
557
+ }
558
+ const summary = String(row.summary || row.detail || row.concise || "");
559
+ const startTime = Number(row.start_time);
560
+ const endTime = Number(row.end_time);
561
+ const covered = messages.filter(
562
+ (message) => Number(message.created_at) >= startTime && Number(message.created_at) <= endTime
563
+ );
564
+ const values = {
565
+ summary,
566
+ tokens: positiveNumber(row.tokens) || Math.max(1, countTokens(summary)),
567
+ start_message_id: row.start_message_id ?? covered.at(0)?.message_id ?? null,
568
+ end_message_id: row.end_message_id ?? covered.at(-1)?.message_id ?? null,
569
+ updated_at: Number(row.updated_at || Date.now())
570
+ };
571
+ const changed = Object.entries(values).some(
572
+ ([key, value]) => !migrationValueEquals(row[key], value)
573
+ );
574
+ if (changed) {
575
+ await this.provider.update(TOPICS_TABLE, values, [eq("summary_id", String(row.summary_id))]);
576
+ report.topicsUpdated++;
577
+ }
578
+ }
579
+ const messageRows = await this.provider.query(MESSAGES_TABLE);
580
+ report.messagesScanned = messageRows.length;
581
+ for (const row of messageRows) {
582
+ const usage2 = countTokens(
583
+ [row.content, row.parts ?? "[]", row.metadata ?? "{}", row.payload ?? ""].map(String).join("\n")
584
+ );
585
+ if (Number(row.usage) === usage2) continue;
586
+ await this.provider.update(MESSAGES_TABLE, { usage: usage2 }, [
587
+ eq("message_id", String(row.message_id))
588
+ ]);
589
+ report.messagesUpdated++;
590
+ }
591
+ const factRows = await this.provider.query(FACTS_TABLE);
592
+ report.factsScanned = factRows.length;
593
+ for (const row of factRows) {
594
+ const updatedAt = Number(row.updated_at || row.created_at);
595
+ if (Number(row.updated_at) === updatedAt) continue;
596
+ await this.provider.update(FACTS_TABLE, { updated_at: updatedAt }, [
597
+ eq("fact_id", String(row.fact_id))
598
+ ]);
599
+ report.factsUpdated++;
600
+ }
601
+ return report;
602
+ }
603
+ /** message_id 稳定时更新原行,否则新增;用于流式 assistant 消息最终态覆盖。 */
604
+ async upsertMessages(messages) {
605
+ if (messages.length === 0) return;
606
+ const ids = messages.map((message) => message.messageId);
607
+ const existing = await this.provider.query(MESSAGES_TABLE, {
608
+ filter: [{ op: "in", field: "message_id", values: ids }],
609
+ select: ["message_id"]
610
+ });
611
+ const existingIds = new Set(existing.map((row) => String(row.message_id)));
612
+ await Promise.all(
613
+ messages.filter((message) => existingIds.has(message.messageId)).map(
614
+ (message) => this.provider.update(
615
+ MESSAGES_TABLE,
616
+ messageToRow(message),
617
+ [eq("message_id", message.messageId)]
618
+ )
619
+ )
620
+ );
621
+ await this.addMessages(messages.filter((message) => !existingIds.has(message.messageId)));
622
+ }
623
+ async getMessagesSince(sessionId, since, limit) {
624
+ try {
625
+ const rows = await this.provider.query(MESSAGES_TABLE, {
626
+ filter: [eq("session_id", sessionId), { op: "gt", field: "created_at", value: since }],
627
+ limit
628
+ });
629
+ return rows.sort((a, b) => Number(a.created_at) - Number(b.created_at)).map(rowToMessage);
630
+ } catch {
631
+ return [];
632
+ }
633
+ }
634
+ async getMessagesAfterBoundary(sessionId, endTime, endMessageId) {
635
+ const messages = await this.getAllMessagesBySession(sessionId);
636
+ return messages.filter(
637
+ (message) => message.createdAt > endTime || message.createdAt === endTime && endMessageId != null && message.messageId.localeCompare(endMessageId) > 0
638
+ );
639
+ }
640
+ async getLatestMessages(sessionId, limit) {
641
+ if (limit <= 0) return [];
642
+ try {
643
+ const rows = await this.provider.query(MESSAGES_TABLE, {
644
+ filter: [eq("session_id", sessionId)],
645
+ orderBy: [
646
+ { column: "created_at", ascending: false },
647
+ { column: "message_id", ascending: false }
648
+ ],
649
+ limit
650
+ });
651
+ return rows.reverse().map(rowToMessage);
652
+ } catch {
653
+ return [];
654
+ }
655
+ }
656
+ /** 取某会话的全部消息(按 createdAt 升序),供管理面板分页切片使用 */
657
+ async getAllMessagesBySession(sessionId) {
658
+ try {
659
+ const rows = await this.provider.query(MESSAGES_TABLE, {
660
+ filter: [eq("session_id", sessionId)]
661
+ });
662
+ return rows.sort(
663
+ (a, b) => Number(a.created_at) - Number(b.created_at) || cmpStr(a.message_id, b.message_id)
664
+ ).map(rowToMessage);
665
+ } catch {
666
+ return [];
667
+ }
668
+ }
669
+ async searchMessages(vector, filter, limit = 10) {
670
+ const rows = await this.provider.vectorSearch(MESSAGES_TABLE, vector, { filter, limit });
671
+ return rows.map((r) => ({ ...rowToMessage(r), _distance: r._distance }));
672
+ }
673
+ // 对 messages 表执行混合搜索(BM25 + 向量),用于 topics 搜索无结果时的回退
674
+ async hybridSearchMessages(query, vector, filter, limit = 10) {
675
+ const fused = await this.hybridSearch(
676
+ MESSAGES_TABLE,
677
+ ["content", "metadata"],
678
+ query,
679
+ vector,
680
+ filter,
681
+ limit
682
+ );
683
+ return fused.slice(0, limit).map(rowToMessage);
684
+ }
685
+ async hybridSearchTopics(query, vector, filter, limit = 10) {
686
+ try {
687
+ const fused = (await this.hybridSearch(TOPICS_TABLE, ["summary"], query, vector, filter, limit)).slice(0, limit);
688
+ if (fused.length > 0) {
689
+ const total = fused.length;
690
+ return fused.map((r, idx) => ({
691
+ row: r,
692
+ score: total - idx + Math.log1p(Number(r.recall_count))
693
+ })).sort((a, b) => b.score - a.score).map((x) => rowToTopic(x.row));
694
+ }
695
+ return [];
696
+ } catch {
697
+ return [];
698
+ }
699
+ }
700
+ /**
701
+ * 通用混合检索:FTS(BM25)与向量两路各取 limit*HYBRID_OVERFETCH 候选,RRF 融合。
702
+ * FTS 不可用(后端不支持或查询失败)时降级为纯向量。返回融合后的完整候选序列(未截断)。
703
+ */
704
+ async hybridSearch(table, ftsColumns, query, vector, filter, limit) {
705
+ const fetchN = limit * HYBRID_OVERFETCH;
706
+ const supportsFts = this.provider.capabilities().fts;
707
+ const [ftsRows, vecRows] = await Promise.all([
708
+ supportsFts ? this.provider.ftsSearch(table, query, { columns: ftsColumns, filter, limit: fetchN }).catch(() => []) : Promise.resolve([]),
709
+ this.provider.vectorSearch(table, vector, { filter, limit: fetchN }).catch(() => [])
710
+ ]);
711
+ const keyColumn = this.keyColumnOf(table);
712
+ return rrfFuse([ftsRows, vecRows], (r) => String(r[keyColumn]));
713
+ }
714
+ keyColumnOf(table) {
715
+ switch (table) {
716
+ case MESSAGES_TABLE:
717
+ return "message_id";
718
+ case TOPICS_TABLE:
719
+ return "summary_id";
720
+ case DOCUMENTS_TABLE:
721
+ return "doc_id";
722
+ case CHUNKS_TABLE:
723
+ return "chunk_id";
724
+ case SESSIONS_TABLE:
725
+ return "session_id";
726
+ case FACTS_TABLE:
727
+ return "fact_id";
728
+ default:
729
+ throw new Error(`Unknown table: ${table}`);
730
+ }
731
+ }
732
+ // ── topics ─────────────────────────────────────────────────────────────────
733
+ async addTopic(topic) {
734
+ await this.provider.add(TOPICS_TABLE, [topicToRow(topic)]);
735
+ }
736
+ async updateTopic(topic) {
737
+ await this.provider.update(
738
+ TOPICS_TABLE,
739
+ topicToRow(topic),
740
+ [eq("summary_id", topic.summaryId)]
741
+ );
742
+ }
743
+ async updateTopicRecallCount(summaryId, count) {
744
+ await this.provider.update(
745
+ TOPICS_TABLE,
746
+ { recall_count: count, updated_at: Date.now() },
747
+ [eq("summary_id", summaryId)]
748
+ );
749
+ }
750
+ async incrementTopicRecallCounts(topics) {
751
+ await Promise.all(
752
+ topics.map((topic) => this.updateTopicRecallCount(topic.summaryId, topic.recallCount + 1))
753
+ );
754
+ }
755
+ async getTopicsBySession(sessionId, since = 0) {
756
+ try {
757
+ const filter = [eq("session_id", sessionId)];
758
+ if (since > 0) filter.push({ op: "gte", field: "end_time", value: since });
759
+ const rows = await this.provider.query(TOPICS_TABLE, { filter });
760
+ return rows.sort(
761
+ (a, b) => Number(a.end_time) - Number(b.end_time) || cmpStr(a.summary_id, b.summary_id)
762
+ ).map(rowToTopic);
763
+ } catch {
764
+ return [];
765
+ }
766
+ }
767
+ async getRecentTopics(chatId, userId, n1, n2, n3) {
768
+ const filter = [eq("chat_id", chatId), eq("user_id", userId)];
769
+ const recallBoostMs = this.config.recallBoostMs;
770
+ const fetchTopics = async (count) => {
771
+ if (count <= 0) return [];
772
+ try {
773
+ const rows = await this.provider.query(TOPICS_TABLE, {
774
+ filter,
775
+ limit: count * 5
776
+ });
777
+ return rows.sort((a, b) => {
778
+ const scoreA = Number(a.end_time) + Number(a.recall_count) * recallBoostMs;
779
+ const scoreB = Number(b.end_time) + Number(b.recall_count) * recallBoostMs;
780
+ return scoreB - scoreA;
781
+ }).slice(0, count).map(rowToTopic);
782
+ } catch {
783
+ return [];
784
+ }
785
+ };
786
+ const allTopics = await fetchTopics(n1 + n2 + n3);
787
+ return {
788
+ detail: allTopics.slice(0, n1),
789
+ summary: allTopics.slice(n1, n1 + n2),
790
+ concise: allTopics.slice(n1 + n2, n1 + n2 + n3)
791
+ };
792
+ }
793
+ /** 全量读取 topics(管理面板列表用,不做 recall 加权排序,按 endTime 倒序)*/
794
+ async getAllTopics() {
795
+ try {
796
+ const rows = await this.provider.query(TOPICS_TABLE);
797
+ return rows.sort(
798
+ (a, b) => Number(b.end_time) - Number(a.end_time) || cmpStr(a.summary_id, b.summary_id)
799
+ ).map(rowToTopic);
800
+ } catch {
801
+ return [];
802
+ }
803
+ }
804
+ async deleteTopicsBySession(sessionId) {
805
+ await this.provider.deleteWhere(TOPICS_TABLE, [eq("session_id", sessionId)]);
806
+ }
807
+ async deleteTopicsByIds(summaryIds) {
808
+ if (summaryIds.length === 0) return;
809
+ await this.provider.deleteWhere(TOPICS_TABLE, [
810
+ { op: "in", field: "summary_id", values: summaryIds }
811
+ ]);
812
+ }
813
+ // ── facts ──────────────────────────────────────────────────────────────────
814
+ async saveFact(fact) {
815
+ await this.provider.add(FACTS_TABLE, [factToRow(fact)]);
816
+ }
817
+ async updateFact(fact) {
818
+ await this.provider.update(FACTS_TABLE, factToRow(fact), [eq("fact_id", fact.factId)]);
819
+ }
820
+ async getAllFacts() {
821
+ try {
822
+ const rows = await this.provider.query(FACTS_TABLE);
823
+ return rows.map(rowToFact);
824
+ } catch {
825
+ return [];
826
+ }
827
+ }
828
+ /** 删除单条 fact */
829
+ async deleteFact(factId) {
830
+ await this.provider.deleteWhere(FACTS_TABLE, [eq("fact_id", factId)]);
831
+ }
832
+ // ── sessions ───────────────────────────────────────────────────────────────
833
+ async getAllSessionIds() {
834
+ try {
835
+ const rows = await this.provider.query(MESSAGES_TABLE, {
836
+ select: ["session_id"],
837
+ limit: 1e4
838
+ });
839
+ return [...new Set(rows.map((r) => r.session_id))];
840
+ } catch {
841
+ return [];
842
+ }
843
+ }
844
+ async insertSession(session) {
845
+ await this.provider.add(SESSIONS_TABLE, [sessionToRow(session)]);
846
+ }
847
+ async upsertSession(session) {
848
+ await this.provider.deleteWhere(SESSIONS_TABLE, [eq("session_id", session.sessionId)]);
849
+ await this.provider.add(SESSIONS_TABLE, [sessionToRow(session)]);
850
+ }
851
+ async getAllSessions() {
852
+ try {
853
+ const rows = await this.provider.query(SESSIONS_TABLE);
854
+ return rows.map(rowToSession);
855
+ } catch {
856
+ return [];
857
+ }
858
+ }
859
+ async deleteSession(sessionId) {
860
+ await this.provider.deleteWhere(SESSIONS_TABLE, [eq("session_id", sessionId)]);
861
+ }
862
+ /** 删除某会话下的全部消息(级联删除会话时使用)*/
863
+ async deleteMessagesBySession(sessionId) {
864
+ await this.provider.deleteWhere(MESSAGES_TABLE, [eq("session_id", sessionId)]);
865
+ }
866
+ // ── 统计 ───────────────────────────────────────────────────────────────────
867
+ /** 各表行数统计(概览卡片用)*/
868
+ async countAll() {
869
+ const safeCount = async (table) => {
870
+ try {
871
+ return await this.provider.count(table);
872
+ } catch {
873
+ return 0;
874
+ }
875
+ };
876
+ const [sessions, messages, topics, facts] = await Promise.all([
877
+ safeCount(SESSIONS_TABLE),
878
+ safeCount(MESSAGES_TABLE),
879
+ safeCount(TOPICS_TABLE),
880
+ safeCount(FACTS_TABLE)
881
+ ]);
882
+ return { sessions, messages, topics, facts };
883
+ }
884
+ /**
885
+ * 按天聚合最近 days 天的活跃趋势(概览图表用)。
886
+ * 返回连续日期序列(含无数据的零值天),按本地日期分桶。
887
+ */
888
+ async trendDaily(days) {
889
+ const dayMs = 864e5;
890
+ const now = /* @__PURE__ */ new Date();
891
+ const todayMidnight = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
892
+ const startMs = todayMidnight - (days - 1) * dayMs;
893
+ const fmt = (ts) => {
894
+ const d = new Date(ts);
895
+ const mm = String(d.getMonth() + 1).padStart(2, "0");
896
+ const dd = String(d.getDate()).padStart(2, "0");
897
+ return `${d.getFullYear()}-${mm}-${dd}`;
898
+ };
899
+ const readCreatedAt = async (table) => {
900
+ try {
901
+ const rows = await this.provider.query(table, {
902
+ select: ["created_at"],
903
+ filter: [{ op: "gte", field: "created_at", value: startMs }],
904
+ limit: 1e6
905
+ });
906
+ return rows.map((r) => Number(r.created_at)).filter((n) => Number.isFinite(n));
907
+ } catch {
908
+ return [];
909
+ }
910
+ };
911
+ const [sessionTs, messageTs, factTs] = await Promise.all([
912
+ readCreatedAt(SESSIONS_TABLE),
913
+ readCreatedAt(MESSAGES_TABLE),
914
+ readCreatedAt(FACTS_TABLE)
915
+ ]);
916
+ const buckets = /* @__PURE__ */ new Map();
917
+ for (let i = 0; i < days; i++) {
918
+ const key = fmt(startMs + i * dayMs);
919
+ buckets.set(key, { date: key, sessions: 0, messages: 0, facts: 0 });
920
+ }
921
+ const tally = (list, field) => {
922
+ for (const ts of list) {
923
+ const b = buckets.get(fmt(ts));
924
+ if (b) b[field]++;
925
+ }
926
+ };
927
+ tally(sessionTs, "sessions");
928
+ tally(messageTs, "messages");
929
+ tally(factTs, "facts");
930
+ return [...buckets.values()];
931
+ }
932
+ // ── 知识库:documents / chunks ─────────────────────────────────────────────
933
+ async addDocument(doc) {
934
+ await this.provider.add(DOCUMENTS_TABLE, [documentToRow(doc)]);
935
+ }
936
+ async addChunks(chunks) {
937
+ if (chunks.length === 0) return;
938
+ await this.provider.add(CHUNKS_TABLE, chunks.map(chunkToRow));
939
+ }
940
+ async updateDocumentGraphFlag(docId, hasGraph) {
941
+ await this.provider.update(
942
+ DOCUMENTS_TABLE,
943
+ { has_graph: hasGraph ? 1 : 0, updated_at: Date.now() },
944
+ [eq("doc_id", docId)]
945
+ );
946
+ }
947
+ async getDocument(docId) {
948
+ try {
949
+ const rows = await this.provider.query(DOCUMENTS_TABLE, {
950
+ filter: [eq("doc_id", docId)],
951
+ limit: 1
952
+ });
953
+ if (rows.length === 0) return null;
954
+ return rowToDocument(rows[0]);
955
+ } catch {
956
+ return null;
957
+ }
958
+ }
959
+ /** 查找同域同 hash 的文档(去重用)*/
960
+ async findDocumentByHash(contentHash, filter) {
961
+ try {
962
+ const rows = await this.provider.query(DOCUMENTS_TABLE, {
963
+ filter: [eq("content_hash", contentHash), ...filter ?? []],
964
+ limit: 1
965
+ });
966
+ if (rows.length === 0) return null;
967
+ return rowToDocument(rows[0]);
968
+ } catch {
969
+ return null;
970
+ }
971
+ }
972
+ /** 按域过滤返回 docId 列表(chunkRedundantIds=false 时用于 chunk 过滤)*/
973
+ async getDocIdsByDomain(filter) {
974
+ try {
975
+ const rows = await this.provider.query(DOCUMENTS_TABLE, {
976
+ select: ["doc_id"],
977
+ filter,
978
+ limit: 1e5
979
+ });
980
+ return [...new Set(rows.map((r) => r.doc_id))];
981
+ } catch {
982
+ return [];
983
+ }
984
+ }
985
+ /** 文档级粗召回:对摘要向量做向量搜索,定位候选文档 */
986
+ async searchDocuments(vector, filter, limit = 5) {
987
+ try {
988
+ const rows = await this.provider.vectorSearch(DOCUMENTS_TABLE, vector, { filter, limit });
989
+ return rows.map((r) => ({ ...rowToDocument(r), _distance: r._distance }));
990
+ } catch {
991
+ return [];
992
+ }
993
+ }
994
+ /** 知识片段混合检索(BM25 + 向量),失败回退纯向量 */
995
+ async searchChunks(query, vector, filter, limit = 8) {
996
+ try {
997
+ const fused = await this.hybridSearch(CHUNKS_TABLE, ["content"], query, vector, filter, limit);
998
+ if (fused.length > 0) return fused.slice(0, limit).map(rowToChunk);
999
+ } catch {
1000
+ }
1001
+ try {
1002
+ const rows = await this.provider.vectorSearch(CHUNKS_TABLE, vector, { filter, limit });
1003
+ return rows.map(rowToChunk);
1004
+ } catch {
1005
+ return [];
1006
+ }
1007
+ }
1008
+ async deleteDocument(docId) {
1009
+ await this.provider.deleteWhere(DOCUMENTS_TABLE, [eq("doc_id", docId)]);
1010
+ }
1011
+ async deleteChunksByDoc(docId) {
1012
+ await this.provider.deleteWhere(CHUNKS_TABLE, [eq("doc_id", docId)]);
1013
+ }
1014
+ /** 全量读取文档(管理面板用),按更新时间倒序 */
1015
+ async getAllDocuments() {
1016
+ try {
1017
+ const rows = await this.provider.query(DOCUMENTS_TABLE);
1018
+ return rows.sort(
1019
+ (a, b) => Number(b.updated_at) - Number(a.updated_at) || cmpStr(a.doc_id, b.doc_id)
1020
+ ).map(rowToDocument);
1021
+ } catch {
1022
+ return [];
1023
+ }
1024
+ }
1025
+ /** 知识库行数统计 */
1026
+ async countKnowledge() {
1027
+ const safeCount = async (table) => {
1028
+ try {
1029
+ return await this.provider.count(table);
1030
+ } catch {
1031
+ return 0;
1032
+ }
1033
+ };
1034
+ const [documents, chunks] = await Promise.all([
1035
+ safeCount(DOCUMENTS_TABLE),
1036
+ safeCount(CHUNKS_TABLE)
1037
+ ]);
1038
+ return { documents, chunks };
1039
+ }
1040
+ /**
1041
+ * 根据 metadata 字段内容构建结构化过滤条件。
1042
+ *
1043
+ * metadata 以 JSON 字符串存储,此方法将键值对转换为 jsonContains 条件,
1044
+ * 可直接传给 searchMessages / hybridSearchTopics 的 filter 参数。
1045
+ *
1046
+ * 注意:仅适合简单标量值(字符串、数字、布尔)的精确匹配。
1047
+ * 复杂嵌套对象或含空格的 JSON 值可能无法可靠匹配。
1048
+ */
1049
+ static buildMetadataFilter(conditions) {
1050
+ return Object.entries(conditions).map(([key, value]) => ({
1051
+ op: "jsonContains",
1052
+ field: "metadata",
1053
+ key,
1054
+ value
1055
+ }));
1056
+ }
1057
+ };
1058
+
1059
+ // src/cli.ts
1060
+ async function main() {
1061
+ const [command, ...args] = process.argv.slice(2);
1062
+ if (command !== "migrate") {
1063
+ usage();
1064
+ process.exitCode = command ? 1 : 0;
1065
+ return;
1066
+ }
1067
+ const configArg = valueOf(args, "--config");
1068
+ if (!configArg) throw new Error("migrate requires --config <config.json>");
1069
+ const dryRun = args.includes("--dry-run");
1070
+ const configPath = path2.resolve(configArg);
1071
+ const configDir = path2.dirname(configPath);
1072
+ const parsed = JSON.parse(await fs.readFile(configPath, "utf8"));
1073
+ const source = findMemoryConfig(parsed);
1074
+ const liveConfig = resolveConfig(absolutizeConfig(source, configDir));
1075
+ let config = liveConfig;
1076
+ console.info(
1077
+ `[ppagent-memory] migration ${dryRun ? "dry-run" : "start"}; stop all processes using the memory database first.`
1078
+ );
1079
+ let tempDir;
1080
+ if (dryRun) {
1081
+ tempDir = await fs.mkdtemp(path2.join(os.tmpdir(), "ppagent-memory-migrate-"));
1082
+ config = await cloneConfigStorage(liveConfig, tempDir);
1083
+ } else {
1084
+ const backupArg = valueOf(args, "--backup");
1085
+ const backupDir = path2.resolve(
1086
+ backupArg ?? path2.join(configDir, `memory-backup-${safeTimestamp()}`)
1087
+ );
1088
+ await backupStorage(liveConfig, backupDir);
1089
+ console.info(`[ppagent-memory] backup created at ${backupDir}`);
1090
+ }
1091
+ const store = new MemoryStore(config);
1092
+ try {
1093
+ await store.init();
1094
+ const report = await store.migrateLegacyData();
1095
+ console.info(JSON.stringify({ dryRun, ...report }, null, 2));
1096
+ console.info(
1097
+ dryRun ? "[ppagent-memory] dry-run complete; original storage was not modified." : "[ppagent-memory] migration complete; original raw messages were retained."
1098
+ );
1099
+ } finally {
1100
+ await store.close().catch(() => {
1101
+ });
1102
+ if (tempDir) await fs.rm(tempDir, { recursive: true, force: true }).catch(() => {
1103
+ });
1104
+ }
1105
+ }
1106
+ function findMemoryConfig(root) {
1107
+ const candidates = [
1108
+ root,
1109
+ root.memory,
1110
+ objectAt(root, ["app", "memory", "auto"]),
1111
+ objectAt(root, ["service", "memory"])
1112
+ ];
1113
+ for (const candidate of candidates) {
1114
+ if (candidate && typeof candidate === "object" && "lancedbPath" in candidate && "embeddingModel" in candidate && "llmModel" in candidate) {
1115
+ const value = candidate;
1116
+ const { advanced, ...base } = value;
1117
+ return { ...advanced ?? {}, ...base };
1118
+ }
1119
+ }
1120
+ throw new Error("No @ppagent/memory config found (checked root and app.memory.auto)");
1121
+ }
1122
+ function objectAt(root, keys) {
1123
+ let value = root;
1124
+ for (const key of keys) {
1125
+ if (!value || typeof value !== "object") return void 0;
1126
+ value = value[key];
1127
+ }
1128
+ return value;
1129
+ }
1130
+ function absolutizeConfig(config, base) {
1131
+ const absolute = (value) => value == null || path2.isAbsolute(value) ? value : path2.resolve(base, value);
1132
+ return {
1133
+ ...config,
1134
+ lancedbPath: absolute(config.lancedbPath),
1135
+ sqlitePath: absolute(config.sqlitePath),
1136
+ grafeoPath: absolute(config.grafeoPath)
1137
+ };
1138
+ }
1139
+ async function cloneConfigStorage(config, tempDir) {
1140
+ const lancedbPath = path2.join(tempDir, "lancedb");
1141
+ const sqlitePath = path2.join(tempDir, "memory.sqlite3");
1142
+ if (await exists(config.lancedbPath)) await fs.cp(config.lancedbPath, lancedbPath, { recursive: true });
1143
+ if (await exists(config.sqlitePath)) await fs.copyFile(config.sqlitePath, sqlitePath);
1144
+ if (await exists(`${config.sqlitePath}-wal`)) await fs.copyFile(`${config.sqlitePath}-wal`, `${sqlitePath}-wal`);
1145
+ if (await exists(`${config.sqlitePath}-shm`)) await fs.copyFile(`${config.sqlitePath}-shm`, `${sqlitePath}-shm`);
1146
+ const marker = path2.join(path2.dirname(config.lancedbPath), ".memory-provider");
1147
+ const marked = await fs.readFile(marker, "utf8").then((value) => value.trim(), () => "");
1148
+ const provider = config.provider === "auto" ? marked === "sqlite" || marked === "lancedb" ? marked : await exists(config.sqlitePath) ? "sqlite" : "lancedb" : config.provider;
1149
+ return { ...config, provider, lancedbPath, sqlitePath, grafeoPath: path2.join(tempDir, "grafeo") };
1150
+ }
1151
+ async function backupStorage(config, backupDir) {
1152
+ await fs.mkdir(backupDir, { recursive: true });
1153
+ const entries = [
1154
+ [config.lancedbPath, "lancedb"],
1155
+ [config.sqlitePath, "memory.sqlite3"],
1156
+ [config.sqlitePath ? `${config.sqlitePath}-wal` : void 0, "memory.sqlite3-wal"],
1157
+ [config.sqlitePath ? `${config.sqlitePath}-shm` : void 0, "memory.sqlite3-shm"],
1158
+ [config.grafeoPath, "grafeo"]
1159
+ ];
1160
+ for (const [source, name] of entries) {
1161
+ if (!source || !await exists(source)) continue;
1162
+ await fs.cp(source, path2.join(backupDir, name), { recursive: true });
1163
+ }
1164
+ await fs.writeFile(
1165
+ path2.join(backupDir, "MIGRATION.txt"),
1166
+ "Backup created before @ppagent/memory migration. Stop the service, then restore these files to roll back.\n"
1167
+ );
1168
+ }
1169
+ async function exists(file) {
1170
+ return fs.access(file).then(() => true, () => false);
1171
+ }
1172
+ function valueOf(args, flag) {
1173
+ const index = args.indexOf(flag);
1174
+ return index >= 0 ? args[index + 1] : void 0;
1175
+ }
1176
+ function safeTimestamp() {
1177
+ return (/* @__PURE__ */ new Date()).toISOString().replace(/[:.]/g, "-");
1178
+ }
1179
+ function usage() {
1180
+ console.info(
1181
+ "Usage: ppagent-memory migrate --config <config.json> [--dry-run] [--backup <directory>]"
1182
+ );
1183
+ }
1184
+ main().catch((error) => {
1185
+ console.error(`[ppagent-memory] ${error instanceof Error ? error.message : String(error)}`);
1186
+ process.exitCode = 1;
1187
+ });