Skip to content

fix: prevent heartbeat-driven token burn in autoCapture#77

Merged
wey-gu merged 5 commits into
mainfrom
dev_062
Feb 25, 2026
Merged

fix: prevent heartbeat-driven token burn in autoCapture#77
wey-gu merged 5 commits into
mainfrom
dev_062

Conversation

@wey-gu

@wey-gu wey-gu commented Feb 25, 2026

Copy link
Copy Markdown
Member

OpenClaw heartbeat fires agent_end every 30min, which triggered expensive triage + distillation even when no new messages existed. Three fixes:

  1. appendOrCreateThread() returns messagesAdded; agent_end handler skips triage/distillation when messagesAdded === 0 (zero-delta guard)
  2. Per-thread in-memory capture cooldown prevents burst captures
  3. New captureMinInterval config (default 300s) makes cooldown tunable

Summary by CodeRabbit

  • New Features

    • Added captureMinInterval setting to control how often auto-captures run per thread (default: 300s; range: 0–86400).
    • Introduced per-thread capture cooldown to reduce repeated auto-captures within the configured interval.
  • Improvements

    • More accurate reporting of messages added during thread operations to better gate downstream processing.
    • maxRecallResults now defaults to 5 when not specified.
  • Chores

    • Package version bumped to 0.6.2.

OpenClaw heartbeat fires agent_end every 30min, which triggered expensive
triage + distillation even when no new messages existed. Three fixes:

1. appendOrCreateThread() returns messagesAdded; agent_end handler skips
   triage/distillation when messagesAdded === 0 (zero-delta guard)
2. Per-thread in-memory capture cooldown prevents burst captures
3. New captureMinInterval config (default 300s) makes cooldown tunable

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@coderabbitai

coderabbitai Bot commented Feb 25, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@wey-gu has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 11 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

📥 Commits

Reviewing files that changed from the base of the PR and between 2c4f88b and f12c59f.

📒 Files selected for processing (1)
  • nowledge-mem-openclaw-plugin/src/hooks/capture.js
📝 Walkthrough

Walkthrough

Adds a new numeric configuration option captureMinInterval (default 300) to manifest and config parsing, and implements per-thread cooldown and gating in the capture hook so triage/distillation can be skipped for rapid, empty, or very short captures while still performing append/create.

Changes

Cohort / File(s) Summary
Plugin manifest / package
nowledge-mem-openclaw-plugin/openclaw.plugin.json, nowledge-mem-openclaw-plugin/package.json
Bumped version 0.3.0 → 0.6.2; added captureMinInterval UI hint and config schema (integer, default 300, min 0, max 86400).
Config parsing
nowledge-mem-openclaw-plugin/src/config.js
Allow captureMinInterval key; parseConfig validates finite number, truncates to integer, clamps to [0,86400], defaults to 300; maxRecallResults default/clamping updated (default 5, range [1,20]).
Capture hook logic
nowledge-mem-openclaw-plugin/src/hooks/capture.js
Add per-thread _lastCaptureAt cooldown state; appendOrCreateThread now returns { threadId, normalized, messagesAdded }; always perform append/create, but skip triage/distill when messagesAdded === 0, conversation too short, or cooldown active based on captureMinInterval; cooldown timestamp is set only when triage/distill actually runs.

Sequence Diagram(s)

sequenceDiagram
    participant Client
    participant CaptureHook as CaptureHook
    participant ThreadStore as ThreadStore
    participant TriageService as TriageService

    Client->>CaptureHook: agent_end event
    CaptureHook->>ThreadStore: read thread._lastCaptureAt
    alt within cooldown (now < captureMinInterval)
        CaptureHook->>ThreadStore: appendOrCreateThread(event)
        ThreadStore-->>CaptureHook: { threadId, normalized, messagesAdded }
        alt messagesAdded == 0 or content too short
            CaptureHook-->>Client: skip triage/distill
        else
            CaptureHook->>TriageService: triage/distill(normalized)
            TriageService-->>CaptureHook: triage result
            CaptureHook->>ThreadStore: update _lastCaptureAt and persist
        end
    else allowed (no cooldown)
        CaptureHook->>ThreadStore: appendOrCreateThread(event)
        ThreadStore-->>CaptureHook: { threadId, normalized, messagesAdded }
        alt messagesAdded == 0 or content too short
            CaptureHook-->>Client: skip triage/distill
        else
            CaptureHook->>TriageService: triage/distill(normalized)
            TriageService-->>CaptureHook: triage result
            CaptureHook->>ThreadStore: update _lastCaptureAt and persist
        end
    end
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

  • bump v0.6.1 #71 — modifies src/hooks/capture.js and capture/triage flow; likely related to append/triage behavior and cooldown handling.

Poem

🐇 I count the beats between each hop,
A gentle pause so churn will stop,
I still add notes but skip the grind,
Let clever triage wake in time 🥕

🚥 Pre-merge checks | ✅ 2 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 25.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: introducing a cooldown mechanism to prevent expensive triage/distillation operations from running too frequently during heartbeat-driven agent_end events when no new messages are added.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch dev_062

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 3

🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nowledge-mem-openclaw-plugin/src/config.js`:
- Around line 52-56: The captureMinInterval value currently only clamps a lower
bound; update the parsing logic for captureMinInterval (in the config handling
where captureMinInterval is computed) to also enforce the schema's upper bound
by applying Math.min(86400, ...) around the truncated value similar to how
maxRecallResults uses Math.min/Math.max; ensure you still use Math.trunc and
Math.max(0, ...) but wrap that result with Math.min(86400, ...) so values above
86400 are capped to the manifest limit.

In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js`:
- Line 264: The cooldown calculation incorrectly treats an explicit 0 as falsy;
in buildAgentEndCaptureHandler replace the fallback logic so cooldownMs is
derived directly from cfg.captureMinInterval (e.g., remove the "|| 300"
fallback) — i.e., set cooldownMs = cfg.captureMinInterval * 1000 so a configured
0 yields 0ms and the existing if (cooldownMs > 0) guard works as intended;
reference symbols: cfg.captureMinInterval, buildAgentEndCaptureHandler,
cooldownMs.
- Around line 271-280: The cooldown early-return is dropping legitimate
messages; remove the cooldown gate that returns before calling
appendOrCreateThread (the block using cooldownMs, buildStableThreadId, and
_lastCaptureAt) and instead apply the cooldown only around the expensive
triage/distill path after appendOrCreateThread and after you inspect
messagesAdded; call appendOrCreateThread unconditionally so dedup/zero-delta
logic (messagesAdded === 0) can skip work, then check cooldown and update
_lastCaptureAt before running triage/distill so only that expensive step is
rate-limited.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between f69a373 and 31953d3.

📒 Files selected for processing (3)
  • nowledge-mem-openclaw-plugin/openclaw.plugin.json
  • nowledge-mem-openclaw-plugin/src/config.js
  • nowledge-mem-openclaw-plugin/src/hooks/capture.js

Comment thread nowledge-mem-openclaw-plugin/src/config.js
Comment thread nowledge-mem-openclaw-plugin/src/hooks/capture.js Outdated
Comment thread nowledge-mem-openclaw-plugin/src/hooks/capture.js Outdated
… thread append

1. config.js: add Math.min(86400, ...) to match manifest maximum
2. capture.js: use ?? instead of || so captureMinInterval=0 disables cooldown
3. capture.js: move cooldown to gate only triage/distill path — thread append
   always runs so legitimate messages are never silently dropped

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
nowledge-mem-openclaw-plugin/src/hooks/capture.js (1)

272-284: ⚠️ Potential issue | 🟠 Major

Gate agent_end thread append when autoCapture is disabled.

At Line 272, appendOrCreateThread runs before the autoCapture guard at Line 283, so thread writes still occur when capture is disabled. This conflicts with the file-level capture policy.

🔧 Proposed fix
 return async (event, ctx) => {
   if (!event?.success) return;
+  if (!cfg.autoCapture) return;

   // 1. Always thread-append (idempotent, self-guards on empty messages).
   //    Never skip this — messages must always be persisted regardless of
   //    cooldown state, since appendOrCreateThread is deduped and cheap.
   const result = await appendOrCreateThread({
     client,
     logger,
     event,
     ctx,
     reason: "agent_end",
   });

-  // 2. Triage + distill: language-agnostic LLM-based capture.
-  //    Defensive guard — registration in index.js already gates on autoCapture,
-  //    but check here too so the handler is safe if called directly.
-  if (!cfg.autoCapture) return;
+  // 2. Triage + distill: language-agnostic LLM-based capture.

As per coding guidelines: "agent_end hook must quality-gate memory note and thread append only when autoCapture is enabled".

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js` around lines 272 - 284,
The appendOrCreateThread call (result = await appendOrCreateThread({...})) is
executed before checking cfg.autoCapture, causing thread writes even when
autoCapture is false; update the agent_end handler so you first check if
cfg.autoCapture is truthy and only then call appendOrCreateThread (or skip and
return early), i.e., move or guard the appendOrCreateThread invocation behind
the existing if (!cfg.autoCapture) return; using the same parameters (client,
logger, event, ctx, reason: "agent_end") to ensure thread append and subsequent
triage/distill run only when autoCapture is enabled.
🧹 Nitpick comments (1)
nowledge-mem-openclaw-plugin/src/hooks/capture.js (1)

9-12: Bound _lastCaptureAt growth to avoid long-lived memory creep.

The cooldown map currently has no eviction path. On long-running agents with many thread IDs, this can grow indefinitely.

♻️ Suggested refactor
 const _lastCaptureAt = new Map();
+const MAX_TRACKED_CAPTURE_THREADS = 10_000;
       _lastCaptureAt.set(result.threadId, now);
+      if (_lastCaptureAt.size > MAX_TRACKED_CAPTURE_THREADS) {
+        const oldestKey = _lastCaptureAt.keys().next().value;
+        if (oldestKey) _lastCaptureAt.delete(oldestKey);
+      }

Also applies to: 302-303

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js` around lines 9 - 12, The
Map _lastCaptureAt in capture.js can grow unbounded; update the capture logic
that reads/writes _lastCaptureAt to evict stale entries by age (e.g., remove
entries older than a configured TTL like 1 hour) or cap its size with an LRU
eviction policy; implement the eviction inside the functions that call/modify
_lastCaptureAt (the per-thread capture cooldown helpers) and/or run a
lightweight periodic cleanup (setInterval) to prune old threadId keys so
long-lived agents do not leak memory.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js`:
- Around line 291-303: The code is recording cooldown too early by calling
_lastCaptureAt.set(result.threadId, Date.now()) before the short-conversation
filters run, which can wrongly suppress the first eligible triage; update the
logic in the capture flow so the cooldown timestamp for result.threadId is only
set after all triage eligibility checks (including the short-conversation
filters around captureMinInterval) pass and just before/after you perform the
triage/distillation action, i.e., remove or move the _lastCaptureAt.set call out
of the early cooldown block and into the code path that actually executes triage
so that you only record a capture when triage runs.

---

Outside diff comments:
In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js`:
- Around line 272-284: The appendOrCreateThread call (result = await
appendOrCreateThread({...})) is executed before checking cfg.autoCapture,
causing thread writes even when autoCapture is false; update the agent_end
handler so you first check if cfg.autoCapture is truthy and only then call
appendOrCreateThread (or skip and return early), i.e., move or guard the
appendOrCreateThread invocation behind the existing if (!cfg.autoCapture)
return; using the same parameters (client, logger, event, ctx, reason:
"agent_end") to ensure thread append and subsequent triage/distill run only when
autoCapture is enabled.

---

Nitpick comments:
In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js`:
- Around line 9-12: The Map _lastCaptureAt in capture.js can grow unbounded;
update the capture logic that reads/writes _lastCaptureAt to evict stale entries
by age (e.g., remove entries older than a configured TTL like 1 hour) or cap its
size with an LRU eviction policy; implement the eviction inside the functions
that call/modify _lastCaptureAt (the per-thread capture cooldown helpers) and/or
run a lightweight periodic cleanup (setInterval) to prune old threadId keys so
long-lived agents do not leak memory.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 05d8e6a and 723b577.

📒 Files selected for processing (2)
  • nowledge-mem-openclaw-plugin/src/config.js
  • nowledge-mem-openclaw-plugin/src/hooks/capture.js
🚧 Files skipped from review as they are similar to previous changes (1)
  • nowledge-mem-openclaw-plugin/src/config.js

Comment thread nowledge-mem-openclaw-plugin/src/hooks/capture.js
Cooldown was set before short-conversation filters, so if those filters
skipped triage, the cooldown was burned without LLM work actually running.
Move _lastCaptureAt.set() to after all eligibility checks pass.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
nowledge-mem-openclaw-plugin/src/hooks/capture.js (1)

269-283: ⚠️ Potential issue | 🟠 Major

Gate appendOrCreateThread behind cfg.autoCapture in agent_end.

appendOrCreateThread(...) executes before the if (!cfg.autoCapture) return guard, so direct invocation of this handler can still persist messages while auto-capture is disabled.

🔧 Proposed fix
 return async (event, ctx) => {
 	if (!event?.success) return;
+	if (!cfg.autoCapture) return;
 
 	// 1. Always thread-append (idempotent, self-guards on empty messages).
 	//    Never skip this — messages must always be persisted regardless of
 	//    cooldown state, since appendOrCreateThread is deduped and cheap.
 	const result = await appendOrCreateThread({
 		client,
 		logger,
 		event,
 		ctx,
 		reason: "agent_end",
 	});
 
 	// 2. Triage + distill: language-agnostic LLM-based capture.
 	//    Defensive guard — registration in index.js already gates on autoCapture,
 	//    but check here too so the handler is safe if called directly.
-	if (!cfg.autoCapture) return;

As per coding guidelines nowledge-mem-openclaw-plugin/src/hooks/capture.js: “agent_end hook must quality-gate memory note and thread append only when autoCapture is enabled”.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js` around lines 269 - 283,
The call to appendOrCreateThread in the agent_end handler runs unconditionally
and can persist messages even when cfg.autoCapture is false; wrap or move that
call so it only executes when cfg.autoCapture is true (i.e., check
cfg.autoCapture before invoking appendOrCreateThread or place the
appendOrCreateThread block inside the existing if (!cfg.autoCapture) guard),
ensuring all logic that appends or creates threads is gated by cfg.autoCapture
while preserving any other unconditional behaviour in agent_end.
🧹 Nitpick comments (1)
nowledge-mem-openclaw-plugin/src/hooks/capture.js (1)

9-11: Bound _lastCaptureAt growth to avoid long-lived memory drift.

_lastCaptureAt is process-global and never evicted. Over many sessions, stale thread keys can accumulate indefinitely. Consider opportunistic TTL cleanup (or capped LRU) when setting cooldown entries.

Also applies to: 315-320

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js` around lines 9 - 11,
_current code creates a process-global Map named _lastCaptureAt that never
evicts keys; modify the capture logic where _lastCaptureAt is written and read
(the code paths that set/check per-thread cooldown) to perform opportunistic
cleanup: when setting an entry, prune keys older than a configurable TTL (e.g.,
compare stored timestamp to Date.now() - TTL) and/or enforce a max size by
removing least-recently-used keys; ensure the TTL and max-size constants are
declared near _lastCaptureAt and that reads also ignore/cleanup expired entries
so stale thread IDs do not accumulate.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Outside diff comments:
In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js`:
- Around line 269-283: The call to appendOrCreateThread in the agent_end handler
runs unconditionally and can persist messages even when cfg.autoCapture is
false; wrap or move that call so it only executes when cfg.autoCapture is true
(i.e., check cfg.autoCapture before invoking appendOrCreateThread or place the
appendOrCreateThread block inside the existing if (!cfg.autoCapture) guard),
ensuring all logic that appends or creates threads is gated by cfg.autoCapture
while preserving any other unconditional behaviour in agent_end.

---

Nitpick comments:
In `@nowledge-mem-openclaw-plugin/src/hooks/capture.js`:
- Around line 9-11: _current code creates a process-global Map named
_lastCaptureAt that never evicts keys; modify the capture logic where
_lastCaptureAt is written and read (the code paths that set/check per-thread
cooldown) to perform opportunistic cleanup: when setting an entry, prune keys
older than a configurable TTL (e.g., compare stored timestamp to Date.now() -
TTL) and/or enforce a max size by removing least-recently-used keys; ensure the
TTL and max-size constants are declared near _lastCaptureAt and that reads also
ignore/cleanup expired entries so stale thread IDs do not accumulate.

ℹ️ Review info

Configuration used: defaults

Review profile: CHILL

Plan: Pro

📥 Commits

Reviewing files that changed from the base of the PR and between 723b577 and 2c4f88b.

📒 Files selected for processing (1)
  • nowledge-mem-openclaw-plugin/src/hooks/capture.js

Sweep entries older than 24h when map exceeds 200 entries to prevent
unbounded growth in long-lived processes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@wey-gu wey-gu merged commit c43d8ea into main Feb 25, 2026
1 check passed
@wey-gu wey-gu deleted the dev_062 branch February 25, 2026 08:41
@coderabbitai coderabbitai Bot mentioned this pull request Feb 26, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant