@nowledge/openclaw-nowledge-mem 0.3.0 → 0.6.5

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/client.js CHANGED
@@ -359,6 +359,13 @@ export class NowledgeMemClient {
359
359
  eventStart: m.event_start ?? null,
360
360
  eventEnd: m.event_end ?? null,
361
361
  temporalContext: m.temporal_context ?? null,
362
+ // Thread provenance: links memory to the conversation it was distilled from.
363
+ // CLI returns `source_thread`, API metadata returns `source_thread_id`.
364
+ sourceThreadId:
365
+ m.source_thread ??
366
+ m.source_thread_id ??
367
+ m.metadata?.source_thread_id ??
368
+ null,
362
369
  };
363
370
  }
364
371
 
@@ -722,6 +729,181 @@ export class NowledgeMemClient {
722
729
  );
723
730
  }
724
731
 
732
+ // ── Thread search ────────────────────────────────────────────────────────
733
+
734
+ /**
735
+ * Search conversation threads by keyword. Uses BM25 full-text search
736
+ * across all stored messages.
737
+ *
738
+ * @param {string} query Search keywords
739
+ * @param {number} [limit=3] Max threads to return
740
+ * @returns {Promise<{ threads: Array, totalFound: number }>}
741
+ */
742
+ async searchThreads(query, limit = 3) {
743
+ const normalizedLimit = Math.min(
744
+ 20,
745
+ Math.max(1, Math.trunc(Number(limit) || 3)),
746
+ );
747
+ const qs = new URLSearchParams({
748
+ query: String(query || ""),
749
+ mode: "full",
750
+ limit: String(normalizedLimit),
751
+ });
752
+ try {
753
+ const data = await this.apiJson(
754
+ "GET",
755
+ `/threads/search?${qs.toString()}`,
756
+ );
757
+ return {
758
+ threads: data.threads ?? [],
759
+ totalFound: Number(data.total_found ?? 0),
760
+ };
761
+ } catch (err) {
762
+ // Thread search is best-effort — don't fail the whole operation
763
+ this.logger.warn?.(
764
+ `searchThreads failed: ${err instanceof Error ? err.message : String(err)}`,
765
+ );
766
+ return { threads: [], totalFound: 0 };
767
+ }
768
+ }
769
+
770
+ /**
771
+ * Full-featured thread search — used by nowledge_mem_thread_search tool.
772
+ * Unlike searchThreads() (best-effort, swallows errors), this throws on failure
773
+ * and supports additional parameters.
774
+ *
775
+ * Uses CLI: `nmem --json t search "<query>" --limit N`
776
+ * Falls back to API: GET /threads/search
777
+ *
778
+ * @param {string} query Search keywords
779
+ * @param {{ limit?: number; source?: string }} [options]
780
+ * @returns {Promise<{ threads: Array, totalFound: number }>}
781
+ */
782
+ async searchThreadsFull(query, { limit = 10, source } = {}) {
783
+ const normalizedLimit = Math.min(
784
+ 50,
785
+ Math.max(1, Math.trunc(Number(limit) || 10)),
786
+ );
787
+ const args = [
788
+ "--json",
789
+ "t",
790
+ "search",
791
+ String(query || ""),
792
+ "--limit",
793
+ String(normalizedLimit),
794
+ ];
795
+ if (source) args.push("--source", String(source));
796
+
797
+ try {
798
+ const data = this.execJson(args);
799
+ return {
800
+ threads: data.threads ?? [],
801
+ totalFound: Number(data.total_found ?? data.total ?? 0),
802
+ };
803
+ } catch (err) {
804
+ const message = err instanceof Error ? err.message : String(err);
805
+ const needsFallback =
806
+ message.includes("unrecognized arguments") ||
807
+ message.includes("invalid choice") ||
808
+ message.includes("argument command: invalid choice");
809
+ if (!needsFallback) throw err;
810
+
811
+ this.logger.warn("searchThreadsFull: CLI too old, falling back to API");
812
+ const qs = new URLSearchParams({
813
+ query: String(query || ""),
814
+ mode: "full",
815
+ limit: String(normalizedLimit),
816
+ });
817
+ if (source) qs.set("source", String(source));
818
+ const data = await this.apiJson(
819
+ "GET",
820
+ `/threads/search?${qs.toString()}`,
821
+ );
822
+ return {
823
+ threads: data.threads ?? [],
824
+ totalFound: Number(data.total_found ?? 0),
825
+ };
826
+ }
827
+ }
828
+
829
+ /**
830
+ * Fetch full messages from a specific thread.
831
+ * Supports pagination for progressive retrieval of long conversations.
832
+ *
833
+ * Uses CLI: `nmem --json t show <id> --limit N --offset O`
834
+ * Falls back to API: GET /threads/{id}
835
+ *
836
+ * @param {string} threadId Thread ID
837
+ * @param {{ offset?: number; limit?: number }} [options]
838
+ */
839
+ async fetchThread(threadId, { offset = 0, limit = 50 } = {}) {
840
+ const id = String(threadId || "").trim();
841
+ if (!id) throw new Error("fetchThread requires threadId");
842
+
843
+ const normalizedLimit = Math.min(
844
+ 200,
845
+ Math.max(1, Math.trunc(Number(limit) || 50)),
846
+ );
847
+ const normalizedOffset = Math.max(0, Math.trunc(Number(offset) || 0));
848
+
849
+ const args = [
850
+ "--json",
851
+ "t",
852
+ "show",
853
+ id,
854
+ "--limit",
855
+ String(normalizedLimit),
856
+ ];
857
+ if (normalizedOffset > 0) {
858
+ args.push("--offset", String(normalizedOffset));
859
+ }
860
+
861
+ try {
862
+ const data = this.execJson(args);
863
+ return {
864
+ threadId: data.thread_id ?? data.id ?? id,
865
+ title: data.title ?? "(untitled)",
866
+ source: data.source ?? "unknown",
867
+ messageCount: Number(data.message_count ?? data.total_messages ?? 0),
868
+ messages: (data.messages ?? []).map((msg) => ({
869
+ role: String(msg.role ?? "unknown"),
870
+ content: String(msg.content ?? ""),
871
+ timestamp: msg.timestamp ?? msg.created_at ?? null,
872
+ })),
873
+ };
874
+ } catch (err) {
875
+ const message = err instanceof Error ? err.message : String(err);
876
+ const needsFallback =
877
+ message.includes("unrecognized arguments") ||
878
+ message.includes("invalid choice") ||
879
+ message.includes("argument command: invalid choice");
880
+ if (!needsFallback) throw err;
881
+
882
+ this.logger.warn("fetchThread: CLI too old, falling back to API");
883
+ const qs = new URLSearchParams({
884
+ limit: String(normalizedLimit),
885
+ });
886
+ if (normalizedOffset > 0) {
887
+ qs.set("offset", String(normalizedOffset));
888
+ }
889
+ const data = await this.apiJson(
890
+ "GET",
891
+ `/threads/${encodeURIComponent(id)}?${qs.toString()}`,
892
+ );
893
+ return {
894
+ threadId: data.thread_id ?? data.id ?? id,
895
+ title: data.title ?? "(untitled)",
896
+ source: data.source ?? "unknown",
897
+ messageCount: Number(data.message_count ?? data.total_messages ?? 0),
898
+ messages: (data.messages ?? []).map((msg) => ({
899
+ role: String(msg.role ?? "unknown"),
900
+ content: String(msg.content ?? ""),
901
+ timestamp: msg.timestamp ?? msg.created_at ?? null,
902
+ })),
903
+ };
904
+ }
905
+ }
906
+
725
907
  async checkHealth() {
726
908
  try {
727
909
  this.exec(["status"]);
package/src/config.js CHANGED
@@ -9,21 +9,42 @@ export function isDefaultApiUrl(url) {
9
9
  return !trimmed || trimmed === API_DEFAULT_URL;
10
10
  }
11
11
 
12
+ // Canonical config keys (new names).
12
13
  const ALLOWED_KEYS = new Set([
13
- "autoRecall",
14
- "autoCapture",
15
- "maxRecallResults",
14
+ "sessionContext",
15
+ "sessionDigest",
16
+ "digestMinInterval",
17
+ "maxContextResults",
16
18
  "apiUrl",
17
19
  "apiKey",
18
20
  ]);
19
21
 
22
+ // Backward-compatible aliases (old names → new names).
23
+ // Accepted silently so existing users' configs keep working.
24
+ const ALIAS_KEYS = {
25
+ autoRecall: "sessionContext",
26
+ autoCapture: "sessionDigest",
27
+ captureMinInterval: "digestMinInterval",
28
+ maxRecallResults: "maxContextResults",
29
+ };
30
+
20
31
  export function parseConfig(raw) {
21
32
  const obj = raw && typeof raw === "object" ? raw : {};
22
33
 
34
+ // Resolve aliases: copy old-name values to new-name slots
35
+ // (new name takes precedence if both are present).
36
+ const resolved = { ...obj };
37
+ for (const [oldKey, newKey] of Object.entries(ALIAS_KEYS)) {
38
+ if (oldKey in resolved) {
39
+ if (!(newKey in resolved)) {
40
+ resolved[newKey] = resolved[oldKey];
41
+ }
42
+ delete resolved[oldKey];
43
+ }
44
+ }
45
+
23
46
  // Strict: reject unknown keys so users catch typos immediately.
24
- // If OpenClaw adds new platform-level keys that should pass through,
25
- // add them to ALLOWED_KEYS rather than silently accepting anything.
26
- const unknownKeys = Object.keys(obj).filter((k) => !ALLOWED_KEYS.has(k));
47
+ const unknownKeys = Object.keys(resolved).filter((k) => !ALLOWED_KEYS.has(k));
27
48
  if (unknownKeys.length > 0) {
28
49
  throw new Error(
29
50
  `nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}. ` +
@@ -33,25 +54,36 @@ export function parseConfig(raw) {
33
54
 
34
55
  // apiUrl: config wins, then env var, then local default
35
56
  const apiUrl =
36
- (typeof obj.apiUrl === "string" && obj.apiUrl.trim()) ||
57
+ (typeof resolved.apiUrl === "string" && resolved.apiUrl.trim()) ||
37
58
  (typeof process.env.NMEM_API_URL === "string" &&
38
59
  process.env.NMEM_API_URL.trim()) ||
39
60
  "";
40
61
 
41
62
  // apiKey: config wins, then env var — never logged, never in CLI args
42
63
  const apiKey =
43
- (typeof obj.apiKey === "string" && obj.apiKey.trim()) ||
64
+ (typeof resolved.apiKey === "string" && resolved.apiKey.trim()) ||
44
65
  (typeof process.env.NMEM_API_KEY === "string" &&
45
66
  process.env.NMEM_API_KEY.trim()) ||
46
67
  "";
47
68
 
48
69
  return {
49
- autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall : false,
50
- autoCapture: typeof obj.autoCapture === "boolean" ? obj.autoCapture : false,
51
- maxRecallResults:
52
- typeof obj.maxRecallResults === "number" &&
53
- Number.isFinite(obj.maxRecallResults)
54
- ? Math.min(20, Math.max(1, Math.trunc(obj.maxRecallResults)))
70
+ sessionContext:
71
+ typeof resolved.sessionContext === "boolean"
72
+ ? resolved.sessionContext
73
+ : false,
74
+ sessionDigest:
75
+ typeof resolved.sessionDigest === "boolean"
76
+ ? resolved.sessionDigest
77
+ : true,
78
+ digestMinInterval:
79
+ typeof resolved.digestMinInterval === "number" &&
80
+ Number.isFinite(resolved.digestMinInterval)
81
+ ? Math.min(86400, Math.max(0, Math.trunc(resolved.digestMinInterval)))
82
+ : 300,
83
+ maxContextResults:
84
+ typeof resolved.maxContextResults === "number" &&
85
+ Number.isFinite(resolved.maxContextResults)
86
+ ? Math.min(20, Math.max(1, Math.trunc(resolved.maxContextResults)))
55
87
  : 5,
56
88
  apiUrl,
57
89
  apiKey,
@@ -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
+ }
@@ -6,6 +6,23 @@ const MAX_DISTILL_MESSAGE_CHARS = 2000;
6
6
  const MAX_CONVERSATION_CHARS = 30_000;
7
7
  const MIN_MESSAGES_FOR_DISTILL = 4;
8
8
 
9
+ // Per-thread triage cooldown: prevents burst triage/distillation from heartbeat.
10
+ // Maps threadId -> timestamp (ms) of last successful triage.
11
+ // Evicted opportunistically when new entries are set (see _setLastCapture).
12
+ const _lastCaptureAt = new Map();
13
+ const _MAX_COOLDOWN_ENTRIES = 200;
14
+
15
+ function _setLastCapture(threadId, now) {
16
+ _lastCaptureAt.set(threadId, now);
17
+ // Opportunistic eviction: sweep stale entries when map grows large
18
+ if (_lastCaptureAt.size > _MAX_COOLDOWN_ENTRIES) {
19
+ const cutoff = now - 86_400_000; // 24h — generous TTL
20
+ for (const [key, ts] of _lastCaptureAt) {
21
+ if (ts < cutoff) _lastCaptureAt.delete(key);
22
+ }
23
+ }
24
+ }
25
+
9
26
  function truncate(text, max = MAX_MESSAGE_CHARS) {
10
27
  const str = String(text || "").trim();
11
28
  if (!str) return "";
@@ -189,10 +206,11 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
189
206
  deduplicate: true,
190
207
  idempotencyKey,
191
208
  });
209
+ const added = appended.messagesAdded ?? 0;
192
210
  logger.info(
193
- `capture: appended ${appended.messagesAdded} messages to ${threadId} (${reason || "event"})`,
211
+ `capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
194
212
  );
195
- return { threadId, normalized };
213
+ return { threadId, normalized, messagesAdded: added };
196
214
  } catch (err) {
197
215
  if (!client.isThreadNotFoundError(err)) {
198
216
  const message = err instanceof Error ? err.message : String(err);
@@ -211,7 +229,7 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
211
229
  logger.info(
212
230
  `capture: created thread ${createdId} with ${messages.length} messages (${reason || "event"})`,
213
231
  );
214
- return { threadId, normalized };
232
+ return { threadId, normalized, messagesAdded: messages.length };
215
233
  } catch (err) {
216
234
  const message = err instanceof Error ? err.message : String(err);
217
235
  logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
@@ -256,10 +274,14 @@ function buildConversationText(normalized) {
256
274
  * triage or distillation, since those are mid-session checkpoints.
257
275
  */
258
276
  export function buildAgentEndCaptureHandler(client, cfg, logger) {
277
+ const cooldownMs = (cfg.digestMinInterval ?? 300) * 1000;
278
+
259
279
  return async (event, ctx) => {
260
280
  if (!event?.success) return;
261
281
 
262
282
  // 1. Always thread-append (idempotent, self-guards on empty messages).
283
+ // Never skip this — messages must always be persisted regardless of
284
+ // cooldown state, since appendOrCreateThread is deduped and cheap.
263
285
  const result = await appendOrCreateThread({
264
286
  client,
265
287
  logger,
@@ -271,11 +293,29 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
271
293
  // 2. Triage + distill: language-agnostic LLM-based capture.
272
294
  // Defensive guard — registration in index.js already gates on autoCapture,
273
295
  // but check here too so the handler is safe if called directly.
274
- if (!cfg.autoCapture) return;
296
+ if (!cfg.sessionDigest) return;
297
+
298
+ // Skip when no new messages were added (e.g. heartbeat re-sync).
299
+ if (!result || result.messagesAdded === 0) {
300
+ logger.debug?.("capture: no new messages since last sync, skipping triage");
301
+ return;
302
+ }
303
+
304
+ // Triage cooldown: skip expensive LLM triage/distillation if this
305
+ // thread was already triaged recently. Thread append above still ran,
306
+ // so no messages are lost — only the LLM cost is avoided.
307
+ if (cooldownMs > 0 && result.threadId) {
308
+ const lastCapture = _lastCaptureAt.get(result.threadId) || 0;
309
+ if (Date.now() - lastCapture < cooldownMs) {
310
+ logger.debug?.(
311
+ `capture: triage cooldown active for ${result.threadId}, skipping`,
312
+ );
313
+ return;
314
+ }
315
+ }
275
316
 
276
317
  // Skip short conversations — not worth the triage cost.
277
318
  if (
278
- !result ||
279
319
  !result.normalized ||
280
320
  result.normalized.length < MIN_MESSAGES_FOR_DISTILL
281
321
  ) {
@@ -285,6 +325,13 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
285
325
  const conversationText = buildConversationText(result.normalized);
286
326
  if (conversationText.length < 100) return;
287
327
 
328
+ // Record cooldown AFTER all eligibility checks pass, right before
329
+ // the expensive LLM call. If triage was skipped by filters above,
330
+ // the cooldown stays unset so the next call can retry.
331
+ if (cooldownMs > 0 && result.threadId) {
332
+ _setLastCapture(result.threadId, Date.now());
333
+ }
334
+
288
335
  try {
289
336
  const triage = await client.triageConversation(conversationText);
290
337
  if (!triage?.should_distill) {
@@ -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 {
@@ -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);