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
@@ -3,10 +3,11 @@
3
3
  // ---------------------------------------------------------------------------
4
4
 
5
5
  import { Type } from "typebox"
6
- import type { ExtensionAPI } from "@earendil-works/pi-coding-agent"
7
- import type { DcpState } from "./state.js"
6
+ import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"
7
+ import type { CompressionMember, DcpState } from "./state.js"
8
8
  import { modelKeysFromContext, resolveModelConfig, type DcpConfig } from "./config.js"
9
- import { saveDcpState } from "./state-persistence.js"
9
+ import { captureDcpTransactionGuard, cloneDcpTransactionState, runDcpStateTransaction } from "./state-transaction.js"
10
+ import { createHash } from "node:crypto"
10
11
  import { clearDcpNudgeAnchors } from "./pruner.js"
11
12
  import type { DcpCompressionVisualDetails } from "./ui.js"
12
13
  import { normalizeDcpContextUsage } from "./ui.js"
@@ -14,13 +15,23 @@ import { COMPRESS_TOOL_DESCRIPTION } from "../tool-descriptions.js"
14
15
  import { safeGetContextUsage } from "../context-usage.js"
15
16
  import { summarizeDcpState, writeDcpDebugLog } from "./debug-log.js"
16
17
  import {
18
+ compareCompressionBoundaries,
17
19
  createRangeCompressionBlock,
18
20
  findCoveredAndPartialBlocks,
19
21
  formatCompressionIdDiagnostics,
20
22
  getMessageMeta,
23
+ prepareCompressionProtectedFragments,
21
24
  resolveAnchorBoundary,
25
+ resolveExactRangeMembership,
22
26
  resolveIdToBoundary,
23
27
  } from "./compression-blocks.js"
28
+ import {
29
+ closeConversationRange,
30
+ detectToolGroupSpans,
31
+ findConversationIndexEntry,
32
+ } from "./conversation-index.js"
33
+ import { previewManualCompressionProjection } from "./compression-preview.js"
34
+ import { settleCompressionProgress } from "./compression-progress.js"
24
35
 
25
36
  type MessageSkipKind =
26
37
  | "duplicate"
@@ -29,6 +40,7 @@ type MessageSkipKind =
29
40
  | "non-finite"
30
41
  | "protected-user"
31
42
  | "already-compressed"
43
+ | "tool-group"
32
44
 
33
45
  interface MessageSkipIssue {
34
46
  kind: MessageSkipKind
@@ -44,19 +56,45 @@ interface ResolvedRangePlan {
44
56
  endTimestamp: number
45
57
  startMessageId?: string
46
58
  endMessageId?: string
59
+ sourceMembers: CompressionMember[]
60
+ mutationMembers: CompressionMember[]
61
+ }
62
+
63
+ interface ResolvedMessagePlan {
64
+ messageId: string
65
+ topic: string
66
+ summary: string
67
+ timestamp: number
68
+ stableId?: string
69
+ sourceMembers: CompressionMember[]
70
+ mutationMembers: CompressionMember[]
47
71
  }
48
72
 
49
- function validateNonOverlappingRanges(plans: ResolvedRangePlan[]): void {
73
+ type ResolvedRangeBoundaries = Omit<ResolvedRangePlan, "sourceMembers" | "mutationMembers">
74
+
75
+ function validateNonOverlappingRanges(plans: ResolvedRangeBoundaries[], state: DcpState): void {
50
76
  const sorted = [...plans].sort((a, b) =>
51
- a.startTimestamp - b.startTimestamp ||
52
- a.endTimestamp - b.endTimestamp,
77
+ compareCompressionBoundaries(
78
+ { timestamp: a.startTimestamp, stableId: a.startMessageId },
79
+ { timestamp: b.startTimestamp, stableId: b.startMessageId },
80
+ state,
81
+ ) ||
82
+ compareCompressionBoundaries(
83
+ { timestamp: a.endTimestamp, stableId: a.endMessageId },
84
+ { timestamp: b.endTimestamp, stableId: b.endMessageId },
85
+ state,
86
+ ),
53
87
  )
54
88
  const issues: string[] = []
55
89
 
56
90
  for (let i = 1; i < sorted.length; i++) {
57
91
  const previous = sorted[i - 1]!
58
92
  const current = sorted[i]!
59
- if (current.startTimestamp > previous.endTimestamp) continue
93
+ if (compareCompressionBoundaries(
94
+ { timestamp: current.startTimestamp, stableId: current.startMessageId },
95
+ { timestamp: previous.endTimestamp, stableId: previous.endMessageId },
96
+ state,
97
+ ) > 0) continue
60
98
  issues.push(
61
99
  `${previous.startId}..${previous.endId} overlaps ${current.startId}..${current.endId}`,
62
100
  )
@@ -69,6 +107,67 @@ function validateNonOverlappingRanges(plans: ResolvedRangePlan[]): void {
69
107
  }
70
108
  }
71
109
 
110
+ function validateProtocolClosedRanges(plans: ResolvedRangeBoundaries[], state: DcpState): void {
111
+ if (state.conversationIndexSnapshot.length === 0) return
112
+
113
+ for (const plan of plans) {
114
+ const closure = closeConversationRange(state.conversationIndexSnapshot, plan.startId, plan.endId)
115
+ if (!closure) continue
116
+ if (closure.incompleteToolGroup) {
117
+ throw new Error(
118
+ `Compression range ${plan.startId}..${plan.endId} intersects an incomplete tool group. ` +
119
+ "Wait until the complete assistant/tool-result group is present or choose another closed range.",
120
+ )
121
+ }
122
+ if (closure.expanded) {
123
+ const safeStart = closure.startId ?? plan.startId
124
+ const safeEnd = closure.endId ?? plan.endId
125
+ throw new Error(
126
+ `Compression range ${plan.startId}..${plan.endId} cuts through a tool group. ` +
127
+ `The protocol-safe closed range is ${safeStart}..${safeEnd}. ` +
128
+ "Retry with the complete range so the supplied summary covers every message that will be removed.",
129
+ )
130
+ }
131
+ }
132
+ }
133
+
134
+ function selectionMemberKeys(membership: Pick<ResolvedRangePlan | ResolvedMessagePlan, "sourceMembers" | "mutationMembers">): Set<string> {
135
+ return new Set(
136
+ [...membership.sourceMembers, ...membership.mutationMembers]
137
+ .map((member) => `${member.stableId}:${member.hash}`),
138
+ )
139
+ }
140
+
141
+ function assertDisjointSelections(
142
+ ranges: ResolvedRangePlan[],
143
+ messages: ResolvedMessagePlan[],
144
+ ): void {
145
+ const selections = [
146
+ ...ranges.map((range) => ({ label: `${range.startId}..${range.endId}`, membership: range })),
147
+ ...messages.map((message) => ({ label: message.messageId, membership: message })),
148
+ ]
149
+ for (let leftIndex = 0; leftIndex < selections.length; leftIndex++) {
150
+ const left = selections[leftIndex]!
151
+ const leftKeys = selectionMemberKeys(left.membership)
152
+ for (let rightIndex = leftIndex + 1; rightIndex < selections.length; rightIndex++) {
153
+ const right = selections[rightIndex]!
154
+ if ([...selectionMemberKeys(right.membership)].some((key) => leftKeys.has(key))) {
155
+ throw new Error(
156
+ `Overlapping compression selections cannot be committed in the same call: ${left.label} overlaps ${right.label}.`,
157
+ )
158
+ }
159
+ }
160
+ }
161
+ }
162
+
163
+ function messageTouchesIncompleteToolGroup(messageId: string, state: DcpState): boolean {
164
+ const entry = findConversationIndexEntry(state.conversationIndexSnapshot, messageId)
165
+ if (!entry) return false
166
+ return detectToolGroupSpans(state.conversationIndexSnapshot).some((group) =>
167
+ !group.complete && entry.index >= group.startIndex && entry.index <= group.endIndex,
168
+ )
169
+ }
170
+
72
171
  function formatSkippedMessages(issues: MessageSkipIssue[]): string[] {
73
172
  const grouped = new Map<MessageSkipKind, string[]>()
74
173
  for (const issue of issues) {
@@ -82,6 +181,7 @@ function formatSkippedMessages(issues: MessageSkipIssue[]): string[] {
82
181
  "non-finite": "resolved to a corrupted non-finite timestamp",
83
182
  "protected-user": "is a raw user message protected by compress.protectUserMessages",
84
183
  "already-compressed": "already belongs to an active compression block",
184
+ "tool-group": "belongs to a structurally coupled tool group and cannot be replaced as one isolated message",
85
185
  }
86
186
 
87
187
  return Array.from(grouped.entries()).map(([kind, ids]) => {
@@ -98,15 +198,49 @@ function formatSkippedMessages(issues: MessageSkipIssue[]): string[] {
98
198
  })
99
199
  }
100
200
 
201
+ function createCompressionWorkingState(state: DcpState): DcpState {
202
+ return cloneDcpTransactionState(state)
203
+ }
204
+
205
+ function commitCompressionWorkingState(state: DcpState, workingState: DcpState): void {
206
+ state.compressionBlocks = workingState.compressionBlocks
207
+ state.nextBlockId = workingState.nextBlockId
208
+ state.nudgeAnchors = workingState.nudgeAnchors
209
+ state.nudgeCounter = workingState.nudgeCounter
210
+ state.lastNudge = workingState.lastNudge
211
+ state.consecutiveIgnoredStrongNudges = workingState.consecutiveIgnoredStrongNudges
212
+ state.consecutiveIgnoredNudges = workingState.consecutiveIgnoredNudges
213
+ state.compressionProgress = workingState.compressionProgress
214
+ }
215
+
101
216
  // ---------------------------------------------------------------------------
102
217
  // Tool registration
103
218
  // ---------------------------------------------------------------------------
104
219
 
220
+ export interface CompressToolDependencies {
221
+ persistState?: (
222
+ ctx: ExtensionContext,
223
+ state: DcpState,
224
+ publication?: { beforePublish?: () => void; onPublished?: () => void },
225
+ ) => Promise<void> | void
226
+ isSessionSupported?: () => boolean
227
+ }
228
+
105
229
  export function registerCompressTool(
106
230
  pi: ExtensionAPI,
107
231
  state: DcpState,
108
232
  config: DcpConfig,
233
+ dependencies: CompressToolDependencies = {},
109
234
  ): void {
235
+ const persistState = dependencies.persistState ?? (async (
236
+ _ctx: ExtensionContext,
237
+ _state: DcpState,
238
+ publication?: { beforePublish?: () => void; onPublished?: () => void },
239
+ ) => {
240
+ publication?.beforePublish?.()
241
+ publication?.onPublished?.()
242
+ })
243
+ const isSessionSupported = dependencies.isSessionSupported ?? (() => true)
110
244
  pi.registerTool({
111
245
  name: "compress",
112
246
  label: COMPRESS_TOOL_DESCRIPTION.label,
@@ -121,11 +255,11 @@ export function registerCompressTool(
121
255
  Type.Object({
122
256
  startId: Type.String({
123
257
  description:
124
- "Message ID marking start of range (e.g. m001, b2)",
258
+ "First ID (mNNN/bN); never start inside a tool group—include its calling assistant.",
125
259
  }),
126
260
  endId: Type.String({
127
261
  description:
128
- "Message ID marking end of range (e.g. m042, b5)",
262
+ "Last ID (mNNN/bN); include all results of the final tool group, including parallel calls.",
129
263
  }),
130
264
  summary: Type.String({
131
265
  description:
@@ -151,6 +285,15 @@ export function registerCompressTool(
151
285
  }),
152
286
 
153
287
  async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
288
+ _signal?.throwIfAborted()
289
+ if (!isSessionSupported()) {
290
+ throw new Error("DCP is unavailable in this session. Start a new session to use the current DCP journal format.")
291
+ }
292
+ const assertCurrent = captureDcpTransactionGuard(state, config, _signal, ctx)
293
+ const requestHash = createHash("sha256").update(JSON.stringify(params)).digest("hex")
294
+ return runDcpStateTransaction(state, async () => {
295
+ assertCurrent()
296
+ const operationEpoch = state.sessionEpoch
154
297
  const effectiveConfig = resolveModelConfig(config, modelKeysFromContext(ctx))
155
298
  if (!effectiveConfig.enabled) {
156
299
  throw new Error("DCP is disabled for the active model")
@@ -159,6 +302,8 @@ export function registerCompressTool(
159
302
  const newBlockIds: number[] = []
160
303
  const ranges = Array.isArray(params.ranges) ? params.ranges : []
161
304
  const messages = Array.isArray(params.messages) ? params.messages : []
305
+ const workingState = createCompressionWorkingState(state)
306
+ const committedRangeLogs: Array<Record<string, unknown>> = []
162
307
  let operationRemovedTokens = 0
163
308
  let operationSummaryTokens = 0
164
309
 
@@ -170,16 +315,53 @@ export function registerCompressTool(
170
315
  topic: params.topic,
171
316
  ranges: ranges.map((range) => ({ startId: range.startId, endId: range.endId })),
172
317
  messages: messages.map((entry) => ({ messageId: entry.messageId, topic: entry.topic })),
173
- state: summarizeDcpState(state),
318
+ state: summarizeDcpState(state, effectiveConfig),
174
319
  })
175
320
 
321
+ const replayBlocks = state.compressionBlocks.filter((block) => block.createdByToolCallId === _toolCallId)
322
+ if (replayBlocks.length > 0) {
323
+ if (replayBlocks.some((block) => block.operationRequestHash !== requestHash)) {
324
+ throw new Error("Compression operation identity conflict: tool call was reused with different or unverifiable parameters")
325
+ }
326
+ const replayBlockIds = replayBlocks.map((block) => block.id)
327
+ const usage = normalizeDcpContextUsage(safeGetContextUsage(ctx))
328
+ const visualDetails: DcpCompressionVisualDetails = {
329
+ blockIds: replayBlockIds,
330
+ topic: params.topic,
331
+ ranges: ranges.length,
332
+ messages: messages.length,
333
+ itemCount: ranges.length + messages.length,
334
+ totalSummaryTokens: replayBlocks.reduce((sum, block) => sum + (block.summaryTokenEstimate ?? 0), 0),
335
+ activeBlocks: state.compressionBlocks.filter((block) => block.active).length,
336
+ totalBlocks: state.compressionBlocks.length,
337
+ prunedTools: state.prunedToolIds.size,
338
+ tokensSaved: replayBlockIds.reduce((sum, id) => sum + (state.compressionTokenSavings.get(id) ?? 0), 0),
339
+ contextTokens: usage?.tokens,
340
+ contextWindow: usage?.contextWindow,
341
+ contextPercent: usage?.percent,
342
+ skippedMessages: 0,
343
+ skippedMessageIssues: [],
344
+ idempotentReplay: true,
345
+ }
346
+ const resultDetails = { ...visualDetails, outputFormat: "json" as const }
347
+ log("compress.idempotent_replay", {
348
+ toolCallId: _toolCallId,
349
+ blockIds: replayBlockIds.map((id) => `b${id}`),
350
+ state: summarizeDcpState(state, effectiveConfig),
351
+ })
352
+ return {
353
+ content: [{ type: "text", text: JSON.stringify(resultDetails, null, 2) }],
354
+ details: resultDetails,
355
+ }
356
+ }
357
+
176
358
  if (ranges.length === 0 && messages.length === 0) {
177
359
  throw new Error("compress requires at least one ranges[] or messages[] entry")
178
360
  }
179
361
 
180
362
  let rangePlans: ResolvedRangePlan[]
181
363
  try {
182
- rangePlans = ranges.map((range) => {
364
+ const boundaryPlans: ResolvedRangeBoundaries[] = ranges.map((range) => {
183
365
  const { startId, endId, summary } = range
184
366
 
185
367
  // ── Resolve boundary timestamps ──────────────────────────────────
@@ -188,7 +370,7 @@ export function registerCompressTool(
188
370
  const startTimestamp = startBoundary.timestamp
189
371
  const endTimestamp = endBoundary.timestamp
190
372
 
191
- if (startTimestamp > endTimestamp) {
373
+ if (compareCompressionBoundaries(startBoundary, endBoundary, state) > 0) {
192
374
  throw new Error(
193
375
  `Range start "${startId}" must appear before end "${endId}" in the conversation`,
194
376
  )
@@ -219,19 +401,80 @@ export function registerCompressTool(
219
401
  }
220
402
  })
221
403
 
222
- validateNonOverlappingRanges(rangePlans)
404
+ validateNonOverlappingRanges(boundaryPlans, state)
405
+ validateProtocolClosedRanges(boundaryPlans, state)
406
+ rangePlans = boundaryPlans.map((range) => {
407
+ const { partialBlocks } = findCoveredAndPartialBlocks(range.startTimestamp, range.endTimestamp, state, range)
408
+ if (partialBlocks.length > 0) {
409
+ throw new Error(
410
+ `Compression range partially overlaps existing block(s): ${partialBlocks.map((block) => `b${block.id}`).join(", ")}. ` +
411
+ `Select the whole block or non-overlapping boundaries.\n${formatCompressionIdDiagnostics(state)}`,
412
+ )
413
+ }
414
+ const membership = resolveExactRangeMembership(range.startId, range.endId, state)
415
+ if (!membership) {
416
+ throw new Error(
417
+ `Manual compression requires exact source membership for ${range.startId}..${range.endId}; refresh the current DCP context and retry.`,
418
+ )
419
+ }
420
+ return { ...range, ...membership }
421
+ })
223
422
  } catch (error) {
224
423
  log("compress.resolve_failed", {
225
424
  toolCallId: _toolCallId,
226
425
  error: error instanceof Error ? error.message : String(error),
227
- state: summarizeDcpState(state),
426
+ state: summarizeDcpState(state, effectiveConfig),
228
427
  })
229
428
  throw error
230
429
  }
231
430
 
431
+ // Reject a batch that would claim the same provider-visible source more
432
+ // than once before any detached block is created. Invalid message-mode
433
+ // entries retain their established soft-skip behavior below.
434
+ const preflightMessages: ResolvedMessagePlan[] = []
435
+ const preflightSeenMessageIds = new Set<string>()
436
+ for (const entry of messages) {
437
+ const messageId = typeof entry.messageId === "string" ? entry.messageId.trim() : ""
438
+ if (preflightSeenMessageIds.has(messageId)) continue
439
+ preflightSeenMessageIds.add(messageId)
440
+ if (/^b\d+$/i.test(messageId)) continue
441
+ const meta = getMessageMeta(messageId, state)
442
+ const indexEntry = findConversationIndexEntry(state.conversationIndexSnapshot, messageId)
443
+ if (!meta || meta.blockId !== undefined || !Number.isFinite(meta.timestamp) ||
444
+ (effectiveConfig.compress.protectUserMessages && meta.role === "user") ||
445
+ (meta.role === "assistant" && ((meta.toolCallIds?.length ?? 0) > 0 || indexEntry?.signedAssistant)) ||
446
+ messageTouchesIncompleteToolGroup(messageId, state)) continue
447
+ const membership = resolveExactRangeMembership(messageId, messageId, state, true)
448
+ if (!membership) {
449
+ throw new Error(
450
+ `Manual message compression requires exact source membership for ${messageId}; refresh the current DCP context and retry.`,
451
+ )
452
+ }
453
+ preflightMessages.push({
454
+ messageId,
455
+ topic: entry.topic ?? params.topic,
456
+ summary: entry.summary,
457
+ timestamp: meta.timestamp,
458
+ stableId: meta.stableId,
459
+ sourceMembers: membership.sourceMembers,
460
+ mutationMembers: membership.mutationMembers,
461
+ })
462
+ }
463
+ assertDisjointSelections(rangePlans, preflightMessages)
464
+
232
465
  for (const range of rangePlans) {
233
466
  try {
234
- const anchor = resolveAnchorBoundary(range.endTimestamp, state)
467
+ const anchor = resolveAnchorBoundary(range.endTimestamp, workingState, range.endMessageId)
468
+ const preparedProtectedFragments = await prepareCompressionProtectedFragments({
469
+ startTimestamp: range.startTimestamp,
470
+ endTimestamp: range.endTimestamp,
471
+ startMessageId: range.startMessageId,
472
+ endMessageId: range.endMessageId,
473
+ state: workingState,
474
+ config: effectiveConfig,
475
+ mode: "range",
476
+ cwd: (ctx as any).cwd,
477
+ })
235
478
 
236
479
  const created = createRangeCompressionBlock({
237
480
  topic: params.topic,
@@ -243,15 +486,20 @@ export function registerCompressTool(
243
486
  anchorTimestamp: anchor.timestamp,
244
487
  anchorMessageId: anchor.stableId,
245
488
  createdByToolCallId: _toolCallId,
246
- state,
489
+ state: workingState,
247
490
  config: effectiveConfig,
248
491
  mode: "range",
492
+ version: 2,
493
+ replacementMode: "range",
494
+ preparedProtectedFragments,
495
+ sourceMembers: range.sourceMembers,
496
+ mutationMembers: range.mutationMembers,
249
497
  })
250
498
  const block = created.block
251
499
  newBlockIds.push(block.id)
252
500
  operationRemovedTokens += created.removedTokenEstimate
253
501
  operationSummaryTokens += created.summaryTokenEstimate
254
- log("compress.range_created", {
502
+ committedRangeLogs.push({
255
503
  toolCallId: _toolCallId,
256
504
  range: { startId: range.startId, endId: range.endId },
257
505
  blockId: `b${block.id}`,
@@ -263,7 +511,7 @@ export function registerCompressTool(
263
511
  toolCallId: _toolCallId,
264
512
  range: { startId: range.startId, endId: range.endId },
265
513
  error: error instanceof Error ? error.message : String(error),
266
- state: summarizeDcpState(state),
514
+ state: summarizeDcpState(state, effectiveConfig),
267
515
  })
268
516
  throw error
269
517
  }
@@ -286,14 +534,14 @@ export function registerCompressTool(
286
534
  continue
287
535
  }
288
536
 
289
- const meta = getMessageMeta(messageId, state)
537
+ const meta = getMessageMeta(messageId, workingState)
290
538
  if (!meta) {
291
539
  skippedMessageIssues.push({
292
540
  kind: "unknown",
293
541
  messageId,
294
542
  detail:
295
543
  "The ID is not present in the current DCP snapshot; it may be stale after compression, pruning, reload, or session switching.\n" +
296
- formatCompressionIdDiagnostics(state),
544
+ formatCompressionIdDiagnostics(workingState),
297
545
  })
298
546
  continue
299
547
  }
@@ -309,11 +557,30 @@ export function registerCompressTool(
309
557
  skippedMessageIssues.push({ kind: "protected-user", messageId })
310
558
  continue
311
559
  }
560
+ const indexEntry = findConversationIndexEntry(workingState.conversationIndexSnapshot, messageId)
561
+ if (meta.role === "assistant" && ((meta.toolCallIds?.length ?? 0) > 0 || indexEntry?.signedAssistant)) {
562
+ skippedMessageIssues.push({
563
+ kind: "tool-group",
564
+ messageId,
565
+ detail:
566
+ "Signed assistant content and assistants containing tool calls cannot be edited in place; use ranges[] with a complete protocol-safe group.",
567
+ })
568
+ continue
569
+ }
570
+ if (messageTouchesIncompleteToolGroup(messageId, workingState)) {
571
+ skippedMessageIssues.push({
572
+ kind: "tool-group",
573
+ messageId,
574
+ detail: "The selected message belongs to an incomplete in-flight tool group and is not safe to replace yet.",
575
+ })
576
+ continue
577
+ }
312
578
 
313
579
  const { coveredBlocks, partialBlocks } = findCoveredAndPartialBlocks(
314
580
  meta.timestamp,
315
581
  meta.timestamp,
316
- state,
582
+ workingState,
583
+ { startMessageId: meta.stableId, endMessageId: meta.stableId },
317
584
  )
318
585
  if (coveredBlocks.length > 0 || partialBlocks.length > 0) {
319
586
  const blockList = [...coveredBlocks, ...partialBlocks]
@@ -323,7 +590,17 @@ export function registerCompressTool(
323
590
  continue
324
591
  }
325
592
 
326
- const anchor = resolveAnchorBoundary(meta.timestamp, state)
593
+ const anchor = resolveAnchorBoundary(meta.timestamp, workingState, meta.stableId)
594
+ const preparedProtectedFragments = await prepareCompressionProtectedFragments({
595
+ startTimestamp: meta.timestamp,
596
+ endTimestamp: meta.timestamp,
597
+ startMessageId: meta.stableId,
598
+ endMessageId: meta.stableId,
599
+ state: workingState,
600
+ config: effectiveConfig,
601
+ mode: "message",
602
+ cwd: (ctx as any).cwd,
603
+ })
327
604
 
328
605
  const created = createRangeCompressionBlock({
329
606
  topic: entry.topic ?? params.topic,
@@ -335,11 +612,16 @@ export function registerCompressTool(
335
612
  anchorTimestamp: anchor.timestamp,
336
613
  anchorMessageId: anchor.stableId,
337
614
  createdByToolCallId: _toolCallId,
338
- state,
615
+ state: workingState,
339
616
  config: effectiveConfig,
340
617
  mode: "message",
618
+ version: 2,
619
+ replacementMode: "message-body",
341
620
  validatePlaceholders: false,
342
621
  expandPlaceholders: false,
622
+ preparedProtectedFragments,
623
+ sourceMembers: preflightMessages.find((plan) => plan.messageId === messageId)?.sourceMembers,
624
+ mutationMembers: preflightMessages.find((plan) => plan.messageId === messageId)?.mutationMembers,
343
625
  })
344
626
  const block = created.block
345
627
  newBlockIds.push(block.id)
@@ -354,15 +636,58 @@ export function registerCompressTool(
354
636
  )
355
637
  }
356
638
 
357
- const clearedNudgeAnchors = newBlockIds.length > 0 ? clearDcpNudgeAnchors(state) : 0
639
+ let projection: ReturnType<typeof previewManualCompressionProjection> | undefined
640
+ let pressureRelieved = false
641
+ let clearedNudgeAnchors = 0
358
642
  if (newBlockIds.length > 0) {
359
- state.consecutiveIgnoredStrongNudges = 0
643
+ const newBlocks = workingState.compressionBlocks.filter((block) => newBlockIds.includes(block.id))
644
+ projection = previewManualCompressionProjection(state, effectiveConfig, newBlocks)
645
+ if (projection.netGain <= 0) {
646
+ log("compress.projection_rejected", {
647
+ toolCallId: _toolCallId,
648
+ projectedBeforeTokens: projection.projectedBeforeTokens,
649
+ projectedAfterTokens: projection.projectedAfterTokens,
650
+ netGain: projection.netGain,
651
+ state: summarizeDcpState(state, effectiveConfig),
652
+ })
653
+ throw new Error(
654
+ `Manual compression rejected non-positive full-projection gain: ` +
655
+ `${projection.projectedBeforeTokens} before, ${projection.projectedAfterTokens} after, ${projection.netGain} net. ` +
656
+ "No DCP state was published.",
657
+ )
658
+ }
659
+ // This is deliberately after the full projection proof. Partial
660
+ // progress retains its outstanding recovery debt and patience.
661
+ pressureRelieved = settleCompressionProgress(
662
+ workingState,
663
+ projection.netGain,
664
+ projection.projectedAfterTokens,
665
+ )
666
+ // The selected IDs may have been replaced, so one old reminder may be
667
+ // invalidated at this rewrite boundary. The next context pass will
668
+ // render a fresh anchor without erasing the patience counters above.
669
+ clearedNudgeAnchors = clearDcpNudgeAnchors(workingState)
670
+ if (!pressureRelieved) workingState.nudgeCounter = Math.max(1, effectiveConfig.compress.nudgeFrequency)
671
+ for (const block of workingState.compressionBlocks) {
672
+ if (newBlockIds.includes(block.id)) block.operationRequestHash = requestHash
673
+ }
674
+ assertCurrent()
675
+ let published = false
676
+ await persistState(ctx, workingState, {
677
+ beforePublish: assertCurrent,
678
+ onPublished: () => { published = true },
679
+ })
680
+ if (!published) assertCurrent()
681
+ if (state.sessionEpoch === operationEpoch) commitCompressionWorkingState(state, workingState)
682
+ for (const details of committedRangeLogs) log("compress.range_created", details)
360
683
  }
361
- if (clearedNudgeAnchors > 0) {
684
+ if (clearedNudgeAnchors > 0 && state.sessionEpoch === operationEpoch) {
362
685
  try {
363
686
  pi.appendEntry("dcp-nudge", {
364
687
  event: "cleared",
365
- reason: "compress",
688
+ reason: pressureRelieved ? "compress" : "compress-partial",
689
+ pressureRelieved,
690
+ remainingRecoveryTokens: workingState.compressionProgress?.remainingTokens ?? 0,
366
691
  clearedAnchors: clearedNudgeAnchors,
367
692
  blockIds: newBlockIds,
368
693
  createdAt: Date.now(),
@@ -372,22 +697,23 @@ export function registerCompressTool(
372
697
  }
373
698
  }
374
699
 
375
- if (newBlockIds.length > 0) {
376
- await saveDcpState(ctx, state)
377
- }
378
-
379
700
  log("compress.success", {
380
701
  toolCallId: _toolCallId,
381
702
  newBlockIds: newBlockIds.map((id) => `b${id}`),
382
703
  skippedMessages: skippedMessageIssues.length,
383
- state: summarizeDcpState(state),
704
+ projectedBeforeTokens: projection?.projectedBeforeTokens,
705
+ projectedAfterTokens: projection?.projectedAfterTokens,
706
+ netGain: projection?.netGain,
707
+ pressureRelieved,
708
+ remainingRecoveryTokens: workingState.compressionProgress?.remainingTokens ?? 0,
709
+ state: summarizeDcpState(state, effectiveConfig),
384
710
  })
385
711
 
386
- const usage = normalizeDcpContextUsage(safeGetContextUsage(ctx))
387
- const operationTokensSaved = Math.max(0, operationRemovedTokens - operationSummaryTokens)
712
+ const usage = state.sessionEpoch === operationEpoch ? normalizeDcpContextUsage(safeGetContextUsage(ctx)) : undefined
713
+ const operationTokensSaved = Math.max(0, projection?.netGain ?? operationRemovedTokens - operationSummaryTokens)
388
714
  const itemCount = ranges.length + messages.length
389
715
  const totalSummaryTokens = newBlockIds.reduce((sum, id) => {
390
- const b = state.compressionBlocks.find((block) => block.id === id)
716
+ const b = workingState.compressionBlocks.find((block) => block.id === id)
391
717
  return sum + (b?.summaryTokenEstimate ?? 0)
392
718
  }, 0)
393
719
  const visualDetails: DcpCompressionVisualDetails = {
@@ -409,6 +735,13 @@ export function registerCompressTool(
409
735
  }
410
736
  const resultDetails = {
411
737
  ...visualDetails,
738
+ projectedBeforeTokens: projection?.projectedBeforeTokens,
739
+ projectedAfterTokens: projection?.projectedAfterTokens,
740
+ netGain: projection?.netGain,
741
+ pressureRelieved,
742
+ remainingRecoveryTokens: workingState.compressionProgress?.remainingTokens ?? 0,
743
+ committed: true,
744
+ ownerChangedAfterPublication: state.sessionEpoch !== operationEpoch,
412
745
  outputFormat: "json" as const,
413
746
  }
414
747
 
@@ -422,6 +755,7 @@ export function registerCompressTool(
422
755
  ],
423
756
  details: resultDetails,
424
757
  }
758
+ })
425
759
  },
426
760
  })
427
761
  }