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.ts CHANGED
@@ -9,7 +9,7 @@
9
9
  */
10
10
 
11
11
  import { readFile, readdir, stat, lstat, writeFile, copyFile, mkdir } from "node:fs/promises";
12
- import { deleteMemories, getMemoryStats, ingestMemory, prepareMemory, searchMemory, warmupMemory } from "./memoryrouter-api.js";
12
+ import { commitReflections, consolidateCheckout, dateSearch, deleteMemories, deleteMemoryIds, getMemoryStats, ingestMemory, inspectMemory, prepareMemory, searchMemory, warmupMemory } from "./memoryrouter-api.js";
13
13
  import { join, resolve, relative, isAbsolute, sep, dirname } from "node:path";
14
14
  import path from "node:path";
15
15
  import os from "node:os";
@@ -17,6 +17,16 @@ import type { OpenClawPluginApi } from "openclaw/plugin-sdk";
17
17
 
18
18
  const DEFAULT_ENDPOINT = "https://api.memoryrouter.ai";
19
19
 
20
+ /** Keep-warm defaults.
21
+ *
22
+ * Warmth only matters in the minutes right after real activity, so warmup
23
+ * pings are activity-scoped instead of always-on: every message opens a
24
+ * KEEP_WARM window, pings fire at KEEP_WARM_PING_INTERVAL_MS inside it, and
25
+ * the timer is cleared completely once the window closes. An idle gateway
26
+ * sends zero warmup traffic. */
27
+ const DEFAULT_KEEP_WARM_MS = 3 * 60 * 1000; // 3 minutes after the last message
28
+ const KEEP_WARM_PING_INTERVAL_MS = 60 * 1000; // cadence inside the window
29
+
20
30
  /** Strip media-attached references and OpenClaw media instruction text so
21
31
  * old screenshots/photos/audio don't get stored and re-injected forever. */
22
32
  const MEDIA_ATTACHED_RE = /\[media attached[^\]]*\]/gi;
@@ -55,6 +65,27 @@ function jsonToolResult(payload: unknown) {
55
65
  return { content: [{ type: "text" as const, text: JSON.stringify(payload, null, 2) }], details: payload };
56
66
  }
57
67
 
68
+ // ── Reflection debt nudge (rate-limited, one line, appended to prepare injections)
69
+ type ReflectionDebt = {
70
+ suggestion?: string | null;
71
+ unconsolidated?: {
72
+ raw?: { units?: number; est_tokens?: number };
73
+ tier2?: { count?: number; importance?: number; est_tokens?: number };
74
+ };
75
+ threshold_tokens?: number;
76
+ };
77
+
78
+ const NUDGE_INTERVAL_MS = 24 * 60 * 60 * 1000; // at most one nudge per 24h
79
+
80
+ function buildDebtNudgeLine(reflection: ReflectionDebt | undefined): string | null {
81
+ if (!reflection || !reflection.suggestion) return null;
82
+ const rawTokens = reflection.unconsolidated?.raw?.est_tokens ?? 0;
83
+ const tier2Tokens = reflection.unconsolidated?.tier2?.est_tokens ?? 0;
84
+ const totalTokens = rawTokens + tier2Tokens;
85
+ const kTokens = Math.max(1, Math.round(totalTokens / 1000));
86
+ return `[MemoryRouter] Consolidation available: ${kTokens}k unconsolidated tokens. Use the memory_reflect tool when convenient.`;
87
+ }
88
+
58
89
  // ── memory_get helpers (copied from OpenClaw source — pure filesystem, no embeddings)
59
90
  function isFileMissingError(err: unknown): boolean {
60
91
  return Boolean(err && typeof err === "object" && "code" in err && (err as any).code === "ENOENT");
@@ -282,6 +313,9 @@ type MemoryRouterConfig = {
282
313
  logging?: boolean;
283
314
  embeddings?: string;
284
315
  agentKeys?: Record<string, string>;
316
+ /** How long to keep the vault warm after the last message, in ms.
317
+ * Default 180000 (3 minutes). Set 0 to disable warmup pings entirely. */
318
+ keepWarmMs?: number;
285
319
  };
286
320
 
287
321
  // ──────────────────────────────────────────────────────
@@ -320,6 +354,36 @@ async function writeOpenClawConfigFile(config: JsonObject): Promise<void> {
320
354
  `, "utf8");
321
355
  }
322
356
 
357
+ // Nudge rate-limit state lives beside the OpenClaw config so it survives restarts
358
+ // without churning the main config file.
359
+ function nudgeStatePath(): string {
360
+ return join(dirname(openClawConfigPath()), "mr-memory-state.json");
361
+ }
362
+
363
+ async function consumeNudgeBudget(): Promise<boolean> {
364
+ const now = Date.now();
365
+ let state: { lastNudgeAt?: number } = {};
366
+ try {
367
+ state = JSON.parse(await readFile(nudgeStatePath(), "utf8"));
368
+ } catch { /* missing or unreadable state: treat as never nudged */ }
369
+ if (typeof state.lastNudgeAt === "number" && now - state.lastNudgeAt < NUDGE_INTERVAL_MS) {
370
+ return false;
371
+ }
372
+ try {
373
+ await writeFile(nudgeStatePath(), `${JSON.stringify({ ...state, lastNudgeAt: now }, null, 2)}\n`, "utf8");
374
+ } catch { /* state write failure must never break prepare */ }
375
+ return true;
376
+ }
377
+
378
+ /** One-line, rate-limited debt nudge for prepare injections. Returns null when
379
+ * there is no suggestion or the 24h budget is spent. */
380
+ async function maybeDebtNudge(reflection: ReflectionDebt | undefined): Promise<string | null> {
381
+ const line = buildDebtNudgeLine(reflection);
382
+ if (!line) return null;
383
+ if (!(await consumeNudgeBudget())) return null;
384
+ return line;
385
+ }
386
+
323
387
  // ──────────────────────────────────────────────────────
324
388
  // Full-replace: disable OpenClaw's native memory search so MemoryRouter
325
389
  // owns the memory_search tool (no name conflict, no native embeddings 429).
@@ -501,7 +565,18 @@ const memoryRouterPlugin = {
501
565
  }
502
566
 
503
567
  const hasAnyKey = !!(memoryKey || (agentKeys && Object.keys(agentKeys).length > 0));
568
+
569
+ // Keep-warm window: 0 (or negative) disables warmup pings entirely.
570
+ const keepWarmMs = typeof cfg?.keepWarmMs === "number" && cfg.keepWarmMs >= 0
571
+ ? cfg.keepWarmMs
572
+ : DEFAULT_KEEP_WARM_MS;
504
573
  let warmupTimer: ReturnType<typeof setInterval> | undefined;
574
+ // undefined = no activity yet (never compare against 0; a 0 timestamp is a
575
+ // valid clock value and must not read as "no activity").
576
+ let lastActivityAt: number | undefined;
577
+ // Starts true so keep-warm works even if the host never calls service.start();
578
+ // service.stop() flips it off so no timer can outlive the plugin.
579
+ let serviceRunning = true;
505
580
 
506
581
  function configuredKeys(): string[] {
507
582
  const keys = new Set<string>();
@@ -531,6 +606,41 @@ const memoryRouterPlugin = {
531
606
  }
532
607
  }
533
608
 
609
+ function stopWarmLoop(): void {
610
+ if (warmupTimer) {
611
+ clearInterval(warmupTimer);
612
+ warmupTimer = undefined;
613
+ }
614
+ }
615
+
616
+ /** True while we're still inside the post-message keep-warm window. */
617
+ function withinWarmWindow(): boolean {
618
+ if (keepWarmMs <= 0 || lastActivityAt === undefined) return false;
619
+ return Date.now() - lastActivityAt <= keepWarmMs;
620
+ }
621
+
622
+ /** Called on every message-driven activity (prepare / ingest).
623
+ * Opens (or extends) the keep-warm window and makes sure the loop runs.
624
+ * Once the window lapses with no new activity the interval is cleared, so
625
+ * an idle gateway sends no warmup traffic at all. */
626
+ function noteActivity(): void {
627
+ if (!hasAnyKey || keepWarmMs <= 0 || !serviceRunning) return;
628
+ lastActivityAt = Date.now();
629
+ if (warmupTimer) return; // window extended; existing loop keeps running
630
+ // No immediate ping: the prepare/ingest request that triggered this call
631
+ // already warmed the vault server-side. Pings only cover the idle gaps
632
+ // inside the window.
633
+ warmupTimer = setInterval(() => {
634
+ if (!withinWarmWindow()) {
635
+ log("memoryrouter: keep-warm window elapsed — pings stopped until next message");
636
+ stopWarmLoop();
637
+ return;
638
+ }
639
+ pingWarmup("keepalive");
640
+ }, KEEP_WARM_PING_INTERVAL_MS);
641
+ warmupTimer.unref?.();
642
+ }
643
+
534
644
  void (async () => {
535
645
  await ensureDoctorSafePluginEntry(api);
536
646
  if (hasAnyKey) await selfHealNativeMemorySearch(api);
@@ -684,6 +794,9 @@ If setting up MemoryRouter for another agent:
684
794
  }
685
795
 
686
796
 
797
+ // Message activity — open/extend the keep-warm window.
798
+ noteActivity();
799
+
687
800
  const res = await prepareMemory(endpoint, activeKey, {
688
801
  messages: contextPayload,
689
802
  density,
@@ -706,13 +819,17 @@ If setting up MemoryRouter for another agent:
706
819
  tokens_billed?: number;
707
820
  memory_tokens?: number;
708
821
  metrics?: Record<string, unknown>;
822
+ reflection?: ReflectionDebt;
709
823
  };
710
824
 
711
825
  if (data.context) {
712
826
  const elapsed = Date.now() - startMs;
713
827
  api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms) [llm_input]`);
714
828
  if (elapsed > 2000 && data.metrics) log(`memoryrouter: prepare metrics ${JSON.stringify(data.metrics)}`);
715
- const wrapped = wrapForInjection(data.context);
829
+ let contextText = data.context;
830
+ const nudge = await maybeDebtNudge(data.reflection);
831
+ if (nudge) contextText = `${contextText}\n\n${nudge}`;
832
+ const wrapped = wrapForInjection(contextText);
716
833
  return { appendSystemContext: wrapped };
717
834
  }
718
835
  } catch {
@@ -795,6 +912,9 @@ If setting up MemoryRouter for another agent:
795
912
 
796
913
  // 4. Call /v1/memory/prepare (no session_id — always search core vault)
797
914
 
915
+ // Message activity — open/extend the keep-warm window.
916
+ noteActivity();
917
+
798
918
  const res = await prepareMemory(endpoint, activeKey, {
799
919
  messages: contextPayload,
800
920
  density,
@@ -820,13 +940,17 @@ If setting up MemoryRouter for another agent:
820
940
  tokens_billed?: number;
821
941
  memory_tokens?: number;
822
942
  metrics?: Record<string, unknown>;
943
+ reflection?: ReflectionDebt;
823
944
  };
824
945
 
825
946
  if (data.context) {
826
947
  const elapsed = Date.now() - startMs;
827
948
  api.logger.info?.(`memoryrouter: injected ${data.memories_found || 0} memories (${data.memory_tokens || 0} tokens, ${elapsed}ms)`);
828
949
  if (elapsed > 2000 && data.metrics) log(`memoryrouter: prepare metrics ${JSON.stringify(data.metrics)}`);
829
- const wrapped = wrapForInjection(data.context);
950
+ let contextText = data.context;
951
+ const nudge = await maybeDebtNudge(data.reflection);
952
+ if (nudge) contextText = `${contextText}\n\n${nudge}`;
953
+ const wrapped = wrapForInjection(contextText);
830
954
  return { appendSystemContext: wrapped };
831
955
  }
832
956
  } catch (err) {
@@ -914,6 +1038,9 @@ If setting up MemoryRouter for another agent:
914
1038
  return;
915
1039
  }
916
1040
 
1041
+ // Message activity — open/extend the keep-warm window.
1042
+ noteActivity();
1043
+
917
1044
  // Fire and forget — don't block message delivery waiting for ingest
918
1045
  ingestMemory(endpoint, activeKey, {
919
1046
  messages: toStore,
@@ -957,6 +1084,17 @@ If setting up MemoryRouter for another agent:
957
1084
  query: { type: "string" },
958
1085
  maxResults: { type: "number" },
959
1086
  minScore: { type: "number" },
1087
+ tiers: {
1088
+ type: "array",
1089
+ items: { type: "integer", enum: [1, 2, 3] },
1090
+ description: "Restrict results to these memory tiers. 1 raw memories, 2 reflections, 3 high-level reflections. Omit to search all tiers blended.",
1091
+ },
1092
+ importance: {
1093
+ type: "integer",
1094
+ minimum: 1,
1095
+ maximum: 10,
1096
+ 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.",
1097
+ },
960
1098
  },
961
1099
  required: ["query"],
962
1100
  } as any,
@@ -965,14 +1103,26 @@ If setting up MemoryRouter for another agent:
965
1103
  const query = typeof params.query === "string" ? params.query.trim() : "";
966
1104
  if (!query) return jsonToolResult({ results: [], error: "query required" });
967
1105
  const limit = typeof params.maxResults === "number" ? params.maxResults : 50;
1106
+ const tiers = Array.isArray(params.tiers)
1107
+ ? (params.tiers as unknown[]).filter((t): t is number => typeof t === "number" && [1, 2, 3].includes(t))
1108
+ : undefined;
1109
+ const importance = typeof params.importance === "number" && params.importance >= 1 && params.importance <= 10
1110
+ ? Math.floor(params.importance)
1111
+ : undefined;
968
1112
  try {
969
- const res = await searchMemory(endpoint, toolKey, { query, limit, embeddings });
1113
+ const res = await searchMemory(endpoint, toolKey, {
1114
+ query,
1115
+ limit,
1116
+ ...(tiers && tiers.length > 0 && { tiers }),
1117
+ ...(importance !== undefined && { importance }),
1118
+ embeddings,
1119
+ });
970
1120
  if (!res.ok) {
971
1121
  const errBody = await res.text().catch(() => "");
972
1122
  return jsonToolResult({ results: [], error: `Search failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
973
1123
  }
974
1124
  const data = await res.json() as {
975
- memories?: Array<{ content: string; score: number; role: string; window: string; timestampHuman: string }>;
1125
+ memories?: Array<{ content: string; score: number; role: string; window: string; timestampHuman: string; tier?: number; importance?: number }>;
976
1126
  totalMemories?: number;
977
1127
  tokenCount?: number;
978
1128
  };
@@ -984,6 +1134,8 @@ If setting up MemoryRouter for another agent:
984
1134
  role: m.role,
985
1135
  window: m.window,
986
1136
  date: m.timestampHuman,
1137
+ ...(m.tier !== undefined && { tier: m.tier }),
1138
+ ...(m.importance !== undefined && m.importance !== null && { importance: m.importance }),
987
1139
  }));
988
1140
  return jsonToolResult({
989
1141
  results,
@@ -999,6 +1151,211 @@ If setting up MemoryRouter for another agent:
999
1151
  };
1000
1152
  });
1001
1153
 
1154
+ // ==================================================================
1155
+ // Tools: memory_reflect + memory_commit (consolidation, two-tool shape)
1156
+ // Mirrors the MCP consolidate_memories / commit_reflections pair.
1157
+ // Invariants: source_ids are stripped, the model never sees row IDs,
1158
+ // and contract instructions are transmitted verbatim.
1159
+ // ==================================================================
1160
+
1161
+ // memory_reflect: checkout wrapper (nothing is modified until memory_commit)
1162
+ api.registerTool((ctx) => {
1163
+ const toolKey = resolveKey(ctx);
1164
+ return {
1165
+ label: "Memory Reflect",
1166
+ name: "memory_reflect",
1167
+ 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.",
1168
+ parameters: {
1169
+ type: "object",
1170
+ properties: {
1171
+ tier: {
1172
+ type: "integer",
1173
+ enum: [1, 2],
1174
+ description: "1 consolidates raw memories into reflections; 2 consolidates reflections into high-level reflections. Default 1.",
1175
+ },
1176
+ max_tokens: {
1177
+ type: "integer",
1178
+ minimum: 1000,
1179
+ description: "Token budget for the checked-out batch. Defaults to 50000. No connector-side cap.",
1180
+ },
1181
+ },
1182
+ required: [],
1183
+ } as any,
1184
+ execute: async (_toolCallId: string, params: Record<string, unknown>) => {
1185
+ if (!toolKey) return jsonToolResult({ error: "No memory key configured for this agent" });
1186
+ const tier = params.tier === 2 ? 2 : 1;
1187
+ const maxTokens = typeof params.max_tokens === "number" && params.max_tokens >= 1000
1188
+ ? Math.floor(params.max_tokens)
1189
+ : 50000;
1190
+ try {
1191
+ const res = await consolidateCheckout(endpoint, toolKey, {
1192
+ tier: tier as 1 | 2,
1193
+ max_tokens: maxTokens,
1194
+ ...(embeddings && { embeddings }),
1195
+ });
1196
+ if (!res.ok) {
1197
+ const errBody = await res.text().catch(() => "");
1198
+ return jsonToolResult({ error: `Checkout failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
1199
+ }
1200
+ const data = await res.json() as {
1201
+ batch_id?: string | null;
1202
+ tier?: number;
1203
+ count?: number;
1204
+ est_tokens?: number;
1205
+ lease_expires_at?: string | null;
1206
+ memories?: Array<{ text: string; date: string }>;
1207
+ reflection_contract?: { version?: number; instructions?: string };
1208
+ tier_definitions?: { target_tier?: number; instruction?: string };
1209
+ };
1210
+ if (!data.batch_id) {
1211
+ return jsonToolResult({ batch_id: null, count: 0, message: "Nothing to consolidate at this tier right now." });
1212
+ }
1213
+ // Contract instructions are server-authored and passed through verbatim.
1214
+ // If neither contract form is present, refuse; never author a local prompt.
1215
+ const instructions = data.reflection_contract?.instructions ?? data.tier_definitions?.instruction;
1216
+ if (!instructions) {
1217
+ return jsonToolResult({ error: "Checkout response carried no reflection contract. Cannot consolidate without server instructions." });
1218
+ }
1219
+ // source_ids stripped: the model never sees memory row IDs.
1220
+ return jsonToolResult({
1221
+ batch_id: data.batch_id,
1222
+ lease_expires_at: data.lease_expires_at ?? null,
1223
+ count: data.count ?? 0,
1224
+ est_tokens: data.est_tokens ?? 0,
1225
+ instructions,
1226
+ memories: (data.memories || []).map((m) => ({ text: m.text, date: m.date })),
1227
+ message: `When your entries are ready, call memory_commit with batch_id ${data.batch_id} before ${data.lease_expires_at ?? "the lease expires"}.`,
1228
+ });
1229
+ } catch (err) {
1230
+ return jsonToolResult({ error: err instanceof Error ? err.message : String(err) });
1231
+ }
1232
+ },
1233
+ };
1234
+ });
1235
+
1236
+ // memory_commit: commit wrapper (idempotent; 409 means the lease expired)
1237
+ api.registerTool((ctx) => {
1238
+ const toolKey = resolveKey(ctx);
1239
+ return {
1240
+ label: "Memory Commit",
1241
+ name: "memory_commit",
1242
+ 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.",
1243
+ parameters: {
1244
+ type: "object",
1245
+ properties: {
1246
+ batch_id: { type: "string", description: "The batch_id returned by memory_reflect." },
1247
+ entries: {
1248
+ type: "array",
1249
+ items: {
1250
+ type: "object",
1251
+ properties: {
1252
+ text: { type: "string", description: "Standalone reflection prose, 1 to 8000 chars. No IDs or scores inline." },
1253
+ importance: { type: "integer", minimum: 1, maximum: 10 },
1254
+ },
1255
+ required: ["text", "importance"],
1256
+ additionalProperties: false,
1257
+ },
1258
+ minItems: 1,
1259
+ maxItems: 100,
1260
+ },
1261
+ },
1262
+ required: ["batch_id", "entries"],
1263
+ } as any,
1264
+ execute: async (_toolCallId: string, params: Record<string, unknown>) => {
1265
+ if (!toolKey) return jsonToolResult({ error: "No memory key configured for this agent" });
1266
+ const batchId = typeof params.batch_id === "string" ? params.batch_id.trim() : "";
1267
+ if (!batchId) return jsonToolResult({ error: "batch_id required" });
1268
+ const rawEntries = Array.isArray(params.entries) ? params.entries as Array<Record<string, unknown>> : [];
1269
+ if (rawEntries.length < 1 || rawEntries.length > 100) {
1270
+ return jsonToolResult({ error: "entries must contain between 1 and 100 items" });
1271
+ }
1272
+ const entries: Array<{ text: string; importance: number }> = [];
1273
+ for (let i = 0; i < rawEntries.length; i++) {
1274
+ const e = rawEntries[i];
1275
+ if (!e || typeof e !== "object") return jsonToolResult({ error: `entry ${i + 1} is not an object` });
1276
+ const extraKeys = Object.keys(e).filter((k) => k !== "text" && k !== "importance");
1277
+ if (extraKeys.length > 0) {
1278
+ // Position-reference impossibility: entries are text plus importance only.
1279
+ return jsonToolResult({ error: `entry ${i + 1} has unsupported fields: ${extraKeys.join(", ")}. Entries are text plus importance only; coverage is always the whole batch.` });
1280
+ }
1281
+ const text = typeof e.text === "string" ? e.text : "";
1282
+ if (!text || text.length > 8000) return jsonToolResult({ error: `entry ${i + 1} text must be 1 to 8000 chars` });
1283
+ const importance = typeof e.importance === "number" && Number.isInteger(e.importance) && e.importance >= 1 && e.importance <= 10
1284
+ ? e.importance
1285
+ : null;
1286
+ if (importance === null) return jsonToolResult({ error: `entry ${i + 1} importance must be an integer 1 to 10` });
1287
+ entries.push({ text, importance });
1288
+ }
1289
+ try {
1290
+ const res = await commitReflections(endpoint, toolKey, {
1291
+ batch_id: batchId,
1292
+ entries,
1293
+ ...(embeddings && { embeddings }),
1294
+ });
1295
+ if (res.status === 409) {
1296
+ return jsonToolResult({ error: "lease expired, call memory_reflect again" });
1297
+ }
1298
+ if (!res.ok) {
1299
+ const errBody = await res.text().catch(() => "");
1300
+ return jsonToolResult({ error: `Commit failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
1301
+ }
1302
+ const data = await res.json() as {
1303
+ committed?: boolean;
1304
+ already_committed?: boolean;
1305
+ created?: Array<{ id: string; importance: number }>;
1306
+ sources_consolidated?: number;
1307
+ reflection?: ReflectionDebt;
1308
+ };
1309
+ // Created UUIDs dropped: the model never sees memory row IDs.
1310
+ return jsonToolResult({
1311
+ committed: data.committed ?? false,
1312
+ already_committed: data.already_committed ?? false,
1313
+ entries_created: data.created?.length ?? 0,
1314
+ sources_consolidated: data.sources_consolidated ?? 0,
1315
+ debt: data.reflection?.suggestion ?? null,
1316
+ });
1317
+ } catch (err) {
1318
+ return jsonToolResult({ error: err instanceof Error ? err.message : String(err) });
1319
+ }
1320
+ },
1321
+ };
1322
+ });
1323
+
1324
+ // memory_inspect: read-only lineage drill-down (curation surface, ids allowed)
1325
+ api.registerTool((ctx) => {
1326
+ const toolKey = resolveKey(ctx);
1327
+ return {
1328
+ label: "Memory Inspect",
1329
+ name: "memory_inspect",
1330
+ 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.",
1331
+ parameters: {
1332
+ type: "object",
1333
+ properties: {
1334
+ memory_id: { type: "string", description: "The memory id to inspect." },
1335
+ },
1336
+ required: ["memory_id"],
1337
+ } as any,
1338
+ execute: async (_toolCallId: string, params: Record<string, unknown>) => {
1339
+ if (!toolKey) return jsonToolResult({ error: "No memory key configured for this agent" });
1340
+ const memoryId = typeof params.memory_id === "string" ? params.memory_id.trim() : "";
1341
+ if (!memoryId) return jsonToolResult({ error: "memory_id required" });
1342
+ try {
1343
+ const res = await inspectMemory(endpoint, toolKey, {
1344
+ memory_id: memoryId,
1345
+ ...(embeddings && { embeddings }),
1346
+ });
1347
+ if (!res.ok) {
1348
+ const errBody = await res.text().catch(() => "");
1349
+ return jsonToolResult({ error: `Inspect failed: HTTP ${res.status}`, details: errBody.slice(0, 200) });
1350
+ }
1351
+ return jsonToolResult(await res.json());
1352
+ } catch (err) {
1353
+ return jsonToolResult({ error: err instanceof Error ? err.message : String(err) });
1354
+ }
1355
+ },
1356
+ };
1357
+ });
1358
+
1002
1359
  // memory_get — exact copy of OpenClaw's implementation (pure local file read)
1003
1360
  api.registerTool((ctx) => {
1004
1361
  const workspaceDir = ctx.workspaceDir || "";
@@ -1285,11 +1642,36 @@ If setting up MemoryRouter for another agent:
1285
1642
  });
1286
1643
 
1287
1644
  mr.command("delete")
1288
- .description("Clear all memories from vault")
1645
+ .description("Clear all memories from vault, or delete specific memories with --ids")
1646
+ .option("--ids <ids>", "Comma-separated memory ids to delete (instead of the whole vault)")
1647
+ .option("--yes", "Confirm deletion (required with --ids)")
1289
1648
  .option("--key <key>", "Override memory key (for subagent vaults)")
1290
- .action(async (opts: { key?: string }) => {
1649
+ .action(async (opts: { ids?: string; yes?: boolean; key?: string }) => {
1291
1650
  const effectiveKey = opts.key || memoryKey;
1292
1651
  if (!effectiveKey) { console.error("Not configured. Run: openclaw mr <key> or pass --key <key>"); return; }
1652
+ if (opts.ids) {
1653
+ const ids = opts.ids.split(",").map((s) => s.trim()).filter(Boolean);
1654
+ if (ids.length === 0) { console.error("No ids provided."); return; }
1655
+ console.log(`The following ${ids.length} memor${ids.length === 1 ? "y" : "ies"} will be PERMANENTLY deleted:`);
1656
+ for (const id of ids) console.log(` • ${id}`);
1657
+ if (!opts.yes) {
1658
+ console.error("\nRefusing to delete without --yes. Re-run with --yes to confirm.");
1659
+ return;
1660
+ }
1661
+ try {
1662
+ const res = await deleteMemoryIds(endpoint, effectiveKey, { ids, ...(embeddings && { embeddings }) });
1663
+ if (!res.ok) {
1664
+ const errBody = await res.text().catch(() => "");
1665
+ console.error(`Delete failed: HTTP ${res.status}${errBody ? `: ${errBody.slice(0, 200)}` : ""}`);
1666
+ return;
1667
+ }
1668
+ const data = (await res.json()) as { requested?: number; deleted?: number; not_found?: number };
1669
+ console.log(`✓ Deleted ${data.deleted ?? 0} of ${data.requested ?? ids.length} (not found: ${data.not_found ?? 0})`);
1670
+ } catch (err) {
1671
+ console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
1672
+ }
1673
+ return;
1674
+ }
1293
1675
  try {
1294
1676
  const res = await deleteMemories(endpoint, effectiveKey, embeddings);
1295
1677
  const data = (await res.json()) as { message?: string };
@@ -1299,6 +1681,42 @@ If setting up MemoryRouter for another agent:
1299
1681
  console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
1300
1682
  }
1301
1683
  });
1684
+
1685
+ mr.command("reflect")
1686
+ .description("Show reflection debt status (consolidation runs via the agent's memory_reflect tool)")
1687
+ .option("--key <key>", "Override memory key (for subagent vaults)")
1688
+ .action(async (opts: { key?: string }) => {
1689
+ const effectiveKey = opts.key || memoryKey;
1690
+ if (!effectiveKey) { console.error("Not configured. Run: openclaw mr <key> or pass --key <key>"); return; }
1691
+ try {
1692
+ // Debt probe: an empty-window date-search returns the reflection
1693
+ // debt block with zero results and zero retrieval billing.
1694
+ const from = new Date(Date.now() - 1000).toISOString();
1695
+ const res = await dateSearch(endpoint, effectiveKey, { from, max_tokens: 1000, ...(embeddings && { embeddings }) });
1696
+ if (!res.ok) {
1697
+ const errBody = await res.text().catch(() => "");
1698
+ console.error(`Debt check failed: HTTP ${res.status}${errBody ? `: ${errBody.slice(0, 200)}` : ""}`);
1699
+ return;
1700
+ }
1701
+ const data = (await res.json()) as { reflection?: ReflectionDebt };
1702
+ const debt = data.reflection;
1703
+ console.log("Reflection Debt");
1704
+ console.log("───────────────────────────");
1705
+ const raw = debt?.unconsolidated?.raw;
1706
+ const tier2 = debt?.unconsolidated?.tier2;
1707
+ console.log(`Raw unconsolidated: ${raw?.units ?? 0} memories (~${raw?.est_tokens ?? 0} tokens)`);
1708
+ console.log(`Tier 2 unconsolidated: ${tier2?.count ?? 0} reflections (~${tier2?.est_tokens ?? 0} tokens)`);
1709
+ console.log(`Threshold: ${debt?.threshold_tokens ?? 0} tokens`);
1710
+ if (debt?.suggestion) {
1711
+ console.log(`\nStatus: ${debt.suggestion}`);
1712
+ console.log("Consolidation runs through the agent. Ask your agent to consolidate memories (it uses the memory_reflect and memory_commit tools).");
1713
+ } else {
1714
+ console.log("\nStatus: nothing to consolidate right now.");
1715
+ }
1716
+ } catch (err) {
1717
+ console.error(`Failed: ${err instanceof Error ? err.message : String(err)}`);
1718
+ }
1719
+ });
1302
1720
  },
1303
1721
  { commands: ["mr"] },
1304
1722
  );
@@ -1311,17 +1729,19 @@ If setting up MemoryRouter for another agent:
1311
1729
  id: "mr-memory",
1312
1730
  start: () => {
1313
1731
  if (memoryKey) api.logger.info?.(`memoryrouter: active (key: ${memoryKey.slice(0, 6)}...)`);
1314
- if (hasAnyKey && !warmupTimer) {
1315
- pingWarmup("startup");
1316
- warmupTimer = setInterval(() => pingWarmup("keepalive"), 4 * 60 * 1000);
1317
- warmupTimer.unref?.();
1732
+ serviceRunning = true;
1733
+ // No startup ping and no always-on keepalive: warmth is driven by real
1734
+ // activity. The first message opens the keep-warm window.
1735
+ if (hasAnyKey) {
1736
+ log(keepWarmMs > 0
1737
+ ? `memoryrouter: keep-warm armed (${Math.round(keepWarmMs / 1000)}s after each message)`
1738
+ : "memoryrouter: keep-warm disabled (keepWarmMs: 0)");
1318
1739
  }
1319
1740
  },
1320
1741
  stop: () => {
1321
- if (warmupTimer) {
1322
- clearInterval(warmupTimer);
1323
- warmupTimer = undefined;
1324
- }
1742
+ serviceRunning = false;
1743
+ lastActivityAt = undefined;
1744
+ stopWarmLoop();
1325
1745
  api.logger.info?.("memoryrouter: stopped");
1326
1746
  },
1327
1747
  });