Skip to content

fix(memory-lancedb): skip already-processed messages in auto-capture#42083

Closed
as775116191 wants to merge 1 commit into
openclaw:mainfrom
as775116191:fix/memory-lancedb-skip-processed-messages
Closed

fix(memory-lancedb): skip already-processed messages in auto-capture#42083
as775116191 wants to merge 1 commit into
openclaw:mainfrom
as775116191:fix/memory-lancedb-skip-processed-messages

Conversation

@as775116191

Copy link
Copy Markdown

Summary

  • Problem: The agent_end hook passes the full session message history to plugins, but the LanceDB memory plugin's auto-capture logic iterated over all messages every turn. This caused redundant embedding API calls for already-processed messages and risked old messages occupying the per-turn capture slots (slice(0, 3)), potentially starving new messages.
  • Fix: Maintain a per-session cursor (Map<string, number>) that tracks how many messages have been processed. On each agent_end invocation, only scan messages beyond the cursor.
  • Corrected the comment from "limit to 3 per conversation" to "limit to 3 per turn" to accurately reflect the behavior.

Test plan

  • Verify auto-capture still works: send a message matching MEMORY_TRIGGERS regex and confirm it is stored in LanceDB.
  • Verify no duplicate processing: in a multi-turn conversation, confirm embedding API is only called for new user messages (check logs for memory-lancedb: auto-captured).
  • Verify the slice(0, 3) limit now correctly applies to new messages only, not to previously captured ones.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7502d9a3d4

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

sessionMsgCursor.set(sessionKey, allMessages.length);

// Only inspect messages added since our last scan.
const newMessages = allMessages.slice(cursor);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Clamp cursor when history shrinks before slicing

Handle the case where the stored cursor is larger than the current event.messages.length before calling slice(cursor). agent_end can receive a shorter history after session restructuring (for example compaction/reset flows), and with the current logic slice(cursor) becomes empty, so the entire current turn is skipped and never auto-captured.

Useful? React with 👍 / 👎.

Comment thread extensions/memory-lancedb/index.ts Outdated
Comment on lines +589 to +590
// Advance cursor to current length so next invocation skips these.
sessionMsgCursor.set(sessionKey, allMessages.length);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Advance the per-session cursor only after success

Move the cursor update to after successful processing of the new messages (or update it incrementally per message). Right now the cursor is advanced before embedding/search/store runs, so any transient failure inside the try block permanently drops that turn from future retries because later invocations start after these messages.

Useful? React with 👍 / 👎.

@greptile-apps

greptile-apps Bot commented Mar 10, 2026

Copy link
Copy Markdown
Contributor

Greptile Summary

This PR fixes a real inefficiency in the LanceDB memory plugin's agent_end handler, where the full session message history was re-scanned on every turn. The cursor-based approach is the right pattern, but there are two correctness issues that need to be addressed before merging:

  1. Cursor advanced before processing (logic bug): sessionMsgCursor.set(sessionKey, allMessages.length) is called at line 590, before the try block. If embedding or storage fails, the cursor has already moved forward and those messages will never be retried on subsequent turns—they are permanently lost.

  2. Unbounded map growth (minor): sessionMsgCursor accumulates entries indefinitely with no cleanup mechanism. For long-running services, registering a session_end handler to call sessionMsgCursor.delete(key) would keep memory usage proportional to active sessions rather than all sessions ever seen.

The comment fix from "per conversation" → "per turn" is accurate and helpful.

Confidence Score: 2/5

  • The cursor-advancement logic bug silently loses unprocessed messages on transient failures, regressing from the original re-scan behavior. The unbounded map accumulation is a secondary concern but also problematic in production.
  • The PR solves a real efficiency problem but introduces a correctness bug at its core: advancing the cursor before the try block means any exception during embedding or storage permanently skips those messages with no retry mechanism. While the cursor-based approach is sound, the placement of the cursor update relative to the try/catch block is a critical placement error. The memory leak from the un-pruned map is secondary but also warrants fixing. Both issues are straightforward to address.
  • extensions/memory-lancedb/index.ts — specifically the cursor advancement placement (line 590) relative to the try/catch block, and lack of session cleanup for the cursor map.

Last reviewed commit: 7502d9a

Comment thread extensions/memory-lancedb/index.ts Outdated
Comment on lines 590 to 598
sessionMsgCursor.set(sessionKey, allMessages.length);

// Only inspect messages added since our last scan.
const newMessages = allMessages.slice(cursor);
if (newMessages.length === 0) {
return;
}

try {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Cursor advanced before processing — failed messages silently dropped

The cursor is set to allMessages.length at line 590, before the try block at line 598. If an exception is thrown during processing (e.g., the embedding API is temporarily unreachable, embeddings.embed() rejects, or db.store() fails), the cursor has already been advanced. On the next invocation, the new messages from this turn are permanently skipped — the catch block only logs a warning (line 671).

To preserve correctness under transient failures, the sessionMsgCursor.set(sessionKey, allMessages.length) call should be moved to the end of the try block, after all storage operations succeed. That way a failed turn simply retries its messages on the next agent_end invocation.

Suggested change
sessionMsgCursor.set(sessionKey, allMessages.length);
// Only inspect messages added since our last scan.
const newMessages = allMessages.slice(cursor);
if (newMessages.length === 0) {
return;
}
try {
try {
// Extract text content from new messages (handling unknown[] type)
const texts: string[] = [];
for (const msg of newMessages) {
if (!msg || typeof msg !== "object") {
continue;
}
const msgObj = msg as Record<string, unknown>;
// Only process user messages to avoid self-poisoning from model output
const role = msgObj.role;
if (role !== "user") {
continue;
}
const content = msgObj.content;
// Handle string content directly
if (typeof content === "string") {
texts.push(content);
continue;
}
// Handle array content (content blocks)
if (Array.isArray(content)) {
for (const block of content) {
if (
block &&
typeof block === "object" &&
"type" in block &&
(block as Record<string, unknown>).type === "text" &&
"text" in block &&
typeof (block as Record<string, unknown>).text === "string"
) {
texts.push((block as Record<string, unknown>).text as string);
}
}
}
}
// Filter for capturable content
const toCapture = texts.filter(
(text) => text && shouldCapture(text, { maxChars: cfg.captureMaxChars }),
);
if (toCapture.length === 0) {
return;
}
// Store each capturable piece (limit to 3 per turn)
let stored = 0;
for (const text of toCapture.slice(0, 3)) {
const category = detectCategory(text);
const vector = await embeddings.embed(text);
// Check for duplicates (high similarity threshold)
const existing = await db.search(vector, 1, 0.95);
if (existing.length > 0) {
continue;
}
await db.store({
text,
vector,
importance: 0.7,
category,
});
stored++;
}
if (stored > 0) {
api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
}
// Only advance cursor after successful processing
sessionMsgCursor.set(sessionKey, allMessages.length);
} catch (err) {
api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
}
Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/memory-lancedb/index.ts
Line: 590-598

Comment:
**Cursor advanced before processing — failed messages silently dropped**

The cursor is set to `allMessages.length` at line 590, *before* the `try` block at line 598. If an exception is thrown during processing (e.g., the embedding API is temporarily unreachable, `embeddings.embed()` rejects, or `db.store()` fails), the cursor has already been advanced. On the next invocation, the new messages from this turn are permanently skipped — the catch block only logs a warning (line 671).

To preserve correctness under transient failures, the `sessionMsgCursor.set(sessionKey, allMessages.length)` call should be moved to the end of the `try` block, *after* all storage operations succeed. That way a failed turn simply retries its messages on the next `agent_end` invocation.

```suggestion
        try {
          // Extract text content from new messages (handling unknown[] type)
          const texts: string[] = [];
          for (const msg of newMessages) {
            if (!msg || typeof msg !== "object") {
              continue;
            }
            const msgObj = msg as Record<string, unknown>;

            // Only process user messages to avoid self-poisoning from model output
            const role = msgObj.role;
            if (role !== "user") {
              continue;
            }

            const content = msgObj.content;

            // Handle string content directly
            if (typeof content === "string") {
              texts.push(content);
              continue;
            }

            // Handle array content (content blocks)
            if (Array.isArray(content)) {
              for (const block of content) {
                if (
                  block &&
                  typeof block === "object" &&
                  "type" in block &&
                  (block as Record<string, unknown>).type === "text" &&
                  "text" in block &&
                  typeof (block as Record<string, unknown>).text === "string"
                ) {
                  texts.push((block as Record<string, unknown>).text as string);
                }
              }
            }
          }

          // Filter for capturable content
          const toCapture = texts.filter(
            (text) => text && shouldCapture(text, { maxChars: cfg.captureMaxChars }),
          );
          if (toCapture.length === 0) {
            return;
          }

          // Store each capturable piece (limit to 3 per turn)
          let stored = 0;
          for (const text of toCapture.slice(0, 3)) {
            const category = detectCategory(text);
            const vector = await embeddings.embed(text);

            // Check for duplicates (high similarity threshold)
            const existing = await db.search(vector, 1, 0.95);
            if (existing.length > 0) {
              continue;
            }

            await db.store({
              text,
              vector,
              importance: 0.7,
              category,
            });
            stored++;
          }

          if (stored > 0) {
            api.logger.info(`memory-lancedb: auto-captured ${stored} memories`);
          }

          // Only advance cursor after successful processing
          sessionMsgCursor.set(sessionKey, allMessages.length);
        } catch (err) {
          api.logger.warn(`memory-lancedb: capture failed: ${String(err)}`);
        }
```

How can I resolve this? If you propose a fix, please make it concise.

Comment thread extensions/memory-lancedb/index.ts Outdated
api.on("agent_end", async (event) => {
// Track how many messages have been processed per session to avoid
// re-scanning the entire conversation history on every turn.
const sessionMsgCursor = new Map<string, number>();

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

sessionMsgCursor map is never evicted — unbounded memory growth

The Map accumulates one entry per unique session and is never pruned. For a long-running process serving many conversations this will grow without bound.

The plugin API already exposes a session_end hook (see PluginHookSessionContext in src/plugins/types.ts). Registering a session_end handler that calls sessionMsgCursor.delete(key) for the ending session would keep the map's size proportional to the number of active sessions rather than all sessions ever seen.

Consider adding cleanup in a session_end handler to delete the cursor entry when sessions complete.

Prompt To Fix With AI
This is a comment left during a code review.
Path: extensions/memory-lancedb/index.ts
Line: 578

Comment:
**`sessionMsgCursor` map is never evicted — unbounded memory growth**

The Map accumulates one entry per unique session and is never pruned. For a long-running process serving many conversations this will grow without bound.

The plugin API already exposes a `session_end` hook (see `PluginHookSessionContext` in `src/plugins/types.ts`). Registering a `session_end` handler that calls `sessionMsgCursor.delete(key)` for the ending session would keep the map's size proportional to the number of *active* sessions rather than all sessions ever seen.

Consider adding cleanup in a `session_end` handler to delete the cursor entry when sessions complete.

How can I resolve this? If you propose a fix, please make it concise.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from 7502d9a to 8116286 Compare March 10, 2026 11:39

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 8116286fcb

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

const allMessages = event.messages;

// Only inspect messages added since our last scan.
const newMessages = allMessages.slice(cursor);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Clamp per-session cursor before slicing new messages

When session history is compacted, event.messages can become shorter than a previously stored cursor, so allMessages.slice(cursor) returns empty and this handler exits without updating the cursor. In that state, auto-capture is effectively disabled for that session because every later agent_end keeps returning early unless history grows past the stale index; this is especially likely in sessions that compact regularly.

Useful? React with 👍 / 👎.

Comment thread extensions/memory-lancedb/index.ts Outdated
@@ -627,7 +640,7 @@ const memoryPlugin = {
return;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Advance cursor even when no text matches capture filters

If a turn has new user messages but none pass shouldCapture, the early return leaves the cursor unchanged, so the same already-processed messages are re-scanned on every subsequent turn until a capturable message appears. This reintroduces repeated O(history) work in long chats and undermines the intended "process only new messages" behavior from this change.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from 8116286 to 01aa051 Compare March 10, 2026 11:47

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 01aa051e42

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/memory-lancedb/index.ts Outdated
Comment on lines +591 to +594
const cursor = Math.min(
sessionMsgCursor.get(sessionKey) ?? 0,
allMessages.length,
);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Rewind cursor on compacted histories

When compaction shrinks event.messages, Math.min(storedCursor, allMessages.length) sets the cursor to the new tail, so the subsequent slice(cursor) path sees no new messages and skips capture for the entire current turn even though that turn's user message is present in the compacted snapshot. This means compaction cycles can silently drop auto-capture results; on shrink (storedCursor > allMessages.length), the cursor should be rebased (for example reset/rescan) instead of clamping to the end.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from 01aa051 to 5d3775a Compare March 10, 2026 11:54

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 5d3775a052

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/memory-lancedb/index.ts Outdated
// history is re-scanned; the vector-similarity dedup (0.95 threshold)
// prevents already-captured memories from being stored again.
const storedCursor = sessionMsgCursor.get(sessionKey) ?? 0;
const cursor = storedCursor > allMessages.length ? 0 : storedCursor;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Reset cursor when compaction rewrites equal-length histories

The cursor invalidation only handles storedCursor > allMessages.length, but agent_end receives post-compaction snapshots where history can be restructured without a net length drop (the runner explicitly treats compaction as message restructuring). In that case storedCursor === allMessages.length, slice(cursor) is empty, and the turn is skipped even though new user content is present in the rewritten snapshot; with steady-state compaction this can suppress auto-capture for many turns. The cursor logic needs an additional rebasing path for same-length rewrites, not just strict shrink.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from 5d3775a to d44df8d Compare March 10, 2026 12:03

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d44df8d6be

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/memory-lancedb/index.ts Outdated
Comment on lines +689 to +690
const sessionKey = ctx?.sessionKey ?? ctx?.sessionId ?? "";
sessionMsgCursor.delete(sessionKey);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Reset cursor when compaction context is missing

This cursor invalidation relies on ctx.sessionKey/sessionId, but one active compaction path calls runAfterCompaction(..., {}) (src/agents/pi-embedded-subscribe.handlers.compaction.ts:79-85), so this code deletes only the "" entry and leaves the real session cursor stale. After a compaction shrink, agent_end then clamps that stale cursor to the new tail and returns on newMessages.length === 0, which can suppress auto-capture for subsequent turns in that session until history grows past the old index.

Useful? React with 👍 / 👎.

Comment thread extensions/memory-lancedb/index.ts Outdated
// stored) the new messages. Placed inside try so that transient
// failures (e.g. embedding API errors) leave the cursor unchanged,
// allowing the same messages to be retried on the next turn.
sessionMsgCursor.set(sessionKey, allMessages.length);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Make cursor advancement monotonic across async agent_end runs

agent_end hooks are launched fire-and-forget (src/agents/pi-embedded-runner/run/attempt.ts:1968-1990), so two turns for the same session can overlap; if the newer hook finishes first and sets a larger cursor, this later assignment from the older hook can rewind it to a smaller length. That reintroduces repeated rescans/embedding calls for already-processed turns, undermining the performance and dedup goals of this change in busy sessions.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from d44df8d to f74862e Compare March 10, 2026 12:12

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: f74862ea92

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/memory-lancedb/index.ts Outdated
Comment on lines +681 to +682
const prev = sessionMsgCursor.get(sessionKey) ?? 0;
sessionMsgCursor.set(sessionKey, Math.max(prev, allMessages.length));

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Prevent stale cursor restoration after compaction

This monotonic Math.max update can resurrect a pre-compaction cursor when agent_end handlers overlap: hooks are launched fire-and-forget in src/agents/pi-embedded-runner/run/attempt.ts:1968-1990, so an older handler that saw a longer pre-compaction history can finish after after_compaction cleared the cursor and write a larger value back. Once that happens, later turns in the compacted session can hit slice(cursor) === [] and skip auto-capture until history grows past the stale index or another reset/compaction occurs.

Useful? React with 👍 / 👎.

Comment thread extensions/memory-lancedb/index.ts Outdated
if (!event.success || !event.messages || event.messages.length === 0) {
return;
}

const sessionKey = ctx?.sessionKey ?? ctx?.sessionId ?? "";

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Use sessionId-first cursor keys across reset boundaries

Using sessionKey first makes cursor state span /new and /reset boundaries, but reset cleanup is asynchronous fire-and-forget (src/auto-reply/reply/commands-core.ts:99-133), so the next run can arrive before before_reset deletes the old entry. In that window, a stale cursor from the previous session can be larger than the new message history, making slice(cursor) empty and skipping capture for initial post-reset turns.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from f74862e to 64da30e Compare March 10, 2026 12:23

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 64da30eb90

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment thread extensions/memory-lancedb/index.ts Outdated
const sid = ctx?.sessionId ?? ctx?.sessionKey ?? "";
const state = sessionState.get(sid);
if (state) {
sessionState.set(sid, { cursor: 0, epoch: state.epoch + 1 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Delete stale session cursor state on reset

The new before_reset handler is intended to prevent unbounded cursor-map growth, but it currently calls sessionState.set(...) instead of removing the entry. Since auto-capture state is keyed by sessionId, each /new or /reset can leave a dead session entry behind indefinitely; in long-running gateways this causes unbounded memory growth and also increases compaction overhead because the empty-context after_compaction fallback iterates every stored session state. Deleting the session entry on reset avoids both issues.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from 64da30e to 7e97898 Compare March 10, 2026 12:29

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7e9789872d

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +706 to +707
for (const [key, s] of sessionState) {
sessionState.set(key, { cursor: 0, epoch: s.epoch + 1 });

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Stop resetting all session cursors on every compaction

after_compaction currently falls back to iterating and resetting every entry in sessionState, and in the current runner wiring this fallback is always taken because runAfterCompaction is invoked with an empty context (src/agents/pi-embedded-subscribe.handlers.compaction.ts, runAfterCompaction(..., {})). That means a compaction in one chat invalidates cursors for unrelated chats, so their next agent_end rescans full history and redoes embedding/search work; in busy multi-session deployments this undermines the "only new messages" optimization and can re-expose first-3-from-history capture behavior after each compaction.

Useful? React with 👍 / 👎.

Comment on lines +716 to +718
api.on("before_reset", (_event, ctx) => {
const sid = ctx?.sessionId ?? ctx?.sessionKey ?? "";
sessionState.delete(sid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Bump reset epoch before deleting per-session cursor state

Deleting the cursor entry on before_reset without changing epoch allows an in-flight agent_end to recreate stale state for the old session: agent_end snapshots epochAtStart and later writes back when (currentState?.epoch ?? 0) === epochAtStart, which is still true after a plain delete (0 === 0). Because both hooks run fire-and-forget (runAgentEnd in src/agents/pi-embedded-runner/run/attempt.ts and runBeforeReset in src/auto-reply/reply/commands-core.ts), a reset during capture can resurrect dead entries and defeat the cleanup this handler is trying to enforce.

Useful? React with 👍 / 👎.

@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch 2 times, most recently from 2d85780 to 7f612ed Compare March 10, 2026 12:40

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 7f612edbf2

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +726 to +727
if (sid) {
const state = sessionState.get(sid);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P1 Badge Invalidate stale cursor when compaction context is absent

Handle the empty-context compaction path here instead of no-oping on missing sid: handleAutoCompactionEnd currently calls runAfterCompaction(..., {}) in src/agents/pi-embedded-subscribe.handlers.compaction.ts, so this branch is skipped for those compactions and the old per-session cursor remains stale after history is rewritten/shrunk. On the next agent_end, cursor is clamped to allMessages.length, slice(cursor) is empty, and auto-capture returns early; this can suppress memory capture for that session until message count grows past the pre-compaction cursor.

Useful? React with 👍 / 👎.

The agent_end hook receives the full session message history, not just
new messages from the current turn. This caused the auto-capture logic
to re-scan and re-embed every previous user message on each turn,
wasting embedding API calls and risking old messages consuming the
per-turn capture limit (slice 0..3) before new ones are reached.

Track a per-session cursor so only messages added since the last scan
are inspected.

Made-with: Cursor
@as775116191
as775116191 force-pushed the fix/memory-lancedb-skip-processed-messages branch from 7f612ed to 4736602 Compare March 10, 2026 12:48

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: 473660234b

ℹ️ About Codex in GitHub

Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".

Comment on lines +696 to +700
if (!hadState && !currentState) {
// First-ever write for this session
sessionState.set(sid, {
cursor: allMessages.length,
epoch: INITIAL_EPOCH,

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

P2 Badge Block zero-epoch writeback after reset deletes state

When agent_end starts before any cursor state exists, epochAtStart is 0; if /reset runs while this handler is in flight, before_reset deletes the entry but epochNow is still 0, so this branch recreates sessionState for the old session anyway. Because runAgentEnd and before_reset are both fire-and-forget (src/agents/pi-embedded-runner/run/attempt.ts and src/auto-reply/reply/commands-core.ts), this race is reachable and leaks stale per-session entries that are never used again, increasing map size and later compaction-epoch update cost over time.

Useful? React with 👍 / 👎.

@openclaw-barnacle

Copy link
Copy Markdown

This pull request has been automatically marked as stale due to inactivity.
Please add updates or it will be closed.

@openclaw-barnacle openclaw-barnacle Bot added the stale Marked as stale due to inactivity label Apr 27, 2026
@clawsweeper

clawsweeper Bot commented Apr 27, 2026

Copy link
Copy Markdown
Contributor

Codex automated review: keeping this open.

Keep this PR open. Current main still scans the full memory-lancedb agent_end message history and applies toCapture.slice(0, 3) after filtering, so the inefficiency/starvation behavior this PR targets is still present. The PR is not ready to land as-is because it is stale against current live-config hook semantics and needs focused cursor/reset/compaction/retry tests, but it remains a useful implementation candidate, especially with related open issue #71349 active.

Best possible solution:

Keep this PR open as an implementation candidate. Rebase it onto current main, preserve live runtime config semantics, simplify or prove the cursor invalidation model, and add focused memory-lancedb regression tests for duplicate skipping across repeated agent_end calls, retry-after-failure, reset/session cleanup, and compaction or history rewrite handling. Coordinate the final fix with #71349.

What I checked:

  • current_main_still_scans_full_history: The current agent_end handler reads every entry in event.messages, filters all capturable user texts, and stores only toCapture.slice(0, 3). There is no per-session cursor or equivalent new-message boundary in current main. (extensions/memory-lancedb/index.ts:614, aac83e00cfe7)
  • agent_end_payload_contains_session_snapshot: The embedded runner passes messagesSnapshot to runAgentEnd with sessionKey and sessionId in the hook context, supporting the PR premise that the hook can receive accumulated session history rather than just the newest user message. (src/agents/pi-embedded-runner/run/attempt.ts:2951, aac83e00cfe7)
  • existing_tests_do_not_cover_cursor_behavior: The current tests cover basic auto-capture and live config enable/disable behavior, but no inspected test proves old messages are skipped across repeated agent_end calls or validates cursor cleanup/retry behavior around reset and compaction. (extensions/memory-lancedb/index.test.ts:787, aac83e00cfe7)
  • current_hook_contract_has_needed_lifecycle_seams: Plugin hooks include agent_end, before_compaction, after_compaction, before_reset, session_start, and session_end, and docs describe session/compaction hook availability. This means the remaining work can be implemented in the plugin without a missing core API being obvious from the current contract. (src/plugins/hook-types.ts:91, aac83e00cfe7)
  • pr_diff_is_functional_only: The PR patch modifies only extensions/memory-lancedb/index.ts with a cursor/epoch implementation and hook cleanup. The security/supply-chain pass found no CI workflow, dependency, lockfile, package publishing, install script, secrets, downloaded artifact, or unrelated execution-path change in the PR file list. (extensions/memory-lancedb/index.ts:573, 473660234b69)
  • related_issue_keeps_problem_active: Related issue memory-lancedb: autoCapture rarely fires; only 2 entries persisted across many sessions #71349 is open and reports memory-lancedb autoCapture failures in current use. Its latest ClawSweeper review explicitly keeps that issue open and names this PR as a partial implementation candidate rather than a shipped fix.

Remaining risk / open question:

  • Applying the PR as-is could regress current live config behavior unless it is rebased to keep hooks registered and gate behavior through resolveCurrentHookConfig().
  • The proposed cursor state is concurrency-sensitive because agent_end and reset/compaction hooks are fire-and-forget; maintainers need tests for repeated turns, transient embed/store failure, reset cleanup, and compaction/history rewrite behavior.
  • Closing this PR now would drop useful work while current main still has the full-history auto-capture behavior and related issue memory-lancedb: autoCapture rarely fires; only 2 entries persisted across many sessions #71349 remains open.

Codex Review notes: model gpt-5.5, reasoning high; reviewed against aac83e00cfe7.

@vincentkoc

Copy link
Copy Markdown
Member

ProjectClownfish could not safely update this branch, so it opened a narrow replacement PR instead.

Replacement PR: #72663
Source PR: #42083
Contributor credit is preserved in the replacement PR body and changelog plan.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

extensions: memory-lancedb Extension: memory-lancedb size: S stale Marked as stale due to inactivity

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants