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 CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "openclaw-memory-hierarchical",
3
- "version": "0.3.0",
4
- "description": "Hierarchical (2048-style) autobiographical memory plugin for OpenClaw. Continuously summarizes conversations into layered first-person memories (L1 → L2 → L3).",
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
- * The key insight: summaries should be first-person memories, not third-person
5
- * narration. The model reads these as its own history, preserving continuity
6
- * of identity across compactions.
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
- * Format messages for summarization prompt.
51
- * Strips unnecessary metadata, keeps the conversational essence.
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
- // Extract text content
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
- * Build the prompt for summarizing a chunk of conversation.
122
+ * Convert summary strings into assistant messages.
123
+ * Each summary becomes the model's own prior recollection.
111
124
  */
112
- export function buildChunkSummarizationPrompt(params: {
113
- /** Prior summaries (L3, L2, L1) for context */
114
- priorSummaries: string[];
115
- /** Messages to summarize */
116
- messages: Array<{ role: string; content?: unknown }>;
117
- }): string {
118
- const parts: string[] = [];
119
-
120
- if (params.priorSummaries.length > 0) {
121
- parts.push("## My earlier memories\n");
122
- parts.push(params.priorSummaries.join("\n\n---\n\n"));
123
- parts.push("\n\n---\n\n");
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
- parts.push("## Recent conversation to remember\n\n");
127
- parts.push(formatMessagesForSummary(params.messages));
128
- parts.push("\n\n---\n\n");
129
- parts.push("Write your memory of this conversation:");
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
- return parts.join("");
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 the prompt for merging multiple summaries.
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 buildMergeSummariesPrompt(params: {
138
- /** Summaries to merge */
139
- summaries: string[];
140
- /** Older context (higher-level summaries) */
141
- olderContext?: string[];
142
- }): string {
143
- const parts: string[] = [];
144
-
145
- if (params.olderContext && params.olderContext.length > 0) {
146
- parts.push("## Long-term memory (for context)\n\n");
147
- parts.push(params.olderContext.join("\n\n---\n\n"));
148
- parts.push("\n\n---\n\n");
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
- parts.push("## Memories to consolidate\n\n");
152
- parts.push(params.summaries.map((s, i) => `### Memory ${i + 1}\n\n${s}`).join("\n\n---\n\n"));
153
- parts.push("\n\n---\n\n");
154
- parts.push("Write a consolidated memory that captures the essence of all these memories:");
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 parts.join("");
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
- * Uses the LLM to generate autobiographical summaries of conversation chunks.
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
- buildChunkSummarizationPrompt,
10
- buildMergeSummariesPrompt,
11
- MERGE_SUMMARIES_SYSTEM,
12
- SUMMARIZE_CHUNK_SYSTEM,
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
- priorSummaries: string[];
62
+ context: LayeredContext;
62
63
  config: HierarchicalMemoryConfig;
63
64
  summarization: SummarizationParams;
64
65
  }): Promise<string> {
65
- const { chunk, priorSummaries, config, summarization } = params;
66
+ const { chunk, context, config, summarization } = params;
66
67
 
67
- const prompt = buildChunkSummarizationPrompt({
68
- priorSummaries,
69
- messages: chunk.messages,
68
+ const messages = buildL1Messages({
69
+ context,
70
+ chunk: chunk.messages,
71
+ targetTokens: config.summaryTargetTokens,
70
72
  });
71
73
 
72
- const summary = await callLlmForSummary({
73
- systemPrompt: SUMMARIZE_CHUNK_SYSTEM,
74
- userPrompt: prompt,
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 multiple summaries into one.
85
+ * Merge L1 summaries into an L2 summary.
87
86
  */
88
- export async function mergeSummaries(params: {
89
- summaries: string[];
90
- olderContext: string[];
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 { summaries, olderContext, config, summarization } = params;
93
+ const messages = buildL2Messages({
94
+ context: params.context,
95
+ summariesToMerge: params.summariesToMerge,
96
+ targetTokens: params.config.summaryTargetTokens,
97
+ });
95
98
 
96
- const prompt = buildMergeSummariesPrompt({
97
- summaries,
98
- olderContext: olderContext.length > 0 ? olderContext : undefined,
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
- const merged = await callLlmForSummary({
102
- systemPrompt: MERGE_SUMMARIES_SYSTEM,
103
- userPrompt: prompt,
104
- targetTokens: config.summaryTargetTokens,
105
- model: summarization.model,
106
- provider: summarization.provider,
107
- apiKey: summarization.apiKey,
108
- signal: summarization.signal,
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 merged;
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 to generate a summary.
116
- * Uses completeSimple for a straightforward non-streaming completion.
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 callLlmForSummary(params: {
119
- systemPrompt: string;
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
- systemPrompt: params.systemPrompt,
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; // No level above L3
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: 1000,
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
- mergeSummaries,
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
- // Load prior summaries for context
156
- const summaryContext = getAllSummariesForContext(index);
157
- const priorSummaries = [
158
- ...(await loadSummaryContents(summaryContext.L3, agentId)),
159
- ...(await loadSummaryContents(summaryContext.L2, agentId)),
160
- ...(await loadSummaryContents(summaryContext.L1, agentId)),
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
- priorSummaries,
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: Check for merges at each level
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
- for (const level of ["L1", "L2"] as const) {
204
- if (signal?.aborted) {
205
- break;
206
- }
207
-
208
- const merged = await maybeMergeLevel({
209
- index,
210
- level,
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
- if (merged) {
217
- mergesPerformed++;
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
- // Load older context (unmerged higher-level summaries only)
391
- const olderContext: string[] = [];
620
+ // Build non-redundant layered context for this merge level
621
+ let mergedContent: string;
392
622
  if (nextLevel === "L2") {
393
- olderContext.push(...(await loadSummaryContents(getUnmergedSummaries(index, "L3"), agentId)));
394
- }
395
- if (nextLevel === "L3") {
396
- // L3 has no older context
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
- // --- Inline session helpers (replacing core imports) ---
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 {