@nowledge/openclaw-nowledge-mem 0.8.8 → 0.8.12
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 +50 -0
- package/CLAUDE.md +4 -0
- package/README.md +29 -2
- package/SKILL.md +1 -0
- package/openclaw.plugin.json +19 -1
- package/package.json +2 -1
- package/src/client.js +56 -10
- package/src/config.js +70 -12
- package/src/hooks/capture.js +10 -13
- package/src/hooks/thread-identity.js +129 -0
- package/src/index.js +5 -0
- package/src/spawn-env.js +16 -2
- package/src/tools/status.js +21 -12
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,56 @@
|
|
|
2
2
|
|
|
3
3
|
All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
|
|
4
4
|
|
|
5
|
+
## Unreleased
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
|
|
9
|
+
- OpenClaw now preserves an explicit empty `space` setting as a real choice to stay on `Default`, instead of silently falling through to ambient `NMEM_SPACE`.
|
|
10
|
+
- CLI-backed OpenClaw operations now honor that same explicit Default-space choice. Earlier builds could still inherit `NMEM_SPACE` in child `nmem` processes, which split CLI-backed reads and writes away from HTTP-backed thread sync.
|
|
11
|
+
- Fallback HTTP requests now carry the ambient lane through one shared path, so direct API calls stay aligned with CLI-backed memory operations.
|
|
12
|
+
- `nowledge_mem_status` now reports the configured Context Engine slot correctly instead of reading from an unset field.
|
|
13
|
+
- `nowledge_mem_status` now reports `legacy` when no Context Engine slot is configured, instead of surfacing `undefined` in default OpenClaw setups.
|
|
14
|
+
- Legacy `~/.nowledge-mem/openclaw.json` now gets the same strict key validation as dashboard config, so typoed or unsupported keys fail loudly instead of silently altering runtime behavior.
|
|
15
|
+
- Thread capture no longer fails at runtime after the thread-identity refactor. The crypto import used by external IDs and append idempotency keys is restored, so `sessionDigest` capture keeps creating and appending threads normally.
|
|
16
|
+
|
|
17
|
+
### Changed
|
|
18
|
+
|
|
19
|
+
- Lower-priority `spaceTemplate` values are now evaluated lazily. A broken template no longer overrides a higher-priority concrete `space` value.
|
|
20
|
+
- Thread-identity tests now reset conversation-root state between cases, so the suite matches real runtime boundaries more closely.
|
|
21
|
+
|
|
22
|
+
## [0.8.12] - 2026-04-10
|
|
23
|
+
|
|
24
|
+
### Changed
|
|
25
|
+
|
|
26
|
+
- OpenClaw's space settings and setup guidance now lead with profile-owned config (`space`, `spaceTemplate`) and keep `NMEM_SPACE` as a fallback for launcher-driven sessions without a richer config surface.
|
|
27
|
+
|
|
28
|
+
## [0.8.11] - 2026-04-10
|
|
29
|
+
|
|
30
|
+
### Added
|
|
31
|
+
|
|
32
|
+
- OpenClaw profiles can now own one ambient space directly through plugin config `space` or `spaceTemplate`, instead of relying only on the process-wide `NMEM_SPACE` environment variable.
|
|
33
|
+
|
|
34
|
+
### Changed
|
|
35
|
+
|
|
36
|
+
- `nowledge_mem_status` now reports the effective ambient space and where it came from.
|
|
37
|
+
|
|
38
|
+
## [0.8.10] - 2026-04-10
|
|
39
|
+
|
|
40
|
+
### Changed
|
|
41
|
+
|
|
42
|
+
- Clarified OpenClaw's ambient space model. `NMEM_SPACE` now remains the single session lane across CLI-backed memory calls and API-backed thread/feed fallbacks, while shared spaces, default retrieval, and agent guidance stay in Mem's shared `/spaces` profile.
|
|
43
|
+
|
|
44
|
+
## [0.8.9] - 2026-04-10
|
|
45
|
+
|
|
46
|
+
### Fixed
|
|
47
|
+
|
|
48
|
+
- **`/new` once again starts a fresh Mem thread.** Thread identity now follows OpenClaw's session lifecycle instead of collapsing everything under the channel-level `sessionKey`. Explicit `/new`, `/reset`, idle rollovers, and daily rollovers create a new Mem thread as users expect.
|
|
49
|
+
- **Compaction still stays inside the same Mem thread.** When OpenClaw rotates the underlying transcript during compaction, the plugin now carries the existing conversation root forward so compaction does not fork a second thread for the same chat.
|
|
50
|
+
|
|
51
|
+
### Changed
|
|
52
|
+
|
|
53
|
+
- **Capture now uses lifecycle-aware conversation roots.** `session_end` and `session_start` hooks teach the plugin when a new chat begins and when a transcript rotation is only internal maintenance. Context Engine capture and hook-based capture still converge on the same thread, but `/new` is no longer merged into the prior conversation.
|
|
54
|
+
|
|
5
55
|
## [0.8.8] - 2026-04-09
|
|
6
56
|
|
|
7
57
|
### Fixed
|
package/CLAUDE.md
CHANGED
|
@@ -11,6 +11,7 @@ Continuation guide for `community/nowledge-mem-openclaw-plugin`.
|
|
|
11
11
|
- Architecture: **CLI-first via OpenClaw runtime** - all CLI execution goes through `api.runtime.system.runCommandWithTimeout`, not direct `child_process`
|
|
12
12
|
- Context engine: registered via `api.registerContextEngine("nowledge-mem", factory)`. Activated when user sets `plugins.slots.contextEngine: "nowledge-mem"`. Falls back to hooks when CE is not active.
|
|
13
13
|
- Remote mode: `~/.nowledge-mem/config.json` (shared) or OpenClaw dashboard. Legacy `openclaw.json` still honored.
|
|
14
|
+
- Ambient space lane: prefer plugin config `space` or `spaceTemplate`. Use `NMEM_SPACE` only when OpenClaw is launched in one stable lane and there is no better profile-owned config surface. Legacy `NMEM_SPACE_ID` still works for older setups.
|
|
14
15
|
|
|
15
16
|
## Design Philosophy
|
|
16
17
|
|
|
@@ -165,6 +166,7 @@ When `contextEngine` points elsewhere (or is absent), hooks handle everything. N
|
|
|
165
166
|
|
|
166
167
|
Plugin settings: OpenClaw dashboard (pluginConfig). Legacy `~/.nowledge-mem/openclaw.json` still honored.
|
|
167
168
|
Credentials (apiUrl/apiKey): also reads `~/.nowledge-mem/config.json` (shared with all Nowledge Mem tools).
|
|
169
|
+
Ambient space: choose one real lane through plugin config `space` or `spaceTemplate` first. Use `NMEM_SPACE` only as the fallback for launcher-driven sessions that already belong to one real lane. Do not introduce a second plugin-local vault concept. Legacy `NMEM_SPACE_ID` remains compatibility-only.
|
|
168
170
|
|
|
169
171
|
| Key | Type | Default | Env Var | Description |
|
|
170
172
|
|-----|------|---------|---------|-------------|
|
|
@@ -181,6 +183,8 @@ Credentials (apiUrl/apiKey): also reads `~/.nowledge-mem/config.json` (shared wi
|
|
|
181
183
|
| `corpusMinScore` | integer 0-100 | `0` | `NMEM_CORPUS_MIN_SCORE` | Min score for supplement results (0 = all) |
|
|
182
184
|
| `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
|
|
183
185
|
| `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
|
|
186
|
+
| `space` | string | `""` | `NMEM_SPACE` (fallback only) | Fixed ambient space name for this OpenClaw profile or agent lane |
|
|
187
|
+
| `spaceTemplate` | string | `""` | — | Template with `${ENV_VAR}` placeholders, used only when `space` is empty |
|
|
184
188
|
|
|
185
189
|
**Priority** (plugin-specific keys): `openclaw.json` (legacy) > pluginConfig > env var > default.
|
|
186
190
|
**Priority** (apiUrl/apiKey): `openclaw.json` (legacy) > pluginConfig > `config.json` (shared) > env var > default.
|
package/README.md
CHANGED
|
@@ -97,6 +97,31 @@ Legacy `~/.nowledge-mem/openclaw.json` is still honored first for backward compa
|
|
|
97
97
|
|
|
98
98
|
The resolved `apiUrl` and `apiKey` are reused across the plugin: CLI-backed memory tools and API-backed thread sync both talk to the same backend. The `apiKey` is never passed as a CLI argument and is never logged.
|
|
99
99
|
|
|
100
|
+
### Spaces
|
|
101
|
+
|
|
102
|
+
Spaces are optional. OpenClaw should choose one ambient lane only when the profile or process already belongs to one real project or agent lane.
|
|
103
|
+
|
|
104
|
+
The OpenClaw plugin settings can own that lane directly:
|
|
105
|
+
|
|
106
|
+
```json
|
|
107
|
+
{
|
|
108
|
+
"space": "Research Agent",
|
|
109
|
+
"spaceTemplate": "agent-${OPENCLAW_AGENT_NAME}"
|
|
110
|
+
}
|
|
111
|
+
```
|
|
112
|
+
|
|
113
|
+
Use `space` when one OpenClaw profile belongs to one stable lane. Use `spaceTemplate` only when your launcher or host environment already sets a trustworthy variable that identifies the lane. If your OpenClaw runtime does not expose per-agent identity to plugins, do not fake it. Use one profile/process per lane or stay on `Default`.
|
|
114
|
+
|
|
115
|
+
If you are running OpenClaw from a launcher or script with no richer config surface, you can still set one session-wide fallback lane with:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
NMEM_SPACE="Research Agent"
|
|
119
|
+
```
|
|
120
|
+
|
|
121
|
+
Nowledge Mem's CLI-backed Working Memory, memory search/save, and the plugin's API-backed thread/feed fallbacks will then stay in that lane together. There is no second OpenClaw-only vault setting; the shared Mem boundary is still one hidden space key, but humans and agents should normally work with the space name instead.
|
|
122
|
+
|
|
123
|
+
Shared spaces, default retrieval, and agent guidance still live in Mem's own space profile. OpenClaw chooses the lane and preserves it across transports; it should not duplicate the profile semantics.
|
|
124
|
+
|
|
100
125
|
### Configure via WebUI of OpenClaw
|
|
101
126
|
|
|
102
127
|
<img width="1192" height="2007" alt="openclaw-config" src="https://github.com/user-attachments/assets/a4abd855-db56-4a1f-b0ac-0114909d0c20" />
|
|
@@ -365,7 +390,7 @@ The behavioral guidance adjusts when `sessionContext` is enabled. Instead of "se
|
|
|
365
390
|
|
|
366
391
|
**What happens to conversations I don't explicitly save?**
|
|
367
392
|
|
|
368
|
-
With `sessionDigest` enabled (the default), every conversation is saved as a searchable thread.
|
|
393
|
+
With `sessionDigest` enabled (the default), every conversation is saved as a searchable thread. The boundary follows OpenClaw's own session lifecycle: one active chat becomes one Mem thread, `/new` or `/reset` starts a fresh thread, and internal compaction keeps appending to the same thread instead of forking a duplicate. Context Engine capture and hook-based capture still converge on the same conversation. Internal helper sessions like `temp:*` and subagent runs are excluded, so your recent threads stay focused on real chats. On top of thread capture, 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.
|
|
369
394
|
|
|
370
395
|
**Can memories be wrong or outdated?**
|
|
371
396
|
|
|
@@ -530,7 +555,9 @@ Remember: OpenClaw applies plugin setting changes after restart. If you turned `
|
|
|
530
555
|
|
|
531
556
|
What healthy OpenClaw thread sync looks like:
|
|
532
557
|
|
|
533
|
-
- one
|
|
558
|
+
- one active OpenClaw chat becomes one Mem thread
|
|
559
|
+
- running `/new` or `/reset` starts a fresh Mem thread
|
|
560
|
+
- compaction does not fork a second thread for the same chat
|
|
534
561
|
- helper sessions like `temp:slug-generator` do not appear
|
|
535
562
|
- the synthetic `/new` / `/reset` startup prompt is not stored as the first user message
|
|
536
563
|
|
package/SKILL.md
CHANGED
|
@@ -63,6 +63,7 @@ Explain the difference plainly:
|
|
|
63
63
|
|
|
64
64
|
- Local mode: OpenClaw talks to the local Nowledge Mem app/server through `nmem`
|
|
65
65
|
- Remote mode: OpenClaw talks to a remote Nowledge Mem server through `nmem --api-url ...`; add `apiKey` when that server has auth enabled
|
|
66
|
+
- If the user wants one agent-specific lane, use plugin config `space` first, plugin config `spaceTemplate` only when the launcher already exposes a trustworthy lane signal, and `NMEM_SPACE="<space name>"` only as the fallback for CLI-style launches instead of inventing a second OpenClaw-only memory namespace
|
|
66
67
|
|
|
67
68
|
### Step 1 — Verify `nmem`
|
|
68
69
|
|
package/openclaw.plugin.json
CHANGED
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
"id": "openclaw-nowledge-mem",
|
|
3
3
|
"name": "Nowledge Mem",
|
|
4
4
|
"description": "Cross-tool knowledge graph memory for OpenClaw with Working Memory, graph search, and session distillation.",
|
|
5
|
-
"version": "0.8.
|
|
5
|
+
"version": "0.8.12",
|
|
6
6
|
"kind": "memory",
|
|
7
7
|
"skills": [
|
|
8
8
|
"skills/memory-guide"
|
|
@@ -60,6 +60,14 @@
|
|
|
60
60
|
"label": "API key (remote mode)",
|
|
61
61
|
"help": "Required when connecting to a remote server. Never logged or passed as a CLI argument. Injected as NMEM_API_KEY env var only.",
|
|
62
62
|
"sensitive": true
|
|
63
|
+
},
|
|
64
|
+
"space": {
|
|
65
|
+
"label": "Ambient space",
|
|
66
|
+
"help": "Optional fixed space name for this OpenClaw profile or agent lane. Leave empty to stay on Default."
|
|
67
|
+
},
|
|
68
|
+
"spaceTemplate": {
|
|
69
|
+
"label": "Ambient space template",
|
|
70
|
+
"help": "Optional template with ${ENV_VAR} placeholders, used only when Ambient space is empty and your launcher already exports a trustworthy lane signal. Example: agent-${OPENCLAW_AGENT_NAME}."
|
|
63
71
|
}
|
|
64
72
|
},
|
|
65
73
|
"configSchema": {
|
|
@@ -144,6 +152,16 @@
|
|
|
144
152
|
"type": "string",
|
|
145
153
|
"default": "",
|
|
146
154
|
"description": "API key for remote access. Stored in plugin config, injected as NMEM_API_KEY env var. Never logged."
|
|
155
|
+
},
|
|
156
|
+
"space": {
|
|
157
|
+
"type": "string",
|
|
158
|
+
"default": "",
|
|
159
|
+
"description": "Optional fixed space name for this OpenClaw profile or agent lane."
|
|
160
|
+
},
|
|
161
|
+
"spaceTemplate": {
|
|
162
|
+
"type": "string",
|
|
163
|
+
"default": "",
|
|
164
|
+
"description": "Optional template with ${ENV_VAR} placeholders. Used only when space is empty."
|
|
147
165
|
}
|
|
148
166
|
}
|
|
149
167
|
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nowledge/openclaw-nowledge-mem",
|
|
3
3
|
"displayName": "Nowledge Mem for OpenClaw",
|
|
4
|
-
"version": "0.8.
|
|
4
|
+
"version": "0.8.12",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
|
|
7
7
|
"author": {
|
|
@@ -42,6 +42,7 @@
|
|
|
42
42
|
},
|
|
43
43
|
"main": "src/index.js",
|
|
44
44
|
"scripts": {
|
|
45
|
+
"test": "node --test tests/*.test.mjs",
|
|
45
46
|
"validate": "node scripts/validate-plugin.mjs",
|
|
46
47
|
"lint": "biome check .",
|
|
47
48
|
"format": "biome format --write ."
|
package/src/client.js
CHANGED
|
@@ -70,7 +70,7 @@ export class NowledgeMemClient {
|
|
|
70
70
|
/**
|
|
71
71
|
* @param {object} logger
|
|
72
72
|
* @param {object} runtimeSystem
|
|
73
|
-
* @param {{ apiUrl?: string; apiKey?: string }} [credentials]
|
|
73
|
+
* @param {{ apiUrl?: string; apiKey?: string; space?: string; spaceId?: string }} [credentials]
|
|
74
74
|
*/
|
|
75
75
|
constructor(logger, runtimeSystem, credentials = {}) {
|
|
76
76
|
this.logger = logger;
|
|
@@ -82,6 +82,18 @@ export class NowledgeMemClient {
|
|
|
82
82
|
(credentials.apiUrl || "").trim() || "http://127.0.0.1:14242"
|
|
83
83
|
).replace(/\/+$/, "");
|
|
84
84
|
this._apiKey = (credentials.apiKey || "").trim();
|
|
85
|
+
const hasSpace = Object.prototype.hasOwnProperty.call(credentials, "space");
|
|
86
|
+
const hasSpaceId = Object.prototype.hasOwnProperty.call(
|
|
87
|
+
credentials,
|
|
88
|
+
"spaceId",
|
|
89
|
+
);
|
|
90
|
+
this._hasExplicitSpace = hasSpace || hasSpaceId;
|
|
91
|
+
const resolvedSpace = hasSpace
|
|
92
|
+
? credentials.space
|
|
93
|
+
: hasSpaceId
|
|
94
|
+
? credentials.spaceId
|
|
95
|
+
: process.env.NMEM_SPACE ?? process.env.NMEM_SPACE_ID ?? "";
|
|
96
|
+
this._spaceRef = String(resolvedSpace ?? "").trim();
|
|
85
97
|
}
|
|
86
98
|
|
|
87
99
|
// ── API helpers (fallback path and direct operations) ─────────────────────
|
|
@@ -99,15 +111,39 @@ export class NowledgeMemClient {
|
|
|
99
111
|
return headers;
|
|
100
112
|
}
|
|
101
113
|
|
|
114
|
+
_withSpaceQuery(path) {
|
|
115
|
+
if (!this._spaceRef) return path;
|
|
116
|
+
const [pathname, rawQuery = ""] = String(path).split("?", 2);
|
|
117
|
+
const query = new URLSearchParams(rawQuery);
|
|
118
|
+
if (!query.has("space_id")) {
|
|
119
|
+
query.set("space_id", this._spaceRef);
|
|
120
|
+
}
|
|
121
|
+
const rendered = query.toString();
|
|
122
|
+
return rendered ? `${pathname}?${rendered}` : pathname;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
_withSpaceBody(body) {
|
|
126
|
+
if (!this._spaceRef || body == null || Array.isArray(body)) {
|
|
127
|
+
return body;
|
|
128
|
+
}
|
|
129
|
+
if (Object.prototype.hasOwnProperty.call(body, "space_id")) {
|
|
130
|
+
return body;
|
|
131
|
+
}
|
|
132
|
+
return { ...body, space_id: this._spaceRef };
|
|
133
|
+
}
|
|
134
|
+
|
|
102
135
|
async apiJson(method, path, body, timeout = 30_000) {
|
|
103
136
|
const controller = new AbortController();
|
|
104
137
|
const timer = setTimeout(() => controller.abort(), timeout);
|
|
105
|
-
const
|
|
138
|
+
const scopedPath = this._withSpaceQuery(path);
|
|
139
|
+
const scopedBody = this._withSpaceBody(body);
|
|
140
|
+
const url = `${this.getApiBaseUrl()}${scopedPath}`;
|
|
106
141
|
try {
|
|
107
142
|
const response = await fetch(url, {
|
|
108
143
|
method,
|
|
109
144
|
headers: this.getApiHeaders(),
|
|
110
|
-
body:
|
|
145
|
+
body:
|
|
146
|
+
scopedBody === undefined ? undefined : JSON.stringify(scopedBody),
|
|
111
147
|
signal: controller.signal,
|
|
112
148
|
});
|
|
113
149
|
|
|
@@ -184,6 +220,8 @@ export class NowledgeMemClient {
|
|
|
184
220
|
return buildNmemSpawnEnv({
|
|
185
221
|
apiUrl: this._apiUrl,
|
|
186
222
|
apiKey: this._apiKey,
|
|
223
|
+
spaceId: this._spaceRef,
|
|
224
|
+
hasExplicitSpace: this._hasExplicitSpace,
|
|
187
225
|
});
|
|
188
226
|
}
|
|
189
227
|
|
|
@@ -557,12 +595,16 @@ export class NowledgeMemClient {
|
|
|
557
595
|
throw new Error("createThread requires at least one message");
|
|
558
596
|
}
|
|
559
597
|
|
|
560
|
-
const data = await this.apiJson(
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
598
|
+
const data = await this.apiJson(
|
|
599
|
+
"POST",
|
|
600
|
+
"/threads",
|
|
601
|
+
{
|
|
602
|
+
...(threadId ? { thread_id: String(threadId) } : {}),
|
|
603
|
+
title: normalizedTitle,
|
|
604
|
+
source: String(source),
|
|
605
|
+
messages,
|
|
606
|
+
},
|
|
607
|
+
);
|
|
566
608
|
|
|
567
609
|
return String(
|
|
568
610
|
data.id ?? data.thread?.thread_id ?? data.thread_id ?? "created",
|
|
@@ -692,7 +734,11 @@ export class NowledgeMemClient {
|
|
|
692
734
|
});
|
|
693
735
|
if (updated === null) throw new Error(`Section not found: ${heading}`);
|
|
694
736
|
|
|
695
|
-
return this.apiJson(
|
|
737
|
+
return this.apiJson(
|
|
738
|
+
"PUT",
|
|
739
|
+
"/agent/working-memory",
|
|
740
|
+
{ content: updated },
|
|
741
|
+
);
|
|
696
742
|
}
|
|
697
743
|
}
|
|
698
744
|
|
package/src/config.js
CHANGED
|
@@ -28,6 +28,8 @@ const ALLOWED_KEYS = new Set([
|
|
|
28
28
|
"corpusMinScore",
|
|
29
29
|
"apiUrl",
|
|
30
30
|
"apiKey",
|
|
31
|
+
"space",
|
|
32
|
+
"spaceTemplate",
|
|
31
33
|
// Legacy aliases — accepted but not advertised
|
|
32
34
|
"autoRecall",
|
|
33
35
|
"autoCapture",
|
|
@@ -128,6 +130,30 @@ function envInt(name) {
|
|
|
128
130
|
return Number.isFinite(n) ? n : undefined;
|
|
129
131
|
}
|
|
130
132
|
|
|
133
|
+
function pickStr(obj, key) {
|
|
134
|
+
if (typeof obj[key] !== "string") return undefined;
|
|
135
|
+
const trimmed = obj[key].trim();
|
|
136
|
+
return trimmed || undefined;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
function pickPresentString(obj, key) {
|
|
140
|
+
if (!Object.prototype.hasOwnProperty.call(obj, key)) return undefined;
|
|
141
|
+
if (typeof obj[key] !== "string") return undefined;
|
|
142
|
+
return obj[key].trim();
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
function resolveEnvTemplate(value) {
|
|
146
|
+
return value.replace(/\$\{([^}]+)\}/g, (_, envVar) => {
|
|
147
|
+
const envValue = process.env[envVar];
|
|
148
|
+
if (typeof envValue !== "string" || !envValue.trim()) {
|
|
149
|
+
throw new Error(
|
|
150
|
+
`nowledge-mem: environment variable ${envVar} is required by spaceTemplate`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
return envValue.trim();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
156
|
+
|
|
131
157
|
// --- helpers to read a boolean/number from resolved sources --------------
|
|
132
158
|
|
|
133
159
|
function pickBool(obj, key) {
|
|
@@ -162,7 +188,8 @@ function resolveAliases(obj) {
|
|
|
162
188
|
*/
|
|
163
189
|
function firstDefined(...options) {
|
|
164
190
|
for (const opt of options) {
|
|
165
|
-
|
|
191
|
+
const value = typeof opt.resolve === "function" ? opt.resolve() : opt.value;
|
|
192
|
+
if (value !== undefined) return { ...opt, value };
|
|
166
193
|
}
|
|
167
194
|
return options[options.length - 1];
|
|
168
195
|
}
|
|
@@ -185,7 +212,8 @@ function firstDefined(...options) {
|
|
|
185
212
|
* but is no longer the recommended path.
|
|
186
213
|
*
|
|
187
214
|
* Canonical keys: sessionContext, sessionDigest, digestMinInterval,
|
|
188
|
-
* maxContextResults, recallMinScore, maxThreadMessageChars,
|
|
215
|
+
* maxContextResults, recallMinScore, maxThreadMessageChars,
|
|
216
|
+
* apiUrl, apiKey, space, spaceTemplate
|
|
189
217
|
*
|
|
190
218
|
* Legacy aliases (accepted from all sources; never shown in docs):
|
|
191
219
|
* autoRecall → sessionContext
|
|
@@ -205,6 +233,7 @@ function firstDefined(...options) {
|
|
|
205
233
|
* NMEM_CORPUS_MIN_SCORE — integer (0-100)
|
|
206
234
|
* NMEM_API_URL — remote server URL
|
|
207
235
|
* NMEM_API_KEY — API key (never logged)
|
|
236
|
+
* NMEM_SPACE — ambient space name (legacy: NMEM_SPACE_ID)
|
|
208
237
|
*/
|
|
209
238
|
export function parseConfig(raw, logger) {
|
|
210
239
|
const pluginCfg = raw && typeof raw === "object" ? raw : {};
|
|
@@ -215,16 +244,19 @@ export function parseConfig(raw, logger) {
|
|
|
215
244
|
const resolvedPlugin = resolveAliases(pluginCfg);
|
|
216
245
|
const resolvedFile = resolveAliases(fileCfg);
|
|
217
246
|
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
(
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
247
|
+
const validateKnownKeys = (obj, sourceLabel) => {
|
|
248
|
+
const unknownKeys = Object.keys(obj).filter((k) => !ALLOWED_KEYS.has(k));
|
|
249
|
+
if (unknownKeys.length > 0) {
|
|
250
|
+
throw new Error(
|
|
251
|
+
`nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""} in ${sourceLabel}: ${unknownKeys.join(", ")}. ` +
|
|
252
|
+
`Allowed keys: ${[...ALLOWED_KEYS].filter((k) => !Object.keys(ALIAS_KEYS).includes(k)).join(", ")}`,
|
|
253
|
+
);
|
|
254
|
+
}
|
|
255
|
+
};
|
|
256
|
+
|
|
257
|
+
// Strict: reject unknown keys in both legacy file config and dashboard config.
|
|
258
|
+
validateKnownKeys(resolvedFile, "~/.nowledge-mem/openclaw.json");
|
|
259
|
+
validateKnownKeys(resolvedPlugin, "plugin config");
|
|
228
260
|
|
|
229
261
|
const _sources = {};
|
|
230
262
|
|
|
@@ -351,6 +383,31 @@ export function parseConfig(raw, logger) {
|
|
|
351
383
|
const apiKey = ak.value;
|
|
352
384
|
_sources.apiKey = ak.source;
|
|
353
385
|
|
|
386
|
+
// --- space: file (legacy) > pluginConfig > template > env > default ---
|
|
387
|
+
const fs = pickPresentString(resolvedFile, "space");
|
|
388
|
+
const ps = pickPresentString(resolvedPlugin, "space");
|
|
389
|
+
const fst = pickStr(resolvedFile, "spaceTemplate");
|
|
390
|
+
const pst = pickStr(resolvedPlugin, "spaceTemplate");
|
|
391
|
+
const spaceChoice = firstDefined(
|
|
392
|
+
{ value: fs, source: "file" },
|
|
393
|
+
{ value: ps, source: "pluginConfig" },
|
|
394
|
+
{
|
|
395
|
+
resolve: () => (fst ? resolveEnvTemplate(fst) : undefined),
|
|
396
|
+
source: "file:template",
|
|
397
|
+
},
|
|
398
|
+
{
|
|
399
|
+
resolve: () => (pst ? resolveEnvTemplate(pst) : undefined),
|
|
400
|
+
source: "pluginConfig:template",
|
|
401
|
+
},
|
|
402
|
+
{
|
|
403
|
+
value: envStr("NMEM_SPACE") ?? envStr("NMEM_SPACE_ID"),
|
|
404
|
+
source: "env",
|
|
405
|
+
},
|
|
406
|
+
{ value: "", source: "default" },
|
|
407
|
+
);
|
|
408
|
+
const space = spaceChoice.value || "";
|
|
409
|
+
_sources.space = spaceChoice.source;
|
|
410
|
+
|
|
354
411
|
// --- corpusSupplement: file > pluginConfig > env > default ---
|
|
355
412
|
const cs = firstDefined(
|
|
356
413
|
{ value: pickBool(resolvedFile, "corpusSupplement"), source: "file" },
|
|
@@ -445,6 +502,7 @@ export function parseConfig(raw, logger) {
|
|
|
445
502
|
corpusMinScore,
|
|
446
503
|
apiUrl,
|
|
447
504
|
apiKey,
|
|
505
|
+
space,
|
|
448
506
|
_sources,
|
|
449
507
|
};
|
|
450
508
|
}
|
package/src/hooks/capture.js
CHANGED
|
@@ -1,6 +1,15 @@
|
|
|
1
|
-
import { createHash } from "node:crypto";
|
|
2
1
|
import { readFile } from "node:fs/promises";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
3
|
import { isCronSessionKey, isSubagentSessionKey } from "openclaw/plugin-sdk/routing";
|
|
4
|
+
import {
|
|
5
|
+
buildStableThreadId,
|
|
6
|
+
} from "./thread-identity.js";
|
|
7
|
+
export {
|
|
8
|
+
_resetConversationRoots,
|
|
9
|
+
buildStableThreadId,
|
|
10
|
+
registerSessionEndConversation,
|
|
11
|
+
registerSessionStartConversation,
|
|
12
|
+
} from "./thread-identity.js";
|
|
4
13
|
|
|
5
14
|
export const DEFAULT_MAX_MESSAGE_CHARS = 800;
|
|
6
15
|
export const MAX_DISTILL_MESSAGE_CHARS = 2000;
|
|
@@ -20,7 +29,6 @@ const _lastCaptureAt = new Map();
|
|
|
20
29
|
const _MAX_COOLDOWN_ENTRIES = 200;
|
|
21
30
|
const _syncedMessageCounts = new Map();
|
|
22
31
|
const _MAX_SYNC_CURSOR_ENTRIES = 500;
|
|
23
|
-
|
|
24
32
|
function _setLastCapture(threadId, now) {
|
|
25
33
|
_lastCaptureAt.set(threadId, now);
|
|
26
34
|
// Opportunistic eviction: sweep stale entries when map grows large
|
|
@@ -255,17 +263,6 @@ function sanitizeIdPart(input, max = 48) {
|
|
|
255
263
|
return normalized.slice(0, max);
|
|
256
264
|
}
|
|
257
265
|
|
|
258
|
-
export function buildStableThreadId(event, ctx) {
|
|
259
|
-
const base =
|
|
260
|
-
String(ctx?.sessionKey || "").trim() ||
|
|
261
|
-
String(ctx?.sessionId || "").trim() ||
|
|
262
|
-
String(event?.sessionFile || "").trim() ||
|
|
263
|
-
"session";
|
|
264
|
-
const slug = sanitizeIdPart(base);
|
|
265
|
-
const digest = createHash("sha1").update(base).digest("hex").slice(0, 10);
|
|
266
|
-
return `openclaw-${slug}-${digest}`;
|
|
267
|
-
}
|
|
268
|
-
|
|
269
266
|
function buildExternalId({ normalized, index, threadId, sessionKey }) {
|
|
270
267
|
if (normalized.externalHint) {
|
|
271
268
|
return `oc:${sanitizeIdPart(normalized.externalHint, 96)}`;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
const _conversationRootsBySessionId = new Map();
|
|
4
|
+
const _activeConversationRootsBySessionKey = new Map();
|
|
5
|
+
const _MAX_CONVERSATION_ROOT_ENTRIES = 500;
|
|
6
|
+
|
|
7
|
+
function sanitizeIdPart(input, max = 48) {
|
|
8
|
+
const normalized = String(input || "")
|
|
9
|
+
.toLowerCase()
|
|
10
|
+
.replace(/[^a-z0-9]+/g, "-")
|
|
11
|
+
.replace(/^-+|-+$/g, "");
|
|
12
|
+
if (!normalized) return "session";
|
|
13
|
+
return normalized.slice(0, max);
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function _setConversationRoot({ sessionId, sessionKey, rootId }) {
|
|
17
|
+
const normalizedRoot = String(rootId || "").trim();
|
|
18
|
+
if (!normalizedRoot) return;
|
|
19
|
+
const normalizedSessionId = String(sessionId || "").trim();
|
|
20
|
+
const normalizedSessionKey = String(sessionKey || "").trim();
|
|
21
|
+
|
|
22
|
+
if (normalizedSessionId) {
|
|
23
|
+
_conversationRootsBySessionId.set(normalizedSessionId, normalizedRoot);
|
|
24
|
+
if (_conversationRootsBySessionId.size > _MAX_CONVERSATION_ROOT_ENTRIES) {
|
|
25
|
+
const excess =
|
|
26
|
+
_conversationRootsBySessionId.size - _MAX_CONVERSATION_ROOT_ENTRIES;
|
|
27
|
+
let removed = 0;
|
|
28
|
+
for (const key of _conversationRootsBySessionId.keys()) {
|
|
29
|
+
_conversationRootsBySessionId.delete(key);
|
|
30
|
+
removed += 1;
|
|
31
|
+
if (removed >= excess) break;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (normalizedSessionKey) {
|
|
37
|
+
_activeConversationRootsBySessionKey.set(
|
|
38
|
+
normalizedSessionKey,
|
|
39
|
+
normalizedRoot,
|
|
40
|
+
);
|
|
41
|
+
if (
|
|
42
|
+
_activeConversationRootsBySessionKey.size > _MAX_CONVERSATION_ROOT_ENTRIES
|
|
43
|
+
) {
|
|
44
|
+
const excess =
|
|
45
|
+
_activeConversationRootsBySessionKey.size - _MAX_CONVERSATION_ROOT_ENTRIES;
|
|
46
|
+
let removed = 0;
|
|
47
|
+
for (const key of _activeConversationRootsBySessionKey.keys()) {
|
|
48
|
+
_activeConversationRootsBySessionKey.delete(key);
|
|
49
|
+
removed += 1;
|
|
50
|
+
if (removed >= excess) break;
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function resolveConversationRoot(event, ctx) {
|
|
57
|
+
const sessionId = String(ctx?.sessionId || event?.sessionId || "").trim();
|
|
58
|
+
if (sessionId) {
|
|
59
|
+
const mappedRoot = _conversationRootsBySessionId.get(sessionId);
|
|
60
|
+
if (mappedRoot) return mappedRoot;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const sessionKey = String(ctx?.sessionKey || event?.sessionKey || "").trim();
|
|
64
|
+
if (sessionKey) {
|
|
65
|
+
const activeRoot = _activeConversationRootsBySessionKey.get(sessionKey);
|
|
66
|
+
if (activeRoot) return activeRoot;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
sessionId ||
|
|
71
|
+
sessionKey ||
|
|
72
|
+
String(event?.sessionFile || "").trim() ||
|
|
73
|
+
"session"
|
|
74
|
+
);
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export function registerSessionStartConversation(event, ctx) {
|
|
78
|
+
const sessionId = String(event?.sessionId || ctx?.sessionId || "").trim();
|
|
79
|
+
const sessionKey = String(event?.sessionKey || ctx?.sessionKey || "").trim();
|
|
80
|
+
if (!sessionId && !sessionKey) return;
|
|
81
|
+
|
|
82
|
+
const resumedFrom = String(event?.resumedFrom || "").trim();
|
|
83
|
+
const inheritedRoot =
|
|
84
|
+
_conversationRootsBySessionId.get(sessionId) ||
|
|
85
|
+
(resumedFrom && _conversationRootsBySessionId.get(resumedFrom)) ||
|
|
86
|
+
sessionId;
|
|
87
|
+
|
|
88
|
+
_setConversationRoot({
|
|
89
|
+
sessionId,
|
|
90
|
+
sessionKey,
|
|
91
|
+
rootId: inheritedRoot || sessionId || sessionKey,
|
|
92
|
+
});
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export function registerSessionEndConversation(event, ctx) {
|
|
96
|
+
const sessionId = String(event?.sessionId || ctx?.sessionId || "").trim();
|
|
97
|
+
const sessionKey = String(event?.sessionKey || ctx?.sessionKey || "").trim();
|
|
98
|
+
if (!sessionId && !sessionKey) return;
|
|
99
|
+
|
|
100
|
+
const currentRoot = resolveConversationRoot(event, ctx);
|
|
101
|
+
_setConversationRoot({
|
|
102
|
+
sessionId,
|
|
103
|
+
sessionKey,
|
|
104
|
+
rootId: currentRoot,
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
const nextSessionId = String(event?.nextSessionId || "").trim();
|
|
108
|
+
if (!nextSessionId) return;
|
|
109
|
+
|
|
110
|
+
const reason = String(event?.reason || "").trim().toLowerCase();
|
|
111
|
+
const nextRoot = reason === "compaction" ? currentRoot : nextSessionId;
|
|
112
|
+
_setConversationRoot({
|
|
113
|
+
sessionId: nextSessionId,
|
|
114
|
+
sessionKey,
|
|
115
|
+
rootId: nextRoot,
|
|
116
|
+
});
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function buildStableThreadId(event, ctx) {
|
|
120
|
+
const base = resolveConversationRoot(event, ctx);
|
|
121
|
+
const slug = sanitizeIdPart(base);
|
|
122
|
+
const digest = createHash("sha1").update(base).digest("hex").slice(0, 10);
|
|
123
|
+
return `openclaw-${slug}-${digest}`;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function _resetConversationRoots() {
|
|
127
|
+
_conversationRootsBySessionId.clear();
|
|
128
|
+
_activeConversationRootsBySessionKey.clear();
|
|
129
|
+
}
|
package/src/index.js
CHANGED
|
@@ -12,6 +12,8 @@ import { buildBehavioralHook } from "./hooks/behavioral.js";
|
|
|
12
12
|
import {
|
|
13
13
|
buildAgentEndCaptureHandler,
|
|
14
14
|
buildBeforeResetCaptureHandler,
|
|
15
|
+
registerSessionEndConversation,
|
|
16
|
+
registerSessionStartConversation,
|
|
15
17
|
} from "./hooks/capture.js";
|
|
16
18
|
import { buildRecallHandler } from "./hooks/recall.js";
|
|
17
19
|
import { createConnectionsTool } from "./tools/connections.js";
|
|
@@ -38,6 +40,7 @@ export default {
|
|
|
38
40
|
const client = new NowledgeMemClient(logger, api.runtime.system, {
|
|
39
41
|
apiUrl: cfg.apiUrl,
|
|
40
42
|
apiKey: cfg.apiKey,
|
|
43
|
+
space: cfg.space,
|
|
41
44
|
});
|
|
42
45
|
|
|
43
46
|
// OpenClaw memory-slot compatibility (required for system prompt activation)
|
|
@@ -137,6 +140,8 @@ export default {
|
|
|
137
140
|
cfg,
|
|
138
141
|
logger,
|
|
139
142
|
);
|
|
143
|
+
api.on("session_start", registerSessionStartConversation);
|
|
144
|
+
api.on("session_end", registerSessionEndConversation);
|
|
140
145
|
api.on("agent_end", buildAgentEndCaptureHandler(client, cfg, logger));
|
|
141
146
|
api.on("after_compaction", (event, ctx) =>
|
|
142
147
|
threadCaptureHandler({ ...event, reason: "compaction" }, ctx),
|
package/src/spawn-env.js
CHANGED
|
@@ -3,17 +3,31 @@ const LOCAL_API_URL = "http://127.0.0.1:14242";
|
|
|
3
3
|
/**
|
|
4
4
|
* Build the child env for nmem CLI execution.
|
|
5
5
|
* apiKey stays in env only and is never passed as a CLI flag.
|
|
6
|
-
* Clear inherited
|
|
6
|
+
* Clear inherited API endpoint/auth values first so the resolved plugin config wins.
|
|
7
|
+
* Keep the ambient space lane unless an explicit override is provided. Prefer
|
|
8
|
+
* NMEM_SPACE for the human-facing contract, while preserving legacy
|
|
9
|
+
* NMEM_SPACE_ID for older setups. An explicit empty override means "stay on
|
|
10
|
+
* Default", so inherited ambient space vars must be cleared too.
|
|
7
11
|
*/
|
|
8
|
-
export function buildNmemSpawnEnv(
|
|
12
|
+
export function buildNmemSpawnEnv(
|
|
13
|
+
{ apiUrl, apiKey, spaceId, hasExplicitSpace = false } = {},
|
|
14
|
+
) {
|
|
9
15
|
const env = { ...process.env };
|
|
10
16
|
delete env.NMEM_API_URL;
|
|
11
17
|
delete env.NMEM_API_KEY;
|
|
18
|
+
if (hasExplicitSpace) {
|
|
19
|
+
delete env.NMEM_SPACE;
|
|
20
|
+
delete env.NMEM_SPACE_ID;
|
|
21
|
+
}
|
|
12
22
|
if (apiUrl && apiUrl !== LOCAL_API_URL) {
|
|
13
23
|
env.NMEM_API_URL = apiUrl;
|
|
14
24
|
}
|
|
15
25
|
if (apiKey) {
|
|
16
26
|
env.NMEM_API_KEY = apiKey;
|
|
17
27
|
}
|
|
28
|
+
if (hasExplicitSpace && typeof spaceId === "string" && spaceId.length > 0) {
|
|
29
|
+
env.NMEM_SPACE = spaceId;
|
|
30
|
+
env.NMEM_SPACE_ID = spaceId;
|
|
31
|
+
}
|
|
18
32
|
return env;
|
|
19
33
|
}
|
package/src/tools/status.js
CHANGED
|
@@ -63,18 +63,18 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
|
|
|
63
63
|
const memorySlot = runtimeInfo.memorySlot;
|
|
64
64
|
const contextEngineSlot = runtimeInfo.contextEngineSlot;
|
|
65
65
|
const contextEngineRegistered = runtimeInfo.contextEngineRegistered === true;
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
66
|
+
const contextEngineRegistrationError =
|
|
67
|
+
typeof runtimeInfo.contextEngineRegistrationError === "string" &&
|
|
68
|
+
runtimeInfo.contextEngineRegistrationError.trim()
|
|
69
|
+
? runtimeInfo.contextEngineRegistrationError.trim()
|
|
70
|
+
: null;
|
|
71
|
+
details.memorySlot = memorySlot ?? "(unknown)";
|
|
72
|
+
const ceSlot =
|
|
73
|
+
typeof contextEngineSlot === "string" && contextEngineSlot.trim()
|
|
74
|
+
? contextEngineSlot.trim()
|
|
75
|
+
: "legacy";
|
|
76
|
+
details.contextEngineSlot = ceSlot;
|
|
77
|
+
details.contextEngineRegistered = contextEngineRegistered;
|
|
78
78
|
if (memorySlot && memorySlot !== "openclaw-nowledge-mem") {
|
|
79
79
|
const corpusOn = cfg.corpusSupplement === true;
|
|
80
80
|
if (corpusOn) {
|
|
@@ -134,6 +134,10 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
if (!cfg.sessionDigest) {
|
|
136
136
|
lines.push("Thread capture: disabled (sessionDigest=false)");
|
|
137
|
+
} else {
|
|
138
|
+
lines.push(
|
|
139
|
+
" Thread identity follows OpenClaw session lifecycle: /new or /reset starts a fresh thread, while compaction stays in the same thread.",
|
|
140
|
+
);
|
|
137
141
|
}
|
|
138
142
|
|
|
139
143
|
// 1. Mode + connection target
|
|
@@ -142,11 +146,16 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
|
|
|
142
146
|
details.mode = mode;
|
|
143
147
|
details.apiUrl = cfg.apiUrl || "http://127.0.0.1:14242";
|
|
144
148
|
details.apiKeySet = Boolean(cfg.apiKey);
|
|
149
|
+
details.space = cfg.space || null;
|
|
150
|
+
details.spaceSource = sources.space || "default";
|
|
145
151
|
|
|
146
152
|
lines.push(
|
|
147
153
|
`Mode: ${remote ? `Remote (${cfg.apiUrl})` : "Local (127.0.0.1:14242)"}`,
|
|
148
154
|
);
|
|
149
155
|
lines.push(`API key: ${cfg.apiKey ? "set" : "not set"}`);
|
|
156
|
+
lines.push(
|
|
157
|
+
`Ambient space: ${cfg.space ? `${cfg.space} (${details.spaceSource})` : "Default"}`,
|
|
158
|
+
);
|
|
150
159
|
lines.push("Memory tools: nmem CLI");
|
|
151
160
|
lines.push("Thread sync: Mem HTTP API");
|
|
152
161
|
|