@ppagent/memory 0.1.0 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (4) hide show
  1. package/dist/index.d.ts +231 -122
  2. package/dist/index.js +410 -160
  3. package/llms.txt +845 -845
  4. package/package.json +49 -48
package/dist/index.js CHANGED
@@ -45,7 +45,10 @@ function resolveConfig(config) {
45
45
  knowledgeGraphAnchorTopK: config.knowledgeGraphAnchorTopK ?? 3,
46
46
  knowledgeGraphHopLimit: config.knowledgeGraphHopLimit ?? 10,
47
47
  graphExtractConcurrency: config.graphExtractConcurrency ?? 2,
48
- graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4
48
+ graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4,
49
+ autoOptimizeOnInit: config.autoOptimizeOnInit ?? true,
50
+ optimizeVersionRetentionMs: config.optimizeVersionRetentionMs ?? 0,
51
+ restoreConcurrency: config.restoreConcurrency ?? 8
49
52
  };
50
53
  }
51
54
 
@@ -165,6 +168,16 @@ import { GrafeoDB } from "@grafeo-db/js";
165
168
  import { get_encoding } from "@dqbd/tiktoken";
166
169
 
167
170
  // src/llm/http.ts
171
+ function normalizeFetchError(err, timeoutMs, errorLabel) {
172
+ if (isAbortError(err)) {
173
+ return new Error(`${errorLabel} request timed out after ${timeoutMs}ms`, { cause: err });
174
+ }
175
+ if (err instanceof Error) return err;
176
+ return new Error(String(err));
177
+ }
178
+ function isAbortError(err) {
179
+ return typeof err === "object" && err !== null && "name" in err && err.name === "AbortError";
180
+ }
168
181
  function backoffDelay(attempt) {
169
182
  return Math.min(1e3 * 2 ** attempt, 8e3);
170
183
  }
@@ -173,7 +186,9 @@ async function postJsonWithRetry(url, headers, body, opts, errorLabel = "HTTP")
173
186
  let lastErr;
174
187
  for (let attempt = 0; attempt <= opts.maxRetries; attempt++) {
175
188
  const controller = new AbortController();
176
- const timer = setTimeout(() => controller.abort(), opts.timeoutMs);
189
+ const timer = setTimeout(() => {
190
+ controller.abort(new Error(`${errorLabel} request timed out after ${opts.timeoutMs}ms`));
191
+ }, opts.timeoutMs);
177
192
  let res;
178
193
  try {
179
194
  res = await fetch(url, {
@@ -184,12 +199,12 @@ async function postJsonWithRetry(url, headers, body, opts, errorLabel = "HTTP")
184
199
  });
185
200
  } catch (err) {
186
201
  clearTimeout(timer);
187
- lastErr = err;
202
+ lastErr = normalizeFetchError(err, opts.timeoutMs, errorLabel);
188
203
  if (attempt < opts.maxRetries) {
189
204
  await sleep(backoffDelay(attempt));
190
205
  continue;
191
206
  }
192
- throw err;
207
+ throw lastErr;
193
208
  }
194
209
  clearTimeout(timer);
195
210
  if (res.ok) {
@@ -281,8 +296,24 @@ function cosineSimilarity(a, b) {
281
296
  return dot / (Math.sqrt(normA) * Math.sqrt(normB));
282
297
  }
283
298
 
299
+ // src/page.util.ts
300
+ function normalizePage(page) {
301
+ const offset = Math.max(0, Math.floor(page.offset ?? 0));
302
+ const limit = page.limit != null && page.limit > 0 ? Math.floor(page.limit) : 0;
303
+ return { offset, limit };
304
+ }
305
+ function paginate(items, page) {
306
+ const total = items.length;
307
+ const { offset, limit } = normalizePage(page);
308
+ const slice = limit > 0 ? items.slice(offset, offset + limit) : items.slice(offset);
309
+ return { items: slice, total, offset, limit };
310
+ }
311
+ function cmpStr(a, b) {
312
+ return a < b ? -1 : a > b ? 1 : 0;
313
+ }
314
+
284
315
  // src/db/grafeo.service.ts
285
- var GrafeoService = class {
316
+ var GrafeoService = class _GrafeoService {
286
317
  config;
287
318
  db;
288
319
  constructor(config) {
@@ -459,16 +490,17 @@ var GrafeoService = class {
459
490
  const r = row;
460
491
  return {
461
492
  type: "relation",
462
- content: `${r["a.name"]} -[${r["type(r)"]}]-> ${r["b.name"]}`,
493
+ content: `${r["fromName"]} -[${r["relType"]}]-> ${r["toName"]}`,
463
494
  score: 1,
464
- meta: { happenedAt: r["r.happenedAt"] }
495
+ meta: { happenedAt: r["happenedAt"] }
465
496
  };
466
497
  });
498
+ const RETURN_CLAUSE = "RETURN a.name AS fromName, type(r) AS relType, b.name AS toName, r.happenedAt AS happenedAt";
467
499
  try {
468
500
  const result = await this.db.execute(
469
501
  `MATCH (a:Entity {name: $name, userId: $userId, chatId: $chatId, kind: $kind})-[r]->(b)
470
502
  WHERE r.chatId = $chatId AND r.userId = $userId
471
- RETURN a.name, type(r), b.name, r.happenedAt LIMIT $limit`,
503
+ ${RETURN_CLAUSE} LIMIT $limit`,
472
504
  { name: entityName, userId, chatId, kind, limit }
473
505
  );
474
506
  return mapRows(result.toArray());
@@ -477,7 +509,7 @@ var GrafeoService = class {
477
509
  try {
478
510
  const result = await this.db.execute(
479
511
  `MATCH (a:Entity {name: $name, userId: $userId, chatId: $chatId, kind: $kind})-[r]->(b)
480
- RETURN a.name, type(r), b.name, r.happenedAt LIMIT $limit`,
512
+ ${RETURN_CLAUSE} LIMIT $limit`,
481
513
  { name: entityName, userId, chatId, kind, limit }
482
514
  );
483
515
  return mapRows(result.toArray());
@@ -500,56 +532,176 @@ var GrafeoService = class {
500
532
  }
501
533
  }
502
534
  // ── 管理面板用:全量读取实体 / 关系 ────────────────────────────────────────────────
503
- /** 全量读取实体节点(管理面板/知识图谱用,剔除体积大的 embedding 字段)*/
504
- async getAllEntities(limit = 1e3) {
535
+ /**
536
+ * 构建按域过滤的内联属性子句(如 ` {userId: $userId, chatId: $chatId}`)。
537
+ * 在 LIMIT 之前生效,避免「先截断再过滤」导致结果不足。
538
+ */
539
+ buildScopeMatch(filter, params) {
540
+ const props = [];
541
+ if (filter?.userId) {
542
+ props.push("userId: $userId");
543
+ params.userId = filter.userId;
544
+ }
545
+ if (filter?.chatId) {
546
+ props.push("chatId: $chatId");
547
+ params.chatId = filter.chatId;
548
+ }
549
+ return props.length > 0 ? ` {${props.join(", ")}}` : "";
550
+ }
551
+ // 关系查询的统一 RETURN 子句:用别名(AS)取列,避免函数表达式 type(r)/id(r) 的默认列名
552
+ // 被归一为 "type(...)"/"id(...)"(按 r["type(r)"] 取会得到 undefined)。id(r) 同时用于 ORDER BY。
553
+ static RELATION_RETURN = `RETURN a.name AS fromName, b.name AS toName, type(r) AS relType,
554
+ r.happenedAt AS happenedAt, r.userId AS userId, r.chatId AS chatId,
555
+ r.sessionId AS sessionId, id(r) AS rid`;
556
+ /** 节点 → Entity 映射(剔除体积大的 embedding 字段)*/
557
+ nodeToEntity(node) {
558
+ const props = node.properties();
559
+ const { embedding: _embedding, name, userId, chatId, sessionId, ...rest } = props;
560
+ const type = (node.labels ?? []).filter((l) => l !== "Entity").join(", ") || "\u672A\u77E5";
561
+ return {
562
+ name: String(name ?? ""),
563
+ type,
564
+ meta: {
565
+ userId: String(userId ?? ""),
566
+ chatId: String(chatId ?? ""),
567
+ sessionId: String(sessionId ?? ""),
568
+ ...rest
569
+ }
570
+ };
571
+ }
572
+ /** 关系查询结果行 → Relation 映射(依赖 RELATION_RETURN 的别名列)*/
573
+ rowToRelation(row) {
574
+ const r = row;
575
+ return {
576
+ from: String(r["fromName"] ?? ""),
577
+ to: String(r["toName"] ?? ""),
578
+ type: String(r["relType"] ?? ""),
579
+ happenedAt: r["happenedAt"] != null ? Number(r["happenedAt"]) : void 0,
580
+ meta: {
581
+ userId: String(r["userId"] ?? ""),
582
+ chatId: String(r["chatId"] ?? ""),
583
+ sessionId: String(r["sessionId"] ?? "")
584
+ }
585
+ };
586
+ }
587
+ /** 统计实体数(可选按域过滤),用于分页 total */
588
+ async countEntities(filter) {
505
589
  try {
506
- const result = await this.db.execute(`MATCH (n:Entity) RETURN n LIMIT $limit`, { limit });
590
+ const params = {};
591
+ const scope = this.buildScopeMatch(filter, params);
592
+ const res = await this.db.execute(`MATCH (n:Entity${scope}) RETURN count(n) AS cnt`, params);
593
+ return Number(res.toArray()[0]?.cnt ?? 0);
594
+ } catch {
595
+ return 0;
596
+ }
597
+ }
598
+ /** 统计关系数(可选按域过滤),用于分页 total */
599
+ async countRelations(filter) {
600
+ try {
601
+ const params = {};
602
+ const scope = this.buildScopeMatch(filter, params);
603
+ const res = await this.db.execute(
604
+ `MATCH (a:Entity${scope})-[r]->(b:Entity) RETURN count(r) AS cnt`,
605
+ params
606
+ );
607
+ return Number(res.toArray()[0]?.cnt ?? 0);
608
+ } catch {
609
+ return 0;
610
+ }
611
+ }
612
+ /**
613
+ * 全量读取实体节点(管理面板/知识图谱用,剔除体积大的 embedding 字段)。
614
+ * 可选按 userId / chatId 过滤(在节点属性上匹配,LIMIT 前生效)。
615
+ * 按节点内部 id 升序,确保「同样条件 → 同样顺序」。
616
+ * 注意:此方法带安全上限 limit,超出会截断;需要准确 total 与翻页请用 pageEntities。
617
+ */
618
+ async getAllEntities(filter, limit = 1e3) {
619
+ try {
620
+ const params = { limit };
621
+ const scope = this.buildScopeMatch(filter, params);
622
+ const result = await this.db.execute(`MATCH (n:Entity${scope}) RETURN n LIMIT $limit`, params);
507
623
  const nodes = result.nodes();
508
- return nodes.map((node) => {
509
- const props = node.properties();
510
- const { embedding: _embedding, name, userId, chatId, sessionId, ...rest } = props;
511
- const type = (node.labels ?? []).filter((l) => l !== "Entity").join(", ") || "\u672A\u77E5";
512
- return {
513
- name: String(name ?? ""),
514
- type,
515
- meta: {
516
- userId: String(userId ?? ""),
517
- chatId: String(chatId ?? ""),
518
- sessionId: String(sessionId ?? ""),
519
- ...rest
520
- }
521
- };
522
- });
624
+ nodes.sort((a, b) => a.id - b.id);
625
+ return nodes.map((node) => this.nodeToEntity(node));
523
626
  } catch {
524
627
  return [];
525
628
  }
526
629
  }
527
- /** 全量读取关系边(管理面板/知识图谱用)*/
528
- async getAllRelations(limit = 2e3) {
630
+ /**
631
+ * 分页读取实体节点:把 ORDER BY id(n) + SKIP/LIMIT 下推到查询层,并用 count 取准确 total。
632
+ * 不受 getAllEntities 安全上限约束。返回当前页 items 与过滤后的 total。
633
+ */
634
+ async pageEntities(filter, page) {
635
+ const { offset, limit } = normalizePage(page);
636
+ try {
637
+ const total = await this.countEntities(filter);
638
+ const effLimit = limit > 0 ? limit : Math.max(0, total - offset);
639
+ if (effLimit <= 0) return { items: [], total };
640
+ const params = {};
641
+ const scope = this.buildScopeMatch(filter, params);
642
+ params.offset = offset;
643
+ params.limit = effLimit;
644
+ const res = await this.db.execute(
645
+ `MATCH (n:Entity${scope}) RETURN id(n) AS nid ORDER BY nid SKIP $offset LIMIT $limit`,
646
+ params
647
+ );
648
+ const items = [];
649
+ for (const row of res.toArray()) {
650
+ const nid = Number(row.nid);
651
+ const node = this.db.getNode(nid);
652
+ if (node) items.push(this.nodeToEntity(node));
653
+ }
654
+ return { items, total };
655
+ } catch {
656
+ return { items: [], total: 0 };
657
+ }
658
+ }
659
+ /**
660
+ * 全量读取关系边(管理面板/知识图谱用)。可选按 userId / chatId 过滤:在源实体节点的域属性上匹配
661
+ *(关系两端与边均同域,故按源节点过滤即可,且节点内联属性匹配在各 grafeo 版本均可靠)。
662
+ * 按 id(r) 升序确保顺序确定。
663
+ * 注意:此方法带安全上限 limit,超出会截断;需要准确 total 与翻页请用 pageRelations。
664
+ */
665
+ async getAllRelations(filter, limit = 2e3) {
529
666
  try {
667
+ const params = { limit };
668
+ const scope = this.buildScopeMatch(filter, params);
530
669
  const result = await this.db.execute(
531
- `MATCH (a:Entity)-[r]->(b:Entity)
532
- RETURN a.name, type(r), b.name, r.happenedAt, r.userId, r.chatId, r.sessionId LIMIT $limit`,
533
- { limit }
670
+ `MATCH (a:Entity${scope})-[r]->(b:Entity)
671
+ ${_GrafeoService.RELATION_RETURN}
672
+ ORDER BY rid LIMIT $limit`,
673
+ params
534
674
  );
535
- return result.toArray().map((row) => {
536
- const r = row;
537
- return {
538
- from: String(r["a.name"] ?? ""),
539
- to: String(r["b.name"] ?? ""),
540
- type: String(r["type(r)"] ?? ""),
541
- happenedAt: r["r.happenedAt"] != null ? Number(r["r.happenedAt"]) : void 0,
542
- meta: {
543
- userId: String(r["r.userId"] ?? ""),
544
- chatId: String(r["r.chatId"] ?? ""),
545
- sessionId: String(r["r.sessionId"] ?? "")
546
- }
547
- };
548
- });
675
+ return result.toArray().map((row) => this.rowToRelation(row));
549
676
  } catch {
550
677
  return [];
551
678
  }
552
679
  }
680
+ /**
681
+ * 分页读取关系边:把 ORDER BY id(r) + SKIP/LIMIT 下推到查询层,并用 count 取准确 total。
682
+ * 不受 getAllRelations 安全上限约束。返回当前页 items 与过滤后的 total。
683
+ */
684
+ async pageRelations(filter, page) {
685
+ const { offset, limit } = normalizePage(page);
686
+ try {
687
+ const total = await this.countRelations(filter);
688
+ const effLimit = limit > 0 ? limit : Math.max(0, total - offset);
689
+ if (effLimit <= 0) return { items: [], total };
690
+ const params = {};
691
+ const scope = this.buildScopeMatch(filter, params);
692
+ params.offset = offset;
693
+ params.limit = effLimit;
694
+ const res = await this.db.execute(
695
+ `MATCH (a:Entity${scope})-[r]->(b:Entity)
696
+ ${_GrafeoService.RELATION_RETURN}
697
+ ORDER BY rid SKIP $offset LIMIT $limit`,
698
+ params
699
+ );
700
+ return { items: res.toArray().map((row) => this.rowToRelation(row)), total };
701
+ } catch {
702
+ return { items: [], total: 0 };
703
+ }
704
+ }
553
705
  close() {
554
706
  try {
555
707
  this.db.close();
@@ -863,6 +1015,24 @@ function safeParseObject(s) {
863
1015
  return {};
864
1016
  }
865
1017
  }
1018
+ var MESSAGES_INDEXES = [
1019
+ { column: "message_id" },
1020
+ { column: "session_id" },
1021
+ { column: "content", fts: true },
1022
+ { column: "metadata", fts: true }
1023
+ // 支持 metadata 内容全文检索
1024
+ ];
1025
+ var TOPICS_INDEXES = [
1026
+ { column: "chat_id" },
1027
+ { column: "detail", fts: true }
1028
+ ];
1029
+ var SESSIONS_INDEXES = [{ column: "session_id" }];
1030
+ var DOCUMENTS_INDEXES = [{ column: "doc_id" }];
1031
+ var CHUNKS_INDEXES = [
1032
+ { column: "doc_id" },
1033
+ { column: "content", fts: true }
1034
+ // 知识片段混合检索
1035
+ ];
866
1036
  var LanceService = class {
867
1037
  config;
868
1038
  conn;
@@ -875,6 +1045,7 @@ var LanceService = class {
875
1045
  // Scalar indexes cannot be created on empty tables (LanceDB btree limitation).
876
1046
  // These flags defer creation to the first insert.
877
1047
  isNewMessagesTable = false;
1048
+ isNewTopicsTable = false;
878
1049
  isNewSessionsTable = false;
879
1050
  isNewDocumentsTable = false;
880
1051
  isNewChunksTable = false;
@@ -888,7 +1059,6 @@ var LanceService = class {
888
1059
  if (existingTables.includes(MESSAGES_TABLE)) {
889
1060
  this.messagesTable = await this.conn.openTable(MESSAGES_TABLE);
890
1061
  await this._ensurePartsColumn();
891
- await this.ensureScalarIndex(this.messagesTable, "message_id");
892
1062
  } else {
893
1063
  this.messagesTable = await this.conn.createEmptyTable(
894
1064
  MESSAGES_TABLE,
@@ -904,6 +1074,7 @@ var LanceService = class {
904
1074
  TOPICS_TABLE,
905
1075
  topicsSchema(dim)
906
1076
  );
1077
+ this.isNewTopicsTable = true;
907
1078
  }
908
1079
  if (existingTables.includes(FACTS_TABLE)) {
909
1080
  this.factsTable = await this.conn.openTable(FACTS_TABLE);
@@ -912,43 +1083,83 @@ var LanceService = class {
912
1083
  }
913
1084
  if (existingTables.includes(SESSIONS_TABLE)) {
914
1085
  this.sessionsTable = await this.conn.openTable(SESSIONS_TABLE);
915
- await this.ensureScalarIndex(this.sessionsTable, "session_id");
916
1086
  } else {
917
1087
  this.sessionsTable = await this.conn.createEmptyTable(SESSIONS_TABLE, sessionsSchema());
918
1088
  this.isNewSessionsTable = true;
919
1089
  }
920
1090
  if (existingTables.includes(DOCUMENTS_TABLE)) {
921
1091
  this.documentsTable = await this.conn.openTable(DOCUMENTS_TABLE);
922
- await this.ensureScalarIndex(this.documentsTable, "doc_id");
923
1092
  } else {
924
1093
  this.documentsTable = await this.conn.createEmptyTable(DOCUMENTS_TABLE, documentsSchema(dim));
925
1094
  this.isNewDocumentsTable = true;
926
1095
  }
927
1096
  if (existingTables.includes(CHUNKS_TABLE)) {
928
1097
  this.chunksTable = await this.conn.openTable(CHUNKS_TABLE);
929
- await this.ensureScalarIndex(this.chunksTable, "doc_id");
930
1098
  } else {
931
1099
  this.chunksTable = await this.conn.createEmptyTable(CHUNKS_TABLE, chunksSchema(dim));
932
1100
  this.isNewChunksTable = true;
933
1101
  }
934
- await this.ensureFtsIndexes();
1102
+ await this.ensureIndexes(this.messagesTable, MESSAGES_INDEXES);
1103
+ await this.ensureIndexes(this.topicsTable, TOPICS_INDEXES);
1104
+ await this.ensureIndexes(this.sessionsTable, SESSIONS_INDEXES);
1105
+ await this.ensureIndexes(this.documentsTable, DOCUMENTS_INDEXES);
1106
+ await this.ensureIndexes(this.chunksTable, CHUNKS_INDEXES);
935
1107
  }
936
- async ensureFtsIndexes() {
937
- const ftsTargets = [
938
- { table: this.messagesTable, column: "content" },
939
- { table: this.messagesTable, column: "metadata" },
940
- // 支持 metadata 内容全文检索
941
- { table: this.topicsTable, column: "detail" },
942
- { table: this.chunksTable, column: "content" }
943
- // 知识片段混合检索
944
- ];
945
- for (const { table, column } of ftsTargets) {
1108
+ /**
1109
+ * 按需补齐索引:先经 listIndices 判存在,缺失的列才 createIndex(且 replace:false)。
1110
+ * createIndex 默认 replace:true 会在每次启动时全量重建索引并提交新表版本——
1111
+ * 这正是历史上「启动越来越慢 + _versions 目录膨胀」的根源,绝不可回退到无条件 createIndex。
1112
+ */
1113
+ async ensureIndexes(table, specs) {
1114
+ let indexed;
1115
+ try {
1116
+ indexed = new Set((await table.listIndices()).flatMap((i) => i.columns));
1117
+ } catch {
1118
+ indexed = /* @__PURE__ */ new Set();
1119
+ }
1120
+ for (const { column, fts } of specs) {
1121
+ if (indexed.has(column)) continue;
946
1122
  try {
947
- await table.createIndex(column, { config: lancedb.Index.fts() });
1123
+ await table.createIndex(
1124
+ column,
1125
+ fts ? { config: lancedb.Index.fts(), replace: false } : { replace: false }
1126
+ );
948
1127
  } catch {
949
1128
  }
950
1129
  }
951
1130
  }
1131
+ /**
1132
+ * 存储压实:逐表执行碎片合并 + 清理 retentionMs 之前的历史版本。
1133
+ * 嵌入式场景下 LanceDB 不会自动做这件事,长期运行后版本/碎片无限累积会显著拖慢启动与查询。
1134
+ */
1135
+ async optimizeStorage(retentionMs = 0) {
1136
+ const cutoff = new Date(Date.now() - Math.max(0, retentionMs));
1137
+ const targets = [
1138
+ [MESSAGES_TABLE, this.messagesTable],
1139
+ [TOPICS_TABLE, this.topicsTable],
1140
+ [FACTS_TABLE, this.factsTable],
1141
+ [SESSIONS_TABLE, this.sessionsTable],
1142
+ [DOCUMENTS_TABLE, this.documentsTable],
1143
+ [CHUNKS_TABLE, this.chunksTable]
1144
+ ];
1145
+ const results = [];
1146
+ for (const [name, table] of targets) {
1147
+ try {
1148
+ const stats = await table.optimize({ cleanupOlderThan: cutoff });
1149
+ results.push({
1150
+ table: name,
1151
+ fragmentsRemoved: stats.compaction.fragmentsRemoved,
1152
+ fragmentsAdded: stats.compaction.fragmentsAdded,
1153
+ filesRemoved: stats.compaction.filesRemoved,
1154
+ oldVersionsRemoved: stats.prune.oldVersionsRemoved,
1155
+ bytesRemoved: Number(stats.prune.bytesRemoved)
1156
+ });
1157
+ } catch (err) {
1158
+ console.warn(`[LanceService] optimize table "${name}" failed:`, err);
1159
+ }
1160
+ }
1161
+ return results;
1162
+ }
952
1163
  /**
953
1164
  * 为存量 messages 表添加 parts 列(如果缺失)。
954
1165
  * LanceDB 0.14+ 支持 addColumns;旧版本会 throw,此时 rowToMessage 的 ?? "[]" 兜底。
@@ -985,24 +1196,20 @@ var LanceService = class {
985
1196
  } catch {
986
1197
  }
987
1198
  }
988
- // LanceDB btree scalar indexes require at least one row.
989
- // For existing tables (opened in init) this is safe; for new tables we defer via isNew*Table flags.
990
- async ensureScalarIndex(table, column) {
991
- try {
992
- await table.createIndex(column);
993
- } catch {
994
- }
995
- }
996
1199
  async addMessages(messages) {
997
1200
  if (messages.length === 0) return;
998
1201
  await this.messagesTable.add(messages.map(messageToRow));
999
1202
  if (this.isNewMessagesTable) {
1000
- await this.ensureScalarIndex(this.messagesTable, "message_id");
1203
+ await this.ensureIndexes(this.messagesTable, MESSAGES_INDEXES);
1001
1204
  this.isNewMessagesTable = false;
1002
1205
  }
1003
1206
  }
1004
1207
  async addTopic(topic) {
1005
1208
  await this.topicsTable.add([topicToRow(topic)]);
1209
+ if (this.isNewTopicsTable) {
1210
+ await this.ensureIndexes(this.topicsTable, TOPICS_INDEXES);
1211
+ this.isNewTopicsTable = false;
1212
+ }
1006
1213
  }
1007
1214
  async updateTopicRecallCount(summaryId, count) {
1008
1215
  await this.topicsTable.update({
@@ -1051,6 +1258,15 @@ var LanceService = class {
1051
1258
  return [];
1052
1259
  }
1053
1260
  }
1261
+ /** 取某会话的全部消息(按 createdAt 升序),供管理面板分页切片使用 */
1262
+ async getAllMessagesBySession(sessionId) {
1263
+ try {
1264
+ const rows = await this.messagesTable.query().where(eqFilter("session_id", sessionId)).toArray();
1265
+ return rows.sort((a, b) => Number(a.created_at) - Number(b.created_at) || cmpStr(a.message_id, b.message_id)).map(rowToMessage);
1266
+ } catch {
1267
+ return [];
1268
+ }
1269
+ }
1054
1270
  async searchMessages(vector, filter, limit = 10) {
1055
1271
  const q = this.messagesTable.vectorSearch(vector).limit(limit);
1056
1272
  if (filter) q.where(filter);
@@ -1082,8 +1298,7 @@ var LanceService = class {
1082
1298
  const total = rows.length;
1083
1299
  return rows.map((r, idx) => ({
1084
1300
  row: r,
1085
- // 位置分(越前越高)+ recall_count 加成
1086
- score: 1 - idx / total + Math.log1p(Number(r.recall_count)) * 0.1
1301
+ score: total - idx + Math.log1p(Number(r.recall_count))
1087
1302
  })).sort((a, b) => b.score - a.score).map((x) => rowToTopic(x.row));
1088
1303
  }
1089
1304
  } catch {
@@ -1127,7 +1342,7 @@ var LanceService = class {
1127
1342
  async insertSession(session) {
1128
1343
  await this.sessionsTable.add([sessionToRow(session)]);
1129
1344
  if (this.isNewSessionsTable) {
1130
- await this.ensureScalarIndex(this.sessionsTable, "session_id");
1345
+ await this.ensureIndexes(this.sessionsTable, SESSIONS_INDEXES);
1131
1346
  this.isNewSessionsTable = false;
1132
1347
  }
1133
1348
  }
@@ -1151,7 +1366,7 @@ var LanceService = class {
1151
1366
  async getAllTopics() {
1152
1367
  try {
1153
1368
  const rows = await this.topicsTable.query().toArray();
1154
- return rows.sort((a, b) => Number(b.end_time) - Number(a.end_time)).map(rowToTopic);
1369
+ return rows.sort((a, b) => Number(b.end_time) - Number(a.end_time) || cmpStr(a.summary_id, b.summary_id)).map(rowToTopic);
1155
1370
  } catch {
1156
1371
  return [];
1157
1372
  }
@@ -1233,7 +1448,7 @@ var LanceService = class {
1233
1448
  async addDocument(doc) {
1234
1449
  await this.documentsTable.add([documentToRow(doc)]);
1235
1450
  if (this.isNewDocumentsTable) {
1236
- await this.ensureScalarIndex(this.documentsTable, "doc_id");
1451
+ await this.ensureIndexes(this.documentsTable, DOCUMENTS_INDEXES);
1237
1452
  this.isNewDocumentsTable = false;
1238
1453
  }
1239
1454
  }
@@ -1241,7 +1456,7 @@ var LanceService = class {
1241
1456
  if (chunks.length === 0) return;
1242
1457
  await this.chunksTable.add(chunks.map(chunkToRow));
1243
1458
  if (this.isNewChunksTable) {
1244
- await this.ensureScalarIndex(this.chunksTable, "doc_id");
1459
+ await this.ensureIndexes(this.chunksTable, CHUNKS_INDEXES);
1245
1460
  this.isNewChunksTable = false;
1246
1461
  }
1247
1462
  }
@@ -1324,7 +1539,7 @@ var LanceService = class {
1324
1539
  async getAllDocuments() {
1325
1540
  try {
1326
1541
  const rows = await this.documentsTable.query().toArray();
1327
- return rows.sort((a, b) => Number(b.updated_at) - Number(a.updated_at)).map(rowToDocument);
1542
+ return rows.sort((a, b) => Number(b.updated_at) - Number(a.updated_at) || cmpStr(a.doc_id, b.doc_id)).map(rowToDocument);
1328
1543
  } catch {
1329
1544
  return [];
1330
1545
  }
@@ -1364,6 +1579,40 @@ var LanceService = class {
1364
1579
  }
1365
1580
  };
1366
1581
 
1582
+ // src/manager/semaphore.ts
1583
+ var Semaphore = class {
1584
+ count;
1585
+ queue = [];
1586
+ constructor(max) {
1587
+ this.count = max;
1588
+ }
1589
+ async run(fn) {
1590
+ await this.acquire();
1591
+ try {
1592
+ return await fn();
1593
+ } finally {
1594
+ this.release();
1595
+ }
1596
+ }
1597
+ acquire() {
1598
+ if (this.count > 0) {
1599
+ this.count--;
1600
+ return Promise.resolve();
1601
+ }
1602
+ return new Promise((resolve) => {
1603
+ this.queue.push(resolve);
1604
+ });
1605
+ }
1606
+ release() {
1607
+ const next = this.queue.shift();
1608
+ if (next) {
1609
+ next();
1610
+ } else {
1611
+ this.count++;
1612
+ }
1613
+ }
1614
+ };
1615
+
1367
1616
  // src/llm/llm.service.ts
1368
1617
  var ENTITY_EXTRACTOR_TOOL = {
1369
1618
  type: "function",
@@ -1634,42 +1883,6 @@ ${context}`, false);
1634
1883
 
1635
1884
  // src/manager/compress.manager.ts
1636
1885
  import { v4 as uuidv4 } from "uuid";
1637
-
1638
- // src/manager/semaphore.ts
1639
- var Semaphore = class {
1640
- count;
1641
- queue = [];
1642
- constructor(max) {
1643
- this.count = max;
1644
- }
1645
- async run(fn) {
1646
- await this.acquire();
1647
- try {
1648
- return await fn();
1649
- } finally {
1650
- this.release();
1651
- }
1652
- }
1653
- acquire() {
1654
- if (this.count > 0) {
1655
- this.count--;
1656
- return Promise.resolve();
1657
- }
1658
- return new Promise((resolve) => {
1659
- this.queue.push(resolve);
1660
- });
1661
- }
1662
- release() {
1663
- const next = this.queue.shift();
1664
- if (next) {
1665
- next();
1666
- } else {
1667
- this.count++;
1668
- }
1669
- }
1670
- };
1671
-
1672
- // src/manager/compress.manager.ts
1673
1886
  var CompressManager = class {
1674
1887
  config;
1675
1888
  lance;
@@ -1698,7 +1911,7 @@ var CompressManager = class {
1698
1911
  return next;
1699
1912
  }
1700
1913
  async doCompress(sessionId, force, waitGraph) {
1701
- const messages = this.sessionCache.getSessionMessages(sessionId);
1914
+ const messages = this.sessionCache.getSessionMessages(sessionId).slice();
1702
1915
  if (messages.length === 0) return;
1703
1916
  const entry = this.sessionCache.getEntry(sessionId);
1704
1917
  if (!entry) return;
@@ -1710,7 +1923,7 @@ var CompressManager = class {
1710
1923
  try {
1711
1924
  summary = await this.llm.summarizeMessages(messages);
1712
1925
  } catch (err) {
1713
- console.error(`[CompressManager] LLM compress failed for session ${sessionId}:`, err);
1926
+ console.error(`[CompressManager] LLM compress failed for session ${sessionId}: ${formatError(err)}`);
1714
1927
  return;
1715
1928
  }
1716
1929
  const { title, detail, summary: summaryText, concise } = summary;
@@ -1805,6 +2018,9 @@ var CompressManager = class {
1805
2018
  }
1806
2019
  }
1807
2020
  };
2021
+ function formatError(err) {
2022
+ return err instanceof Error ? err.message : String(err);
2023
+ }
1808
2024
  function withTimeout(promise, timeoutMs, label) {
1809
2025
  let timer;
1810
2026
  const timeout = new Promise((_, reject) => {
@@ -2293,12 +2509,12 @@ ${p.content}` : p.content
2293
2509
  });
2294
2510
  return true;
2295
2511
  }
2296
- async listDocuments(filter) {
2512
+ async listDocuments(filter, page) {
2297
2513
  let docs = await this.lance.getAllDocuments();
2298
2514
  if (filter?.userId) docs = docs.filter((d) => d.userId === filter.userId);
2299
2515
  if (filter?.chatId) docs = docs.filter((d) => d.chatId === filter.chatId);
2300
2516
  if (filter?.sessionId) docs = docs.filter((d) => d.sessionId === filter.sessionId);
2301
- return docs;
2517
+ return page ? paginate(docs, page) : docs;
2302
2518
  }
2303
2519
  };
2304
2520
  var NO_MATCH = Symbol("no-match");
@@ -2476,27 +2692,62 @@ var MemoryManager = class {
2476
2692
  this.sessionMap.set(s.sessionId, this.deserializeSession(s));
2477
2693
  }
2478
2694
  await this.restoreFromStorage();
2695
+ if (this.config.autoOptimizeOnInit) {
2696
+ void this.optimizeStorage().then((results) => {
2697
+ const versions = results.reduce((s, r) => s + r.oldVersionsRemoved, 0);
2698
+ const fragments = results.reduce((s, r) => s + r.fragmentsRemoved, 0);
2699
+ if (versions > 0 || fragments > 0) {
2700
+ console.info(
2701
+ `[MemoryManager] \u540E\u53F0\u5B58\u50A8\u538B\u5B9E\u5B8C\u6210\uFF1A\u6E05\u7406\u5386\u53F2\u7248\u672C ${versions} \u4E2A\uFF0C\u5408\u5E76\u788E\u7247 ${fragments} \u4E2A`
2702
+ );
2703
+ }
2704
+ }).catch((err) => {
2705
+ console.warn("[MemoryManager] \u540E\u53F0\u5B58\u50A8\u538B\u5B9E\u5931\u8D25\uFF08\u4E0D\u5F71\u54CD\u670D\u52A1\uFF09:", err);
2706
+ });
2707
+ }
2708
+ }
2709
+ /**
2710
+ * 手动触发存储压实与历史版本清理(init 后台会自动执行一次;上层维护任务也可调用)。
2711
+ * @param retentionMs 保留多久内的历史版本,默认取配置 optimizeVersionRetentionMs
2712
+ */
2713
+ async optimizeStorage(retentionMs) {
2714
+ return this.lance.optimizeStorage(retentionMs ?? this.config.optimizeVersionRetentionMs);
2479
2715
  }
2480
2716
  async restoreFromStorage() {
2481
2717
  const [n1, n2, n3] = this.config.topicRatio;
2482
2718
  const sessionIds = await this.lance.getAllSessionIds();
2483
2719
  if (sessionIds.length === 0) return;
2484
- for (const sessionId of sessionIds) {
2485
- const recentMessages = await this.lance.getLatestMessages(sessionId, 1);
2486
- if (recentMessages.length === 0) continue;
2487
- const { chatId, userId } = recentMessages[0];
2488
- const topicGroups = await this.lance.getRecentTopics(chatId, userId, n1, n2, n3);
2489
- const n1EndTime = topicGroups.detail.length > 0 ? topicGroups.detail[topicGroups.detail.length - 1].endTime : 0;
2490
- const rawMessages = n1EndTime > 0 ? await this.lance.getMessagesSince(sessionId, n1EndTime) : await this.lance.getLatestMessages(sessionId, 100);
2491
- if (rawMessages.length > 0) {
2492
- this.sessionCache.addMessages(sessionId, rawMessages, chatId, userId);
2493
- }
2494
- if (topicGroups.detail.length + topicGroups.summary.length + topicGroups.concise.length > 0) {
2495
- this.sessionCache.buildHistoryWindow(sessionId, topicGroups);
2496
- } else {
2497
- this.sessionCache.setHistoryWindow(sessionId, "");
2720
+ const topicsCache = /* @__PURE__ */ new Map();
2721
+ const getTopics = (chatId, userId) => {
2722
+ const key = `${chatId}\0${userId}`;
2723
+ let cached = topicsCache.get(key);
2724
+ if (!cached) {
2725
+ cached = this.lance.getRecentTopics(chatId, userId, n1, n2, n3);
2726
+ topicsCache.set(key, cached);
2498
2727
  }
2499
- }
2728
+ return cached;
2729
+ };
2730
+ const sem = new Semaphore(this.config.restoreConcurrency);
2731
+ await Promise.all(
2732
+ sessionIds.map(
2733
+ (sessionId) => sem.run(async () => {
2734
+ const recentMessages = await this.lance.getLatestMessages(sessionId, 1);
2735
+ if (recentMessages.length === 0) return;
2736
+ const { chatId, userId } = recentMessages[0];
2737
+ const topicGroups = await getTopics(chatId, userId);
2738
+ const n1EndTime = topicGroups.detail.length > 0 ? topicGroups.detail[topicGroups.detail.length - 1].endTime : 0;
2739
+ const rawMessages = n1EndTime > 0 ? await this.lance.getMessagesSince(sessionId, n1EndTime) : await this.lance.getLatestMessages(sessionId, 100);
2740
+ if (rawMessages.length > 0) {
2741
+ this.sessionCache.addMessages(sessionId, rawMessages, chatId, userId);
2742
+ }
2743
+ if (topicGroups.detail.length + topicGroups.summary.length + topicGroups.concise.length > 0) {
2744
+ this.sessionCache.buildHistoryWindow(sessionId, topicGroups);
2745
+ } else {
2746
+ this.sessionCache.setHistoryWindow(sessionId, "");
2747
+ }
2748
+ })
2749
+ )
2750
+ );
2500
2751
  }
2501
2752
  async updateChat(messages, opts) {
2502
2753
  if (messages.length === 0) return;
@@ -2778,33 +3029,28 @@ var MemoryManager = class {
2778
3029
  const n = Math.min(365, Math.max(1, Math.floor(days)));
2779
3030
  return this.lance.trendDaily(n);
2780
3031
  }
2781
- /** 列出会话,可选按 userId / chatId 过滤,按更新时间倒序 */
2782
- listSessions(filter) {
3032
+ listSessions(filter, page) {
2783
3033
  let results = [...this.sessionMap.values()];
2784
3034
  if (filter?.userId) results = results.filter((s) => s.userId === filter.userId);
2785
3035
  if (filter?.chatId) results = results.filter((s) => s.chatId === filter.chatId);
2786
- return results.sort((a, b) => b.updatedAt - a.updatedAt);
3036
+ results.sort((a, b) => b.updatedAt - a.updatedAt || cmpStr(a.sessionId, b.sessionId));
3037
+ return page ? paginate(results, page) : results;
2787
3038
  }
2788
- /** 列出会话内的消息(按时间正序)*/
2789
- async listMessages(sessionId, limit = 100) {
2790
- return this.lance.getLatestMessages(sessionId, limit);
3039
+ async listMessages(sessionId, limitOrPage) {
3040
+ if (typeof limitOrPage === "object") {
3041
+ const all = await this.lance.getAllMessagesBySession(sessionId);
3042
+ return paginate(all, limitOrPage);
3043
+ }
3044
+ return this.lance.getLatestMessages(sessionId, limitOrPage ?? 100);
2791
3045
  }
2792
- /** 列出主题/摘要,可选按 sessionId / userId / chatId 过滤 */
2793
- async listTopics(filter) {
3046
+ async listTopics(filter, page) {
2794
3047
  let topics = await this.lance.getAllTopics();
2795
3048
  if (filter?.sessionId) topics = topics.filter((t) => t.sessionId === filter.sessionId);
2796
3049
  if (filter?.userId) topics = topics.filter((t) => t.userId === filter.userId);
2797
3050
  if (filter?.chatId) topics = topics.filter((t) => t.chatId === filter.chatId);
2798
- return topics;
3051
+ return page ? paginate(topics, page) : topics;
2799
3052
  }
2800
- /**
2801
- * 列出事实,按创建时间倒序。过滤遵循 scope 层级包含语义:
2802
- * - 按 chatId 过滤时,除该 chat 级 facts 外,还包含该 chat 所属 user 的 user 级 facts
2803
- * (user 级对其下所有 chat/session 生效)。
2804
- * - 按 userId 过滤时,返回该 user 名下全部 facts(两级都带 userId)。
2805
- * - level 过滤在层级过滤之后再叠加。
2806
- */
2807
- listFacts(filter) {
3053
+ listFacts(filter, page) {
2808
3054
  let facts = this.factCache.all();
2809
3055
  if (filter?.chatId) {
2810
3056
  const chatId = filter.chatId;
@@ -2816,7 +3062,8 @@ var MemoryManager = class {
2816
3062
  facts = facts.filter((f) => f.userId === filter.userId);
2817
3063
  }
2818
3064
  if (filter?.level) facts = facts.filter((f) => f.level === filter.level);
2819
- return facts.sort((a, b) => b.createdAt - a.createdAt);
3065
+ facts.sort((a, b) => b.createdAt - a.createdAt || cmpStr(a.factId, b.factId));
3066
+ return page ? paginate(facts, page) : facts;
2820
3067
  }
2821
3068
  /** 由 chatId 反查所属 userId:优先用会话表映射,兜底用 chat 级 fact 自身。 */
2822
3069
  resolveChatOwner(chatId) {
@@ -2833,13 +3080,17 @@ var MemoryManager = class {
2833
3080
  async deleteFact(factId) {
2834
3081
  return this.factCache.remove(factId);
2835
3082
  }
2836
- /** 列出全部实体(知识图谱节点)*/
2837
- async listEntities() {
2838
- return this.grafeo.getAllEntities();
3083
+ async listEntities(filter, page) {
3084
+ if (!page) return this.grafeo.getAllEntities(filter);
3085
+ const { offset, limit } = normalizePage(page);
3086
+ const { items, total } = await this.grafeo.pageEntities(filter, page);
3087
+ return { items, total, offset, limit };
2839
3088
  }
2840
- /** 列出全部关系(知识图谱边)*/
2841
- async listRelations() {
2842
- return this.grafeo.getAllRelations();
3089
+ async listRelations(filter, page) {
3090
+ if (!page) return this.grafeo.getAllRelations(filter);
3091
+ const { offset, limit } = normalizePage(page);
3092
+ const { items, total } = await this.grafeo.pageRelations(filter, page);
3093
+ return { items, total, offset, limit };
2843
3094
  }
2844
3095
  // ── 知识库 API ────────────────────────────────────────────────────────────────
2845
3096
  /** 摄入一个文档(markdown)。document 落库即返回,切块/embedding/图谱后台执行(wait=true 可等待)。*/
@@ -2858,9 +3109,8 @@ var MemoryManager = class {
2858
3109
  async deleteDocument(docId) {
2859
3110
  return this.knowledgeManager.deleteDocument(docId);
2860
3111
  }
2861
- /** 列出文档,可选按 userId / chatId / sessionId 过滤,按更新时间倒序。*/
2862
- async listDocuments(filter) {
2863
- return this.knowledgeManager.listDocuments(filter);
3112
+ async listDocuments(filter, page) {
3113
+ return page ? this.knowledgeManager.listDocuments(filter, page) : this.knowledgeManager.listDocuments(filter);
2864
3114
  }
2865
3115
  destroy() {
2866
3116
  this.grafeo.close();