@nowledge/openclaw-nowledge-mem 0.7.1 → 0.8.1

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,7 +2,49 @@
2
2
 
3
3
  All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
4
4
 
5
- ## [Unreleased]
5
+ ## [0.8.1] - 2026-04-07
6
+
7
+ ### Fixed
8
+
9
+ - **Thread provenance linking now handles API response shape.** The backend returns `source_thread` as an `{id, title}` object, but `_normalizeMemory` and `memory-get.js` assigned it directly to `sourceThreadId`, producing `[object Object]` instead of a usable thread ID. Both paths now extract the `.id` string, with fallbacks for the CLI string format and metadata `source_thread_id`.
10
+ - **Child process env no longer leaks `"undefined"` strings.** `spawn-env.js` set `NMEM_API_URL` and `NMEM_API_KEY` to JavaScript `undefined` to clear inherited values, but Node's `child_process` stringifies that to the literal `"undefined"`. Now uses `delete` to properly remove the keys.
11
+
12
+ ## [0.8.0] - 2026-04-07
13
+
14
+ ### Fixed
15
+
16
+ - **Cron and isolated-agent runs no longer sync into Mem threads by default.** OpenClaw schedules these as `agent:<agentId>:cron:...` (for example the `cron-worker` agent). They were previously captured like normal chat because `captureExclude` defaulted to empty and segment-based globs do not cover variable-depth keys. The plugin now calls OpenClaw's published `isCronSessionKey` from `openclaw/plugin-sdk/routing` (available since v2026.3.22, no duplicated parsing logic), also skips bare `cron:*` internal keys that the SDK does not classify, and applies this in hook handlers and Context Engine `afterTurn` (thread append + distillation).
17
+
18
+ ### Added
19
+
20
+
21
+ - **Corpus supplement for OpenClaw dreaming.** Nowledge Mem's knowledge graph now participates in memory-core's recall pipeline and dreaming promotion. When `corpusSupplement: true` is set, memories stored in Nowledge Mem are searchable through memory-core's native `memory_search` tool and its three-phase dreaming system (light, deep, REM). Recalled Nowledge Mem content accumulates frequency, relevance, and diversity scores that feed into deep-phase promotion decisions. Cross-tool knowledge organically strengthens OpenClaw's local workspace memory.
22
+
23
+ Enable in your OpenClaw config:
24
+ ```json
25
+ { "corpusSupplement": true }
26
+ ```
27
+
28
+ Three new config keys control the supplement: `corpusSupplement` (boolean, default false), `corpusMaxResults` (1-20, default 5), `corpusMinScore` (0-100%, default 0).
29
+
30
+ - **Duplicate recall prevention.** When the corpus supplement is active, the plugin's own search-based recall (in the hook and Context Engine) is automatically disabled. Working Memory injection continues as before. This prevents the same memories from appearing twice in the agent's context.
31
+
32
+ ### Changed
33
+
34
+ - **Minimum OpenClaw version bumped to `>=2026.4.5`** for `registerMemoryCorpusSupplement` API support. Graceful fallback for older versions (registration silently skipped).
35
+
36
+ ## [0.7.3] - 2026-03-30
37
+
38
+ ### Added
39
+
40
+ - **Status tool now checks plugin trust and memory slot.** `nowledge_mem_status` reads `plugins.allow` from the OpenClaw config: warns when the allowlist exists but the plugin is missing (loading will be blocked); shows a tip when no allowlist is set (plugin loads via `entries.enabled`, but trust is implicit). Also warns when the memory slot points to `memory-core` instead of `openclaw-nowledge-mem` — a common misconfiguration after upgrading to OpenClaw 3.22+. Both checks include the exact fix command.
41
+ - **Clarified that no `tools.*` config is needed.** All plugin tools register automatically when the plugin loads. Listing `nowledge_mem_*` names in `tools.allow` is a misconfiguration — OpenClaw silently strips allowlists that contain only plugin entries. The only config users need is `plugins.allow: ["openclaw-nowledge-mem"]`. Updated troubleshooting in README, SKILL.md, and website docs (EN + ZH).
42
+
43
+ ## [0.7.2] - 2026-03-30
44
+
45
+ ### Added
46
+
47
+ - **Status tool now reports memory slot configuration.** `nowledge_mem_status` checks whether the OpenClaw memory slot points to `openclaw-nowledge-mem`. If another plugin (e.g. the built-in `memory-core`) holds the slot, the tool shows a warning with the fix command. This catches a common misconfiguration after upgrading to OpenClaw 3.22+, where the implicit slot default changed to `memory-core`.
6
48
 
7
49
  ### Fixed
8
50
 
package/CLAUDE.md CHANGED
@@ -7,7 +7,7 @@ Continuation guide for `community/nowledge-mem-openclaw-plugin`.
7
7
  - Plugin target: OpenClaw plugin runtime (memory slot + context engine)
8
8
  - Runtime: JS ESM modules under `src/`, no TS build pipeline
9
9
  - Memory backend: `nmem` CLI (fallback: `uvx --from nmem-cli nmem`)
10
- - OpenClaw minimum: `2026.3.7` (`appendSystemContext` / system-context guidance required)
10
+ - OpenClaw minimum: `2026.4.5` (`registerMemoryCorpusSupplement` for dreaming integration)
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.
@@ -34,6 +34,7 @@ src/
34
34
  client.js - CLI wrapper with API fallback; async runtime command execution; credential handling
35
35
  spawn-env.js - env-only credential injection for the nmem runner
36
36
  config.js - config cascade: openclaw.json (legacy) > pluginConfig > config.json (credentials) > env > defaults
37
+ corpus-supplement.js - MemoryCorpusSupplement for memory-core dreaming pipeline; search + get backed by nmem
37
38
  hooks/
38
39
  behavioral.js - always-on behavioral guidance (~50 tokens/turn); no-ops when CE active
39
40
  recall.js - before_prompt_build: inject Working Memory + recalled memories; no-ops when CE active
@@ -56,6 +57,28 @@ openclaw.plugin.json - manifest + config schema (version, uiHints, configSchema,
56
57
  ~/.nowledge-mem/config.json - shared credentials (apiUrl/apiKey) read by all Nowledge Mem tools
57
58
  ```
58
59
 
60
+ ## Corpus Supplement (Dreaming Integration)
61
+
62
+ When `corpusSupplement: true`, the plugin registers a `MemoryCorpusSupplement` via `api.registerMemoryCorpusSupplement()`. This makes Nowledge Mem's knowledge graph searchable through memory-core's recall pipeline, including its three-phase dreaming system (light, deep, REM).
63
+
64
+ ### How it works
65
+
66
+ 1. **Recall**: When memory-core's `memory_search` runs, it fans out to all registered corpus supplements. Our supplement calls `client.search()` and maps results to `MemoryCorpusSearchResult` format.
67
+ 2. **Dreaming**: Recalled Nowledge Mem content accumulates frequency, relevance, and diversity scores in memory-core's short-term recall store. High-scoring content is promoted to `MEMORY.md` during deep-phase dreaming.
68
+ 3. **Dedup**: When active, the recall hook and CE `assemble()` skip their own search-based recall (memories section only). Working Memory injection continues as before.
69
+
70
+ ### When to enable
71
+
72
+ Enable when memory-core is the memory slot and you want cross-tool knowledge to participate in OpenClaw's native recall and dreaming. Most users have Nowledge Mem as the memory slot (default `false`).
73
+
74
+ ### Config keys
75
+
76
+ | Key | Type | Default | Env Var | Description |
77
+ |-----|------|---------|---------|-------------|
78
+ | `corpusSupplement` | boolean | `false` | `NMEM_CORPUS_SUPPLEMENT` | Register as corpus supplement |
79
+ | `corpusMaxResults` | integer 1-20 | `5` | `NMEM_CORPUS_MAX_RESULTS` | Max results per search call |
80
+ | `corpusMinScore` | integer 0-100 | `0` | `NMEM_CORPUS_MIN_SCORE` | Min score filter (0 = include all) |
81
+
59
82
  ## Context Engine (CE) Architecture
60
83
 
61
84
  The plugin registers both a **memory slot** (`kind: "memory"`) and a **context engine** (`api.registerContextEngine`). These are independent registrations:
@@ -118,7 +141,7 @@ When `contextEngine` points elsewhere (or is absent), hooks handle everything. N
118
141
  - `nowledge_mem_thread_fetch` - fetch messages from a specific thread. Start with a small page, then use `offset` + `limit` for progressive retrieval only when more context is needed.
119
142
 
120
143
  ### Diagnostics
121
- - `nowledge_mem_status` - show effective config (mode, apiUrl, apiKey set, sessionContext, sessionDigest, etc.), backend connectivity, and version. No parameters.
144
+ - `nowledge_mem_status` - show plugin trust status, memory slot status, effective config (mode, apiUrl, apiKey set, sessionContext, sessionDigest, etc.), backend connectivity, and version. Checks `plugins.allow`: warns if set but plugin not included (loading blocked); shows tip if not set (plugin loads via `entries.enabled` but trust is implicit). Warns if the memory slot points to `memory-core` instead of `openclaw-nowledge-mem` (common after OpenClaw 3.22+ upgrade). No parameters.
122
145
 
123
146
  ## Skill Surface
124
147
 
@@ -151,6 +174,11 @@ Credentials (apiUrl/apiKey): also reads `~/.nowledge-mem/config.json` (shared wi
151
174
  | `maxContextResults` | integer 1-20 | `5` | `NMEM_MAX_CONTEXT_RESULTS` | How many memories to inject at prompt time |
152
175
  | `recallMinScore` | integer 0-100 | `0` | `NMEM_RECALL_MIN_SCORE` | Min relevance score (%) to include in auto-recall |
153
176
  | `maxThreadMessageChars` | integer 200-20000 | `800` | `NMEM_MAX_THREAD_MESSAGE_CHARS` | Max chars per captured thread message before truncation |
177
+ | `captureExclude` | string[] | `[]` | — | Session key glob patterns to skip during auto-capture. `*` matches within a colon-segment. Example: `["agent:*:cron:*"]` |
178
+ | `captureSkipMarker` | string | `"#nmem-skip"` | — | In-band marker: any message containing this text skips capture for the session. Not sticky across compaction |
179
+ | `corpusSupplement` | boolean | `false` | `NMEM_CORPUS_SUPPLEMENT` | Register as MemoryCorpusSupplement for memory-core recall + dreaming |
180
+ | `corpusMaxResults` | integer 1-20 | `5` | `NMEM_CORPUS_MAX_RESULTS` | Max results per corpus supplement search |
181
+ | `corpusMinScore` | integer 0-100 | `0` | `NMEM_CORPUS_MIN_SCORE` | Min score for supplement results (0 = all) |
154
182
  | `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
155
183
  | `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
156
184
 
package/README.md CHANGED
@@ -9,7 +9,7 @@ This is not a flat note store. Nowledge Mem links related knowledge into a graph
9
9
  ## Requirements
10
10
 
11
11
  - [Nowledge Mem](https://mem.nowledge.co) desktop app **or** `nmem` CLI
12
- - [OpenClaw](https://openclaw.ai) >= 2026.3.7
12
+ - [OpenClaw](https://openclaw.ai) >= 2026.4.5 (corpus supplement requires this version; core features work on >= 2026.3.7)
13
13
 
14
14
  ## Installation
15
15
 
@@ -187,6 +187,20 @@ Two entry points:
187
187
  1. **From a memory**: `memory_search` or `memory_get` returns `sourceThreadId`, then fetch the source conversation
188
188
  2. **Direct search**: `nowledge_mem_thread_search` finds conversations by keyword, then fetch any result
189
189
 
190
+ ### Corpus Supplement (Dreaming Integration)
191
+
192
+ When OpenClaw's memory-core is the primary memory slot and Nowledge Mem runs alongside it, you can enable `corpusSupplement: true`. This registers Nowledge Mem as a searchable corpus inside memory-core's recall pipeline and three-phase dreaming system (light, deep, REM).
193
+
194
+ **What happens:**
195
+ - Every time memory-core runs `memory_search`, it also searches Nowledge Mem's knowledge graph via the supplement.
196
+ - Recalled Nowledge Mem content accumulates frequency and relevance scores in memory-core's short-term store.
197
+ - High-scoring content is promoted to `MEMORY.md` during deep-phase dreaming (daily, 3 AM).
198
+ - The plugin's own search-based recall is automatically disabled to avoid duplicates. Working Memory injection continues as before.
199
+
200
+ **When to enable:** When memory-core is the memory slot and you want your cross-tool knowledge graph to participate in OpenClaw's native dreaming cycle. Most users have Nowledge Mem as the memory slot directly, so this defaults to `false`.
201
+
202
+ Requires OpenClaw >= 2026.4.5.
203
+
190
204
  ### Three Modes at a Glance
191
205
 
192
206
  ```mermaid
@@ -372,6 +386,9 @@ To change settings, use the OpenClaw plugin settings UI. Changes take effect on
372
386
  | `maxContextResults` | integer | `5` | Max memories to inject at prompt time (1-20, only used when sessionContext is enabled) |
373
387
  | `recallMinScore` | integer | `0` | Min relevance score (0-100%) to include in auto-recall. 0 = include all |
374
388
  | `maxThreadMessageChars` | integer | `800` | Maximum characters preserved per captured OpenClaw thread message before truncation |
389
+ | `corpusSupplement` | boolean | `false` | Register as a searchable corpus inside memory-core's recall and dreaming pipeline |
390
+ | `corpusMaxResults` | integer | `5` | Max results per corpus supplement search (1-20) |
391
+ | `corpusMinScore` | integer | `0` | Min relevance score (0-100%) for supplement results. 0 = include all |
375
392
  | `apiUrl` | string | `""` | Remote server URL. Empty = local (`http://127.0.0.1:14242`) |
376
393
  | `apiKey` | string | `""` | API key for remote access. Injected as `NMEM_API_KEY` env var, never logged |
377
394
 
@@ -399,6 +416,9 @@ NMEM_DIGEST_MIN_INTERVAL=300
399
416
  NMEM_MAX_CONTEXT_RESULTS=5
400
417
  NMEM_RECALL_MIN_SCORE=0
401
418
  NMEM_MAX_THREAD_MESSAGE_CHARS=800
419
+ NMEM_CORPUS_SUPPLEMENT=false
420
+ NMEM_CORPUS_MAX_RESULTS=5
421
+ NMEM_CORPUS_MIN_SCORE=0
402
422
  NMEM_API_URL=https://...
403
423
  NMEM_API_KEY=your-key
404
424
  ```
@@ -433,6 +453,56 @@ If OpenClaw logs a warning like `plugins.allow is empty; discovered non-bundled
433
453
 
434
454
  If you also use linked or workspace copies, review `plugins.load.paths` before relying on an allowlist. OpenClaw allowlists plugin ids, not install provenance.
435
455
 
456
+ ## Troubleshooting
457
+
458
+ **Only `memory_search` and `memory_get` show up — saves go to local markdown files**
459
+
460
+ The memory slot is probably still set to the built-in `memory-core`. OpenClaw 3.22+ defaults to `memory-core` when no explicit slot is configured. Reinstall to reset the slot automatically:
461
+
462
+ ```bash
463
+ openclaw plugins install @nowledge/openclaw-nowledge-mem
464
+ ```
465
+
466
+ Or set the slot manually in `~/.openclaw/openclaw.json`:
467
+
468
+ ```json
469
+ {
470
+ "plugins": {
471
+ "slots": { "memory": "openclaw-nowledge-mem" }
472
+ }
473
+ }
474
+ ```
475
+
476
+ Restart OpenClaw after either change.
477
+
478
+ **Plugin tools not available**
479
+
480
+ All 10 plugin tools register automatically when the plugin loads. No tool-level config is needed — just make sure the plugin itself is trusted:
481
+
482
+ ```json
483
+ {
484
+ "plugins": {
485
+ "allow": ["openclaw-nowledge-mem"]
486
+ }
487
+ }
488
+ ```
489
+
490
+ If `openclaw status` shows a CRITICAL warning about `plugins.allow`, this is the fix. Run `nowledge_mem_status` inside a conversation to check both plugin trust and memory slot status.
491
+
492
+ 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.
493
+
494
+ **Search timeouts with many concurrent agents**
495
+
496
+ 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.
497
+
498
+ **`nmem --json status` fails in local mode**
499
+
500
+ The Nowledge Mem backend is not running. Start it from the desktop app, or run the backend manually.
501
+
502
+ **Remote mode not connecting**
503
+
504
+ Check `~/.nowledge-mem/config.json` credentials. Run `nmem --json --api-url "$URL" status` to verify the server is reachable.
505
+
436
506
  ## What Makes This Different
437
507
 
438
508
  - **Local-first**: no API key, no cloud account. Your knowledge stays on your machine.
package/SKILL.md CHANGED
@@ -186,6 +186,18 @@ First check the plugin status:
186
186
  openclaw nowledge-mem status
187
187
  ```
188
188
 
189
+ If only `memory_search` and `memory_get` tools are available (other Nowledge Mem tools missing), the memory slot may still point to the built-in `memory-core`. Verify the slot in `~/.openclaw/openclaw.json`:
190
+
191
+ ```json
192
+ {
193
+ "plugins": {
194
+ "slots": { "memory": "openclaw-nowledge-mem" }
195
+ }
196
+ }
197
+ ```
198
+
199
+ If missing, reinstall (`openclaw plugins install @nowledge/openclaw-nowledge-mem`) which sets the slot automatically. This is common after upgrading OpenClaw to 3.22+ where the default memory slot changed.
200
+
189
201
  Then verify in chat.
190
202
 
191
203
  For a clean synthetic check:
@@ -237,6 +249,7 @@ What to expect:
237
249
  | `plugins.allow is empty` warning | Add `openclaw-nowledge-mem` to `plugins.allow` if the user wants explicit trust |
238
250
  | Remote config seems ignored | Check whether `~/.nowledge-mem/openclaw.json` is overriding plugin settings |
239
251
  | Local mode unexpectedly talks to a remote server | Check for stale `NMEM_API_URL` / `NMEM_API_KEY` in the environment or an overriding `~/.nowledge-mem/openclaw.json` |
252
+ | Plugin tools missing | Ensure the plugin is in `plugins.allow: ["openclaw-nowledge-mem"]`. Do **not** put `nowledge_mem_*` tool names in `tools.allow` — OpenClaw silently strips plugin-only allowlists. No `tools.*` config is needed; plugin tools load automatically when the plugin is allowed. |
240
253
 
241
254
  ## Notes for Agents
242
255
 
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "kind": "memory",
5
- "skills": ["skills/memory-guide"],
5
+ "skills": [
6
+ "skills/memory-guide"
7
+ ],
6
8
  "uiHints": {
7
9
  "sessionContext": {
8
10
  "label": "Session context injection",
@@ -28,6 +30,26 @@
28
30
  "label": "Max thread message chars",
29
31
  "help": "Maximum characters preserved per captured OpenClaw thread message (200-20000). Higher values keep more of long messages in Nowledge thread history."
30
32
  },
33
+ "captureExclude": {
34
+ "label": "Exclude session patterns",
35
+ "help": "Extra session key glob patterns to skip during auto-capture (* = one colon segment). OpenClaw cron / isolated automation sessions are always excluded using the host's own session classification\u2014no patterns required. Use this list for subagents or other sessions you want to filter."
36
+ },
37
+ "captureSkipMarker": {
38
+ "label": "Skip marker",
39
+ "help": "Text marker in messages that prevents capture for the session. Default: #nmem-skip. Note: if compaction drops the marked message, capture resumes \u2014 use captureExclude patterns for persistent filtering."
40
+ },
41
+ "corpusSupplement": {
42
+ "label": "Register as corpus supplement",
43
+ "help": "Make Nowledge Mem searchable through memory-core's recall pipeline and dreaming promotion. Enable when memory-core is your memory slot and you want cross-tool knowledge to participate in OpenClaw's native recall and dreaming. When enabled, the plugin's own search-based recall is skipped to avoid duplicates (Working Memory injection continues)."
44
+ },
45
+ "corpusMaxResults": {
46
+ "label": "Corpus supplement max results",
47
+ "help": "Maximum results returned per corpus supplement search call (1-20). Only used when corpusSupplement is enabled."
48
+ },
49
+ "corpusMinScore": {
50
+ "label": "Corpus supplement min score (%)",
51
+ "help": "Minimum relevance score (0-100%) for corpus supplement results. Set to 0 to include all. Only used when corpusSupplement is enabled."
52
+ },
31
53
  "apiUrl": {
32
54
  "label": "Server URL (remote mode)",
33
55
  "help": "Leave empty for local mode (default: http://127.0.0.1:14242). Set to your remote server URL for cross-device or team access. See: https://docs.nowledge.co/docs/remote-access"
@@ -79,6 +101,38 @@
79
101
  "maximum": 20000,
80
102
  "description": "Maximum characters preserved per captured OpenClaw thread message before truncation."
81
103
  },
104
+ "captureExclude": {
105
+ "type": "array",
106
+ "items": {
107
+ "type": "string"
108
+ },
109
+ "default": [],
110
+ "description": "Additional glob patterns to exclude from auto-capture (* = one colon segment). Cron and isolated automation are always excluded via OpenClaw's isCronSessionKey (plugin-sdk/routing) plus bare cron:* internal keys."
111
+ },
112
+ "captureSkipMarker": {
113
+ "type": "string",
114
+ "default": "#nmem-skip",
115
+ "description": "Text marker in messages that prevents capture for the entire session. When any message contains this marker, the session is skipped. Note: compaction may drop the marked message \u2014 use captureExclude for persistent filtering."
116
+ },
117
+ "corpusSupplement": {
118
+ "type": "boolean",
119
+ "default": false,
120
+ "description": "Register Nowledge Mem as a MemoryCorpusSupplement for memory-core's recall pipeline and dreaming. Enable when memory-core is your memory slot."
121
+ },
122
+ "corpusMaxResults": {
123
+ "type": "integer",
124
+ "default": 5,
125
+ "minimum": 1,
126
+ "maximum": 20,
127
+ "description": "Maximum results per corpus supplement search call (only used when corpusSupplement is enabled)"
128
+ },
129
+ "corpusMinScore": {
130
+ "type": "integer",
131
+ "default": 0,
132
+ "minimum": 0,
133
+ "maximum": 100,
134
+ "description": "Minimum relevance score (0-100%) for corpus supplement results (only used when corpusSupplement is enabled)"
135
+ },
82
136
  "apiUrl": {
83
137
  "type": "string",
84
138
  "default": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
- "version": "0.7.1",
3
+ "version": "0.8.1",
4
4
  "type": "module",
5
5
  "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
6
6
  "author": {
@@ -32,7 +32,7 @@
32
32
  "format": "biome format --write ."
33
33
  },
34
34
  "peerDependencies": {
35
- "openclaw": ">=2026.3.7"
35
+ "openclaw": ">=2026.4.5"
36
36
  },
37
37
  "devDependencies": {
38
38
  "@biomejs/biome": "^1.9.0"
package/src/client.js CHANGED
@@ -396,9 +396,11 @@ export class NowledgeMemClient {
396
396
  eventEnd: m.event_end ?? null,
397
397
  temporalContext: m.temporal_context ?? null,
398
398
  // Thread provenance: links memory to the conversation it was distilled from.
399
- // CLI returns `source_thread`, API metadata returns `source_thread_id`.
399
+ // CLI returns `source_thread` (string), API returns `source_thread: {id, title}` (object).
400
400
  sourceThreadId:
401
- m.source_thread ??
401
+ (typeof m.source_thread === "object" && m.source_thread?.id
402
+ ? String(m.source_thread.id)
403
+ : typeof m.source_thread === "string" ? m.source_thread : null) ??
402
404
  m.source_thread_id ??
403
405
  m.metadata?.source_thread_id ??
404
406
  null,
package/src/config.js CHANGED
@@ -21,6 +21,11 @@ const ALLOWED_KEYS = new Set([
21
21
  "maxContextResults",
22
22
  "recallMinScore",
23
23
  "maxThreadMessageChars",
24
+ "captureExclude",
25
+ "captureSkipMarker",
26
+ "corpusSupplement",
27
+ "corpusMaxResults",
28
+ "corpusMinScore",
24
29
  "apiUrl",
25
30
  "apiKey",
26
31
  // Legacy aliases — accepted but not advertised
@@ -195,6 +200,9 @@ function firstDefined(...options) {
195
200
  * NMEM_MAX_CONTEXT_RESULTS — integer (1-20)
196
201
  * NMEM_RECALL_MIN_SCORE — integer (0-100)
197
202
  * NMEM_MAX_THREAD_MESSAGE_CHARS — integer (200-20000)
203
+ * NMEM_CORPUS_SUPPLEMENT — true/1/yes to register as MemoryCorpusSupplement
204
+ * NMEM_CORPUS_MAX_RESULTS — integer (1-20)
205
+ * NMEM_CORPUS_MIN_SCORE — integer (0-100)
198
206
  * NMEM_API_URL — remote server URL
199
207
  * NMEM_API_KEY — API key (never logged)
200
208
  */
@@ -343,6 +351,86 @@ export function parseConfig(raw, logger) {
343
351
  const apiKey = ak.value;
344
352
  _sources.apiKey = ak.source;
345
353
 
354
+ // --- corpusSupplement: file > pluginConfig > env > default ---
355
+ const cs = firstDefined(
356
+ { value: pickBool(resolvedFile, "corpusSupplement"), source: "file" },
357
+ {
358
+ value: pickBool(resolvedPlugin, "corpusSupplement"),
359
+ source: "pluginConfig",
360
+ },
361
+ {
362
+ value: envBool("NMEM_CORPUS_SUPPLEMENT"),
363
+ source: "env",
364
+ },
365
+ { value: false, source: "default" },
366
+ );
367
+ const corpusSupplement = cs.value;
368
+ _sources.corpusSupplement = cs.source;
369
+
370
+ // --- corpusMaxResults: file > pluginConfig > env > default ---
371
+ const cmr = firstDefined(
372
+ { value: pickNum(resolvedFile, "corpusMaxResults"), source: "file" },
373
+ {
374
+ value: pickNum(resolvedPlugin, "corpusMaxResults"),
375
+ source: "pluginConfig",
376
+ },
377
+ { value: envInt("NMEM_CORPUS_MAX_RESULTS"), source: "env" },
378
+ { value: 5, source: "default" },
379
+ );
380
+ const corpusMaxResults = Math.min(20, Math.max(1, Math.trunc(cmr.value)));
381
+ _sources.corpusMaxResults = cmr.source;
382
+
383
+ // --- corpusMinScore: file > pluginConfig > env > default ---
384
+ const cmsEnv = envInt("NMEM_CORPUS_MIN_SCORE");
385
+ const cms = firstDefined(
386
+ { value: pickNum(resolvedFile, "corpusMinScore"), source: "file" },
387
+ {
388
+ value: pickNum(resolvedPlugin, "corpusMinScore"),
389
+ source: "pluginConfig",
390
+ },
391
+ { value: cmsEnv, source: "env" },
392
+ { value: 0, source: "default" },
393
+ );
394
+ const corpusMinScore = Math.min(100, Math.max(0, Math.trunc(cms.value)));
395
+ _sources.corpusMinScore = cms.source;
396
+
397
+ // --- captureExclude: file > pluginConfig > default ---
398
+ const captureExclude = (() => {
399
+ const fromFile = Array.isArray(resolvedFile.captureExclude)
400
+ ? resolvedFile.captureExclude
401
+ : null;
402
+ const fromPlugin = Array.isArray(resolvedPlugin.captureExclude)
403
+ ? resolvedPlugin.captureExclude
404
+ : null;
405
+ _sources.captureExclude = fromFile
406
+ ? "file"
407
+ : fromPlugin
408
+ ? "pluginConfig"
409
+ : "default";
410
+ const entries = fromFile ?? fromPlugin ?? [];
411
+ return entries
412
+ .filter((v) => typeof v === "string" && v.trim())
413
+ .map((v) => v.trim());
414
+ })();
415
+
416
+ // --- captureSkipMarker: file > pluginConfig > default ---
417
+ const captureSkipMarker = (() => {
418
+ const fromFile =
419
+ typeof resolvedFile.captureSkipMarker === "string"
420
+ ? resolvedFile.captureSkipMarker.trim()
421
+ : undefined;
422
+ const fromPlugin =
423
+ typeof resolvedPlugin.captureSkipMarker === "string"
424
+ ? resolvedPlugin.captureSkipMarker.trim()
425
+ : undefined;
426
+ _sources.captureSkipMarker = fromFile
427
+ ? "file"
428
+ : fromPlugin
429
+ ? "pluginConfig"
430
+ : "default";
431
+ return fromFile || fromPlugin || "#nmem-skip";
432
+ })();
433
+
346
434
  return {
347
435
  sessionContext,
348
436
  sessionDigest,
@@ -350,6 +438,11 @@ export function parseConfig(raw, logger) {
350
438
  maxContextResults,
351
439
  recallMinScore,
352
440
  maxThreadMessageChars,
441
+ captureExclude,
442
+ captureSkipMarker,
443
+ corpusSupplement,
444
+ corpusMaxResults,
445
+ corpusMinScore,
353
446
  apiUrl,
354
447
  apiKey,
355
448
  _sources,
@@ -22,7 +22,13 @@
22
22
 
23
23
  import { ceState } from "./ce-state.js";
24
24
  import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
25
- import { appendOrCreateThread, triageAndDistill } from "./hooks/capture.js";
25
+ import {
26
+ appendOrCreateThread,
27
+ hasSkipMarker,
28
+ isCronCaptureSessionKey,
29
+ matchesExcludePattern,
30
+ triageAndDistill,
31
+ } from "./hooks/capture.js";
26
32
  import {
27
33
  MAX_QUERY_LENGTH,
28
34
  SHORT_QUERY_THRESHOLD,
@@ -165,7 +171,7 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
165
171
  info: {
166
172
  id: "nowledge-mem",
167
173
  name: "Nowledge Mem",
168
- version: "0.7.0",
174
+ version: "0.8.0",
169
175
  ownsCompaction: false,
170
176
  },
171
177
 
@@ -230,7 +236,8 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
230
236
  }
231
237
 
232
238
  // 3. Recalled memories (when sessionContext enabled)
233
- if (cfg.sessionContext) {
239
+ // Skipped when corpus supplement handles search-based recall via memory-core.
240
+ if (cfg.sessionContext && !cfg.corpusSupplement) {
234
241
  const query = buildAssembleSearchQuery(prompt, messages);
235
242
  if (query) {
236
243
  try {
@@ -342,8 +349,28 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
342
349
  }) {
343
350
  if (isHeartbeat) return;
344
351
 
352
+ // Normalize sessionKey consistently with hook handlers
353
+ const normalizedKey = String(sessionKey || sessionId || "");
354
+
355
+ if (isCronCaptureSessionKey(normalizedKey)) {
356
+ logger.debug?.(`ce: skipped cron session ${normalizedKey}`);
357
+ return;
358
+ }
359
+
360
+ // Capture exclusion: pattern-based and marker-based filters
361
+ if (matchesExcludePattern(normalizedKey, cfg.captureExclude)) {
362
+ logger.debug?.(`ce: skipped excluded session ${normalizedKey}`);
363
+ return;
364
+ }
365
+ if (hasSkipMarker(messages, cfg.captureSkipMarker)) {
366
+ logger.debug?.(
367
+ `ce: skipped session with skip marker ${normalizedKey}`,
368
+ );
369
+ return;
370
+ }
371
+
345
372
  const event = { messages, sessionFile };
346
- const ctx = { sessionId, sessionKey };
373
+ const ctx = { sessionId, sessionKey: normalizedKey };
347
374
 
348
375
  // 1. Always capture thread (idempotent, deduped)
349
376
  const captureResult = await appendOrCreateThread({
@@ -0,0 +1,167 @@
1
+ /**
2
+ * MemoryCorpusSupplement implementation for OpenClaw's memory-core recall pipeline.
3
+ *
4
+ * When registered, Nowledge Mem's knowledge graph becomes searchable through
5
+ * memory-core's native `memory_search` tool and its dreaming promotion pipeline.
6
+ * This means memories stored in Nowledge Mem participate in OpenClaw's recall
7
+ * scoring, temporal decay, and short-term-to-long-term promotion — without the
8
+ * user needing to explicitly call Nowledge Mem tools.
9
+ *
10
+ * Enable via config: `corpusSupplement: true`
11
+ *
12
+ * Designed to coexist with the plugin's own tools and hooks:
13
+ * - Working Memory injection: still handled by the recall hook / CE (only we know about WM)
14
+ * - Search-based recall: handled by this supplement when active, by hooks/CE when not
15
+ *
16
+ * Error handling: both search() and get() catch all errors and return [] / null.
17
+ * Supplement failures must never break OpenClaw's recall pipeline.
18
+ */
19
+
20
+ /** Max snippet length in search results (matches memory-search.js truncation). */
21
+ const SNIPPET_MAX_CHARS = 700;
22
+
23
+ /**
24
+ * Parse a memory ID from a path or bare ID string.
25
+ * Handles `nowledgemem://memory/<id>` URIs and bare alphanumeric IDs.
26
+ */
27
+ function parseMemoryId(pathValue) {
28
+ const value = String(pathValue || "").trim();
29
+ if (!value) return null;
30
+
31
+ const fromDeeplink = value.match(
32
+ /^nowledgemem:\/\/memory\/([a-zA-Z0-9_-]+)$/u,
33
+ );
34
+ if (fromDeeplink) return fromDeeplink[1];
35
+
36
+ if (/^[a-zA-Z0-9_-]{8,}$/u.test(value)) {
37
+ return value;
38
+ }
39
+
40
+ return null;
41
+ }
42
+
43
+ /**
44
+ * Slice lines from text content with 1-based line indexing.
45
+ */
46
+ function sliceLines(text, from, lines) {
47
+ const allLines = String(text || "").split(/\r?\n/u);
48
+ const start = Math.max(1, Math.trunc(Number(from) || 1));
49
+ const maxLines = Math.max(1, Math.trunc(Number(lines) || allLines.length));
50
+ const startIdx = start - 1;
51
+ const endIdx = Math.min(allLines.length, startIdx + maxLines);
52
+ const selected = allLines.slice(startIdx, endIdx);
53
+ return {
54
+ text: selected.join("\n"),
55
+ startLine: start,
56
+ endLine: Math.max(start, start + selected.length - 1),
57
+ totalLines: allLines.length,
58
+ };
59
+ }
60
+
61
+ /**
62
+ * Create a MemoryCorpusSupplement backed by the Nowledge Mem knowledge graph.
63
+ *
64
+ * @param {import('./client.js').NowledgeMemClient} client
65
+ * @param {object} cfg Parsed plugin config (corpusMaxResults, corpusMinScore)
66
+ * @param {object} logger
67
+ * @returns {{ search: Function, get: Function }}
68
+ */
69
+ export function createNowledgeMemCorpusSupplement(client, cfg, logger) {
70
+ const maxResults = cfg.corpusMaxResults ?? 5;
71
+ const minScore = (cfg.corpusMinScore ?? 0) / 100; // config is 0-100, API is 0-1
72
+
73
+ return {
74
+ /**
75
+ * Search Nowledge Mem's knowledge graph for memories matching the query.
76
+ * Called by memory-core's recall pipeline on every turn.
77
+ */
78
+ async search({ query, maxResults: requestedMax }) {
79
+ try {
80
+ const limit = Math.min(20, Math.max(1, requestedMax ?? maxResults));
81
+ const results = await client.search(query, limit);
82
+
83
+ const filtered =
84
+ minScore > 0
85
+ ? results.filter((r) => (r.score ?? 0) >= minScore)
86
+ : results;
87
+
88
+ return filtered.map((m) => {
89
+ const snippet = String(m.content || "").slice(0, SNIPPET_MAX_CHARS);
90
+ const lineCount = Math.max(1, snippet.split(/\r?\n/u).length);
91
+ const result = {
92
+ corpus: "nowledge-mem",
93
+ path: `nowledgemem://memory/${m.id}`,
94
+ score: Number(m.score ?? 0),
95
+ snippet,
96
+ title: m.title || undefined,
97
+ kind: m.labels?.[0] || undefined,
98
+ id: m.id,
99
+ startLine: 1,
100
+ endLine: lineCount,
101
+ provenanceLabel: "Nowledge Mem",
102
+ source: "nowledge-mem",
103
+ sourceType: "knowledge-graph",
104
+ };
105
+ return result;
106
+ });
107
+ } catch (err) {
108
+ logger.warn?.(
109
+ `corpus-supplement: search failed: ${err instanceof Error ? err.message : err}`,
110
+ );
111
+ return [];
112
+ }
113
+ },
114
+
115
+ /**
116
+ * Retrieve a specific memory by path or ID.
117
+ * Called when memory-core's agent requests full content for a search result.
118
+ */
119
+ async get({ lookup, fromLine, lineCount }) {
120
+ try {
121
+ // Handle Working Memory alias
122
+ const lowerLookup = String(lookup || "")
123
+ .trim()
124
+ .toLowerCase();
125
+ if (lowerLookup === "memory.md" || lowerLookup === "memory") {
126
+ const wm = await client.readWorkingMemory();
127
+ if (!wm.available) return null;
128
+ const slice = sliceLines(wm.content, fromLine, lineCount);
129
+ return {
130
+ corpus: "nowledge-mem",
131
+ path: "nowledgemem://working-memory",
132
+ title: "Working Memory",
133
+ content: slice.text,
134
+ fromLine: slice.startLine,
135
+ lineCount: slice.endLine - slice.startLine + 1,
136
+ provenanceLabel: "Nowledge Mem",
137
+ sourceType: "working-memory",
138
+ };
139
+ }
140
+
141
+ const memoryId = parseMemoryId(lookup);
142
+ if (!memoryId) return null;
143
+
144
+ const memory = await client.getMemory(memoryId);
145
+ if (!memory) return null;
146
+ const slice = sliceLines(memory.content ?? "", fromLine, lineCount);
147
+
148
+ return {
149
+ corpus: "nowledge-mem",
150
+ path: `nowledgemem://memory/${memoryId}`,
151
+ title: memory.title || undefined,
152
+ content: slice.text,
153
+ fromLine: slice.startLine,
154
+ lineCount: slice.endLine - slice.startLine + 1,
155
+ id: memoryId,
156
+ provenanceLabel: "Nowledge Mem",
157
+ sourceType: "knowledge-graph",
158
+ };
159
+ } catch (err) {
160
+ logger.warn?.(
161
+ `corpus-supplement: get failed: ${err instanceof Error ? err.message : err}`,
162
+ );
163
+ return null;
164
+ }
165
+ },
166
+ };
167
+ }
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
+ import { isCronSessionKey } from "openclaw/plugin-sdk/routing";
3
4
  import { ceState } from "../ce-state.js";
4
5
 
5
6
  export const DEFAULT_MAX_MESSAGE_CHARS = 800;
@@ -40,6 +41,82 @@ function _setSyncedMessageCount(threadId, count) {
40
41
  }
41
42
  }
42
43
 
44
+ // ---------------------------------------------------------------------------
45
+ // Capture exclusion filters
46
+ // ---------------------------------------------------------------------------
47
+
48
+ /**
49
+ * Compile a glob pattern (where `*` matches within a colon-delimited segment)
50
+ * into a RegExp. Results are cached since patterns are immutable after parse.
51
+ */
52
+ const _compiledPatterns = new Map();
53
+ function _compileGlob(pattern) {
54
+ const key = String(pattern).toLowerCase();
55
+ let re = _compiledPatterns.get(key);
56
+ if (!re) {
57
+ re = new RegExp(
58
+ "^" +
59
+ key
60
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
61
+ .replace(/\*/g, "[^:]*") +
62
+ "$",
63
+ );
64
+ _compiledPatterns.set(key, re);
65
+ }
66
+ return re;
67
+ }
68
+
69
+ /**
70
+ * Test whether a session key matches any exclusion glob pattern.
71
+ * Glob `*` matches within a colon-delimited segment (not across colons).
72
+ * Example: "agent:*:cron:*" matches "agent:main:cron:abc123"
73
+ *
74
+ * Exported for reuse by Context Engine and other capture paths.
75
+ */
76
+ export function matchesExcludePattern(sessionKey, patterns) {
77
+ if (!Array.isArray(patterns) || patterns.length === 0) return false;
78
+ const key = String(sessionKey || "").toLowerCase();
79
+ return patterns.some((pattern) => _compileGlob(pattern).test(key));
80
+ }
81
+
82
+ /**
83
+ * Whether automatic thread capture should skip this OpenClaw session.
84
+ *
85
+ * - Agent-scoped cron / isolated-agent keys are classified by
86
+ * `isCronSessionKey` from `openclaw/plugin-sdk/routing` (same implementation
87
+ * the gateway uses). Requires OpenClaw >=2026.3.22.
88
+ * - Bare `cron:*` keys are still excluded: some internal paths (e.g. delivery /
89
+ * failure bookkeeping) use that shape without the `agent:` prefix, and
90
+ * `isCronSessionKey` only parses agent-scoped keys.
91
+ */
92
+ export function isCronCaptureSessionKey(sessionKey) {
93
+ const raw = String(sessionKey || "")
94
+ .trim()
95
+ .toLowerCase();
96
+ if (!raw) return false;
97
+ if (raw.startsWith("cron:")) return true;
98
+ return isCronSessionKey(raw);
99
+ }
100
+
101
+ /**
102
+ * Check if any message contains the skip marker text.
103
+ * Scans both raw message content and nested message objects.
104
+ *
105
+ * Exported for reuse by Context Engine and other capture paths.
106
+ */
107
+ export function hasSkipMarker(messages, marker) {
108
+ if (!marker || typeof marker !== "string" || !Array.isArray(messages)) return false;
109
+ const markerLc = marker.toLowerCase();
110
+ return messages.some((msg) => {
111
+ const text = extractText(msg?.content ?? msg?.message?.content);
112
+ return text.toLowerCase().includes(markerLc);
113
+ });
114
+ }
115
+
116
+ // ---------------------------------------------------------------------------
117
+ // Message normalization utilities
118
+ // ---------------------------------------------------------------------------
119
+
43
120
  export function truncate(text, max = DEFAULT_MAX_MESSAGE_CHARS) {
44
121
  const str = String(text || "").trim();
45
122
  if (!str) return "";
@@ -197,8 +274,9 @@ export async function appendOrCreateThread({
197
274
  ctx,
198
275
  reason,
199
276
  maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,
277
+ resolvedMessages,
200
278
  }) {
201
- const rawMessages = await resolveHookMessages(event);
279
+ const rawMessages = resolvedMessages ?? (await resolveHookMessages(event));
202
280
  if (!Array.isArray(rawMessages) || rawMessages.length === 0) return;
203
281
 
204
282
  const threadId = buildStableThreadId(event, ctx);
@@ -413,6 +491,27 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
413
491
  if (!event?.success) return;
414
492
  if (ctx?.trigger === "heartbeat") return;
415
493
 
494
+ const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
495
+ if (isCronCaptureSessionKey(sessionKey)) {
496
+ logger.debug?.(`capture: skipped cron session ${sessionKey}`);
497
+ return;
498
+ }
499
+
500
+ // Pattern-based exclusion (e.g. subagent sessions, custom jobs)
501
+ if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
502
+ logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
503
+ return;
504
+ }
505
+
506
+ // Layer 2: marker-based exclusion (user typed #nmem-skip in conversation)
507
+ const resolvedMessages = await resolveHookMessages(event);
508
+ if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
509
+ logger.debug?.(
510
+ `capture: skipped session with skip marker ${sessionKey}`,
511
+ );
512
+ return;
513
+ }
514
+
416
515
  const captureResult = await appendOrCreateThread({
417
516
  client,
418
517
  logger,
@@ -420,6 +519,7 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
420
519
  ctx,
421
520
  reason: "agent_end",
422
521
  maxMessageChars: cfg.maxThreadMessageChars,
522
+ resolvedMessages,
423
523
  });
424
524
 
425
525
  await triageAndDistill({ client, cfg, logger, captureResult, ctx });
@@ -435,10 +535,31 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
435
535
  *
436
536
  * Heartbeat sessions are skipped (same rationale as agent_end).
437
537
  */
438
- export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
538
+ export function buildBeforeResetCaptureHandler(client, cfg, logger) {
439
539
  return async (event, ctx) => {
440
540
  if (ceState.active) return;
441
541
  if (ctx?.trigger === "heartbeat") return;
542
+
543
+ const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
544
+ if (isCronCaptureSessionKey(sessionKey)) {
545
+ logger.debug?.(`capture: skipped cron session ${sessionKey}`);
546
+ return;
547
+ }
548
+
549
+ if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
550
+ logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
551
+ return;
552
+ }
553
+
554
+ // Layer 2: marker-based exclusion
555
+ const resolvedMessages = await resolveHookMessages(event);
556
+ if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
557
+ logger.debug?.(
558
+ `capture: skipped session with skip marker ${sessionKey}`,
559
+ );
560
+ return;
561
+ }
562
+
442
563
  const reason = typeof event?.reason === "string" ? event.reason : undefined;
443
564
  await appendOrCreateThread({
444
565
  client,
@@ -446,7 +567,8 @@ export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
446
567
  event,
447
568
  ctx,
448
569
  reason,
449
- maxMessageChars: _cfg?.maxThreadMessageChars,
570
+ maxMessageChars: cfg.maxThreadMessageChars,
571
+ resolvedMessages,
450
572
  });
451
573
  };
452
574
  }
@@ -180,15 +180,18 @@ export function buildRecalledKnowledgeBlock(
180
180
  export function buildRecallHandler(client, cfg, logger) {
181
181
  const minScore = (cfg.recallMinScore ?? 0) / 100; // config is 0-100, API is 0-1
182
182
 
183
+ // When corpus supplement is active, memory-core's recall pipeline searches
184
+ // Nowledge Mem via the supplement. We still inject Working Memory (only we
185
+ // know about WM), but skip our own search-based recall to avoid duplicates.
186
+ const skipSearchRecall = cfg.corpusSupplement;
187
+
183
188
  return async (event) => {
184
189
  if (ceState.active) return;
185
190
 
186
- const searchQuery = buildSearchQuery(event);
187
- if (!searchQuery) return;
188
-
189
191
  const sections = [];
190
192
 
191
- // 1. Working Memory — daily context, not a static profile
193
+ // 1. Working Memory — daily context, not a static profile.
194
+ // Always injected regardless of search query (WM is independent of user message).
192
195
  try {
193
196
  const wm = await client.readWorkingMemory();
194
197
  if (wm.available) {
@@ -200,22 +203,26 @@ export function buildRecallHandler(client, cfg, logger) {
200
203
  logger.error(`recall: working memory read failed: ${err}`);
201
204
  }
202
205
 
203
- // 2. Relevant memories — enriched with scoring signals and labels
204
- try {
205
- const results = await client.searchRich(
206
- searchQuery,
207
- cfg.maxContextResults,
208
- );
209
- // Filter by minimum score if configured
210
- const filtered =
211
- minScore > 0
212
- ? results.filter((r) => (r.score ?? 0) >= minScore)
213
- : results;
214
- if (filtered.length > 0) {
215
- sections.push(buildRecalledKnowledgeBlock(filtered));
206
+ // 2. Relevant memories — enriched with scoring signals and labels.
207
+ // Skipped when corpus supplement handles search-based recall via memory-core.
208
+ const searchQuery = buildSearchQuery(event);
209
+ if (!skipSearchRecall && searchQuery) {
210
+ try {
211
+ const results = await client.searchRich(
212
+ searchQuery,
213
+ cfg.maxContextResults,
214
+ );
215
+ // Filter by minimum score if configured
216
+ const filtered =
217
+ minScore > 0
218
+ ? results.filter((r) => (r.score ?? 0) >= minScore)
219
+ : results;
220
+ if (filtered.length > 0) {
221
+ sections.push(buildRecalledKnowledgeBlock(filtered));
222
+ }
223
+ } catch (err) {
224
+ logger.error(`recall: search failed: ${err}`);
216
225
  }
217
- } catch (err) {
218
- logger.error(`recall: search failed: ${err}`);
219
226
  }
220
227
 
221
228
  if (sections.length === 0) return;
package/src/index.js CHANGED
@@ -7,6 +7,7 @@ import {
7
7
  } from "./commands/slash.js";
8
8
  import { isDefaultApiUrl, parseConfig } from "./config.js";
9
9
  import { createNowledgeMemContextEngineFactory } from "./context-engine.js";
10
+ import { createNowledgeMemCorpusSupplement } from "./corpus-supplement.js";
10
11
  import { buildBehavioralHook } from "./hooks/behavioral.js";
11
12
  import {
12
13
  buildAgentEndCaptureHandler,
@@ -54,8 +55,14 @@ export default {
54
55
  api.registerTool(createThreadSearchTool(client, logger));
55
56
  api.registerTool(createThreadFetchTool(client, logger));
56
57
 
57
- // Diagnostics
58
- api.registerTool(createStatusTool(client, logger, cfg));
58
+ // Diagnostics — pass runtime config so the status tool can detect:
59
+ // - plugins.allow missing or not including this plugin
60
+ // - memory slot pointing elsewhere (e.g. memory-core)
61
+ const memorySlot = api.config?.plugins?.slots?.memory;
62
+ const pluginsAllow = api.config?.plugins?.allow;
63
+ api.registerTool(
64
+ createStatusTool(client, logger, cfg, { memorySlot, pluginsAllow }),
65
+ );
59
66
 
60
67
  // --- Context Engine registration ---
61
68
  // When the user sets `plugins.slots.contextEngine: "nowledge-mem"`,
@@ -74,6 +81,27 @@ export default {
74
81
  );
75
82
  }
76
83
 
84
+ // --- Corpus Supplement (when memory-core is the memory slot) ---
85
+ // Makes Nowledge Mem's knowledge graph searchable through memory-core's
86
+ // recall pipeline and dreaming promotion. Memories participate in
87
+ // temporal decay scoring and short-term-to-long-term promotion.
88
+ if (cfg.corpusSupplement) {
89
+ try {
90
+ api.registerMemoryCorpusSupplement(
91
+ createNowledgeMemCorpusSupplement(client, cfg, logger),
92
+ );
93
+ logger.info(
94
+ "nowledge-mem: registered as MemoryCorpusSupplement for memory-core recall",
95
+ );
96
+ } catch (err) {
97
+ // OpenClaw < corpus supplement support — fall back to normal recall
98
+ cfg.corpusSupplement = false;
99
+ logger.info?.(
100
+ `nowledge-mem: corpus supplement unavailable, falling back to normal recall (${err})`,
101
+ );
102
+ }
103
+ }
104
+
77
105
  // --- Hooks (fallback when CE is not active) ---
78
106
  // Each hook checks ceState.active and returns early when the CE handles
79
107
  // the same lifecycle through assemble/afterTurn.
@@ -114,7 +142,7 @@ export default {
114
142
 
115
143
  const remoteMode = !isDefaultApiUrl(cfg.apiUrl);
116
144
  logger.info(
117
- `nowledge-mem: initialized (context=${cfg.sessionContext}, digest=${cfg.sessionDigest}, mode=${remoteMode ? `remote → ${cfg.apiUrl}` : "local"})`,
145
+ `nowledge-mem: initialized (context=${cfg.sessionContext}, digest=${cfg.sessionDigest}, corpus=${cfg.corpusSupplement}, mode=${remoteMode ? `remote → ${cfg.apiUrl}` : "local"})`,
118
146
  );
119
147
  },
120
148
  };
package/src/spawn-env.js CHANGED
@@ -7,8 +7,8 @@ const LOCAL_API_URL = "http://127.0.0.1:14242";
7
7
  */
8
8
  export function buildNmemSpawnEnv({ apiUrl, apiKey } = {}) {
9
9
  const env = { ...process.env };
10
- env.NMEM_API_URL = undefined;
11
- env.NMEM_API_KEY = undefined;
10
+ delete env.NMEM_API_URL;
11
+ delete env.NMEM_API_KEY;
12
12
  if (apiUrl && apiUrl !== LOCAL_API_URL) {
13
13
  env.NMEM_API_URL = apiUrl;
14
14
  }
@@ -135,9 +135,13 @@ export function createMemoryGetTool(client, logger) {
135
135
  safeParams.lines,
136
136
  );
137
137
 
138
- // Thread provenance: link to the source conversation
138
+ // Thread provenance: link to the source conversation.
139
+ // API returns source_thread as {id, title} object; CLI returns a string.
140
+ const rawSt = memory.source_thread;
139
141
  const sourceThreadId =
140
- memory.source_thread ??
142
+ (typeof rawSt === "object" && rawSt?.id
143
+ ? String(rawSt.id)
144
+ : typeof rawSt === "string" ? rawSt : null) ??
141
145
  memory.source_thread_id ??
142
146
  memory.metadata?.source_thread_id ??
143
147
  null;
@@ -7,7 +7,7 @@
7
7
  */
8
8
  import { isDefaultApiUrl } from "../config.js";
9
9
 
10
- export function createStatusTool(client, _logger, cfg) {
10
+ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
11
11
  return {
12
12
  name: "nowledge_mem_status",
13
13
  description:
@@ -23,6 +23,73 @@ export function createStatusTool(client, _logger, cfg) {
23
23
  const details = {};
24
24
  const sources = cfg._sources || {};
25
25
 
26
+ // 0a. Plugin trust check — detect missing plugins.allow
27
+ const pluginsAllow = runtimeInfo.pluginsAllow;
28
+ const pluginId = "openclaw-nowledge-mem";
29
+ details.pluginsAllow = !Array.isArray(pluginsAllow)
30
+ ? "(not set)"
31
+ : pluginsAllow.includes(pluginId)
32
+ ? "listed"
33
+ : "set but plugin not included";
34
+
35
+ if (
36
+ Array.isArray(pluginsAllow) &&
37
+ pluginsAllow.length > 0 &&
38
+ !pluginsAllow.includes(pluginId)
39
+ ) {
40
+ lines.push(
41
+ `⚠ plugins.allow is set but does not include "${pluginId}".`,
42
+ );
43
+ lines.push(
44
+ " The plugin will be BLOCKED from loading unless it holds the memory slot or is explicitly enabled.",
45
+ );
46
+ lines.push(
47
+ ` Fix: add "${pluginId}" to plugins.allow in your OpenClaw config.`,
48
+ );
49
+ lines.push("");
50
+ } else if (!Array.isArray(pluginsAllow) || pluginsAllow.length === 0) {
51
+ lines.push(
52
+ "Plugin trust: plugins.allow not set (plugin loads via entries.enabled)",
53
+ );
54
+ lines.push(
55
+ ` Tip: set plugins.allow to ["${pluginId}"] to pin trust explicitly.`,
56
+ );
57
+ lines.push("");
58
+ } else {
59
+ lines.push(`Plugin trust: ${pluginId} (allowlisted)`);
60
+ }
61
+
62
+ // 0b. Memory slot check — show current mode and options
63
+ const memorySlot = runtimeInfo.memorySlot;
64
+ details.memorySlot = memorySlot ?? "(unknown)";
65
+ if (memorySlot && memorySlot !== "openclaw-nowledge-mem") {
66
+ const corpusOn = cfg.corpusSupplement === true;
67
+ if (corpusOn) {
68
+ lines.push(
69
+ `Memory slot: "${memorySlot}" + corpus supplement active`,
70
+ );
71
+ lines.push(
72
+ " Nowledge Mem feeds into memory-core's recall and dreaming pipeline. All tools available.",
73
+ );
74
+ } else {
75
+ lines.push(
76
+ `ℹ Memory slot: "${memorySlot}"`,
77
+ );
78
+ lines.push(
79
+ " Two options:",
80
+ );
81
+ lines.push(
82
+ ' - Full mode: set plugins.slots.memory to "openclaw-nowledge-mem" for the complete tool surface',
83
+ );
84
+ lines.push(
85
+ " - Dual mode: set corpusSupplement: true to feed your cross-tool knowledge into memory-core's recall and dreaming",
86
+ );
87
+ }
88
+ lines.push("");
89
+ } else if (memorySlot === "openclaw-nowledge-mem") {
90
+ lines.push("Memory slot: openclaw-nowledge-mem (active)");
91
+ }
92
+
26
93
  // 1. Mode + connection target
27
94
  const remote = !isDefaultApiUrl(cfg.apiUrl);
28
95
  const mode = remote ? "remote" : "local";