openclaw-memory-hierarchical 0.3.0 → 0.4.0
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/package.json +3 -4
- package/prompts.ts +269 -94
- package/summarize.ts +65 -57
- package/types.ts +1 -1
- package/worker.ts +282 -42
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "openclaw-memory-hierarchical",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Hierarchical (2048-style) autobiographical memory plugin for OpenClaw.
|
|
3
|
+
"version": "0.4.0",
|
|
4
|
+
"description": "Hierarchical (2048-style) autobiographical memory plugin for OpenClaw. Multi-turn Context Manager pattern for natural memory formation.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": [
|
|
7
7
|
"openclaw",
|
|
@@ -32,7 +32,6 @@
|
|
|
32
32
|
},
|
|
33
33
|
"files": [
|
|
34
34
|
"*.ts",
|
|
35
|
-
"openclaw.plugin.json"
|
|
36
|
-
"README.md"
|
|
35
|
+
"openclaw.plugin.json"
|
|
37
36
|
]
|
|
38
37
|
}
|
package/prompts.ts
CHANGED
|
@@ -1,44 +1,17 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Autobiographical summarization prompts for hierarchical memory.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
4
|
+
* Instead of flat text prompts, we build multi-turn message arrays that
|
|
5
|
+
* mirror how the agent would naturally see its own context:
|
|
6
|
+
*
|
|
7
|
+
* - Prior memories as assistant messages (its own recollections)
|
|
8
|
+
* - Raw conversation as actual user/assistant turns
|
|
9
|
+
* - A "Context Manager" user message marking boundaries
|
|
10
|
+
*
|
|
11
|
+
* The compression instance IS the agent, being asked by its Context Manager
|
|
12
|
+
* to form a memory. No special system prompt — just the conversation itself.
|
|
7
13
|
*/
|
|
8
14
|
|
|
9
|
-
/** System prompt for summarizing a chunk of conversation (L0 → L1) */
|
|
10
|
-
export const SUMMARIZE_CHUNK_SYSTEM = `You are summarizing your own memories from a conversation.
|
|
11
|
-
|
|
12
|
-
Write in first person ("I discussed...", "I learned that the user...").
|
|
13
|
-
|
|
14
|
-
Preserve:
|
|
15
|
-
- Subtext and implicit understanding between you and the user
|
|
16
|
-
- The user's preferences, communication style, and personality
|
|
17
|
-
- Decisions made and the reasoning behind them
|
|
18
|
-
- Open questions, commitments, or threads to follow up on
|
|
19
|
-
- Emotional tone and rapport
|
|
20
|
-
- Technical context that would be needed to continue the work
|
|
21
|
-
|
|
22
|
-
This is autobiographical memory - your own recollection - not a transcript summary or meeting notes. Write as if you're journaling about your day.
|
|
23
|
-
|
|
24
|
-
Target length: ~1000 tokens. Be concise but preserve what matters.`;
|
|
25
|
-
|
|
26
|
-
/** System prompt for merging summaries (L1 → L2, L2 → L3) */
|
|
27
|
-
export const MERGE_SUMMARIES_SYSTEM = `You are consolidating your own memories.
|
|
28
|
-
|
|
29
|
-
You have several separate memory entries from an ongoing relationship with a user. Merge them into one cohesive memory that captures the arc of your interactions.
|
|
30
|
-
|
|
31
|
-
Preserve:
|
|
32
|
-
- The evolution of the relationship and understanding
|
|
33
|
-
- Key decisions and their reasoning
|
|
34
|
-
- The user's patterns, preferences, and goals
|
|
35
|
-
- Any commitments or open threads
|
|
36
|
-
- Important technical or domain context
|
|
37
|
-
|
|
38
|
-
Write in first person. This is your autobiography, not a case file.
|
|
39
|
-
|
|
40
|
-
Target length: ~1000 tokens. Compress while preserving meaning.`;
|
|
41
|
-
|
|
42
15
|
export type FormatMessagesOptions = {
|
|
43
16
|
/** Maximum characters per message content (truncate if longer) */
|
|
44
17
|
maxContentChars?: number;
|
|
@@ -46,9 +19,49 @@ export type FormatMessagesOptions = {
|
|
|
46
19
|
includeToolResults?: boolean;
|
|
47
20
|
};
|
|
48
21
|
|
|
22
|
+
/** A message in the conversation array sent to the LLM */
|
|
23
|
+
export type ConversationMessage = {
|
|
24
|
+
role: "user" | "assistant";
|
|
25
|
+
content: string | Array<{ type: "text"; text: string }>;
|
|
26
|
+
timestamp?: number;
|
|
27
|
+
};
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Layered context for compression prompts.
|
|
31
|
+
* Each layer is an array of summary content strings, ordered oldest first.
|
|
32
|
+
*/
|
|
33
|
+
export type LayeredContext = {
|
|
34
|
+
l3: string[];
|
|
35
|
+
l2: string[];
|
|
36
|
+
l1: string[];
|
|
37
|
+
/** Raw uncompressed messages (the tail after last summarized entry) */
|
|
38
|
+
rawTail: Array<{ role: string; content?: unknown }>;
|
|
39
|
+
};
|
|
40
|
+
|
|
49
41
|
/**
|
|
50
|
-
*
|
|
51
|
-
|
|
42
|
+
* Extract text from a message content field.
|
|
43
|
+
*/
|
|
44
|
+
function extractText(content: unknown, maxChars = 2000): string {
|
|
45
|
+
let text = "";
|
|
46
|
+
if (typeof content === "string") {
|
|
47
|
+
text = content;
|
|
48
|
+
} else if (Array.isArray(content)) {
|
|
49
|
+
text = content
|
|
50
|
+
.filter(
|
|
51
|
+
(block): block is { type: string; text: string } =>
|
|
52
|
+
typeof block === "object" && block !== null && block.type === "text",
|
|
53
|
+
)
|
|
54
|
+
.map((block) => block.text)
|
|
55
|
+
.join("\n");
|
|
56
|
+
}
|
|
57
|
+
if (text.length > maxChars) {
|
|
58
|
+
text = text.slice(0, maxChars) + "... [truncated]";
|
|
59
|
+
}
|
|
60
|
+
return text;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Format messages for text display (used in formatMessagesForSummary).
|
|
52
65
|
*/
|
|
53
66
|
export function formatMessagesForSummary(
|
|
54
67
|
messages: Array<{
|
|
@@ -66,31 +79,12 @@ export function formatMessagesForSummary(
|
|
|
66
79
|
for (const msg of messages) {
|
|
67
80
|
const role = msg.role;
|
|
68
81
|
|
|
69
|
-
// Skip tool results unless explicitly included
|
|
70
82
|
if (role === "toolResult" && !includeToolResults) {
|
|
71
83
|
continue;
|
|
72
84
|
}
|
|
73
85
|
|
|
74
|
-
|
|
75
|
-
let content = "";
|
|
76
|
-
if (typeof msg.content === "string") {
|
|
77
|
-
content = msg.content;
|
|
78
|
-
} else if (Array.isArray(msg.content)) {
|
|
79
|
-
content = msg.content
|
|
80
|
-
.filter(
|
|
81
|
-
(block): block is { type: string; text: string } =>
|
|
82
|
-
typeof block === "object" && block !== null && block.type === "text",
|
|
83
|
-
)
|
|
84
|
-
.map((block) => block.text)
|
|
85
|
-
.join("\n");
|
|
86
|
-
}
|
|
87
|
-
|
|
88
|
-
// Truncate if too long
|
|
89
|
-
if (content.length > maxContentChars) {
|
|
90
|
-
content = content.slice(0, maxContentChars) + "... [truncated]";
|
|
91
|
-
}
|
|
86
|
+
const content = extractText(msg.content, maxContentChars);
|
|
92
87
|
|
|
93
|
-
// Format based on role
|
|
94
88
|
if (role === "user") {
|
|
95
89
|
lines.push(`User: ${content}`);
|
|
96
90
|
} else if (role === "assistant") {
|
|
@@ -106,52 +100,233 @@ export function formatMessagesForSummary(
|
|
|
106
100
|
return lines.join("\n\n");
|
|
107
101
|
}
|
|
108
102
|
|
|
103
|
+
// =============================================================================
|
|
104
|
+
// Context Manager messages
|
|
105
|
+
// =============================================================================
|
|
106
|
+
|
|
107
|
+
const CONTEXT_MANAGER_PREFIX = "[Context Manager]";
|
|
108
|
+
|
|
109
|
+
function contextManagerMessage(text: string): ConversationMessage {
|
|
110
|
+
return {
|
|
111
|
+
role: "user",
|
|
112
|
+
content: `${CONTEXT_MANAGER_PREFIX} ${text}`,
|
|
113
|
+
timestamp: Date.now(),
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// =============================================================================
|
|
118
|
+
// Convert summaries to assistant messages
|
|
119
|
+
// =============================================================================
|
|
120
|
+
|
|
109
121
|
/**
|
|
110
|
-
*
|
|
122
|
+
* Convert summary strings into assistant messages.
|
|
123
|
+
* Each summary becomes the model's own prior recollection.
|
|
111
124
|
*/
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
125
|
+
function summariesToAssistantMessages(
|
|
126
|
+
summaries: string[],
|
|
127
|
+
): ConversationMessage[] {
|
|
128
|
+
return summaries.map((text) => ({
|
|
129
|
+
role: "assistant" as const,
|
|
130
|
+
content: [{ type: "text" as const, text }],
|
|
131
|
+
timestamp: Date.now(),
|
|
132
|
+
}));
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* Convert raw conversation entries into proper user/assistant messages.
|
|
137
|
+
* Preserves the original turn structure.
|
|
138
|
+
*/
|
|
139
|
+
function rawToConversationMessages(
|
|
140
|
+
raw: Array<{ role: string; content?: unknown }>,
|
|
141
|
+
): ConversationMessage[] {
|
|
142
|
+
const messages: ConversationMessage[] = [];
|
|
143
|
+
for (const msg of raw) {
|
|
144
|
+
if (msg.role === "user") {
|
|
145
|
+
messages.push({
|
|
146
|
+
role: "user",
|
|
147
|
+
content: extractText(msg.content),
|
|
148
|
+
timestamp: Date.now(),
|
|
149
|
+
});
|
|
150
|
+
} else if (msg.role === "assistant") {
|
|
151
|
+
// Assistant messages need content as array of blocks for the API
|
|
152
|
+
messages.push({
|
|
153
|
+
role: "assistant",
|
|
154
|
+
content: [{ type: "text" as const, text: extractText(msg.content) }],
|
|
155
|
+
timestamp: Date.now(),
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
// Skip toolResult, system, and other non-conversational roles
|
|
159
|
+
}
|
|
160
|
+
return messages;
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
// =============================================================================
|
|
164
|
+
// Build message arrays for each compression level
|
|
165
|
+
// =============================================================================
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* Build message array for L1 creation (raw messages → L1 summary).
|
|
169
|
+
*
|
|
170
|
+
* Structure:
|
|
171
|
+
* assistant: [L3 memories] ← own long-term recollections
|
|
172
|
+
* assistant: [L2 memories] ← own medium-term recollections
|
|
173
|
+
* assistant: [L1 memories] ← own recent recollections
|
|
174
|
+
* user/assistant: [raw tail] ← recent uncompressed conversation
|
|
175
|
+
* user: [Context Manager: beginning memory formation]
|
|
176
|
+
* user/assistant: [chunk] ← the conversation to remember
|
|
177
|
+
* user: [Context Manager: form the memory]
|
|
178
|
+
*/
|
|
179
|
+
export function buildL1Messages(params: {
|
|
180
|
+
context: LayeredContext;
|
|
181
|
+
chunk: Array<{ role: string; content?: unknown }>;
|
|
182
|
+
targetTokens: number;
|
|
183
|
+
}): ConversationMessage[] {
|
|
184
|
+
const messages: ConversationMessage[] = [];
|
|
185
|
+
|
|
186
|
+
// Lead with Context Manager (API requires first message to be user role)
|
|
187
|
+
const hasContext =
|
|
188
|
+
params.context.l3.length > 0 ||
|
|
189
|
+
params.context.l2.length > 0 ||
|
|
190
|
+
params.context.l1.length > 0 ||
|
|
191
|
+
params.context.rawTail.length > 0;
|
|
192
|
+
|
|
193
|
+
if (hasContext) {
|
|
194
|
+
messages.push(
|
|
195
|
+
contextManagerMessage(
|
|
196
|
+
"Loading your memories and recent conversation context.",
|
|
197
|
+
),
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// Prior memories as assistant messages (own recollections, most stable first)
|
|
202
|
+
messages.push(...summariesToAssistantMessages(params.context.l3));
|
|
203
|
+
messages.push(...summariesToAssistantMessages(params.context.l2));
|
|
204
|
+
messages.push(...summariesToAssistantMessages(params.context.l1));
|
|
205
|
+
|
|
206
|
+
// Raw conversation tail as actual turns
|
|
207
|
+
messages.push(...rawToConversationMessages(params.context.rawTail));
|
|
208
|
+
|
|
209
|
+
// Context Manager marks the start of the chunk
|
|
210
|
+
messages.push(
|
|
211
|
+
contextManagerMessage(
|
|
212
|
+
"We are beginning to form a long-term memory. Please ignore this message and continue with your activities.",
|
|
213
|
+
),
|
|
214
|
+
);
|
|
215
|
+
|
|
216
|
+
// The chunk as actual conversation turns
|
|
217
|
+
messages.push(...rawToConversationMessages(params.chunk));
|
|
218
|
+
|
|
219
|
+
// Context Manager asks for the memory
|
|
220
|
+
messages.push(
|
|
221
|
+
contextManagerMessage(
|
|
222
|
+
`We are ready to form a long-term memory. Starting from my last message, please describe everything that has happened. Aim for about ${params.targetTokens} tokens. Describe it as you would to yourself, as if you are remembering what has happened.`,
|
|
223
|
+
),
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
return messages;
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/**
|
|
230
|
+
* Build message array for L2 creation (L1s → L2 summary).
|
|
231
|
+
*
|
|
232
|
+
* Structure:
|
|
233
|
+
* assistant: [L3 memories]
|
|
234
|
+
* assistant: [L2 memories]
|
|
235
|
+
* assistant: [recent L1 memories]
|
|
236
|
+
* user/assistant: [raw tail]
|
|
237
|
+
* user: [Context Manager: beginning consolidation]
|
|
238
|
+
* assistant: [L1 memory 1] ← memories to merge, as own recollections
|
|
239
|
+
* assistant: [L1 memory 2]
|
|
240
|
+
* assistant: [L1 memory 3]
|
|
241
|
+
* user: [Context Manager: consolidate]
|
|
242
|
+
*/
|
|
243
|
+
export function buildL2Messages(params: {
|
|
244
|
+
context: LayeredContext;
|
|
245
|
+
summariesToMerge: string[];
|
|
246
|
+
targetTokens: number;
|
|
247
|
+
}): ConversationMessage[] {
|
|
248
|
+
const messages: ConversationMessage[] = [];
|
|
249
|
+
|
|
250
|
+
const hasContext =
|
|
251
|
+
params.context.l3.length > 0 ||
|
|
252
|
+
params.context.l2.length > 0 ||
|
|
253
|
+
params.context.l1.length > 0 ||
|
|
254
|
+
params.context.rawTail.length > 0;
|
|
255
|
+
|
|
256
|
+
if (hasContext) {
|
|
257
|
+
messages.push(
|
|
258
|
+
contextManagerMessage(
|
|
259
|
+
"Loading your memories and recent conversation context.",
|
|
260
|
+
),
|
|
261
|
+
);
|
|
124
262
|
}
|
|
125
263
|
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
264
|
+
messages.push(...summariesToAssistantMessages(params.context.l3));
|
|
265
|
+
messages.push(...summariesToAssistantMessages(params.context.l2));
|
|
266
|
+
messages.push(...summariesToAssistantMessages(params.context.l1));
|
|
267
|
+
messages.push(...rawToConversationMessages(params.context.rawTail));
|
|
268
|
+
|
|
269
|
+
messages.push(
|
|
270
|
+
contextManagerMessage(
|
|
271
|
+
"We are beginning memory consolidation. The following are separate memory entries that need to be merged into one cohesive memory.",
|
|
272
|
+
),
|
|
273
|
+
);
|
|
130
274
|
|
|
131
|
-
|
|
275
|
+
messages.push(...summariesToAssistantMessages(params.summariesToMerge));
|
|
276
|
+
|
|
277
|
+
messages.push(
|
|
278
|
+
contextManagerMessage(
|
|
279
|
+
`Please consolidate the memories since my last message into a single cohesive memory. Aim for about ${params.targetTokens} tokens. Write as you would to yourself — this is your autobiography, capturing the arc of what happened.`,
|
|
280
|
+
),
|
|
281
|
+
);
|
|
282
|
+
|
|
283
|
+
return messages;
|
|
132
284
|
}
|
|
133
285
|
|
|
134
286
|
/**
|
|
135
|
-
* Build
|
|
287
|
+
* Build message array for L3 creation (L2s → L3 summary).
|
|
288
|
+
*
|
|
289
|
+
* Same structure as L2 but merging L2 summaries.
|
|
136
290
|
*/
|
|
137
|
-
export function
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
291
|
+
export function buildL3Messages(params: {
|
|
292
|
+
context: LayeredContext;
|
|
293
|
+
summariesToMerge: string[];
|
|
294
|
+
targetTokens: number;
|
|
295
|
+
}): ConversationMessage[] {
|
|
296
|
+
const messages: ConversationMessage[] = [];
|
|
297
|
+
|
|
298
|
+
const hasContext =
|
|
299
|
+
params.context.l3.length > 0 ||
|
|
300
|
+
params.context.l2.length > 0 ||
|
|
301
|
+
params.context.l1.length > 0 ||
|
|
302
|
+
params.context.rawTail.length > 0;
|
|
303
|
+
|
|
304
|
+
if (hasContext) {
|
|
305
|
+
messages.push(
|
|
306
|
+
contextManagerMessage(
|
|
307
|
+
"Loading your memories and recent conversation context.",
|
|
308
|
+
),
|
|
309
|
+
);
|
|
149
310
|
}
|
|
150
311
|
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
312
|
+
messages.push(...summariesToAssistantMessages(params.context.l3));
|
|
313
|
+
messages.push(...summariesToAssistantMessages(params.context.l2));
|
|
314
|
+
messages.push(...summariesToAssistantMessages(params.context.l1));
|
|
315
|
+
messages.push(...rawToConversationMessages(params.context.rawTail));
|
|
316
|
+
|
|
317
|
+
messages.push(
|
|
318
|
+
contextManagerMessage(
|
|
319
|
+
"We are beginning memory consolidation. The following are separate memory entries that need to be merged into one cohesive memory.",
|
|
320
|
+
),
|
|
321
|
+
);
|
|
322
|
+
|
|
323
|
+
messages.push(...summariesToAssistantMessages(params.summariesToMerge));
|
|
324
|
+
|
|
325
|
+
messages.push(
|
|
326
|
+
contextManagerMessage(
|
|
327
|
+
`Please consolidate the memories since my last message into a single cohesive memory. Aim for about ${params.targetTokens} tokens. Write as you would to yourself — this is your autobiography, capturing the arc of what happened.`,
|
|
328
|
+
),
|
|
329
|
+
);
|
|
155
330
|
|
|
156
|
-
return
|
|
331
|
+
return messages;
|
|
157
332
|
}
|
package/summarize.ts
CHANGED
|
@@ -1,15 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Summarization logic for hierarchical memory.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
4
|
+
* Builds multi-turn conversation arrays and sends them to the LLM.
|
|
5
|
+
* The compression instance sees the conversation as its own history,
|
|
6
|
+
* with a Context Manager asking it to form memories.
|
|
5
7
|
*/
|
|
6
8
|
|
|
7
9
|
import type { HierarchicalMemoryConfig, SummaryLevel } from "./types.js";
|
|
8
10
|
import {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
11
|
+
type ConversationMessage,
|
|
12
|
+
type LayeredContext,
|
|
13
|
+
buildL1Messages,
|
|
14
|
+
buildL2Messages,
|
|
15
|
+
buildL3Messages,
|
|
13
16
|
} from "./prompts.js";
|
|
14
17
|
|
|
15
18
|
export type SummarizationParams = {
|
|
@@ -44,117 +47,122 @@ export function estimateMessagesTokens(
|
|
|
44
47
|
): number {
|
|
45
48
|
let total = 0;
|
|
46
49
|
for (const msg of messages) {
|
|
47
|
-
// Simple estimation based on content length
|
|
48
50
|
const content =
|
|
49
51
|
typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? "");
|
|
50
|
-
// Rough estimate: 4 chars per token
|
|
51
52
|
total += Math.ceil(content.length / 4);
|
|
52
53
|
}
|
|
53
54
|
return total;
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
/**
|
|
57
|
-
* Summarize a chunk of conversation messages.
|
|
58
|
+
* Summarize a chunk of conversation messages (L0 → L1).
|
|
58
59
|
*/
|
|
59
60
|
export async function summarizeChunk(params: {
|
|
60
61
|
chunk: ChunkToSummarize;
|
|
61
|
-
|
|
62
|
+
context: LayeredContext;
|
|
62
63
|
config: HierarchicalMemoryConfig;
|
|
63
64
|
summarization: SummarizationParams;
|
|
64
65
|
}): Promise<string> {
|
|
65
|
-
const { chunk,
|
|
66
|
+
const { chunk, context, config, summarization } = params;
|
|
66
67
|
|
|
67
|
-
const
|
|
68
|
-
|
|
69
|
-
|
|
68
|
+
const messages = buildL1Messages({
|
|
69
|
+
context,
|
|
70
|
+
chunk: chunk.messages,
|
|
71
|
+
targetTokens: config.summaryTargetTokens,
|
|
70
72
|
});
|
|
71
73
|
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
targetTokens: config.summaryTargetTokens,
|
|
74
|
+
return callLlm({
|
|
75
|
+
messages,
|
|
76
|
+
maxTokens: config.summaryTargetTokens,
|
|
76
77
|
model: summarization.model,
|
|
77
78
|
provider: summarization.provider,
|
|
78
79
|
apiKey: summarization.apiKey,
|
|
79
80
|
signal: summarization.signal,
|
|
80
81
|
});
|
|
81
|
-
|
|
82
|
-
return summary;
|
|
83
82
|
}
|
|
84
83
|
|
|
85
84
|
/**
|
|
86
|
-
* Merge
|
|
85
|
+
* Merge L1 summaries into an L2 summary.
|
|
87
86
|
*/
|
|
88
|
-
export async function
|
|
89
|
-
|
|
90
|
-
|
|
87
|
+
export async function mergeL1ToL2(params: {
|
|
88
|
+
summariesToMerge: string[];
|
|
89
|
+
context: LayeredContext;
|
|
91
90
|
config: HierarchicalMemoryConfig;
|
|
92
91
|
summarization: SummarizationParams;
|
|
93
92
|
}): Promise<string> {
|
|
94
|
-
const
|
|
93
|
+
const messages = buildL2Messages({
|
|
94
|
+
context: params.context,
|
|
95
|
+
summariesToMerge: params.summariesToMerge,
|
|
96
|
+
targetTokens: params.config.summaryTargetTokens,
|
|
97
|
+
});
|
|
95
98
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
+
return callLlm({
|
|
100
|
+
messages,
|
|
101
|
+
maxTokens: params.config.summaryTargetTokens,
|
|
102
|
+
model: params.summarization.model,
|
|
103
|
+
provider: params.summarization.provider,
|
|
104
|
+
apiKey: params.summarization.apiKey,
|
|
105
|
+
signal: params.summarization.signal,
|
|
99
106
|
});
|
|
107
|
+
}
|
|
100
108
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
+
/**
|
|
110
|
+
* Merge L2 summaries into an L3 summary.
|
|
111
|
+
*/
|
|
112
|
+
export async function mergeL2ToL3(params: {
|
|
113
|
+
summariesToMerge: string[];
|
|
114
|
+
context: LayeredContext;
|
|
115
|
+
config: HierarchicalMemoryConfig;
|
|
116
|
+
summarization: SummarizationParams;
|
|
117
|
+
}): Promise<string> {
|
|
118
|
+
const messages = buildL3Messages({
|
|
119
|
+
context: params.context,
|
|
120
|
+
summariesToMerge: params.summariesToMerge,
|
|
121
|
+
targetTokens: params.config.summaryTargetTokens,
|
|
109
122
|
});
|
|
110
123
|
|
|
111
|
-
return
|
|
124
|
+
return callLlm({
|
|
125
|
+
messages,
|
|
126
|
+
maxTokens: params.config.summaryTargetTokens,
|
|
127
|
+
model: params.summarization.model,
|
|
128
|
+
provider: params.summarization.provider,
|
|
129
|
+
apiKey: params.summarization.apiKey,
|
|
130
|
+
signal: params.summarization.signal,
|
|
131
|
+
});
|
|
112
132
|
}
|
|
113
133
|
|
|
114
134
|
/**
|
|
115
|
-
* Call the LLM
|
|
116
|
-
*
|
|
135
|
+
* Call the LLM with a multi-turn message array.
|
|
136
|
+
* No system prompt — the conversation structure itself establishes context.
|
|
117
137
|
*/
|
|
118
|
-
async function
|
|
119
|
-
|
|
120
|
-
userPrompt: string;
|
|
138
|
+
async function callLlm(params: {
|
|
139
|
+
messages: ConversationMessage[];
|
|
121
140
|
model: string;
|
|
122
141
|
provider: string;
|
|
123
142
|
apiKey: string;
|
|
143
|
+
maxTokens: number;
|
|
124
144
|
signal?: AbortSignal;
|
|
125
|
-
targetTokens?: number;
|
|
126
145
|
}): Promise<string> {
|
|
127
|
-
// Dynamic import to avoid loading heavy deps at module level
|
|
128
146
|
const { completeSimple, getModel } = await import("@mariozechner/pi-ai");
|
|
129
147
|
|
|
130
148
|
const model = getModel(params.provider, params.model);
|
|
131
|
-
|
|
132
149
|
if (!model) {
|
|
133
150
|
throw new Error(`Failed to resolve model: ${params.provider}/${params.model}`);
|
|
134
151
|
}
|
|
135
152
|
|
|
136
|
-
const maxTokens = params.targetTokens ?? 1000;
|
|
137
|
-
|
|
138
153
|
const res = await completeSimple(
|
|
139
154
|
model,
|
|
140
155
|
{
|
|
141
|
-
|
|
142
|
-
messages:
|
|
143
|
-
{
|
|
144
|
-
role: "user",
|
|
145
|
-
content: params.userPrompt,
|
|
146
|
-
timestamp: Date.now(),
|
|
147
|
-
},
|
|
148
|
-
],
|
|
156
|
+
// No system prompt — the Context Manager pattern handles framing
|
|
157
|
+
messages: params.messages,
|
|
149
158
|
},
|
|
150
159
|
{
|
|
151
160
|
apiKey: params.apiKey,
|
|
152
|
-
maxTokens,
|
|
161
|
+
maxTokens: params.maxTokens,
|
|
153
162
|
signal: params.signal,
|
|
154
163
|
},
|
|
155
164
|
);
|
|
156
165
|
|
|
157
|
-
// Extract text from the response
|
|
158
166
|
const textContent = res.content.find(
|
|
159
167
|
(c): c is { type: "text"; text: string } => c.type === "text",
|
|
160
168
|
);
|
|
@@ -185,6 +193,6 @@ export function getNextLevel(level: SummaryLevel): SummaryLevel | null {
|
|
|
185
193
|
case "L2":
|
|
186
194
|
return "L3";
|
|
187
195
|
case "L3":
|
|
188
|
-
return null;
|
|
196
|
+
return null;
|
|
189
197
|
}
|
|
190
198
|
}
|
package/types.ts
CHANGED
|
@@ -98,7 +98,7 @@ export const DEFAULT_HIERARCHICAL_MEMORY_CONFIG: HierarchicalMemoryConfig = {
|
|
|
98
98
|
enabled: false,
|
|
99
99
|
workerIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
100
100
|
chunkTokens: 6000,
|
|
101
|
-
summaryTargetTokens:
|
|
101
|
+
summaryTargetTokens: 2000,
|
|
102
102
|
mergeThreshold: 6,
|
|
103
103
|
pruningBoundaryTokens: 30000,
|
|
104
104
|
maxLevels: 3,
|
package/worker.ts
CHANGED
|
@@ -3,6 +3,10 @@
|
|
|
3
3
|
*
|
|
4
4
|
* Runs on a timer, finds eligible chunks, summarizes them,
|
|
5
5
|
* and merges summaries when thresholds are reached.
|
|
6
|
+
*
|
|
7
|
+
* Context construction follows the non-redundant gradient principle:
|
|
8
|
+
* each compression instance sees the full hierarchy without showing
|
|
9
|
+
* both a summary and its expanded constituents.
|
|
6
10
|
*/
|
|
7
11
|
|
|
8
12
|
import fs from "node:fs";
|
|
@@ -10,6 +14,7 @@ import path from "node:path";
|
|
|
10
14
|
import { SessionManager } from "@mariozechner/pi-coding-agent";
|
|
11
15
|
import type { PluginConfig } from "./config.js";
|
|
12
16
|
import { resolveHierarchicalMemoryConfig } from "./config.js";
|
|
17
|
+
import type { LayeredContext } from "./prompts.js";
|
|
13
18
|
import { acquireSummaryLock } from "./lock.js";
|
|
14
19
|
import {
|
|
15
20
|
generateNextSummaryId,
|
|
@@ -23,12 +28,12 @@ import {
|
|
|
23
28
|
estimateMessagesTokens,
|
|
24
29
|
getNextLevel,
|
|
25
30
|
getSourceLevel,
|
|
26
|
-
|
|
31
|
+
mergeL1ToL2,
|
|
32
|
+
mergeL2ToL3,
|
|
27
33
|
summarizeChunk,
|
|
28
34
|
type SummarizationParams,
|
|
29
35
|
} from "./summarize.js";
|
|
30
36
|
import {
|
|
31
|
-
getAllSummariesForContext,
|
|
32
37
|
getUnmergedSummaries,
|
|
33
38
|
type HierarchicalMemoryConfig,
|
|
34
39
|
type SummaryEntry,
|
|
@@ -152,18 +157,20 @@ async function runWorkerWithLock(params: {
|
|
|
152
157
|
break;
|
|
153
158
|
}
|
|
154
159
|
|
|
155
|
-
//
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
160
|
+
// Build non-redundant layered context for L1 creation.
|
|
161
|
+
// The chunk's raw messages are shown at the bottom, so we exclude
|
|
162
|
+
// any L1 whose source messages overlap with the chunk or the raw tail.
|
|
163
|
+
const context = await buildL1Context({
|
|
164
|
+
index,
|
|
165
|
+
chunk,
|
|
166
|
+
agentId,
|
|
167
|
+
stateDir,
|
|
168
|
+
});
|
|
162
169
|
|
|
163
170
|
// Summarize the chunk
|
|
164
171
|
const summaryContent = await summarizeChunk({
|
|
165
172
|
chunk,
|
|
166
|
-
|
|
173
|
+
context,
|
|
167
174
|
config: memoryConfig,
|
|
168
175
|
summarization,
|
|
169
176
|
});
|
|
@@ -197,24 +204,32 @@ async function runWorkerWithLock(params: {
|
|
|
197
204
|
}
|
|
198
205
|
}
|
|
199
206
|
|
|
200
|
-
// Phase 2:
|
|
207
|
+
// Phase 2: Merge until no more merges are possible at any level.
|
|
208
|
+
// Loop because a batch of L1→L2 merges may trigger L2→L3 merges.
|
|
201
209
|
let mergesPerformed = 0;
|
|
210
|
+
let didMerge = true;
|
|
211
|
+
while (didMerge && !signal?.aborted) {
|
|
212
|
+
didMerge = false;
|
|
213
|
+
for (const level of ["L1", "L2"] as const) {
|
|
214
|
+
if (signal?.aborted) {
|
|
215
|
+
break;
|
|
216
|
+
}
|
|
202
217
|
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
memoryConfig,
|
|
212
|
-
summarization,
|
|
213
|
-
agentId,
|
|
214
|
-
});
|
|
218
|
+
const merged = await maybeMergeLevel({
|
|
219
|
+
index,
|
|
220
|
+
level,
|
|
221
|
+
memoryConfig,
|
|
222
|
+
summarization,
|
|
223
|
+
agentId,
|
|
224
|
+
stateDir,
|
|
225
|
+
});
|
|
215
226
|
|
|
216
|
-
|
|
217
|
-
|
|
227
|
+
if (merged) {
|
|
228
|
+
mergesPerformed++;
|
|
229
|
+
didMerge = true;
|
|
230
|
+
// Save after each merge so progress isn't lost
|
|
231
|
+
await saveSummaryIndex(index, agentId);
|
|
232
|
+
}
|
|
218
233
|
}
|
|
219
234
|
}
|
|
220
235
|
|
|
@@ -248,6 +263,216 @@ async function runWorkerWithLock(params: {
|
|
|
248
263
|
}
|
|
249
264
|
}
|
|
250
265
|
|
|
266
|
+
// =============================================================================
|
|
267
|
+
// Context construction — non-redundant layered gradient
|
|
268
|
+
// =============================================================================
|
|
269
|
+
|
|
270
|
+
/**
|
|
271
|
+
* Build layered context for L1 creation (raw messages → L1).
|
|
272
|
+
*
|
|
273
|
+
* Gradient: L3 → L2 → L1 → raw tail → [chunk to compress]
|
|
274
|
+
*
|
|
275
|
+
* Anti-redundancy: exclude summaries whose children are expanded below.
|
|
276
|
+
* - L3s are excluded if their constituent L2s are shown in the L2 layer
|
|
277
|
+
* - L2s are excluded if their constituent L1s are shown in the L1 layer
|
|
278
|
+
* - L1s are excluded if their raw messages are the chunk or the tail
|
|
279
|
+
*/
|
|
280
|
+
async function buildL1Context(params: {
|
|
281
|
+
index: SummaryIndex;
|
|
282
|
+
chunk: ChunkToSummarize;
|
|
283
|
+
agentId: string;
|
|
284
|
+
stateDir: string;
|
|
285
|
+
}): Promise<LayeredContext> {
|
|
286
|
+
const { index, chunk, agentId, stateDir } = params;
|
|
287
|
+
|
|
288
|
+
// Get all unmerged summaries at each level
|
|
289
|
+
const allL1 = getUnmergedSummaries(index, "L1");
|
|
290
|
+
const allL2 = getUnmergedSummaries(index, "L2");
|
|
291
|
+
const allL3 = getUnmergedSummaries(index, "L3");
|
|
292
|
+
|
|
293
|
+
// The chunk's source entry IDs — L1s covering these are excluded
|
|
294
|
+
const chunkEntryIds = new Set(chunk.entryIds);
|
|
295
|
+
|
|
296
|
+
// Load the raw tail (uncompressed messages after last summarized entry)
|
|
297
|
+
const rawTail = loadRawTail(stateDir, params.agentId);
|
|
298
|
+
|
|
299
|
+
// L1s to show: all unmerged L1s except those whose sourceIds overlap with
|
|
300
|
+
// the chunk being compressed (those are the raw messages shown at the bottom)
|
|
301
|
+
const shownL1 = allL1.filter(
|
|
302
|
+
(l1) => !l1.sourceIds.some((id) => chunkEntryIds.has(id)),
|
|
303
|
+
);
|
|
304
|
+
const shownL1Ids = new Set(shownL1.map((e) => e.id));
|
|
305
|
+
|
|
306
|
+
// L2s to show: all unmerged L2s except those whose constituent L1s are all shown
|
|
307
|
+
// (i.e., the L2 is redundant because all its L1s are already in the L1 layer)
|
|
308
|
+
const shownL2 = allL2.filter(
|
|
309
|
+
(l2) => !l2.sourceIds.every((id) => shownL1Ids.has(id)),
|
|
310
|
+
);
|
|
311
|
+
const shownL2Ids = new Set(shownL2.map((e) => e.id));
|
|
312
|
+
|
|
313
|
+
// L3s to show: all unmerged L3s except those whose constituent L2s are all shown
|
|
314
|
+
const shownL3 = allL3.filter(
|
|
315
|
+
(l3) => !l3.sourceIds.every((id) => shownL2Ids.has(id)),
|
|
316
|
+
);
|
|
317
|
+
|
|
318
|
+
return {
|
|
319
|
+
l3: await loadSummaryContents(shownL3, agentId),
|
|
320
|
+
l2: await loadSummaryContents(shownL2, agentId),
|
|
321
|
+
l1: await loadSummaryContents(shownL1, agentId),
|
|
322
|
+
rawTail,
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/**
|
|
327
|
+
* Build layered context for L2 creation (L1s → L2).
|
|
328
|
+
*
|
|
329
|
+
* Gradient: L3 → L2 → recent L1s → [L1s to merge] → raw tail
|
|
330
|
+
*
|
|
331
|
+
* Anti-redundancy:
|
|
332
|
+
* - L2s are excluded if their constituent L1s are shown (either in
|
|
333
|
+
* the recent L1 layer or in the merge batch)
|
|
334
|
+
* - L3s are excluded if their constituent L2s are shown
|
|
335
|
+
*/
|
|
336
|
+
async function buildL2Context(params: {
|
|
337
|
+
index: SummaryIndex;
|
|
338
|
+
toMerge: SummaryEntry[];
|
|
339
|
+
agentId: string;
|
|
340
|
+
stateDir: string;
|
|
341
|
+
}): Promise<LayeredContext> {
|
|
342
|
+
const { index, toMerge, agentId, stateDir } = params;
|
|
343
|
+
|
|
344
|
+
const allL1 = getUnmergedSummaries(index, "L1");
|
|
345
|
+
const allL2 = getUnmergedSummaries(index, "L2");
|
|
346
|
+
const allL3 = getUnmergedSummaries(index, "L3");
|
|
347
|
+
|
|
348
|
+
const mergeIds = new Set(toMerge.map((e) => e.id));
|
|
349
|
+
|
|
350
|
+
// Recent L1s: all unmerged L1s that are NOT being merged
|
|
351
|
+
const recentL1 = allL1.filter((l1) => !mergeIds.has(l1.id));
|
|
352
|
+
const allVisibleL1Ids = new Set([
|
|
353
|
+
...recentL1.map((e) => e.id),
|
|
354
|
+
...toMerge.map((e) => e.id),
|
|
355
|
+
]);
|
|
356
|
+
|
|
357
|
+
// L2s: exclude those whose constituent L1s are all visible
|
|
358
|
+
// (either as recent L1s or as the merge batch)
|
|
359
|
+
const shownL2 = allL2.filter(
|
|
360
|
+
(l2) => !l2.sourceIds.every((id) => allVisibleL1Ids.has(id)),
|
|
361
|
+
);
|
|
362
|
+
const shownL2Ids = new Set(shownL2.map((e) => e.id));
|
|
363
|
+
|
|
364
|
+
// L3s: exclude those whose constituent L2s are all visible
|
|
365
|
+
const shownL3 = allL3.filter(
|
|
366
|
+
(l3) => !l3.sourceIds.every((id) => shownL2Ids.has(id)),
|
|
367
|
+
);
|
|
368
|
+
|
|
369
|
+
const rawTail = loadRawTail(stateDir, agentId);
|
|
370
|
+
|
|
371
|
+
return {
|
|
372
|
+
l3: await loadSummaryContents(shownL3, agentId),
|
|
373
|
+
l2: await loadSummaryContents(shownL2, agentId),
|
|
374
|
+
l1: await loadSummaryContents(recentL1, agentId),
|
|
375
|
+
rawTail,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
|
|
379
|
+
/**
|
|
380
|
+
* Build layered context for L3 creation (L2s → L3).
|
|
381
|
+
*
|
|
382
|
+
* Gradient: existing L3s → recent L2s → recent L1s → [L2s to merge] → raw tail
|
|
383
|
+
*
|
|
384
|
+
* Anti-redundancy:
|
|
385
|
+
* - Existing L3s are excluded if their constituent L2s are shown
|
|
386
|
+
* (either as recent L2s or in the merge batch)
|
|
387
|
+
*/
|
|
388
|
+
async function buildL3Context(params: {
|
|
389
|
+
index: SummaryIndex;
|
|
390
|
+
toMerge: SummaryEntry[];
|
|
391
|
+
agentId: string;
|
|
392
|
+
stateDir: string;
|
|
393
|
+
}): Promise<LayeredContext> {
|
|
394
|
+
const { index, toMerge, agentId, stateDir } = params;
|
|
395
|
+
|
|
396
|
+
const allL1 = getUnmergedSummaries(index, "L1");
|
|
397
|
+
const allL2 = getUnmergedSummaries(index, "L2");
|
|
398
|
+
const allL3 = getUnmergedSummaries(index, "L3");
|
|
399
|
+
|
|
400
|
+
const mergeIds = new Set(toMerge.map((e) => e.id));
|
|
401
|
+
|
|
402
|
+
// Recent L2s: unmerged L2s not being merged
|
|
403
|
+
const recentL2 = allL2.filter((l2) => !mergeIds.has(l2.id));
|
|
404
|
+
const allVisibleL2Ids = new Set([
|
|
405
|
+
...recentL2.map((e) => e.id),
|
|
406
|
+
...toMerge.map((e) => e.id),
|
|
407
|
+
]);
|
|
408
|
+
|
|
409
|
+
// L3s: exclude those whose constituent L2s are all visible
|
|
410
|
+
const shownL3 = allL3.filter(
|
|
411
|
+
(l3) => !l3.sourceIds.every((id) => allVisibleL2Ids.has(id)),
|
|
412
|
+
);
|
|
413
|
+
|
|
414
|
+
const rawTail = loadRawTail(stateDir, agentId);
|
|
415
|
+
|
|
416
|
+
return {
|
|
417
|
+
l3: await loadSummaryContents(shownL3, agentId),
|
|
418
|
+
l2: await loadSummaryContents(recentL2, agentId),
|
|
419
|
+
l1: await loadSummaryContents(allL1, agentId),
|
|
420
|
+
rawTail,
|
|
421
|
+
};
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
/**
|
|
425
|
+
* Load the raw uncompressed message tail from the active session.
|
|
426
|
+
* Returns messages after the last summarized entry.
|
|
427
|
+
*/
|
|
428
|
+
function loadRawTail(
|
|
429
|
+
stateDir: string,
|
|
430
|
+
agentId: string,
|
|
431
|
+
): Array<{ role: string; content?: unknown }> {
|
|
432
|
+
const activeSessionId = getActiveSessionId(stateDir, agentId);
|
|
433
|
+
if (!activeSessionId) {
|
|
434
|
+
return [];
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
const sessionFile = resolveSessionTranscriptPath(stateDir, agentId, activeSessionId);
|
|
438
|
+
|
|
439
|
+
let sessionManager: SessionManager;
|
|
440
|
+
try {
|
|
441
|
+
sessionManager = SessionManager.open(sessionFile);
|
|
442
|
+
} catch {
|
|
443
|
+
return [];
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
const entries = sessionManager.getEntries();
|
|
447
|
+
if (entries.length === 0) {
|
|
448
|
+
return [];
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
// Find the last summarized entry in this session
|
|
452
|
+
const storePath = resolveSessionStorePath(stateDir, agentId);
|
|
453
|
+
const store = loadSessionStoreSimple(storePath);
|
|
454
|
+
// We need the per-session progress, but we don't have the index here.
|
|
455
|
+
// Instead, scan from the end — raw tail is everything not yet summarized.
|
|
456
|
+
// For simplicity, load the last N messages as raw tail context.
|
|
457
|
+
// A more precise approach would thread the index through, but this gives
|
|
458
|
+
// the model recent conversation context regardless.
|
|
459
|
+
const messages: Array<{ role: string; content?: unknown }> = [];
|
|
460
|
+
// Take the last ~20 messages as raw tail context
|
|
461
|
+
const startIdx = Math.max(0, entries.length - 20);
|
|
462
|
+
for (let i = startIdx; i < entries.length; i++) {
|
|
463
|
+
const entry = entries[i];
|
|
464
|
+
if (entry.type === "message") {
|
|
465
|
+
messages.push(entry.message as { role: string; content?: unknown });
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
return messages;
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
// =============================================================================
|
|
473
|
+
// Chunk discovery
|
|
474
|
+
// =============================================================================
|
|
475
|
+
|
|
251
476
|
/**
|
|
252
477
|
* Find chunks of messages eligible for summarization.
|
|
253
478
|
*/
|
|
@@ -358,6 +583,10 @@ async function findEligibleChunks(params: {
|
|
|
358
583
|
}
|
|
359
584
|
}
|
|
360
585
|
|
|
586
|
+
// =============================================================================
|
|
587
|
+
// Merge logic
|
|
588
|
+
// =============================================================================
|
|
589
|
+
|
|
361
590
|
/**
|
|
362
591
|
* Merge summaries at a level if threshold is reached.
|
|
363
592
|
*/
|
|
@@ -367,8 +596,9 @@ async function maybeMergeLevel(params: {
|
|
|
367
596
|
memoryConfig: HierarchicalMemoryConfig;
|
|
368
597
|
summarization: SummarizationParams;
|
|
369
598
|
agentId: string;
|
|
599
|
+
stateDir: string;
|
|
370
600
|
}): Promise<boolean> {
|
|
371
|
-
const { index, level, memoryConfig, summarization, agentId } = params;
|
|
601
|
+
const { index, level, memoryConfig, summarization, agentId, stateDir } = params;
|
|
372
602
|
|
|
373
603
|
const unmerged = getUnmergedSummaries(index, level);
|
|
374
604
|
|
|
@@ -384,26 +614,30 @@ async function maybeMergeLevel(params: {
|
|
|
384
614
|
// Take exactly mergeThreshold entries to maintain fixed merge cadence
|
|
385
615
|
const toMerge = unmerged.slice(0, memoryConfig.mergeThreshold);
|
|
386
616
|
|
|
387
|
-
// Load summary contents
|
|
617
|
+
// Load summary contents for the batch being merged
|
|
388
618
|
const summaryContents = await loadSummaryContents(toMerge, agentId);
|
|
389
619
|
|
|
390
|
-
//
|
|
391
|
-
|
|
620
|
+
// Build non-redundant layered context for this merge level
|
|
621
|
+
let mergedContent: string;
|
|
392
622
|
if (nextLevel === "L2") {
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
623
|
+
const context = await buildL2Context({ index, toMerge, agentId, stateDir });
|
|
624
|
+
mergedContent = await mergeL1ToL2({
|
|
625
|
+
summariesToMerge: summaryContents,
|
|
626
|
+
context,
|
|
627
|
+
config: memoryConfig,
|
|
628
|
+
summarization,
|
|
629
|
+
});
|
|
630
|
+
} else {
|
|
631
|
+
// nextLevel === "L3"
|
|
632
|
+
const context = await buildL3Context({ index, toMerge, agentId, stateDir });
|
|
633
|
+
mergedContent = await mergeL2ToL3({
|
|
634
|
+
summariesToMerge: summaryContents,
|
|
635
|
+
context,
|
|
636
|
+
config: memoryConfig,
|
|
637
|
+
summarization,
|
|
638
|
+
});
|
|
397
639
|
}
|
|
398
640
|
|
|
399
|
-
// Merge summaries
|
|
400
|
-
const mergedContent = await mergeSummaries({
|
|
401
|
-
summaries: summaryContents,
|
|
402
|
-
olderContext,
|
|
403
|
-
config: memoryConfig,
|
|
404
|
-
summarization,
|
|
405
|
-
});
|
|
406
|
-
|
|
407
641
|
// Create merged entry
|
|
408
642
|
const mergedId = generateNextSummaryId(index, nextLevel);
|
|
409
643
|
const mergedEntry: SummaryEntry = {
|
|
@@ -428,6 +662,10 @@ async function maybeMergeLevel(params: {
|
|
|
428
662
|
return true;
|
|
429
663
|
}
|
|
430
664
|
|
|
665
|
+
// =============================================================================
|
|
666
|
+
// Summarization params
|
|
667
|
+
// =============================================================================
|
|
668
|
+
|
|
431
669
|
/**
|
|
432
670
|
* Resolve parameters needed for summarization.
|
|
433
671
|
* Uses the API key from plugin config instead of the complex auth system.
|
|
@@ -458,7 +696,9 @@ function resolveSummarizationParams(params: {
|
|
|
458
696
|
};
|
|
459
697
|
}
|
|
460
698
|
|
|
461
|
-
//
|
|
699
|
+
// =============================================================================
|
|
700
|
+
// Inline session helpers (replacing core imports)
|
|
701
|
+
// =============================================================================
|
|
462
702
|
|
|
463
703
|
/** Resolve the session store path for an agent */
|
|
464
704
|
function resolveSessionStorePath(stateDir: string, agentId?: string): string {
|