@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 +14 -0
- package/dist/index.d.ts +34 -3
- package/dist/index.js +344 -79
- package/llms.txt +16 -7
- package/package.json +1 -1
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
|
|
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) =>
|
|
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 ${
|
|
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
|
-
|
|
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
|
|
2000
|
-
const
|
|
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(
|
|
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(
|
|
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
|
|
2155
|
-
const
|
|
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
|
|
2158
|
-
for (const topic of
|
|
2294
|
+
let selectedTokens = 0;
|
|
2295
|
+
for (const topic of topics) {
|
|
2159
2296
|
selected.push(topic);
|
|
2160
|
-
|
|
2161
|
-
if (
|
|
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 ${
|
|
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:
|
|
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
|
-
|
|
2196
|
-
|
|
2197
|
-
|
|
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
|
-
|
|
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(
|
|
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
|
-
|
|
2913
|
-
return this.
|
|
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
|
|
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
|
-
|
|
3382
|
-
|
|
3383
|
-
if (
|
|
3384
|
-
|
|
3385
|
-
|
|
3386
|
-
|
|
3387
|
-
|
|
3388
|
-
|
|
3389
|
-
|
|
3390
|
-
|
|
3391
|
-
|
|
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
|
-
|
|
3394
|
-
false,
|
|
3395
|
-
usageLimits.rawTokenLimit
|
|
3596
|
+
usageLimits.compressedTokenLimit
|
|
3396
3597
|
);
|
|
3397
|
-
|
|
3398
|
-
|
|
3399
|
-
|
|
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
|
-
|
|
3403
|
-
|
|
3404
|
-
|
|
3405
|
-
|
|
3406
|
-
return
|
|
3407
|
-
|
|
3408
|
-
|
|
3409
|
-
|
|
3410
|
-
|
|
3411
|
-
|
|
3412
|
-
|
|
3413
|
-
|
|
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
|
-
|
|
3418
|
-
const
|
|
3419
|
-
|
|
3420
|
-
|
|
3421
|
-
|
|
3422
|
-
|
|
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
|
-
|
|
3426
|
-
|
|
3427
|
-
|
|
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 = [
|
|
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
|
|
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` -
|
|
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
|
|
393
|
-
-
|
|
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
|
|
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
|
|