@nowledge/openclaw-nowledge-mem 0.7.0 → 0.7.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,21 @@
2
2
 
3
3
  All notable changes to the Nowledge Mem OpenClaw plugin will be documented in this file.
4
4
 
5
+ ## [Unreleased]
6
+
7
+ ### Fixed
8
+
9
+ - **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.
10
+ - **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.
11
+ - **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.
12
+ - **Removed lossy repetitive-session collapse.** The temporary content-based dedup workaround for cron-style sessions has been removed so conversation structure is preserved faithfully.
13
+
14
+ ## [0.7.1] - 2026-03-23
15
+
16
+ ### Fixed
17
+
18
+ - **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.
19
+
5
20
  ## [0.7.0] - 2026-03-23
6
21
 
7
22
  ### Added
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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.7.0",
3
+ "version": "0.7.1",
4
4
  "kind": "memory",
5
5
  "skills": ["skills/memory-guide"],
6
6
  "uiHints": {
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.1",
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
 
@@ -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,20 @@ 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
+
27
43
  export function truncate(text, max = DEFAULT_MAX_MESSAGE_CHARS) {
28
44
  const str = String(text || "").trim();
29
45
  if (!str) return "";
@@ -194,7 +210,7 @@ export async function appendOrCreateThread({
194
210
  .filter(Boolean);
195
211
  if (normalized.length === 0) return;
196
212
 
197
- const messages = normalized.map((message, index) => ({
213
+ const allMessages = normalized.map((message, index) => ({
198
214
  role: message.role,
199
215
  content: message.content,
200
216
  timestamp: message.timestamp,
@@ -210,16 +226,46 @@ export async function appendOrCreateThread({
210
226
  session_id: sessionId || undefined,
211
227
  },
212
228
  }));
213
- const idempotencyKey = buildAppendIdempotencyKey(threadId, reason, messages);
229
+
230
+ let syncedCount = _syncedMessageCounts.get(threadId);
231
+ if (syncedCount === undefined) {
232
+ syncedCount = await client.getThreadMessageCount(threadId);
233
+ if (syncedCount !== null) {
234
+ _setSyncedMessageCount(threadId, syncedCount);
235
+ }
236
+ }
237
+
238
+ if (typeof syncedCount === "number" && syncedCount > allMessages.length) {
239
+ // OpenClaw compaction can shrink the active transcript after we already
240
+ // stored the pre-compaction history. Reset the local cursor to the new
241
+ // compacted length so future turns append only the post-compaction tail.
242
+ _setSyncedMessageCount(threadId, allMessages.length);
243
+ return { threadId, normalized, messagesAdded: 0 };
244
+ }
245
+
246
+ const appendStart =
247
+ typeof syncedCount === "number" && syncedCount > 0
248
+ ? Math.min(syncedCount, allMessages.length)
249
+ : 0;
250
+ const newMessages = allMessages.slice(appendStart);
251
+ if (newMessages.length === 0) {
252
+ return { threadId, normalized, messagesAdded: 0 };
253
+ }
254
+ const idempotencyKey = buildAppendIdempotencyKey(
255
+ threadId,
256
+ reason,
257
+ newMessages,
258
+ );
214
259
 
215
260
  try {
216
261
  const appended = await client.appendThread({
217
262
  threadId,
218
- messages,
263
+ messages: newMessages,
219
264
  deduplicate: true,
220
265
  idempotencyKey,
221
266
  });
222
267
  const added = appended.messagesAdded ?? 0;
268
+ _setSyncedMessageCount(threadId, allMessages.length);
223
269
  logger.info(
224
270
  `capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
225
271
  );
@@ -236,13 +282,14 @@ export async function appendOrCreateThread({
236
282
  const createdId = await client.createThread({
237
283
  threadId,
238
284
  title,
239
- messages,
285
+ messages: allMessages,
240
286
  source: "openclaw",
241
287
  });
288
+ _setSyncedMessageCount(threadId, allMessages.length);
242
289
  logger.info(
243
- `capture: created thread ${createdId} with ${messages.length} messages (${reason || "event"})`,
290
+ `capture: created thread ${createdId} with ${allMessages.length} messages (${reason || "event"})`,
244
291
  );
245
- return { threadId, normalized, messagesAdded: messages.length };
292
+ return { threadId, normalized, messagesAdded: allMessages.length };
246
293
  } catch (err) {
247
294
  const message = err instanceof Error ? err.message : String(err);
248
295
  logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
@@ -354,11 +401,17 @@ export async function triageAndDistill({
354
401
  *
355
402
  * When the context engine is active, this hook is a no-op — afterTurn
356
403
  * handles capture and distillation through the CE lifecycle.
404
+ *
405
+ * Heartbeat sessions (ctx.trigger === "heartbeat") are skipped — they
406
+ * produce repetitive status pings that aren't worth preserving.
407
+ * Other sessions use incremental tail sync: we preserve the real
408
+ * transcript, but only append messages that are not already stored.
357
409
  */
358
410
  export function buildAgentEndCaptureHandler(client, cfg, logger) {
359
411
  return async (event, ctx) => {
360
412
  if (ceState.active) return;
361
413
  if (!event?.success) return;
414
+ if (ctx?.trigger === "heartbeat") return;
362
415
 
363
416
  const captureResult = await appendOrCreateThread({
364
417
  client,
@@ -379,10 +432,13 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
379
432
  *
380
433
  * When the context engine is active, this hook is a no-op — afterTurn
381
434
  * handles capture through the CE lifecycle.
435
+ *
436
+ * Heartbeat sessions are skipped (same rationale as agent_end).
382
437
  */
383
438
  export function buildBeforeResetCaptureHandler(client, _cfg, logger) {
384
439
  return async (event, ctx) => {
385
440
  if (ceState.active) return;
441
+ if (ctx?.trigger === "heartbeat") return;
386
442
  const reason = typeof event?.reason === "string" ? event.reason : undefined;
387
443
  await appendOrCreateThread({
388
444
  client,