@nowledge/openclaw-nowledge-mem 0.6.6 → 0.6.9
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 +36 -3
- package/CLAUDE.md +11 -6
- package/README.md +43 -20
- package/openclaw.plugin.json +18 -8
- package/package.json +1 -1
- package/src/config.js +132 -111
- package/src/hooks/capture.js +7 -2
- package/src/hooks/recall.js +147 -9
- package/src/index.js +4 -0
- package/src/tools/connections.js +1 -3
- package/src/tools/status.js +152 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,15 +2,48 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## [0.6.9] - 2026-03-05
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- **Auto-recall searched with full prompt instead of user message**: `event.prompt` contains the entire conversation history (system prompt + all prior turns), making recalled memories irrelevant as conversation grows. Now uses a tiered query strategy: substantial messages (>=40 chars) search alone; short messages (3-39 chars, likely follow-ups like "explain that more") include the last 3 messages as context for topic grounding; tiny messages (<3 chars) skip recall. Falls back to truncated `event.prompt` (500 chars) only when `event.messages` is unavailable.
|
|
10
|
+
|
|
11
|
+
### Added
|
|
12
|
+
|
|
13
|
+
- **`recallMinScore` config option** (0-100, default 0): Minimum relevance score threshold for auto-recalled memories. Set to e.g. 30 to filter out low-confidence results. Configurable via OpenClaw Config UI, config file, or `NMEM_RECALL_MIN_SCORE` env var.
|
|
14
|
+
|
|
15
|
+
## [0.6.8] - 2026-02-27
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- **Config priority flipped**: `~/.nowledge-mem/openclaw.json` now takes priority over OpenClaw plugin settings. Most users configure via OpenClaw UI (no file needed). Users who create the file get predictable overrides.
|
|
20
|
+
- **No auto-created config file**: The plugin no longer creates `~/.nowledge-mem/openclaw.json` on first startup. OpenClaw's settings UI works out of the box. Create the file manually when you need persistent or scripted config.
|
|
21
|
+
- **Status tool shows config sources**: `nowledge_mem_status` now shows where each setting comes from (`file`, `pluginConfig`, `env`, or `default`).
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
|
|
25
|
+
- **configSchema broke OpenClaw form UI**: `additionalProperties: true` (added in 0.6.7) caused OpenClaw's config form to show "Unsupported schema mode. Use Raw mode." Removed the field entirely - JSON Schema defaults to allowing additional properties when omitted, so typos still don't crash the plugin, and the form renders normally.
|
|
26
|
+
|
|
27
|
+
## [0.6.7] - 2026-02-27
|
|
28
|
+
|
|
29
|
+
### Added
|
|
30
|
+
|
|
31
|
+
- `nowledge_mem_status` tool: verify effective config, backend connectivity, and version. Shows mode (local/remote), whether API key is set, backend health, and all config values. 10 tools total.
|
|
32
|
+
|
|
33
|
+
### Fixed
|
|
34
|
+
|
|
35
|
+
- **configSchema too strict**: `additionalProperties` changed from `false` to `true`. Previously, a typo in openclaw.json config (e.g. `sesionContext`) caused the plugin to silently fail to load. Now OpenClaw passes validation and the plugin's own parser gives a descriptive error listing valid keys.
|
|
36
|
+
- **apiUrl port typo**: configSchema description had port 14142 instead of 14242.
|
|
37
|
+
|
|
5
38
|
## [0.6.6] - 2026-02-27
|
|
6
39
|
|
|
7
40
|
### Changed - Config file + env var cascade
|
|
8
41
|
|
|
9
42
|
**Config file at `~/.nowledge-mem/openclaw.json`**
|
|
10
43
|
|
|
11
|
-
The plugin now reads configuration from a dedicated config file, independent of OpenClaw's `openclaw.json`. The file is auto-created on first startup with sensible defaults. If you had settings in OpenClaw's plugin config, those values are automatically
|
|
44
|
+
The plugin now reads configuration from a dedicated config file, independent of OpenClaw's `openclaw.json`. The file is auto-created on first startup with sensible defaults. If you had settings in OpenClaw's plugin config, those values are automatically seeded into the file on first run. No manual steps needed.
|
|
12
45
|
|
|
13
|
-
This
|
|
46
|
+
This gives you a single, easy-to-find place for all Nowledge Mem settings. The file persists across plugin reinstalls and OpenClaw upgrades.
|
|
14
47
|
|
|
15
48
|
**Config cascade** (4 layers, highest to lowest priority):
|
|
16
49
|
1. `api.pluginConfig` - from OpenClaw's plugin config (when supported)
|
|
@@ -34,7 +67,7 @@ This ensures your settings survive OpenClaw version upgrades that may strip cust
|
|
|
34
67
|
|
|
35
68
|
### Added - Thread tools + sourceThreadId linkage
|
|
36
69
|
|
|
37
|
-
**Two new tools for conversation retrieval.**
|
|
70
|
+
**Two new tools for conversation retrieval.**
|
|
38
71
|
|
|
39
72
|
- `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.
|
|
40
73
|
- `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.
|
package/CLAUDE.md
CHANGED
|
@@ -28,7 +28,7 @@ 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 - config cascade:
|
|
31
|
+
config.js - config cascade: ~/.nowledge-mem/openclaw.json > pluginConfig > env vars > defaults
|
|
32
32
|
hooks/
|
|
33
33
|
behavioral.js - always-on behavioral guidance (~50 tokens/turn)
|
|
34
34
|
recall.js - before_prompt_build: inject Working Memory + recalled memories
|
|
@@ -44,10 +44,10 @@ src/
|
|
|
44
44
|
thread-search.js - search past conversations by keyword
|
|
45
45
|
thread-fetch.js - fetch full messages from a thread with pagination
|
|
46
46
|
openclaw.plugin.json - manifest + config schema (version, uiHints, configSchema)
|
|
47
|
-
~/.nowledge-mem/openclaw.json - user config file (
|
|
47
|
+
~/.nowledge-mem/openclaw.json - optional user config file (overrides OpenClaw settings when present)
|
|
48
48
|
```
|
|
49
49
|
|
|
50
|
-
## Tool Surface (
|
|
50
|
+
## Tool Surface (10 tools)
|
|
51
51
|
|
|
52
52
|
### OpenClaw Memory Slot (required for system prompt activation)
|
|
53
53
|
- `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"`.
|
|
@@ -64,6 +64,9 @@ openclaw.plugin.json - manifest + config schema (version, uiHints, configSchema)
|
|
|
64
64
|
- `nowledge_mem_thread_search` - search past conversations by keyword. Returns threads with matched message snippets, relevance scores, and message counts. Supports `source` filter.
|
|
65
65
|
- `nowledge_mem_thread_fetch` - fetch full messages from a specific thread. Supports pagination via `offset` + `limit` for progressive retrieval of long conversations.
|
|
66
66
|
|
|
67
|
+
### Diagnostics
|
|
68
|
+
- `nowledge_mem_status` - show effective config (mode, apiUrl, apiKey set, sessionContext, sessionDigest, etc.), backend connectivity, and version. No parameters.
|
|
69
|
+
|
|
67
70
|
## Hook Surface
|
|
68
71
|
|
|
69
72
|
- `before_prompt_build` (always-on) - behavioral guidance: brief note telling agent to save/search proactively. Adjusts when sessionContext is on to avoid redundant searches.
|
|
@@ -80,7 +83,7 @@ openclaw.plugin.json - manifest + config schema (version, uiHints, configSchema)
|
|
|
80
83
|
|
|
81
84
|
## Config Keys
|
|
82
85
|
|
|
83
|
-
|
|
86
|
+
Optional config file at `~/.nowledge-mem/openclaw.json`. Falls through to OpenClaw plugin settings when the file is absent.
|
|
84
87
|
|
|
85
88
|
| Key | Type | Default | Env Var | Description |
|
|
86
89
|
|-----|------|---------|---------|-------------|
|
|
@@ -88,10 +91,13 @@ Config file at `~/.nowledge-mem/openclaw.json` (auto-created on first run). Also
|
|
|
88
91
|
| `sessionDigest` | boolean | `true` | `NMEM_SESSION_DIGEST` | Thread capture + LLM distillation at session end |
|
|
89
92
|
| `digestMinInterval` | integer 0-86400 | `300` | `NMEM_DIGEST_MIN_INTERVAL` | Minimum seconds between session digests |
|
|
90
93
|
| `maxContextResults` | integer 1-20 | `5` | `NMEM_MAX_CONTEXT_RESULTS` | How many memories to inject at prompt time |
|
|
94
|
+
| `recallMinScore` | integer 0-100 | `0` | `NMEM_RECALL_MIN_SCORE` | Min relevance score (%) to include in auto-recall |
|
|
91
95
|
| `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
|
|
92
96
|
| `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
|
|
93
97
|
|
|
94
|
-
**Priority**:
|
|
98
|
+
**Priority**: config file > pluginConfig > env var > default.
|
|
99
|
+
|
|
100
|
+
`parseConfig()` returns `_sources` object tracking where each value came from (`"file"`, `"pluginConfig"`, `"env"`, `"default"`). Used by `nowledge_mem_status` tool.
|
|
95
101
|
|
|
96
102
|
Legacy aliases accepted silently in all sources for backward compat:
|
|
97
103
|
`autoRecall`→`sessionContext`, `autoCapture`→`sessionDigest`, `captureMinInterval`→`digestMinInterval`, `maxRecallResults`→`maxContextResults`.
|
|
@@ -99,7 +105,6 @@ Legacy aliases accepted silently in all sources for backward compat:
|
|
|
99
105
|
### Credential Handling Rules
|
|
100
106
|
- `apiKey` → ONLY via child process env (`NMEM_API_KEY`). Never CLI arg, never logged.
|
|
101
107
|
- `apiUrl` → passed as `--api-url` flag to CLI (not a secret).
|
|
102
|
-
- Config values win over environment variables.
|
|
103
108
|
- `_spawnEnv()` builds per-spawn env; `_apiUrlArgs()` adds `--api-url` when non-default.
|
|
104
109
|
|
|
105
110
|
## CLI Surface (nmem commands used by plugin)
|
package/README.md
CHANGED
|
@@ -32,13 +32,15 @@ Start Nowledge Mem desktop app or run `nmem serve`, then enable the plugin:
|
|
|
32
32
|
}
|
|
33
33
|
```
|
|
34
34
|
|
|
35
|
-
That's it. The agent gets
|
|
35
|
+
That's it. The agent gets 10 tools and calls them on demand. No extra tokens wasted.
|
|
36
36
|
|
|
37
37
|
### Remote mode
|
|
38
38
|
|
|
39
39
|
Connect to a Nowledge Mem server running elsewhere (a VPS, a home server, or a shared team instance). See [remote access guide](https://mem.nowledge.co/docs/remote-access) for server setup.
|
|
40
40
|
|
|
41
|
-
Enable the plugin the same way as local mode, then set
|
|
41
|
+
Enable the plugin the same way as local mode, then set `apiUrl` and `apiKey` in the OpenClaw plugin settings.
|
|
42
|
+
|
|
43
|
+
Or use a config file at `~/.nowledge-mem/openclaw.json`:
|
|
42
44
|
|
|
43
45
|
```json
|
|
44
46
|
{
|
|
@@ -49,6 +51,11 @@ Enable the plugin the same way as local mode, then set your remote server in `~/
|
|
|
49
51
|
|
|
50
52
|
The `apiKey` is injected as `NMEM_API_KEY` into the nmem CLI process. Never passed as a CLI argument, never logged.
|
|
51
53
|
|
|
54
|
+
### Configure via WebUI of OpenClaw
|
|
55
|
+
|
|
56
|
+
<img width="1192" height="2007" alt="openclaw-config" src="https://github.com/user-attachments/assets/a4abd855-db56-4a1f-b0ac-0114909d0c20" />
|
|
57
|
+
|
|
58
|
+
|
|
52
59
|
## How It Works
|
|
53
60
|
|
|
54
61
|
### Per-Turn Flow
|
|
@@ -62,7 +69,7 @@ flowchart TD
|
|
|
62
69
|
B --> C["Behavioral guidance(always, ~110 tokens)"]
|
|
63
70
|
B -.->|"sessionContext: true"| D["Working Memory +relevant memories (~1-2 KB)"]
|
|
64
71
|
|
|
65
|
-
C --> E["Agent processes message(
|
|
72
|
+
C --> E["Agent processes message(10 tools available)"]
|
|
66
73
|
D --> E
|
|
67
74
|
|
|
68
75
|
E --> F{"Needs past context?"}
|
|
@@ -91,6 +98,7 @@ The behavioral hook nudges the agent to **search before answering** and **save a
|
|
|
91
98
|
| Memory has `sourceThreadId` | `nowledge_mem_thread_fetch` | Fetch full source conversation. Pagination via `offset` + `limit`. |
|
|
92
99
|
| "Find our discussion about X" | `nowledge_mem_thread_search` | Search past conversations by keyword. Returns matched snippets. |
|
|
93
100
|
| "Forget X" | `nowledge_mem_forget` | Delete by ID or search query. |
|
|
101
|
+
| "Is my setup working?" | `nowledge_mem_status` | Show effective config, backend connectivity, and version. |
|
|
94
102
|
|
|
95
103
|
### Session Lifecycle (Automatic Capture)
|
|
96
104
|
|
|
@@ -153,21 +161,21 @@ flowchart LR
|
|
|
153
161
|
subgraph "default" ["Default (recommended)"]
|
|
154
162
|
direction TB
|
|
155
163
|
D1["Every turn: behavioral guidance (~50 tokens)"]
|
|
156
|
-
D2["Agent calls
|
|
164
|
+
D2["Agent calls 10 tools on demand"]
|
|
157
165
|
D3["Session end: thread capture + LLM distillation"]
|
|
158
166
|
end
|
|
159
167
|
|
|
160
168
|
subgraph context ["Session Context"]
|
|
161
169
|
direction TB
|
|
162
170
|
C1["Every turn: guidance + Working Memory+ recalled memories (~1-2 KB)"]
|
|
163
|
-
C2["Agent calls
|
|
171
|
+
C2["Agent calls 10 tools on demand"]
|
|
164
172
|
C3["Session end: thread capture + LLM distillation"]
|
|
165
173
|
end
|
|
166
174
|
|
|
167
175
|
subgraph minimal ["Minimal"]
|
|
168
176
|
direction TB
|
|
169
177
|
M1["Every turn: behavioral guidance (~50 tokens)"]
|
|
170
|
-
M2["Agent calls
|
|
178
|
+
M2["Agent calls 10 tools on demand"]
|
|
171
179
|
M3["No automatic capture"]
|
|
172
180
|
end
|
|
173
181
|
```
|
|
@@ -254,8 +262,8 @@ The plugin supports three modes. Choose based on how much you want to guarantee
|
|
|
254
262
|
|
|
255
263
|
| Mode | Config | Behavior | Tradeoff |
|
|
256
264
|
|------|--------|----------|----------|
|
|
257
|
-
| **Default** (recommended) | `sessionContext: false, sessionDigest: true` | Agent calls
|
|
258
|
-
| **Session context** | `sessionContext: true` | Working Memory + relevant memories injected at prompt time, plus all
|
|
265
|
+
| **Default** (recommended) | `sessionContext: false, sessionDigest: true` | Agent calls 10 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 10 tools still available. | ~1-2 KB/turn. Guarantees context is always present. Best for short sessions or critical workflows. |
|
|
259
267
|
| **Minimal** | `sessionDigest: false` | Tool-only, no automatic capture. | Zero overhead beyond guidance. Use when you handle memory manually. |
|
|
260
268
|
|
|
261
269
|
**Which mode should you use?**
|
|
@@ -319,7 +327,23 @@ openclaw nowledge-mem status
|
|
|
319
327
|
|
|
320
328
|
## Configuration
|
|
321
329
|
|
|
322
|
-
|
|
330
|
+
No config needed to get started. The defaults work for local mode.
|
|
331
|
+
|
|
332
|
+
To change settings, use the OpenClaw plugin settings UI. Changes take effect on restart.
|
|
333
|
+
|
|
334
|
+
| Key | Type | Default | Description |
|
|
335
|
+
|-----|------|---------|-------------|
|
|
336
|
+
| `sessionContext` | boolean | `false` | Inject Working Memory + relevant memories at prompt time |
|
|
337
|
+
| `sessionDigest` | boolean | `true` | Thread capture + LLM distillation at session end |
|
|
338
|
+
| `digestMinInterval` | integer | `300` | Minimum seconds between session digests for the same thread (0-86400) |
|
|
339
|
+
| `maxContextResults` | integer | `5` | Max memories to inject at prompt time (1-20, only used when sessionContext is enabled) |
|
|
340
|
+
| `recallMinScore` | integer | `0` | Min relevance score (0-100%) to include in auto-recall. 0 = include all |
|
|
341
|
+
| `apiUrl` | string | `""` | Remote server URL. Empty = local (`http://127.0.0.1:14242`) |
|
|
342
|
+
| `apiKey` | string | `""` | API key for remote access. Injected as `NMEM_API_KEY` env var, never logged |
|
|
343
|
+
|
|
344
|
+
### Advanced: config file
|
|
345
|
+
|
|
346
|
+
For persistent or scripted config, create `~/.nowledge-mem/openclaw.json`:
|
|
323
347
|
|
|
324
348
|
```json
|
|
325
349
|
{
|
|
@@ -327,21 +351,13 @@ On first startup, the plugin creates a config file at `~/.nowledge-mem/openclaw.
|
|
|
327
351
|
"sessionDigest": true,
|
|
328
352
|
"digestMinInterval": 300,
|
|
329
353
|
"maxContextResults": 5,
|
|
354
|
+
"recallMinScore": 0,
|
|
330
355
|
"apiUrl": "",
|
|
331
356
|
"apiKey": ""
|
|
332
357
|
}
|
|
333
358
|
```
|
|
334
359
|
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
| Key | Type | Default | Description |
|
|
338
|
-
|-----|------|---------|-------------|
|
|
339
|
-
| `sessionContext` | boolean | `false` | Inject Working Memory + relevant memories at prompt time |
|
|
340
|
-
| `sessionDigest` | boolean | `true` | Thread capture + LLM distillation at session end |
|
|
341
|
-
| `digestMinInterval` | integer | `300` | Minimum seconds between session digests for the same thread (0-86400) |
|
|
342
|
-
| `maxContextResults` | integer | `5` | Max memories to inject at prompt time (1-20, only used when sessionContext is enabled) |
|
|
343
|
-
| `apiUrl` | string | `""` | Remote server URL. Empty = local (`http://127.0.0.1:14242`) |
|
|
344
|
-
| `apiKey` | string | `""` | API key for remote access. Injected as `NMEM_API_KEY` env var, never logged |
|
|
360
|
+
The config file takes priority over OpenClaw settings. Only the keys you include are overridden; missing keys fall through to OpenClaw settings, then env vars, then defaults.
|
|
345
361
|
|
|
346
362
|
### Advanced: environment variables
|
|
347
363
|
|
|
@@ -352,11 +368,18 @@ NMEM_SESSION_CONTEXT=true
|
|
|
352
368
|
NMEM_SESSION_DIGEST=true
|
|
353
369
|
NMEM_DIGEST_MIN_INTERVAL=300
|
|
354
370
|
NMEM_MAX_CONTEXT_RESULTS=5
|
|
371
|
+
NMEM_RECALL_MIN_SCORE=0
|
|
355
372
|
NMEM_API_URL=https://...
|
|
356
373
|
NMEM_API_KEY=your-key
|
|
357
374
|
```
|
|
358
375
|
|
|
359
|
-
|
|
376
|
+
### Priority
|
|
377
|
+
|
|
378
|
+
```
|
|
379
|
+
config file > OpenClaw settings > env vars > defaults
|
|
380
|
+
```
|
|
381
|
+
|
|
382
|
+
Use `nowledge_mem_status` (or `openclaw nowledge-mem status`) to see where each value comes from.
|
|
360
383
|
|
|
361
384
|
## What Makes This Different
|
|
362
385
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -1,15 +1,15 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-nowledge-mem",
|
|
3
|
-
"version": "0.6.
|
|
3
|
+
"version": "0.6.9",
|
|
4
4
|
"kind": "memory",
|
|
5
5
|
"uiHints": {
|
|
6
6
|
"sessionContext": {
|
|
7
7
|
"label": "Session context injection",
|
|
8
|
-
"help": "Inject Working Memory and relevant memories at prompt time. Off by default
|
|
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
10
|
"sessionDigest": {
|
|
11
11
|
"label": "Session digest at end",
|
|
12
|
-
"help": "Capture conversation threads and distill key memories via LLM at session end. On by default
|
|
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
14
|
"digestMinInterval": {
|
|
15
15
|
"label": "Minimum digest interval (seconds)",
|
|
@@ -17,7 +17,11 @@
|
|
|
17
17
|
},
|
|
18
18
|
"maxContextResults": {
|
|
19
19
|
"label": "Max context results",
|
|
20
|
-
"help": "How many memories to inject for each context cycle (1
|
|
20
|
+
"help": "How many memories to inject for each context cycle (1-20)"
|
|
21
|
+
},
|
|
22
|
+
"recallMinScore": {
|
|
23
|
+
"label": "Min recall score (%)",
|
|
24
|
+
"help": "Only inject memories with relevance score above this threshold (0-100). Set to 0 to include all results."
|
|
21
25
|
},
|
|
22
26
|
"apiUrl": {
|
|
23
27
|
"label": "Server URL (remote mode)",
|
|
@@ -25,7 +29,7 @@
|
|
|
25
29
|
},
|
|
26
30
|
"apiKey": {
|
|
27
31
|
"label": "API key (remote mode)",
|
|
28
|
-
"help": "Required when connecting to a remote server. Never logged or passed as a CLI argument
|
|
32
|
+
"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
33
|
"secret": true
|
|
30
34
|
}
|
|
31
35
|
},
|
|
@@ -35,7 +39,7 @@
|
|
|
35
39
|
"sessionContext": {
|
|
36
40
|
"type": "boolean",
|
|
37
41
|
"default": false,
|
|
38
|
-
"description": "Inject Working Memory and recalled memories at prompt time. Off by default
|
|
42
|
+
"description": "Inject Working Memory and recalled memories at prompt time. Off by default; the agent queries memory on demand via tools."
|
|
39
43
|
},
|
|
40
44
|
"sessionDigest": {
|
|
41
45
|
"type": "boolean",
|
|
@@ -56,6 +60,13 @@
|
|
|
56
60
|
"maximum": 20,
|
|
57
61
|
"description": "Maximum memories to inject at prompt time (only used when sessionContext is enabled)"
|
|
58
62
|
},
|
|
63
|
+
"recallMinScore": {
|
|
64
|
+
"type": "integer",
|
|
65
|
+
"default": 0,
|
|
66
|
+
"minimum": 0,
|
|
67
|
+
"maximum": 100,
|
|
68
|
+
"description": "Minimum relevance score (0-100%) to include a memory in auto-recall. Set to 0 to include all results."
|
|
69
|
+
},
|
|
59
70
|
"apiUrl": {
|
|
60
71
|
"type": "string",
|
|
61
72
|
"default": "",
|
|
@@ -66,7 +77,6 @@
|
|
|
66
77
|
"default": "",
|
|
67
78
|
"description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var. Never logged."
|
|
68
79
|
}
|
|
69
|
-
}
|
|
70
|
-
"additionalProperties": false
|
|
80
|
+
}
|
|
71
81
|
}
|
|
72
82
|
}
|
package/package.json
CHANGED
package/src/config.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { existsSync, readFileSync } from "node:fs";
|
|
2
2
|
import { homedir } from "node:os";
|
|
3
3
|
import { join } from "node:path";
|
|
4
4
|
|
|
@@ -19,6 +19,7 @@ const ALLOWED_KEYS = new Set([
|
|
|
19
19
|
"sessionDigest",
|
|
20
20
|
"digestMinInterval",
|
|
21
21
|
"maxContextResults",
|
|
22
|
+
"recallMinScore",
|
|
22
23
|
"apiUrl",
|
|
23
24
|
"apiKey",
|
|
24
25
|
// Legacy aliases — accepted but not advertised
|
|
@@ -42,69 +43,14 @@ const ALIAS_KEYS = {
|
|
|
42
43
|
const CONFIG_DIR = join(homedir(), ".nowledge-mem");
|
|
43
44
|
const CONFIG_PATH = join(CONFIG_DIR, "openclaw.json");
|
|
44
45
|
|
|
45
|
-
const DEFAULT_CONFIG = {
|
|
46
|
-
sessionContext: false,
|
|
47
|
-
sessionDigest: true,
|
|
48
|
-
digestMinInterval: 300,
|
|
49
|
-
maxContextResults: 5,
|
|
50
|
-
apiUrl: "",
|
|
51
|
-
apiKey: "",
|
|
52
|
-
};
|
|
53
|
-
|
|
54
46
|
/**
|
|
55
|
-
* Read ~/.nowledge-mem/openclaw.json.
|
|
56
|
-
*
|
|
57
|
-
* (if any) — this migrates settings before OpenClaw strips them.
|
|
58
|
-
* Returns {} on any error — never crashes.
|
|
47
|
+
* Read ~/.nowledge-mem/openclaw.json if it exists.
|
|
48
|
+
* Returns {} when the file is missing or unreadable.
|
|
59
49
|
*/
|
|
60
|
-
function readConfigFile(logger
|
|
50
|
+
function readConfigFile(logger) {
|
|
61
51
|
try {
|
|
62
52
|
if (!existsSync(CONFIG_PATH)) {
|
|
63
|
-
|
|
64
|
-
// Users on older OpenClaw get their values written to the file
|
|
65
|
-
// BEFORE a future OpenClaw update can strip them.
|
|
66
|
-
const initial = { ...DEFAULT_CONFIG };
|
|
67
|
-
if (pluginCfg && typeof pluginCfg === "object") {
|
|
68
|
-
// Resolve aliases in pluginConfig for seeding
|
|
69
|
-
const resolved = { ...pluginCfg };
|
|
70
|
-
for (const [oldKey, newKey] of Object.entries(ALIAS_KEYS)) {
|
|
71
|
-
if (oldKey in resolved) {
|
|
72
|
-
if (!(newKey in resolved)) {
|
|
73
|
-
resolved[newKey] = resolved[oldKey];
|
|
74
|
-
}
|
|
75
|
-
delete resolved[oldKey];
|
|
76
|
-
}
|
|
77
|
-
}
|
|
78
|
-
|
|
79
|
-
if (typeof resolved.sessionContext === "boolean")
|
|
80
|
-
initial.sessionContext = resolved.sessionContext;
|
|
81
|
-
if (typeof resolved.sessionDigest === "boolean")
|
|
82
|
-
initial.sessionDigest = resolved.sessionDigest;
|
|
83
|
-
if (
|
|
84
|
-
typeof resolved.digestMinInterval === "number" &&
|
|
85
|
-
Number.isFinite(resolved.digestMinInterval)
|
|
86
|
-
)
|
|
87
|
-
initial.digestMinInterval = resolved.digestMinInterval;
|
|
88
|
-
if (
|
|
89
|
-
typeof resolved.maxContextResults === "number" &&
|
|
90
|
-
Number.isFinite(resolved.maxContextResults)
|
|
91
|
-
)
|
|
92
|
-
initial.maxContextResults = resolved.maxContextResults;
|
|
93
|
-
if (typeof resolved.apiUrl === "string" && resolved.apiUrl.trim())
|
|
94
|
-
initial.apiUrl = resolved.apiUrl.trim();
|
|
95
|
-
// apiKey intentionally NOT written to disk — keep in env/pluginConfig only
|
|
96
|
-
}
|
|
97
|
-
|
|
98
|
-
mkdirSync(CONFIG_DIR, { recursive: true });
|
|
99
|
-
writeFileSync(
|
|
100
|
-
CONFIG_PATH,
|
|
101
|
-
JSON.stringify(initial, null, 2) + "\n",
|
|
102
|
-
"utf-8",
|
|
103
|
-
);
|
|
104
|
-
logger?.info?.(
|
|
105
|
-
`nowledge-mem: created config at ${CONFIG_PATH} — edit to customize`,
|
|
106
|
-
);
|
|
107
|
-
return { ...initial };
|
|
53
|
+
return {};
|
|
108
54
|
}
|
|
109
55
|
const content = readFileSync(CONFIG_PATH, "utf-8").trim();
|
|
110
56
|
if (!content) return {};
|
|
@@ -148,7 +94,8 @@ function pickBool(obj, key) {
|
|
|
148
94
|
}
|
|
149
95
|
|
|
150
96
|
function pickNum(obj, key) {
|
|
151
|
-
if (typeof obj[key] === "number" && Number.isFinite(obj[key]))
|
|
97
|
+
if (typeof obj[key] === "number" && Number.isFinite(obj[key]))
|
|
98
|
+
return obj[key];
|
|
152
99
|
return undefined;
|
|
153
100
|
}
|
|
154
101
|
|
|
@@ -166,14 +113,30 @@ function resolveAliases(obj) {
|
|
|
166
113
|
return resolved;
|
|
167
114
|
}
|
|
168
115
|
|
|
116
|
+
/**
|
|
117
|
+
* Return the first option whose value is not undefined.
|
|
118
|
+
* Each option is { value, source } where source is a label like
|
|
119
|
+
* "file", "pluginConfig", "env", or "default".
|
|
120
|
+
*/
|
|
121
|
+
function firstDefined(...options) {
|
|
122
|
+
for (const opt of options) {
|
|
123
|
+
if (opt.value !== undefined) return opt;
|
|
124
|
+
}
|
|
125
|
+
return options[options.length - 1];
|
|
126
|
+
}
|
|
127
|
+
|
|
169
128
|
// -------------------------------------------------------------------------
|
|
170
129
|
|
|
171
130
|
/**
|
|
172
131
|
* Parse plugin config with cascade:
|
|
173
|
-
*
|
|
132
|
+
* ~/.nowledge-mem/openclaw.json > pluginConfig > env vars > defaults
|
|
133
|
+
*
|
|
134
|
+
* The config file (if it exists) takes highest priority so users who
|
|
135
|
+
* create it get predictable behavior. Most users won't have one and
|
|
136
|
+
* will configure via OpenClaw's plugin settings UI (pluginConfig).
|
|
174
137
|
*
|
|
175
138
|
* Canonical keys: sessionContext, sessionDigest, digestMinInterval,
|
|
176
|
-
* maxContextResults, apiUrl, apiKey
|
|
139
|
+
* maxContextResults, recallMinScore, apiUrl, apiKey
|
|
177
140
|
*
|
|
178
141
|
* Legacy aliases (accepted from all sources; never shown in docs):
|
|
179
142
|
* autoRecall → sessionContext
|
|
@@ -184,21 +147,21 @@ function resolveAliases(obj) {
|
|
|
184
147
|
* Environment variables (all optional):
|
|
185
148
|
* NMEM_SESSION_CONTEXT — true/1/yes to enable (alias: NMEM_AUTO_RECALL)
|
|
186
149
|
* NMEM_SESSION_DIGEST — true/1/yes to enable (alias: NMEM_AUTO_CAPTURE)
|
|
187
|
-
* NMEM_DIGEST_MIN_INTERVAL — seconds (0
|
|
188
|
-
* NMEM_MAX_CONTEXT_RESULTS — integer (1
|
|
150
|
+
* NMEM_DIGEST_MIN_INTERVAL — seconds (0-86400)
|
|
151
|
+
* NMEM_MAX_CONTEXT_RESULTS — integer (1-20)
|
|
152
|
+
* NMEM_RECALL_MIN_SCORE — integer (0-100)
|
|
189
153
|
* NMEM_API_URL — remote server URL
|
|
190
154
|
* NMEM_API_KEY — API key (never logged)
|
|
191
155
|
*/
|
|
192
156
|
export function parseConfig(raw, logger) {
|
|
193
157
|
const pluginCfg = raw && typeof raw === "object" ? raw : {};
|
|
194
|
-
const fileCfg = readConfigFile(logger
|
|
158
|
+
const fileCfg = readConfigFile(logger);
|
|
195
159
|
|
|
196
160
|
// Resolve aliases in both sources
|
|
197
161
|
const resolvedPlugin = resolveAliases(pluginCfg);
|
|
198
162
|
const resolvedFile = resolveAliases(fileCfg);
|
|
199
163
|
|
|
200
164
|
// Strict: reject unknown keys in pluginConfig (typo catcher).
|
|
201
|
-
// File config is our own — validated by shape, no strict check needed.
|
|
202
165
|
const unknownKeys = Object.keys(resolvedPlugin).filter(
|
|
203
166
|
(k) => !ALLOWED_KEYS.has(k),
|
|
204
167
|
);
|
|
@@ -209,62 +172,120 @@ export function parseConfig(raw, logger) {
|
|
|
209
172
|
);
|
|
210
173
|
}
|
|
211
174
|
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
175
|
+
const _sources = {};
|
|
176
|
+
|
|
177
|
+
// --- sessionContext: file > pluginConfig > env > default ---
|
|
178
|
+
const sc = firstDefined(
|
|
179
|
+
{ value: pickBool(resolvedFile, "sessionContext"), source: "file" },
|
|
180
|
+
{
|
|
181
|
+
value: pickBool(resolvedPlugin, "sessionContext"),
|
|
182
|
+
source: "pluginConfig",
|
|
183
|
+
},
|
|
184
|
+
{
|
|
185
|
+
value: envBool("NMEM_SESSION_CONTEXT", "NMEM_AUTO_RECALL"),
|
|
186
|
+
source: "env",
|
|
187
|
+
},
|
|
188
|
+
{ value: false, source: "default" },
|
|
189
|
+
);
|
|
190
|
+
const sessionContext = sc.value;
|
|
191
|
+
_sources.sessionContext = sc.source;
|
|
192
|
+
|
|
193
|
+
// --- sessionDigest: file > pluginConfig > env > default ---
|
|
194
|
+
const sd = firstDefined(
|
|
195
|
+
{ value: pickBool(resolvedFile, "sessionDigest"), source: "file" },
|
|
196
|
+
{
|
|
197
|
+
value: pickBool(resolvedPlugin, "sessionDigest"),
|
|
198
|
+
source: "pluginConfig",
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
value: envBool("NMEM_SESSION_DIGEST", "NMEM_AUTO_CAPTURE"),
|
|
202
|
+
source: "env",
|
|
203
|
+
},
|
|
204
|
+
{ value: true, source: "default" },
|
|
205
|
+
);
|
|
206
|
+
const sessionDigest = sd.value;
|
|
207
|
+
_sources.sessionDigest = sd.source;
|
|
218
208
|
|
|
219
|
-
// ---
|
|
220
|
-
const
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
209
|
+
// --- digestMinInterval: file > pluginConfig > env > default ---
|
|
210
|
+
const dmiEnv =
|
|
211
|
+
envInt("NMEM_DIGEST_MIN_INTERVAL") ?? envInt("NMEM_CAPTURE_MIN_INTERVAL");
|
|
212
|
+
const dmi = firstDefined(
|
|
213
|
+
{ value: pickNum(resolvedFile, "digestMinInterval"), source: "file" },
|
|
214
|
+
{
|
|
215
|
+
value: pickNum(resolvedPlugin, "digestMinInterval"),
|
|
216
|
+
source: "pluginConfig",
|
|
217
|
+
},
|
|
218
|
+
{ value: dmiEnv, source: "env" },
|
|
219
|
+
{ value: 300, source: "default" },
|
|
220
|
+
);
|
|
221
|
+
const digestMinInterval = Math.min(86400, Math.max(0, Math.trunc(dmi.value)));
|
|
222
|
+
_sources.digestMinInterval = dmi.source;
|
|
225
223
|
|
|
226
|
-
// ---
|
|
227
|
-
const
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
224
|
+
// --- maxContextResults: file > pluginConfig > env > default ---
|
|
225
|
+
const mcrEnv =
|
|
226
|
+
envInt("NMEM_MAX_CONTEXT_RESULTS") ?? envInt("NMEM_MAX_RECALL_RESULTS");
|
|
227
|
+
const mcr = firstDefined(
|
|
228
|
+
{ value: pickNum(resolvedFile, "maxContextResults"), source: "file" },
|
|
229
|
+
{
|
|
230
|
+
value: pickNum(resolvedPlugin, "maxContextResults"),
|
|
231
|
+
source: "pluginConfig",
|
|
232
|
+
},
|
|
233
|
+
{ value: mcrEnv, source: "env" },
|
|
234
|
+
{ value: 5, source: "default" },
|
|
235
|
+
);
|
|
236
|
+
const maxContextResults = Math.min(20, Math.max(1, Math.trunc(mcr.value)));
|
|
237
|
+
_sources.maxContextResults = mcr.source;
|
|
236
238
|
|
|
237
|
-
// ---
|
|
238
|
-
const
|
|
239
|
-
|
|
240
|
-
pickNum(resolvedFile, "
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
239
|
+
// --- recallMinScore: file > pluginConfig > env > default ---
|
|
240
|
+
const rmsEnv = envInt("NMEM_RECALL_MIN_SCORE");
|
|
241
|
+
const rms = firstDefined(
|
|
242
|
+
{ value: pickNum(resolvedFile, "recallMinScore"), source: "file" },
|
|
243
|
+
{
|
|
244
|
+
value: pickNum(resolvedPlugin, "recallMinScore"),
|
|
245
|
+
source: "pluginConfig",
|
|
246
|
+
},
|
|
247
|
+
{ value: rmsEnv, source: "env" },
|
|
248
|
+
{ value: 0, source: "default" },
|
|
249
|
+
);
|
|
250
|
+
const recallMinScore = Math.min(100, Math.max(0, Math.trunc(rms.value)));
|
|
251
|
+
_sources.recallMinScore = rms.source;
|
|
247
252
|
|
|
248
|
-
// --- apiUrl:
|
|
249
|
-
const
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
253
|
+
// --- apiUrl: file > pluginConfig > env > "" ---
|
|
254
|
+
const fileUrl =
|
|
255
|
+
typeof resolvedFile.apiUrl === "string" && resolvedFile.apiUrl.trim();
|
|
256
|
+
const pluginUrl =
|
|
257
|
+
typeof resolvedPlugin.apiUrl === "string" && resolvedPlugin.apiUrl.trim();
|
|
258
|
+
const au = firstDefined(
|
|
259
|
+
{ value: fileUrl || undefined, source: "file" },
|
|
260
|
+
{ value: pluginUrl || undefined, source: "pluginConfig" },
|
|
261
|
+
{ value: envStr("NMEM_API_URL"), source: "env" },
|
|
262
|
+
{ value: "", source: "default" },
|
|
263
|
+
);
|
|
264
|
+
const apiUrl = au.value;
|
|
265
|
+
_sources.apiUrl = au.source;
|
|
254
266
|
|
|
255
|
-
// --- apiKey:
|
|
256
|
-
const
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
267
|
+
// --- apiKey: file > pluginConfig > env > "" ---
|
|
268
|
+
const fileKey =
|
|
269
|
+
typeof resolvedFile.apiKey === "string" && resolvedFile.apiKey.trim();
|
|
270
|
+
const pluginKey =
|
|
271
|
+
typeof resolvedPlugin.apiKey === "string" && resolvedPlugin.apiKey.trim();
|
|
272
|
+
const ak = firstDefined(
|
|
273
|
+
{ value: fileKey || undefined, source: "file" },
|
|
274
|
+
{ value: pluginKey || undefined, source: "pluginConfig" },
|
|
275
|
+
{ value: envStr("NMEM_API_KEY"), source: "env" },
|
|
276
|
+
{ value: "", source: "default" },
|
|
277
|
+
);
|
|
278
|
+
const apiKey = ak.value;
|
|
279
|
+
_sources.apiKey = ak.source;
|
|
261
280
|
|
|
262
281
|
return {
|
|
263
282
|
sessionContext,
|
|
264
283
|
sessionDigest,
|
|
265
284
|
digestMinInterval,
|
|
266
285
|
maxContextResults,
|
|
286
|
+
recallMinScore,
|
|
267
287
|
apiUrl,
|
|
268
288
|
apiKey,
|
|
289
|
+
_sources,
|
|
269
290
|
};
|
|
270
291
|
}
|
package/src/hooks/capture.js
CHANGED
|
@@ -250,7 +250,10 @@ function buildConversationText(normalized) {
|
|
|
250
250
|
const parts = [];
|
|
251
251
|
let total = 0;
|
|
252
252
|
for (const m of normalized) {
|
|
253
|
-
const text = truncate(
|
|
253
|
+
const text = truncate(
|
|
254
|
+
m.fullContent || m.content,
|
|
255
|
+
MAX_DISTILL_MESSAGE_CHARS,
|
|
256
|
+
);
|
|
254
257
|
const line = `${m.role}: ${text}`;
|
|
255
258
|
if (total + line.length > MAX_CONVERSATION_CHARS) break;
|
|
256
259
|
parts.push(line);
|
|
@@ -297,7 +300,9 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
|
|
|
297
300
|
|
|
298
301
|
// Skip when no new messages were added (e.g. heartbeat re-sync).
|
|
299
302
|
if (!result || result.messagesAdded === 0) {
|
|
300
|
-
logger.debug?.(
|
|
303
|
+
logger.debug?.(
|
|
304
|
+
"capture: no new messages since last sync, skipping triage",
|
|
305
|
+
);
|
|
301
306
|
return;
|
|
302
307
|
}
|
|
303
308
|
|
package/src/hooks/recall.js
CHANGED
|
@@ -13,20 +13,148 @@ function escapeForPrompt(text) {
|
|
|
13
13
|
);
|
|
14
14
|
}
|
|
15
15
|
|
|
16
|
+
/** Max query length sent to search — longer messages get truncated. */
|
|
17
|
+
const MAX_QUERY_LENGTH = 500;
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Messages shorter than this get augmented with recent conversational
|
|
21
|
+
* context. Below this threshold, a message is likely referential
|
|
22
|
+
* ("explain that more", "what about the second point?") and needs
|
|
23
|
+
* surrounding context to produce useful search results.
|
|
24
|
+
*
|
|
25
|
+
* Messages at or above this threshold are substantial enough to
|
|
26
|
+
* search on their own ("openviking 不好用", "how do I deploy to k8s?").
|
|
27
|
+
*/
|
|
28
|
+
const SHORT_QUERY_THRESHOLD = 40;
|
|
29
|
+
|
|
30
|
+
/** How many recent messages to include for short-query context. */
|
|
31
|
+
const CONTEXT_MESSAGES = 3;
|
|
32
|
+
|
|
33
|
+
/** Per-message cap when building context window. */
|
|
34
|
+
const CONTEXT_MSG_MAX_CHARS = 150;
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* Extract text from message content (string or structured blocks).
|
|
38
|
+
* Mirrors the pattern from capture.js.
|
|
39
|
+
*/
|
|
40
|
+
function extractText(content) {
|
|
41
|
+
if (typeof content === "string") return content.trim();
|
|
42
|
+
if (!Array.isArray(content)) return "";
|
|
43
|
+
const parts = [];
|
|
44
|
+
for (const block of content) {
|
|
45
|
+
if (!block || typeof block !== "object") continue;
|
|
46
|
+
if (block.type === "text" && typeof block.text === "string") {
|
|
47
|
+
const text = block.text.trim();
|
|
48
|
+
if (text) parts.push(text);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return parts.join("\n").trim();
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* Normalize a raw message entry to { role, text }.
|
|
56
|
+
* Handles both flat { role, content } and nested { message: { role, content } }.
|
|
57
|
+
* Returns null for non-user/assistant messages, empty content, or slash commands.
|
|
58
|
+
*/
|
|
59
|
+
function normalizeMessage(raw) {
|
|
60
|
+
if (!raw || typeof raw !== "object") return null;
|
|
61
|
+
const msg =
|
|
62
|
+
raw.message && typeof raw.message === "object" ? raw.message : raw;
|
|
63
|
+
const role = typeof msg.role === "string" ? msg.role : "";
|
|
64
|
+
if (role !== "user" && role !== "assistant") return null;
|
|
65
|
+
const text = extractText(msg.content);
|
|
66
|
+
if (!text) return null;
|
|
67
|
+
// Skip slash commands — they're plugin directives, not conversational
|
|
68
|
+
if (role === "user" && text.startsWith("/")) return null;
|
|
69
|
+
return { role, text };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Build the search query from the hook event.
|
|
74
|
+
*
|
|
75
|
+
* Three tiers:
|
|
76
|
+
* >= 40 chars : latest user message alone (self-contained)
|
|
77
|
+
* 3-39 chars : latest user message + recent context (likely referential)
|
|
78
|
+
* < 3 chars : skip recall (too short — "ok", emoji, etc.)
|
|
79
|
+
*
|
|
80
|
+
* The event object provides:
|
|
81
|
+
* - event.messages: structured array of {role, content} messages (preferred)
|
|
82
|
+
* - event.prompt: the full formatted prompt (fallback, truncated)
|
|
83
|
+
*/
|
|
84
|
+
function buildSearchQuery(event) {
|
|
85
|
+
const messages = event?.messages;
|
|
86
|
+
|
|
87
|
+
if (Array.isArray(messages) && messages.length > 0) {
|
|
88
|
+
// Find the latest user message (walk backwards)
|
|
89
|
+
let latestUserIdx = -1;
|
|
90
|
+
let latestUserText = "";
|
|
91
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
92
|
+
const norm = normalizeMessage(messages[i]);
|
|
93
|
+
if (norm && norm.role === "user") {
|
|
94
|
+
latestUserIdx = i;
|
|
95
|
+
latestUserText = norm.text;
|
|
96
|
+
break;
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!latestUserText || latestUserText.length < 3) return "";
|
|
101
|
+
|
|
102
|
+
// Substantial message — use it alone
|
|
103
|
+
if (latestUserText.length >= SHORT_QUERY_THRESHOLD) {
|
|
104
|
+
return latestUserText.slice(0, MAX_QUERY_LENGTH);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Short message — likely a follow-up. Add recent context so
|
|
108
|
+
// the search engine sees what topic the conversation is about.
|
|
109
|
+
// Collect up to CONTEXT_MESSAGES messages preceding the latest user
|
|
110
|
+
// message (both user and assistant, for topic grounding).
|
|
111
|
+
const contextParts = [];
|
|
112
|
+
const scanFrom = Math.max(0, latestUserIdx - CONTEXT_MESSAGES);
|
|
113
|
+
for (let i = scanFrom; i < latestUserIdx; i++) {
|
|
114
|
+
const norm = normalizeMessage(messages[i]);
|
|
115
|
+
if (!norm) continue;
|
|
116
|
+
const truncated =
|
|
117
|
+
norm.text.length > CONTEXT_MSG_MAX_CHARS
|
|
118
|
+
? `${norm.text.slice(0, CONTEXT_MSG_MAX_CHARS)}…`
|
|
119
|
+
: norm.text;
|
|
120
|
+
contextParts.push(truncated);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
if (contextParts.length > 0) {
|
|
124
|
+
// Latest message first (primary intent), then context
|
|
125
|
+
const combined = `${latestUserText}\n\n${contextParts.join("\n")}`;
|
|
126
|
+
return combined.slice(0, MAX_QUERY_LENGTH);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// No prior context available (first message) — use as-is
|
|
130
|
+
return latestUserText;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Fallback: use event.prompt but truncate aggressively.
|
|
134
|
+
// This path only fires when OpenClaw doesn't provide event.messages.
|
|
135
|
+
if (typeof event?.prompt === "string" && event.prompt.length >= 5) {
|
|
136
|
+
return event.prompt.slice(0, MAX_QUERY_LENGTH);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
return "";
|
|
140
|
+
}
|
|
141
|
+
|
|
16
142
|
/**
|
|
17
|
-
* Builds the
|
|
143
|
+
* Builds the before_prompt_build hook handler.
|
|
18
144
|
*
|
|
19
|
-
* Injects two layers of context at
|
|
145
|
+
* Injects two layers of context at prompt time:
|
|
20
146
|
* 1. Working Memory — today's focus, priorities, unresolved flags
|
|
21
|
-
* 2. Relevant memories —
|
|
147
|
+
* 2. Relevant memories — searched using the user's latest message
|
|
22
148
|
*
|
|
23
149
|
* Tool guidance is minimal — the agent already sees full tool descriptions
|
|
24
150
|
* in its tool list. We only add a brief behavioral note.
|
|
25
151
|
*/
|
|
26
152
|
export function buildRecallHandler(client, cfg, logger) {
|
|
153
|
+
const minScore = (cfg.recallMinScore ?? 0) / 100; // config is 0-100, API is 0-1
|
|
154
|
+
|
|
27
155
|
return async (event) => {
|
|
28
|
-
const
|
|
29
|
-
if (!
|
|
156
|
+
const searchQuery = buildSearchQuery(event);
|
|
157
|
+
if (!searchQuery) return;
|
|
30
158
|
|
|
31
159
|
const sections = [];
|
|
32
160
|
|
|
@@ -44,9 +172,17 @@ export function buildRecallHandler(client, cfg, logger) {
|
|
|
44
172
|
|
|
45
173
|
// 2. Relevant memories — enriched with scoring signals and labels
|
|
46
174
|
try {
|
|
47
|
-
const results = await client.searchRich(
|
|
48
|
-
|
|
49
|
-
|
|
175
|
+
const results = await client.searchRich(
|
|
176
|
+
searchQuery,
|
|
177
|
+
cfg.maxContextResults,
|
|
178
|
+
);
|
|
179
|
+
// Filter by minimum score if configured
|
|
180
|
+
const filtered =
|
|
181
|
+
minScore > 0
|
|
182
|
+
? results.filter((r) => (r.score ?? 0) >= minScore)
|
|
183
|
+
: results;
|
|
184
|
+
if (filtered.length > 0) {
|
|
185
|
+
const lines = filtered.map((r) => {
|
|
50
186
|
const title = r.title || "(untitled)";
|
|
51
187
|
const score = `${(r.score * 100).toFixed(0)}%`;
|
|
52
188
|
const labels =
|
|
@@ -82,7 +218,9 @@ export function buildRecallHandler(client, cfg, logger) {
|
|
|
82
218
|
"</nowledge-mem>",
|
|
83
219
|
].join("\n");
|
|
84
220
|
|
|
85
|
-
logger.debug?.(
|
|
221
|
+
logger.debug?.(
|
|
222
|
+
`recall: injecting ${context.length} chars (query: ${searchQuery.slice(0, 80)}…)`,
|
|
223
|
+
);
|
|
86
224
|
return { prependContext: context };
|
|
87
225
|
};
|
|
88
226
|
}
|
package/src/index.js
CHANGED
|
@@ -18,6 +18,7 @@ import { createForgetTool } from "./tools/forget.js";
|
|
|
18
18
|
import { createMemoryGetTool } from "./tools/memory-get.js";
|
|
19
19
|
import { createMemorySearchTool } from "./tools/memory-search.js";
|
|
20
20
|
import { createSaveTool } from "./tools/save.js";
|
|
21
|
+
import { createStatusTool } from "./tools/status.js";
|
|
21
22
|
import { createThreadFetchTool } from "./tools/thread-fetch.js";
|
|
22
23
|
import { createThreadSearchTool } from "./tools/thread-search.js";
|
|
23
24
|
import { createTimelineTool } from "./tools/timeline.js";
|
|
@@ -52,6 +53,9 @@ export default {
|
|
|
52
53
|
api.registerTool(createThreadSearchTool(client, logger));
|
|
53
54
|
api.registerTool(createThreadFetchTool(client, logger));
|
|
54
55
|
|
|
56
|
+
// Diagnostics
|
|
57
|
+
api.registerTool(createStatusTool(client, logger, cfg));
|
|
58
|
+
|
|
55
59
|
// Always-on: behavioral guidance so the agent proactively saves and searches.
|
|
56
60
|
// Fires every turn via before_prompt_build — ~50 tokens, negligible cost.
|
|
57
61
|
// When sessionContext is on, guidance adjusts to avoid redundant searches.
|
package/src/tools/connections.js
CHANGED
|
@@ -293,9 +293,7 @@ export function createConnectionsTool(client, logger) {
|
|
|
293
293
|
const otherTitle = isOlderNode
|
|
294
294
|
? edge.newer_title || "(untitled)"
|
|
295
295
|
: edge.older_title || "(untitled)";
|
|
296
|
-
const otherId = isOlderNode
|
|
297
|
-
? edge.newer_id
|
|
298
|
-
: edge.older_id;
|
|
296
|
+
const otherId = isOlderNode ? edge.newer_id : edge.older_id;
|
|
299
297
|
const direction = isOlderNode ? "→" : "←";
|
|
300
298
|
const idLine = otherId ? `\n → id: ${otherId}` : "";
|
|
301
299
|
return ` ${direction} ${otherTitle}${relLabel ? ` — ${relLabel}` : ""}${idLine}`;
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* nowledge_mem_status - verify config, connectivity, and backend health.
|
|
3
|
+
*
|
|
4
|
+
* Lets the user (or agent) confirm that apiUrl, apiKey, and other
|
|
5
|
+
* settings are taking effect, and that the backend is reachable.
|
|
6
|
+
* Shows where each config value comes from (file, pluginConfig, env, default).
|
|
7
|
+
*/
|
|
8
|
+
import { isDefaultApiUrl } from "../config.js";
|
|
9
|
+
|
|
10
|
+
export function createStatusTool(client, _logger, cfg) {
|
|
11
|
+
return {
|
|
12
|
+
name: "nowledge_mem_status",
|
|
13
|
+
description:
|
|
14
|
+
"Check Nowledge Mem status: effective configuration, backend connectivity, and version. " +
|
|
15
|
+
"Call this when the user wants to verify their setup is working, " +
|
|
16
|
+
"especially after changing apiUrl or apiKey for remote mode.",
|
|
17
|
+
parameters: {
|
|
18
|
+
type: "object",
|
|
19
|
+
properties: {},
|
|
20
|
+
},
|
|
21
|
+
async execute() {
|
|
22
|
+
const lines = [];
|
|
23
|
+
const details = {};
|
|
24
|
+
const sources = cfg._sources || {};
|
|
25
|
+
|
|
26
|
+
// 1. Mode + connection target
|
|
27
|
+
const remote = !isDefaultApiUrl(cfg.apiUrl);
|
|
28
|
+
const mode = remote ? "remote" : "local";
|
|
29
|
+
details.mode = mode;
|
|
30
|
+
details.apiUrl = cfg.apiUrl || "http://127.0.0.1:14242";
|
|
31
|
+
details.apiKeySet = Boolean(cfg.apiKey);
|
|
32
|
+
|
|
33
|
+
lines.push(
|
|
34
|
+
`Mode: ${remote ? `Remote (${cfg.apiUrl})` : "Local (127.0.0.1:14242)"}`,
|
|
35
|
+
);
|
|
36
|
+
lines.push(`API key: ${cfg.apiKey ? "set" : "not set"}`);
|
|
37
|
+
|
|
38
|
+
// 2. CLI resolution
|
|
39
|
+
try {
|
|
40
|
+
const cmd = client.resolveCommand();
|
|
41
|
+
details.cli = cmd.join(" ");
|
|
42
|
+
lines.push(`CLI: ${cmd.join(" ")}`);
|
|
43
|
+
} catch {
|
|
44
|
+
details.cli = null;
|
|
45
|
+
lines.push("CLI: not found (install with: pip install nmem-cli)");
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 3. Backend health
|
|
49
|
+
let healthy = false;
|
|
50
|
+
try {
|
|
51
|
+
healthy = await client.checkHealth();
|
|
52
|
+
} catch {
|
|
53
|
+
// checkHealth already returns false on error
|
|
54
|
+
}
|
|
55
|
+
details.healthy = healthy;
|
|
56
|
+
|
|
57
|
+
if (healthy) {
|
|
58
|
+
lines.push("Backend: reachable");
|
|
59
|
+
// Fetch detailed health info
|
|
60
|
+
try {
|
|
61
|
+
const health = await client.apiJson(
|
|
62
|
+
"GET",
|
|
63
|
+
"/health",
|
|
64
|
+
undefined,
|
|
65
|
+
5000,
|
|
66
|
+
);
|
|
67
|
+
if (health.version) {
|
|
68
|
+
details.version = health.version;
|
|
69
|
+
lines.push(`Version: ${health.version}`);
|
|
70
|
+
}
|
|
71
|
+
if (health.database_connected !== undefined) {
|
|
72
|
+
details.databaseConnected = health.database_connected;
|
|
73
|
+
lines.push(
|
|
74
|
+
`Database: ${health.database_connected ? "connected" : "disconnected"}`,
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
} catch {
|
|
78
|
+
// Health endpoint not available on older backends
|
|
79
|
+
}
|
|
80
|
+
} else {
|
|
81
|
+
lines.push("Backend: not reachable");
|
|
82
|
+
if (remote) {
|
|
83
|
+
lines.push(
|
|
84
|
+
" Check that apiUrl is correct and the server is running.",
|
|
85
|
+
);
|
|
86
|
+
if (!cfg.apiKey) {
|
|
87
|
+
lines.push(" Remote mode usually requires an API key.");
|
|
88
|
+
}
|
|
89
|
+
} else {
|
|
90
|
+
lines.push(" Ensure the Nowledge Mem desktop app is running.");
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
// 4. Effective config with sources
|
|
95
|
+
lines.push("");
|
|
96
|
+
lines.push("Config (source):");
|
|
97
|
+
lines.push(
|
|
98
|
+
` sessionContext: ${cfg.sessionContext} (${sources.sessionContext || "?"})`,
|
|
99
|
+
);
|
|
100
|
+
lines.push(
|
|
101
|
+
` sessionDigest: ${cfg.sessionDigest} (${sources.sessionDigest || "?"})`,
|
|
102
|
+
);
|
|
103
|
+
lines.push(
|
|
104
|
+
` digestMinInterval: ${cfg.digestMinInterval}s (${sources.digestMinInterval || "?"})`,
|
|
105
|
+
);
|
|
106
|
+
lines.push(
|
|
107
|
+
` maxContextResults: ${cfg.maxContextResults} (${sources.maxContextResults || "?"})`,
|
|
108
|
+
);
|
|
109
|
+
lines.push(
|
|
110
|
+
` recallMinScore: ${cfg.recallMinScore}% (${sources.recallMinScore || "?"})`,
|
|
111
|
+
);
|
|
112
|
+
|
|
113
|
+
details.config = {
|
|
114
|
+
sessionContext: {
|
|
115
|
+
value: cfg.sessionContext,
|
|
116
|
+
source: sources.sessionContext,
|
|
117
|
+
},
|
|
118
|
+
sessionDigest: {
|
|
119
|
+
value: cfg.sessionDigest,
|
|
120
|
+
source: sources.sessionDigest,
|
|
121
|
+
},
|
|
122
|
+
digestMinInterval: {
|
|
123
|
+
value: cfg.digestMinInterval,
|
|
124
|
+
source: sources.digestMinInterval,
|
|
125
|
+
},
|
|
126
|
+
maxContextResults: {
|
|
127
|
+
value: cfg.maxContextResults,
|
|
128
|
+
source: sources.maxContextResults,
|
|
129
|
+
},
|
|
130
|
+
recallMinScore: {
|
|
131
|
+
value: cfg.recallMinScore,
|
|
132
|
+
source: sources.recallMinScore,
|
|
133
|
+
},
|
|
134
|
+
apiUrl: { value: cfg.apiUrl || "(local)", source: sources.apiUrl },
|
|
135
|
+
apiKey: {
|
|
136
|
+
value: cfg.apiKey ? "(set)" : "(not set)",
|
|
137
|
+
source: sources.apiKey,
|
|
138
|
+
},
|
|
139
|
+
};
|
|
140
|
+
|
|
141
|
+
lines.push("");
|
|
142
|
+
lines.push(
|
|
143
|
+
"Priority: config file > OpenClaw settings > env vars > defaults",
|
|
144
|
+
);
|
|
145
|
+
|
|
146
|
+
return {
|
|
147
|
+
content: [{ type: "text", text: lines.join("\n") }],
|
|
148
|
+
details,
|
|
149
|
+
};
|
|
150
|
+
},
|
|
151
|
+
};
|
|
152
|
+
}
|