@ppagent/memory 0.4.4 → 0.4.6

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
@@ -1,4 +1,6 @@
1
1
  // src/memory.manager.ts
2
+ import * as fs from "node:fs/promises";
3
+ import * as path2 from "node:path";
2
4
  import { v4 as uuidv44 } from "uuid";
3
5
 
4
6
  // src/config.ts
@@ -47,9 +49,27 @@ function resolveConfig(config) {
47
49
  "topicCompactionSyncRatio"
48
50
  ),
49
51
  contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
50
- precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
51
- compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.5, "compressionBatchRatio"),
52
+ precompressionRatio: ratio(config.precompressionRatio ?? 0.6, "precompressionRatio"),
53
+ compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.7, "compressionBatchRatio"),
52
54
  compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
55
+ compressionMaxRetries: nonNegativeInt(
56
+ config.compressionMaxRetries ?? 2,
57
+ "compressionMaxRetries"
58
+ ),
59
+ compressionRetryBaseDelayMs: nonNegativeInt(
60
+ config.compressionRetryBaseDelayMs ?? 500,
61
+ "compressionRetryBaseDelayMs"
62
+ ),
63
+ backgroundCompressionMaxRetries: nonNegativeInt(
64
+ config.backgroundCompressionMaxRetries ?? 2,
65
+ "backgroundCompressionMaxRetries"
66
+ ),
67
+ backgroundCompressionRetryBaseDelayMs: nonNegativeInt(
68
+ config.backgroundCompressionRetryBaseDelayMs ?? 1e3,
69
+ "backgroundCompressionRetryBaseDelayMs"
70
+ ),
71
+ onCompressionEvent: config.onCompressionEvent ?? (() => {
72
+ }),
53
73
  topicSummaryMaxTokens: positiveInt(
54
74
  config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
55
75
  "topicSummaryMaxTokens"
@@ -86,6 +106,8 @@ function resolveConfig(config) {
86
106
  graphExtractConcurrency: config.graphExtractConcurrency ?? 2,
87
107
  graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4,
88
108
  autoOptimizeOnInit: config.autoOptimizeOnInit ?? true,
109
+ autoMigrateLegacyDataOnInit: config.autoMigrateLegacyDataOnInit ?? true,
110
+ autoResumeCompressionOnInit: config.autoResumeCompressionOnInit ?? true,
89
111
  autoOptimizeIntervalMs: config.autoOptimizeIntervalMs ?? 6 * 36e5,
90
112
  optimizeVersionRetentionMs: config.optimizeVersionRetentionMs ?? 0,
91
113
  restoreConcurrency: config.restoreConcurrency ?? 8
@@ -97,6 +119,12 @@ function ratio(value, name) {
97
119
  }
98
120
  return value;
99
121
  }
122
+ function nonNegativeInt(value, name) {
123
+ if (!Number.isFinite(value) || value < 0) {
124
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u975E\u8D1F\u6570`);
125
+ }
126
+ return Math.floor(value);
127
+ }
100
128
  function positiveInt(value, name) {
101
129
  if (!Number.isFinite(value) || value <= 0) {
102
130
  throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u6B63\u6570`);
@@ -255,6 +283,18 @@ import { GrafeoDB } from "@grafeo-db/js";
255
283
  import { get_encoding } from "@dqbd/tiktoken";
256
284
 
257
285
  // src/llm/http.ts
286
+ var HttpRequestError = class extends Error {
287
+ retryable;
288
+ attempts;
289
+ status;
290
+ constructor(message, options) {
291
+ super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
292
+ this.name = "HttpRequestError";
293
+ this.retryable = options.retryable;
294
+ this.attempts = options.attempts;
295
+ this.status = options.status;
296
+ }
297
+ };
258
298
  function normalizeFetchError(err, timeoutMs, errorLabel) {
259
299
  if (isAbortError(err)) {
260
300
  return new Error(`${errorLabel} request timed out after ${timeoutMs}ms`, { cause: err });
@@ -291,15 +331,36 @@ async function postJsonWithRetry(url, headers, body, opts, errorLabel = "HTTP")
291
331
  await sleep(backoffDelay(attempt));
292
332
  continue;
293
333
  }
294
- throw lastErr;
334
+ throw new HttpRequestError(lastErr.message, {
335
+ retryable: true,
336
+ attempts: attempt + 1,
337
+ cause: lastErr
338
+ });
295
339
  }
296
340
  clearTimeout(timer);
297
341
  if (res.ok) {
298
- return await res.json();
342
+ try {
343
+ return await res.json();
344
+ } catch (err) {
345
+ lastErr = new HttpRequestError(`${errorLabel} returned invalid JSON`, {
346
+ retryable: true,
347
+ attempts: attempt + 1,
348
+ cause: err
349
+ });
350
+ if (attempt < opts.maxRetries) {
351
+ await sleep(backoffDelay(attempt));
352
+ continue;
353
+ }
354
+ throw lastErr;
355
+ }
299
356
  }
300
357
  const text = await res.text().catch(() => "");
301
- lastErr = new Error(`${errorLabel} error ${res.status}: ${text}`);
302
358
  const retryable = res.status >= 500 || res.status === 429;
359
+ lastErr = new HttpRequestError(`${errorLabel} error ${res.status}: ${text}`, {
360
+ retryable,
361
+ attempts: attempt + 1,
362
+ status: res.status
363
+ });
303
364
  if (retryable && attempt < opts.maxRetries) {
304
365
  await sleep(backoffDelay(attempt));
305
366
  continue;
@@ -802,6 +863,136 @@ function eq(field, value) {
802
863
  return { op: "eq", field, value };
803
864
  }
804
865
 
866
+ // src/history-image.ts
867
+ var OMIT_IMAGE = Symbol("omit-history-image");
868
+ var IMAGE_PART_TYPES = /* @__PURE__ */ new Set([
869
+ "image",
870
+ "image-data",
871
+ "image-url",
872
+ "image-file-id",
873
+ "image-file-reference",
874
+ "input-image",
875
+ "output-image"
876
+ ]);
877
+ function sanitizeRawHistoryFields(message) {
878
+ const partsResult = sanitizeValue(message.parts ?? []);
879
+ const payloadResult = sanitizeValue(message.payload);
880
+ const contextResult = sanitizeValue(message.contextPayload);
881
+ const metadataResult = sanitizeValue(message.metadata ?? {});
882
+ const payload = optionalSanitizedValue(payloadResult);
883
+ const contextPayload = optionalSanitizedValue(contextResult);
884
+ return {
885
+ parts: Array.isArray(partsResult.value) ? partsResult.value : [],
886
+ ...payload !== void 0 && { payload },
887
+ ...contextPayload !== void 0 && { contextPayload },
888
+ metadata: isRecord(metadataResult.value) ? metadataResult.value : {},
889
+ removedImages: partsResult.removedImages + payloadResult.removedImages + contextResult.removedImages + metadataResult.removedImages
890
+ };
891
+ }
892
+ function sanitizeStoredMessageImages(message) {
893
+ const parsed = {
894
+ content: message.content,
895
+ parts: parseArray(message.parts),
896
+ ...message.payload !== void 0 && { payload: parseValue(message.payload) },
897
+ ...message.contextPayload !== void 0 && {
898
+ contextPayload: parseValue(message.contextPayload)
899
+ },
900
+ metadata: parseObject(message.metadata)
901
+ };
902
+ const sanitized = sanitizeRawHistoryFields(parsed);
903
+ if (sanitized.removedImages === 0) {
904
+ return { message, changed: false, removedImages: 0 };
905
+ }
906
+ const parts = JSON.stringify(sanitized.parts);
907
+ const payload = sanitized.payload === void 0 ? void 0 : JSON.stringify(sanitized.payload);
908
+ const contextPayload = sanitized.contextPayload === void 0 ? void 0 : JSON.stringify(sanitized.contextPayload);
909
+ const metadata = JSON.stringify(sanitized.metadata);
910
+ const usage = countTokens(
911
+ [message.content, parts, metadata, payload ?? "", contextPayload ?? ""].join("\n")
912
+ );
913
+ return {
914
+ message: {
915
+ ...message,
916
+ parts,
917
+ payload,
918
+ contextPayload,
919
+ usage,
920
+ metadata
921
+ },
922
+ changed: true,
923
+ removedImages: sanitized.removedImages
924
+ };
925
+ }
926
+ function sanitizeValue(value) {
927
+ if (typeof value === "string" && /^data:image\//i.test(value.trim())) {
928
+ return { value: OMIT_IMAGE, removedImages: 1, changed: true };
929
+ }
930
+ if (value == null || typeof value !== "object") {
931
+ return { value, removedImages: 0, changed: false };
932
+ }
933
+ if (isImageNode(value)) {
934
+ return { value: OMIT_IMAGE, removedImages: 1, changed: true };
935
+ }
936
+ if (Array.isArray(value)) {
937
+ const out2 = [];
938
+ let removedImages2 = 0;
939
+ let changed2 = false;
940
+ for (const item of value) {
941
+ const sanitized = sanitizeValue(item);
942
+ removedImages2 += sanitized.removedImages;
943
+ changed2 ||= sanitized.changed;
944
+ if (sanitized.value !== OMIT_IMAGE) out2.push(sanitized.value);
945
+ }
946
+ return { value: changed2 ? out2 : value, removedImages: removedImages2, changed: changed2 };
947
+ }
948
+ const out = {};
949
+ let removedImages = 0;
950
+ let changed = false;
951
+ for (const [key, item] of Object.entries(value)) {
952
+ const sanitized = sanitizeValue(item);
953
+ removedImages += sanitized.removedImages;
954
+ changed ||= sanitized.changed;
955
+ if (sanitized.value !== OMIT_IMAGE) out[key] = sanitized.value;
956
+ }
957
+ return { value: changed ? out : value, removedImages, changed };
958
+ }
959
+ function isImageNode(value) {
960
+ if (!isRecord(value)) return false;
961
+ const type = normalizeType(value.type);
962
+ if (IMAGE_PART_TYPES.has(type)) return true;
963
+ const mediaType = String(value.mediaType ?? value.mimeType ?? "").trim().toLowerCase();
964
+ return mediaType.startsWith("image/") && (type === "file" || "data" in value || "url" in value || "fileId" in value || "providerReference" in value);
965
+ }
966
+ function normalizeType(value) {
967
+ return typeof value === "string" ? value.trim().toLowerCase().replaceAll("_", "-") : "";
968
+ }
969
+ function optionalSanitizedValue(result) {
970
+ if (result.value === OMIT_IMAGE) return void 0;
971
+ if (result.changed && isEmptyContainer(result.value)) return void 0;
972
+ return result.value;
973
+ }
974
+ function isEmptyContainer(value) {
975
+ return Array.isArray(value) ? value.length === 0 : isRecord(value) && Object.keys(value).length === 0;
976
+ }
977
+ function isRecord(value) {
978
+ return !!value && typeof value === "object" && !Array.isArray(value);
979
+ }
980
+ function parseValue(value) {
981
+ try {
982
+ return JSON.parse(value);
983
+ } catch {
984
+ return value;
985
+ }
986
+ }
987
+ function parseArray(value) {
988
+ const parsed = parseValue(value);
989
+ return Array.isArray(parsed) ? parsed : [];
990
+ }
991
+ function parseObject(value) {
992
+ const parsed = parseValue(value);
993
+ return isRecord(parsed) ? parsed : {};
994
+ }
995
+
805
996
  // src/db/memory.store.ts
806
997
  var MESSAGES_TABLE = "messages";
807
998
  var TOPICS_TABLE = "topics";
@@ -1264,17 +1455,28 @@ var MemoryStore = class {
1264
1455
  const messageRows = await this.provider.query(MESSAGES_TABLE);
1265
1456
  report.messagesScanned = messageRows.length;
1266
1457
  for (const row of messageRows) {
1458
+ const sanitized = sanitizeStoredMessageImages(rowToMessage(row)).message;
1267
1459
  const usage = countTokens(
1268
1460
  [
1269
- row.content,
1270
- row.parts ?? "[]",
1271
- row.metadata ?? "{}",
1272
- row.payload ?? "",
1273
- row.context_payload ?? ""
1461
+ sanitized.content,
1462
+ sanitized.parts,
1463
+ sanitized.metadata,
1464
+ sanitized.payload ?? "",
1465
+ sanitized.contextPayload ?? ""
1274
1466
  ].map(String).join("\n")
1275
1467
  );
1276
- if (Number(row.usage) === usage) continue;
1277
- await this.provider.update(MESSAGES_TABLE, { usage }, [
1468
+ const values = {
1469
+ parts: sanitized.parts,
1470
+ payload: sanitized.payload ?? null,
1471
+ context_payload: sanitized.contextPayload ?? null,
1472
+ metadata: sanitized.metadata,
1473
+ usage
1474
+ };
1475
+ const changed = Object.entries(values).some(
1476
+ ([key, value]) => !migrationValueEquals(row[key], value)
1477
+ );
1478
+ if (!changed) continue;
1479
+ await this.provider.update(MESSAGES_TABLE, values, [
1278
1480
  eq("message_id", String(row.message_id))
1279
1481
  ]);
1280
1482
  report.messagesUpdated++;
@@ -2095,7 +2297,7 @@ function stripToolCallContent(value) {
2095
2297
  if (Array.isArray(value)) {
2096
2298
  return value.map((item) => stripToolCallContent(item)).filter((item) => item !== OMIT_COMPRESSION_VALUE);
2097
2299
  }
2098
- if (!isRecord(value)) return value;
2300
+ if (!isRecord2(value)) return value;
2099
2301
  if (isToolCallBlock(value)) return OMIT_COMPRESSION_VALUE;
2100
2302
  const sanitized = {};
2101
2303
  for (const [key, child] of Object.entries(value)) {
@@ -2120,138 +2322,520 @@ function isToolCallBlock(value) {
2120
2322
  function normalizeToolKey(value) {
2121
2323
  return value.replace(/[\s_-]/g, "").toLowerCase();
2122
2324
  }
2123
- function isRecord(value) {
2325
+ function isRecord2(value) {
2124
2326
  return value !== null && typeof value === "object";
2125
2327
  }
2126
2328
 
2127
2329
  // src/manager/compress.manager.ts
2128
2330
  import { v4 as uuidv4 } from "uuid";
2129
2331
 
2130
- // src/manager/semaphore.ts
2131
- var Semaphore = class {
2132
- count;
2133
- queue = [];
2134
- constructor(max) {
2135
- this.count = max;
2136
- }
2137
- async run(fn) {
2138
- await this.acquire();
2139
- try {
2140
- return await fn();
2141
- } finally {
2142
- this.release();
2332
+ // src/compression-message.ts
2333
+ var INTERNAL_METADATA_KEY = "__ppagentMemory";
2334
+ function addCompressionHints(metadata, message) {
2335
+ const compressionGroupId = normalizeGroupId(message.compressionGroupId);
2336
+ const compressionRole = normalizeRole(message.compressionRole);
2337
+ if (!compressionGroupId && !compressionRole) return metadata;
2338
+ const existing = isRecord3(metadata[INTERNAL_METADATA_KEY]) ? metadata[INTERNAL_METADATA_KEY] : {};
2339
+ return {
2340
+ ...metadata,
2341
+ [INTERNAL_METADATA_KEY]: {
2342
+ ...existing,
2343
+ ...compressionGroupId && { compressionGroupId },
2344
+ ...compressionRole && { compressionRole }
2143
2345
  }
2346
+ };
2347
+ }
2348
+ function readCompressionHints(message) {
2349
+ const metadata = parseMetadata(message.metadata);
2350
+ const internal = isRecord3(metadata[INTERNAL_METADATA_KEY]) ? metadata[INTERNAL_METADATA_KEY] : {};
2351
+ const { [INTERNAL_METADATA_KEY]: _internal, ...publicMetadata } = metadata;
2352
+ const explicitCompressionRole = normalizeRole(internal.compressionRole);
2353
+ return {
2354
+ metadata: publicMetadata,
2355
+ compressionGroupId: normalizeGroupId(internal.compressionGroupId),
2356
+ explicitCompressionRole,
2357
+ compressionRole: explicitCompressionRole ?? normalizeRole(metadata.role) ?? inferRole(message.talkerId)
2358
+ };
2359
+ }
2360
+ function inferRole(talkerId) {
2361
+ const normalized = talkerId.trim().toLowerCase();
2362
+ if (normalized === "assistant") return "assistant";
2363
+ if (normalized === "tool") return "tool";
2364
+ if (normalized === "system") return "system";
2365
+ return "user";
2366
+ }
2367
+ function normalizeGroupId(value) {
2368
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
2369
+ }
2370
+ function normalizeRole(value) {
2371
+ return value === "user" || value === "assistant" || value === "tool" || value === "system" ? value : void 0;
2372
+ }
2373
+ function parseMetadata(value) {
2374
+ try {
2375
+ const parsed = JSON.parse(value);
2376
+ return isRecord3(parsed) ? parsed : {};
2377
+ } catch {
2378
+ return {};
2144
2379
  }
2145
- acquire() {
2146
- if (this.count > 0) {
2147
- this.count--;
2148
- return Promise.resolve();
2149
- }
2150
- return new Promise((resolve) => {
2151
- this.queue.push(resolve);
2152
- });
2380
+ }
2381
+ function isRecord3(value) {
2382
+ return !!value && typeof value === "object" && !Array.isArray(value);
2383
+ }
2384
+
2385
+ // src/manager/session.cache.ts
2386
+ var SessionCache = class {
2387
+ config;
2388
+ sessions = /* @__PURE__ */ new Map();
2389
+ topicViews = /* @__PURE__ */ new Map();
2390
+ legacyHistoryWindows = /* @__PURE__ */ new Map();
2391
+ constructor(config) {
2392
+ this.config = config;
2153
2393
  }
2154
- release() {
2155
- const next = this.queue.shift();
2156
- if (next) {
2157
- next();
2394
+ getEntry(sessionId) {
2395
+ const entry = this.sessions.get(sessionId);
2396
+ if (entry) entry.lastAccessAt = Date.now();
2397
+ return entry;
2398
+ }
2399
+ peekEntry(sessionId) {
2400
+ return this.sessions.get(sessionId);
2401
+ }
2402
+ getOrCreateEntry(sessionId, chatId, userId) {
2403
+ let entry = this.sessions.get(sessionId);
2404
+ if (!entry) {
2405
+ entry = {
2406
+ messages: [],
2407
+ totalTokens: 0,
2408
+ topics: [],
2409
+ topicTokens: 0,
2410
+ ids: { chatId, userId },
2411
+ lastAccessAt: Date.now()
2412
+ };
2413
+ this.sessions.set(sessionId, entry);
2158
2414
  } else {
2159
- this.count++;
2415
+ entry.ids = { chatId, userId };
2416
+ entry.lastAccessAt = Date.now();
2160
2417
  }
2418
+ return entry;
2161
2419
  }
2162
- };
2163
-
2164
- // src/manager/compress.manager.ts
2165
- var CompressManager = class {
2166
- constructor(config, store, grafeo, llm, embed, sessionCache) {
2167
- this.config = config;
2168
- this.store = store;
2169
- this.grafeo = grafeo;
2170
- this.llm = llm;
2171
- this.embed = embed;
2172
- this.sessionCache = sessionCache;
2173
- this.semaphore = new Semaphore(config.maxConcurrentCompressions);
2420
+ hydrate(sessionId, chatId, userId, messages, topics) {
2421
+ const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2422
+ entry.messages = sortMessages(dedupeMessages(messages));
2423
+ entry.totalTokens = sumMessageTokens(entry.messages);
2424
+ this.setTopics(sessionId, topics, false);
2425
+ return entry;
2174
2426
  }
2175
- semaphore;
2176
- sessionChain = /* @__PURE__ */ new Map();
2177
- backgroundGraphs = /* @__PURE__ */ new Set();
2178
- triggerCompress(sessionId, force = false, waitGraph = false, rawLimit) {
2179
- return this.enqueueSessionTask(
2180
- sessionId,
2181
- () => this.doCompress(sessionId, force, waitGraph, rawLimit)
2182
- );
2427
+ upsertMessages(sessionId, messages, chatId, userId) {
2428
+ const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2429
+ const byId = new Map(entry.messages.map((m) => [m.messageId, m]));
2430
+ for (const message of messages) byId.set(message.messageId, message);
2431
+ entry.messages = sortMessages([...byId.values()]);
2432
+ entry.totalTokens = sumMessageTokens(entry.messages);
2433
+ return entry;
2183
2434
  }
2184
- /** 按给定预算收敛 Topic;persist=false 时只缓存当前模型使用的临时视图。 */
2185
- triggerTopicCompaction(sessionId, tokenLimit, persist) {
2186
- return this.enqueueSessionTask(
2187
- sessionId,
2188
- () => this.compactOldTopics(sessionId, Math.max(1, Math.floor(tokenLimit)), persist)
2189
- );
2435
+ /** @deprecated 0.3.x 单元/API 兼容;新代码使用 upsertMessages。 */
2436
+ addMessages(sessionId, messages, chatId, userId) {
2437
+ const entry = this.upsertMessages(sessionId, messages, chatId, userId);
2438
+ return entry.totalTokens >= this.config.sessionTokenLimit;
2190
2439
  }
2191
- enqueueSessionTask(sessionId, task) {
2192
- const previous = this.sessionChain.get(sessionId) ?? Promise.resolve();
2193
- const next = previous.then(
2194
- () => this.semaphore.run(task)
2195
- );
2196
- const settled = next.catch(() => {
2197
- });
2198
- this.sessionChain.set(sessionId, settled);
2199
- settled.finally(() => {
2200
- if (this.sessionChain.get(sessionId) === settled) this.sessionChain.delete(sessionId);
2201
- });
2202
- return next;
2440
+ /** @deprecated 新压缩链按 messageId 精确移除。 */
2441
+ clearMessages(sessionId, keepAfter) {
2442
+ const entry = this.sessions.get(sessionId);
2443
+ if (!entry) return;
2444
+ entry.messages = keepAfter == null ? [] : entry.messages.filter((message) => message.createdAt > keepAfter);
2445
+ entry.totalTokens = sumMessageTokens(entry.messages);
2203
2446
  }
2204
- async waitForIdle(sessionId) {
2205
- await (this.sessionChain.get(sessionId) ?? Promise.resolve());
2447
+ removeMessages(sessionId, messageIds) {
2448
+ const entry = this.sessions.get(sessionId);
2449
+ if (!entry) return;
2450
+ const ids = new Set(messageIds);
2451
+ entry.messages = entry.messages.filter((m) => !ids.has(m.messageId));
2452
+ entry.totalTokens = sumMessageTokens(entry.messages);
2453
+ entry.lastAccessAt = Date.now();
2206
2454
  }
2207
- isBusy(sessionId) {
2208
- return this.sessionChain.has(sessionId);
2455
+ getSessionMessages(sessionId) {
2456
+ return this.getEntry(sessionId)?.messages ?? [];
2209
2457
  }
2210
- async waitForAllIdle() {
2211
- while (this.sessionChain.size > 0 || this.backgroundGraphs.size > 0) {
2212
- await Promise.all([
2213
- ...this.sessionChain.values(),
2214
- ...this.backgroundGraphs
2215
- ]);
2458
+ selectOldestMessageBatch(sessionId, targetTokens) {
2459
+ const messages = this.getSessionMessages(sessionId);
2460
+ if (messages.length === 0 || targetTokens <= 0) return [];
2461
+ const groups = groupConversationMessages(messages);
2462
+ const selectableGroups = groups.length > 1 ? groups.slice(0, -1) : groups;
2463
+ const selected = [];
2464
+ let tokens = 0;
2465
+ for (const group of selectableGroups) {
2466
+ const nextTokens = tokens + sumMessageTokens(group);
2467
+ if (selected.length > 0 && Math.abs(targetTokens - tokens) <= Math.abs(targetTokens - nextTokens)) {
2468
+ break;
2469
+ }
2470
+ selected.push(...group);
2471
+ tokens = nextTokens;
2472
+ if (tokens >= targetTokens) break;
2216
2473
  }
2474
+ return selected;
2217
2475
  }
2218
- async doCompress(sessionId, force, waitGraph, rawLimitOverride) {
2219
- const entry = this.sessionCache.getEntry(sessionId);
2220
- if (!entry || entry.messages.length === 0) return;
2221
- const rawLimit = Math.max(1, rawLimitOverride ?? this.rawLimit(entry.lastModelContextTokens));
2222
- const threshold = Math.max(1, Math.floor(rawLimit * this.config.precompressionRatio));
2223
- if (!force && entry.totalTokens < threshold) return;
2224
- let batchTarget = Math.max(1, Math.floor(rawLimit * this.config.compressionBatchRatio));
2225
- if (this.config.compressionBatchTokenLimit > 0) {
2226
- batchTarget = Math.min(batchTarget, this.config.compressionBatchTokenLimit);
2227
- }
2228
- const messages = this.sessionCache.selectOldestMessageBatch(sessionId, batchTarget);
2229
- if (messages.length === 0) return;
2230
- let result;
2231
- try {
2232
- result = await this.llm.summarizeMessages(messages);
2233
- } catch (error) {
2234
- console.error(
2235
- `[CompressManager] LLM compress failed for session ${sessionId}: ${formatError(error)}`
2236
- );
2237
- throw error;
2476
+ setTopics(sessionId, topics, truncate = true) {
2477
+ const entry = this.sessions.get(sessionId);
2478
+ if (!entry) return;
2479
+ const ordered = [...topics].sort(topicOrder);
2480
+ const kept = [];
2481
+ let used = 0;
2482
+ for (let i = ordered.length - 1; i >= 0; i--) {
2483
+ const topic = normalizeTopic(ordered[i]);
2484
+ if (truncate && used + topic.tokens > this.config.compressedContextTokenLimit && kept.length > 0) break;
2485
+ kept.push(topic);
2486
+ used += topic.tokens;
2238
2487
  }
2239
- const summary = result.summary.trim();
2240
- if (!summary) throw new Error(`LLM returned an empty summary for session ${sessionId}`);
2241
- const vector = await this.embed.embedOne(summary).catch((error) => {
2242
- console.error(`[CompressManager] Embed topic failed for session ${sessionId}:`, error);
2243
- return [];
2244
- });
2245
- const first = messages[0];
2246
- const last = messages[messages.length - 1];
2247
- const now = Date.now();
2248
- const topic = {
2249
- summaryId: uuidv4(),
2250
- sessionId,
2251
- userId: entry.ids.userId,
2252
- chatId: entry.ids.chatId,
2253
- title: result.title,
2254
- summary,
2488
+ entry.topics = kept.reverse();
2489
+ entry.topicTokens = used;
2490
+ entry.lastAccessAt = Date.now();
2491
+ this.topicViews.delete(sessionId);
2492
+ }
2493
+ appendTopic(sessionId, topic) {
2494
+ const entry = this.sessions.get(sessionId);
2495
+ if (!entry) return;
2496
+ this.setTopics(sessionId, [...entry.topics, topic], false);
2497
+ }
2498
+ replaceTopics(sessionId, removedIds, replacement) {
2499
+ const entry = this.sessions.get(sessionId);
2500
+ if (!entry) return;
2501
+ const ids = new Set(removedIds);
2502
+ this.setTopics(
2503
+ sessionId,
2504
+ [...entry.topics.filter((topic) => !ids.has(topic.summaryId)), replacement],
2505
+ false
2506
+ );
2507
+ }
2508
+ getTopics(sessionId) {
2509
+ return this.getEntry(sessionId)?.topics ?? [];
2510
+ }
2511
+ getTopicView(sessionId, tokenLimit) {
2512
+ return this.topicViews.get(sessionId)?.get(tokenLimit);
2513
+ }
2514
+ setTopicView(sessionId, tokenLimit, topics) {
2515
+ let views = this.topicViews.get(sessionId);
2516
+ if (!views) {
2517
+ views = /* @__PURE__ */ new Map();
2518
+ this.topicViews.set(sessionId, views);
2519
+ }
2520
+ views.set(tokenLimit, [...topics].map(normalizeTopic).sort(topicOrder));
2521
+ }
2522
+ buildCompressedContext(sessionId, topics) {
2523
+ return (topics ?? this.getTopics(sessionId)).map((topic) => topic.title ? `## ${topic.title}
2524
+ ${topic.summary}` : topic.summary).join("\n\n");
2525
+ }
2526
+ /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
2527
+ buildHistoryWindow(sessionId, groups) {
2528
+ const [detailCount, summaryCount, conciseCount] = this.config.topicRatio;
2529
+ const values = [
2530
+ ...groups.detail.slice(0, detailCount).map((topic) => topic.detail ?? topic.summary),
2531
+ ...groups.summary.slice(0, summaryCount).map((topic) => topic.summary),
2532
+ ...groups.concise.slice(0, conciseCount).map((topic) => topic.concise ?? topic.summary)
2533
+ ];
2534
+ let remaining = this.config.historyWindowTokenLimit;
2535
+ const kept = [];
2536
+ for (const value of values) {
2537
+ const tokens = countTokens(value);
2538
+ if (tokens > remaining) break;
2539
+ kept.push(value);
2540
+ remaining -= tokens;
2541
+ }
2542
+ this.legacyHistoryWindows.set(sessionId, kept.join("\n\n"));
2543
+ }
2544
+ /** @deprecated MemoryManager.getHistoryWindow 返回结构化窗口。 */
2545
+ getHistoryWindow(sessionId) {
2546
+ return this.legacyHistoryWindows.get(sessionId) ?? this.buildCompressedContext(sessionId);
2547
+ }
2548
+ /** @deprecated 仅为 0.3.x 兼容。 */
2549
+ setHistoryWindow(sessionId, content) {
2550
+ this.legacyHistoryWindows.set(sessionId, content);
2551
+ }
2552
+ setModelContextTokens(sessionId, modelContextTokens) {
2553
+ const entry = this.sessions.get(sessionId);
2554
+ if (!entry) return;
2555
+ entry.lastModelContextTokens = modelContextTokens;
2556
+ entry.lastAccessAt = Date.now();
2557
+ }
2558
+ getAllSessionIds() {
2559
+ return [...this.sessions.keys()];
2560
+ }
2561
+ delete(sessionId) {
2562
+ this.sessions.delete(sessionId);
2563
+ this.topicViews.delete(sessionId);
2564
+ this.legacyHistoryWindows.delete(sessionId);
2565
+ }
2566
+ evictIdle(now, ttlMs, isBusy) {
2567
+ if (ttlMs <= 0) return [];
2568
+ const evicted = [];
2569
+ for (const [sessionId, entry] of this.sessions) {
2570
+ if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
2571
+ this.sessions.delete(sessionId);
2572
+ this.topicViews.delete(sessionId);
2573
+ evicted.push(sessionId);
2574
+ }
2575
+ return evicted;
2576
+ }
2577
+ };
2578
+ function groupConversationMessages(messages) {
2579
+ const groups = [];
2580
+ let current = [];
2581
+ let explicitGroupId;
2582
+ for (const message of messages) {
2583
+ const hints = readCompressionHints(message);
2584
+ const startsExplicitGroup = hints.compressionGroupId !== explicitGroupId && (hints.compressionGroupId != null || explicitGroupId != null);
2585
+ const startsInferredGroup = hints.compressionGroupId == null && (hints.compressionRole === "user" || hints.compressionRole === "system");
2586
+ if (current.length > 0 && (startsExplicitGroup || startsInferredGroup)) {
2587
+ groups.push(current);
2588
+ current = [];
2589
+ }
2590
+ current.push(message);
2591
+ explicitGroupId = hints.compressionGroupId;
2592
+ }
2593
+ if (current.length > 0) groups.push(current);
2594
+ return groups;
2595
+ }
2596
+ function effectiveUsage(message) {
2597
+ if (Number.isFinite(message.usage) && message.usage > 0) return Math.floor(message.usage);
2598
+ return Math.max(
2599
+ 1,
2600
+ (message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0) + (message.contextPayload?.length ?? 0)) * 2
2601
+ );
2602
+ }
2603
+ function sumMessageTokens(messages) {
2604
+ return messages.reduce((sum, message) => sum + effectiveUsage(message), 0);
2605
+ }
2606
+ function dedupeMessages(messages) {
2607
+ return [...new Map(messages.map((message) => [message.messageId, message])).values()];
2608
+ }
2609
+ function sortMessages(messages) {
2610
+ return messages.sort(
2611
+ (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
2612
+ );
2613
+ }
2614
+ function topicOrder(a, b) {
2615
+ return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2616
+ }
2617
+ function normalizeTopic(topic) {
2618
+ const summary = topic.summary || topic.detail || topic.concise || "";
2619
+ return {
2620
+ ...topic,
2621
+ summary,
2622
+ tokens: topic.tokens > 0 ? topic.tokens : Math.max(1, countTokens(summary))
2623
+ };
2624
+ }
2625
+
2626
+ // src/manager/semaphore.ts
2627
+ var Semaphore = class {
2628
+ count;
2629
+ queue = [];
2630
+ constructor(max) {
2631
+ this.count = max;
2632
+ }
2633
+ async run(fn) {
2634
+ await this.acquire();
2635
+ try {
2636
+ return await fn();
2637
+ } finally {
2638
+ this.release();
2639
+ }
2640
+ }
2641
+ acquire() {
2642
+ if (this.count > 0) {
2643
+ this.count--;
2644
+ return Promise.resolve();
2645
+ }
2646
+ return new Promise((resolve) => {
2647
+ this.queue.push(resolve);
2648
+ });
2649
+ }
2650
+ release() {
2651
+ const next = this.queue.shift();
2652
+ if (next) {
2653
+ next();
2654
+ } else {
2655
+ this.count++;
2656
+ }
2657
+ }
2658
+ };
2659
+
2660
+ // src/compression-events.ts
2661
+ function emitCompressionEvent(config, event) {
2662
+ try {
2663
+ const returned = config.onCompressionEvent(event);
2664
+ if (returned && typeof returned.then === "function") {
2665
+ void Promise.resolve(returned).catch((error) => {
2666
+ console.warn("[memory] onCompressionEvent callback rejected:", error);
2667
+ });
2668
+ }
2669
+ } catch (error) {
2670
+ console.warn("[memory] onCompressionEvent callback failed:", error);
2671
+ }
2672
+ }
2673
+
2674
+ // src/manager/compress.manager.ts
2675
+ var CompressManager = class {
2676
+ constructor(config, store, grafeo, llm, embed, sessionCache) {
2677
+ this.config = config;
2678
+ this.store = store;
2679
+ this.grafeo = grafeo;
2680
+ this.llm = llm;
2681
+ this.embed = embed;
2682
+ this.sessionCache = sessionCache;
2683
+ this.semaphore = new Semaphore(config.maxConcurrentCompressions);
2684
+ }
2685
+ semaphore;
2686
+ sessionChain = /* @__PURE__ */ new Map();
2687
+ backgroundGraphs = /* @__PURE__ */ new Set();
2688
+ triggerCompress(sessionId, force = false, waitGraph = false, rawLimit, mode = "background") {
2689
+ const startedAt = Date.now();
2690
+ const task = this.enqueueSessionTask(
2691
+ sessionId,
2692
+ () => this.doCompress(sessionId, force, waitGraph, rawLimit, mode)
2693
+ );
2694
+ return task.catch((error) => {
2695
+ emitCompressionEvent(this.config, {
2696
+ sessionId,
2697
+ kind: "raw",
2698
+ mode,
2699
+ phase: "failure",
2700
+ attempt: 1,
2701
+ maxAttempts: this.config.compressionMaxRetries + 1,
2702
+ durationMs: Date.now() - startedAt,
2703
+ error: formatError(error)
2704
+ });
2705
+ throw error;
2706
+ });
2707
+ }
2708
+ /** 按给定预算收敛 Topic;persist=false 时只缓存当前模型使用的临时视图。 */
2709
+ triggerTopicCompaction(sessionId, tokenLimit, persist, mode = "background") {
2710
+ const startedAt = Date.now();
2711
+ emitCompressionEvent(this.config, {
2712
+ sessionId,
2713
+ kind: "topics",
2714
+ mode,
2715
+ phase: "start",
2716
+ attempt: 1,
2717
+ maxAttempts: this.config.compressionMaxRetries + 1
2718
+ });
2719
+ const task = this.enqueueSessionTask(
2720
+ sessionId,
2721
+ () => this.compactOldTopics(
2722
+ sessionId,
2723
+ Math.max(1, Math.floor(tokenLimit)),
2724
+ persist,
2725
+ mode
2726
+ )
2727
+ );
2728
+ return task.then(() => {
2729
+ emitCompressionEvent(this.config, {
2730
+ sessionId,
2731
+ kind: "topics",
2732
+ mode,
2733
+ phase: "success",
2734
+ attempt: 1,
2735
+ maxAttempts: this.config.compressionMaxRetries + 1,
2736
+ durationMs: Date.now() - startedAt
2737
+ });
2738
+ }, (error) => {
2739
+ emitCompressionEvent(this.config, {
2740
+ sessionId,
2741
+ kind: "topics",
2742
+ mode,
2743
+ phase: "failure",
2744
+ attempt: 1,
2745
+ maxAttempts: this.config.compressionMaxRetries + 1,
2746
+ durationMs: Date.now() - startedAt,
2747
+ error: formatError(error)
2748
+ });
2749
+ throw error;
2750
+ });
2751
+ }
2752
+ enqueueSessionTask(sessionId, task) {
2753
+ const previous = this.sessionChain.get(sessionId) ?? Promise.resolve();
2754
+ const next = previous.then(
2755
+ () => this.semaphore.run(task)
2756
+ );
2757
+ const settled = next.catch(() => {
2758
+ });
2759
+ this.sessionChain.set(sessionId, settled);
2760
+ settled.finally(() => {
2761
+ if (this.sessionChain.get(sessionId) === settled) this.sessionChain.delete(sessionId);
2762
+ });
2763
+ return next;
2764
+ }
2765
+ async waitForIdle(sessionId) {
2766
+ await (this.sessionChain.get(sessionId) ?? Promise.resolve());
2767
+ }
2768
+ isBusy(sessionId) {
2769
+ return this.sessionChain.has(sessionId);
2770
+ }
2771
+ async waitForAllIdle() {
2772
+ while (this.sessionChain.size > 0 || this.backgroundGraphs.size > 0) {
2773
+ await Promise.all([
2774
+ ...this.sessionChain.values(),
2775
+ ...this.backgroundGraphs
2776
+ ]);
2777
+ }
2778
+ }
2779
+ async doCompress(sessionId, force, waitGraph, rawLimitOverride, mode) {
2780
+ const entry = this.sessionCache.getEntry(sessionId);
2781
+ if (!entry || entry.messages.length === 0) return;
2782
+ const rawLimit = Math.max(1, rawLimitOverride ?? this.rawLimit(entry.lastModelContextTokens));
2783
+ const threshold = Math.max(1, Math.floor(rawLimit * this.config.precompressionRatio));
2784
+ if (!force && entry.totalTokens < threshold) return;
2785
+ let batchTarget = Math.max(
2786
+ 1,
2787
+ Math.floor(entry.totalTokens * this.config.compressionBatchRatio)
2788
+ );
2789
+ if (this.config.compressionBatchTokenLimit > 0) {
2790
+ batchTarget = Math.min(batchTarget, this.config.compressionBatchTokenLimit);
2791
+ }
2792
+ const messages = this.sessionCache.selectOldestMessageBatch(sessionId, batchTarget);
2793
+ if (messages.length === 0) return;
2794
+ const selectedTokens = messages.reduce(
2795
+ (sum, message) => sum + effectiveUsage(message),
2796
+ 0
2797
+ );
2798
+ const startedAt = Date.now();
2799
+ emitCompressionEvent(this.config, {
2800
+ sessionId,
2801
+ kind: "raw",
2802
+ mode,
2803
+ phase: "start",
2804
+ attempt: 1,
2805
+ maxAttempts: this.config.compressionMaxRetries + 1,
2806
+ inputTokens: entry.totalTokens,
2807
+ selectedTokens
2808
+ });
2809
+ let result;
2810
+ try {
2811
+ result = await this.summarizeWithRetry(
2812
+ sessionId,
2813
+ "raw-message compression",
2814
+ () => this.llm.summarizeMessages(messages),
2815
+ "raw",
2816
+ mode
2817
+ );
2818
+ } catch (error) {
2819
+ console.error(
2820
+ `[CompressManager] LLM compress failed for session ${sessionId}: ${formatError(error)}`
2821
+ );
2822
+ throw error;
2823
+ }
2824
+ const summary = result.summary.trim();
2825
+ const vector = await this.embed.embedOne(summary).catch((error) => {
2826
+ console.error(`[CompressManager] Embed topic failed for session ${sessionId}:`, error);
2827
+ return [];
2828
+ });
2829
+ const first = messages[0];
2830
+ const last = messages[messages.length - 1];
2831
+ const now = Date.now();
2832
+ const topic = {
2833
+ summaryId: uuidv4(),
2834
+ sessionId,
2835
+ userId: entry.ids.userId,
2836
+ chatId: entry.ids.chatId,
2837
+ title: result.title,
2838
+ summary,
2255
2839
  tokens: result.tokens != null && result.tokens > 0 ? result.tokens : Math.max(1, countTokens(summary)),
2256
2840
  startMessageId: first.messageId,
2257
2841
  endMessageId: last.messageId,
@@ -2268,7 +2852,8 @@ var CompressManager = class {
2268
2852
  await this.compactOldTopics(
2269
2853
  sessionId,
2270
2854
  this.config.compressedContextTokenLimit,
2271
- true
2855
+ true,
2856
+ mode
2272
2857
  );
2273
2858
  const graphTask = this.extractAndPersistGraph(
2274
2859
  messages,
@@ -2286,8 +2871,20 @@ var CompressManager = class {
2286
2871
  this.backgroundGraphs.add(tracked);
2287
2872
  tracked.finally(() => this.backgroundGraphs.delete(tracked));
2288
2873
  }
2874
+ emitCompressionEvent(this.config, {
2875
+ sessionId,
2876
+ kind: "raw",
2877
+ mode,
2878
+ phase: "success",
2879
+ attempt: 1,
2880
+ maxAttempts: this.config.compressionMaxRetries + 1,
2881
+ inputTokens: entry.totalTokens + selectedTokens,
2882
+ selectedTokens,
2883
+ outputTokens: topic.tokens,
2884
+ durationMs: Date.now() - startedAt
2885
+ });
2289
2886
  }
2290
- async compactOldTopics(sessionId, tokenLimit, persist) {
2887
+ async compactOldTopics(sessionId, tokenLimit, persist, mode) {
2291
2888
  let transientTopics = persist ? void 0 : this.sessionCache.getTopicView(sessionId, tokenLimit) ?? [...this.sessionCache.getEntry(sessionId)?.topics ?? []];
2292
2889
  while (true) {
2293
2890
  const entry = this.sessionCache.getEntry(sessionId);
@@ -2319,9 +2916,14 @@ var CompressManager = class {
2319
2916
  Math.max(1, tokenLimit - unselectedTokens)
2320
2917
  )
2321
2918
  );
2322
- const result = await this.llm.summarizeTopics(selected, maxSummaryTokens);
2919
+ const result = await this.summarizeWithRetry(
2920
+ sessionId,
2921
+ "Topic compaction",
2922
+ () => this.llm.summarizeTopics(selected, maxSummaryTokens),
2923
+ "topics",
2924
+ mode
2925
+ );
2323
2926
  const summary = result.summary.trim();
2324
- if (!summary) throw new Error(`LLM returned an empty Topic rollup for session ${sessionId}`);
2325
2927
  const vector = persist ? await this.embed.embedOne(summary).catch(() => []) : [];
2326
2928
  const first = selected[0];
2327
2929
  const last = selected[selected.length - 1];
@@ -2358,7 +2960,7 @@ var CompressManager = class {
2358
2960
  transientTopics = [
2359
2961
  ...topics.filter((topic) => !ids.has(topic.summaryId)),
2360
2962
  rollup
2361
- ].sort(topicOrder);
2963
+ ].sort(topicOrder2);
2362
2964
  }
2363
2965
  }
2364
2966
  }
@@ -2394,16 +2996,60 @@ var CompressManager = class {
2394
2996
  const embeddings = new Map(names.map((name, index) => [name, vectors[index]]));
2395
2997
  await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
2396
2998
  }
2999
+ /**
3000
+ * HTTP transport already owns bounded retries for network/timeout/5xx/429.
3001
+ * This outer layer retries semantic compression failures only: malformed model
3002
+ * content, missing fields and empty summaries. That avoids retry multiplication.
3003
+ */
3004
+ async summarizeWithRetry(sessionId, label, operation, kind, mode) {
3005
+ let lastError;
3006
+ for (let attempt = 0; attempt <= this.config.compressionMaxRetries; attempt++) {
3007
+ try {
3008
+ const result = await operation();
3009
+ if (typeof result?.summary !== "string" || !result.summary.trim()) {
3010
+ throw new Error(`${label} returned an empty summary`);
3011
+ }
3012
+ return result;
3013
+ } catch (error) {
3014
+ lastError = error;
3015
+ if (error instanceof HttpRequestError || attempt >= this.config.compressionMaxRetries) {
3016
+ throw error;
3017
+ }
3018
+ const delayMs = Math.min(
3019
+ this.config.compressionRetryBaseDelayMs * 2 ** attempt,
3020
+ 8e3
3021
+ );
3022
+ console.warn(
3023
+ `[CompressManager] ${label} failed for session ${sessionId}; retrying ${attempt + 1}/${this.config.compressionMaxRetries} after ${delayMs}ms: ` + formatError(error)
3024
+ );
3025
+ emitCompressionEvent(this.config, {
3026
+ sessionId,
3027
+ kind,
3028
+ mode,
3029
+ phase: "retry",
3030
+ attempt: attempt + 2,
3031
+ maxAttempts: this.config.compressionMaxRetries + 1,
3032
+ retryKind: "semantic",
3033
+ error: formatError(error)
3034
+ });
3035
+ if (delayMs > 0) await delay(delayMs);
3036
+ }
3037
+ }
3038
+ throw lastError;
3039
+ }
2397
3040
  };
2398
3041
  function sumTopicTokens(topics) {
2399
3042
  return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
2400
3043
  }
2401
- function topicOrder(a, b) {
3044
+ function topicOrder2(a, b) {
2402
3045
  return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2403
3046
  }
2404
3047
  function formatError(error) {
2405
3048
  return error instanceof Error ? error.message : String(error);
2406
3049
  }
3050
+ function delay(ms) {
3051
+ return new Promise((resolve) => setTimeout(resolve, ms));
3052
+ }
2407
3053
  function withTimeout(promise, timeoutMs, label) {
2408
3054
  let timer;
2409
3055
  const timeout = new Promise((_, reject) => {
@@ -2823,359 +3469,142 @@ ${p.content}` : p.content
2823
3469
  let title = docTitleMap.get(docId);
2824
3470
  if (title === void 0) {
2825
3471
  const d = await this.store.getDocument(docId);
2826
- title = d?.title ?? "";
2827
- }
2828
- return { docId, title, matchedChunkCount };
2829
- })
2830
- );
2831
- const graphHits = await this.searchGraph(mode, vector, scope, scopeId, scored);
2832
- return { chunks: scored, documents, graphHits };
2833
- }
2834
- async searchGraph(mode, vector, scope, scopeId, topChunks) {
2835
- if (mode === "fast") return [];
2836
- if (mode === "auto") {
2837
- const top = topChunks[0];
2838
- if (!top || top.score < this.config.knowledgeGraphTriggerScore) return [];
2839
- }
2840
- const grafeoFilter = {
2841
- kind: KIND_KNOWLEDGE
2842
- };
2843
- if (scope === "user" && scopeId) grafeoFilter.userId = scopeId;
2844
- if (scope === "chat" && scopeId) grafeoFilter.chatId = scopeId;
2845
- if (scope === "session" && scopeId) grafeoFilter.sessionId = scopeId;
2846
- const entities = await this.grafeo.searchEntities(vector, grafeoFilter, this.config.knowledgeGraphEntityTopK).catch(() => []);
2847
- if (entities.length === 0) return entities;
2848
- const anchorCount = Math.min(this.config.knowledgeGraphAnchorTopK, entities.length);
2849
- const relatedArrays = await Promise.all(
2850
- entities.slice(0, anchorCount).map((e) => {
2851
- const name = e.meta?.name ?? "";
2852
- if (!name) return Promise.resolve([]);
2853
- return this.grafeo.getRelatedEntities(
2854
- name,
2855
- grafeoFilter.userId ?? e.meta?.userId ?? "",
2856
- grafeoFilter.chatId ?? e.meta?.chatId ?? "",
2857
- this.config.knowledgeGraphHopLimit,
2858
- KIND_KNOWLEDGE
2859
- ).catch(() => []);
2860
- })
2861
- );
2862
- return dedupByContent([...entities, ...relatedArrays.flat()]);
2863
- }
2864
- /**
2865
- * 构建片段表的过滤条件。
2866
- * - chunkRedundantIds=true:直接在 chunks 行的 user_id/chat_id/session_id 上过滤
2867
- * - false:先按域查 documents 得到 docId,再用 doc_id IN(...) 过滤
2868
- * 返回 undefined 表示不过滤;返回 NO_MATCH 表示无候选(应直接返回空结果)。
2869
- */
2870
- async buildChunkFilter(scope, scopeId, candidateDocIds) {
2871
- const parts = [];
2872
- if (this.config.chunkRedundantIds) {
2873
- const domain = buildDomainFilter(scope, scopeId);
2874
- if (domain) parts.push(...domain);
2875
- if (candidateDocIds) parts.push({ op: "in", field: "doc_id", values: candidateDocIds });
2876
- } else {
2877
- let docIds = candidateDocIds;
2878
- if (scope !== "all") {
2879
- const domain = buildDomainFilter(scope, scopeId);
2880
- const domainDocIds = await this.store.getDocIdsByDomain(domain);
2881
- docIds = docIds ? domainDocIds.filter((id) => docIds.includes(id)) : domainDocIds;
2882
- }
2883
- if (docIds) {
2884
- if (docIds.length === 0) return NO_MATCH;
2885
- parts.push({ op: "in", field: "doc_id", values: docIds });
2886
- }
2887
- }
2888
- return parts.length > 0 ? parts : void 0;
2889
- }
2890
- // ── 读取 / 删除 ─────────────────────────────────────────────────────────────────
2891
- async getDocument(docId) {
2892
- return this.store.getDocument(docId);
2893
- }
2894
- async deleteDocument(docId) {
2895
- const existing = await this.store.getDocument(docId);
2896
- if (!existing) return false;
2897
- await this.store.deleteDocument(docId);
2898
- await this.store.deleteChunksByDoc(docId).catch((err) => {
2899
- console.error(`[KnowledgeManager] Failed to delete chunks for doc ${docId}:`, err);
2900
- });
2901
- await this.grafeo.deleteKnowledgeByDoc(docId).catch((err) => {
2902
- console.error(`[KnowledgeManager] Failed to delete knowledge graph for doc ${docId}:`, err);
2903
- });
2904
- return true;
2905
- }
2906
- async listDocuments(filter, page) {
2907
- let docs = await this.store.getAllDocuments();
2908
- if (filter?.userId) docs = docs.filter((d) => d.userId === filter.userId);
2909
- if (filter?.chatId) docs = docs.filter((d) => d.chatId === filter.chatId);
2910
- if (filter?.sessionId) docs = docs.filter((d) => d.sessionId === filter.sessionId);
2911
- return page ? paginate(docs, page) : docs;
2912
- }
2913
- };
2914
- var NO_MATCH = Symbol("no-match");
2915
- function dedupByContent(items) {
2916
- const seen = /* @__PURE__ */ new Set();
2917
- const out = [];
2918
- for (const item of items) {
2919
- if (seen.has(item.content)) continue;
2920
- seen.add(item.content);
2921
- out.push(item);
2922
- }
2923
- return out;
2924
- }
2925
- function buildDomainFilter(scope, scopeId) {
2926
- if (scope === "session" && scopeId) return [eq("session_id", scopeId)];
2927
- if (scope === "chat" && scopeId) return [eq("chat_id", scopeId)];
2928
- if (scope === "user" && scopeId) return [eq("user_id", scopeId)];
2929
- return void 0;
2930
- }
2931
- function inferTitle(markdown) {
2932
- const lines = markdown.split(/\r?\n/);
2933
- for (const line of lines) {
2934
- const m = /^(#{1,6})\s+(.*)$/.exec(line);
2935
- if (m && m[2].trim()) return m[2].trim().slice(0, 120);
2936
- }
2937
- for (const line of lines) {
2938
- const t = line.trim();
2939
- if (t) return t.slice(0, 120);
2940
- }
2941
- return void 0;
2942
- }
2943
- async function mapLimit2(items, limit, mapper) {
2944
- const results = new Array(items.length);
2945
- let next = 0;
2946
- const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
2947
- while (true) {
2948
- const index = next++;
2949
- if (index >= items.length) return;
2950
- results[index] = await mapper(items[index], index);
2951
- }
2952
- });
2953
- await Promise.all(workers);
2954
- return results;
2955
- }
2956
- function withTimeout2(promise, timeoutMs, label) {
2957
- let timer;
2958
- const timeout = new Promise((_, reject) => {
2959
- timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
2960
- });
2961
- return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
2962
- }
2963
-
2964
- // src/manager/session.cache.ts
2965
- var SessionCache = class {
2966
- config;
2967
- sessions = /* @__PURE__ */ new Map();
2968
- topicViews = /* @__PURE__ */ new Map();
2969
- legacyHistoryWindows = /* @__PURE__ */ new Map();
2970
- constructor(config) {
2971
- this.config = config;
2972
- }
2973
- getEntry(sessionId) {
2974
- const entry = this.sessions.get(sessionId);
2975
- if (entry) entry.lastAccessAt = Date.now();
2976
- return entry;
2977
- }
2978
- peekEntry(sessionId) {
2979
- return this.sessions.get(sessionId);
2980
- }
2981
- getOrCreateEntry(sessionId, chatId, userId) {
2982
- let entry = this.sessions.get(sessionId);
2983
- if (!entry) {
2984
- entry = {
2985
- messages: [],
2986
- totalTokens: 0,
2987
- topics: [],
2988
- topicTokens: 0,
2989
- ids: { chatId, userId },
2990
- lastAccessAt: Date.now()
2991
- };
2992
- this.sessions.set(sessionId, entry);
2993
- } else {
2994
- entry.ids = { chatId, userId };
2995
- entry.lastAccessAt = Date.now();
2996
- }
2997
- return entry;
2998
- }
2999
- hydrate(sessionId, chatId, userId, messages, topics) {
3000
- const entry = this.getOrCreateEntry(sessionId, chatId, userId);
3001
- entry.messages = sortMessages(dedupeMessages(messages));
3002
- entry.totalTokens = sumMessageTokens(entry.messages);
3003
- this.setTopics(sessionId, topics, false);
3004
- return entry;
3005
- }
3006
- upsertMessages(sessionId, messages, chatId, userId) {
3007
- const entry = this.getOrCreateEntry(sessionId, chatId, userId);
3008
- const byId = new Map(entry.messages.map((m) => [m.messageId, m]));
3009
- for (const message of messages) byId.set(message.messageId, message);
3010
- entry.messages = sortMessages([...byId.values()]);
3011
- entry.totalTokens = sumMessageTokens(entry.messages);
3012
- return entry;
3013
- }
3014
- /** @deprecated 0.3.x 单元/API 兼容;新代码使用 upsertMessages。 */
3015
- addMessages(sessionId, messages, chatId, userId) {
3016
- const entry = this.upsertMessages(sessionId, messages, chatId, userId);
3017
- return entry.totalTokens >= this.config.sessionTokenLimit;
3018
- }
3019
- /** @deprecated 新压缩链按 messageId 精确移除。 */
3020
- clearMessages(sessionId, keepAfter) {
3021
- const entry = this.sessions.get(sessionId);
3022
- if (!entry) return;
3023
- entry.messages = keepAfter == null ? [] : entry.messages.filter((message) => message.createdAt > keepAfter);
3024
- entry.totalTokens = sumMessageTokens(entry.messages);
3025
- }
3026
- removeMessages(sessionId, messageIds) {
3027
- const entry = this.sessions.get(sessionId);
3028
- if (!entry) return;
3029
- const ids = new Set(messageIds);
3030
- entry.messages = entry.messages.filter((m) => !ids.has(m.messageId));
3031
- entry.totalTokens = sumMessageTokens(entry.messages);
3032
- entry.lastAccessAt = Date.now();
3033
- }
3034
- getSessionMessages(sessionId) {
3035
- return this.getEntry(sessionId)?.messages ?? [];
3036
- }
3037
- selectOldestMessageBatch(sessionId, targetTokens) {
3038
- const messages = this.getSessionMessages(sessionId);
3039
- if (messages.length === 0 || targetTokens <= 0) return [];
3040
- const selected = [];
3041
- let tokens = 0;
3042
- for (const message of messages) {
3043
- selected.push(message);
3044
- tokens += effectiveUsage(message);
3045
- if (tokens >= targetTokens) break;
3046
- }
3047
- return selected;
3048
- }
3049
- setTopics(sessionId, topics, truncate = true) {
3050
- const entry = this.sessions.get(sessionId);
3051
- if (!entry) return;
3052
- const ordered = [...topics].sort(topicOrder2);
3053
- const kept = [];
3054
- let used = 0;
3055
- for (let i = ordered.length - 1; i >= 0; i--) {
3056
- const topic = normalizeTopic(ordered[i]);
3057
- if (truncate && used + topic.tokens > this.config.compressedContextTokenLimit && kept.length > 0) break;
3058
- kept.push(topic);
3059
- used += topic.tokens;
3060
- }
3061
- entry.topics = kept.reverse();
3062
- entry.topicTokens = used;
3063
- entry.lastAccessAt = Date.now();
3064
- this.topicViews.delete(sessionId);
3065
- }
3066
- appendTopic(sessionId, topic) {
3067
- const entry = this.sessions.get(sessionId);
3068
- if (!entry) return;
3069
- this.setTopics(sessionId, [...entry.topics, topic], false);
3070
- }
3071
- replaceTopics(sessionId, removedIds, replacement) {
3072
- const entry = this.sessions.get(sessionId);
3073
- if (!entry) return;
3074
- const ids = new Set(removedIds);
3075
- this.setTopics(
3076
- sessionId,
3077
- [...entry.topics.filter((topic) => !ids.has(topic.summaryId)), replacement],
3078
- false
3079
- );
3080
- }
3081
- getTopics(sessionId) {
3082
- return this.getEntry(sessionId)?.topics ?? [];
3083
- }
3084
- getTopicView(sessionId, tokenLimit) {
3085
- return this.topicViews.get(sessionId)?.get(tokenLimit);
3472
+ title = d?.title ?? "";
3473
+ }
3474
+ return { docId, title, matchedChunkCount };
3475
+ })
3476
+ );
3477
+ const graphHits = await this.searchGraph(mode, vector, scope, scopeId, scored);
3478
+ return { chunks: scored, documents, graphHits };
3086
3479
  }
3087
- setTopicView(sessionId, tokenLimit, topics) {
3088
- let views = this.topicViews.get(sessionId);
3089
- if (!views) {
3090
- views = /* @__PURE__ */ new Map();
3091
- this.topicViews.set(sessionId, views);
3480
+ async searchGraph(mode, vector, scope, scopeId, topChunks) {
3481
+ if (mode === "fast") return [];
3482
+ if (mode === "auto") {
3483
+ const top = topChunks[0];
3484
+ if (!top || top.score < this.config.knowledgeGraphTriggerScore) return [];
3092
3485
  }
3093
- views.set(tokenLimit, [...topics].map(normalizeTopic).sort(topicOrder2));
3094
- }
3095
- buildCompressedContext(sessionId, topics) {
3096
- return (topics ?? this.getTopics(sessionId)).map((topic) => topic.title ? `## ${topic.title}
3097
- ${topic.summary}` : topic.summary).join("\n\n");
3486
+ const grafeoFilter = {
3487
+ kind: KIND_KNOWLEDGE
3488
+ };
3489
+ if (scope === "user" && scopeId) grafeoFilter.userId = scopeId;
3490
+ if (scope === "chat" && scopeId) grafeoFilter.chatId = scopeId;
3491
+ if (scope === "session" && scopeId) grafeoFilter.sessionId = scopeId;
3492
+ const entities = await this.grafeo.searchEntities(vector, grafeoFilter, this.config.knowledgeGraphEntityTopK).catch(() => []);
3493
+ if (entities.length === 0) return entities;
3494
+ const anchorCount = Math.min(this.config.knowledgeGraphAnchorTopK, entities.length);
3495
+ const relatedArrays = await Promise.all(
3496
+ entities.slice(0, anchorCount).map((e) => {
3497
+ const name = e.meta?.name ?? "";
3498
+ if (!name) return Promise.resolve([]);
3499
+ return this.grafeo.getRelatedEntities(
3500
+ name,
3501
+ grafeoFilter.userId ?? e.meta?.userId ?? "",
3502
+ grafeoFilter.chatId ?? e.meta?.chatId ?? "",
3503
+ this.config.knowledgeGraphHopLimit,
3504
+ KIND_KNOWLEDGE
3505
+ ).catch(() => []);
3506
+ })
3507
+ );
3508
+ return dedupByContent([...entities, ...relatedArrays.flat()]);
3098
3509
  }
3099
- /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
3100
- buildHistoryWindow(sessionId, groups) {
3101
- const [detailCount, summaryCount, conciseCount] = this.config.topicRatio;
3102
- const values = [
3103
- ...groups.detail.slice(0, detailCount).map((topic) => topic.detail ?? topic.summary),
3104
- ...groups.summary.slice(0, summaryCount).map((topic) => topic.summary),
3105
- ...groups.concise.slice(0, conciseCount).map((topic) => topic.concise ?? topic.summary)
3106
- ];
3107
- let remaining = this.config.historyWindowTokenLimit;
3108
- const kept = [];
3109
- for (const value of values) {
3110
- const tokens = countTokens(value);
3111
- if (tokens > remaining) break;
3112
- kept.push(value);
3113
- remaining -= tokens;
3510
+ /**
3511
+ * 构建片段表的过滤条件。
3512
+ * - chunkRedundantIds=true:直接在 chunks 行的 user_id/chat_id/session_id 上过滤
3513
+ * - false:先按域查 documents 得到 docId,再用 doc_id IN(...) 过滤
3514
+ * 返回 undefined 表示不过滤;返回 NO_MATCH 表示无候选(应直接返回空结果)。
3515
+ */
3516
+ async buildChunkFilter(scope, scopeId, candidateDocIds) {
3517
+ const parts = [];
3518
+ if (this.config.chunkRedundantIds) {
3519
+ const domain = buildDomainFilter(scope, scopeId);
3520
+ if (domain) parts.push(...domain);
3521
+ if (candidateDocIds) parts.push({ op: "in", field: "doc_id", values: candidateDocIds });
3522
+ } else {
3523
+ let docIds = candidateDocIds;
3524
+ if (scope !== "all") {
3525
+ const domain = buildDomainFilter(scope, scopeId);
3526
+ const domainDocIds = await this.store.getDocIdsByDomain(domain);
3527
+ docIds = docIds ? domainDocIds.filter((id) => docIds.includes(id)) : domainDocIds;
3528
+ }
3529
+ if (docIds) {
3530
+ if (docIds.length === 0) return NO_MATCH;
3531
+ parts.push({ op: "in", field: "doc_id", values: docIds });
3532
+ }
3114
3533
  }
3115
- this.legacyHistoryWindows.set(sessionId, kept.join("\n\n"));
3116
- }
3117
- /** @deprecated MemoryManager.getHistoryWindow 返回结构化窗口。 */
3118
- getHistoryWindow(sessionId) {
3119
- return this.legacyHistoryWindows.get(sessionId) ?? this.buildCompressedContext(sessionId);
3120
- }
3121
- /** @deprecated 仅为 0.3.x 兼容。 */
3122
- setHistoryWindow(sessionId, content) {
3123
- this.legacyHistoryWindows.set(sessionId, content);
3124
- }
3125
- setModelContextTokens(sessionId, modelContextTokens) {
3126
- const entry = this.sessions.get(sessionId);
3127
- if (!entry) return;
3128
- entry.lastModelContextTokens = modelContextTokens;
3129
- entry.lastAccessAt = Date.now();
3534
+ return parts.length > 0 ? parts : void 0;
3130
3535
  }
3131
- getAllSessionIds() {
3132
- return [...this.sessions.keys()];
3536
+ // ── 读取 / 删除 ─────────────────────────────────────────────────────────────────
3537
+ async getDocument(docId) {
3538
+ return this.store.getDocument(docId);
3133
3539
  }
3134
- delete(sessionId) {
3135
- this.sessions.delete(sessionId);
3136
- this.topicViews.delete(sessionId);
3137
- this.legacyHistoryWindows.delete(sessionId);
3540
+ async deleteDocument(docId) {
3541
+ const existing = await this.store.getDocument(docId);
3542
+ if (!existing) return false;
3543
+ await this.store.deleteDocument(docId);
3544
+ await this.store.deleteChunksByDoc(docId).catch((err) => {
3545
+ console.error(`[KnowledgeManager] Failed to delete chunks for doc ${docId}:`, err);
3546
+ });
3547
+ await this.grafeo.deleteKnowledgeByDoc(docId).catch((err) => {
3548
+ console.error(`[KnowledgeManager] Failed to delete knowledge graph for doc ${docId}:`, err);
3549
+ });
3550
+ return true;
3138
3551
  }
3139
- evictIdle(now, ttlMs, isBusy) {
3140
- if (ttlMs <= 0) return [];
3141
- const evicted = [];
3142
- for (const [sessionId, entry] of this.sessions) {
3143
- if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
3144
- this.sessions.delete(sessionId);
3145
- this.topicViews.delete(sessionId);
3146
- evicted.push(sessionId);
3147
- }
3148
- return evicted;
3552
+ async listDocuments(filter, page) {
3553
+ let docs = await this.store.getAllDocuments();
3554
+ if (filter?.userId) docs = docs.filter((d) => d.userId === filter.userId);
3555
+ if (filter?.chatId) docs = docs.filter((d) => d.chatId === filter.chatId);
3556
+ if (filter?.sessionId) docs = docs.filter((d) => d.sessionId === filter.sessionId);
3557
+ return page ? paginate(docs, page) : docs;
3149
3558
  }
3150
3559
  };
3151
- function effectiveUsage(message) {
3152
- if (Number.isFinite(message.usage) && message.usage > 0) return Math.floor(message.usage);
3153
- return Math.max(
3154
- 1,
3155
- (message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0) + (message.contextPayload?.length ?? 0)) * 2
3156
- );
3157
- }
3158
- function sumMessageTokens(messages) {
3159
- return messages.reduce((sum, message) => sum + effectiveUsage(message), 0);
3560
+ var NO_MATCH = Symbol("no-match");
3561
+ function dedupByContent(items) {
3562
+ const seen = /* @__PURE__ */ new Set();
3563
+ const out = [];
3564
+ for (const item of items) {
3565
+ if (seen.has(item.content)) continue;
3566
+ seen.add(item.content);
3567
+ out.push(item);
3568
+ }
3569
+ return out;
3160
3570
  }
3161
- function dedupeMessages(messages) {
3162
- return [...new Map(messages.map((message) => [message.messageId, message])).values()];
3571
+ function buildDomainFilter(scope, scopeId) {
3572
+ if (scope === "session" && scopeId) return [eq("session_id", scopeId)];
3573
+ if (scope === "chat" && scopeId) return [eq("chat_id", scopeId)];
3574
+ if (scope === "user" && scopeId) return [eq("user_id", scopeId)];
3575
+ return void 0;
3163
3576
  }
3164
- function sortMessages(messages) {
3165
- return messages.sort(
3166
- (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
3167
- );
3577
+ function inferTitle(markdown) {
3578
+ const lines = markdown.split(/\r?\n/);
3579
+ for (const line of lines) {
3580
+ const m = /^(#{1,6})\s+(.*)$/.exec(line);
3581
+ if (m && m[2].trim()) return m[2].trim().slice(0, 120);
3582
+ }
3583
+ for (const line of lines) {
3584
+ const t = line.trim();
3585
+ if (t) return t.slice(0, 120);
3586
+ }
3587
+ return void 0;
3168
3588
  }
3169
- function topicOrder2(a, b) {
3170
- return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
3589
+ async function mapLimit2(items, limit, mapper) {
3590
+ const results = new Array(items.length);
3591
+ let next = 0;
3592
+ const workers = Array.from({ length: Math.min(Math.max(1, limit), items.length) }, async () => {
3593
+ while (true) {
3594
+ const index = next++;
3595
+ if (index >= items.length) return;
3596
+ results[index] = await mapper(items[index], index);
3597
+ }
3598
+ });
3599
+ await Promise.all(workers);
3600
+ return results;
3171
3601
  }
3172
- function normalizeTopic(topic) {
3173
- const summary = topic.summary || topic.detail || topic.concise || "";
3174
- return {
3175
- ...topic,
3176
- summary,
3177
- tokens: topic.tokens > 0 ? topic.tokens : Math.max(1, countTokens(summary))
3178
- };
3602
+ function withTimeout2(promise, timeoutMs, label) {
3603
+ let timer;
3604
+ const timeout = new Promise((_, reject) => {
3605
+ timer = setTimeout(() => reject(new Error(`${label} timed out after ${timeoutMs}ms`)), timeoutMs);
3606
+ });
3607
+ return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
3179
3608
  }
3180
3609
 
3181
3610
  // src/memory.manager.ts
@@ -3194,10 +3623,12 @@ var MemoryManager = class {
3194
3623
  sessionSweepTimer;
3195
3624
  optimizeRunning = false;
3196
3625
  optimizeTask;
3626
+ startupMaintenanceTask;
3197
3627
  destroyTask;
3198
3628
  hydration = /* @__PURE__ */ new Map();
3199
3629
  pendingWrites = /* @__PURE__ */ new Map();
3200
3630
  topicCompactions = /* @__PURE__ */ new Map();
3631
+ backgroundCompressions = /* @__PURE__ */ new Map();
3201
3632
  warnedDefaultModelContext = false;
3202
3633
  constructor(config) {
3203
3634
  this.config = resolveConfig(config);
@@ -3252,6 +3683,73 @@ var MemoryManager = class {
3252
3683
  }, this.config.sessionSweepIntervalMs);
3253
3684
  this.sessionSweepTimer.unref?.();
3254
3685
  }
3686
+ const sessionIds = allSessions.map((session) => session.sessionId);
3687
+ const maintenance = this.runStartupMaintenance(sessionIds).catch((error) => {
3688
+ console.warn("[MemoryManager] startup maintenance failed (will retry next init):", error);
3689
+ });
3690
+ this.startupMaintenanceTask = maintenance;
3691
+ void maintenance.finally(() => {
3692
+ if (this.startupMaintenanceTask === maintenance) this.startupMaintenanceTask = void 0;
3693
+ });
3694
+ }
3695
+ async runStartupMaintenance(sessionIds) {
3696
+ if (this.config.autoMigrateLegacyDataOnInit) await this.migrateLegacyDataOnce();
3697
+ if (!this.config.autoResumeCompressionOnInit) return;
3698
+ sessionIds = [.../* @__PURE__ */ new Set([...sessionIds, ...await this.store.getAllSessionIds()])];
3699
+ if (sessionIds.length === 0) return;
3700
+ let cursor = 0;
3701
+ const workerCount = Math.min(this.config.restoreConcurrency, sessionIds.length);
3702
+ await Promise.all(Array.from({ length: workerCount }, async () => {
3703
+ while (cursor < sessionIds.length) {
3704
+ const sessionId = sessionIds[cursor++];
3705
+ const rawLimit = this.calculateWindowUsage(
3706
+ this.config.defaultModelContextTokens
3707
+ ).rawTokenLimit;
3708
+ const rawTokens = await this.getPersistedRawTailTokens(sessionId);
3709
+ if (rawTokens >= Math.floor(rawLimit * this.config.precompressionRatio)) {
3710
+ this.scheduleBackgroundCompression(sessionId, rawLimit, "startup");
3711
+ }
3712
+ }
3713
+ }));
3714
+ }
3715
+ async getPersistedRawTailTokens(sessionId) {
3716
+ const topics = await this.store.getTopicsBySession(sessionId);
3717
+ const latestTopic = topics.at(-1);
3718
+ const messages = latestTopic ? await this.store.getMessagesAfterBoundary(
3719
+ sessionId,
3720
+ latestTopic.endTime,
3721
+ latestTopic.endMessageId
3722
+ ) : await this.store.getAllMessagesBySession(sessionId);
3723
+ const since = this.config.maxHistoryAgeMs > 0 ? Date.now() - this.config.maxHistoryAgeMs : 0;
3724
+ return messages.reduce(
3725
+ (sum, message) => sum + (since === 0 || message.createdAt >= since ? effectiveUsage(message) : 0),
3726
+ 0
3727
+ );
3728
+ }
3729
+ async migrateLegacyDataOnce() {
3730
+ const marker = this.legacyMigrationMarkerPath();
3731
+ try {
3732
+ await fs.access(marker);
3733
+ return;
3734
+ } catch (error) {
3735
+ if (!isMissingFileError(error)) throw error;
3736
+ }
3737
+ const report = await this.store.migrateLegacyData();
3738
+ await fs.mkdir(path2.dirname(marker), { recursive: true });
3739
+ await fs.writeFile(marker, JSON.stringify({ completedAt: Date.now(), report }), {
3740
+ encoding: "utf8",
3741
+ flag: "wx"
3742
+ }).catch((error) => {
3743
+ if (!isExistingFileError(error)) throw error;
3744
+ });
3745
+ if (report.messagesUpdated > 0) {
3746
+ console.info(
3747
+ `[MemoryManager] startup migration repaired ${report.messagesUpdated} historical message(s).`
3748
+ );
3749
+ }
3750
+ }
3751
+ legacyMigrationMarkerPath() {
3752
+ return this.store.providerKind === "sqlite" ? `${this.config.sqlitePath}.ppagent-memory-v04-migrated` : path2.join(this.config.lancedbPath, ".ppagent-memory-v04-migrated");
3255
3753
  }
3256
3754
  /** 后台压实的统一入口:防重入(上一轮未结束则跳过),失败仅告警不影响服务。*/
3257
3755
  async runBackgroundOptimize(retentionMs) {
@@ -3286,11 +3784,20 @@ var MemoryManager = class {
3286
3784
  task = (async () => {
3287
3785
  const allTopics = await this.store.getTopicsBySession(sessionId);
3288
3786
  const latestTopic = allTopics.at(-1);
3289
- const allRawAfterBoundary = latestTopic ? await this.store.getMessagesAfterBoundary(
3787
+ const loadedRawAfterBoundary = latestTopic ? await this.store.getMessagesAfterBoundary(
3290
3788
  sessionId,
3291
3789
  latestTopic.endTime,
3292
3790
  latestTopic.endMessageId
3293
3791
  ) : await this.store.getAllMessagesBySession(sessionId);
3792
+ const sanitizedRows = loadedRawAfterBoundary.map(sanitizeStoredMessageImages);
3793
+ const repaired = sanitizedRows.filter((row) => row.changed);
3794
+ if (repaired.length > 0) {
3795
+ await this.store.upsertMessages(repaired.map((row) => row.message));
3796
+ console.info(
3797
+ `[MemoryManager] stripped ${repaired.reduce((sum, row) => sum + row.removedImages, 0)} historical image block(s) from ${repaired.length} message(s) in session ${sessionId}.`
3798
+ );
3799
+ }
3800
+ const allRawAfterBoundary = sanitizedRows.map((row) => row.message);
3294
3801
  const since = this.config.maxHistoryAgeMs > 0 ? Date.now() - this.config.maxHistoryAgeMs : 0;
3295
3802
  const rawMessages = since > 0 ? allRawAfterBoundary.filter((message) => message.createdAt >= since) : allRawAfterBoundary;
3296
3803
  const session = this.sessionMap.get(sessionId);
@@ -3312,7 +3819,7 @@ var MemoryManager = class {
3312
3819
  await task;
3313
3820
  }
3314
3821
  isSessionBusy(sessionId) {
3315
- return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || [...this.topicCompactions.keys()].some((key) => key.startsWith(`${sessionId}:`)) || this.compressManager.isBusy(sessionId);
3822
+ return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || this.backgroundCompressions.has(sessionId) || [...this.topicCompactions.keys()].some((key) => key.startsWith(`${sessionId}:`)) || this.compressManager.isBusy(sessionId);
3316
3823
  }
3317
3824
  waitForPendingWrites(sessionId) {
3318
3825
  return this.pendingWrites.get(sessionId) ?? Promise.resolve();
@@ -3329,6 +3836,77 @@ var MemoryManager = class {
3329
3836
  });
3330
3837
  return task;
3331
3838
  }
3839
+ scheduleBackgroundCompression(sessionId, rawLimit, mode) {
3840
+ if (this.backgroundCompressions.has(sessionId)) return;
3841
+ const maxAttempts = this.config.backgroundCompressionMaxRetries + 1;
3842
+ const task = (async () => {
3843
+ for (let round = 0; round < 100; round++) {
3844
+ await this.ensureSessionHydrated(sessionId);
3845
+ const entry = this.sessionCache.getEntry(sessionId);
3846
+ const threshold = Math.max(1, Math.floor(rawLimit * this.config.precompressionRatio));
3847
+ if (!entry || entry.messages.length === 0 || entry.totalTokens < threshold) return;
3848
+ const beforeTokens = entry.totalTokens;
3849
+ let completed = false;
3850
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3851
+ try {
3852
+ await this.compressManager.triggerCompress(
3853
+ sessionId,
3854
+ false,
3855
+ false,
3856
+ rawLimit,
3857
+ mode
3858
+ );
3859
+ completed = true;
3860
+ break;
3861
+ } catch (error) {
3862
+ if (attempt >= maxAttempts) {
3863
+ console.error(
3864
+ `[MemoryManager] Background compress exhausted ${maxAttempts} attempt(s) for ${sessionId}:`,
3865
+ error
3866
+ );
3867
+ return;
3868
+ }
3869
+ const delayMs = Math.min(
3870
+ this.config.backgroundCompressionRetryBaseDelayMs * 2 ** (attempt - 1),
3871
+ 6e4
3872
+ );
3873
+ emitCompressionEvent(this.config, {
3874
+ sessionId,
3875
+ kind: "raw",
3876
+ mode,
3877
+ phase: "retry",
3878
+ attempt: attempt + 1,
3879
+ maxAttempts,
3880
+ retryKind: "task",
3881
+ error: formatError2(error)
3882
+ });
3883
+ if (delayMs > 0) await unrefDelay(delayMs);
3884
+ }
3885
+ }
3886
+ if (!completed) return;
3887
+ const after = this.sessionCache.getEntry(sessionId);
3888
+ if (!after || after.messages.length === 0 || after.totalTokens < threshold) return;
3889
+ if (after.totalTokens >= beforeTokens) {
3890
+ console.error(`[MemoryManager] Background compression made no progress for ${sessionId}.`);
3891
+ return;
3892
+ }
3893
+ }
3894
+ console.error(`[MemoryManager] Background compression exceeded 100 batches for ${sessionId}.`);
3895
+ })();
3896
+ const settled = task.catch(() => {
3897
+ });
3898
+ this.backgroundCompressions.set(sessionId, settled);
3899
+ void settled.finally(() => {
3900
+ if (this.backgroundCompressions.get(sessionId) === settled) {
3901
+ this.backgroundCompressions.delete(sessionId);
3902
+ }
3903
+ });
3904
+ }
3905
+ async waitForBackgroundCompressionIdle() {
3906
+ while (this.backgroundCompressions.size > 0) {
3907
+ await Promise.all([...this.backgroundCompressions.values()]);
3908
+ }
3909
+ }
3332
3910
  updateChat(messages, opts) {
3333
3911
  if (messages.length === 0) return Promise.resolve();
3334
3912
  const sessionId = opts?.sessionId ?? messages[0]?.sessionId ?? DEFAULT_SESSION_ID;
@@ -3340,10 +3918,11 @@ var MemoryManager = class {
3340
3918
  const now = Date.now();
3341
3919
  await this.ensureSessionHydrated(sessionId, { chatId, userId });
3342
3920
  const normalized = messages.map((message) => {
3343
- const parts = JSON.stringify(message.parts ?? []);
3344
- const metadata = JSON.stringify(message.metadata ?? {});
3345
- const payload = message.payload === void 0 ? void 0 : JSON.stringify(message.payload);
3346
- const contextPayload = message.contextPayload === void 0 ? void 0 : JSON.stringify(message.contextPayload);
3921
+ const sanitized = sanitizeRawHistoryFields(message);
3922
+ const parts = JSON.stringify(sanitized.parts);
3923
+ const metadata = JSON.stringify(addCompressionHints(sanitized.metadata, message));
3924
+ const payload = sanitized.payload === void 0 ? void 0 : JSON.stringify(sanitized.payload);
3925
+ const contextPayload = sanitized.contextPayload === void 0 ? void 0 : JSON.stringify(sanitized.contextPayload);
3347
3926
  const tokenInput = [message.content, parts, metadata, payload ?? "", contextPayload ?? ""].join("\n");
3348
3927
  return {
3349
3928
  messageId: message.messageId ?? uuidv44(),
@@ -3356,7 +3935,9 @@ var MemoryManager = class {
3356
3935
  parts,
3357
3936
  payload,
3358
3937
  contextPayload,
3359
- usage: message.usage ?? countTokens(tokenInput),
3938
+ // Host-provided usage describes the original payload. Once image bytes are
3939
+ // stripped, recalculate against the actual persisted history budget.
3940
+ usage: sanitized.removedImages > 0 ? countTokens(tokenInput) : message.usage ?? countTokens(tokenInput),
3360
3941
  metadata,
3361
3942
  createdAt: message.createdAt ?? now
3362
3943
  };
@@ -3393,9 +3974,7 @@ var MemoryManager = class {
3393
3974
  entry.lastModelContextTokens ?? this.config.defaultModelContextTokens
3394
3975
  ).rawTokenLimit;
3395
3976
  if (entry.totalTokens >= Math.floor(rawLimit * this.config.precompressionRatio)) {
3396
- void this.compressManager.triggerCompress(sessionId, false, false, rawLimit).catch((error) => {
3397
- console.error(`[MemoryManager] Background compress failed for ${sessionId}:`, error);
3398
- });
3977
+ this.scheduleBackgroundCompression(sessionId, rawLimit, "background");
3399
3978
  }
3400
3979
  }
3401
3980
  async flushChat(sessionId, opts) {
@@ -3410,7 +3989,8 @@ var MemoryManager = class {
3410
3989
  sid,
3411
3990
  true,
3412
3991
  opts?.waitGraph === true,
3413
- rawLimit
3992
+ rawLimit,
3993
+ "manual"
3414
3994
  );
3415
3995
  if (opts?.wait) await promise;
3416
3996
  }
@@ -3591,7 +4171,8 @@ var MemoryManager = class {
3591
4171
  sessionId,
3592
4172
  true,
3593
4173
  false,
3594
- usageLimits.rawTokenLimit
4174
+ usageLimits.rawTokenLimit,
4175
+ "blocking"
3595
4176
  );
3596
4177
  const after = this.sessionCache.getEntry(sessionId);
3597
4178
  if (after.messages.length > 0 && after.totalTokens >= beforeTokens) {
@@ -3614,7 +4195,11 @@ var MemoryManager = class {
3614
4195
  if (affectsCurrentWindow || materiallyOverLimit) {
3615
4196
  if (this.compressManager.isBusy(sessionId)) beginBlocking("pending");
3616
4197
  else beginBlocking("topics");
3617
- await this.compactTopicsForBudget(sessionId, usageLimits.compressedTokenLimit);
4198
+ await this.compactTopicsForBudget(
4199
+ sessionId,
4200
+ usageLimits.compressedTokenLimit,
4201
+ "blocking"
4202
+ );
3618
4203
  entry = this.sessionCache.getEntry(sessionId);
3619
4204
  topicView = this.sessionCache.getTopicView(
3620
4205
  sessionId,
@@ -3623,7 +4208,8 @@ var MemoryManager = class {
3623
4208
  } else {
3624
4209
  void this.compactTopicsForBudget(
3625
4210
  sessionId,
3626
- usageLimits.compressedTokenLimit
4211
+ usageLimits.compressedTokenLimit,
4212
+ "background"
3627
4213
  ).catch((error) => {
3628
4214
  console.error(`[MemoryManager] Background Topic compaction failed for ${sessionId}:`, error);
3629
4215
  });
@@ -3655,7 +4241,7 @@ var MemoryManager = class {
3655
4241
  }
3656
4242
  }
3657
4243
  }
3658
- compactTopicsForBudget(sessionId, effectiveLimit) {
4244
+ compactTopicsForBudget(sessionId, effectiveLimit, mode) {
3659
4245
  const key = `${sessionId}:${effectiveLimit}`;
3660
4246
  const existing = this.topicCompactions.get(key);
3661
4247
  if (existing) return existing;
@@ -3666,12 +4252,18 @@ var MemoryManager = class {
3666
4252
  await this.compressManager.triggerTopicCompaction(
3667
4253
  sessionId,
3668
4254
  this.config.compressedContextTokenLimit,
3669
- true
4255
+ true,
4256
+ mode
3670
4257
  );
3671
4258
  entry = this.sessionCache.getEntry(sessionId);
3672
4259
  }
3673
4260
  if (entry && entry.topicTokens > effectiveLimit && !this.sessionCache.getTopicView(sessionId, effectiveLimit)) {
3674
- await this.compressManager.triggerTopicCompaction(sessionId, effectiveLimit, false);
4261
+ await this.compressManager.triggerTopicCompaction(
4262
+ sessionId,
4263
+ effectiveLimit,
4264
+ false,
4265
+ mode
4266
+ );
3675
4267
  }
3676
4268
  })();
3677
4269
  this.topicCompactions.set(key, task);
@@ -3878,12 +4470,13 @@ var MemoryManager = class {
3878
4470
  clearInterval(this.sessionSweepTimer);
3879
4471
  this.sessionSweepTimer = void 0;
3880
4472
  }
3881
- const writes = [
4473
+ this.destroyTask = Promise.resolve(this.startupMaintenanceTask).catch(() => {
4474
+ }).then(() => Promise.all([
3882
4475
  ...this.pendingWrites.values(),
3883
4476
  ...this.hydration.values(),
3884
4477
  ...this.topicCompactions.values()
3885
- ];
3886
- this.destroyTask = Promise.all(writes).catch(() => {
4478
+ ])).catch(() => {
4479
+ }).then(() => this.waitForBackgroundCompressionIdle()).catch(() => {
3887
4480
  }).then(() => this.compressManager.waitForAllIdle()).catch(() => {
3888
4481
  }).then(() => this.optimizeTask).catch(() => {
3889
4482
  }).then(async () => {
@@ -3895,23 +4488,31 @@ var MemoryManager = class {
3895
4488
  }
3896
4489
  };
3897
4490
  function toMemoryRawMessage(message) {
3898
- const parts = safeParseArray(message.parts);
3899
- const payload = message.payload === void 0 ? void 0 : safeParseValue(message.payload);
3900
- const contextPayload = message.contextPayload === void 0 ? void 0 : safeParseValue(message.contextPayload);
4491
+ const sanitized = sanitizeStoredMessageImages(message).message;
4492
+ const parts = safeParseArray(sanitized.parts);
4493
+ const payload = sanitized.payload === void 0 ? void 0 : safeParseValue(sanitized.payload);
4494
+ const contextPayload = sanitized.contextPayload === void 0 ? void 0 : safeParseValue(sanitized.contextPayload);
4495
+ const hints = readCompressionHints(sanitized);
3901
4496
  return {
3902
- messageId: message.messageId,
3903
- talkerId: message.talkerId,
3904
- chatId: message.chatId,
3905
- userId: message.userId,
3906
- sessionId: message.sessionId,
3907
- type: message.type,
3908
- content: message.content,
4497
+ messageId: sanitized.messageId,
4498
+ talkerId: sanitized.talkerId,
4499
+ chatId: sanitized.chatId,
4500
+ userId: sanitized.userId,
4501
+ sessionId: sanitized.sessionId,
4502
+ type: sanitized.type,
4503
+ content: sanitized.content,
3909
4504
  ...parts.length > 0 && { parts },
3910
4505
  ...payload !== void 0 && { payload },
3911
4506
  ...contextPayload !== void 0 && { contextPayload },
3912
- usage: message.usage,
3913
- metadata: safeParseObject2(message.metadata),
3914
- createdAt: message.createdAt
4507
+ ...hints.compressionGroupId !== void 0 && {
4508
+ compressionGroupId: hints.compressionGroupId
4509
+ },
4510
+ ...hints.explicitCompressionRole !== void 0 && {
4511
+ compressionRole: hints.explicitCompressionRole
4512
+ },
4513
+ usage: sanitized.usage,
4514
+ metadata: hints.metadata,
4515
+ createdAt: sanitized.createdAt
3915
4516
  };
3916
4517
  }
3917
4518
  function buildCompressionRevision(topics, messages) {
@@ -3950,6 +4551,21 @@ function safeParseValue(value) {
3950
4551
  return value;
3951
4552
  }
3952
4553
  }
4554
+ function formatError2(error) {
4555
+ return error instanceof Error ? error.message : String(error);
4556
+ }
4557
+ function isMissingFileError(error) {
4558
+ return error?.code === "ENOENT";
4559
+ }
4560
+ function isExistingFileError(error) {
4561
+ return error?.code === "EEXIST";
4562
+ }
4563
+ function unrefDelay(ms) {
4564
+ return new Promise((resolve) => {
4565
+ const timer = setTimeout(resolve, ms);
4566
+ timer.unref?.();
4567
+ });
4568
+ }
3953
4569
  export {
3954
4570
  DEFAULT_NODE_TYPES,
3955
4571
  DEFAULT_RELATION_TYPES,