@nowledge/openclaw-nowledge-mem 0.8.30 → 0.8.33

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
@@ -4,6 +4,40 @@ All notable changes to the Nowledge Mem OpenClaw plugin will be documented in th
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.8.33] - 2026-08-31
8
+
9
+ ### Fixed
10
+
11
+ - Automatic thread capture now honors OpenClaw 2.0 Incognito session keys.
12
+ Incognito conversations remain process-only unless the user explicitly calls
13
+ a memory-writing tool.
14
+ - Context Engine diagnostics now report the package version instead of a stale
15
+ hardcoded version.
16
+
17
+ ## [0.8.32] - 2026-08-21
18
+
19
+ ### Fixed
20
+
21
+ - Automatic thread capture now sends the paired Mem checkpoint contract
22
+ (`expected_message_count`, a content-bound idempotency key, and
23
+ `append_mode: "checkpointed"`) and only advances the local cursor after an
24
+ explicit append/create acknowledgement.
25
+ - Same-length and earlier-prefix replacements reset to a full replay. OpenClaw
26
+ compaction shrinks still reset the local cursor without replaying the
27
+ compacted summary.
28
+ - Destination-lane cursor state is isolated by API URL, credentials, and space.
29
+ - Thread create/append honor `NMEM_SYNC_TIMEOUT_MS` with a 120s default and
30
+ 1-second to 30-minute bounds.
31
+ - Automatic capture now skips persist until a user+assistant pair exists,
32
+ so a user-only reset checkpoint is not stored as a complete thread.
33
+
34
+
35
+ ## [0.8.31] - 2026-07-12
36
+
37
+ ### Improved
38
+
39
+ - New OpenClaw Threads use the first meaningful user message as their title instead of exposing an internal `agent:...` session key. When no user turn is available, the plugin falls back to a readable channel label such as `OpenClaw · WeChat`.
40
+
7
41
  ## [0.8.30] - 2026-07-12
8
42
 
9
43
  ### Fixed
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.4.5` (`registerMemoryCorpusSupplement` for dreaming integration)
10
+ - OpenClaw minimum: `2026.5.3` (`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 under the canonical id `nowledge-mem` plus the compatibility alias `openclaw-nowledge-mem`. Activated when OpenClaw sets either value in `plugins.slots.contextEngine`. Manual config should still prefer `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.
@@ -58,6 +58,8 @@ openclaw.plugin.json - manifest + config schema (version, uiHints, configSchema,
58
58
  ~/.nowledge-mem/config.json - shared credentials (apiUrl/apiKey) read by all Nowledge Mem tools
59
59
  ```
60
60
 
61
+ Automatic capture excludes OpenClaw 2.0's canonical Incognito session keys. Explicit memory tool calls remain available for intentional saves.
62
+
61
63
  ## Corpus Supplement (Dreaming Integration)
62
64
 
63
65
  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).
package/README.md CHANGED
@@ -11,6 +11,11 @@ This is not a flat note store. Nowledge Mem links related knowledge into a graph
11
11
  - [Nowledge Mem](https://mem.nowledge.co) desktop app **or** `nmem` CLI
12
12
  - [OpenClaw](https://openclaw.ai) >= 2026.5.3 for ClawHub installs. Older 2026.4.x builds can reject current ClawHub packages with an archive integrity mismatch before the plugin is installed.
13
13
 
14
+ OpenClaw 2.0 (`2026.8.1`) is supported. Automatic capture follows OpenClaw's
15
+ privacy boundary: Incognito sessions are never copied to Nowledge Mem or
16
+ distilled automatically. An explicit memory tool call can still save something
17
+ the user intentionally asks Mem to keep.
18
+
14
19
  ## Installation
15
20
 
16
21
  ```bash
@@ -220,7 +225,7 @@ flowchart TD
220
225
 
221
226
  **Key points:**
222
227
  - Thread capture is unconditional: every conversation is saved and searchable via `nowledge_mem_thread_search`
223
- - Thread sync is incremental: the plugin preserves the real transcript but appends only the unsynced tail instead of replaying the whole session
228
+ - Thread sync is incremental: the plugin preserves the real transcript, appends only the unsynced tail, and requires Mem's checkpointed acknowledgement before the local cursor advances
224
229
  - LLM distillation only runs at `agent_end`, not during compaction/reset checkpoints
225
230
  - Distilled memories carry `sourceThreadId`, linking them back to the source conversation
226
231
  - Cooldown (`digestMinInterval`, default 300s) prevents burst distillation
@@ -498,6 +503,7 @@ NMEM_CORPUS_MAX_RESULTS=5
498
503
  NMEM_CORPUS_MIN_SCORE=0
499
504
  NMEM_API_URL=https://...
500
505
  NMEM_API_KEY=your-key
506
+ NMEM_SYNC_TIMEOUT_MS=120000
501
507
  ```
502
508
 
503
509
  ### Priority
package/SKILL.md CHANGED
@@ -28,6 +28,8 @@ When talking to users:
28
28
  - For remote mode, say "Set `apiUrl`, and add `apiKey` when the server requires auth."
29
29
  - Do not imply that OpenClaw is the only source of truth. It is one client of the user's larger memory system.
30
30
  - Do not imply that cloud backup is the default. Our default story is local-first.
31
+ - OpenClaw 2.0 Incognito sessions are never automatically captured or distilled.
32
+ Only an explicit memory-writing tool call persists information by user choice.
31
33
 
32
34
  Good user-facing explanation:
33
35
 
@@ -2,7 +2,7 @@
2
2
  "id": "openclaw-nowledge-mem",
3
3
  "name": "Nowledge Mem",
4
4
  "description": "Cross-tool knowledge graph memory for OpenClaw with startup context, graph search, and session distillation.",
5
- "version": "0.8.30",
5
+ "version": "0.8.33",
6
6
  "kind": ["memory", "context-engine"],
7
7
  "contracts": {
8
8
  "tools": [
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
3
  "displayName": "Nowledge Mem for OpenClaw",
4
- "version": "0.8.30",
4
+ "version": "0.8.33",
5
5
  "type": "module",
6
6
  "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
7
7
  "author": {
@@ -37,7 +37,7 @@
37
37
  "pluginApi": ">=2026.5.3"
38
38
  },
39
39
  "build": {
40
- "openclawVersion": "2026.6.10"
40
+ "openclawVersion": "2026.8.1"
41
41
  },
42
42
  "release": {
43
43
  "publishToClawHub": true,
package/src/client.js CHANGED
@@ -1,4 +1,11 @@
1
1
  import { buildNmemSpawnEnv } from "./spawn-env.js";
2
+ import {
3
+ isCheckpointConflictResponse,
4
+ isCheckpointedAppendAck,
5
+ isThreadAppendAck,
6
+ sessionSyncLaneKey,
7
+ threadCreateAck,
8
+ } from "./session-delta.js";
2
9
 
3
10
  /**
4
11
  * Patch a single markdown section in a Working Memory document.
@@ -94,6 +101,26 @@ export class NowledgeMemClient {
94
101
  ? credentials.spaceId
95
102
  : "";
96
103
  this._spaceRef = String(resolvedSpace ?? "").trim();
104
+ this._threadSyncTimeoutMs = Number.isInteger(credentials.threadSyncTimeoutMs)
105
+ ? credentials.threadSyncTimeoutMs
106
+ : 120_000;
107
+ }
108
+
109
+ getSpaceRef() {
110
+ return this._spaceRef;
111
+ }
112
+
113
+ getThreadSyncTimeoutMs() {
114
+ return this._threadSyncTimeoutMs;
115
+ }
116
+
117
+ cursorKey(threadId) {
118
+ return sessionSyncLaneKey(
119
+ threadId,
120
+ this._apiUrl,
121
+ this._apiKey,
122
+ this._spaceRef,
123
+ );
97
124
  }
98
125
 
99
126
  // ── API helpers (fallback path and direct operations) ─────────────────────
@@ -162,7 +189,11 @@ export class NowledgeMemClient {
162
189
  : typeof data?.raw === "string"
163
190
  ? data.raw
164
191
  : `HTTP ${response.status}`;
165
- throw new Error(detail);
192
+ const error = new Error(detail);
193
+ error.status = response.status;
194
+ error.code =
195
+ typeof data?.error_code === "string" ? data.error_code : undefined;
196
+ throw error;
166
197
  }
167
198
  return data;
168
199
  } finally {
@@ -585,6 +616,10 @@ export class NowledgeMemClient {
585
616
  }
586
617
 
587
618
  async createThread({ threadId, title, messages, source = "openclaw" }) {
619
+ const normalizedThreadId = String(threadId || "").trim();
620
+ if (!normalizedThreadId) {
621
+ throw new Error("createThread requires threadId");
622
+ }
588
623
  const normalizedTitle = String(title || "").trim();
589
624
  if (!normalizedTitle) {
590
625
  throw new Error("createThread requires a non-empty title");
@@ -597,16 +632,21 @@ export class NowledgeMemClient {
597
632
  "POST",
598
633
  "/threads",
599
634
  {
600
- ...(threadId ? { thread_id: String(threadId) } : {}),
635
+ thread_id: normalizedThreadId,
601
636
  title: normalizedTitle,
602
637
  source: String(source),
603
638
  messages,
604
639
  },
640
+ this._threadSyncTimeoutMs,
605
641
  );
606
642
 
607
- return String(
608
- data.id ?? data.thread?.thread_id ?? data.thread_id ?? "created",
609
- );
643
+ const ack = threadCreateAck(data, normalizedThreadId);
644
+ if (!ack) {
645
+ throw new Error(
646
+ "Thread create did not include an explicit persistence acknowledgement",
647
+ );
648
+ }
649
+ return ack;
610
650
  }
611
651
 
612
652
  async appendThread({
@@ -614,6 +654,7 @@ export class NowledgeMemClient {
614
654
  messages,
615
655
  deduplicate = true,
616
656
  idempotencyKey,
657
+ expectedMessageCount,
617
658
  }) {
618
659
  const normalizedThreadId = String(threadId || "").trim();
619
660
  if (!normalizedThreadId) {
@@ -623,6 +664,7 @@ export class NowledgeMemClient {
623
664
  return { messagesAdded: 0, totalMessages: 0 };
624
665
  }
625
666
 
667
+ const checkpointed = Number.isInteger(expectedMessageCount);
626
668
  const data = await this.apiJson(
627
669
  "POST",
628
670
  `/threads/${encodeURIComponent(normalizedThreadId)}/append`,
@@ -632,11 +674,25 @@ export class NowledgeMemClient {
632
674
  ...(idempotencyKey
633
675
  ? { idempotency_key: String(idempotencyKey) }
634
676
  : {}),
677
+ ...(checkpointed
678
+ ? { expected_message_count: expectedMessageCount }
679
+ : {}),
635
680
  },
681
+ this._threadSyncTimeoutMs,
636
682
  );
683
+ if (!isThreadAppendAck(data)) {
684
+ throw new Error(
685
+ "Thread append did not include an explicit persistence acknowledgement",
686
+ );
687
+ }
688
+ if (checkpointed && !isCheckpointedAppendAck(data)) {
689
+ throw new Error(
690
+ "Thread append was not acknowledged as checkpointed",
691
+ );
692
+ }
637
693
  return {
638
- messagesAdded: Number(data.messages_added ?? 0),
639
- totalMessages: Number(data.total_messages ?? 0),
694
+ messagesAdded: data.messages_added,
695
+ totalMessages: data.total_messages,
640
696
  };
641
697
  }
642
698
 
@@ -679,9 +735,16 @@ export class NowledgeMemClient {
679
735
  err instanceof Error ? err.message : String(err)
680
736
  ).toLowerCase();
681
737
  return (
682
- message.includes("thread not found") ||
683
- message.includes("404") ||
684
- message.includes("not found")
738
+ err?.code === "thread_not_found" ||
739
+ err?.status === 404 ||
740
+ (err?.status === 400 && message.includes("thread not found"))
741
+ );
742
+ }
743
+
744
+ isCheckpointConflictError(err) {
745
+ return (
746
+ err?.code === "checkpoint_conflict" ||
747
+ isCheckpointConflictResponse({ error_code: err?.code })
685
748
  );
686
749
  }
687
750
 
@@ -30,8 +30,8 @@ import { BASE_GUIDANCE, SESSION_CONTEXT_GUIDANCE } from "./hooks/behavioral.js";
30
30
  import {
31
31
  appendOrCreateThread,
32
32
  hasSkipMarker,
33
- isInternalCaptureSessionKey,
34
33
  isCronCaptureSessionKey,
34
+ isInternalCaptureSessionKey,
35
35
  matchesExcludePattern,
36
36
  triageAndDistill,
37
37
  } from "./hooks/capture.js";
@@ -41,6 +41,7 @@ import {
41
41
  buildRecalledKnowledgeBlock,
42
42
  escapeForPrompt,
43
43
  } from "./hooks/recall.js";
44
+ import { NOWLEDGE_MEM_PLUGIN_VERSION } from "./plugin-version.js";
44
45
 
45
46
  // ---------------------------------------------------------------------------
46
47
  // Per-session state
@@ -183,7 +184,7 @@ export function createNowledgeMemContextEngineFactory(
183
184
  info: {
184
185
  id: "nowledge-mem",
185
186
  name: "Nowledge Mem",
186
- version: "0.8.25",
187
+ version: NOWLEDGE_MEM_PLUGIN_VERSION,
187
188
  ownsCompaction: false,
188
189
  },
189
190
 
@@ -1,6 +1,12 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { buildStableThreadId } from "./thread-identity.js";
4
+ import {
5
+ contentBoundIdempotencyKey,
6
+ hasUserAndAssistant,
7
+ prefixFingerprint,
8
+ selectSnapshotDelta,
9
+ } from "../session-delta.js";
4
10
  export {
5
11
  _resetConversationRoots,
6
12
  buildStableThreadId,
@@ -12,6 +18,7 @@ export const DEFAULT_MAX_MESSAGE_CHARS = 800;
12
18
  export const MAX_DISTILL_MESSAGE_CHARS = 2000;
13
19
  export const MAX_CONVERSATION_CHARS = 30_000;
14
20
  export const MIN_MESSAGES_FOR_DISTILL = 4;
21
+ export const MAX_THREAD_TITLE_CHARS = 80;
15
22
  const SESSION_RESET_PROMPT_PREFIX =
16
23
  "A new session was started via /new or /reset.";
17
24
  const OPENCLAW_DIRECTIVE_TAG_RE =
@@ -24,7 +31,7 @@ const OPENCLAW_INTERNAL_SENDER_BLOCK_RE =
24
31
  // Evicted opportunistically when new entries are set (see _setLastCapture).
25
32
  const _lastCaptureAt = new Map();
26
33
  const _MAX_COOLDOWN_ENTRIES = 200;
27
- const _syncedMessageCounts = new Map();
34
+ const _acknowledgedCursors = new Map();
28
35
  const _MAX_SYNC_CURSOR_ENTRIES = 500;
29
36
  const CAPTURE_MESSAGE_MODE_AUTO = "auto";
30
37
  const CAPTURE_MESSAGE_MODE_SNAPSHOT = "snapshot";
@@ -69,20 +76,34 @@ function _setLastCapture(threadId, now) {
69
76
  }
70
77
  }
71
78
 
72
- function _setSyncedMessageCount(threadId, count) {
73
- if (!threadId || !Number.isFinite(count) || count < 0) return;
74
- _syncedMessageCounts.set(threadId, Math.trunc(count));
75
- if (_syncedMessageCounts.size > _MAX_SYNC_CURSOR_ENTRIES) {
76
- const excess = _syncedMessageCounts.size - _MAX_SYNC_CURSOR_ENTRIES;
79
+ function _laneKey(client, threadId) {
80
+ return typeof client?.cursorKey === "function"
81
+ ? client.cursorKey(threadId)
82
+ : String(threadId || "");
83
+ }
84
+
85
+ function _getCursor(client, threadId) {
86
+ return _acknowledgedCursors.get(_laneKey(client, threadId));
87
+ }
88
+
89
+ function _setAcknowledgedCursor(client, threadId, cursor) {
90
+ if (!threadId || !cursor) return;
91
+ _acknowledgedCursors.set(_laneKey(client, threadId), cursor);
92
+ if (_acknowledgedCursors.size > _MAX_SYNC_CURSOR_ENTRIES) {
93
+ const excess = _acknowledgedCursors.size - _MAX_SYNC_CURSOR_ENTRIES;
77
94
  let removed = 0;
78
- for (const key of _syncedMessageCounts.keys()) {
79
- _syncedMessageCounts.delete(key);
95
+ for (const key of _acknowledgedCursors.keys()) {
96
+ _acknowledgedCursors.delete(key);
80
97
  removed += 1;
81
98
  if (removed >= excess) break;
82
99
  }
83
100
  }
84
101
  }
85
102
 
103
+ export function _resetSyncCursors() {
104
+ _acknowledgedCursors.clear();
105
+ }
106
+
86
107
  // ---------------------------------------------------------------------------
87
108
  // Capture exclusion filters
88
109
  // ---------------------------------------------------------------------------
@@ -135,6 +156,21 @@ export function isCronCaptureSessionKey(sessionKey) {
135
156
  return isAgentCronSessionKey(raw);
136
157
  }
137
158
 
159
+ /**
160
+ * OpenClaw 2.0 reserves these process-only keys for Incognito sessions.
161
+ * Keep this aligned with the host's routing helper so automatic capture does
162
+ * not turn an in-memory conversation into a durable external transcript.
163
+ * Explicit memory tool calls are unaffected because this only guards hooks.
164
+ */
165
+ export function isIncognitoCaptureSessionKey(sessionKey) {
166
+ const raw = String(sessionKey || "")
167
+ .trim()
168
+ .toLowerCase();
169
+ return /^(?:dashboard|subagent|internal-session-effects):incognito-[^:]+$/u.test(
170
+ raw,
171
+ );
172
+ }
173
+
138
174
  /**
139
175
  * Check if any message contains the skip marker text.
140
176
  * Scans both raw message content and nested message objects.
@@ -163,7 +199,7 @@ export function isInternalCaptureSessionKey(sessionKey) {
163
199
  .toLowerCase();
164
200
  if (!raw) return false;
165
201
  if (raw.startsWith("temp:")) return true;
166
- return isSubagentSessionKey(raw);
202
+ return isSubagentSessionKey(raw) || isIncognitoCaptureSessionKey(raw);
167
203
  }
168
204
 
169
205
  function stripOpenClawDirectiveTags(text) {
@@ -309,9 +345,47 @@ export function normalizeRoleMessage(
309
345
  };
310
346
  }
311
347
 
312
- export function buildThreadTitle(ctx) {
313
- const session = ctx?.sessionKey || ctx?.sessionId || "session";
314
- return `OpenClaw ${session}`;
348
+ function humanizeChannel(value) {
349
+ const normalized = String(value || "")
350
+ .trim()
351
+ .toLowerCase();
352
+ if (!normalized) return "";
353
+ const known = {
354
+ dashboard: "Dashboard",
355
+ "openclaw-weixin": "WeChat",
356
+ weixin: "WeChat",
357
+ wechat: "WeChat",
358
+ telegram: "Telegram",
359
+ discord: "Discord",
360
+ whatsapp: "WhatsApp",
361
+ };
362
+ if (known[normalized]) return known[normalized];
363
+ return normalized
364
+ .replace(/^openclaw[-_]/, "")
365
+ .split(/[-_]+/)
366
+ .filter(Boolean)
367
+ .map((part) => `${part.charAt(0).toUpperCase()}${part.slice(1)}`)
368
+ .join(" ");
369
+ }
370
+
371
+ export function buildThreadTitle(ctx, normalizedMessages = []) {
372
+ const firstUserMessage = normalizedMessages.find(
373
+ (message) => message?.role === "user",
374
+ );
375
+ const userText = String(
376
+ firstUserMessage?.fullContent || firstUserMessage?.content || "",
377
+ )
378
+ .replace(/\s+/gu, " ")
379
+ .trim();
380
+ if (userText) return truncate(userText, MAX_THREAD_TITLE_CHARS);
381
+
382
+ const explicitChannel = ctx?.channel || ctx?.channelId;
383
+ const sessionKey = String(ctx?.sessionKey || "");
384
+ const sessionParts = sessionKey.split(":").filter(Boolean);
385
+ const sessionChannel =
386
+ sessionParts[0]?.toLowerCase() === "agent" ? sessionParts[2] : "";
387
+ const channel = humanizeChannel(explicitChannel || sessionChannel);
388
+ return channel ? `OpenClaw · ${channel}` : "OpenClaw conversation";
315
389
  }
316
390
 
317
391
  function sanitizeIdPart(input, max = 48) {
@@ -347,18 +421,18 @@ function buildExternalId({
347
421
  return `oc-msg:${digest}`;
348
422
  }
349
423
 
350
- function buildAppendIdempotencyKey(threadId, reason, messages) {
351
- const seed = {
352
- threadId: String(threadId || ""),
353
- reason: String(reason || "event"),
354
- count: Array.isArray(messages) ? messages.length : 0,
355
- externalIds: Array.isArray(messages)
356
- ? messages
357
- .map((m) => m?.metadata?.external_id)
358
- .filter((v) => typeof v === "string" && v.length > 0)
359
- : [],
360
- };
361
- return `oc-batch:${createHash("sha1").update(JSON.stringify(seed)).digest("hex")}`;
424
+ function messageExternalId(message) {
425
+ return String(message?.metadata?.external_id || "");
426
+ }
427
+
428
+ function buildContentBoundIdempotencyKey(threadId, reason, start, end, fingerprint) {
429
+ return contentBoundIdempotencyKey(
430
+ `oc:${String(reason || "event")}`,
431
+ threadId,
432
+ start,
433
+ end,
434
+ fingerprint,
435
+ );
362
436
  }
363
437
 
364
438
  function hasStableExternalHints(normalized) {
@@ -423,14 +497,32 @@ export async function appendOrCreateThread({
423
497
  const threadId = buildStableThreadId(event, ctx);
424
498
  const sessionKey = String(ctx?.sessionKey || ctx?.sessionId || "session");
425
499
  const sessionId = String(ctx?.sessionId || "").trim();
426
- const title = buildThreadTitle(ctx);
427
500
  const normalized = rawMessages
428
501
  .map((message) => normalizeRoleMessage(message, maxMessageChars))
429
502
  .filter(Boolean);
430
503
  if (normalized.length === 0) return;
504
+ const resolvedMessageMode =
505
+ messageMode === CAPTURE_MESSAGE_MODE_DELTA ||
506
+ (messageMode === CAPTURE_MESSAGE_MODE_AUTO &&
507
+ hasStableExternalHints(normalized))
508
+ ? CAPTURE_MESSAGE_MODE_DELTA
509
+ : CAPTURE_MESSAGE_MODE_SNAPSHOT;
510
+ let captured = normalized;
511
+ if (resolvedMessageMode === CAPTURE_MESSAGE_MODE_SNAPSHOT) {
512
+ let lastAssistantIndex = -1;
513
+ for (let index = normalized.length - 1; index >= 0; index -= 1) {
514
+ if (normalized[index].role === "assistant") {
515
+ lastAssistantIndex = index;
516
+ break;
517
+ }
518
+ }
519
+ captured = normalized.slice(0, lastAssistantIndex + 1);
520
+ }
521
+ if (!hasUserAndAssistant(captured)) return;
522
+ const title = buildThreadTitle(ctx, captured);
431
523
 
432
524
  const buildMessages = (resolvedMessageMode) =>
433
- normalized.map((message, index) => ({
525
+ captured.map((message, index) => ({
434
526
  role: message.role,
435
527
  content: message.content,
436
528
  timestamp: message.timestamp,
@@ -456,111 +548,174 @@ export async function appendOrCreateThread({
456
548
  : CAPTURE_MESSAGE_MODE_SNAPSHOT,
457
549
  );
458
550
 
459
- let syncedCount = _syncedMessageCounts.get(threadId);
460
- if (syncedCount === undefined) {
461
- syncedCount = await client.getThreadMessageCount(threadId);
462
- if (syncedCount !== null) {
463
- _setSyncedMessageCount(threadId, syncedCount);
551
+ let cursor = _getCursor(client, threadId);
552
+ if (cursor === undefined) {
553
+ let remoteCount = null;
554
+ try {
555
+ remoteCount = await client.getThreadMessageCount(threadId);
556
+ } catch (err) {
557
+ const message = err instanceof Error ? err.message : String(err);
558
+ logger.warn(
559
+ `capture: remote message count failed for ${threadId}: ${message}`,
560
+ );
561
+ }
562
+ if (remoteCount !== null && Number.isFinite(remoteCount) && remoteCount >= 0) {
563
+ cursor = {
564
+ count: 0,
565
+ remoteCount: Math.trunc(remoteCount),
566
+ prefixFingerprint: "",
567
+ };
464
568
  }
465
569
  }
466
570
 
467
- const resolvedMessageMode =
468
- messageMode === CAPTURE_MESSAGE_MODE_DELTA ||
469
- (messageMode === CAPTURE_MESSAGE_MODE_AUTO &&
470
- hasStableExternalHints(normalized))
471
- ? CAPTURE_MESSAGE_MODE_DELTA
472
- : CAPTURE_MESSAGE_MODE_SNAPSHOT;
473
571
  if (resolvedMessageMode === CAPTURE_MESSAGE_MODE_DELTA) {
474
572
  allMessages = buildMessages(CAPTURE_MESSAGE_MODE_DELTA);
475
- const idempotencyKey = buildAppendIdempotencyKey(
573
+ const next = {
574
+ count: allMessages.length,
575
+ remoteCount: cursor?.remoteCount ?? allMessages.length,
576
+ ...(allMessages.length > 0
577
+ ? { lastExternalId: messageExternalId(allMessages[allMessages.length - 1]) }
578
+ : {}),
579
+ prefixFingerprint: prefixFingerprint(allMessages, allMessages.length),
580
+ };
581
+ return persistMessages({
582
+ client,
583
+ logger,
476
584
  threadId,
585
+ title,
477
586
  reason,
587
+ normalized,
478
588
  allMessages,
479
- );
480
- try {
481
- const appended = await client.appendThread({
482
- threadId,
483
- messages: allMessages,
484
- deduplicate: true,
485
- idempotencyKey,
486
- });
487
- const added = appended.messagesAdded ?? 0;
488
- const total =
489
- Number.isFinite(appended.totalMessages) && appended.totalMessages >= 0
490
- ? appended.totalMessages
491
- : typeof syncedCount === "number"
492
- ? syncedCount + added
493
- : allMessages.length;
494
- _setSyncedMessageCount(threadId, total);
495
- logger.info(
496
- `capture: appended ${added} delta messages to ${threadId} (${reason || "event"})`,
497
- );
498
- return { threadId, normalized, messagesAdded: added };
499
- } catch (err) {
500
- if (!client.isThreadNotFoundError(err)) {
501
- const message = err instanceof Error ? err.message : String(err);
502
- logger.warn(
503
- `capture: thread append failed for ${threadId}: ${message}`,
504
- );
505
- return null;
506
- }
507
- }
508
-
509
- try {
510
- const createdId = await client.createThread({
511
- threadId,
512
- title,
513
- messages: allMessages,
514
- source: "openclaw",
515
- });
516
- _setSyncedMessageCount(threadId, allMessages.length);
517
- logger.info(
518
- `capture: created thread ${createdId} with ${allMessages.length} delta messages (${reason || "event"})`,
519
- );
520
- return { threadId, normalized, messagesAdded: allMessages.length };
521
- } catch (err) {
522
- const message = err instanceof Error ? err.message : String(err);
523
- logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
524
- return null;
525
- }
589
+ toSend: allMessages,
590
+ start: 0,
591
+ end: allMessages.length,
592
+ next,
593
+ cursor,
594
+ reset: false,
595
+ label: "delta",
596
+ });
526
597
  }
527
598
 
528
- if (typeof syncedCount === "number" && syncedCount > allMessages.length) {
529
- // OpenClaw compaction can shrink the active transcript after we already
530
- // stored the pre-compaction history. Reset the local cursor to the new
531
- // compacted length so future turns append only the tail after compaction.
532
- _setSyncedMessageCount(threadId, allMessages.length);
599
+ const delta = selectSnapshotDelta(
600
+ allMessages,
601
+ cursor,
602
+ messageExternalId,
603
+ );
604
+ if (delta.compactionShrink) {
605
+ _setAcknowledgedCursor(client, threadId, delta.next);
533
606
  return { threadId, normalized, messagesAdded: 0 };
534
607
  }
535
-
536
- const appendStart =
537
- typeof syncedCount === "number" && syncedCount > 0
538
- ? Math.min(syncedCount, allMessages.length)
539
- : 0;
540
- const newMessages = allMessages.slice(appendStart);
541
- if (newMessages.length === 0) {
608
+ if (delta.messages.length === 0) {
542
609
  return { threadId, normalized, messagesAdded: 0 };
543
610
  }
544
- const idempotencyKey = buildAppendIdempotencyKey(
611
+ return persistMessages({
612
+ client,
613
+ logger,
545
614
  threadId,
615
+ title,
546
616
  reason,
547
- newMessages,
617
+ normalized,
618
+ allMessages,
619
+ toSend: delta.messages,
620
+ start: delta.start,
621
+ end: delta.end,
622
+ next: delta.next,
623
+ cursor,
624
+ reset: delta.reset,
625
+ label: "snapshot",
626
+ });
627
+ }
628
+
629
+ async function persistMessages({
630
+ client,
631
+ logger,
632
+ threadId,
633
+ title,
634
+ reason,
635
+ normalized,
636
+ allMessages,
637
+ toSend,
638
+ start,
639
+ end,
640
+ next,
641
+ cursor,
642
+ reset,
643
+ label,
644
+ }) {
645
+ const fingerprint = next.prefixFingerprint;
646
+ const idempotencyKey = buildContentBoundIdempotencyKey(
647
+ threadId,
648
+ reason,
649
+ start,
650
+ end,
651
+ fingerprint,
548
652
  );
653
+ const expectedMessageCount =
654
+ !reset && Number.isInteger(cursor?.remoteCount) && cursor.prefixFingerprint
655
+ ? cursor.remoteCount
656
+ : undefined;
657
+
658
+ const acknowledge = (remoteCount, added) => {
659
+ _setAcknowledgedCursor(client, threadId, {
660
+ ...next,
661
+ remoteCount,
662
+ });
663
+ return { threadId, normalized, messagesAdded: added };
664
+ };
549
665
 
550
666
  try {
551
667
  const appended = await client.appendThread({
552
668
  threadId,
553
- messages: newMessages,
669
+ messages: toSend,
554
670
  deduplicate: true,
555
671
  idempotencyKey,
672
+ expectedMessageCount,
556
673
  });
557
674
  const added = appended.messagesAdded ?? 0;
558
- _setSyncedMessageCount(threadId, allMessages.length);
675
+ const total =
676
+ Number.isInteger(appended.totalMessages) && appended.totalMessages >= 0
677
+ ? appended.totalMessages
678
+ : toSend.length;
559
679
  logger.info(
560
- `capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
680
+ `capture: appended ${added} ${label} messages to ${threadId} (${reason || "event"})`,
561
681
  );
562
- return { threadId, normalized, messagesAdded: added };
682
+ return acknowledge(total, added);
563
683
  } catch (err) {
684
+ if (client.isCheckpointConflictError?.(err)) {
685
+ try {
686
+ const reconciled = await client.appendThread({
687
+ threadId,
688
+ messages: allMessages,
689
+ deduplicate: true,
690
+ idempotencyKey: buildContentBoundIdempotencyKey(
691
+ threadId,
692
+ `${reason || "event"}-reconcile`,
693
+ 0,
694
+ allMessages.length,
695
+ fingerprint,
696
+ ),
697
+ });
698
+ const added = reconciled.messagesAdded ?? allMessages.length;
699
+ const total =
700
+ Number.isInteger(reconciled.totalMessages) &&
701
+ reconciled.totalMessages >= 0
702
+ ? reconciled.totalMessages
703
+ : allMessages.length;
704
+ logger.info(
705
+ `capture: reconciled ${added} messages to ${threadId} (${reason || "event"})`,
706
+ );
707
+ return acknowledge(total, added);
708
+ } catch (reconcileErr) {
709
+ const message =
710
+ reconcileErr instanceof Error
711
+ ? reconcileErr.message
712
+ : String(reconcileErr);
713
+ logger.warn(
714
+ `capture: thread reconcile failed for ${threadId}: ${message}`,
715
+ );
716
+ return null;
717
+ }
718
+ }
564
719
  if (!client.isThreadNotFoundError(err)) {
565
720
  const message = err instanceof Error ? err.message : String(err);
566
721
  logger.warn(`capture: thread append failed for ${threadId}: ${message}`);
@@ -569,17 +724,16 @@ export async function appendOrCreateThread({
569
724
  }
570
725
 
571
726
  try {
572
- const createdId = await client.createThread({
727
+ const created = await client.createThread({
573
728
  threadId,
574
729
  title,
575
730
  messages: allMessages,
576
731
  source: "openclaw",
577
732
  });
578
- _setSyncedMessageCount(threadId, allMessages.length);
579
733
  logger.info(
580
- `capture: created thread ${createdId} with ${allMessages.length} messages (${reason || "event"})`,
734
+ `capture: created thread ${created.threadId} with ${created.totalMessages} persisted messages (${reason || "event"})`,
581
735
  );
582
- return { threadId, normalized, messagesAdded: allMessages.length };
736
+ return acknowledge(created.totalMessages, allMessages.length);
583
737
  } catch (err) {
584
738
  const message = err instanceof Error ? err.message : String(err);
585
739
  logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
@@ -669,7 +823,7 @@ export async function triageAndDistill({
669
823
 
670
824
  const distillResult = await client.distillThread({
671
825
  threadId: captureResult.threadId,
672
- title: buildThreadTitle(ctx),
826
+ title: buildThreadTitle(ctx, captureResult.normalized),
673
827
  content: conversationText,
674
828
  });
675
829
 
package/src/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  import { NowledgeMemClient } from "./client.js";
2
+ import { resolveThreadSyncTimeoutMs } from "./thread-sync-timeout.js";
2
3
  import { createCliRegistrar } from "./commands/cli.js";
3
4
  import {
4
5
  createForgetCommand,
@@ -46,6 +47,7 @@ export default {
46
47
  apiUrl: cfg.apiUrl,
47
48
  apiKey: cfg.apiKey,
48
49
  space: cfg.space,
50
+ threadSyncTimeoutMs: resolveThreadSyncTimeoutMs(process.env.NMEM_SYNC_TIMEOUT_MS),
49
51
  });
50
52
 
51
53
  const { memorySlot, memorySlotSelected } = resolveRegistrationMode({
@@ -0,0 +1,3 @@
1
+ import packageJson from "../package.json" with { type: "json" };
2
+
3
+ export const NOWLEDGE_MEM_PLUGIN_VERSION = packageJson.version;
@@ -0,0 +1,151 @@
1
+ import { createHash } from "node:crypto";
2
+
3
+ export function sessionSyncLaneKey(threadId, apiUrl, apiKey, spaceId) {
4
+ const destination = createHash("sha256")
5
+ .update(
6
+ [String(apiUrl || ""), String(apiKey || ""), String(spaceId || "")].join(
7
+ "\0",
8
+ ),
9
+ )
10
+ .digest("hex");
11
+ return `${destination}\0${String(threadId || "")}`;
12
+ }
13
+
14
+ export function stableMessageFingerprint(message) {
15
+ return JSON.stringify({
16
+ role: message?.role,
17
+ content: message?.content,
18
+ external_id: message?.metadata?.external_id,
19
+ });
20
+ }
21
+
22
+ export function prefixFingerprint(
23
+ messages,
24
+ end,
25
+ messageFingerprint = stableMessageFingerprint,
26
+ ) {
27
+ const hash = createHash("sha256");
28
+ for (const message of messages.slice(0, end)) {
29
+ const value = messageFingerprint(message);
30
+ hash.update(String(Buffer.byteLength(value)));
31
+ hash.update(":");
32
+ hash.update(value);
33
+ }
34
+ return hash.digest("hex");
35
+ }
36
+
37
+ export function isThreadAppendAck(data) {
38
+ return (
39
+ data !== null &&
40
+ typeof data === "object" &&
41
+ data.success === true &&
42
+ Number.isInteger(data.messages_added) &&
43
+ data.messages_added >= 0 &&
44
+ Number.isInteger(data.total_messages) &&
45
+ data.total_messages >= 0
46
+ );
47
+ }
48
+
49
+ export function threadCreateAck(data, expectedThreadId) {
50
+ if (data === null || typeof data !== "object") return null;
51
+ const thread =
52
+ data.thread !== null && typeof data.thread === "object" ? data.thread : data;
53
+ const threadId = String(thread.thread_id ?? "").trim();
54
+ const totalMessages = thread.message_count ?? thread.total_messages;
55
+ if (
56
+ threadId !== String(expectedThreadId || "").trim() ||
57
+ !Number.isInteger(totalMessages) ||
58
+ totalMessages < 0
59
+ ) {
60
+ return null;
61
+ }
62
+ return { threadId, totalMessages };
63
+ }
64
+
65
+ export function isCheckpointedAppendAck(data) {
66
+ return isThreadAppendAck(data) && data.append_mode === "checkpointed";
67
+ }
68
+
69
+ export function isCheckpointConflictResponse(data) {
70
+ return (
71
+ data !== null &&
72
+ typeof data === "object" &&
73
+ data.error_code === "checkpoint_conflict"
74
+ );
75
+ }
76
+
77
+ export function selectSnapshotDelta(
78
+ messages,
79
+ cursor,
80
+ externalId,
81
+ messageFingerprint = stableMessageFingerprint,
82
+ ) {
83
+ let start = cursor?.count ?? 0;
84
+ let reset = false;
85
+ if (start > messages.length) {
86
+ // OpenClaw compaction shrinks the live transcript after we already
87
+ // stored the pre-compaction history. Reset the local cursor to the
88
+ // compacted length so later turns append only the post-compaction tail.
89
+ const end = messages.length;
90
+ return {
91
+ start: end,
92
+ end,
93
+ messages: [],
94
+ next: {
95
+ count: end,
96
+ remoteCount: cursor?.remoteCount ?? end,
97
+ ...(end > 0 ? { lastExternalId: externalId(messages[end - 1]) } : {}),
98
+ prefixFingerprint: prefixFingerprint(messages, end, messageFingerprint),
99
+ },
100
+ reset: false,
101
+ compactionShrink: true,
102
+ };
103
+ }
104
+ const acknowledgedPrefix =
105
+ start >= 0
106
+ ? prefixFingerprint(messages, start, messageFingerprint)
107
+ : "";
108
+ const fingerprintTrusted = Boolean(cursor?.prefixFingerprint);
109
+ const externalIdTrusted = Boolean(cursor?.lastExternalId);
110
+ if (
111
+ start < 0 ||
112
+ (start > 0 &&
113
+ ((externalIdTrusted &&
114
+ externalId(messages[start - 1]) !== cursor.lastExternalId) ||
115
+ (fingerprintTrusted &&
116
+ acknowledgedPrefix !== cursor.prefixFingerprint)))
117
+ ) {
118
+ start = 0;
119
+ reset = true;
120
+ }
121
+ const end = messages.length;
122
+ return {
123
+ start,
124
+ end,
125
+ messages: messages.slice(start),
126
+ next: {
127
+ count: end,
128
+ remoteCount: cursor?.remoteCount ?? end,
129
+ ...(end > 0 ? { lastExternalId: externalId(messages[end - 1]) } : {}),
130
+ prefixFingerprint: prefixFingerprint(messages, end, messageFingerprint),
131
+ },
132
+ reset,
133
+ compactionShrink: false,
134
+ };
135
+ }
136
+
137
+ export function contentBoundIdempotencyKey(prefix, threadId, start, end, fingerprint) {
138
+ return `${prefix}:${threadId}:${start}-${end}:${fingerprint}`;
139
+ }
140
+
141
+ export function hasUserAndAssistant(messages) {
142
+ if (!Array.isArray(messages) || messages.length < 2) return false;
143
+ let hasUser = false;
144
+ let hasAssistant = false;
145
+ for (const message of messages) {
146
+ if (message?.role === "user") hasUser = true;
147
+ else if (message?.role === "assistant") hasAssistant = true;
148
+ if (hasUser && hasAssistant) return true;
149
+ }
150
+ return false;
151
+ }
@@ -0,0 +1,14 @@
1
+ const DEFAULT_THREAD_SYNC_TIMEOUT_MS = 120_000;
2
+ const MIN_THREAD_SYNC_TIMEOUT_MS = 1_000;
3
+ const MAX_THREAD_SYNC_TIMEOUT_MS = 30 * 60_000;
4
+
5
+ export function resolveThreadSyncTimeoutMs(raw) {
6
+ const parsed = Number(raw);
7
+ if (!String(raw ?? "").trim() || !Number.isSafeInteger(parsed) || parsed <= 0) {
8
+ return DEFAULT_THREAD_SYNC_TIMEOUT_MS;
9
+ }
10
+ return Math.min(
11
+ MAX_THREAD_SYNC_TIMEOUT_MS,
12
+ Math.max(MIN_THREAD_SYNC_TIMEOUT_MS, parsed),
13
+ );
14
+ }