@ppagent/memory 0.4.2 → 0.4.5
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 +168 -4
- package/dist/index.d.ts +19 -1
- package/dist/index.js +308 -35
- package/llms.txt +18 -10
- package/package.json +1 -1
package/dist/cli.js
CHANGED
|
@@ -52,8 +52,16 @@ function resolveConfig(config) {
|
|
|
52
52
|
),
|
|
53
53
|
contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
|
|
54
54
|
precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
|
|
55
|
-
compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.
|
|
55
|
+
compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.7, "compressionBatchRatio"),
|
|
56
56
|
compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
|
|
57
|
+
compressionMaxRetries: nonNegativeInt(
|
|
58
|
+
config.compressionMaxRetries ?? 2,
|
|
59
|
+
"compressionMaxRetries"
|
|
60
|
+
),
|
|
61
|
+
compressionRetryBaseDelayMs: nonNegativeInt(
|
|
62
|
+
config.compressionRetryBaseDelayMs ?? 500,
|
|
63
|
+
"compressionRetryBaseDelayMs"
|
|
64
|
+
),
|
|
57
65
|
topicSummaryMaxTokens: positiveInt(
|
|
58
66
|
config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
|
|
59
67
|
"topicSummaryMaxTokens"
|
|
@@ -101,6 +109,12 @@ function ratio(value, name) {
|
|
|
101
109
|
}
|
|
102
110
|
return value;
|
|
103
111
|
}
|
|
112
|
+
function nonNegativeInt(value, name) {
|
|
113
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
114
|
+
throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u975E\u8D1F\u6570`);
|
|
115
|
+
}
|
|
116
|
+
return Math.floor(value);
|
|
117
|
+
}
|
|
104
118
|
function positiveInt(value, name) {
|
|
105
119
|
if (!Number.isFinite(value) || value <= 0) {
|
|
106
120
|
throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u6B63\u6570`);
|
|
@@ -134,6 +148,136 @@ function countTokens(text) {
|
|
|
134
148
|
return encoder.encode(text).length;
|
|
135
149
|
}
|
|
136
150
|
|
|
151
|
+
// src/history-image.ts
|
|
152
|
+
var OMIT_IMAGE = Symbol("omit-history-image");
|
|
153
|
+
var IMAGE_PART_TYPES = /* @__PURE__ */ new Set([
|
|
154
|
+
"image",
|
|
155
|
+
"image-data",
|
|
156
|
+
"image-url",
|
|
157
|
+
"image-file-id",
|
|
158
|
+
"image-file-reference",
|
|
159
|
+
"input-image",
|
|
160
|
+
"output-image"
|
|
161
|
+
]);
|
|
162
|
+
function sanitizeRawHistoryFields(message) {
|
|
163
|
+
const partsResult = sanitizeValue(message.parts ?? []);
|
|
164
|
+
const payloadResult = sanitizeValue(message.payload);
|
|
165
|
+
const contextResult = sanitizeValue(message.contextPayload);
|
|
166
|
+
const metadataResult = sanitizeValue(message.metadata ?? {});
|
|
167
|
+
const payload = optionalSanitizedValue(payloadResult);
|
|
168
|
+
const contextPayload = optionalSanitizedValue(contextResult);
|
|
169
|
+
return {
|
|
170
|
+
parts: Array.isArray(partsResult.value) ? partsResult.value : [],
|
|
171
|
+
...payload !== void 0 && { payload },
|
|
172
|
+
...contextPayload !== void 0 && { contextPayload },
|
|
173
|
+
metadata: isRecord(metadataResult.value) ? metadataResult.value : {},
|
|
174
|
+
removedImages: partsResult.removedImages + payloadResult.removedImages + contextResult.removedImages + metadataResult.removedImages
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
function sanitizeStoredMessageImages(message) {
|
|
178
|
+
const parsed = {
|
|
179
|
+
content: message.content,
|
|
180
|
+
parts: parseArray(message.parts),
|
|
181
|
+
...message.payload !== void 0 && { payload: parseValue(message.payload) },
|
|
182
|
+
...message.contextPayload !== void 0 && {
|
|
183
|
+
contextPayload: parseValue(message.contextPayload)
|
|
184
|
+
},
|
|
185
|
+
metadata: parseObject(message.metadata)
|
|
186
|
+
};
|
|
187
|
+
const sanitized = sanitizeRawHistoryFields(parsed);
|
|
188
|
+
if (sanitized.removedImages === 0) {
|
|
189
|
+
return { message, changed: false, removedImages: 0 };
|
|
190
|
+
}
|
|
191
|
+
const parts = JSON.stringify(sanitized.parts);
|
|
192
|
+
const payload = sanitized.payload === void 0 ? void 0 : JSON.stringify(sanitized.payload);
|
|
193
|
+
const contextPayload = sanitized.contextPayload === void 0 ? void 0 : JSON.stringify(sanitized.contextPayload);
|
|
194
|
+
const metadata = JSON.stringify(sanitized.metadata);
|
|
195
|
+
const usage2 = countTokens(
|
|
196
|
+
[message.content, parts, metadata, payload ?? "", contextPayload ?? ""].join("\n")
|
|
197
|
+
);
|
|
198
|
+
return {
|
|
199
|
+
message: {
|
|
200
|
+
...message,
|
|
201
|
+
parts,
|
|
202
|
+
payload,
|
|
203
|
+
contextPayload,
|
|
204
|
+
usage: usage2,
|
|
205
|
+
metadata
|
|
206
|
+
},
|
|
207
|
+
changed: true,
|
|
208
|
+
removedImages: sanitized.removedImages
|
|
209
|
+
};
|
|
210
|
+
}
|
|
211
|
+
function sanitizeValue(value) {
|
|
212
|
+
if (typeof value === "string" && /^data:image\//i.test(value.trim())) {
|
|
213
|
+
return { value: OMIT_IMAGE, removedImages: 1, changed: true };
|
|
214
|
+
}
|
|
215
|
+
if (value == null || typeof value !== "object") {
|
|
216
|
+
return { value, removedImages: 0, changed: false };
|
|
217
|
+
}
|
|
218
|
+
if (isImageNode(value)) {
|
|
219
|
+
return { value: OMIT_IMAGE, removedImages: 1, changed: true };
|
|
220
|
+
}
|
|
221
|
+
if (Array.isArray(value)) {
|
|
222
|
+
const out2 = [];
|
|
223
|
+
let removedImages2 = 0;
|
|
224
|
+
let changed2 = false;
|
|
225
|
+
for (const item of value) {
|
|
226
|
+
const sanitized = sanitizeValue(item);
|
|
227
|
+
removedImages2 += sanitized.removedImages;
|
|
228
|
+
changed2 ||= sanitized.changed;
|
|
229
|
+
if (sanitized.value !== OMIT_IMAGE) out2.push(sanitized.value);
|
|
230
|
+
}
|
|
231
|
+
return { value: changed2 ? out2 : value, removedImages: removedImages2, changed: changed2 };
|
|
232
|
+
}
|
|
233
|
+
const out = {};
|
|
234
|
+
let removedImages = 0;
|
|
235
|
+
let changed = false;
|
|
236
|
+
for (const [key, item] of Object.entries(value)) {
|
|
237
|
+
const sanitized = sanitizeValue(item);
|
|
238
|
+
removedImages += sanitized.removedImages;
|
|
239
|
+
changed ||= sanitized.changed;
|
|
240
|
+
if (sanitized.value !== OMIT_IMAGE) out[key] = sanitized.value;
|
|
241
|
+
}
|
|
242
|
+
return { value: changed ? out : value, removedImages, changed };
|
|
243
|
+
}
|
|
244
|
+
function isImageNode(value) {
|
|
245
|
+
if (!isRecord(value)) return false;
|
|
246
|
+
const type = normalizeType(value.type);
|
|
247
|
+
if (IMAGE_PART_TYPES.has(type)) return true;
|
|
248
|
+
const mediaType = String(value.mediaType ?? value.mimeType ?? "").trim().toLowerCase();
|
|
249
|
+
return mediaType.startsWith("image/") && (type === "file" || "data" in value || "url" in value || "fileId" in value || "providerReference" in value);
|
|
250
|
+
}
|
|
251
|
+
function normalizeType(value) {
|
|
252
|
+
return typeof value === "string" ? value.trim().toLowerCase().replaceAll("_", "-") : "";
|
|
253
|
+
}
|
|
254
|
+
function optionalSanitizedValue(result) {
|
|
255
|
+
if (result.value === OMIT_IMAGE) return void 0;
|
|
256
|
+
if (result.changed && isEmptyContainer(result.value)) return void 0;
|
|
257
|
+
return result.value;
|
|
258
|
+
}
|
|
259
|
+
function isEmptyContainer(value) {
|
|
260
|
+
return Array.isArray(value) ? value.length === 0 : isRecord(value) && Object.keys(value).length === 0;
|
|
261
|
+
}
|
|
262
|
+
function isRecord(value) {
|
|
263
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
264
|
+
}
|
|
265
|
+
function parseValue(value) {
|
|
266
|
+
try {
|
|
267
|
+
return JSON.parse(value);
|
|
268
|
+
} catch {
|
|
269
|
+
return value;
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
function parseArray(value) {
|
|
273
|
+
const parsed = parseValue(value);
|
|
274
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
275
|
+
}
|
|
276
|
+
function parseObject(value) {
|
|
277
|
+
const parsed = parseValue(value);
|
|
278
|
+
return isRecord(parsed) ? parsed : {};
|
|
279
|
+
}
|
|
280
|
+
|
|
137
281
|
// src/db/memory.store.ts
|
|
138
282
|
var MESSAGES_TABLE = "messages";
|
|
139
283
|
var TOPICS_TABLE = "topics";
|
|
@@ -159,6 +303,7 @@ function tableDefs(dim) {
|
|
|
159
303
|
{ name: "parts", type: "text", nullable: true },
|
|
160
304
|
// 兼容存量数据
|
|
161
305
|
{ name: "payload", type: "text", nullable: true },
|
|
306
|
+
{ name: "context_payload", type: "text", nullable: true },
|
|
162
307
|
{ name: "vector", type: "vector" },
|
|
163
308
|
{ name: "usage", type: "int" },
|
|
164
309
|
{ name: "metadata", type: "text" },
|
|
@@ -290,6 +435,7 @@ function messageToRow(m) {
|
|
|
290
435
|
content: m.content,
|
|
291
436
|
parts: m.parts ?? "[]",
|
|
292
437
|
payload: m.payload ?? null,
|
|
438
|
+
context_payload: m.contextPayload ?? null,
|
|
293
439
|
vector: m.vector,
|
|
294
440
|
usage: m.usage,
|
|
295
441
|
metadata: m.metadata,
|
|
@@ -308,6 +454,7 @@ function rowToMessage(r) {
|
|
|
308
454
|
parts: r.parts ?? "[]",
|
|
309
455
|
// 旧数据无此列时安全降级
|
|
310
456
|
payload: r.payload ?? void 0,
|
|
457
|
+
contextPayload: r.context_payload ?? void 0,
|
|
311
458
|
vector: toVector(r.vector),
|
|
312
459
|
usage: positiveNumber(r.usage),
|
|
313
460
|
metadata: r.metadata,
|
|
@@ -593,11 +740,28 @@ var MemoryStore = class {
|
|
|
593
740
|
const messageRows = await this.provider.query(MESSAGES_TABLE);
|
|
594
741
|
report.messagesScanned = messageRows.length;
|
|
595
742
|
for (const row of messageRows) {
|
|
743
|
+
const sanitized = sanitizeStoredMessageImages(rowToMessage(row)).message;
|
|
596
744
|
const usage2 = countTokens(
|
|
597
|
-
[
|
|
745
|
+
[
|
|
746
|
+
sanitized.content,
|
|
747
|
+
sanitized.parts,
|
|
748
|
+
sanitized.metadata,
|
|
749
|
+
sanitized.payload ?? "",
|
|
750
|
+
sanitized.contextPayload ?? ""
|
|
751
|
+
].map(String).join("\n")
|
|
752
|
+
);
|
|
753
|
+
const values = {
|
|
754
|
+
parts: sanitized.parts,
|
|
755
|
+
payload: sanitized.payload ?? null,
|
|
756
|
+
context_payload: sanitized.contextPayload ?? null,
|
|
757
|
+
metadata: sanitized.metadata,
|
|
758
|
+
usage: usage2
|
|
759
|
+
};
|
|
760
|
+
const changed = Object.entries(values).some(
|
|
761
|
+
([key, value]) => !migrationValueEquals(row[key], value)
|
|
598
762
|
);
|
|
599
|
-
if (
|
|
600
|
-
await this.provider.update(MESSAGES_TABLE,
|
|
763
|
+
if (!changed) continue;
|
|
764
|
+
await this.provider.update(MESSAGES_TABLE, values, [
|
|
601
765
|
eq("message_id", String(row.message_id))
|
|
602
766
|
]);
|
|
603
767
|
report.messagesUpdated++;
|
package/dist/index.d.ts
CHANGED
|
@@ -61,6 +61,12 @@ interface RawMessage {
|
|
|
61
61
|
parts?: ContentPart[];
|
|
62
62
|
/** 宿主框架的原始消息载荷;记忆框架仅透明保存和回读。 */
|
|
63
63
|
payload?: unknown;
|
|
64
|
+
/**
|
|
65
|
+
* 仅用于下一次模型上下文重放的宿主载荷。
|
|
66
|
+
* 会透明保存、原样回读并计入原始窗口 token,但不会进入 embedding、全文检索、
|
|
67
|
+
* Topic 摘要或知识图谱抽取。适合保存 provider-ready 工具协议消息等大体积细节。
|
|
68
|
+
*/
|
|
69
|
+
contextPayload?: unknown;
|
|
64
70
|
usage?: number;
|
|
65
71
|
metadata?: Record<string, unknown>;
|
|
66
72
|
createdAt?: number;
|
|
@@ -78,6 +84,8 @@ interface StoredMessage {
|
|
|
78
84
|
parts: string;
|
|
79
85
|
/** JSON.stringify(payload),未提供时为 undefined。 */
|
|
80
86
|
payload?: string;
|
|
87
|
+
/** JSON.stringify(contextPayload),未提供时为 undefined。 */
|
|
88
|
+
contextPayload?: string;
|
|
81
89
|
usage: number;
|
|
82
90
|
metadata: string;
|
|
83
91
|
vector: number[];
|
|
@@ -236,6 +244,7 @@ interface MemoryRawMessage {
|
|
|
236
244
|
content: string;
|
|
237
245
|
parts?: ContentPart[];
|
|
238
246
|
payload?: unknown;
|
|
247
|
+
contextPayload?: unknown;
|
|
239
248
|
usage: number;
|
|
240
249
|
metadata?: Record<string, unknown>;
|
|
241
250
|
createdAt: number;
|
|
@@ -261,6 +270,11 @@ interface GetHistoryWindowOptions {
|
|
|
261
270
|
/** 固定预算的压缩记忆 + 近期未压缩原始消息。 */
|
|
262
271
|
interface MemoryContextWindow {
|
|
263
272
|
sessionId: string;
|
|
273
|
+
/**
|
|
274
|
+
* 当前压缩边界的轻量版本标识。后台/阻塞压缩或 Topic 归并改变窗口时随之变化;
|
|
275
|
+
* 新增原始消息本身不保证改变该值。
|
|
276
|
+
*/
|
|
277
|
+
compressionRevision?: string;
|
|
264
278
|
compressedContext: string;
|
|
265
279
|
recentMessages: MemoryRawMessage[];
|
|
266
280
|
usage: MemoryContextWindowUsage;
|
|
@@ -407,10 +421,14 @@ interface MemoryConfig {
|
|
|
407
421
|
contextUsageRatio?: number;
|
|
408
422
|
/** 原始消息达到其可用预算的此比例时后台预压缩,默认 0.75。 */
|
|
409
423
|
precompressionRatio?: number;
|
|
410
|
-
/**
|
|
424
|
+
/** 单次压缩目标占当前未压缩消息 token 总量的比例,默认 0.7(压 7 留 3)。 */
|
|
411
425
|
compressionBatchRatio?: number;
|
|
412
426
|
/** 单次压缩硬上限;0 表示只受 compressionBatchRatio 控制。 */
|
|
413
427
|
compressionBatchTokenLimit?: number;
|
|
428
|
+
/** 压缩模型返回非法 JSON、缺字段或空摘要时的最大重试次数,默认 2(即最多 3 次)。 */
|
|
429
|
+
compressionMaxRetries?: number;
|
|
430
|
+
/** 压缩结果级重试的指数退避基数(毫秒),默认 500;0 表示不等待。 */
|
|
431
|
+
compressionRetryBaseDelayMs?: number;
|
|
414
432
|
/** 单条 Topic 摘要硬上限,实际长度由重要性决定,默认 2048。 */
|
|
415
433
|
topicSummaryMaxTokens?: number;
|
|
416
434
|
/** 未传 getHistoryWindow 模型窗口时使用,默认 256K。 */
|
package/dist/index.js
CHANGED
|
@@ -48,8 +48,16 @@ function resolveConfig(config) {
|
|
|
48
48
|
),
|
|
49
49
|
contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
|
|
50
50
|
precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
|
|
51
|
-
compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.
|
|
51
|
+
compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.7, "compressionBatchRatio"),
|
|
52
52
|
compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
|
|
53
|
+
compressionMaxRetries: nonNegativeInt(
|
|
54
|
+
config.compressionMaxRetries ?? 2,
|
|
55
|
+
"compressionMaxRetries"
|
|
56
|
+
),
|
|
57
|
+
compressionRetryBaseDelayMs: nonNegativeInt(
|
|
58
|
+
config.compressionRetryBaseDelayMs ?? 500,
|
|
59
|
+
"compressionRetryBaseDelayMs"
|
|
60
|
+
),
|
|
53
61
|
topicSummaryMaxTokens: positiveInt(
|
|
54
62
|
config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
|
|
55
63
|
"topicSummaryMaxTokens"
|
|
@@ -97,6 +105,12 @@ function ratio(value, name) {
|
|
|
97
105
|
}
|
|
98
106
|
return value;
|
|
99
107
|
}
|
|
108
|
+
function nonNegativeInt(value, name) {
|
|
109
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
110
|
+
throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u975E\u8D1F\u6570`);
|
|
111
|
+
}
|
|
112
|
+
return Math.floor(value);
|
|
113
|
+
}
|
|
100
114
|
function positiveInt(value, name) {
|
|
101
115
|
if (!Number.isFinite(value) || value <= 0) {
|
|
102
116
|
throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u6B63\u6570`);
|
|
@@ -255,6 +269,18 @@ import { GrafeoDB } from "@grafeo-db/js";
|
|
|
255
269
|
import { get_encoding } from "@dqbd/tiktoken";
|
|
256
270
|
|
|
257
271
|
// src/llm/http.ts
|
|
272
|
+
var HttpRequestError = class extends Error {
|
|
273
|
+
retryable;
|
|
274
|
+
attempts;
|
|
275
|
+
status;
|
|
276
|
+
constructor(message, options) {
|
|
277
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
278
|
+
this.name = "HttpRequestError";
|
|
279
|
+
this.retryable = options.retryable;
|
|
280
|
+
this.attempts = options.attempts;
|
|
281
|
+
this.status = options.status;
|
|
282
|
+
}
|
|
283
|
+
};
|
|
258
284
|
function normalizeFetchError(err, timeoutMs, errorLabel) {
|
|
259
285
|
if (isAbortError(err)) {
|
|
260
286
|
return new Error(`${errorLabel} request timed out after ${timeoutMs}ms`, { cause: err });
|
|
@@ -291,15 +317,36 @@ async function postJsonWithRetry(url, headers, body, opts, errorLabel = "HTTP")
|
|
|
291
317
|
await sleep(backoffDelay(attempt));
|
|
292
318
|
continue;
|
|
293
319
|
}
|
|
294
|
-
throw lastErr
|
|
320
|
+
throw new HttpRequestError(lastErr.message, {
|
|
321
|
+
retryable: true,
|
|
322
|
+
attempts: attempt + 1,
|
|
323
|
+
cause: lastErr
|
|
324
|
+
});
|
|
295
325
|
}
|
|
296
326
|
clearTimeout(timer);
|
|
297
327
|
if (res.ok) {
|
|
298
|
-
|
|
328
|
+
try {
|
|
329
|
+
return await res.json();
|
|
330
|
+
} catch (err) {
|
|
331
|
+
lastErr = new HttpRequestError(`${errorLabel} returned invalid JSON`, {
|
|
332
|
+
retryable: true,
|
|
333
|
+
attempts: attempt + 1,
|
|
334
|
+
cause: err
|
|
335
|
+
});
|
|
336
|
+
if (attempt < opts.maxRetries) {
|
|
337
|
+
await sleep(backoffDelay(attempt));
|
|
338
|
+
continue;
|
|
339
|
+
}
|
|
340
|
+
throw lastErr;
|
|
341
|
+
}
|
|
299
342
|
}
|
|
300
343
|
const text = await res.text().catch(() => "");
|
|
301
|
-
lastErr = new Error(`${errorLabel} error ${res.status}: ${text}`);
|
|
302
344
|
const retryable = res.status >= 500 || res.status === 429;
|
|
345
|
+
lastErr = new HttpRequestError(`${errorLabel} error ${res.status}: ${text}`, {
|
|
346
|
+
retryable,
|
|
347
|
+
attempts: attempt + 1,
|
|
348
|
+
status: res.status
|
|
349
|
+
});
|
|
303
350
|
if (retryable && attempt < opts.maxRetries) {
|
|
304
351
|
await sleep(backoffDelay(attempt));
|
|
305
352
|
continue;
|
|
@@ -802,6 +849,136 @@ function eq(field, value) {
|
|
|
802
849
|
return { op: "eq", field, value };
|
|
803
850
|
}
|
|
804
851
|
|
|
852
|
+
// src/history-image.ts
|
|
853
|
+
var OMIT_IMAGE = Symbol("omit-history-image");
|
|
854
|
+
var IMAGE_PART_TYPES = /* @__PURE__ */ new Set([
|
|
855
|
+
"image",
|
|
856
|
+
"image-data",
|
|
857
|
+
"image-url",
|
|
858
|
+
"image-file-id",
|
|
859
|
+
"image-file-reference",
|
|
860
|
+
"input-image",
|
|
861
|
+
"output-image"
|
|
862
|
+
]);
|
|
863
|
+
function sanitizeRawHistoryFields(message) {
|
|
864
|
+
const partsResult = sanitizeValue(message.parts ?? []);
|
|
865
|
+
const payloadResult = sanitizeValue(message.payload);
|
|
866
|
+
const contextResult = sanitizeValue(message.contextPayload);
|
|
867
|
+
const metadataResult = sanitizeValue(message.metadata ?? {});
|
|
868
|
+
const payload = optionalSanitizedValue(payloadResult);
|
|
869
|
+
const contextPayload = optionalSanitizedValue(contextResult);
|
|
870
|
+
return {
|
|
871
|
+
parts: Array.isArray(partsResult.value) ? partsResult.value : [],
|
|
872
|
+
...payload !== void 0 && { payload },
|
|
873
|
+
...contextPayload !== void 0 && { contextPayload },
|
|
874
|
+
metadata: isRecord(metadataResult.value) ? metadataResult.value : {},
|
|
875
|
+
removedImages: partsResult.removedImages + payloadResult.removedImages + contextResult.removedImages + metadataResult.removedImages
|
|
876
|
+
};
|
|
877
|
+
}
|
|
878
|
+
function sanitizeStoredMessageImages(message) {
|
|
879
|
+
const parsed = {
|
|
880
|
+
content: message.content,
|
|
881
|
+
parts: parseArray(message.parts),
|
|
882
|
+
...message.payload !== void 0 && { payload: parseValue(message.payload) },
|
|
883
|
+
...message.contextPayload !== void 0 && {
|
|
884
|
+
contextPayload: parseValue(message.contextPayload)
|
|
885
|
+
},
|
|
886
|
+
metadata: parseObject(message.metadata)
|
|
887
|
+
};
|
|
888
|
+
const sanitized = sanitizeRawHistoryFields(parsed);
|
|
889
|
+
if (sanitized.removedImages === 0) {
|
|
890
|
+
return { message, changed: false, removedImages: 0 };
|
|
891
|
+
}
|
|
892
|
+
const parts = JSON.stringify(sanitized.parts);
|
|
893
|
+
const payload = sanitized.payload === void 0 ? void 0 : JSON.stringify(sanitized.payload);
|
|
894
|
+
const contextPayload = sanitized.contextPayload === void 0 ? void 0 : JSON.stringify(sanitized.contextPayload);
|
|
895
|
+
const metadata = JSON.stringify(sanitized.metadata);
|
|
896
|
+
const usage = countTokens(
|
|
897
|
+
[message.content, parts, metadata, payload ?? "", contextPayload ?? ""].join("\n")
|
|
898
|
+
);
|
|
899
|
+
return {
|
|
900
|
+
message: {
|
|
901
|
+
...message,
|
|
902
|
+
parts,
|
|
903
|
+
payload,
|
|
904
|
+
contextPayload,
|
|
905
|
+
usage,
|
|
906
|
+
metadata
|
|
907
|
+
},
|
|
908
|
+
changed: true,
|
|
909
|
+
removedImages: sanitized.removedImages
|
|
910
|
+
};
|
|
911
|
+
}
|
|
912
|
+
function sanitizeValue(value) {
|
|
913
|
+
if (typeof value === "string" && /^data:image\//i.test(value.trim())) {
|
|
914
|
+
return { value: OMIT_IMAGE, removedImages: 1, changed: true };
|
|
915
|
+
}
|
|
916
|
+
if (value == null || typeof value !== "object") {
|
|
917
|
+
return { value, removedImages: 0, changed: false };
|
|
918
|
+
}
|
|
919
|
+
if (isImageNode(value)) {
|
|
920
|
+
return { value: OMIT_IMAGE, removedImages: 1, changed: true };
|
|
921
|
+
}
|
|
922
|
+
if (Array.isArray(value)) {
|
|
923
|
+
const out2 = [];
|
|
924
|
+
let removedImages2 = 0;
|
|
925
|
+
let changed2 = false;
|
|
926
|
+
for (const item of value) {
|
|
927
|
+
const sanitized = sanitizeValue(item);
|
|
928
|
+
removedImages2 += sanitized.removedImages;
|
|
929
|
+
changed2 ||= sanitized.changed;
|
|
930
|
+
if (sanitized.value !== OMIT_IMAGE) out2.push(sanitized.value);
|
|
931
|
+
}
|
|
932
|
+
return { value: changed2 ? out2 : value, removedImages: removedImages2, changed: changed2 };
|
|
933
|
+
}
|
|
934
|
+
const out = {};
|
|
935
|
+
let removedImages = 0;
|
|
936
|
+
let changed = false;
|
|
937
|
+
for (const [key, item] of Object.entries(value)) {
|
|
938
|
+
const sanitized = sanitizeValue(item);
|
|
939
|
+
removedImages += sanitized.removedImages;
|
|
940
|
+
changed ||= sanitized.changed;
|
|
941
|
+
if (sanitized.value !== OMIT_IMAGE) out[key] = sanitized.value;
|
|
942
|
+
}
|
|
943
|
+
return { value: changed ? out : value, removedImages, changed };
|
|
944
|
+
}
|
|
945
|
+
function isImageNode(value) {
|
|
946
|
+
if (!isRecord(value)) return false;
|
|
947
|
+
const type = normalizeType(value.type);
|
|
948
|
+
if (IMAGE_PART_TYPES.has(type)) return true;
|
|
949
|
+
const mediaType = String(value.mediaType ?? value.mimeType ?? "").trim().toLowerCase();
|
|
950
|
+
return mediaType.startsWith("image/") && (type === "file" || "data" in value || "url" in value || "fileId" in value || "providerReference" in value);
|
|
951
|
+
}
|
|
952
|
+
function normalizeType(value) {
|
|
953
|
+
return typeof value === "string" ? value.trim().toLowerCase().replaceAll("_", "-") : "";
|
|
954
|
+
}
|
|
955
|
+
function optionalSanitizedValue(result) {
|
|
956
|
+
if (result.value === OMIT_IMAGE) return void 0;
|
|
957
|
+
if (result.changed && isEmptyContainer(result.value)) return void 0;
|
|
958
|
+
return result.value;
|
|
959
|
+
}
|
|
960
|
+
function isEmptyContainer(value) {
|
|
961
|
+
return Array.isArray(value) ? value.length === 0 : isRecord(value) && Object.keys(value).length === 0;
|
|
962
|
+
}
|
|
963
|
+
function isRecord(value) {
|
|
964
|
+
return !!value && typeof value === "object" && !Array.isArray(value);
|
|
965
|
+
}
|
|
966
|
+
function parseValue(value) {
|
|
967
|
+
try {
|
|
968
|
+
return JSON.parse(value);
|
|
969
|
+
} catch {
|
|
970
|
+
return value;
|
|
971
|
+
}
|
|
972
|
+
}
|
|
973
|
+
function parseArray(value) {
|
|
974
|
+
const parsed = parseValue(value);
|
|
975
|
+
return Array.isArray(parsed) ? parsed : [];
|
|
976
|
+
}
|
|
977
|
+
function parseObject(value) {
|
|
978
|
+
const parsed = parseValue(value);
|
|
979
|
+
return isRecord(parsed) ? parsed : {};
|
|
980
|
+
}
|
|
981
|
+
|
|
805
982
|
// src/db/memory.store.ts
|
|
806
983
|
var MESSAGES_TABLE = "messages";
|
|
807
984
|
var TOPICS_TABLE = "topics";
|
|
@@ -827,6 +1004,7 @@ function tableDefs(dim) {
|
|
|
827
1004
|
{ name: "parts", type: "text", nullable: true },
|
|
828
1005
|
// 兼容存量数据
|
|
829
1006
|
{ name: "payload", type: "text", nullable: true },
|
|
1007
|
+
{ name: "context_payload", type: "text", nullable: true },
|
|
830
1008
|
{ name: "vector", type: "vector" },
|
|
831
1009
|
{ name: "usage", type: "int" },
|
|
832
1010
|
{ name: "metadata", type: "text" },
|
|
@@ -958,6 +1136,7 @@ function messageToRow(m) {
|
|
|
958
1136
|
content: m.content,
|
|
959
1137
|
parts: m.parts ?? "[]",
|
|
960
1138
|
payload: m.payload ?? null,
|
|
1139
|
+
context_payload: m.contextPayload ?? null,
|
|
961
1140
|
vector: m.vector,
|
|
962
1141
|
usage: m.usage,
|
|
963
1142
|
metadata: m.metadata,
|
|
@@ -976,6 +1155,7 @@ function rowToMessage(r) {
|
|
|
976
1155
|
parts: r.parts ?? "[]",
|
|
977
1156
|
// 旧数据无此列时安全降级
|
|
978
1157
|
payload: r.payload ?? void 0,
|
|
1158
|
+
contextPayload: r.context_payload ?? void 0,
|
|
979
1159
|
vector: toVector(r.vector),
|
|
980
1160
|
usage: positiveNumber(r.usage),
|
|
981
1161
|
metadata: r.metadata,
|
|
@@ -1261,11 +1441,28 @@ var MemoryStore = class {
|
|
|
1261
1441
|
const messageRows = await this.provider.query(MESSAGES_TABLE);
|
|
1262
1442
|
report.messagesScanned = messageRows.length;
|
|
1263
1443
|
for (const row of messageRows) {
|
|
1444
|
+
const sanitized = sanitizeStoredMessageImages(rowToMessage(row)).message;
|
|
1264
1445
|
const usage = countTokens(
|
|
1265
|
-
[
|
|
1446
|
+
[
|
|
1447
|
+
sanitized.content,
|
|
1448
|
+
sanitized.parts,
|
|
1449
|
+
sanitized.metadata,
|
|
1450
|
+
sanitized.payload ?? "",
|
|
1451
|
+
sanitized.contextPayload ?? ""
|
|
1452
|
+
].map(String).join("\n")
|
|
1453
|
+
);
|
|
1454
|
+
const values = {
|
|
1455
|
+
parts: sanitized.parts,
|
|
1456
|
+
payload: sanitized.payload ?? null,
|
|
1457
|
+
context_payload: sanitized.contextPayload ?? null,
|
|
1458
|
+
metadata: sanitized.metadata,
|
|
1459
|
+
usage
|
|
1460
|
+
};
|
|
1461
|
+
const changed = Object.entries(values).some(
|
|
1462
|
+
([key, value]) => !migrationValueEquals(row[key], value)
|
|
1266
1463
|
);
|
|
1267
|
-
if (
|
|
1268
|
-
await this.provider.update(MESSAGES_TABLE,
|
|
1464
|
+
if (!changed) continue;
|
|
1465
|
+
await this.provider.update(MESSAGES_TABLE, values, [
|
|
1269
1466
|
eq("message_id", String(row.message_id))
|
|
1270
1467
|
]);
|
|
1271
1468
|
report.messagesUpdated++;
|
|
@@ -2086,7 +2283,7 @@ function stripToolCallContent(value) {
|
|
|
2086
2283
|
if (Array.isArray(value)) {
|
|
2087
2284
|
return value.map((item) => stripToolCallContent(item)).filter((item) => item !== OMIT_COMPRESSION_VALUE);
|
|
2088
2285
|
}
|
|
2089
|
-
if (!
|
|
2286
|
+
if (!isRecord2(value)) return value;
|
|
2090
2287
|
if (isToolCallBlock(value)) return OMIT_COMPRESSION_VALUE;
|
|
2091
2288
|
const sanitized = {};
|
|
2092
2289
|
for (const [key, child] of Object.entries(value)) {
|
|
@@ -2111,7 +2308,7 @@ function isToolCallBlock(value) {
|
|
|
2111
2308
|
function normalizeToolKey(value) {
|
|
2112
2309
|
return value.replace(/[\s_-]/g, "").toLowerCase();
|
|
2113
2310
|
}
|
|
2114
|
-
function
|
|
2311
|
+
function isRecord2(value) {
|
|
2115
2312
|
return value !== null && typeof value === "object";
|
|
2116
2313
|
}
|
|
2117
2314
|
|
|
@@ -2212,7 +2409,10 @@ var CompressManager = class {
|
|
|
2212
2409
|
const rawLimit = Math.max(1, rawLimitOverride ?? this.rawLimit(entry.lastModelContextTokens));
|
|
2213
2410
|
const threshold = Math.max(1, Math.floor(rawLimit * this.config.precompressionRatio));
|
|
2214
2411
|
if (!force && entry.totalTokens < threshold) return;
|
|
2215
|
-
let batchTarget = Math.max(
|
|
2412
|
+
let batchTarget = Math.max(
|
|
2413
|
+
1,
|
|
2414
|
+
Math.floor(entry.totalTokens * this.config.compressionBatchRatio)
|
|
2415
|
+
);
|
|
2216
2416
|
if (this.config.compressionBatchTokenLimit > 0) {
|
|
2217
2417
|
batchTarget = Math.min(batchTarget, this.config.compressionBatchTokenLimit);
|
|
2218
2418
|
}
|
|
@@ -2220,7 +2420,11 @@ var CompressManager = class {
|
|
|
2220
2420
|
if (messages.length === 0) return;
|
|
2221
2421
|
let result;
|
|
2222
2422
|
try {
|
|
2223
|
-
result = await this.
|
|
2423
|
+
result = await this.summarizeWithRetry(
|
|
2424
|
+
sessionId,
|
|
2425
|
+
"raw-message compression",
|
|
2426
|
+
() => this.llm.summarizeMessages(messages)
|
|
2427
|
+
);
|
|
2224
2428
|
} catch (error) {
|
|
2225
2429
|
console.error(
|
|
2226
2430
|
`[CompressManager] LLM compress failed for session ${sessionId}: ${formatError(error)}`
|
|
@@ -2228,7 +2432,6 @@ var CompressManager = class {
|
|
|
2228
2432
|
throw error;
|
|
2229
2433
|
}
|
|
2230
2434
|
const summary = result.summary.trim();
|
|
2231
|
-
if (!summary) throw new Error(`LLM returned an empty summary for session ${sessionId}`);
|
|
2232
2435
|
const vector = await this.embed.embedOne(summary).catch((error) => {
|
|
2233
2436
|
console.error(`[CompressManager] Embed topic failed for session ${sessionId}:`, error);
|
|
2234
2437
|
return [];
|
|
@@ -2310,9 +2513,12 @@ var CompressManager = class {
|
|
|
2310
2513
|
Math.max(1, tokenLimit - unselectedTokens)
|
|
2311
2514
|
)
|
|
2312
2515
|
);
|
|
2313
|
-
const result = await this.
|
|
2516
|
+
const result = await this.summarizeWithRetry(
|
|
2517
|
+
sessionId,
|
|
2518
|
+
"Topic compaction",
|
|
2519
|
+
() => this.llm.summarizeTopics(selected, maxSummaryTokens)
|
|
2520
|
+
);
|
|
2314
2521
|
const summary = result.summary.trim();
|
|
2315
|
-
if (!summary) throw new Error(`LLM returned an empty Topic rollup for session ${sessionId}`);
|
|
2316
2522
|
const vector = persist ? await this.embed.embedOne(summary).catch(() => []) : [];
|
|
2317
2523
|
const first = selected[0];
|
|
2318
2524
|
const last = selected[selected.length - 1];
|
|
@@ -2385,6 +2591,37 @@ var CompressManager = class {
|
|
|
2385
2591
|
const embeddings = new Map(names.map((name, index) => [name, vectors[index]]));
|
|
2386
2592
|
await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
|
|
2387
2593
|
}
|
|
2594
|
+
/**
|
|
2595
|
+
* HTTP transport already owns bounded retries for network/timeout/5xx/429.
|
|
2596
|
+
* This outer layer retries semantic compression failures only: malformed model
|
|
2597
|
+
* content, missing fields and empty summaries. That avoids retry multiplication.
|
|
2598
|
+
*/
|
|
2599
|
+
async summarizeWithRetry(sessionId, label, operation) {
|
|
2600
|
+
let lastError;
|
|
2601
|
+
for (let attempt = 0; attempt <= this.config.compressionMaxRetries; attempt++) {
|
|
2602
|
+
try {
|
|
2603
|
+
const result = await operation();
|
|
2604
|
+
if (typeof result?.summary !== "string" || !result.summary.trim()) {
|
|
2605
|
+
throw new Error(`${label} returned an empty summary`);
|
|
2606
|
+
}
|
|
2607
|
+
return result;
|
|
2608
|
+
} catch (error) {
|
|
2609
|
+
lastError = error;
|
|
2610
|
+
if (error instanceof HttpRequestError || attempt >= this.config.compressionMaxRetries) {
|
|
2611
|
+
throw error;
|
|
2612
|
+
}
|
|
2613
|
+
const delayMs = Math.min(
|
|
2614
|
+
this.config.compressionRetryBaseDelayMs * 2 ** attempt,
|
|
2615
|
+
8e3
|
|
2616
|
+
);
|
|
2617
|
+
console.warn(
|
|
2618
|
+
`[CompressManager] ${label} failed for session ${sessionId}; retrying ${attempt + 1}/${this.config.compressionMaxRetries} after ${delayMs}ms: ` + formatError(error)
|
|
2619
|
+
);
|
|
2620
|
+
if (delayMs > 0) await delay(delayMs);
|
|
2621
|
+
}
|
|
2622
|
+
}
|
|
2623
|
+
throw lastError;
|
|
2624
|
+
}
|
|
2388
2625
|
};
|
|
2389
2626
|
function sumTopicTokens(topics) {
|
|
2390
2627
|
return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
|
|
@@ -2395,6 +2632,9 @@ function topicOrder(a, b) {
|
|
|
2395
2632
|
function formatError(error) {
|
|
2396
2633
|
return error instanceof Error ? error.message : String(error);
|
|
2397
2634
|
}
|
|
2635
|
+
function delay(ms) {
|
|
2636
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
2637
|
+
}
|
|
2398
2638
|
function withTimeout(promise, timeoutMs, label) {
|
|
2399
2639
|
let timer;
|
|
2400
2640
|
const timeout = new Promise((_, reject) => {
|
|
@@ -3030,9 +3270,14 @@ var SessionCache = class {
|
|
|
3030
3270
|
if (messages.length === 0 || targetTokens <= 0) return [];
|
|
3031
3271
|
const selected = [];
|
|
3032
3272
|
let tokens = 0;
|
|
3033
|
-
|
|
3273
|
+
const selectableCount = messages.length > 1 ? messages.length - 1 : 1;
|
|
3274
|
+
for (const message of messages.slice(0, selectableCount)) {
|
|
3275
|
+
const nextTokens = tokens + effectiveUsage(message);
|
|
3276
|
+
if (selected.length > 0 && Math.abs(targetTokens - tokens) <= Math.abs(targetTokens - nextTokens)) {
|
|
3277
|
+
break;
|
|
3278
|
+
}
|
|
3034
3279
|
selected.push(message);
|
|
3035
|
-
tokens
|
|
3280
|
+
tokens = nextTokens;
|
|
3036
3281
|
if (tokens >= targetTokens) break;
|
|
3037
3282
|
}
|
|
3038
3283
|
return selected;
|
|
@@ -3143,7 +3388,7 @@ function effectiveUsage(message) {
|
|
|
3143
3388
|
if (Number.isFinite(message.usage) && message.usage > 0) return Math.floor(message.usage);
|
|
3144
3389
|
return Math.max(
|
|
3145
3390
|
1,
|
|
3146
|
-
(message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0)) * 2
|
|
3391
|
+
(message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0) + (message.contextPayload?.length ?? 0)) * 2
|
|
3147
3392
|
);
|
|
3148
3393
|
}
|
|
3149
3394
|
function sumMessageTokens(messages) {
|
|
@@ -3277,11 +3522,20 @@ var MemoryManager = class {
|
|
|
3277
3522
|
task = (async () => {
|
|
3278
3523
|
const allTopics = await this.store.getTopicsBySession(sessionId);
|
|
3279
3524
|
const latestTopic = allTopics.at(-1);
|
|
3280
|
-
const
|
|
3525
|
+
const loadedRawAfterBoundary = latestTopic ? await this.store.getMessagesAfterBoundary(
|
|
3281
3526
|
sessionId,
|
|
3282
3527
|
latestTopic.endTime,
|
|
3283
3528
|
latestTopic.endMessageId
|
|
3284
3529
|
) : await this.store.getAllMessagesBySession(sessionId);
|
|
3530
|
+
const sanitizedRows = loadedRawAfterBoundary.map(sanitizeStoredMessageImages);
|
|
3531
|
+
const repaired = sanitizedRows.filter((row) => row.changed);
|
|
3532
|
+
if (repaired.length > 0) {
|
|
3533
|
+
await this.store.upsertMessages(repaired.map((row) => row.message));
|
|
3534
|
+
console.info(
|
|
3535
|
+
`[MemoryManager] stripped ${repaired.reduce((sum, row) => sum + row.removedImages, 0)} historical image block(s) from ${repaired.length} message(s) in session ${sessionId}.`
|
|
3536
|
+
);
|
|
3537
|
+
}
|
|
3538
|
+
const allRawAfterBoundary = sanitizedRows.map((row) => row.message);
|
|
3285
3539
|
const since = this.config.maxHistoryAgeMs > 0 ? Date.now() - this.config.maxHistoryAgeMs : 0;
|
|
3286
3540
|
const rawMessages = since > 0 ? allRawAfterBoundary.filter((message) => message.createdAt >= since) : allRawAfterBoundary;
|
|
3287
3541
|
const session = this.sessionMap.get(sessionId);
|
|
@@ -3331,10 +3585,12 @@ var MemoryManager = class {
|
|
|
3331
3585
|
const now = Date.now();
|
|
3332
3586
|
await this.ensureSessionHydrated(sessionId, { chatId, userId });
|
|
3333
3587
|
const normalized = messages.map((message) => {
|
|
3334
|
-
const
|
|
3335
|
-
const
|
|
3336
|
-
const
|
|
3337
|
-
const
|
|
3588
|
+
const sanitized = sanitizeRawHistoryFields(message);
|
|
3589
|
+
const parts = JSON.stringify(sanitized.parts);
|
|
3590
|
+
const metadata = JSON.stringify(sanitized.metadata);
|
|
3591
|
+
const payload = sanitized.payload === void 0 ? void 0 : JSON.stringify(sanitized.payload);
|
|
3592
|
+
const contextPayload = sanitized.contextPayload === void 0 ? void 0 : JSON.stringify(sanitized.contextPayload);
|
|
3593
|
+
const tokenInput = [message.content, parts, metadata, payload ?? "", contextPayload ?? ""].join("\n");
|
|
3338
3594
|
return {
|
|
3339
3595
|
messageId: message.messageId ?? uuidv44(),
|
|
3340
3596
|
talkerId: message.talkerId ?? "user",
|
|
@@ -3345,7 +3601,10 @@ var MemoryManager = class {
|
|
|
3345
3601
|
content: message.content,
|
|
3346
3602
|
parts,
|
|
3347
3603
|
payload,
|
|
3348
|
-
|
|
3604
|
+
contextPayload,
|
|
3605
|
+
// Host-provided usage describes the original payload. Once image bytes are
|
|
3606
|
+
// stripped, recalculate against the actual persisted history budget.
|
|
3607
|
+
usage: sanitized.removedImages > 0 ? countTokens(tokenInput) : message.usage ?? countTokens(tokenInput),
|
|
3349
3608
|
metadata,
|
|
3350
3609
|
createdAt: message.createdAt ?? now
|
|
3351
3610
|
};
|
|
@@ -3625,6 +3884,7 @@ var MemoryManager = class {
|
|
|
3625
3884
|
}
|
|
3626
3885
|
return {
|
|
3627
3886
|
sessionId,
|
|
3887
|
+
compressionRevision: buildCompressionRevision(topics, entry.messages),
|
|
3628
3888
|
compressedContext: this.sessionCache.buildCompressedContext(sessionId, topics),
|
|
3629
3889
|
recentMessages: entry.messages.map(toMemoryRawMessage),
|
|
3630
3890
|
usage: {
|
|
@@ -3883,23 +4143,36 @@ var MemoryManager = class {
|
|
|
3883
4143
|
}
|
|
3884
4144
|
};
|
|
3885
4145
|
function toMemoryRawMessage(message) {
|
|
3886
|
-
const
|
|
3887
|
-
const
|
|
4146
|
+
const sanitized = sanitizeStoredMessageImages(message).message;
|
|
4147
|
+
const parts = safeParseArray(sanitized.parts);
|
|
4148
|
+
const payload = sanitized.payload === void 0 ? void 0 : safeParseValue(sanitized.payload);
|
|
4149
|
+
const contextPayload = sanitized.contextPayload === void 0 ? void 0 : safeParseValue(sanitized.contextPayload);
|
|
3888
4150
|
return {
|
|
3889
|
-
messageId:
|
|
3890
|
-
talkerId:
|
|
3891
|
-
chatId:
|
|
3892
|
-
userId:
|
|
3893
|
-
sessionId:
|
|
3894
|
-
type:
|
|
3895
|
-
content:
|
|
4151
|
+
messageId: sanitized.messageId,
|
|
4152
|
+
talkerId: sanitized.talkerId,
|
|
4153
|
+
chatId: sanitized.chatId,
|
|
4154
|
+
userId: sanitized.userId,
|
|
4155
|
+
sessionId: sanitized.sessionId,
|
|
4156
|
+
type: sanitized.type,
|
|
4157
|
+
content: sanitized.content,
|
|
3896
4158
|
...parts.length > 0 && { parts },
|
|
3897
4159
|
...payload !== void 0 && { payload },
|
|
3898
|
-
|
|
3899
|
-
|
|
3900
|
-
|
|
4160
|
+
...contextPayload !== void 0 && { contextPayload },
|
|
4161
|
+
usage: sanitized.usage,
|
|
4162
|
+
metadata: safeParseObject2(sanitized.metadata),
|
|
4163
|
+
createdAt: sanitized.createdAt
|
|
3901
4164
|
};
|
|
3902
4165
|
}
|
|
4166
|
+
function buildCompressionRevision(topics, messages) {
|
|
4167
|
+
const lastTopic = topics.at(-1);
|
|
4168
|
+
const firstRaw = messages.at(0);
|
|
4169
|
+
return [
|
|
4170
|
+
topics.length,
|
|
4171
|
+
lastTopic?.summaryId ?? "none",
|
|
4172
|
+
lastTopic?.updatedAt ?? 0,
|
|
4173
|
+
firstRaw?.messageId ?? "none"
|
|
4174
|
+
].join(":");
|
|
4175
|
+
}
|
|
3903
4176
|
function sumTopicTokens2(topics) {
|
|
3904
4177
|
return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
|
|
3905
4178
|
}
|
package/llms.txt
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# @ppagent/memory
|
|
2
2
|
|
|
3
|
-
> `@ppagent/memory` is an independent TypeScript/Node.js long-term memory package for AI agents. It stores
|
|
3
|
+
> `@ppagent/memory` is an independent TypeScript/Node.js long-term memory package for AI agents. It stores semantic raw conversation history in a pluggable vector store (LanceDB or SQLite/sqlite-vec, auto-detected per platform), strips historical image payloads, builds importance-aware Topic summaries, returns dynamically budgeted Topics plus recent uncompressed messages as one context window, extracts and searches entity relationships with Grafeo, supports cached user/chat facts, and ingests Markdown documents as a searchable knowledge base.
|
|
4
4
|
|
|
5
5
|
This file is written for AI coding agents and assistants. Use it as the primary package guide when generating code that depends on `@ppagent/memory`.
|
|
6
6
|
|
|
@@ -154,8 +154,10 @@ const memory = new MemoryManager({
|
|
|
154
154
|
topicCompactionSyncRatio: 1.25,
|
|
155
155
|
contextUsageRatio: 0.75,
|
|
156
156
|
precompressionRatio: 0.75,
|
|
157
|
-
compressionBatchRatio: 0.
|
|
157
|
+
compressionBatchRatio: 0.7,
|
|
158
158
|
compressionBatchTokenLimit: 0,
|
|
159
|
+
compressionMaxRetries: 2,
|
|
160
|
+
compressionRetryBaseDelayMs: 500,
|
|
159
161
|
topicSummaryMaxTokens: 2048,
|
|
160
162
|
defaultModelContextTokens: 256 * 1024,
|
|
161
163
|
maxHistoryAgeMs: 0,
|
|
@@ -327,7 +329,7 @@ Embedding fallback behavior:
|
|
|
327
329
|
HTTP and retry fields:
|
|
328
330
|
|
|
329
331
|
- `httpTimeoutMs?: number` - timeout per LLM or embedding HTTP attempt. Default: `60000`.
|
|
330
|
-
- `httpMaxRetries?: number` - retry count after the first attempt for network errors, timeout/abort, HTTP `429`,
|
|
332
|
+
- `httpMaxRetries?: number` - retry count after the first attempt for network errors, timeout/abort, HTTP `429`, HTTP `5xx`, and invalid JSON in a successful HTTP response. Default: `2`.
|
|
331
333
|
- `embeddingBatchSize?: number` - max texts per embedding request. Default: `20`.
|
|
332
334
|
- `embeddingConcurrency?: number` - concurrent embedding batches. Default: `2`.
|
|
333
335
|
|
|
@@ -338,8 +340,10 @@ Conversation memory fields:
|
|
|
338
340
|
- `topicCompactionSyncRatio?: number` - a cold-start Topic overage blocks `getHistoryWindow` only after it exceeds this multiple of the effective Topic budget, unless Topic plus raw history already exceeds the usable window. Default: `1.25`.
|
|
339
341
|
- `contextUsageRatio?: number` - fraction of the current model context available to conversation history. Default: `0.75`.
|
|
340
342
|
- `precompressionRatio?: number` - start background raw-message compression when raw usage reaches this fraction of its dynamic budget. Default: `0.75`.
|
|
341
|
-
- `compressionBatchRatio?: number` - target fraction of the raw-message
|
|
343
|
+
- `compressionBatchRatio?: number` - target fraction of the current uncompressed raw-message tokens compressed from the oldest edge in one batch. Default: `0.7`, leaving the newest approximately `0.3` verbatim; message boundaries are atomic.
|
|
342
344
|
- `compressionBatchTokenLimit?: number` - optional hard limit for one compression batch. Default: `0` (ratio only).
|
|
345
|
+
- `compressionMaxRetries?: number` - result-level retry count after the first compression-model attempt when the returned content is malformed, missing a summary, or empty. Default: `2`. Transport failures do not multiply this count because the HTTP layer already owns bounded retries.
|
|
346
|
+
- `compressionRetryBaseDelayMs?: number` - exponential-backoff base for result-level compression retries. Default: `500`; set `0` to retry immediately.
|
|
343
347
|
- `topicSummaryMaxTokens?: number` - hard maximum for one Topic summary. The LLM uses less for low-value chat and more for facts, experience, decisions, preferences, constraints, and reusable knowledge. Default: `2048`.
|
|
344
348
|
- `defaultModelContextTokens?: number` - used when `getHistoryWindow` receives no model size; a warning is logged. Default: `262144`.
|
|
345
349
|
- `maxHistoryAgeMs?: number` - maximum Topic and raw-message age restored during lazy cold start. Default: `0` (all history).
|
|
@@ -374,8 +378,10 @@ The package intentionally separates durable base writes from expensive graph con
|
|
|
374
378
|
`updateChat(messages, opts)`:
|
|
375
379
|
|
|
376
380
|
- Registers its write synchronously, so a caller may intentionally fire-and-forget it and a following `getHistoryWindow` will still wait for that write.
|
|
377
|
-
-
|
|
378
|
-
-
|
|
381
|
+
- Recursively strips known historical image blocks (`image*` content parts and `file` parts with `image/*` media types) from `parts`, `payload`, `contextPayload`, and `metadata`, while preserving adjacent text, tool protocol, and non-image files. Legacy image rows are lazily repaired and written back when a session is first hydrated; the manual migration command performs the same repair.
|
|
382
|
+
- Calculates tokens over the sanitized `content`, `parts`, `payload`, `contextPayload`, and `metadata`, embeds only the searchable text, and upserts raw messages by stable `messageId` before resolving. If image removal changes a host payload, any host-supplied usage is recalculated against the stored form.
|
|
383
|
+
- `contextPayload` is a replay-only host payload: its non-image structure is stored and returned and counts toward the raw budget, but it is excluded from embedding, FTS, Topic-summary input, and conversation graph extraction.
|
|
384
|
+
- Raw-message token usage counts the complete sanitized stored `metadata`. When a raw batch is summarized, common tool-call/tool-result fields are removed only from the temporary LLM compression input; persisted non-image metadata and uncompressed `recentMessages` remain unchanged.
|
|
379
385
|
- Creates or updates the session record.
|
|
380
386
|
- Updates the in-memory session cache.
|
|
381
387
|
- Once `getHistoryWindow` has supplied the current model size, reaching the precompression threshold starts background compression.
|
|
@@ -393,7 +399,7 @@ The package intentionally separates durable base writes from expensive graph con
|
|
|
393
399
|
|
|
394
400
|
- Lazily hydrates the session from persistent Topics plus raw messages after the newest Topic boundary.
|
|
395
401
|
- Waits for registered message writes. If the hard raw budget is exceeded, it also waits for or starts compression until the returned context fits.
|
|
396
|
-
- Returns `{ compressedContext, recentMessages, usage }`. `recentMessages` preserves the `RawMessage` input shape, including `messageId`, `parts`, `payload`, `metadata`, and `createdAt`.
|
|
402
|
+
- Returns `{ compressionRevision, compressedContext, recentMessages, usage }`. `recentMessages` preserves the sanitized `RawMessage` input shape, including `messageId`, non-image `parts`, `payload`, `contextPayload`, `metadata`, and `createdAt`. `compressionRevision` changes when the returned Topic/raw compression boundary changes and is intended for host observability rather than optimistic locking.
|
|
397
403
|
- The effective Topic budget is `min(compressedContextTokenLimit, usableContextTokens * compressedContextRatio)`; the remaining usable history budget is reserved for raw messages.
|
|
398
404
|
- Cold hydration always restores all persisted Topics and never silently truncates them. Each rollup summarizes the oldest approximately half of the current Topic tokens; a single oversized Topic is re-summarized by itself.
|
|
399
405
|
- A small Topic-only overage returns immediately and schedules a transient background rollup for the next read. It blocks only when the overage exceeds `topicCompactionSyncRatio` or Topic plus raw history cannot fit the usable window.
|
|
@@ -423,8 +429,9 @@ Use `wait: true` when the next line of code must immediately call `searchKnowled
|
|
|
423
429
|
- `sessionId?: string` - session id.
|
|
424
430
|
- `type?: "text" | "image" | "file"` - default: `"text"`.
|
|
425
431
|
- `content: string` - required plain text used for embedding and retrieval.
|
|
426
|
-
- `parts?: ContentPart[]` - optional multimodal content parts; stored serialized.
|
|
427
|
-
- `payload?: unknown` - host-framework message payload stored and returned
|
|
432
|
+
- `parts?: ContentPart[]` - optional multimodal content parts; non-image parts are stored serialized.
|
|
433
|
+
- `payload?: unknown` - host-framework message payload stored and returned after recursive historical-image stripping.
|
|
434
|
+
- `contextPayload?: unknown` - replay-only host context stored and returned after recursive historical-image stripping. Its remaining content counts toward the raw history budget but is excluded from retrieval, compression summaries, and graph extraction.
|
|
428
435
|
- `usage?: number` - token count; estimated with `tiktoken` if omitted.
|
|
429
436
|
- `metadata?: Record<string, unknown>` - custom metadata stored as JSON.
|
|
430
437
|
- `createdAt?: number` - Unix milliseconds; default is current time.
|
|
@@ -618,8 +625,9 @@ Context:
|
|
|
618
625
|
|
|
619
626
|
Returns the complete model-history window:
|
|
620
627
|
|
|
628
|
+
- `compressionRevision?: string` - lightweight identifier for the current Topic/raw compression boundary. Background or blocking compression changes it; ordinary raw appends are not guaranteed to do so.
|
|
621
629
|
- `compressedContext: string` - chronological Topic summaries for the current model-size budget. A soft cold-start overage may be returned once while its transient rollup runs in the background.
|
|
622
|
-
- `recentMessages: MemoryRawMessage[]` - all currently uncompressed raw messages in chronological order, preserving
|
|
630
|
+
- `recentMessages: MemoryRawMessage[]` - all currently uncompressed raw messages in chronological order, preserving sanitized non-image host payload, replay-only context payload, and metadata.
|
|
623
631
|
- `usage` - model size, usable history size, compressed usage, and dynamic raw-message budget/usage.
|
|
624
632
|
|
|
625
633
|
Pass the active model's context size on every read. Omitting it uses `defaultModelContextTokens` (256K by default) and logs a warning.
|