@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/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
- export function parseConfig(raw) {
22
- const obj = raw && typeof raw === "object" ? raw : {};
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
- // Strict: reject unknown keys so users catch typos immediately.
25
- // If OpenClaw adds new platform-level keys that should pass through,
26
- // add them to ALLOWED_KEYS rather than silently accepting anything.
27
- const unknownKeys = Object.keys(obj).filter((k) => !ALLOWED_KEYS.has(k));
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
- // apiUrl: config wins, then env var, then local default
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 obj.apiUrl === "string" && obj.apiUrl.trim()) ||
38
- (typeof process.env.NMEM_API_URL === "string" &&
39
- process.env.NMEM_API_URL.trim()) ||
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: config wins, then env var — never logged, never in CLI args
255
+ // --- apiKey: pluginConfig > file > env > "" ---
43
256
  const apiKey =
44
- (typeof obj.apiKey === "string" && obj.apiKey.trim()) ||
45
- (typeof process.env.NMEM_API_KEY === "string" &&
46
- process.env.NMEM_API_KEY.trim()) ||
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
- autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall : false,
51
- autoCapture: typeof obj.autoCapture === "boolean" ? obj.autoCapture : false,
52
- captureMinInterval:
53
- typeof obj.captureMinInterval === "number" &&
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
+ }
@@ -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.captureMinInterval ?? 300) * 1000;
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 — registration in index.js already gates on autoCapture,
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.autoCapture) return;
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) {
@@ -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.maxRecallResults);
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
- // Hooks
49
- if (cfg.autoRecall) {
50
- api.on("before_agent_start", buildRecallHandler(client, cfg, logger));
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
- if (cfg.autoCapture) {
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 (recall=${cfg.autoRecall}, capture=${cfg.autoCapture}, mode=${remoteMode ? `remote → ${cfg.apiUrl}` : "local"})`,
91
+ `nowledge-mem: initialized (context=${cfg.sessionContext}, digest=${cfg.sessionDigest}, mode=${remoteMode ? `remote → ${cfg.apiUrl}` : "local"})`,
79
92
  );
80
93
  },
81
94
  };
@@ -87,8 +87,9 @@ export function createForgetTool(client, logger) {
87
87
  };
88
88
  }
89
89
 
90
- // Single high-confidence match — delete directly
91
- if (results.length === 1 || results[0].score >= 0.85) {
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)}`,
@@ -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);