openclaw-memory-hierarchical 0.1.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/README.md +80 -0
- package/config.ts +126 -0
- package/context.ts +148 -0
- package/index.ts +241 -0
- package/lock.ts +94 -0
- package/openclaw.plugin.json +80 -0
- package/package.json +36 -0
- package/prompts.ts +157 -0
- package/storage.ts +256 -0
- package/summarize.ts +190 -0
- package/timer.ts +128 -0
- package/types.ts +139 -0
- package/worker.ts +457 -0
package/summarize.ts
ADDED
|
@@ -0,0 +1,190 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Summarization logic for hierarchical memory.
|
|
3
|
+
*
|
|
4
|
+
* Uses the LLM to generate autobiographical summaries of conversation chunks.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { HierarchicalMemoryConfig, SummaryLevel } from "./types.js";
|
|
8
|
+
import {
|
|
9
|
+
buildChunkSummarizationPrompt,
|
|
10
|
+
buildMergeSummariesPrompt,
|
|
11
|
+
MERGE_SUMMARIES_SYSTEM,
|
|
12
|
+
SUMMARIZE_CHUNK_SYSTEM,
|
|
13
|
+
} from "./prompts.js";
|
|
14
|
+
|
|
15
|
+
export type SummarizationParams = {
|
|
16
|
+
/** Model to use for summarization */
|
|
17
|
+
model: string;
|
|
18
|
+
/** Provider for the model */
|
|
19
|
+
provider: string;
|
|
20
|
+
/** API key for the provider */
|
|
21
|
+
apiKey: string;
|
|
22
|
+
/** Abort signal */
|
|
23
|
+
signal?: AbortSignal;
|
|
24
|
+
/** Target token count for the summary */
|
|
25
|
+
targetTokens?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export type ChunkToSummarize = {
|
|
29
|
+
/** Messages in this chunk */
|
|
30
|
+
messages: Array<{ role: string; content?: unknown }>;
|
|
31
|
+
/** Entry IDs covered by this chunk */
|
|
32
|
+
entryIds: string[];
|
|
33
|
+
/** Session ID these entries came from */
|
|
34
|
+
sessionId: string;
|
|
35
|
+
/** Estimated token count of the chunk */
|
|
36
|
+
tokenEstimate: number;
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Estimate tokens for an array of messages.
|
|
41
|
+
*/
|
|
42
|
+
export function estimateMessagesTokens(
|
|
43
|
+
messages: Array<{ role: string; content?: unknown }>,
|
|
44
|
+
): number {
|
|
45
|
+
let total = 0;
|
|
46
|
+
for (const msg of messages) {
|
|
47
|
+
// Simple estimation based on content length
|
|
48
|
+
const content =
|
|
49
|
+
typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content ?? "");
|
|
50
|
+
// Rough estimate: 4 chars per token
|
|
51
|
+
total += Math.ceil(content.length / 4);
|
|
52
|
+
}
|
|
53
|
+
return total;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Summarize a chunk of conversation messages.
|
|
58
|
+
*/
|
|
59
|
+
export async function summarizeChunk(params: {
|
|
60
|
+
chunk: ChunkToSummarize;
|
|
61
|
+
priorSummaries: string[];
|
|
62
|
+
config: HierarchicalMemoryConfig;
|
|
63
|
+
summarization: SummarizationParams;
|
|
64
|
+
}): Promise<string> {
|
|
65
|
+
const { chunk, priorSummaries, config, summarization } = params;
|
|
66
|
+
|
|
67
|
+
const prompt = buildChunkSummarizationPrompt({
|
|
68
|
+
priorSummaries,
|
|
69
|
+
messages: chunk.messages,
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const summary = await callLlmForSummary({
|
|
73
|
+
systemPrompt: SUMMARIZE_CHUNK_SYSTEM,
|
|
74
|
+
userPrompt: prompt,
|
|
75
|
+
targetTokens: config.summaryTargetTokens,
|
|
76
|
+
model: summarization.model,
|
|
77
|
+
provider: summarization.provider,
|
|
78
|
+
apiKey: summarization.apiKey,
|
|
79
|
+
signal: summarization.signal,
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
return summary;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* Merge multiple summaries into one.
|
|
87
|
+
*/
|
|
88
|
+
export async function mergeSummaries(params: {
|
|
89
|
+
summaries: string[];
|
|
90
|
+
olderContext: string[];
|
|
91
|
+
config: HierarchicalMemoryConfig;
|
|
92
|
+
summarization: SummarizationParams;
|
|
93
|
+
}): Promise<string> {
|
|
94
|
+
const { summaries, olderContext, config, summarization } = params;
|
|
95
|
+
|
|
96
|
+
const prompt = buildMergeSummariesPrompt({
|
|
97
|
+
summaries,
|
|
98
|
+
olderContext: olderContext.length > 0 ? olderContext : undefined,
|
|
99
|
+
});
|
|
100
|
+
|
|
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
|
+
|
|
111
|
+
return merged;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Call the LLM to generate a summary.
|
|
116
|
+
* Uses completeSimple for a straightforward non-streaming completion.
|
|
117
|
+
*/
|
|
118
|
+
async function callLlmForSummary(params: {
|
|
119
|
+
systemPrompt: string;
|
|
120
|
+
userPrompt: string;
|
|
121
|
+
model: string;
|
|
122
|
+
provider: string;
|
|
123
|
+
apiKey: string;
|
|
124
|
+
signal?: AbortSignal;
|
|
125
|
+
targetTokens?: number;
|
|
126
|
+
}): Promise<string> {
|
|
127
|
+
// Dynamic import to avoid loading heavy deps at module level
|
|
128
|
+
const { completeSimple, getModel } = await import("@mariozechner/pi-ai");
|
|
129
|
+
|
|
130
|
+
const model = getModel(params.provider, params.model);
|
|
131
|
+
|
|
132
|
+
if (!model) {
|
|
133
|
+
throw new Error(`Failed to resolve model: ${params.provider}/${params.model}`);
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const maxTokens = params.targetTokens ?? 1000;
|
|
137
|
+
|
|
138
|
+
const res = await completeSimple(
|
|
139
|
+
model,
|
|
140
|
+
{
|
|
141
|
+
systemPrompt: params.systemPrompt,
|
|
142
|
+
messages: [
|
|
143
|
+
{
|
|
144
|
+
role: "user",
|
|
145
|
+
content: params.userPrompt,
|
|
146
|
+
timestamp: Date.now(),
|
|
147
|
+
},
|
|
148
|
+
],
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
apiKey: params.apiKey,
|
|
152
|
+
maxTokens,
|
|
153
|
+
signal: params.signal,
|
|
154
|
+
},
|
|
155
|
+
);
|
|
156
|
+
|
|
157
|
+
// Extract text from the response
|
|
158
|
+
const textContent = res.content.find(
|
|
159
|
+
(c): c is { type: "text"; text: string } => c.type === "text",
|
|
160
|
+
);
|
|
161
|
+
return textContent?.text?.trim() ?? "";
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/**
|
|
165
|
+
* Determine the source level for a target level.
|
|
166
|
+
*/
|
|
167
|
+
export function getSourceLevel(targetLevel: SummaryLevel): "L0" | "L1" | "L2" {
|
|
168
|
+
switch (targetLevel) {
|
|
169
|
+
case "L1":
|
|
170
|
+
return "L0";
|
|
171
|
+
case "L2":
|
|
172
|
+
return "L1";
|
|
173
|
+
case "L3":
|
|
174
|
+
return "L2";
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* Get the next level up from a given level.
|
|
180
|
+
*/
|
|
181
|
+
export function getNextLevel(level: SummaryLevel): SummaryLevel | null {
|
|
182
|
+
switch (level) {
|
|
183
|
+
case "L1":
|
|
184
|
+
return "L2";
|
|
185
|
+
case "L2":
|
|
186
|
+
return "L3";
|
|
187
|
+
case "L3":
|
|
188
|
+
return null; // No level above L3
|
|
189
|
+
}
|
|
190
|
+
}
|
package/timer.ts
ADDED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Timer for running the hierarchical memory worker periodically.
|
|
3
|
+
*
|
|
4
|
+
* All state is instance-scoped in the returned handle, so multiple
|
|
5
|
+
* timers (e.g., different agentIds or tests) don't collide.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { PluginConfig } from "./config.js";
|
|
9
|
+
import { resolveHierarchicalMemoryConfig } from "./config.js";
|
|
10
|
+
import { runHierarchicalMemoryWorker } from "./worker.js";
|
|
11
|
+
|
|
12
|
+
export type HierarchicalMemoryTimerHandle = {
|
|
13
|
+
/** Stop the timer */
|
|
14
|
+
stop: () => void;
|
|
15
|
+
/** Get the last run result */
|
|
16
|
+
getLastResult: () => WorkerRunInfo | null;
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
type WorkerRunInfo = {
|
|
20
|
+
timestamp: number;
|
|
21
|
+
success: boolean;
|
|
22
|
+
chunksProcessed?: number;
|
|
23
|
+
mergesPerformed?: number;
|
|
24
|
+
error?: string;
|
|
25
|
+
durationMs?: number;
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Start the hierarchical memory worker timer.
|
|
30
|
+
* Returns a handle to stop the timer.
|
|
31
|
+
*/
|
|
32
|
+
export function startHierarchicalMemoryTimer(params: {
|
|
33
|
+
agentId: string;
|
|
34
|
+
pluginConfig: PluginConfig;
|
|
35
|
+
stateDir: string;
|
|
36
|
+
log?: {
|
|
37
|
+
info: (msg: string) => void;
|
|
38
|
+
warn: (msg: string) => void;
|
|
39
|
+
error: (msg: string) => void;
|
|
40
|
+
};
|
|
41
|
+
}): HierarchicalMemoryTimerHandle {
|
|
42
|
+
const memoryConfig = resolveHierarchicalMemoryConfig(params.pluginConfig);
|
|
43
|
+
|
|
44
|
+
const log = params.log ?? {
|
|
45
|
+
info: console.log,
|
|
46
|
+
warn: console.warn,
|
|
47
|
+
error: console.error,
|
|
48
|
+
};
|
|
49
|
+
|
|
50
|
+
// Instance-scoped state
|
|
51
|
+
let handle: ReturnType<typeof setInterval> | null = null;
|
|
52
|
+
let lastResult: WorkerRunInfo | null = null;
|
|
53
|
+
let isRunning = false;
|
|
54
|
+
|
|
55
|
+
const runWorker = async () => {
|
|
56
|
+
if (isRunning) {
|
|
57
|
+
return; // Skip if previous run is still in progress
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
isRunning = true;
|
|
61
|
+
try {
|
|
62
|
+
const result = await runHierarchicalMemoryWorker({
|
|
63
|
+
agentId: params.agentId,
|
|
64
|
+
pluginConfig: params.pluginConfig,
|
|
65
|
+
stateDir: params.stateDir,
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
lastResult = {
|
|
69
|
+
timestamp: Date.now(),
|
|
70
|
+
success: result.success,
|
|
71
|
+
chunksProcessed: result.chunksProcessed,
|
|
72
|
+
mergesPerformed: result.mergesPerformed,
|
|
73
|
+
error: result.error,
|
|
74
|
+
durationMs: result.durationMs,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
if (result.skipped) {
|
|
78
|
+
// Silent skip - lock held or disabled
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (result.success) {
|
|
83
|
+
if ((result.chunksProcessed ?? 0) > 0 || (result.mergesPerformed ?? 0) > 0) {
|
|
84
|
+
log.info(
|
|
85
|
+
`hierarchical memory: processed ${result.chunksProcessed ?? 0} chunks, ` +
|
|
86
|
+
`${result.mergesPerformed ?? 0} merges (${result.durationMs}ms)`,
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
} else if (result.error) {
|
|
90
|
+
log.error(`hierarchical memory worker failed: ${result.error}`);
|
|
91
|
+
}
|
|
92
|
+
} catch (err) {
|
|
93
|
+
const error = err instanceof Error ? err.message : String(err);
|
|
94
|
+
lastResult = {
|
|
95
|
+
timestamp: Date.now(),
|
|
96
|
+
success: false,
|
|
97
|
+
error,
|
|
98
|
+
};
|
|
99
|
+
log.error(`hierarchical memory worker error: ${error}`);
|
|
100
|
+
} finally {
|
|
101
|
+
isRunning = false;
|
|
102
|
+
}
|
|
103
|
+
};
|
|
104
|
+
|
|
105
|
+
// Run immediately on start
|
|
106
|
+
void runWorker();
|
|
107
|
+
|
|
108
|
+
// Then run on interval
|
|
109
|
+
handle = setInterval(() => {
|
|
110
|
+
void runWorker();
|
|
111
|
+
}, memoryConfig.workerIntervalMs);
|
|
112
|
+
|
|
113
|
+
log.info(
|
|
114
|
+
`hierarchical memory timer started (interval: ${Math.round(memoryConfig.workerIntervalMs / 1000)}s)`,
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
const timerHandle: HierarchicalMemoryTimerHandle = {
|
|
118
|
+
stop: () => {
|
|
119
|
+
if (handle) {
|
|
120
|
+
clearInterval(handle);
|
|
121
|
+
handle = null;
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
getLastResult: () => lastResult,
|
|
125
|
+
};
|
|
126
|
+
|
|
127
|
+
return timerHandle;
|
|
128
|
+
}
|
package/types.ts
ADDED
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical Memory System Types
|
|
3
|
+
*
|
|
4
|
+
* A 2048-style compression system for long-running conversations.
|
|
5
|
+
* Chunks of ~6k tokens are summarized to ~1k, then 6 summaries merge into 1.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type SummaryLevel = "L1" | "L2" | "L3";
|
|
9
|
+
|
|
10
|
+
export type SummaryEntry = {
|
|
11
|
+
/** Unique ID within the level (e.g., "0001") */
|
|
12
|
+
id: string;
|
|
13
|
+
|
|
14
|
+
/** When this summary was created */
|
|
15
|
+
createdAt: number;
|
|
16
|
+
|
|
17
|
+
/** Estimated token count of the summary */
|
|
18
|
+
tokenEstimate: number;
|
|
19
|
+
|
|
20
|
+
/** What level this summary belongs to */
|
|
21
|
+
level: SummaryLevel;
|
|
22
|
+
|
|
23
|
+
/** Source level that was summarized */
|
|
24
|
+
sourceLevel: "L0" | "L1" | "L2";
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* IDs of source items:
|
|
28
|
+
* - For L1: entry IDs from the session JSONL
|
|
29
|
+
* - For L2/L3: summary IDs from the lower level
|
|
30
|
+
*/
|
|
31
|
+
sourceIds: string[];
|
|
32
|
+
|
|
33
|
+
/** Session ID this summary originated from (for L1 only) */
|
|
34
|
+
sourceSessionId?: string;
|
|
35
|
+
|
|
36
|
+
/** If merged into a higher level, the ID of that summary */
|
|
37
|
+
mergedInto: string | null;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export type SummaryIndex = {
|
|
41
|
+
/** Schema version for migrations */
|
|
42
|
+
version: 1;
|
|
43
|
+
|
|
44
|
+
/** Agent this index belongs to */
|
|
45
|
+
agentId: string;
|
|
46
|
+
|
|
47
|
+
/** Last entry ID from JSONL that was summarized */
|
|
48
|
+
lastSummarizedEntryId: string | null;
|
|
49
|
+
|
|
50
|
+
/** Session ID of the last summarized entry */
|
|
51
|
+
lastSummarizedSessionId: string | null;
|
|
52
|
+
|
|
53
|
+
/** Summaries organized by level */
|
|
54
|
+
levels: {
|
|
55
|
+
L1: SummaryEntry[];
|
|
56
|
+
L2: SummaryEntry[];
|
|
57
|
+
L3: SummaryEntry[];
|
|
58
|
+
};
|
|
59
|
+
|
|
60
|
+
/** Worker state */
|
|
61
|
+
worker: {
|
|
62
|
+
lastRunAt: number | null;
|
|
63
|
+
lastRunDurationMs: number | null;
|
|
64
|
+
lastError: string | null;
|
|
65
|
+
};
|
|
66
|
+
};
|
|
67
|
+
|
|
68
|
+
export type HierarchicalMemoryConfig = {
|
|
69
|
+
/** Enable the hierarchical memory system (default: false) */
|
|
70
|
+
enabled: boolean;
|
|
71
|
+
|
|
72
|
+
/** How often the worker runs in milliseconds (default: 300000 = 5 min) */
|
|
73
|
+
workerIntervalMs: number;
|
|
74
|
+
|
|
75
|
+
/** Minimum tokens in a chunk before summarization (default: 6000) */
|
|
76
|
+
chunkTokens: number;
|
|
77
|
+
|
|
78
|
+
/** Target token count for summaries (default: 1000) */
|
|
79
|
+
summaryTargetTokens: number;
|
|
80
|
+
|
|
81
|
+
/** Number of summaries before merging to next level (default: 6) */
|
|
82
|
+
mergeThreshold: number;
|
|
83
|
+
|
|
84
|
+
/** Messages must be this many tokens behind current to be eligible (default: 30000) */
|
|
85
|
+
pruningBoundaryTokens: number;
|
|
86
|
+
|
|
87
|
+
/** Model to use for summarization (default: session's model) */
|
|
88
|
+
model?: string;
|
|
89
|
+
|
|
90
|
+
/** Maximum levels (default: 3) */
|
|
91
|
+
maxLevels: number;
|
|
92
|
+
};
|
|
93
|
+
|
|
94
|
+
export const DEFAULT_HIERARCHICAL_MEMORY_CONFIG: HierarchicalMemoryConfig = {
|
|
95
|
+
enabled: false,
|
|
96
|
+
workerIntervalMs: 5 * 60 * 1000, // 5 minutes
|
|
97
|
+
chunkTokens: 6000,
|
|
98
|
+
summaryTargetTokens: 1000,
|
|
99
|
+
mergeThreshold: 6,
|
|
100
|
+
pruningBoundaryTokens: 30000,
|
|
101
|
+
maxLevels: 3,
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
export function createEmptyIndex(agentId: string): SummaryIndex {
|
|
105
|
+
return {
|
|
106
|
+
version: 1,
|
|
107
|
+
agentId,
|
|
108
|
+
lastSummarizedEntryId: null,
|
|
109
|
+
lastSummarizedSessionId: null,
|
|
110
|
+
levels: {
|
|
111
|
+
L1: [],
|
|
112
|
+
L2: [],
|
|
113
|
+
L3: [],
|
|
114
|
+
},
|
|
115
|
+
worker: {
|
|
116
|
+
lastRunAt: null,
|
|
117
|
+
lastRunDurationMs: null,
|
|
118
|
+
lastError: null,
|
|
119
|
+
},
|
|
120
|
+
};
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** Get summaries that haven't been merged into a higher level */
|
|
124
|
+
export function getUnmergedSummaries(index: SummaryIndex, level: SummaryLevel): SummaryEntry[] {
|
|
125
|
+
return index.levels[level].filter((s) => s.mergedInto === null);
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
/** Get all summaries for context injection (oldest to newest) */
|
|
129
|
+
export function getAllSummariesForContext(index: SummaryIndex): {
|
|
130
|
+
L3: SummaryEntry[];
|
|
131
|
+
L2: SummaryEntry[];
|
|
132
|
+
L1: SummaryEntry[];
|
|
133
|
+
} {
|
|
134
|
+
return {
|
|
135
|
+
L3: getUnmergedSummaries(index, "L3"),
|
|
136
|
+
L2: getUnmergedSummaries(index, "L2"),
|
|
137
|
+
L1: getUnmergedSummaries(index, "L1"),
|
|
138
|
+
};
|
|
139
|
+
}
|