mr-memory 3.7.10 → 3.9.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 +12 -2
- package/index.js +474 -41
- package/index.ts +435 -15
- package/memoryrouter-api.js +69 -2
- package/memoryrouter-api.ts +75 -2
- package/openclaw.plugin.json +11 -0
- package/package.json +1 -1
package/index.js
CHANGED
|
@@ -8,10 +8,19 @@
|
|
|
8
8
|
* BYOK — provider API keys never touch MemoryRouter.
|
|
9
9
|
*/
|
|
10
10
|
import { readFile, lstat, writeFile, copyFile, mkdir } from "node:fs/promises";
|
|
11
|
-
import { deleteMemories, getMemoryStats, ingestMemory, prepareMemory, searchMemory, warmupMemory } from "./memoryrouter-api.js";
|
|
11
|
+
import { commitReflections, consolidateCheckout, dateSearch, deleteMemories, deleteMemoryIds, getMemoryStats, ingestMemory, inspectMemory, prepareMemory, searchMemory, warmupMemory } from "./memoryrouter-api.js";
|
|
12
12
|
import { join, resolve, relative, isAbsolute, sep, dirname } from "node:path";
|
|
13
13
|
import os from "node:os";
|
|
14
14
|
const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
|
|
15
|
+
/** Keep-warm defaults.
|
|
16
|
+
*
|
|
17
|
+
* Warmth only matters in the minutes right after real activity, so warmup
|
|
18
|
+
* pings are activity-scoped instead of always-on: every message opens a
|
|
19
|
+
* KEEP_WARM window, pings fire at KEEP_WARM_PING_INTERVAL_MS inside it, and
|
|
20
|
+
* the timer is cleared completely once the window closes. An idle gateway
|
|
21
|
+
* sends zero warmup traffic. */
|
|
22
|
+
const DEFAULT_KEEP_WARM_MS = 3 * 60 * 1000; // 3 minutes after the last message
|
|
23
|
+
const KEEP_WARM_PING_INTERVAL_MS = 60 * 1000; // cadence inside the window
|
|
15
24
|
/** Strip media-attached references and OpenClaw media instruction text so
|
|
16
25
|
* old screenshots/photos/audio don't get stored and re-injected forever. */
|
|
17
26
|
const MEDIA_ATTACHED_RE = /\[media attached[^\]]*\]/gi;
|
|
@@ -46,6 +55,16 @@ function stripOldMemory(text) {
|
|
|
46
55
|
function jsonToolResult(payload) {
|
|
47
56
|
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }], details: payload };
|
|
48
57
|
}
|
|
58
|
+
const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1000; // at most one nudge per 24h
|
|
59
|
+
function buildDebtNudgeLine(reflection) {
|
|
60
|
+
if (!reflection || !reflection.suggestion)
|
|
61
|
+
return null;
|
|
62
|
+
const rawTokens = reflection.unconsolidated?.raw?.est_tokens ?? 0;
|
|
63
|
+
const tier2Tokens = reflection.unconsolidated?.tier2?.est_tokens ?? 0;
|
|
64
|
+
const totalTokens = rawTokens + tier2Tokens;
|
|
65
|
+
const kTokens = Math.max(1, Math.round(totalTokens / 1000));
|
|
66
|
+
return `[MemoryRouter] Consolidation available: ${kTokens}k unconsolidated tokens. Use the memory_reflect tool when convenient.`;
|
|
67
|
+
}
|
|
49
68
|
// ── memory_get helpers (copied from OpenClaw source — pure filesystem, no embeddings)
|
|
50
69
|
function isFileMissingError(err) {
|
|
51
70
|
return Boolean(err && typeof err === "object" && "code" in err && err.code === "ENOENT");
|
|
@@ -292,6 +311,37 @@ async function writeOpenClawConfigFile(config) {
|
|
|
292
311
|
await writeFile(configPath, `${JSON.stringify(config, null, 2)}
|
|
293
312
|
`, "utf8");
|
|
294
313
|
}
|
|
314
|
+
// Nudge rate-limit state lives beside the OpenClaw config so it survives restarts
|
|
315
|
+
// without churning the main config file.
|
|
316
|
+
function nudgeStatePath() {
|
|
317
|
+
return join(dirname(openClawConfigPath()), "mr-memory-state.json");
|
|
318
|
+
}
|
|
319
|
+
async function consumeNudgeBudget() {
|
|
320
|
+
const now = Date.now();
|
|
321
|
+
let state = {};
|
|
322
|
+
try {
|
|
323
|
+
state = JSON.parse(await readFile(nudgeStatePath(), "utf8"));
|
|
324
|
+
}
|
|
325
|
+
catch { /* missing or unreadable state: treat as never nudged */ }
|
|
326
|
+
if (typeof state.lastNudgeAt === "number" && now - state.lastNudgeAt < NUDGE_INTERVAL_MS) {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
try {
|
|
330
|
+
await writeFile(nudgeStatePath(), `${JSON.stringify({ ...state, lastNudgeAt: now }, null, 2)}\n`, "utf8");
|
|
331
|
+
}
|
|
332
|
+
catch { /* state write failure must never break prepare */ }
|
|
333
|
+
return true;
|
|
334
|
+
}
|
|
335
|
+
/** One-line, rate-limited debt nudge for prepare injections. Returns null when
|
|
336
|
+
* there is no suggestion or the 24h budget is spent. */
|
|
337
|
+
async function maybeDebtNudge(reflection) {
|
|
338
|
+
const line = buildDebtNudgeLine(reflection);
|
|
339
|
+
if (!line)
|
|
340
|
+
return null;
|
|
341
|
+
if (!(await consumeNudgeBudget()))
|
|
342
|
+
return null;
|
|
343
|
+
return line;
|
|
344
|
+
}
|
|
295
345
|
// ──────────────────────────────────────────────────────
|
|
296
346
|
// Full-replace: disable OpenClaw's native memory search so MemoryRouter
|
|
297
347
|
// owns the memory_search tool (no name conflict, no native embeddings 429).
|
|
@@ -299,37 +349,34 @@ async function writeOpenClawConfigFile(config) {
|
|
|
299
349
|
// Global + subagents: writes agents.defaults.memorySearch.enabled.
|
|
300
350
|
// ──────────────────────────────────────────────────────
|
|
301
351
|
async function setNativeMemorySearchEnabled(enabled) {
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
352
|
+
const config = await readOpenClawConfigFile();
|
|
353
|
+
config.agents ??= {};
|
|
354
|
+
config.agents.defaults ??= {};
|
|
355
|
+
config.agents.defaults.memorySearch ??= {};
|
|
356
|
+
if (config.agents.defaults.memorySearch.enabled === enabled)
|
|
357
|
+
return false; // no-op
|
|
358
|
+
config.agents.defaults.memorySearch.enabled = enabled;
|
|
359
|
+
await writeOpenClawConfigFile(config);
|
|
360
|
+
return true;
|
|
310
361
|
}
|
|
311
|
-
|
|
312
362
|
// Boot-time self-heal: if a key is configured but native search is still on,
|
|
313
363
|
// flip it off so already-installed users are fixed on restart after updating
|
|
314
364
|
// (no need to re-run `openclaw mr <key>`).
|
|
315
365
|
async function selfHealNativeMemorySearch(api) {
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
366
|
+
try {
|
|
367
|
+
const config = await readOpenClawConfigFile();
|
|
368
|
+
const ms = config?.agents?.defaults?.memorySearch;
|
|
369
|
+
if (ms?.enabled === false)
|
|
370
|
+
return; // already disabled — nothing to do
|
|
371
|
+
const changed = await setNativeMemorySearchEnabled(false);
|
|
372
|
+
if (changed) {
|
|
373
|
+
api.logger.info?.("memoryrouter: disabled native memory search (full-replace). Restart gateway to apply.");
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
catch (err) {
|
|
377
|
+
api.logger.info?.(`memoryrouter: self-heal skipped — ${err instanceof Error ? err.message : String(err)}`);
|
|
325
378
|
}
|
|
326
|
-
} catch (err) {
|
|
327
|
-
api.logger.info?.(
|
|
328
|
-
`memoryrouter: self-heal skipped — ${err instanceof Error ? err.message : String(err)}`,
|
|
329
|
-
);
|
|
330
|
-
}
|
|
331
379
|
}
|
|
332
|
-
|
|
333
380
|
async function updateOpenClawPluginEntry(api, patch) {
|
|
334
381
|
const config = await readOpenClawConfigFile();
|
|
335
382
|
config.plugins ??= {};
|
|
@@ -475,6 +522,80 @@ const memoryRouterPlugin = {
|
|
|
475
522
|
return memoryKey;
|
|
476
523
|
}
|
|
477
524
|
const hasAnyKey = !!(memoryKey || (agentKeys && Object.keys(agentKeys).length > 0));
|
|
525
|
+
// Keep-warm window: 0 (or negative) disables warmup pings entirely.
|
|
526
|
+
const keepWarmMs = typeof cfg?.keepWarmMs === "number" && cfg.keepWarmMs >= 0
|
|
527
|
+
? cfg.keepWarmMs
|
|
528
|
+
: DEFAULT_KEEP_WARM_MS;
|
|
529
|
+
let warmupTimer;
|
|
530
|
+
// undefined = no activity yet (never compare against 0; a 0 timestamp is a
|
|
531
|
+
// valid clock value and must not read as "no activity").
|
|
532
|
+
let lastActivityAt;
|
|
533
|
+
// Starts true so keep-warm works even if the host never calls service.start();
|
|
534
|
+
// service.stop() flips it off so no timer can outlive the plugin.
|
|
535
|
+
let serviceRunning = true;
|
|
536
|
+
function configuredKeys() {
|
|
537
|
+
const keys = new Set();
|
|
538
|
+
if (memoryKey)
|
|
539
|
+
keys.add(memoryKey);
|
|
540
|
+
if (agentKeys) {
|
|
541
|
+
for (const key of Object.values(agentKeys)) {
|
|
542
|
+
if (key)
|
|
543
|
+
keys.add(key);
|
|
544
|
+
}
|
|
545
|
+
}
|
|
546
|
+
return Array.from(keys);
|
|
547
|
+
}
|
|
548
|
+
function pingWarmup(reason) {
|
|
549
|
+
for (const key of configuredKeys()) {
|
|
550
|
+
warmupMemory(endpoint, key, embeddings)
|
|
551
|
+
.then(async (res) => {
|
|
552
|
+
if (!res.ok) {
|
|
553
|
+
log(`memoryrouter: warmup ${reason} failed (${res.status})`);
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const data = await res.json().catch(() => null);
|
|
557
|
+
log(`memoryrouter: warmup ${reason} ok (${data?.totalVectors ?? 0} vectors, ${data?.warmupTimeMs ?? "?"}ms)`);
|
|
558
|
+
})
|
|
559
|
+
.catch((err) => {
|
|
560
|
+
log(`memoryrouter: warmup ${reason} error — ${err instanceof Error ? err.message : String(err)}`);
|
|
561
|
+
});
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
function stopWarmLoop() {
|
|
565
|
+
if (warmupTimer) {
|
|
566
|
+
clearInterval(warmupTimer);
|
|
567
|
+
warmupTimer = undefined;
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
/** True while we're still inside the post-message keep-warm window. */
|
|
571
|
+
function withinWarmWindow() {
|
|
572
|
+
if (keepWarmMs <= 0 || lastActivityAt === undefined)
|
|
573
|
+
return false;
|
|
574
|
+
return Date.now() - lastActivityAt <= keepWarmMs;
|
|
575
|
+
}
|
|
576
|
+
/** Called on every message-driven activity (prepare / ingest).
|
|
577
|
+
* Opens (or extends) the keep-warm window and makes sure the loop runs.
|
|
578
|
+
* Once the window lapses with no new activity the interval is cleared, so
|
|
579
|
+
* an idle gateway sends no warmup traffic at all. */
|
|
580
|
+
function noteActivity() {
|
|
581
|
+
if (!hasAnyKey || keepWarmMs <= 0 || !serviceRunning)
|
|
582
|
+
return;
|
|
583
|
+
lastActivityAt = Date.now();
|
|
584
|
+
if (warmupTimer)
|
|
585
|
+
return; // window extended; existing loop keeps running
|
|
586
|
+
// No immediate ping: the prepare/ingest request that triggered this call
|
|
587
|
+
// already warmed the vault server-side. Pings only cover the idle gaps
|
|
588
|
+
// inside the window.
|
|
589
|
+
warmupTimer = setInterval(() => {
|
|
590
|
+
if (!withinWarmWindow()) {
|
|
591
|
+
log("memoryrouter: keep-warm window elapsed — pings stopped until next message");
|
|
592
|
+
stopWarmLoop();
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
pingWarmup("keepalive");
|
|
596
|
+
}, KEEP_WARM_PING_INTERVAL_MS);
|
|
597
|
+
warmupTimer.unref?.();
|
|
598
|
+
}
|
|
478
599
|
void (async () => {
|
|
479
600
|
await ensureDoctorSafePluginEntry(api);
|
|
480
601
|
if (hasAnyKey)
|
|
@@ -628,6 +749,8 @@ If setting up MemoryRouter for another agent:
|
|
|
628
749
|
contextPayload.push({ role: "user", content: taskPrompt });
|
|
629
750
|
}
|
|
630
751
|
}
|
|
752
|
+
// Message activity — open/extend the keep-warm window.
|
|
753
|
+
noteActivity();
|
|
631
754
|
const res = await prepareMemory(endpoint, activeKey, {
|
|
632
755
|
messages: contextPayload,
|
|
633
756
|
density,
|
|
@@ -649,7 +772,11 @@ If setting up MemoryRouter for another agent:
|
|
|
649
772
|
api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms) [llm_input]`);
|
|
650
773
|
if (elapsed > 2000 && data.metrics)
|
|
651
774
|
log(`memoryrouter: prepare metrics ${JSON.stringify(data.metrics)}`);
|
|
652
|
-
|
|
775
|
+
let contextText = data.context;
|
|
776
|
+
const nudge = await maybeDebtNudge(data.reflection);
|
|
777
|
+
if (nudge)
|
|
778
|
+
contextText = `${contextText}\n\n${nudge}`;
|
|
779
|
+
const wrapped = wrapForInjection(contextText);
|
|
653
780
|
return { appendSystemContext: wrapped };
|
|
654
781
|
}
|
|
655
782
|
}
|
|
@@ -728,6 +855,8 @@ If setting up MemoryRouter for another agent:
|
|
|
728
855
|
}
|
|
729
856
|
}
|
|
730
857
|
// 4. Call /v1/memory/prepare (no session_id — always search core vault)
|
|
858
|
+
// Message activity — open/extend the keep-warm window.
|
|
859
|
+
noteActivity();
|
|
731
860
|
const res = await prepareMemory(endpoint, activeKey, {
|
|
732
861
|
messages: contextPayload,
|
|
733
862
|
density,
|
|
@@ -752,7 +881,11 @@ If setting up MemoryRouter for another agent:
|
|
|
752
881
|
api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms)`);
|
|
753
882
|
if (elapsed > 2000 && data.metrics)
|
|
754
883
|
log(`memoryrouter: prepare metrics ${JSON.stringify(data.metrics)}`);
|
|
755
|
-
|
|
884
|
+
let contextText = data.context;
|
|
885
|
+
const nudge = await maybeDebtNudge(data.reflection);
|
|
886
|
+
if (nudge)
|
|
887
|
+
contextText = `${contextText}\n\n${nudge}`;
|
|
888
|
+
const wrapped = wrapForInjection(contextText);
|
|
756
889
|
return { appendSystemContext: wrapped };
|
|
757
890
|
}
|
|
758
891
|
}
|
|
@@ -834,6 +967,8 @@ If setting up MemoryRouter for another agent:
|
|
|
834
967
|
log("memoryrouter: skipped ingest — system noise filtered out");
|
|
835
968
|
return;
|
|
836
969
|
}
|
|
970
|
+
// Message activity — open/extend the keep-warm window.
|
|
971
|
+
noteActivity();
|
|
837
972
|
// Fire and forget — don't block message delivery waiting for ingest
|
|
838
973
|
ingestMemory(endpoint, activeKey, {
|
|
839
974
|
messages: toStore,
|
|
@@ -877,6 +1012,17 @@ If setting up MemoryRouter for another agent:
|
|
|
877
1012
|
query: { type: "string" },
|
|
878
1013
|
maxResults: { type: "number" },
|
|
879
1014
|
minScore: { type: "number" },
|
|
1015
|
+
tiers: {
|
|
1016
|
+
type: "array",
|
|
1017
|
+
items: { type: "integer", enum: [1, 2, 3] },
|
|
1018
|
+
description: "Restrict results to these memory tiers. 1 raw memories, 2 reflections, 3 high-level reflections. Omit to search all tiers blended.",
|
|
1019
|
+
},
|
|
1020
|
+
importance: {
|
|
1021
|
+
type: "integer",
|
|
1022
|
+
minimum: 1,
|
|
1023
|
+
maximum: 10,
|
|
1024
|
+
description: "Only return memories rated at least this important (1 to 10). Only reflections carry ratings, so this filters to tier 2 and 3 content.",
|
|
1025
|
+
},
|
|
880
1026
|
},
|
|
881
1027
|
required: ["query"],
|
|
882
1028
|
},
|
|
@@ -887,8 +1033,20 @@ If setting up MemoryRouter for another agent:
|
|
|
887
1033
|
if (!query)
|
|
888
1034
|
return jsonToolResult({ results: [], error: "query required" });
|
|
889
1035
|
const limit = typeof params.maxResults === "number" ? params.maxResults : 50;
|
|
1036
|
+
const tiers = Array.isArray(params.tiers)
|
|
1037
|
+
? params.tiers.filter((t) => typeof t === "number" && [1, 2, 3].includes(t))
|
|
1038
|
+
: undefined;
|
|
1039
|
+
const importance = typeof params.importance === "number" && params.importance >= 1 && params.importance <= 10
|
|
1040
|
+
? Math.floor(params.importance)
|
|
1041
|
+
: undefined;
|
|
890
1042
|
try {
|
|
891
|
-
const res = await searchMemory(endpoint, toolKey, {
|
|
1043
|
+
const res = await searchMemory(endpoint, toolKey, {
|
|
1044
|
+
query,
|
|
1045
|
+
limit,
|
|
1046
|
+
...(tiers && tiers.length > 0 && { tiers }),
|
|
1047
|
+
...(importance !== undefined && { importance }),
|
|
1048
|
+
embeddings,
|
|
1049
|
+
});
|
|
892
1050
|
if (!res.ok) {
|
|
893
1051
|
const errBody = await res.text().catch(() => "");
|
|
894
1052
|
return jsonToolResult({ results: [], error: `Search failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
|
|
@@ -902,6 +1060,8 @@ If setting up MemoryRouter for another agent:
|
|
|
902
1060
|
role: m.role,
|
|
903
1061
|
window: m.window,
|
|
904
1062
|
date: m.timestampHuman,
|
|
1063
|
+
...(m.tier !== undefined && { tier: m.tier }),
|
|
1064
|
+
...(m.importance !== undefined && m.importance !== null && { importance: m.importance }),
|
|
905
1065
|
}));
|
|
906
1066
|
return jsonToolResult({
|
|
907
1067
|
results,
|
|
@@ -917,6 +1077,203 @@ If setting up MemoryRouter for another agent:
|
|
|
917
1077
|
},
|
|
918
1078
|
};
|
|
919
1079
|
});
|
|
1080
|
+
// ==================================================================
|
|
1081
|
+
// Tools: memory_reflect + memory_commit (consolidation, two-tool shape)
|
|
1082
|
+
// Mirrors the MCP consolidate_memories / commit_reflections pair.
|
|
1083
|
+
// Invariants: source_ids are stripped, the model never sees row IDs,
|
|
1084
|
+
// and contract instructions are transmitted verbatim.
|
|
1085
|
+
// ==================================================================
|
|
1086
|
+
// memory_reflect: checkout wrapper (nothing is modified until memory_commit)
|
|
1087
|
+
api.registerTool((ctx) => {
|
|
1088
|
+
const toolKey = resolveKey(ctx);
|
|
1089
|
+
return {
|
|
1090
|
+
label: "Memory Reflect",
|
|
1091
|
+
name: "memory_reflect",
|
|
1092
|
+
description: "Consolidate unconsolidated memories into reflections. Use when memory responses indicate consolidation is available, or when the user asks to organize or reflect on memory. Checks out a batch of memories under a 15 minute lease and returns their texts plus server instructions for writing reflection entries. Follow the instructions, then call memory_commit with the batch_id. Nothing is modified until commit.",
|
|
1093
|
+
parameters: {
|
|
1094
|
+
type: "object",
|
|
1095
|
+
properties: {
|
|
1096
|
+
tier: {
|
|
1097
|
+
type: "integer",
|
|
1098
|
+
enum: [1, 2],
|
|
1099
|
+
description: "1 consolidates raw memories into reflections; 2 consolidates reflections into high-level reflections. Default 1.",
|
|
1100
|
+
},
|
|
1101
|
+
max_tokens: {
|
|
1102
|
+
type: "integer",
|
|
1103
|
+
minimum: 1000,
|
|
1104
|
+
description: "Token budget for the checked-out batch. Defaults to 50000. No connector-side cap.",
|
|
1105
|
+
},
|
|
1106
|
+
},
|
|
1107
|
+
required: [],
|
|
1108
|
+
},
|
|
1109
|
+
execute: async (_toolCallId, params) => {
|
|
1110
|
+
if (!toolKey)
|
|
1111
|
+
return jsonToolResult({ error: "No memory key configured for this agent" });
|
|
1112
|
+
const tier = params.tier === 2 ? 2 : 1;
|
|
1113
|
+
const maxTokens = typeof params.max_tokens === "number" && params.max_tokens >= 1000
|
|
1114
|
+
? Math.floor(params.max_tokens)
|
|
1115
|
+
: 50000;
|
|
1116
|
+
try {
|
|
1117
|
+
const res = await consolidateCheckout(endpoint, toolKey, {
|
|
1118
|
+
tier: tier,
|
|
1119
|
+
max_tokens: maxTokens,
|
|
1120
|
+
...(embeddings && { embeddings }),
|
|
1121
|
+
});
|
|
1122
|
+
if (!res.ok) {
|
|
1123
|
+
const errBody = await res.text().catch(() => "");
|
|
1124
|
+
return jsonToolResult({ error: `Checkout failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
|
|
1125
|
+
}
|
|
1126
|
+
const data = await res.json();
|
|
1127
|
+
if (!data.batch_id) {
|
|
1128
|
+
return jsonToolResult({ batch_id: null, count: 0, message: "Nothing to consolidate at this tier right now." });
|
|
1129
|
+
}
|
|
1130
|
+
// Contract instructions are server-authored and passed through verbatim.
|
|
1131
|
+
// If neither contract form is present, refuse; never author a local prompt.
|
|
1132
|
+
const instructions = data.reflection_contract?.instructions ?? data.tier_definitions?.instruction;
|
|
1133
|
+
if (!instructions) {
|
|
1134
|
+
return jsonToolResult({ error: "Checkout response carried no reflection contract. Cannot consolidate without server instructions." });
|
|
1135
|
+
}
|
|
1136
|
+
// source_ids stripped: the model never sees memory row IDs.
|
|
1137
|
+
return jsonToolResult({
|
|
1138
|
+
batch_id: data.batch_id,
|
|
1139
|
+
lease_expires_at: data.lease_expires_at ?? null,
|
|
1140
|
+
count: data.count ?? 0,
|
|
1141
|
+
est_tokens: data.est_tokens ?? 0,
|
|
1142
|
+
instructions,
|
|
1143
|
+
memories: (data.memories || []).map((m) => ({ text: m.text, date: m.date })),
|
|
1144
|
+
message: `When your entries are ready, call memory_commit with batch_id ${data.batch_id} before ${data.lease_expires_at ?? "the lease expires"}.`,
|
|
1145
|
+
});
|
|
1146
|
+
}
|
|
1147
|
+
catch (err) {
|
|
1148
|
+
return jsonToolResult({ error: err instanceof Error ? err.message : String(err) });
|
|
1149
|
+
}
|
|
1150
|
+
},
|
|
1151
|
+
};
|
|
1152
|
+
});
|
|
1153
|
+
// memory_commit: commit wrapper (idempotent; 409 means the lease expired)
|
|
1154
|
+
api.registerTool((ctx) => {
|
|
1155
|
+
const toolKey = resolveKey(ctx);
|
|
1156
|
+
return {
|
|
1157
|
+
label: "Memory Commit",
|
|
1158
|
+
name: "memory_commit",
|
|
1159
|
+
description: "Commit reflection entries for a batch checked out with memory_reflect. Each entry is standalone text plus an importance rating from 1 to 10. Safe to retry: committing the same batch twice reports success without duplicating.",
|
|
1160
|
+
parameters: {
|
|
1161
|
+
type: "object",
|
|
1162
|
+
properties: {
|
|
1163
|
+
batch_id: { type: "string", description: "The batch_id returned by memory_reflect." },
|
|
1164
|
+
entries: {
|
|
1165
|
+
type: "array",
|
|
1166
|
+
items: {
|
|
1167
|
+
type: "object",
|
|
1168
|
+
properties: {
|
|
1169
|
+
text: { type: "string", description: "Standalone reflection prose, 1 to 8000 chars. No IDs or scores inline." },
|
|
1170
|
+
importance: { type: "integer", minimum: 1, maximum: 10 },
|
|
1171
|
+
},
|
|
1172
|
+
required: ["text", "importance"],
|
|
1173
|
+
additionalProperties: false,
|
|
1174
|
+
},
|
|
1175
|
+
minItems: 1,
|
|
1176
|
+
maxItems: 100,
|
|
1177
|
+
},
|
|
1178
|
+
},
|
|
1179
|
+
required: ["batch_id", "entries"],
|
|
1180
|
+
},
|
|
1181
|
+
execute: async (_toolCallId, params) => {
|
|
1182
|
+
if (!toolKey)
|
|
1183
|
+
return jsonToolResult({ error: "No memory key configured for this agent" });
|
|
1184
|
+
const batchId = typeof params.batch_id === "string" ? params.batch_id.trim() : "";
|
|
1185
|
+
if (!batchId)
|
|
1186
|
+
return jsonToolResult({ error: "batch_id required" });
|
|
1187
|
+
const rawEntries = Array.isArray(params.entries) ? params.entries : [];
|
|
1188
|
+
if (rawEntries.length < 1 || rawEntries.length > 100) {
|
|
1189
|
+
return jsonToolResult({ error: "entries must contain between 1 and 100 items" });
|
|
1190
|
+
}
|
|
1191
|
+
const entries = [];
|
|
1192
|
+
for (let i = 0; i < rawEntries.length; i++) {
|
|
1193
|
+
const e = rawEntries[i];
|
|
1194
|
+
if (!e || typeof e !== "object")
|
|
1195
|
+
return jsonToolResult({ error: `entry ${i + 1} is not an object` });
|
|
1196
|
+
const extraKeys = Object.keys(e).filter((k) => k !== "text" && k !== "importance");
|
|
1197
|
+
if (extraKeys.length > 0) {
|
|
1198
|
+
// Position-reference impossibility: entries are text plus importance only.
|
|
1199
|
+
return jsonToolResult({ error: `entry ${i + 1} has unsupported fields: ${extraKeys.join(", ")}. Entries are text plus importance only; coverage is always the whole batch.` });
|
|
1200
|
+
}
|
|
1201
|
+
const text = typeof e.text === "string" ? e.text : "";
|
|
1202
|
+
if (!text || text.length > 8000)
|
|
1203
|
+
return jsonToolResult({ error: `entry ${i + 1} text must be 1 to 8000 chars` });
|
|
1204
|
+
const importance = typeof e.importance === "number" && Number.isInteger(e.importance) && e.importance >= 1 && e.importance <= 10
|
|
1205
|
+
? e.importance
|
|
1206
|
+
: null;
|
|
1207
|
+
if (importance === null)
|
|
1208
|
+
return jsonToolResult({ error: `entry ${i + 1} importance must be an integer 1 to 10` });
|
|
1209
|
+
entries.push({ text, importance });
|
|
1210
|
+
}
|
|
1211
|
+
try {
|
|
1212
|
+
const res = await commitReflections(endpoint, toolKey, {
|
|
1213
|
+
batch_id: batchId,
|
|
1214
|
+
entries,
|
|
1215
|
+
...(embeddings && { embeddings }),
|
|
1216
|
+
});
|
|
1217
|
+
if (res.status === 409) {
|
|
1218
|
+
return jsonToolResult({ error: "lease expired, call memory_reflect again" });
|
|
1219
|
+
}
|
|
1220
|
+
if (!res.ok) {
|
|
1221
|
+
const errBody = await res.text().catch(() => "");
|
|
1222
|
+
return jsonToolResult({ error: `Commit failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
|
|
1223
|
+
}
|
|
1224
|
+
const data = await res.json();
|
|
1225
|
+
// Created UUIDs dropped: the model never sees memory row IDs.
|
|
1226
|
+
return jsonToolResult({
|
|
1227
|
+
committed: data.committed ?? false,
|
|
1228
|
+
already_committed: data.already_committed ?? false,
|
|
1229
|
+
entries_created: data.created?.length ?? 0,
|
|
1230
|
+
sources_consolidated: data.sources_consolidated ?? 0,
|
|
1231
|
+
debt: data.reflection?.suggestion ?? null,
|
|
1232
|
+
});
|
|
1233
|
+
}
|
|
1234
|
+
catch (err) {
|
|
1235
|
+
return jsonToolResult({ error: err instanceof Error ? err.message : String(err) });
|
|
1236
|
+
}
|
|
1237
|
+
},
|
|
1238
|
+
};
|
|
1239
|
+
});
|
|
1240
|
+
// memory_inspect: read-only lineage drill-down (curation surface, ids allowed)
|
|
1241
|
+
api.registerTool((ctx) => {
|
|
1242
|
+
const toolKey = resolveKey(ctx);
|
|
1243
|
+
return {
|
|
1244
|
+
label: "Memory Inspect",
|
|
1245
|
+
name: "memory_inspect",
|
|
1246
|
+
description: "Inspect a memory's consolidation lineage by memory_id: its tier, the batch it came from, the sources it consolidated, and what it was consolidated into. Read-only.",
|
|
1247
|
+
parameters: {
|
|
1248
|
+
type: "object",
|
|
1249
|
+
properties: {
|
|
1250
|
+
memory_id: { type: "string", description: "The memory id to inspect." },
|
|
1251
|
+
},
|
|
1252
|
+
required: ["memory_id"],
|
|
1253
|
+
},
|
|
1254
|
+
execute: async (_toolCallId, params) => {
|
|
1255
|
+
if (!toolKey)
|
|
1256
|
+
return jsonToolResult({ error: "No memory key configured for this agent" });
|
|
1257
|
+
const memoryId = typeof params.memory_id === "string" ? params.memory_id.trim() : "";
|
|
1258
|
+
if (!memoryId)
|
|
1259
|
+
return jsonToolResult({ error: "memory_id required" });
|
|
1260
|
+
try {
|
|
1261
|
+
const res = await inspectMemory(endpoint, toolKey, {
|
|
1262
|
+
memory_id: memoryId,
|
|
1263
|
+
...(embeddings && { embeddings }),
|
|
1264
|
+
});
|
|
1265
|
+
if (!res.ok) {
|
|
1266
|
+
const errBody = await res.text().catch(() => "");
|
|
1267
|
+
return jsonToolResult({ error: `Inspect failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
|
|
1268
|
+
}
|
|
1269
|
+
return jsonToolResult(await res.json());
|
|
1270
|
+
}
|
|
1271
|
+
catch (err) {
|
|
1272
|
+
return jsonToolResult({ error: err instanceof Error ? err.message : String(err) });
|
|
1273
|
+
}
|
|
1274
|
+
},
|
|
1275
|
+
};
|
|
1276
|
+
});
|
|
920
1277
|
// memory_get — exact copy of OpenClaw's implementation (pure local file read)
|
|
921
1278
|
api.registerTool((ctx) => {
|
|
922
1279
|
const workspaceDir = ctx.workspaceDir || "";
|
|
@@ -971,8 +1328,10 @@ If setting up MemoryRouter for another agent:
|
|
|
971
1328
|
await setPluginEnabled(api, true);
|
|
972
1329
|
try {
|
|
973
1330
|
const changed = await setNativeMemorySearchEnabled(false);
|
|
974
|
-
if (changed)
|
|
975
|
-
|
|
1331
|
+
if (changed)
|
|
1332
|
+
console.log(` • Native memory search disabled — MemoryRouter now owns memory_search`);
|
|
1333
|
+
}
|
|
1334
|
+
catch (err) {
|
|
976
1335
|
console.error(` ⚠ Could not disable native memory search: ${err instanceof Error ? err.message : String(err)}`);
|
|
977
1336
|
}
|
|
978
1337
|
console.log(`✓ MemoryRouter enabled. Key: ${key.slice(0, 6)}...${key.slice(-3)}`);
|
|
@@ -1010,8 +1369,10 @@ If setting up MemoryRouter for another agent:
|
|
|
1010
1369
|
// Restore native memory search so the user isn't left with NO memory search.
|
|
1011
1370
|
try {
|
|
1012
1371
|
const changed = await setNativeMemorySearchEnabled(true);
|
|
1013
|
-
if (changed)
|
|
1014
|
-
|
|
1372
|
+
if (changed)
|
|
1373
|
+
console.log(" • Native memory search restored");
|
|
1374
|
+
}
|
|
1375
|
+
catch (err) {
|
|
1015
1376
|
console.error(` ⚠ Could not restore native memory search: ${err instanceof Error ? err.message : String(err)}`);
|
|
1016
1377
|
}
|
|
1017
1378
|
console.log("✓ MemoryRouter disabled (key cleared).");
|
|
@@ -1188,7 +1549,9 @@ If setting up MemoryRouter for another agent:
|
|
|
1188
1549
|
}
|
|
1189
1550
|
});
|
|
1190
1551
|
mr.command("delete")
|
|
1191
|
-
.description("Clear all memories from vault")
|
|
1552
|
+
.description("Clear all memories from vault, or delete specific memories with --ids")
|
|
1553
|
+
.option("--ids <ids>", "Comma-separated memory ids to delete (instead of the whole vault)")
|
|
1554
|
+
.option("--yes", "Confirm deletion (required with --ids)")
|
|
1192
1555
|
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1193
1556
|
.action(async (opts) => {
|
|
1194
1557
|
const effectiveKey = opts.key || memoryKey;
|
|
@@ -1196,6 +1559,34 @@ If setting up MemoryRouter for another agent:
|
|
|
1196
1559
|
console.error("Not configured. Run: openclaw mr <key> or pass --key <key>");
|
|
1197
1560
|
return;
|
|
1198
1561
|
}
|
|
1562
|
+
if (opts.ids) {
|
|
1563
|
+
const ids = opts.ids.split(",").map((s) => s.trim()).filter(Boolean);
|
|
1564
|
+
if (ids.length === 0) {
|
|
1565
|
+
console.error("No ids provided.");
|
|
1566
|
+
return;
|
|
1567
|
+
}
|
|
1568
|
+
console.log(`The following ${ids.length} memor${ids.length === 1 ? "y" : "ies"} will be PERMANENTLY deleted:`);
|
|
1569
|
+
for (const id of ids)
|
|
1570
|
+
console.log(` • ${id}`);
|
|
1571
|
+
if (!opts.yes) {
|
|
1572
|
+
console.error("\nRefusing to delete without --yes. Re-run with --yes to confirm.");
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
try {
|
|
1576
|
+
const res = await deleteMemoryIds(endpoint, effectiveKey, { ids, ...(embeddings && { embeddings }) });
|
|
1577
|
+
if (!res.ok) {
|
|
1578
|
+
const errBody = await res.text().catch(() => "");
|
|
1579
|
+
console.error(`Delete failed: HTTP ${res.status}${errBody ? `: ${errBody.slice(0, 200)}` : ""}`);
|
|
1580
|
+
return;
|
|
1581
|
+
}
|
|
1582
|
+
const data = (await res.json());
|
|
1583
|
+
console.log(`✓ Deleted ${data.deleted ?? 0} of ${data.requested ?? ids.length} (not found: ${data.not_found ?? 0})`);
|
|
1584
|
+
}
|
|
1585
|
+
catch (err) {
|
|
1586
|
+
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1587
|
+
}
|
|
1588
|
+
return;
|
|
1589
|
+
}
|
|
1199
1590
|
try {
|
|
1200
1591
|
const res = await deleteMemories(endpoint, effectiveKey, embeddings);
|
|
1201
1592
|
const data = (await res.json());
|
|
@@ -1206,6 +1597,46 @@ If setting up MemoryRouter for another agent:
|
|
|
1206
1597
|
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1207
1598
|
}
|
|
1208
1599
|
});
|
|
1600
|
+
mr.command("reflect")
|
|
1601
|
+
.description("Show reflection debt status (consolidation runs via the agent's memory_reflect tool)")
|
|
1602
|
+
.option("--key <key>", "Override memory key (for subagent vaults)")
|
|
1603
|
+
.action(async (opts) => {
|
|
1604
|
+
const effectiveKey = opts.key || memoryKey;
|
|
1605
|
+
if (!effectiveKey) {
|
|
1606
|
+
console.error("Not configured. Run: openclaw mr <key> or pass --key <key>");
|
|
1607
|
+
return;
|
|
1608
|
+
}
|
|
1609
|
+
try {
|
|
1610
|
+
// Debt probe: an empty-window date-search returns the reflection
|
|
1611
|
+
// debt block with zero results and zero retrieval billing.
|
|
1612
|
+
const from = new Date(Date.now() - 1000).toISOString();
|
|
1613
|
+
const res = await dateSearch(endpoint, effectiveKey, { from, max_tokens: 1000, ...(embeddings && { embeddings }) });
|
|
1614
|
+
if (!res.ok) {
|
|
1615
|
+
const errBody = await res.text().catch(() => "");
|
|
1616
|
+
console.error(`Debt check failed: HTTP ${res.status}${errBody ? `: ${errBody.slice(0, 200)}` : ""}`);
|
|
1617
|
+
return;
|
|
1618
|
+
}
|
|
1619
|
+
const data = (await res.json());
|
|
1620
|
+
const debt = data.reflection;
|
|
1621
|
+
console.log("Reflection Debt");
|
|
1622
|
+
console.log("───────────────────────────");
|
|
1623
|
+
const raw = debt?.unconsolidated?.raw;
|
|
1624
|
+
const tier2 = debt?.unconsolidated?.tier2;
|
|
1625
|
+
console.log(`Raw unconsolidated: ${raw?.units ?? 0} memories (~${raw?.est_tokens ?? 0} tokens)`);
|
|
1626
|
+
console.log(`Tier 2 unconsolidated: ${tier2?.count ?? 0} reflections (~${tier2?.est_tokens ?? 0} tokens)`);
|
|
1627
|
+
console.log(`Threshold: ${debt?.threshold_tokens ?? 0} tokens`);
|
|
1628
|
+
if (debt?.suggestion) {
|
|
1629
|
+
console.log(`\nStatus: ${debt.suggestion}`);
|
|
1630
|
+
console.log("Consolidation runs through the agent. Ask your agent to consolidate memories (it uses the memory_reflect and memory_commit tools).");
|
|
1631
|
+
}
|
|
1632
|
+
else {
|
|
1633
|
+
console.log("\nStatus: nothing to consolidate right now.");
|
|
1634
|
+
}
|
|
1635
|
+
}
|
|
1636
|
+
catch (err) {
|
|
1637
|
+
console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1638
|
+
}
|
|
1639
|
+
});
|
|
1209
1640
|
}, { commands: ["mr"] });
|
|
1210
1641
|
// ==================================================================
|
|
1211
1642
|
// Service
|
|
@@ -1215,17 +1646,19 @@ If setting up MemoryRouter for another agent:
|
|
|
1215
1646
|
start: () => {
|
|
1216
1647
|
if (memoryKey)
|
|
1217
1648
|
api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
|
|
1218
|
-
|
|
1219
|
-
|
|
1220
|
-
|
|
1221
|
-
|
|
1649
|
+
serviceRunning = true;
|
|
1650
|
+
// No startup ping and no always-on keepalive: warmth is driven by real
|
|
1651
|
+
// activity. The first message opens the keep-warm window.
|
|
1652
|
+
if (hasAnyKey) {
|
|
1653
|
+
log(keepWarmMs > 0
|
|
1654
|
+
? `memoryrouter: keep-warm armed (${Math.round(keepWarmMs / 1000)}s after each message)`
|
|
1655
|
+
: "memoryrouter: keep-warm disabled (keepWarmMs: 0)");
|
|
1222
1656
|
}
|
|
1223
1657
|
},
|
|
1224
1658
|
stop: () => {
|
|
1225
|
-
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
}
|
|
1659
|
+
serviceRunning = false;
|
|
1660
|
+
lastActivityAt = undefined;
|
|
1661
|
+
stopWarmLoop();
|
|
1229
1662
|
api.logger.info?.("memoryrouter: stopped");
|
|
1230
1663
|
},
|
|
1231
1664
|
});
|