@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 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. One OpenClaw chat becomes one Mem thread: capture uses the stable OpenClaw `sessionKey`, not transient runtime ids, so Context Engine capture and hook-based capture append to 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.
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 visible chat becomes one Mem thread
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
 
@@ -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.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.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 url = `${this.getApiBaseUrl()}${path}`;
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: body === undefined ? undefined : JSON.stringify(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("POST", "/threads", {
561
- ...(threadId ? { thread_id: String(threadId) } : {}),
562
- title: normalizedTitle,
563
- source: String(source),
564
- messages,
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("PUT", "/agent/working-memory", { content: updated });
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
- if (opt.value !== undefined) return opt;
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, apiUrl, apiKey
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
- // Strict: reject unknown keys in pluginConfig (typo catcher).
219
- const unknownKeys = Object.keys(resolvedPlugin).filter(
220
- (k) => !ALLOWED_KEYS.has(k),
221
- );
222
- if (unknownKeys.length > 0) {
223
- throw new Error(
224
- `nowledge-mem: unknown config key${unknownKeys.length > 1 ? "s" : ""}: ${unknownKeys.join(", ")}. ` +
225
- `Allowed keys: ${[...ALLOWED_KEYS].filter((k) => !Object.keys(ALIAS_KEYS).includes(k)).join(", ")}`,
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
  }
@@ -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 NMEM_* values first so the resolved plugin config wins.
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({ apiUrl, apiKey } = {}) {
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
  }
@@ -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
- 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
- contextEngineSlot && String(contextEngineSlot).trim()
74
- ? String(contextEngineSlot).trim()
75
- : "legacy";
76
- details.contextEngineSlot = ceSlot;
77
- details.contextEngineRegistered = contextEngineRegistered;
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