@nowledge/openclaw-nowledge-mem 0.6.8 → 0.6.12

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.
@@ -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
  }
package/src/index.js CHANGED
@@ -33,7 +33,7 @@ export default {
33
33
  register(api) {
34
34
  const logger = api.logger;
35
35
  const cfg = parseConfig(api.pluginConfig, logger);
36
- const client = new NowledgeMemClient(logger, {
36
+ const client = new NowledgeMemClient(logger, api.runtime.system, {
37
37
  apiUrl: cfg.apiUrl,
38
38
  apiKey: cfg.apiKey,
39
39
  });
@@ -0,0 +1,19 @@
1
+ const LOCAL_API_URL = "http://127.0.0.1:14242";
2
+
3
+ /**
4
+ * Build the child env for nmem CLI execution.
5
+ * apiKey stays in env only and is never passed as a CLI flag.
6
+ * Clear inherited NMEM_* values first so the resolved plugin config wins.
7
+ */
8
+ export function buildNmemSpawnEnv({ apiUrl, apiKey } = {}) {
9
+ const env = { ...process.env };
10
+ env.NMEM_API_URL = undefined;
11
+ env.NMEM_API_KEY = undefined;
12
+ if (apiUrl && apiUrl !== LOCAL_API_URL) {
13
+ env.NMEM_API_URL = apiUrl;
14
+ }
15
+ if (apiKey) {
16
+ env.NMEM_API_KEY = apiKey;
17
+ }
18
+ return env;
19
+ }
@@ -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}`;
@@ -46,7 +46,7 @@ export function createForgetTool(client, logger) {
46
46
  // Mode 1: Direct delete by ID
47
47
  if (memoryId) {
48
48
  try {
49
- client.exec(["--json", "m", "delete", "-f", memoryId]);
49
+ await client.exec(["--json", "m", "delete", "-f", memoryId]);
50
50
  logger.info(`forget: deleted memory ${memoryId}`);
51
51
  return {
52
52
  content: [
@@ -92,7 +92,7 @@ export function createForgetTool(client, logger) {
92
92
  if (results.length === 1 && results[0].score >= 0.85) {
93
93
  const target = results[0];
94
94
  try {
95
- client.exec(["--json", "m", "delete", "-f", target.id]);
95
+ await client.exec(["--json", "m", "delete", "-f", target.id]);
96
96
  logger.info(`forget: deleted memory ${target.id} via search`);
97
97
  return {
98
98
  content: [
package/src/tools/save.js CHANGED
@@ -169,7 +169,7 @@ export function createSaveTool(client, logger) {
169
169
  if (eventEnd) args.push("--event-end", eventEnd);
170
170
  if (temporalContext) args.push("--when", temporalContext);
171
171
 
172
- const data = client.execJson(args);
172
+ const data = await client.execJson(args);
173
173
  const id = String(
174
174
  data.id ?? data.memory?.id ?? data.memory_id ?? "created",
175
175
  );
@@ -37,7 +37,7 @@ export function createStatusTool(client, _logger, cfg) {
37
37
 
38
38
  // 2. CLI resolution
39
39
  try {
40
- const cmd = client.resolveCommand();
40
+ const cmd = await client.resolveCommand();
41
41
  details.cli = cmd.join(" ");
42
42
  lines.push(`CLI: ${cmd.join(" ")}`);
43
43
  } catch {
@@ -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)",