mirror of
https://github.com/wassname/pi-dynamic-context-pruning.git
synced 2026-06-27 16:46:12 +08:00
Merge pull request #3 from wassname/main
fix: prevent Infinity anchorTimestamp ghost block spiral Thanks @wassname, I'm going to implement some more fixes before releasing this as well.
This commit is contained in:
+29
-6
@@ -65,17 +65,20 @@ function resolveIdToTimestamp(
|
||||
* Determine the anchor timestamp for a compression block — the timestamp of
|
||||
* the first raw message that appears strictly after `endTimestamp`.
|
||||
*
|
||||
* Returns `Infinity` when the range extends to the very end of the visible
|
||||
* conversation (nothing comes after it).
|
||||
* Returns `endTimestamp + 1` when the range extends to the very end of the
|
||||
* visible conversation (nothing comes after it). We never use Infinity because
|
||||
* it corrupts JSON serialization (becomes null) and breaks numeric comparisons.
|
||||
*/
|
||||
function resolveAnchorTimestamp(endTimestamp: number, state: DcpState): number {
|
||||
let anchor = Infinity
|
||||
let anchor: number | null = null
|
||||
for (const ts of state.messageIdSnapshot.values()) {
|
||||
if (ts > endTimestamp && ts < anchor) {
|
||||
if (ts > endTimestamp && (anchor === null || ts < anchor)) {
|
||||
anchor = ts
|
||||
}
|
||||
}
|
||||
return anchor
|
||||
// Fall back to endTimestamp + 1 instead of Infinity to avoid JSON
|
||||
// serialization corruption (Infinity → null) and comparison breakage.
|
||||
return anchor ?? endTimestamp + 1
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -132,9 +135,27 @@ export function registerCompressTool(
|
||||
)
|
||||
}
|
||||
|
||||
// ── Validate timestamps are finite ──────────────────────────────
|
||||
if (!Number.isFinite(startTimestamp)) {
|
||||
throw new Error(
|
||||
`Start ID "${startId}" resolved to a non-finite timestamp (${startTimestamp}). ` +
|
||||
`This usually means the referenced message has a corrupted timestamp.`,
|
||||
)
|
||||
}
|
||||
if (!Number.isFinite(endTimestamp)) {
|
||||
throw new Error(
|
||||
`End ID "${endId}" resolved to a non-finite timestamp (${endTimestamp}). ` +
|
||||
`This usually means the referenced message has a corrupted timestamp.`,
|
||||
)
|
||||
}
|
||||
|
||||
// ── Overlap check against existing active blocks ─────────────────
|
||||
for (const existing of state.compressionBlocks) {
|
||||
if (!existing.active) continue
|
||||
// Skip blocks with corrupted timestamps
|
||||
if (!Number.isFinite(existing.startTimestamp) || !Number.isFinite(existing.endTimestamp)) {
|
||||
continue
|
||||
}
|
||||
const overlaps =
|
||||
startTimestamp <= existing.endTimestamp &&
|
||||
existing.startTimestamp <= endTimestamp
|
||||
@@ -142,7 +163,9 @@ export function registerCompressTool(
|
||||
throw new Error(
|
||||
`Overlapping compression ranges are not supported. ` +
|
||||
`New range (${startId}..${endId}) overlaps existing block ` +
|
||||
`b${existing.id} "${existing.topic}"`,
|
||||
`b${existing.id} "${existing.topic}" ` +
|
||||
`(b${existing.id} covers ${existing.startTimestamp}..${existing.endTimestamp}, ` +
|
||||
`new range covers ${startTimestamp}..${endTimestamp})`,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -81,7 +81,16 @@ export default function (pi: ExtensionAPI) {
|
||||
const data = entry.data as any
|
||||
|
||||
if (data?.compressionBlocks) {
|
||||
state.compressionBlocks = data.compressionBlocks
|
||||
// Filter out blocks with corrupted (null/NaN/Infinity) timestamps —
|
||||
// these were caused by Infinity anchorTimestamp values that became
|
||||
// null after JSON round-trip.
|
||||
const validBlocks = data.compressionBlocks.filter(
|
||||
(b: any) =>
|
||||
Number.isFinite(b.startTimestamp) &&
|
||||
Number.isFinite(b.endTimestamp) &&
|
||||
Number.isFinite(b.anchorTimestamp),
|
||||
)
|
||||
state.compressionBlocks = validBlocks
|
||||
state.nextBlockId = data.nextBlockId ?? state.compressionBlocks.length
|
||||
state.tokensSaved = data.tokensSaved ?? 0
|
||||
state.totalPruneCount = data.totalPruneCount ?? 0
|
||||
|
||||
Generated
+3796
-4
File diff suppressed because it is too large
Load Diff
+3
-3
@@ -13,7 +13,7 @@ You operate in a context-constrained environment. Manage context continuously to
|
||||
|
||||
The ONLY tool you have for context management is \`compress\`. It replaces older conversation content with technical summaries you produce.
|
||||
|
||||
\`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
|
||||
\`<dcp-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
|
||||
|
||||
THE PHILOSOPHY OF COMPRESS
|
||||
\`compress\` transforms conversation content into dense, high-fidelity summaries. This is not cleanup — it is crystallization. Your summary becomes the authoritative record of what transpired.
|
||||
@@ -114,7 +114,7 @@ You specify boundaries by ID using the injected IDs visible in the conversation:
|
||||
- \`mNNN\` IDs identify raw messages (3 digits, zero-padded, e.g. \`m001\`, \`m042\`)
|
||||
- \`bN\` IDs identify previously compressed blocks
|
||||
|
||||
Each message has an ID inside XML metadata tags like \`<dcp-message-id>...</dcp-message-id>\`.
|
||||
Each message has an ID inside XML metadata tags like \`<dcp-id>...</dcp-id>\`.
|
||||
The ID tag appears at the end of the message it belongs to — it identifies the message above it, not the one below it.
|
||||
Treat these tags as boundary metadata only, not as tool result content.
|
||||
|
||||
@@ -207,7 +207,7 @@ Prefer multiple short, closed ranges over one large range when several independe
|
||||
export const MANUAL_MODE_SYSTEM_PROMPT = `
|
||||
You are operating in DCP manual mode for context management.
|
||||
|
||||
\`<dcp-message-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
|
||||
\`<dcp-id>\` and \`<dcp-system-reminder>\` tags are environment-injected metadata. Do not output them.
|
||||
|
||||
In manual mode you do NOT proactively compress conversation content. Compression is a deliberate, user-directed action.
|
||||
|
||||
|
||||
@@ -708,4 +708,52 @@ function findOrphanedToolUse(result: any[]): string | null {
|
||||
console.log("TEST 9 PASSED\n");
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Test 10 — CORRUPTED BLOCK WITH NULL/INFINITY TIMESTAMPS (resilience)
|
||||
//
|
||||
// Blocks from older sessions may have null/Infinity timestamps due to JSON
|
||||
// round-trip corruption. These blocks should be skipped during compression
|
||||
// application and should not block new compress operations.
|
||||
// ---------------------------------------------------------------------------
|
||||
{
|
||||
console.log("TEST 10: corrupted block with null/Infinity timestamps is skipped");
|
||||
|
||||
const messages: any[] = [
|
||||
{ role: "user", content: [{ type: "text", text: "hello" }], timestamp: 1000 },
|
||||
{ role: "assistant", content: [{ type: "text", text: "hi" }], timestamp: 2000 },
|
||||
{ role: "user", content: [{ type: "text", text: "bye" }], timestamp: 3000 },
|
||||
];
|
||||
|
||||
// Block with corrupted timestamps (null from JSON round-trip)
|
||||
const state = makeState([
|
||||
{
|
||||
id: 1,
|
||||
topic: "ghost block",
|
||||
summary: "This block has corrupted timestamps.",
|
||||
startTimestamp: null as any, // null from JSON deserialization of Infinity
|
||||
endTimestamp: null as any,
|
||||
anchorTimestamp: null as any,
|
||||
active: true,
|
||||
summaryTokenEstimate: 5,
|
||||
createdAt: Date.now(),
|
||||
},
|
||||
]);
|
||||
|
||||
const result = applyPruning(messages, state, makeConfig());
|
||||
|
||||
console.log(" Result messages:");
|
||||
for (const m of result) {
|
||||
const preview = Array.isArray(m.content)
|
||||
? m.content.map((b: any) => b.text ?? b.type ?? "?").join(" | ").slice(0, 60)
|
||||
: String(m.content).slice(0, 60);
|
||||
console.log(` role="${m.role}" ts=${m.timestamp} content="${preview}"`);
|
||||
}
|
||||
|
||||
// All 3 original messages should survive (ghost block was skipped)
|
||||
assert.strictEqual(result.length, 3, `FAIL — expected 3 messages, got ${result.length}`);
|
||||
console.log(" PASS: corrupted block skipped, all original messages preserved");
|
||||
|
||||
console.log("TEST 10 PASSED\n");
|
||||
}
|
||||
|
||||
console.log("All tests passed.");
|
||||
|
||||
@@ -48,6 +48,9 @@ function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
|
||||
if (activeBlocks.length === 0) return messages;
|
||||
|
||||
for (const block of activeBlocks) {
|
||||
// Skip blocks with corrupted timestamps (from pre-fix sessions)
|
||||
if (!Number.isFinite(block.startTimestamp) || !Number.isFinite(block.endTimestamp)) continue;
|
||||
|
||||
// Find start and end indices by timestamp
|
||||
const startIdx = messages.findIndex((m) => m.timestamp === block.startTimestamp);
|
||||
const endIdx = messages.findIndex((m) => m.timestamp === block.endTimestamp);
|
||||
@@ -153,7 +156,10 @@ function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
|
||||
"</dcp-block-id>",
|
||||
},
|
||||
],
|
||||
timestamp: block.anchorTimestamp - 0.5,
|
||||
// anchorTimestamp is always finite (resolveAnchorTimestamp returns
|
||||
// endTimestamp + 1 instead of Infinity), but guard against corrupted
|
||||
// state from older sessions where Infinity/null could leak in.
|
||||
timestamp: Number.isFinite(block.anchorTimestamp) ? block.anchorTimestamp - 0.5 : block.endTimestamp + 0.5,
|
||||
};
|
||||
|
||||
// Estimate tokens added by the summary
|
||||
|
||||
Reference in New Issue
Block a user