@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.
- package/openclaw.plugin.json +12 -1
- package/package.json +1 -1
- package/src/config.js +6 -0
- package/src/hooks/capture.js +51 -4
package/openclaw.plugin.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "openclaw-nowledge-mem",
|
|
3
|
-
"version": "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
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)
|
package/src/hooks/capture.js
CHANGED
|
@@ -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 ${
|
|
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) {
|