@ppagent/memory 0.4.0 → 0.4.1

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
@@ -42,6 +42,14 @@ function resolveConfig(config) {
42
42
  summaryMaxTokens: config.summaryMaxTokens ?? 512,
43
43
  conciseMaxTokens: config.conciseMaxTokens ?? 128,
44
44
  compressedContextTokenLimit,
45
+ compressedContextRatio: ratio(
46
+ config.compressedContextRatio ?? 0.1,
47
+ "compressedContextRatio"
48
+ ),
49
+ topicCompactionSyncRatio: atLeastOne(
50
+ config.topicCompactionSyncRatio ?? 1.25,
51
+ "topicCompactionSyncRatio"
52
+ ),
45
53
  contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
46
54
  precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
47
55
  compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.5, "compressionBatchRatio"),
@@ -99,6 +107,12 @@ function positiveInt(value, name) {
99
107
  }
100
108
  return Math.floor(value);
101
109
  }
110
+ function atLeastOne(value, name) {
111
+ if (!Number.isFinite(value) || value < 1) {
112
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u5927\u4E8E\u7B49\u4E8E 1 \u7684\u6709\u9650\u6570`);
113
+ }
114
+ return value;
115
+ }
102
116
 
103
117
  // src/db/store.types.ts
104
118
  function eq(field, value) {
package/dist/index.d.ts CHANGED
@@ -248,6 +248,16 @@ interface MemoryContextWindowUsage {
248
248
  rawTokenLimit: number;
249
249
  rawTokens: number;
250
250
  }
251
+ type MemoryBlockingCompressionReason = "pending" | "raw" | "topics";
252
+ interface MemoryBlockingCompressionEvent {
253
+ sessionId: string;
254
+ phase: "start" | "end";
255
+ reason: MemoryBlockingCompressionReason;
256
+ }
257
+ interface GetHistoryWindowOptions {
258
+ /** 仅当前读取确实被压缩阻塞时调用;回调异常不会影响记忆读取。 */
259
+ onBlockingCompression?: (event: MemoryBlockingCompressionEvent) => void;
260
+ }
251
261
  /** 固定预算的压缩记忆 + 近期未压缩原始消息。 */
252
262
  interface MemoryContextWindow {
253
263
  sessionId: string;
@@ -387,8 +397,12 @@ interface MemoryConfig {
387
397
  embeddingBatchSize?: number;
388
398
  /** embedding 批次并发数(默认 2)。*/
389
399
  embeddingConcurrency?: number;
390
- /** 压缩 Topic 在历史窗口中的固定预算,默认 16K。 */
400
+ /** 压缩 Topic 的全局存储上限,默认 16K。 */
391
401
  compressedContextTokenLimit?: number;
402
+ /** Topic 最多占本次可用模型上下文的比例,默认 0.10。 */
403
+ compressedContextRatio?: number;
404
+ /** Topic 超过本次有效预算多少倍后必须同步归并,默认 1.25。 */
405
+ topicCompactionSyncRatio?: number;
392
406
  /** 模型上下文中允许历史记忆使用的比例,默认 0.75。 */
393
407
  contextUsageRatio?: number;
394
408
  /** 原始消息达到其可用预算的此比例时后台预压缩,默认 0.75。 */
@@ -659,6 +673,7 @@ declare class MemoryManager {
659
673
  private destroyTask?;
660
674
  private readonly hydration;
661
675
  private readonly pendingWrites;
676
+ private readonly topicCompactions;
662
677
  private warnedDefaultModelContext;
663
678
  constructor(config: MemoryConfig);
664
679
  init(): Promise<void>;
@@ -695,7 +710,9 @@ declare class MemoryManager {
695
710
  * 返回 user 级(userId) ∪ chat 级(chatId),按时间正序拼接。
696
711
  */
697
712
  getFactsForContext(userId: string, chatId: string): Promise<string>;
698
- getHistoryWindow(sessionId: string, modelContextTokens?: number): Promise<MemoryContextWindow>;
713
+ getHistoryWindow(sessionId: string, modelContextTokens?: number, options?: GetHistoryWindowOptions): Promise<MemoryContextWindow>;
714
+ private compactTopicsForBudget;
715
+ private notifyBlockingCompression;
699
716
  private calculateWindowUsage;
700
717
  private buildScopeFilter;
701
718
  private deserializeSession;
@@ -836,6 +853,20 @@ declare class MemoryManager {
836
853
  destroy(): Promise<void>;
837
854
  }
838
855
 
856
+ interface MemoryContextBudget {
857
+ modelContextTokens: number;
858
+ usableContextTokens: number;
859
+ compressedTokenLimit: number;
860
+ rawTokenLimit: number;
861
+ }
862
+ /**
863
+ * 统一计算一次 history window 的 Topic / raw 预算。
864
+ *
865
+ * Topic 同时受全局质量上限和当前模型比例约束;最后保留至少 1 token raw 空间,
866
+ * 避免小窗口模型被固定 Topic 上限完全挤空。
867
+ */
868
+ declare function calculateContextBudget(config: Pick<ResolvedConfig, "contextUsageRatio" | "compressedContextRatio" | "compressedContextTokenLimit">, modelContextTokens: number): MemoryContextBudget;
869
+
839
870
  interface MemoryMigrationReport {
840
871
  provider: string;
841
872
  topicsScanned: number;
@@ -974,4 +1005,4 @@ type RelationType = (typeof DEFAULT_RELATION_TYPES)[number];
974
1005
  declare const KIND_CONVERSATION = "conversation";
975
1006
  declare const KIND_KNOWLEDGE = "knowledge";
976
1007
 
977
- 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, KIND_CONVERSATION, KIND_KNOWLEDGE, type KnowledgeSearchOptions, type KnowledgeSearchResult, type MemoryConfig, 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 };
1008
+ 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 };
package/dist/index.js CHANGED
@@ -38,6 +38,14 @@ function resolveConfig(config) {
38
38
  summaryMaxTokens: config.summaryMaxTokens ?? 512,
39
39
  conciseMaxTokens: config.conciseMaxTokens ?? 128,
40
40
  compressedContextTokenLimit,
41
+ compressedContextRatio: ratio(
42
+ config.compressedContextRatio ?? 0.1,
43
+ "compressedContextRatio"
44
+ ),
45
+ topicCompactionSyncRatio: atLeastOne(
46
+ config.topicCompactionSyncRatio ?? 1.25,
47
+ "topicCompactionSyncRatio"
48
+ ),
41
49
  contextUsageRatio: ratio(config.contextUsageRatio ?? 0.75, "contextUsageRatio"),
42
50
  precompressionRatio: ratio(config.precompressionRatio ?? 0.75, "precompressionRatio"),
43
51
  compressionBatchRatio: ratio(config.compressionBatchRatio ?? 0.5, "compressionBatchRatio"),
@@ -95,6 +103,41 @@ function positiveInt(value, name) {
95
103
  }
96
104
  return Math.floor(value);
97
105
  }
106
+ function atLeastOne(value, name) {
107
+ if (!Number.isFinite(value) || value < 1) {
108
+ throw new Error(`[MemoryConfig] ${name} \u5FC5\u987B\u662F\u5927\u4E8E\u7B49\u4E8E 1 \u7684\u6709\u9650\u6570`);
109
+ }
110
+ return value;
111
+ }
112
+
113
+ // src/context-budget.ts
114
+ function calculateContextBudget(config, modelContextTokens) {
115
+ if (!Number.isFinite(modelContextTokens) || modelContextTokens <= 0) {
116
+ throw new Error("modelContextTokens \u5FC5\u987B\u662F\u6B63\u6570");
117
+ }
118
+ const normalizedModelTokens = Math.floor(modelContextTokens);
119
+ const usableContextTokens = Math.floor(normalizedModelTokens * config.contextUsageRatio);
120
+ if (usableContextTokens < 2) {
121
+ throw new Error(
122
+ `modelContextTokens=${normalizedModelTokens} \u5728 contextUsageRatio=${config.contextUsageRatio} \u4E0B\u4E0D\u8DB3\u4EE5\u540C\u65F6\u4FDD\u7559 Topic \u4E0E\u539F\u59CB\u6D88\u606F\u9884\u7B97`
123
+ );
124
+ }
125
+ const ratioLimit = Math.max(
126
+ 1,
127
+ Math.floor(usableContextTokens * config.compressedContextRatio)
128
+ );
129
+ const compressedTokenLimit = Math.min(
130
+ config.compressedContextTokenLimit,
131
+ ratioLimit,
132
+ usableContextTokens - 1
133
+ );
134
+ return {
135
+ modelContextTokens: normalizedModelTokens,
136
+ usableContextTokens,
137
+ compressedTokenLimit,
138
+ rawTokenLimit: usableContextTokens - compressedTokenLimit
139
+ };
140
+ }
98
141
 
99
142
  // src/constants.ts
100
143
  var DEFAULT_NODE_TYPES = [
@@ -1847,16 +1890,16 @@ ${formatted}`,
1847
1890
  tokens: response.completionTokens
1848
1891
  };
1849
1892
  }
1850
- async summarizeTopics(topics) {
1893
+ async summarizeTopics(topics, maxSummaryTokens = this.config.topicSummaryMaxTokens) {
1851
1894
  const input = topics.map(
1852
1895
  (topic) => `[${new Date(topic.startTime).toISOString()} - ${new Date(topic.endTime).toISOString()}] ${topic.title}
1853
1896
  ${topic.summary}`
1854
1897
  ).join("\n\n");
1855
1898
  const response = await this.chatCompletionWithUsage(
1856
- `\u4F60\u662F\u8BB0\u5FC6\u5F52\u5E76\u52A9\u624B\u3002\u628A\u4E00\u7EC4\u6309\u65F6\u95F4\u6392\u5217\u7684\u65E7\u4E3B\u9898\u5F52\u5E76\u6210\u4E00\u4E2A\u4E3B\u9898\u3002\u4FDD\u7559\u7A33\u5B9A\u504F\u597D\u3001\u4E8B\u5B9E\u3001\u51B3\u5B9A\u53CA\u7406\u7531\u3001\u957F\u671F\u7EA6\u675F\u3001\u963B\u585E\u4E0E\u53EF\u590D\u7528\u7ECF\u9A8C\uFF1B\u5220\u9664\u91CD\u590D\u5185\u5BB9\u3002summary \u6700\u591A ${this.config.topicSummaryMaxTokens} tokens\u3002\u53EA\u8F93\u51FA JSON\uFF1A{"title":"...","summary":"..."}`,
1899
+ `\u4F60\u662F\u8BB0\u5FC6\u5F52\u5E76\u52A9\u624B\u3002\u628A\u4E00\u7EC4\u6309\u65F6\u95F4\u6392\u5217\u7684\u65E7\u4E3B\u9898\u5F52\u5E76\u6210\u4E00\u4E2A\u4E3B\u9898\u3002\u4FDD\u7559\u7A33\u5B9A\u504F\u597D\u3001\u4E8B\u5B9E\u3001\u51B3\u5B9A\u53CA\u7406\u7531\u3001\u957F\u671F\u7EA6\u675F\u3001\u963B\u585E\u4E0E\u53EF\u590D\u7528\u7ECF\u9A8C\uFF1B\u5220\u9664\u91CD\u590D\u5185\u5BB9\u3002summary \u6700\u591A ${maxSummaryTokens} tokens\u3002\u53EA\u8F93\u51FA JSON\uFF1A{"title":"...","summary":"..."}`,
1857
1900
  input,
1858
1901
  true,
1859
- this.config.topicSummaryMaxTokens + 128
1902
+ maxSummaryTokens + 128
1860
1903
  );
1861
1904
  const parsed = JSON.parse(response.content);
1862
1905
  return {
@@ -2054,9 +2097,22 @@ var CompressManager = class {
2054
2097
  sessionChain = /* @__PURE__ */ new Map();
2055
2098
  backgroundGraphs = /* @__PURE__ */ new Set();
2056
2099
  triggerCompress(sessionId, force = false, waitGraph = false, rawLimit) {
2100
+ return this.enqueueSessionTask(
2101
+ sessionId,
2102
+ () => this.doCompress(sessionId, force, waitGraph, rawLimit)
2103
+ );
2104
+ }
2105
+ /** 按给定预算收敛 Topic;persist=false 时只缓存当前模型使用的临时视图。 */
2106
+ triggerTopicCompaction(sessionId, tokenLimit, persist) {
2107
+ return this.enqueueSessionTask(
2108
+ sessionId,
2109
+ () => this.compactOldTopics(sessionId, Math.max(1, Math.floor(tokenLimit)), persist)
2110
+ );
2111
+ }
2112
+ enqueueSessionTask(sessionId, task) {
2057
2113
  const previous = this.sessionChain.get(sessionId) ?? Promise.resolve();
2058
2114
  const next = previous.then(
2059
- () => this.semaphore.run(() => this.doCompress(sessionId, force, waitGraph, rawLimit))
2115
+ () => this.semaphore.run(task)
2060
2116
  );
2061
2117
  const settled = next.catch(() => {
2062
2118
  });
@@ -2130,7 +2186,11 @@ var CompressManager = class {
2130
2186
  await this.store.addTopic(topic);
2131
2187
  this.sessionCache.appendTopic(sessionId, topic);
2132
2188
  this.sessionCache.removeMessages(sessionId, messages.map((message) => message.messageId));
2133
- await this.compactOldTopics(sessionId);
2189
+ await this.compactOldTopics(
2190
+ sessionId,
2191
+ this.config.compressedContextTokenLimit,
2192
+ true
2193
+ );
2134
2194
  const graphTask = this.extractAndPersistGraph(
2135
2195
  messages,
2136
2196
  sessionId,
@@ -2148,29 +2208,42 @@ var CompressManager = class {
2148
2208
  tracked.finally(() => this.backgroundGraphs.delete(tracked));
2149
2209
  }
2150
2210
  }
2151
- async compactOldTopics(sessionId) {
2211
+ async compactOldTopics(sessionId, tokenLimit, persist) {
2212
+ let transientTopics = persist ? void 0 : this.sessionCache.getTopicView(sessionId, tokenLimit) ?? [...this.sessionCache.getEntry(sessionId)?.topics ?? []];
2152
2213
  while (true) {
2153
2214
  const entry = this.sessionCache.getEntry(sessionId);
2154
- if (!entry || entry.topicTokens <= this.config.compressedContextTokenLimit) return;
2155
- const target = Math.max(1, Math.floor(this.config.compressedContextTokenLimit / 2));
2215
+ if (!entry) return;
2216
+ const topics = persist ? entry.topics : transientTopics;
2217
+ const topicTokens = sumTopicTokens(topics);
2218
+ if (topicTokens <= tokenLimit) {
2219
+ if (!persist) this.sessionCache.setTopicView(sessionId, tokenLimit, topics);
2220
+ return;
2221
+ }
2222
+ const target = Math.max(1, Math.floor(topicTokens / 2));
2156
2223
  const selected = [];
2157
- let tokens = 0;
2158
- for (const topic of entry.topics) {
2224
+ let selectedTokens = 0;
2225
+ for (const topic of topics) {
2159
2226
  selected.push(topic);
2160
- tokens += topic.tokens;
2161
- if (tokens >= target && selected.length >= 2) break;
2162
- }
2163
- if (selected.length < 2) {
2164
- this.sessionCache.setTopics(sessionId, entry.topics, true);
2165
- return;
2227
+ selectedTokens += topic.tokens;
2228
+ if (selectedTokens >= target && (selected.length >= 2 || topics.length === 1)) break;
2166
2229
  }
2230
+ if (selected.length === 0) return;
2167
2231
  console.info(
2168
- `[CompressManager] session ${sessionId} compressed Topic context exceeds ${this.config.compressedContextTokenLimit} tokens; compacting ${selected.length} old topics.`
2232
+ `[CompressManager] session ${sessionId} compressed Topic context exceeds ${tokenLimit} tokens; compacting ${selected.length} old topics${persist ? " persistently" : " for a transient model view"}.`
2169
2233
  );
2170
- const result = await this.llm.summarizeTopics(selected);
2234
+ const unselectedTokens = topicTokens - selectedTokens;
2235
+ const maxSummaryTokens = Math.max(
2236
+ 1,
2237
+ Math.min(
2238
+ this.config.topicSummaryMaxTokens,
2239
+ selectedTokens - 1,
2240
+ Math.max(1, tokenLimit - unselectedTokens)
2241
+ )
2242
+ );
2243
+ const result = await this.llm.summarizeTopics(selected, maxSummaryTokens);
2171
2244
  const summary = result.summary.trim();
2172
2245
  if (!summary) throw new Error(`LLM returned an empty Topic rollup for session ${sessionId}`);
2173
- const vector = await this.embed.embedOne(summary).catch(() => []);
2246
+ const vector = persist ? await this.embed.embedOne(summary).catch(() => []) : [];
2174
2247
  const first = selected[0];
2175
2248
  const last = selected[selected.length - 1];
2176
2249
  const now = Date.now();
@@ -2181,7 +2254,7 @@ var CompressManager = class {
2181
2254
  chatId: entry.ids.chatId,
2182
2255
  title: result.title,
2183
2256
  summary,
2184
- tokens: result.tokens != null && result.tokens > 0 ? result.tokens : Math.max(1, countTokens(summary)),
2257
+ tokens: Math.max(1, countTokens(summary)),
2185
2258
  startMessageId: first.startMessageId,
2186
2259
  endMessageId: last.endMessageId,
2187
2260
  startTime: first.startTime,
@@ -2191,15 +2264,27 @@ var CompressManager = class {
2191
2264
  recallCount: 0,
2192
2265
  vector
2193
2266
  };
2267
+ if (rollup.tokens >= selectedTokens) {
2268
+ throw new Error(
2269
+ `Topic compaction made no progress for session ${sessionId}: ${selectedTokens} -> ${rollup.tokens} tokens`
2270
+ );
2271
+ }
2194
2272
  const removedIds = selected.map((topic) => topic.summaryId);
2195
- await this.store.addTopic(rollup);
2196
- this.sessionCache.replaceTopics(sessionId, removedIds, rollup);
2197
- await this.store.deleteTopicsByIds(removedIds);
2273
+ if (persist) {
2274
+ await this.store.addTopic(rollup);
2275
+ this.sessionCache.replaceTopics(sessionId, removedIds, rollup);
2276
+ await this.store.deleteTopicsByIds(removedIds);
2277
+ } else {
2278
+ const ids = new Set(removedIds);
2279
+ transientTopics = [
2280
+ ...topics.filter((topic) => !ids.has(topic.summaryId)),
2281
+ rollup
2282
+ ].sort(topicOrder);
2283
+ }
2198
2284
  }
2199
2285
  }
2200
2286
  rawLimit(modelContextTokens = this.config.defaultModelContextTokens) {
2201
- const usable = Math.floor(modelContextTokens * this.config.contextUsageRatio);
2202
- return Math.max(1, usable - this.config.compressedContextTokenLimit);
2287
+ return calculateContextBudget(this.config, modelContextTokens).rawTokenLimit;
2203
2288
  }
2204
2289
  async extractAndPersistGraph(messages, sessionId, chatId, userId, endTime) {
2205
2290
  const { entities, relations } = await this.llm.extractEntitiesFromMessages(messages);
@@ -2231,6 +2316,12 @@ var CompressManager = class {
2231
2316
  await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
2232
2317
  }
2233
2318
  };
2319
+ function sumTopicTokens(topics) {
2320
+ return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
2321
+ }
2322
+ function topicOrder(a, b) {
2323
+ return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2324
+ }
2234
2325
  function formatError(error) {
2235
2326
  return error instanceof Error ? error.message : String(error);
2236
2327
  }
@@ -2795,6 +2886,7 @@ function withTimeout2(promise, timeoutMs, label) {
2795
2886
  var SessionCache = class {
2796
2887
  config;
2797
2888
  sessions = /* @__PURE__ */ new Map();
2889
+ topicViews = /* @__PURE__ */ new Map();
2798
2890
  legacyHistoryWindows = /* @__PURE__ */ new Map();
2799
2891
  constructor(config) {
2800
2892
  this.config = config;
@@ -2829,7 +2921,7 @@ var SessionCache = class {
2829
2921
  const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2830
2922
  entry.messages = sortMessages(dedupeMessages(messages));
2831
2923
  entry.totalTokens = sumMessageTokens(entry.messages);
2832
- this.setTopics(sessionId, topics);
2924
+ this.setTopics(sessionId, topics, false);
2833
2925
  return entry;
2834
2926
  }
2835
2927
  upsertMessages(sessionId, messages, chatId, userId) {
@@ -2878,7 +2970,7 @@ var SessionCache = class {
2878
2970
  setTopics(sessionId, topics, truncate = true) {
2879
2971
  const entry = this.sessions.get(sessionId);
2880
2972
  if (!entry) return;
2881
- const ordered = [...topics].sort(topicOrder);
2973
+ const ordered = [...topics].sort(topicOrder2);
2882
2974
  const kept = [];
2883
2975
  let used = 0;
2884
2976
  for (let i = ordered.length - 1; i >= 0; i--) {
@@ -2890,6 +2982,7 @@ var SessionCache = class {
2890
2982
  entry.topics = kept.reverse();
2891
2983
  entry.topicTokens = used;
2892
2984
  entry.lastAccessAt = Date.now();
2985
+ this.topicViews.delete(sessionId);
2893
2986
  }
2894
2987
  appendTopic(sessionId, topic) {
2895
2988
  const entry = this.sessions.get(sessionId);
@@ -2909,8 +3002,19 @@ var SessionCache = class {
2909
3002
  getTopics(sessionId) {
2910
3003
  return this.getEntry(sessionId)?.topics ?? [];
2911
3004
  }
2912
- buildCompressedContext(sessionId) {
2913
- return this.getTopics(sessionId).map((topic) => topic.title ? `## ${topic.title}
3005
+ getTopicView(sessionId, tokenLimit) {
3006
+ return this.topicViews.get(sessionId)?.get(tokenLimit);
3007
+ }
3008
+ setTopicView(sessionId, tokenLimit, topics) {
3009
+ let views = this.topicViews.get(sessionId);
3010
+ if (!views) {
3011
+ views = /* @__PURE__ */ new Map();
3012
+ this.topicViews.set(sessionId, views);
3013
+ }
3014
+ views.set(tokenLimit, [...topics].map(normalizeTopic).sort(topicOrder2));
3015
+ }
3016
+ buildCompressedContext(sessionId, topics) {
3017
+ return (topics ?? this.getTopics(sessionId)).map((topic) => topic.title ? `## ${topic.title}
2914
3018
  ${topic.summary}` : topic.summary).join("\n\n");
2915
3019
  }
2916
3020
  /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
@@ -2950,6 +3054,7 @@ ${topic.summary}` : topic.summary).join("\n\n");
2950
3054
  }
2951
3055
  delete(sessionId) {
2952
3056
  this.sessions.delete(sessionId);
3057
+ this.topicViews.delete(sessionId);
2953
3058
  this.legacyHistoryWindows.delete(sessionId);
2954
3059
  }
2955
3060
  evictIdle(now, ttlMs, isBusy) {
@@ -2958,6 +3063,7 @@ ${topic.summary}` : topic.summary).join("\n\n");
2958
3063
  for (const [sessionId, entry] of this.sessions) {
2959
3064
  if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
2960
3065
  this.sessions.delete(sessionId);
3066
+ this.topicViews.delete(sessionId);
2961
3067
  evicted.push(sessionId);
2962
3068
  }
2963
3069
  return evicted;
@@ -2981,7 +3087,7 @@ function sortMessages(messages) {
2981
3087
  (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
2982
3088
  );
2983
3089
  }
2984
- function topicOrder(a, b) {
3090
+ function topicOrder2(a, b) {
2985
3091
  return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2986
3092
  }
2987
3093
  function normalizeTopic(topic) {
@@ -3012,6 +3118,7 @@ var MemoryManager = class {
3012
3118
  destroyTask;
3013
3119
  hydration = /* @__PURE__ */ new Map();
3014
3120
  pendingWrites = /* @__PURE__ */ new Map();
3121
+ topicCompactions = /* @__PURE__ */ new Map();
3015
3122
  warnedDefaultModelContext = false;
3016
3123
  constructor(config) {
3017
3124
  this.config = resolveConfig(config);
@@ -3126,7 +3233,7 @@ var MemoryManager = class {
3126
3233
  await task;
3127
3234
  }
3128
3235
  isSessionBusy(sessionId) {
3129
- return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || this.compressManager.isBusy(sessionId);
3236
+ return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || [...this.topicCompactions.keys()].some((key) => key.startsWith(`${sessionId}:`)) || this.compressManager.isBusy(sessionId);
3130
3237
  }
3131
3238
  waitForPendingWrites(sessionId) {
3132
3239
  return this.pendingWrites.get(sessionId) ?? Promise.resolve();
@@ -3359,7 +3466,7 @@ var MemoryManager = class {
3359
3466
  (f) => `${new Date(f.updatedAt ?? f.createdAt).toISOString()}\uFF1A${f.key ? `[${f.key}] ` : ""}${f.content}`
3360
3467
  ).join("\n");
3361
3468
  }
3362
- async getHistoryWindow(sessionId, modelContextTokens) {
3469
+ async getHistoryWindow(sessionId, modelContextTokens, options) {
3363
3470
  let modelTokens = modelContextTokens;
3364
3471
  if (modelTokens == null) {
3365
3472
  modelTokens = this.config.defaultModelContextTokens;
@@ -3378,56 +3485,136 @@ var MemoryManager = class {
3378
3485
  await this.ensureSessionHydrated(sessionId);
3379
3486
  this.sessionCache.setModelContextTokens(sessionId, modelTokens);
3380
3487
  const usageLimits = this.calculateWindowUsage(modelTokens);
3381
- for (let attempt = 0; attempt < 100; attempt++) {
3382
- let entry2 = this.sessionCache.getEntry(sessionId);
3383
- if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3384
- await this.compressManager.waitForIdle(sessionId);
3385
- entry2 = this.sessionCache.getEntry(sessionId);
3386
- if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3387
- console.warn(
3388
- `[MemoryManager] session ${sessionId} raw history (${entry2.totalTokens}) exceeds current model budget (${usageLimits.rawTokenLimit}); waiting for immediate compression.`
3389
- );
3390
- const beforeTokens = entry2.totalTokens;
3391
- await this.compressManager.triggerCompress(
3488
+ let blockingReason;
3489
+ const beginBlocking = (reason) => {
3490
+ if (blockingReason) return;
3491
+ blockingReason = reason;
3492
+ this.notifyBlockingCompression(options, { sessionId, phase: "start", reason });
3493
+ };
3494
+ try {
3495
+ for (let attempt = 0; attempt < 100; attempt++) {
3496
+ let entry2 = this.sessionCache.getEntry(sessionId);
3497
+ if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3498
+ if (this.compressManager.isBusy(sessionId)) {
3499
+ beginBlocking("pending");
3500
+ await this.compressManager.waitForIdle(sessionId);
3501
+ entry2 = this.sessionCache.getEntry(sessionId);
3502
+ if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3503
+ }
3504
+ beginBlocking("raw");
3505
+ console.warn(
3506
+ `[MemoryManager] session ${sessionId} raw history (${entry2.totalTokens}) exceeds current model budget (${usageLimits.rawTokenLimit}); waiting for immediate compression.`
3507
+ );
3508
+ const beforeTokens = entry2.totalTokens;
3509
+ await this.compressManager.triggerCompress(
3510
+ sessionId,
3511
+ true,
3512
+ false,
3513
+ usageLimits.rawTokenLimit
3514
+ );
3515
+ const after = this.sessionCache.getEntry(sessionId);
3516
+ if (after.messages.length > 0 && after.totalTokens >= beforeTokens) {
3517
+ throw new Error(`Compression made no progress for session ${sessionId}`);
3518
+ }
3519
+ }
3520
+ let entry = this.sessionCache.getEntry(sessionId);
3521
+ if (entry.totalTokens > usageLimits.rawTokenLimit) {
3522
+ throw new Error(`Unable to fit session ${sessionId} into the requested model context`);
3523
+ }
3524
+ let topicView = this.sessionCache.getTopicView(
3392
3525
  sessionId,
3393
- true,
3394
- false,
3395
- usageLimits.rawTokenLimit
3526
+ usageLimits.compressedTokenLimit
3396
3527
  );
3397
- const after = this.sessionCache.getEntry(sessionId);
3398
- if (after.messages.length > 0 && after.totalTokens >= beforeTokens) {
3399
- throw new Error(`Compression made no progress for session ${sessionId}`);
3528
+ if (!topicView && entry.topicTokens > usageLimits.compressedTokenLimit) {
3529
+ const affectsCurrentWindow = entry.topicTokens + entry.totalTokens > usageLimits.usableContextTokens;
3530
+ const materiallyOverLimit = entry.topicTokens > Math.floor(
3531
+ usageLimits.compressedTokenLimit * this.config.topicCompactionSyncRatio
3532
+ );
3533
+ if (affectsCurrentWindow || materiallyOverLimit) {
3534
+ if (this.compressManager.isBusy(sessionId)) beginBlocking("pending");
3535
+ else beginBlocking("topics");
3536
+ await this.compactTopicsForBudget(sessionId, usageLimits.compressedTokenLimit);
3537
+ entry = this.sessionCache.getEntry(sessionId);
3538
+ topicView = this.sessionCache.getTopicView(
3539
+ sessionId,
3540
+ usageLimits.compressedTokenLimit
3541
+ );
3542
+ } else {
3543
+ void this.compactTopicsForBudget(
3544
+ sessionId,
3545
+ usageLimits.compressedTokenLimit
3546
+ ).catch((error) => {
3547
+ console.error(`[MemoryManager] Background Topic compaction failed for ${sessionId}:`, error);
3548
+ });
3549
+ }
3550
+ }
3551
+ const topics = topicView ?? entry.topics;
3552
+ const compressedTokens = sumTopicTokens2(topics);
3553
+ if (compressedTokens + entry.totalTokens > usageLimits.usableContextTokens) {
3554
+ throw new Error(`Unable to fit session ${sessionId} into the requested model context`);
3555
+ }
3556
+ return {
3557
+ sessionId,
3558
+ compressedContext: this.sessionCache.buildCompressedContext(sessionId, topics),
3559
+ recentMessages: entry.messages.map(toMemoryRawMessage),
3560
+ usage: {
3561
+ ...usageLimits,
3562
+ compressedTokens,
3563
+ rawTokens: entry.totalTokens
3564
+ }
3565
+ };
3566
+ } finally {
3567
+ if (blockingReason) {
3568
+ this.notifyBlockingCompression(options, {
3569
+ sessionId,
3570
+ phase: "end",
3571
+ reason: blockingReason
3572
+ });
3400
3573
  }
3401
3574
  }
3402
- const entry = this.sessionCache.getEntry(sessionId);
3403
- if (entry.totalTokens > usageLimits.rawTokenLimit) {
3404
- throw new Error(`Unable to fit session ${sessionId} into the requested model context`);
3405
- }
3406
- return {
3407
- sessionId,
3408
- compressedContext: this.sessionCache.buildCompressedContext(sessionId),
3409
- recentMessages: entry.messages.map(toMemoryRawMessage),
3410
- usage: {
3411
- ...usageLimits,
3412
- compressedTokens: entry.topicTokens,
3413
- rawTokens: entry.totalTokens
3575
+ }
3576
+ compactTopicsForBudget(sessionId, effectiveLimit) {
3577
+ const key = `${sessionId}:${effectiveLimit}`;
3578
+ const existing = this.topicCompactions.get(key);
3579
+ if (existing) return existing;
3580
+ const task = (async () => {
3581
+ let entry = this.sessionCache.getEntry(sessionId);
3582
+ if (!entry) return;
3583
+ if (entry.topicTokens > this.config.compressedContextTokenLimit) {
3584
+ await this.compressManager.triggerTopicCompaction(
3585
+ sessionId,
3586
+ this.config.compressedContextTokenLimit,
3587
+ true
3588
+ );
3589
+ entry = this.sessionCache.getEntry(sessionId);
3590
+ }
3591
+ if (entry && entry.topicTokens > effectiveLimit && !this.sessionCache.getTopicView(sessionId, effectiveLimit)) {
3592
+ await this.compressManager.triggerTopicCompaction(sessionId, effectiveLimit, false);
3414
3593
  }
3594
+ })();
3595
+ this.topicCompactions.set(key, task);
3596
+ const clear = () => {
3597
+ if (this.topicCompactions.get(key) === task) this.topicCompactions.delete(key);
3415
3598
  };
3599
+ void task.then(clear, clear);
3600
+ return task;
3416
3601
  }
3417
- calculateWindowUsage(modelContextTokens) {
3418
- const usableContextTokens = Math.floor(modelContextTokens * this.config.contextUsageRatio);
3419
- const rawTokenLimit = usableContextTokens - this.config.compressedContextTokenLimit;
3420
- if (rawTokenLimit <= 0) {
3421
- throw new Error(
3422
- `modelContextTokens=${modelContextTokens} \u5728 contextUsageRatio=${this.config.contextUsageRatio} \u4E0B\u4E0D\u8DB3\u4EE5\u5BB9\u7EB3 compressedContextTokenLimit=${this.config.compressedContextTokenLimit}`
3423
- );
3602
+ notifyBlockingCompression(options, event) {
3603
+ const callback = options?.onBlockingCompression;
3604
+ if (!callback) return;
3605
+ try {
3606
+ const returned = callback(event);
3607
+ if (returned && typeof returned.then === "function") {
3608
+ void Promise.resolve(returned).catch((error) => {
3609
+ console.warn("[MemoryManager] onBlockingCompression callback rejected:", error);
3610
+ });
3611
+ }
3612
+ } catch (error) {
3613
+ console.warn("[MemoryManager] onBlockingCompression callback failed:", error);
3424
3614
  }
3425
- return {
3426
- modelContextTokens,
3427
- usableContextTokens,
3428
- compressedTokenLimit: this.config.compressedContextTokenLimit,
3429
- rawTokenLimit
3430
- };
3615
+ }
3616
+ calculateWindowUsage(modelContextTokens) {
3617
+ return calculateContextBudget(this.config, modelContextTokens);
3431
3618
  }
3432
3619
  buildScopeFilter(scope, scopeId) {
3433
3620
  if (scope === "session" && scopeId) return [eq("session_id", scopeId)];
@@ -3609,7 +3796,11 @@ var MemoryManager = class {
3609
3796
  clearInterval(this.sessionSweepTimer);
3610
3797
  this.sessionSweepTimer = void 0;
3611
3798
  }
3612
- const writes = [...this.pendingWrites.values(), ...this.hydration.values()];
3799
+ const writes = [
3800
+ ...this.pendingWrites.values(),
3801
+ ...this.hydration.values(),
3802
+ ...this.topicCompactions.values()
3803
+ ];
3613
3804
  this.destroyTask = Promise.all(writes).catch(() => {
3614
3805
  }).then(() => this.compressManager.waitForAllIdle()).catch(() => {
3615
3806
  }).then(() => this.optimizeTask).catch(() => {
@@ -3639,6 +3830,9 @@ function toMemoryRawMessage(message) {
3639
3830
  createdAt: message.createdAt
3640
3831
  };
3641
3832
  }
3833
+ function sumTopicTokens2(topics) {
3834
+ return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
3835
+ }
3642
3836
  function safeParseArray(value) {
3643
3837
  try {
3644
3838
  const parsed = JSON.parse(value);
@@ -3668,5 +3862,6 @@ export {
3668
3862
  KIND_CONVERSATION,
3669
3863
  KIND_KNOWLEDGE,
3670
3864
  MemoryManager,
3671
- MemoryStore
3865
+ MemoryStore,
3866
+ calculateContextBudget
3672
3867
  };
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 fixed-budget 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 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.
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
 
@@ -150,6 +150,8 @@ const memory = new MemoryManager({
150
150
  embeddingConcurrency: 2,
151
151
 
152
152
  compressedContextTokenLimit: 16 * 1024,
153
+ compressedContextRatio: 0.10,
154
+ topicCompactionSyncRatio: 1.25,
153
155
  contextUsageRatio: 0.75,
154
156
  precompressionRatio: 0.75,
155
157
  compressionBatchRatio: 0.5,
@@ -331,7 +333,9 @@ HTTP and retry fields:
331
333
 
332
334
  Conversation memory fields:
333
335
 
334
- - `compressedContextTokenLimit?: number` - fixed token budget for compressed Topics. Default: `16384`; startup logs a warning above `32768`.
336
+ - `compressedContextTokenLimit?: number` - global storage cap for compressed Topics. Default: `16384`; startup logs a warning above `32768`.
337
+ - `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`.
338
+ - `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`.
335
339
  - `contextUsageRatio?: number` - fraction of the current model context available to conversation history. Default: `0.75`.
336
340
  - `precompressionRatio?: number` - start background raw-message compression when raw usage reaches this fraction of its dynamic budget. Default: `0.75`.
337
341
  - `compressionBatchRatio?: number` - target fraction of the raw-message budget compressed in one batch. Default: `0.5`.
@@ -384,13 +388,15 @@ The package intentionally separates durable base writes from expensive graph con
384
388
  - `waitGraph: true` waits for graph extraction/persistence too.
385
389
  - When `waitGraph: true`, graph waiting is bounded by `graphBuildTimeoutMs` and may throw a timeout error.
386
390
 
387
- `getHistoryWindow(sessionId, modelContextTokens?)`:
391
+ `getHistoryWindow(sessionId, modelContextTokens?, options?)`:
388
392
 
389
393
  - Lazily hydrates the session from persistent Topics plus raw messages after the newest Topic boundary.
390
394
  - Waits for registered message writes. If the hard raw budget is exceeded, it also waits for or starts compression until the returned context fits.
391
395
  - Returns `{ compressedContext, recentMessages, usage }`. `recentMessages` preserves the `RawMessage` input shape, including `messageId`, `parts`, `payload`, `metadata`, and `createdAt`.
392
- - Topic budget is fixed by manager configuration; the raw-message budget is recalculated from the supplied model context size.
393
- - If accumulated Topics exceed their fixed budget, the oldest approximately half are summarized into one Topic. This Topic-to-Topic rollup does not rebuild graph data.
396
+ - The effective Topic budget is `min(compressedContextTokenLimit, usableContextTokens * compressedContextRatio)`; the remaining usable history budget is reserved for raw messages.
397
+ - 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.
398
+ - 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.
399
+ - `options.onBlockingCompression` receives `{ phase: "start" | "end", reason }` only when this read actually waits for compression. Silent precompression and non-blocking Topic rollups do not invoke it. Callback failures are contained.
394
400
 
395
401
  `addDocument(opts)`:
396
402
 
@@ -607,15 +613,17 @@ Context:
607
613
  - `chatId?: string`
608
614
  - `userId?: string`
609
615
 
610
- ### `getHistoryWindow(sessionId, modelContextTokens?): Promise<MemoryContextWindow>`
616
+ ### `getHistoryWindow(sessionId, modelContextTokens?, options?): Promise<MemoryContextWindow>`
611
617
 
612
618
  Returns the complete model-history window:
613
619
 
614
- - `compressedContext: string` - chronological Topic summaries within the fixed compressed budget.
620
+ - `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.
615
621
  - `recentMessages: MemoryRawMessage[]` - all currently uncompressed raw messages in chronological order, preserving the host payload and metadata.
616
622
  - `usage` - model size, usable history size, compressed usage, and dynamic raw-message budget/usage.
617
623
 
618
624
  Pass the active model's context size on every read. Omitting it uses `defaultModelContextTokens` (256K by default) and logs a warning.
625
+
626
+ Pass `options.onBlockingCompression` when the host needs a foreground-wait UI signal. It is deliberately not a general compression-progress callback.
619
627
 
620
628
  ### `getRecentMessages(sessionId, limit): Promise<StoredMessage[]>`
621
629
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ppagent/memory",
3
- "version": "0.4.0",
3
+ "version": "0.4.1",
4
4
  "description": "独立记忆系统模块,向量存储支持 LanceDB / SQLite(sqlite-vec) 双后端自动切换 + Grafeo 知识图谱",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",