@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/CHANGELOG.md +70 -0
- package/CLAUDE.md +27 -15
- package/README.md +188 -15
- package/openclaw.plugin.json +30 -19
- package/package.json +1 -1
- package/src/client.js +182 -0
- package/src/config.js +46 -14
- package/src/hooks/behavioral.js +43 -0
- package/src/hooks/capture.js +52 -5
- package/src/hooks/recall.js +1 -1
- package/src/index.js +18 -5
- 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/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
|
-
"
|
|
14
|
-
"
|
|
15
|
-
"
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
+
}
|
package/src/hooks/capture.js
CHANGED
|
@@ -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 ${
|
|
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.
|
|
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) {
|
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 {
|
|
@@ -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);
|