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/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
- const config = await readOpenClawConfigFile();
303
- config.agents ??= {};
304
- config.agents.defaults ??= {};
305
- config.agents.defaults.memorySearch ??= {};
306
- if (config.agents.defaults.memorySearch.enabled === enabled) return false; // no-op
307
- config.agents.defaults.memorySearch.enabled = enabled;
308
- await writeOpenClawConfigFile(config);
309
- return true;
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
- try {
317
- const config = await readOpenClawConfigFile();
318
- const ms = config?.agents?.defaults?.memorySearch;
319
- if (ms?.enabled === false) return; // already disabled — nothing to do
320
- const changed = await setNativeMemorySearchEnabled(false);
321
- if (changed) {
322
- api.logger.info?.(
323
- "memoryrouter: disabled native memory search (full-replace). Restart gateway to apply.",
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
- const wrapped = wrapForInjection(data.context);
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
- const wrapped = wrapForInjection(data.context);
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, { query, limit, embeddings });
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) console.log(` • Native memory search disabled — MemoryRouter now owns memory_search`);
975
- } catch (err) {
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) console.log(" • Native memory search restored");
1014
- } catch (err) {
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
- if (hasAnyKey && !warmupTimer) {
1219
- pingWarmup("startup");
1220
- warmupTimer = setInterval(() => pingWarmup("keepalive"), 4 * 60 * 1000);
1221
- warmupTimer.unref?.();
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
- if (warmupTimer) {
1226
- clearInterval(warmupTimer);
1227
- warmupTimer = undefined;
1228
- }
1659
+ serviceRunning = false;
1660
+ lastActivityAt = undefined;
1661
+ stopWarmLoop();
1229
1662
  api.logger.info?.("memoryrouter: stopped");
1230
1663
  },
1231
1664
  });