mr-memory 3.10.0 → 3.11.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 CHANGED
@@ -69,6 +69,22 @@ Tune it with `keepWarmMs` (milliseconds, default `180000`; `0` disables warmup p
69
69
  { "plugins": { "entries": { "mr-memory": { "config": { "keepWarmMs": 180000 } } } } }
70
70
  ```
71
71
 
72
+ ### Automatic consolidation (default on)
73
+
74
+ MemoryRouter's Reflection Hierarchy consolidates raw memories into higher-tier reflections. The server tracks consolidation debt per vault; when a retrieval response reports debt above the server's threshold, this plugin consolidates automatically:
75
+
76
+ - It starts a dedicated isolated agent turn through the OpenClaw gateway (a background subagent session, never your main conversation) whose only instruction is to call the plugin's `memory_reflect` and `memory_commit` tools. Your agent's model writes the reflections, so the inference is your own model spend; MemoryRouter never calls a provider.
77
+ - At most 3 batches per trigger, with a fresh debt check between batches; the run stops when debt drops below the threshold. The threshold always comes from the server response.
78
+ - A machine-wide lockfile (`~/.memoryrouter/consolidate.lock`, stale after 20 minutes) prevents double-firing across sessions and across other MemoryRouter connectors on the same machine, and a 30 minute cooldown follows each completed run.
79
+ - The consolidation session is excluded from memory capture/injection and its transcript is deleted afterward. Failures never surface in your conversation; everything is logged to `~/.memoryrouter/consolidate.log`.
80
+ - MemoryRouter bills the consolidation checkout/commit tokens at the normal 1x raw rate.
81
+
82
+ Opt out with `"autoConsolidate": false` in the plugin config (restart the gateway to apply). The in-context consolidation hint and the `memory_reflect`/`memory_commit` agent tools keep working; only the automatic background trigger is disabled:
83
+
84
+ ```json
85
+ { "plugins": { "entries": { "mr-memory": { "config": { "autoConsolidate": false } } } } }
86
+ ```
87
+
72
88
  ## Config
73
89
 
74
90
  After `openclaw mr <key>`, config is stored at:
@@ -0,0 +1,292 @@
1
+ /**
2
+ * Automatic threshold-activated consolidation for the OpenClaw plugin.
3
+ *
4
+ * The injected suggestion nudge depends on main-session model discipline,
5
+ * which is a proven failure mode: the model can ignore it forever. This
6
+ * module instead drives consolidation deterministically from plugin code
7
+ * running inside the gateway, using the plugin runtime's subagent capability
8
+ * (api.runtime.subagent.run), which executes a dedicated isolated agent turn.
9
+ * That isolated turn is OpenClaw's equivalent of the headless model runs the
10
+ * claude-code and codex connectors use (claude -p / codex exec): the host's
11
+ * own model does the reflecting in a session whose only instruction is to
12
+ * consolidate, the user pays their own inference, and MemoryRouter never
13
+ * calls a provider.
14
+ *
15
+ * Reliability rails (shared with the other MemoryRouter connectors on this
16
+ * machine via the same files):
17
+ * - Lockfile ~/.memoryrouter/consolidate.lock (pid + timestamp, stale after
18
+ * 20 minutes) so concurrent sessions/connectors never double-fire.
19
+ * - Bounded: at most 3 batches per trigger, re-checking debt via the API
20
+ * between batches and stopping once the server reports no suggestion.
21
+ * - Cooldown: no re-trigger within 30 minutes of the last completed run,
22
+ * nor within 10 minutes of the last attempt.
23
+ * - The threshold always comes from the server's reflection debt block;
24
+ * nothing is hardcoded client-side.
25
+ * - Failures are silent to the user's session and logged to
26
+ * ~/.memoryrouter/consolidate.log.
27
+ */
28
+ import fs from "node:fs";
29
+ import os from "node:os";
30
+ import path from "node:path";
31
+ import { randomUUID } from "node:crypto";
32
+ import { dateSearch } from "./memoryrouter-api.js";
33
+ export const AUTO_SESSION_MARKER = "mrconsolidate";
34
+ export const LOCK_STALE_MS = 20 * 60 * 1000;
35
+ export const COOLDOWN_MS = 30 * 60 * 1000;
36
+ export const ATTEMPT_COOLDOWN_MS = 10 * 60 * 1000;
37
+ export const MAX_BATCHES_PER_TRIGGER = 3;
38
+ const RUN_TIMEOUT_MS = 12 * 60 * 1000; // inside the server's 15 minute lease
39
+ export function autoDir(env = process.env) {
40
+ return env.MEMORYROUTER_AUTO_DIR || path.join(os.homedir(), ".memoryrouter");
41
+ }
42
+ function lockPath(env) { return path.join(autoDir(env), "consolidate.lock"); }
43
+ function statePath(env) { return path.join(autoDir(env), "consolidate-state.json"); }
44
+ function logPath(env) { return path.join(autoDir(env), "consolidate.log"); }
45
+ function ensureDir(dir) {
46
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
47
+ }
48
+ export function appendAutoLog(line, env = process.env) {
49
+ try {
50
+ ensureDir(autoDir(env));
51
+ const file = logPath(env);
52
+ try {
53
+ if (fs.statSync(file).size > 1024 * 1024)
54
+ fs.renameSync(file, `${file}.1`);
55
+ }
56
+ catch { /* new file */ }
57
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`, { mode: 0o600 });
58
+ }
59
+ catch { /* logging never throws */ }
60
+ }
61
+ export function readAutoState(env = process.env) {
62
+ try {
63
+ return JSON.parse(fs.readFileSync(statePath(env), "utf8")) || {};
64
+ }
65
+ catch {
66
+ return {};
67
+ }
68
+ }
69
+ export function writeAutoState(patch, env = process.env) {
70
+ try {
71
+ ensureDir(autoDir(env));
72
+ const next = { ...readAutoState(env), ...patch };
73
+ fs.writeFileSync(statePath(env), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
74
+ }
75
+ catch { /* state is best-effort */ }
76
+ }
77
+ export function lockFresh(env = process.env, now = Date.now()) {
78
+ try {
79
+ const info = JSON.parse(fs.readFileSync(lockPath(env), "utf8"));
80
+ return now - Number(info.at) <= LOCK_STALE_MS;
81
+ }
82
+ catch (err) {
83
+ if (err?.code === "ENOENT")
84
+ return false;
85
+ try {
86
+ return now - fs.statSync(lockPath(env)).mtimeMs <= LOCK_STALE_MS;
87
+ }
88
+ catch {
89
+ return false;
90
+ }
91
+ }
92
+ }
93
+ export function acquireLock(env = process.env, now = Date.now()) {
94
+ ensureDir(autoDir(env));
95
+ for (let attempt = 0; attempt < 2; attempt++) {
96
+ try {
97
+ const fd = fs.openSync(lockPath(env), "wx", 0o600);
98
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now }) + "\n");
99
+ fs.closeSync(fd);
100
+ return true;
101
+ }
102
+ catch (err) {
103
+ if (err?.code !== "EEXIST")
104
+ return false;
105
+ if (lockFresh(env, now))
106
+ return false;
107
+ try {
108
+ fs.unlinkSync(lockPath(env));
109
+ }
110
+ catch {
111
+ return false;
112
+ }
113
+ }
114
+ }
115
+ return false;
116
+ }
117
+ export function releaseLock(env = process.env) {
118
+ try {
119
+ fs.unlinkSync(lockPath(env));
120
+ }
121
+ catch { /* already gone */ }
122
+ }
123
+ export function cooldownActive(env = process.env, now = Date.now()) {
124
+ const state = readAutoState(env);
125
+ const completed = Number(state.lastCompletedAt);
126
+ if (Number.isFinite(completed) && now - completed < COOLDOWN_MS)
127
+ return true;
128
+ const attempted = Number(state.lastAttemptAt);
129
+ if (Number.isFinite(attempted) && now - attempted < ATTEMPT_COOLDOWN_MS)
130
+ return true;
131
+ return false;
132
+ }
133
+ /** Tier choice comes entirely from the server's debt block. */
134
+ export function pickTier(debt) {
135
+ const threshold = Number(debt?.threshold_importance);
136
+ const rawEquiv = Number(debt?.unconsolidated?.raw?.importance_equiv);
137
+ const tier2 = Number(debt?.unconsolidated?.tier2?.importance);
138
+ if (Number.isFinite(threshold) && threshold > 0) {
139
+ if (Number.isFinite(rawEquiv) && rawEquiv >= threshold)
140
+ return 1;
141
+ if (Number.isFinite(tier2) && tier2 >= threshold)
142
+ return 2;
143
+ }
144
+ return /tier 2/i.test(String(debt?.suggestion || "")) ? 2 : 1;
145
+ }
146
+ /** True for sessions created by this module; hooks skip them entirely. */
147
+ export function isAutoConsolidationSession(sessionKey) {
148
+ return typeof sessionKey === "string" && sessionKey.includes(AUTO_SESSION_MARKER);
149
+ }
150
+ async function probeDebt(endpoint, key, embeddings) {
151
+ // Sanctioned zero-billing debt read: an empty-window date-search returns
152
+ // the reflection debt block with zero results.
153
+ const from = new Date(Date.now() - 1000).toISOString();
154
+ const res = await dateSearch(endpoint, key, { from, max_tokens: 1000, ...(embeddings && { embeddings }) });
155
+ if (!res.ok)
156
+ return null;
157
+ const data = await res.json().catch(() => null);
158
+ return data?.reflection ?? null;
159
+ }
160
+ // The isolated turn's message is connector transport mechanics (which tools
161
+ // to call, in what order). Every reflection semantic comes from the server
162
+ // contract that the memory_reflect tool returns verbatim.
163
+ function consolidationMessage(tier) {
164
+ return [
165
+ "MemoryRouter automatic consolidation run. Complete exactly one consolidation batch:",
166
+ `1. Call the memory_reflect tool with tier: ${tier}.`,
167
+ "2. If it returns batch_id null or an error, reply \"nothing to consolidate\" and stop.",
168
+ "3. Otherwise follow the instructions it returns to write reflection entries, then call memory_commit with that batch_id and your entries.",
169
+ "4. Reply \"done\" once the commit succeeds.",
170
+ "Do not call any other tools. Do not check out more than one batch.",
171
+ ].join("\n");
172
+ }
173
+ /**
174
+ * The full bounded run: lock -> up to 3 (probe debt -> isolated agent turn)
175
+ * cycles -> record completion -> release. Never throws.
176
+ */
177
+ export async function runAutoConsolidate(options) {
178
+ const env = options.env || process.env;
179
+ const now = options.now || (() => Date.now());
180
+ const log = options.log || ((line) => appendAutoLog(line, env));
181
+ const bound = Math.min(options.maxBatches ?? MAX_BATCHES_PER_TRIGGER, MAX_BATCHES_PER_TRIGGER);
182
+ if (cooldownActive(env, now())) {
183
+ log("auto: cooldown active; skipping");
184
+ return { committed: 0, reason: "cooldown" };
185
+ }
186
+ if (!acquireLock(env, now())) {
187
+ log("auto: another consolidation run holds the lock; skipping");
188
+ return { committed: 0, reason: "locked" };
189
+ }
190
+ writeAutoState({ lastAttemptAt: now() }, env);
191
+ let committed = 0;
192
+ try {
193
+ for (let batch = 0; batch < bound; batch++) {
194
+ let debt = null;
195
+ try {
196
+ debt = await probeDebt(options.endpoint, options.key, options.embeddings);
197
+ }
198
+ catch (err) {
199
+ log(`auto: debt probe failed: ${err?.message}`);
200
+ break;
201
+ }
202
+ if (!debt || !debt.suggestion) {
203
+ log(`auto: debt below threshold after ${committed} batch(es); stopping`);
204
+ break;
205
+ }
206
+ const tier = pickTier(debt);
207
+ const sessionKey = `agent:${options.agentId}:subagent:${AUTO_SESSION_MARKER}-${randomUUID()}`;
208
+ log(`auto: batch ${batch + 1}/${bound} starting (tier ${tier}; ${debt.suggestion})`);
209
+ let status = "error";
210
+ try {
211
+ const { runId } = await options.runtime.run({
212
+ sessionKey,
213
+ message: consolidationMessage(tier),
214
+ deliver: false,
215
+ });
216
+ const waited = await options.runtime.waitForRun({ runId, timeoutMs: RUN_TIMEOUT_MS });
217
+ status = waited.status;
218
+ if (waited.status !== "ok")
219
+ log(`auto: isolated run ${waited.status}${waited.error ? `: ${waited.error}` : ""}`);
220
+ }
221
+ catch (err) {
222
+ log(`auto: isolated run failed: ${err?.message}`);
223
+ break;
224
+ }
225
+ finally {
226
+ try {
227
+ await options.runtime.deleteSession({ sessionKey, deleteTranscript: true });
228
+ }
229
+ catch { /* cleanup best-effort */ }
230
+ }
231
+ if (status !== "ok")
232
+ break;
233
+ // Verify a commit actually happened: the server-side debt must move.
234
+ let after = null;
235
+ try {
236
+ after = await probeDebt(options.endpoint, options.key, options.embeddings);
237
+ }
238
+ catch { /* treated as no movement */ }
239
+ const before = JSON.stringify(debt.unconsolidated ?? {});
240
+ const current = JSON.stringify(after?.unconsolidated ?? {});
241
+ if (after && before !== current) {
242
+ committed++;
243
+ }
244
+ else {
245
+ log("auto: run finished but debt did not move; stopping (no commit detected)");
246
+ break;
247
+ }
248
+ }
249
+ if (committed > 0) {
250
+ writeAutoState({ lastCompletedAt: now() }, env);
251
+ log(`auto: run complete, ${committed} batch(es) committed`);
252
+ }
253
+ }
254
+ finally {
255
+ releaseLock(env);
256
+ }
257
+ return { committed };
258
+ }
259
+ /**
260
+ * Trigger check called from the hook path with the reflection debt block the
261
+ * prepare response already carried. Fire-and-forget; never blocks the hook,
262
+ * never throws.
263
+ */
264
+ export function maybeTriggerAutoConsolidate(reflection, options) {
265
+ const env = options.env || process.env;
266
+ try {
267
+ if (env.MEMORYROUTER_DISABLE === "1")
268
+ return false;
269
+ if (options.autoConsolidate === false)
270
+ return false;
271
+ if (!reflection || !reflection.suggestion)
272
+ return false;
273
+ if (isAutoConsolidationSession(options.sessionKey))
274
+ return false;
275
+ const now = options.now ? options.now() : Date.now();
276
+ if (cooldownActive(env, now))
277
+ return false;
278
+ if (lockFresh(env, now))
279
+ return false;
280
+ if (!options.runtime || typeof options.runtime.run !== "function")
281
+ return false;
282
+ appendAutoLog(`trigger: starting auto consolidation (suggestion: ${reflection.suggestion})`, env);
283
+ void runAutoConsolidate(options).catch((err) => {
284
+ appendAutoLog(`auto: unexpected error: ${err?.message}`, env);
285
+ });
286
+ return true;
287
+ }
288
+ catch (err) {
289
+ appendAutoLog(`trigger error: ${err?.message}`, env);
290
+ return false;
291
+ }
292
+ }
@@ -0,0 +1,279 @@
1
+ /**
2
+ * Automatic threshold-activated consolidation for the OpenClaw plugin.
3
+ *
4
+ * The injected suggestion nudge depends on main-session model discipline,
5
+ * which is a proven failure mode: the model can ignore it forever. This
6
+ * module instead drives consolidation deterministically from plugin code
7
+ * running inside the gateway, using the plugin runtime's subagent capability
8
+ * (api.runtime.subagent.run), which executes a dedicated isolated agent turn.
9
+ * That isolated turn is OpenClaw's equivalent of the headless model runs the
10
+ * claude-code and codex connectors use (claude -p / codex exec): the host's
11
+ * own model does the reflecting in a session whose only instruction is to
12
+ * consolidate, the user pays their own inference, and MemoryRouter never
13
+ * calls a provider.
14
+ *
15
+ * Reliability rails (shared with the other MemoryRouter connectors on this
16
+ * machine via the same files):
17
+ * - Lockfile ~/.memoryrouter/consolidate.lock (pid + timestamp, stale after
18
+ * 20 minutes) so concurrent sessions/connectors never double-fire.
19
+ * - Bounded: at most 3 batches per trigger, re-checking debt via the API
20
+ * between batches and stopping once the server reports no suggestion.
21
+ * - Cooldown: no re-trigger within 30 minutes of the last completed run,
22
+ * nor within 10 minutes of the last attempt.
23
+ * - The threshold always comes from the server's reflection debt block;
24
+ * nothing is hardcoded client-side.
25
+ * - Failures are silent to the user's session and logged to
26
+ * ~/.memoryrouter/consolidate.log.
27
+ */
28
+
29
+ import fs from "node:fs";
30
+ import os from "node:os";
31
+ import path from "node:path";
32
+ import { randomUUID } from "node:crypto";
33
+ import { dateSearch } from "./memoryrouter-api.js";
34
+
35
+ export const AUTO_SESSION_MARKER = "mrconsolidate";
36
+ export const LOCK_STALE_MS = 20 * 60 * 1000;
37
+ export const COOLDOWN_MS = 30 * 60 * 1000;
38
+ export const ATTEMPT_COOLDOWN_MS = 10 * 60 * 1000;
39
+ export const MAX_BATCHES_PER_TRIGGER = 3;
40
+ const RUN_TIMEOUT_MS = 12 * 60 * 1000; // inside the server's 15 minute lease
41
+
42
+ export type ReflectionDebtBlock = {
43
+ suggestion?: string | null;
44
+ threshold_importance?: number;
45
+ unconsolidated?: {
46
+ raw?: { units?: number; est_tokens?: number; importance_equiv?: number };
47
+ tier2?: { count?: number; importance?: number; est_tokens?: number };
48
+ };
49
+ };
50
+
51
+ type SubagentRuntime = {
52
+ run: (params: { sessionKey: string; message: string; deliver?: boolean; lane?: string }) => Promise<{ runId: string }>;
53
+ waitForRun: (params: { runId: string; timeoutMs?: number }) => Promise<{ status: string; error?: string }>;
54
+ deleteSession: (params: { sessionKey: string; deleteTranscript?: boolean }) => Promise<void>;
55
+ };
56
+
57
+ export function autoDir(env: NodeJS.ProcessEnv = process.env): string {
58
+ return env.MEMORYROUTER_AUTO_DIR || path.join(os.homedir(), ".memoryrouter");
59
+ }
60
+
61
+ function lockPath(env?: NodeJS.ProcessEnv): string { return path.join(autoDir(env), "consolidate.lock"); }
62
+ function statePath(env?: NodeJS.ProcessEnv): string { return path.join(autoDir(env), "consolidate-state.json"); }
63
+ function logPath(env?: NodeJS.ProcessEnv): string { return path.join(autoDir(env), "consolidate.log"); }
64
+
65
+ function ensureDir(dir: string): void {
66
+ fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
67
+ }
68
+
69
+ export function appendAutoLog(line: string, env: NodeJS.ProcessEnv = process.env): void {
70
+ try {
71
+ ensureDir(autoDir(env));
72
+ const file = logPath(env);
73
+ try {
74
+ if (fs.statSync(file).size > 1024 * 1024) fs.renameSync(file, `${file}.1`);
75
+ } catch { /* new file */ }
76
+ fs.appendFileSync(file, `[${new Date().toISOString()}] ${line}\n`, { mode: 0o600 });
77
+ } catch { /* logging never throws */ }
78
+ }
79
+
80
+ export function readAutoState(env: NodeJS.ProcessEnv = process.env): Record<string, unknown> {
81
+ try { return JSON.parse(fs.readFileSync(statePath(env), "utf8")) || {}; } catch { return {}; }
82
+ }
83
+
84
+ export function writeAutoState(patch: Record<string, unknown>, env: NodeJS.ProcessEnv = process.env): void {
85
+ try {
86
+ ensureDir(autoDir(env));
87
+ const next = { ...readAutoState(env), ...patch };
88
+ fs.writeFileSync(statePath(env), JSON.stringify(next, null, 2) + "\n", { mode: 0o600 });
89
+ } catch { /* state is best-effort */ }
90
+ }
91
+
92
+ export function lockFresh(env: NodeJS.ProcessEnv = process.env, now: number = Date.now()): boolean {
93
+ try {
94
+ const info = JSON.parse(fs.readFileSync(lockPath(env), "utf8"));
95
+ return now - Number(info.at) <= LOCK_STALE_MS;
96
+ } catch (err: any) {
97
+ if (err?.code === "ENOENT") return false;
98
+ try { return now - fs.statSync(lockPath(env)).mtimeMs <= LOCK_STALE_MS; } catch { return false; }
99
+ }
100
+ }
101
+
102
+ export function acquireLock(env: NodeJS.ProcessEnv = process.env, now: number = Date.now()): boolean {
103
+ ensureDir(autoDir(env));
104
+ for (let attempt = 0; attempt < 2; attempt++) {
105
+ try {
106
+ const fd = fs.openSync(lockPath(env), "wx", 0o600);
107
+ fs.writeFileSync(fd, JSON.stringify({ pid: process.pid, at: now }) + "\n");
108
+ fs.closeSync(fd);
109
+ return true;
110
+ } catch (err: any) {
111
+ if (err?.code !== "EEXIST") return false;
112
+ if (lockFresh(env, now)) return false;
113
+ try { fs.unlinkSync(lockPath(env)); } catch { return false; }
114
+ }
115
+ }
116
+ return false;
117
+ }
118
+
119
+ export function releaseLock(env: NodeJS.ProcessEnv = process.env): void {
120
+ try { fs.unlinkSync(lockPath(env)); } catch { /* already gone */ }
121
+ }
122
+
123
+ export function cooldownActive(env: NodeJS.ProcessEnv = process.env, now: number = Date.now()): boolean {
124
+ const state = readAutoState(env);
125
+ const completed = Number(state.lastCompletedAt);
126
+ if (Number.isFinite(completed) && now - completed < COOLDOWN_MS) return true;
127
+ const attempted = Number(state.lastAttemptAt);
128
+ if (Number.isFinite(attempted) && now - attempted < ATTEMPT_COOLDOWN_MS) return true;
129
+ return false;
130
+ }
131
+
132
+ /** Tier choice comes entirely from the server's debt block. */
133
+ export function pickTier(debt: ReflectionDebtBlock | null | undefined): 1 | 2 {
134
+ const threshold = Number(debt?.threshold_importance);
135
+ const rawEquiv = Number(debt?.unconsolidated?.raw?.importance_equiv);
136
+ const tier2 = Number(debt?.unconsolidated?.tier2?.importance);
137
+ if (Number.isFinite(threshold) && threshold > 0) {
138
+ if (Number.isFinite(rawEquiv) && rawEquiv >= threshold) return 1;
139
+ if (Number.isFinite(tier2) && tier2 >= threshold) return 2;
140
+ }
141
+ return /tier 2/i.test(String(debt?.suggestion || "")) ? 2 : 1;
142
+ }
143
+
144
+ /** True for sessions created by this module; hooks skip them entirely. */
145
+ export function isAutoConsolidationSession(sessionKey: string | undefined): boolean {
146
+ return typeof sessionKey === "string" && sessionKey.includes(AUTO_SESSION_MARKER);
147
+ }
148
+
149
+ async function probeDebt(endpoint: string, key: string, embeddings?: string): Promise<ReflectionDebtBlock | null> {
150
+ // Sanctioned zero-billing debt read: an empty-window date-search returns
151
+ // the reflection debt block with zero results.
152
+ const from = new Date(Date.now() - 1000).toISOString();
153
+ const res = await dateSearch(endpoint, key, { from, max_tokens: 1000, ...(embeddings && { embeddings }) });
154
+ if (!res.ok) return null;
155
+ const data = await res.json().catch(() => null) as { reflection?: ReflectionDebtBlock } | null;
156
+ return data?.reflection ?? null;
157
+ }
158
+
159
+ // The isolated turn's message is connector transport mechanics (which tools
160
+ // to call, in what order). Every reflection semantic comes from the server
161
+ // contract that the memory_reflect tool returns verbatim.
162
+ function consolidationMessage(tier: 1 | 2): string {
163
+ return [
164
+ "MemoryRouter automatic consolidation run. Complete exactly one consolidation batch:",
165
+ `1. Call the memory_reflect tool with tier: ${tier}.`,
166
+ "2. If it returns batch_id null or an error, reply \"nothing to consolidate\" and stop.",
167
+ "3. Otherwise follow the instructions it returns to write reflection entries, then call memory_commit with that batch_id and your entries.",
168
+ "4. Reply \"done\" once the commit succeeds.",
169
+ "Do not call any other tools. Do not check out more than one batch.",
170
+ ].join("\n");
171
+ }
172
+
173
+ export type AutoConsolidateOptions = {
174
+ runtime: SubagentRuntime;
175
+ agentId: string;
176
+ endpoint: string;
177
+ key: string;
178
+ embeddings?: string;
179
+ env?: NodeJS.ProcessEnv;
180
+ now?: () => number;
181
+ log?: (line: string) => void;
182
+ maxBatches?: number;
183
+ };
184
+
185
+ /**
186
+ * The full bounded run: lock -> up to 3 (probe debt -> isolated agent turn)
187
+ * cycles -> record completion -> release. Never throws.
188
+ */
189
+ export async function runAutoConsolidate(options: AutoConsolidateOptions): Promise<{ committed: number; reason?: string }> {
190
+ const env = options.env || process.env;
191
+ const now = options.now || (() => Date.now());
192
+ const log = options.log || ((line: string) => appendAutoLog(line, env));
193
+ const bound = Math.min(options.maxBatches ?? MAX_BATCHES_PER_TRIGGER, MAX_BATCHES_PER_TRIGGER);
194
+
195
+ if (cooldownActive(env, now())) { log("auto: cooldown active; skipping"); return { committed: 0, reason: "cooldown" }; }
196
+ if (!acquireLock(env, now())) { log("auto: another consolidation run holds the lock; skipping"); return { committed: 0, reason: "locked" }; }
197
+
198
+ writeAutoState({ lastAttemptAt: now() }, env);
199
+ let committed = 0;
200
+ try {
201
+ for (let batch = 0; batch < bound; batch++) {
202
+ let debt: ReflectionDebtBlock | null = null;
203
+ try { debt = await probeDebt(options.endpoint, options.key, options.embeddings); }
204
+ catch (err: any) { log(`auto: debt probe failed: ${err?.message}`); break; }
205
+ if (!debt || !debt.suggestion) {
206
+ log(`auto: debt below threshold after ${committed} batch(es); stopping`);
207
+ break;
208
+ }
209
+ const tier = pickTier(debt);
210
+ const sessionKey = `agent:${options.agentId}:subagent:${AUTO_SESSION_MARKER}-${randomUUID()}`;
211
+ log(`auto: batch ${batch + 1}/${bound} starting (tier ${tier}; ${debt.suggestion})`);
212
+ let status = "error";
213
+ try {
214
+ const { runId } = await options.runtime.run({
215
+ sessionKey,
216
+ message: consolidationMessage(tier),
217
+ deliver: false,
218
+ });
219
+ const waited = await options.runtime.waitForRun({ runId, timeoutMs: RUN_TIMEOUT_MS });
220
+ status = waited.status;
221
+ if (waited.status !== "ok") log(`auto: isolated run ${waited.status}${waited.error ? `: ${waited.error}` : ""}`);
222
+ } catch (err: any) {
223
+ log(`auto: isolated run failed: ${err?.message}`);
224
+ break;
225
+ } finally {
226
+ try { await options.runtime.deleteSession({ sessionKey, deleteTranscript: true }); } catch { /* cleanup best-effort */ }
227
+ }
228
+ if (status !== "ok") break;
229
+ // Verify a commit actually happened: the server-side debt must move.
230
+ let after: ReflectionDebtBlock | null = null;
231
+ try { after = await probeDebt(options.endpoint, options.key, options.embeddings); } catch { /* treated as no movement */ }
232
+ const before = JSON.stringify(debt.unconsolidated ?? {});
233
+ const current = JSON.stringify(after?.unconsolidated ?? {});
234
+ if (after && before !== current) {
235
+ committed++;
236
+ } else {
237
+ log("auto: run finished but debt did not move; stopping (no commit detected)");
238
+ break;
239
+ }
240
+ }
241
+ if (committed > 0) {
242
+ writeAutoState({ lastCompletedAt: now() }, env);
243
+ log(`auto: run complete, ${committed} batch(es) committed`);
244
+ }
245
+ } finally {
246
+ releaseLock(env);
247
+ }
248
+ return { committed };
249
+ }
250
+
251
+ /**
252
+ * Trigger check called from the hook path with the reflection debt block the
253
+ * prepare response already carried. Fire-and-forget; never blocks the hook,
254
+ * never throws.
255
+ */
256
+ export function maybeTriggerAutoConsolidate(
257
+ reflection: ReflectionDebtBlock | null | undefined,
258
+ options: Omit<AutoConsolidateOptions, "now"> & { autoConsolidate?: boolean; sessionKey?: string; now?: () => number },
259
+ ): boolean {
260
+ const env = options.env || process.env;
261
+ try {
262
+ if (env.MEMORYROUTER_DISABLE === "1") return false;
263
+ if (options.autoConsolidate === false) return false;
264
+ if (!reflection || !reflection.suggestion) return false;
265
+ if (isAutoConsolidationSession(options.sessionKey)) return false;
266
+ const now = options.now ? options.now() : Date.now();
267
+ if (cooldownActive(env, now)) return false;
268
+ if (lockFresh(env, now)) return false;
269
+ if (!options.runtime || typeof options.runtime.run !== "function") return false;
270
+ appendAutoLog(`trigger: starting auto consolidation (suggestion: ${reflection.suggestion})`, env);
271
+ void runAutoConsolidate(options).catch((err: any) => {
272
+ appendAutoLog(`auto: unexpected error: ${err?.message}`, env);
273
+ });
274
+ return true;
275
+ } catch (err: any) {
276
+ appendAutoLog(`trigger error: ${err?.message}`, env);
277
+ return false;
278
+ }
279
+ }
package/index.js CHANGED
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { readFile, lstat, writeFile, copyFile, mkdir } from "node:fs/promises";
11
11
  import { commitReflections, consolidateCheckout, dateSearch, deleteMemories, deleteMemoryIds, getMemoryStats, ingestMemory, inspectMemory, prepareMemory, searchMemory, warmupMemory } from "./memoryrouter-api.js";
12
+ import { isAutoConsolidationSession, maybeTriggerAutoConsolidate } from "./auto-consolidate.js";
12
13
  import { join, resolve, relative, isAbsolute, sep, dirname } from "node:path";
13
14
  import os from "node:os";
14
15
  const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
@@ -478,8 +479,27 @@ const memoryRouterPlugin = {
478
479
  const logging = cfg?.logging ?? false;
479
480
  const embeddings = cfg?.embeddings; // undefined = bge (default), "qwen" = qwen3-8b
480
481
  const agentKeys = cfg?.agentKeys;
482
+ const autoConsolidate = cfg?.autoConsolidate !== false; // default true
481
483
  const log = (msg) => { if (logging)
482
484
  api.logger.info?.(msg); };
485
+ // Automatic consolidation trigger: deterministic plugin-side automation
486
+ // driving an isolated agent turn via the gateway's subagent runtime.
487
+ // Fire-and-forget; all rails (lock, cooldown, 3-batch bound) live in
488
+ // auto-consolidate.ts. Failures never propagate into the hook path.
489
+ function triggerAutoConsolidation(reflection, ctx, activeKey) {
490
+ try {
491
+ maybeTriggerAutoConsolidate(reflection ?? null, {
492
+ autoConsolidate,
493
+ sessionKey: ctx?.sessionKey,
494
+ runtime: api.runtime?.subagent,
495
+ agentId: ctx?.agentId || "main",
496
+ endpoint,
497
+ key: activeKey,
498
+ embeddings,
499
+ });
500
+ }
501
+ catch { /* never blocks or crashes the host */ }
502
+ }
483
503
  // Resolve the correct memory key for a given agent context.
484
504
  // Multi-agent gateways: each agent gets its own key via agentKeys map.
485
505
  // Single-agent: falls back to the global key.
@@ -669,6 +689,11 @@ If setting up MemoryRouter for another agent:
669
689
  // When PR #24122 merges, OpenClaw will use the returned prependContext.
670
690
  // This gives forward compatibility — no plugin update needed.
671
691
  api.on("llm_input", async (event, ctx) => {
692
+ // Auto-consolidation runs are single-purpose tool sessions: no memory
693
+ // injection (their prompt must stay exactly the consolidation
694
+ // mission) and no re-trigger loops.
695
+ if (isAutoConsolidationSession(ctx?.sessionKey))
696
+ return;
672
697
  // Skip the first call — before_prompt_build already handled it
673
698
  // (before_prompt_build includes workspace+tools+skills for accurate billing)
674
699
  if (promptBuildFiredThisRun) {
@@ -740,6 +765,7 @@ If setting up MemoryRouter for another agent:
740
765
  return;
741
766
  }
742
767
  const data = (await res.json());
768
+ triggerAutoConsolidation(data.reflection, ctx, activeKey);
743
769
  if (data.context) {
744
770
  const elapsed = Date.now() - startMs;
745
771
  api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms) [llm_input]`);
@@ -762,6 +788,8 @@ If setting up MemoryRouter for another agent:
762
788
  });
763
789
  // ── before_prompt_build: fires once per run (primary, includes full billing context)
764
790
  api.on("before_prompt_build", async (event, ctx) => {
791
+ if (isAutoConsolidationSession(ctx?.sessionKey))
792
+ return;
765
793
  promptBuildFiredThisRun = true;
766
794
  const activeKey = resolveKey(ctx);
767
795
  if (!activeKey)
@@ -852,6 +880,7 @@ If setting up MemoryRouter for another agent:
852
880
  return;
853
881
  }
854
882
  const data = (await res.json());
883
+ triggerAutoConsolidation(data.reflection, ctx, activeKey);
855
884
  if (data.context) {
856
885
  const elapsed = Date.now() - startMs;
857
886
  api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms)`);
@@ -876,6 +905,9 @@ If setting up MemoryRouter for another agent:
876
905
  // Core: agent_end — store this turn's conversation
877
906
  // ==================================================================
878
907
  api.on("agent_end", async (event, ctx) => {
908
+ // Never store the auto-consolidation session's own chatter as memory.
909
+ if (isAutoConsolidationSession(ctx?.sessionKey))
910
+ return;
879
911
  const activeKey = resolveKey(ctx);
880
912
  if (!activeKey)
881
913
  return; // No key for this agent
package/index.ts CHANGED
@@ -10,6 +10,7 @@
10
10
 
11
11
  import { readFile, readdir, stat, lstat, writeFile, copyFile, mkdir } from "node:fs/promises";
12
12
  import { commitReflections, consolidateCheckout, dateSearch, deleteMemories, deleteMemoryIds, getMemoryStats, ingestMemory, inspectMemory, prepareMemory, searchMemory, warmupMemory } from "./memoryrouter-api.js";
13
+ import { isAutoConsolidationSession, maybeTriggerAutoConsolidate } from "./auto-consolidate.js";
13
14
  import { join, resolve, relative, isAbsolute, sep, dirname } from "node:path";
14
15
  import path from "node:path";
15
16
  import os from "node:os";
@@ -323,6 +324,10 @@ type MemoryRouterConfig = {
323
324
  /** How long to keep the vault warm after the last message, in ms.
324
325
  * Default 180000 (3 minutes). Set 0 to disable warmup pings entirely. */
325
326
  keepWarmMs?: number;
327
+ /** Automatic consolidation when the server reports debt above its
328
+ * threshold. Default true. Set false to opt out (the injected suggestion
329
+ * nudge remains). */
330
+ autoConsolidate?: boolean;
326
331
  };
327
332
 
328
333
  // ──────────────────────────────────────────────────────
@@ -525,8 +530,27 @@ const memoryRouterPlugin = {
525
530
  const logging = cfg?.logging ?? false;
526
531
  const embeddings = cfg?.embeddings; // undefined = bge (default), "qwen" = qwen3-8b
527
532
  const agentKeys = cfg?.agentKeys;
533
+ const autoConsolidate = cfg?.autoConsolidate !== false; // default true
528
534
  const log = (msg: string) => { if (logging) api.logger.info?.(msg); };
529
535
 
536
+ // Automatic consolidation trigger: deterministic plugin-side automation
537
+ // driving an isolated agent turn via the gateway's subagent runtime.
538
+ // Fire-and-forget; all rails (lock, cooldown, 3-batch bound) live in
539
+ // auto-consolidate.ts. Failures never propagate into the hook path.
540
+ function triggerAutoConsolidation(reflection: ReflectionDebt | undefined, ctx: any, activeKey: string): void {
541
+ try {
542
+ maybeTriggerAutoConsolidate(reflection ?? null, {
543
+ autoConsolidate,
544
+ sessionKey: ctx?.sessionKey,
545
+ runtime: (api as any).runtime?.subagent,
546
+ agentId: ctx?.agentId || "main",
547
+ endpoint,
548
+ key: activeKey,
549
+ embeddings,
550
+ });
551
+ } catch { /* never blocks or crashes the host */ }
552
+ }
553
+
530
554
  // Resolve the correct memory key for a given agent context.
531
555
  // Multi-agent gateways: each agent gets its own key via agentKeys map.
532
556
  // Single-agent: falls back to the global key.
@@ -721,6 +745,10 @@ If setting up MemoryRouter for another agent:
721
745
  // When PR #24122 merges, OpenClaw will use the returned prependContext.
722
746
  // This gives forward compatibility — no plugin update needed.
723
747
  api.on("llm_input", async (event, ctx) => {
748
+ // Auto-consolidation runs are single-purpose tool sessions: no memory
749
+ // injection (their prompt must stay exactly the consolidation
750
+ // mission) and no re-trigger loops.
751
+ if (isAutoConsolidationSession(ctx?.sessionKey)) return;
724
752
  // Skip the first call — before_prompt_build already handled it
725
753
  // (before_prompt_build includes workspace+tools+skills for accurate billing)
726
754
  if (promptBuildFiredThisRun) {
@@ -800,6 +828,8 @@ If setting up MemoryRouter for another agent:
800
828
  import?: ImportSuggestedState;
801
829
  };
802
830
 
831
+ triggerAutoConsolidation(data.reflection, ctx, activeKey);
832
+
803
833
  if (data.context) {
804
834
  const elapsed = Date.now() - startMs;
805
835
  api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms) [llm_input]`);
@@ -819,6 +849,7 @@ If setting up MemoryRouter for another agent:
819
849
 
820
850
  // ── before_prompt_build: fires once per run (primary, includes full billing context)
821
851
  api.on("before_prompt_build", async (event, ctx) => {
852
+ if (isAutoConsolidationSession(ctx?.sessionKey)) return;
822
853
  promptBuildFiredThisRun = true;
823
854
  const activeKey = resolveKey(ctx);
824
855
  if (!activeKey) return; // No key for this agent
@@ -924,6 +955,8 @@ If setting up MemoryRouter for another agent:
924
955
  import?: ImportSuggestedState;
925
956
  };
926
957
 
958
+ triggerAutoConsolidation(data.reflection, ctx, activeKey);
959
+
927
960
  if (data.context) {
928
961
  const elapsed = Date.now() - startMs;
929
962
  api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms)`);
@@ -948,6 +981,8 @@ If setting up MemoryRouter for another agent:
948
981
  // ==================================================================
949
982
 
950
983
  api.on("agent_end", async (event, ctx) => {
984
+ // Never store the auto-consolidation session's own chatter as memory.
985
+ if (isAutoConsolidationSession(ctx?.sessionKey)) return;
951
986
  const activeKey = resolveKey(ctx);
952
987
  if (!activeKey) return; // No key for this agent
953
988
  try {
@@ -27,6 +27,12 @@
27
27
  "placeholder": "180000",
28
28
  "advanced": true,
29
29
  "help": "How long to keep your vault warm after each message. Default 180000 (3 min). Set 0 to disable warmup pings."
30
+ },
31
+ "autoConsolidate": {
32
+ "label": "Automatic Consolidation",
33
+ "placeholder": "true",
34
+ "advanced": true,
35
+ "help": "Automatically consolidate memories in the background when debt crosses the server threshold. Default true."
30
36
  }
31
37
  },
32
38
  "configSchema": {
@@ -61,6 +67,10 @@
61
67
  "minimum": 0,
62
68
  "description": "Keep the vault warm for this many ms after each message. Default 180000 (3 minutes). 0 disables warmup pings entirely."
63
69
  },
70
+ "autoConsolidate": {
71
+ "type": "boolean",
72
+ "description": "Automatically consolidate memories in an isolated background agent turn when the server reports debt above its threshold. Default true. Set false to opt out (the in-context suggestion nudge remains)."
73
+ },
64
74
  "agentKeys": {
65
75
  "type": "object",
66
76
  "additionalProperties": {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mr-memory",
3
- "version": "3.10.0",
3
+ "version": "3.11.0",
4
4
  "description": "Persistent memory for OpenClaw, powered by MemoryRouter. Relay-based AI agent memory across sessions, compaction, and model changes.",
5
5
  "type": "module",
6
6
  "files": [
@@ -11,6 +11,8 @@
11
11
  "upload.ts",
12
12
  "memoryrouter-api.js",
13
13
  "memoryrouter-api.ts",
14
+ "auto-consolidate.js",
15
+ "auto-consolidate.ts",
14
16
  "import-transport.js",
15
17
  "import-transport.ts",
16
18
  "upload-api.js",