@nowledge/openclaw-nowledge-mem 0.6.2 → 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 +24 -24
- package/package.json +1 -1
- package/src/client.js +182 -0
- package/src/config.js +45 -19
- package/src/hooks/behavioral.js +43 -0
- package/src/hooks/capture.js +2 -2
- 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/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,76 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.6.5] - 2026-02-26
|
|
6
|
+
|
|
7
|
+
### Added — Thread tools + sourceThreadId linkage
|
|
8
|
+
|
|
9
|
+
**Two new tools for conversation retrieval.** The plugin now provides 9 tools total.
|
|
10
|
+
|
|
11
|
+
- `nowledge_mem_thread_search` — Search past conversations by keyword. Returns matched threads with message snippets and relevance scores. Use when the user asks about a past discussion or wants to find a specific conversation.
|
|
12
|
+
- `nowledge_mem_thread_fetch` — Fetch full messages from a specific thread with pagination support. Use to progressively retrieve long conversations. Accepts `offset` and `limit` for paginated retrieval.
|
|
13
|
+
|
|
14
|
+
**Memories now link back to their source conversations.**
|
|
15
|
+
|
|
16
|
+
Every memory distilled from a conversation now includes `sourceThreadId` in search results (`memory_search`) and individual lookups (`memory_get`). This enables the progressive retrieval workflow: search memories, see which thread they came from, fetch the full conversation for context.
|
|
17
|
+
|
|
18
|
+
**New client methods:**
|
|
19
|
+
|
|
20
|
+
- `client.searchThreadsFull(query, { limit, source })` — Full-featured thread search (throws on error, supports source filter). CLI-first with API fallback.
|
|
21
|
+
- `client.fetchThread(threadId, { offset, limit })` — Fetch messages from a thread with pagination. CLI-first with API fallback.
|
|
22
|
+
|
|
23
|
+
**Save deduplication.**
|
|
24
|
+
|
|
25
|
+
Before saving, the plugin checks for near-identical existing memories (≥90% similarity). If a match is found, the save is skipped and the existing memory is returned — preventing obvious duplicates at the plugin level. Deeper semantic dedup is handled by the Knowledge Agent's EVOLVES chains in the background.
|
|
26
|
+
|
|
27
|
+
**Context-aware behavioral guidance.**
|
|
28
|
+
|
|
29
|
+
The always-on hook now tells the agent about `nowledge_mem_thread_fetch` for following up on `sourceThreadId` links. When `sessionContext` is enabled, the guidance adjusts to note that relevant memories have already been injected — reducing redundant `memory_search` calls.
|
|
30
|
+
|
|
31
|
+
## [0.6.4] - 2026-02-26
|
|
32
|
+
|
|
33
|
+
### Changed — Thread search enrichment + sessionDigest default on
|
|
34
|
+
|
|
35
|
+
**`memory_search` now includes relevant conversation threads.**
|
|
36
|
+
|
|
37
|
+
When you search memories, the response now also includes `relatedThreads` — snippets from past conversations that match the query. This uses the backend's BM25 thread search (`GET /threads/search`). Thread search is best-effort: if it fails or returns nothing, the memory results are returned as before. No new tool for the agent to learn — thread context surfaces automatically alongside memories.
|
|
38
|
+
|
|
39
|
+
**`sessionDigest` now defaults to `true`.**
|
|
40
|
+
|
|
41
|
+
Previously, session digest (thread capture + LLM distillation at session end) was off by default. The cost is negligible — one lightweight triage call per session end, full distillation only when worthwhile. With this change, conversations are captured and distilled automatically out of the box. Users who explicitly set `sessionDigest: false` are unaffected.
|
|
42
|
+
|
|
43
|
+
**Behavioral hook updated.**
|
|
44
|
+
|
|
45
|
+
The always-on guidance now mentions that `memory_search` returns conversation snippets alongside memories, so the agent knows to use the enriched results.
|
|
46
|
+
|
|
47
|
+
### Added
|
|
48
|
+
|
|
49
|
+
- `client.searchThreads(query, limit)` — calls `GET /threads/search` API. Returns top matching threads with message snippets and relevance scores. Best-effort: never throws.
|
|
50
|
+
- Version bumped to 0.6.4.
|
|
51
|
+
|
|
52
|
+
## [0.4.0] - 2026-02-26
|
|
53
|
+
|
|
54
|
+
### Changed — Rename config + always-on behavioral guidance
|
|
55
|
+
|
|
56
|
+
**Renamed config options** (old names still work as silent aliases):
|
|
57
|
+
|
|
58
|
+
| Old name | New name |
|
|
59
|
+
|----------|----------|
|
|
60
|
+
| `autoRecall` | `sessionContext` |
|
|
61
|
+
| `autoCapture` | `sessionDigest` |
|
|
62
|
+
| `captureMinInterval` | `digestMinInterval` |
|
|
63
|
+
| `maxRecallResults` | `maxContextResults` |
|
|
64
|
+
|
|
65
|
+
Existing configs with old names continue to work — aliases are resolved transparently in `parseConfig()`. New names take precedence if both are present.
|
|
66
|
+
|
|
67
|
+
**Added: Always-on behavioral hook**
|
|
68
|
+
|
|
69
|
+
The agent now receives brief behavioral guidance (~50 tokens) on every turn via `before_prompt_build`, telling it to proactively save and search. This fires in ALL modes, including tool-only (default). Previously, without `sessionContext` enabled, the agent had no system-level instruction to use memory tools — most LLMs ignored the "call proactively" hints in tool descriptions. This is the single most impactful change for memory adoption.
|
|
70
|
+
|
|
71
|
+
**Migrated: `before_prompt_build` for session context**
|
|
72
|
+
|
|
73
|
+
Session context injection (formerly "autoRecall") now uses the modern `before_prompt_build` hook instead of legacy `before_agent_start`. Both the behavioral hook and session context coexist — OpenClaw concatenates multiple `prependContext` values with `\n\n`.
|
|
74
|
+
|
|
5
75
|
## [0.3.0] - 2026-02-23
|
|
6
76
|
|
|
7
77
|
### Changed — Architecture overhaul: tool-first, LLM-based capture
|
package/CLAUDE.md
CHANGED
|
@@ -28,26 +28,29 @@ Reflects Nowledge Mem's genuine v0.6 strengths:
|
|
|
28
28
|
src/
|
|
29
29
|
index.js — plugin registration (tools, hooks, commands, CLI)
|
|
30
30
|
client.js — CLI wrapper with API fallback; credential handling
|
|
31
|
-
config.js — strict config parsing (apiUrl, apiKey,
|
|
31
|
+
config.js — strict config parsing (apiUrl, apiKey, sessionContext, etc.)
|
|
32
32
|
hooks/
|
|
33
|
-
|
|
34
|
-
|
|
33
|
+
behavioral.js — always-on behavioral guidance (~50 tokens/turn)
|
|
34
|
+
recall.js — before_prompt_build: inject Working Memory + recalled memories
|
|
35
|
+
capture.js — thread capture + LLM triage/distillation at session lifecycle events
|
|
35
36
|
tools/
|
|
36
|
-
memory-search.js — OpenClaw compat; multi-signal; bi-temporal; relevance_reason
|
|
37
|
-
memory-get.js — OpenClaw compat; supports MEMORY.md alias
|
|
38
|
-
save.js — structured capture: unit_type, labels, temporal, importance
|
|
39
|
-
context.js — Working Memory daily briefing (read-
|
|
37
|
+
memory-search.js — OpenClaw compat; multi-signal; bi-temporal; relevance_reason; sourceThreadId
|
|
38
|
+
memory-get.js — OpenClaw compat; supports MEMORY.md alias; sourceThreadId
|
|
39
|
+
save.js — structured capture: unit_type, labels, temporal, importance; pre-save dedup check
|
|
40
|
+
context.js — Working Memory daily briefing (read + section-level patch)
|
|
40
41
|
connections.js — graph exploration: edge types, relationship strength, provenance
|
|
41
42
|
timeline.js — activity feed: daily grouping, event_type filter, memoryId hints
|
|
42
43
|
forget.js — memory deletion by ID or search
|
|
44
|
+
thread-search.js — search past conversations by keyword
|
|
45
|
+
thread-fetch.js — fetch full messages from a thread with pagination
|
|
43
46
|
openclaw.plugin.json — manifest + config schema (version, uiHints, configSchema)
|
|
44
47
|
```
|
|
45
48
|
|
|
46
|
-
## Tool Surface (
|
|
49
|
+
## Tool Surface (9 tools)
|
|
47
50
|
|
|
48
51
|
### OpenClaw Memory Slot (required for system prompt activation)
|
|
49
|
-
- `memory_search` — multi-signal: BM25 + embedding + label + graph + decay. Returns `matchedVia` ("Text Match 100% + Semantic 69%"), `importance`, bi-temporal filters (`event_date_from/to`, `recorded_date_from/to`). Mode: `"multi-signal"`.
|
|
50
|
-
- `memory_get` — retrieve by `nowledgemem://memory/<id>` path or bare ID. `MEMORY.md` → Working Memory.
|
|
52
|
+
- `memory_search` — multi-signal: BM25 + embedding + label + graph + decay. Returns `matchedVia` ("Text Match 100% + Semantic 69%"), `importance`, bi-temporal filters (`event_date_from/to`, `recorded_date_from/to`). Also returns `relatedThreads` (past conversation snippets matching the query) and `sourceThreadId` (link to source conversation). Mode: `"multi-signal"`.
|
|
53
|
+
- `memory_get` — retrieve by `nowledgemem://memory/<id>` path or bare ID. `MEMORY.md` → Working Memory. Returns `sourceThreadId` when available.
|
|
51
54
|
|
|
52
55
|
### Nowledge Mem Native (differentiators)
|
|
53
56
|
- `nowledge_mem_save` — structured capture: `unit_type`, `labels[]`, `event_start`, `event_end`, `temporal_context`, `importance`. All fields wired to CLI and API.
|
|
@@ -56,10 +59,15 @@ openclaw.plugin.json — manifest + config schema (version, uiHints, configSchem
|
|
|
56
59
|
- `nowledge_mem_timeline` — activity feed via `nmem f`. Groups by day. `event_type` filter. Exact date range via `date_from`/`date_to` (YYYY-MM-DD). Entries include `(id: <memoryId>)` for chaining to connections.
|
|
57
60
|
- `nowledge_mem_forget` — delete by ID or fuzzy query.
|
|
58
61
|
|
|
62
|
+
### Thread Tools (progressive conversation retrieval)
|
|
63
|
+
- `nowledge_mem_thread_search` — search past conversations by keyword. Returns threads with matched message snippets, relevance scores, and message counts. Supports `source` filter.
|
|
64
|
+
- `nowledge_mem_thread_fetch` — fetch full messages from a specific thread. Supports pagination via `offset` + `limit` for progressive retrieval of long conversations.
|
|
65
|
+
|
|
59
66
|
## Hook Surface
|
|
60
67
|
|
|
61
|
-
- `
|
|
62
|
-
- `
|
|
68
|
+
- `before_prompt_build` (always-on) — behavioral guidance: brief note telling agent to save/search proactively. Adjusts when sessionContext is on to avoid redundant searches.
|
|
69
|
+
- `before_prompt_build` (sessionContext) — session context: Working Memory + `searchRich()` with `relevanceReason` in context
|
|
70
|
+
- `agent_end` — thread capture + LLM triage/distillation (requires `sessionDigest: true`)
|
|
63
71
|
- `after_compaction` — thread append
|
|
64
72
|
- `before_reset` — thread append
|
|
65
73
|
|
|
@@ -73,9 +81,10 @@ openclaw.plugin.json — manifest + config schema (version, uiHints, configSchem
|
|
|
73
81
|
|
|
74
82
|
| Key | Type | Default | Description |
|
|
75
83
|
|-----|------|---------|-------------|
|
|
76
|
-
| `
|
|
77
|
-
| `
|
|
78
|
-
| `
|
|
84
|
+
| `sessionContext` | boolean | `false` | Inject Working Memory + relevant memories at prompt time |
|
|
85
|
+
| `sessionDigest` | boolean | `true` | Thread capture + LLM distillation at session end |
|
|
86
|
+
| `maxContextResults` | integer 1–20 | `5` | How many memories to inject at prompt time |
|
|
87
|
+
| `digestMinInterval` | integer | `300` | Minimum seconds between session digests |
|
|
79
88
|
| `apiUrl` | string | `""` | Remote server URL. Empty = local (127.0.0.1:14242) |
|
|
80
89
|
| `apiKey` | string | `""` | API key. Injected as `NMEM_API_KEY` env var only. Never logged. |
|
|
81
90
|
|
|
@@ -98,6 +107,9 @@ openclaw.plugin.json — manifest + config schema (version, uiHints, configSchem
|
|
|
98
107
|
| `nmem --json wm read` | `client.readWorkingMemory()` | Working Memory read |
|
|
99
108
|
| `nmem --json wm patch --heading "## S" --content/--append` | `client.patchWorkingMemory()` | Section-level WM update |
|
|
100
109
|
| `nmem --json m delete <id>` | `client.execJson()` in forget.js | Delete |
|
|
110
|
+
| `GET /threads/search?query=&limit=` | `client.searchThreads()` | Thread search enrichment (best-effort, API-only) |
|
|
111
|
+
| `nmem --json t search <q> --limit N` | `client.searchThreadsFull()` | Full thread search (CLI-first, API fallback) |
|
|
112
|
+
| `nmem --json t show <id> --limit N --offset O` | `client.fetchThread()` | Fetch thread messages with pagination |
|
|
101
113
|
|
|
102
114
|
All commands have API fallback for older CLI versions.
|
|
103
115
|
|
package/README.md
CHANGED
|
@@ -32,7 +32,7 @@ Start Nowledge Mem desktop app or run `nmem serve`, then configure:
|
|
|
32
32
|
}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
That's it. The agent gets
|
|
35
|
+
That's it. The agent gets 9 tools and calls them on demand. No extra tokens wasted.
|
|
36
36
|
|
|
37
37
|
### Remote mode
|
|
38
38
|
|
|
@@ -57,15 +57,138 @@ Connect to a Nowledge Mem server running elsewhere — on a VPS, a home server,
|
|
|
57
57
|
|
|
58
58
|
The `apiKey` is injected as `NMEM_API_KEY` into the nmem CLI process — never passed as a CLI argument, never logged.
|
|
59
59
|
|
|
60
|
+
## How It Works
|
|
61
|
+
|
|
62
|
+
### Per-Turn Flow
|
|
63
|
+
|
|
64
|
+
Every user message triggers hooks before the agent sees it, then the agent decides which tools to call.
|
|
65
|
+
|
|
66
|
+
```mermaid
|
|
67
|
+
flowchart TD
|
|
68
|
+
A["User sends message"] --> B["before_prompt_build hook"]
|
|
69
|
+
|
|
70
|
+
B --> C["Behavioral guidance(always, ~110 tokens)"]
|
|
71
|
+
B -.->|"sessionContext: true"| D["Working Memory +relevant memories (~1-2 KB)"]
|
|
72
|
+
|
|
73
|
+
C --> E["Agent processes message(9 tools available)"]
|
|
74
|
+
D --> E
|
|
75
|
+
|
|
76
|
+
E --> F{"Needs past context?"}
|
|
77
|
+
F -->|"Yes"| G["memory_search"]
|
|
78
|
+
G --> R
|
|
79
|
+
|
|
80
|
+
E --> H{"Something worth keeping?"}
|
|
81
|
+
H -->|"Yes"| I["nowledge_mem_save(typed, labeled, temporal)"]
|
|
82
|
+
I --> R
|
|
83
|
+
|
|
84
|
+
E --> R["Respond to user"]
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
### When Each Tool Gets Called
|
|
88
|
+
|
|
89
|
+
The behavioral hook nudges the agent to **search before answering** and **save after deciding**. Here's when each tool fires:
|
|
90
|
+
|
|
91
|
+
| Scenario | Tool | What happens |
|
|
92
|
+
|----------|------|--------------|
|
|
93
|
+
| User asks a question | `memory_search` | Search knowledge base before answering. Also returns `relatedThreads` snippets and `sourceThreadId`. |
|
|
94
|
+
| Agent needs full memory text | `memory_get` | Read one memory by ID or path. `MEMORY.md` alias reads Working Memory. Returns `sourceThreadId`. |
|
|
95
|
+
| Decision made, insight learned | `nowledge_mem_save` | Structured save with `unit_type`, `labels`, `event_start`, `importance`. |
|
|
96
|
+
| "What was I doing last week?" | `nowledge_mem_timeline` | Activity feed grouped by day. Supports exact date ranges. |
|
|
97
|
+
| "How is X connected to Y?" | `nowledge_mem_connections` | Graph walk: edges, entities, EVOLVES chains, document provenance. |
|
|
98
|
+
| Need today's focus/priorities | `nowledge_mem_context` | Read Working Memory daily briefing. Supports section-level patch. |
|
|
99
|
+
| Memory has `sourceThreadId` | `nowledge_mem_thread_fetch` | Fetch full source conversation. Pagination via `offset` + `limit`. |
|
|
100
|
+
| "Find our discussion about X" | `nowledge_mem_thread_search` | Search past conversations by keyword. Returns matched snippets. |
|
|
101
|
+
| "Forget X" | `nowledge_mem_forget` | Delete by ID or search query. |
|
|
102
|
+
|
|
103
|
+
### Session Lifecycle (Automatic Capture)
|
|
104
|
+
|
|
105
|
+
When sessions end, conversations are captured and optionally distilled. No user action needed.
|
|
106
|
+
|
|
107
|
+
```mermaid
|
|
108
|
+
flowchart TD
|
|
109
|
+
A["Session lifecycle event"] --> B{"Event type"}
|
|
110
|
+
|
|
111
|
+
B -->|"agent_end"| C["Append messages to thread(idempotent, deduped)"]
|
|
112
|
+
B -->|"after_compaction"| D["Append messages to thread(checkpoint only)"]
|
|
113
|
+
B -->|"before_reset"| E["Append messages to thread(checkpoint only)"]
|
|
114
|
+
|
|
115
|
+
C --> F{"sessionDigest enabled+ enough messages+ cooldown passed?"}
|
|
116
|
+
F -->|"Yes"| G["LLM triage (~100 tokens):worth distilling?"]
|
|
117
|
+
F -->|"No"| H["Done — thread saved"]
|
|
118
|
+
G -->|"Decisions, insights,preferences found"| I["Full distillation ->structured memories(with sourceThreadId)"]
|
|
119
|
+
G -->|"Routine chat"| H
|
|
120
|
+
|
|
121
|
+
D --> H
|
|
122
|
+
E --> H
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
**Key points:**
|
|
126
|
+
- Thread capture is **unconditional** — every conversation is saved and searchable via `nowledge_mem_thread_search`
|
|
127
|
+
- LLM distillation only runs at `agent_end`, not during compaction/reset checkpoints
|
|
128
|
+
- Distilled memories carry `sourceThreadId`, linking them back to the source conversation
|
|
129
|
+
- Cooldown (`digestMinInterval`, default 300s) prevents burst distillation
|
|
130
|
+
|
|
131
|
+
### Progressive Retrieval (Memory -> Thread -> Messages)
|
|
132
|
+
|
|
133
|
+
Memories distilled from conversations carry a `sourceThreadId`. This creates a retrieval chain:
|
|
134
|
+
|
|
135
|
+
```mermaid
|
|
136
|
+
flowchart TD
|
|
137
|
+
A["memory_search'PostgreSQL decision'"] --> B["Result includessourceThreadId"]
|
|
138
|
+
B --> C["nowledge_mem_thread_fetch(offset=0, limit=50)"]
|
|
139
|
+
C --> D["50 messageshasMore: true"]
|
|
140
|
+
D --> E["nowledge_mem_thread_fetch(offset=50, limit=50)"]
|
|
141
|
+
E --> F["Next page"]
|
|
142
|
+
```
|
|
143
|
+
|
|
144
|
+
Direct conversation search also works:
|
|
145
|
+
|
|
146
|
+
```mermaid
|
|
147
|
+
flowchart LR
|
|
148
|
+
G["nowledge_mem_thread_search 'database architecture'"] --> H["Threads with matched snippets"]
|
|
149
|
+
H --> I["nowledge_mem_thread_fetch(threadId from results)"]
|
|
150
|
+
I --> J["Full messages"]
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Two entry points:
|
|
154
|
+
1. **From a memory** — `memory_search` or `memory_get` returns `sourceThreadId` -> fetch the source conversation
|
|
155
|
+
2. **Direct search** — `nowledge_mem_thread_search` finds conversations by keyword -> fetch any result
|
|
156
|
+
|
|
157
|
+
### Three Modes at a Glance
|
|
158
|
+
|
|
159
|
+
```mermaid
|
|
160
|
+
flowchart LR
|
|
161
|
+
subgraph "default" ["Default (recommended)"]
|
|
162
|
+
direction TB
|
|
163
|
+
D1["Every turn: behavioral guidance (~50 tokens)"]
|
|
164
|
+
D2["Agent calls 9 tools on demand"]
|
|
165
|
+
D3["Session end: thread capture + LLM distillation"]
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
subgraph context ["Session Context"]
|
|
169
|
+
direction TB
|
|
170
|
+
C1["Every turn: guidance + Working Memory+ recalled memories (~1-2 KB)"]
|
|
171
|
+
C2["Agent calls 9 tools on demand"]
|
|
172
|
+
C3["Session end: thread capture + LLM distillation"]
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
subgraph minimal ["Minimal"]
|
|
176
|
+
direction TB
|
|
177
|
+
M1["Every turn: behavioral guidance (~50 tokens)"]
|
|
178
|
+
M2["Agent calls 9 tools on demand"]
|
|
179
|
+
M3["No automatic capture"]
|
|
180
|
+
end
|
|
181
|
+
```
|
|
182
|
+
|
|
60
183
|
## Tools
|
|
61
184
|
|
|
62
185
|
### OpenClaw Memory Compatibility
|
|
63
186
|
|
|
64
187
|
These satisfy the OpenClaw memory slot contract and activate the "Memory Recall" section in OpenClaw's system prompt.
|
|
65
188
|
|
|
66
|
-
**memory_search** — Multi-signal recall using embedding, BM25, label match, graph signals, and recency decay. Returns structured source paths (`nowledgemem://memory/<id>`) for follow-up with `memory_get` or `nowledge_mem_connections`.
|
|
189
|
+
**memory_search** — Multi-signal recall using embedding, BM25, label match, graph signals, and recency decay. Returns structured source paths (`nowledgemem://memory/<id>`) for follow-up with `memory_get` or `nowledge_mem_connections`. Also returns relevant past conversation snippets (`relatedThreads`) and `sourceThreadId` (link to the conversation the memory was distilled from) when available.
|
|
67
190
|
|
|
68
|
-
**memory_get** — Read a specific memory by ID or path. Supports `MEMORY.md` alias for Working Memory.
|
|
191
|
+
**memory_get** — Read a specific memory by ID or path. Supports `MEMORY.md` alias for Working Memory. Returns `sourceThreadId` when the memory was distilled from a conversation.
|
|
69
192
|
|
|
70
193
|
### Nowledge Mem Native
|
|
71
194
|
|
|
@@ -118,21 +241,44 @@ last_n_days: 7
|
|
|
118
241
|
|
|
119
242
|
**nowledge_mem_forget** — Delete a memory by ID or search query. Supports user confirmation when multiple matches are found.
|
|
120
243
|
|
|
244
|
+
### Thread Tools
|
|
245
|
+
|
|
246
|
+
**nowledge_mem_thread_search** — Search past conversations by keyword. Returns matched threads with message snippets and relevance scores. Use when the user asks about a past discussion or wants to find a conversation from a specific time.
|
|
247
|
+
|
|
248
|
+
**nowledge_mem_thread_fetch** — Fetch full messages from a conversation thread with pagination. Pass a `sourceThreadId` from memory results or a `threadId` from thread search. Supports `offset` and `limit` for progressive retrieval of long conversations.
|
|
249
|
+
|
|
250
|
+
```
|
|
251
|
+
threadId: "openclaw-db-arch-a1b2c3"
|
|
252
|
+
offset: 0, limit: 50
|
|
253
|
+
-> Thread: "Database architecture discussion" (128 messages)
|
|
254
|
+
[user] We need to decide on the database for task events...
|
|
255
|
+
[assistant] Based on the requirements, PostgreSQL with JSONB...
|
|
256
|
+
... (126 more messages, use offset=50 for next page)
|
|
257
|
+
```
|
|
258
|
+
|
|
121
259
|
## Operating Modes
|
|
122
260
|
|
|
123
|
-
The plugin supports three modes.
|
|
261
|
+
The plugin supports three modes. Choose based on how much you want to guarantee versus how much token budget you're willing to spend.
|
|
262
|
+
|
|
263
|
+
| Mode | Config | Behavior | Tradeoff |
|
|
264
|
+
|------|--------|----------|----------|
|
|
265
|
+
| **Default** (recommended) | `sessionContext: false, sessionDigest: true` | Agent calls 9 tools on demand. Behavioral guidance every turn (~50 tokens). Conversations captured + distilled at session end. | Lowest cost. Agent decides when to search — usually smart, occasionally forgets. |
|
|
266
|
+
| **Session context** | `sessionContext: true` | Working Memory + relevant memories injected at prompt time, plus all 9 tools still available. | ~1-2 KB/turn. Guarantees context is always present. Best for short sessions or critical workflows. |
|
|
267
|
+
| **Minimal** | `sessionDigest: false` | Tool-only, no automatic capture. | Zero overhead beyond guidance. Use when you handle memory manually. |
|
|
268
|
+
|
|
269
|
+
**Which mode should you use?**
|
|
270
|
+
|
|
271
|
+
- **Most users**: start with default. The agent gets behavioral guidance nudging it to search before answering and save after deciding. It works well for most conversations.
|
|
272
|
+
- **Short sessions or critical accuracy**: enable `sessionContext`. This guarantees relevant memories are present from the first turn — the agent doesn't need to decide whether to search. The tradeoff is ~1-2 KB of context per turn.
|
|
273
|
+
- **Full manual control**: set `sessionDigest: false`. You control what gets saved (via `/remember` or `nowledge_mem_save`) and nothing is captured automatically.
|
|
124
274
|
|
|
125
|
-
|
|
126
|
-
|------|--------|----------|
|
|
127
|
-
| **Tool-only** (default) | `autoRecall: false, autoCapture: false` | Agent calls tools on demand. Zero overhead. |
|
|
128
|
-
| **Auto-recall** | `autoRecall: true` | Working Memory + relevant memories injected at session start. |
|
|
129
|
-
| **Auto-capture** | `autoCapture: true` | Thread capture + LLM distillation at session end. |
|
|
275
|
+
### Session Context (`sessionContext`, default: false)
|
|
130
276
|
|
|
131
|
-
|
|
277
|
+
When enabled, the plugin injects Working Memory and relevant search results at prompt time. The behavioral guidance automatically adjusts to tell the agent that context is already present — use `memory_search` only for specific follow-up queries, not broad recall. This prevents the agent from redundantly searching for the same context that was just injected.
|
|
132
278
|
|
|
133
|
-
|
|
279
|
+
Useful for giving the agent immediate context without waiting for it to search proactively.
|
|
134
280
|
|
|
135
|
-
###
|
|
281
|
+
### Session Digest (`sessionDigest`, default: true)
|
|
136
282
|
|
|
137
283
|
When enabled, two things happen at `agent_end`, `after_compaction`, and `before_reset`:
|
|
138
284
|
|
|
@@ -140,6 +286,30 @@ When enabled, two things happen at `agent_end`, `after_compaction`, and `before_
|
|
|
140
286
|
|
|
141
287
|
**2. LLM distillation (when worthwhile).** A lightweight LLM triage determines if the conversation has save-worthy content. If yes, a full distillation pass creates structured memories with types, labels, and temporal data. Language-agnostic — works in any language.
|
|
142
288
|
|
|
289
|
+
## Design Decisions
|
|
290
|
+
|
|
291
|
+
Honest answers to common questions about how the memory system works.
|
|
292
|
+
|
|
293
|
+
**Does the agent always search before answering?**
|
|
294
|
+
|
|
295
|
+
No. The behavioral guidance nudges the agent to search, but doesn't force it. This is a deliberate tradeoff — forcing a search on every turn would add latency and cost for messages that don't need past context (like "hello" or "thanks"). In practice, modern LLMs follow behavioral guidance reliably for knowledge-related questions. If guaranteed recall matters for your use case, enable `sessionContext: true` — that injects relevant memories at prompt time, before the agent even processes your message.
|
|
296
|
+
|
|
297
|
+
**What stops the agent from saving duplicate memories?**
|
|
298
|
+
|
|
299
|
+
Two layers. First, the plugin checks for near-identical existing memories before every save. If a memory with ≥90% similarity already exists, the save is skipped and the existing memory is returned instead. Second, Nowledge Mem's Knowledge Agent runs in the background and handles deeper deduplication — it identifies semantic overlap across memories and links them via EVOLVES chains (replaces, enriches, confirms, or challenges). The plugin handles obvious duplicates; the Knowledge Agent handles subtle ones.
|
|
300
|
+
|
|
301
|
+
**When `sessionContext` is on, will the agent search again and get duplicate context?**
|
|
302
|
+
|
|
303
|
+
The behavioral guidance adjusts when `sessionContext` is enabled. Instead of "search with memory_search when prior context would help," it tells the agent "relevant memories have already been injected — use memory_search only for specific follow-up queries." This reduces redundant searches. The injected context and manual search results are in different XML blocks, so even if overlap occurs, it doesn't confuse the agent — it just sees the same fact confirmed from two sources.
|
|
304
|
+
|
|
305
|
+
**What happens to conversations I don't explicitly save?**
|
|
306
|
+
|
|
307
|
+
With `sessionDigest` enabled (the default), every conversation is saved as a searchable thread — you can find it later with `nowledge_mem_thread_search`. On top of that, a lightweight LLM triage checks if the conversation contained decisions, insights, or preferences worth keeping as structured memories. If yes, they're extracted with proper types, labels, and temporal context. If the conversation was routine ("fix this typo"), nothing extra is saved.
|
|
308
|
+
|
|
309
|
+
**Can memories be wrong or outdated?**
|
|
310
|
+
|
|
311
|
+
Yes. Memories are what you or the AI saved at a point in time. Nowledge Mem's EVOLVES chains track how understanding changes — a newer memory can supersede, enrich, or challenge an older one. The Knowledge Agent identifies these relationships automatically. When you search, the relevance scoring considers recency, so newer memories rank higher by default.
|
|
312
|
+
|
|
143
313
|
## Slash Commands
|
|
144
314
|
|
|
145
315
|
| Command | Description |
|
|
@@ -159,9 +329,12 @@ openclaw nowledge-mem status
|
|
|
159
329
|
|
|
160
330
|
| Key | Type | Default | Description |
|
|
161
331
|
|-----|------|---------|-------------|
|
|
162
|
-
| `
|
|
163
|
-
| `
|
|
164
|
-
| `
|
|
332
|
+
| `sessionContext` | boolean | `false` | Inject Working Memory + relevant memories at prompt time |
|
|
333
|
+
| `sessionDigest` | boolean | `true` | Thread capture + LLM distillation at session end |
|
|
334
|
+
| `maxContextResults` | integer | `5` | Max memories to inject at prompt time (1–20, only used when sessionContext is enabled) |
|
|
335
|
+
| `digestMinInterval` | integer | `300` | Minimum seconds between session digests for the same thread (0–86400) |
|
|
336
|
+
| `apiUrl` | string | `""` | Remote server URL. Empty = local (`http://127.0.0.1:14242`) |
|
|
337
|
+
| `apiKey` | string | `""` | API key for remote access. Injected as `NMEM_API_KEY` env var — never logged |
|
|
165
338
|
|
|
166
339
|
## What Makes This Different
|
|
167
340
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-nowledge-mem",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.5",
|
|
4
4
|
"kind": "memory",
|
|
5
5
|
"uiHints": {
|
|
6
|
-
"
|
|
7
|
-
"label": "
|
|
8
|
-
"help": "Inject Working Memory and relevant memories at
|
|
6
|
+
"sessionContext": {
|
|
7
|
+
"label": "Session context injection",
|
|
8
|
+
"help": "Inject Working Memory and relevant memories at prompt time. Off by default — the agent can call memory_search on demand instead."
|
|
9
9
|
},
|
|
10
|
-
"
|
|
11
|
-
"label": "
|
|
12
|
-
"help": "Capture conversation threads and distill key memories via LLM at session end"
|
|
10
|
+
"sessionDigest": {
|
|
11
|
+
"label": "Session digest at end",
|
|
12
|
+
"help": "Capture conversation threads and distill key memories via LLM at session end. On by default — negligible cost, one triage call per session."
|
|
13
13
|
},
|
|
14
|
-
"
|
|
15
|
-
"label": "Minimum
|
|
16
|
-
"help": "Minimum seconds between
|
|
14
|
+
"digestMinInterval": {
|
|
15
|
+
"label": "Minimum digest interval (seconds)",
|
|
16
|
+
"help": "Minimum seconds between session digests for the same thread. Prevents heartbeat-driven burst captures. Set to 0 for no limit."
|
|
17
17
|
},
|
|
18
|
-
"
|
|
19
|
-
"label": "Max
|
|
20
|
-
"help": "How many memories to inject for each
|
|
18
|
+
"maxContextResults": {
|
|
19
|
+
"label": "Max context results",
|
|
20
|
+
"help": "How many memories to inject for each context cycle (1–20)"
|
|
21
21
|
},
|
|
22
22
|
"apiUrl": {
|
|
23
23
|
"label": "Server URL (remote mode)",
|
|
@@ -25,36 +25,36 @@
|
|
|
25
25
|
},
|
|
26
26
|
"apiKey": {
|
|
27
27
|
"label": "API key (remote mode)",
|
|
28
|
-
"help": "Required when connecting to a remote server. Never logged or passed as a CLI argument
|
|
28
|
+
"help": "Required when connecting to a remote server. Never logged or passed as a CLI argument — injected as NMEM_API_KEY env var only.",
|
|
29
29
|
"secret": true
|
|
30
30
|
}
|
|
31
31
|
},
|
|
32
32
|
"configSchema": {
|
|
33
33
|
"type": "object",
|
|
34
34
|
"properties": {
|
|
35
|
-
"
|
|
35
|
+
"sessionContext": {
|
|
36
36
|
"type": "boolean",
|
|
37
37
|
"default": false,
|
|
38
|
-
"description": "Inject Working Memory and recalled memories at
|
|
38
|
+
"description": "Inject Working Memory and recalled memories at prompt time. Off by default — the agent queries memory on demand via tools."
|
|
39
39
|
},
|
|
40
|
-
"
|
|
40
|
+
"sessionDigest": {
|
|
41
41
|
"type": "boolean",
|
|
42
|
-
"default":
|
|
43
|
-
"description": "Capture conversation threads and distill key memories via LLM at session end"
|
|
42
|
+
"default": true,
|
|
43
|
+
"description": "Capture conversation threads and distill key memories via LLM at session end. On by default."
|
|
44
44
|
},
|
|
45
|
-
"
|
|
45
|
+
"digestMinInterval": {
|
|
46
46
|
"type": "integer",
|
|
47
47
|
"default": 300,
|
|
48
48
|
"minimum": 0,
|
|
49
49
|
"maximum": 86400,
|
|
50
|
-
"description": "Minimum seconds between
|
|
50
|
+
"description": "Minimum seconds between session digests for the same thread (0 = no limit). Prevents heartbeat-driven burst captures."
|
|
51
51
|
},
|
|
52
|
-
"
|
|
52
|
+
"maxContextResults": {
|
|
53
53
|
"type": "integer",
|
|
54
54
|
"default": 5,
|
|
55
55
|
"minimum": 1,
|
|
56
56
|
"maximum": 20,
|
|
57
|
-
"description": "Maximum memories to
|
|
57
|
+
"description": "Maximum memories to inject at prompt time (only used when sessionContext is enabled)"
|
|
58
58
|
},
|
|
59
59
|
"apiUrl": {
|
|
60
60
|
"type": "string",
|
|
@@ -64,7 +64,7 @@
|
|
|
64
64
|
"apiKey": {
|
|
65
65
|
"type": "string",
|
|
66
66
|
"default": "",
|
|
67
|
-
"description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var
|
|
67
|
+
"description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var — never logged."
|
|
68
68
|
}
|
|
69
69
|
},
|
|
70
70
|
"additionalProperties": false
|
package/package.json
CHANGED
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,22 +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
|
-
"
|
|
16
|
-
"
|
|
14
|
+
"sessionContext",
|
|
15
|
+
"sessionDigest",
|
|
16
|
+
"digestMinInterval",
|
|
17
|
+
"maxContextResults",
|
|
17
18
|
"apiUrl",
|
|
18
19
|
"apiKey",
|
|
19
20
|
]);
|
|
20
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
|
+
|
|
21
31
|
export function parseConfig(raw) {
|
|
22
32
|
const obj = raw && typeof raw === "object" ? raw : {};
|
|
23
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
|
+
|
|
24
46
|
// Strict: reject unknown keys so users catch typos immediately.
|
|
25
|
-
|
|
26
|
-
// add them to ALLOWED_KEYS rather than silently accepting anything.
|
|
27
|
-
const unknownKeys = Object.keys(obj).filter((k) => !ALLOWED_KEYS.has(k));
|
|
47
|
+
const unknownKeys = Object.keys(resolved).filter((k) => !ALLOWED_KEYS.has(k));
|
|
28
48
|
if (unknownKeys.length > 0) {
|
|
29
49
|
throw new Error(
|
|
30
50
|
`nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}. ` +
|
|
@@ -34,30 +54,36 @@ export function parseConfig(raw) {
|
|
|
34
54
|
|
|
35
55
|
// apiUrl: config wins, then env var, then local default
|
|
36
56
|
const apiUrl =
|
|
37
|
-
(typeof
|
|
57
|
+
(typeof resolved.apiUrl === "string" && resolved.apiUrl.trim()) ||
|
|
38
58
|
(typeof process.env.NMEM_API_URL === "string" &&
|
|
39
59
|
process.env.NMEM_API_URL.trim()) ||
|
|
40
60
|
"";
|
|
41
61
|
|
|
42
62
|
// apiKey: config wins, then env var — never logged, never in CLI args
|
|
43
63
|
const apiKey =
|
|
44
|
-
(typeof
|
|
64
|
+
(typeof resolved.apiKey === "string" && resolved.apiKey.trim()) ||
|
|
45
65
|
(typeof process.env.NMEM_API_KEY === "string" &&
|
|
46
66
|
process.env.NMEM_API_KEY.trim()) ||
|
|
47
67
|
"";
|
|
48
68
|
|
|
49
69
|
return {
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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)))
|
|
56
82
|
: 300,
|
|
57
|
-
|
|
58
|
-
typeof
|
|
59
|
-
Number.isFinite(
|
|
60
|
-
? Math.min(20, Math.max(1, Math.trunc(
|
|
83
|
+
maxContextResults:
|
|
84
|
+
typeof resolved.maxContextResults === "number" &&
|
|
85
|
+
Number.isFinite(resolved.maxContextResults)
|
|
86
|
+
? Math.min(20, Math.max(1, Math.trunc(resolved.maxContextResults)))
|
|
61
87
|
: 5,
|
|
62
88
|
apiUrl,
|
|
63
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
|
@@ -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;
|
|
@@ -293,7 +293,7 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
|
|
|
293
293
|
// 2. Triage + distill: language-agnostic LLM-based capture.
|
|
294
294
|
// Defensive guard — registration in index.js already gates on autoCapture,
|
|
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 {
|
|
@@ -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);
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
export function createThreadFetchTool(client, logger) {
|
|
2
|
+
return {
|
|
3
|
+
name: "nowledge_mem_thread_fetch",
|
|
4
|
+
description:
|
|
5
|
+
"Fetch full messages from a conversation thread. Use to read the complete context " +
|
|
6
|
+
"around a memory — pass the sourceThreadId from memory_search or memory_get results, " +
|
|
7
|
+
"or a threadId from nowledge_mem_thread_search. " +
|
|
8
|
+
"Supports pagination for long conversations: set offset to skip earlier messages. " +
|
|
9
|
+
"Useful for progressive retrieval — fetch the first page, then request more if needed.",
|
|
10
|
+
parameters: {
|
|
11
|
+
type: "object",
|
|
12
|
+
properties: {
|
|
13
|
+
threadId: {
|
|
14
|
+
type: "string",
|
|
15
|
+
description:
|
|
16
|
+
"Thread ID from nowledge_mem_thread_search, or sourceThreadId from memory_search / memory_get results",
|
|
17
|
+
},
|
|
18
|
+
offset: {
|
|
19
|
+
type: "number",
|
|
20
|
+
description: "Skip first N messages (for pagination, default 0)",
|
|
21
|
+
},
|
|
22
|
+
limit: {
|
|
23
|
+
type: "number",
|
|
24
|
+
description: "Max messages to return (1-200, default 50)",
|
|
25
|
+
},
|
|
26
|
+
},
|
|
27
|
+
required: ["threadId"],
|
|
28
|
+
},
|
|
29
|
+
async execute(_toolCallId, params) {
|
|
30
|
+
const safeParams = params && typeof params === "object" ? params : {};
|
|
31
|
+
const threadId = String(safeParams.threadId ?? "").trim();
|
|
32
|
+
const offset = Math.max(
|
|
33
|
+
0,
|
|
34
|
+
Math.trunc(Number(safeParams.offset ?? 0) || 0),
|
|
35
|
+
);
|
|
36
|
+
const limit = Math.min(
|
|
37
|
+
200,
|
|
38
|
+
Math.max(1, Math.trunc(Number(safeParams.limit ?? 50) || 50)),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
if (!threadId) {
|
|
42
|
+
return {
|
|
43
|
+
content: [
|
|
44
|
+
{
|
|
45
|
+
type: "text",
|
|
46
|
+
text: JSON.stringify({
|
|
47
|
+
threadId: "",
|
|
48
|
+
error: "threadId is required",
|
|
49
|
+
}),
|
|
50
|
+
},
|
|
51
|
+
],
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
try {
|
|
56
|
+
const thread = await client.fetchThread(threadId, {
|
|
57
|
+
offset,
|
|
58
|
+
limit,
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
const hasMore =
|
|
62
|
+
thread.messageCount > 0 &&
|
|
63
|
+
offset + thread.messages.length < thread.messageCount;
|
|
64
|
+
|
|
65
|
+
const result = {
|
|
66
|
+
threadId: thread.threadId,
|
|
67
|
+
title: thread.title,
|
|
68
|
+
source: thread.source,
|
|
69
|
+
totalMessages: thread.messageCount,
|
|
70
|
+
offset,
|
|
71
|
+
limit,
|
|
72
|
+
returnedMessages: thread.messages.length,
|
|
73
|
+
hasMore,
|
|
74
|
+
messages: thread.messages,
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
return {
|
|
78
|
+
content: [
|
|
79
|
+
{
|
|
80
|
+
type: "text",
|
|
81
|
+
text: JSON.stringify(result, null, 2),
|
|
82
|
+
},
|
|
83
|
+
],
|
|
84
|
+
details: {
|
|
85
|
+
threadId: thread.threadId,
|
|
86
|
+
messageCount: thread.messages.length,
|
|
87
|
+
},
|
|
88
|
+
};
|
|
89
|
+
} catch (err) {
|
|
90
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
91
|
+
logger.error(`nowledge_mem_thread_fetch failed: ${message}`);
|
|
92
|
+
return {
|
|
93
|
+
content: [
|
|
94
|
+
{
|
|
95
|
+
type: "text",
|
|
96
|
+
text: JSON.stringify({
|
|
97
|
+
threadId,
|
|
98
|
+
error: message,
|
|
99
|
+
}),
|
|
100
|
+
},
|
|
101
|
+
],
|
|
102
|
+
};
|
|
103
|
+
}
|
|
104
|
+
},
|
|
105
|
+
};
|
|
106
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
function truncateSnippet(text, maxChars = 400) {
|
|
2
|
+
const value = String(text || "").trim();
|
|
3
|
+
if (!value) return "";
|
|
4
|
+
if (value.length <= maxChars) return value;
|
|
5
|
+
return `${value.slice(0, maxChars)}…`;
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export function createThreadSearchTool(client, logger) {
|
|
9
|
+
return {
|
|
10
|
+
name: "nowledge_mem_thread_search",
|
|
11
|
+
description:
|
|
12
|
+
"Search past conversations by keyword. Use when the user asks about a past discussion, " +
|
|
13
|
+
"wants to find a conversation from a specific time, or when you want to explore the full context " +
|
|
14
|
+
"around a topic beyond what memory_search returned. " +
|
|
15
|
+
"Returns matched threads with message snippets and relevance scores. " +
|
|
16
|
+
"To read full messages from a thread, pass its threadId to nowledge_mem_thread_fetch.",
|
|
17
|
+
parameters: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {
|
|
20
|
+
query: {
|
|
21
|
+
type: "string",
|
|
22
|
+
description: "Search keywords or natural language query",
|
|
23
|
+
},
|
|
24
|
+
limit: {
|
|
25
|
+
type: "number",
|
|
26
|
+
description: "Max threads to return (1-20, default 5)",
|
|
27
|
+
},
|
|
28
|
+
source: {
|
|
29
|
+
type: "string",
|
|
30
|
+
description:
|
|
31
|
+
"Filter by source platform (e.g. 'openclaw', 'claude-code'). Omit to search all sources.",
|
|
32
|
+
},
|
|
33
|
+
},
|
|
34
|
+
required: ["query"],
|
|
35
|
+
},
|
|
36
|
+
async execute(_toolCallId, params) {
|
|
37
|
+
const safeParams = params && typeof params === "object" ? params : {};
|
|
38
|
+
const query = String(safeParams.query ?? "").trim();
|
|
39
|
+
const limit = Math.min(
|
|
40
|
+
20,
|
|
41
|
+
Math.max(1, Math.trunc(Number(safeParams.limit ?? 5) || 5)),
|
|
42
|
+
);
|
|
43
|
+
const rawSource = safeParams.source
|
|
44
|
+
? String(safeParams.source).trim()
|
|
45
|
+
: "";
|
|
46
|
+
const source = rawSource || undefined;
|
|
47
|
+
|
|
48
|
+
if (!query) {
|
|
49
|
+
return {
|
|
50
|
+
content: [
|
|
51
|
+
{
|
|
52
|
+
type: "text",
|
|
53
|
+
text: JSON.stringify({ threads: [], totalFound: 0 }),
|
|
54
|
+
},
|
|
55
|
+
],
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
try {
|
|
60
|
+
const { threads, totalFound } = await client.searchThreadsFull(query, {
|
|
61
|
+
limit,
|
|
62
|
+
source,
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
const results = threads.map((t) => {
|
|
66
|
+
const msgs = (t.matched_messages ?? []).slice(0, 3);
|
|
67
|
+
return {
|
|
68
|
+
threadId: t.thread_id ?? t.id,
|
|
69
|
+
title: t.title || "(untitled thread)",
|
|
70
|
+
source: t.source ?? "unknown",
|
|
71
|
+
messageCount: Number(t.message_count ?? 0),
|
|
72
|
+
lastActivity: t.last_activity ?? null,
|
|
73
|
+
relevanceScore: Number(t.relevance_score ?? 0),
|
|
74
|
+
matchedMessages: msgs.map((m) => ({
|
|
75
|
+
role: m.role,
|
|
76
|
+
snippet: truncateSnippet(m.snippet),
|
|
77
|
+
})),
|
|
78
|
+
};
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
return {
|
|
82
|
+
content: [
|
|
83
|
+
{
|
|
84
|
+
type: "text",
|
|
85
|
+
text: JSON.stringify({ threads: results, totalFound }, null, 2),
|
|
86
|
+
},
|
|
87
|
+
],
|
|
88
|
+
details: { query, threadCount: results.length },
|
|
89
|
+
};
|
|
90
|
+
} catch (err) {
|
|
91
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
92
|
+
logger.error(`nowledge_mem_thread_search failed: ${message}`);
|
|
93
|
+
return {
|
|
94
|
+
content: [
|
|
95
|
+
{
|
|
96
|
+
type: "text",
|
|
97
|
+
text: JSON.stringify({
|
|
98
|
+
threads: [],
|
|
99
|
+
totalFound: 0,
|
|
100
|
+
error: message,
|
|
101
|
+
}),
|
|
102
|
+
},
|
|
103
|
+
],
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
},
|
|
107
|
+
};
|
|
108
|
+
}
|