@ppagent/memory 0.3.1 → 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/index.js CHANGED
@@ -16,6 +16,15 @@ function resolveConfig(config) {
16
16
  console.warn("[MemoryConfig] embeddingApiKey \u672A\u914D\u7F6E\uFF0C\u81EA\u52A8\u56DE\u9000\u4F7F\u7528 llmApiKey\u3002");
17
17
  embeddingApiKey = config.llmApiKey;
18
18
  }
19
+ const compressedContextTokenLimit = positiveInt(
20
+ config.compressedContextTokenLimit ?? config.historyWindowTokenLimit ?? 16 * 1024,
21
+ "compressedContextTokenLimit"
22
+ );
23
+ if (compressedContextTokenLimit > 32 * 1024) {
24
+ console.warn(
25
+ `[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`
26
+ );
27
+ }
19
28
  return {
20
29
  ...config,
21
30
  embeddingBaseUrl,
@@ -23,11 +32,30 @@ function resolveConfig(config) {
23
32
  provider: config.provider ?? "auto",
24
33
  sqlitePath: config.sqlitePath ?? path.join(path.dirname(config.lancedbPath), "memory.sqlite3"),
25
34
  sessionTokenLimit: config.sessionTokenLimit ?? 16386,
26
- historyWindowTokenLimit: config.historyWindowTokenLimit ?? 10240,
35
+ historyWindowTokenLimit: config.historyWindowTokenLimit ?? compressedContextTokenLimit,
27
36
  topicRatio: config.topicRatio ?? [1, 5, 20],
28
37
  detailMaxTokens: config.detailMaxTokens ?? 2048,
29
38
  summaryMaxTokens: config.summaryMaxTokens ?? 512,
30
39
  conciseMaxTokens: config.conciseMaxTokens ?? 128,
40
+ compressedContextTokenLimit,
41
+ contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
42
+ precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
43
+ compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.5, "compressionBatchRatio"),
44
+ compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
45
+ topicSummaryMaxTokens: positiveInt(
46
+ config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
47
+ "topicSummaryMaxTokens"
48
+ ),
49
+ defaultModelContextTokens: positiveInt(
50
+ config.defaultModelContextTokens ?? 256 * 1024,
51
+ "defaultModelContextTokens"
52
+ ),
53
+ maxHistoryAgeMs: Math.max(0, Math.floor(config.maxHistoryAgeMs ?? 0)),
54
+ sessionIdleTtlMs: Math.floor(config.sessionIdleTtlMs ?? 30 * 6e4),
55
+ sessionSweepIntervalMs: positiveInt(
56
+ config.sessionSweepIntervalMs ?? 6e4,
57
+ "sessionSweepIntervalMs"
58
+ ),
31
59
  httpTimeoutMs: config.httpTimeoutMs ?? 6e4,
32
60
  httpMaxRetries: config.httpMaxRetries ?? 2,
33
61
  embeddingBatchSize: config.embeddingBatchSize ?? 20,
@@ -55,6 +83,18 @@ function resolveConfig(config) {
55
83
  restoreConcurrency: config.restoreConcurrency ?? 8
56
84
  };
57
85
  }
86
+ function ratio(value, name) {
87
+ if (!Number.isFinite(value) || value <= 0 || value > 1) {
88
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u5728 (0, 1] \u8303\u56F4\u5185`);
89
+ }
90
+ return value;
91
+ }
92
+ function positiveInt(value, name) {
93
+ if (!Number.isFinite(value) || value <= 0) {
94
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u6B63\u6570`);
95
+ }
96
+ return Math.floor(value);
97
+ }
58
98
 
59
99
  // src/constants.ts
60
100
  var DEFAULT_NODE_TYPES = [
@@ -743,6 +783,7 @@ function tableDefs(dim) {
743
783
  { name: "content", type: "text" },
744
784
  { name: "parts", type: "text", nullable: true },
745
785
  // 兼容存量数据
786
+ { name: "payload", type: "text", nullable: true },
746
787
  { name: "vector", type: "vector" },
747
788
  { name: "usage", type: "int" },
748
789
  { name: "metadata", type: "text" },
@@ -765,9 +806,12 @@ function tableDefs(dim) {
765
806
  { name: "chat_id", type: "text" },
766
807
  { name: "title", type: "text", nullable: true },
767
808
  // 兼容存量数据
768
- { name: "detail", type: "text" },
809
+ { name: "detail", type: "text", nullable: true },
769
810
  { name: "summary", type: "text" },
770
- { name: "concise", type: "text" },
811
+ { name: "concise", type: "text", nullable: true },
812
+ { name: "tokens", type: "int", nullable: true },
813
+ { name: "start_message_id", type: "text", nullable: true },
814
+ { name: "end_message_id", type: "text", nullable: true },
771
815
  { name: "vector", type: "vector" },
772
816
  { name: "start_time", type: "long" },
773
817
  { name: "end_time", type: "long" },
@@ -777,7 +821,7 @@ function tableDefs(dim) {
777
821
  ],
778
822
  indexes: [
779
823
  { column: "chat_id", kind: "scalar" },
780
- { column: "detail", kind: "fts" }
824
+ { column: "summary", kind: "fts" }
781
825
  ]
782
826
  },
783
827
  {
@@ -788,8 +832,10 @@ function tableDefs(dim) {
788
832
  { name: "chat_id", type: "text" },
789
833
  { name: "session_id", type: "text" },
790
834
  { name: "user_id", type: "text" },
835
+ { name: "fact_key", type: "text", nullable: true },
791
836
  { name: "content", type: "text" },
792
- { name: "created_at", type: "long" }
837
+ { name: "created_at", type: "long" },
838
+ { name: "updated_at", type: "long", nullable: true }
793
839
  ],
794
840
  indexes: []
795
841
  },
@@ -868,6 +914,7 @@ function messageToRow(m) {
868
914
  type: m.type,
869
915
  content: m.content,
870
916
  parts: m.parts ?? "[]",
917
+ payload: m.payload ?? null,
871
918
  vector: m.vector,
872
919
  usage: m.usage,
873
920
  metadata: m.metadata,
@@ -885,8 +932,9 @@ function rowToMessage(r) {
885
932
  content: r.content,
886
933
  parts: r.parts ?? "[]",
887
934
  // 旧数据无此列时安全降级
935
+ payload: r.payload ?? void 0,
888
936
  vector: toVector(r.vector),
889
- usage: Number(r.usage),
937
+ usage: positiveNumber(r.usage),
890
938
  metadata: r.metadata,
891
939
  createdAt: Number(r.created_at)
892
940
  };
@@ -898,9 +946,13 @@ function topicToRow(t) {
898
946
  user_id: t.userId,
899
947
  chat_id: t.chatId,
900
948
  title: t.title ?? "",
901
- detail: t.detail,
949
+ // 旧库的 detail/concise 可能仍是 NOT NULL;兼容写入但业务只读取 summary。
950
+ detail: t.detail ?? t.summary,
902
951
  summary: t.summary,
903
- concise: t.concise,
952
+ concise: t.concise ?? t.summary,
953
+ tokens: t.tokens,
954
+ start_message_id: t.startMessageId ?? null,
955
+ end_message_id: t.endMessageId ?? null,
904
956
  vector: t.vector,
905
957
  start_time: t.startTime,
906
958
  end_time: t.endTime,
@@ -916,9 +968,12 @@ function rowToTopic(r) {
916
968
  userId: r.user_id,
917
969
  chatId: r.chat_id,
918
970
  title: r.title ?? "",
919
- detail: r.detail,
920
- summary: r.summary,
921
- concise: r.concise,
971
+ summary: r.summary || r.detail || r.concise || "",
972
+ tokens: positiveNumber(r.tokens),
973
+ startMessageId: r.start_message_id ?? void 0,
974
+ endMessageId: r.end_message_id ?? void 0,
975
+ detail: r.detail ?? void 0,
976
+ concise: r.concise ?? void 0,
922
977
  vector: toVector(r.vector),
923
978
  startTime: Number(r.start_time),
924
979
  endTime: Number(r.end_time),
@@ -934,8 +989,10 @@ function factToRow(f) {
934
989
  chat_id: f.chatId,
935
990
  session_id: f.sessionId,
936
991
  user_id: f.userId,
992
+ fact_key: f.key ?? null,
937
993
  content: f.content,
938
- created_at: f.createdAt
994
+ created_at: f.createdAt,
995
+ updated_at: f.updatedAt ?? f.createdAt
939
996
  };
940
997
  }
941
998
  function rowToFact(r) {
@@ -945,8 +1002,10 @@ function rowToFact(r) {
945
1002
  chatId: r.chat_id,
946
1003
  sessionId: r.session_id,
947
1004
  userId: r.user_id,
1005
+ key: r.fact_key ?? void 0,
948
1006
  content: r.content,
949
- createdAt: Number(r.created_at)
1007
+ createdAt: Number(r.created_at),
1008
+ updatedAt: Number(r.updated_at ?? r.created_at)
950
1009
  };
951
1010
  }
952
1011
  function sessionToRow(s) {
@@ -1048,6 +1107,15 @@ function safeParseObject(s) {
1048
1107
  return {};
1049
1108
  }
1050
1109
  }
1110
+ function positiveNumber(value) {
1111
+ const parsed = Number(value);
1112
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : 0;
1113
+ }
1114
+ function migrationValueEquals(current, next) {
1115
+ if (current == null && next == null) return true;
1116
+ if (typeof next === "number") return Number(current) === next;
1117
+ return String(current) === String(next);
1118
+ }
1051
1119
  function rrfFuse(lists, keyOf) {
1052
1120
  const acc = /* @__PURE__ */ new Map();
1053
1121
  for (const list of lists) {
@@ -1078,7 +1146,7 @@ var MemoryStore = class {
1078
1146
  if (this.providerOverride) {
1079
1147
  this.provider = this.providerOverride;
1080
1148
  } else {
1081
- const { createProvider } = await import("./provider.resolver-ZLQ766IO.js");
1149
+ const { createProvider } = await import("./provider.resolver-2KS2YYNV.js");
1082
1150
  this.provider = await createProvider(this.config);
1083
1151
  console.info(`[memory] \u5411\u91CF\u5B58\u50A8\u540E\u7AEF\uFF1A${this.provider.kind}`);
1084
1152
  }
@@ -1099,6 +1167,98 @@ var MemoryStore = class {
1099
1167
  if (messages.length === 0) return;
1100
1168
  await this.provider.add(MESSAGES_TABLE, messages.map(messageToRow));
1101
1169
  }
1170
+ /** 0.3.x → 0.4.x 数据回填;幂等,不调用 LLM、不删除原始消息。 */
1171
+ async migrateLegacyData() {
1172
+ const report = {
1173
+ provider: this.providerKind,
1174
+ topicsScanned: 0,
1175
+ topicsUpdated: 0,
1176
+ messagesScanned: 0,
1177
+ messagesUpdated: 0,
1178
+ factsScanned: 0,
1179
+ factsUpdated: 0
1180
+ };
1181
+ const topicRows = await this.provider.query(TOPICS_TABLE);
1182
+ report.topicsScanned = topicRows.length;
1183
+ const messagesBySession = /* @__PURE__ */ new Map();
1184
+ for (const row of topicRows) {
1185
+ const sessionId = String(row.session_id);
1186
+ let messages = messagesBySession.get(sessionId);
1187
+ if (!messages) {
1188
+ messages = await this.provider.query(MESSAGES_TABLE, {
1189
+ filter: [eq("session_id", sessionId)],
1190
+ orderBy: [
1191
+ { column: "created_at", ascending: true },
1192
+ { column: "message_id", ascending: true }
1193
+ ]
1194
+ });
1195
+ messagesBySession.set(sessionId, messages);
1196
+ }
1197
+ const summary = String(row.summary || row.detail || row.concise || "");
1198
+ const startTime = Number(row.start_time);
1199
+ const endTime = Number(row.end_time);
1200
+ const covered = messages.filter(
1201
+ (message) => Number(message.created_at) >= startTime && Number(message.created_at) <= endTime
1202
+ );
1203
+ const values = {
1204
+ summary,
1205
+ tokens: positiveNumber(row.tokens) || Math.max(1, countTokens(summary)),
1206
+ start_message_id: row.start_message_id ?? covered.at(0)?.message_id ?? null,
1207
+ end_message_id: row.end_message_id ?? covered.at(-1)?.message_id ?? null,
1208
+ updated_at: Number(row.updated_at || Date.now())
1209
+ };
1210
+ const changed = Object.entries(values).some(
1211
+ ([key, value]) => !migrationValueEquals(row[key], value)
1212
+ );
1213
+ if (changed) {
1214
+ await this.provider.update(TOPICS_TABLE, values, [eq("summary_id", String(row.summary_id))]);
1215
+ report.topicsUpdated++;
1216
+ }
1217
+ }
1218
+ const messageRows = await this.provider.query(MESSAGES_TABLE);
1219
+ report.messagesScanned = messageRows.length;
1220
+ for (const row of messageRows) {
1221
+ const usage = countTokens(
1222
+ [row.content, row.parts ?? "[]", row.metadata ?? "{}", row.payload ?? ""].map(String).join("\n")
1223
+ );
1224
+ if (Number(row.usage) === usage) continue;
1225
+ await this.provider.update(MESSAGES_TABLE, { usage }, [
1226
+ eq("message_id", String(row.message_id))
1227
+ ]);
1228
+ report.messagesUpdated++;
1229
+ }
1230
+ const factRows = await this.provider.query(FACTS_TABLE);
1231
+ report.factsScanned = factRows.length;
1232
+ for (const row of factRows) {
1233
+ const updatedAt = Number(row.updated_at || row.created_at);
1234
+ if (Number(row.updated_at) === updatedAt) continue;
1235
+ await this.provider.update(FACTS_TABLE, { updated_at: updatedAt }, [
1236
+ eq("fact_id", String(row.fact_id))
1237
+ ]);
1238
+ report.factsUpdated++;
1239
+ }
1240
+ return report;
1241
+ }
1242
+ /** message_id 稳定时更新原行,否则新增;用于流式 assistant 消息最终态覆盖。 */
1243
+ async upsertMessages(messages) {
1244
+ if (messages.length === 0) return;
1245
+ const ids = messages.map((message) => message.messageId);
1246
+ const existing = await this.provider.query(MESSAGES_TABLE, {
1247
+ filter: [{ op: "in", field: "message_id", values: ids }],
1248
+ select: ["message_id"]
1249
+ });
1250
+ const existingIds = new Set(existing.map((row) => String(row.message_id)));
1251
+ await Promise.all(
1252
+ messages.filter((message) => existingIds.has(message.messageId)).map(
1253
+ (message) => this.provider.update(
1254
+ MESSAGES_TABLE,
1255
+ messageToRow(message),
1256
+ [eq("message_id", message.messageId)]
1257
+ )
1258
+ )
1259
+ );
1260
+ await this.addMessages(messages.filter((message) => !existingIds.has(message.messageId)));
1261
+ }
1102
1262
  async getMessagesSince(sessionId, since, limit) {
1103
1263
  try {
1104
1264
  const rows = await this.provider.query(MESSAGES_TABLE, {
@@ -1110,6 +1270,12 @@ var MemoryStore = class {
1110
1270
  return [];
1111
1271
  }
1112
1272
  }
1273
+ async getMessagesAfterBoundary(sessionId, endTime, endMessageId) {
1274
+ const messages = await this.getAllMessagesBySession(sessionId);
1275
+ return messages.filter(
1276
+ (message) => message.createdAt > endTime || message.createdAt === endTime && endMessageId != null && message.messageId.localeCompare(endMessageId) > 0
1277
+ );
1278
+ }
1113
1279
  async getLatestMessages(sessionId, limit) {
1114
1280
  if (limit <= 0) return [];
1115
1281
  try {
@@ -1157,7 +1323,7 @@ var MemoryStore = class {
1157
1323
  }
1158
1324
  async hybridSearchTopics(query, vector, filter, limit = 10) {
1159
1325
  try {
1160
- const fused = (await this.hybridSearch(TOPICS_TABLE, ["detail"], query, vector, filter, limit)).slice(0, limit);
1326
+ const fused = (await this.hybridSearch(TOPICS_TABLE, ["summary"], query, vector, filter, limit)).slice(0, limit);
1161
1327
  if (fused.length > 0) {
1162
1328
  const total = fused.length;
1163
1329
  return fused.map((r, idx) => ({
@@ -1165,25 +1331,10 @@ var MemoryStore = class {
1165
1331
  score: total - idx + Math.log1p(Number(r.recall_count))
1166
1332
  })).sort((a, b) => b.score - a.score).map((x) => rowToTopic(x.row));
1167
1333
  }
1334
+ return [];
1168
1335
  } catch {
1336
+ return [];
1169
1337
  }
1170
- const msgs = await this.hybridSearchMessages(query, vector, filter, limit);
1171
- return msgs.map((r) => ({
1172
- summaryId: r.messageId,
1173
- sessionId: r.sessionId,
1174
- userId: r.userId,
1175
- chatId: r.chatId,
1176
- title: "",
1177
- detail: r.content,
1178
- summary: r.content,
1179
- concise: r.content,
1180
- startTime: r.createdAt,
1181
- endTime: r.createdAt,
1182
- createdAt: r.createdAt,
1183
- updatedAt: r.createdAt,
1184
- recallCount: 0,
1185
- vector: r.vector
1186
- }));
1187
1338
  }
1188
1339
  /**
1189
1340
  * 通用混合检索:FTS(BM25)与向量两路各取 limit*HYBRID_OVERFETCH 候选,RRF 融合。
@@ -1221,6 +1372,13 @@ var MemoryStore = class {
1221
1372
  async addTopic(topic) {
1222
1373
  await this.provider.add(TOPICS_TABLE, [topicToRow(topic)]);
1223
1374
  }
1375
+ async updateTopic(topic) {
1376
+ await this.provider.update(
1377
+ TOPICS_TABLE,
1378
+ topicToRow(topic),
1379
+ [eq("summary_id", topic.summaryId)]
1380
+ );
1381
+ }
1224
1382
  async updateTopicRecallCount(summaryId, count) {
1225
1383
  await this.provider.update(
1226
1384
  TOPICS_TABLE,
@@ -1228,6 +1386,23 @@ var MemoryStore = class {
1228
1386
  [eq("summary_id", summaryId)]
1229
1387
  );
1230
1388
  }
1389
+ async incrementTopicRecallCounts(topics) {
1390
+ await Promise.all(
1391
+ topics.map((topic) => this.updateTopicRecallCount(topic.summaryId, topic.recallCount + 1))
1392
+ );
1393
+ }
1394
+ async getTopicsBySession(sessionId, since = 0) {
1395
+ try {
1396
+ const filter = [eq("session_id", sessionId)];
1397
+ if (since > 0) filter.push({ op: "gte", field: "end_time", value: since });
1398
+ const rows = await this.provider.query(TOPICS_TABLE, { filter });
1399
+ return rows.sort(
1400
+ (a, b) => Number(a.end_time) - Number(b.end_time) || cmpStr(a.summary_id, b.summary_id)
1401
+ ).map(rowToTopic);
1402
+ } catch {
1403
+ return [];
1404
+ }
1405
+ }
1231
1406
  async getRecentTopics(chatId, userId, n1, n2, n3) {
1232
1407
  const filter = [eq("chat_id", chatId), eq("user_id", userId)];
1233
1408
  const recallBoostMs = this.config.recallBoostMs;
@@ -1268,10 +1443,19 @@ var MemoryStore = class {
1268
1443
  async deleteTopicsBySession(sessionId) {
1269
1444
  await this.provider.deleteWhere(TOPICS_TABLE, [eq("session_id", sessionId)]);
1270
1445
  }
1446
+ async deleteTopicsByIds(summaryIds) {
1447
+ if (summaryIds.length === 0) return;
1448
+ await this.provider.deleteWhere(TOPICS_TABLE, [
1449
+ { op: "in", field: "summary_id", values: summaryIds }
1450
+ ]);
1451
+ }
1271
1452
  // ── facts ──────────────────────────────────────────────────────────────────
1272
1453
  async saveFact(fact) {
1273
1454
  await this.provider.add(FACTS_TABLE, [factToRow(fact)]);
1274
1455
  }
1456
+ async updateFact(fact) {
1457
+ await this.provider.update(FACTS_TABLE, factToRow(fact), [eq("fact_id", fact.factId)]);
1458
+ }
1275
1459
  async getAllFacts() {
1276
1460
  try {
1277
1461
  const rows = await this.provider.query(FACTS_TABLE);
@@ -1511,40 +1695,6 @@ var MemoryStore = class {
1511
1695
  }
1512
1696
  };
1513
1697
 
1514
- // src/manager/semaphore.ts
1515
- var Semaphore = class {
1516
- count;
1517
- queue = [];
1518
- constructor(max) {
1519
- this.count = max;
1520
- }
1521
- async run(fn) {
1522
- await this.acquire();
1523
- try {
1524
- return await fn();
1525
- } finally {
1526
- this.release();
1527
- }
1528
- }
1529
- acquire() {
1530
- if (this.count > 0) {
1531
- this.count--;
1532
- return Promise.resolve();
1533
- }
1534
- return new Promise((resolve) => {
1535
- this.queue.push(resolve);
1536
- });
1537
- }
1538
- release() {
1539
- const next = this.queue.shift();
1540
- if (next) {
1541
- next();
1542
- } else {
1543
- this.count++;
1544
- }
1545
- }
1546
- };
1547
-
1548
1698
  // src/llm/llm.service.ts
1549
1699
  var ENTITY_EXTRACTOR_TOOL = {
1550
1700
  type: "function",
@@ -1617,7 +1767,10 @@ var LlmService = class {
1617
1767
  * 仅在提示词明确要求 JSON 时启用;返回自然语言的调用(如 summarizeSearchResults)
1618
1768
  * 必须传 false,否则部分端点会因 "messages 未含 json 字样" 而 400。
1619
1769
  */
1620
- async chatCompletion(systemPrompt, userPrompt, jsonMode = true) {
1770
+ async chatCompletion(systemPrompt, userPrompt, jsonMode = true, maxTokens) {
1771
+ return (await this.chatCompletionWithUsage(systemPrompt, userPrompt, jsonMode, maxTokens)).content;
1772
+ }
1773
+ async chatCompletionWithUsage(systemPrompt, userPrompt, jsonMode = true, maxTokens) {
1621
1774
  const url = `${this.config.llmBaseUrl.replace(/\/$/, "")}/chat/completions`;
1622
1775
  const body = {
1623
1776
  model: this.config.llmModel,
@@ -1628,6 +1781,7 @@ var LlmService = class {
1628
1781
  ]
1629
1782
  };
1630
1783
  if (jsonMode) body.response_format = { type: "json_object" };
1784
+ if (maxTokens != null) body.max_tokens = maxTokens;
1631
1785
  const data = await postJsonWithRetry(
1632
1786
  url,
1633
1787
  { Authorization: `Bearer ${this.config.llmApiKey}` },
@@ -1635,7 +1789,10 @@ var LlmService = class {
1635
1789
  { timeoutMs: this.config.httpTimeoutMs, maxRetries: this.config.httpMaxRetries },
1636
1790
  "LLM API"
1637
1791
  );
1638
- return data.choices[0].message.content ?? "";
1792
+ return {
1793
+ content: data.choices[0].message.content ?? "",
1794
+ completionTokens: data.usage?.completion_tokens
1795
+ };
1639
1796
  }
1640
1797
  // ── 带工具调用的请求 ──────────────────────────────────────────────────────────
1641
1798
  async chatWithTools(systemPrompt, userPrompt) {
@@ -1658,27 +1815,55 @@ var LlmService = class {
1658
1815
  );
1659
1816
  return data.choices[0].message;
1660
1817
  }
1661
- // ── 第一步:生成三级摘要(JSON 输出)──────────────────────────────────────────
1818
+ // ── 对话 Topic 摘要(JSON 输出)──────────────────────────────────────────────
1662
1819
  async summarizeMessages(messages) {
1663
1820
  const now = (/* @__PURE__ */ new Date()).toISOString();
1664
- const { detailMaxTokens, summaryMaxTokens, conciseMaxTokens } = this.config;
1821
+ const { topicSummaryMaxTokens } = this.config;
1665
1822
  const systemPrompt = `\u4F60\u662F\u4E00\u4E2A\u5BF9\u8BDD\u8BB0\u5FC6\u7BA1\u7406\u52A9\u624B\u3002\u5F53\u524D\u7CFB\u7EDF\u65F6\u95F4\uFF1A${now}\u3002
1666
- \u8BF7\u5BF9\u7ED9\u5B9A\u7684\u5BF9\u8BDD\u5185\u5BB9\u751F\u6210\u4E00\u4E2A\u4E3B\u9898\u6807\u9898\u548C\u4E09\u7EA7\u6458\u8981\u3002
1823
+ \u8BF7\u628A\u7ED9\u5B9A\u5BF9\u8BDD\u538B\u7F29\u6210\u4E00\u4E2A\u53EF\u957F\u671F\u590D\u7528\u7684\u4E3B\u9898\u8BB0\u5FC6\u3002
1667
1824
 
1668
1825
  \u8981\u6C42\uFF1A
1669
1826
  - title\uFF1A\u4E3B\u9898\u6807\u9898\uFF0C\u4E00\u53E5\u8BDD\uFF08\u5EFA\u8BAE\u4E0D\u8D85\u8FC7 20 \u5B57\uFF09\uFF0C\u9AD8\u5EA6\u6982\u62EC\u8FD9\u6BB5\u5BF9\u8BDD\u7684\u4E3B\u9898\uFF0C\u7528\u4E8E\u5217\u8868\u5C55\u793A
1670
- - detail\uFF1A\u8BE6\u7EC6\u6458\u8981\uFF0C\u6700\u591A ${detailMaxTokens} tokens\uFF0C\u4FDD\u7559\u5173\u952E\u4E8B\u5B9E\u3001\u4EBA\u7269\u548C\u65F6\u95F4
1671
- - summary\uFF1A\u4E2D\u7B49\u6458\u8981\uFF0C\u6700\u591A ${summaryMaxTokens} tokens
1672
- - concise\uFF1A\u4E00\u4E24\u53E5\u8BDD\u7684\u7B80\u6D01\u6458\u8981\uFF0C\u6700\u591A ${conciseMaxTokens} tokens
1827
+ - summary\uFF1A\u552F\u4E00\u7684\u6458\u8981\u6B63\u6587\uFF0C\u6700\u591A ${topicSummaryMaxTokens} tokens
1828
+ - \u6839\u636E\u91CD\u8981\u6027\u52A8\u6001\u51B3\u5B9A\u7BC7\u5E45\uFF1A\u7528\u6237\u7A33\u5B9A\u504F\u597D\u3001\u660E\u786E\u4E8B\u5B9E\u3001\u51B3\u5B9A\u4E0E\u7406\u7531\u3001\u957F\u671F\u7EA6\u675F\u3001\u672A\u89E3\u51B3\u963B\u585E\u3001\u53EF\u590D\u7528\u7ECF\u9A8C\u8981\u8BE6\u7EC6\u4FDD\u7559\uFF1B\u91CD\u590D\u8868\u8FF0\u3001\u5BD2\u6684\u548C\u4E00\u6B21\u6027\u8FC7\u7A0B\u53EF\u6781\u77ED\u6216\u7701\u7565
1829
+ - \u4FDD\u7559\u8C01\u5728\u4F55\u65F6\u505A\u4E86\u4EC0\u4E48\u3001\u5173\u952E\u6807\u8BC6\u7B26\u3001\u6570\u503C\u3001\u7ED3\u8BBA\u3001\u5931\u8D25\u539F\u56E0\u548C\u540E\u7EED\u52A8\u4F5C\uFF1B\u4E0D\u8981\u865A\u6784
1830
+ - metadata\u3001\u7ED3\u6784\u5316 parts \u548C payload \u4E2D\u5F71\u54CD\u8BED\u4E49\u7684\u4FE1\u606F\u540C\u6837\u5C5E\u4E8E\u8BB0\u5FC6\u5185\u5BB9
1673
1831
 
1674
1832
  \u8F93\u51FA\u5FC5\u987B\u662F\u5408\u6CD5\u7684 JSON \u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u4EFB\u4F55 markdown \u6807\u8BB0\uFF0C\u683C\u5F0F\u5982\u4E0B\uFF1A
1675
- {"title": "...", "detail": "...", "summary": "...", "concise": "..."}`;
1676
- const formatted = messages.map((m) => `[${new Date(m.createdAt).toISOString()}] ${m.talkerId || "user"}: ${m.content}`).join("\n");
1677
- const raw = await this.chatCompletion(systemPrompt, `\u8BF7\u5BF9\u4EE5\u4E0B\u5BF9\u8BDD\u751F\u6210\u4E3B\u9898\u6807\u9898\u548C\u4E09\u7EA7\u6458\u8981\uFF1A
1833
+ {"title": "...", "summary": "..."}`;
1834
+ const formatted = messages.map((m) => formatStoredMessage(m)).join("\n");
1835
+ const response = await this.chatCompletionWithUsage(
1836
+ systemPrompt,
1837
+ `\u8BF7\u538B\u7F29\u4EE5\u4E0B\u5BF9\u8BDD\uFF1A
1678
1838
 
1679
- ${formatted}`);
1680
- const parsed = JSON.parse(raw);
1681
- return { title: parsed.title ?? "", detail: parsed.detail, summary: parsed.summary, concise: parsed.concise };
1839
+ ${formatted}`,
1840
+ true,
1841
+ topicSummaryMaxTokens + 128
1842
+ );
1843
+ const parsed = JSON.parse(response.content);
1844
+ return {
1845
+ title: parsed.title ?? "",
1846
+ summary: parsed.summary ?? "",
1847
+ tokens: response.completionTokens
1848
+ };
1849
+ }
1850
+ async summarizeTopics(topics) {
1851
+ const input = topics.map(
1852
+ (topic) => `[${new Date(topic.startTime).toISOString()} - ${new Date(topic.endTime).toISOString()}] ${topic.title}
1853
+ ${topic.summary}`
1854
+ ).join("\n\n");
1855
+ const response = await this.chatCompletionWithUsage(
1856
+ `\u4F60\u662F\u8BB0\u5FC6\u5F52\u5E76\u52A9\u624B\u3002\u628A\u4E00\u7EC4\u6309\u65F6\u95F4\u6392\u5217\u7684\u65E7\u4E3B\u9898\u5F52\u5E76\u6210\u4E00\u4E2A\u4E3B\u9898\u3002\u4FDD\u7559\u7A33\u5B9A\u504F\u597D\u3001\u4E8B\u5B9E\u3001\u51B3\u5B9A\u53CA\u7406\u7531\u3001\u957F\u671F\u7EA6\u675F\u3001\u963B\u585E\u4E0E\u53EF\u590D\u7528\u7ECF\u9A8C\uFF1B\u5220\u9664\u91CD\u590D\u5185\u5BB9\u3002summary \u6700\u591A ${this.config.topicSummaryMaxTokens} tokens\u3002\u53EA\u8F93\u51FA JSON\uFF1A{"title":"...","summary":"..."}`,
1857
+ input,
1858
+ true,
1859
+ this.config.topicSummaryMaxTokens + 128
1860
+ );
1861
+ const parsed = JSON.parse(response.content);
1862
+ return {
1863
+ title: parsed.title ?? "\u5386\u53F2\u4E3B\u9898\u5F52\u5E76",
1864
+ summary: parsed.summary ?? "",
1865
+ tokens: response.completionTokens
1866
+ };
1682
1867
  }
1683
1868
  // ── 第二步:通过工具调用提取实体和关系 ──────────────────────────────────────
1684
1869
  async extractEntitiesFromMessages(messages) {
@@ -1721,9 +1906,8 @@ ${formatted}`
1721
1906
  ]);
1722
1907
  return {
1723
1908
  title: summary.title,
1724
- detail: summary.detail,
1725
1909
  summary: summary.summary,
1726
- concise: summary.concise,
1910
+ tokens: summary.tokens,
1727
1911
  entities: extraction.entities,
1728
1912
  relations: extraction.relations
1729
1913
  };
@@ -1812,18 +1996,51 @@ ${text}`
1812
1996
  ${context}`, false);
1813
1997
  }
1814
1998
  };
1999
+ function formatStoredMessage(message) {
2000
+ const extras = [message.parts, message.metadata, message.payload].filter((value) => value && value !== "[]" && value !== "{}").join(" ");
2001
+ return `[${new Date(message.createdAt).toISOString()}] ${message.talkerId || "user"}: ${message.content}${extras ? `
2002
+ meta=${extras}` : ""}`;
2003
+ }
1815
2004
 
1816
2005
  // src/manager/compress.manager.ts
1817
2006
  import { v4 as uuidv4 } from "uuid";
2007
+
2008
+ // src/manager/semaphore.ts
2009
+ var Semaphore = class {
2010
+ count;
2011
+ queue = [];
2012
+ constructor(max) {
2013
+ this.count = max;
2014
+ }
2015
+ async run(fn) {
2016
+ await this.acquire();
2017
+ try {
2018
+ return await fn();
2019
+ } finally {
2020
+ this.release();
2021
+ }
2022
+ }
2023
+ acquire() {
2024
+ if (this.count > 0) {
2025
+ this.count--;
2026
+ return Promise.resolve();
2027
+ }
2028
+ return new Promise((resolve) => {
2029
+ this.queue.push(resolve);
2030
+ });
2031
+ }
2032
+ release() {
2033
+ const next = this.queue.shift();
2034
+ if (next) {
2035
+ next();
2036
+ } else {
2037
+ this.count++;
2038
+ }
2039
+ }
2040
+ };
2041
+
2042
+ // src/manager/compress.manager.ts
1818
2043
  var CompressManager = class {
1819
- config;
1820
- store;
1821
- grafeo;
1822
- llm;
1823
- embed;
1824
- sessionCache;
1825
- semaphore;
1826
- sessionChain = /* @__PURE__ */ new Map();
1827
2044
  constructor(config, store, grafeo, llm, embed, sessionCache) {
1828
2045
  this.config = config;
1829
2046
  this.store = store;
@@ -1833,125 +2050,189 @@ var CompressManager = class {
1833
2050
  this.sessionCache = sessionCache;
1834
2051
  this.semaphore = new Semaphore(config.maxConcurrentCompressions);
1835
2052
  }
1836
- triggerCompress(sessionId, force = false, waitGraph = false) {
1837
- const chain = this.sessionChain.get(sessionId) ?? Promise.resolve();
1838
- const next = chain.then(
1839
- () => this.semaphore.run(() => this.doCompress(sessionId, force, waitGraph))
2053
+ semaphore;
2054
+ sessionChain = /* @__PURE__ */ new Map();
2055
+ backgroundGraphs = /* @__PURE__ */ new Set();
2056
+ triggerCompress(sessionId, force = false, waitGraph = false, rawLimit) {
2057
+ const previous = this.sessionChain.get(sessionId) ?? Promise.resolve();
2058
+ const next = previous.then(
2059
+ () => this.semaphore.run(() => this.doCompress(sessionId, force, waitGraph, rawLimit))
1840
2060
  );
1841
- this.sessionChain.set(sessionId, next.catch(() => {
1842
- }));
2061
+ const settled = next.catch(() => {
2062
+ });
2063
+ this.sessionChain.set(sessionId, settled);
2064
+ settled.finally(() => {
2065
+ if (this.sessionChain.get(sessionId) === settled) this.sessionChain.delete(sessionId);
2066
+ });
1843
2067
  return next;
1844
2068
  }
1845
- async doCompress(sessionId, force, waitGraph) {
1846
- const messages = this.sessionCache.getSessionMessages(sessionId).slice();
1847
- if (messages.length === 0) return;
1848
- const entry = this.sessionCache.getEntry(sessionId);
1849
- if (!entry) return;
1850
- if (!force && entry.totalTokens < this.config.sessionTokenLimit) return;
1851
- const { chatId, userId } = entry.ids;
1852
- const startTime = messages[0].createdAt;
1853
- const endTime = messages[messages.length - 1].createdAt;
1854
- let summary;
1855
- try {
1856
- summary = await this.llm.summarizeMessages(messages);
1857
- } catch (err) {
1858
- console.error(`[CompressManager] LLM compress failed for session ${sessionId}: ${formatError(err)}`);
1859
- return;
2069
+ async waitForIdle(sessionId) {
2070
+ await (this.sessionChain.get(sessionId) ?? Promise.resolve());
2071
+ }
2072
+ isBusy(sessionId) {
2073
+ return this.sessionChain.has(sessionId);
2074
+ }
2075
+ async waitForAllIdle() {
2076
+ while (this.sessionChain.size > 0 || this.backgroundGraphs.size > 0) {
2077
+ await Promise.all([
2078
+ ...this.sessionChain.values(),
2079
+ ...this.backgroundGraphs
2080
+ ]);
1860
2081
  }
1861
- const { title, detail, summary: summaryText, concise } = summary;
1862
- const topicTextForEmbed = [detail, summaryText, concise].find((t) => t.length > 0) ?? "";
1863
- let topicVector = [];
2082
+ }
2083
+ async doCompress(sessionId, force, waitGraph, rawLimitOverride) {
2084
+ const entry = this.sessionCache.getEntry(sessionId);
2085
+ if (!entry || entry.messages.length === 0) return;
2086
+ const rawLimit = Math.max(1, rawLimitOverride ?? this.rawLimit(entry.lastModelContextTokens));
2087
+ const threshold = Math.max(1, Math.floor(rawLimit * this.config.precompressionRatio));
2088
+ if (!force && entry.totalTokens < threshold) return;
2089
+ let batchTarget = Math.max(1, Math.floor(rawLimit * this.config.compressionBatchRatio));
2090
+ if (this.config.compressionBatchTokenLimit > 0) {
2091
+ batchTarget = Math.min(batchTarget, this.config.compressionBatchTokenLimit);
2092
+ }
2093
+ const messages = this.sessionCache.selectOldestMessageBatch(sessionId, batchTarget);
2094
+ if (messages.length === 0) return;
2095
+ let result;
1864
2096
  try {
1865
- topicVector = await this.embed.embedOne(topicTextForEmbed);
1866
- } catch (err) {
1867
- console.error(`[CompressManager] Embed topic failed:`, err);
2097
+ result = await this.llm.summarizeMessages(messages);
2098
+ } catch (error) {
2099
+ console.error(
2100
+ `[CompressManager] LLM compress failed for session ${sessionId}: ${formatError(error)}`
2101
+ );
2102
+ throw error;
1868
2103
  }
2104
+ const summary = result.summary.trim();
2105
+ if (!summary) throw new Error(`LLM returned an empty summary for session ${sessionId}`);
2106
+ const vector = await this.embed.embedOne(summary).catch((error) => {
2107
+ console.error(`[CompressManager] Embed topic failed for session ${sessionId}:`, error);
2108
+ return [];
2109
+ });
2110
+ const first = messages[0];
2111
+ const last = messages[messages.length - 1];
2112
+ const now = Date.now();
1869
2113
  const topic = {
1870
2114
  summaryId: uuidv4(),
1871
2115
  sessionId,
1872
- userId,
1873
- chatId,
1874
- title,
1875
- detail,
1876
- summary: summaryText,
1877
- concise,
1878
- startTime,
1879
- endTime,
1880
- createdAt: Date.now(),
1881
- updatedAt: Date.now(),
2116
+ userId: entry.ids.userId,
2117
+ chatId: entry.ids.chatId,
2118
+ title: result.title,
2119
+ summary,
2120
+ tokens: result.tokens != null && result.tokens > 0 ? result.tokens : Math.max(1, countTokens(summary)),
2121
+ startMessageId: first.messageId,
2122
+ endMessageId: last.messageId,
2123
+ startTime: first.createdAt,
2124
+ endTime: last.createdAt,
2125
+ createdAt: now,
2126
+ updatedAt: now,
1882
2127
  recallCount: 0,
1883
- vector: topicVector
2128
+ vector
1884
2129
  };
1885
- try {
1886
- await this.store.addTopic(topic);
1887
- } catch (err) {
1888
- console.error(`[CompressManager] Save topic failed:`, err);
1889
- }
1890
- this.sessionCache.clearMessages(sessionId, endTime);
1891
- const [n1, n2, n3] = this.config.topicRatio;
1892
- try {
1893
- const topicGroups = await this.store.getRecentTopics(chatId, userId, n1, n2, n3);
1894
- this.sessionCache.buildHistoryWindow(sessionId, topicGroups);
1895
- } catch (err) {
1896
- console.error(`[CompressManager] Rebuild history window failed:`, err);
1897
- }
1898
- const graphTask = this.extractAndPersistGraph(messages, sessionId, chatId, userId, endTime);
2130
+ await this.store.addTopic(topic);
2131
+ this.sessionCache.appendTopic(sessionId, topic);
2132
+ this.sessionCache.removeMessages(sessionId, messages.map((message) => message.messageId));
2133
+ await this.compactOldTopics(sessionId);
2134
+ const graphTask = this.extractAndPersistGraph(
2135
+ messages,
2136
+ sessionId,
2137
+ entry.ids.chatId,
2138
+ entry.ids.userId,
2139
+ last.createdAt
2140
+ );
1899
2141
  if (waitGraph) {
1900
2142
  await withTimeout(graphTask, this.config.graphBuildTimeoutMs, "flushChat graph build");
1901
2143
  } else {
1902
- graphTask.catch((err) => {
1903
- console.error(`[CompressManager] Background graph persist failed:`, err);
2144
+ const tracked = graphTask.catch((error) => {
2145
+ console.error(`[CompressManager] Background graph persist failed:`, error);
1904
2146
  });
2147
+ this.backgroundGraphs.add(tracked);
2148
+ tracked.finally(() => this.backgroundGraphs.delete(tracked));
2149
+ }
2150
+ }
2151
+ async compactOldTopics(sessionId) {
2152
+ while (true) {
2153
+ const entry = this.sessionCache.getEntry(sessionId);
2154
+ if (!entry || entry.topicTokens <= this.config.compressedContextTokenLimit) return;
2155
+ const target = Math.max(1, Math.floor(this.config.compressedContextTokenLimit / 2));
2156
+ const selected = [];
2157
+ let tokens = 0;
2158
+ for (const topic of entry.topics) {
2159
+ selected.push(topic);
2160
+ tokens += topic.tokens;
2161
+ if (tokens >= target && selected.length >= 2) break;
2162
+ }
2163
+ if (selected.length < 2) {
2164
+ this.sessionCache.setTopics(sessionId, entry.topics, true);
2165
+ return;
2166
+ }
2167
+ console.info(
2168
+ `[CompressManager] session ${sessionId} compressed Topic context exceeds ${this.config.compressedContextTokenLimit} tokens; compacting ${selected.length} old topics.`
2169
+ );
2170
+ const result = await this.llm.summarizeTopics(selected);
2171
+ const summary = result.summary.trim();
2172
+ if (!summary) throw new Error(`LLM returned an empty Topic rollup for session ${sessionId}`);
2173
+ const vector = await this.embed.embedOne(summary).catch(() => []);
2174
+ const first = selected[0];
2175
+ const last = selected[selected.length - 1];
2176
+ const now = Date.now();
2177
+ const rollup = {
2178
+ summaryId: uuidv4(),
2179
+ sessionId,
2180
+ userId: entry.ids.userId,
2181
+ chatId: entry.ids.chatId,
2182
+ title: result.title,
2183
+ summary,
2184
+ tokens: result.tokens != null && result.tokens > 0 ? result.tokens : Math.max(1, countTokens(summary)),
2185
+ startMessageId: first.startMessageId,
2186
+ endMessageId: last.endMessageId,
2187
+ startTime: first.startTime,
2188
+ endTime: last.endTime,
2189
+ createdAt: now,
2190
+ updatedAt: now,
2191
+ recallCount: 0,
2192
+ vector
2193
+ };
2194
+ const removedIds = selected.map((topic) => topic.summaryId);
2195
+ await this.store.addTopic(rollup);
2196
+ this.sessionCache.replaceTopics(sessionId, removedIds, rollup);
2197
+ await this.store.deleteTopicsByIds(removedIds);
1905
2198
  }
1906
2199
  }
2200
+ rawLimit(modelContextTokens = this.config.defaultModelContextTokens) {
2201
+ const usable = Math.floor(modelContextTokens * this.config.contextUsageRatio);
2202
+ return Math.max(1, usable - this.config.compressedContextTokenLimit);
2203
+ }
1907
2204
  async extractAndPersistGraph(messages, sessionId, chatId, userId, endTime) {
1908
2205
  const { entities, relations } = await this.llm.extractEntitiesFromMessages(messages);
1909
2206
  if (entities.length === 0 && relations.length === 0) return;
1910
2207
  await this.persistGraph(entities, relations, sessionId, chatId, userId, endTime);
1911
2208
  }
1912
2209
  async persistGraph(rawEntities, rawRelations, sessionId, chatId, userId, messageTime) {
1913
- const entities = rawEntities.map((e) => ({
1914
- name: e.name,
1915
- type: e.type,
1916
- meta: {
1917
- sessionId,
1918
- chatId,
1919
- userId,
1920
- messageTime,
1921
- ...e.meta
1922
- }
2210
+ const entities = rawEntities.map((entity) => ({
2211
+ name: entity.name,
2212
+ type: entity.type,
2213
+ meta: { sessionId, chatId, userId, messageTime, ...entity.meta }
1923
2214
  }));
1924
- const relations = rawRelations.map((r) => ({
1925
- from: r.from,
1926
- to: r.to,
1927
- type: r.type,
1928
- happenedAt: r.happenedAt ?? void 0,
1929
- meta: {
1930
- sessionId,
1931
- chatId,
1932
- userId,
1933
- messageTime,
1934
- ...r.meta
1935
- }
2215
+ const relations = rawRelations.map((relation) => ({
2216
+ from: relation.from,
2217
+ to: relation.to,
2218
+ type: relation.type,
2219
+ happenedAt: relation.happenedAt,
2220
+ meta: { sessionId, chatId, userId, messageTime, ...relation.meta }
1936
2221
  }));
1937
- const allNames = [.../* @__PURE__ */ new Set([...entities.map((e) => e.name), ...relations.flatMap((r) => [r.from, r.to])])];
1938
- const embeddings = /* @__PURE__ */ new Map();
1939
- try {
1940
- const vecs = await this.embed.embed(allNames);
1941
- allNames.forEach((name, i) => embeddings.set(name, vecs[i]));
1942
- } catch (err) {
1943
- console.error(`[CompressManager] Embed entity names failed:`, err);
1944
- return;
1945
- }
1946
- try {
1947
- await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
1948
- } catch (err) {
1949
- console.error(`[CompressManager] Upsert entities/relations failed:`, err);
1950
- }
2222
+ const names = [
2223
+ .../* @__PURE__ */ new Set([
2224
+ ...entities.map((entity) => entity.name),
2225
+ ...relations.flatMap((relation) => [relation.from, relation.to])
2226
+ ])
2227
+ ];
2228
+ if (names.length === 0) return;
2229
+ const vectors = await this.embed.embed(names);
2230
+ const embeddings = new Map(names.map((name, index) => [name, vectors[index]]));
2231
+ await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
1951
2232
  }
1952
2233
  };
1953
- function formatError(err) {
1954
- return err instanceof Error ? err.message : String(err);
2234
+ function formatError(error) {
2235
+ return error instanceof Error ? error.message : String(error);
1955
2236
  }
1956
2237
  function withTimeout(promise, timeoutMs, label) {
1957
2238
  let timer;
@@ -1981,22 +2262,30 @@ var FactCache = class {
1981
2262
  key(level, id) {
1982
2263
  return `${level}:${id}`;
1983
2264
  }
1984
- async add(content, level, userId, chatId, sessionId = "default") {
2265
+ async add(content, level, userId, chatId, sessionId = "default", key) {
2266
+ const id = level === "user" ? userId : chatId;
2267
+ const cacheKey = this.key(level, id);
2268
+ const existing = this.cache.get(cacheKey) ?? [];
2269
+ const normalizedKey = key?.trim() || void 0;
2270
+ const existingIndex = normalizedKey ? existing.findIndex((fact2) => fact2.key === normalizedKey) : -1;
2271
+ const previous = existingIndex >= 0 ? existing[existingIndex] : void 0;
2272
+ const now = Date.now();
1985
2273
  const fact = {
1986
- factId: uuidv42(),
2274
+ factId: previous?.factId ?? uuidv42(),
1987
2275
  level,
1988
2276
  chatId,
1989
2277
  sessionId,
1990
2278
  userId,
2279
+ key: normalizedKey,
1991
2280
  content,
1992
- createdAt: Date.now()
2281
+ createdAt: previous?.createdAt ?? now,
2282
+ updatedAt: now
1993
2283
  };
1994
- await this.store.saveFact(fact);
1995
- const id = level === "user" ? userId : chatId;
1996
- const k = this.key(level, id);
1997
- const existing = this.cache.get(k) ?? [];
1998
- existing.push(fact);
1999
- this.cache.set(k, existing);
2284
+ if (previous) await this.store.updateFact(fact);
2285
+ else await this.store.saveFact(fact);
2286
+ if (existingIndex >= 0) existing[existingIndex] = fact;
2287
+ else existing.push(fact);
2288
+ this.cache.set(cacheKey, existing);
2000
2289
  }
2001
2290
  get(level, id) {
2002
2291
  return this.cache.get(this.key(level, id)) ?? [];
@@ -2024,7 +2313,9 @@ var FactCache = class {
2024
2313
  toString(level, id) {
2025
2314
  const facts = this.get(level, id);
2026
2315
  if (facts.length === 0) return "";
2027
- return facts.sort((a, b) => a.createdAt - b.createdAt).map((f) => `${new Date(f.createdAt).toISOString()}\uFF1A${f.content}`).join("\n");
2316
+ return facts.sort((a, b) => a.createdAt - b.createdAt).map(
2317
+ (f) => `${new Date(f.updatedAt ?? f.createdAt).toISOString()}\uFF1A${f.key ? `[${f.key}] ` : ""}${f.content}`
2318
+ ).join("\n");
2028
2319
  }
2029
2320
  };
2030
2321
 
@@ -2184,11 +2475,8 @@ var KnowledgeManager = class {
2184
2475
  const now = Date.now();
2185
2476
  const content = opts.content;
2186
2477
  const contentHash = createHash("sha256").update(content).digest("hex");
2187
- const domainFilter = [
2188
- eq("user_id", userId),
2189
- eq("chat_id", chatId),
2190
- eq("session_id", sessionId)
2191
- ];
2478
+ const dedupeScope = opts.scope ?? "session";
2479
+ const domainFilter = dedupeScope === "user" ? [eq("user_id", userId)] : dedupeScope === "chat" ? [eq("chat_id", chatId)] : [eq("session_id", sessionId)];
2192
2480
  const existing = await this.store.findDocumentByHash(contentHash, domainFilter);
2193
2481
  if (existing) return { docId: existing.docId };
2194
2482
  const pieces = chunkMarkdown(content, {
@@ -2507,73 +2795,203 @@ function withTimeout2(promise, timeoutMs, label) {
2507
2795
  var SessionCache = class {
2508
2796
  config;
2509
2797
  sessions = /* @__PURE__ */ new Map();
2510
- historyWindows = /* @__PURE__ */ new Map();
2798
+ legacyHistoryWindows = /* @__PURE__ */ new Map();
2511
2799
  constructor(config) {
2512
2800
  this.config = config;
2513
2801
  }
2514
2802
  getEntry(sessionId) {
2803
+ const entry = this.sessions.get(sessionId);
2804
+ if (entry) entry.lastAccessAt = Date.now();
2805
+ return entry;
2806
+ }
2807
+ peekEntry(sessionId) {
2515
2808
  return this.sessions.get(sessionId);
2516
2809
  }
2517
2810
  getOrCreateEntry(sessionId, chatId, userId) {
2518
2811
  let entry = this.sessions.get(sessionId);
2519
2812
  if (!entry) {
2520
- entry = { messages: [], totalTokens: 0, ids: { chatId, userId } };
2813
+ entry = {
2814
+ messages: [],
2815
+ totalTokens: 0,
2816
+ topics: [],
2817
+ topicTokens: 0,
2818
+ ids: { chatId, userId },
2819
+ lastAccessAt: Date.now()
2820
+ };
2521
2821
  this.sessions.set(sessionId, entry);
2822
+ } else {
2823
+ entry.ids = { chatId, userId };
2824
+ entry.lastAccessAt = Date.now();
2522
2825
  }
2523
2826
  return entry;
2524
2827
  }
2525
- addMessages(sessionId, messages, chatId, userId) {
2828
+ hydrate(sessionId, chatId, userId, messages, topics) {
2526
2829
  const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2527
- entry.messages.push(...messages);
2528
- entry.totalTokens += messages.reduce((sum, m) => sum + m.usage, 0);
2830
+ entry.messages = sortMessages(dedupeMessages(messages));
2831
+ entry.totalTokens = sumMessageTokens(entry.messages);
2832
+ this.setTopics(sessionId, topics);
2833
+ return entry;
2834
+ }
2835
+ upsertMessages(sessionId, messages, chatId, userId) {
2836
+ const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2837
+ const byId = new Map(entry.messages.map((m) => [m.messageId, m]));
2838
+ for (const message of messages) byId.set(message.messageId, message);
2839
+ entry.messages = sortMessages([...byId.values()]);
2840
+ entry.totalTokens = sumMessageTokens(entry.messages);
2841
+ return entry;
2842
+ }
2843
+ /** @deprecated 0.3.x 单元/API 兼容;新代码使用 upsertMessages。 */
2844
+ addMessages(sessionId, messages, chatId, userId) {
2845
+ const entry = this.upsertMessages(sessionId, messages, chatId, userId);
2529
2846
  return entry.totalTokens >= this.config.sessionTokenLimit;
2530
2847
  }
2848
+ /** @deprecated 新压缩链按 messageId 精确移除。 */
2531
2849
  clearMessages(sessionId, keepAfter) {
2532
2850
  const entry = this.sessions.get(sessionId);
2533
2851
  if (!entry) return;
2534
- if (keepAfter != null) {
2535
- entry.messages = entry.messages.filter((m) => m.createdAt > keepAfter);
2536
- entry.totalTokens = entry.messages.reduce((sum, m) => sum + m.usage, 0);
2537
- } else {
2538
- entry.messages = [];
2539
- entry.totalTokens = 0;
2540
- }
2852
+ entry.messages = keepAfter == null ? [] : entry.messages.filter((message) => message.createdAt > keepAfter);
2853
+ entry.totalTokens = sumMessageTokens(entry.messages);
2854
+ }
2855
+ removeMessages(sessionId, messageIds) {
2856
+ const entry = this.sessions.get(sessionId);
2857
+ if (!entry) return;
2858
+ const ids = new Set(messageIds);
2859
+ entry.messages = entry.messages.filter((m) => !ids.has(m.messageId));
2860
+ entry.totalTokens = sumMessageTokens(entry.messages);
2861
+ entry.lastAccessAt = Date.now();
2541
2862
  }
2542
2863
  getSessionMessages(sessionId) {
2543
- return this.sessions.get(sessionId)?.messages ?? [];
2864
+ return this.getEntry(sessionId)?.messages ?? [];
2544
2865
  }
2545
- getAllSessionIds() {
2546
- return [...this.sessions.keys()];
2866
+ selectOldestMessageBatch(sessionId, targetTokens) {
2867
+ const messages = this.getSessionMessages(sessionId);
2868
+ if (messages.length === 0 || targetTokens <= 0) return [];
2869
+ const selected = [];
2870
+ let tokens = 0;
2871
+ for (const message of messages) {
2872
+ selected.push(message);
2873
+ tokens += effectiveUsage(message);
2874
+ if (tokens >= targetTokens) break;
2875
+ }
2876
+ return selected;
2547
2877
  }
2548
- buildHistoryWindow(sessionId, topicGroups) {
2549
- const { topicRatio, historyWindowTokenLimit } = this.config;
2550
- const [n1, n2, n3] = topicRatio;
2551
- let remaining = historyWindowTokenLimit;
2552
- const parts = [];
2553
- const addTopics = (topics, field, max) => {
2554
- let count = 0;
2555
- for (const t of topics) {
2556
- if (count >= max || remaining <= 0) break;
2557
- const text = t[field];
2558
- const tokens = countTokens(text);
2559
- if (tokens > remaining) break;
2560
- parts.push(text);
2561
- remaining -= tokens;
2562
- count++;
2563
- }
2564
- };
2565
- addTopics(topicGroups.detail, "detail", n1);
2566
- addTopics(topicGroups.summary, "summary", n2);
2567
- addTopics(topicGroups.concise, "concise", n3);
2568
- this.historyWindows.set(sessionId, parts.join("\n\n"));
2878
+ setTopics(sessionId, topics, truncate = true) {
2879
+ const entry = this.sessions.get(sessionId);
2880
+ if (!entry) return;
2881
+ const ordered = [...topics].sort(topicOrder);
2882
+ const kept = [];
2883
+ let used = 0;
2884
+ for (let i = ordered.length - 1; i >= 0; i--) {
2885
+ const topic = normalizeTopic(ordered[i]);
2886
+ if (truncate && used + topic.tokens > this.config.compressedContextTokenLimit && kept.length > 0) break;
2887
+ kept.push(topic);
2888
+ used += topic.tokens;
2889
+ }
2890
+ entry.topics = kept.reverse();
2891
+ entry.topicTokens = used;
2892
+ entry.lastAccessAt = Date.now();
2893
+ }
2894
+ appendTopic(sessionId, topic) {
2895
+ const entry = this.sessions.get(sessionId);
2896
+ if (!entry) return;
2897
+ this.setTopics(sessionId, [...entry.topics, topic], false);
2569
2898
  }
2899
+ replaceTopics(sessionId, removedIds, replacement) {
2900
+ const entry = this.sessions.get(sessionId);
2901
+ if (!entry) return;
2902
+ const ids = new Set(removedIds);
2903
+ this.setTopics(
2904
+ sessionId,
2905
+ [...entry.topics.filter((topic) => !ids.has(topic.summaryId)), replacement],
2906
+ false
2907
+ );
2908
+ }
2909
+ getTopics(sessionId) {
2910
+ return this.getEntry(sessionId)?.topics ?? [];
2911
+ }
2912
+ buildCompressedContext(sessionId) {
2913
+ return this.getTopics(sessionId).map((topic) => topic.title ? `## ${topic.title}
2914
+ ${topic.summary}` : topic.summary).join("\n\n");
2915
+ }
2916
+ /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
2917
+ buildHistoryWindow(sessionId, groups) {
2918
+ const [detailCount, summaryCount, conciseCount] = this.config.topicRatio;
2919
+ const values = [
2920
+ ...groups.detail.slice(0, detailCount).map((topic) => topic.detail ?? topic.summary),
2921
+ ...groups.summary.slice(0, summaryCount).map((topic) => topic.summary),
2922
+ ...groups.concise.slice(0, conciseCount).map((topic) => topic.concise ?? topic.summary)
2923
+ ];
2924
+ let remaining = this.config.historyWindowTokenLimit;
2925
+ const kept = [];
2926
+ for (const value of values) {
2927
+ const tokens = countTokens(value);
2928
+ if (tokens > remaining) break;
2929
+ kept.push(value);
2930
+ remaining -= tokens;
2931
+ }
2932
+ this.legacyHistoryWindows.set(sessionId, kept.join("\n\n"));
2933
+ }
2934
+ /** @deprecated MemoryManager.getHistoryWindow 返回结构化窗口。 */
2570
2935
  getHistoryWindow(sessionId) {
2571
- return this.historyWindows.get(sessionId) ?? "";
2936
+ return this.legacyHistoryWindows.get(sessionId) ?? this.buildCompressedContext(sessionId);
2572
2937
  }
2938
+ /** @deprecated 仅为 0.3.x 兼容。 */
2573
2939
  setHistoryWindow(sessionId, content) {
2574
- this.historyWindows.set(sessionId, content);
2940
+ this.legacyHistoryWindows.set(sessionId, content);
2941
+ }
2942
+ setModelContextTokens(sessionId, modelContextTokens) {
2943
+ const entry = this.sessions.get(sessionId);
2944
+ if (!entry) return;
2945
+ entry.lastModelContextTokens = modelContextTokens;
2946
+ entry.lastAccessAt = Date.now();
2947
+ }
2948
+ getAllSessionIds() {
2949
+ return [...this.sessions.keys()];
2950
+ }
2951
+ delete(sessionId) {
2952
+ this.sessions.delete(sessionId);
2953
+ this.legacyHistoryWindows.delete(sessionId);
2954
+ }
2955
+ evictIdle(now, ttlMs, isBusy) {
2956
+ if (ttlMs <= 0) return [];
2957
+ const evicted = [];
2958
+ for (const [sessionId, entry] of this.sessions) {
2959
+ if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
2960
+ this.sessions.delete(sessionId);
2961
+ evicted.push(sessionId);
2962
+ }
2963
+ return evicted;
2575
2964
  }
2576
2965
  };
2966
+ function effectiveUsage(message) {
2967
+ if (Number.isFinite(message.usage) && message.usage > 0) return Math.floor(message.usage);
2968
+ return Math.max(
2969
+ 1,
2970
+ (message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0)) * 2
2971
+ );
2972
+ }
2973
+ function sumMessageTokens(messages) {
2974
+ return messages.reduce((sum, message) => sum + effectiveUsage(message), 0);
2975
+ }
2976
+ function dedupeMessages(messages) {
2977
+ return [...new Map(messages.map((message) => [message.messageId, message])).values()];
2978
+ }
2979
+ function sortMessages(messages) {
2980
+ return messages.sort(
2981
+ (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
2982
+ );
2983
+ }
2984
+ function topicOrder(a, b) {
2985
+ return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2986
+ }
2987
+ function normalizeTopic(topic) {
2988
+ const summary = topic.summary || topic.detail || topic.concise || "";
2989
+ return {
2990
+ ...topic,
2991
+ summary,
2992
+ tokens: topic.tokens > 0 ? topic.tokens : Math.max(1, countTokens(summary))
2993
+ };
2994
+ }
2577
2995
 
2578
2996
  // src/memory.manager.ts
2579
2997
  var MemoryManager = class {
@@ -2588,7 +3006,13 @@ var MemoryManager = class {
2588
3006
  knowledgeManager;
2589
3007
  sessionMap = /* @__PURE__ */ new Map();
2590
3008
  optimizeTimer;
3009
+ sessionSweepTimer;
2591
3010
  optimizeRunning = false;
3011
+ optimizeTask;
3012
+ destroyTask;
3013
+ hydration = /* @__PURE__ */ new Map();
3014
+ pendingWrites = /* @__PURE__ */ new Map();
3015
+ warnedDefaultModelContext = false;
2592
3016
  constructor(config) {
2593
3017
  this.config = resolveConfig(config);
2594
3018
  this.store = new MemoryStore(this.config);
@@ -2622,17 +3046,26 @@ var MemoryManager = class {
2622
3046
  for (const s of allSessions) {
2623
3047
  this.sessionMap.set(s.sessionId, this.deserializeSession(s));
2624
3048
  }
2625
- await this.restoreFromStorage();
2626
3049
  if (this.config.autoOptimizeOnInit) {
2627
- void this.runBackgroundOptimize(this.config.optimizeVersionRetentionMs);
3050
+ this.optimizeTask = this.runBackgroundOptimize(this.config.optimizeVersionRetentionMs);
2628
3051
  }
2629
3052
  if (this.config.autoOptimizeIntervalMs > 0) {
2630
3053
  const retention = Math.max(this.config.optimizeVersionRetentionMs, 6e4);
2631
3054
  this.optimizeTimer = setInterval(() => {
2632
- void this.runBackgroundOptimize(retention);
3055
+ this.optimizeTask = this.runBackgroundOptimize(retention);
2633
3056
  }, this.config.autoOptimizeIntervalMs);
2634
3057
  this.optimizeTimer.unref?.();
2635
3058
  }
3059
+ if (this.config.sessionIdleTtlMs > 0) {
3060
+ this.sessionSweepTimer = setInterval(() => {
3061
+ this.sessionCache.evictIdle(
3062
+ Date.now(),
3063
+ this.config.sessionIdleTtlMs,
3064
+ (sessionId) => this.isSessionBusy(sessionId)
3065
+ );
3066
+ }, this.config.sessionSweepIntervalMs);
3067
+ this.sessionSweepTimer.unref?.();
3068
+ }
2636
3069
  }
2637
3070
  /** 后台压实的统一入口:防重入(上一轮未结束则跳过),失败仅告警不影响服务。*/
2638
3071
  async runBackgroundOptimize(retentionMs) {
@@ -2660,120 +3093,141 @@ var MemoryManager = class {
2660
3093
  async optimizeStorage(retentionMs) {
2661
3094
  return this.store.optimizeStorage(retentionMs ?? this.config.optimizeVersionRetentionMs);
2662
3095
  }
2663
- async restoreFromStorage() {
2664
- const [n1, n2, n3] = this.config.topicRatio;
2665
- const sessionIds = await this.store.getAllSessionIds();
2666
- if (sessionIds.length === 0) return;
2667
- const topicsCache = /* @__PURE__ */ new Map();
2668
- const getTopics = (chatId, userId) => {
2669
- const key = `${chatId}\0${userId}`;
2670
- let cached = topicsCache.get(key);
2671
- if (!cached) {
2672
- cached = this.store.getRecentTopics(chatId, userId, n1, n2, n3);
2673
- topicsCache.set(key, cached);
2674
- }
2675
- return cached;
2676
- };
2677
- const sem = new Semaphore(this.config.restoreConcurrency);
2678
- await Promise.all(
2679
- sessionIds.map(
2680
- (sessionId) => sem.run(async () => {
2681
- const recentMessages = await this.store.getLatestMessages(sessionId, 1);
2682
- if (recentMessages.length === 0) return;
2683
- const { chatId, userId } = recentMessages[0];
2684
- const topicGroups = await getTopics(chatId, userId);
2685
- const n1EndTime = topicGroups.detail.length > 0 ? topicGroups.detail[topicGroups.detail.length - 1].endTime : 0;
2686
- const rawMessages = n1EndTime > 0 ? await this.store.getMessagesSince(sessionId, n1EndTime) : await this.store.getLatestMessages(sessionId, 100);
2687
- if (rawMessages.length > 0) {
2688
- this.sessionCache.addMessages(sessionId, rawMessages, chatId, userId);
2689
- }
2690
- if (topicGroups.detail.length + topicGroups.summary.length + topicGroups.concise.length > 0) {
2691
- this.sessionCache.buildHistoryWindow(sessionId, topicGroups);
2692
- } else {
2693
- this.sessionCache.setHistoryWindow(sessionId, "");
2694
- }
2695
- })
2696
- )
2697
- );
3096
+ async ensureSessionHydrated(sessionId, fallbackIds) {
3097
+ if (this.sessionCache.peekEntry(sessionId)) return;
3098
+ let task = this.hydration.get(sessionId);
3099
+ if (!task) {
3100
+ task = (async () => {
3101
+ const allTopics = await this.store.getTopicsBySession(sessionId);
3102
+ const latestTopic = allTopics.at(-1);
3103
+ const allRawAfterBoundary = latestTopic ? await this.store.getMessagesAfterBoundary(
3104
+ sessionId,
3105
+ latestTopic.endTime,
3106
+ latestTopic.endMessageId
3107
+ ) : await this.store.getAllMessagesBySession(sessionId);
3108
+ const since = this.config.maxHistoryAgeMs > 0 ? Date.now() - this.config.maxHistoryAgeMs : 0;
3109
+ const rawMessages = since > 0 ? allRawAfterBoundary.filter((message) => message.createdAt >= since) : allRawAfterBoundary;
3110
+ const session = this.sessionMap.get(sessionId);
3111
+ const ids = allRawAfterBoundary.at(-1) ?? latestTopic ?? session ?? fallbackIds ?? { chatId: DEFAULT_CHAT_ID, userId: DEFAULT_USER_ID };
3112
+ const visibleTopics = since > 0 ? allTopics.filter((topic) => topic.endTime >= since) : allTopics;
3113
+ this.sessionCache.hydrate(
3114
+ sessionId,
3115
+ ids.chatId,
3116
+ ids.userId,
3117
+ rawMessages,
3118
+ visibleTopics
3119
+ );
3120
+ })();
3121
+ this.hydration.set(sessionId, task);
3122
+ task.finally(() => {
3123
+ if (this.hydration.get(sessionId) === task) this.hydration.delete(sessionId);
3124
+ });
3125
+ }
3126
+ await task;
2698
3127
  }
2699
- async updateChat(messages, opts) {
2700
- if (messages.length === 0) return;
2701
- const userId = opts?.userId ?? DEFAULT_USER_ID;
2702
- const chatId = opts?.chatId ?? DEFAULT_CHAT_ID;
2703
- const sessionId = opts?.sessionId ?? DEFAULT_SESSION_ID;
3128
+ isSessionBusy(sessionId) {
3129
+ return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || this.compressManager.isBusy(sessionId);
3130
+ }
3131
+ waitForPendingWrites(sessionId) {
3132
+ return this.pendingWrites.get(sessionId) ?? Promise.resolve();
3133
+ }
3134
+ queueSessionWrite(sessionId, write) {
3135
+ const previous = this.pendingWrites.get(sessionId) ?? Promise.resolve();
3136
+ const task = previous.catch(() => {
3137
+ }).then(write);
3138
+ const settled = task.catch(() => {
3139
+ });
3140
+ this.pendingWrites.set(sessionId, settled);
3141
+ settled.finally(() => {
3142
+ if (this.pendingWrites.get(sessionId) === settled) this.pendingWrites.delete(sessionId);
3143
+ });
3144
+ return task;
3145
+ }
3146
+ updateChat(messages, opts) {
3147
+ if (messages.length === 0) return Promise.resolve();
3148
+ const sessionId = opts?.sessionId ?? messages[0]?.sessionId ?? DEFAULT_SESSION_ID;
3149
+ return this.queueSessionWrite(sessionId, () => this.doUpdateChat(messages, opts, sessionId));
3150
+ }
3151
+ async doUpdateChat(messages, opts, sessionId) {
3152
+ const userId = opts?.userId ?? messages[0]?.userId ?? DEFAULT_USER_ID;
3153
+ const chatId = opts?.chatId ?? messages[0]?.chatId ?? DEFAULT_CHAT_ID;
2704
3154
  const now = Date.now();
2705
- const withUsage = messages.map((m) => ({
2706
- messageId: m.messageId ?? uuidv44(),
2707
- talkerId: m.talkerId ?? "user",
2708
- chatId: m.chatId ?? chatId,
2709
- userId: m.userId ?? userId,
2710
- sessionId: m.sessionId ?? sessionId,
2711
- type: m.type ?? "text",
2712
- content: m.content,
2713
- parts: JSON.stringify(m.parts ?? []),
2714
- usage: m.usage ?? countTokens(m.content),
2715
- metadata: JSON.stringify(m.metadata ?? {}),
2716
- createdAt: m.createdAt ?? now
2717
- }));
2718
- let vectors;
2719
- try {
2720
- vectors = await this.embed.embed(withUsage.map((m) => m.content));
2721
- } catch (err) {
2722
- console.error("[MemoryManager] Embedding failed:", err);
2723
- throw err;
2724
- }
2725
- const stored = withUsage.map((m, i) => ({
2726
- messageId: m.messageId,
2727
- talkerId: m.talkerId,
2728
- chatId: m.chatId,
2729
- userId: m.userId,
2730
- sessionId: m.sessionId,
2731
- type: m.type,
2732
- content: m.content,
2733
- parts: m.parts,
2734
- usage: m.usage,
2735
- metadata: m.metadata,
2736
- vector: vectors[i],
2737
- createdAt: m.createdAt
2738
- }));
2739
- await this.store.addMessages(stored);
2740
- if (!this.sessionMap.has(sessionId)) {
2741
- const session = {
2742
- sessionId,
2743
- chatId,
2744
- userId,
2745
- title: opts?.sessionTitle ?? "",
2746
- metadata: JSON.stringify(opts?.sessionMetadata ?? {}),
2747
- createdAt: now,
2748
- updatedAt: now
3155
+ await this.ensureSessionHydrated(sessionId, { chatId, userId });
3156
+ const normalized = messages.map((message) => {
3157
+ const parts = JSON.stringify(message.parts ?? []);
3158
+ const metadata = JSON.stringify(message.metadata ?? {});
3159
+ const payload = message.payload === void 0 ? void 0 : JSON.stringify(message.payload);
3160
+ const tokenInput = [message.content, parts, metadata, payload ?? ""].join("\n");
3161
+ return {
3162
+ messageId: message.messageId ?? uuidv44(),
3163
+ talkerId: message.talkerId ?? "user",
3164
+ chatId: message.chatId ?? chatId,
3165
+ userId: message.userId ?? userId,
3166
+ sessionId: message.sessionId ?? sessionId,
3167
+ type: message.type ?? "text",
3168
+ content: message.content,
3169
+ parts,
3170
+ payload,
3171
+ usage: message.usage ?? countTokens(tokenInput),
3172
+ metadata,
3173
+ createdAt: message.createdAt ?? now
2749
3174
  };
2750
- this.sessionMap.set(sessionId, this.deserializeSession(session));
2751
- this.store.insertSession(session).catch((err) => {
2752
- console.error("[MemoryManager] Failed to insert session:", err);
2753
- });
2754
- } else {
2755
- const view = this.sessionMap.get(sessionId);
2756
- view.updatedAt = now;
2757
- this.store.upsertSession(this.serializeSession(view)).catch((err) => {
2758
- console.error(`[MemoryManager] Failed to upsert session ${sessionId}:`, err);
3175
+ });
3176
+ const vectors = await this.embed.embed(normalized.map((message) => message.content));
3177
+ const stored = normalized.map((message, index) => ({
3178
+ ...message,
3179
+ vector: vectors[index]
3180
+ }));
3181
+ await this.store.upsertMessages(stored);
3182
+ this.sessionCache.upsertMessages(sessionId, stored, chatId, userId);
3183
+ const existing = this.sessionMap.get(sessionId);
3184
+ const session = existing ? {
3185
+ ...this.serializeSession(existing),
3186
+ updatedAt: now,
3187
+ ...opts?.sessionTitle !== void 0 && { title: opts.sessionTitle },
3188
+ ...opts?.sessionMetadata !== void 0 && {
3189
+ metadata: JSON.stringify(opts.sessionMetadata)
3190
+ }
3191
+ } : {
3192
+ sessionId,
3193
+ chatId,
3194
+ userId,
3195
+ title: opts?.sessionTitle ?? "",
3196
+ metadata: JSON.stringify(opts?.sessionMetadata ?? {}),
3197
+ createdAt: now,
3198
+ updatedAt: now
3199
+ };
3200
+ this.sessionMap.set(sessionId, this.deserializeSession(session));
3201
+ if (existing) await this.store.upsertSession(session);
3202
+ else await this.store.insertSession(session);
3203
+ const entry = this.sessionCache.getEntry(sessionId);
3204
+ const rawLimit = this.calculateWindowUsage(
3205
+ entry.lastModelContextTokens ?? this.config.defaultModelContextTokens
3206
+ ).rawTokenLimit;
3207
+ if (entry.totalTokens >= Math.floor(rawLimit * this.config.precompressionRatio)) {
3208
+ void this.compressManager.triggerCompress(sessionId, false, false, rawLimit).catch((error) => {
3209
+ console.error(`[MemoryManager] Background compress failed for ${sessionId}:`, error);
2759
3210
  });
2760
3211
  }
2761
- (async () => {
2762
- const overLimit = this.sessionCache.addMessages(sessionId, stored, chatId, userId);
2763
- if (overLimit) {
2764
- await this.compressManager.triggerCompress(sessionId).catch((err) => {
2765
- console.error("[MemoryManager] Background compress failed:", err);
2766
- });
2767
- }
2768
- })();
2769
3212
  }
2770
3213
  async flushChat(sessionId, opts) {
2771
3214
  const sid = sessionId ?? DEFAULT_SESSION_ID;
2772
- const promise = this.compressManager.triggerCompress(sid, true, opts?.waitGraph === true);
3215
+ await this.waitForPendingWrites(sid);
3216
+ await this.ensureSessionHydrated(sid);
3217
+ const entry = this.sessionCache.getEntry(sid);
3218
+ const rawLimit = this.calculateWindowUsage(
3219
+ entry?.lastModelContextTokens ?? this.config.defaultModelContextTokens
3220
+ ).rawTokenLimit;
3221
+ const promise = this.compressManager.triggerCompress(
3222
+ sid,
3223
+ true,
3224
+ opts?.waitGraph === true,
3225
+ rawLimit
3226
+ );
2773
3227
  if (opts?.wait) await promise;
2774
3228
  }
2775
- async updateFacts(content, level, userId, chatId, sessionId) {
2776
- await this.factCache.add(content, level, userId, chatId, sessionId);
3229
+ async updateFacts(content, level, userId, chatId, sessionId, key) {
3230
+ await this.factCache.add(content, level, userId, chatId, sessionId, key);
2777
3231
  }
2778
3232
  async updateEntity(entities, relations, context) {
2779
3233
  if (entities.length === 0 && relations.length === 0) return;
@@ -2817,16 +3271,32 @@ var MemoryManager = class {
2817
3271
  useGraph = await this.llm.judgeNeedsGraphSearch(query).catch(() => false);
2818
3272
  }
2819
3273
  const tasks = [
2820
- this.store.hybridSearchTopics(query, vector, filter, limit).then(
2821
- (topics) => topics.map((t) => ({
3274
+ this.store.hybridSearchTopics(query, vector, filter, limit).then((topics) => {
3275
+ void this.store.incrementTopicRecallCounts(topics).catch(() => {
3276
+ });
3277
+ return topics.map((topic, index) => ({
2822
3278
  type: "topic",
2823
- content: t.detail,
2824
- score: 1,
3279
+ content: topic.summary,
3280
+ score: 1 / (index + 1),
2825
3281
  meta: {
2826
- summaryId: t.summaryId,
2827
- sessionId: t.sessionId,
2828
- chatId: t.chatId,
2829
- userId: t.userId
3282
+ summaryId: topic.summaryId,
3283
+ sessionId: topic.sessionId,
3284
+ chatId: topic.chatId,
3285
+ userId: topic.userId
3286
+ }
3287
+ }));
3288
+ }).catch(() => []),
3289
+ this.store.hybridSearchMessages(query, vector, filter, limit).then(
3290
+ (messages) => messages.map((message, index) => ({
3291
+ type: "message",
3292
+ content: message.content,
3293
+ score: 1 / (index + 1),
3294
+ meta: {
3295
+ messageId: message.messageId,
3296
+ sessionId: message.sessionId,
3297
+ chatId: message.chatId,
3298
+ userId: message.userId,
3299
+ metadata: safeParseObject2(message.metadata)
2830
3300
  }
2831
3301
  }))
2832
3302
  ).catch(() => [])
@@ -2885,10 +3355,79 @@ var MemoryManager = class {
2885
3355
  ...this.factCache.get("chat", chatId)
2886
3356
  ].sort((a, b) => a.createdAt - b.createdAt);
2887
3357
  if (merged.length === 0) return "";
2888
- return merged.map((f) => `${new Date(f.createdAt).toISOString()}\uFF1A${f.content}`).join("\n");
3358
+ return merged.map(
3359
+ (f) => `${new Date(f.updatedAt ?? f.createdAt).toISOString()}\uFF1A${f.key ? `[${f.key}] ` : ""}${f.content}`
3360
+ ).join("\n");
3361
+ }
3362
+ async getHistoryWindow(sessionId, modelContextTokens) {
3363
+ let modelTokens = modelContextTokens;
3364
+ if (modelTokens == null) {
3365
+ modelTokens = this.config.defaultModelContextTokens;
3366
+ if (!this.warnedDefaultModelContext) {
3367
+ this.warnedDefaultModelContext = true;
3368
+ console.warn(
3369
+ `[MemoryManager] getHistoryWindow \u672A\u4F20 modelContextTokens\uFF0C\u4F7F\u7528\u9ED8\u8BA4 ${modelTokens} tokens\u3002`
3370
+ );
3371
+ }
3372
+ }
3373
+ if (!Number.isFinite(modelTokens) || modelTokens <= 0) {
3374
+ throw new Error("modelContextTokens \u5FC5\u987B\u662F\u6B63\u6570");
3375
+ }
3376
+ modelTokens = Math.floor(modelTokens);
3377
+ await this.waitForPendingWrites(sessionId);
3378
+ await this.ensureSessionHydrated(sessionId);
3379
+ this.sessionCache.setModelContextTokens(sessionId, modelTokens);
3380
+ const usageLimits = this.calculateWindowUsage(modelTokens);
3381
+ for (let attempt = 0; attempt < 100; attempt++) {
3382
+ let entry2 = this.sessionCache.getEntry(sessionId);
3383
+ if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3384
+ await this.compressManager.waitForIdle(sessionId);
3385
+ entry2 = this.sessionCache.getEntry(sessionId);
3386
+ if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3387
+ console.warn(
3388
+ `[MemoryManager] session ${sessionId} raw history (${entry2.totalTokens}) exceeds current model budget (${usageLimits.rawTokenLimit}); waiting for immediate compression.`
3389
+ );
3390
+ const beforeTokens = entry2.totalTokens;
3391
+ await this.compressManager.triggerCompress(
3392
+ sessionId,
3393
+ true,
3394
+ false,
3395
+ usageLimits.rawTokenLimit
3396
+ );
3397
+ const after = this.sessionCache.getEntry(sessionId);
3398
+ if (after.messages.length > 0 && after.totalTokens >= beforeTokens) {
3399
+ throw new Error(`Compression made no progress for session ${sessionId}`);
3400
+ }
3401
+ }
3402
+ const entry = this.sessionCache.getEntry(sessionId);
3403
+ if (entry.totalTokens > usageLimits.rawTokenLimit) {
3404
+ throw new Error(`Unable to fit session ${sessionId} into the requested model context`);
3405
+ }
3406
+ return {
3407
+ sessionId,
3408
+ compressedContext: this.sessionCache.buildCompressedContext(sessionId),
3409
+ recentMessages: entry.messages.map(toMemoryRawMessage),
3410
+ usage: {
3411
+ ...usageLimits,
3412
+ compressedTokens: entry.topicTokens,
3413
+ rawTokens: entry.totalTokens
3414
+ }
3415
+ };
2889
3416
  }
2890
- getHistoryWindow(sessionId) {
2891
- return this.sessionCache.getHistoryWindow(sessionId);
3417
+ calculateWindowUsage(modelContextTokens) {
3418
+ const usableContextTokens = Math.floor(modelContextTokens * this.config.contextUsageRatio);
3419
+ const rawTokenLimit = usableContextTokens - this.config.compressedContextTokenLimit;
3420
+ if (rawTokenLimit <= 0) {
3421
+ throw new Error(
3422
+ `modelContextTokens=${modelContextTokens} \u5728 contextUsageRatio=${this.config.contextUsageRatio} \u4E0B\u4E0D\u8DB3\u4EE5\u5BB9\u7EB3 compressedContextTokenLimit=${this.config.compressedContextTokenLimit}`
3423
+ );
3424
+ }
3425
+ return {
3426
+ modelContextTokens,
3427
+ usableContextTokens,
3428
+ compressedTokenLimit: this.config.compressedContextTokenLimit,
3429
+ rawTokenLimit
3430
+ };
2892
3431
  }
2893
3432
  buildScopeFilter(scope, scopeId) {
2894
3433
  if (scope === "session" && scopeId) return [eq("session_id", scopeId)];
@@ -2946,6 +3485,7 @@ var MemoryManager = class {
2946
3485
  async deleteSession(sessionId) {
2947
3486
  if (!this.sessionMap.has(sessionId)) return false;
2948
3487
  this.sessionMap.delete(sessionId);
3488
+ this.sessionCache.delete(sessionId);
2949
3489
  await this.store.deleteSession(sessionId);
2950
3490
  await this.store.deleteMessagesBySession(sessionId).catch((err) => {
2951
3491
  console.error(`[MemoryManager] Failed to delete messages for session ${sessionId}:`, err);
@@ -3020,8 +3560,8 @@ var MemoryManager = class {
3020
3560
  return this.factCache.all().find((f) => f.chatId === chatId)?.userId;
3021
3561
  }
3022
3562
  /** 手动新增一条事实 */
3023
- async addFact(content, level, userId, chatId, sessionId) {
3024
- await this.factCache.add(content, level, userId, chatId, sessionId);
3563
+ async addFact(content, level, userId, chatId, sessionId, key) {
3564
+ await this.factCache.add(content, level, userId, chatId, sessionId, key);
3025
3565
  }
3026
3566
  /** 删除单条事实,返回是否命中 */
3027
3567
  async deleteFact(factId) {
@@ -3060,15 +3600,68 @@ var MemoryManager = class {
3060
3600
  return page ? this.knowledgeManager.listDocuments(filter, page) : this.knowledgeManager.listDocuments(filter);
3061
3601
  }
3062
3602
  destroy() {
3603
+ if (this.destroyTask) return this.destroyTask;
3063
3604
  if (this.optimizeTimer) {
3064
3605
  clearInterval(this.optimizeTimer);
3065
3606
  this.optimizeTimer = void 0;
3066
3607
  }
3067
- this.grafeo.close();
3068
- void this.store.close().catch(() => {
3608
+ if (this.sessionSweepTimer) {
3609
+ clearInterval(this.sessionSweepTimer);
3610
+ this.sessionSweepTimer = void 0;
3611
+ }
3612
+ const writes = [...this.pendingWrites.values(), ...this.hydration.values()];
3613
+ this.destroyTask = Promise.all(writes).catch(() => {
3614
+ }).then(() => this.compressManager.waitForAllIdle()).catch(() => {
3615
+ }).then(() => this.optimizeTask).catch(() => {
3616
+ }).then(async () => {
3617
+ this.grafeo.close();
3618
+ await this.store.close().catch(() => {
3619
+ });
3069
3620
  });
3621
+ return this.destroyTask;
3070
3622
  }
3071
3623
  };
3624
+ function toMemoryRawMessage(message) {
3625
+ const parts = safeParseArray(message.parts);
3626
+ const payload = message.payload === void 0 ? void 0 : safeParseValue(message.payload);
3627
+ return {
3628
+ messageId: message.messageId,
3629
+ talkerId: message.talkerId,
3630
+ chatId: message.chatId,
3631
+ userId: message.userId,
3632
+ sessionId: message.sessionId,
3633
+ type: message.type,
3634
+ content: message.content,
3635
+ ...parts.length > 0 && { parts },
3636
+ ...payload !== void 0 && { payload },
3637
+ usage: message.usage,
3638
+ metadata: safeParseObject2(message.metadata),
3639
+ createdAt: message.createdAt
3640
+ };
3641
+ }
3642
+ function safeParseArray(value) {
3643
+ try {
3644
+ const parsed = JSON.parse(value);
3645
+ return Array.isArray(parsed) ? parsed : [];
3646
+ } catch {
3647
+ return [];
3648
+ }
3649
+ }
3650
+ function safeParseObject2(value) {
3651
+ try {
3652
+ const parsed = JSON.parse(value);
3653
+ return parsed != null && typeof parsed === "object" && !Array.isArray(parsed) ? parsed : {};
3654
+ } catch {
3655
+ return {};
3656
+ }
3657
+ }
3658
+ function safeParseValue(value) {
3659
+ try {
3660
+ return JSON.parse(value);
3661
+ } catch {
3662
+ return value;
3663
+ }
3664
+ }
3072
3665
  export {
3073
3666
  DEFAULT_NODE_TYPES,
3074
3667
  DEFAULT_RELATION_TYPES,