mr-memory 3.10.0 → 3.11.1
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 +16 -0
- package/auto-consolidate.js +296 -0
- package/auto-consolidate.ts +283 -0
- package/index.js +32 -0
- package/index.ts +35 -0
- package/openclaw.plugin.json +10 -0
- package/package.json +3 -1
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,296 @@
|
|
|
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
|
+
// The gateway's `agent` method requires idempotencyKey on some
|
|
216
|
+
// deployments (and auto-generates it on others); always send a
|
|
217
|
+
// fresh one per run attempt so both validate.
|
|
218
|
+
idempotencyKey: randomUUID(),
|
|
219
|
+
});
|
|
220
|
+
const waited = await options.runtime.waitForRun({ runId, timeoutMs: RUN_TIMEOUT_MS });
|
|
221
|
+
status = waited.status;
|
|
222
|
+
if (waited.status !== "ok")
|
|
223
|
+
log(`auto: isolated run ${waited.status}${waited.error ? `: ${waited.error}` : ""}`);
|
|
224
|
+
}
|
|
225
|
+
catch (err) {
|
|
226
|
+
log(`auto: isolated run failed: ${err?.message}`);
|
|
227
|
+
break;
|
|
228
|
+
}
|
|
229
|
+
finally {
|
|
230
|
+
try {
|
|
231
|
+
await options.runtime.deleteSession({ sessionKey, deleteTranscript: true });
|
|
232
|
+
}
|
|
233
|
+
catch { /* cleanup best-effort */ }
|
|
234
|
+
}
|
|
235
|
+
if (status !== "ok")
|
|
236
|
+
break;
|
|
237
|
+
// Verify a commit actually happened: the server-side debt must move.
|
|
238
|
+
let after = null;
|
|
239
|
+
try {
|
|
240
|
+
after = await probeDebt(options.endpoint, options.key, options.embeddings);
|
|
241
|
+
}
|
|
242
|
+
catch { /* treated as no movement */ }
|
|
243
|
+
const before = JSON.stringify(debt.unconsolidated ?? {});
|
|
244
|
+
const current = JSON.stringify(after?.unconsolidated ?? {});
|
|
245
|
+
if (after && before !== current) {
|
|
246
|
+
committed++;
|
|
247
|
+
}
|
|
248
|
+
else {
|
|
249
|
+
log("auto: run finished but debt did not move; stopping (no commit detected)");
|
|
250
|
+
break;
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
if (committed > 0) {
|
|
254
|
+
writeAutoState({ lastCompletedAt: now() }, env);
|
|
255
|
+
log(`auto: run complete, ${committed} batch(es) committed`);
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
finally {
|
|
259
|
+
releaseLock(env);
|
|
260
|
+
}
|
|
261
|
+
return { committed };
|
|
262
|
+
}
|
|
263
|
+
/**
|
|
264
|
+
* Trigger check called from the hook path with the reflection debt block the
|
|
265
|
+
* prepare response already carried. Fire-and-forget; never blocks the hook,
|
|
266
|
+
* never throws.
|
|
267
|
+
*/
|
|
268
|
+
export function maybeTriggerAutoConsolidate(reflection, options) {
|
|
269
|
+
const env = options.env || process.env;
|
|
270
|
+
try {
|
|
271
|
+
if (env.MEMORYROUTER_DISABLE === "1")
|
|
272
|
+
return false;
|
|
273
|
+
if (options.autoConsolidate === false)
|
|
274
|
+
return false;
|
|
275
|
+
if (!reflection || !reflection.suggestion)
|
|
276
|
+
return false;
|
|
277
|
+
if (isAutoConsolidationSession(options.sessionKey))
|
|
278
|
+
return false;
|
|
279
|
+
const now = options.now ? options.now() : Date.now();
|
|
280
|
+
if (cooldownActive(env, now))
|
|
281
|
+
return false;
|
|
282
|
+
if (lockFresh(env, now))
|
|
283
|
+
return false;
|
|
284
|
+
if (!options.runtime || typeof options.runtime.run !== "function")
|
|
285
|
+
return false;
|
|
286
|
+
appendAutoLog(`trigger: starting auto consolidation (suggestion: ${reflection.suggestion})`, env);
|
|
287
|
+
void runAutoConsolidate(options).catch((err) => {
|
|
288
|
+
appendAutoLog(`auto: unexpected error: ${err?.message}`, env);
|
|
289
|
+
});
|
|
290
|
+
return true;
|
|
291
|
+
}
|
|
292
|
+
catch (err) {
|
|
293
|
+
appendAutoLog(`trigger error: ${err?.message}`, env);
|
|
294
|
+
return false;
|
|
295
|
+
}
|
|
296
|
+
}
|
|
@@ -0,0 +1,283 @@
|
|
|
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; idempotencyKey?: 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
|
+
// The gateway's `agent` method requires idempotencyKey on some
|
|
219
|
+
// deployments (and auto-generates it on others); always send a
|
|
220
|
+
// fresh one per run attempt so both validate.
|
|
221
|
+
idempotencyKey: randomUUID(),
|
|
222
|
+
});
|
|
223
|
+
const waited = await options.runtime.waitForRun({ runId, timeoutMs: RUN_TIMEOUT_MS });
|
|
224
|
+
status = waited.status;
|
|
225
|
+
if (waited.status !== "ok") log(`auto: isolated run ${waited.status}${waited.error ? `: ${waited.error}` : ""}`);
|
|
226
|
+
} catch (err: any) {
|
|
227
|
+
log(`auto: isolated run failed: ${err?.message}`);
|
|
228
|
+
break;
|
|
229
|
+
} finally {
|
|
230
|
+
try { await options.runtime.deleteSession({ sessionKey, deleteTranscript: true }); } catch { /* cleanup best-effort */ }
|
|
231
|
+
}
|
|
232
|
+
if (status !== "ok") break;
|
|
233
|
+
// Verify a commit actually happened: the server-side debt must move.
|
|
234
|
+
let after: ReflectionDebtBlock | null = null;
|
|
235
|
+
try { after = await probeDebt(options.endpoint, options.key, options.embeddings); } catch { /* treated as no movement */ }
|
|
236
|
+
const before = JSON.stringify(debt.unconsolidated ?? {});
|
|
237
|
+
const current = JSON.stringify(after?.unconsolidated ?? {});
|
|
238
|
+
if (after && before !== current) {
|
|
239
|
+
committed++;
|
|
240
|
+
} else {
|
|
241
|
+
log("auto: run finished but debt did not move; stopping (no commit detected)");
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
}
|
|
245
|
+
if (committed > 0) {
|
|
246
|
+
writeAutoState({ lastCompletedAt: now() }, env);
|
|
247
|
+
log(`auto: run complete, ${committed} batch(es) committed`);
|
|
248
|
+
}
|
|
249
|
+
} finally {
|
|
250
|
+
releaseLock(env);
|
|
251
|
+
}
|
|
252
|
+
return { committed };
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* Trigger check called from the hook path with the reflection debt block the
|
|
257
|
+
* prepare response already carried. Fire-and-forget; never blocks the hook,
|
|
258
|
+
* never throws.
|
|
259
|
+
*/
|
|
260
|
+
export function maybeTriggerAutoConsolidate(
|
|
261
|
+
reflection: ReflectionDebtBlock | null | undefined,
|
|
262
|
+
options: Omit<AutoConsolidateOptions, "now"> & { autoConsolidate?: boolean; sessionKey?: string; now?: () => number },
|
|
263
|
+
): boolean {
|
|
264
|
+
const env = options.env || process.env;
|
|
265
|
+
try {
|
|
266
|
+
if (env.MEMORYROUTER_DISABLE === "1") return false;
|
|
267
|
+
if (options.autoConsolidate === false) return false;
|
|
268
|
+
if (!reflection || !reflection.suggestion) return false;
|
|
269
|
+
if (isAutoConsolidationSession(options.sessionKey)) return false;
|
|
270
|
+
const now = options.now ? options.now() : Date.now();
|
|
271
|
+
if (cooldownActive(env, now)) return false;
|
|
272
|
+
if (lockFresh(env, now)) return false;
|
|
273
|
+
if (!options.runtime || typeof options.runtime.run !== "function") return false;
|
|
274
|
+
appendAutoLog(`trigger: starting auto consolidation (suggestion: ${reflection.suggestion})`, env);
|
|
275
|
+
void runAutoConsolidate(options).catch((err: any) => {
|
|
276
|
+
appendAutoLog(`auto: unexpected error: ${err?.message}`, env);
|
|
277
|
+
});
|
|
278
|
+
return true;
|
|
279
|
+
} catch (err: any) {
|
|
280
|
+
appendAutoLog(`trigger error: ${err?.message}`, env);
|
|
281
|
+
return false;
|
|
282
|
+
}
|
|
283
|
+
}
|
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 {
|
package/openclaw.plugin.json
CHANGED
|
@@ -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.
|
|
3
|
+
"version": "3.11.1",
|
|
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",
|