fix(memory-lancedb): skip already-processed messages in auto-capture#42083
fix(memory-lancedb): skip already-processed messages in auto-capture#42083as775116191 wants to merge 1 commit into
Conversation
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| // Advance cursor to current length so next invocation skips these. | ||
| sessionMsgCursor.set(sessionKey, allMessages.length); |
There was a problem hiding this comment.
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 SummaryThis PR fixes a real inefficiency in the LanceDB memory plugin's
The comment fix from "per conversation" → "per turn" is accurate and helpful. Confidence Score: 2/5
Last reviewed commit: 7502d9a |
| sessionMsgCursor.set(sessionKey, allMessages.length); | ||
|
|
||
| // Only inspect messages added since our last scan. | ||
| const newMessages = allMessages.slice(cursor); | ||
| if (newMessages.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| try { |
There was a problem hiding this 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.
| 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.| 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>(); |
There was a problem hiding this 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.
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.7502d9a to
8116286
Compare
There was a problem hiding this comment.
💡 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); |
There was a problem hiding this comment.
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 👍 / 👎.
| @@ -627,7 +640,7 @@ const memoryPlugin = { | |||
| return; | |||
There was a problem hiding this comment.
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 👍 / 👎.
8116286 to
01aa051
Compare
There was a problem hiding this comment.
💡 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".
| const cursor = Math.min( | ||
| sessionMsgCursor.get(sessionKey) ?? 0, | ||
| allMessages.length, | ||
| ); |
There was a problem hiding this comment.
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 👍 / 👎.
01aa051 to
5d3775a
Compare
There was a problem hiding this comment.
💡 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".
| // 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; |
There was a problem hiding this comment.
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 👍 / 👎.
5d3775a to
d44df8d
Compare
There was a problem hiding this comment.
💡 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".
| const sessionKey = ctx?.sessionKey ?? ctx?.sessionId ?? ""; | ||
| sessionMsgCursor.delete(sessionKey); |
There was a problem hiding this comment.
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 👍 / 👎.
| // 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); |
There was a problem hiding this comment.
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 👍 / 👎.
d44df8d to
f74862e
Compare
There was a problem hiding this comment.
💡 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".
| const prev = sessionMsgCursor.get(sessionKey) ?? 0; | ||
| sessionMsgCursor.set(sessionKey, Math.max(prev, allMessages.length)); |
There was a problem hiding this comment.
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 👍 / 👎.
| if (!event.success || !event.messages || event.messages.length === 0) { | ||
| return; | ||
| } | ||
|
|
||
| const sessionKey = ctx?.sessionKey ?? ctx?.sessionId ?? ""; |
There was a problem hiding this comment.
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 👍 / 👎.
f74862e to
64da30e
Compare
There was a problem hiding this comment.
💡 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".
| const sid = ctx?.sessionId ?? ctx?.sessionKey ?? ""; | ||
| const state = sessionState.get(sid); | ||
| if (state) { | ||
| sessionState.set(sid, { cursor: 0, epoch: state.epoch + 1 }); |
There was a problem hiding this comment.
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 👍 / 👎.
64da30e to
7e97898
Compare
There was a problem hiding this comment.
💡 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".
| for (const [key, s] of sessionState) { | ||
| sessionState.set(key, { cursor: 0, epoch: s.epoch + 1 }); |
There was a problem hiding this comment.
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 👍 / 👎.
| api.on("before_reset", (_event, ctx) => { | ||
| const sid = ctx?.sessionId ?? ctx?.sessionKey ?? ""; | ||
| sessionState.delete(sid); |
There was a problem hiding this comment.
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 👍 / 👎.
2d85780 to
7f612ed
Compare
There was a problem hiding this comment.
💡 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".
| if (sid) { | ||
| const state = sessionState.get(sid); |
There was a problem hiding this comment.
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
7f612ed to
4736602
Compare
There was a problem hiding this comment.
💡 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".
| if (!hadState && !currentState) { | ||
| // First-ever write for this session | ||
| sessionState.set(sid, { | ||
| cursor: allMessages.length, | ||
| epoch: INITIAL_EPOCH, |
There was a problem hiding this comment.
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 👍 / 👎.
|
This pull request has been automatically marked as stale due to inactivity. |
|
Codex automated review: keeping this open. Keep this PR open. Current main still scans the full 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 What I checked:
Remaining risk / open question:
Codex Review notes: model gpt-5.5, reasoning high; reviewed against aac83e00cfe7. |
Summary
agent_endhook 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.Map<string, number>) that tracks how many messages have been processed. On eachagent_endinvocation, only scan messages beyond the cursor.Test plan
MEMORY_TRIGGERSregex and confirm it is stored in LanceDB.memory-lancedb: auto-captured).slice(0, 3)limit now correctly applies to new messages only, not to previously captured ones.