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
@@ -4,6 +4,7 @@
4
4
 
5
5
  import { createHash } from "node:crypto"
6
6
  import type { DcpNudgeType } from "./pruner-types.js"
7
+ import type { DcpBlockedReason } from "./progress-controller.js"
7
8
 
8
9
  /**
9
10
  * A record of a single tool call, keyed by toolCallId in DcpState.toolCalls.
@@ -15,11 +16,7 @@ export interface ToolRecord {
15
16
  toolName: string
16
17
  /** The arguments passed to the tool (from the corresponding ToolCall) */
17
18
  inputArgs: Record<string, unknown>
18
- /**
19
- * Deduplication fingerprint: `toolName::sha256:<hash>` where the hash is
20
- * computed over recursively key-sorted args. Two calls with the same name +
21
- * identical args share the same fingerprint without persisting full args.
22
- */
19
+ /** Stable input fingerprint: `toolName::sha256:<hash>` over key-sorted args. */
23
20
  inputFingerprint: string
24
21
  /** Whether the tool result was an error */
25
22
  isError: boolean
@@ -43,6 +40,8 @@ export interface MessageIdMeta {
43
40
  timestamp: number
44
41
  /** Stable raw message key when available; falls back to timestamp-derived key. */
45
42
  stableId?: string
43
+ /** SHA-256 of canonical provider-visible content at snapshot publication. */
44
+ contentHash?: string
46
45
  /** The message role at the time the id was injected. */
47
46
  role: string
48
47
  /** Present when this addressable message represents an active compression block. */
@@ -50,6 +49,8 @@ export interface MessageIdMeta {
50
49
  /** Tool call metadata for tool-result-like messages. */
51
50
  toolCallId?: string
52
51
  toolName?: string
52
+ /** Tool calls emitted by an assistant message; these messages are structurally coupled to their results. */
53
+ toolCallIds?: string[]
53
54
  /** Plain text extracted from the message when the id was injected. */
54
55
  text?: string
55
56
  /** Rough token estimate for priority/candidate guidance. */
@@ -58,6 +59,64 @@ export interface MessageIdMeta {
58
59
  priority?: "low" | "medium" | "high"
59
60
  }
60
61
 
62
+ export interface ConversationIndexEntry {
63
+ /** Position in the current projected branch before DCP metadata carriers. */
64
+ index: number
65
+ /** Stable identity of this projected message. */
66
+ stableId: string
67
+ /** SHA-256 of the canonical provider-visible message content. */
68
+ contentHash: string
69
+ /**
70
+ * Canonical hash of the raw session message before DCP rewrote this
71
+ * projection entry's body (e.g. a tool-output placeholder). Equals
72
+ * `contentHash` for untouched entries. Runtime-only; exact mutation
73
+ * membership binds to this so a later raw pass still materializes the block.
74
+ * Omitted when the raw and projected hashes are identical.
75
+ */
76
+ rawContentHash?: string
77
+ /** Persistent mNNN identifier when the message is addressable as raw content. */
78
+ visibleId?: string
79
+ /** Message role in the current projection. */
80
+ role: string
81
+ /** Message timestamp when finite. */
82
+ timestamp?: number
83
+ /** Active block represented by this projected placeholder, when any. */
84
+ blockId?: number
85
+ /** Tool result identity for result-like messages. */
86
+ toolCallId?: string
87
+ /** Tool calls emitted by an assistant message. */
88
+ toolCallIds?: string[]
89
+ /** PI-internal entries are not directly addressable but remain part of range closure. */
90
+ passthrough: boolean
91
+ /** Provenance of this projected entry; never inferred from model-visible text. */
92
+ origin: "raw" | "block" | "dcp-control"
93
+ /** Signed assistant content must never be edited in place. */
94
+ signedAssistant: boolean
95
+ }
96
+
97
+ export interface CompressionProtectedFragment {
98
+ kind: "user" | "prompt" | "tool"
99
+ /** Stable provenance key; not rendered into provider-visible context. */
100
+ origin: string
101
+ /** SHA-256 of the exact protected text. */
102
+ hash: string
103
+ /** Exact text that must survive rollups. */
104
+ text: string
105
+ }
106
+
107
+ /**
108
+ * An immutable source/mutation member recorded by modern compression blocks.
109
+ *
110
+ * `stableId` identifies one occurrence in the canonical branch sequence and
111
+ * `hash` binds that identity to its original provider-visible content. The
112
+ * timestamp is deliberately not part of this record: host imports/reloads can
113
+ * normalize timestamps without changing the actual message.
114
+ */
115
+ export interface CompressionMember {
116
+ stableId: string
117
+ hash: string
118
+ }
119
+
61
120
  /**
62
121
  * A compression block created by the `compress` tool.
63
122
  * Tracks the range of messages that were summarised and where to inject the
@@ -88,6 +147,8 @@ export interface CompressionBlock {
88
147
  anchorMessageId?: string
89
148
  /** Tool call ID of the compress invocation that created this block. */
90
149
  createdByToolCallId?: string
150
+ /** Binds idempotent manual replay to the exact requested parameters. */
151
+ operationRequestHash?: string
91
152
  /** Whether this block is still being applied (false = soft-deleted) */
92
153
  active: boolean
93
154
  /** Token estimate for the summary text itself */
@@ -98,8 +159,38 @@ export interface CompressionBlock {
98
159
  coveredBlockIds?: number[]
99
160
  /** Whether this block was created from a range or a single raw message. */
100
161
  mode?: "range" | "message"
101
- /** Set when a user explicitly decompressed this block via /dcp decompress. */
102
- deactivatedByUser?: boolean
162
+ /**
163
+ * Exact projection semantics. New journal sessions only accept version 2
164
+ * blocks, planned before commit without widening their mutation set.
165
+ */
166
+ version?: 2
167
+ /** Exact v2 materialization behavior. */
168
+ replacementMode?: "range" | "message-body"
169
+ /**
170
+ * Exact provider-projection entries that supplied this summary. For a
171
+ * roll-up this can contain a synthetic block placeholder while
172
+ * `mutationMembers` contains that placeholder's canonical raw provenance.
173
+ */
174
+ sourceMembers?: CompressionMember[]
175
+ /**
176
+ * Exact canonical raw entries that a v2 application is allowed to replace.
177
+ * The apply path requires every member, in this order and with this hash, to
178
+ * be present contiguously before it mutates the projection.
179
+ */
180
+ mutationMembers?: CompressionMember[]
181
+ /** E06 provenance for automatically prepared summaries. */
182
+ autoSummaryRepresentation?: "model" | "extractive" | "extractive-fallback"
183
+ /** SHA-256 of the bounded source manifest used by the auto summary. */
184
+ sourceHash?: string
185
+ /** Bounded source-manifest coverage recorded at auto-summary commit. */
186
+ sourceCoverage?: {
187
+ itemCount: number
188
+ truncatedItems: number
189
+ toolCallCount: number
190
+ toolResultCount: number
191
+ }
192
+ /** Exact protected continuity fragments carried across rollups. */
193
+ protectedFragments?: CompressionProtectedFragment[]
103
194
  /** Internal reason for automatic soft-deactivation. */
104
195
  deactivatedReason?: string
105
196
  }
@@ -139,7 +230,29 @@ export interface DcpLastNudge {
139
230
  /**
140
231
  * Full runtime state for the DCP extension.
141
232
  */
233
+ export interface DcpProgressRecovery {
234
+ blockedReason: DcpBlockedReason
235
+ projectedBeforeTokens: number
236
+ inputCapacityTokens: number
237
+ requiredSavingsTokens: number
238
+ contextWindow: number
239
+ createdAt: number
240
+ }
241
+
242
+ /** Outstanding net savings goal; a small successful block is not necessarily relief. */
243
+ export interface DcpCompressionProgress {
244
+ remainingTokens: number
245
+ projectedTokens: number
246
+ contextWindow: number
247
+ kind: "routine" | "emergency"
248
+ /** Last budget observation; unchanged native usage must not undo partial savings. */
249
+ observedTokens?: number
250
+ targetHeadroomTokens?: number
251
+ }
252
+
142
253
  export interface DcpState {
254
+ /** Runtime-only owner epoch. Incremented whenever the active session state is replaced. */
255
+ sessionEpoch: number
143
256
  // ── Tool tracking ──────────────────────────────────────────────────────────
144
257
  /** toolCallId → ToolRecord, populated when a tool_result event fires */
145
258
  toolCalls: Map<string, ToolRecord>
@@ -147,7 +260,7 @@ export interface DcpState {
147
260
  prunedToolIds: Set<string>
148
261
  /** toolCallId → reason used for human-readable pruning placeholders/stats. */
149
262
  prunedToolReasons: Map<string, string>
150
- /** Tool results included in at least one provider request. */
263
+ /** Tool results consumed by at least one successfully completed provider response stream. */
151
264
  providerSeenToolIds: Set<string>
152
265
 
153
266
  // ── Compression ────────────────────────────────────────────────────────────
@@ -168,6 +281,8 @@ export interface DcpState {
168
281
  messageIdSnapshot: Map<string, number>
169
282
  /** Extra metadata for the model-visible DCP message IDs in messageIdSnapshot. */
170
283
  messageMetaSnapshot: Map<string, MessageIdMeta>
284
+ /** Ephemeral canonical projection index from the latest context pass. */
285
+ conversationIndexSnapshot: ConversationIndexEntry[]
171
286
  /** Stable message identity → persistent model-visible mNNN assignment. */
172
287
  messageIdsByStableId: Map<string, string>
173
288
  /** Monotonic counter for persistent message IDs. */
@@ -180,11 +295,6 @@ export interface DcpState {
180
295
  * context array in the `context` event handler.
181
296
  */
182
297
  currentTurn: number
183
- /** Last user-turn count at which automatic prune discovery ran. */
184
- lastAutomaticPruneTurn: number
185
- /** Highest created block ID at the last automatic-prune checkpoint. */
186
- lastAutomaticPruneBlockId: number
187
-
188
298
  // ── Statistics ─────────────────────────────────────────────────────────────
189
299
  /** Running total of tokens estimated to have been saved by pruning/compression */
190
300
  tokensSaved: number
@@ -204,9 +314,9 @@ export interface DcpState {
204
314
  accountedPrunedToolIds: Set<string>
205
315
  // ── Mode ───────────────────────────────────────────────────────────────────
206
316
  /**
207
- * When true, the extension will not autonomously emit compress nudges.
208
- * Automatic deduplication/error-purge strategies may still run depending on
209
- * the `manualMode.automaticStrategies` config flag.
317
+ * When true, DCP will not autonomously create summaries or routine nudges.
318
+ * The explicit compress tool and bounded emergency safety behavior retain
319
+ * their separate contracts.
210
320
  */
211
321
  manualMode: boolean
212
322
 
@@ -240,12 +350,16 @@ export interface DcpState {
240
350
  */
241
351
  lastContextWindow?: number
242
352
  /**
243
- * How many consecutive emergency context reminders have been delivered
244
- * without a successful compression or emergency prune. This advances even
245
- * when no normal compression candidate exists so current-turn fallback
246
- * patience cannot be reset by the exact condition it is designed to handle.
353
+ * How many completed, correlated main-provider opportunities contained an
354
+ * emergency DCP reminder without sufficient committed compression/pruning gain.
355
+ * Repeated `context` transforms and retries do not advance this counter.
247
356
  */
248
357
  consecutiveIgnoredStrongNudges: number
358
+ /** Completed provider opportunities with any actionable reminder, including failed compress attempts. */
359
+ consecutiveIgnoredNudges: number
360
+ compressionProgress?: DcpCompressionProgress
361
+ /** Last terminal E05 handoff, persisted so restart does not hide why progress stopped. */
362
+ progressRecovery?: DcpProgressRecovery
249
363
  }
250
364
 
251
365
  // ---------------------------------------------------------------------------
@@ -255,6 +369,7 @@ export interface DcpState {
255
369
  /** Create a fresh, zeroed DcpState instance. */
256
370
  export function createState(): DcpState {
257
371
  return {
372
+ sessionEpoch: 1,
258
373
  toolCalls: new Map(),
259
374
  prunedToolIds: new Set(),
260
375
  prunedToolReasons: new Map(),
@@ -263,11 +378,10 @@ export function createState(): DcpState {
263
378
  nextBlockId: 1,
264
379
  messageIdSnapshot: new Map(),
265
380
  messageMetaSnapshot: new Map(),
381
+ conversationIndexSnapshot: [],
266
382
  messageIdsByStableId: new Map(),
267
383
  nextMessageId: 1,
268
384
  currentTurn: 0,
269
- lastAutomaticPruneTurn: -1,
270
- lastAutomaticPruneBlockId: 0,
271
385
  tokensSaved: 0,
272
386
  totalPruneCount: 0,
273
387
  totalToolCallCount: 0,
@@ -282,6 +396,9 @@ export function createState(): DcpState {
282
396
  lastNudge: undefined,
283
397
  lastContextWindow: undefined,
284
398
  consecutiveIgnoredStrongNudges: 0,
399
+ consecutiveIgnoredNudges: 0,
400
+ compressionProgress: undefined,
401
+ progressRecovery: undefined,
285
402
  }
286
403
  }
287
404
 
@@ -291,6 +408,7 @@ export function createState(): DcpState {
291
408
  * reset immediately.
292
409
  */
293
410
  export function resetState(state: DcpState): void {
411
+ state.sessionEpoch = Math.max(1, Math.floor(state.sessionEpoch || 0) + 1)
294
412
  state.toolCalls.clear()
295
413
  state.prunedToolIds.clear()
296
414
  state.prunedToolReasons.clear()
@@ -299,11 +417,10 @@ export function resetState(state: DcpState): void {
299
417
  state.nextBlockId = 1
300
418
  state.messageIdSnapshot.clear()
301
419
  state.messageMetaSnapshot.clear()
420
+ state.conversationIndexSnapshot = []
302
421
  state.messageIdsByStableId.clear()
303
422
  state.nextMessageId = 1
304
423
  state.currentTurn = 0
305
- state.lastAutomaticPruneTurn = -1
306
- state.lastAutomaticPruneBlockId = 0
307
424
  state.tokensSaved = 0
308
425
  state.totalPruneCount = 0
309
426
  state.totalToolCallCount = 0
@@ -318,546 +435,32 @@ export function resetState(state: DcpState): void {
318
435
  state.lastNudge = undefined
319
436
  state.lastContextWindow = undefined
320
437
  state.consecutiveIgnoredStrongNudges = 0
438
+ state.consecutiveIgnoredNudges = 0
439
+ state.compressionProgress = undefined
440
+ state.progressRecovery = undefined
321
441
  }
322
442
 
323
- /**
324
- * Merge compression blocks (and their accounting) from a source sidecar into
325
- * the current state without clobbering other fields. Used at session_start to
326
- * carry compression state across fork/resume/new into a session whose own
327
- * sidecar is empty.
328
- *
329
- * Returns the number of newly added blocks (blocks whose id already exists are
330
- * skipped). Accounting is carried over so inherited blocks are neither lost
331
- * nor double-counted when later folded by a new compression range.
332
- */
333
- export function inheritCompressionBlocks(state: DcpState, data: unknown): number {
334
- if (!data || typeof data !== "object") return 0
335
- const saved = data as Partial<SerializedDcpState>
336
- if (!Array.isArray(saved.compressionBlocks) || saved.compressionBlocks.length === 0) return 0
337
-
338
- const existingIds = new Set(state.compressionBlocks.map((b) => b.id))
339
- const validBlocks = saved.compressionBlocks
340
- .filter(
341
- (b: any) =>
342
- b && Number.isFinite(b.startTimestamp) && Number.isFinite(b.endTimestamp),
343
- )
344
- .map((b: any) => ({
345
- ...b,
346
- anchorTimestamp: Number.isFinite(b.anchorTimestamp)
347
- ? b.anchorTimestamp
348
- : b.endTimestamp + 1,
349
- })) as CompressionBlock[]
350
-
351
- const toAdd = validBlocks.filter((b) => !existingIds.has(b.id))
352
- if (toAdd.length === 0) return 0
353
-
354
- state.compressionBlocks.push(...toAdd)
355
- state.nextBlockId =
356
- Math.max(state.nextBlockId, ...state.compressionBlocks.map((b) => b.id)) + 1
357
-
358
- if (Array.isArray(saved.accountedCompressionBlockIds)) {
359
- for (const id of saved.accountedCompressionBlockIds) {
360
- if (typeof id === "number") state.accountedCompressionBlockIds.add(id)
361
- }
362
- }
363
- if (Array.isArray(saved.compressionTokenSavings)) {
364
- for (const [id, val] of saved.compressionTokenSavings) {
365
- if (
366
- typeof id === "number" &&
367
- typeof val === "number" &&
368
- !state.compressionTokenSavings.has(id)
369
- ) {
370
- state.compressionTokenSavings.set(id, val)
371
- }
372
- }
373
- }
374
- if (typeof saved.tokensSaved === "number" && saved.tokensSaved > state.tokensSaved) {
375
- state.tokensSaved = saved.tokensSaved
376
- }
377
-
378
- return toAdd.length
379
- }
443
+ const COMPRESSION_MEMBER_HASH_RE = /^[a-f0-9]{64}$/i
380
444
 
381
- /**
382
- * Compact tool record for persistence — strips outputText, outputDetails,
383
- * and truncates/summarises inputArgs to keep serialized state bounded.
384
- */
385
- export interface CompactToolRecord {
386
- toolCallId: string
387
- toolName: string
388
- inputFingerprint: string
389
- isError: boolean
390
- turnIndex: number
391
- timestamp: number
392
- tokenEstimate: number
393
- /**
394
- * Extracted string values from inputArgs that could match file-protection
395
- * patterns, persisted so `isProtectedByFilePattern` still works after
396
- * session restore. Capped to avoid bloating state with huge arg values.
397
- */
398
- inputStringValues?: string[]
399
- }
400
-
401
- /**
402
- * Maximum number of recent tool records retained in persisted state.
403
- * Older records are still kept when referenced by active compression blocks,
404
- * pruned tool IDs, or accounted prune IDs.
405
- */
406
- export const PERSISTED_TOOL_CALLS_MAX_RECENT = 200
407
-
408
- /**
409
- * Maximum length of individual string values extracted from inputArgs
410
- * for file-pattern matching. Longer values are truncated.
411
- */
412
- const INPUT_STRING_VALUE_MAX_LENGTH = 512
413
-
414
- /**
415
- * Maximum number of inputStringValues to keep per tool record.
416
- */
417
- const INPUT_STRING_VALUES_MAX_COUNT = 20
418
-
419
- export interface SerializedDcpState {
420
- compressionBlocks: CompressionBlock[]
421
- nextBlockId: number
422
- prunedToolIds: string[]
423
- prunedToolReasons: Array<[string, string]>
424
- /** Tool result IDs known to have appeared in a provider request. */
425
- providerSeenToolIds?: string[]
426
- /** Full tool records — present in legacy snapshots. */
427
- toolCalls?: ToolRecord[]
428
- /** Compact tool records — present in new compact snapshots. */
429
- compactToolCalls?: CompactToolRecord[]
430
- /**
431
- * Total number of tool calls observed during the session, including those
432
- * trimmed from the persisted snapshot. Allows `/dcp stats` to report
433
- * approximate totals.
434
- */
435
- totalToolCallCount?: number
436
- tokensSaved: number
437
- totalPruneCount: number
438
- accountedCompressionBlockIds: number[]
439
- compressionTokenSavings: Array<[number, number]>
440
- accountedPrunedToolIds: string[]
441
- manualMode: boolean
442
- nudgeAnchors?: DcpNudgeAnchor[]
443
- nextNudgeAnchorId?: number
444
- lastNudge?: DcpLastNudge
445
- /** Persistent stable-identity → mNNN assignments. */
446
- messageIdsByStableId?: Array<[string, string]>
447
- nextMessageId?: number
448
- /**
449
- * Persisted since v??. `context` events re-seed currentTurn from raw
450
- * messages, but keeping it across session restarts gives diagnostics and
451
- * telemetry a contiguous turn counter instead of resetting to 0.
452
- */
453
- currentTurn?: number
454
- /** Automatic pruning runs only when one of these checkpoints changes. */
455
- lastAutomaticPruneTurn?: number
456
- lastAutomaticPruneBlockId?: number
457
- /**
458
- * Persisted since v?.?. Without persistence a pi process restart silently
459
- * reset the nudge cadence counter to 0, which could suppress the next
460
- * reminder on a session that was already near a context threshold.
461
- */
462
- nudgeCounter?: number
463
- /** Persisted since v?.?. Diagnostic turn of the last emitted nudge. */
464
- lastNudgeTurn?: number
465
- /**
466
- * Persisted so a mid-session window downgrade (model switch to a smaller
467
- * context window) is still detectable after a pi process restart / resume.
468
- */
469
- lastContextWindow?: number
470
- /**
471
- * Persisted so the auto-compress fallback's patience counter survives a pi
472
- * process restart / resume, preventing a stuck "model ignores strong nudges"
473
- * state from being silently cleared by a reload.
474
- */
475
- consecutiveIgnoredStrongNudges?: number
476
- /** Hash of the last persisted serialized state, used for dedup. */
477
- _stateHash?: string
478
- }
479
-
480
- function isToolRecord(value: unknown): value is ToolRecord {
445
+ function isCompressionMember(value: unknown): value is CompressionMember {
481
446
  if (!value || typeof value !== "object") return false
482
- const record = value as Partial<ToolRecord>
447
+ const member = value as Partial<CompressionMember>
483
448
  return (
484
- typeof record.toolCallId === "string" &&
485
- typeof record.toolName === "string" &&
486
- typeof record.inputFingerprint === "string"
449
+ typeof member.stableId === "string" &&
450
+ member.stableId.length > 0 &&
451
+ typeof member.hash === "string" &&
452
+ COMPRESSION_MEMBER_HASH_RE.test(member.hash)
487
453
  )
488
454
  }
489
455
 
490
- function isCompactToolRecord(value: unknown): value is CompactToolRecord {
491
- if (!value || typeof value !== "object") return false
492
- const record = value as Partial<CompactToolRecord>
493
- return (
494
- typeof record.toolCallId === "string" &&
495
- typeof record.toolName === "string" &&
496
- typeof record.inputFingerprint === "string"
497
- )
456
+ function isCompressionMemberList(value: unknown): value is CompressionMember[] {
457
+ if (!Array.isArray(value) || value.length === 0 || !value.every(isCompressionMember)) return false
458
+ return new Set(value.map((member) => member.stableId)).size === value.length
498
459
  }
499
460
 
500
- function isNudgeAnchor(value: unknown): value is DcpNudgeAnchor {
501
- if (!value || typeof value !== "object") return false
502
- const anchor = value as Partial<DcpNudgeAnchor>
503
- return (
504
- typeof anchor.id === "number" &&
505
- typeof anchor.type === "string" &&
506
- Number.isFinite(anchor.anchorTimestamp) &&
507
- typeof anchor.anchorRole === "string" &&
508
- typeof anchor.turnIndex === "number" &&
509
- typeof anchor.createdAt === "number" &&
510
- typeof anchor.updatedAt === "number"
511
- )
512
- }
513
-
514
- function isLastNudge(value: unknown): value is DcpLastNudge {
515
- if (!value || typeof value !== "object") return false
516
- const nudge = value as Partial<DcpLastNudge>
517
- return (
518
- typeof nudge.type === "string" &&
519
- typeof nudge.anchorId === "number" &&
520
- Number.isFinite(nudge.anchorTimestamp) &&
521
- typeof nudge.createdAt === "number"
522
- )
523
- }
524
-
525
- // ---------------------------------------------------------------------------
526
- // Compact tool-record helpers
527
- // ---------------------------------------------------------------------------
528
-
529
- /**
530
- * Recursively extract string values from a nested object, matching the
531
- * logic in `pruner-tools.ts::collectStringValues`. Depth-limited to 6.
532
- */
533
- function extractStringValues(value: unknown, out: string[] = [], depth = 0): string[] {
534
- if (depth > 6) return out
535
- if (typeof value === "string") {
536
- out.push(value)
537
- return out
538
- }
539
- if (Array.isArray(value)) {
540
- for (const item of value) extractStringValues(item, out, depth + 1)
541
- return out
542
- }
543
- if (value !== null && typeof value === "object") {
544
- for (const item of Object.values(value as Record<string, unknown>)) {
545
- extractStringValues(item, out, depth + 1)
546
- }
547
- }
548
- return out
549
- }
550
-
551
- /**
552
- * Produce a compact tool record for persistence: strip outputText,
553
- * outputDetails, and reduce inputArgs to just extracted string values
554
- * for file-pattern protection checking.
555
- */
556
- export function compactifyToolRecord(record: ToolRecord): CompactToolRecord {
557
- const stringValues = extractStringValues(record.inputArgs)
558
- // Truncate individual values and limit total count
559
- const cappedValues = stringValues
560
- .slice(0, INPUT_STRING_VALUES_MAX_COUNT)
561
- .map((v) => (v.length > INPUT_STRING_VALUE_MAX_LENGTH ? v.slice(0, INPUT_STRING_VALUE_MAX_LENGTH) : v))
562
-
563
- const compact: CompactToolRecord = {
564
- toolCallId: record.toolCallId,
565
- toolName: record.toolName,
566
- inputFingerprint: record.inputFingerprint,
567
- isError: record.isError,
568
- turnIndex: record.turnIndex,
569
- timestamp: record.timestamp,
570
- tokenEstimate: record.tokenEstimate,
571
- }
572
- if (cappedValues.length > 0) {
573
- compact.inputStringValues = cappedValues
574
- }
575
- return compact
576
- }
577
-
578
- /**
579
- * Determine which tool-call IDs are referenced by active compression blocks
580
- * (via createdByToolCallId) or by pruned/accounted sets, and therefore must
581
- * be retained even when trimming old records.
582
- */
583
- function referencedToolCallIds(state: DcpState): Set<string> {
584
- const refs = new Set<string>()
585
- // Tool IDs referenced by active compression blocks
586
- for (const block of state.compressionBlocks) {
587
- if (block.active && block.createdByToolCallId) {
588
- refs.add(block.createdByToolCallId)
589
- }
590
- }
591
- // Pruned tool IDs
592
- for (const id of state.prunedToolIds) refs.add(id)
593
- // Accounted pruned tool IDs (superset of prunedToolIds in some cases)
594
- for (const id of state.accountedPrunedToolIds) refs.add(id)
595
- return refs
596
- }
597
-
598
- /** Serialize runtime state into a JSON-safe object for pi.appendEntry(). */
599
- export function serializeState(state: DcpState): SerializedDcpState {
600
- // Build compact tool records, keeping referenced + recent ones.
601
- const allRecords = Array.from(state.toolCalls.values())
602
- const refs = referencedToolCallIds(state)
603
-
604
- // Sort by timestamp descending so we can pick the most recent ones
605
- const sorted = allRecords
606
- .slice()
607
- .sort((a, b) => b.timestamp - a.timestamp)
608
-
609
- const compactToolCalls: CompactToolRecord[] = []
610
- const seen = new Set<string>()
611
-
612
- // First pass: always include referenced records
613
- for (const record of sorted) {
614
- if (refs.has(record.toolCallId)) {
615
- compactToolCalls.push(compactifyToolRecord(record))
616
- seen.add(record.toolCallId)
617
- }
618
- }
619
-
620
- // Second pass: add recent records up to the limit
621
- for (const record of sorted) {
622
- if (seen.has(record.toolCallId)) continue
623
- if (compactToolCalls.length >= PERSISTED_TOOL_CALLS_MAX_RECENT) break
624
- compactToolCalls.push(compactifyToolRecord(record))
625
- seen.add(record.toolCallId)
626
- }
627
-
628
- const persistedToolCallIds = new Set(compactToolCalls.map((record) => record.toolCallId))
629
- const providerSeenToolIds = Array.from(state.providerSeenToolIds)
630
- .filter((toolCallId) => persistedToolCallIds.has(toolCallId))
631
-
632
- return {
633
- compressionBlocks: state.compressionBlocks,
634
- nextBlockId: state.nextBlockId,
635
- prunedToolIds: Array.from(state.prunedToolIds),
636
- prunedToolReasons: Array.from(state.prunedToolReasons.entries()),
637
- providerSeenToolIds,
638
- compactToolCalls,
639
- totalToolCallCount: allRecords.length,
640
- tokensSaved: state.tokensSaved,
641
- totalPruneCount: state.totalPruneCount,
642
- accountedCompressionBlockIds: Array.from(state.accountedCompressionBlockIds),
643
- compressionTokenSavings: Array.from(state.compressionTokenSavings.entries()),
644
- accountedPrunedToolIds: Array.from(state.accountedPrunedToolIds),
645
- manualMode: state.manualMode,
646
- nudgeAnchors: state.nudgeAnchors,
647
- nextNudgeAnchorId: state.nextNudgeAnchorId,
648
- lastNudge: state.lastNudge,
649
- messageIdsByStableId: Array.from(state.messageIdsByStableId.entries()),
650
- nextMessageId: state.nextMessageId,
651
- currentTurn: state.currentTurn,
652
- lastAutomaticPruneTurn: state.lastAutomaticPruneTurn,
653
- lastAutomaticPruneBlockId: state.lastAutomaticPruneBlockId,
654
- nudgeCounter: state.nudgeCounter,
655
- lastNudgeTurn: state.lastNudgeTurn,
656
- lastContextWindow: state.lastContextWindow,
657
- consecutiveIgnoredStrongNudges: state.consecutiveIgnoredStrongNudges,
658
- }
659
- }
660
-
661
- /**
662
- * Restore persisted DCP state into an already-reset state object.
663
- * Handles older persisted payloads that did not include the newer accounting
664
- * and tool-call fingerprint fields.
665
- */
666
- export function restoreState(state: DcpState, data: unknown): void {
667
- if (!data || typeof data !== "object") return
668
- const saved = data as Partial<SerializedDcpState>
669
-
670
- if (Array.isArray(saved.compressionBlocks)) {
671
- const validBlocks = saved.compressionBlocks
672
- .filter((b: any) => Number.isFinite(b?.startTimestamp) && Number.isFinite(b?.endTimestamp))
673
- .map((b: any) => ({
674
- ...b,
675
- anchorTimestamp: Number.isFinite(b.anchorTimestamp)
676
- ? b.anchorTimestamp
677
- : b.endTimestamp + 1,
678
- })) as CompressionBlock[]
679
- state.compressionBlocks = validBlocks
680
- state.nextBlockId =
681
- typeof saved.nextBlockId === "number"
682
- ? saved.nextBlockId
683
- : validBlocks.length > 0
684
- ? Math.max(0, ...validBlocks.map((b) => b.id)) + 1
685
- : 1
686
- }
687
-
688
- if (Array.isArray(saved.prunedToolIds)) {
689
- state.prunedToolIds = new Set(saved.prunedToolIds.filter((id): id is string => typeof id === "string"))
690
- }
691
-
692
- if (Array.isArray(saved.prunedToolReasons)) {
693
- state.prunedToolReasons = new Map(
694
- saved.prunedToolReasons.filter(
695
- (entry): entry is [string, string] =>
696
- Array.isArray(entry) && typeof entry[0] === "string" && typeof entry[1] === "string",
697
- ),
698
- )
699
- }
700
-
701
- if (Array.isArray(saved.providerSeenToolIds)) {
702
- state.providerSeenToolIds = new Set(
703
- saved.providerSeenToolIds.filter((id): id is string => typeof id === "string"),
704
- )
705
- }
706
-
707
- if (Array.isArray(saved.compactToolCalls)) {
708
- // New compact format: restore CompactToolRecords as ToolRecords with
709
- // synthetic inputArgs derived from inputStringValues.
710
- state.toolCalls = new Map(
711
- saved.compactToolCalls
712
- .filter(isCompactToolRecord)
713
- .map((compact) => {
714
- const record: ToolRecord = {
715
- toolCallId: compact.toolCallId,
716
- toolName: compact.toolName,
717
- // Reconstruct minimal inputArgs from persisted string values
718
- // so isProtectedByFilePattern still works.
719
- inputArgs: compact.inputStringValues
720
- ? { _restoredValues: compact.inputStringValues }
721
- : {},
722
- inputFingerprint: compact.inputFingerprint,
723
- isError: compact.isError,
724
- turnIndex: compact.turnIndex,
725
- timestamp: compact.timestamp,
726
- tokenEstimate: compact.tokenEstimate,
727
- // outputText and outputDetails intentionally not restored —
728
- // they are only used during live compression block creation.
729
- }
730
- return [record.toolCallId, record] as const
731
- }),
732
- )
733
- } else if (Array.isArray(saved.toolCalls)) {
734
- // Legacy full format: restore as-is for backward compatibility.
735
- state.toolCalls = new Map(
736
- saved.toolCalls
737
- .filter(isToolRecord)
738
- .map((record) => [record.toolCallId, record] as const),
739
- )
740
- }
741
-
742
- if (typeof saved.tokensSaved === "number") state.tokensSaved = saved.tokensSaved
743
- if (typeof saved.totalPruneCount === "number") state.totalPruneCount = saved.totalPruneCount
744
-
745
- // Restore totalToolCallCount from the persisted snapshot, or fall back to
746
- // the number of restored tool records (which may be a trimmed subset).
747
- if (typeof saved.totalToolCallCount === "number" && saved.totalToolCallCount >= 0) {
748
- state.totalToolCallCount = saved.totalToolCallCount
749
- } else {
750
- state.totalToolCallCount = state.toolCalls.size
751
- }
752
-
753
- if (Array.isArray(saved.accountedCompressionBlockIds)) {
754
- state.accountedCompressionBlockIds = new Set(
755
- saved.accountedCompressionBlockIds.filter((id): id is number => typeof id === "number"),
756
- )
757
- } else {
758
- // Old payloads already persisted aggregate stats but not per-block accounting.
759
- // Treat existing active blocks as accounted to avoid inflated stats after reload.
760
- state.accountedCompressionBlockIds = new Set(state.compressionBlocks.map((b) => b.id))
761
- }
762
-
763
- if (Array.isArray(saved.compressionTokenSavings)) {
764
- state.compressionTokenSavings = new Map(
765
- saved.compressionTokenSavings.filter(
766
- (entry): entry is [number, number] =>
767
- Array.isArray(entry) && typeof entry[0] === "number" && typeof entry[1] === "number",
768
- ),
769
- )
770
- }
771
-
772
- if (Array.isArray(saved.accountedPrunedToolIds)) {
773
- state.accountedPrunedToolIds = new Set(
774
- saved.accountedPrunedToolIds.filter((id): id is string => typeof id === "string"),
775
- )
776
- } else {
777
- // Same migration safety for pre-accounting payloads.
778
- state.accountedPrunedToolIds = new Set(state.prunedToolIds)
779
- }
780
-
781
- if (typeof saved.manualMode === "boolean") {
782
- state.manualMode = saved.manualMode
783
- }
784
-
785
- if (Array.isArray(saved.nudgeAnchors)) {
786
- const seen = new Set<string>()
787
- state.nudgeAnchors = saved.nudgeAnchors
788
- .filter(isNudgeAnchor)
789
- .filter((anchor) => {
790
- const key = `${anchor.anchorStableId ?? ""}|${anchor.anchorTimestamp}`
791
- if (seen.has(key)) return false
792
- seen.add(key)
793
- return true
794
- })
795
- }
796
-
797
- if (typeof saved.nextNudgeAnchorId === "number" && saved.nextNudgeAnchorId > 0) {
798
- state.nextNudgeAnchorId = Math.floor(saved.nextNudgeAnchorId)
799
- } else if (state.nudgeAnchors.length > 0) {
800
- state.nextNudgeAnchorId = Math.max(...state.nudgeAnchors.map((anchor) => anchor.id)) + 1
801
- }
802
-
803
- if (isLastNudge(saved.lastNudge)) {
804
- state.lastNudge = saved.lastNudge
805
- }
806
-
807
- if (Array.isArray(saved.messageIdsByStableId)) {
808
- const usedIds = new Set<string>()
809
- const usedStableKeys = new Set<string>()
810
- state.messageIdsByStableId = new Map(
811
- saved.messageIdsByStableId.filter(
812
- (entry): entry is [string, string] => {
813
- if (!Array.isArray(entry) || typeof entry[0] !== "string" || typeof entry[1] !== "string") return false
814
- if (!entry[0] || !/^m\d+$/.test(entry[1]) || usedIds.has(entry[1]) || usedStableKeys.has(entry[0])) return false
815
- usedIds.add(entry[1])
816
- usedStableKeys.add(entry[0])
817
- return true
818
- },
819
- ),
820
- )
821
- }
822
- const highestRestoredMessageId = Math.max(
823
- 0,
824
- ...Array.from(state.messageIdsByStableId.values()).map((id) => Number.parseInt(id.slice(1), 10) || 0),
825
- )
826
- if (typeof saved.nextMessageId === "number" && Number.isFinite(saved.nextMessageId) && saved.nextMessageId > 0) {
827
- state.nextMessageId = Math.max(Math.floor(saved.nextMessageId), highestRestoredMessageId + 1)
828
- } else {
829
- state.nextMessageId = highestRestoredMessageId + 1
830
- }
831
-
832
- // nudgeCounter: clamp to a non-negative integer so a corrupted payload
833
- // cannot stall reminders by going negative. Default 0 keeps older sessions
834
- // behaving like a fresh cadence on first post-restart context event.
835
- if (typeof saved.nudgeCounter === "number" && Number.isFinite(saved.nudgeCounter) && saved.nudgeCounter >= 0) {
836
- state.nudgeCounter = Math.floor(saved.nudgeCounter)
837
- }
838
-
839
- // lastNudgeTurn and currentTurn default to createState() values when absent
840
- // (lastNudgeTurn = -1, currentTurn = 0). currentTurn is re-derived from
841
- // raw messages on every `context` event, so persistence here is best-effort
842
- // for telemetry continuity rather than authoritative.
843
- if (typeof saved.lastNudgeTurn === "number" && Number.isFinite(saved.lastNudgeTurn)) {
844
- state.lastNudgeTurn = Math.floor(saved.lastNudgeTurn)
845
- }
846
- if (typeof saved.currentTurn === "number" && Number.isFinite(saved.currentTurn) && saved.currentTurn >= 0) {
847
- state.currentTurn = Math.floor(saved.currentTurn)
848
- }
849
- if (typeof saved.lastAutomaticPruneTurn === "number" && Number.isFinite(saved.lastAutomaticPruneTurn)) {
850
- state.lastAutomaticPruneTurn = Math.floor(saved.lastAutomaticPruneTurn)
851
- }
852
- if (typeof saved.lastAutomaticPruneBlockId === "number" && Number.isFinite(saved.lastAutomaticPruneBlockId) && saved.lastAutomaticPruneBlockId >= 0) {
853
- state.lastAutomaticPruneBlockId = Math.floor(saved.lastAutomaticPruneBlockId)
854
- }
855
- if (typeof saved.lastContextWindow === "number" && Number.isFinite(saved.lastContextWindow) && saved.lastContextWindow > 0) {
856
- state.lastContextWindow = saved.lastContextWindow
857
- }
858
- if (typeof saved.consecutiveIgnoredStrongNudges === "number" && Number.isFinite(saved.consecutiveIgnoredStrongNudges) && saved.consecutiveIgnoredStrongNudges >= 0) {
859
- state.consecutiveIgnoredStrongNudges = Math.floor(saved.consecutiveIgnoredStrongNudges)
860
- }
461
+ /** True only for a complete, unambiguous modern v2 membership record. */
462
+ export function hasExactCompressionMembership(block: Pick<CompressionBlock, "sourceMembers" | "mutationMembers">): boolean {
463
+ return isCompressionMemberList(block.sourceMembers) && isCompressionMemberList(block.mutationMembers)
861
464
  }
862
465
 
863
466
  // ---------------------------------------------------------------------------
@@ -883,14 +486,7 @@ function sortObjectKeys(value: unknown): unknown {
883
486
  return value
884
487
  }
885
488
 
886
- /**
887
- * Create a stable deduplication fingerprint for a tool call.
888
- *
889
- * Two calls with the same `toolName` and semantically identical `args`
890
- * (regardless of key ordering) will produce the same fingerprint.
891
- *
892
- * Format: `<toolName>::sha256:<hash of recursively key-sorted args>`
893
- */
489
+ /** Create a stable fingerprint for a tool call and canonicalized input args. */
894
490
  export function createInputFingerprint(
895
491
  toolName: string,
896
492
  args: Record<string, unknown>,
@@ -901,21 +497,3 @@ export function createInputFingerprint(
901
497
  .digest("hex")
902
498
  return `${toolName}::sha256:${hash}`
903
499
  }
904
-
905
- // ---------------------------------------------------------------------------
906
- // State hashing for save deduplication
907
- // ---------------------------------------------------------------------------
908
-
909
- /**
910
- * Compute a fast hash of serialized state for deduplication.
911
- * Uses a simple DJB2-like hash over the JSON string. This is not
912
- * cryptographic — it's only used to avoid writing identical snapshots.
913
- */
914
- export function hashSerializedState(serialized: SerializedDcpState): string {
915
- const json = JSON.stringify(serialized)
916
- let hash = 5381
917
- for (let i = 0; i < json.length; i++) {
918
- hash = ((hash << 5) + hash + json.charCodeAt(i)) | 0
919
- }
920
- return (hash >>> 0).toString(36)
921
- }