@nowledge/openclaw-nowledge-mem 0.7.3 → 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,6 +2,37 @@
2
2
 
3
3
  All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
4
4
 
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
+
5
36
  ## [0.7.3] - 2026-03-30
6
37
 
7
38
  ### Added
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:
@@ -153,6 +176,9 @@ Credentials (apiUrl/apiKey): also reads `~/.nowledge-mem/config.json` (shared wi
153
176
  | `maxThreadMessageChars` | integer 200-20000 | `800` | `NMEM_MAX_THREAD_MESSAGE_CHARS` | Max chars per captured thread message before truncation |
154
177
  | `captureExclude` | string[] | `[]` | — | Session key glob patterns to skip during auto-capture. `*` matches within a colon-segment. Example: `["agent:*:cron:*"]` |
155
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) |
156
182
  | `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
157
183
  | `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
158
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
  ```
@@ -1,8 +1,10 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.7.3",
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",
@@ -30,11 +32,23 @@
30
32
  },
31
33
  "captureExclude": {
32
34
  "label": "Exclude session patterns",
33
- "help": "Session key glob patterns to skip during auto-capture. Use * to match within a colon-segment. Example: agent:*:cron:*"
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."
34
36
  },
35
37
  "captureSkipMarker": {
36
38
  "label": "Skip marker",
37
- "help": "Text marker in messages that prevents capture for the session. Default: #nmem-skip. Note: if compaction drops the marked message, capture resumes — use captureExclude patterns for persistent filtering."
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."
38
52
  },
39
53
  "apiUrl": {
40
54
  "label": "Server URL (remote mode)",
@@ -89,14 +103,35 @@
89
103
  },
90
104
  "captureExclude": {
91
105
  "type": "array",
92
- "items": { "type": "string" },
106
+ "items": {
107
+ "type": "string"
108
+ },
93
109
  "default": [],
94
- "description": "Session key glob patterns to exclude from auto-capture. Glob * matches within a colon-delimited segment. Example: agent:*:cron:* excludes all cron job sessions."
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."
95
111
  },
96
112
  "captureSkipMarker": {
97
113
  "type": "string",
98
114
  "default": "#nmem-skip",
99
- "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 — use captureExclude for persistent filtering."
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)"
100
135
  },
101
136
  "apiUrl": {
102
137
  "type": "string",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
- "version": "0.7.3",
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
@@ -23,6 +23,9 @@ const ALLOWED_KEYS = new Set([
23
23
  "maxThreadMessageChars",
24
24
  "captureExclude",
25
25
  "captureSkipMarker",
26
+ "corpusSupplement",
27
+ "corpusMaxResults",
28
+ "corpusMinScore",
26
29
  "apiUrl",
27
30
  "apiKey",
28
31
  // Legacy aliases — accepted but not advertised
@@ -197,6 +200,9 @@ function firstDefined(...options) {
197
200
  * NMEM_MAX_CONTEXT_RESULTS — integer (1-20)
198
201
  * NMEM_RECALL_MIN_SCORE — integer (0-100)
199
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)
200
206
  * NMEM_API_URL — remote server URL
201
207
  * NMEM_API_KEY — API key (never logged)
202
208
  */
@@ -345,6 +351,49 @@ export function parseConfig(raw, logger) {
345
351
  const apiKey = ak.value;
346
352
  _sources.apiKey = ak.source;
347
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
+
348
397
  // --- captureExclude: file > pluginConfig > default ---
349
398
  const captureExclude = (() => {
350
399
  const fromFile = Array.isArray(resolvedFile.captureExclude)
@@ -391,6 +440,9 @@ export function parseConfig(raw, logger) {
391
440
  maxThreadMessageChars,
392
441
  captureExclude,
393
442
  captureSkipMarker,
443
+ corpusSupplement,
444
+ corpusMaxResults,
445
+ corpusMinScore,
394
446
  apiUrl,
395
447
  apiKey,
396
448
  _sources,
@@ -25,6 +25,7 @@ import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
25
25
  import {
26
26
  appendOrCreateThread,
27
27
  hasSkipMarker,
28
+ isCronCaptureSessionKey,
28
29
  matchesExcludePattern,
29
30
  triageAndDistill,
30
31
  } from "./hooks/capture.js";
@@ -170,7 +171,7 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
170
171
  info: {
171
172
  id: "nowledge-mem",
172
173
  name: "Nowledge Mem",
173
- version: "0.7.0",
174
+ version: "0.8.0",
174
175
  ownsCompaction: false,
175
176
  },
176
177
 
@@ -235,7 +236,8 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
235
236
  }
236
237
 
237
238
  // 3. Recalled memories (when sessionContext enabled)
238
- if (cfg.sessionContext) {
239
+ // Skipped when corpus supplement handles search-based recall via memory-core.
240
+ if (cfg.sessionContext && !cfg.corpusSupplement) {
239
241
  const query = buildAssembleSearchQuery(prompt, messages);
240
242
  if (query) {
241
243
  try {
@@ -350,6 +352,11 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
350
352
  // Normalize sessionKey consistently with hook handlers
351
353
  const normalizedKey = String(sessionKey || sessionId || "");
352
354
 
355
+ if (isCronCaptureSessionKey(normalizedKey)) {
356
+ logger.debug?.(`ce: skipped cron session ${normalizedKey}`);
357
+ return;
358
+ }
359
+
353
360
  // Capture exclusion: pattern-based and marker-based filters
354
361
  if (matchesExcludePattern(normalizedKey, cfg.captureExclude)) {
355
362
  logger.debug?.(`ce: skipped excluded session ${normalizedKey}`);
@@ -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;
@@ -78,6 +79,25 @@ export function matchesExcludePattern(sessionKey, patterns) {
78
79
  return patterns.some((pattern) => _compileGlob(pattern).test(key));
79
80
  }
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
+
81
101
  /**
82
102
  * Check if any message contains the skip marker text.
83
103
  * Scans both raw message content and nested message objects.
@@ -471,8 +491,13 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
471
491
  if (!event?.success) return;
472
492
  if (ctx?.trigger === "heartbeat") return;
473
493
 
474
- // Layer 1: pattern-based exclusion (e.g. cron jobs, subagent sessions)
475
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)
476
501
  if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
477
502
  logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
478
503
  return;
@@ -515,8 +540,12 @@ export function buildBeforeResetCaptureHandler(client, cfg, logger) {
515
540
  if (ceState.active) return;
516
541
  if (ctx?.trigger === "heartbeat") return;
517
542
 
518
- // Layer 1: pattern-based exclusion
519
543
  const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
544
+ if (isCronCaptureSessionKey(sessionKey)) {
545
+ logger.debug?.(`capture: skipped cron session ${sessionKey}`);
546
+ return;
547
+ }
548
+
520
549
  if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
521
550
  logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
522
551
  return;
@@ -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,
@@ -80,6 +81,27 @@ export default {
80
81
  );
81
82
  }
82
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
+
83
105
  // --- Hooks (fallback when CE is not active) ---
84
106
  // Each hook checks ceState.active and returns early when the CE handles
85
107
  // the same lifecycle through assemble/afterTurn.
@@ -120,7 +142,7 @@ export default {
120
142
 
121
143
  const remoteMode = !isDefaultApiUrl(cfg.apiUrl);
122
144
  logger.info(
123
- `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"})`,
124
146
  );
125
147
  },
126
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;
@@ -59,19 +59,32 @@ export function createStatusTool(client, _logger, cfg, runtimeInfo = {}) {
59
59
  lines.push(`Plugin trust: ${pluginId} (allowlisted)`);
60
60
  }
61
61
 
62
- // 0b. Memory slot check — detect misconfiguration early
62
+ // 0b. Memory slot check — show current mode and options
63
63
  const memorySlot = runtimeInfo.memorySlot;
64
64
  details.memorySlot = memorySlot ?? "(unknown)";
65
65
  if (memorySlot && memorySlot !== "openclaw-nowledge-mem") {
66
- lines.push(
67
- `⚠ Memory slot: "${memorySlot}" — Nowledge Mem is NOT the active memory plugin.`,
68
- );
69
- lines.push(
70
- " Only memory_search and memory_get will work. Other tools (save, connections, timeline, etc.) are inactive.",
71
- );
72
- lines.push(
73
- ' Fix: run "openclaw plugins install @nowledge/openclaw-nowledge-mem" or set plugins.slots.memory to "openclaw-nowledge-mem" in your config.',
74
- );
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
+ }
75
88
  lines.push("");
76
89
  } else if (memorySlot === "openclaw-nowledge-mem") {
77
90
  lines.push("Memory slot: openclaw-nowledge-mem (active)");