@nowledge/openclaw-nowledge-mem 0.3.0 → 0.6.2

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.
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "id": "openclaw-nowledge-mem",
3
- "version": "0.3.0",
3
+ "version": "0.6.2",
4
4
  "kind": "memory",
5
5
  "uiHints": {
6
6
  "autoRecall": {
@@ -11,6 +11,10 @@
11
11
  "label": "Auto-capture at session end",
12
12
  "help": "Capture conversation threads and distill key memories via LLM at session end"
13
13
  },
14
+ "captureMinInterval": {
15
+ "label": "Minimum capture interval (seconds)",
16
+ "help": "Minimum seconds between auto-captures for the same thread. Prevents heartbeat-driven burst captures. Set to 0 for no limit."
17
+ },
14
18
  "maxRecallResults": {
15
19
  "label": "Max recall results",
16
20
  "help": "How many memories to inject for each recall cycle (1\u201320)"
@@ -38,6 +42,13 @@
38
42
  "default": false,
39
43
  "description": "Capture conversation threads and distill key memories via LLM at session end"
40
44
  },
45
+ "captureMinInterval": {
46
+ "type": "integer",
47
+ "default": 300,
48
+ "minimum": 0,
49
+ "maximum": 86400,
50
+ "description": "Minimum seconds between auto-captures for the same thread (0 = no limit). Prevents heartbeat-driven burst captures."
51
+ },
41
52
  "maxRecallResults": {
42
53
  "type": "integer",
43
54
  "default": 5,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nowledge/openclaw-nowledge-mem",
3
- "version": "0.3.0",
3
+ "version": "0.6.2",
4
4
  "type": "module",
5
5
  "description": "Nowledge Mem memory plugin for OpenClaw, local-first personal knowledge base",
6
6
  "author": {
package/src/config.js CHANGED
@@ -12,6 +12,7 @@ export function isDefaultApiUrl(url) {
12
12
  const ALLOWED_KEYS = new Set([
13
13
  "autoRecall",
14
14
  "autoCapture",
15
+ "captureMinInterval",
15
16
  "maxRecallResults",
16
17
  "apiUrl",
17
18
  "apiKey",
@@ -48,6 +49,11 @@ export function parseConfig(raw) {
48
49
  return {
49
50
  autoRecall: typeof obj.autoRecall === "boolean" ? obj.autoRecall : false,
50
51
  autoCapture: typeof obj.autoCapture === "boolean" ? obj.autoCapture : false,
52
+ captureMinInterval:
53
+ typeof obj.captureMinInterval === "number" &&
54
+ Number.isFinite(obj.captureMinInterval)
55
+ ? Math.min(86400, Math.max(0, Math.trunc(obj.captureMinInterval)))
56
+ : 300,
51
57
  maxRecallResults:
52
58
  typeof obj.maxRecallResults === "number" &&
53
59
  Number.isFinite(obj.maxRecallResults)
@@ -6,6 +6,23 @@ const MAX_DISTILL_MESSAGE_CHARS = 2000;
6
6
  const MAX_CONVERSATION_CHARS = 30_000;
7
7
  const MIN_MESSAGES_FOR_DISTILL = 4;
8
8
 
9
+ // Per-thread triage cooldown: prevents burst triage/distillation from heartbeat.
10
+ // Maps threadId -> timestamp (ms) of last successful triage.
11
+ // Evicted opportunistically when new entries are set (see _setLastCapture).
12
+ const _lastCaptureAt = new Map();
13
+ const _MAX_COOLDOWN_ENTRIES = 200;
14
+
15
+ function _setLastCapture(threadId, now) {
16
+ _lastCaptureAt.set(threadId, now);
17
+ // Opportunistic eviction: sweep stale entries when map grows large
18
+ if (_lastCaptureAt.size > _MAX_COOLDOWN_ENTRIES) {
19
+ const cutoff = now - 86_400_000; // 24h — generous TTL
20
+ for (const [key, ts] of _lastCaptureAt) {
21
+ if (ts < cutoff) _lastCaptureAt.delete(key);
22
+ }
23
+ }
24
+ }
25
+
9
26
  function truncate(text, max = MAX_MESSAGE_CHARS) {
10
27
  const str = String(text || "").trim();
11
28
  if (!str) return "";
@@ -189,10 +206,11 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
189
206
  deduplicate: true,
190
207
  idempotencyKey,
191
208
  });
209
+ const added = appended.messagesAdded ?? 0;
192
210
  logger.info(
193
- `capture: appended ${appended.messagesAdded} messages to ${threadId} (${reason || "event"})`,
211
+ `capture: appended ${added} messages to ${threadId} (${reason || "event"})`,
194
212
  );
195
- return { threadId, normalized };
213
+ return { threadId, normalized, messagesAdded: added };
196
214
  } catch (err) {
197
215
  if (!client.isThreadNotFoundError(err)) {
198
216
  const message = err instanceof Error ? err.message : String(err);
@@ -211,7 +229,7 @@ async function appendOrCreateThread({ client, logger, event, ctx, reason }) {
211
229
  logger.info(
212
230
  `capture: created thread ${createdId} with ${messages.length} messages (${reason || "event"})`,
213
231
  );
214
- return { threadId, normalized };
232
+ return { threadId, normalized, messagesAdded: messages.length };
215
233
  } catch (err) {
216
234
  const message = err instanceof Error ? err.message : String(err);
217
235
  logger.warn(`capture: thread create failed for ${threadId}: ${message}`);
@@ -256,10 +274,14 @@ function buildConversationText(normalized) {
256
274
  * triage or distillation, since those are mid-session checkpoints.
257
275
  */
258
276
  export function buildAgentEndCaptureHandler(client, cfg, logger) {
277
+ const cooldownMs = (cfg.captureMinInterval ?? 300) * 1000;
278
+
259
279
  return async (event, ctx) => {
260
280
  if (!event?.success) return;
261
281
 
262
282
  // 1. Always thread-append (idempotent, self-guards on empty messages).
283
+ // Never skip this — messages must always be persisted regardless of
284
+ // cooldown state, since appendOrCreateThread is deduped and cheap.
263
285
  const result = await appendOrCreateThread({
264
286
  client,
265
287
  logger,
@@ -273,9 +295,27 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
273
295
  // but check here too so the handler is safe if called directly.
274
296
  if (!cfg.autoCapture) return;
275
297
 
298
+ // Skip when no new messages were added (e.g. heartbeat re-sync).
299
+ if (!result || result.messagesAdded === 0) {
300
+ logger.debug?.("capture: no new messages since last sync, skipping triage");
301
+ return;
302
+ }
303
+
304
+ // Triage cooldown: skip expensive LLM triage/distillation if this
305
+ // thread was already triaged recently. Thread append above still ran,
306
+ // so no messages are lost — only the LLM cost is avoided.
307
+ if (cooldownMs > 0 && result.threadId) {
308
+ const lastCapture = _lastCaptureAt.get(result.threadId) || 0;
309
+ if (Date.now() - lastCapture < cooldownMs) {
310
+ logger.debug?.(
311
+ `capture: triage cooldown active for ${result.threadId}, skipping`,
312
+ );
313
+ return;
314
+ }
315
+ }
316
+
276
317
  // Skip short conversations — not worth the triage cost.
277
318
  if (
278
- !result ||
279
319
  !result.normalized ||
280
320
  result.normalized.length < MIN_MESSAGES_FOR_DISTILL
281
321
  ) {
@@ -285,6 +325,13 @@ export function buildAgentEndCaptureHandler(client, cfg, logger) {
285
325
  const conversationText = buildConversationText(result.normalized);
286
326
  if (conversationText.length < 100) return;
287
327
 
328
+ // Record cooldown AFTER all eligibility checks pass, right before
329
+ // the expensive LLM call. If triage was skipped by filters above,
330
+ // the cooldown stays unset so the next call can retry.
331
+ if (cooldownMs > 0 && result.threadId) {
332
+ _setLastCapture(result.threadId, Date.now());
333
+ }
334
+
288
335
  try {
289
336
  const triage = await client.triageConversation(conversationText);
290
337
  if (!triage?.should_distill) {