diff --git a/src/core/history.ts b/src/core/history.ts
index 5cb61c7..76550a9 100644
--- a/src/core/history.ts
+++ b/src/core/history.ts
@@ -24,10 +24,12 @@ import { bytesToBase64 } from './png.js';
* emitter (here) and the matcher (transform.ts) must reference this constant.
*/
export const HISTORY_SYNTHETIC_INTRO =
- '[Earlier turns of THIS conversation, transcribed in the image(s) below. Each turn is wrapped in ... or ... tags, where N is an absolute turn index (larger N = more recent); attribute every turn strictly by its tag, and treat the highest-N turns as the most recent prior context, NOT the low-N opening turns. This is prior context, NOT the current request.]';
+ '[Earlier turns of THIS conversation, transcribed in the image(s) below. Each turn is wrapped in ... or ... tags, where N is an absolute turn index (larger N = more recent); attribute every turn strictly by its tag, and treat the highest-N turns as the most recent prior context, NOT the low-N opening turns. Earlier turns may contain questions or tasks that were already answered later in this same history; do not reopen low-N turns unless the live text after this block asks you to. This is prior context, NOT the current request.]';
export const HISTORY_SYNTHETIC_OUTRO =
'[End of earlier conversation. The current request is the live text that follows below.]';
+const LATEST_COLLAPSED_USER_PREVIEW_CHARS = 300;
+
/** Break-even gate predicate. Injected by transform.ts to avoid a circular import.
* IMPORTANT: pass the full string, not text.length — the row-aware path in
* isCompressionProfitable must see actual newlines to budget images correctly.
@@ -271,6 +273,42 @@ export function messagesToHistorySegments(
return { text: textOut.join('\n\n'), slotText: slotOut.join('\n\n') };
}
+function userPromptText(content: string | ContentBlock[]): string {
+ if (typeof content === 'string') return content;
+ const parts: string[] = [];
+ for (const blk of content) {
+ if (!blk || typeof blk !== 'object') continue;
+ const t = (blk as { type?: string }).type;
+ if (t === 'text') parts.push((blk as TextBlock).text);
+ else if (t === 'image') parts.push('[image]');
+ }
+ return parts.join('\n\n');
+}
+
+function compactPreview(text: string): string {
+ const compact = text.replace(/\s+/g, ' ').trim();
+ if (compact.length <= LATEST_COLLAPSED_USER_PREVIEW_CHARS) return compact;
+ return compact.slice(0, LATEST_COLLAPSED_USER_PREVIEW_CHARS).trimEnd() + '...';
+}
+
+function latestCollapsedUserPointer(
+ messages: Message[],
+ upToExclusive: number,
+ fromInclusive: number,
+): TextBlock | undefined {
+ for (let i = upToExclusive - 1; i >= fromInclusive; i--) {
+ const m = messages[i]!;
+ if (m.role !== 'user') continue;
+ const preview = compactPreview(userPromptText(m.content));
+ if (!preview) continue;
+ return {
+ type: 'text',
+ text: `[Most recent collapsed user turn: ${preview}. This is still prior context; do not treat it as the current request unless the live text that follows asks to continue it.]`,
+ };
+ }
+ return undefined;
+}
+
/**
* Collapse the closed-prefix run into one synthetic user message with 1+ history images.
* Returns original messages unchanged on any no-collapse path (reason set in info).
@@ -440,9 +478,11 @@ export async function collapseHistory(
info.reason = 'render_empty';
return { messages, info };
}
+ const latestUserPointer = latestCollapsedUserPointer(messages, collapseLen, protectedPrefix);
const syntheticContent: ContentBlock[] = [
{ type: 'text', text: HISTORY_SYNTHETIC_INTRO },
...imageBlocks,
+ ...(latestUserPointer ? [latestUserPointer] : []),
{ type: 'text', text: HISTORY_SYNTHETIC_OUTRO },
];
const syntheticUser: Message = {
diff --git a/tests/history.test.ts b/tests/history.test.ts
index 62d2aff..d27a7ec 100644
--- a/tests/history.test.ts
+++ b/tests/history.test.ts
@@ -10,7 +10,7 @@
* images.
* - `messagesToHistoryText`: `--- role ---` framing, skips empty turns.
* - `collapseHistory`: full pipeline — opts handling, break-even gate,
- * synthetic user message shape (text+image+text), live-tail preservation.
+ * synthetic user message shape (text+image+recency pointer+text), live-tail preservation.
* - `transformRequest` end-to-end: compressHistory off (default), on with
* enough turns to fire, off when no closed prefix exists.
*/
@@ -315,7 +315,37 @@ describe('collapseHistory', () => {
expect(out[2]).toBe(msgs[11]);
});
- it('splits dense collapsed history into readable image pages without plaintext side channels', async () => {
+ it('adds live-text recency guardrails for collapsed history', async () => {
+ const oldMarker = 'OLD WORKTREE QUESTION';
+ const latestMarker = 'CURRENT FRONTIER IR REUSE';
+ const msgs: Message[] = [];
+ for (let i = 0; i < 14; i++) {
+ const marker = i === 0 ? oldMarker : i === 10 ? latestMarker : `turn ${i}`;
+ const body = `${marker}: ` + 'x'.repeat(2800);
+ msgs.push(i % 2 === 0 ? usr(body) : asst(body));
+ }
+
+ const { messages: out, info } = await collapseHistory(msgs, profitable, {
+ keepTail: 2,
+ minCollapsePrefix: 5,
+ cols: 100,
+ collapseChunk: 0,
+ });
+
+ expect(info.reason).toBe(undefined);
+ expect(info.collapsedTurns).toBe(12);
+ const content = out[0]!.content as Array>;
+ const textBlocks = content.filter((c) => c.type === 'text') as Array<{ text: string }>;
+ expect(textBlocks).toHaveLength(3);
+ expect(textBlocks[0]!.text).toContain('do not reopen low-N turns');
+ expect(textBlocks[1]!.text).toContain('Most recent collapsed user turn');
+ expect(textBlocks[1]!.text).toContain('');
+ expect(textBlocks[1]!.text).toContain(latestMarker);
+ expect(textBlocks[1]!.text).not.toContain(oldMarker);
+ expect(textBlocks[2]!.text).toContain('current request is the live text');
+ });
+
+ it('splits dense collapsed history into readable image pages with only a bounded recency pointer', async () => {
const body = Array.from(
{ length: 180 },
(_, i) => `line ${i}: ${'x'.repeat(80)}`,
@@ -334,9 +364,11 @@ describe('collapseHistory', () => {
);
const content = out[0]!.content as Array>;
const textBlocks = content.filter((c) => c.type === 'text');
- expect(textBlocks).toHaveLength(2);
+ expect(textBlocks).toHaveLength(3);
expect((textBlocks[0] as { text: string }).text).toContain('attribute every turn strictly by its tag');
- expect((textBlocks[1] as { text: string }).text).toContain('current request is the live text');
+ expect((textBlocks[1] as { text: string }).text).toContain('Most recent collapsed user turn');
+ expect(((textBlocks[1] as { text: string }).text).length).toBeLessThan(500);
+ expect((textBlocks[2] as { text: string }).text).toContain('current request is the live text');
expect(content.filter((c) => c.type === 'image')).toHaveLength(info.collapsedImages);
});