Merge PR #6202: fix(mnemopi): retain only unretained turns on consolidation (@roboomp)
This commit is contained in:
@@ -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
|
||||
|
||||
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user