@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 +15 -0
- package/README.md +5 -2
- package/openclaw.plugin.json +1 -1
- package/package.json +1 -1
- package/src/client.js +47 -85
- package/src/hooks/capture.js +62 -6
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
|
|
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
|
-
|
|
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
|
package/openclaw.plugin.json
CHANGED
package/package.json
CHANGED
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
|
|
57
|
+
* Nowledge Mem client. Wraps the nmem CLI plus direct API calls.
|
|
58
58
|
*
|
|
59
|
-
*
|
|
60
|
-
* -
|
|
61
|
-
*
|
|
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
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
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
|
-
"
|
|
649
|
-
`/threads/${encodeURIComponent(normalizedThreadId)}
|
|
650
|
-
|
|
651
|
-
|
|
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
|
-
|
|
660
|
-
|
|
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/hooks/capture.js
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 ${
|
|
290
|
+
`capture: created thread ${createdId} with ${allMessages.length} messages (${reason || "event"})`,
|
|
244
291
|
);
|
|
245
|
-
return { threadId, normalized, messagesAdded:
|
|
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,
|