@ppagent/memory 0.4.5 → 0.4.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/cli.js CHANGED
@@ -51,7 +51,7 @@ function resolveConfig(config) {
51
51
  "topicCompactionSyncRatio"
52
52
  ),
53
53
  contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
54
- precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
54
+ precompressionRatio: ratio(config.precompressionRatio ?? 0.6, "precompressionRatio"),
55
55
  compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.7, "compressionBatchRatio"),
56
56
  compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
57
57
  compressionMaxRetries: nonNegativeInt(
@@ -62,6 +62,16 @@ function resolveConfig(config) {
62
62
  config.compressionRetryBaseDelayMs ?? 500,
63
63
  "compressionRetryBaseDelayMs"
64
64
  ),
65
+ backgroundCompressionMaxRetries: nonNegativeInt(
66
+ config.backgroundCompressionMaxRetries ?? 2,
67
+ "backgroundCompressionMaxRetries"
68
+ ),
69
+ backgroundCompressionRetryBaseDelayMs: nonNegativeInt(
70
+ config.backgroundCompressionRetryBaseDelayMs ?? 1e3,
71
+ "backgroundCompressionRetryBaseDelayMs"
72
+ ),
73
+ onCompressionEvent: config.onCompressionEvent ?? (() => {
74
+ }),
65
75
  topicSummaryMaxTokens: positiveInt(
66
76
  config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
67
77
  "topicSummaryMaxTokens"
@@ -98,6 +108,8 @@ function resolveConfig(config) {
98
108
  graphExtractConcurrency: config.graphExtractConcurrency ?? 2,
99
109
  graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4,
100
110
  autoOptimizeOnInit: config.autoOptimizeOnInit ?? true,
111
+ autoMigrateLegacyDataOnInit: config.autoMigrateLegacyDataOnInit ?? true,
112
+ autoResumeCompressionOnInit: config.autoResumeCompressionOnInit ?? true,
101
113
  autoOptimizeIntervalMs: config.autoOptimizeIntervalMs ?? 6 * 36e5,
102
114
  optimizeVersionRetentionMs: config.optimizeVersionRetentionMs ?? 0,
103
115
  restoreConcurrency: config.restoreConcurrency ?? 8
package/dist/index.d.ts CHANGED
@@ -2,6 +2,26 @@ type MessageType = "text" | "image" | "file";
2
2
  type FactLevel = "user" | "chat";
3
3
  type SearchMode = "fast" | "auto" | "all";
4
4
  type SearchScope = "session" | "chat" | "user" | "all";
5
+ type CompressionRole = "user" | "assistant" | "tool" | "system";
6
+ type MemoryCompressionKind = "raw" | "topics";
7
+ type MemoryCompressionMode = "background" | "blocking" | "manual" | "startup";
8
+ type MemoryCompressionPhase = "start" | "retry" | "success" | "failure";
9
+ interface MemoryCompressionEvent {
10
+ sessionId: string;
11
+ kind: MemoryCompressionKind;
12
+ mode: MemoryCompressionMode;
13
+ phase: MemoryCompressionPhase;
14
+ /** 当前尝试序号,从 1 开始。 */
15
+ attempt: number;
16
+ /** 本层最多尝试次数。 */
17
+ maxAttempts: number;
18
+ inputTokens?: number;
19
+ selectedTokens?: number;
20
+ outputTokens?: number;
21
+ durationMs?: number;
22
+ retryKind?: "semantic" | "task";
23
+ error?: string;
24
+ }
5
25
  /**
6
26
  * 分页参数(可选)。
7
27
  * - offset:起始偏移,从 0 开始(负数按 0 处理)。
@@ -67,6 +87,13 @@ interface RawMessage {
67
87
  * Topic 摘要或知识图谱抽取。适合保存 provider-ready 工具协议消息等大体积细节。
68
88
  */
69
89
  contextPayload?: unknown;
90
+ /**
91
+ * 同一个完整对话轮次(含 user、assistant 与 tool 链)应使用相同 id。
92
+ * 压缩只会在 group 边界切分;省略时按 compressionRole/talkerId 兼容推断。
93
+ */
94
+ compressionGroupId?: string;
95
+ /** 消息在对话轮次中的角色,用于旧数据缺少显式 group id 时推断完整边界。 */
96
+ compressionRole?: CompressionRole;
70
97
  usage?: number;
71
98
  metadata?: Record<string, unknown>;
72
99
  createdAt?: number;
@@ -245,6 +272,8 @@ interface MemoryRawMessage {
245
272
  parts?: ContentPart[];
246
273
  payload?: unknown;
247
274
  contextPayload?: unknown;
275
+ compressionGroupId?: string;
276
+ compressionRole?: CompressionRole;
248
277
  usage: number;
249
278
  metadata?: Record<string, unknown>;
250
279
  createdAt: number;
@@ -419,7 +448,7 @@ interface MemoryConfig {
419
448
  topicCompactionSyncRatio?: number;
420
449
  /** 模型上下文中允许历史记忆使用的比例,默认 0.75。 */
421
450
  contextUsageRatio?: number;
422
- /** 原始消息达到其可用预算的此比例时后台预压缩,默认 0.75。 */
451
+ /** 原始消息达到其可用预算的此比例时后台预压缩,默认 0.6。 */
423
452
  precompressionRatio?: number;
424
453
  /** 单次压缩目标占当前未压缩消息 token 总量的比例,默认 0.7(压 7 留 3)。 */
425
454
  compressionBatchRatio?: number;
@@ -429,6 +458,12 @@ interface MemoryConfig {
429
458
  compressionMaxRetries?: number;
430
459
  /** 压缩结果级重试的指数退避基数(毫秒),默认 500;0 表示不等待。 */
431
460
  compressionRetryBaseDelayMs?: number;
461
+ /** 后台压缩整任务失败后的最大重试次数,默认 2(即最多执行 3 轮任务)。 */
462
+ backgroundCompressionMaxRetries?: number;
463
+ /** 后台压缩整任务重试的指数退避基数(毫秒),默认 1000;0 表示立即重试。 */
464
+ backgroundCompressionRetryBaseDelayMs?: number;
465
+ /** 压缩生命周期事件;回调异常会被隔离,不影响记忆读写。 */
466
+ onCompressionEvent?: (event: MemoryCompressionEvent) => void | Promise<void>;
432
467
  /** 单条 Topic 摘要硬上限,实际长度由重要性决定,默认 2048。 */
433
468
  topicSummaryMaxTokens?: number;
434
469
  /** 未传 getHistoryWindow 模型窗口时使用,默认 256K。 */
@@ -494,6 +529,10 @@ interface MemoryConfig {
494
529
  * 不阻塞启动;嵌入式单进程使用时建议保持开启,否则 LanceDB 版本目录会无限膨胀、启动越来越慢。
495
530
  */
496
531
  autoOptimizeOnInit?: boolean;
532
+ /** init 后是否在后台幂等执行一次 0.4 历史数据迁移(含历史图片剥离),默认 true。 */
533
+ autoMigrateLegacyDataOnInit?: boolean;
534
+ /** init 后是否后台扫描未完成的原始窗口并恢复自动压缩,默认 true。 */
535
+ autoResumeCompressionOnInit?: boolean;
497
536
  /**
498
537
  * 运行期定期压实的间隔(毫秒,默认 6 小时;设为 0 或负数关闭定时任务)。
499
538
  * 长驻服务不重启时版本仍会随写入累积,靠该定时任务周期性回收。
@@ -688,13 +727,19 @@ declare class MemoryManager {
688
727
  private sessionSweepTimer?;
689
728
  private optimizeRunning;
690
729
  private optimizeTask?;
730
+ private startupMaintenanceTask?;
691
731
  private destroyTask?;
692
732
  private readonly hydration;
693
733
  private readonly pendingWrites;
694
734
  private readonly topicCompactions;
735
+ private readonly backgroundCompressions;
695
736
  private warnedDefaultModelContext;
696
737
  constructor(config: MemoryConfig);
697
738
  init(): Promise<void>;
739
+ private runStartupMaintenance;
740
+ private getPersistedRawTailTokens;
741
+ private migrateLegacyDataOnce;
742
+ private legacyMigrationMarkerPath;
698
743
  /** 后台压实的统一入口:防重入(上一轮未结束则跳过),失败仅告警不影响服务。*/
699
744
  private runBackgroundOptimize;
700
745
  /**
@@ -706,6 +751,8 @@ declare class MemoryManager {
706
751
  private isSessionBusy;
707
752
  private waitForPendingWrites;
708
753
  private queueSessionWrite;
754
+ private scheduleBackgroundCompression;
755
+ private waitForBackgroundCompressionIdle;
709
756
  updateChat(messages: RawMessage[], opts?: UpdateChatOptions): Promise<void>;
710
757
  private doUpdateChat;
711
758
  flushChat(sessionId?: string, opts?: {
@@ -1023,4 +1070,4 @@ type RelationType = (typeof DEFAULT_RELATION_TYPES)[number];
1023
1070
  declare const KIND_CONVERSATION = "conversation";
1024
1071
  declare const KIND_KNOWLEDGE = "knowledge";
1025
1072
 
1026
- export { type AddDocumentOptions, type BuildGraphMode, type Chunk, type ChunkHit, type ChunkPiece, type CompressOutput, type ContentPart, DEFAULT_NODE_TYPES, DEFAULT_RELATION_TYPES, type Document, type DomainIds, type Entity, type EntityMeta, type Fact, type FactLevel, type Filter, type FilterCondition, type GetHistoryWindowOptions, KIND_CONVERSATION, KIND_KNOWLEDGE, type KnowledgeSearchOptions, type KnowledgeSearchResult, type MemoryBlockingCompressionEvent, type MemoryBlockingCompressionReason, type MemoryConfig, type MemoryContextBudget, type MemoryContextWindow, type MemoryContextWindowUsage, MemoryManager, type MemoryMigrationReport, type MemoryRawMessage, MemoryStore, type MessageType, type NodeType, type PageParams, type Paginated, type ProviderCapabilities, type ProviderKind, type RawMessage, type Relation, type RelationType, type SearchMode, type SearchOptions, type SearchResult, type SearchScope, type Session, type SessionEntry, type SessionSearchOptions, type SessionView, type StorageOptimizeResult, type StoredMessage, type Topic, type UpdateChatOptions, type UpdateEntityOptions, type UpdateSessionOptions, type VectorStoreProvider, calculateContextBudget };
1073
+ export { type AddDocumentOptions, type BuildGraphMode, type Chunk, type ChunkHit, type ChunkPiece, type CompressOutput, type CompressionRole, type ContentPart, DEFAULT_NODE_TYPES, DEFAULT_RELATION_TYPES, type Document, type DomainIds, type Entity, type EntityMeta, type Fact, type FactLevel, type Filter, type FilterCondition, type GetHistoryWindowOptions, KIND_CONVERSATION, KIND_KNOWLEDGE, type KnowledgeSearchOptions, type KnowledgeSearchResult, type MemoryBlockingCompressionEvent, type MemoryBlockingCompressionReason, type MemoryCompressionEvent, type MemoryCompressionKind, type MemoryCompressionMode, type MemoryCompressionPhase, type MemoryConfig, type MemoryContextBudget, type MemoryContextWindow, type MemoryContextWindowUsage, MemoryManager, type MemoryMigrationReport, type MemoryRawMessage, MemoryStore, type MessageType, type NodeType, type PageParams, type Paginated, type ProviderCapabilities, type ProviderKind, type RawMessage, type Relation, type RelationType, type SearchMode, type SearchOptions, type SearchResult, type SearchScope, type Session, type SessionEntry, type SessionSearchOptions, type SessionView, type StorageOptimizeResult, type StoredMessage, type Topic, type UpdateChatOptions, type UpdateEntityOptions, type UpdateSessionOptions, type VectorStoreProvider, calculateContextBudget };
package/dist/index.js CHANGED
@@ -1,4 +1,6 @@
1
1
  // src/memory.manager.ts
2
+ import * as fs from "node:fs/promises";
3
+ import * as path2 from "node:path";
2
4
  import { v4 as uuidv44 } from "uuid";
3
5
 
4
6
  // src/config.ts
@@ -47,7 +49,7 @@ function resolveConfig(config) {
47
49
  "topicCompactionSyncRatio"
48
50
  ),
49
51
  contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
50
- precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
52
+ precompressionRatio: ratio(config.precompressionRatio ?? 0.6, "precompressionRatio"),
51
53
  compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.7, "compressionBatchRatio"),
52
54
  compressionBatchTokenLimit: Math.max(0, Math.floor(config.compressionBatchTokenLimit ?? 0)),
53
55
  compressionMaxRetries: nonNegativeInt(
@@ -58,6 +60,16 @@ function resolveConfig(config) {
58
60
  config.compressionRetryBaseDelayMs ?? 500,
59
61
  "compressionRetryBaseDelayMs"
60
62
  ),
63
+ backgroundCompressionMaxRetries: nonNegativeInt(
64
+ config.backgroundCompressionMaxRetries ?? 2,
65
+ "backgroundCompressionMaxRetries"
66
+ ),
67
+ backgroundCompressionRetryBaseDelayMs: nonNegativeInt(
68
+ config.backgroundCompressionRetryBaseDelayMs ?? 1e3,
69
+ "backgroundCompressionRetryBaseDelayMs"
70
+ ),
71
+ onCompressionEvent: config.onCompressionEvent ?? (() => {
72
+ }),
61
73
  topicSummaryMaxTokens: positiveInt(
62
74
  config.topicSummaryMaxTokens ?? config.detailMaxTokens ?? 2048,
63
75
  "topicSummaryMaxTokens"
@@ -94,6 +106,8 @@ function resolveConfig(config) {
94
106
  graphExtractConcurrency: config.graphExtractConcurrency ?? 2,
95
107
  graphBuildTimeoutMs: config.graphBuildTimeoutMs ?? 12e4,
96
108
  autoOptimizeOnInit: config.autoOptimizeOnInit ?? true,
109
+ autoMigrateLegacyDataOnInit: config.autoMigrateLegacyDataOnInit ?? true,
110
+ autoResumeCompressionOnInit: config.autoResumeCompressionOnInit ?? true,
97
111
  autoOptimizeIntervalMs: config.autoOptimizeIntervalMs ?? 6 * 36e5,
98
112
  optimizeVersionRetentionMs: config.optimizeVersionRetentionMs ?? 0,
99
113
  restoreConcurrency: config.restoreConcurrency ?? 8
@@ -2315,6 +2329,300 @@ function isRecord2(value) {
2315
2329
  // src/manager/compress.manager.ts
2316
2330
  import { v4 as uuidv4 } from "uuid";
2317
2331
 
2332
+ // src/compression-message.ts
2333
+ var INTERNAL_METADATA_KEY = "__ppagentMemory";
2334
+ function addCompressionHints(metadata, message) {
2335
+ const compressionGroupId = normalizeGroupId(message.compressionGroupId);
2336
+ const compressionRole = normalizeRole(message.compressionRole);
2337
+ if (!compressionGroupId && !compressionRole) return metadata;
2338
+ const existing = isRecord3(metadata[INTERNAL_METADATA_KEY]) ? metadata[INTERNAL_METADATA_KEY] : {};
2339
+ return {
2340
+ ...metadata,
2341
+ [INTERNAL_METADATA_KEY]: {
2342
+ ...existing,
2343
+ ...compressionGroupId && { compressionGroupId },
2344
+ ...compressionRole && { compressionRole }
2345
+ }
2346
+ };
2347
+ }
2348
+ function readCompressionHints(message) {
2349
+ const metadata = parseMetadata(message.metadata);
2350
+ const internal = isRecord3(metadata[INTERNAL_METADATA_KEY]) ? metadata[INTERNAL_METADATA_KEY] : {};
2351
+ const { [INTERNAL_METADATA_KEY]: _internal, ...publicMetadata } = metadata;
2352
+ const explicitCompressionRole = normalizeRole(internal.compressionRole);
2353
+ return {
2354
+ metadata: publicMetadata,
2355
+ compressionGroupId: normalizeGroupId(internal.compressionGroupId),
2356
+ explicitCompressionRole,
2357
+ compressionRole: explicitCompressionRole ?? normalizeRole(metadata.role) ?? inferRole(message.talkerId)
2358
+ };
2359
+ }
2360
+ function inferRole(talkerId) {
2361
+ const normalized = talkerId.trim().toLowerCase();
2362
+ if (normalized === "assistant") return "assistant";
2363
+ if (normalized === "tool") return "tool";
2364
+ if (normalized === "system") return "system";
2365
+ return "user";
2366
+ }
2367
+ function normalizeGroupId(value) {
2368
+ return typeof value === "string" && value.trim() ? value.trim() : void 0;
2369
+ }
2370
+ function normalizeRole(value) {
2371
+ return value === "user" || value === "assistant" || value === "tool" || value === "system" ? value : void 0;
2372
+ }
2373
+ function parseMetadata(value) {
2374
+ try {
2375
+ const parsed = JSON.parse(value);
2376
+ return isRecord3(parsed) ? parsed : {};
2377
+ } catch {
2378
+ return {};
2379
+ }
2380
+ }
2381
+ function isRecord3(value) {
2382
+ return !!value && typeof value === "object" && !Array.isArray(value);
2383
+ }
2384
+
2385
+ // src/manager/session.cache.ts
2386
+ var SessionCache = class {
2387
+ config;
2388
+ sessions = /* @__PURE__ */ new Map();
2389
+ topicViews = /* @__PURE__ */ new Map();
2390
+ legacyHistoryWindows = /* @__PURE__ */ new Map();
2391
+ constructor(config) {
2392
+ this.config = config;
2393
+ }
2394
+ getEntry(sessionId) {
2395
+ const entry = this.sessions.get(sessionId);
2396
+ if (entry) entry.lastAccessAt = Date.now();
2397
+ return entry;
2398
+ }
2399
+ peekEntry(sessionId) {
2400
+ return this.sessions.get(sessionId);
2401
+ }
2402
+ getOrCreateEntry(sessionId, chatId, userId) {
2403
+ let entry = this.sessions.get(sessionId);
2404
+ if (!entry) {
2405
+ entry = {
2406
+ messages: [],
2407
+ totalTokens: 0,
2408
+ topics: [],
2409
+ topicTokens: 0,
2410
+ ids: { chatId, userId },
2411
+ lastAccessAt: Date.now()
2412
+ };
2413
+ this.sessions.set(sessionId, entry);
2414
+ } else {
2415
+ entry.ids = { chatId, userId };
2416
+ entry.lastAccessAt = Date.now();
2417
+ }
2418
+ return entry;
2419
+ }
2420
+ hydrate(sessionId, chatId, userId, messages, topics) {
2421
+ const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2422
+ entry.messages = sortMessages(dedupeMessages(messages));
2423
+ entry.totalTokens = sumMessageTokens(entry.messages);
2424
+ this.setTopics(sessionId, topics, false);
2425
+ return entry;
2426
+ }
2427
+ upsertMessages(sessionId, messages, chatId, userId) {
2428
+ const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2429
+ const byId = new Map(entry.messages.map((m) => [m.messageId, m]));
2430
+ for (const message of messages) byId.set(message.messageId, message);
2431
+ entry.messages = sortMessages([...byId.values()]);
2432
+ entry.totalTokens = sumMessageTokens(entry.messages);
2433
+ return entry;
2434
+ }
2435
+ /** @deprecated 0.3.x 单元/API 兼容;新代码使用 upsertMessages。 */
2436
+ addMessages(sessionId, messages, chatId, userId) {
2437
+ const entry = this.upsertMessages(sessionId, messages, chatId, userId);
2438
+ return entry.totalTokens >= this.config.sessionTokenLimit;
2439
+ }
2440
+ /** @deprecated 新压缩链按 messageId 精确移除。 */
2441
+ clearMessages(sessionId, keepAfter) {
2442
+ const entry = this.sessions.get(sessionId);
2443
+ if (!entry) return;
2444
+ entry.messages = keepAfter == null ? [] : entry.messages.filter((message) => message.createdAt > keepAfter);
2445
+ entry.totalTokens = sumMessageTokens(entry.messages);
2446
+ }
2447
+ removeMessages(sessionId, messageIds) {
2448
+ const entry = this.sessions.get(sessionId);
2449
+ if (!entry) return;
2450
+ const ids = new Set(messageIds);
2451
+ entry.messages = entry.messages.filter((m) => !ids.has(m.messageId));
2452
+ entry.totalTokens = sumMessageTokens(entry.messages);
2453
+ entry.lastAccessAt = Date.now();
2454
+ }
2455
+ getSessionMessages(sessionId) {
2456
+ return this.getEntry(sessionId)?.messages ?? [];
2457
+ }
2458
+ selectOldestMessageBatch(sessionId, targetTokens) {
2459
+ const messages = this.getSessionMessages(sessionId);
2460
+ if (messages.length === 0 || targetTokens <= 0) return [];
2461
+ const groups = groupConversationMessages(messages);
2462
+ const selectableGroups = groups.length > 1 ? groups.slice(0, -1) : groups;
2463
+ const selected = [];
2464
+ let tokens = 0;
2465
+ for (const group of selectableGroups) {
2466
+ const nextTokens = tokens + sumMessageTokens(group);
2467
+ if (selected.length > 0 && Math.abs(targetTokens - tokens) <= Math.abs(targetTokens - nextTokens)) {
2468
+ break;
2469
+ }
2470
+ selected.push(...group);
2471
+ tokens = nextTokens;
2472
+ if (tokens >= targetTokens) break;
2473
+ }
2474
+ return selected;
2475
+ }
2476
+ setTopics(sessionId, topics, truncate = true) {
2477
+ const entry = this.sessions.get(sessionId);
2478
+ if (!entry) return;
2479
+ const ordered = [...topics].sort(topicOrder);
2480
+ const kept = [];
2481
+ let used = 0;
2482
+ for (let i = ordered.length - 1; i >= 0; i--) {
2483
+ const topic = normalizeTopic(ordered[i]);
2484
+ if (truncate && used + topic.tokens > this.config.compressedContextTokenLimit && kept.length > 0) break;
2485
+ kept.push(topic);
2486
+ used += topic.tokens;
2487
+ }
2488
+ entry.topics = kept.reverse();
2489
+ entry.topicTokens = used;
2490
+ entry.lastAccessAt = Date.now();
2491
+ this.topicViews.delete(sessionId);
2492
+ }
2493
+ appendTopic(sessionId, topic) {
2494
+ const entry = this.sessions.get(sessionId);
2495
+ if (!entry) return;
2496
+ this.setTopics(sessionId, [...entry.topics, topic], false);
2497
+ }
2498
+ replaceTopics(sessionId, removedIds, replacement) {
2499
+ const entry = this.sessions.get(sessionId);
2500
+ if (!entry) return;
2501
+ const ids = new Set(removedIds);
2502
+ this.setTopics(
2503
+ sessionId,
2504
+ [...entry.topics.filter((topic) => !ids.has(topic.summaryId)), replacement],
2505
+ false
2506
+ );
2507
+ }
2508
+ getTopics(sessionId) {
2509
+ return this.getEntry(sessionId)?.topics ?? [];
2510
+ }
2511
+ getTopicView(sessionId, tokenLimit) {
2512
+ return this.topicViews.get(sessionId)?.get(tokenLimit);
2513
+ }
2514
+ setTopicView(sessionId, tokenLimit, topics) {
2515
+ let views = this.topicViews.get(sessionId);
2516
+ if (!views) {
2517
+ views = /* @__PURE__ */ new Map();
2518
+ this.topicViews.set(sessionId, views);
2519
+ }
2520
+ views.set(tokenLimit, [...topics].map(normalizeTopic).sort(topicOrder));
2521
+ }
2522
+ buildCompressedContext(sessionId, topics) {
2523
+ return (topics ?? this.getTopics(sessionId)).map((topic) => topic.title ? `## ${topic.title}
2524
+ ${topic.summary}` : topic.summary).join("\n\n");
2525
+ }
2526
+ /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
2527
+ buildHistoryWindow(sessionId, groups) {
2528
+ const [detailCount, summaryCount, conciseCount] = this.config.topicRatio;
2529
+ const values = [
2530
+ ...groups.detail.slice(0, detailCount).map((topic) => topic.detail ?? topic.summary),
2531
+ ...groups.summary.slice(0, summaryCount).map((topic) => topic.summary),
2532
+ ...groups.concise.slice(0, conciseCount).map((topic) => topic.concise ?? topic.summary)
2533
+ ];
2534
+ let remaining = this.config.historyWindowTokenLimit;
2535
+ const kept = [];
2536
+ for (const value of values) {
2537
+ const tokens = countTokens(value);
2538
+ if (tokens > remaining) break;
2539
+ kept.push(value);
2540
+ remaining -= tokens;
2541
+ }
2542
+ this.legacyHistoryWindows.set(sessionId, kept.join("\n\n"));
2543
+ }
2544
+ /** @deprecated MemoryManager.getHistoryWindow 返回结构化窗口。 */
2545
+ getHistoryWindow(sessionId) {
2546
+ return this.legacyHistoryWindows.get(sessionId) ?? this.buildCompressedContext(sessionId);
2547
+ }
2548
+ /** @deprecated 仅为 0.3.x 兼容。 */
2549
+ setHistoryWindow(sessionId, content) {
2550
+ this.legacyHistoryWindows.set(sessionId, content);
2551
+ }
2552
+ setModelContextTokens(sessionId, modelContextTokens) {
2553
+ const entry = this.sessions.get(sessionId);
2554
+ if (!entry) return;
2555
+ entry.lastModelContextTokens = modelContextTokens;
2556
+ entry.lastAccessAt = Date.now();
2557
+ }
2558
+ getAllSessionIds() {
2559
+ return [...this.sessions.keys()];
2560
+ }
2561
+ delete(sessionId) {
2562
+ this.sessions.delete(sessionId);
2563
+ this.topicViews.delete(sessionId);
2564
+ this.legacyHistoryWindows.delete(sessionId);
2565
+ }
2566
+ evictIdle(now, ttlMs, isBusy) {
2567
+ if (ttlMs <= 0) return [];
2568
+ const evicted = [];
2569
+ for (const [sessionId, entry] of this.sessions) {
2570
+ if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
2571
+ this.sessions.delete(sessionId);
2572
+ this.topicViews.delete(sessionId);
2573
+ evicted.push(sessionId);
2574
+ }
2575
+ return evicted;
2576
+ }
2577
+ };
2578
+ function groupConversationMessages(messages) {
2579
+ const groups = [];
2580
+ let current = [];
2581
+ let explicitGroupId;
2582
+ for (const message of messages) {
2583
+ const hints = readCompressionHints(message);
2584
+ const startsExplicitGroup = hints.compressionGroupId !== explicitGroupId && (hints.compressionGroupId != null || explicitGroupId != null);
2585
+ const startsInferredGroup = hints.compressionGroupId == null && (hints.compressionRole === "user" || hints.compressionRole === "system");
2586
+ if (current.length > 0 && (startsExplicitGroup || startsInferredGroup)) {
2587
+ groups.push(current);
2588
+ current = [];
2589
+ }
2590
+ current.push(message);
2591
+ explicitGroupId = hints.compressionGroupId;
2592
+ }
2593
+ if (current.length > 0) groups.push(current);
2594
+ return groups;
2595
+ }
2596
+ function effectiveUsage(message) {
2597
+ if (Number.isFinite(message.usage) && message.usage > 0) return Math.floor(message.usage);
2598
+ return Math.max(
2599
+ 1,
2600
+ (message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0) + (message.contextPayload?.length ?? 0)) * 2
2601
+ );
2602
+ }
2603
+ function sumMessageTokens(messages) {
2604
+ return messages.reduce((sum, message) => sum + effectiveUsage(message), 0);
2605
+ }
2606
+ function dedupeMessages(messages) {
2607
+ return [...new Map(messages.map((message) => [message.messageId, message])).values()];
2608
+ }
2609
+ function sortMessages(messages) {
2610
+ return messages.sort(
2611
+ (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
2612
+ );
2613
+ }
2614
+ function topicOrder(a, b) {
2615
+ return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2616
+ }
2617
+ function normalizeTopic(topic) {
2618
+ const summary = topic.summary || topic.detail || topic.concise || "";
2619
+ return {
2620
+ ...topic,
2621
+ summary,
2622
+ tokens: topic.tokens > 0 ? topic.tokens : Math.max(1, countTokens(summary))
2623
+ };
2624
+ }
2625
+
2318
2626
  // src/manager/semaphore.ts
2319
2627
  var Semaphore = class {
2320
2628
  count;
@@ -2349,6 +2657,20 @@ var Semaphore = class {
2349
2657
  }
2350
2658
  };
2351
2659
 
2660
+ // src/compression-events.ts
2661
+ function emitCompressionEvent(config, event) {
2662
+ try {
2663
+ const returned = config.onCompressionEvent(event);
2664
+ if (returned && typeof returned.then === "function") {
2665
+ void Promise.resolve(returned).catch((error) => {
2666
+ console.warn("[memory] onCompressionEvent callback rejected:", error);
2667
+ });
2668
+ }
2669
+ } catch (error) {
2670
+ console.warn("[memory] onCompressionEvent callback failed:", error);
2671
+ }
2672
+ }
2673
+
2352
2674
  // src/manager/compress.manager.ts
2353
2675
  var CompressManager = class {
2354
2676
  constructor(config, store, grafeo, llm, embed, sessionCache) {
@@ -2363,19 +2685,70 @@ var CompressManager = class {
2363
2685
  semaphore;
2364
2686
  sessionChain = /* @__PURE__ */ new Map();
2365
2687
  backgroundGraphs = /* @__PURE__ */ new Set();
2366
- triggerCompress(sessionId, force = false, waitGraph = false, rawLimit) {
2367
- return this.enqueueSessionTask(
2688
+ triggerCompress(sessionId, force = false, waitGraph = false, rawLimit, mode = "background") {
2689
+ const startedAt = Date.now();
2690
+ const task = this.enqueueSessionTask(
2368
2691
  sessionId,
2369
- () => this.doCompress(sessionId, force, waitGraph, rawLimit)
2692
+ () => this.doCompress(sessionId, force, waitGraph, rawLimit, mode)
2370
2693
  );
2694
+ return task.catch((error) => {
2695
+ emitCompressionEvent(this.config, {
2696
+ sessionId,
2697
+ kind: "raw",
2698
+ mode,
2699
+ phase: "failure",
2700
+ attempt: 1,
2701
+ maxAttempts: this.config.compressionMaxRetries + 1,
2702
+ durationMs: Date.now() - startedAt,
2703
+ error: formatError(error)
2704
+ });
2705
+ throw error;
2706
+ });
2371
2707
  }
2372
2708
  /** 按给定预算收敛 Topic;persist=false 时只缓存当前模型使用的临时视图。 */
2373
- triggerTopicCompaction(sessionId, tokenLimit, persist) {
2374
- return this.enqueueSessionTask(
2709
+ triggerTopicCompaction(sessionId, tokenLimit, persist, mode = "background") {
2710
+ const startedAt = Date.now();
2711
+ emitCompressionEvent(this.config, {
2712
+ sessionId,
2713
+ kind: "topics",
2714
+ mode,
2715
+ phase: "start",
2716
+ attempt: 1,
2717
+ maxAttempts: this.config.compressionMaxRetries + 1
2718
+ });
2719
+ const task = this.enqueueSessionTask(
2375
2720
  sessionId,
2376
- () => this.compactOldTopics(sessionId, Math.max(1, Math.floor(tokenLimit)), persist)
2721
+ () => this.compactOldTopics(
2722
+ sessionId,
2723
+ Math.max(1, Math.floor(tokenLimit)),
2724
+ persist,
2725
+ mode
2726
+ )
2377
2727
  );
2378
- }
2728
+ return task.then(() => {
2729
+ emitCompressionEvent(this.config, {
2730
+ sessionId,
2731
+ kind: "topics",
2732
+ mode,
2733
+ phase: "success",
2734
+ attempt: 1,
2735
+ maxAttempts: this.config.compressionMaxRetries + 1,
2736
+ durationMs: Date.now() - startedAt
2737
+ });
2738
+ }, (error) => {
2739
+ emitCompressionEvent(this.config, {
2740
+ sessionId,
2741
+ kind: "topics",
2742
+ mode,
2743
+ phase: "failure",
2744
+ attempt: 1,
2745
+ maxAttempts: this.config.compressionMaxRetries + 1,
2746
+ durationMs: Date.now() - startedAt,
2747
+ error: formatError(error)
2748
+ });
2749
+ throw error;
2750
+ });
2751
+ }
2379
2752
  enqueueSessionTask(sessionId, task) {
2380
2753
  const previous = this.sessionChain.get(sessionId) ?? Promise.resolve();
2381
2754
  const next = previous.then(
@@ -2403,7 +2776,7 @@ var CompressManager = class {
2403
2776
  ]);
2404
2777
  }
2405
2778
  }
2406
- async doCompress(sessionId, force, waitGraph, rawLimitOverride) {
2779
+ async doCompress(sessionId, force, waitGraph, rawLimitOverride, mode) {
2407
2780
  const entry = this.sessionCache.getEntry(sessionId);
2408
2781
  if (!entry || entry.messages.length === 0) return;
2409
2782
  const rawLimit = Math.max(1, rawLimitOverride ?? this.rawLimit(entry.lastModelContextTokens));
@@ -2418,12 +2791,29 @@ var CompressManager = class {
2418
2791
  }
2419
2792
  const messages = this.sessionCache.selectOldestMessageBatch(sessionId, batchTarget);
2420
2793
  if (messages.length === 0) return;
2794
+ const selectedTokens = messages.reduce(
2795
+ (sum, message) => sum + effectiveUsage(message),
2796
+ 0
2797
+ );
2798
+ const startedAt = Date.now();
2799
+ emitCompressionEvent(this.config, {
2800
+ sessionId,
2801
+ kind: "raw",
2802
+ mode,
2803
+ phase: "start",
2804
+ attempt: 1,
2805
+ maxAttempts: this.config.compressionMaxRetries + 1,
2806
+ inputTokens: entry.totalTokens,
2807
+ selectedTokens
2808
+ });
2421
2809
  let result;
2422
2810
  try {
2423
2811
  result = await this.summarizeWithRetry(
2424
2812
  sessionId,
2425
2813
  "raw-message compression",
2426
- () => this.llm.summarizeMessages(messages)
2814
+ () => this.llm.summarizeMessages(messages),
2815
+ "raw",
2816
+ mode
2427
2817
  );
2428
2818
  } catch (error) {
2429
2819
  console.error(
@@ -2462,7 +2852,8 @@ var CompressManager = class {
2462
2852
  await this.compactOldTopics(
2463
2853
  sessionId,
2464
2854
  this.config.compressedContextTokenLimit,
2465
- true
2855
+ true,
2856
+ mode
2466
2857
  );
2467
2858
  const graphTask = this.extractAndPersistGraph(
2468
2859
  messages,
@@ -2480,8 +2871,20 @@ var CompressManager = class {
2480
2871
  this.backgroundGraphs.add(tracked);
2481
2872
  tracked.finally(() => this.backgroundGraphs.delete(tracked));
2482
2873
  }
2874
+ emitCompressionEvent(this.config, {
2875
+ sessionId,
2876
+ kind: "raw",
2877
+ mode,
2878
+ phase: "success",
2879
+ attempt: 1,
2880
+ maxAttempts: this.config.compressionMaxRetries + 1,
2881
+ inputTokens: entry.totalTokens + selectedTokens,
2882
+ selectedTokens,
2883
+ outputTokens: topic.tokens,
2884
+ durationMs: Date.now() - startedAt
2885
+ });
2483
2886
  }
2484
- async compactOldTopics(sessionId, tokenLimit, persist) {
2887
+ async compactOldTopics(sessionId, tokenLimit, persist, mode) {
2485
2888
  let transientTopics = persist ? void 0 : this.sessionCache.getTopicView(sessionId, tokenLimit) ?? [...this.sessionCache.getEntry(sessionId)?.topics ?? []];
2486
2889
  while (true) {
2487
2890
  const entry = this.sessionCache.getEntry(sessionId);
@@ -2516,7 +2919,9 @@ var CompressManager = class {
2516
2919
  const result = await this.summarizeWithRetry(
2517
2920
  sessionId,
2518
2921
  "Topic compaction",
2519
- () => this.llm.summarizeTopics(selected, maxSummaryTokens)
2922
+ () => this.llm.summarizeTopics(selected, maxSummaryTokens),
2923
+ "topics",
2924
+ mode
2520
2925
  );
2521
2926
  const summary = result.summary.trim();
2522
2927
  const vector = persist ? await this.embed.embedOne(summary).catch(() => []) : [];
@@ -2555,7 +2960,7 @@ var CompressManager = class {
2555
2960
  transientTopics = [
2556
2961
  ...topics.filter((topic) => !ids.has(topic.summaryId)),
2557
2962
  rollup
2558
- ].sort(topicOrder);
2963
+ ].sort(topicOrder2);
2559
2964
  }
2560
2965
  }
2561
2966
  }
@@ -2596,7 +3001,7 @@ var CompressManager = class {
2596
3001
  * This outer layer retries semantic compression failures only: malformed model
2597
3002
  * content, missing fields and empty summaries. That avoids retry multiplication.
2598
3003
  */
2599
- async summarizeWithRetry(sessionId, label, operation) {
3004
+ async summarizeWithRetry(sessionId, label, operation, kind, mode) {
2600
3005
  let lastError;
2601
3006
  for (let attempt = 0; attempt <= this.config.compressionMaxRetries; attempt++) {
2602
3007
  try {
@@ -2617,6 +3022,16 @@ var CompressManager = class {
2617
3022
  console.warn(
2618
3023
  `[CompressManager] ${label} failed for session ${sessionId}; retrying ${attempt + 1}/${this.config.compressionMaxRetries} after ${delayMs}ms: ` + formatError(error)
2619
3024
  );
3025
+ emitCompressionEvent(this.config, {
3026
+ sessionId,
3027
+ kind,
3028
+ mode,
3029
+ phase: "retry",
3030
+ attempt: attempt + 2,
3031
+ maxAttempts: this.config.compressionMaxRetries + 1,
3032
+ retryKind: "semantic",
3033
+ error: formatError(error)
3034
+ });
2620
3035
  if (delayMs > 0) await delay(delayMs);
2621
3036
  }
2622
3037
  }
@@ -2626,7 +3041,7 @@ var CompressManager = class {
2626
3041
  function sumTopicTokens(topics) {
2627
3042
  return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
2628
3043
  }
2629
- function topicOrder(a, b) {
3044
+ function topicOrder2(a, b) {
2630
3045
  return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2631
3046
  }
2632
3047
  function formatError(error) {
@@ -3192,228 +3607,6 @@ function withTimeout2(promise, timeoutMs, label) {
3192
3607
  return Promise.race([promise, timeout]).finally(() => clearTimeout(timer));
3193
3608
  }
3194
3609
 
3195
- // src/manager/session.cache.ts
3196
- var SessionCache = class {
3197
- config;
3198
- sessions = /* @__PURE__ */ new Map();
3199
- topicViews = /* @__PURE__ */ new Map();
3200
- legacyHistoryWindows = /* @__PURE__ */ new Map();
3201
- constructor(config) {
3202
- this.config = config;
3203
- }
3204
- getEntry(sessionId) {
3205
- const entry = this.sessions.get(sessionId);
3206
- if (entry) entry.lastAccessAt = Date.now();
3207
- return entry;
3208
- }
3209
- peekEntry(sessionId) {
3210
- return this.sessions.get(sessionId);
3211
- }
3212
- getOrCreateEntry(sessionId, chatId, userId) {
3213
- let entry = this.sessions.get(sessionId);
3214
- if (!entry) {
3215
- entry = {
3216
- messages: [],
3217
- totalTokens: 0,
3218
- topics: [],
3219
- topicTokens: 0,
3220
- ids: { chatId, userId },
3221
- lastAccessAt: Date.now()
3222
- };
3223
- this.sessions.set(sessionId, entry);
3224
- } else {
3225
- entry.ids = { chatId, userId };
3226
- entry.lastAccessAt = Date.now();
3227
- }
3228
- return entry;
3229
- }
3230
- hydrate(sessionId, chatId, userId, messages, topics) {
3231
- const entry = this.getOrCreateEntry(sessionId, chatId, userId);
3232
- entry.messages = sortMessages(dedupeMessages(messages));
3233
- entry.totalTokens = sumMessageTokens(entry.messages);
3234
- this.setTopics(sessionId, topics, false);
3235
- return entry;
3236
- }
3237
- upsertMessages(sessionId, messages, chatId, userId) {
3238
- const entry = this.getOrCreateEntry(sessionId, chatId, userId);
3239
- const byId = new Map(entry.messages.map((m) => [m.messageId, m]));
3240
- for (const message of messages) byId.set(message.messageId, message);
3241
- entry.messages = sortMessages([...byId.values()]);
3242
- entry.totalTokens = sumMessageTokens(entry.messages);
3243
- return entry;
3244
- }
3245
- /** @deprecated 0.3.x 单元/API 兼容;新代码使用 upsertMessages。 */
3246
- addMessages(sessionId, messages, chatId, userId) {
3247
- const entry = this.upsertMessages(sessionId, messages, chatId, userId);
3248
- return entry.totalTokens >= this.config.sessionTokenLimit;
3249
- }
3250
- /** @deprecated 新压缩链按 messageId 精确移除。 */
3251
- clearMessages(sessionId, keepAfter) {
3252
- const entry = this.sessions.get(sessionId);
3253
- if (!entry) return;
3254
- entry.messages = keepAfter == null ? [] : entry.messages.filter((message) => message.createdAt > keepAfter);
3255
- entry.totalTokens = sumMessageTokens(entry.messages);
3256
- }
3257
- removeMessages(sessionId, messageIds) {
3258
- const entry = this.sessions.get(sessionId);
3259
- if (!entry) return;
3260
- const ids = new Set(messageIds);
3261
- entry.messages = entry.messages.filter((m) => !ids.has(m.messageId));
3262
- entry.totalTokens = sumMessageTokens(entry.messages);
3263
- entry.lastAccessAt = Date.now();
3264
- }
3265
- getSessionMessages(sessionId) {
3266
- return this.getEntry(sessionId)?.messages ?? [];
3267
- }
3268
- selectOldestMessageBatch(sessionId, targetTokens) {
3269
- const messages = this.getSessionMessages(sessionId);
3270
- if (messages.length === 0 || targetTokens <= 0) return [];
3271
- const selected = [];
3272
- let tokens = 0;
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
- }
3279
- selected.push(message);
3280
- tokens = nextTokens;
3281
- if (tokens >= targetTokens) break;
3282
- }
3283
- return selected;
3284
- }
3285
- setTopics(sessionId, topics, truncate = true) {
3286
- const entry = this.sessions.get(sessionId);
3287
- if (!entry) return;
3288
- const ordered = [...topics].sort(topicOrder2);
3289
- const kept = [];
3290
- let used = 0;
3291
- for (let i = ordered.length - 1; i >= 0; i--) {
3292
- const topic = normalizeTopic(ordered[i]);
3293
- if (truncate && used + topic.tokens > this.config.compressedContextTokenLimit && kept.length > 0) break;
3294
- kept.push(topic);
3295
- used += topic.tokens;
3296
- }
3297
- entry.topics = kept.reverse();
3298
- entry.topicTokens = used;
3299
- entry.lastAccessAt = Date.now();
3300
- this.topicViews.delete(sessionId);
3301
- }
3302
- appendTopic(sessionId, topic) {
3303
- const entry = this.sessions.get(sessionId);
3304
- if (!entry) return;
3305
- this.setTopics(sessionId, [...entry.topics, topic], false);
3306
- }
3307
- replaceTopics(sessionId, removedIds, replacement) {
3308
- const entry = this.sessions.get(sessionId);
3309
- if (!entry) return;
3310
- const ids = new Set(removedIds);
3311
- this.setTopics(
3312
- sessionId,
3313
- [...entry.topics.filter((topic) => !ids.has(topic.summaryId)), replacement],
3314
- false
3315
- );
3316
- }
3317
- getTopics(sessionId) {
3318
- return this.getEntry(sessionId)?.topics ?? [];
3319
- }
3320
- getTopicView(sessionId, tokenLimit) {
3321
- return this.topicViews.get(sessionId)?.get(tokenLimit);
3322
- }
3323
- setTopicView(sessionId, tokenLimit, topics) {
3324
- let views = this.topicViews.get(sessionId);
3325
- if (!views) {
3326
- views = /* @__PURE__ */ new Map();
3327
- this.topicViews.set(sessionId, views);
3328
- }
3329
- views.set(tokenLimit, [...topics].map(normalizeTopic).sort(topicOrder2));
3330
- }
3331
- buildCompressedContext(sessionId, topics) {
3332
- return (topics ?? this.getTopics(sessionId)).map((topic) => topic.title ? `## ${topic.title}
3333
- ${topic.summary}` : topic.summary).join("\n\n");
3334
- }
3335
- /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
3336
- buildHistoryWindow(sessionId, groups) {
3337
- const [detailCount, summaryCount, conciseCount] = this.config.topicRatio;
3338
- const values = [
3339
- ...groups.detail.slice(0, detailCount).map((topic) => topic.detail ?? topic.summary),
3340
- ...groups.summary.slice(0, summaryCount).map((topic) => topic.summary),
3341
- ...groups.concise.slice(0, conciseCount).map((topic) => topic.concise ?? topic.summary)
3342
- ];
3343
- let remaining = this.config.historyWindowTokenLimit;
3344
- const kept = [];
3345
- for (const value of values) {
3346
- const tokens = countTokens(value);
3347
- if (tokens > remaining) break;
3348
- kept.push(value);
3349
- remaining -= tokens;
3350
- }
3351
- this.legacyHistoryWindows.set(sessionId, kept.join("\n\n"));
3352
- }
3353
- /** @deprecated MemoryManager.getHistoryWindow 返回结构化窗口。 */
3354
- getHistoryWindow(sessionId) {
3355
- return this.legacyHistoryWindows.get(sessionId) ?? this.buildCompressedContext(sessionId);
3356
- }
3357
- /** @deprecated 仅为 0.3.x 兼容。 */
3358
- setHistoryWindow(sessionId, content) {
3359
- this.legacyHistoryWindows.set(sessionId, content);
3360
- }
3361
- setModelContextTokens(sessionId, modelContextTokens) {
3362
- const entry = this.sessions.get(sessionId);
3363
- if (!entry) return;
3364
- entry.lastModelContextTokens = modelContextTokens;
3365
- entry.lastAccessAt = Date.now();
3366
- }
3367
- getAllSessionIds() {
3368
- return [...this.sessions.keys()];
3369
- }
3370
- delete(sessionId) {
3371
- this.sessions.delete(sessionId);
3372
- this.topicViews.delete(sessionId);
3373
- this.legacyHistoryWindows.delete(sessionId);
3374
- }
3375
- evictIdle(now, ttlMs, isBusy) {
3376
- if (ttlMs <= 0) return [];
3377
- const evicted = [];
3378
- for (const [sessionId, entry] of this.sessions) {
3379
- if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
3380
- this.sessions.delete(sessionId);
3381
- this.topicViews.delete(sessionId);
3382
- evicted.push(sessionId);
3383
- }
3384
- return evicted;
3385
- }
3386
- };
3387
- function effectiveUsage(message) {
3388
- if (Number.isFinite(message.usage) && message.usage > 0) return Math.floor(message.usage);
3389
- return Math.max(
3390
- 1,
3391
- (message.content.length + (message.parts?.length ?? 0) + message.metadata.length + (message.payload?.length ?? 0) + (message.contextPayload?.length ?? 0)) * 2
3392
- );
3393
- }
3394
- function sumMessageTokens(messages) {
3395
- return messages.reduce((sum, message) => sum + effectiveUsage(message), 0);
3396
- }
3397
- function dedupeMessages(messages) {
3398
- return [...new Map(messages.map((message) => [message.messageId, message])).values()];
3399
- }
3400
- function sortMessages(messages) {
3401
- return messages.sort(
3402
- (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
3403
- );
3404
- }
3405
- function topicOrder2(a, b) {
3406
- return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
3407
- }
3408
- function normalizeTopic(topic) {
3409
- const summary = topic.summary || topic.detail || topic.concise || "";
3410
- return {
3411
- ...topic,
3412
- summary,
3413
- tokens: topic.tokens > 0 ? topic.tokens : Math.max(1, countTokens(summary))
3414
- };
3415
- }
3416
-
3417
3610
  // src/memory.manager.ts
3418
3611
  var MemoryManager = class {
3419
3612
  config;
@@ -3430,10 +3623,12 @@ var MemoryManager = class {
3430
3623
  sessionSweepTimer;
3431
3624
  optimizeRunning = false;
3432
3625
  optimizeTask;
3626
+ startupMaintenanceTask;
3433
3627
  destroyTask;
3434
3628
  hydration = /* @__PURE__ */ new Map();
3435
3629
  pendingWrites = /* @__PURE__ */ new Map();
3436
3630
  topicCompactions = /* @__PURE__ */ new Map();
3631
+ backgroundCompressions = /* @__PURE__ */ new Map();
3437
3632
  warnedDefaultModelContext = false;
3438
3633
  constructor(config) {
3439
3634
  this.config = resolveConfig(config);
@@ -3488,6 +3683,73 @@ var MemoryManager = class {
3488
3683
  }, this.config.sessionSweepIntervalMs);
3489
3684
  this.sessionSweepTimer.unref?.();
3490
3685
  }
3686
+ const sessionIds = allSessions.map((session) => session.sessionId);
3687
+ const maintenance = this.runStartupMaintenance(sessionIds).catch((error) => {
3688
+ console.warn("[MemoryManager] startup maintenance failed (will retry next init):", error);
3689
+ });
3690
+ this.startupMaintenanceTask = maintenance;
3691
+ void maintenance.finally(() => {
3692
+ if (this.startupMaintenanceTask === maintenance) this.startupMaintenanceTask = void 0;
3693
+ });
3694
+ }
3695
+ async runStartupMaintenance(sessionIds) {
3696
+ if (this.config.autoMigrateLegacyDataOnInit) await this.migrateLegacyDataOnce();
3697
+ if (!this.config.autoResumeCompressionOnInit) return;
3698
+ sessionIds = [.../* @__PURE__ */ new Set([...sessionIds, ...await this.store.getAllSessionIds()])];
3699
+ if (sessionIds.length === 0) return;
3700
+ let cursor = 0;
3701
+ const workerCount = Math.min(this.config.restoreConcurrency, sessionIds.length);
3702
+ await Promise.all(Array.from({ length: workerCount }, async () => {
3703
+ while (cursor < sessionIds.length) {
3704
+ const sessionId = sessionIds[cursor++];
3705
+ const rawLimit = this.calculateWindowUsage(
3706
+ this.config.defaultModelContextTokens
3707
+ ).rawTokenLimit;
3708
+ const rawTokens = await this.getPersistedRawTailTokens(sessionId);
3709
+ if (rawTokens >= Math.floor(rawLimit * this.config.precompressionRatio)) {
3710
+ this.scheduleBackgroundCompression(sessionId, rawLimit, "startup");
3711
+ }
3712
+ }
3713
+ }));
3714
+ }
3715
+ async getPersistedRawTailTokens(sessionId) {
3716
+ const topics = await this.store.getTopicsBySession(sessionId);
3717
+ const latestTopic = topics.at(-1);
3718
+ const messages = latestTopic ? await this.store.getMessagesAfterBoundary(
3719
+ sessionId,
3720
+ latestTopic.endTime,
3721
+ latestTopic.endMessageId
3722
+ ) : await this.store.getAllMessagesBySession(sessionId);
3723
+ const since = this.config.maxHistoryAgeMs > 0 ? Date.now() - this.config.maxHistoryAgeMs : 0;
3724
+ return messages.reduce(
3725
+ (sum, message) => sum + (since === 0 || message.createdAt >= since ? effectiveUsage(message) : 0),
3726
+ 0
3727
+ );
3728
+ }
3729
+ async migrateLegacyDataOnce() {
3730
+ const marker = this.legacyMigrationMarkerPath();
3731
+ try {
3732
+ await fs.access(marker);
3733
+ return;
3734
+ } catch (error) {
3735
+ if (!isMissingFileError(error)) throw error;
3736
+ }
3737
+ const report = await this.store.migrateLegacyData();
3738
+ await fs.mkdir(path2.dirname(marker), { recursive: true });
3739
+ await fs.writeFile(marker, JSON.stringify({ completedAt: Date.now(), report }), {
3740
+ encoding: "utf8",
3741
+ flag: "wx"
3742
+ }).catch((error) => {
3743
+ if (!isExistingFileError(error)) throw error;
3744
+ });
3745
+ if (report.messagesUpdated > 0) {
3746
+ console.info(
3747
+ `[MemoryManager] startup migration repaired ${report.messagesUpdated} historical message(s).`
3748
+ );
3749
+ }
3750
+ }
3751
+ legacyMigrationMarkerPath() {
3752
+ return this.store.providerKind === "sqlite" ? `${this.config.sqlitePath}.ppagent-memory-v04-migrated` : path2.join(this.config.lancedbPath, ".ppagent-memory-v04-migrated");
3491
3753
  }
3492
3754
  /** 后台压实的统一入口:防重入(上一轮未结束则跳过),失败仅告警不影响服务。*/
3493
3755
  async runBackgroundOptimize(retentionMs) {
@@ -3557,7 +3819,7 @@ var MemoryManager = class {
3557
3819
  await task;
3558
3820
  }
3559
3821
  isSessionBusy(sessionId) {
3560
- return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || [...this.topicCompactions.keys()].some((key) => key.startsWith(`${sessionId}:`)) || this.compressManager.isBusy(sessionId);
3822
+ return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || this.backgroundCompressions.has(sessionId) || [...this.topicCompactions.keys()].some((key) => key.startsWith(`${sessionId}:`)) || this.compressManager.isBusy(sessionId);
3561
3823
  }
3562
3824
  waitForPendingWrites(sessionId) {
3563
3825
  return this.pendingWrites.get(sessionId) ?? Promise.resolve();
@@ -3574,6 +3836,77 @@ var MemoryManager = class {
3574
3836
  });
3575
3837
  return task;
3576
3838
  }
3839
+ scheduleBackgroundCompression(sessionId, rawLimit, mode) {
3840
+ if (this.backgroundCompressions.has(sessionId)) return;
3841
+ const maxAttempts = this.config.backgroundCompressionMaxRetries + 1;
3842
+ const task = (async () => {
3843
+ for (let round = 0; round < 100; round++) {
3844
+ await this.ensureSessionHydrated(sessionId);
3845
+ const entry = this.sessionCache.getEntry(sessionId);
3846
+ const threshold = Math.max(1, Math.floor(rawLimit * this.config.precompressionRatio));
3847
+ if (!entry || entry.messages.length === 0 || entry.totalTokens < threshold) return;
3848
+ const beforeTokens = entry.totalTokens;
3849
+ let completed = false;
3850
+ for (let attempt = 1; attempt <= maxAttempts; attempt++) {
3851
+ try {
3852
+ await this.compressManager.triggerCompress(
3853
+ sessionId,
3854
+ false,
3855
+ false,
3856
+ rawLimit,
3857
+ mode
3858
+ );
3859
+ completed = true;
3860
+ break;
3861
+ } catch (error) {
3862
+ if (attempt >= maxAttempts) {
3863
+ console.error(
3864
+ `[MemoryManager] Background compress exhausted ${maxAttempts} attempt(s) for ${sessionId}:`,
3865
+ error
3866
+ );
3867
+ return;
3868
+ }
3869
+ const delayMs = Math.min(
3870
+ this.config.backgroundCompressionRetryBaseDelayMs * 2 ** (attempt - 1),
3871
+ 6e4
3872
+ );
3873
+ emitCompressionEvent(this.config, {
3874
+ sessionId,
3875
+ kind: "raw",
3876
+ mode,
3877
+ phase: "retry",
3878
+ attempt: attempt + 1,
3879
+ maxAttempts,
3880
+ retryKind: "task",
3881
+ error: formatError2(error)
3882
+ });
3883
+ if (delayMs > 0) await unrefDelay(delayMs);
3884
+ }
3885
+ }
3886
+ if (!completed) return;
3887
+ const after = this.sessionCache.getEntry(sessionId);
3888
+ if (!after || after.messages.length === 0 || after.totalTokens < threshold) return;
3889
+ if (after.totalTokens >= beforeTokens) {
3890
+ console.error(`[MemoryManager] Background compression made no progress for ${sessionId}.`);
3891
+ return;
3892
+ }
3893
+ }
3894
+ console.error(`[MemoryManager] Background compression exceeded 100 batches for ${sessionId}.`);
3895
+ })();
3896
+ const settled = task.catch(() => {
3897
+ });
3898
+ this.backgroundCompressions.set(sessionId, settled);
3899
+ void settled.finally(() => {
3900
+ if (this.backgroundCompressions.get(sessionId) === settled) {
3901
+ this.backgroundCompressions.delete(sessionId);
3902
+ }
3903
+ });
3904
+ }
3905
+ async waitForBackgroundCompressionIdle() {
3906
+ while (this.backgroundCompressions.size > 0) {
3907
+ await Promise.all([...this.backgroundCompressions.values()]);
3908
+ }
3909
+ }
3577
3910
  updateChat(messages, opts) {
3578
3911
  if (messages.length === 0) return Promise.resolve();
3579
3912
  const sessionId = opts?.sessionId ?? messages[0]?.sessionId ?? DEFAULT_SESSION_ID;
@@ -3587,7 +3920,7 @@ var MemoryManager = class {
3587
3920
  const normalized = messages.map((message) => {
3588
3921
  const sanitized = sanitizeRawHistoryFields(message);
3589
3922
  const parts = JSON.stringify(sanitized.parts);
3590
- const metadata = JSON.stringify(sanitized.metadata);
3923
+ const metadata = JSON.stringify(addCompressionHints(sanitized.metadata, message));
3591
3924
  const payload = sanitized.payload === void 0 ? void 0 : JSON.stringify(sanitized.payload);
3592
3925
  const contextPayload = sanitized.contextPayload === void 0 ? void 0 : JSON.stringify(sanitized.contextPayload);
3593
3926
  const tokenInput = [message.content, parts, metadata, payload ?? "", contextPayload ?? ""].join("\n");
@@ -3641,9 +3974,7 @@ var MemoryManager = class {
3641
3974
  entry.lastModelContextTokens ?? this.config.defaultModelContextTokens
3642
3975
  ).rawTokenLimit;
3643
3976
  if (entry.totalTokens >= Math.floor(rawLimit * this.config.precompressionRatio)) {
3644
- void this.compressManager.triggerCompress(sessionId, false, false, rawLimit).catch((error) => {
3645
- console.error(`[MemoryManager] Background compress failed for ${sessionId}:`, error);
3646
- });
3977
+ this.scheduleBackgroundCompression(sessionId, rawLimit, "background");
3647
3978
  }
3648
3979
  }
3649
3980
  async flushChat(sessionId, opts) {
@@ -3658,7 +3989,8 @@ var MemoryManager = class {
3658
3989
  sid,
3659
3990
  true,
3660
3991
  opts?.waitGraph === true,
3661
- rawLimit
3992
+ rawLimit,
3993
+ "manual"
3662
3994
  );
3663
3995
  if (opts?.wait) await promise;
3664
3996
  }
@@ -3839,7 +4171,8 @@ var MemoryManager = class {
3839
4171
  sessionId,
3840
4172
  true,
3841
4173
  false,
3842
- usageLimits.rawTokenLimit
4174
+ usageLimits.rawTokenLimit,
4175
+ "blocking"
3843
4176
  );
3844
4177
  const after = this.sessionCache.getEntry(sessionId);
3845
4178
  if (after.messages.length > 0 && after.totalTokens >= beforeTokens) {
@@ -3862,7 +4195,11 @@ var MemoryManager = class {
3862
4195
  if (affectsCurrentWindow || materiallyOverLimit) {
3863
4196
  if (this.compressManager.isBusy(sessionId)) beginBlocking("pending");
3864
4197
  else beginBlocking("topics");
3865
- await this.compactTopicsForBudget(sessionId, usageLimits.compressedTokenLimit);
4198
+ await this.compactTopicsForBudget(
4199
+ sessionId,
4200
+ usageLimits.compressedTokenLimit,
4201
+ "blocking"
4202
+ );
3866
4203
  entry = this.sessionCache.getEntry(sessionId);
3867
4204
  topicView = this.sessionCache.getTopicView(
3868
4205
  sessionId,
@@ -3871,7 +4208,8 @@ var MemoryManager = class {
3871
4208
  } else {
3872
4209
  void this.compactTopicsForBudget(
3873
4210
  sessionId,
3874
- usageLimits.compressedTokenLimit
4211
+ usageLimits.compressedTokenLimit,
4212
+ "background"
3875
4213
  ).catch((error) => {
3876
4214
  console.error(`[MemoryManager] Background Topic compaction failed for ${sessionId}:`, error);
3877
4215
  });
@@ -3903,7 +4241,7 @@ var MemoryManager = class {
3903
4241
  }
3904
4242
  }
3905
4243
  }
3906
- compactTopicsForBudget(sessionId, effectiveLimit) {
4244
+ compactTopicsForBudget(sessionId, effectiveLimit, mode) {
3907
4245
  const key = `${sessionId}:${effectiveLimit}`;
3908
4246
  const existing = this.topicCompactions.get(key);
3909
4247
  if (existing) return existing;
@@ -3914,12 +4252,18 @@ var MemoryManager = class {
3914
4252
  await this.compressManager.triggerTopicCompaction(
3915
4253
  sessionId,
3916
4254
  this.config.compressedContextTokenLimit,
3917
- true
4255
+ true,
4256
+ mode
3918
4257
  );
3919
4258
  entry = this.sessionCache.getEntry(sessionId);
3920
4259
  }
3921
4260
  if (entry && entry.topicTokens > effectiveLimit && !this.sessionCache.getTopicView(sessionId, effectiveLimit)) {
3922
- await this.compressManager.triggerTopicCompaction(sessionId, effectiveLimit, false);
4261
+ await this.compressManager.triggerTopicCompaction(
4262
+ sessionId,
4263
+ effectiveLimit,
4264
+ false,
4265
+ mode
4266
+ );
3923
4267
  }
3924
4268
  })();
3925
4269
  this.topicCompactions.set(key, task);
@@ -4126,12 +4470,13 @@ var MemoryManager = class {
4126
4470
  clearInterval(this.sessionSweepTimer);
4127
4471
  this.sessionSweepTimer = void 0;
4128
4472
  }
4129
- const writes = [
4473
+ this.destroyTask = Promise.resolve(this.startupMaintenanceTask).catch(() => {
4474
+ }).then(() => Promise.all([
4130
4475
  ...this.pendingWrites.values(),
4131
4476
  ...this.hydration.values(),
4132
4477
  ...this.topicCompactions.values()
4133
- ];
4134
- this.destroyTask = Promise.all(writes).catch(() => {
4478
+ ])).catch(() => {
4479
+ }).then(() => this.waitForBackgroundCompressionIdle()).catch(() => {
4135
4480
  }).then(() => this.compressManager.waitForAllIdle()).catch(() => {
4136
4481
  }).then(() => this.optimizeTask).catch(() => {
4137
4482
  }).then(async () => {
@@ -4147,6 +4492,7 @@ function toMemoryRawMessage(message) {
4147
4492
  const parts = safeParseArray(sanitized.parts);
4148
4493
  const payload = sanitized.payload === void 0 ? void 0 : safeParseValue(sanitized.payload);
4149
4494
  const contextPayload = sanitized.contextPayload === void 0 ? void 0 : safeParseValue(sanitized.contextPayload);
4495
+ const hints = readCompressionHints(sanitized);
4150
4496
  return {
4151
4497
  messageId: sanitized.messageId,
4152
4498
  talkerId: sanitized.talkerId,
@@ -4158,8 +4504,14 @@ function toMemoryRawMessage(message) {
4158
4504
  ...parts.length > 0 && { parts },
4159
4505
  ...payload !== void 0 && { payload },
4160
4506
  ...contextPayload !== void 0 && { contextPayload },
4507
+ ...hints.compressionGroupId !== void 0 && {
4508
+ compressionGroupId: hints.compressionGroupId
4509
+ },
4510
+ ...hints.explicitCompressionRole !== void 0 && {
4511
+ compressionRole: hints.explicitCompressionRole
4512
+ },
4161
4513
  usage: sanitized.usage,
4162
- metadata: safeParseObject2(sanitized.metadata),
4514
+ metadata: hints.metadata,
4163
4515
  createdAt: sanitized.createdAt
4164
4516
  };
4165
4517
  }
@@ -4199,6 +4551,21 @@ function safeParseValue(value) {
4199
4551
  return value;
4200
4552
  }
4201
4553
  }
4554
+ function formatError2(error) {
4555
+ return error instanceof Error ? error.message : String(error);
4556
+ }
4557
+ function isMissingFileError(error) {
4558
+ return error?.code === "ENOENT";
4559
+ }
4560
+ function isExistingFileError(error) {
4561
+ return error?.code === "EEXIST";
4562
+ }
4563
+ function unrefDelay(ms) {
4564
+ return new Promise((resolve) => {
4565
+ const timer = setTimeout(resolve, ms);
4566
+ timer.unref?.();
4567
+ });
4568
+ }
4202
4569
  export {
4203
4570
  DEFAULT_NODE_TYPES,
4204
4571
  DEFAULT_RELATION_TYPES,
package/llms.txt CHANGED
@@ -339,16 +339,22 @@ Conversation memory fields:
339
339
  - `compressedContextRatio?: number` - maximum fraction of the usable model-history window assigned to Topics. Default: `0.10`. The effective Topic budget is the smaller of this ratio and `compressedContextTokenLimit`.
340
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`.
341
341
  - `contextUsageRatio?: number` - fraction of the current model context available to conversation history. Default: `0.75`.
342
- - `precompressionRatio?: number` - start background raw-message compression when raw usage reaches this fraction of its dynamic budget. Default: `0.75`.
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
+ - `precompressionRatio?: number` - start background raw-message compression when raw usage reaches this fraction of its dynamic budget. Default: `0.60`, leaving enough headroom for normal runs while compression finishes.
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; complete conversation-group boundaries are atomic.
344
344
  - `compressionBatchTokenLimit?: number` - optional hard limit for one compression batch. Default: `0` (ratio only).
345
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
346
  - `compressionRetryBaseDelayMs?: number` - exponential-backoff base for result-level compression retries. Default: `500`; set `0` to retry immediately.
347
+ - `backgroundCompressionMaxRetries?: number` - whole-task retries after a background compression still fails (including exhausted HTTP attempts). Default: `2`, so at most three task waves run.
348
+ - `backgroundCompressionRetryBaseDelayMs?: number` - exponential-backoff base for whole-task background retries. Default: `1000`; set `0` to retry immediately.
349
+ - `onCompressionEvent?: (event) => void | Promise<void>` - receives contained raw/Topic compression `start`, `retry`, `success`, and `failure` lifecycle events with mode, attempts, token counts, latency, and error text. Callback failures never fail memory work.
347
350
  - `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`.
348
351
  - `defaultModelContextTokens?: number` - used when `getHistoryWindow` receives no model size; a warning is logged. Default: `262144`.
349
352
  - `maxHistoryAgeMs?: number` - maximum Topic and raw-message age restored during lazy cold start. Default: `0` (all history).
350
353
  - `sessionIdleTtlMs?: number` - idle time before a session cache is released. Persistent data is not deleted. Default: `1800000`.
351
354
  - `sessionSweepIntervalMs?: number` - idle cache sweep interval. Default: `60000`.
355
+ - `autoMigrateLegacyDataOnInit?: boolean` - run the idempotent 0.4 storage migration in the background once per data store, including full historical-image stripping. Default: `true`; a sidecar marker prevents a full scan on every restart.
356
+ - `autoResumeCompressionOnInit?: boolean` - scan persisted sessions in the background and resume raw windows that were eligible but not compressed before shutdown/failure. Default: `true`.
357
+ - `restoreConcurrency?: number` - bounded startup migration/recovery scan concurrency. Default: `8`.
352
358
  - `maxConcurrentCompressions?: number` - global semaphore limit for concurrent compression and knowledge ingestion tasks. Default: `3`.
353
359
  - `entitySimilarityThreshold?: number` - graph entity similarity threshold. Default: `0.92`.
354
360
  - `defaultSearchLimit?: number` - default `search` result limit. Default: `10`.
@@ -379,12 +385,14 @@ The package intentionally separates durable base writes from expensive graph con
379
385
 
380
386
  - Registers its write synchronously, so a caller may intentionally fire-and-forget it and a following `getHistoryWindow` will still wait for that write.
381
387
  - 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.
388
+ - Persists optional `compressionGroupId` / `compressionRole` hints in a reserved internal metadata namespace and removes that namespace again on read. Host metadata remains unchanged.
382
389
  - 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
390
  - `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
391
  - 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.
385
392
  - Creates or updates the session record.
386
393
  - Updates the in-memory session cache.
387
- - Once `getHistoryWindow` has supplied the current model size, reaching the precompression threshold starts background compression.
394
+ - Reaching the dynamic precompression threshold starts background compression. A failed task is retried with finite exponential backoff, and an unfinished eligible tail is discovered again during the next startup scan.
395
+ - Compression selects an oldest prefix of complete conversation groups. Explicit group ids are authoritative; older rows fall back to `user`/`assistant`/`tool`/`system` role inference. The newest complete group is kept verbatim whenever another group exists.
388
396
  - Topic storage and cache replacement finish before conversation graph extraction; graph work remains asynchronous.
389
397
 
390
398
  `flushChat(sessionId?, opts?)`:
@@ -432,6 +440,8 @@ Use `wait: true` when the next line of code must immediately call `searchKnowled
432
440
  - `parts?: ContentPart[]` - optional multimodal content parts; non-image parts are stored serialized.
433
441
  - `payload?: unknown` - host-framework message payload stored and returned after recursive historical-image stripping.
434
442
  - `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.
443
+ - `compressionGroupId?: string` - stable id shared by a user message and all assistant/tool messages in the same complete turn. Compression never splits this group.
444
+ - `compressionRole?: "user" | "assistant" | "tool" | "system"` - explicit role used for compatible grouping when no group id is supplied.
435
445
  - `usage?: number` - token count; estimated with `tiktoken` if omitted.
436
446
  - `metadata?: Record<string, unknown>` - custom metadata stored as JSON.
437
447
  - `createdAt?: number` - Unix milliseconds; default is current time.
@@ -528,7 +538,7 @@ Creates the manager and resolves defaults. It does not connect to storage until
528
538
 
529
539
  ### `init(): Promise<void>`
530
540
 
531
- Initializes tiktoken, the vector store (backend auto-detection happens here), Grafeo, and the facts cache. Conversation sessions are hydrated lazily on first access and evicted after the configured idle timeout. Always call before using read/write APIs.
541
+ Initializes tiktoken, the vector store (backend auto-detection happens here), Grafeo, and the facts cache. It then starts non-blocking startup maintenance: a marker-guarded full legacy/image migration and a bounded scan that resumes eligible unfinished compression. Conversation sessions remain available immediately and are evicted after the configured idle timeout. Always call before using read/write APIs.
532
542
 
533
543
  ### `updateChat(messages, opts?): Promise<void>`
534
544
 
@@ -737,7 +747,7 @@ Lists documents, optionally filtered by:
737
747
 
738
748
  ### `destroy(): Promise<void>`
739
749
 
740
- Waits for registered writes, compression, graph work, and storage maintenance, then closes Grafeo and the vector store. It does not delete data.
750
+ Waits for registered writes, startup recovery, finite background compression retries, graph work, and storage maintenance, then closes Grafeo and the vector store. It does not delete data.
741
751
 
742
752
  ## Common Recipes
743
753
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ppagent/memory",
3
- "version": "0.4.5",
3
+ "version": "0.4.6",
4
4
  "description": "独立记忆系统模块,向量存储支持 LanceDB / SQLite(sqlite-vec) 双后端自动切换 + Grafeo 知识图谱",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",