Merge PR #6202: fix(mnemopi): retain only unretained turns on consolidation (@roboomp)

This commit is contained in:
can1357
2026-07-22 21:13:16 +02:00
3 changed files with 119 additions and 5 deletions
+1
View File
@@ -157,6 +157,7 @@
### Fixed
- Fixed the single 30s Hindsight client timeout aborting healthy `reflect` calls, which are agentic retrieve+synthesize ops that routinely exceed 30s; each op now has its own deadline (reflect defaults to 120s) and the timeout error reports the effective seconds ([#6125](https://github.com/can1357/oh-my-pi/issues/6125)).
- 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
+70 -3
View File
@@ -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<void> {
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<void> {
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<void> {
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<MnemopiRetentionCursorRow, [string]>(`
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) => {
@@ -158,6 +158,7 @@ function makeMnemopiConfig(
interface RegisterMnemopiStateOptions {
cwd?: string;
sessionId?: string;
entries?: () => unknown[];
}
function registerMnemopiState(
@@ -172,7 +173,7 @@ function registerMnemopiState(
session: {
sessionId,
sessionManager: {
getEntries: () => [],
getEntries: options.entries ?? (() => []),
getCwd: () => options.cwd ?? "/tmp",
} as never,
emitNotice: () => {},
@@ -461,9 +462,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);
@@ -476,6 +477,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");