@nowledge/openclaw-nowledge-mem 0.8.3 → 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,88 @@
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
+
55
+ ## [0.8.8] - 2026-04-09
56
+
57
+ ### Fixed
58
+
59
+ - **Internal OpenClaw control sender wrappers no longer leak into saved thread content.** Messages wrapped with `Sender (untrusted metadata)` for the internal `openclaw-control-ui` source now persist only the user-visible message body, instead of storing the control metadata block in Mem.
60
+
61
+ ## [0.8.7] - 2026-04-09
62
+
63
+ ### Fixed
64
+
65
+ - **One OpenClaw chat now persists as one Mem thread.** Thread identity now prefers the stable OpenClaw `sessionKey` over transient runtime `sessionId`, so Context Engine `afterTurn` capture and hook-based `agent_end` capture append to the same thread instead of creating parallel copies of the same conversation.
66
+ - **Internal OpenClaw helper sessions no longer pollute Mem.** Temporary sessions like `temp:slug-generator` and internal subagent sessions are now excluded from thread auto-sync, keeping the thread list focused on real user conversations.
67
+ - **Stored thread content is cleaner.** The synthetic `/new` or `/reset` startup prompt is no longer persisted as a user message, and OpenClaw control tags like `[[reply_to_current]]` / `[[reply_to:...]]` / `[[audio_as_voice]]` are stripped before capture.
68
+
69
+ ## [0.8.6] - 2026-04-09
70
+
71
+ ### Fixed
72
+
73
+ - **Thread capture no longer depends entirely on the Context Engine path.** When `plugins.slots.contextEngine` points to `nowledge-mem`, behavioral and recall prompt injection still defer to the Context Engine, but capture hooks now remain active as a safety net. Thread append/create is already tail-synced and idempotent, so this backstop avoids missed syncs on sessions where the Context Engine lifecycle does not fire as expected without reintroducing duplicate threads.
74
+
75
+ ### Changed
76
+
77
+ - **Troubleshooting now distinguishes sandbox mode from capture mode.** A `session_status` label like `direct/non-main` reflects OpenClaw sandbox configuration, not a cron or non-interactive session. The OpenClaw main session key `agent:main:main` remains a normal interactive capture target.
78
+ - **Status now reports Context Engine activity precisely.** `nowledge_mem_status` only reports Context Engine capture as active when registration actually succeeded in the current runtime. If the slot points to `nowledge-mem` but the runtime falls back to hooks-only mode, the status output now says so directly.
79
+
80
+ ## [0.8.5] - 2026-04-09
81
+
82
+ ### Changed
83
+
84
+ - **Reduced loader coupling in the published package.** `openclaw.install.minHostVersion` and `peerDependencies.openclaw` have been removed. The plugin already uses runtime feature detection and graceful fallback, so those package-level host constraints were unnecessary. `openclaw.compat.pluginApi` and `openclaw.build.openclawVersion` remain for ClawHub validation.
85
+ - **`nowledge_mem_status` now explains capture routing and transport split.** It reports whether thread capture is running through lifecycle hooks or Context Engine `afterTurn`, and makes the transport model explicit: memory tools use the `nmem` CLI, while thread auto-sync uses the Mem HTTP API. This closes the common debugging gap where search works but thread sync does not.
86
+
5
87
  ## [0.8.3] - 2026-04-08
6
88
 
7
89
  ### Added
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
 
@@ -94,7 +95,7 @@ A shared `ceState.active` flag (in `ce-state.js`) coordinates the two paths:
94
95
  |-----------|-----------|---------------------|
95
96
  | Behavioral guidance | `assemble()` → `systemPromptAddition` | `before_prompt_build` → `appendSystemContext` |
96
97
  | Memory recall | `assemble()` → `systemPromptAddition` | `before_prompt_build` → `appendSystemContext` |
97
- | Thread capture | `afterTurn()` (every turn) | `agent_end` / `after_compaction` / `before_reset` |
98
+ | Thread capture | `afterTurn()` (every turn) + hook fallback | `agent_end` / `after_compaction` / `before_reset` |
98
99
  | Triage + distill | `afterTurn()` | `agent_end` only |
99
100
  | Compaction | `compact()` with memory-aware instructions | None (OpenClaw legacy) |
100
101
  | Subagent context | `prepareSubagentSpawn()` + `onSubagentEnded()` | None |
@@ -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. You can find it later with `nowledge_mem_thread_search`. On top of that, a lightweight LLM triage checks if the conversation contained decisions, insights, or preferences worth keeping as structured memories. If yes, they're extracted with proper types, labels, and temporal context. If the conversation was routine ("fix this typo"), nothing extra is saved.
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
 
@@ -408,15 +433,15 @@ To change settings, use the OpenClaw plugin settings UI. Changes take effect on
408
433
 
409
434
  ### Remote access
410
435
 
411
- Create `~/.nowledge-mem/config.json` with your credentials. This file is shared by all Nowledge Mem integrations (nmem CLI, Bub, Claude Code, etc.) so one file connects everything:
436
+ Configure this machine once:
412
437
 
413
- ```json
414
- {
415
- "apiUrl": "https://<your-url>",
416
- "apiKey": "nmem_..."
417
- }
438
+ ```bash
439
+ nmem config client set url https://<your-url>
440
+ nmem config client set api-key nmem_...
418
441
  ```
419
442
 
443
+ That writes the shared local client config used by `nmem`, OpenClaw, Bub, Claude Code, and other integrations on this machine. You can still set **Server URL** and **API key** in the OpenClaw dashboard if you want plugin-specific overrides.
444
+
420
445
  See [Access Mem Anywhere](https://mem.nowledge.co/docs/remote-access).
421
446
 
422
447
  ### Advanced: environment variables
@@ -449,7 +474,7 @@ Credentials (apiUrl, apiKey):
449
474
  openclaw.json (legacy) > OpenClaw dashboard > config.json (shared) > env vars > defaults
450
475
  ```
451
476
 
452
- `~/.nowledge-mem/openclaw.json` is still honored for backward compatibility but is no longer the recommended path. New users should configure plugin-specific settings via the OpenClaw dashboard and shared credentials via `~/.nowledge-mem/config.json`.
477
+ `~/.nowledge-mem/openclaw.json` is still honored for backward compatibility but is no longer the recommended path. New users should configure plugin-specific settings via the OpenClaw dashboard and shared credentials via `nmem config client ...`.
453
478
 
454
479
  Use `nowledge_mem_status` (or `openclaw nowledge-mem status`) to see where each value comes from.
455
480
 
@@ -505,6 +530,43 @@ If `openclaw status` shows a CRITICAL warning about `plugins.allow`, this is the
505
530
 
506
531
  Do not list `nowledge_mem_*` tool names in `tools.allow` — OpenClaw silently strips allowlists that contain only plugin entries, so the config looks active but does nothing.
507
532
 
533
+ **Memory tools still work, but thread auto-sync does not**
534
+
535
+ This is usually a capture-path issue, not a search/save issue:
536
+
537
+ - memory tools use the local `nmem` CLI
538
+ - thread auto-sync uses the Mem HTTP API
539
+ - cron / isolated automation sessions are intentionally excluded from capture in `0.8.x`
540
+
541
+ Run `nowledge_mem_status` in a conversation and check, in this order:
542
+
543
+ - the plugin is loaded
544
+ - `sessionDigest` is still `true`
545
+ - the backend is reachable
546
+ - which capture path is active: hook events, or Context Engine `afterTurn` with hook fallback
547
+ - whether you changed plugin settings recently without restarting OpenClaw yet
548
+
549
+ If your config enables `plugins.slots.contextEngine: "nowledge-mem"`:
550
+
551
+ - on `0.8.6+`, Context Engine capture stays active and hooks remain enabled as a safety net
552
+ - on `0.8.5` and earlier, temporarily removing the `contextEngine` slot is a valid isolation step if thread sync stops while tools still work
553
+
554
+ Remember: OpenClaw applies plugin setting changes after restart. If you turned `sessionDigest` off earlier but had not restarted yet, thread sync could appear to keep working until the next restart, then stop.
555
+
556
+ What healthy OpenClaw thread sync looks like:
557
+
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
561
+ - helper sessions like `temp:slug-generator` do not appear
562
+ - the synthetic `/new` / `/reset` startup prompt is not stored as the first user message
563
+
564
+ To inspect the recent synced threads directly, run:
565
+
566
+ ```bash
567
+ nmem t list --source openclaw -n 20
568
+ ```
569
+
508
570
  **Search timeouts with many concurrent agents**
509
571
 
510
572
  When running many agents in parallel, all searches share a single database connection. Upgrade to Nowledge Mem v0.6.12+ (backend) so scoring writes no longer block search responses.
@@ -515,7 +577,7 @@ The Nowledge Mem backend is not running. Start it from the desktop app, or run t
515
577
 
516
578
  **Remote mode not connecting**
517
579
 
518
- Check `~/.nowledge-mem/config.json` credentials. Run `nmem --json --api-url "$URL" status` to verify the server is reachable.
580
+ Run `nmem config client show` and confirm the effective URL and API key state. Then run `nmem --json --api-url "$URL" status` to verify the server is reachable.
519
581
 
520
582
  ## What Makes This Different
521
583
 
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.3",
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.3",
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": {
@@ -27,8 +27,7 @@
27
27
  "openclaw": {
28
28
  "extensions": ["./src/index.js"],
29
29
  "install": {
30
- "npmSpec": "@nowledge/openclaw-nowledge-mem",
31
- "minHostVersion": ">=2026.4.5"
30
+ "npmSpec": "@nowledge/openclaw-nowledge-mem"
32
31
  },
33
32
  "compat": {
34
33
  "pluginApi": ">=2026.4.5"
@@ -43,13 +42,11 @@
43
42
  },
44
43
  "main": "src/index.js",
45
44
  "scripts": {
45
+ "test": "node --test tests/*.test.mjs",
46
46
  "validate": "node scripts/validate-plugin.mjs",
47
47
  "lint": "biome check .",
48
48
  "format": "biome format --write ."
49
49
  },
50
- "peerDependencies": {
51
- "openclaw": ">=2026.4.5"
52
- },
53
50
  "devDependencies": {
54
51
  "@biomejs/biome": "^1.9.0"
55
52
  }
package/src/ce-state.js CHANGED
@@ -1,8 +1,8 @@
1
1
  /**
2
2
  * Shared mutable state flag for context engine activation.
3
3
  *
4
- * When the CE is bootstrapped, `active` is set to true. Event hooks
5
- * check this flag to avoid duplicate work — the CE's lifecycle methods
6
- * (assemble, afterTurn, etc.) replace hook behavior when active.
4
+ * When the CE is bootstrapped, `active` is set to true. Prompt-building hooks
5
+ * use this to avoid duplicate context injection. Capture hooks stay enabled as
6
+ * a safety net because thread sync is already tail-synced and idempotent.
7
7
  */
8
8
  export const ceState = { active: false };
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
  }
@@ -2,8 +2,8 @@
2
2
  * Nowledge Mem Context Engine for OpenClaw.
3
3
  *
4
4
  * Registers alongside the memory slot (kind: "memory"). When users activate
5
- * this CE via `plugins.slots.contextEngine: "nowledge-mem"`, it replaces the
6
- * hook-based approach with the richer CE lifecycle:
5
+ * this CE via `plugins.slots.contextEngine: "nowledge-mem"`, it takes over
6
+ * prompt assembly and adds richer lifecycle behavior:
7
7
  *
8
8
  * assemble() — behavioral guidance + recalled memories via systemPromptAddition
9
9
  * afterTurn() — continuous thread capture + triage/distillation (every turn)
@@ -12,7 +12,9 @@
12
12
  * bootstrap() — pre-warm Working Memory for first assemble
13
13
  *
14
14
  * When not activated, the existing hooks (behavioral, recall, capture) work
15
- * as before — full backward compatibility.
15
+ * as before. When activated, behavioral + recall hooks defer to the CE to
16
+ * avoid duplicate prompt context, while capture hooks remain as a safe
17
+ * fallback if a CE post-turn lifecycle is missed.
16
18
  *
17
19
  * Design:
18
20
  * - ownsCompaction: false — we enhance compaction instructions, not the algorithm
@@ -25,6 +27,7 @@ import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
25
27
  import {
26
28
  appendOrCreateThread,
27
29
  hasSkipMarker,
30
+ isInternalCaptureSessionKey,
28
31
  isCronCaptureSessionKey,
29
32
  matchesExcludePattern,
30
33
  triageAndDistill,
@@ -356,6 +359,10 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
356
359
  logger.debug?.(`ce: skipped cron session ${normalizedKey}`);
357
360
  return;
358
361
  }
362
+ if (isInternalCaptureSessionKey(normalizedKey)) {
363
+ logger.debug?.(`ce: skipped internal session ${normalizedKey}`);
364
+ return;
365
+ }
359
366
 
360
367
  // Capture exclusion: pattern-based and marker-based filters
361
368
  if (matchesExcludePattern(normalizedKey, cfg.captureExclude)) {
@@ -1,12 +1,26 @@
1
- import { createHash } from "node:crypto";
2
1
  import { readFile } from "node:fs/promises";
3
- import { isCronSessionKey } from "openclaw/plugin-sdk/routing";
4
- import { ceState } from "../ce-state.js";
2
+ import { createHash } from "node:crypto";
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";
5
13
 
6
14
  export const DEFAULT_MAX_MESSAGE_CHARS = 800;
7
15
  export const MAX_DISTILL_MESSAGE_CHARS = 2000;
8
16
  export const MAX_CONVERSATION_CHARS = 30_000;
9
17
  export const MIN_MESSAGES_FOR_DISTILL = 4;
18
+ const SESSION_RESET_PROMPT_PREFIX =
19
+ "A new session was started via /new or /reset.";
20
+ const OPENCLAW_DIRECTIVE_TAG_RE =
21
+ /\s*\[\[\s*(?:audio_as_voice|reply_to_current|reply_to\s*:\s*[^\]\n]+)\s*\]\]\s*/giu;
22
+ const OPENCLAW_INTERNAL_SENDER_BLOCK_RE =
23
+ /^Sender \(untrusted metadata\):\s*```json\s*([\s\S]*?)\s*```\s*([\s\S]*)$/iu;
10
24
 
11
25
  // Per-thread triage cooldown: prevents burst triage/distillation from heartbeat.
12
26
  // Maps threadId -> timestamp (ms) of last successful triage.
@@ -15,7 +29,6 @@ const _lastCaptureAt = new Map();
15
29
  const _MAX_COOLDOWN_ENTRIES = 200;
16
30
  const _syncedMessageCounts = new Map();
17
31
  const _MAX_SYNC_CURSOR_ENTRIES = 500;
18
-
19
32
  function _setLastCapture(threadId, now) {
20
33
  _lastCaptureAt.set(threadId, now);
21
34
  // Opportunistic eviction: sweep stale entries when map grows large
@@ -113,6 +126,52 @@ export function hasSkipMarker(messages, marker) {
113
126
  });
114
127
  }
115
128
 
129
+ /**
130
+ * Internal helper / system sessions should not become user-facing Mem threads.
131
+ *
132
+ * - temp:* covers OpenClaw helper sessions such as slug generation
133
+ * - subagent sessions are execution internals, not first-class user chats
134
+ */
135
+ export function isInternalCaptureSessionKey(sessionKey) {
136
+ const raw = String(sessionKey || "")
137
+ .trim()
138
+ .toLowerCase();
139
+ if (!raw) return false;
140
+ if (raw.startsWith("temp:")) return true;
141
+ return isSubagentSessionKey(raw);
142
+ }
143
+
144
+ function stripOpenClawDirectiveTags(text) {
145
+ return String(text || "")
146
+ .replace(OPENCLAW_DIRECTIVE_TAG_RE, " ")
147
+ .replace(/[ \t]+\n/g, "\n")
148
+ .replace(/\n{3,}/g, "\n\n")
149
+ .trim();
150
+ }
151
+
152
+ function stripInternalSenderMetadata(text) {
153
+ const raw = String(text || "").trim();
154
+ if (!raw.startsWith("Sender (untrusted metadata):")) return raw;
155
+ const match = raw.match(OPENCLAW_INTERNAL_SENDER_BLOCK_RE);
156
+ if (!match) return raw;
157
+ const [, jsonBlock, remainder] = match;
158
+ try {
159
+ const parsed = JSON.parse(jsonBlock);
160
+ const label = String(parsed?.label || "")
161
+ .trim()
162
+ .toLowerCase();
163
+ const id = String(parsed?.id || "")
164
+ .trim()
165
+ .toLowerCase();
166
+ if (label === "openclaw-control-ui" || id === "openclaw-control-ui") {
167
+ return String(remainder || "").trim();
168
+ }
169
+ } catch {
170
+ return raw;
171
+ }
172
+ return raw;
173
+ }
174
+
116
175
  // ---------------------------------------------------------------------------
117
176
  // Message normalization utilities
118
177
  // ---------------------------------------------------------------------------
@@ -151,7 +210,13 @@ export function normalizeRoleMessage(
151
210
  raw.message && typeof raw.message === "object" ? raw.message : raw;
152
211
  const role = typeof msg.role === "string" ? msg.role : "";
153
212
  if (role !== "user" && role !== "assistant") return null;
154
- const text = extractText(msg.content);
213
+ const extractedText = extractText(msg.content);
214
+ if (!extractedText) return null;
215
+ const cleanedText = stripInternalSenderMetadata(extractedText);
216
+ if (role === "user" && cleanedText.startsWith(SESSION_RESET_PROMPT_PREFIX)) {
217
+ return null;
218
+ }
219
+ const text = stripOpenClawDirectiveTags(cleanedText);
155
220
  if (!text) return null;
156
221
  if (role === "user" && text.startsWith("/")) return null;
157
222
 
@@ -184,10 +249,9 @@ export function normalizeRoleMessage(
184
249
  };
185
250
  }
186
251
 
187
- export function buildThreadTitle(ctx, reason) {
252
+ export function buildThreadTitle(ctx) {
188
253
  const session = ctx?.sessionKey || ctx?.sessionId || "session";
189
- const reasonSuffix = reason ? ` (${reason})` : "";
190
- return `OpenClaw ${session}${reasonSuffix}`;
254
+ return `OpenClaw ${session}`;
191
255
  }
192
256
 
193
257
  function sanitizeIdPart(input, max = 48) {
@@ -199,17 +263,6 @@ function sanitizeIdPart(input, max = 48) {
199
263
  return normalized.slice(0, max);
200
264
  }
201
265
 
202
- export function buildStableThreadId(event, ctx) {
203
- const base =
204
- String(ctx?.sessionId || "").trim() ||
205
- String(ctx?.sessionKey || "").trim() ||
206
- String(event?.sessionFile || "").trim() ||
207
- "session";
208
- const slug = sanitizeIdPart(base);
209
- const digest = createHash("sha1").update(base).digest("hex").slice(0, 10);
210
- return `openclaw-${slug}-${digest}`;
211
- }
212
-
213
266
  function buildExternalId({ normalized, index, threadId, sessionKey }) {
214
267
  if (normalized.externalHint) {
215
268
  return `oc:${sanitizeIdPart(normalized.externalHint, 96)}`;
@@ -282,7 +335,7 @@ export async function appendOrCreateThread({
282
335
  const threadId = buildStableThreadId(event, ctx);
283
336
  const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "session");
284
337
  const sessionId = String(ctx?.sessionId || "").trim();
285
- const title = buildThreadTitle(ctx, reason);
338
+ const title = buildThreadTitle(ctx);
286
339
  const normalized = rawMessages
287
340
  .map((message) => normalizeRoleMessage(message, maxMessageChars))
288
341
  .filter(Boolean);
@@ -457,7 +510,7 @@ export async function triageAndDistill({
457
510
 
458
511
  const distillResult = await client.distillThread({
459
512
  threadId: captureResult.threadId,
460
- title: buildThreadTitle(ctx, "distilled"),
513
+ title: buildThreadTitle(ctx),
461
514
  content: conversationText,
462
515
  });
463
516
 
@@ -477,8 +530,9 @@ export async function triageAndDistill({
477
530
  /**
478
531
  * Capture thread + LLM-based distillation after a successful agent run.
479
532
  *
480
- * When the context engine is active, this hook is a no-op — afterTurn
481
- * handles capture and distillation through the CE lifecycle.
533
+ * Even when the context engine is active, this hook remains as a safety net.
534
+ * Thread append/create is already idempotent, so the hook path can safely
535
+ * backstop CE afterTurn without duplicating stored messages.
482
536
  *
483
537
  * Heartbeat sessions (ctx.trigger === "heartbeat") are skipped — they
484
538
  * produce repetitive status pings that aren't worth preserving.
@@ -487,7 +541,6 @@ export async function triageAndDistill({
487
541
  */
488
542
  export function buildAgentEndCaptureHandler(client, cfg, logger) {
489
543
  return async (event, ctx) => {
490
- if (ceState.active) return;
491
544
  if (!event?.success) return;
492
545
  if (ctx?.trigger === "heartbeat") return;
493
546
 
@@ -496,6 +549,10 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
496
549
  logger.debug?.(`capture: skipped cron session ${sessionKey}`);
497
550
  return;
498
551
  }
552
+ if (isInternalCaptureSessionKey(sessionKey)) {
553
+ logger.debug?.(`capture: skipped internal session ${sessionKey}`);
554
+ return;
555
+ }
499
556
 
500
557
  // Pattern-based exclusion (e.g. subagent sessions, custom jobs)
501
558
  if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
@@ -530,14 +587,13 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
530
587
  * Capture thread messages before reset or after compaction.
531
588
  * Thread-only (no distillation) — these are lifecycle checkpoints.
532
589
  *
533
- * When the context engine is active, this hook is a no-op — afterTurn
534
- * handles capture through the CE lifecycle.
590
+ * This hook also stays enabled when the context engine is active.
591
+ * The shared tail-sync dedup keeps the fallback path cheap and safe.
535
592
  *
536
593
  * Heartbeat sessions are skipped (same rationale as agent_end).
537
594
  */
538
595
  export function buildBeforeResetCaptureHandler(client, cfg, logger) {
539
596
  return async (event, ctx) => {
540
- if (ceState.active) return;
541
597
  if (ctx?.trigger === "heartbeat") return;
542
598
 
543
599
  const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
@@ -545,6 +601,10 @@ export function buildBeforeResetCaptureHandler(client, cfg, logger) {
545
601
  logger.debug?.(`capture: skipped cron session ${sessionKey}`);
546
602
  return;
547
603
  }
604
+ if (isInternalCaptureSessionKey(sessionKey)) {
605
+ logger.debug?.(`capture: skipped internal session ${sessionKey}`);
606
+ return;
607
+ }
548
608
 
549
609
  if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
550
610
  logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
@@ -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)
@@ -57,30 +60,43 @@ export default {
57
60
 
58
61
  // Diagnostics — pass runtime config so the status tool can detect:
59
62
  // - plugins.allow missing or not including this plugin
60
- // - memory slot pointing elsewhere (e.g. memory-core)
63
+ // - memory/context-engine slot routing for capture and recall
61
64
  const memorySlot = api.config?.plugins?.slots?.memory;
65
+ const contextEngineSlot = api.config?.plugins?.slots?.contextEngine;
62
66
  const pluginsAllow = api.config?.plugins?.allow;
63
- api.registerTool(
64
- createStatusTool(client, logger, cfg, { memorySlot, pluginsAllow }),
65
- );
67
+ let contextEngineRegistered = false;
68
+ let contextEngineRegistrationError = null;
66
69
 
67
70
  // --- Context Engine registration ---
68
71
  // When the user sets `plugins.slots.contextEngine: "nowledge-mem"`,
69
- // this CE takes over from the hooks below (assemble replaces behavioral
70
- // + recall; afterTurn replaces agent_end + capture hooks). When the CE
71
- // slot points elsewhere, hooks continue working as before.
72
+ // this CE takes over prompt assembly from the hooks below (assemble
73
+ // replaces behavioral + recall prompt injection). Capture hooks remain
74
+ // enabled as a reliability backstop because thread sync is idempotent.
75
+ // When the CE slot points elsewhere, hooks continue working as before.
72
76
  try {
73
77
  api.registerContextEngine(
74
78
  "nowledge-mem",
75
79
  createNowledgeMemContextEngineFactory(client, cfg, logger),
76
80
  );
81
+ contextEngineRegistered = true;
77
82
  } catch (err) {
78
83
  // OpenClaw < CE support — degrade gracefully to hooks-only mode
84
+ contextEngineRegistrationError = String(err);
79
85
  logger.debug?.(
80
86
  `nowledge-mem: context engine registration unavailable (${err}), using hooks`,
81
87
  );
82
88
  }
83
89
 
90
+ api.registerTool(
91
+ createStatusTool(client, logger, cfg, {
92
+ memorySlot,
93
+ contextEngineSlot,
94
+ pluginsAllow,
95
+ contextEngineRegistered,
96
+ contextEngineRegistrationError,
97
+ }),
98
+ );
99
+
84
100
  // --- Corpus Supplement (when memory-core is the memory slot) ---
85
101
  // Makes Nowledge Mem's knowledge graph searchable through memory-core's
86
102
  // recall pipeline and dreaming promotion. Memories participate in
@@ -102,9 +118,10 @@ export default {
102
118
  }
103
119
  }
104
120
 
105
- // --- Hooks (fallback when CE is not active) ---
106
- // Each hook checks ceState.active and returns early when the CE handles
107
- // the same lifecycle through assemble/afterTurn.
121
+ // --- Hooks ---
122
+ // Behavioral + recall hooks defer to the active CE to avoid duplicate
123
+ // prompt injection. Capture hooks remain enabled as a backstop because
124
+ // thread append/create is tail-synced and idempotent.
108
125
 
109
126
  // Always-on: behavioral guidance so the agent proactively saves and searches.
110
127
  // Fires every turn via before_prompt_build — ~50 tokens, negligible cost.
@@ -123,6 +140,8 @@ export default {
123
140
  cfg,
124
141
  logger,
125
142
  );
143
+ api.on("session_start", registerSessionStartConversation);
144
+ api.on("session_end", registerSessionEndConversation);
126
145
  api.on("agent_end", buildAgentEndCaptureHandler(client, cfg, logger));
127
146
  api.on("after_compaction", (event, ctx) =>
128
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
  }
@@ -61,7 +61,20 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
61
61
 
62
62
  // 0b. Memory slot check — show current mode and options
63
63
  const memorySlot = runtimeInfo.memorySlot;
64
- details.memorySlot = memorySlot ?? "(unknown)";
64
+ const contextEngineSlot = runtimeInfo.contextEngineSlot;
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
+ typeof contextEngineSlot === "string" && contextEngineSlot.trim()
74
+ ? contextEngineSlot.trim()
75
+ : "legacy";
76
+ details.contextEngineSlot = ceSlot;
77
+ details.contextEngineRegistered = contextEngineRegistered;
65
78
  if (memorySlot && memorySlot !== "openclaw-nowledge-mem") {
66
79
  const corpusOn = cfg.corpusSupplement === true;
67
80
  if (corpusOn) {
@@ -90,17 +103,61 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
90
103
  lines.push("Memory slot: openclaw-nowledge-mem (active)");
91
104
  }
92
105
 
106
+ // 0c. Capture routing — hooks vs context engine
107
+ const ceActive = ceSlot === "nowledge-mem" && contextEngineRegistered;
108
+ const captureMode = !cfg.sessionDigest
109
+ ? "disabled"
110
+ : ceActive ? "context-engine+hooks" : "hooks";
111
+ details.captureMode = captureMode;
112
+ if (ceActive) {
113
+ lines.push("Context Engine slot: nowledge-mem (active)");
114
+ lines.push(
115
+ " Thread capture runs through Context Engine afterTurn with hook fallback (agent_end, after_compaction, before_reset).",
116
+ );
117
+ } else if (ceSlot === "nowledge-mem") {
118
+ lines.push("Context Engine slot: nowledge-mem (configured, fallback to hooks)");
119
+ lines.push(
120
+ " The slot points to Nowledge Mem, but Context Engine registration is not active in this runtime. Thread capture is using hooks only.",
121
+ );
122
+ if (contextEngineRegistrationError) {
123
+ lines.push(` CE registration detail: ${contextEngineRegistrationError}`);
124
+ }
125
+ } else {
126
+ lines.push(
127
+ `Context Engine slot: ${ceSlot === "legacy" ? "legacy (default)" : `"${ceSlot}"`}`,
128
+ );
129
+ if (cfg.sessionDigest) {
130
+ lines.push(
131
+ " Thread capture runs through hooks: agent_end, after_compaction, before_reset.",
132
+ );
133
+ }
134
+ }
135
+ if (!cfg.sessionDigest) {
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
+ );
141
+ }
142
+
93
143
  // 1. Mode + connection target
94
144
  const remote = !isDefaultApiUrl(cfg.apiUrl);
95
145
  const mode = remote ? "remote" : "local";
96
146
  details.mode = mode;
97
147
  details.apiUrl = cfg.apiUrl || "http://127.0.0.1:14242";
98
148
  details.apiKeySet = Boolean(cfg.apiKey);
149
+ details.space = cfg.space || null;
150
+ details.spaceSource = sources.space || "default";
99
151
 
100
152
  lines.push(
101
153
  `Mode: ${remote ? `Remote (${cfg.apiUrl})` : "Local (127.0.0.1:14242)"}`,
102
154
  );
103
155
  lines.push(`API key: ${cfg.apiKey ? "set" : "not set"}`);
156
+ lines.push(
157
+ `Ambient space: ${cfg.space ? `${cfg.space} (${details.spaceSource})` : "Default"}`,
158
+ );
159
+ lines.push("Memory tools: nmem CLI");
160
+ lines.push("Thread sync: Mem HTTP API");
104
161
 
105
162
  // 2. CLI resolution
106
163
  try {
@@ -156,6 +213,9 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
156
213
  } else {
157
214
  lines.push(" Ensure the Nowledge Mem desktop app is running.");
158
215
  }
216
+ lines.push(
217
+ " Memory tools may still work through the CLI, but thread auto-sync will fail while the HTTP API is unreachable.",
218
+ );
159
219
  }
160
220
 
161
221
  // 4. Effective config with sources
@@ -179,6 +239,9 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
179
239
  lines.push(
180
240
  ` maxThreadMessageChars: ${cfg.maxThreadMessageChars} (${sources.maxThreadMessageChars || "?"})`,
181
241
  );
242
+ lines.push(
243
+ ` corpusSupplement: ${cfg.corpusSupplement} (${sources.corpusSupplement || "?"})`,
244
+ );
182
245
 
183
246
  details.config = {
184
247
  sessionContext: {
@@ -205,6 +268,10 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
205
268
  value: cfg.maxThreadMessageChars,
206
269
  source: sources.maxThreadMessageChars,
207
270
  },
271
+ corpusSupplement: {
272
+ value: cfg.corpusSupplement,
273
+ source: sources.corpusSupplement,
274
+ },
208
275
  apiUrl: { value: cfg.apiUrl || "(local)", source: sources.apiUrl },
209
276
  apiKey: {
210
277
  value: cfg.apiKey ? "(set)" : "(not set)",