@nowledge/openclaw-nowledge-mem 0.7.0 → 0.7.3

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,32 @@
2
2
 
3
3
  All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
4
4
 
5
+ ## [0.7.3] - 2026-03-30
6
+
7
+ ### Added
8
+
9
+ - **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.
10
+ - **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).
11
+
12
+ ## [0.7.2] - 2026-03-30
13
+
14
+ ### Added
15
+
16
+ - **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`.
17
+
18
+ ### Fixed
19
+
20
+ - **Thread sync no longer depends on argv-sized CLI payloads.** OpenClaw conversation capture now creates and appends threads through the Mem HTTP API instead of passing whole message arrays through `nmem ... -m '<json>'`. This removes the transport limit that caused repeated append failures on long or repetitive sessions.
21
+ - **Session capture now syncs only the unsynced tail.** The plugin preserves the real transcript, asks Mem how many messages are already stored, and appends only the new tail instead of replaying the whole session on every hook or Context Engine turn.
22
+ - **Remote config is still unified after the transport change.** The same resolved `apiUrl` and `apiKey` from OpenClaw settings / `~/.nowledge-mem/config.json` now drive both CLI-backed memory tools and API-backed thread sync.
23
+ - **Removed lossy repetitive-session collapse.** The temporary content-based dedup workaround for cron-style sessions has been removed so conversation structure is preserved faithfully.
24
+
25
+ ## [0.7.1] - 2026-03-23
26
+
27
+ ### Fixed
28
+
29
+ - **Heartbeat sessions no longer trigger thread capture.** Sessions with `ctx.trigger === "heartbeat"` are now skipped in hook handlers. For cron-triggered heartbeat sessions (which use `trigger: "cron"`), a content-based dedup detects repetitive patterns: when >50% of messages in a session are duplicates, only unique messages are kept. This collapses 20 repetitive heartbeat messages down to 2, eliminating the CLI timeout caused by oversized payloads.
30
+
5
31
  ## [0.7.0] - 2026-03-23
6
32
 
7
33
  ### Added
package/CLAUDE.md CHANGED
@@ -118,7 +118,7 @@ When `contextEngine` points elsewhere (or is absent), hooks handle everything. N
118
118
  - `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
119
 
120
120
  ### Diagnostics
121
- - `nowledge_mem_status` - show effective config (mode, apiUrl, apiKey set, sessionContext, sessionDigest, etc.), backend connectivity, and version. No parameters.
121
+ - `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
122
 
123
123
  ## Skill Surface
124
124
 
@@ -151,6 +151,8 @@ Credentials (apiUrl/apiKey): also reads `~/.nowledge-mem/config.json` (shared wi
151
151
  | `maxContextResults` | integer 1-20 | `5` | `NMEM_MAX_CONTEXT_RESULTS` | How many memories to inject at prompt time |
152
152
  | `recallMinScore` | integer 0-100 | `0` | `NMEM_RECALL_MIN_SCORE` | Min relevance score (%) to include in auto-recall |
153
153
  | `maxThreadMessageChars` | integer 200-20000 | `800` | `NMEM_MAX_THREAD_MESSAGE_CHARS` | Max chars per captured thread message before truncation |
154
+ | `captureExclude` | string[] | `[]` | — | Session key glob patterns to skip during auto-capture. `*` matches within a colon-segment. Example: `["agent:*:cron:*"]` |
155
+ | `captureSkipMarker` | string | `"#nmem-skip"` | — | In-band marker: any message containing this text skips capture for the session. Not sticky across compaction |
154
156
  | `apiUrl` | string | `""` | `NMEM_API_URL` | Remote server URL. Empty = local (127.0.0.1:14242) |
155
157
  | `apiKey` | string | `""` | `NMEM_API_KEY` | API key. Never logged. |
156
158
 
package/README.md CHANGED
@@ -70,7 +70,7 @@ Connect to a Nowledge Mem server running elsewhere (a VPS, a home server, or a s
70
70
 
71
71
  Enable the plugin the same way as local mode, then set `apiUrl` and `apiKey` in the OpenClaw plugin settings.
72
72
 
73
- Or use a config file at `~/.nowledge-mem/openclaw.json`:
73
+ Or use the shared config file at `~/.nowledge-mem/config.json` so OpenClaw, `nmem`, Bub, Claude Code, and other integrations all point at the same remote Mem:
74
74
 
75
75
  ```json
76
76
  {
@@ -79,7 +79,9 @@ Or use a config file at `~/.nowledge-mem/openclaw.json`:
79
79
  }
80
80
  ```
81
81
 
82
- The `apiKey` is injected as `NMEM_API_KEY` into the nmem CLI process. Never passed as a CLI argument, never logged.
82
+ Legacy `~/.nowledge-mem/openclaw.json` is still honored first for backward compatibility.
83
+
84
+ 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.
83
85
 
84
86
  ### Configure via WebUI of OpenClaw
85
87
 
@@ -154,6 +156,7 @@ flowchart TD
154
156
 
155
157
  **Key points:**
156
158
  - Thread capture is unconditional: every conversation is saved and searchable via `nowledge_mem_thread_search`
159
+ - Thread sync is incremental: the plugin preserves the real transcript but appends only the unsynced tail instead of replaying the whole session
157
160
  - LLM distillation only runs at `agent_end`, not during compaction/reset checkpoints
158
161
  - Distilled memories carry `sourceThreadId`, linking them back to the source conversation
159
162
  - Cooldown (`digestMinInterval`, default 300s) prevents burst distillation
@@ -430,6 +433,56 @@ If OpenClaw logs a warning like `plugins.allow is empty; discovered non-bundled
430
433
 
431
434
  If you also use linked or workspace copies, review `plugins.load.paths` before relying on an allowlist. OpenClaw allowlists plugin ids, not install provenance.
432
435
 
436
+ ## Troubleshooting
437
+
438
+ **Only `memory_search` and `memory_get` show up — saves go to local markdown files**
439
+
440
+ 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:
441
+
442
+ ```bash
443
+ openclaw plugins install @nowledge/openclaw-nowledge-mem
444
+ ```
445
+
446
+ Or set the slot manually in `~/.openclaw/openclaw.json`:
447
+
448
+ ```json
449
+ {
450
+ "plugins": {
451
+ "slots": { "memory": "openclaw-nowledge-mem" }
452
+ }
453
+ }
454
+ ```
455
+
456
+ Restart OpenClaw after either change.
457
+
458
+ **Plugin tools not available**
459
+
460
+ All 10 plugin tools register automatically when the plugin loads. No tool-level config is needed — just make sure the plugin itself is trusted:
461
+
462
+ ```json
463
+ {
464
+ "plugins": {
465
+ "allow": ["openclaw-nowledge-mem"]
466
+ }
467
+ }
468
+ ```
469
+
470
+ 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.
471
+
472
+ 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.
473
+
474
+ **Search timeouts with many concurrent agents**
475
+
476
+ 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.
477
+
478
+ **`nmem --json status` fails in local mode**
479
+
480
+ The Nowledge Mem backend is not running. Start it from the desktop app, or run the backend manually.
481
+
482
+ **Remote mode not connecting**
483
+
484
+ Check `~/.nowledge-mem/config.json` credentials. Run `nmem --json --api-url "$URL" status` to verify the server is reachable.
485
+
433
486
  ## What Makes This Different
434
487
 
435
488
  - **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,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "kind": "memory",
5
5
  "skills": ["skills/memory-guide"],
6
6
  "uiHints": {
@@ -28,6 +28,14 @@
28
28
  "label": "Max thread message chars",
29
29
  "help": "Maximum characters preserved per captured OpenClaw thread message (200-20000). Higher values keep more of long messages in Nowledge thread history."
30
30
  },
31
+ "captureExclude": {
32
+ "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:*"
34
+ },
35
+ "captureSkipMarker": {
36
+ "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."
38
+ },
31
39
  "apiUrl": {
32
40
  "label": "Server URL (remote mode)",
33
41
  "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 +87,17 @@
79
87
  "maximum": 20000,
80
88
  "description": "Maximum characters preserved per captured OpenClaw thread message before truncation."
81
89
  },
90
+ "captureExclude": {
91
+ "type": "array",
92
+ "items": { "type": "string" },
93
+ "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."
95
+ },
96
+ "captureSkipMarker": {
97
+ "type": "string",
98
+ "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."
100
+ },
82
101
  "apiUrl": {
83
102
  "type": "string",
84
103
  "default": "",
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
- "version": "0.7.0",
3
+ "version": "0.7.3",
4
4
  "type": "module",
5
5
  "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
6
6
  "author": {
package/src/client.js CHANGED
@@ -54,15 +54,11 @@ function patchWmSection(currentContent, heading, { content, append } = {}) {
54
54
  }
55
55
 
56
56
  /**
57
- * Nowledge Mem client. Wraps the nmem CLI for local-first and remote operations.
57
+ * Nowledge Mem client. Wraps the nmem CLI plus direct API calls.
58
58
  *
59
- * All operations go through the CLI first. This means:
60
- * - Local mode: CLI uses http://127.0.0.1:14242 automatically
61
- * - Remote mode: configure via apiUrl + apiKey (plugin config or env vars)
62
- * (see: https://docs.nowledge.co/docs/remote-access)
63
- *
64
- * Falls back to direct API calls when a CLI command is too new for the installed
65
- * version. The fallback path uses the same apiUrl / apiKey.
59
+ * Most interactive reads/writes stay CLI-first for local-first ergonomics.
60
+ * Thread create/append are API-first because large message batches should
61
+ * travel in an HTTP body, not as a single argv-sized JSON argument.
66
62
  *
67
63
  * Credential rules:
68
64
  * - apiUrl: passed to CLI via --api-url flag (not a secret)
@@ -559,42 +555,12 @@ export class NowledgeMemClient {
559
555
  throw new Error("createThread requires at least one message");
560
556
  }
561
557
 
562
- let data;
563
- try {
564
- const args = [
565
- "--json",
566
- "t",
567
- "create",
568
- "-t",
569
- normalizedTitle,
570
- "-m",
571
- JSON.stringify(messages),
572
- "-s",
573
- String(source),
574
- ];
575
- if (threadId) {
576
- args.push("--id", String(threadId));
577
- }
578
- data = await this.execJson(args);
579
- } catch (err) {
580
- const message = err instanceof Error ? err.message : String(err);
581
- const needsApiFallback =
582
- Boolean(threadId) &&
583
- (message.includes("unrecognized arguments: --id") ||
584
- message.includes("invalid choice"));
585
- if (!needsApiFallback) {
586
- throw err;
587
- }
588
- this.logger.warn(
589
- "createThread: CLI missing --id support, falling back to API",
590
- );
591
- data = await this.apiJson("POST", "/threads", {
592
- thread_id: String(threadId),
593
- title: normalizedTitle,
594
- source: String(source),
595
- messages,
596
- });
597
- }
558
+ const data = await this.apiJson("POST", "/threads", {
559
+ ...(threadId ? { thread_id: String(threadId) } : {}),
560
+ title: normalizedTitle,
561
+ source: String(source),
562
+ messages,
563
+ });
598
564
 
599
565
  return String(
600
566
  data.id ?? data.thread?.thread_id ?? data.thread_id ?? "created",
@@ -615,50 +581,46 @@ export class NowledgeMemClient {
615
581
  return { messagesAdded: 0, totalMessages: 0 };
616
582
  }
617
583
 
584
+ const data = await this.apiJson(
585
+ "POST",
586
+ `/threads/${encodeURIComponent(normalizedThreadId)}/append`,
587
+ {
588
+ messages,
589
+ deduplicate,
590
+ ...(idempotencyKey
591
+ ? { idempotency_key: String(idempotencyKey) }
592
+ : {}),
593
+ },
594
+ );
595
+ return {
596
+ messagesAdded: Number(data.messages_added ?? 0),
597
+ totalMessages: Number(data.total_messages ?? 0),
598
+ };
599
+ }
600
+
601
+ async getThreadMessageCount(threadId) {
602
+ const normalizedThreadId = String(threadId || "").trim();
603
+ if (!normalizedThreadId) {
604
+ throw new Error("getThreadMessageCount requires threadId");
605
+ }
606
+
618
607
  try {
619
- const args = [
620
- "--json",
621
- "t",
622
- "append",
623
- normalizedThreadId,
624
- "-m",
625
- JSON.stringify(messages),
626
- ...(deduplicate ? [] : ["--no-deduplicate"]),
627
- ];
628
- if (idempotencyKey) {
629
- args.push("--idempotency-key", String(idempotencyKey));
630
- }
631
- const data = await this.execJson(args);
632
- return {
633
- messagesAdded: Number(data.messages_added ?? 0),
634
- totalMessages: Number(data.total_messages ?? 0),
635
- };
636
- } catch (err) {
637
- const message = err instanceof Error ? err.message : String(err);
638
- const needsApiFallback =
639
- message.includes("invalid choice") ||
640
- message.includes("unrecognized arguments");
641
- if (!needsApiFallback) {
642
- throw err;
643
- }
644
- this.logger.warn(
645
- "appendThread: CLI missing append support, falling back to API",
646
- );
647
608
  const data = await this.apiJson(
648
- "POST",
649
- `/threads/${encodeURIComponent(normalizedThreadId)}/append`,
650
- {
651
- messages,
652
- deduplicate,
653
- ...(idempotencyKey
654
- ? { idempotency_key: String(idempotencyKey) }
655
- : {}),
656
- },
609
+ "GET",
610
+ `/threads/${encodeURIComponent(normalizedThreadId)}?limit=1`,
611
+ undefined,
612
+ 15_000,
657
613
  );
658
- return {
659
- messagesAdded: Number(data.messages_added ?? 0),
660
- totalMessages: Number(data.total_messages ?? 0),
661
- };
614
+ return Number(
615
+ data.message_count ??
616
+ data.total_messages ??
617
+ (Array.isArray(data.messages) ? data.messages.length : 0),
618
+ );
619
+ } catch (err) {
620
+ if (this.isThreadNotFoundError(err)) {
621
+ return null;
622
+ }
623
+ throw err;
662
624
  }
663
625
  }
664
626
 
package/src/config.js CHANGED
@@ -21,6 +21,8 @@ const ALLOWED_KEYS = new Set([
21
21
  "maxContextResults",
22
22
  "recallMinScore",
23
23
  "maxThreadMessageChars",
24
+ "captureExclude",
25
+ "captureSkipMarker",
24
26
  "apiUrl",
25
27
  "apiKey",
26
28
  // Legacy aliases — accepted but not advertised
@@ -343,6 +345,43 @@ export function parseConfig(raw, logger) {
343
345
  const apiKey = ak.value;
344
346
  _sources.apiKey = ak.source;
345
347
 
348
+ // --- captureExclude: file > pluginConfig > default ---
349
+ const captureExclude = (() => {
350
+ const fromFile = Array.isArray(resolvedFile.captureExclude)
351
+ ? resolvedFile.captureExclude
352
+ : null;
353
+ const fromPlugin = Array.isArray(resolvedPlugin.captureExclude)
354
+ ? resolvedPlugin.captureExclude
355
+ : null;
356
+ _sources.captureExclude = fromFile
357
+ ? "file"
358
+ : fromPlugin
359
+ ? "pluginConfig"
360
+ : "default";
361
+ const entries = fromFile ?? fromPlugin ?? [];
362
+ return entries
363
+ .filter((v) => typeof v === "string" && v.trim())
364
+ .map((v) => v.trim());
365
+ })();
366
+
367
+ // --- captureSkipMarker: file > pluginConfig > default ---
368
+ const captureSkipMarker = (() => {
369
+ const fromFile =
370
+ typeof resolvedFile.captureSkipMarker === "string"
371
+ ? resolvedFile.captureSkipMarker.trim()
372
+ : undefined;
373
+ const fromPlugin =
374
+ typeof resolvedPlugin.captureSkipMarker === "string"
375
+ ? resolvedPlugin.captureSkipMarker.trim()
376
+ : undefined;
377
+ _sources.captureSkipMarker = fromFile
378
+ ? "file"
379
+ : fromPlugin
380
+ ? "pluginConfig"
381
+ : "default";
382
+ return fromFile || fromPlugin || "#nmem-skip";
383
+ })();
384
+
346
385
  return {
347
386
  sessionContext,
348
387
  sessionDigest,
@@ -350,6 +389,8 @@ export function parseConfig(raw, logger) {
350
389
  maxContextResults,
351
390
  recallMinScore,
352
391
  maxThreadMessageChars,
392
+ captureExclude,
393
+ captureSkipMarker,
353
394
  apiUrl,
354
395
  apiKey,
355
396
  _sources,
@@ -22,7 +22,12 @@
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
+ matchesExcludePattern,
29
+ triageAndDistill,
30
+ } from "./hooks/capture.js";
26
31
  import {
27
32
  MAX_QUERY_LENGTH,
28
33
  SHORT_QUERY_THRESHOLD,
@@ -342,8 +347,23 @@ export function createNowledgeMemContextEngineFactory(client, cfg, logger) {
342
347
  }) {
343
348
  if (isHeartbeat) return;
344
349
 
350
+ // Normalize sessionKey consistently with hook handlers
351
+ const normalizedKey = String(sessionKey || sessionId || "");
352
+
353
+ // Capture exclusion: pattern-based and marker-based filters
354
+ if (matchesExcludePattern(normalizedKey, cfg.captureExclude)) {
355
+ logger.debug?.(`ce: skipped excluded session ${normalizedKey}`);
356
+ return;
357
+ }
358
+ if (hasSkipMarker(messages, cfg.captureSkipMarker)) {
359
+ logger.debug?.(
360
+ `ce: skipped session with skip marker ${normalizedKey}`,
361
+ );
362
+ return;
363
+ }
364
+
345
365
  const event = { messages, sessionFile };
346
- const ctx = { sessionId, sessionKey };
366
+ const ctx = { sessionId, sessionKey: normalizedKey };
347
367
 
348
368
  // 1. Always capture thread (idempotent, deduped)
349
369
  const captureResult = await appendOrCreateThread({
@@ -12,6 +12,8 @@ export const MIN_MESSAGES_FOR_DISTILL = 4;
12
12
  // Evicted opportunistically when new entries are set (see _setLastCapture).
13
13
  const _lastCaptureAt = new Map();
14
14
  const _MAX_COOLDOWN_ENTRIES = 200;
15
+ const _syncedMessageCounts = new Map();
16
+ const _MAX_SYNC_CURSOR_ENTRIES = 500;
15
17
 
16
18
  function _setLastCapture(threadId, now) {
17
19
  _lastCaptureAt.set(threadId, now);
@@ -24,6 +26,77 @@ function _setLastCapture(threadId, now) {
24
26
  }
25
27
  }
26
28
 
29
+ function _setSyncedMessageCount(threadId, count) {
30
+ if (!threadId || !Number.isFinite(count) || count < 0) return;
31
+ _syncedMessageCounts.set(threadId, Math.trunc(count));
32
+ if (_syncedMessageCounts.size > _MAX_SYNC_CURSOR_ENTRIES) {
33
+ const excess = _syncedMessageCounts.size - _MAX_SYNC_CURSOR_ENTRIES;
34
+ let removed = 0;
35
+ for (const key of _syncedMessageCounts.keys()) {
36
+ _syncedMessageCounts.delete(key);
37
+ removed += 1;
38
+ if (removed >= excess) break;
39
+ }
40
+ }
41
+ }
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Capture exclusion filters
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /**
48
+ * Compile a glob pattern (where `*` matches within a colon-delimited segment)
49
+ * into a RegExp. Results are cached since patterns are immutable after parse.
50
+ */
51
+ const _compiledPatterns = new Map();
52
+ function _compileGlob(pattern) {
53
+ const key = String(pattern).toLowerCase();
54
+ let re = _compiledPatterns.get(key);
55
+ if (!re) {
56
+ re = new RegExp(
57
+ "^" +
58
+ key
59
+ .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
60
+ .replace(/\*/g, "[^:]*") +
61
+ "$",
62
+ );
63
+ _compiledPatterns.set(key, re);
64
+ }
65
+ return re;
66
+ }
67
+
68
+ /**
69
+ * Test whether a session key matches any exclusion glob pattern.
70
+ * Glob `*` matches within a colon-delimited segment (not across colons).
71
+ * Example: "agent:*:cron:*" matches "agent:main:cron:abc123"
72
+ *
73
+ * Exported for reuse by Context Engine and other capture paths.
74
+ */
75
+ export function matchesExcludePattern(sessionKey, patterns) {
76
+ if (!Array.isArray(patterns) || patterns.length === 0) return false;
77
+ const key = String(sessionKey || "").toLowerCase();
78
+ return patterns.some((pattern) => _compileGlob(pattern).test(key));
79
+ }
80
+
81
+ /**
82
+ * Check if any message contains the skip marker text.
83
+ * Scans both raw message content and nested message objects.
84
+ *
85
+ * Exported for reuse by Context Engine and other capture paths.
86
+ */
87
+ export function hasSkipMarker(messages, marker) {
88
+ if (!marker || typeof marker !== "string" || !Array.isArray(messages)) return false;
89
+ const markerLc = marker.toLowerCase();
90
+ return messages.some((msg) => {
91
+ const text = extractText(msg?.content ?? msg?.message?.content);
92
+ return text.toLowerCase().includes(markerLc);
93
+ });
94
+ }
95
+
96
+ // ---------------------------------------------------------------------------
97
+ // Message normalization utilities
98
+ // ---------------------------------------------------------------------------
99
+
27
100
  export function truncate(text, max = DEFAULT_MAX_MESSAGE_CHARS) {
28
101
  const str = String(text || "").trim();
29
102
  if (!str) return "";
@@ -181,8 +254,9 @@ export async function appendOrCreateThread({
181
254
  ctx,
182
255
  reason,
183
256
  maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,
257
+ resolvedMessages,
184
258
  }) {
185
- const rawMessages = await resolveHookMessages(event);
259
+ const rawMessages = resolvedMessages ?? (await resolveHookMessages(event));
186
260
  if (!Array.isArray(rawMessages) || rawMessages.length === 0) return;
187
261
 
188
262
  const threadId = buildStableThreadId(event, ctx);
@@ -194,7 +268,7 @@ export async function appendOrCreateThread({
194
268
  .filter(Boolean);
195
269
  if (normalized.length === 0) return;
196
270
 
197
- const messages = normalized.map((message, index) => ({
271
+ const allMessages = normalized.map((message, index) => ({
198
272
  role: message.role,
199
273
  content: message.content,
200
274
  timestamp: message.timestamp,
@@ -210,16 +284,46 @@ export async function appendOrCreateThread({
210
284
  session_id: sessionId || undefined,
211
285
  },
212
286
  }));
213
- const idempotencyKey = buildAppendIdempotencyKey(threadId, reason, messages);
287
+
288
+ let syncedCount = _syncedMessageCounts.get(threadId);
289
+ if (syncedCount === undefined) {
290
+ syncedCount = await client.getThreadMessageCount(threadId);
291
+ if (syncedCount !== null) {
292
+ _setSyncedMessageCount(threadId, syncedCount);
293
+ }
294
+ }
295
+
296
+ if (typeof syncedCount === "number" && syncedCount > allMessages.length) {
297
+ // OpenClaw compaction can shrink the active transcript after we already
298
+ // stored the pre-compaction history. Reset the local cursor to the new
299
+ // compacted length so future turns append only the post-compaction tail.
300
+ _setSyncedMessageCount(threadId, allMessages.length);
301
+ return { threadId, normalized, messagesAdded: 0 };
302
+ }
303
+
304
+ const appendStart =
305
+ typeof syncedCount === "number" && syncedCount > 0
306
+ ? Math.min(syncedCount, allMessages.length)
307
+ : 0;
308
+ const newMessages = allMessages.slice(appendStart);
309
+ if (newMessages.length === 0) {
310
+ return { threadId, normalized, messagesAdded: 0 };
311
+ }
312
+ const idempotencyKey = buildAppendIdempotencyKey(
313
+ threadId,
314
+ reason,
315
+ newMessages,
316
+ );
214
317
 
215
318
  try {
216
319
  const appended = await client.appendThread({
217
320
  threadId,
218
- messages,
321
+ messages: newMessages,
219
322
  deduplicate: true,
220
323
  idempotencyKey,
221
324
  });
222
325
  const added = appended.messagesAdded ?? 0;
326
+ _setSyncedMessageCount(threadId, allMessages.length);
223
327
  logger.info(
224
328
  `capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
225
329
  );
@@ -236,13 +340,14 @@ export async function appendOrCreateThread({
236
340
  const createdId = await client.createThread({
237
341
  threadId,
238
342
  title,
239
- messages,
343
+ messages: allMessages,
240
344
  source: "openclaw",
241
345
  });
346
+ _setSyncedMessageCount(threadId, allMessages.length);
242
347
  logger.info(
243
- `capture: created thread ${createdId} with ${messages.length} messages (${reason || "event"})`,
348
+ `capture: created thread ${createdId} with ${allMessages.length} messages (${reason || "event"})`,
244
349
  );
245
- return { threadId, normalized, messagesAdded: messages.length };
350
+ return { threadId, normalized, messagesAdded: allMessages.length };
246
351
  } catch (err) {
247
352
  const message = err instanceof Error ? err.message : String(err);
248
353
  logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
@@ -354,11 +459,33 @@ export async function triageAndDistill({
354
459
  *
355
460
  * When the context engine is active, this hook is a no-op — afterTurn
356
461
  * handles capture and distillation through the CE lifecycle.
462
+ *
463
+ * Heartbeat sessions (ctx.trigger === "heartbeat") are skipped — they
464
+ * produce repetitive status pings that aren't worth preserving.
465
+ * Other sessions use incremental tail sync: we preserve the real
466
+ * transcript, but only append messages that are not already stored.
357
467
  */
358
468
  export function buildAgentEndCaptureHandler(client, cfg, logger) {
359
469
  return async (event, ctx) => {
360
470
  if (ceState.active) return;
361
471
  if (!event?.success) return;
472
+ if (ctx?.trigger === "heartbeat") return;
473
+
474
+ // Layer 1: pattern-based exclusion (e.g. cron jobs, subagent sessions)
475
+ const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
476
+ if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
477
+ logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
478
+ return;
479
+ }
480
+
481
+ // Layer 2: marker-based exclusion (user typed #nmem-skip in conversation)
482
+ const resolvedMessages = await resolveHookMessages(event);
483
+ if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
484
+ logger.debug?.(
485
+ `capture: skipped session with skip marker ${sessionKey}`,
486
+ );
487
+ return;
488
+ }
362
489
 
363
490
  const captureResult = await appendOrCreateThread({
364
491
  client,
@@ -367,6 +494,7 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
367
494
  ctx,
368
495
  reason: "agent_end",
369
496
  maxMessageChars: cfg.maxThreadMessageChars,
497
+ resolvedMessages,
370
498
  });
371
499
 
372
500
  await triageAndDistill({ client, cfg, logger, captureResult, ctx });
@@ -379,10 +507,30 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
379
507
  *
380
508
  * When the context engine is active, this hook is a no-op — afterTurn
381
509
  * handles capture through the CE lifecycle.
510
+ *
511
+ * Heartbeat sessions are skipped (same rationale as agent_end).
382
512
  */
383
- export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
513
+ export function buildBeforeResetCaptureHandler(client, cfg, logger) {
384
514
  return async (event, ctx) => {
385
515
  if (ceState.active) return;
516
+ if (ctx?.trigger === "heartbeat") return;
517
+
518
+ // Layer 1: pattern-based exclusion
519
+ const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "");
520
+ if (matchesExcludePattern(sessionKey, cfg.captureExclude)) {
521
+ logger.debug?.(`capture: skipped excluded session ${sessionKey}`);
522
+ return;
523
+ }
524
+
525
+ // Layer 2: marker-based exclusion
526
+ const resolvedMessages = await resolveHookMessages(event);
527
+ if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
528
+ logger.debug?.(
529
+ `capture: skipped session with skip marker ${sessionKey}`,
530
+ );
531
+ return;
532
+ }
533
+
386
534
  const reason = typeof event?.reason === "string" ? event.reason : undefined;
387
535
  await appendOrCreateThread({
388
536
  client,
@@ -390,7 +538,8 @@ export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
390
538
  event,
391
539
  ctx,
392
540
  reason,
393
- maxMessageChars: _cfg?.maxThreadMessageChars,
541
+ maxMessageChars: cfg.maxThreadMessageChars,
542
+ resolvedMessages,
394
543
  });
395
544
  };
396
545
  }
package/src/index.js CHANGED
@@ -54,8 +54,14 @@ export default {
54
54
  api.registerTool(createThreadSearchTool(client, logger));
55
55
  api.registerTool(createThreadFetchTool(client, logger));
56
56
 
57
- // Diagnostics
58
- api.registerTool(createStatusTool(client, logger, cfg));
57
+ // Diagnostics — pass runtime config so the status tool can detect:
58
+ // - plugins.allow missing or not including this plugin
59
+ // - memory slot pointing elsewhere (e.g. memory-core)
60
+ const memorySlot = api.config?.plugins?.slots?.memory;
61
+ const pluginsAllow = api.config?.plugins?.allow;
62
+ api.registerTool(
63
+ createStatusTool(client, logger, cfg, { memorySlot, pluginsAllow }),
64
+ );
59
65
 
60
66
  // --- Context Engine registration ---
61
67
  // When the user sets `plugins.slots.contextEngine: "nowledge-mem"`,
@@ -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,60 @@ 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 — detect misconfiguration early
63
+ const memorySlot = runtimeInfo.memorySlot;
64
+ details.memorySlot = memorySlot ?? "(unknown)";
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
+ );
75
+ lines.push("");
76
+ } else if (memorySlot === "openclaw-nowledge-mem") {
77
+ lines.push("Memory slot: openclaw-nowledge-mem (active)");
78
+ }
79
+
26
80
  // 1. Mode + connection target
27
81
  const remote = !isDefaultApiUrl(cfg.apiUrl);
28
82
  const mode = remote ? "remote" : "local";