pi-ui-extend 1.0.39 → 1.0.41

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.
Files changed (113) hide show
  1. package/README.md +1 -1
  2. package/dist/app/commands/command-registry.js +2 -2
  3. package/dist/app/commands/command-session-actions.d.ts +0 -1
  4. package/dist/app/commands/command-session-actions.js +22 -13
  5. package/dist/app/icons.d.ts +14 -0
  6. package/dist/app/icons.js +33 -0
  7. package/dist/app/rendering/conversation-tool-renderer.js +2 -2
  8. package/dist/app/rendering/dcp-stats.d.ts +6 -1
  9. package/dist/app/rendering/dcp-stats.js +214 -46
  10. package/dist/app/rendering/editor-panels.js +8 -5
  11. package/dist/app/session/lazy-session-manager.js +12 -1
  12. package/dist/app/session/tabs-controller.d.ts +2 -5
  13. package/dist/app/session/tabs-controller.js +12 -21
  14. package/dist/app/subagents/subagents-model.d.ts +14 -1
  15. package/dist/app/subagents/subagents-model.js +34 -15
  16. package/dist/app/types.d.ts +2 -0
  17. package/dist/bundled-extensions/session-title/config.js +1 -1
  18. package/dist/markdown-format.js +27 -9
  19. package/dist/schemas/pi-tools-suite-schema.d.ts +29 -16
  20. package/dist/schemas/pi-tools-suite-schema.js +46 -31
  21. package/external/pi-tools-suite/README.md +392 -52
  22. package/external/pi-tools-suite/docs/browser-qa-subagent.md +31 -21
  23. package/external/pi-tools-suite/docs/context-gateway-p00-adr.md +216 -0
  24. package/external/pi-tools-suite/docs/context-gateway-p01n-gate-review.md +122 -0
  25. package/external/pi-tools-suite/docs/context-gateway-p01n-measurement.md +133 -0
  26. package/external/pi-tools-suite/docs/context-gateway-p01r-ra-evidence.md +111 -0
  27. package/external/pi-tools-suite/docs/context-gateway-p01r-rb-evidence.md +100 -0
  28. package/external/pi-tools-suite/docs/context-gateway-p01r-rc-evidence.md +69 -0
  29. package/external/pi-tools-suite/docs/context-gateway-p01r-rd-evidence.md +100 -0
  30. package/external/pi-tools-suite/docs/context-gateway-p01r-re-evidence.md +74 -0
  31. package/external/pi-tools-suite/docs/context-gateway-p01r-rf-evidence.md +153 -0
  32. package/external/pi-tools-suite/docs/context-gateway-p01r-rg-evidence.md +235 -0
  33. package/external/pi-tools-suite/docs/evals.md +684 -0
  34. package/external/pi-tools-suite/docs/subagent-model-pools.md +109 -0
  35. package/external/pi-tools-suite/package.json +10 -3
  36. package/external/pi-tools-suite/src/async-subagents/{private-skills → agents}/browser-qa/scripts/browser-qa-runner.mjs +82 -1
  37. package/external/pi-tools-suite/src/async-subagents/{private-skills/browser-qa/SKILL.md → agents/browser-qa.md} +261 -12
  38. package/external/pi-tools-suite/src/async-subagents/agents/implement.md +20 -0
  39. package/external/pi-tools-suite/src/async-subagents/agents/oracle.md +16 -0
  40. package/external/pi-tools-suite/src/async-subagents/agents/research.md +18 -0
  41. package/external/pi-tools-suite/src/async-subagents/agents/verify.md +18 -0
  42. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +27 -243
  43. package/external/pi-tools-suite/src/async-subagents/commands.ts +6 -2
  44. package/external/pi-tools-suite/src/async-subagents/core/agent-catalog.ts +41 -0
  45. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +13 -93
  46. package/external/pi-tools-suite/src/async-subagents/core/agents-dir.ts +494 -0
  47. package/external/pi-tools-suite/src/async-subagents/core/browser-qa.ts +9 -0
  48. package/external/pi-tools-suite/src/async-subagents/core/config.ts +200 -143
  49. package/external/pi-tools-suite/src/async-subagents/core/model-fallback.ts +1 -1
  50. package/external/pi-tools-suite/src/async-subagents/core/model-selection.ts +54 -0
  51. package/external/pi-tools-suite/src/async-subagents/core/prompt.ts +7 -6
  52. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +52 -45
  53. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +12 -4
  54. package/external/pi-tools-suite/src/async-subagents/index.ts +11 -1
  55. package/external/pi-tools-suite/src/async-subagents/lib.ts +6 -2
  56. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +46 -18
  57. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +3 -2
  58. package/external/pi-tools-suite/src/async-subagents/types.ts +2 -0
  59. package/external/pi-tools-suite/src/coding-discipline/index.ts +41 -142
  60. package/external/pi-tools-suite/src/config.ts +1 -22
  61. package/external/pi-tools-suite/src/context-gateway/accounting.ts +151 -0
  62. package/external/pi-tools-suite/src/context-gateway/config.ts +111 -0
  63. package/external/pi-tools-suite/src/context-gateway/index.ts +160 -0
  64. package/external/pi-tools-suite/src/context-gateway/metadata-normalization.ts +88 -0
  65. package/external/pi-tools-suite/src/context-gateway/storeless-capabilities.ts +89 -0
  66. package/external/pi-tools-suite/src/context-gateway/telemetry.ts +429 -0
  67. package/external/pi-tools-suite/src/context-gateway/test-output-parser.ts +326 -0
  68. package/external/pi-tools-suite/src/context-gateway/types.ts +152 -0
  69. package/external/pi-tools-suite/src/dcp/auto-compress-budget.ts +106 -0
  70. package/external/pi-tools-suite/src/dcp/auto-compress.ts +810 -106
  71. package/external/pi-tools-suite/src/dcp/commands.ts +64 -139
  72. package/external/pi-tools-suite/src/dcp/compress-tool.ts +369 -35
  73. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +510 -64
  74. package/external/pi-tools-suite/src/dcp/compression-preview.ts +113 -0
  75. package/external/pi-tools-suite/src/dcp/compression-progress.ts +70 -0
  76. package/external/pi-tools-suite/src/dcp/config.ts +36 -61
  77. package/external/pi-tools-suite/src/dcp/conversation-index.ts +421 -0
  78. package/external/pi-tools-suite/src/dcp/debug-log.ts +7 -5
  79. package/external/pi-tools-suite/src/dcp/index.ts +617 -203
  80. package/external/pi-tools-suite/src/dcp/journal.ts +566 -0
  81. package/external/pi-tools-suite/src/dcp/progress-controller.ts +244 -0
  82. package/external/pi-tools-suite/src/dcp/prompts.ts +10 -7
  83. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +189 -0
  84. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +298 -78
  85. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +173 -281
  86. package/external/pi-tools-suite/src/dcp/pruner-emergency.ts +2 -4
  87. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +17 -5
  88. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +11 -1
  89. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +30 -82
  90. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +22 -133
  91. package/external/pi-tools-suite/src/dcp/pruner.ts +18 -33
  92. package/external/pi-tools-suite/src/dcp/recovery.ts +129 -0
  93. package/external/pi-tools-suite/src/dcp/shadow-plan.ts +127 -0
  94. package/external/pi-tools-suite/src/dcp/state-transaction.ts +102 -0
  95. package/external/pi-tools-suite/src/dcp/state.ts +158 -580
  96. package/external/pi-tools-suite/src/dcp/ui.ts +1 -0
  97. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +55 -220
  98. package/external/pi-tools-suite/src/index.ts +9 -0
  99. package/external/pi-tools-suite/src/model-tools/index.ts +76 -42
  100. package/external/pi-tools-suite/src/repo-discovery/index.ts +84 -18
  101. package/external/pi-tools-suite/src/repo-discovery/native-compact.ts +458 -0
  102. package/external/pi-tools-suite/src/session-recovery/index.ts +189 -43
  103. package/external/pi-tools-suite/src/tool-descriptions.ts +43 -38
  104. package/external/pi-tools-suite/src/truncation-metadata-normalizer/index.ts +17 -0
  105. package/package.json +6 -6
  106. package/schemas/pi-tools-suite.json +159 -78
  107. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/auth-scaffold-spec.md +0 -78
  108. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +0 -223
  109. package/external/pi-tools-suite/src/dcp/state-persistence.ts +0 -195
  110. /package/external/pi-tools-suite/src/async-subagents/{private-skills/browser-qa/references → agents/browser-qa/examples}/qa-auth.example.jsonc +0 -0
  111. /package/external/pi-tools-suite/src/async-subagents/{private-skills/browser-qa/references → agents/browser-qa/examples}/qa-flow.example.jsonc +0 -0
  112. /package/external/pi-tools-suite/src/async-subagents/{private-skills → agents}/browser-qa/vendor/fflate.LICENSE +0 -0
  113. /package/external/pi-tools-suite/src/async-subagents/{private-skills → agents}/browser-qa/vendor/fflate.mjs +0 -0
@@ -1,107 +1,55 @@
1
1
  import type { DcpConfig } from "./config.js";
2
- import type { DcpState } from "./state.js";
3
- import { PASSTHROUGH_ROLES, estimateMessageTokens } from "./pruner-metadata.js";
2
+ import { hasExactCompressionMembership, type CompressionBlock, type CompressionMember, type DcpState } from "./state.js";
3
+ import { estimateMessageTokens } from "./pruner-metadata.js";
4
4
  import { stableMessageKeys } from "./pruner-message-ids.js";
5
5
  import { writeDcpDebugLog } from "./debug-log.js";
6
+ import { canonicalMessageHash, rawMutationHashOf } from "./conversation-index.js";
6
7
 
7
8
  function findBoundaryIndex(messages: any[], stableId: string | undefined, timestamp: number): number {
8
- const stableKeys = stableId ? stableMessageKeys(messages) : [];
9
- return messages.findIndex((message, index) => {
10
- if (stableId && stableKeys[index] === stableId) return true;
11
- return message.timestamp === timestamp;
12
- });
13
- }
14
-
15
- export interface ReconcileInheritedBlocksResult {
16
- fittingBlockIds: number[];
17
- activatedBlockIds: number[];
18
- deactivatedBlockIds: number[];
9
+ if (stableId) {
10
+ const stableKeys = stableMessageKeys(messages);
11
+ return stableKeys.indexOf(stableId);
12
+ }
13
+ const matches = messages.flatMap((message, index) => message.timestamp === timestamp ? [index] : []);
14
+ return matches.length === 1 ? matches[0]! : -1;
19
15
  }
20
16
 
21
17
  /**
22
- * Reconcile inherited compression state with the branch that is actually
23
- * being sent to the provider.
24
- *
25
- * A fork can branch before the source session's newest roll-up block ends.
26
- * That roll-up remains active in the source sidecar, while the older block it
27
- * covered is inactive. Since the roll-up's end boundary is absent on the
28
- * fork, neither block would otherwise be applied and the whole history would
29
- * be sent raw. Select the widest fitting block(s) instead, while preserving an
30
- * explicit user decompression for the block and its covered descendants.
18
+ * Mutation identity of one message occurrence. A projection entry whose body
19
+ * DCP replaced (tool-output placeholder) legitimately stands in for its
20
+ * preserved raw content, so its identity is that recorded raw hash; every
21
+ * other message is identified by its own canonical hash. Exactness is not
22
+ * relaxed: the member hash must still match this identity, in order,
23
+ * contiguously, and any changed raw content still fails closed.
31
24
  */
32
- export function reconcileInheritedCompressionBlocks(
33
- messages: any[],
34
- state: DcpState,
35
- ): ReconcileInheritedBlocksResult {
36
- const byId = new Map(state.compressionBlocks.map((block) => [block.id, block]));
37
- const fittingBlockIds = new Set<number>();
38
- const descendantCache = new Map<number, Set<number>>();
39
-
40
- for (const block of state.compressionBlocks) {
41
- if (!Number.isFinite(block.startTimestamp) || !Number.isFinite(block.endTimestamp)) continue;
42
- const startFound = findBoundaryIndex(messages, block.startMessageId, block.startTimestamp) !== -1;
43
- const endFound = findBoundaryIndex(messages, block.endMessageId, block.endTimestamp) !== -1;
44
- if (startFound && endFound) fittingBlockIds.add(block.id);
45
- }
46
-
47
- function descendantsOf(rootId: number): Set<number> {
48
- const cached = descendantCache.get(rootId);
49
- if (cached) return cached;
50
-
51
- const descendants = new Set<number>();
52
- const pending = [...(byId.get(rootId)?.coveredBlockIds ?? [])];
53
- while (pending.length > 0) {
54
- const id = pending.pop()!;
55
- if (id === rootId || descendants.has(id)) continue;
56
- descendants.add(id);
57
- pending.push(...(byId.get(id)?.coveredBlockIds ?? []));
58
- }
59
- descendantCache.set(rootId, descendants);
60
- return descendants;
61
- }
62
-
63
- // Decompressing a roll-up intentionally exposes its whole range. Do not
64
- // unexpectedly reactivate one of its older nested summaries after a fork.
65
- const suppressedByUser = new Set<number>();
66
- for (const block of state.compressionBlocks) {
67
- if (!block.deactivatedByUser) continue;
68
- suppressedByUser.add(block.id);
69
- for (const id of descendantsOf(block.id)) suppressedByUser.add(id);
70
- }
25
+ function mutationIdentity(message: any): string {
26
+ return rawMutationHashOf(message) ?? canonicalMessageHash(message);
27
+ }
71
28
 
72
- const eligibleBlockIds = new Set(
73
- [...fittingBlockIds].filter((id) => !suppressedByUser.has(id)),
74
- );
75
- const coveredByFittingBlock = new Set<number>();
76
- for (const id of eligibleBlockIds) {
77
- for (const coveredId of descendantsOf(id)) coveredByFittingBlock.add(coveredId);
29
+ function exactMemberSpan(messages: any[], members: CompressionMember[]): { lo: number; hi: number } | undefined {
30
+ const keys = stableMessageKeys(messages);
31
+ const lo = keys.indexOf(members[0]!.stableId);
32
+ if (lo < 0 || lo + members.length > messages.length) return undefined;
33
+ for (let offset = 0; offset < members.length; offset++) {
34
+ const member = members[offset]!;
35
+ if (keys[lo + offset] !== member.stableId || mutationIdentity(messages[lo + offset]) !== member.hash) return undefined;
78
36
  }
37
+ return { lo, hi: lo + members.length - 1 };
38
+ }
79
39
 
80
- const activeBlockIds = new Set(
81
- [...eligibleBlockIds].filter((id) => !coveredByFittingBlock.has(id)),
82
- );
83
- const activatedBlockIds: number[] = [];
84
- const deactivatedBlockIds: number[] = [];
85
-
86
- for (const block of state.compressionBlocks) {
87
- const shouldBeActive = activeBlockIds.has(block.id);
88
- if (block.active !== shouldBeActive) {
89
- if (shouldBeActive) activatedBlockIds.push(block.id);
90
- else deactivatedBlockIds.push(block.id);
91
- }
92
- block.active = shouldBeActive;
93
-
94
- if (block.deactivatedByUser) continue;
95
- block.deactivatedReason = fittingBlockIds.has(block.id)
96
- ? undefined
97
- : "outside-inherited-branch";
40
+ function exactStableIdSpan(messages: any[], members: CompressionMember[]): { lo: number; hi: number } | undefined {
41
+ const keys = stableMessageKeys(messages);
42
+ const lo = keys.indexOf(members[0]!.stableId);
43
+ if (lo < 0 || lo + members.length > messages.length) return undefined;
44
+ for (let offset = 0; offset < members.length; offset++) {
45
+ if (keys[lo + offset] !== members[offset]!.stableId) return undefined;
98
46
  }
47
+ return { lo, hi: lo + members.length - 1 };
48
+ }
99
49
 
100
- return {
101
- fittingBlockIds: [...fittingBlockIds].sort((a, b) => a - b),
102
- activatedBlockIds: activatedBlockIds.sort((a, b) => a - b),
103
- deactivatedBlockIds: deactivatedBlockIds.sort((a, b) => a - b),
104
- };
50
+ function exactBlockSpan(messages: any[], block: CompressionBlock): { lo: number; hi: number } | undefined {
51
+ if (!hasExactCompressionMembership(block)) return undefined;
52
+ return exactMemberSpan(messages, block.mutationMembers!) ?? exactMemberSpan(messages, block.sourceMembers!);
105
53
  }
106
54
 
107
55
 
@@ -123,7 +71,35 @@ export function syncCompressionBlocks(messages: any[], state: DcpState, config:
123
71
  }
124
72
 
125
73
  for (const block of state.compressionBlocks) {
126
- if (!block.active || block.deactivatedByUser) continue;
74
+ if (!block.active) continue;
75
+
76
+ if (!hasExactCompressionMembership(block)) {
77
+ block.active = false;
78
+ block.deactivatedReason = "invalid-exact-membership";
79
+ writeDcpDebugLog(config, "block.auto_deactivated", {
80
+ blockId: `b${block.id}`,
81
+ reason: block.deactivatedReason,
82
+ topic: block.topic,
83
+ });
84
+ continue;
85
+ }
86
+
87
+ // If the same exact stable sequence still exists but the recorded source
88
+ // and raw hashes no longer match, never widen or guess a replacement.
89
+ if (
90
+ exactStableIdSpan(messages, block.mutationMembers!) &&
91
+ !exactMemberSpan(messages, block.mutationMembers!) &&
92
+ !exactMemberSpan(messages, block.sourceMembers!)
93
+ ) {
94
+ block.active = false;
95
+ block.deactivatedReason = "exact-membership-mismatch";
96
+ writeDcpDebugLog(config, "block.auto_deactivated", {
97
+ blockId: `b${block.id}`,
98
+ reason: block.deactivatedReason,
99
+ topic: block.topic,
100
+ });
101
+ continue;
102
+ }
127
103
 
128
104
  // ── Skip the missing-origin-compress-call check ────────────────────
129
105
  // The compress tool-call that *created* this block is not the block's
@@ -178,208 +154,124 @@ export function syncCompressionBlocks(messages: any[], state: DcpState, config:
178
154
  }
179
155
  }
180
156
 
181
- export function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
182
- const activeBlocks = state.compressionBlocks
183
- .filter((b) => b.active)
184
- .sort((a, b) => a.startTimestamp - b.startTimestamp);
185
- if (activeBlocks.length === 0) return messages;
186
-
187
- for (const block of activeBlocks) {
188
- // Skip blocks with corrupted timestamps (from pre-fix sessions)
189
- if (!Number.isFinite(block.startTimestamp) || !Number.isFinite(block.endTimestamp)) continue;
190
-
191
- // Find start and end indices by timestamp
192
- const startIdx = findBoundaryIndex(messages, block.startMessageId, block.startTimestamp);
193
- const endIdx = findBoundaryIndex(messages, block.endMessageId, block.endTimestamp);
194
-
195
- if (startIdx === -1 || endIdx === -1) continue;
196
-
197
- let lo = Math.min(startIdx, endIdx);
198
- let hi = Math.max(startIdx, endIdx);
199
-
200
- // Expand lo backward: if there is an assistant before lo whose tool_use
201
- // blocks have matching tool_results inside [lo..hi], pull the entire
202
- // assistant + any intermediate result messages into the range so the
203
- // group is always removed atomically.
204
- //
205
- // Critically we must skip backward past any toolResult / bashExecution
206
- // messages before lo, because an assistant with multiple tool_calls emits
207
- // N consecutive result messages — the assistant itself sits further back.
208
- while (lo > 0) {
209
- // Walk backward past tool-result messages to find the preceding assistant
210
- let scanIdx = lo - 1;
211
- while (scanIdx >= 0) {
212
- const r = (messages[scanIdx] as any).role as string;
213
- if (r !== "toolResult" && r !== "bashExecution" && !PASSTHROUGH_ROLES.has(r)) break;
214
- scanIdx--;
215
- }
216
- if (scanIdx < 0 || (messages[scanIdx] as any).role !== "assistant") break;
217
-
218
- const prev = messages[scanIdx] as any;
219
- const toolCallIdsInRange = new Set<string>();
220
- for (let i = lo; i <= hi; i++) {
221
- const m = messages[i] as any;
222
- if (
223
- (m.role === "toolResult" || m.role === "bashExecution") &&
224
- typeof m.toolCallId === "string"
225
- ) {
226
- toolCallIdsInRange.add(m.toolCallId);
227
- }
228
- }
229
- const prevContent: any[] = Array.isArray(prev.content) ? prev.content : [];
230
- const hasMatchingToolCalls = prevContent.some(
231
- (contentBlock: any) => contentBlock.type === "toolCall" && toolCallIdsInRange.has(contentBlock.id),
232
- );
233
- if (!hasMatchingToolCalls) break;
234
- // Pull assistant + all intermediate result messages into the range
235
- lo = scanIdx;
236
- }
237
-
238
- // Expand hi forward: for every assistant message in [lo..hi] that has
239
- // tool_use blocks, include any immediately-following tool_result messages
240
- // that correspond to those blocks. Loop to fixed point because expanding
241
- // hi could expose more assistants in theory.
242
- let prevHi: number;
243
- do {
244
- prevHi = hi;
245
- const assistantToolCallIds = new Set<string>();
246
- for (let i = lo; i <= hi; i++) {
247
- const m = messages[i] as any;
248
- if (m.role !== "assistant") continue;
249
- const content: any[] = Array.isArray(m.content) ? m.content : [];
250
- for (const contentBlock of content) {
251
- if (contentBlock.type === "toolCall" && typeof contentBlock.id === "string") {
252
- assistantToolCallIds.add(contentBlock.id);
253
- }
254
- }
255
- }
256
- while (hi + 1 < messages.length) {
257
- const next = messages[hi + 1] as any;
258
- if (
259
- (next.role === "toolResult" || next.role === "bashExecution") &&
260
- assistantToolCallIds.has(next.toolCallId)
261
- ) {
262
- hi++;
263
- } else if (PASSTHROUGH_ROLES.has(next.role)) {
264
- hi++;
265
- } else {
266
- break;
267
- }
268
- }
269
- } while (hi !== prevHi);
270
-
271
- // Estimate tokens removed
272
- let removedTokens = 0;
273
- for (let i = lo; i <= hi; i++) {
274
- removedTokens += estimateMessageTokens(messages[i]);
275
- }
276
-
277
- // Remove the range (inclusive)
278
- messages.splice(lo, hi - lo + 1);
279
-
280
- // Build synthetic user message for the compressed block
281
- const syntheticMsg = {
282
- role: "user",
283
- content: [
284
- {
285
- type: "text",
286
- text:
287
- "[Compressed section: " +
288
- block.topic +
289
- "]\n\n" +
290
- block.summary +
291
- "\n\n[dcp-block-id]: # (b" +
292
- block.id +
293
- ")",
294
- },
295
- ],
296
- // anchorTimestamp is always finite (resolveAnchorTimestamp returns
297
- // endTimestamp + 1 instead of Infinity), but guard against corrupted
298
- // state from older sessions where Infinity/null could leak in.
299
- timestamp: Number.isFinite(block.anchorTimestamp) ? block.anchorTimestamp - 0.5 : block.endTimestamp + 0.5,
300
- };
157
+ function compressedBlockText(block: CompressionBlock, label: "section" | "message"): string {
158
+ return [
159
+ `[Compressed ${label}: ${block.topic}]`,
160
+ "",
161
+ block.summary,
162
+ "",
163
+ `[dcp-block-id]: # (b${block.id})`,
164
+ ].join("\n");
165
+ }
301
166
 
302
- // Estimate tokens added by the summary
303
- const addedTokens = estimateMessageTokens(syntheticMsg);
167
+ /** Exact estimator cost of the synthetic v2 range replacement, including the
168
+ * DCP wrapper/carrier text that is not part of block.summaryTokenEstimate. */
169
+ export function estimateCompressionBlockReplacementTokens(block: CompressionBlock): number {
170
+ return estimateMessageTokens({
171
+ role: "user",
172
+ content: [{ type: "text", text: compressedBlockText(block, "section") }],
173
+ timestamp: block.startTimestamp,
174
+ });
175
+ }
304
176
 
305
- // Insert the synthetic message
306
- messages.push(syntheticMsg);
177
+ function accountCompressionBlock(
178
+ block: CompressionBlock,
179
+ removedTokens: number,
180
+ addedTokens: number,
181
+ state: DcpState,
182
+ ): void {
183
+ if (state.accountedCompressionBlockIds.has(block.id)) return;
184
+ state.accountedCompressionBlockIds.add(block.id);
185
+ state.totalPruneCount++;
186
+ const rawSaved = Math.max(0, removedTokens - addedTokens);
187
+ const coveredSavings = (block.coveredBlockIds ?? []).reduce(
188
+ (sum, id) => sum + (state.compressionTokenSavings.get(id) ?? 0),
189
+ 0,
190
+ );
191
+ state.tokensSaved = Math.max(0, state.tokensSaved + rawSaved - coveredSavings);
192
+ state.compressionTokenSavings.set(block.id, rawSaved);
193
+ }
307
194
 
308
- // Re-sort by timestamp
309
- messages.sort((a, b) => (a.timestamp ?? 0) - (b.timestamp ?? 0));
195
+ function preserveProjectedStableId(message: any, stableId: string | undefined): void {
196
+ if (!stableId || !message || typeof message !== "object") return;
197
+ Object.defineProperty(message, "_dcpStableId", {
198
+ value: stableId,
199
+ enumerable: false,
200
+ configurable: true,
201
+ });
202
+ }
310
203
 
311
- // Update tokens saved exactly once per compression block.
312
- if (!state.accountedCompressionBlockIds.has(block.id)) {
313
- state.accountedCompressionBlockIds.add(block.id);
314
- state.totalPruneCount++;
315
- const rawSaved = Math.max(0, removedTokens - addedTokens);
316
- const coveredSavings = (block.coveredBlockIds ?? []).reduce(
317
- (sum, id) => sum + (state.compressionTokenSavings.get(id) ?? 0),
318
- 0,
319
- );
320
- const adjustment = rawSaved - coveredSavings;
321
- state.tokensSaved = Math.max(0, state.tokensSaved + adjustment);
322
- state.compressionTokenSavings.set(block.id, rawSaved);
323
- }
204
+ function markProjectedOrigin(message: any, origin: "block" | "dcp-control", blockId?: number): void {
205
+ if (!message || typeof message !== "object") return;
206
+ Object.defineProperty(message, "_dcpOrigin", {
207
+ value: origin,
208
+ enumerable: false,
209
+ configurable: true,
210
+ });
211
+ if (blockId !== undefined) {
212
+ Object.defineProperty(message, "_dcpBlockId", {
213
+ value: blockId,
214
+ enumerable: false,
215
+ configurable: true,
216
+ });
324
217
  }
325
-
326
- return messages;
327
218
  }
328
219
 
329
- /**
330
- * Remove orphaned toolResult/bashExecution messages whose corresponding
331
- * assistant toolCall was removed, and strip orphaned toolCall blocks from
332
- * assistant messages whose toolResult was removed.
333
- *
334
- * This is a safety net that runs after all compression blocks are applied.
335
- */
336
- export function repairOrphanedToolPairs(messages: any[]): void {
337
- // 1. Build set of all toolCall IDs present in assistant messages
338
- const assistantToolCallIds = new Set<string>();
339
- for (const msg of messages) {
340
- if (msg.role !== "assistant") continue;
341
- const content: any[] = Array.isArray(msg.content) ? msg.content : [];
342
- for (const contentBlock of content) {
343
- if (contentBlock.type === "toolCall" && typeof contentBlock.id === "string") {
344
- assistantToolCallIds.add(contentBlock.id);
345
- }
346
- }
347
- }
220
+ function applyExactMessageBodyBlock(messages: any[], block: CompressionBlock, state: DcpState): boolean {
221
+ const span = exactBlockSpan(messages, block);
222
+ if (!span || span.lo !== span.hi) return false;
223
+ const targetIndex = span.lo;
224
+ const target = messages[targetIndex];
225
+ const removedTokens = estimateMessageTokens(target);
226
+ const text = compressedBlockText(block, "section");
227
+
228
+ if (typeof target?.content === "string") target.content = text;
229
+ else target.content = [{ type: "text", text }];
230
+ preserveProjectedStableId(target, block.startMessageId);
231
+ markProjectedOrigin(target, "block", block.id);
232
+
233
+ const addedTokens = estimateMessageTokens(target);
234
+ accountCompressionBlock(block, removedTokens, addedTokens, state);
235
+ return true;
236
+ }
348
237
 
349
- // 2. Build set of all toolCallIds present in toolResult/bashExecution messages
350
- const resultToolCallIds = new Set<string>();
351
- for (const msg of messages) {
352
- if (msg.role !== "toolResult" && msg.role !== "bashExecution") continue;
353
- if (typeof msg.toolCallId === "string") {
354
- resultToolCallIds.add(msg.toolCallId);
355
- }
356
- }
238
+ function applyExactRangeBlock(messages: any[], block: CompressionBlock, state: DcpState): boolean {
239
+ const span = exactBlockSpan(messages, block);
240
+ if (!span) return false;
241
+ const { lo, hi } = span;
242
+ let removedTokens = 0;
243
+ for (let i = lo; i <= hi; i++) removedTokens += estimateMessageTokens(messages[i]);
244
+
245
+ const syntheticMsg = {
246
+ role: "user",
247
+ content: [{ type: "text", text: compressedBlockText(block, "section") }],
248
+ timestamp: block.startTimestamp,
249
+ };
250
+ markProjectedOrigin(syntheticMsg, "block", block.id);
251
+ messages.splice(lo, hi - lo + 1, syntheticMsg);
357
252
 
358
- // 3. Remove orphaned toolResult/bashExecution messages (no matching assistant toolCall)
359
- for (let i = messages.length - 1; i >= 0; i--) {
360
- const msg = messages[i];
361
- if (msg.role !== "toolResult" && msg.role !== "bashExecution") continue;
362
- if (typeof msg.toolCallId === "string" && !assistantToolCallIds.has(msg.toolCallId)) {
363
- messages.splice(i, 1);
364
- }
365
- }
253
+ const addedTokens = estimateMessageTokens(syntheticMsg);
254
+ accountCompressionBlock(block, removedTokens, addedTokens, state);
255
+ return true;
256
+ }
366
257
 
367
- // 4. Strip orphaned toolCall blocks from assistant messages (no matching toolResult)
368
- for (const msg of messages) {
369
- if (msg.role !== "assistant") continue;
370
- const content: any[] = Array.isArray(msg.content) ? msg.content : [];
371
- const hasToolCalls = content.some((b: any) => b.type === "toolCall");
372
- if (!hasToolCalls) continue;
258
+ export function applyCompressionBlocks(messages: any[], state: DcpState): any[] {
259
+ const activeBlocks = state.compressionBlocks
260
+ .filter((b) => b.active)
261
+ .sort((a, b) => a.startTimestamp - b.startTimestamp);
262
+ if (activeBlocks.length === 0) return messages;
373
263
 
374
- const filtered = content.filter((contentBlock: any) => {
375
- if (contentBlock.type !== "toolCall") return true;
376
- return typeof contentBlock.id === "string" && resultToolCallIds.has(contentBlock.id);
377
- });
264
+ for (const block of activeBlocks) {
265
+ if (!hasExactCompressionMembership(block)) continue;
378
266
 
379
- // Only update if we actually removed something
380
- if (filtered.length !== content.length) {
381
- // If the assistant has no content left at all, keep at least an empty array
382
- msg.content = filtered.length > 0 ? filtered : [];
267
+ if (block.version === 2 && block.replacementMode === "message-body") {
268
+ applyExactMessageBodyBlock(messages, block, state);
269
+ continue;
270
+ }
271
+ if (block.version === 2 && block.replacementMode === "range") {
272
+ applyExactRangeBlock(messages, block, state);
383
273
  }
384
274
  }
275
+
276
+ return messages;
385
277
  }
@@ -1,5 +1,5 @@
1
1
  import type { DcpConfig } from "./config.js";
2
- import { estimateMessageTokens, extractBlockId, messageText } from "./pruner-metadata.js";
2
+ import { estimateMessageTokens, messageText } from "./pruner-metadata.js";
3
3
  import type {
4
4
  EmergencyCurrentTurnOutput,
5
5
  EmergencyCurrentTurnSelection,
@@ -48,9 +48,7 @@ export function emergencyPressureState(
48
48
  }
49
49
 
50
50
  function isRealUserMessage(message: any): boolean {
51
- if (message?.role !== "user") return false;
52
- const text = messageText(message);
53
- return !text.includes("<dcp-system-reminder>") && extractBlockId(text) === undefined;
51
+ return message?.role === "user" && message?._dcpOrigin !== "block" && message?._dcpOrigin !== "dcp-control";
54
52
  }
55
53
 
56
54
  function assistantToolCallIds(message: any): string[] {
@@ -5,10 +5,11 @@ import {
5
5
  ID_ELIGIBLE_ROLES,
6
6
  PASSTHROUGH_ROLES,
7
7
  estimateMessageTokens,
8
- extractBlockId,
9
8
  messageText,
10
9
  stripStaleDcpMetadataLines,
11
10
  } from "./pruner-metadata.js";
11
+ import { buildConversationIndex, canonicalMessageHash } from "./conversation-index.js";
12
+ import { recordCompressionProjection } from "./compression-preview.js";
12
13
 
13
14
  export interface InjectMessageIdsOptions {
14
15
  /** Config enables priority markers for message-mode candidates. */
@@ -49,6 +50,9 @@ function fallbackContentFingerprint(msg: any): string {
49
50
  }
50
51
 
51
52
  export function stableMessageId(msg: any, fallbackIndex = 0): string {
53
+ if (typeof msg?._dcpStableId === "string" && msg._dcpStableId.length > 0) {
54
+ return msg._dcpStableId;
55
+ }
52
56
  const candidates = [
53
57
  msg?.id,
54
58
  msg?.entryId,
@@ -67,8 +71,7 @@ export function stableMessageId(msg: any, fallbackIndex = 0): string {
67
71
  return `tool:${msg.toolCallId.trim()}`;
68
72
  }
69
73
 
70
- const blockId = extractBlockId(messageText(msg));
71
- if (blockId !== undefined) return `block:${blockId}`;
74
+ if (Number.isInteger(msg?._dcpBlockId)) return `block:${msg._dcpBlockId}`;
72
75
  if (Number.isFinite(msg?.timestamp)) {
73
76
  return `ts:${msg.timestamp}:${fallbackContentFingerprint(msg)}`;
74
77
  }
@@ -78,7 +81,7 @@ export function stableMessageId(msg: any, fallbackIndex = 0): string {
78
81
  /**
79
82
  * Return deterministic per-occurrence identities for a message sequence.
80
83
  * Session entry IDs and content-fingerprinted fallbacks are normally unique;
81
- * the suffix keeps byte-identical legacy messages from sharing one mNNN ID.
84
+ * the suffix keeps byte-identical fallback identities from sharing one mNNN ID.
82
85
  */
83
86
  export function stableMessageKeys(messages: any[]): string[] {
84
87
  const occurrences = new Map<string, number>();
@@ -172,17 +175,24 @@ export function injectMessageIds(
172
175
  const stableKey = stableKeys[messageIndex]!;
173
176
  const id = persistentMessageId(stableKey, state);
174
177
  const originalText = messageText(msg);
175
- const blockId = extractBlockId(originalText);
178
+ const blockId = Number.isInteger(msg?._dcpBlockId) ? msg._dcpBlockId as number : undefined;
176
179
  const tokenEstimate = estimateMessageTokens(msg);
180
+ const toolCallIds = role === "assistant" && Array.isArray(msg.content)
181
+ ? msg.content
182
+ .filter((part: any) => part?.type === "toolCall" && typeof part.id === "string")
183
+ .map((part: any) => part.id as string)
184
+ : [];
177
185
 
178
186
  assignedIds.set(messageIndex, id);
179
187
  assignedMeta.set(messageIndex, {
180
188
  timestamp: msg.timestamp,
181
189
  stableId: stableKey,
190
+ contentHash: canonicalMessageHash(msg),
182
191
  role,
183
192
  blockId,
184
193
  toolCallId: typeof msg.toolCallId === "string" ? msg.toolCallId : undefined,
185
194
  toolName: typeof msg.toolName === "string" ? msg.toolName : undefined,
195
+ toolCallIds: toolCallIds.length > 0 ? toolCallIds : undefined,
186
196
  text: originalText,
187
197
  tokenEstimate,
188
198
  priority: priorityForMessage(tokenEstimate, options.config),
@@ -224,4 +234,6 @@ export function injectMessageIds(
224
234
 
225
235
  state.messageIdSnapshot = nextIdSnapshot;
226
236
  state.messageMetaSnapshot = nextMetaSnapshot;
237
+ state.conversationIndexSnapshot = buildConversationIndex(messages, stableKeys, state);
238
+ recordCompressionProjection(state.conversationIndexSnapshot, messages);
227
239
  }
@@ -76,7 +76,17 @@ export function estimateMessageTokens(msg: any): number {
76
76
  if (part && typeof part === "object") {
77
77
  if (typeof part.text === "string") total += estimateTokens(part.text);
78
78
  else if (typeof part.thinking === "string") total += estimateTokens(part.thinking);
79
- else if (part.type === "image") total += 500; // rough estimate for images
79
+ else if (part.type === "toolCall") {
80
+ try {
81
+ // Tool calls are provider-visible context too. Counting the full
82
+ // serialized block keeps range-before and replacement-after on the
83
+ // same estimator and includes IDs, names, arguments/input, and any
84
+ // provider replay metadata without guessing field-by-field.
85
+ total += estimateTokens(JSON.stringify(part) ?? "");
86
+ } catch {
87
+ total += estimateTokens(`${part.name ?? "tool"}:${part.id ?? ""}`);
88
+ }
89
+ } else if (part.type === "image") total += 500; // rough estimate for images
80
90
  }
81
91
  }
82
92
  return total;