@nowledge/openclaw-nowledge-mem 0.8.23 → 0.8.24

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,14 @@ All notable changes to the Nowledge Mem OpenClaw plugin will be documented in th
4
4
 
5
5
  ## Unreleased
6
6
 
7
+ ## [0.8.24] - 2026-05-15
8
+
9
+ ### Fixed
10
+
11
+ - OpenClaw session capture now handles newer per-turn `agent_end` hook payloads from Codex app-server runtimes. The plugin no longer mistakes those shorter batches for transcript compaction, so continued turns keep syncing into the same Mem thread after an OpenClaw upgrade.
12
+ - Captured Codex app-server messages now use OpenClaw's stable mirror identity for Mem-side dedupe, keeping Context Engine `afterTurn` and hook backstop capture idempotent when both paths see the same turn.
13
+ - Capture setup no longer depends on runtime-only OpenClaw SDK imports, keeping the plugin loadable on existing OpenClaw installer builds while still matching the host session-key contract.
14
+
7
15
  ## [0.8.23] - 2026-05-02
8
16
 
9
17
  ### Fixed
@@ -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 Working Memory, graph search, and session distillation.",
5
- "version": "0.8.23",
5
+ "version": "0.8.24",
6
6
  "kind": ["memory", "context-engine"],
7
7
  "skills": [
8
8
  "skills/memory-guide"
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.23",
4
+ "version": "0.8.24",
5
5
  "type": "module",
6
6
  "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
7
7
  "author": {
@@ -1,9 +1,6 @@
1
- import { readFile } from "node:fs/promises";
2
1
  import { createHash } from "node:crypto";
3
- import { isCronSessionKey, isSubagentSessionKey } from "openclaw/plugin-sdk/routing";
4
- import {
5
- buildStableThreadId,
6
- } from "./thread-identity.js";
2
+ import { readFile } from "node:fs/promises";
3
+ import { buildStableThreadId } from "./thread-identity.js";
7
4
  export {
8
5
  _resetConversationRoots,
9
6
  buildStableThreadId,
@@ -29,6 +26,38 @@ const _lastCaptureAt = new Map();
29
26
  const _MAX_COOLDOWN_ENTRIES = 200;
30
27
  const _syncedMessageCounts = new Map();
31
28
  const _MAX_SYNC_CURSOR_ENTRIES = 500;
29
+ const CAPTURE_MESSAGE_MODE_AUTO = "auto";
30
+ const CAPTURE_MESSAGE_MODE_SNAPSHOT = "snapshot";
31
+ const CAPTURE_MESSAGE_MODE_DELTA = "delta";
32
+
33
+ function parseAgentSessionKey(sessionKey) {
34
+ const raw = String(sessionKey || "")
35
+ .trim()
36
+ .toLowerCase();
37
+ if (!raw) return null;
38
+ const parts = raw.split(":").filter(Boolean);
39
+ if (parts.length < 3 || parts[0] !== "agent") return null;
40
+ const agentId = parts[1];
41
+ const rest = parts.slice(2).join(":");
42
+ if (!agentId || !rest) return null;
43
+ return { agentId, rest };
44
+ }
45
+
46
+ function isAgentCronSessionKey(sessionKey) {
47
+ const parsed = parseAgentSessionKey(sessionKey);
48
+ return parsed?.rest?.startsWith("cron:") === true;
49
+ }
50
+
51
+ function isSubagentSessionKey(sessionKey) {
52
+ const raw = String(sessionKey || "")
53
+ .trim()
54
+ .toLowerCase();
55
+ if (!raw) return false;
56
+ if (raw.startsWith("subagent:")) return true;
57
+ const parsed = parseAgentSessionKey(raw);
58
+ return parsed?.rest?.startsWith("subagent:") === true;
59
+ }
60
+
32
61
  function _setLastCapture(threadId, now) {
33
62
  _lastCaptureAt.set(threadId, now);
34
63
  // Opportunistic eviction: sweep stale entries when map grows large
@@ -68,11 +97,7 @@ function _compileGlob(pattern) {
68
97
  let re = _compiledPatterns.get(key);
69
98
  if (!re) {
70
99
  re = new RegExp(
71
- "^" +
72
- key
73
- .replace(/[.+?^${}()|[\]\\]/g, "\\$&")
74
- .replace(/\*/g, "[^:]*") +
75
- "$",
100
+ `^${key.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, "[^:]*")}$`,
76
101
  );
77
102
  _compiledPatterns.set(key, re);
78
103
  }
@@ -95,12 +120,11 @@ export function matchesExcludePattern(sessionKey, patterns) {
95
120
  /**
96
121
  * Whether automatic thread capture should skip this OpenClaw session.
97
122
  *
98
- * - Agent-scoped cron / isolated-agent keys are classified by
99
- * `isCronSessionKey` from `openclaw/plugin-sdk/routing` (same implementation
100
- * the gateway uses). Requires OpenClaw >=2026.3.22.
123
+ * - Agent-scoped cron / isolated-agent keys follow OpenClaw's canonical
124
+ * `agent:<id>:cron:*` session-key shape.
101
125
  * - Bare `cron:*` keys are still excluded: some internal paths (e.g. delivery /
102
126
  * failure bookkeeping) use that shape without the `agent:` prefix, and
103
- * `isCronSessionKey` only parses agent-scoped keys.
127
+ * agent-scoped parsing intentionally does not classify bare keys.
104
128
  */
105
129
  export function isCronCaptureSessionKey(sessionKey) {
106
130
  const raw = String(sessionKey || "")
@@ -108,7 +132,7 @@ export function isCronCaptureSessionKey(sessionKey) {
108
132
  .toLowerCase();
109
133
  if (!raw) return false;
110
134
  if (raw.startsWith("cron:")) return true;
111
- return isCronSessionKey(raw);
135
+ return isAgentCronSessionKey(raw);
112
136
  }
113
137
 
114
138
  /**
@@ -118,7 +142,8 @@ export function isCronCaptureSessionKey(sessionKey) {
118
142
  * Exported for reuse by Context Engine and other capture paths.
119
143
  */
120
144
  export function hasSkipMarker(messages, marker) {
121
- if (!marker || typeof marker !== "string" || !Array.isArray(messages)) return false;
145
+ if (!marker || typeof marker !== "string" || !Array.isArray(messages))
146
+ return false;
122
147
  const markerLc = marker.toLowerCase();
123
148
  return messages.some((msg) => {
124
149
  const text = extractText(msg?.content ?? msg?.message?.content);
@@ -225,17 +250,45 @@ export function normalizeRoleMessage(
225
250
  timestamp = msg.timestamp;
226
251
  }
227
252
 
253
+ const openclawMeta =
254
+ msg.__openclaw && typeof msg.__openclaw === "object"
255
+ ? msg.__openclaw
256
+ : raw.__openclaw && typeof raw.__openclaw === "object"
257
+ ? raw.__openclaw
258
+ : null;
259
+ const msgMetadata =
260
+ msg.metadata && typeof msg.metadata === "object" ? msg.metadata : null;
261
+ const rawMetadata =
262
+ raw.metadata && typeof raw.metadata === "object" ? raw.metadata : null;
228
263
  const externalHint = [
264
+ openclawMeta?.mirrorIdentity,
265
+ openclawMeta?.idempotencyKey,
266
+ msg.idempotencyKey,
267
+ msg.idempotency_key,
229
268
  msg.external_id,
230
269
  msg.externalId,
231
270
  msg.message_id,
232
271
  msg.messageId,
272
+ msg.openclaw_entry_id,
273
+ msg.openclawEntryId,
233
274
  msg.id,
275
+ raw.idempotencyKey,
276
+ raw.idempotency_key,
234
277
  raw.external_id,
235
278
  raw.externalId,
236
279
  raw.message_id,
237
280
  raw.messageId,
281
+ raw.openclaw_entry_id,
282
+ raw.openclawEntryId,
238
283
  raw.id,
284
+ msgMetadata?.external_id,
285
+ msgMetadata?.externalId,
286
+ msgMetadata?.message_id,
287
+ msgMetadata?.messageId,
288
+ rawMetadata?.external_id,
289
+ rawMetadata?.externalId,
290
+ rawMetadata?.message_id,
291
+ rawMetadata?.messageId,
239
292
  ]
240
293
  .find((v) => typeof v === "string" && v.trim().length > 0)
241
294
  ?.trim();
@@ -263,11 +316,26 @@ function sanitizeIdPart(input, max = 48) {
263
316
  return normalized.slice(0, max);
264
317
  }
265
318
 
266
- function buildExternalId({ normalized, index, threadId, sessionKey }) {
319
+ function buildExternalId({
320
+ normalized,
321
+ index,
322
+ threadId,
323
+ sessionKey,
324
+ event,
325
+ ctx,
326
+ messageMode,
327
+ }) {
267
328
  if (normalized.externalHint) {
268
329
  return `oc:${sanitizeIdPart(normalized.externalHint, 96)}`;
269
330
  }
270
- const seed = `${threadId}|${sessionKey}|${index}|${normalized.role}|${normalized.content}`;
331
+ const runId =
332
+ messageMode === CAPTURE_MESSAGE_MODE_DELTA
333
+ ? String(ctx?.runId || event?.runId || "").trim()
334
+ : "";
335
+ const seedParts = [threadId, sessionKey];
336
+ if (runId) seedParts.push(`run:${runId}`);
337
+ seedParts.push(index, normalized.role, normalized.content);
338
+ const seed = seedParts.join("|");
271
339
  const digest = createHash("sha1").update(seed).digest("hex");
272
340
  return `oc-msg:${digest}`;
273
341
  }
@@ -286,6 +354,18 @@ function buildAppendIdempotencyKey(threadId, reason, messages) {
286
354
  return `oc-batch:${createHash("sha1").update(JSON.stringify(seed)).digest("hex")}`;
287
355
  }
288
356
 
357
+ function hasStableExternalHints(normalized) {
358
+ return (
359
+ Array.isArray(normalized) &&
360
+ normalized.length > 0 &&
361
+ normalized.every(
362
+ (message) =>
363
+ typeof message?.externalHint === "string" &&
364
+ message.externalHint.trim().length > 0,
365
+ )
366
+ );
367
+ }
368
+
289
369
  async function loadMessagesFromSessionFile(sessionFile) {
290
370
  try {
291
371
  const content = await readFile(sessionFile, "utf-8");
@@ -328,6 +408,7 @@ export async function appendOrCreateThread({
328
408
  reason,
329
409
  maxMessageChars = DEFAULT_MAX_MESSAGE_CHARS,
330
410
  resolvedMessages,
411
+ messageMode = CAPTURE_MESSAGE_MODE_SNAPSHOT,
331
412
  }) {
332
413
  const rawMessages = resolvedMessages ?? (await resolveHookMessages(event));
333
414
  if (!Array.isArray(rawMessages) || rawMessages.length === 0) return;
@@ -341,22 +422,32 @@ export async function appendOrCreateThread({
341
422
  .filter(Boolean);
342
423
  if (normalized.length === 0) return;
343
424
 
344
- const allMessages = normalized.map((message, index) => ({
345
- role: message.role,
346
- content: message.content,
347
- timestamp: message.timestamp,
348
- metadata: {
349
- external_id: buildExternalId({
350
- normalized: message,
351
- index,
352
- threadId,
353
- sessionKey,
354
- }),
355
- source: "openclaw",
356
- session_key: sessionKey,
357
- session_id: sessionId || undefined,
358
- },
359
- }));
425
+ const buildMessages = (resolvedMessageMode) =>
426
+ normalized.map((message, index) => ({
427
+ role: message.role,
428
+ content: message.content,
429
+ timestamp: message.timestamp,
430
+ metadata: {
431
+ external_id: buildExternalId({
432
+ normalized: message,
433
+ index,
434
+ threadId,
435
+ sessionKey,
436
+ event,
437
+ ctx,
438
+ messageMode: resolvedMessageMode,
439
+ }),
440
+ source: "openclaw",
441
+ session_key: sessionKey,
442
+ session_id: sessionId || undefined,
443
+ },
444
+ }));
445
+
446
+ let allMessages = buildMessages(
447
+ messageMode === CAPTURE_MESSAGE_MODE_DELTA
448
+ ? CAPTURE_MESSAGE_MODE_DELTA
449
+ : CAPTURE_MESSAGE_MODE_SNAPSHOT,
450
+ );
360
451
 
361
452
  let syncedCount = _syncedMessageCounts.get(threadId);
362
453
  if (syncedCount === undefined) {
@@ -366,6 +457,67 @@ export async function appendOrCreateThread({
366
457
  }
367
458
  }
368
459
 
460
+ const resolvedMessageMode =
461
+ messageMode === CAPTURE_MESSAGE_MODE_DELTA ||
462
+ (messageMode === CAPTURE_MESSAGE_MODE_AUTO &&
463
+ hasStableExternalHints(normalized))
464
+ ? CAPTURE_MESSAGE_MODE_DELTA
465
+ : CAPTURE_MESSAGE_MODE_SNAPSHOT;
466
+ if (resolvedMessageMode === CAPTURE_MESSAGE_MODE_DELTA) {
467
+ allMessages = buildMessages(CAPTURE_MESSAGE_MODE_DELTA);
468
+ const idempotencyKey = buildAppendIdempotencyKey(
469
+ threadId,
470
+ reason,
471
+ allMessages,
472
+ );
473
+ try {
474
+ const appended = await client.appendThread({
475
+ threadId,
476
+ messages: allMessages,
477
+ deduplicate: true,
478
+ idempotencyKey,
479
+ });
480
+ const added = appended.messagesAdded ?? 0;
481
+ const total =
482
+ Number.isFinite(appended.totalMessages) && appended.totalMessages >= 0
483
+ ? appended.totalMessages
484
+ : typeof syncedCount === "number"
485
+ ? syncedCount + added
486
+ : allMessages.length;
487
+ _setSyncedMessageCount(threadId, total);
488
+ logger.info(
489
+ `capture: appended ${added} delta messages to ${threadId} (${reason || "event"})`,
490
+ );
491
+ return { threadId, normalized, messagesAdded: added };
492
+ } catch (err) {
493
+ if (!client.isThreadNotFoundError(err)) {
494
+ const message = err instanceof Error ? err.message : String(err);
495
+ logger.warn(
496
+ `capture: thread append failed for ${threadId}: ${message}`,
497
+ );
498
+ return null;
499
+ }
500
+ }
501
+
502
+ try {
503
+ const createdId = await client.createThread({
504
+ threadId,
505
+ title,
506
+ messages: allMessages,
507
+ source: "openclaw",
508
+ });
509
+ _setSyncedMessageCount(threadId, allMessages.length);
510
+ logger.info(
511
+ `capture: created thread ${createdId} with ${allMessages.length} delta messages (${reason || "event"})`,
512
+ );
513
+ return { threadId, normalized, messagesAdded: allMessages.length };
514
+ } catch (err) {
515
+ const message = err instanceof Error ? err.message : String(err);
516
+ logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
517
+ return null;
518
+ }
519
+ }
520
+
369
521
  if (typeof syncedCount === "number" && syncedCount > allMessages.length) {
370
522
  // OpenClaw compaction can shrink the active transcript after we already
371
523
  // stored the pre-compaction history. Reset the local cursor to the new
@@ -563,9 +715,7 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
563
715
  // Layer 2: marker-based exclusion (user typed #nmem-skip in conversation)
564
716
  const resolvedMessages = await resolveHookMessages(event);
565
717
  if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
566
- logger.debug?.(
567
- `capture: skipped session with skip marker ${sessionKey}`,
568
- );
718
+ logger.debug?.(`capture: skipped session with skip marker ${sessionKey}`);
569
719
  return;
570
720
  }
571
721
 
@@ -577,6 +727,7 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
577
727
  reason: "agent_end",
578
728
  maxMessageChars: cfg.maxThreadMessageChars,
579
729
  resolvedMessages,
730
+ messageMode: CAPTURE_MESSAGE_MODE_AUTO,
580
731
  });
581
732
 
582
733
  await triageAndDistill({ client, cfg, logger, captureResult, ctx });
@@ -614,9 +765,7 @@ export function buildBeforeResetCaptureHandler(client, cfg, logger) {
614
765
  // Layer 2: marker-based exclusion
615
766
  const resolvedMessages = await resolveHookMessages(event);
616
767
  if (hasSkipMarker(resolvedMessages, cfg.captureSkipMarker)) {
617
- logger.debug?.(
618
- `capture: skipped session with skip marker ${sessionKey}`,
619
- );
768
+ logger.debug?.(`capture: skipped session with skip marker ${sessionKey}`);
620
769
  return;
621
770
  }
622
771