From a480e992052512bec2d2e746d1d75d4ad5c1e164 Mon Sep 17 00:00:00 2001 From: roboomp Date: Tue, 21 Jul 2026 20:34:48 +0000 Subject: [PATCH] fix(mnemopi): retained only unretained session turns Persisted the retained user-turn cursor in transcript metadata and restored it when sessions resume, including legacy rows from before the cursor existed. Made forced lifecycle retention slice from that cursor so enqueue and shutdown no longer add cumulative snapshots beside incremental windows. Fixes #6058 --- packages/coding-agent/CHANGELOG.md | 4 + packages/coding-agent/src/mnemopi/state.ts | 73 ++++++++++++++++++- .../coding-agent/test/memory-tools.test.ts | 50 ++++++++++++- 3 files changed, 122 insertions(+), 5 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 6f32dc885..5000d522d 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Fixed + +- Fixed Mnemopi consolidation re-storing cumulative session transcripts after incremental auto-retain, including after resuming a session ([#6058](https://github.com/can1357/oh-my-pi/issues/6058)). + ## [17.0.5] - 2026-07-18 ### Added diff --git a/packages/coding-agent/src/mnemopi/state.ts b/packages/coding-agent/src/mnemopi/state.ts index 70f26f1b9..dd6419983 100644 --- a/packages/coding-agent/src/mnemopi/state.ts +++ b/packages/coding-agent/src/mnemopi/state.ts @@ -158,6 +158,39 @@ export interface MnemopiScopedMemoryHit { type MnemopiRetentionMessage = { role: string; content: string }; +interface MnemopiRetentionCursorRow { + content: string; + sourceId: string | null; + retainedThroughUserTurn: number | null; +} + +function countRetainedUserTurns(transcript: string): number { + let turns = 0; + for (const line of transcript.split(/\r?\n/)) { + if (line === "[role: user]") turns++; + } + return turns; +} + +function deriveRetainedTurnCursor(rows: readonly MnemopiRetentionCursorRow[], sessionId: string): number { + let explicitCursor = 0; + let legacyIncrementalTurns = 0; + let legacySnapshotTurns = 0; + for (const row of rows) { + if (Number.isInteger(row.retainedThroughUserTurn) && row.retainedThroughUserTurn !== null) { + explicitCursor = Math.max(explicitCursor, row.retainedThroughUserTurn); + continue; + } + const turns = countRetainedUserTurns(row.content); + if (row.sourceId === sessionId) { + legacySnapshotTurns = Math.max(legacySnapshotTurns, turns); + } else if (row.sourceId?.startsWith(`${sessionId}-`)) { + legacyIncrementalTurns += turns; + } + } + return Math.max(explicitCursor, legacyIncrementalTurns, legacySnapshotTurns); +} + function sliceUnretainedMessages( messages: MnemopiRetentionMessage[], lastRetainedTurn: number, @@ -208,6 +241,7 @@ export class MnemopiSessionState { hasRecalledForFirstTurn: boolean; lastRecallSnippet?: string; unsubscribe?: () => void; + #retentionCursorLoaded = false; constructor(options: MnemopiSessionStateOptions) { this.sessionId = options.sessionId; @@ -222,11 +256,15 @@ export class MnemopiSessionState { } setSessionId(sessionId: string): void { + if (this.sessionId === sessionId) return; this.sessionId = sessionId; + this.lastRetainedTurn = 0; + this.#retentionCursorLoaded = false; } resetConversationTracking(): void { this.lastRetainedTurn = 0; + this.#retentionCursorLoaded = false; this.hasRecalledForFirstTurn = false; this.lastRecallSnippet = undefined; } @@ -445,11 +483,13 @@ export class MnemopiSessionState { async maybeRetainOnAgentEnd(_messages: AgentMessage[]): Promise { if (!this.config.autoRetain || this.aliasOf) return; const flat = extractMessages(this.session.sessionManager); + this.#restoreRetainedTurnCursor(); const userTurns = flat.filter(message => message.role === "user").length; if (userTurns - this.lastRetainedTurn < this.config.retainEveryNTurns) return; await this.retainMessages( sliceUnretainedMessages(flat, this.lastRetainedTurn), `${this.sessionId}-${Date.now()}`, + { retainedThroughUserTurn: userTurns }, ); this.lastRetainedTurn = userTurns; } @@ -457,14 +497,19 @@ export class MnemopiSessionState { async forceRetainCurrentSession(options: { extract?: boolean } = {}): Promise { if (this.aliasOf) return; const flat = extractMessages(this.session.sessionManager); - await this.retainMessages(flat, this.sessionId, options); - this.lastRetainedTurn = flat.filter(message => message.role === "user").length; + this.#restoreRetainedTurnCursor(); + const userTurns = flat.filter(message => message.role === "user").length; + await this.retainMessages(sliceUnretainedMessages(flat, this.lastRetainedTurn), this.sessionId, { + ...options, + retainedThroughUserTurn: userTurns, + }); + this.lastRetainedTurn = Math.max(this.lastRetainedTurn, userTurns); } async retainMessages( messages: Array<{ role: string; content: string }>, sourceId: string, - options: { extract?: boolean } = {}, + options: { extract?: boolean; retainedThroughUserTurn?: number } = {}, ): Promise { const { transcript, messageCount } = prepareRetentionTranscript(messages, true); if (!transcript) return; @@ -478,6 +523,9 @@ export class MnemopiSessionState { session_id: this.sessionId, source_id: sourceId, message_count: messageCount, + ...(options.retainedThroughUserTurn === undefined + ? {} + : { retained_through_user_turn: options.retainedThroughUserTurn }), cwd: this.session.sessionManager.getCwd(), }, scope: "bank", @@ -490,6 +538,25 @@ export class MnemopiSessionState { }); } + #restoreRetainedTurnCursor(): void { + if (this.#retentionCursorLoaded) return; + this.#retentionCursorLoaded = true; + const rows = this.memory.beam.db + .prepare(` + SELECT + content, + json_extract(metadata_json, '$.source_id') AS sourceId, + CAST(json_extract(metadata_json, '$.retained_through_user_turn') AS INTEGER) + AS retainedThroughUserTurn + FROM working_memory + WHERE source = 'coding-agent-transcript' + AND json_extract(metadata_json, '$.session_id') = ? + ORDER BY rowid + `) + .all(this.sessionId); + this.lastRetainedTurn = Math.max(this.lastRetainedTurn, deriveRetainedTurnCursor(rows, this.sessionId)); + } + attachSessionListeners(): void { this.unsubscribe?.(); this.unsubscribe = this.session.subscribe((event: AgentSessionEvent) => { diff --git a/packages/coding-agent/test/memory-tools.test.ts b/packages/coding-agent/test/memory-tools.test.ts index a6911182d..ac723ad48 100644 --- a/packages/coding-agent/test/memory-tools.test.ts +++ b/packages/coding-agent/test/memory-tools.test.ts @@ -154,6 +154,7 @@ function makeMnemopiConfig( interface RegisterMnemopiStateOptions { cwd?: string; sessionId?: string; + entries?: () => unknown[]; } function registerMnemopiState( @@ -168,7 +169,7 @@ function registerMnemopiState( session: { sessionId, sessionManager: { - getEntries: () => [], + getEntries: options.entries ?? (() => []), getCwd: () => options.cwd ?? "/tmp", } as never, emitNotice: () => {}, @@ -457,9 +458,9 @@ describe("Mnemopi backend lifecycle", () => { })); const state = registerMnemopiState(makeMnemopiConfig({ retainEveryNTurns: 2 }), { cwd: "/work/project-alpha", + entries: () => entries, }); state.lastRetainedTurn = 2; - (state.session.sessionManager as { getEntries: () => unknown[] }).getEntries = () => entries; const retainSpy = vi.spyOn(state, "retainMessages").mockResolvedValue(); await state.maybeRetainOnAgentEnd([{ role: "user", content: [{ type: "text", text: "turn 4" }] }] as never); @@ -472,6 +473,51 @@ describe("Mnemopi backend lifecycle", () => { expect(state.lastRetainedTurn).toBe(4); }); + it("does not re-store retained turns during consolidation or after resume", async () => { + const entries = Array.from({ length: 6 }, (_, index) => ({ + type: "message", + message: { role: "user", content: `turn ${index + 1}` }, + })); + let visibleTurns = 2; + const config = makeMnemopiConfig({ retainEveryNTurns: 2 }); + const state = registerMnemopiState(config, { + cwd: "/work/project-alpha", + entries: () => entries.slice(0, visibleTurns), + }); + + await state.maybeRetainOnAgentEnd([] as never); + visibleTurns = 4; + await state.maybeRetainOnAgentEnd([] as never); + await state.forceRetainCurrentSession(); + await state.dispose({ consolidate: false }); + + visibleTurns = 6; + const resumed = registerMnemopiState(config, { + cwd: "/work/project-alpha", + entries: () => entries.slice(0, visibleTurns), + }); + await resumed.forceRetainCurrentSession(); + + const rows = resumed.memory.beam.db + .prepare<{ content: string; retainedThroughUserTurn: number }, [string]>(` + SELECT + content, + CAST(json_extract(metadata_json, '$.retained_through_user_turn') AS INTEGER) + AS retainedThroughUserTurn + FROM working_memory + WHERE source = 'coding-agent-transcript' + AND json_extract(metadata_json, '$.session_id') = ? + ORDER BY rowid + `) + .all(TEST_SESSION_ID); + expect(rows.map(row => row.content.match(/turn \d+/g))).toEqual([ + ["turn 1", "turn 2"], + ["turn 3", "turn 4"], + ["turn 5", "turn 6"], + ]); + expect(rows.map(row => row.retainedThroughUserTurn)).toEqual([2, 4, 6]); + }); + it("retains the full transcript but extracts and embeds clean projections", async () => { const state = registerMnemopiState(makeMnemopiConfig(), { cwd: "/work/project-alpha" }); const rememberSpy = vi.spyOn(state, "rememberInScope").mockReturnValue("memory-id");