@nowledge/openclaw-nowledge-mem 0.6.8 → 0.6.9

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/CHANGELOG.md CHANGED
@@ -2,6 +2,16 @@
2
2
 
3
3
  All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
4
4
 
5
+ ## [0.6.9] - 2026-03-05
6
+
7
+ ### Fixed
8
+
9
+ - **Auto-recall searched with full prompt instead of user message**: `event.prompt` contains the entire conversation history (system prompt + all prior turns), making recalled memories irrelevant as conversation grows. Now uses a tiered query strategy: substantial messages (>=40 chars) search alone; short messages (3-39 chars, likely follow-ups like "explain that more") include the last 3 messages as context for topic grounding; tiny messages (<3 chars) skip recall. Falls back to truncated `event.prompt` (500 chars) only when `event.messages` is unavailable.
10
+
11
+ ### Added
12
+
13
+ - **`recallMinScore` config option** (0-100, default 0): Minimum relevance score threshold for auto-recalled memories. Set to e.g. 30 to filter out low-confidence results. Configurable via OpenClaw Config UI, config file, or `NMEM_RECALL_MIN_SCORE` env var.
14
+
5
15
  ## [0.6.8] - 2026-02-27
6
16
 
7
17
  ### Changed
package/CLAUDE.md CHANGED
@@ -91,6 +91,7 @@ Optional config file at `~/.nowledge-mem/openclaw.json`. Falls through to OpenCl
91
91
  | `sessionDigest` | boolean | `true` | `NMEM_SESSION_DIGEST` | Thread capture + LLM distillation at session end |
92
92
  | `digestMinInterval` | integer 0-86400 | `300` | `NMEM_DIGEST_MIN_INTERVAL` | Minimum seconds between session digests |
93
93
  | `maxContextResults` | integer 1-20 | `5` | `NMEM_MAX_CONTEXT_RESULTS` | How many memories to inject at prompt time |
94
+ | `recallMinScore` | integer 0-100 | `0` | `NMEM_RECALL_MIN_SCORE` | Min relevance score (%) to include in auto-recall |
94
95
  | `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
95
96
  | `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
96
97
 
package/README.md CHANGED
@@ -51,6 +51,11 @@ Or use a config file at `~/.nowledge-mem/openclaw.json`:
51
51
 
52
52
  The `apiKey` is injected as `NMEM_API_KEY` into the nmem CLI process. Never passed as a CLI argument, never logged.
53
53
 
54
+ ### Configure via WebUI of OpenClaw
55
+
56
+ <img width="1192" height="2007" alt="openclaw-config" src="https://github.com/user-attachments/assets/a4abd855-db56-4a1f-b0ac-0114909d0c20" />
57
+
58
+
54
59
  ## How It Works
55
60
 
56
61
  ### Per-Turn Flow
@@ -332,6 +337,7 @@ To change settings, use the OpenClaw plugin settings UI. Changes take effect on
332
337
  | `sessionDigest` | boolean | `true` | Thread capture + LLM distillation at session end |
333
338
  | `digestMinInterval` | integer | `300` | Minimum seconds between session digests for the same thread (0-86400) |
334
339
  | `maxContextResults` | integer | `5` | Max memories to inject at prompt time (1-20, only used when sessionContext is enabled) |
340
+ | `recallMinScore` | integer | `0` | Min relevance score (0-100%) to include in auto-recall. 0 = include all |
335
341
  | `apiUrl` | string | `""` | Remote server URL. Empty = local (`http://127.0.0.1:14242`) |
336
342
  | `apiKey` | string | `""` | API key for remote access. Injected as `NMEM_API_KEY` env var, never logged |
337
343
 
@@ -345,6 +351,7 @@ For persistent or scripted config, create `~/.nowledge-mem/openclaw.json`:
345
351
  "sessionDigest": true,
346
352
  "digestMinInterval": 300,
347
353
  "maxContextResults": 5,
354
+ "recallMinScore": 0,
348
355
  "apiUrl": "",
349
356
  "apiKey": ""
350
357
  }
@@ -361,6 +368,7 @@ NMEM_SESSION_CONTEXT=true
361
368
  NMEM_SESSION_DIGEST=true
362
369
  NMEM_DIGEST_MIN_INTERVAL=300
363
370
  NMEM_MAX_CONTEXT_RESULTS=5
371
+ NMEM_RECALL_MIN_SCORE=0
364
372
  NMEM_API_URL=https://...
365
373
  NMEM_API_KEY=your-key
366
374
  ```
@@ -1,15 +1,15 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "kind": "memory",
5
5
  "uiHints": {
6
6
  "sessionContext": {
7
7
  "label": "Session context injection",
8
- "help": "Inject Working Memory and relevant memories at prompt time. Off by default \u2014 the agent can call memory_search on demand instead."
8
+ "help": "Inject Working Memory and relevant memories at prompt time. Off by default; the agent can call memory_search on demand instead."
9
9
  },
10
10
  "sessionDigest": {
11
11
  "label": "Session digest at end",
12
- "help": "Capture conversation threads and distill key memories via LLM at session end. On by default \u2014 negligible cost, one triage call per session."
12
+ "help": "Capture conversation threads and distill key memories via LLM at session end. On by default. Negligible cost, one triage call per session."
13
13
  },
14
14
  "digestMinInterval": {
15
15
  "label": "Minimum digest interval (seconds)",
@@ -17,7 +17,11 @@
17
17
  },
18
18
  "maxContextResults": {
19
19
  "label": "Max context results",
20
- "help": "How many memories to inject for each context cycle (1\u201320)"
20
+ "help": "How many memories to inject for each context cycle (1-20)"
21
+ },
22
+ "recallMinScore": {
23
+ "label": "Min recall score (%)",
24
+ "help": "Only inject memories with relevance score above this threshold (0-100). Set to 0 to include all results."
21
25
  },
22
26
  "apiUrl": {
23
27
  "label": "Server URL (remote mode)",
@@ -25,7 +29,7 @@
25
29
  },
26
30
  "apiKey": {
27
31
  "label": "API key (remote mode)",
28
- "help": "Required when connecting to a remote server. Never logged or passed as a CLI argument \u2014 injected as NMEM_API_KEY env var only.",
32
+ "help": "Required when connecting to a remote server. Never logged or passed as a CLI argument. Injected as NMEM_API_KEY env var only.",
29
33
  "secret": true
30
34
  }
31
35
  },
@@ -35,7 +39,7 @@
35
39
  "sessionContext": {
36
40
  "type": "boolean",
37
41
  "default": false,
38
- "description": "Inject Working Memory and recalled memories at prompt time. Off by default \u2014 the agent queries memory on demand via tools."
42
+ "description": "Inject Working Memory and recalled memories at prompt time. Off by default; the agent queries memory on demand via tools."
39
43
  },
40
44
  "sessionDigest": {
41
45
  "type": "boolean",
@@ -56,6 +60,13 @@
56
60
  "maximum": 20,
57
61
  "description": "Maximum memories to inject at prompt time (only used when sessionContext is enabled)"
58
62
  },
63
+ "recallMinScore": {
64
+ "type": "integer",
65
+ "default": 0,
66
+ "minimum": 0,
67
+ "maximum": 100,
68
+ "description": "Minimum relevance score (0-100%) to include a memory in auto-recall. Set to 0 to include all results."
69
+ },
59
70
  "apiUrl": {
60
71
  "type": "string",
61
72
  "default": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
- "version": "0.6.8",
3
+ "version": "0.6.9",
4
4
  "type": "module",
5
5
  "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
6
6
  "author": {
package/src/config.js CHANGED
@@ -19,6 +19,7 @@ const ALLOWED_KEYS = new Set([
19
19
  "sessionDigest",
20
20
  "digestMinInterval",
21
21
  "maxContextResults",
22
+ "recallMinScore",
22
23
  "apiUrl",
23
24
  "apiKey",
24
25
  // Legacy aliases — accepted but not advertised
@@ -135,7 +136,7 @@ function firstDefined(...options) {
135
136
  * will configure via OpenClaw's plugin settings UI (pluginConfig).
136
137
  *
137
138
  * Canonical keys: sessionContext, sessionDigest, digestMinInterval,
138
- * maxContextResults, apiUrl, apiKey
139
+ * maxContextResults, recallMinScore, apiUrl, apiKey
139
140
  *
140
141
  * Legacy aliases (accepted from all sources; never shown in docs):
141
142
  * autoRecall → sessionContext
@@ -148,6 +149,7 @@ function firstDefined(...options) {
148
149
  * NMEM_SESSION_DIGEST — true/1/yes to enable (alias: NMEM_AUTO_CAPTURE)
149
150
  * NMEM_DIGEST_MIN_INTERVAL — seconds (0-86400)
150
151
  * NMEM_MAX_CONTEXT_RESULTS — integer (1-20)
152
+ * NMEM_RECALL_MIN_SCORE — integer (0-100)
151
153
  * NMEM_API_URL — remote server URL
152
154
  * NMEM_API_KEY — API key (never logged)
153
155
  */
@@ -234,6 +236,20 @@ export function parseConfig(raw, logger) {
234
236
  const maxContextResults = Math.min(20, Math.max(1, Math.trunc(mcr.value)));
235
237
  _sources.maxContextResults = mcr.source;
236
238
 
239
+ // --- recallMinScore: file > pluginConfig > env > default ---
240
+ const rmsEnv = envInt("NMEM_RECALL_MIN_SCORE");
241
+ const rms = firstDefined(
242
+ { value: pickNum(resolvedFile, "recallMinScore"), source: "file" },
243
+ {
244
+ value: pickNum(resolvedPlugin, "recallMinScore"),
245
+ source: "pluginConfig",
246
+ },
247
+ { value: rmsEnv, source: "env" },
248
+ { value: 0, source: "default" },
249
+ );
250
+ const recallMinScore = Math.min(100, Math.max(0, Math.trunc(rms.value)));
251
+ _sources.recallMinScore = rms.source;
252
+
237
253
  // --- apiUrl: file > pluginConfig > env > "" ---
238
254
  const fileUrl =
239
255
  typeof resolvedFile.apiUrl === "string" && resolvedFile.apiUrl.trim();
@@ -267,6 +283,7 @@ export function parseConfig(raw, logger) {
267
283
  sessionDigest,
268
284
  digestMinInterval,
269
285
  maxContextResults,
286
+ recallMinScore,
270
287
  apiUrl,
271
288
  apiKey,
272
289
  _sources,
@@ -250,7 +250,10 @@ function buildConversationText(normalized) {
250
250
  const parts = [];
251
251
  let total = 0;
252
252
  for (const m of normalized) {
253
- const text = truncate(m.fullContent || m.content, MAX_DISTILL_MESSAGE_CHARS);
253
+ const text = truncate(
254
+ m.fullContent || m.content,
255
+ MAX_DISTILL_MESSAGE_CHARS,
256
+ );
254
257
  const line = `${m.role}: ${text}`;
255
258
  if (total + line.length > MAX_CONVERSATION_CHARS) break;
256
259
  parts.push(line);
@@ -297,7 +300,9 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
297
300
 
298
301
  // Skip when no new messages were added (e.g. heartbeat re-sync).
299
302
  if (!result || result.messagesAdded === 0) {
300
- logger.debug?.("capture: no new messages since last sync, skipping triage");
303
+ logger.debug?.(
304
+ "capture: no new messages since last sync, skipping triage",
305
+ );
301
306
  return;
302
307
  }
303
308
 
@@ -13,20 +13,148 @@ function escapeForPrompt(text) {
13
13
  );
14
14
  }
15
15
 
16
+ /** Max query length sent to search — longer messages get truncated. */
17
+ const MAX_QUERY_LENGTH = 500;
18
+
19
+ /**
20
+ * Messages shorter than this get augmented with recent conversational
21
+ * context. Below this threshold, a message is likely referential
22
+ * ("explain that more", "what about the second point?") and needs
23
+ * surrounding context to produce useful search results.
24
+ *
25
+ * Messages at or above this threshold are substantial enough to
26
+ * search on their own ("openviking 不好用", "how do I deploy to k8s?").
27
+ */
28
+ const SHORT_QUERY_THRESHOLD = 40;
29
+
30
+ /** How many recent messages to include for short-query context. */
31
+ const CONTEXT_MESSAGES = 3;
32
+
33
+ /** Per-message cap when building context window. */
34
+ const CONTEXT_MSG_MAX_CHARS = 150;
35
+
36
+ /**
37
+ * Extract text from message content (string or structured blocks).
38
+ * Mirrors the pattern from capture.js.
39
+ */
40
+ function extractText(content) {
41
+ if (typeof content === "string") return content.trim();
42
+ if (!Array.isArray(content)) return "";
43
+ const parts = [];
44
+ for (const block of content) {
45
+ if (!block || typeof block !== "object") continue;
46
+ if (block.type === "text" && typeof block.text === "string") {
47
+ const text = block.text.trim();
48
+ if (text) parts.push(text);
49
+ }
50
+ }
51
+ return parts.join("\n").trim();
52
+ }
53
+
54
+ /**
55
+ * Normalize a raw message entry to { role, text }.
56
+ * Handles both flat { role, content } and nested { message: { role, content } }.
57
+ * Returns null for non-user/assistant messages, empty content, or slash commands.
58
+ */
59
+ function normalizeMessage(raw) {
60
+ if (!raw || typeof raw !== "object") return null;
61
+ const msg =
62
+ raw.message && typeof raw.message === "object" ? raw.message : raw;
63
+ const role = typeof msg.role === "string" ? msg.role : "";
64
+ if (role !== "user" && role !== "assistant") return null;
65
+ const text = extractText(msg.content);
66
+ if (!text) return null;
67
+ // Skip slash commands — they're plugin directives, not conversational
68
+ if (role === "user" && text.startsWith("/")) return null;
69
+ return { role, text };
70
+ }
71
+
72
+ /**
73
+ * Build the search query from the hook event.
74
+ *
75
+ * Three tiers:
76
+ * >= 40 chars : latest user message alone (self-contained)
77
+ * 3-39 chars : latest user message + recent context (likely referential)
78
+ * < 3 chars : skip recall (too short — "ok", emoji, etc.)
79
+ *
80
+ * The event object provides:
81
+ * - event.messages: structured array of {role, content} messages (preferred)
82
+ * - event.prompt: the full formatted prompt (fallback, truncated)
83
+ */
84
+ function buildSearchQuery(event) {
85
+ const messages = event?.messages;
86
+
87
+ if (Array.isArray(messages) && messages.length > 0) {
88
+ // Find the latest user message (walk backwards)
89
+ let latestUserIdx = -1;
90
+ let latestUserText = "";
91
+ for (let i = messages.length - 1; i >= 0; i--) {
92
+ const norm = normalizeMessage(messages[i]);
93
+ if (norm && norm.role === "user") {
94
+ latestUserIdx = i;
95
+ latestUserText = norm.text;
96
+ break;
97
+ }
98
+ }
99
+
100
+ if (!latestUserText || latestUserText.length < 3) return "";
101
+
102
+ // Substantial message — use it alone
103
+ if (latestUserText.length >= SHORT_QUERY_THRESHOLD) {
104
+ return latestUserText.slice(0, MAX_QUERY_LENGTH);
105
+ }
106
+
107
+ // Short message — likely a follow-up. Add recent context so
108
+ // the search engine sees what topic the conversation is about.
109
+ // Collect up to CONTEXT_MESSAGES messages preceding the latest user
110
+ // message (both user and assistant, for topic grounding).
111
+ const contextParts = [];
112
+ const scanFrom = Math.max(0, latestUserIdx - CONTEXT_MESSAGES);
113
+ for (let i = scanFrom; i < latestUserIdx; i++) {
114
+ const norm = normalizeMessage(messages[i]);
115
+ if (!norm) continue;
116
+ const truncated =
117
+ norm.text.length > CONTEXT_MSG_MAX_CHARS
118
+ ? `${norm.text.slice(0, CONTEXT_MSG_MAX_CHARS)}…`
119
+ : norm.text;
120
+ contextParts.push(truncated);
121
+ }
122
+
123
+ if (contextParts.length > 0) {
124
+ // Latest message first (primary intent), then context
125
+ const combined = `${latestUserText}\n\n${contextParts.join("\n")}`;
126
+ return combined.slice(0, MAX_QUERY_LENGTH);
127
+ }
128
+
129
+ // No prior context available (first message) — use as-is
130
+ return latestUserText;
131
+ }
132
+
133
+ // Fallback: use event.prompt but truncate aggressively.
134
+ // This path only fires when OpenClaw doesn't provide event.messages.
135
+ if (typeof event?.prompt === "string" && event.prompt.length >= 5) {
136
+ return event.prompt.slice(0, MAX_QUERY_LENGTH);
137
+ }
138
+
139
+ return "";
140
+ }
141
+
16
142
  /**
17
- * Builds the before_agent_start hook handler.
143
+ * Builds the before_prompt_build hook handler.
18
144
  *
19
- * Injects two layers of context at session start:
145
+ * Injects two layers of context at prompt time:
20
146
  * 1. Working Memory — today's focus, priorities, unresolved flags
21
- * 2. Relevant memories — with types, labels, and source provenance
147
+ * 2. Relevant memories — searched using the user's latest message
22
148
  *
23
149
  * Tool guidance is minimal — the agent already sees full tool descriptions
24
150
  * in its tool list. We only add a brief behavioral note.
25
151
  */
26
152
  export function buildRecallHandler(client, cfg, logger) {
153
+ const minScore = (cfg.recallMinScore ?? 0) / 100; // config is 0-100, API is 0-1
154
+
27
155
  return async (event) => {
28
- const prompt = event.prompt;
29
- if (!prompt || prompt.length < 5) return;
156
+ const searchQuery = buildSearchQuery(event);
157
+ if (!searchQuery) return;
30
158
 
31
159
  const sections = [];
32
160
 
@@ -44,9 +172,17 @@ export function buildRecallHandler(client, cfg, logger) {
44
172
 
45
173
  // 2. Relevant memories — enriched with scoring signals and labels
46
174
  try {
47
- const results = await client.searchRich(prompt, cfg.maxContextResults);
48
- if (results.length > 0) {
49
- const lines = results.map((r) => {
175
+ const results = await client.searchRich(
176
+ searchQuery,
177
+ cfg.maxContextResults,
178
+ );
179
+ // Filter by minimum score if configured
180
+ const filtered =
181
+ minScore > 0
182
+ ? results.filter((r) => (r.score ?? 0) >= minScore)
183
+ : results;
184
+ if (filtered.length > 0) {
185
+ const lines = filtered.map((r) => {
50
186
  const title = r.title || "(untitled)";
51
187
  const score = `${(r.score * 100).toFixed(0)}%`;
52
188
  const labels =
@@ -82,7 +218,9 @@ export function buildRecallHandler(client, cfg, logger) {
82
218
  "</nowledge-mem>",
83
219
  ].join("\n");
84
220
 
85
- logger.debug?.(`recall: injecting ${context.length} chars`);
221
+ logger.debug?.(
222
+ `recall: injecting ${context.length} chars (query: ${searchQuery.slice(0, 80)}…)`,
223
+ );
86
224
  return { prependContext: context };
87
225
  };
88
226
  }
@@ -293,9 +293,7 @@ export function createConnectionsTool(client, logger) {
293
293
  const otherTitle = isOlderNode
294
294
  ? edge.newer_title || "(untitled)"
295
295
  : edge.older_title || "(untitled)";
296
- const otherId = isOlderNode
297
- ? edge.newer_id
298
- : edge.older_id;
296
+ const otherId = isOlderNode ? edge.newer_id : edge.older_id;
299
297
  const direction = isOlderNode ? "→" : "←";
300
298
  const idLine = otherId ? `\n → id: ${otherId}` : "";
301
299
  return ` ${direction} ${otherTitle}${relLabel ? ` — ${relLabel}` : ""}${idLine}`;
@@ -106,6 +106,9 @@ export function createStatusTool(client, _logger, cfg) {
106
106
  lines.push(
107
107
  ` maxContextResults: ${cfg.maxContextResults} (${sources.maxContextResults || "?"})`,
108
108
  );
109
+ lines.push(
110
+ ` recallMinScore: ${cfg.recallMinScore}% (${sources.recallMinScore || "?"})`,
111
+ );
109
112
 
110
113
  details.config = {
111
114
  sessionContext: {
@@ -124,6 +127,10 @@ export function createStatusTool(client, _logger, cfg) {
124
127
  value: cfg.maxContextResults,
125
128
  source: sources.maxContextResults,
126
129
  },
130
+ recallMinScore: {
131
+ value: cfg.recallMinScore,
132
+ source: sources.recallMinScore,
133
+ },
127
134
  apiUrl: { value: cfg.apiUrl || "(local)", source: sources.apiUrl },
128
135
  apiKey: {
129
136
  value: cfg.apiKey ? "(set)" : "(not set)",