@ppagent/memory 0.4.0 → 0.4.2

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 = [
@@ -1831,7 +1874,7 @@ var LlmService = class {
1831
1874
 
1832
1875
  \u8F93\u51FA\u5FC5\u987B\u662F\u5408\u6CD5\u7684 JSON \u5BF9\u8C61\uFF0C\u4E0D\u8981\u5305\u542B\u4EFB\u4F55 markdown \u6807\u8BB0\uFF0C\u683C\u5F0F\u5982\u4E0B\uFF1A
1833
1876
  {"title": "...", "summary": "..."}`;
1834
- const formatted = messages.map((m) => formatStoredMessage(m)).join("\n");
1877
+ const formatted = messages.map((m) => formatStoredMessageForCompression(m)).join("\n");
1835
1878
  const response = await this.chatCompletionWithUsage(
1836
1879
  systemPrompt,
1837
1880
  `\u8BF7\u538B\u7F29\u4EE5\u4E0B\u5BF9\u8BDD\uFF1A
@@ -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 {
@@ -1996,11 +2039,81 @@ ${text}`
1996
2039
  ${context}`, false);
1997
2040
  }
1998
2041
  };
1999
- function formatStoredMessage(message) {
2000
- const extras = [message.parts, message.metadata, message.payload].filter((value) => value && value !== "[]" && value !== "{}").join(" ");
2042
+ function formatStoredMessageForCompression(message) {
2043
+ const metadata = stripToolCallContentFromMetadata(message.metadata);
2044
+ const extras = [message.parts, metadata, message.payload].filter((value) => value && value !== "[]" && value !== "{}").join(" ");
2001
2045
  return `[${new Date(message.createdAt).toISOString()}] ${message.talkerId || "user"}: ${message.content}${extras ? `
2002
2046
  meta=${extras}` : ""}`;
2003
2047
  }
2048
+ var OMIT_COMPRESSION_VALUE = Symbol("omit-compression-value");
2049
+ var TOOL_CALL_CONTAINER_KEYS = /* @__PURE__ */ new Set([
2050
+ "toolcall",
2051
+ "toolcalls",
2052
+ "toolresult",
2053
+ "toolresults",
2054
+ "tooloutput",
2055
+ "tooloutputs",
2056
+ "toolresponse",
2057
+ "toolresponses",
2058
+ "tooluse",
2059
+ "tooluses",
2060
+ "functioncall",
2061
+ "functioncalls",
2062
+ "functionresponse",
2063
+ "functionresponses"
2064
+ ]);
2065
+ var TOOL_CALL_ID_KEYS = /* @__PURE__ */ new Set(["toolcallid", "tooluseid"]);
2066
+ var TOOL_BLOCK_TYPES = /* @__PURE__ */ new Set([
2067
+ "toolcall",
2068
+ "tooloutput",
2069
+ "toolresponse",
2070
+ "toolresult",
2071
+ "tooluse",
2072
+ "functioncall",
2073
+ "functionresponse"
2074
+ ]);
2075
+ function stripToolCallContentFromMetadata(metadata) {
2076
+ if (!metadata || metadata === "{}") return metadata;
2077
+ try {
2078
+ const parsed = JSON.parse(metadata);
2079
+ const sanitized = stripToolCallContent(parsed);
2080
+ return JSON.stringify(sanitized === OMIT_COMPRESSION_VALUE ? {} : sanitized);
2081
+ } catch {
2082
+ return metadata;
2083
+ }
2084
+ }
2085
+ function stripToolCallContent(value) {
2086
+ if (Array.isArray(value)) {
2087
+ return value.map((item) => stripToolCallContent(item)).filter((item) => item !== OMIT_COMPRESSION_VALUE);
2088
+ }
2089
+ if (!isRecord(value)) return value;
2090
+ if (isToolCallBlock(value)) return OMIT_COMPRESSION_VALUE;
2091
+ const sanitized = {};
2092
+ for (const [key, child] of Object.entries(value)) {
2093
+ const normalizedKey = normalizeToolKey(key);
2094
+ if (TOOL_CALL_CONTAINER_KEYS.has(normalizedKey) || TOOL_CALL_ID_KEYS.has(normalizedKey)) {
2095
+ continue;
2096
+ }
2097
+ const next = stripToolCallContent(child);
2098
+ if (next !== OMIT_COMPRESSION_VALUE) sanitized[key] = next;
2099
+ }
2100
+ return sanitized;
2101
+ }
2102
+ function isToolCallBlock(value) {
2103
+ const role = typeof value.role === "string" ? normalizeToolKey(value.role) : "";
2104
+ const type = typeof value.type === "string" ? normalizeToolKey(value.type) : "";
2105
+ if (role === "tool" || role === "function") return true;
2106
+ if (TOOL_BLOCK_TYPES.has(type)) return true;
2107
+ const normalizedKeys = new Set(Object.keys(value).map(normalizeToolKey));
2108
+ const hasToolCallId = [...TOOL_CALL_ID_KEYS].some((key) => normalizedKeys.has(key));
2109
+ return hasToolCallId && ["content", "output", "result", "response"].some((key) => normalizedKeys.has(key));
2110
+ }
2111
+ function normalizeToolKey(value) {
2112
+ return value.replace(/[\s_-]/g, "").toLowerCase();
2113
+ }
2114
+ function isRecord(value) {
2115
+ return value !== null && typeof value === "object";
2116
+ }
2004
2117
 
2005
2118
  // src/manager/compress.manager.ts
2006
2119
  import { v4 as uuidv4 } from "uuid";
@@ -2054,9 +2167,22 @@ var CompressManager = class {
2054
2167
  sessionChain = /* @__PURE__ */ new Map();
2055
2168
  backgroundGraphs = /* @__PURE__ */ new Set();
2056
2169
  triggerCompress(sessionId, force = false, waitGraph = false, rawLimit) {
2170
+ return this.enqueueSessionTask(
2171
+ sessionId,
2172
+ () => this.doCompress(sessionId, force, waitGraph, rawLimit)
2173
+ );
2174
+ }
2175
+ /** 按给定预算收敛 Topic;persist=false 时只缓存当前模型使用的临时视图。 */
2176
+ triggerTopicCompaction(sessionId, tokenLimit, persist) {
2177
+ return this.enqueueSessionTask(
2178
+ sessionId,
2179
+ () => this.compactOldTopics(sessionId, Math.max(1, Math.floor(tokenLimit)), persist)
2180
+ );
2181
+ }
2182
+ enqueueSessionTask(sessionId, task) {
2057
2183
  const previous = this.sessionChain.get(sessionId) ?? Promise.resolve();
2058
2184
  const next = previous.then(
2059
- () => this.semaphore.run(() => this.doCompress(sessionId, force, waitGraph, rawLimit))
2185
+ () => this.semaphore.run(task)
2060
2186
  );
2061
2187
  const settled = next.catch(() => {
2062
2188
  });
@@ -2130,7 +2256,11 @@ var CompressManager = class {
2130
2256
  await this.store.addTopic(topic);
2131
2257
  this.sessionCache.appendTopic(sessionId, topic);
2132
2258
  this.sessionCache.removeMessages(sessionId, messages.map((message) => message.messageId));
2133
- await this.compactOldTopics(sessionId);
2259
+ await this.compactOldTopics(
2260
+ sessionId,
2261
+ this.config.compressedContextTokenLimit,
2262
+ true
2263
+ );
2134
2264
  const graphTask = this.extractAndPersistGraph(
2135
2265
  messages,
2136
2266
  sessionId,
@@ -2148,29 +2278,42 @@ var CompressManager = class {
2148
2278
  tracked.finally(() => this.backgroundGraphs.delete(tracked));
2149
2279
  }
2150
2280
  }
2151
- async compactOldTopics(sessionId) {
2281
+ async compactOldTopics(sessionId, tokenLimit, persist) {
2282
+ let transientTopics = persist ? void 0 : this.sessionCache.getTopicView(sessionId, tokenLimit) ?? [...this.sessionCache.getEntry(sessionId)?.topics ?? []];
2152
2283
  while (true) {
2153
2284
  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));
2285
+ if (!entry) return;
2286
+ const topics = persist ? entry.topics : transientTopics;
2287
+ const topicTokens = sumTopicTokens(topics);
2288
+ if (topicTokens <= tokenLimit) {
2289
+ if (!persist) this.sessionCache.setTopicView(sessionId, tokenLimit, topics);
2290
+ return;
2291
+ }
2292
+ const target = Math.max(1, Math.floor(topicTokens / 2));
2156
2293
  const selected = [];
2157
- let tokens = 0;
2158
- for (const topic of entry.topics) {
2294
+ let selectedTokens = 0;
2295
+ for (const topic of topics) {
2159
2296
  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;
2297
+ selectedTokens += topic.tokens;
2298
+ if (selectedTokens >= target && (selected.length >= 2 || topics.length === 1)) break;
2166
2299
  }
2300
+ if (selected.length === 0) return;
2167
2301
  console.info(
2168
- `[CompressManager] session ${sessionId} compressed Topic context exceeds ${this.config.compressedContextTokenLimit} tokens; compacting ${selected.length} old topics.`
2302
+ `[CompressManager] session ${sessionId} compressed Topic context exceeds ${tokenLimit} tokens; compacting ${selected.length} old topics${persist ? " persistently" : " for a transient model view"}.`
2303
+ );
2304
+ const unselectedTokens = topicTokens - selectedTokens;
2305
+ const maxSummaryTokens = Math.max(
2306
+ 1,
2307
+ Math.min(
2308
+ this.config.topicSummaryMaxTokens,
2309
+ selectedTokens - 1,
2310
+ Math.max(1, tokenLimit - unselectedTokens)
2311
+ )
2169
2312
  );
2170
- const result = await this.llm.summarizeTopics(selected);
2313
+ const result = await this.llm.summarizeTopics(selected, maxSummaryTokens);
2171
2314
  const summary = result.summary.trim();
2172
2315
  if (!summary) throw new Error(`LLM returned an empty Topic rollup for session ${sessionId}`);
2173
- const vector = await this.embed.embedOne(summary).catch(() => []);
2316
+ const vector = persist ? await this.embed.embedOne(summary).catch(() => []) : [];
2174
2317
  const first = selected[0];
2175
2318
  const last = selected[selected.length - 1];
2176
2319
  const now = Date.now();
@@ -2181,7 +2324,7 @@ var CompressManager = class {
2181
2324
  chatId: entry.ids.chatId,
2182
2325
  title: result.title,
2183
2326
  summary,
2184
- tokens: result.tokens != null && result.tokens > 0 ? result.tokens : Math.max(1, countTokens(summary)),
2327
+ tokens: Math.max(1, countTokens(summary)),
2185
2328
  startMessageId: first.startMessageId,
2186
2329
  endMessageId: last.endMessageId,
2187
2330
  startTime: first.startTime,
@@ -2191,15 +2334,27 @@ var CompressManager = class {
2191
2334
  recallCount: 0,
2192
2335
  vector
2193
2336
  };
2337
+ if (rollup.tokens >= selectedTokens) {
2338
+ throw new Error(
2339
+ `Topic compaction made no progress for session ${sessionId}: ${selectedTokens} -> ${rollup.tokens} tokens`
2340
+ );
2341
+ }
2194
2342
  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);
2343
+ if (persist) {
2344
+ await this.store.addTopic(rollup);
2345
+ this.sessionCache.replaceTopics(sessionId, removedIds, rollup);
2346
+ await this.store.deleteTopicsByIds(removedIds);
2347
+ } else {
2348
+ const ids = new Set(removedIds);
2349
+ transientTopics = [
2350
+ ...topics.filter((topic) => !ids.has(topic.summaryId)),
2351
+ rollup
2352
+ ].sort(topicOrder);
2353
+ }
2198
2354
  }
2199
2355
  }
2200
2356
  rawLimit(modelContextTokens = this.config.defaultModelContextTokens) {
2201
- const usable = Math.floor(modelContextTokens * this.config.contextUsageRatio);
2202
- return Math.max(1, usable - this.config.compressedContextTokenLimit);
2357
+ return calculateContextBudget(this.config, modelContextTokens).rawTokenLimit;
2203
2358
  }
2204
2359
  async extractAndPersistGraph(messages, sessionId, chatId, userId, endTime) {
2205
2360
  const { entities, relations } = await this.llm.extractEntitiesFromMessages(messages);
@@ -2231,6 +2386,12 @@ var CompressManager = class {
2231
2386
  await this.grafeo.upsertEntitiesAndRelations(entities, relations, embeddings);
2232
2387
  }
2233
2388
  };
2389
+ function sumTopicTokens(topics) {
2390
+ return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
2391
+ }
2392
+ function topicOrder(a, b) {
2393
+ return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2394
+ }
2234
2395
  function formatError(error) {
2235
2396
  return error instanceof Error ? error.message : String(error);
2236
2397
  }
@@ -2795,6 +2956,7 @@ function withTimeout2(promise, timeoutMs, label) {
2795
2956
  var SessionCache = class {
2796
2957
  config;
2797
2958
  sessions = /* @__PURE__ */ new Map();
2959
+ topicViews = /* @__PURE__ */ new Map();
2798
2960
  legacyHistoryWindows = /* @__PURE__ */ new Map();
2799
2961
  constructor(config) {
2800
2962
  this.config = config;
@@ -2829,7 +2991,7 @@ var SessionCache = class {
2829
2991
  const entry = this.getOrCreateEntry(sessionId, chatId, userId);
2830
2992
  entry.messages = sortMessages(dedupeMessages(messages));
2831
2993
  entry.totalTokens = sumMessageTokens(entry.messages);
2832
- this.setTopics(sessionId, topics);
2994
+ this.setTopics(sessionId, topics, false);
2833
2995
  return entry;
2834
2996
  }
2835
2997
  upsertMessages(sessionId, messages, chatId, userId) {
@@ -2878,7 +3040,7 @@ var SessionCache = class {
2878
3040
  setTopics(sessionId, topics, truncate = true) {
2879
3041
  const entry = this.sessions.get(sessionId);
2880
3042
  if (!entry) return;
2881
- const ordered = [...topics].sort(topicOrder);
3043
+ const ordered = [...topics].sort(topicOrder2);
2882
3044
  const kept = [];
2883
3045
  let used = 0;
2884
3046
  for (let i = ordered.length - 1; i >= 0; i--) {
@@ -2890,6 +3052,7 @@ var SessionCache = class {
2890
3052
  entry.topics = kept.reverse();
2891
3053
  entry.topicTokens = used;
2892
3054
  entry.lastAccessAt = Date.now();
3055
+ this.topicViews.delete(sessionId);
2893
3056
  }
2894
3057
  appendTopic(sessionId, topic) {
2895
3058
  const entry = this.sessions.get(sessionId);
@@ -2909,8 +3072,19 @@ var SessionCache = class {
2909
3072
  getTopics(sessionId) {
2910
3073
  return this.getEntry(sessionId)?.topics ?? [];
2911
3074
  }
2912
- buildCompressedContext(sessionId) {
2913
- return this.getTopics(sessionId).map((topic) => topic.title ? `## ${topic.title}
3075
+ getTopicView(sessionId, tokenLimit) {
3076
+ return this.topicViews.get(sessionId)?.get(tokenLimit);
3077
+ }
3078
+ setTopicView(sessionId, tokenLimit, topics) {
3079
+ let views = this.topicViews.get(sessionId);
3080
+ if (!views) {
3081
+ views = /* @__PURE__ */ new Map();
3082
+ this.topicViews.set(sessionId, views);
3083
+ }
3084
+ views.set(tokenLimit, [...topics].map(normalizeTopic).sort(topicOrder2));
3085
+ }
3086
+ buildCompressedContext(sessionId, topics) {
3087
+ return (topics ?? this.getTopics(sessionId)).map((topic) => topic.title ? `## ${topic.title}
2914
3088
  ${topic.summary}` : topic.summary).join("\n\n");
2915
3089
  }
2916
3090
  /** @deprecated 三级窗口兼容,仅供 0.3.x 调用方过渡。 */
@@ -2950,6 +3124,7 @@ ${topic.summary}` : topic.summary).join("\n\n");
2950
3124
  }
2951
3125
  delete(sessionId) {
2952
3126
  this.sessions.delete(sessionId);
3127
+ this.topicViews.delete(sessionId);
2953
3128
  this.legacyHistoryWindows.delete(sessionId);
2954
3129
  }
2955
3130
  evictIdle(now, ttlMs, isBusy) {
@@ -2958,6 +3133,7 @@ ${topic.summary}` : topic.summary).join("\n\n");
2958
3133
  for (const [sessionId, entry] of this.sessions) {
2959
3134
  if (now - entry.lastAccessAt < ttlMs || isBusy(sessionId)) continue;
2960
3135
  this.sessions.delete(sessionId);
3136
+ this.topicViews.delete(sessionId);
2961
3137
  evicted.push(sessionId);
2962
3138
  }
2963
3139
  return evicted;
@@ -2981,7 +3157,7 @@ function sortMessages(messages) {
2981
3157
  (a, b) => a.createdAt - b.createdAt || a.messageId.localeCompare(b.messageId)
2982
3158
  );
2983
3159
  }
2984
- function topicOrder(a, b) {
3160
+ function topicOrder2(a, b) {
2985
3161
  return a.endTime - b.endTime || a.summaryId.localeCompare(b.summaryId);
2986
3162
  }
2987
3163
  function normalizeTopic(topic) {
@@ -3012,6 +3188,7 @@ var MemoryManager = class {
3012
3188
  destroyTask;
3013
3189
  hydration = /* @__PURE__ */ new Map();
3014
3190
  pendingWrites = /* @__PURE__ */ new Map();
3191
+ topicCompactions = /* @__PURE__ */ new Map();
3015
3192
  warnedDefaultModelContext = false;
3016
3193
  constructor(config) {
3017
3194
  this.config = resolveConfig(config);
@@ -3126,7 +3303,7 @@ var MemoryManager = class {
3126
3303
  await task;
3127
3304
  }
3128
3305
  isSessionBusy(sessionId) {
3129
- return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || this.compressManager.isBusy(sessionId);
3306
+ return this.hydration.has(sessionId) || this.pendingWrites.has(sessionId) || [...this.topicCompactions.keys()].some((key) => key.startsWith(`${sessionId}:`)) || this.compressManager.isBusy(sessionId);
3130
3307
  }
3131
3308
  waitForPendingWrites(sessionId) {
3132
3309
  return this.pendingWrites.get(sessionId) ?? Promise.resolve();
@@ -3359,7 +3536,7 @@ var MemoryManager = class {
3359
3536
  (f) => `${new Date(f.updatedAt ?? f.createdAt).toISOString()}\uFF1A${f.key ? `[${f.key}] ` : ""}${f.content}`
3360
3537
  ).join("\n");
3361
3538
  }
3362
- async getHistoryWindow(sessionId, modelContextTokens) {
3539
+ async getHistoryWindow(sessionId, modelContextTokens, options) {
3363
3540
  let modelTokens = modelContextTokens;
3364
3541
  if (modelTokens == null) {
3365
3542
  modelTokens = this.config.defaultModelContextTokens;
@@ -3378,56 +3555,136 @@ var MemoryManager = class {
3378
3555
  await this.ensureSessionHydrated(sessionId);
3379
3556
  this.sessionCache.setModelContextTokens(sessionId, modelTokens);
3380
3557
  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(
3558
+ let blockingReason;
3559
+ const beginBlocking = (reason) => {
3560
+ if (blockingReason) return;
3561
+ blockingReason = reason;
3562
+ this.notifyBlockingCompression(options, { sessionId, phase: "start", reason });
3563
+ };
3564
+ try {
3565
+ for (let attempt = 0; attempt < 100; attempt++) {
3566
+ let entry2 = this.sessionCache.getEntry(sessionId);
3567
+ if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3568
+ if (this.compressManager.isBusy(sessionId)) {
3569
+ beginBlocking("pending");
3570
+ await this.compressManager.waitForIdle(sessionId);
3571
+ entry2 = this.sessionCache.getEntry(sessionId);
3572
+ if (entry2.totalTokens <= usageLimits.rawTokenLimit || entry2.messages.length === 0) break;
3573
+ }
3574
+ beginBlocking("raw");
3575
+ console.warn(
3576
+ `[MemoryManager] session ${sessionId} raw history (${entry2.totalTokens}) exceeds current model budget (${usageLimits.rawTokenLimit}); waiting for immediate compression.`
3577
+ );
3578
+ const beforeTokens = entry2.totalTokens;
3579
+ await this.compressManager.triggerCompress(
3580
+ sessionId,
3581
+ true,
3582
+ false,
3583
+ usageLimits.rawTokenLimit
3584
+ );
3585
+ const after = this.sessionCache.getEntry(sessionId);
3586
+ if (after.messages.length > 0 && after.totalTokens >= beforeTokens) {
3587
+ throw new Error(`Compression made no progress for session ${sessionId}`);
3588
+ }
3589
+ }
3590
+ let entry = this.sessionCache.getEntry(sessionId);
3591
+ if (entry.totalTokens > usageLimits.rawTokenLimit) {
3592
+ throw new Error(`Unable to fit session ${sessionId} into the requested model context`);
3593
+ }
3594
+ let topicView = this.sessionCache.getTopicView(
3392
3595
  sessionId,
3393
- true,
3394
- false,
3395
- usageLimits.rawTokenLimit
3596
+ usageLimits.compressedTokenLimit
3396
3597
  );
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}`);
3598
+ if (!topicView && entry.topicTokens > usageLimits.compressedTokenLimit) {
3599
+ const affectsCurrentWindow = entry.topicTokens + entry.totalTokens > usageLimits.usableContextTokens;
3600
+ const materiallyOverLimit = entry.topicTokens > Math.floor(
3601
+ usageLimits.compressedTokenLimit * this.config.topicCompactionSyncRatio
3602
+ );
3603
+ if (affectsCurrentWindow || materiallyOverLimit) {
3604
+ if (this.compressManager.isBusy(sessionId)) beginBlocking("pending");
3605
+ else beginBlocking("topics");
3606
+ await this.compactTopicsForBudget(sessionId, usageLimits.compressedTokenLimit);
3607
+ entry = this.sessionCache.getEntry(sessionId);
3608
+ topicView = this.sessionCache.getTopicView(
3609
+ sessionId,
3610
+ usageLimits.compressedTokenLimit
3611
+ );
3612
+ } else {
3613
+ void this.compactTopicsForBudget(
3614
+ sessionId,
3615
+ usageLimits.compressedTokenLimit
3616
+ ).catch((error) => {
3617
+ console.error(`[MemoryManager] Background Topic compaction failed for ${sessionId}:`, error);
3618
+ });
3619
+ }
3620
+ }
3621
+ const topics = topicView ?? entry.topics;
3622
+ const compressedTokens = sumTopicTokens2(topics);
3623
+ if (compressedTokens + entry.totalTokens > usageLimits.usableContextTokens) {
3624
+ throw new Error(`Unable to fit session ${sessionId} into the requested model context`);
3625
+ }
3626
+ return {
3627
+ sessionId,
3628
+ compressedContext: this.sessionCache.buildCompressedContext(sessionId, topics),
3629
+ recentMessages: entry.messages.map(toMemoryRawMessage),
3630
+ usage: {
3631
+ ...usageLimits,
3632
+ compressedTokens,
3633
+ rawTokens: entry.totalTokens
3634
+ }
3635
+ };
3636
+ } finally {
3637
+ if (blockingReason) {
3638
+ this.notifyBlockingCompression(options, {
3639
+ sessionId,
3640
+ phase: "end",
3641
+ reason: blockingReason
3642
+ });
3400
3643
  }
3401
3644
  }
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
3645
+ }
3646
+ compactTopicsForBudget(sessionId, effectiveLimit) {
3647
+ const key = `${sessionId}:${effectiveLimit}`;
3648
+ const existing = this.topicCompactions.get(key);
3649
+ if (existing) return existing;
3650
+ const task = (async () => {
3651
+ let entry = this.sessionCache.getEntry(sessionId);
3652
+ if (!entry) return;
3653
+ if (entry.topicTokens > this.config.compressedContextTokenLimit) {
3654
+ await this.compressManager.triggerTopicCompaction(
3655
+ sessionId,
3656
+ this.config.compressedContextTokenLimit,
3657
+ true
3658
+ );
3659
+ entry = this.sessionCache.getEntry(sessionId);
3414
3660
  }
3661
+ if (entry && entry.topicTokens > effectiveLimit && !this.sessionCache.getTopicView(sessionId, effectiveLimit)) {
3662
+ await this.compressManager.triggerTopicCompaction(sessionId, effectiveLimit, false);
3663
+ }
3664
+ })();
3665
+ this.topicCompactions.set(key, task);
3666
+ const clear = () => {
3667
+ if (this.topicCompactions.get(key) === task) this.topicCompactions.delete(key);
3415
3668
  };
3669
+ void task.then(clear, clear);
3670
+ return task;
3416
3671
  }
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
- );
3672
+ notifyBlockingCompression(options, event) {
3673
+ const callback = options?.onBlockingCompression;
3674
+ if (!callback) return;
3675
+ try {
3676
+ const returned = callback(event);
3677
+ if (returned && typeof returned.then === "function") {
3678
+ void Promise.resolve(returned).catch((error) => {
3679
+ console.warn("[MemoryManager] onBlockingCompression callback rejected:", error);
3680
+ });
3681
+ }
3682
+ } catch (error) {
3683
+ console.warn("[MemoryManager] onBlockingCompression callback failed:", error);
3424
3684
  }
3425
- return {
3426
- modelContextTokens,
3427
- usableContextTokens,
3428
- compressedTokenLimit: this.config.compressedContextTokenLimit,
3429
- rawTokenLimit
3430
- };
3685
+ }
3686
+ calculateWindowUsage(modelContextTokens) {
3687
+ return calculateContextBudget(this.config, modelContextTokens);
3431
3688
  }
3432
3689
  buildScopeFilter(scope, scopeId) {
3433
3690
  if (scope === "session" && scopeId) return [eq("session_id", scopeId)];
@@ -3609,7 +3866,11 @@ var MemoryManager = class {
3609
3866
  clearInterval(this.sessionSweepTimer);
3610
3867
  this.sessionSweepTimer = void 0;
3611
3868
  }
3612
- const writes = [...this.pendingWrites.values(), ...this.hydration.values()];
3869
+ const writes = [
3870
+ ...this.pendingWrites.values(),
3871
+ ...this.hydration.values(),
3872
+ ...this.topicCompactions.values()
3873
+ ];
3613
3874
  this.destroyTask = Promise.all(writes).catch(() => {
3614
3875
  }).then(() => this.compressManager.waitForAllIdle()).catch(() => {
3615
3876
  }).then(() => this.optimizeTask).catch(() => {
@@ -3639,6 +3900,9 @@ function toMemoryRawMessage(message) {
3639
3900
  createdAt: message.createdAt
3640
3901
  };
3641
3902
  }
3903
+ function sumTopicTokens2(topics) {
3904
+ return topics.reduce((sum, topic) => sum + Math.max(1, topic.tokens), 0);
3905
+ }
3642
3906
  function safeParseArray(value) {
3643
3907
  try {
3644
3908
  const parsed = JSON.parse(value);
@@ -3668,5 +3932,6 @@ export {
3668
3932
  KIND_CONVERSATION,
3669
3933
  KIND_KNOWLEDGE,
3670
3934
  MemoryManager,
3671
- MemoryStore
3935
+ MemoryStore,
3936
+ calculateContextBudget
3672
3937
  };
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`.
@@ -371,6 +375,7 @@ The package intentionally separates durable base writes from expensive graph con
371
375
 
372
376
  - Registers its write synchronously, so a caller may intentionally fire-and-forget it and a following `getHistoryWindow` will still wait for that write.
373
377
  - Calculates tokens over `content`, `parts`, `payload`, and `metadata`, embeds the searchable text, and upserts raw messages by stable `messageId` before resolving.
378
+ - Raw-message token usage always counts the complete stored `metadata`. When a raw batch is summarized, common tool-call/tool-result fields are removed only from the temporary LLM compression input; persisted metadata and uncompressed `recentMessages` remain unchanged.
374
379
  - Creates or updates the session record.
375
380
  - Updates the in-memory session cache.
376
381
  - Once `getHistoryWindow` has supplied the current model size, reaching the precompression threshold starts background compression.
@@ -384,13 +389,15 @@ The package intentionally separates durable base writes from expensive graph con
384
389
  - `waitGraph: true` waits for graph extraction/persistence too.
385
390
  - When `waitGraph: true`, graph waiting is bounded by `graphBuildTimeoutMs` and may throw a timeout error.
386
391
 
387
- `getHistoryWindow(sessionId, modelContextTokens?)`:
392
+ `getHistoryWindow(sessionId, modelContextTokens?, options?)`:
388
393
 
389
394
  - Lazily hydrates the session from persistent Topics plus raw messages after the newest Topic boundary.
390
395
  - 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
396
  - 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.
397
+ - The effective Topic budget is `min(compressedContextTokenLimit, usableContextTokens * compressedContextRatio)`; the remaining usable history budget is reserved for raw messages.
398
+ - Cold hydration always restores all persisted Topics and never silently truncates them. Each rollup summarizes the oldest approximately half of the current Topic tokens; a single oversized Topic is re-summarized by itself.
399
+ - 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.
400
+ - `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
401
 
395
402
  `addDocument(opts)`:
396
403
 
@@ -607,15 +614,17 @@ Context:
607
614
  - `chatId?: string`
608
615
  - `userId?: string`
609
616
 
610
- ### `getHistoryWindow(sessionId, modelContextTokens?): Promise<MemoryContextWindow>`
617
+ ### `getHistoryWindow(sessionId, modelContextTokens?, options?): Promise<MemoryContextWindow>`
611
618
 
612
619
  Returns the complete model-history window:
613
620
 
614
- - `compressedContext: string` - chronological Topic summaries within the fixed compressed budget.
621
+ - `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
622
  - `recentMessages: MemoryRawMessage[]` - all currently uncompressed raw messages in chronological order, preserving the host payload and metadata.
616
623
  - `usage` - model size, usable history size, compressed usage, and dynamic raw-message budget/usage.
617
624
 
618
625
  Pass the active model's context size on every read. Omitting it uses `defaultModelContextTokens` (256K by default) and logs a warning.
626
+
627
+ Pass `options.onBlockingCompression` when the host needs a foreground-wait UI signal. It is deliberately not a general compression-progress callback.
619
628
 
620
629
  ### `getRecentMessages(sessionId, limit): Promise<StoredMessage[]>`
621
630
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ppagent/memory",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "独立记忆系统模块,向量存储支持 LanceDB / SQLite(sqlite-vec) 双后端自动切换 + Grafeo 知识图谱",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",