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,13 +3,13 @@ import type { DcpState } from "./state.js";
3
3
  import type { CompressionCandidate, MessageCompressionCandidate } from "./pruner-types.js";
4
4
  import {
5
5
  estimateMessageTokens,
6
- extractBlockId,
7
- messageText,
8
6
  } from "./pruner-metadata.js";
9
7
  import { stableMessageKeys } from "./pruner-message-ids.js";
8
+ import { detectToolGroupSpans, findConversationIndexEntry } from "./conversation-index.js";
10
9
 
11
10
  interface CandidateBoundary {
12
11
  id: string;
12
+ messageIndex: number;
13
13
  role: string;
14
14
  timestamp: number;
15
15
  tokenEstimate: number;
@@ -17,41 +17,62 @@ interface CandidateBoundary {
17
17
  isSystemReminder: boolean;
18
18
  }
19
19
 
20
- function hasAddressableSnapshot(state: DcpState): boolean {
21
- return state.messageMetaSnapshot.size > 0 || state.messageIdSnapshot.size > 0;
20
+ function assistantToolCallIds(message: any): string[] {
21
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return [];
22
+ return message.content
23
+ .filter((part: any) => part?.type === "toolCall" && typeof part.id === "string")
24
+ .map((part: any) => part.id as string);
25
+ }
26
+
27
+ function isRealUserBoundary(boundary: CandidateBoundary): boolean {
28
+ return boundary.role === "user" && boundary.blockId === undefined && !boundary.isSystemReminder;
29
+ }
30
+
31
+ function buildCandidateBoundaries(
32
+ messages: any[],
33
+ state: DcpState,
34
+ options: { allowBlocks: boolean },
35
+ ): CandidateBoundary[] {
36
+ const boundaries: CandidateBoundary[] = [];
37
+ const stableKeys = stableMessageKeys(messages);
38
+ for (let index = 0; index < messages.length; index++) {
39
+ const msg = messages[index];
40
+ const boundary = resolveAddressableBoundaryId(msg, stableKeys[index]!, state, options);
41
+ if (!boundary) continue;
42
+ if (!Number.isFinite(msg.timestamp)) continue;
43
+ boundaries.push({
44
+ id: boundary.id,
45
+ messageIndex: index,
46
+ role: msg.role ?? "",
47
+ timestamp: msg.timestamp,
48
+ tokenEstimate: state.messageMetaSnapshot.get(boundary.id)?.tokenEstimate ?? estimateMessageTokens(msg),
49
+ blockId: boundary.blockId,
50
+ isSystemReminder: msg?._dcpOrigin === "dcp-control",
51
+ });
52
+ }
53
+ return boundaries;
22
54
  }
23
55
 
24
56
  function isActiveBlockId(blockId: number, state: DcpState): boolean {
25
57
  return state.compressionBlocks.some((block) => block.id === blockId && block.active);
26
58
  }
27
59
 
28
- function findCurrentMessageId(msg: any, stableKey: string, state: DcpState): string | undefined {
29
- const role = msg?.role ?? "";
30
- const timestamp = msg?.timestamp;
31
- if (!Number.isFinite(timestamp)) return undefined;
60
+ /**
61
+ * Compression-block identity is internal projection provenance, never a
62
+ * provider-visible text convention. A raw message may quote a DCP block tag
63
+ * verbatim (including in a fenced example), so parsing its content would let
64
+ * untrusted text impersonate a generated summary.
65
+ */
66
+ function projectedBlockId(msg: any): number | undefined {
67
+ if (msg?._dcpOrigin !== "block") return undefined;
68
+ const blockId = msg?._dcpBlockId;
69
+ return Number.isInteger(blockId) && blockId > 0 ? blockId : undefined;
70
+ }
32
71
 
72
+ function findCurrentMessageId(stableKey: string, state: DcpState): string | undefined {
33
73
  for (const [id, meta] of state.messageMetaSnapshot) {
34
74
  if (meta.stableId === stableKey) return id;
35
75
  }
36
-
37
- // Backward compatibility for snapshots created before stable IDs were
38
- // recorded. Never mix timestamp fallback with a modern stable-ID snapshot.
39
- const isLegacySnapshot = [...state.messageMetaSnapshot.values()]
40
- .every((meta) => meta.stableId === undefined);
41
- if (!isLegacySnapshot) return undefined;
42
-
43
- for (const [id, meta] of state.messageMetaSnapshot) {
44
- if (
45
- meta.timestamp === timestamp &&
46
- meta.role === role &&
47
- meta.blockId === undefined
48
- ) return id;
49
- }
50
-
51
- for (const [id, ts] of state.messageIdSnapshot) {
52
- if (ts === timestamp) return id;
53
- }
54
-
55
76
  return undefined;
56
77
  }
57
78
 
@@ -60,51 +81,102 @@ function resolveAddressableBoundaryId(
60
81
  stableKey: string,
61
82
  state: DcpState,
62
83
  options: { allowBlocks: boolean },
63
- ): { id: string; blockId?: number; text: string } | null {
64
- const text = messageText(msg);
65
- const blockId = extractBlockId(text);
84
+ ): { id: string; blockId?: number } | null {
85
+ const blockId = projectedBlockId(msg);
66
86
  if (blockId !== undefined) {
67
- if (options.allowBlocks && isActiveBlockId(blockId, state)) return { id: `b${blockId}`, blockId, text };
68
- if (!hasAddressableSnapshot(state) && options.allowBlocks) return { id: `b${blockId}`, blockId, text };
87
+ if (options.allowBlocks && isActiveBlockId(blockId, state)) return { id: `b${blockId}`, blockId };
69
88
  return null;
70
89
  }
71
90
 
72
91
  // Inline [dcp-id] markers are no longer injected into message content; the
73
92
  // snapshot rebuilt by injectMessageIds() is the sole addressability source.
74
- // Resolve the message ID by its persistent stable identity. Timestamp/role
75
- // matching is retained only for legacy snapshots without stable IDs.
76
- const currentId = findCurrentMessageId(msg, stableKey, state);
77
- if (currentId) return { id: currentId, text };
93
+ // Resolve the message ID only by its persistent stable identity.
94
+ const currentId = findCurrentMessageId(stableKey, state);
95
+ if (currentId) return { id: currentId };
78
96
 
79
97
  return null;
80
98
  }
81
99
 
100
+ export interface CompressionCandidateSelectionOptions {
101
+ /** Net projected savings required to restore the current budget. */
102
+ requiredSavingsTokens?: number;
103
+ /** Conservative estimator margin added to the required source size. */
104
+ estimatorMarginTokens?: number;
105
+ }
106
+
107
+ function selectOldestSafePrefix(
108
+ boundaries: CandidateBoundary[],
109
+ state: DcpState,
110
+ settings: { minMessages: number; minTokens: number },
111
+ maxSafeMessageIndex: number,
112
+ options?: CompressionCandidateSelectionOptions,
113
+ ): CandidateBoundary[] | null {
114
+ if (boundaries.length === 0) return null;
115
+ if (!options || options.requiredSavingsTokens === undefined) {
116
+ let full = boundaries.slice();
117
+ while (full.length > 0 && full[0]!.isSystemReminder) full = full.slice(1);
118
+ while (full.length > 0 && full[full.length - 1]!.isSystemReminder) full = full.slice(0, -1);
119
+ if (full.length < Math.max(1, settings.minMessages)) return null;
120
+ if (full.reduce((sum, boundary) => sum + boundary.tokenEstimate, 0) < settings.minTokens) return null;
121
+ return full;
122
+ }
123
+ const requiredSavings = Math.max(0, Math.floor(options.requiredSavingsTokens));
124
+ const estimatorMargin = Math.max(0, Math.floor(options?.estimatorMarginTokens ?? 0));
125
+ const targetSourceTokens = Math.max(settings.minTokens, requiredSavings + estimatorMargin);
126
+ const minMessages = Math.max(1, settings.minMessages);
127
+
128
+ let selectedEnd = -1;
129
+ let selectedTokens = 0;
130
+ for (let index = 0; index < boundaries.length; index++) {
131
+ selectedTokens += boundaries[index]!.tokenEstimate;
132
+ if (index + 1 >= minMessages && selectedTokens >= targetSourceTokens) {
133
+ selectedEnd = index;
134
+ break;
135
+ }
136
+ }
137
+ if (selectedEnd < 0) selectedEnd = boundaries.length - 1;
138
+
139
+ let selected = boundaries.slice(0, selectedEnd + 1);
140
+ if (state.conversationIndexSnapshot.length > 0) {
141
+ const groups = detectToolGroupSpans(state.conversationIndexSnapshot);
142
+ const startMessageIndex = selected[0]!.messageIndex;
143
+ let endMessageIndex = selected[selected.length - 1]!.messageIndex;
144
+ let changed = true;
145
+ while (changed) {
146
+ changed = false;
147
+ for (const group of groups) {
148
+ const overlaps = group.startIndex <= endMessageIndex && group.endIndex >= startMessageIndex;
149
+ if (!overlaps) continue;
150
+ if (!group.complete) return null;
151
+ if (group.endIndex > endMessageIndex) {
152
+ if (group.endIndex > maxSafeMessageIndex) return null;
153
+ endMessageIndex = group.endIndex;
154
+ changed = true;
155
+ }
156
+ }
157
+ }
158
+ selected = boundaries.filter((boundary) => boundary.messageIndex <= endMessageIndex);
159
+ }
160
+
161
+ while (selected.length > 0 && selected[0]!.isSystemReminder) selected = selected.slice(1);
162
+ while (selected.length > 0 && selected[selected.length - 1]!.isSystemReminder) selected = selected.slice(0, -1);
163
+ if (selected.length < minMessages) return null;
164
+ if (selected.reduce((sum, boundary) => sum + boundary.tokenEstimate, 0) < settings.minTokens) return null;
165
+ return selected;
166
+ }
167
+
82
168
  export function detectCompressionCandidate(
83
169
  messages: any[],
84
170
  _state: DcpState,
85
171
  config: DcpConfig,
86
172
  contextPercent: number,
173
+ options?: CompressionCandidateSelectionOptions,
87
174
  ): CompressionCandidate | null {
88
175
  const settings = config.compress.autoCandidates;
89
176
  if (!settings.enabled) return null;
90
177
  if (contextPercent < settings.minContextPercent) return null;
91
178
 
92
- const boundaries: CandidateBoundary[] = [];
93
- const stableKeys = stableMessageKeys(messages);
94
- for (let index = 0; index < messages.length; index++) {
95
- const msg = messages[index];
96
- const boundary = resolveAddressableBoundaryId(msg, stableKeys[index]!, _state, { allowBlocks: true });
97
- if (!boundary) continue;
98
- if (!Number.isFinite(msg.timestamp)) continue;
99
- boundaries.push({
100
- id: boundary.id,
101
- role: msg.role ?? "",
102
- timestamp: msg.timestamp,
103
- tokenEstimate: _state.messageMetaSnapshot.get(boundary.id)?.tokenEstimate ?? estimateMessageTokens(msg),
104
- blockId: boundary.blockId,
105
- isSystemReminder: boundary.text.includes("<dcp-system-reminder>"),
106
- });
107
- }
179
+ const boundaries = buildCandidateBoundaries(messages, _state, { allowBlocks: true });
108
180
 
109
181
  if (boundaries.length < settings.minMessages) return null;
110
182
 
@@ -114,9 +186,7 @@ export function detectCompressionCandidate(
114
186
 
115
187
  for (let i = boundaries.length - 1; i >= 0; i--) {
116
188
  const boundary = boundaries[i]!;
117
- const isRealUserMessage =
118
- boundary.role === "user" && boundary.blockId === undefined && !boundary.isSystemReminder;
119
- if (!isRealUserMessage) continue;
189
+ if (!isRealUserBoundary(boundary)) continue;
120
190
  recentUserTurns++;
121
191
  if (recentUserTurns >= keepRecentTurns) {
122
192
  cutoffIndex = i - 1;
@@ -126,16 +196,157 @@ export function detectCompressionCandidate(
126
196
 
127
197
  if (cutoffIndex < 0) return null;
128
198
 
129
- let candidate = boundaries.slice(0, cutoffIndex + 1);
130
- while (candidate.length > 0 && candidate[0]!.isSystemReminder) candidate = candidate.slice(1);
131
- while (candidate.length > 0 && candidate[candidate.length - 1]!.isSystemReminder) {
132
- candidate = candidate.slice(0, -1);
199
+ const eligible = boundaries.slice(0, cutoffIndex + 1);
200
+ const candidate = selectOldestSafePrefix(
201
+ eligible,
202
+ _state,
203
+ settings,
204
+ eligible[eligible.length - 1]?.messageIndex ?? -1,
205
+ options,
206
+ );
207
+ if (!candidate) return null;
208
+
209
+ const estimatedTokens = candidate.reduce((sum, item) => sum + item.tokenEstimate, 0);
210
+
211
+ const includedBlockIds = Array.from(
212
+ new Set(candidate.map((item) => item.blockId).filter((id): id is number => id !== undefined)),
213
+ );
214
+
215
+ return {
216
+ startId: candidate[0]!.id,
217
+ endId: candidate[candidate.length - 1]!.id,
218
+ messageCount: candidate.length,
219
+ estimatedTokens,
220
+ includedBlockIds,
221
+ reason: options?.requiredSavingsTokens
222
+ ? `minimal oldest protocol-safe prefix restoring ~${Math.max(0, Math.floor(options.requiredSavingsTokens))} budget tokens; older than the most recent ${keepRecentTurns} user turn(s)`
223
+ : `older than the most recent ${keepRecentTurns} user turn(s)`,
224
+ };
225
+ }
226
+
227
+ /**
228
+ * Emergency-only range candidate for marathon turns.
229
+ *
230
+ * Normal candidates deliberately protect the newest N user turns. That means a
231
+ * single user task can never produce a range candidate no matter how many
232
+ * provider transactions follow it. Above the configured max-context threshold,
233
+ * this detector instead keeps the user request plus a live tail of recent tool
234
+ * transactions and exposes only the older, provider-committed prefix.
235
+ *
236
+ * The newest assistant group is always retained, even when
237
+ * keepRecentToolPairs=0, so the in-flight request/result head is never selected.
238
+ * For older tool groups, a later assistant is only structural ordering evidence:
239
+ * every tool result selected for compression must also have completed provider
240
+ * evidence in `providerSeenToolIds`. Unknown evidence truncates the safe prefix.
241
+ */
242
+ export function detectEmergencyCompressionCandidate(
243
+ messages: any[],
244
+ state: DcpState,
245
+ config: DcpConfig,
246
+ contextPercent: number,
247
+ maxContextPercent: number,
248
+ options?: CompressionCandidateSelectionOptions,
249
+ ): CompressionCandidate | null {
250
+ const settings = config.compress.autoCandidates;
251
+ const emergencySettings = config.strategies.emergencyCurrentTurnPruning;
252
+ // `autoCandidates.enabled` controls routine advisory suggestions only. The
253
+ // emergency planner is a safety path and is gated by its own destructive
254
+ // policy (`emergencyCurrentTurnPruning.enabled` / autoCompress at commit).
255
+ // Reuse autoCandidates sizing knobs without letting its advisory toggle
256
+ // silently disable emergency range planning.
257
+ if (!emergencySettings.enabled) return null;
258
+ if (contextPercent <= maxContextPercent) return null;
259
+
260
+ const boundaries = buildCandidateBoundaries(messages, state, { allowBlocks: true });
261
+ if (boundaries.length < settings.minMessages) return null;
262
+
263
+ let latestUserIndex = -1;
264
+ for (let index = boundaries.length - 1; index >= 0; index--) {
265
+ if (!isRealUserBoundary(boundaries[index]!)) continue;
266
+ latestUserIndex = boundaries[index]!.messageIndex;
267
+ break;
133
268
  }
269
+ if (latestUserIndex < 0) return null;
134
270
 
135
- if (candidate.length < settings.minMessages) return null;
271
+ const assistantIndexes: number[] = [];
272
+ for (let index = latestUserIndex + 1; index < messages.length; index++) {
273
+ if (messages[index]?.role === "assistant") assistantIndexes.push(index);
274
+ }
275
+ if (assistantIndexes.length < 2) return null;
276
+
277
+ // Always keep the newest assistant group as the live head. Then extend the
278
+ // preserved head backwards until it contains the requested number of newest
279
+ // complete tool-call/result pairs. Grouping by assistant avoids splitting a
280
+ // parallel tool-call transaction.
281
+ const keepRecentPairs = Math.max(0, Math.floor(emergencySettings.keepRecentToolPairs));
282
+ let preservedPairs = 0;
283
+ let preservedHeadStart = assistantIndexes[assistantIndexes.length - 1]!;
284
+ for (let assistantPos = assistantIndexes.length - 1; assistantPos >= 0; assistantPos--) {
285
+ const assistantIndex = assistantIndexes[assistantPos]!;
286
+ preservedHeadStart = assistantIndex;
287
+ const nextAssistantIndex = assistantIndexes[assistantPos + 1] ?? messages.length;
288
+ const callIds = new Set(assistantToolCallIds(messages[assistantIndex]));
289
+ if (callIds.size > 0) {
290
+ const completedIds = new Set<string>();
291
+ for (let index = assistantIndex + 1; index < nextAssistantIndex; index++) {
292
+ const message = messages[index];
293
+ if (
294
+ message?.role === "toolResult" &&
295
+ typeof message.toolCallId === "string" &&
296
+ callIds.has(message.toolCallId)
297
+ ) {
298
+ completedIds.add(message.toolCallId);
299
+ }
300
+ }
301
+ preservedPairs += completedIds.size;
302
+ }
303
+ if (assistantPos === assistantIndexes.length - 1 && keepRecentPairs === 0) break;
304
+ if (preservedPairs >= keepRecentPairs) break;
305
+ }
306
+
307
+ // F09: provider completion evidence is authoritative for tool-result
308
+ // eligibility in both emergency range and output-pruning paths. A later
309
+ // assistant establishes ordering only; it cannot prove that an earlier tool
310
+ // result survived serialization + stream completion. Stop before the first
311
+ // group whose evidence is incomplete/unknown rather than silently spanning it.
312
+ let evidenceSafeEnd = preservedHeadStart - 1;
313
+ const groups = detectToolGroupSpans(state.conversationIndexSnapshot);
314
+ for (const group of groups) {
315
+ if (group.endIndex <= latestUserIndex) continue;
316
+ if (group.startIndex >= preservedHeadStart) break;
317
+ if (!group.complete || group.toolCallIds.some((id) => !state.providerSeenToolIds.has(id))) {
318
+ evidenceSafeEnd = Math.min(evidenceSafeEnd, group.startIndex - 1);
319
+ break;
320
+ }
321
+ }
322
+
323
+ const eligible = boundaries.filter((boundary) =>
324
+ boundary.messageIndex > latestUserIndex &&
325
+ boundary.messageIndex <= evidenceSafeEnd,
326
+ );
327
+ const candidate = selectOldestSafePrefix(
328
+ eligible,
329
+ state,
330
+ settings,
331
+ preservedHeadStart - 1,
332
+ options,
333
+ );
334
+ if (!candidate) return null;
335
+
336
+ // Independent fail-closed guard: structural ordering is never enough to
337
+ // authorize deletion of an unseen tool result.
338
+ const selectedStart = candidate[0]!.messageIndex;
339
+ const selectedEnd = candidate[candidate.length - 1]!.messageIndex;
340
+ for (let index = selectedStart; index <= selectedEnd; index++) {
341
+ const message = messages[index];
342
+ if (
343
+ (message?.role === "toolResult" || message?.role === "bashExecution") &&
344
+ typeof message.toolCallId === "string" &&
345
+ !state.providerSeenToolIds.has(message.toolCallId)
346
+ ) return null;
347
+ }
136
348
 
137
349
  const estimatedTokens = candidate.reduce((sum, item) => sum + item.tokenEstimate, 0);
138
- if (estimatedTokens < settings.minTokens) return null;
139
350
 
140
351
  const includedBlockIds = Array.from(
141
352
  new Set(candidate.map((item) => item.blockId).filter((id): id is number => id !== undefined)),
@@ -147,7 +358,9 @@ export function detectCompressionCandidate(
147
358
  messageCount: candidate.length,
148
359
  estimatedTokens,
149
360
  includedBlockIds,
150
- reason: `older than the most recent ${keepRecentTurns} user turn(s)`,
361
+ reason: options?.requiredSavingsTokens
362
+ ? `minimal emergency same-turn provider-evidenced prefix restoring ~${Math.max(0, Math.floor(options.requiredSavingsTokens))} budget tokens; preserves newest ${keepRecentPairs} tool pair(s) plus the live assistant head`
363
+ : `emergency same-turn provider-evidenced prefix; preserves newest ${keepRecentPairs} tool pair(s) plus the live assistant head`,
151
364
  };
152
365
  }
153
366
 
@@ -171,21 +384,8 @@ export function detectMessageCompressionCandidates(
171
384
  if (!settings?.enabled) return [];
172
385
  if (contextPercent < settings.minContextPercent) return [];
173
386
 
174
- const boundaries: CandidateBoundary[] = [];
175
- const stableKeys = stableMessageKeys(messages);
176
- for (let index = 0; index < messages.length; index++) {
177
- const msg = messages[index];
178
- const boundary = resolveAddressableBoundaryId(msg, stableKeys[index]!, state, { allowBlocks: false });
179
- if (!boundary || boundary.blockId !== undefined) continue;
180
- if (!Number.isFinite(msg.timestamp)) continue;
181
- boundaries.push({
182
- id: boundary.id,
183
- role: msg.role ?? "",
184
- timestamp: msg.timestamp,
185
- tokenEstimate: state.messageMetaSnapshot.get(boundary.id)?.tokenEstimate ?? estimateMessageTokens(msg),
186
- isSystemReminder: boundary.text.includes("<dcp-system-reminder>"),
187
- });
188
- }
387
+ const boundaries = buildCandidateBoundaries(messages, state, { allowBlocks: false })
388
+ .filter((boundary) => boundary.blockId === undefined);
189
389
 
190
390
  const keepRecentTurns = Math.max(1, settings.keepRecentTurns ?? 2);
191
391
  let recentUserTurns = 0;
@@ -202,7 +402,10 @@ export function detectMessageCompressionCandidates(
202
402
  }
203
403
  }
204
404
 
205
- if (cutoffIndex < 0) return [];
405
+ // If the transcript does not yet contain enough complete user turns to
406
+ // satisfy the retention policy, every message belongs to the protected
407
+ // recent window. Do not reinterpret the live head as stale history.
408
+ if (recentUserTurns < keepRecentTurns || cutoffIndex < 0) return [];
206
409
 
207
410
  const mediumTokens = Math.max(1, settings.mediumTokens ?? 500);
208
411
  const highTokens = Math.max(mediumTokens, settings.highTokens ?? 5000);
@@ -212,6 +415,23 @@ export function detectMessageCompressionCandidates(
212
415
  .slice(0, cutoffIndex + 1)
213
416
  .filter((candidate) => !candidate.isSystemReminder)
214
417
  .filter((candidate) => candidate.role !== "user" || !config.compress.protectUserMessages)
418
+ // V2 message mode replaces only the selected body, so completed tool
419
+ // results are safe surgical candidates. Signed assistants and assistants
420
+ // carrying tool calls remain structurally immutable, and an incomplete
421
+ // tool group is still in-flight even if it appears in an unusual snapshot.
422
+ .filter((candidate) => {
423
+ const message = messages[candidate.messageIndex];
424
+ if (candidate.role === "assistant" && assistantToolCallIds(message).length > 0) return false;
425
+ const indexEntry = findConversationIndexEntry(state.conversationIndexSnapshot, candidate.id);
426
+ if (candidate.role === "assistant" && indexEntry?.signedAssistant) return false;
427
+ if (indexEntry) {
428
+ const incompleteGroup = detectToolGroupSpans(state.conversationIndexSnapshot).some((group) =>
429
+ !group.complete && indexEntry.index >= group.startIndex && indexEntry.index <= group.endIndex,
430
+ );
431
+ if (incompleteGroup) return false;
432
+ }
433
+ return true;
434
+ })
215
435
  .filter((candidate) => candidate.tokenEstimate >= mediumTokens)
216
436
  .map((candidate): MessageCompressionCandidate => ({
217
437
  messageId: candidate.id,