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
@@ -6,7 +6,7 @@ import type {
6
6
  MessageCompressionCandidate,
7
7
  NudgeThresholds,
8
8
  } from "./pruner-types.js";
9
- import { extractBlockId, messageText } from "./pruner-metadata.js";
9
+ import { messageText } from "./pruner-metadata.js";
10
10
  import { stableMessageKeys } from "./pruner-message-ids.js";
11
11
 
12
12
  function coercePercentThreshold(value: number | string | undefined, fallback: number): number {
@@ -21,23 +21,6 @@ function coercePercentThreshold(value: number | string | undefined, fallback: nu
21
21
  return parsed <= 1 ? parsed : fallback;
22
22
  }
23
23
 
24
- export function injectNudge(messages: any[], nudgeText: string): void {
25
- messages.push({
26
- role: "user",
27
- content: nudgeText,
28
- timestamp: Date.now(),
29
- });
30
- }
31
-
32
- function typePriority(type: DcpNudgeType): number {
33
- switch (type) {
34
- case "context-strong": return 4;
35
- case "context-soft": return 3;
36
- case "iteration": return 2;
37
- case "turn": return 1;
38
- }
39
- }
40
-
41
24
  function nudgeTypeLabel(type: DcpNudgeType): string {
42
25
  switch (type) {
43
26
  case "context-strong": return "context limit (strong)";
@@ -50,45 +33,28 @@ function nudgeTypeLabel(type: DcpNudgeType): string {
50
33
  function isRealAnchorCandidate(msg: any): boolean {
51
34
  const role = msg?.role ?? "";
52
35
  if (role !== "user" && role !== "assistant") return false;
53
- const text = messageText(msg);
54
- if (extractBlockId(text) !== undefined) return false;
55
- return true;
36
+ return msg?._dcpOrigin !== "block" && msg?._dcpOrigin !== "dcp-control";
56
37
  }
57
38
 
58
39
  function findAnchorMessage(messages: any[]): { msg: any; index: number; stableId: string; timestamp: number; role: string } | null {
59
40
  const stableKeys = stableMessageKeys(messages);
60
- // Prefer the latest real user message: it gives the reminder direct user-like
61
- // salience without creating a new synthetic message at the end of context.
41
+ // A reminder may only be introduced on a fresh user tail. If assistant/tool
42
+ // traffic already follows that user message then it has already belonged to
43
+ // an earlier provider prefix; editing it now would invalidate that prefix.
62
44
  for (let i = messages.length - 1; i >= 0; i--) {
63
45
  const msg = messages[i];
64
- if (msg?.role !== "user" || !isRealAnchorCandidate(msg)) continue;
65
- if (!Number.isFinite(msg.timestamp)) continue;
66
- return {
67
- msg,
68
- index: i,
69
- stableId: stableKeys[i]!,
70
- timestamp: msg.timestamp,
71
- role: msg.role,
72
- };
73
- }
74
-
75
- // Fallback to assistant messages if no raw user message is present.
76
- for (let i = messages.length - 1; i >= 0; i--) {
77
- const msg = messages[i];
78
- if (msg?.role !== "assistant" || !isRealAnchorCandidate(msg)) continue;
79
- if (!Number.isFinite(msg.timestamp)) continue;
80
- return {
81
- msg,
82
- index: i,
83
- stableId: stableKeys[i]!,
84
- timestamp: msg.timestamp,
85
- role: msg.role,
86
- };
46
+ if (msg?._dcpOrigin === "dcp-control") continue;
47
+ if (msg?.role !== "user" || !isRealAnchorCandidate(msg) || !Number.isFinite(msg.timestamp)) return null;
48
+ return { msg, index: i, stableId: stableKeys[i]!, timestamp: msg.timestamp, role: msg.role };
87
49
  }
88
50
 
89
51
  return null;
90
52
  }
91
53
 
54
+ export function hasCacheSafeNudgeCarrier(messages: any[]): boolean {
55
+ return findAnchorMessage(messages) !== null;
56
+ }
57
+
92
58
  function anchorMatchesMessage(anchor: DcpNudgeAnchor, msg: any, stableKey: string): boolean {
93
59
  if (anchor.anchorStableId && stableKey === anchor.anchorStableId) return true;
94
60
  return msg?.timestamp === anchor.anchorTimestamp;
@@ -220,41 +186,27 @@ export function upsertNudgeAnchor(
220
186
  if (!existing || isNewerAnchor(anchor, existing)) existing = anchor;
221
187
  }
222
188
 
223
- const now = Date.now();
224
189
  if (existing) {
225
- // Keep the original carrier and frozen text. Moving a reminder to the
226
- // newest user message would remove bytes from an old provider-cache prefix.
190
+ // Once published, both carrier and bytes are immutable. A later urgency
191
+ // increase must not rewrite an old provider prefix.
227
192
  state.nudgeAnchors = [existing];
228
- const shouldUpgrade = typePriority(type) > typePriority(existing.type);
229
- if (shouldUpgrade) {
230
- existing.type = type;
231
- existing.contextPercent = options.contextPercent ?? existing.contextPercent;
232
- existing.renderedReminder = options.renderedReminder ?? existing.renderedReminder;
233
- existing.updatedAt = now;
234
- } else if (!existing.renderedReminder && options.renderedReminder) {
235
- // One-time migration for anchors persisted before frozen rendering.
236
- existing.renderedReminder = options.renderedReminder;
237
- }
238
- state.lastNudge = {
239
- type: existing.type,
240
- anchorId: existing.id,
241
- anchorTimestamp: existing.anchorTimestamp,
242
- anchorStableId: existing.anchorStableId,
243
- contextPercent: existing.contextPercent,
244
- createdAt: now,
245
- };
246
- return { anchor: existing, created: false, updated: shouldUpgrade };
193
+ return { anchor: existing, created: false, updated: false };
247
194
  }
248
195
 
249
- const target = findAnchorMessage(messages);
250
- if (!target) {
251
- // The caller will append one synthetic tail reminder. Drop persisted
252
- // anchors first so applyAnchoredNudges cannot render a second reminder.
196
+ if (state.nudgeAnchors.length > 0) {
253
197
  state.nudgeAnchors = [];
254
198
  state.lastNudge = undefined;
199
+ }
200
+
201
+ const target = findAnchorMessage(messages);
202
+ if (!target) {
203
+ // Mid-turn reminder creation is deliberately deferred rather than
204
+ // synthesizing an ephemeral tail message that would disappear on the next
205
+ // provider continuation and break prefix equality.
255
206
  return { anchor: null, created: false, updated: false };
256
207
  }
257
208
 
209
+ const now = Date.now();
258
210
  const anchor: DcpNudgeAnchor = {
259
211
  id: state.nextNudgeAnchorId++,
260
212
  type,
@@ -301,8 +253,7 @@ export function applyAnchoredNudges(
301
253
  }
302
254
  }
303
255
 
304
- // Defensive migration for persisted pre-singleton state: render only the
305
- // newest valid anchor and discard every stale predecessor.
256
+ // Render only the newest valid anchor and discard stale predecessors.
306
257
  state.nudgeAnchors = selected ? [selected.anchor] : [];
307
258
  const anchorSetChanged = state.nudgeAnchors.length !== priorAnchors.length ||
308
259
  state.nudgeAnchors.some((anchor, index) => anchor !== priorAnchors[index]);
@@ -312,15 +263,12 @@ export function applyAnchoredNudges(
312
263
  const reminder = selected.anchor.renderedReminder ?? render(selected.anchor);
313
264
  if (materializedReminder) selected.anchor.renderedReminder = reminder;
314
265
  const anchorMessage = messages[selected.index];
315
- if (anchorMessage?.role === "assistant") {
316
- messages.splice(selected.index + 1, 0, {
317
- role: "user",
318
- content: reminder,
319
- timestamp: selected.anchor.createdAt,
320
- });
321
- } else {
322
- appendTextToMessage(anchorMessage, reminder);
266
+ if (anchorMessage?.role !== "user") {
267
+ state.nudgeAnchors = [];
268
+ state.lastNudge = undefined;
269
+ return { rendered: false, stateChanged: true };
323
270
  }
271
+ appendTextToMessage(anchorMessage, reminder);
324
272
  return {
325
273
  rendered: true,
326
274
  stateChanged: anchorSetChanged || materializedReminder,
@@ -1,19 +1,28 @@
1
1
  import type { DcpConfig } from "./config.js";
2
2
  import type { DcpState, ToolRecord } from "./state.js";
3
- import { estimateMessageTokens } from "./pruner-metadata.js";
3
+ import { preserveRawMutationHash } from "./conversation-index.js";
4
4
 
5
5
  export const EMERGENCY_CURRENT_TURN_PLACEHOLDER =
6
6
  "[Older tool output removed during current-turn context emergency; re-run the tool if exact content is needed]";
7
7
 
8
- // Tool outputs that must never be auto-pruned unless a future explicit user
9
- // action intentionally changes that policy. They mutate state or control DCP.
10
- const ALWAYS_PROTECTED_TOOLS = new Set(["compress", "write", "edit"]);
8
+ // Tool outputs whose effects may mutate the workspace/process or control DCP.
9
+ // Normalize names so provider/extension casing and aliases cannot bypass the
10
+ // safety policy. Unknown tools are handled conservatively by autonomous
11
+ // supersession below rather than assumed read-only.
12
+ const ALWAYS_PROTECTED_TOOLS = new Set([
13
+ "compress", "write", "edit", "apply_patch", "patch",
14
+ "bash", "shell", "powershell", "exec", "execute",
15
+ ]);
16
+
17
+ function normalizeToolName(name: string | undefined): string {
18
+ return (name ?? "").trim().toLowerCase().replace(/[\s-]+/g, "_");
19
+ }
11
20
 
12
21
  function buildProtectedTools(config: DcpConfig, extra: string[] = []): Set<string> {
13
22
  return new Set([
14
23
  ...ALWAYS_PROTECTED_TOOLS,
15
- ...(config.compress.protectedTools ?? []),
16
- ...extra,
24
+ ...(config.compress.protectedTools ?? []).map(normalizeToolName),
25
+ ...extra.map(normalizeToolName),
17
26
  ]);
18
27
  }
19
28
 
@@ -82,38 +91,13 @@ function isProtectedByFilePattern(record: ToolRecord | undefined, config: DcpCon
82
91
  );
83
92
  }
84
93
 
85
- function recordForToolResult(msg: any, state: DcpState): ToolRecord | undefined {
86
- if (typeof msg.toolCallId !== "string") return undefined;
87
- return state.toolCalls.get(msg.toolCallId);
88
- }
89
-
90
- function toolNameForResult(msg: any, record: ToolRecord | undefined): string {
91
- return record?.toolName ?? msg.toolName ?? "";
92
- }
93
-
94
- function estimateToolResultTokens(msg: any, record: ToolRecord | undefined): number {
95
- return Math.max(record?.tokenEstimate ?? 0, estimateMessageTokens(msg));
96
- }
97
-
98
- function toolResultIsProtected(
99
- msg: any,
100
- record: ToolRecord | undefined,
101
- config: DcpConfig,
102
- extraProtectedTools: string[] = [],
103
- ): boolean {
104
- const protectedTools = buildProtectedTools(config, extraProtectedTools);
105
- const toolName = toolNameForResult(msg, record);
106
- if (protectedTools.has(toolName)) return true;
107
- return isProtectedByFilePattern(record, config);
108
- }
109
-
110
94
  export function isToolRecordProtected(
111
95
  record: ToolRecord,
112
96
  config: DcpConfig,
113
97
  extraProtectedTools: string[] = [],
114
98
  ): boolean {
115
99
  const protectedTools = buildProtectedTools(config, extraProtectedTools);
116
- if (protectedTools.has(record.toolName)) return true;
100
+ if (protectedTools.has(normalizeToolName(record.toolName))) return true;
117
101
  return isProtectedByFilePattern(record, config);
118
102
  }
119
103
 
@@ -138,127 +122,32 @@ export function markToolPruned(
138
122
  return !wasAlreadyPruned;
139
123
  }
140
124
 
141
- export function applyDeduplication(messages: any[], state: DcpState, config: DcpConfig): void {
142
- if (!config.strategies.deduplication.enabled) return;
143
- if (state.manualMode && !config.manualMode.automaticStrategies) return;
144
-
145
- // fingerprint → array of toolCallIds in timestamp order
146
- const fingerprintMap = new Map<string, Array<{ id: string; tokens: number }>>();
147
-
148
- for (const msg of messages) {
149
- if (msg.role !== "toolResult") continue;
150
- const record = recordForToolResult(msg, state);
151
- if (!record) continue;
152
- if (toolResultIsProtected(msg, record, config, config.strategies.deduplication.protectedTools ?? [])) continue;
153
-
154
- const fp = record.inputFingerprint;
155
- if (!fingerprintMap.has(fp)) {
156
- fingerprintMap.set(fp, []);
157
- }
158
- fingerprintMap.get(fp)!.push({ id: msg.toolCallId, tokens: estimateToolResultTokens(msg, record) });
159
- }
160
-
161
- // For each fingerprint with duplicates, prune all but the last
162
- for (const [, ids] of fingerprintMap) {
163
- if (ids.length <= 1) continue;
164
- // Keep the last one; prune the rest
165
- for (let i = 0; i < ids.length - 1; i++) {
166
- markToolPruned(state, ids[i]!.id, "duplicate", ids[i]!.tokens);
167
- }
168
- }
169
- }
170
-
171
- /**
172
- * Apply error purging: mark old error tool outputs for pruning.
173
- * Mutates state.prunedToolIds.
174
- */
175
- export function applyErrorPurging(messages: any[], state: DcpState, config: DcpConfig): void {
176
- if (!config.strategies.purgeErrors.enabled) return;
177
- if (state.manualMode && !config.manualMode.automaticStrategies) return;
178
-
179
- const turnsThreshold = config.strategies.purgeErrors.turns ?? 3;
180
-
181
- for (const msg of messages) {
182
- if (msg.role !== "toolResult") continue;
183
- if (!msg.isError) continue;
184
-
185
- const record = recordForToolResult(msg, state);
186
- if (!record) continue;
187
- if (toolResultIsProtected(msg, record, config, config.strategies.purgeErrors.protectedTools ?? [])) continue;
188
-
189
- if (state.currentTurn - record.turnIndex >= turnsThreshold) {
190
- markToolPruned(state, msg.toolCallId, "old-error", estimateToolResultTokens(msg, record));
191
- }
192
- }
193
- }
194
-
195
- /**
196
- * Policy-based autonomous tool-output pruning. This is the non-LLM half of
197
- * DCP: large, old, repeated, or stale discovery outputs can be replaced with
198
- * placeholders without waiting for the model to call `compress`.
199
- */
200
- export function applyAutoToolOutputPruning(messages: any[], state: DcpState, config: DcpConfig): void {
201
- const strategy = config.strategies.autoToolPruning;
202
- if (!strategy.enabled) return;
203
- if (state.manualMode && !config.manualMode.automaticStrategies) return;
204
-
205
- const maxOutputTokens = Math.max(1, strategy.maxOutputTokens ?? 2000);
206
- const keepRecentTurns = Math.max(0, strategy.keepRecentTurns ?? 2);
207
- const readLikeTurns = Math.max(0, strategy.readLikeTurns ?? 3);
208
- const readLikeTools = new Set(strategy.readLikeTools ?? []);
209
-
210
- for (const msg of messages) {
211
- if (msg.role !== "toolResult") continue;
212
- if (typeof msg.toolCallId !== "string") continue;
213
-
214
- const record = recordForToolResult(msg, state);
215
- if (!record) continue;
216
- if (toolResultIsProtected(msg, record, config, strategy.protectedTools ?? [])) continue;
217
-
218
- const ageTurns = Math.max(0, state.currentTurn - record.turnIndex);
219
- const tokenEstimate = estimateToolResultTokens(msg, record);
220
- const toolName = toolNameForResult(msg, record);
221
-
222
- if (tokenEstimate > maxOutputTokens && ageTurns >= keepRecentTurns) {
223
- markToolPruned(state, msg.toolCallId, "large-output", tokenEstimate);
224
- } else if (readLikeTools.has(toolName) && ageTurns >= readLikeTurns) {
225
- markToolPruned(state, msg.toolCallId, "stale-read", tokenEstimate);
226
- }
227
- }
228
- }
229
-
230
125
  function placeholderForPrunedTool(msg: any, state: DcpState): string {
231
126
  const reason = state.prunedToolReasons.get(msg.toolCallId);
232
- if (reason === "duplicate") {
233
- return "[Output removed to save context - duplicate tool call; latest matching result kept]";
234
- }
235
- if (reason === "large-output") {
236
- return "[Large tool output removed to save context after it aged out of the active working set]";
237
- }
238
- if (reason === "stale-read") {
239
- return "[Stale read/search output removed to save context; re-read if exact content is needed again]";
240
- }
241
127
  if (reason === "manual-sweep") {
242
128
  return "[Output removed by /dcp sweep to save context]";
243
129
  }
244
130
  if (reason === "emergency-current-turn") {
245
131
  return EMERGENCY_CURRENT_TURN_PLACEHOLDER;
246
132
  }
247
- if (reason === "old-error" || msg.isError) {
248
- return "[Error output removed - tool failed more than the configured number of turns ago]";
249
- }
250
133
  return "[Output removed to save context - information superseded or no longer needed]";
251
134
  }
252
135
 
253
136
  /**
254
137
  * Apply explicit tool output pruning from state.prunedToolIds.
255
138
  * Replaces content of matching toolResult messages in place.
139
+ *
140
+ * The canonical hash of the un-pruned raw message is preserved on the
141
+ * projection entry (runtime-only) before the body is replaced: exact mutation
142
+ * membership for compression blocks must bind to the raw content a later
143
+ * materialization pass will actually see, not to this placeholder.
256
144
  */
257
145
  export function applyToolOutputPruning(messages: any[], state: DcpState): void {
258
146
  for (const msg of messages) {
259
147
  if (msg.role !== "toolResult") continue;
260
148
  if (!state.prunedToolIds.has(msg.toolCallId)) continue;
261
149
 
150
+ preserveRawMutationHash(msg);
262
151
  msg.content = [
263
152
  {
264
153
  type: "text",
@@ -1,14 +1,10 @@
1
1
  import type { DcpConfig } from "./config.js";
2
2
  import type { DcpState } from "./state.js";
3
- import { applyCompressionBlocks, repairOrphanedToolPairs, syncCompressionBlocks } from "./pruner-compression-blocks.js";
3
+ import { applyCompressionBlocks, syncCompressionBlocks } from "./pruner-compression-blocks.js";
4
4
  import { stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js";
5
5
  import { injectMessageIds } from "./pruner-message-ids.js";
6
- import {
7
- applyAutoToolOutputPruning,
8
- applyDeduplication,
9
- applyErrorPurging,
10
- applyToolOutputPruning,
11
- } from "./pruner-tools.js";
6
+ import { copyRawMutationHash } from "./conversation-index.js";
7
+ import { applyToolOutputPruning } from "./pruner-tools.js";
12
8
 
13
9
  export type {
14
10
  CompressionCandidate,
@@ -35,6 +31,7 @@ export {
35
31
  } from "./pruner-tools.js";
36
32
  export {
37
33
  detectCompressionCandidate,
34
+ detectEmergencyCompressionCandidate,
38
35
  detectMessageCompressionCandidates,
39
36
  formatCompressionCandidateHint,
40
37
  formatMessageCompressionCandidateHint,
@@ -44,7 +41,7 @@ export {
44
41
  applyAnchoredNudges,
45
42
  clearDcpNudgeAnchors,
46
43
  getNudgeType,
47
- injectNudge,
44
+ hasCacheSafeNudgeCarrier,
48
45
  nudgeTypeLabel,
49
46
  upsertNudgeAnchor,
50
47
  } from "./pruner-nudge.js";
@@ -63,7 +60,13 @@ export function applyPruning(
63
60
  typeof contentBlock === "object" && contentBlock !== null ? { ...contentBlock } : contentBlock,
64
61
  );
65
62
  }
66
- return stripStaleDcpMetadataFromMessage(clone);
63
+ const stripped = stripStaleDcpMetadataFromMessage(clone);
64
+ for (const key of ["_dcpOrigin", "_dcpBlockId", "_dcpStableId"] as const) {
65
+ const descriptor = Object.getOwnPropertyDescriptor(m, key);
66
+ if (descriptor) Object.defineProperty(stripped, key, descriptor);
67
+ }
68
+ copyRawMutationHash(m, stripped);
69
+ return stripped;
67
70
  });
68
71
 
69
72
  // 1. Count user turns → update state.currentTurn. Do this before inserting
@@ -75,32 +78,14 @@ export function applyPruning(
75
78
  syncCompressionBlocks(msgs, state, config);
76
79
  applyCompressionBlocks(msgs, state);
77
80
 
78
- // 2b. Post-compression safety net: remove any orphaned tool pairs that the
79
- // expansion logic could not catch (e.g. multi-block interactions, pre-broken state).
80
- repairOrphanedToolPairs(msgs);
81
-
82
- // 3-5. Discover new automatic pruning decisions only at stable checkpoints.
83
- // Rewriting an old result after every same-turn duplicate breaks provider
84
- // continuation repeatedly. A new user turn or compression block already
85
- // establishes a natural history boundary where one prefix rebuild is
86
- // acceptable. Branches that move the turn count backwards also reopen the
87
- // checkpoint instead of suppressing pruning indefinitely.
88
- const newestBlockId = Math.max(0, state.nextBlockId - 1);
89
- const automaticPruneCheckpoint =
90
- state.currentTurn !== state.lastAutomaticPruneTurn ||
91
- newestBlockId !== state.lastAutomaticPruneBlockId;
92
- if (automaticPruneCheckpoint) {
93
- applyDeduplication(msgs, state, config);
94
- applyErrorPurging(msgs, state, config);
95
- applyAutoToolOutputPruning(msgs, state, config);
96
- state.lastAutomaticPruneTurn = state.currentTurn;
97
- state.lastAutomaticPruneBlockId = newestBlockId;
98
- }
99
-
100
- // 6. Apply explicit tool output pruning (prunedToolIds)
81
+ // Existing explicit/emergency pruning decisions are replayed, but routine
82
+ // context construction never discovers new retroactive deletions. A new user
83
+ // turn must remain append-only for provider prefix caching; destructive
84
+ // rewrites happen only through an explicit compress/sweep boundary or the
85
+ // bounded emergency path.
101
86
  applyToolOutputPruning(msgs, state);
102
87
 
103
- // 7. Refresh message ID snapshots and append stable distributed metadata to
88
+ // Refresh message ID snapshots and append stable distributed metadata to
104
89
  // user/tool-result carriers. Assistant items remain byte-stable.
105
90
  injectMessageIds(msgs, state, { config });
106
91
 
@@ -0,0 +1,129 @@
1
+ import { createInputFingerprint, type DcpState, type ToolRecord } from "./state.js"
2
+ import { estimateMessageTokens, messageText } from "./pruner-metadata.js"
3
+
4
+ export interface DcpToolRecordRehydration {
5
+ recordsUpdated: number
6
+ exactArgsRestored: number
7
+ exactOutputsRestored: number
8
+ }
9
+
10
+ interface RawToolCall {
11
+ id: string
12
+ name: string
13
+ input: Record<string, unknown>
14
+ turnIndex: number
15
+ timestamp: number
16
+ }
17
+
18
+ function assistantToolCalls(message: any, turnIndex: number): RawToolCall[] {
19
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return []
20
+ const calls: RawToolCall[] = []
21
+ for (const part of message.content) {
22
+ if (!part || part.type !== "toolCall" || typeof part.id !== "string") continue
23
+ const name = typeof part.name === "string"
24
+ ? part.name
25
+ : typeof part.function?.name === "string"
26
+ ? part.function.name
27
+ : ""
28
+ const rawInput = part.input ?? part.arguments ?? part.function?.arguments
29
+ let input: Record<string, unknown> = {}
30
+ if (rawInput && typeof rawInput === "object" && !Array.isArray(rawInput)) {
31
+ input = rawInput as Record<string, unknown>
32
+ } else if (typeof rawInput === "string") {
33
+ try {
34
+ const parsed = JSON.parse(rawInput)
35
+ if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) input = parsed
36
+ } catch {
37
+ // Keep opaque/non-JSON provider arguments out of structured recovery.
38
+ }
39
+ }
40
+ calls.push({
41
+ id: part.id,
42
+ name,
43
+ input,
44
+ turnIndex,
45
+ timestamp: Number.isFinite(message.timestamp) ? message.timestamp : 0,
46
+ })
47
+ }
48
+ return calls
49
+ }
50
+
51
+ function hasRestoredOnlyArgs(record: ToolRecord): boolean {
52
+ const keys = Object.keys(record.inputArgs ?? {})
53
+ return keys.length === 0 || (keys.length === 1 && keys[0] === "_restoredValues")
54
+ }
55
+
56
+ /**
57
+ * Rebuild exact, ephemeral tool metadata from the raw session branch.
58
+ *
59
+ * The journal intentionally persists only projection decisions, not tool
60
+ * outputs or delivery evidence. After restart this function reconstructs the
61
+ * tool records needed for protection/fingerprinting from raw history while
62
+ * leaving `providerSeenToolIds` untouched. Absence of provider evidence after
63
+ * restart therefore remains "unknown" and cannot authorize emergency pruning.
64
+ */
65
+ export function rehydrateToolRecordsFromMessages(
66
+ messages: any[],
67
+ state: DcpState,
68
+ ): DcpToolRecordRehydration {
69
+ const calls = new Map<string, RawToolCall>()
70
+ let turnIndex = 0
71
+ for (const message of messages) {
72
+ if (message?.role === "user") turnIndex++
73
+ for (const call of assistantToolCalls(message, turnIndex)) calls.set(call.id, call)
74
+ }
75
+
76
+ let recordsUpdated = 0
77
+ let exactArgsRestored = 0
78
+ let exactOutputsRestored = 0
79
+ const touched = new Set<string>()
80
+
81
+ for (const message of messages) {
82
+ if (message?.role !== "toolResult" || typeof message.toolCallId !== "string") continue
83
+ const call = calls.get(message.toolCallId)
84
+ const text = messageText(message).trim()
85
+ const toolName = call?.name || (typeof message.toolName === "string" ? message.toolName : "")
86
+ let record = state.toolCalls.get(message.toolCallId)
87
+ if (!record) {
88
+ const inputArgs = call?.input ?? {}
89
+ record = {
90
+ toolCallId: message.toolCallId,
91
+ toolName,
92
+ inputArgs,
93
+ inputFingerprint: createInputFingerprint(toolName, inputArgs),
94
+ isError: message.isError === true,
95
+ turnIndex: call?.turnIndex ?? turnIndex,
96
+ timestamp: Number.isFinite(message.timestamp) ? message.timestamp : call?.timestamp ?? 0,
97
+ tokenEstimate: estimateMessageTokens(message),
98
+ ...(text ? { outputText: text } : {}),
99
+ ...(message.details !== undefined ? { outputDetails: message.details } : {}),
100
+ }
101
+ state.toolCalls.set(message.toolCallId, record)
102
+ if (Object.keys(inputArgs).length > 0) exactArgsRestored++
103
+ if (text) exactOutputsRestored++
104
+ touched.add(message.toolCallId)
105
+ continue
106
+ }
107
+ if (call && hasRestoredOnlyArgs(record) && Object.keys(call.input).length > 0) {
108
+ record.inputArgs = call.input
109
+ if (call.name) record.toolName = call.name
110
+ record.inputFingerprint = createInputFingerprint(record.toolName, call.input)
111
+ record.turnIndex = call.turnIndex
112
+ exactArgsRestored++
113
+ touched.add(message.toolCallId)
114
+ }
115
+ if (!record.outputText && text) {
116
+ record.outputText = text
117
+ exactOutputsRestored++
118
+ touched.add(message.toolCallId)
119
+ }
120
+ if (typeof message.toolName === "string" && message.toolName) record.toolName = message.toolName
121
+ if (typeof message.isError === "boolean") record.isError = message.isError
122
+ if (Number.isFinite(message.timestamp)) record.timestamp = message.timestamp
123
+ record.tokenEstimate = Math.max(record.tokenEstimate ?? 0, estimateMessageTokens(message))
124
+ }
125
+
126
+ recordsUpdated = touched.size
127
+ state.totalToolCallCount = Math.max(state.totalToolCallCount, state.toolCalls.size)
128
+ return { recordsUpdated, exactArgsRestored, exactOutputsRestored }
129
+ }