mirror of
https://github.com/teamchong/pxpipe.git
synced 2026-07-22 02:02:51 +02:00
fix(history): anchor collapsed history recency
This commit is contained in:
+41
-1
@@ -24,10 +24,12 @@ import { bytesToBase64 } from './png.js';
|
|||||||
* emitter (here) and the matcher (transform.ts) must reference this constant.
|
* emitter (here) and the matcher (transform.ts) must reference this constant.
|
||||||
*/
|
*/
|
||||||
export const HISTORY_SYNTHETIC_INTRO =
|
export const HISTORY_SYNTHETIC_INTRO =
|
||||||
'[Earlier turns of THIS conversation, transcribed in the image(s) below. Each turn is wrapped in <user t="N">...</user> or <assistant t="N">...</assistant> 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 <user t="N">...</user> or <assistant t="N">...</assistant> 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 =
|
export const HISTORY_SYNTHETIC_OUTRO =
|
||||||
'[End of earlier conversation. The current request is the live text that follows below.]';
|
'[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.
|
/** 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
|
* IMPORTANT: pass the full string, not text.length — the row-aware path in
|
||||||
* isCompressionProfitable must see actual newlines to budget images correctly.
|
* 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') };
|
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: <user t="${i}">${preview}</user>. 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.
|
* 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).
|
* 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';
|
info.reason = 'render_empty';
|
||||||
return { messages, info };
|
return { messages, info };
|
||||||
}
|
}
|
||||||
|
const latestUserPointer = latestCollapsedUserPointer(messages, collapseLen, protectedPrefix);
|
||||||
const syntheticContent: ContentBlock[] = [
|
const syntheticContent: ContentBlock[] = [
|
||||||
{ type: 'text', text: HISTORY_SYNTHETIC_INTRO },
|
{ type: 'text', text: HISTORY_SYNTHETIC_INTRO },
|
||||||
...imageBlocks,
|
...imageBlocks,
|
||||||
|
...(latestUserPointer ? [latestUserPointer] : []),
|
||||||
{ type: 'text', text: HISTORY_SYNTHETIC_OUTRO },
|
{ type: 'text', text: HISTORY_SYNTHETIC_OUTRO },
|
||||||
];
|
];
|
||||||
const syntheticUser: Message = {
|
const syntheticUser: Message = {
|
||||||
|
|||||||
+36
-4
@@ -10,7 +10,7 @@
|
|||||||
* images.
|
* images.
|
||||||
* - `messagesToHistoryText`: `--- role ---` framing, skips empty turns.
|
* - `messagesToHistoryText`: `--- role ---` framing, skips empty turns.
|
||||||
* - `collapseHistory`: full pipeline — opts handling, break-even gate,
|
* - `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
|
* - `transformRequest` end-to-end: compressHistory off (default), on with
|
||||||
* enough turns to fire, off when no closed prefix exists.
|
* enough turns to fire, off when no closed prefix exists.
|
||||||
*/
|
*/
|
||||||
@@ -315,7 +315,37 @@ describe('collapseHistory', () => {
|
|||||||
expect(out[2]).toBe(msgs[11]);
|
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<Record<string, unknown>>;
|
||||||
|
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('<user t="10">');
|
||||||
|
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(
|
const body = Array.from(
|
||||||
{ length: 180 },
|
{ length: 180 },
|
||||||
(_, i) => `line ${i}: ${'x'.repeat(80)}`,
|
(_, i) => `line ${i}: ${'x'.repeat(80)}`,
|
||||||
@@ -334,9 +364,11 @@ describe('collapseHistory', () => {
|
|||||||
);
|
);
|
||||||
const content = out[0]!.content as Array<Record<string, unknown>>;
|
const content = out[0]!.content as Array<Record<string, unknown>>;
|
||||||
const textBlocks = content.filter((c) => c.type === 'text');
|
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[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);
|
expect(content.filter((c) => c.type === 'image')).toHaveLength(info.collapsedImages);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user