pi-ui-extend 0.1.57 → 0.1.60

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 (45) 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/model/model-usage-status.js +26 -10
  5. package/dist/app/rendering/conversation-entry-renderer.d.ts +3 -0
  6. package/dist/app/rendering/conversation-entry-renderer.js +2 -0
  7. package/dist/app/rendering/conversation-viewport.d.ts +3 -0
  8. package/dist/app/rendering/conversation-viewport.js +4 -0
  9. package/dist/app/rendering/extension-entry-renderer.d.ts +6 -0
  10. package/dist/app/rendering/extension-entry-renderer.js +100 -0
  11. package/dist/app/rendering/tool-block-renderer.d.ts +5 -0
  12. package/dist/app/rendering/tool-block-renderer.js +1 -1
  13. package/dist/app/screen/mouse-controller.js +1 -1
  14. package/dist/app/session/lazy-session-manager.js +18 -1
  15. package/dist/app/session/pix-system-message.d.ts +1 -0
  16. package/dist/app/session/pix-system-message.js +4 -0
  17. package/dist/app/session/session-event-controller.js +7 -1
  18. package/dist/app/session/session-history.d.ts +3 -0
  19. package/dist/app/session/session-history.js +19 -2
  20. package/dist/app/session/session-search.js +2 -0
  21. package/dist/app/session/session-stats.d.ts +3 -0
  22. package/dist/app/session/session-stats.js +65 -0
  23. package/dist/app/types.d.ts +6 -1
  24. package/dist/bundled-extensions/terminal-bell/index.js +1 -37
  25. package/external/pi-tools-suite/README.md +17 -1
  26. package/external/pi-tools-suite/docs/dcp-emergency-current-turn.md +102 -0
  27. package/external/pi-tools-suite/package.json +3 -3
  28. package/external/pi-tools-suite/src/antigravity-auth/payload.ts +1 -1
  29. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +7 -7
  30. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +0 -33
  31. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +1 -1
  32. package/external/pi-tools-suite/src/codex-reasoning-fix/index.ts +7 -114
  33. package/external/pi-tools-suite/src/dcp/config.ts +27 -0
  34. package/external/pi-tools-suite/src/dcp/debug-log.ts +2 -1
  35. package/external/pi-tools-suite/src/dcp/index.ts +182 -20
  36. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +85 -0
  37. package/external/pi-tools-suite/src/dcp/pruner-emergency.ts +254 -0
  38. package/external/pi-tools-suite/src/dcp/pruner-tools.ts +6 -0
  39. package/external/pi-tools-suite/src/dcp/pruner-types.ts +30 -0
  40. package/external/pi-tools-suite/src/dcp/pruner.ts +8 -0
  41. package/external/pi-tools-suite/src/dcp/state.ts +21 -6
  42. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +22 -8
  43. package/external/pi-tools-suite/src/telegram-mirror/events.ts +9 -4
  44. package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
  45. package/package.json +4 -4
@@ -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
@@ -224,6 +232,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
224
232
 
225
233
  // ── 2. Create state ───────────────────────────────────────────────────────
226
234
  const state = createState()
235
+ const pendingProviderToolIds = new Set<string>()
227
236
  const appendNudgeTelemetry = (
228
237
  event: "emitted" | "upgraded" | "reapplied",
229
238
  type: DcpNudgeType,
@@ -427,6 +436,8 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
427
436
  let prunedMessages = applyPruning(contextMessages, state, effectiveConfig)
428
437
  let candidate = null as ReturnType<typeof detectCompressionCandidate>
429
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
430
441
 
431
442
  // In manual mode we still apply pruning strategies (if
432
443
  // automaticStrategies is on) but skip routine autonomous nudges. Emergency
@@ -472,15 +483,27 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
472
483
  thresholds.maxContextPercent += Math.min(summaryBonus, SUMMARY_BUFFER_MAX_CONTEXT_BONUS)
473
484
  }
474
485
 
475
- 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
+ )
476
496
  const routineNudgesAllowed = contextPercent > thresholds.minContextPercent
477
- if (!contextLimitReached && !routineNudgesAllowed) {
497
+ if (!emergencyPressureReached && !routineNudgesAllowed) {
478
498
  const clearedAnchors = clearDcpNudgeAnchors(state)
479
- 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)
480
502
  return finishContext("below-threshold", prunedMessages, {
481
503
  contextPercent,
482
504
  thresholds,
483
505
  clearedAnchors,
506
+ resetEmergencyPasses,
484
507
  })
485
508
  }
486
509
 
@@ -505,8 +528,17 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
505
528
  Number.isFinite(currentContextWindow) &&
506
529
  currentContextWindow < previousContextWindow * 0.9 &&
507
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
508
538
 
509
- const nudgeType = windowDowngraded
539
+ const nudgeType = hardEmergencyReached && !contextLimitReached
540
+ ? "context-strong"
541
+ : windowDowngraded
510
542
  ? "context-strong"
511
543
  : getNudgeType(
512
544
  contextPercent,
@@ -543,11 +575,37 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
543
575
  }, ctx)
544
576
  }
545
577
 
546
- const hasCompressionSuggestion =
578
+ const hasNormalCompressionSuggestion =
579
+ candidate !== null || messageCandidates.length > 0
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 =
547
606
  candidate !== null || messageCandidates.length > 0
548
- if (!manualEmergencyOnly && !hasCompressionSuggestion) {
607
+ if (!manualEmergencyOnly && !emergencyPressureReached && !hasCompressionSuggestion) {
549
608
  const clearedAnchors = clearDcpNudgeAnchors(state)
550
- state.consecutiveIgnoredStrongNudges = 0
551
609
  if (clearedAnchors > 0) await saveDcpState(ctx, state)
552
610
  if (nudgeType || clearedAnchors > 0) {
553
611
  writeDcpDebugLog(effectiveConfig, "context.no_compression_candidate", {
@@ -560,19 +618,11 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
560
618
  }
561
619
  }
562
620
 
563
- // Track consecutive ignored context-strong nudges for the
564
- // auto-compress fallback. A strong nudge is "ignored" if it fires
565
- // again on a later context event without a successful compress in
566
- // between. Reset on non-strong nudges and when pressure drops below
567
- // the emergency threshold (handled by the below-threshold early
568
- // return above, which clears anchors; counter is also reset on any
569
- // successful compress in the compress tool).
570
- if (
571
- hasCompressionSuggestion &&
572
- (nudgeType === "context-strong" || nudgeType === "context-soft")
573
- ) {
621
+ // Emergency pressure must advance even when normal candidates are
622
+ // absent; otherwise the current-turn fallback can never reach patience.
623
+ if (emergencyPressureReached) {
574
624
  state.consecutiveIgnoredStrongNudges += 1
575
- } else if (nudgeType === null || !hasCompressionSuggestion) {
625
+ } else {
576
626
  state.consecutiveIgnoredStrongNudges = 0
577
627
  }
578
628
 
@@ -587,6 +637,17 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
587
637
  thresholds.maxContextPercent,
588
638
  candidate,
589
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
+ }
590
651
  if (autoDecision.shouldFire && candidate) {
591
652
  try {
592
653
  const autoResult = await createAutoCompressionBlock({
@@ -635,7 +696,64 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
635
696
  }
636
697
  }
637
698
 
638
- 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)) {
639
757
  const nudgeText = appendConcreteNudgeGuidance(
640
758
  baseNudgeText(nudgeType),
641
759
  candidate,
@@ -685,6 +803,10 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
685
803
  } else {
686
804
  state.nudgeCounter++
687
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)
688
810
  }
689
811
 
690
812
  if (state.manualMode) {
@@ -699,25 +821,65 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
699
821
  return finishContext("complete", prunedMessages, {
700
822
  candidate,
701
823
  messageCandidates,
824
+ emergencyCurrentTurn: emergencySelection?.stats,
825
+ emergencyPrune: emergencyPruneResult,
702
826
  })
703
827
  })
704
828
 
705
829
  // ── 10b. before_provider_request: inject DCP IDs outside transcript ────────
706
830
  pi.on("before_provider_request", async (event, ctx) => {
707
831
  const effectiveConfig = configForContext(ctx)
832
+ pendingProviderToolIds.clear()
708
833
  if (!effectiveConfig.enabled) return undefined
709
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
+
710
846
  const controlText = buildMessageIdControlText(state)
711
847
  if (!controlText) return undefined
712
848
 
713
849
  const payload = appendDcpControlToProviderPayload(event.payload, controlText)
714
850
  writeDcpDebugLog(effectiveConfig, "provider_payload.message_ids", {
715
851
  injected: payload !== event.payload,
852
+ pendingToolResults: pendingProviderToolIds.size,
716
853
  state: summarizeDcpState(state),
717
854
  }, ctx)
718
855
  return payload === event.payload ? undefined : payload
719
856
  })
720
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
+
721
883
  // ── 11. agent_end: persist state after each agent run ────────────────────
722
884
  pi.on("agent_end", async (_event, ctx) => {
723
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
  }