@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.
- package/CHANGELOG.md +40 -0
- package/CLAUDE.md +18 -8
- package/README.md +67 -15
- package/SKILL.md +245 -0
- package/openclaw.plugin.json +18 -6
- package/package.json +11 -2
- package/skills/memory-guide/SKILL.md +65 -0
- package/src/client.js +105 -73
- package/src/commands/slash.js +2 -2
- package/src/config.js +18 -1
- package/src/hooks/behavioral.js +11 -11
- package/src/hooks/capture.js +7 -2
- package/src/hooks/recall.js +147 -9
- package/src/index.js +1 -1
- package/src/spawn-env.js +19 -0
- package/src/tools/connections.js +1 -3
- package/src/tools/forget.js +2 -2
- package/src/tools/save.js +1 -1
- package/src/tools/status.js +8 -1
package/src/hooks/recall.js
CHANGED
|
@@ -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
|
|
143
|
+
* Builds the before_prompt_build hook handler.
|
|
18
144
|
*
|
|
19
|
-
* Injects two layers of context at
|
|
145
|
+
* Injects two layers of context at prompt time:
|
|
20
146
|
* 1. Working Memory — today's focus, priorities, unresolved flags
|
|
21
|
-
* 2. Relevant memories —
|
|
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
|
|
29
|
-
if (!
|
|
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(
|
|
48
|
-
|
|
49
|
-
|
|
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?.(
|
|
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
|
});
|
package/src/spawn-env.js
ADDED
|
@@ -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
|
+
}
|
package/src/tools/connections.js
CHANGED
|
@@ -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}`;
|
package/src/tools/forget.js
CHANGED
|
@@ -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
|
);
|
package/src/tools/status.js
CHANGED
|
@@ -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)",
|