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 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
+ }