pi-ui-extend 0.1.56 → 0.1.59

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 (44) hide show
  1. package/dist/app/app.js +2 -0
  2. package/dist/app/commands/command-session-actions.js +3 -2
  3. package/dist/app/extensions/extension-actions-controller.js +1 -3
  4. package/dist/app/rendering/conversation-entry-renderer.d.ts +3 -0
  5. package/dist/app/rendering/conversation-entry-renderer.js +2 -0
  6. package/dist/app/rendering/conversation-viewport.d.ts +3 -0
  7. package/dist/app/rendering/conversation-viewport.js +4 -0
  8. package/dist/app/rendering/extension-entry-renderer.d.ts +6 -0
  9. package/dist/app/rendering/extension-entry-renderer.js +100 -0
  10. package/dist/app/rendering/tool-block-renderer.d.ts +5 -0
  11. package/dist/app/rendering/tool-block-renderer.js +1 -1
  12. package/dist/app/screen/mouse-controller.js +1 -1
  13. package/dist/app/session/lazy-session-manager.js +18 -1
  14. package/dist/app/session/pix-system-message.d.ts +1 -0
  15. package/dist/app/session/pix-system-message.js +4 -0
  16. package/dist/app/session/session-event-controller.js +7 -1
  17. package/dist/app/session/session-history.d.ts +3 -0
  18. package/dist/app/session/session-history.js +19 -2
  19. package/dist/app/session/session-search.js +2 -0
  20. package/dist/app/session/session-stats.d.ts +3 -0
  21. package/dist/app/session/session-stats.js +65 -0
  22. package/dist/app/types.d.ts +6 -1
  23. package/dist/bundled-extensions/terminal-bell/index.js +1 -37
  24. package/external/pi-tools-suite/README.md +17 -1
  25. package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +102 -0
  26. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +1 -1
  27. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +0 -33
  28. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +1 -1
  29. package/external/pi-tools-suite/src/codex-reasoning-fix/index.ts +13 -121
  30. package/external/pi-tools-suite/src/dcp/config.ts +27 -0
  31. package/external/pi-tools-suite/src/dcp/debug-log.ts +2 -1
  32. package/external/pi-tools-suite/src/dcp/index.ts +193 -20
  33. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +85 -0
  34. package/external/pi-tools-suite/src/dcp/pruner-emergency.ts +254 -0
  35. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -0
  36. package/external/pi-tools-suite/src/dcp/pruner-types.ts +30 -0
  37. package/external/pi-tools-suite/src/dcp/pruner.ts +8 -0
  38. package/external/pi-tools-suite/src/dcp/state.ts +21 -6
  39. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +14 -0
  40. package/external/pi-tools-suite/src/index.ts +4 -2
  41. package/external/pi-tools-suite/src/telegram-mirror/events.ts +9 -4
  42. package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
  43. package/external/pi-tools-suite/src/todo/index.ts +23 -27
  44. package/package.json +1 -1
@@ -32,6 +32,10 @@ import {
32
32
  getNudgeType,
33
33
  detectCompressionCandidate,
34
34
  detectMessageCompressionCandidates,
35
+ analyzeEmergencyCurrentTurn,
36
+ emergencyPressureState,
37
+ emergencyCurrentTurnMessageCandidates,
38
+ pruneEmergencyCurrentTurn,
35
39
  appendConcreteNudgeGuidance,
36
40
  applyAnchoredNudges,
37
41
  clearDcpNudgeAnchors,
@@ -55,6 +59,10 @@ import { decideAutoCompress, createAutoCompressionBlock } from "./auto-compress.
55
59
  import { DCP_STATS_MESSAGE_TYPE, registerCommands } from "./commands.js"
56
60
  import { normalizeDcpContextUsage } from "./ui.js"
57
61
  import { safeGetContextUsage } from "../context-usage.js"
62
+ import {
63
+ collectProviderToolResultEvidence,
64
+ providerPayloadIncludesToolResult,
65
+ } from "./provider-tool-results.js"
58
66
 
59
67
  // ---------------------------------------------------------------------------
60
68
  // Helpers
@@ -132,6 +140,17 @@ function appendDcpControlToMessages(messages: unknown, text: string): unknown {
132
140
  for (let index = messages.length - 1; index >= 0; index--) {
133
141
  const message = messages[index] as any
134
142
  if (!message || typeof message !== "object") continue
143
+ // Responses items such as reasoning/function_call_output do not accept a
144
+ // `content` field. Keep a function output at the tail by appending to its
145
+ // valid `output` string; otherwise scan back to an actual message item.
146
+ if (typeof message.type === "string" && message.type !== "message" && message.role === undefined) {
147
+ if (message.type === "function_call_output" && typeof message.output === "string") {
148
+ return messages.map((candidate: any, candidateIndex) => candidateIndex === index
149
+ ? { ...candidate, output: `${candidate.output}\n\n${block}` }
150
+ : candidate)
151
+ }
152
+ continue
153
+ }
135
154
  if (message.role === "system" || message.role === "developer") continue
136
155
  targetIndex = index
137
156
  break
@@ -213,6 +232,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
213
232
 
214
233
  // ── 2. Create state ───────────────────────────────────────────────────────
215
234
  const state = createState()
235
+ const pendingProviderToolIds = new Set<string>()
216
236
  const appendNudgeTelemetry = (
217
237
  event: "emitted" | "upgraded" | "reapplied",
218
238
  type: DcpNudgeType,
@@ -416,6 +436,8 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
416
436
  let prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
417
437
  let candidate = null as ReturnType<typeof detectCompressionCandidate>
418
438
  let messageCandidates = [] as ReturnType<typeof detectMessageCompressionCandidates>
439
+ let emergencySelection = null as ReturnType<typeof analyzeEmergencyCurrentTurn> | null
440
+ let emergencyPruneResult = null as ReturnType<typeof pruneEmergencyCurrentTurn> | null
419
441
 
420
442
  // In manual mode we still apply pruning strategies (if
421
443
  // automaticStrategies is on) but skip routine autonomous nudges. Emergency
@@ -461,15 +483,27 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
461
483
  thresholds.maxContextPercent += Math.min(summaryBonus, SUMMARY_BUFFER_MAX_CONTEXT_BONUS)
462
484
  }
463
485
 
464
- const contextLimitReached = contextPercent > thresholds.maxContextPercent
486
+ const emergencySettings = effectiveConfig.strategies.emergencyCurrentTurnPruning
487
+ const {
488
+ hardEmergencyReached,
489
+ contextLimitReached,
490
+ emergencyPressureReached,
491
+ } = emergencyPressureState(
492
+ contextPercent,
493
+ thresholds.maxContextPercent,
494
+ emergencySettings.hardContextPercent,
495
+ )
465
496
  const routineNudgesAllowed = contextPercent > thresholds.minContextPercent
466
- if (!contextLimitReached && !routineNudgesAllowed) {
497
+ if (!emergencyPressureReached && !routineNudgesAllowed) {
467
498
  const clearedAnchors = clearDcpNudgeAnchors(state)
468
- if (clearedAnchors > 0) await saveDcpState(ctx, state)
499
+ const resetEmergencyPasses = state.consecutiveIgnoredStrongNudges > 0
500
+ state.consecutiveIgnoredStrongNudges = 0
501
+ if (clearedAnchors > 0 || resetEmergencyPasses) await saveDcpState(ctx, state)
469
502
  return finishContext("below-threshold", prunedMessages, {
470
503
  contextPercent,
471
504
  thresholds,
472
505
  clearedAnchors,
506
+ resetEmergencyPasses,
473
507
  })
474
508
  }
475
509
 
@@ -494,8 +528,17 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
494
528
  Number.isFinite(currentContextWindow) &&
495
529
  currentContextWindow < previousContextWindow * 0.9 &&
496
530
  contextPercent > thresholds.minContextPercent
531
+ const contextWindowChanged =
532
+ typeof previousContextWindow === "number" &&
533
+ Number.isFinite(previousContextWindow) &&
534
+ typeof currentContextWindow === "number" &&
535
+ Number.isFinite(currentContextWindow) &&
536
+ currentContextWindow !== previousContextWindow
537
+ if (contextWindowChanged) state.consecutiveIgnoredStrongNudges = 0
497
538
 
498
- const nudgeType = windowDowngraded
539
+ const nudgeType = hardEmergencyReached && !contextLimitReached
540
+ ? "context-strong"
541
+ : windowDowngraded
499
542
  ? "context-strong"
500
543
  : getNudgeType(
501
544
  contextPercent,
@@ -532,11 +575,37 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
532
575
  }, ctx)
533
576
  }
534
577
 
535
- const hasCompressionSuggestion =
578
+ const hasNormalCompressionSuggestion =
536
579
  candidate !== null || messageCandidates.length > 0
537
- if (!manualEmergencyOnly && !hasCompressionSuggestion) {
580
+ if (
581
+ emergencySettings.enabled &&
582
+ emergencyPressureReached &&
583
+ !manualEmergencyOnly &&
584
+ !hasNormalCompressionSuggestion
585
+ ) {
586
+ emergencySelection = analyzeEmergencyCurrentTurn(
587
+ prunedMessages,
588
+ state,
589
+ effectiveConfig,
590
+ )
591
+ messageCandidates = emergencyCurrentTurnMessageCandidates(
592
+ emergencySelection,
593
+ effectiveConfig,
594
+ )
595
+ writeDcpDebugLog(effectiveConfig, "context.strong_nudge_without_candidate", {
596
+ contextPercent,
597
+ thresholds,
598
+ nudgeType,
599
+ emergencyCandidates: messageCandidates.length,
600
+ ...emergencySelection.stats,
601
+ state: summarizeDcpState(state),
602
+ }, ctx)
603
+ }
604
+
605
+ let hasCompressionSuggestion =
606
+ candidate !== null || messageCandidates.length > 0
607
+ if (!manualEmergencyOnly && !emergencyPressureReached && !hasCompressionSuggestion) {
538
608
  const clearedAnchors = clearDcpNudgeAnchors(state)
539
- state.consecutiveIgnoredStrongNudges = 0
540
609
  if (clearedAnchors > 0) await saveDcpState(ctx, state)
541
610
  if (nudgeType || clearedAnchors > 0) {
542
611
  writeDcpDebugLog(effectiveConfig, "context.no_compression_candidate", {
@@ -549,19 +618,11 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
549
618
  }
550
619
  }
551
620
 
552
- // Track consecutive ignored context-strong nudges for the
553
- // auto-compress fallback. A strong nudge is "ignored" if it fires
554
- // again on a later context event without a successful compress in
555
- // between. Reset on non-strong nudges and when pressure drops below
556
- // the emergency threshold (handled by the below-threshold early
557
- // return above, which clears anchors; counter is also reset on any
558
- // successful compress in the compress tool).
559
- if (
560
- hasCompressionSuggestion &&
561
- (nudgeType === "context-strong" || nudgeType === "context-soft")
562
- ) {
621
+ // Emergency pressure must advance even when normal candidates are
622
+ // absent; otherwise the current-turn fallback can never reach patience.
623
+ if (emergencyPressureReached) {
563
624
  state.consecutiveIgnoredStrongNudges += 1
564
- } else if (nudgeType === null || !hasCompressionSuggestion) {
625
+ } else {
565
626
  state.consecutiveIgnoredStrongNudges = 0
566
627
  }
567
628
 
@@ -576,6 +637,17 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
576
637
  thresholds.maxContextPercent,
577
638
  candidate,
578
639
  )
640
+ if (contextLimitReached && candidate === null) {
641
+ writeDcpDebugLog(effectiveConfig, "compress.auto_blocked_no_candidate", {
642
+ autoCompressEnabled: effectiveConfig.compress.autoCompress.enabled,
643
+ decisionReason: autoDecision.reason,
644
+ contextPercent,
645
+ thresholds,
646
+ consecutiveEmergencyPasses: state.consecutiveIgnoredStrongNudges,
647
+ ...(emergencySelection?.stats ?? {}),
648
+ state: summarizeDcpState(state),
649
+ }, ctx)
650
+ }
579
651
  if (autoDecision.shouldFire && candidate) {
580
652
  try {
581
653
  const autoResult = await createAutoCompressionBlock({
@@ -624,7 +696,64 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
624
696
  }
625
697
  }
626
698
 
627
- if (nudgeType && !manualEmergencyOnly && hasCompressionSuggestion) {
699
+ // Model-independent safety floor for an unfinished active turn. Only
700
+ // result bodies are replaced; user messages and structural tool pairs
701
+ // stay intact. Fresh results are ineligible until before_provider_request
702
+ // records that the model has had a chance to consume them.
703
+ const emergencyPatienceExceeded =
704
+ state.consecutiveIgnoredStrongNudges > Math.max(0, Math.floor(emergencySettings.patience))
705
+ if (
706
+ emergencySettings.enabled &&
707
+ emergencyPressureReached &&
708
+ candidate === null &&
709
+ emergencySelection &&
710
+ emergencySelection.eligible.length > 0 &&
711
+ (hardEmergencyReached || emergencyPatienceExceeded)
712
+ ) {
713
+ const selectionStatsBeforePrune = emergencySelection.stats
714
+ const configuredTarget = Math.max(0, Math.min(1, emergencySettings.targetContextPercent))
715
+ const emergencyMarginTarget = Math.max(0, thresholds.maxContextPercent * 0.9)
716
+ const targetContextPercent = Math.min(configuredTarget, emergencyMarginTarget)
717
+ const targetRecoveryTokens = Math.max(
718
+ 1,
719
+ Math.ceil((contextPercent - targetContextPercent) * usage.contextWindow),
720
+ )
721
+ emergencyPruneResult = pruneEmergencyCurrentTurn(
722
+ emergencySelection,
723
+ state,
724
+ targetRecoveryTokens,
725
+ )
726
+ if (emergencyPruneResult.prunedToolCallIds.length > 0) {
727
+ prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
728
+ state.consecutiveIgnoredStrongNudges = 0
729
+ emergencySelection = analyzeEmergencyCurrentTurn(prunedMessages, state, effectiveConfig)
730
+ messageCandidates = emergencyCurrentTurnMessageCandidates(emergencySelection, effectiveConfig)
731
+ hasCompressionSuggestion = candidate !== null || messageCandidates.length > 0
732
+ await saveDcpState(ctx, state)
733
+ writeDcpDebugLog(effectiveConfig, "prune.emergency_current_turn", {
734
+ trigger: hardEmergencyReached ? "hard-context-percent" : "ignored-emergency-reminders",
735
+ contextPercent,
736
+ thresholds,
737
+ targetContextPercent,
738
+ targetRecoveryTokens,
739
+ prunedOutputs: emergencyPruneResult.prunedToolCallIds.length,
740
+ estimatedTokensRecovered: emergencyPruneResult.estimatedTokensRecovered,
741
+ estimatedContextPercentAfter: Math.max(
742
+ 0,
743
+ ((usage.tokens ?? contextPercent * usage.contextWindow) -
744
+ emergencyPruneResult.estimatedTokensRecovered) /
745
+ usage.contextWindow,
746
+ ),
747
+ targetMet: emergencyPruneResult.estimatedTokensRecovered >= targetRecoveryTokens,
748
+ eligibleExhausted:
749
+ emergencyPruneResult.prunedToolCallIds.length >= selectionStatsBeforePrune.eligiblePairs,
750
+ ...selectionStatsBeforePrune,
751
+ state: summarizeDcpState(state),
752
+ }, ctx)
753
+ }
754
+ }
755
+
756
+ if (nudgeType && !manualEmergencyOnly && (hasCompressionSuggestion || emergencyPressureReached)) {
628
757
  const nudgeText = appendConcreteNudgeGuidance(
629
758
  baseNudgeText(nudgeType),
630
759
  candidate,
@@ -674,6 +803,10 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
674
803
  } else {
675
804
  state.nudgeCounter++
676
805
  }
806
+
807
+ // Persist patience/window changes even when an existing anchor was only
808
+ // re-applied (that path intentionally emits telemetry without updating it).
809
+ await saveDcpState(ctx, state)
677
810
  }
678
811
 
679
812
  if (state.manualMode) {
@@ -688,25 +821,65 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
688
821
  return finishContext("complete", prunedMessages, {
689
822
  candidate,
690
823
  messageCandidates,
824
+ emergencyCurrentTurn: emergencySelection?.stats,
825
+ emergencyPrune: emergencyPruneResult,
691
826
  })
692
827
  })
693
828
 
694
829
  // ── 10b. before_provider_request: inject DCP IDs outside transcript ────────
695
830
  pi.on("before_provider_request", async (event, ctx) => {
696
831
  const effectiveConfig = configForContext(ctx)
832
+ pendingProviderToolIds.clear()
697
833
  if (!effectiveConfig.enabled) return undefined
698
834
 
835
+ const providerEvidence = collectProviderToolResultEvidence(event.payload)
836
+ for (const meta of state.messageMetaSnapshot.values()) {
837
+ if (meta.role !== "toolResult") continue
838
+ if (!meta.toolCallId || state.prunedToolIds.has(meta.toolCallId)) continue
839
+ if (state.providerSeenToolIds.has(meta.toolCallId)) continue
840
+ const record = state.toolCalls.get(meta.toolCallId)
841
+ if (record && providerPayloadIncludesToolResult(providerEvidence, record)) {
842
+ pendingProviderToolIds.add(meta.toolCallId)
843
+ }
844
+ }
845
+
699
846
  const controlText = buildMessageIdControlText(state)
700
847
  if (!controlText) return undefined
701
848
 
702
849
  const payload = appendDcpControlToProviderPayload(event.payload, controlText)
703
850
  writeDcpDebugLog(effectiveConfig, "provider_payload.message_ids", {
704
851
  injected: payload !== event.payload,
852
+ pendingToolResults: pendingProviderToolIds.size,
705
853
  state: summarizeDcpState(state),
706
854
  }, ctx)
707
855
  return payload === event.payload ? undefined : payload
708
856
  })
709
857
 
858
+ // Promote pending IDs only after the provider accepted the exact payload.
859
+ // Failed/aborted requests leave results fresh and therefore ineligible.
860
+ pi.on("after_provider_response", async (event, ctx) => {
861
+ const effectiveConfig = configForContext(ctx)
862
+ const accepted = event.status >= 200 && event.status < 300
863
+ let newlySeenToolResults = 0
864
+ if (effectiveConfig.enabled && accepted) {
865
+ for (const toolCallId of pendingProviderToolIds) {
866
+ if (!state.providerSeenToolIds.has(toolCallId)) {
867
+ state.providerSeenToolIds.add(toolCallId)
868
+ newlySeenToolResults++
869
+ }
870
+ }
871
+ if (newlySeenToolResults > 0) await saveDcpState(ctx, state)
872
+ }
873
+ writeDcpDebugLog(effectiveConfig, "provider_payload.tool_results_seen", {
874
+ status: event.status,
875
+ accepted,
876
+ pendingToolResults: pendingProviderToolIds.size,
877
+ newlySeenToolResults,
878
+ state: summarizeDcpState(state),
879
+ }, ctx)
880
+ pendingProviderToolIds.clear()
881
+ })
882
+
710
883
  // ── 11. agent_end: persist state after each agent run ────────────────────
711
884
  pi.on("agent_end", async (_event, ctx) => {
712
885
  await saveDcpState(ctx, state)
@@ -0,0 +1,85 @@
1
+ import { createHash } from "node:crypto";
2
+ import type { ToolRecord } from "./state.js";
3
+
4
+ export interface ProviderToolResultEvidence {
5
+ ids: Set<string>;
6
+ anonymousSignatures: Set<string>;
7
+ }
8
+
9
+ function responseText(response: unknown): string | undefined {
10
+ if (typeof response === "string") return response;
11
+ if (!response || typeof response !== "object") return undefined;
12
+ const record = response as Record<string, unknown>;
13
+ if (typeof record.output === "string") return record.output;
14
+ if (typeof record.error === "string") return record.error;
15
+ return undefined;
16
+ }
17
+
18
+ function signature(toolName: string, outputText: string): string {
19
+ return createHash("sha256")
20
+ .update(toolName)
21
+ .update("\u0000")
22
+ .update(outputText)
23
+ .digest("hex");
24
+ }
25
+
26
+ /** Collect only tool-result evidence, never assistant tool-call IDs. */
27
+ export function collectProviderToolResultEvidence(payload: unknown): ProviderToolResultEvidence {
28
+ const evidence: ProviderToolResultEvidence = {
29
+ ids: new Set<string>(),
30
+ anonymousSignatures: new Set<string>(),
31
+ };
32
+ const visited = new Set<object>();
33
+
34
+ function visit(value: unknown): void {
35
+ if (!value || typeof value !== "object") return;
36
+ if (visited.has(value)) return;
37
+ visited.add(value);
38
+
39
+ if (Array.isArray(value)) {
40
+ for (const item of value) visit(item);
41
+ return;
42
+ }
43
+
44
+ const record = value as Record<string, unknown>;
45
+ if (record.role === "toolResult" && typeof record.toolCallId === "string") {
46
+ evidence.ids.add(record.toolCallId);
47
+ }
48
+ if (record.role === "tool" && typeof record.tool_call_id === "string") {
49
+ evidence.ids.add(record.tool_call_id);
50
+ }
51
+ if (record.type === "function_call_output" && typeof record.call_id === "string") {
52
+ evidence.ids.add(record.call_id);
53
+ }
54
+ if (record.type === "tool_result" && typeof record.tool_use_id === "string") {
55
+ evidence.ids.add(record.tool_use_id);
56
+ }
57
+
58
+ const functionResponse = record.functionResponse;
59
+ if (functionResponse && typeof functionResponse === "object") {
60
+ const responseRecord = functionResponse as Record<string, unknown>;
61
+ if (typeof responseRecord.id === "string") evidence.ids.add(responseRecord.id);
62
+ const outputText = responseText(responseRecord.response);
63
+ if (typeof responseRecord.name === "string" && outputText !== undefined) {
64
+ evidence.anonymousSignatures.add(signature(responseRecord.name, outputText));
65
+ }
66
+ }
67
+
68
+ for (const nested of Object.values(record)) visit(nested);
69
+ }
70
+
71
+ visit(payload);
72
+ return evidence;
73
+ }
74
+
75
+ export function providerPayloadIncludesToolResult(
76
+ evidence: ProviderToolResultEvidence,
77
+ record: ToolRecord,
78
+ ): boolean {
79
+ if (evidence.ids.has(record.toolCallId)) return true;
80
+ for (const id of evidence.ids) {
81
+ if (record.toolCallId.startsWith(`${id}|`)) return true;
82
+ }
83
+ return typeof record.outputText === "string" &&
84
+ evidence.anonymousSignatures.has(signature(record.toolName, record.outputText));
85
+ }
@@ -0,0 +1,254 @@
1
+ import type { DcpConfig } from "./config.js";
2
+ import { estimateMessageTokens, extractBlockId, messageText } from "./pruner-metadata.js";
3
+ import type {
4
+ EmergencyCurrentTurnOutput,
5
+ EmergencyCurrentTurnSelection,
6
+ MessageCompressionCandidate,
7
+ } from "./pruner-types.js";
8
+ import {
9
+ EMERGENCY_CURRENT_TURN_PLACEHOLDER,
10
+ isToolRecordProtected,
11
+ markToolPruned,
12
+ } from "./pruner-tools.js";
13
+ import type { DcpState } from "./state.js";
14
+
15
+ function emptySelection(): EmergencyCurrentTurnSelection {
16
+ return {
17
+ eligible: [],
18
+ stats: {
19
+ totalPairs: 0,
20
+ totalPairTokens: 0,
21
+ eligiblePairs: 0,
22
+ eligibleTokens: 0,
23
+ eligibleRecoverableTokens: 0,
24
+ preservedPairs: 0,
25
+ preservedTokens: 0,
26
+ preservedRecentPairs: 0,
27
+ preservedRecentTokens: 0,
28
+ preservedUnseenPairs: 0,
29
+ preservedUnseenTokens: 0,
30
+ preservedProtectedPairs: 0,
31
+ preservedProtectedTokens: 0,
32
+ },
33
+ };
34
+ }
35
+
36
+ export function emergencyPressureState(
37
+ contextPercent: number,
38
+ maxContextPercent: number,
39
+ hardContextPercent: number,
40
+ ): { hardEmergencyReached: boolean; contextLimitReached: boolean; emergencyPressureReached: boolean } {
41
+ const hardEmergencyReached = contextPercent >= Math.max(0, Math.min(1, hardContextPercent));
42
+ const contextLimitReached = contextPercent > maxContextPercent;
43
+ return {
44
+ hardEmergencyReached,
45
+ contextLimitReached,
46
+ emergencyPressureReached: hardEmergencyReached || contextLimitReached,
47
+ };
48
+ }
49
+
50
+ function isRealUserMessage(message: any): boolean {
51
+ if (message?.role !== "user") return false;
52
+ const text = messageText(message);
53
+ return !text.includes("<dcp-system-reminder>") && extractBlockId(text) === undefined;
54
+ }
55
+
56
+ function assistantToolCallIds(message: any): string[] {
57
+ if (message?.role !== "assistant" || !Array.isArray(message.content)) return [];
58
+ return message.content
59
+ .filter((part: any) => part?.type === "toolCall" && typeof part.id === "string")
60
+ .map((part: any) => part.id as string);
61
+ }
62
+
63
+ function messageIdForResult(message: any, state: DcpState): string | undefined {
64
+ for (const [id, meta] of state.messageMetaSnapshot) {
65
+ if (
66
+ meta.blockId === undefined &&
67
+ meta.toolCallId === message.toolCallId &&
68
+ meta.role === message.role &&
69
+ meta.timestamp === message.timestamp
70
+ ) return id;
71
+ }
72
+ return undefined;
73
+ }
74
+
75
+ function hasProtectedTag(message: any, config: DcpConfig): boolean {
76
+ return config.compress.protectTags && /<protect\b[^>]*>[\s\S]*?<\/protect>/i.test(messageText(message));
77
+ }
78
+
79
+ export function analyzeEmergencyCurrentTurn(
80
+ messages: any[],
81
+ state: DcpState,
82
+ config: DcpConfig,
83
+ ): EmergencyCurrentTurnSelection {
84
+ const settings = config.strategies.emergencyCurrentTurnPruning;
85
+ if (!settings.enabled) return emptySelection();
86
+ let latestUserIndex = -1;
87
+ for (let index = messages.length - 1; index >= 0; index--) {
88
+ if (isRealUserMessage(messages[index])) {
89
+ latestUserIndex = index;
90
+ break;
91
+ }
92
+ }
93
+
94
+ if (latestUserIndex < 0) {
95
+ return emptySelection();
96
+ }
97
+
98
+ const pairedCallIds = new Set<string>();
99
+ for (let index = latestUserIndex + 1; index < messages.length; index++) {
100
+ for (const toolCallId of assistantToolCallIds(messages[index])) pairedCallIds.add(toolCallId);
101
+ }
102
+
103
+ const pairs: Array<EmergencyCurrentTurnOutput & { message: any }> = [];
104
+ for (let index = latestUserIndex + 1; index < messages.length; index++) {
105
+ const message = messages[index];
106
+ if (message?.role !== "toolResult") continue;
107
+ if (typeof message.toolCallId !== "string" || !pairedCallIds.has(message.toolCallId)) continue;
108
+ const record = state.toolCalls.get(message.toolCallId);
109
+ const tokenEstimate = Math.max(record?.tokenEstimate ?? 0, estimateMessageTokens(message));
110
+ const placeholderEstimate = estimateMessageTokens({
111
+ ...message,
112
+ content: [{ type: "text", text: EMERGENCY_CURRENT_TURN_PLACEHOLDER }],
113
+ });
114
+ pairs.push({
115
+ toolCallId: message.toolCallId,
116
+ messageId: messageIdForResult(message, state),
117
+ toolName: record?.toolName ?? message.toolName ?? "",
118
+ tokenEstimate,
119
+ recoverableTokens: Math.max(0, tokenEstimate - placeholderEstimate),
120
+ resultIndex: index,
121
+ message,
122
+ });
123
+ }
124
+
125
+ const keepRecent = Math.max(0, Math.floor(settings.keepRecentToolPairs));
126
+ const recentIds = new Set(pairs.slice(Math.max(0, pairs.length - keepRecent)).map((pair) => pair.toolCallId));
127
+ const eligible: EmergencyCurrentTurnOutput[] = [];
128
+ const preservedIds = new Set<string>();
129
+ let preservedTokens = 0;
130
+ let preservedRecentPairs = 0;
131
+ let preservedRecentTokens = 0;
132
+ let preservedUnseenPairs = 0;
133
+ let preservedUnseenTokens = 0;
134
+ let preservedProtectedPairs = 0;
135
+ let preservedProtectedTokens = 0;
136
+
137
+ const preserve = (
138
+ pair: EmergencyCurrentTurnOutput,
139
+ reason: "recent" | "unseen" | "protected" | "other",
140
+ ): void => {
141
+ if (!preservedIds.has(pair.toolCallId)) {
142
+ preservedIds.add(pair.toolCallId);
143
+ preservedTokens += pair.tokenEstimate;
144
+ }
145
+ if (reason === "recent") {
146
+ preservedRecentPairs++;
147
+ preservedRecentTokens += pair.tokenEstimate;
148
+ } else if (reason === "unseen") {
149
+ preservedUnseenPairs++;
150
+ preservedUnseenTokens += pair.tokenEstimate;
151
+ } else if (reason === "protected") {
152
+ preservedProtectedPairs++;
153
+ preservedProtectedTokens += pair.tokenEstimate;
154
+ }
155
+ };
156
+
157
+ for (const pair of pairs) {
158
+ if (recentIds.has(pair.toolCallId)) {
159
+ preserve(pair, "recent");
160
+ continue;
161
+ }
162
+ if (!state.providerSeenToolIds.has(pair.toolCallId)) {
163
+ preserve(pair, "unseen");
164
+ continue;
165
+ }
166
+ const record = state.toolCalls.get(pair.toolCallId);
167
+ if (
168
+ !record ||
169
+ isToolRecordProtected(record, config, settings.protectedTools) ||
170
+ hasProtectedTag(pair.message, config)
171
+ ) {
172
+ preserve(pair, "protected");
173
+ continue;
174
+ }
175
+ if (
176
+ state.prunedToolIds.has(pair.toolCallId) ||
177
+ pair.tokenEstimate < Math.max(1, settings.minOutputTokens) ||
178
+ pair.recoverableTokens <= 0
179
+ ) {
180
+ preserve(pair, "other");
181
+ continue;
182
+ }
183
+ eligible.push({
184
+ toolCallId: pair.toolCallId,
185
+ messageId: pair.messageId,
186
+ toolName: pair.toolName,
187
+ tokenEstimate: pair.tokenEstimate,
188
+ recoverableTokens: pair.recoverableTokens,
189
+ resultIndex: pair.resultIndex,
190
+ });
191
+ }
192
+
193
+ eligible.sort((a, b) =>
194
+ a.resultIndex - b.resultIndex ||
195
+ b.tokenEstimate - a.tokenEstimate ||
196
+ a.toolCallId.localeCompare(b.toolCallId),
197
+ );
198
+ return {
199
+ eligible,
200
+ stats: {
201
+ totalPairs: pairs.length,
202
+ totalPairTokens: pairs.reduce((sum, pair) => sum + pair.tokenEstimate, 0),
203
+ eligiblePairs: eligible.length,
204
+ eligibleTokens: eligible.reduce((sum, pair) => sum + pair.tokenEstimate, 0),
205
+ eligibleRecoverableTokens: eligible.reduce((sum, pair) => sum + pair.recoverableTokens, 0),
206
+ preservedPairs: preservedIds.size,
207
+ preservedTokens,
208
+ preservedRecentPairs,
209
+ preservedRecentTokens,
210
+ preservedUnseenPairs,
211
+ preservedUnseenTokens,
212
+ preservedProtectedPairs,
213
+ preservedProtectedTokens,
214
+ },
215
+ };
216
+ }
217
+
218
+ export function emergencyCurrentTurnMessageCandidates(
219
+ selection: EmergencyCurrentTurnSelection,
220
+ config: DcpConfig,
221
+ ): MessageCompressionCandidate[] {
222
+ const settings = config.strategies.emergencyCurrentTurnPruning;
223
+ if (!settings.enabled) return [];
224
+ const maxSuggestions = Math.max(1, Math.floor(settings.maxSuggestions));
225
+ const highTokens = Math.max(1, config.compress.messageMode.highTokens);
226
+ return selection.eligible
227
+ .filter((output) => output.messageId !== undefined)
228
+ .slice(0, maxSuggestions)
229
+ .map((output) => ({
230
+ messageId: output.messageId!,
231
+ role: "toolResult",
232
+ estimatedTokens: output.tokenEstimate,
233
+ priority: output.tokenEstimate >= highTokens ? "high" : "medium",
234
+ reason: `old same-turn tool output; newest ${settings.keepRecentToolPairs} complete pair(s) preserved`,
235
+ }));
236
+ }
237
+
238
+ export function pruneEmergencyCurrentTurn(
239
+ selection: EmergencyCurrentTurnSelection,
240
+ state: DcpState,
241
+ targetRecoveryTokens: number,
242
+ ): { prunedToolCallIds: string[]; estimatedTokensRecovered: number } {
243
+ const prunedToolCallIds: string[] = [];
244
+ let estimatedTokensRecovered = 0;
245
+ const target = Math.max(1, Math.ceil(targetRecoveryTokens));
246
+ for (const output of selection.eligible) {
247
+ if (estimatedTokensRecovered >= target) break;
248
+ if (markToolPruned(state, output.toolCallId, "emergency-current-turn", output.recoverableTokens)) {
249
+ prunedToolCallIds.push(output.toolCallId);
250
+ estimatedTokensRecovered += output.recoverableTokens;
251
+ }
252
+ }
253
+ return { prunedToolCallIds, estimatedTokensRecovered };
254
+ }
@@ -2,6 +2,9 @@ import type { DcpConfig } from "./config.js";
2
2
  import type { DcpState, ToolRecord } from "./state.js";
3
3
  import { estimateMessageTokens } from "./pruner-metadata.js";
4
4
 
5
+ export const EMERGENCY_CURRENT_TURN_PLACEHOLDER =
6
+ "[Older tool output removed during current-turn context emergency; re-run the tool if exact content is needed]";
7
+
5
8
  // Tool outputs that must never be auto-pruned unless a future explicit user
6
9
  // action intentionally changes that policy. They mutate state or control DCP.
7
10
  const ALWAYS_PROTECTED_TOOLS = new Set(["compress", "write", "edit"]);
@@ -238,6 +241,9 @@ function placeholderForPrunedTool(msg: any, state: DcpState): string {
238
241
  if (reason === "manual-sweep") {
239
242
  return "[Output removed by /dcp sweep to save context]";
240
243
  }
244
+ if (reason === "emergency-current-turn") {
245
+ return EMERGENCY_CURRENT_TURN_PLACEHOLDER;
246
+ }
241
247
  if (reason === "old-error" || msg.isError) {
242
248
  return "[Error output removed - tool failed more than the configured number of turns ago]";
243
249
  }