@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/cli.js +176 -9
- package/dist/index.d.ts +54 -3
- package/dist/index.js +1119 -503
- package/llms.txt +31 -16
- package/package.json +1 -1
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.
|
|
51
|
-
compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.
|
|
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
|
-
|
|
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
|
-
|
|
1270
|
-
|
|
1271
|
-
|
|
1272
|
-
|
|
1273
|
-
|
|
1461
|
+
sanitized.content,
|
|
1462
|
+
sanitized.parts,
|
|
1463
|
+
sanitized.metadata,
|
|
1464
|
+
sanitized.payload ?? "",
|
|
1465
|
+
sanitized.contextPayload ?? ""
|
|
1274
1466
|
].map(String).join("\n")
|
|
1275
1467
|
);
|
|
1276
|
-
|
|
1277
|
-
|
|
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 (!
|
|
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
|
|
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/
|
|
2131
|
-
var
|
|
2132
|
-
|
|
2133
|
-
|
|
2134
|
-
|
|
2135
|
-
|
|
2136
|
-
}
|
|
2137
|
-
|
|
2138
|
-
|
|
2139
|
-
|
|
2140
|
-
|
|
2141
|
-
|
|
2142
|
-
|
|
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
|
-
|
|
2146
|
-
|
|
2147
|
-
|
|
2148
|
-
|
|
2149
|
-
|
|
2150
|
-
|
|
2151
|
-
|
|
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
|
-
|
|
2155
|
-
const
|
|
2156
|
-
if (
|
|
2157
|
-
|
|
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
|
-
|
|
2415
|
+
entry.ids = { chatId, userId };
|
|
2416
|
+
entry.lastAccessAt = Date.now();
|
|
2160
2417
|
}
|
|
2418
|
+
return entry;
|
|
2161
2419
|
}
|
|
2162
|
-
|
|
2163
|
-
|
|
2164
|
-
|
|
2165
|
-
|
|
2166
|
-
|
|
2167
|
-
|
|
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
|
-
|
|
2176
|
-
|
|
2177
|
-
|
|
2178
|
-
|
|
2179
|
-
|
|
2180
|
-
|
|
2181
|
-
|
|
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
|
-
/**
|
|
2185
|
-
|
|
2186
|
-
|
|
2187
|
-
|
|
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
|
-
|
|
2192
|
-
|
|
2193
|
-
const
|
|
2194
|
-
|
|
2195
|
-
);
|
|
2196
|
-
|
|
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
|
-
|
|
2205
|
-
|
|
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
|
-
|
|
2208
|
-
return this.
|
|
2455
|
+
getSessionMessages(sessionId) {
|
|
2456
|
+
return this.getEntry(sessionId)?.messages ?? [];
|
|
2209
2457
|
}
|
|
2210
|
-
|
|
2211
|
-
|
|
2212
|
-
|
|
2213
|
-
|
|
2214
|
-
|
|
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
|
-
|
|
2219
|
-
const entry = this.
|
|
2220
|
-
if (!entry
|
|
2221
|
-
const
|
|
2222
|
-
const
|
|
2223
|
-
|
|
2224
|
-
let
|
|
2225
|
-
|
|
2226
|
-
|
|
2227
|
-
|
|
2228
|
-
|
|
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
|
-
|
|
2240
|
-
|
|
2241
|
-
|
|
2242
|
-
|
|
2243
|
-
|
|
2244
|
-
|
|
2245
|
-
const
|
|
2246
|
-
|
|
2247
|
-
|
|
2248
|
-
|
|
2249
|
-
|
|
2250
|
-
|
|
2251
|
-
|
|
2252
|
-
|
|
2253
|
-
|
|
2254
|
-
|
|
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.
|
|
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(
|
|
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
|
|
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
|
-
|
|
3088
|
-
|
|
3089
|
-
if (
|
|
3090
|
-
|
|
3091
|
-
this.
|
|
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
|
-
|
|
3094
|
-
|
|
3095
|
-
|
|
3096
|
-
|
|
3097
|
-
|
|
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
|
-
/**
|
|
3100
|
-
|
|
3101
|
-
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
];
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3110
|
-
|
|
3111
|
-
|
|
3112
|
-
|
|
3113
|
-
|
|
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
|
-
|
|
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
|
-
|
|
3132
|
-
|
|
3536
|
+
// ── 读取 / 删除 ─────────────────────────────────────────────────────────────────
|
|
3537
|
+
async getDocument(docId) {
|
|
3538
|
+
return this.store.getDocument(docId);
|
|
3133
3539
|
}
|
|
3134
|
-
|
|
3135
|
-
this.
|
|
3136
|
-
|
|
3137
|
-
this.
|
|
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
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
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
|
-
|
|
3152
|
-
|
|
3153
|
-
|
|
3154
|
-
|
|
3155
|
-
|
|
3156
|
-
|
|
3157
|
-
|
|
3158
|
-
|
|
3159
|
-
|
|
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
|
|
3162
|
-
|
|
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
|
|
3165
|
-
|
|
3166
|
-
|
|
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
|
|
3170
|
-
|
|
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
|
|
3173
|
-
|
|
3174
|
-
|
|
3175
|
-
|
|
3176
|
-
|
|
3177
|
-
|
|
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
|
|
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
|
|
3344
|
-
const
|
|
3345
|
-
const
|
|
3346
|
-
const
|
|
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
|
|
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
|
-
|
|
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(
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
3899
|
-
const
|
|
3900
|
-
const
|
|
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:
|
|
3903
|
-
talkerId:
|
|
3904
|
-
chatId:
|
|
3905
|
-
userId:
|
|
3906
|
-
sessionId:
|
|
3907
|
-
type:
|
|
3908
|
-
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
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
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,
|