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/README.md
ADDED
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
# openclaw-memory-hierarchical
|
|
2
|
+
|
|
3
|
+
Hierarchical (2048-style) autobiographical memory plugin for [OpenClaw](https://github.com/openclaw/openclaw).
|
|
4
|
+
|
|
5
|
+
Continuously summarizes conversations in the background, creating layers of progressively compressed first-person memories:
|
|
6
|
+
|
|
7
|
+
- **L1** — Recent memory chunks (~6k tokens summarized to ~1k)
|
|
8
|
+
- **L2** — Earlier context (6 L1 summaries merged into 1)
|
|
9
|
+
- **L3** — Long-term memory (6 L2 summaries merged into 1)
|
|
10
|
+
|
|
11
|
+
Memories are written in first-person ("I discussed...", "I learned that the user...") — autobiographical, not transcript summaries.
|
|
12
|
+
|
|
13
|
+
## Install
|
|
14
|
+
|
|
15
|
+
```bash
|
|
16
|
+
openclaw plugins install openclaw-memory-hierarchical
|
|
17
|
+
```
|
|
18
|
+
|
|
19
|
+
## Configure
|
|
20
|
+
|
|
21
|
+
Add to your OpenClaw config:
|
|
22
|
+
|
|
23
|
+
```yaml
|
|
24
|
+
plugins:
|
|
25
|
+
entries:
|
|
26
|
+
memory-hierarchical:
|
|
27
|
+
config:
|
|
28
|
+
# Optional: explicit API key (falls back to ANTHROPIC_API_KEY env var)
|
|
29
|
+
# apiKey: "sk-..."
|
|
30
|
+
|
|
31
|
+
# Optional: model for summarization (default: anthropic/claude-sonnet-4-5-20250929)
|
|
32
|
+
# model: "anthropic/claude-sonnet-4-5-20250929"
|
|
33
|
+
|
|
34
|
+
# Optional: worker interval (default: 5m)
|
|
35
|
+
# workerInterval: "5m"
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
Minimal config (uses `ANTHROPIC_API_KEY` from environment):
|
|
39
|
+
|
|
40
|
+
```yaml
|
|
41
|
+
plugins:
|
|
42
|
+
entries:
|
|
43
|
+
memory-hierarchical:
|
|
44
|
+
config: {}
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
## How it works
|
|
48
|
+
|
|
49
|
+
1. A background worker runs every 5 minutes (configurable)
|
|
50
|
+
2. It reads the current session transcript and finds messages old enough to summarize
|
|
51
|
+
3. Chunks of ~6k tokens are summarized to ~1k token first-person memories (L1)
|
|
52
|
+
4. When 6 L1 summaries accumulate, they merge into 1 L2 summary
|
|
53
|
+
5. When 6 L2 summaries accumulate, they merge into 1 L3 summary
|
|
54
|
+
6. Before each agent run, active memories are injected into the system prompt
|
|
55
|
+
|
|
56
|
+
## CLI commands
|
|
57
|
+
|
|
58
|
+
```bash
|
|
59
|
+
openclaw memory-hierarchical status # Show memory stats
|
|
60
|
+
openclaw memory-hierarchical status --json # JSON output
|
|
61
|
+
openclaw memory-hierarchical inspect # View summaries
|
|
62
|
+
openclaw memory-hierarchical inspect --level L1 --limit 3
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
## Configuration options
|
|
66
|
+
|
|
67
|
+
| Option | Type | Default | Description |
|
|
68
|
+
|--------|------|---------|-------------|
|
|
69
|
+
| `apiKey` | string | `$ANTHROPIC_API_KEY` | API key for the summarization model |
|
|
70
|
+
| `model` | string | `anthropic/claude-sonnet-4-5-20250929` | Model to use for summarization |
|
|
71
|
+
| `workerInterval` | string | `"5m"` | How often the background worker runs |
|
|
72
|
+
| `chunkTokens` | number | `6000` | Minimum tokens before summarizing a chunk |
|
|
73
|
+
| `summaryTargetTokens` | number | `1000` | Target summary length in tokens |
|
|
74
|
+
| `mergeThreshold` | number | `6` | Summaries before merging to next level |
|
|
75
|
+
| `pruningBoundaryTokens` | number | `30000` | Messages must be this far behind to summarize |
|
|
76
|
+
| `maxLevels` | number | `3` | Maximum summary levels |
|
|
77
|
+
|
|
78
|
+
## License
|
|
79
|
+
|
|
80
|
+
MIT
|
package/config.ts
ADDED
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Configuration resolution for hierarchical memory plugin.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { DEFAULT_HIERARCHICAL_MEMORY_CONFIG, type HierarchicalMemoryConfig } from "./types.js";
|
|
6
|
+
|
|
7
|
+
/** Plugin-specific configuration (from openclaw.plugin.json schema) */
|
|
8
|
+
export type PluginConfig = {
|
|
9
|
+
/** API key for the summarization model. Falls back to ANTHROPIC_API_KEY env var if not set. */
|
|
10
|
+
apiKey?: string;
|
|
11
|
+
workerInterval?: string;
|
|
12
|
+
chunkTokens?: number;
|
|
13
|
+
summaryTargetTokens?: number;
|
|
14
|
+
mergeThreshold?: number;
|
|
15
|
+
pruningBoundaryTokens?: number;
|
|
16
|
+
model?: string;
|
|
17
|
+
maxLevels?: number;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Parse duration string to milliseconds (e.g., "5m" → 300000) */
|
|
21
|
+
function parseDurationMs(raw: string, opts?: { defaultUnit?: string }): number {
|
|
22
|
+
const trimmed = String(raw ?? "")
|
|
23
|
+
.trim()
|
|
24
|
+
.toLowerCase();
|
|
25
|
+
if (!trimmed) {
|
|
26
|
+
throw new Error("invalid duration (empty)");
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const m = /^(\d+(?:\.\d+)?)(ms|s|m|h|d)?$/.exec(trimmed);
|
|
30
|
+
if (!m) {
|
|
31
|
+
throw new Error(`invalid duration: ${raw}`);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const value = Number(m[1]);
|
|
35
|
+
if (!Number.isFinite(value) || value < 0) {
|
|
36
|
+
throw new Error(`invalid duration: ${raw}`);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
const unit = (m[2] ?? opts?.defaultUnit ?? "ms") as "ms" | "s" | "m" | "h" | "d";
|
|
40
|
+
const multiplier =
|
|
41
|
+
unit === "ms"
|
|
42
|
+
? 1
|
|
43
|
+
: unit === "s"
|
|
44
|
+
? 1000
|
|
45
|
+
: unit === "m"
|
|
46
|
+
? 60_000
|
|
47
|
+
: unit === "h"
|
|
48
|
+
? 3_600_000
|
|
49
|
+
: 86_400_000;
|
|
50
|
+
const ms = Math.round(value * multiplier);
|
|
51
|
+
if (!Number.isFinite(ms)) {
|
|
52
|
+
throw new Error(`invalid duration: ${raw}`);
|
|
53
|
+
}
|
|
54
|
+
return ms;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Parse workerInterval string or fall back to default */
|
|
58
|
+
function resolveWorkerIntervalMs(raw: { workerInterval?: string }): number {
|
|
59
|
+
if (raw.workerInterval) {
|
|
60
|
+
try {
|
|
61
|
+
return parseDurationMs(raw.workerInterval, { defaultUnit: "m" });
|
|
62
|
+
} catch {
|
|
63
|
+
// Fall through to default
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
return DEFAULT_HIERARCHICAL_MEMORY_CONFIG.workerIntervalMs;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** Resolve the API key: explicit config > ANTHROPIC_API_KEY > provider-specific env vars */
|
|
70
|
+
function resolveApiKey(explicit?: string): string | undefined {
|
|
71
|
+
if (explicit) {
|
|
72
|
+
return explicit;
|
|
73
|
+
}
|
|
74
|
+
// Fall back to common provider env vars
|
|
75
|
+
return (
|
|
76
|
+
process.env.ANTHROPIC_API_KEY ??
|
|
77
|
+
process.env.OPENAI_API_KEY ??
|
|
78
|
+
undefined
|
|
79
|
+
);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
/** Parse and validate plugin config */
|
|
83
|
+
export function parsePluginConfig(raw: unknown): PluginConfig {
|
|
84
|
+
if (!raw || typeof raw !== "object") {
|
|
85
|
+
// Allow empty config — API key can come from env
|
|
86
|
+
return { apiKey: resolveApiKey() };
|
|
87
|
+
}
|
|
88
|
+
const cfg = raw as Record<string, unknown>;
|
|
89
|
+
const apiKey = typeof cfg.apiKey === "string" ? cfg.apiKey : undefined;
|
|
90
|
+
return {
|
|
91
|
+
apiKey: resolveApiKey(apiKey),
|
|
92
|
+
workerInterval: typeof cfg.workerInterval === "string" ? cfg.workerInterval : undefined,
|
|
93
|
+
chunkTokens: typeof cfg.chunkTokens === "number" ? cfg.chunkTokens : undefined,
|
|
94
|
+
summaryTargetTokens:
|
|
95
|
+
typeof cfg.summaryTargetTokens === "number" ? cfg.summaryTargetTokens : undefined,
|
|
96
|
+
mergeThreshold: typeof cfg.mergeThreshold === "number" ? cfg.mergeThreshold : undefined,
|
|
97
|
+
pruningBoundaryTokens:
|
|
98
|
+
typeof cfg.pruningBoundaryTokens === "number" ? cfg.pruningBoundaryTokens : undefined,
|
|
99
|
+
model: typeof cfg.model === "string" ? cfg.model : undefined,
|
|
100
|
+
maxLevels: typeof cfg.maxLevels === "number" ? cfg.maxLevels : undefined,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** Resolve hierarchical memory config from plugin config */
|
|
105
|
+
export function resolveHierarchicalMemoryConfig(
|
|
106
|
+
pluginConfig?: PluginConfig,
|
|
107
|
+
): HierarchicalMemoryConfig {
|
|
108
|
+
if (!pluginConfig) {
|
|
109
|
+
return { ...DEFAULT_HIERARCHICAL_MEMORY_CONFIG, enabled: true };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
enabled: true, // If plugin is loaded, it's enabled
|
|
114
|
+
workerIntervalMs: resolveWorkerIntervalMs(pluginConfig),
|
|
115
|
+
chunkTokens: pluginConfig.chunkTokens ?? DEFAULT_HIERARCHICAL_MEMORY_CONFIG.chunkTokens,
|
|
116
|
+
summaryTargetTokens:
|
|
117
|
+
pluginConfig.summaryTargetTokens ?? DEFAULT_HIERARCHICAL_MEMORY_CONFIG.summaryTargetTokens,
|
|
118
|
+
mergeThreshold:
|
|
119
|
+
pluginConfig.mergeThreshold ?? DEFAULT_HIERARCHICAL_MEMORY_CONFIG.mergeThreshold,
|
|
120
|
+
pruningBoundaryTokens:
|
|
121
|
+
pluginConfig.pruningBoundaryTokens ??
|
|
122
|
+
DEFAULT_HIERARCHICAL_MEMORY_CONFIG.pruningBoundaryTokens,
|
|
123
|
+
model: pluginConfig.model,
|
|
124
|
+
maxLevels: pluginConfig.maxLevels ?? DEFAULT_HIERARCHICAL_MEMORY_CONFIG.maxLevels,
|
|
125
|
+
};
|
|
126
|
+
}
|
package/context.ts
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Context injection for hierarchical memory.
|
|
3
|
+
*
|
|
4
|
+
* Loads summaries and formats them for injection into the system prompt.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { hasSummaries, loadSummaryContents, loadSummaryIndex } from "./storage.js";
|
|
8
|
+
import { getAllSummariesForContext } from "./types.js";
|
|
9
|
+
|
|
10
|
+
export type MemoryContext = {
|
|
11
|
+
/** Formatted memory section to inject into system prompt */
|
|
12
|
+
memorySection: string;
|
|
13
|
+
/** Number of summaries at each level */
|
|
14
|
+
counts: {
|
|
15
|
+
L1: number;
|
|
16
|
+
L2: number;
|
|
17
|
+
L3: number;
|
|
18
|
+
};
|
|
19
|
+
/** Estimated token count of the memory section */
|
|
20
|
+
tokenEstimate: number;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Load and format hierarchical memory for system prompt injection.
|
|
25
|
+
*/
|
|
26
|
+
export async function loadMemoryContext(agentId?: string): Promise<MemoryContext | null> {
|
|
27
|
+
// Quick check if there are any summaries
|
|
28
|
+
if (!(await hasSummaries(agentId))) {
|
|
29
|
+
return null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const index = await loadSummaryIndex(agentId);
|
|
33
|
+
const summaryContext = getAllSummariesForContext(index);
|
|
34
|
+
|
|
35
|
+
// Load contents for each level
|
|
36
|
+
const L3Contents = await loadSummaryContents(summaryContext.L3, agentId);
|
|
37
|
+
const L2Contents = await loadSummaryContents(summaryContext.L2, agentId);
|
|
38
|
+
const L1Contents = await loadSummaryContents(summaryContext.L1, agentId);
|
|
39
|
+
|
|
40
|
+
// If no summaries at any level, return null
|
|
41
|
+
if (L3Contents.length === 0 && L2Contents.length === 0 && L1Contents.length === 0) {
|
|
42
|
+
return null;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Format the memory section
|
|
46
|
+
const memorySection = formatMemorySection(L3Contents, L2Contents, L1Contents);
|
|
47
|
+
|
|
48
|
+
// Rough token estimate (4 chars per token)
|
|
49
|
+
const tokenEstimate = Math.ceil(memorySection.length / 4);
|
|
50
|
+
|
|
51
|
+
return {
|
|
52
|
+
memorySection,
|
|
53
|
+
counts: {
|
|
54
|
+
L1: L1Contents.length,
|
|
55
|
+
L2: L2Contents.length,
|
|
56
|
+
L3: L3Contents.length,
|
|
57
|
+
},
|
|
58
|
+
tokenEstimate,
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Format summaries into a memory section for the system prompt.
|
|
64
|
+
*/
|
|
65
|
+
function formatMemorySection(L3: string[], L2: string[], L1: string[]): string {
|
|
66
|
+
const sections: string[] = [];
|
|
67
|
+
|
|
68
|
+
sections.push("## My memories of our conversation\n");
|
|
69
|
+
sections.push("(These are my autobiographical memories from our ongoing relationship.)\n");
|
|
70
|
+
|
|
71
|
+
if (L3.length > 0) {
|
|
72
|
+
sections.push("\n### Long-term memory\n");
|
|
73
|
+
sections.push(L3.join("\n\n---\n\n"));
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (L2.length > 0) {
|
|
77
|
+
sections.push("\n### Earlier context\n");
|
|
78
|
+
sections.push(L2.join("\n\n---\n\n"));
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (L1.length > 0) {
|
|
82
|
+
sections.push("\n### Recent memory\n");
|
|
83
|
+
sections.push(L1.join("\n\n---\n\n"));
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
return sections.join("\n");
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* Get the last summarized entry ID from the index.
|
|
91
|
+
* Used to filter recent messages (only include those after this ID).
|
|
92
|
+
*/
|
|
93
|
+
export async function getLastSummarizedEntryId(agentId?: string): Promise<string | null> {
|
|
94
|
+
try {
|
|
95
|
+
const index = await loadSummaryIndex(agentId);
|
|
96
|
+
return index.lastSummarizedEntryId;
|
|
97
|
+
} catch {
|
|
98
|
+
return null;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Check if hierarchical memory has any data for an agent.
|
|
104
|
+
*/
|
|
105
|
+
export async function hasMemoryData(agentId?: string): Promise<boolean> {
|
|
106
|
+
return hasSummaries(agentId);
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/**
|
|
110
|
+
* Get summary statistics for display.
|
|
111
|
+
*/
|
|
112
|
+
export async function getMemoryStats(agentId?: string): Promise<{
|
|
113
|
+
totalSummaries: number;
|
|
114
|
+
levels: { L1: number; L2: number; L3: number };
|
|
115
|
+
lastSummarizedAt: number | null;
|
|
116
|
+
lastWorkerRun: number | null;
|
|
117
|
+
} | null> {
|
|
118
|
+
try {
|
|
119
|
+
const index = await loadSummaryIndex(agentId);
|
|
120
|
+
|
|
121
|
+
const L1Count = index.levels.L1.length;
|
|
122
|
+
const L2Count = index.levels.L2.length;
|
|
123
|
+
const L3Count = index.levels.L3.length;
|
|
124
|
+
|
|
125
|
+
if (L1Count === 0 && L2Count === 0 && L3Count === 0) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Find most recent summary timestamp
|
|
130
|
+
let lastSummarizedAt: number | null = null;
|
|
131
|
+
for (const level of [index.levels.L1, index.levels.L2, index.levels.L3]) {
|
|
132
|
+
for (const summary of level) {
|
|
133
|
+
if (!lastSummarizedAt || summary.createdAt > lastSummarizedAt) {
|
|
134
|
+
lastSummarizedAt = summary.createdAt;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return {
|
|
140
|
+
totalSummaries: L1Count + L2Count + L3Count,
|
|
141
|
+
levels: { L1: L1Count, L2: L2Count, L3: L3Count },
|
|
142
|
+
lastSummarizedAt,
|
|
143
|
+
lastWorkerRun: index.worker.lastRunAt,
|
|
144
|
+
};
|
|
145
|
+
} catch {
|
|
146
|
+
return null;
|
|
147
|
+
}
|
|
148
|
+
}
|
package/index.ts
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Hierarchical Memory Plugin for OpenClaw
|
|
3
|
+
*
|
|
4
|
+
* A 2048-style autobiographical memory compression system.
|
|
5
|
+
* Continuously summarizes conversation chunks in the background,
|
|
6
|
+
* creating layers of progressively compressed context (L1 → L2 → L3).
|
|
7
|
+
*
|
|
8
|
+
* Integration points:
|
|
9
|
+
* - before_agent_start hook: injects memory section into system prompt
|
|
10
|
+
* - registerService: background worker for periodic summarization
|
|
11
|
+
* - registerCli: memory-hierarchical status/inspect commands
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
|
|
15
|
+
import { parsePluginConfig, type PluginConfig } from "./config.js";
|
|
16
|
+
import { loadMemoryContext, getMemoryStats, hasMemoryData } from "./context.js";
|
|
17
|
+
import { setStateDir, hasSummaries, loadSummaryIndex, readSummary, resolveSummariesDir } from "./storage.js";
|
|
18
|
+
import type { SummaryEntry, SummaryLevel } from "./types.js";
|
|
19
|
+
import { startHierarchicalMemoryTimer, type HierarchicalMemoryTimerHandle } from "./timer.js";
|
|
20
|
+
|
|
21
|
+
function formatAge(ms: number): string {
|
|
22
|
+
if (ms < 0) {
|
|
23
|
+
return "future";
|
|
24
|
+
}
|
|
25
|
+
if (ms < 60_000) {
|
|
26
|
+
return `${Math.floor(ms / 1000)}s ago`;
|
|
27
|
+
}
|
|
28
|
+
if (ms < 3600_000) {
|
|
29
|
+
return `${Math.floor(ms / 60_000)}m ago`;
|
|
30
|
+
}
|
|
31
|
+
if (ms < 86400_000) {
|
|
32
|
+
return `${Math.floor(ms / 3600_000)}h ago`;
|
|
33
|
+
}
|
|
34
|
+
return `${Math.floor(ms / 86400_000)}d ago`;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export default {
|
|
38
|
+
id: "memory-hierarchical",
|
|
39
|
+
name: "Hierarchical Memory",
|
|
40
|
+
description: "2048-style autobiographical memory compression for long-running conversations",
|
|
41
|
+
|
|
42
|
+
register(api: OpenClawPluginApi) {
|
|
43
|
+
const cfg = parsePluginConfig(api.pluginConfig);
|
|
44
|
+
let timer: HierarchicalMemoryTimerHandle | null = null;
|
|
45
|
+
|
|
46
|
+
// Inject memory into system prompt before each agent run
|
|
47
|
+
api.on("before_agent_start", async (_event, ctx) => {
|
|
48
|
+
const agentId = ctx.agentId ?? "main";
|
|
49
|
+
try {
|
|
50
|
+
const memCtx = await loadMemoryContext(agentId);
|
|
51
|
+
if (memCtx) {
|
|
52
|
+
api.logger.debug?.(
|
|
53
|
+
`loaded hierarchical memory: L1=${memCtx.counts.L1} L2=${memCtx.counts.L2} L3=${memCtx.counts.L3} (~${memCtx.tokenEstimate} tokens)`,
|
|
54
|
+
);
|
|
55
|
+
return { prependContext: memCtx.memorySection };
|
|
56
|
+
}
|
|
57
|
+
} catch (err) {
|
|
58
|
+
api.logger.warn(
|
|
59
|
+
`failed to load hierarchical memory: ${err instanceof Error ? err.message : String(err)}`,
|
|
60
|
+
);
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
// Background worker for summarization
|
|
65
|
+
api.registerService({
|
|
66
|
+
id: "memory-hierarchical-worker",
|
|
67
|
+
start: async (ctx) => {
|
|
68
|
+
// Set state directory for storage module
|
|
69
|
+
setStateDir(ctx.stateDir);
|
|
70
|
+
|
|
71
|
+
timer = startHierarchicalMemoryTimer({
|
|
72
|
+
agentId: "main",
|
|
73
|
+
pluginConfig: cfg,
|
|
74
|
+
stateDir: ctx.stateDir,
|
|
75
|
+
log: {
|
|
76
|
+
info: (msg) => ctx.logger.info(msg),
|
|
77
|
+
warn: (msg) => ctx.logger.warn(msg),
|
|
78
|
+
error: (msg) => ctx.logger.error(msg),
|
|
79
|
+
},
|
|
80
|
+
});
|
|
81
|
+
},
|
|
82
|
+
stop: async () => {
|
|
83
|
+
timer?.stop();
|
|
84
|
+
},
|
|
85
|
+
});
|
|
86
|
+
|
|
87
|
+
// CLI commands
|
|
88
|
+
api.registerCli(
|
|
89
|
+
({ program, logger }) => {
|
|
90
|
+
const cmd = program
|
|
91
|
+
.command("memory-hierarchical")
|
|
92
|
+
.description("Hierarchical memory (autobiographical summaries)");
|
|
93
|
+
|
|
94
|
+
cmd
|
|
95
|
+
.command("status")
|
|
96
|
+
.description("Show hierarchical memory status and statistics")
|
|
97
|
+
.option("--agent <id>", "Agent id (default: main)")
|
|
98
|
+
.option("--json", "Print JSON")
|
|
99
|
+
.action(async (opts: { agent?: string; json?: boolean }) => {
|
|
100
|
+
const agentId = opts.agent ?? "main";
|
|
101
|
+
const hasData = await hasSummaries(agentId);
|
|
102
|
+
|
|
103
|
+
if (opts.json) {
|
|
104
|
+
const stats = hasData ? await getMemoryStats(agentId) : null;
|
|
105
|
+
logger.info(
|
|
106
|
+
JSON.stringify(
|
|
107
|
+
{ agentId, enabled: true, hasData, stats, summariesDir: resolveSummariesDir(agentId) },
|
|
108
|
+
null,
|
|
109
|
+
2,
|
|
110
|
+
),
|
|
111
|
+
);
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
logger.info("Hierarchical Memory Status");
|
|
116
|
+
logger.info("");
|
|
117
|
+
logger.info(` Agent: ${agentId}`);
|
|
118
|
+
logger.info(` Enabled: yes`);
|
|
119
|
+
logger.info(` Storage: ${resolveSummariesDir(agentId)}`);
|
|
120
|
+
logger.info("");
|
|
121
|
+
|
|
122
|
+
if (!hasData) {
|
|
123
|
+
logger.info(" No memory data yet.");
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
const stats = await getMemoryStats(agentId);
|
|
128
|
+
if (!stats) {
|
|
129
|
+
logger.info(" No summaries found.");
|
|
130
|
+
return;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
logger.info(" Summaries:");
|
|
134
|
+
logger.info(` L1 (recent): ${stats.levels.L1}`);
|
|
135
|
+
logger.info(` L2 (earlier): ${stats.levels.L2}`);
|
|
136
|
+
logger.info(` L3 (long-term): ${stats.levels.L3}`);
|
|
137
|
+
logger.info(` ${"─".repeat(20)}`);
|
|
138
|
+
logger.info(` Total: ${stats.totalSummaries}`);
|
|
139
|
+
logger.info("");
|
|
140
|
+
if (stats.lastSummarizedAt) {
|
|
141
|
+
logger.info(` Last summarized: ${formatAge(Date.now() - stats.lastSummarizedAt)}`);
|
|
142
|
+
}
|
|
143
|
+
if (stats.lastWorkerRun) {
|
|
144
|
+
logger.info(` Last worker run: ${formatAge(Date.now() - stats.lastWorkerRun)}`);
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
cmd
|
|
149
|
+
.command("inspect")
|
|
150
|
+
.description("View hierarchical memory summaries")
|
|
151
|
+
.option("--agent <id>", "Agent id (default: main)")
|
|
152
|
+
.option("--level <level>", "Filter by level (L1, L2, or L3)")
|
|
153
|
+
.option("--limit <n>", "Maximum summaries per level", "5")
|
|
154
|
+
.option("--json", "Print JSON")
|
|
155
|
+
.action(
|
|
156
|
+
async (opts: { agent?: string; level?: string; limit?: string; json?: boolean }) => {
|
|
157
|
+
const agentId = opts.agent ?? "main";
|
|
158
|
+
const limit = parseInt(opts.limit ?? "5", 10);
|
|
159
|
+
const validLevels = ["L1", "L2", "L3"];
|
|
160
|
+
|
|
161
|
+
if (opts.level && !validLevels.includes(opts.level.toUpperCase())) {
|
|
162
|
+
logger.error(
|
|
163
|
+
`Invalid level: ${opts.level}. Must be one of: ${validLevels.join(", ")}`,
|
|
164
|
+
);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const filterLevel = opts.level?.toUpperCase() as SummaryLevel | undefined;
|
|
168
|
+
|
|
169
|
+
const hasData = await hasSummaries(agentId);
|
|
170
|
+
if (!hasData) {
|
|
171
|
+
if (opts.json) {
|
|
172
|
+
logger.info(JSON.stringify({ agentId, summaries: [] }, null, 2));
|
|
173
|
+
} else {
|
|
174
|
+
logger.info("No memory data yet.");
|
|
175
|
+
}
|
|
176
|
+
return;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const index = await loadSummaryIndex(agentId);
|
|
180
|
+
const summariesToShow: Array<{ entry: SummaryEntry; content: string }> = [];
|
|
181
|
+
const levels: SummaryLevel[] = filterLevel ? [filterLevel] : ["L3", "L2", "L1"];
|
|
182
|
+
|
|
183
|
+
for (const level of levels) {
|
|
184
|
+
const entries = index.levels[level]
|
|
185
|
+
.filter((e) => !e.mergedInto)
|
|
186
|
+
.toSorted((a, b) => b.createdAt - a.createdAt); // Node 22+ baseline
|
|
187
|
+
|
|
188
|
+
for (const entry of entries.slice(0, limit)) {
|
|
189
|
+
const result = await readSummary(level, entry.id, agentId);
|
|
190
|
+
if (result) {
|
|
191
|
+
summariesToShow.push({ entry, content: result.content });
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
if (opts.json) {
|
|
197
|
+
logger.info(
|
|
198
|
+
JSON.stringify(
|
|
199
|
+
{
|
|
200
|
+
agentId,
|
|
201
|
+
summaries: summariesToShow.map(({ entry, content }) => ({
|
|
202
|
+
id: entry.id,
|
|
203
|
+
level: entry.level,
|
|
204
|
+
createdAt: entry.createdAt,
|
|
205
|
+
tokenEstimate: entry.tokenEstimate,
|
|
206
|
+
content,
|
|
207
|
+
})),
|
|
208
|
+
},
|
|
209
|
+
null,
|
|
210
|
+
2,
|
|
211
|
+
),
|
|
212
|
+
);
|
|
213
|
+
return;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
logger.info("Memory Summaries");
|
|
217
|
+
logger.info(`Agent: ${agentId}`);
|
|
218
|
+
if (filterLevel) {
|
|
219
|
+
logger.info(`Level: ${filterLevel}`);
|
|
220
|
+
}
|
|
221
|
+
logger.info("");
|
|
222
|
+
|
|
223
|
+
if (summariesToShow.length === 0) {
|
|
224
|
+
logger.info("No active summaries found.");
|
|
225
|
+
return;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
for (const { entry, content } of summariesToShow) {
|
|
229
|
+
const header = `[${entry.level}] ${entry.id} - ${formatAge(Date.now() - entry.createdAt)} (~${entry.tokenEstimate} tokens)`;
|
|
230
|
+
logger.info(header);
|
|
231
|
+
logger.info("─".repeat(60));
|
|
232
|
+
logger.info(content);
|
|
233
|
+
logger.info("");
|
|
234
|
+
}
|
|
235
|
+
},
|
|
236
|
+
);
|
|
237
|
+
},
|
|
238
|
+
{ commands: ["memory-hierarchical"] },
|
|
239
|
+
);
|
|
240
|
+
},
|
|
241
|
+
};
|
package/lock.ts
ADDED
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Simple file-based locking for the hierarchical memory worker.
|
|
3
|
+
* Prevents concurrent runs from multiple processes.
|
|
4
|
+
*
|
|
5
|
+
* Atomicity is provided by `writeFile` with the `wx` flag (create-exclusive).
|
|
6
|
+
* Only one process can successfully create the lock file; all others get EEXIST.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import fs from "node:fs/promises";
|
|
10
|
+
import path from "node:path";
|
|
11
|
+
import { resolveSummariesDir } from "./storage.js";
|
|
12
|
+
|
|
13
|
+
const LOCK_FILENAME = ".worker.lock";
|
|
14
|
+
const LOCK_STALE_MS = 10 * 60 * 1000; // 10 minutes
|
|
15
|
+
|
|
16
|
+
export type WorkerLock = {
|
|
17
|
+
release: () => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
/** Try to create the lock file atomically. Returns null if it already exists. */
|
|
21
|
+
async function tryCreateLock(lockPath: string): Promise<WorkerLock | null> {
|
|
22
|
+
const lockContent = JSON.stringify({
|
|
23
|
+
pid: process.pid,
|
|
24
|
+
acquiredAt: Date.now(),
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
try {
|
|
28
|
+
await fs.writeFile(lockPath, lockContent, { flag: "wx" });
|
|
29
|
+
} catch (err) {
|
|
30
|
+
if ((err as NodeJS.ErrnoException).code === "EEXIST") {
|
|
31
|
+
return null;
|
|
32
|
+
}
|
|
33
|
+
throw err;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
release: async () => {
|
|
38
|
+
try {
|
|
39
|
+
await fs.unlink(lockPath);
|
|
40
|
+
} catch {
|
|
41
|
+
// Ignore errors on release (file may already be removed)
|
|
42
|
+
}
|
|
43
|
+
},
|
|
44
|
+
};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Acquire a lock for the summary worker. Returns null if already locked. */
|
|
48
|
+
export async function acquireSummaryLock(agentId?: string): Promise<WorkerLock | null> {
|
|
49
|
+
const lockPath = path.join(resolveSummariesDir(agentId), LOCK_FILENAME);
|
|
50
|
+
|
|
51
|
+
// Ensure directory exists
|
|
52
|
+
await fs.mkdir(path.dirname(lockPath), { recursive: true });
|
|
53
|
+
|
|
54
|
+
// First attempt: try to create the lock file
|
|
55
|
+
const firstAttempt = await tryCreateLock(lockPath);
|
|
56
|
+
if (firstAttempt) {
|
|
57
|
+
return firstAttempt;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Lock file exists — check if it's stale
|
|
61
|
+
try {
|
|
62
|
+
const stat = await fs.stat(lockPath);
|
|
63
|
+
const age = Date.now() - stat.mtimeMs;
|
|
64
|
+
|
|
65
|
+
if (age < LOCK_STALE_MS) {
|
|
66
|
+
return null; // Lock is fresh, another process holds it
|
|
67
|
+
}
|
|
68
|
+
} catch (err) {
|
|
69
|
+
if ((err as NodeJS.ErrnoException).code === "ENOENT") {
|
|
70
|
+
// Lock was released between our failed create and this stat — retry
|
|
71
|
+
return tryCreateLock(lockPath);
|
|
72
|
+
}
|
|
73
|
+
throw err;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Lock is stale — remove and retry. If another process also removes the stale
|
|
77
|
+
// lock, unlink may fail (harmless). The subsequent tryCreateLock is atomic:
|
|
78
|
+
// only one process wins the `wx` create.
|
|
79
|
+
await fs.unlink(lockPath).catch(() => {});
|
|
80
|
+
return tryCreateLock(lockPath);
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** Check if a lock is currently held (without acquiring) */
|
|
84
|
+
export async function isLockHeld(agentId?: string): Promise<boolean> {
|
|
85
|
+
const lockPath = path.join(resolveSummariesDir(agentId), LOCK_FILENAME);
|
|
86
|
+
|
|
87
|
+
try {
|
|
88
|
+
const stat = await fs.stat(lockPath);
|
|
89
|
+
const age = Date.now() - stat.mtimeMs;
|
|
90
|
+
return age < LOCK_STALE_MS;
|
|
91
|
+
} catch {
|
|
92
|
+
return false;
|
|
93
|
+
}
|
|
94
|
+
}
|