@nowledge/openclaw-nowledge-mem 0.6.2 → 0.6.6
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 +135 -37
- package/CLAUDE.md +72 -52
- package/README.md +240 -45
- package/openclaw.plugin.json +23 -23
- package/package.json +1 -1
- package/src/client.js +182 -0
- package/src/config.js +234 -29
- package/src/hooks/behavioral.js +43 -0
- package/src/hooks/capture.js +3 -3
- package/src/hooks/recall.js +1 -1
- package/src/index.js +19 -6
- package/src/tools/forget.js +4 -3
- package/src/tools/memory-get.js +19 -9
- package/src/tools/memory-search.js +38 -9
- package/src/tools/save.js +33 -0
- package/src/tools/thread-fetch.js +106 -0
- package/src/tools/thread-search.js +108 -0
package/src/config.js
CHANGED
|
@@ -1,3 +1,7 @@
|
|
|
1
|
+
import { readFileSync, writeFileSync, mkdirSync, existsSync } from "node:fs";
|
|
2
|
+
import { homedir } from "node:os";
|
|
3
|
+
import { join } from "node:path";
|
|
4
|
+
|
|
1
5
|
export const API_DEFAULT_URL = "http://127.0.0.1:14242";
|
|
2
6
|
|
|
3
7
|
/**
|
|
@@ -9,56 +13,257 @@ export function isDefaultApiUrl(url) {
|
|
|
9
13
|
return !trimmed || trimmed === API_DEFAULT_URL;
|
|
10
14
|
}
|
|
11
15
|
|
|
16
|
+
// Canonical config keys (new names).
|
|
12
17
|
const ALLOWED_KEYS = new Set([
|
|
18
|
+
"sessionContext",
|
|
19
|
+
"sessionDigest",
|
|
20
|
+
"digestMinInterval",
|
|
21
|
+
"maxContextResults",
|
|
22
|
+
"apiUrl",
|
|
23
|
+
"apiKey",
|
|
24
|
+
// Legacy aliases — accepted but not advertised
|
|
13
25
|
"autoRecall",
|
|
14
26
|
"autoCapture",
|
|
15
27
|
"captureMinInterval",
|
|
16
28
|
"maxRecallResults",
|
|
17
|
-
"apiUrl",
|
|
18
|
-
"apiKey",
|
|
19
29
|
]);
|
|
20
30
|
|
|
21
|
-
|
|
22
|
-
|
|
31
|
+
// Backward-compatible aliases (old names → new names).
|
|
32
|
+
// Accepted silently so existing users' configs keep working.
|
|
33
|
+
const ALIAS_KEYS = {
|
|
34
|
+
autoRecall: "sessionContext",
|
|
35
|
+
autoCapture: "sessionDigest",
|
|
36
|
+
captureMinInterval: "digestMinInterval",
|
|
37
|
+
maxRecallResults: "maxContextResults",
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
// --- config file ---------------------------------------------------------
|
|
41
|
+
|
|
42
|
+
const CONFIG_DIR = join(homedir(), ".nowledge-mem");
|
|
43
|
+
const CONFIG_PATH = join(CONFIG_DIR, "openclaw.json");
|
|
44
|
+
|
|
45
|
+
const DEFAULT_CONFIG = {
|
|
46
|
+
sessionContext: false,
|
|
47
|
+
sessionDigest: true,
|
|
48
|
+
digestMinInterval: 300,
|
|
49
|
+
maxContextResults: 5,
|
|
50
|
+
apiUrl: "",
|
|
51
|
+
apiKey: "",
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Read ~/.nowledge-mem/openclaw.json.
|
|
56
|
+
* On first run, creates the file seeded with values from pluginConfig
|
|
57
|
+
* (if any) — this migrates settings before OpenClaw strips them.
|
|
58
|
+
* Returns {} on any error — never crashes.
|
|
59
|
+
*/
|
|
60
|
+
function readConfigFile(logger, pluginCfg) {
|
|
61
|
+
try {
|
|
62
|
+
if (!existsSync(CONFIG_PATH)) {
|
|
63
|
+
// Seed from pluginConfig + env vars for migration.
|
|
64
|
+
// Users on older OpenClaw get their values written to the file
|
|
65
|
+
// BEFORE a future OpenClaw update can strip them.
|
|
66
|
+
const initial = { ...DEFAULT_CONFIG };
|
|
67
|
+
if (pluginCfg && typeof pluginCfg === "object") {
|
|
68
|
+
// Resolve aliases in pluginConfig for seeding
|
|
69
|
+
const resolved = { ...pluginCfg };
|
|
70
|
+
for (const [oldKey, newKey] of Object.entries(ALIAS_KEYS)) {
|
|
71
|
+
if (oldKey in resolved) {
|
|
72
|
+
if (!(newKey in resolved)) {
|
|
73
|
+
resolved[newKey] = resolved[oldKey];
|
|
74
|
+
}
|
|
75
|
+
delete resolved[oldKey];
|
|
76
|
+
}
|
|
77
|
+
}
|
|
23
78
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
79
|
+
if (typeof resolved.sessionContext === "boolean")
|
|
80
|
+
initial.sessionContext = resolved.sessionContext;
|
|
81
|
+
if (typeof resolved.sessionDigest === "boolean")
|
|
82
|
+
initial.sessionDigest = resolved.sessionDigest;
|
|
83
|
+
if (
|
|
84
|
+
typeof resolved.digestMinInterval === "number" &&
|
|
85
|
+
Number.isFinite(resolved.digestMinInterval)
|
|
86
|
+
)
|
|
87
|
+
initial.digestMinInterval = resolved.digestMinInterval;
|
|
88
|
+
if (
|
|
89
|
+
typeof resolved.maxContextResults === "number" &&
|
|
90
|
+
Number.isFinite(resolved.maxContextResults)
|
|
91
|
+
)
|
|
92
|
+
initial.maxContextResults = resolved.maxContextResults;
|
|
93
|
+
if (typeof resolved.apiUrl === "string" && resolved.apiUrl.trim())
|
|
94
|
+
initial.apiUrl = resolved.apiUrl.trim();
|
|
95
|
+
// apiKey intentionally NOT written to disk — keep in env/pluginConfig only
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
99
|
+
writeFileSync(
|
|
100
|
+
CONFIG_PATH,
|
|
101
|
+
JSON.stringify(initial, null, 2) + "\n",
|
|
102
|
+
"utf-8",
|
|
103
|
+
);
|
|
104
|
+
logger?.info?.(
|
|
105
|
+
`nowledge-mem: created config at ${CONFIG_PATH} — edit to customize`,
|
|
106
|
+
);
|
|
107
|
+
return { ...initial };
|
|
108
|
+
}
|
|
109
|
+
const content = readFileSync(CONFIG_PATH, "utf-8").trim();
|
|
110
|
+
if (!content) return {};
|
|
111
|
+
return JSON.parse(content);
|
|
112
|
+
} catch (err) {
|
|
113
|
+
logger?.warn?.(
|
|
114
|
+
`nowledge-mem: could not read ${CONFIG_PATH}: ${err instanceof Error ? err.message : err}`,
|
|
115
|
+
);
|
|
116
|
+
return {};
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/** Exported for tests / CLI tooling. */
|
|
121
|
+
export { CONFIG_PATH };
|
|
122
|
+
|
|
123
|
+
// --- env var helpers -----------------------------------------------------
|
|
124
|
+
|
|
125
|
+
function envStr(name) {
|
|
126
|
+
const v = process.env[name];
|
|
127
|
+
return typeof v === "string" ? v.trim() : undefined;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
function envBool(name, fallbackName) {
|
|
131
|
+
const v = envStr(name) ?? envStr(fallbackName);
|
|
132
|
+
if (v === undefined) return undefined;
|
|
133
|
+
return v === "true" || v === "1" || v === "yes";
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function envInt(name) {
|
|
137
|
+
const v = envStr(name);
|
|
138
|
+
if (v === undefined) return undefined;
|
|
139
|
+
const n = Number.parseInt(v, 10);
|
|
140
|
+
return Number.isFinite(n) ? n : undefined;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// --- helpers to read a boolean/number from resolved sources --------------
|
|
144
|
+
|
|
145
|
+
function pickBool(obj, key) {
|
|
146
|
+
if (typeof obj[key] === "boolean") return obj[key];
|
|
147
|
+
return undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
function pickNum(obj, key) {
|
|
151
|
+
if (typeof obj[key] === "number" && Number.isFinite(obj[key])) return obj[key];
|
|
152
|
+
return undefined;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Resolve legacy aliases in an object: old names → new names. */
|
|
156
|
+
function resolveAliases(obj) {
|
|
157
|
+
const resolved = { ...obj };
|
|
158
|
+
for (const [oldKey, newKey] of Object.entries(ALIAS_KEYS)) {
|
|
159
|
+
if (oldKey in resolved) {
|
|
160
|
+
if (!(newKey in resolved)) {
|
|
161
|
+
resolved[newKey] = resolved[oldKey];
|
|
162
|
+
}
|
|
163
|
+
delete resolved[oldKey];
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return resolved;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// -------------------------------------------------------------------------
|
|
170
|
+
|
|
171
|
+
/**
|
|
172
|
+
* Parse plugin config with cascade:
|
|
173
|
+
* pluginConfig > ~/.nowledge-mem/openclaw.json > env vars > defaults
|
|
174
|
+
*
|
|
175
|
+
* Canonical keys: sessionContext, sessionDigest, digestMinInterval,
|
|
176
|
+
* maxContextResults, apiUrl, apiKey
|
|
177
|
+
*
|
|
178
|
+
* Legacy aliases (accepted from all sources; never shown in docs):
|
|
179
|
+
* autoRecall → sessionContext
|
|
180
|
+
* autoCapture → sessionDigest
|
|
181
|
+
* captureMinInterval → digestMinInterval
|
|
182
|
+
* maxRecallResults → maxContextResults
|
|
183
|
+
*
|
|
184
|
+
* Environment variables (all optional):
|
|
185
|
+
* NMEM_SESSION_CONTEXT — true/1/yes to enable (alias: NMEM_AUTO_RECALL)
|
|
186
|
+
* NMEM_SESSION_DIGEST — true/1/yes to enable (alias: NMEM_AUTO_CAPTURE)
|
|
187
|
+
* NMEM_DIGEST_MIN_INTERVAL — seconds (0–86400)
|
|
188
|
+
* NMEM_MAX_CONTEXT_RESULTS — integer (1–20)
|
|
189
|
+
* NMEM_API_URL — remote server URL
|
|
190
|
+
* NMEM_API_KEY — API key (never logged)
|
|
191
|
+
*/
|
|
192
|
+
export function parseConfig(raw, logger) {
|
|
193
|
+
const pluginCfg = raw && typeof raw === "object" ? raw : {};
|
|
194
|
+
const fileCfg = readConfigFile(logger, pluginCfg);
|
|
195
|
+
|
|
196
|
+
// Resolve aliases in both sources
|
|
197
|
+
const resolvedPlugin = resolveAliases(pluginCfg);
|
|
198
|
+
const resolvedFile = resolveAliases(fileCfg);
|
|
199
|
+
|
|
200
|
+
// Strict: reject unknown keys in pluginConfig (typo catcher).
|
|
201
|
+
// File config is our own — validated by shape, no strict check needed.
|
|
202
|
+
const unknownKeys = Object.keys(resolvedPlugin).filter(
|
|
203
|
+
(k) => !ALLOWED_KEYS.has(k),
|
|
204
|
+
);
|
|
28
205
|
if (unknownKeys.length > 0) {
|
|
29
206
|
throw new Error(
|
|
30
207
|
`nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}. ` +
|
|
31
|
-
`Allowed keys: ${[...ALLOWED_KEYS].join(", ")}`,
|
|
208
|
+
`Allowed keys: ${[...ALLOWED_KEYS].filter((k) => !Object.keys(ALIAS_KEYS).includes(k)).join(", ")}`,
|
|
32
209
|
);
|
|
33
210
|
}
|
|
34
211
|
|
|
35
|
-
//
|
|
212
|
+
// --- sessionContext ---
|
|
213
|
+
const sessionContext =
|
|
214
|
+
pickBool(resolvedPlugin, "sessionContext") ??
|
|
215
|
+
pickBool(resolvedFile, "sessionContext") ??
|
|
216
|
+
envBool("NMEM_SESSION_CONTEXT", "NMEM_AUTO_RECALL") ??
|
|
217
|
+
false;
|
|
218
|
+
|
|
219
|
+
// --- sessionDigest ---
|
|
220
|
+
const sessionDigest =
|
|
221
|
+
pickBool(resolvedPlugin, "sessionDigest") ??
|
|
222
|
+
pickBool(resolvedFile, "sessionDigest") ??
|
|
223
|
+
envBool("NMEM_SESSION_DIGEST", "NMEM_AUTO_CAPTURE") ??
|
|
224
|
+
true;
|
|
225
|
+
|
|
226
|
+
// --- digestMinInterval ---
|
|
227
|
+
const rawInterval =
|
|
228
|
+
pickNum(resolvedPlugin, "digestMinInterval") ??
|
|
229
|
+
pickNum(resolvedFile, "digestMinInterval") ??
|
|
230
|
+
envInt("NMEM_DIGEST_MIN_INTERVAL") ??
|
|
231
|
+
envInt("NMEM_CAPTURE_MIN_INTERVAL");
|
|
232
|
+
const digestMinInterval =
|
|
233
|
+
rawInterval !== undefined
|
|
234
|
+
? Math.min(86400, Math.max(0, Math.trunc(rawInterval)))
|
|
235
|
+
: 300;
|
|
236
|
+
|
|
237
|
+
// --- maxContextResults ---
|
|
238
|
+
const rawMax =
|
|
239
|
+
pickNum(resolvedPlugin, "maxContextResults") ??
|
|
240
|
+
pickNum(resolvedFile, "maxContextResults") ??
|
|
241
|
+
envInt("NMEM_MAX_CONTEXT_RESULTS") ??
|
|
242
|
+
envInt("NMEM_MAX_RECALL_RESULTS");
|
|
243
|
+
const maxContextResults =
|
|
244
|
+
rawMax !== undefined
|
|
245
|
+
? Math.min(20, Math.max(1, Math.trunc(rawMax)))
|
|
246
|
+
: 5;
|
|
247
|
+
|
|
248
|
+
// --- apiUrl: pluginConfig > file > env > "" ---
|
|
36
249
|
const apiUrl =
|
|
37
|
-
(typeof
|
|
38
|
-
(typeof
|
|
39
|
-
|
|
250
|
+
(typeof resolvedPlugin.apiUrl === "string" && resolvedPlugin.apiUrl.trim()) ||
|
|
251
|
+
(typeof resolvedFile.apiUrl === "string" && resolvedFile.apiUrl.trim()) ||
|
|
252
|
+
envStr("NMEM_API_URL") ||
|
|
40
253
|
"";
|
|
41
254
|
|
|
42
|
-
// apiKey:
|
|
255
|
+
// --- apiKey: pluginConfig > file > env > "" ---
|
|
43
256
|
const apiKey =
|
|
44
|
-
(typeof
|
|
45
|
-
(typeof
|
|
46
|
-
|
|
257
|
+
(typeof resolvedPlugin.apiKey === "string" && resolvedPlugin.apiKey.trim()) ||
|
|
258
|
+
(typeof resolvedFile.apiKey === "string" && resolvedFile.apiKey.trim()) ||
|
|
259
|
+
envStr("NMEM_API_KEY") ||
|
|
47
260
|
"";
|
|
48
261
|
|
|
49
262
|
return {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
Number.isFinite(obj.captureMinInterval)
|
|
55
|
-
? Math.min(86400, Math.max(0, Math.trunc(obj.captureMinInterval)))
|
|
56
|
-
: 300,
|
|
57
|
-
maxRecallResults:
|
|
58
|
-
typeof obj.maxRecallResults === "number" &&
|
|
59
|
-
Number.isFinite(obj.maxRecallResults)
|
|
60
|
-
? Math.min(20, Math.max(1, Math.trunc(obj.maxRecallResults)))
|
|
61
|
-
: 5,
|
|
263
|
+
sessionContext,
|
|
264
|
+
sessionDigest,
|
|
265
|
+
digestMinInterval,
|
|
266
|
+
maxContextResults,
|
|
62
267
|
apiUrl,
|
|
63
268
|
apiKey,
|
|
64
269
|
};
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Always-on behavioral hook — injected via before_prompt_build on EVERY turn.
|
|
3
|
+
*
|
|
4
|
+
* This is the single most impactful change for memory adoption:
|
|
5
|
+
* most LLMs ignore "call this proactively" in tool descriptions,
|
|
6
|
+
* but reliably follow behavioral guidance in prepended context.
|
|
7
|
+
*
|
|
8
|
+
* Cost: ~50 tokens per turn. Negligible.
|
|
9
|
+
*
|
|
10
|
+
* When sessionContext is enabled, guidance is adjusted to note that
|
|
11
|
+
* relevant memories are already injected — reducing redundant searches
|
|
12
|
+
* while keeping the save nudge and thread awareness.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
const BASE_GUIDANCE = [
|
|
16
|
+
"<nowledge-mem-guidance>",
|
|
17
|
+
"You have access to the user's personal knowledge graph (Nowledge Mem).",
|
|
18
|
+
"When the conversation produces something worth keeping — a decision made, a preference stated,",
|
|
19
|
+
"something learned, a plan formed, a useful discovery — save it with nowledge_mem_save. Don't wait to be asked.",
|
|
20
|
+
"When prior context would improve your response, search with memory_search.",
|
|
21
|
+
"memory_search also returns relevant past conversation snippets alongside memories.",
|
|
22
|
+
"When a memory has a sourceThreadId, fetch the full conversation with nowledge_mem_thread_fetch.",
|
|
23
|
+
"</nowledge-mem-guidance>",
|
|
24
|
+
].join("\n");
|
|
25
|
+
|
|
26
|
+
const SESSION_CONTEXT_GUIDANCE = [
|
|
27
|
+
"<nowledge-mem-guidance>",
|
|
28
|
+
"You have access to the user's personal knowledge graph (Nowledge Mem).",
|
|
29
|
+
"Relevant memories and your Working Memory have already been injected into this prompt.",
|
|
30
|
+
"Use memory_search only when you need something specific beyond what was auto-recalled.",
|
|
31
|
+
"When the conversation produces something worth keeping — a decision made, a preference stated,",
|
|
32
|
+
"something learned, a plan formed, a useful discovery — save it with nowledge_mem_save. Don't wait to be asked.",
|
|
33
|
+
"When a memory has a sourceThreadId, fetch the full conversation with nowledge_mem_thread_fetch.",
|
|
34
|
+
"</nowledge-mem-guidance>",
|
|
35
|
+
].join("\n");
|
|
36
|
+
|
|
37
|
+
export function buildBehavioralHook(logger, { sessionContext = false } = {}) {
|
|
38
|
+
const guidance = sessionContext ? SESSION_CONTEXT_GUIDANCE : BASE_GUIDANCE;
|
|
39
|
+
return (_event, _ctx) => {
|
|
40
|
+
logger.debug?.("behavioral: injecting guidance");
|
|
41
|
+
return { prependContext: guidance };
|
|
42
|
+
};
|
|
43
|
+
}
|
package/src/hooks/capture.js
CHANGED
|
@@ -274,7 +274,7 @@ function buildConversationText(normalized) {
|
|
|
274
274
|
* triage or distillation, since those are mid-session checkpoints.
|
|
275
275
|
*/
|
|
276
276
|
export function buildAgentEndCaptureHandler(client, cfg, logger) {
|
|
277
|
-
const cooldownMs = (cfg.
|
|
277
|
+
const cooldownMs = (cfg.digestMinInterval ?? 300) * 1000;
|
|
278
278
|
|
|
279
279
|
return async (event, ctx) => {
|
|
280
280
|
if (!event?.success) return;
|
|
@@ -291,9 +291,9 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
|
|
|
291
291
|
});
|
|
292
292
|
|
|
293
293
|
// 2. Triage + distill: language-agnostic LLM-based capture.
|
|
294
|
-
// Defensive guard
|
|
294
|
+
// Defensive guard - registration in index.js already gates on sessionDigest,
|
|
295
295
|
// but check here too so the handler is safe if called directly.
|
|
296
|
-
if (!cfg.
|
|
296
|
+
if (!cfg.sessionDigest) return;
|
|
297
297
|
|
|
298
298
|
// Skip when no new messages were added (e.g. heartbeat re-sync).
|
|
299
299
|
if (!result || result.messagesAdded === 0) {
|
package/src/hooks/recall.js
CHANGED
|
@@ -44,7 +44,7 @@ export function buildRecallHandler(client, cfg, logger) {
|
|
|
44
44
|
|
|
45
45
|
// 2. Relevant memories — enriched with scoring signals and labels
|
|
46
46
|
try {
|
|
47
|
-
const results = await client.searchRich(prompt, cfg.
|
|
47
|
+
const results = await client.searchRich(prompt, cfg.maxContextResults);
|
|
48
48
|
if (results.length > 0) {
|
|
49
49
|
const lines = results.map((r) => {
|
|
50
50
|
const title = r.title || "(untitled)";
|
package/src/index.js
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
createRememberCommand,
|
|
7
7
|
} from "./commands/slash.js";
|
|
8
8
|
import { isDefaultApiUrl, parseConfig } from "./config.js";
|
|
9
|
+
import { buildBehavioralHook } from "./hooks/behavioral.js";
|
|
9
10
|
import {
|
|
10
11
|
buildAgentEndCaptureHandler,
|
|
11
12
|
buildBeforeResetCaptureHandler,
|
|
@@ -17,6 +18,8 @@ import { createForgetTool } from "./tools/forget.js";
|
|
|
17
18
|
import { createMemoryGetTool } from "./tools/memory-get.js";
|
|
18
19
|
import { createMemorySearchTool } from "./tools/memory-search.js";
|
|
19
20
|
import { createSaveTool } from "./tools/save.js";
|
|
21
|
+
import { createThreadFetchTool } from "./tools/thread-fetch.js";
|
|
22
|
+
import { createThreadSearchTool } from "./tools/thread-search.js";
|
|
20
23
|
import { createTimelineTool } from "./tools/timeline.js";
|
|
21
24
|
|
|
22
25
|
export default {
|
|
@@ -27,8 +30,8 @@ export default {
|
|
|
27
30
|
kind: "memory",
|
|
28
31
|
|
|
29
32
|
register(api) {
|
|
30
|
-
const cfg = parseConfig(api.pluginConfig);
|
|
31
33
|
const logger = api.logger;
|
|
34
|
+
const cfg = parseConfig(api.pluginConfig, logger);
|
|
32
35
|
const client = new NowledgeMemClient(logger, {
|
|
33
36
|
apiUrl: cfg.apiUrl,
|
|
34
37
|
apiKey: cfg.apiKey,
|
|
@@ -45,12 +48,22 @@ export default {
|
|
|
45
48
|
api.registerTool(createTimelineTool(client, logger));
|
|
46
49
|
api.registerTool(createForgetTool(client, logger));
|
|
47
50
|
|
|
48
|
-
//
|
|
49
|
-
|
|
50
|
-
|
|
51
|
+
// Thread tools (progressive conversation retrieval)
|
|
52
|
+
api.registerTool(createThreadSearchTool(client, logger));
|
|
53
|
+
api.registerTool(createThreadFetchTool(client, logger));
|
|
54
|
+
|
|
55
|
+
// Always-on: behavioral guidance so the agent proactively saves and searches.
|
|
56
|
+
// Fires every turn via before_prompt_build — ~50 tokens, negligible cost.
|
|
57
|
+
// When sessionContext is on, guidance adjusts to avoid redundant searches.
|
|
58
|
+
api.on("before_prompt_build", buildBehavioralHook(logger, cfg));
|
|
59
|
+
|
|
60
|
+
// Session context: inject Working Memory + recalled memories at prompt time.
|
|
61
|
+
if (cfg.sessionContext) {
|
|
62
|
+
api.on("before_prompt_build", buildRecallHandler(client, cfg, logger));
|
|
51
63
|
}
|
|
52
64
|
|
|
53
|
-
|
|
65
|
+
// Session digest: capture threads + LLM distillation at lifecycle events.
|
|
66
|
+
if (cfg.sessionDigest) {
|
|
54
67
|
const threadCaptureHandler = buildBeforeResetCaptureHandler(
|
|
55
68
|
client,
|
|
56
69
|
cfg,
|
|
@@ -75,7 +88,7 @@ export default {
|
|
|
75
88
|
|
|
76
89
|
const remoteMode = !isDefaultApiUrl(cfg.apiUrl);
|
|
77
90
|
logger.info(
|
|
78
|
-
`nowledge-mem: initialized (
|
|
91
|
+
`nowledge-mem: initialized (context=${cfg.sessionContext}, digest=${cfg.sessionDigest}, mode=${remoteMode ? `remote → ${cfg.apiUrl}` : "local"})`,
|
|
79
92
|
);
|
|
80
93
|
},
|
|
81
94
|
};
|
package/src/tools/forget.js
CHANGED
|
@@ -87,8 +87,9 @@ export function createForgetTool(client, logger) {
|
|
|
87
87
|
};
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
//
|
|
91
|
-
|
|
90
|
+
// Auto-delete only when exactly one result with high confidence.
|
|
91
|
+
// Deletion is irreversible — if multiple matches exist, always ask.
|
|
92
|
+
if (results.length === 1 && results[0].score >= 0.85) {
|
|
92
93
|
const target = results[0];
|
|
93
94
|
try {
|
|
94
95
|
client.exec(["--json", "m", "delete", "-f", target.id]);
|
|
@@ -115,7 +116,7 @@ export function createForgetTool(client, logger) {
|
|
|
115
116
|
}
|
|
116
117
|
}
|
|
117
118
|
|
|
118
|
-
// Multiple candidates — return list for user to pick
|
|
119
|
+
// Multiple candidates or low confidence — return list for user to pick
|
|
119
120
|
const candidates = results.map(
|
|
120
121
|
(r, i) =>
|
|
121
122
|
`${i + 1}. ${r.title || "(untitled)"} (${(r.score * 100).toFixed(0)}%) — id: ${r.id}\n ${r.content.slice(0, 100)}`,
|
package/src/tools/memory-get.js
CHANGED
|
@@ -135,19 +135,29 @@ export function createMemoryGetTool(client, logger) {
|
|
|
135
135
|
safeParams.lines,
|
|
136
136
|
);
|
|
137
137
|
|
|
138
|
+
// Thread provenance: link to the source conversation
|
|
139
|
+
const sourceThreadId =
|
|
140
|
+
memory.source_thread ??
|
|
141
|
+
memory.source_thread_id ??
|
|
142
|
+
memory.metadata?.source_thread_id ??
|
|
143
|
+
null;
|
|
144
|
+
|
|
145
|
+
const result = {
|
|
146
|
+
path,
|
|
147
|
+
memoryId,
|
|
148
|
+
title: memory.title || "(untitled)",
|
|
149
|
+
text: snippet.text,
|
|
150
|
+
startLine: snippet.startLine,
|
|
151
|
+
endLine: snippet.endLine,
|
|
152
|
+
totalLines: snippet.totalLines,
|
|
153
|
+
};
|
|
154
|
+
if (sourceThreadId) result.sourceThreadId = sourceThreadId;
|
|
155
|
+
|
|
138
156
|
return {
|
|
139
157
|
content: [
|
|
140
158
|
{
|
|
141
159
|
type: "text",
|
|
142
|
-
text: JSON.stringify(
|
|
143
|
-
path,
|
|
144
|
-
memoryId,
|
|
145
|
-
title: memory.title || "(untitled)",
|
|
146
|
-
text: snippet.text,
|
|
147
|
-
startLine: snippet.startLine,
|
|
148
|
-
endLine: snippet.endLine,
|
|
149
|
-
totalLines: snippet.totalLines,
|
|
150
|
-
}),
|
|
160
|
+
text: JSON.stringify(result),
|
|
151
161
|
},
|
|
152
162
|
],
|
|
153
163
|
};
|
|
@@ -19,6 +19,7 @@ export function createMemorySearchTool(client, logger) {
|
|
|
19
19
|
"Don't wait to be asked — if the conversation topic might have relevant history, search for it. " +
|
|
20
20
|
"Uses multi-signal scoring: semantic, BM25 keyword, label, graph & community signals, recency/importance decay. " +
|
|
21
21
|
"Returns snippets with memoryIds — pass a memoryId to nowledge_mem_connections for cross-topic synthesis or source provenance. " +
|
|
22
|
+
"Also returns relevant past conversation snippets (relatedThreads) when available. " +
|
|
22
23
|
"Supports bi-temporal filtering: event_date_from/to (when the fact HAPPENED), recorded_date_from/to (when it was SAVED). " +
|
|
23
24
|
"For browsing recent activity by day use nowledge_mem_timeline instead.",
|
|
24
25
|
parameters: {
|
|
@@ -140,22 +141,50 @@ export function createMemorySearchTool(client, logger) {
|
|
|
140
141
|
if (entry.eventEnd) result.eventEnd = entry.eventEnd;
|
|
141
142
|
if (entry.temporalContext)
|
|
142
143
|
result.temporalContext = entry.temporalContext;
|
|
144
|
+
// Thread provenance: link to the source conversation
|
|
145
|
+
if (entry.sourceThreadId)
|
|
146
|
+
result.sourceThreadId = entry.sourceThreadId;
|
|
143
147
|
return result;
|
|
144
148
|
});
|
|
145
149
|
|
|
150
|
+
// Enrich with thread snippets (best-effort, non-blocking)
|
|
151
|
+
let threadSnippets = [];
|
|
152
|
+
try {
|
|
153
|
+
const { threads } = await client.searchThreads(query, 3);
|
|
154
|
+
if (threads.length > 0) {
|
|
155
|
+
threadSnippets = threads.map((t) => {
|
|
156
|
+
const msgs = (t.matched_messages ?? []).slice(0, 2);
|
|
157
|
+
return {
|
|
158
|
+
threadId: t.thread_id ?? t.id,
|
|
159
|
+
title: t.title || "(untitled thread)",
|
|
160
|
+
source: t.source ?? "unknown",
|
|
161
|
+
lastActivity: t.last_activity ?? null,
|
|
162
|
+
relevanceScore: Number(t.relevance_score ?? 0),
|
|
163
|
+
matchedMessages: msgs.map((m) => ({
|
|
164
|
+
role: m.role,
|
|
165
|
+
snippet: truncateSnippet(m.snippet, 200),
|
|
166
|
+
})),
|
|
167
|
+
};
|
|
168
|
+
});
|
|
169
|
+
}
|
|
170
|
+
} catch {
|
|
171
|
+
// Thread search failure is not fatal
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const responsePayload = {
|
|
175
|
+
results,
|
|
176
|
+
provider: "nmem",
|
|
177
|
+
mode: "multi-signal",
|
|
178
|
+
};
|
|
179
|
+
if (threadSnippets.length > 0) {
|
|
180
|
+
responsePayload.relatedThreads = threadSnippets;
|
|
181
|
+
}
|
|
182
|
+
|
|
146
183
|
return {
|
|
147
184
|
content: [
|
|
148
185
|
{
|
|
149
186
|
type: "text",
|
|
150
|
-
text: JSON.stringify(
|
|
151
|
-
{
|
|
152
|
-
results,
|
|
153
|
-
provider: "nmem",
|
|
154
|
-
mode: "multi-signal",
|
|
155
|
-
},
|
|
156
|
-
null,
|
|
157
|
-
2,
|
|
158
|
-
),
|
|
187
|
+
text: JSON.stringify(responsePayload, null, 2),
|
|
159
188
|
},
|
|
160
189
|
],
|
|
161
190
|
details: { query, resultCount: results.length },
|
package/src/tools/save.js
CHANGED
|
@@ -124,6 +124,39 @@ export function createSaveTool(client, logger) {
|
|
|
124
124
|
};
|
|
125
125
|
}
|
|
126
126
|
|
|
127
|
+
// Dedup check: search for near-identical existing memories before saving.
|
|
128
|
+
// Only blocks at very high similarity (≥0.9) to avoid false positives.
|
|
129
|
+
// Lower matches (0.7-0.9) are noted but allowed — the Knowledge Agent
|
|
130
|
+
// handles deeper dedup via EVOLVES chains in the background.
|
|
131
|
+
try {
|
|
132
|
+
const query = title || text.slice(0, 200);
|
|
133
|
+
const existing = await client.searchRich(query, 3);
|
|
134
|
+
if (existing.length > 0) {
|
|
135
|
+
const top = existing[0];
|
|
136
|
+
if (top.score >= 0.9) {
|
|
137
|
+
logger.info(
|
|
138
|
+
`save: skipped — near-identical memory exists: ${top.id} (${(top.score * 100).toFixed(0)}%)`,
|
|
139
|
+
);
|
|
140
|
+
return {
|
|
141
|
+
content: [
|
|
142
|
+
{
|
|
143
|
+
type: "text",
|
|
144
|
+
text: `Not saved — a near-identical memory already exists:\n"${top.title}" (id: ${top.id}, similarity: ${(top.score * 100).toFixed(0)}%)\nUse memory_get with id ${top.id} to read it.`,
|
|
145
|
+
},
|
|
146
|
+
],
|
|
147
|
+
details: {
|
|
148
|
+
skipped: true,
|
|
149
|
+
reason: "duplicate",
|
|
150
|
+
existingId: top.id,
|
|
151
|
+
similarity: top.score,
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
} catch {
|
|
157
|
+
// Dedup check is best-effort — never block saves on search failure
|
|
158
|
+
}
|
|
159
|
+
|
|
127
160
|
try {
|
|
128
161
|
const args = ["--json", "m", "add", text];
|
|
129
162
|
if (title) args.push("-t", title);
|