pi-ui-extend 0.1.44 → 0.1.45

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -383,11 +383,86 @@ export function resolveIdToBoundary(
383
383
  }
384
384
 
385
385
  const meta = state.messageMetaSnapshot.get(id)
386
- if (meta) return { timestamp: meta.timestamp, stableId: meta.stableId }
386
+ if (meta) return resolveMetaBoundary(meta, field, state)
387
387
 
388
388
  const ts = state.messageIdSnapshot.get(id)
389
- if (ts === undefined) throw unknownCompressionIdError(id, state)
390
- return { timestamp: ts }
389
+ if (ts !== undefined) return { timestamp: ts }
390
+
391
+ // ── Stale mNNN fallback: direction-only clamp ────────────────────────
392
+ // When compression or pruning removes messages between context passes,
393
+ // positional mNNN IDs shift (e.g. an end boundary m145 becomes m123
394
+ // after 22 messages are removed). Clamp to the closest valid ID, but
395
+ // ONLY in a direction that preserves the range's semantics:
396
+ // - start boundary: clamp upward to the first available ID at or after
397
+ // the requested number. The start must never move backwards into
398
+ // older content; if no such ID exists, the requested start is gone
399
+ // and we cannot safely compress — throw.
400
+ // - end boundary: clamp downward to the last available ID at or before
401
+ // the requested number. The end must never move forwards into newer
402
+ // content; if no such ID exists, throw.
403
+ // The previous implementation fell back to the highest available ID in
404
+ // both "no match" cases, which could clamp e.g. m010..m010 over a
405
+ // snapshot of m001..m003 to a single-message block over m003 — silently
406
+ // compressing the wrong content.
407
+ const mMatch = id.match(/^m(\d+)$/i)
408
+ if (mMatch && state.messageIdSnapshot.size > 0) {
409
+ const requestedNum = parseInt(mMatch[1]!, 10)
410
+ const allIds = sortIds([...state.messageIdSnapshot.keys()])
411
+ const allNums = allIds
412
+ .map((mid) => {
413
+ const n = mid.match(/^m(\d+)$/i)
414
+ return n ? { id: mid, num: parseInt(n[1]!, 10) } : null
415
+ })
416
+ .filter((entry): entry is { id: string; num: number } => entry !== null)
417
+ .sort((a, b) => a.num - b.num)
418
+
419
+ if (allNums.length > 0) {
420
+ let clamped: { id: string; num: number } | undefined
421
+ if (field === "startTimestamp") {
422
+ clamped = allNums.find((entry) => entry.num >= requestedNum)
423
+ } else {
424
+ for (let i = allNums.length - 1; i >= 0; i--) {
425
+ if (allNums[i]!.num <= requestedNum) {
426
+ clamped = allNums[i]
427
+ break
428
+ }
429
+ }
430
+ }
431
+ if (clamped) {
432
+ const clampedMeta = state.messageMetaSnapshot.get(clamped.id)
433
+ if (clampedMeta) return resolveMetaBoundary(clampedMeta, field, state)
434
+ const clampedTs = state.messageIdSnapshot.get(clamped.id)
435
+ if (clampedTs !== undefined) return { timestamp: clampedTs }
436
+ }
437
+ }
438
+ }
439
+
440
+ throw unknownCompressionIdError(id, state)
441
+ }
442
+
443
+ /**
444
+ * Resolve a `MessageIdMeta` to a compression boundary. When the meta is a
445
+ * synthetic placeholder for an active compression block (`meta.blockId` is
446
+ * set), resolve to that block's stored boundary so the caller rolls the
447
+ * block up instead of nesting a new block on top of the placeholder. This
448
+ * matters for both exact-match and clamped mNNN IDs: a model-visible mNNN
449
+ * may itself represent a previously compressed section.
450
+ */
451
+ function resolveMetaBoundary(
452
+ meta: MessageIdMeta,
453
+ field: "startTimestamp" | "endTimestamp",
454
+ state: DcpState,
455
+ ): ResolvedCompressionBoundary {
456
+ if (meta.blockId !== undefined) {
457
+ const block = state.compressionBlocks.find((b) => b.id === meta.blockId && b.active)
458
+ if (block) {
459
+ return {
460
+ timestamp: block[field],
461
+ stableId: field === "startTimestamp" ? block.startMessageId : block.endMessageId,
462
+ }
463
+ }
464
+ }
465
+ return { timestamp: meta.timestamp, stableId: meta.stableId }
391
466
  }
392
467
 
393
468
  /**
@@ -13,60 +13,76 @@ function findBoundaryIndex(messages: any[], stableId: string | undefined, timest
13
13
  return messages.findIndex((m, index) => messageMatchesBoundary(m, index, stableId, timestamp));
14
14
  }
15
15
 
16
- function collectToolCallIds(messages: any[]): Set<string> {
17
- const ids = new Set<string>();
18
- for (const msg of messages) {
19
- if (typeof msg?.toolCallId === "string") ids.add(msg.toolCallId);
20
- if (msg?.role !== "assistant" || !Array.isArray(msg.content)) continue;
21
- for (const block of msg.content) {
22
- if (block?.type === "toolCall" && typeof block.id === "string") ids.add(block.id);
23
- }
24
- }
25
- return ids;
26
- }
27
16
 
28
17
  export function syncCompressionBlocks(messages: any[], state: DcpState, config: DcpConfig): void {
29
18
  if (state.compressionBlocks.length === 0) return;
30
19
 
31
- const toolCallIds = collectToolCallIds(messages);
20
+ // Determine the conversation's timestamp range so we can tell whether a
21
+ // block's range is genuinely outside the current session history (which
22
+ // warrants deactivation) vs merely having its boundary messages pruned
23
+ // (which is normal and should NOT cause deactivation).
24
+ let conversationMinTs = Infinity;
25
+ let conversationMaxTs = -Infinity;
26
+ for (const msg of messages) {
27
+ const ts = msg?.timestamp;
28
+ if (typeof ts === "number" && Number.isFinite(ts)) {
29
+ if (ts < conversationMinTs) conversationMinTs = ts;
30
+ if (ts > conversationMaxTs) conversationMaxTs = ts;
31
+ }
32
+ }
32
33
 
33
34
  for (const block of state.compressionBlocks) {
34
35
  if (!block.active || block.deactivatedByUser) continue;
35
36
 
37
+ // ── Skip the missing-origin-compress-call check ────────────────────
38
+ // The compress tool-call that *created* this block is not the block's
39
+ // content — once the block exists the tool-call is irrelevant and will
40
+ // naturally be pruned by tool-output pruning, session compaction, or
41
+ // nested compression. Deactivating a valid block because its creation
42
+ // tool-call was pruned silently loses the compressed summary and forces
43
+ // the original (larger) messages to be re-sent, which also invalidates
44
+ // the provider's prompt prefix cache.
45
+
46
+ // ── Boundary validation ────────────────────────────────────────────
47
+ // Only deactivate when the block's timestamp range falls entirely
48
+ // outside the conversation's time range, meaning the session genuinely
49
+ // does not contain that history (e.g. after a branch switch to a
50
+ // completely different conversation). When boundary messages are merely
51
+ // absent because they were themselves pruned, compressed, or have
52
+ // fragile timestamp-based stable IDs, the block is still valid —
53
+ // applyCompressionBlocks already handles missing boundaries gracefully
54
+ // by skipping splicing when findBoundaryIndex returns -1.
36
55
  if (
37
- typeof block.createdByToolCallId === "string" &&
38
- state.toolCalls.has(block.createdByToolCallId) &&
39
- !toolCallIds.has(block.createdByToolCallId)
56
+ Number.isFinite(block.startTimestamp) &&
57
+ Number.isFinite(block.endTimestamp) &&
58
+ Number.isFinite(conversationMinTs) &&
59
+ Number.isFinite(conversationMaxTs)
40
60
  ) {
41
- block.active = false;
42
- block.deactivatedReason = "missing-origin-compress-call";
43
- writeDcpDebugLog(config, "block.auto_deactivated", {
44
- blockId: `b${block.id}`,
45
- reason: "missing-origin-compress-call",
46
- topic: block.topic,
47
- createdByToolCallId: block.createdByToolCallId,
48
- activeBlocksAfter: state.compressionBlocks.filter((b) => b.active).length,
49
- });
50
- continue;
51
- }
61
+ // Block range is entirely before or entirely after the conversation
62
+ const blockEntirelyOutside =
63
+ block.endTimestamp < conversationMinTs ||
64
+ block.startTimestamp > conversationMaxTs;
52
65
 
53
- const hasStableBoundaries = !!block.startMessageId && !!block.endMessageId;
54
- if (!hasStableBoundaries) continue;
66
+ if (blockEntirelyOutside) {
67
+ // Before deactivating, check if boundary messages can still be
68
+ // found by their stable ID — timestamps can change across
69
+ // session reloads while the underlying message persists.
70
+ const startFound = findBoundaryIndex(messages, block.startMessageId, block.startTimestamp) !== -1;
71
+ const endFound = findBoundaryIndex(messages, block.endMessageId, block.endTimestamp) !== -1;
55
72
 
56
- const startIdx = findBoundaryIndex(messages, block.startMessageId, block.startTimestamp);
57
- const endIdx = findBoundaryIndex(messages, block.endMessageId, block.endTimestamp);
58
- if (startIdx === -1 || endIdx === -1) {
59
- block.active = false;
60
- block.deactivatedReason = "missing-origin-message";
61
- writeDcpDebugLog(config, "block.auto_deactivated", {
62
- blockId: `b${block.id}`,
63
- reason: "missing-origin-message",
64
- topic: block.topic,
65
- missingBoundary: startIdx === -1 ? "start" : "end",
66
- startMessageId: block.startMessageId,
67
- endMessageId: block.endMessageId,
68
- activeBlocksAfter: state.compressionBlocks.filter((b) => b.active).length,
69
- });
73
+ if (!startFound && !endFound) {
74
+ block.active = false;
75
+ block.deactivatedReason = "outside-conversation-range";
76
+ writeDcpDebugLog(config, "block.auto_deactivated", {
77
+ blockId: `b${block.id}`,
78
+ reason: "outside-conversation-range",
79
+ topic: block.topic,
80
+ blockRange: [block.startTimestamp, block.endTimestamp],
81
+ conversationRange: [conversationMinTs, conversationMaxTs],
82
+ activeBlocksAfter: state.compressionBlocks.filter((b) => b.active).length,
83
+ });
84
+ }
85
+ }
70
86
  }
71
87
  }
72
88
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.44",
3
+ "version": "0.1.45",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {