@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 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.5, "compressionBatchRatio"),
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
- [row.content, row.parts ?? "[]", row.metadata ?? "{}", row.payload ?? ""].map(String).join("\n")
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 (Number(row.usage) === usage2) continue;
600
- await this.provider.update(MESSAGES_TABLE, { usage: usage2 }, [
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
- /** 单次压缩目标占原始消息预算的比例,默认 0.5。 */
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.5, "compressionBatchRatio"),
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
- return await res.json();
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
- [row.content, row.parts ?? "[]", row.metadata ?? "{}", row.payload ?? ""].map(String).join("\n")
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 (Number(row.usage) === usage) continue;
1268
- await this.provider.update(MESSAGES_TABLE, { usage }, [
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 (!isRecord(value)) return value;
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 isRecord(value) {
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(1, Math.floor(rawLimit * this.config.compressionBatchRatio));
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.llm.summarizeMessages(messages);
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.llm.summarizeTopics(selected, maxSummaryTokens);
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
- for (const message of messages) {
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 += effectiveUsage(message);
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 allRawAfterBoundary = latestTopic ? await this.store.getMessagesAfterBoundary(
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 parts = JSON.stringify(message.parts ?? []);
3335
- const metadata = JSON.stringify(message.metadata ?? {});
3336
- const payload = message.payload === void 0 ? void 0 : JSON.stringify(message.payload);
3337
- const tokenInput = [message.content, parts, metadata, payload ?? ""].join("\n");
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
- usage: message.usage ?? countTokens(tokenInput),
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 parts = safeParseArray(message.parts);
3887
- const payload = message.payload === void 0 ? void 0 : safeParseValue(message.payload);
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: message.messageId,
3890
- talkerId: message.talkerId,
3891
- chatId: message.chatId,
3892
- userId: message.userId,
3893
- sessionId: message.sessionId,
3894
- type: message.type,
3895
- content: message.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
- usage: message.usage,
3899
- metadata: safeParseObject2(message.metadata),
3900
- createdAt: message.createdAt
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 complete raw conversation messages in a pluggable vector store (LanceDB or SQLite/sqlite-vec, auto-detected per platform), 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.
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.5,
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`, and HTTP `5xx`. Default: `2`.
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 budget compressed in one batch. Default: `0.5`.
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
- - Calculates tokens over `content`, `parts`, `payload`, and `metadata`, embeds the searchable text, and upserts raw messages by stable `messageId` before resolving.
378
- - Raw-message token usage always counts the complete stored `metadata`. When a raw batch is summarized, common tool-call/tool-result fields are removed only from the temporary LLM compression input; persisted metadata and uncompressed `recentMessages` remain unchanged.
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 without interpretation.
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 the host payload and metadata.
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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ppagent/memory",
3
- "version": "0.4.2",
3
+ "version": "0.4.5",
4
4
  "description": "独立记忆系统模块,向量存储支持 LanceDB / SQLite(sqlite-vec) 双后端自动切换 + Grafeo 知识图谱",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",