pi-ui-extend 1.0.38 → 1.0.39

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 (40) hide show
  1. package/dist/app/extensions/extension-ui-controller.js +3 -0
  2. package/dist/app/rendering/extension-entry-renderer.js +2 -0
  3. package/dist/bundled-extensions/question/contract.d.ts +3 -0
  4. package/dist/bundled-extensions/question/contract.js +24 -0
  5. package/dist/bundled-extensions/question/desktop.d.ts +11 -0
  6. package/dist/bundled-extensions/question/desktop.js +143 -0
  7. package/dist/bundled-extensions/question/index.d.ts +1 -0
  8. package/dist/bundled-extensions/question/index.js +5 -1
  9. package/dist/bundled-extensions/question/render.js +6 -0
  10. package/dist/bundled-extensions/question/result.js +71 -9
  11. package/dist/bundled-extensions/question/tool-description.js +4 -3
  12. package/dist/bundled-extensions/question/tui.js +127 -12
  13. package/dist/bundled-extensions/question/types.d.ts +23 -2
  14. package/dist/tool-renderers/question.js +20 -1
  15. package/docs/desktop-markdown-media.md +77 -0
  16. package/docs/desktop-mvp.md +134 -0
  17. package/docs/desktop-task-manager.md +124 -0
  18. package/external/pi-tools-suite/package.json +3 -3
  19. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +8 -0
  20. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +41 -2
  21. package/external/pi-tools-suite/src/async-subagents/core/config.ts +12 -0
  22. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -2
  23. package/external/pi-tools-suite/src/async-subagents/subagent-overlay.ts +1 -1
  24. package/external/pi-tools-suite/src/dcp/auto-compress.ts +1 -1
  25. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -51
  26. package/external/pi-tools-suite/src/dcp/debug-log.ts +6 -0
  27. package/external/pi-tools-suite/src/dcp/index.ts +27 -125
  28. package/external/pi-tools-suite/src/dcp/prompts.ts +2 -2
  29. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +2 -1
  30. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +31 -10
  31. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +6 -7
  32. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +153 -111
  33. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +28 -17
  34. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +61 -27
  35. package/external/pi-tools-suite/src/dcp/pruner.ts +21 -13
  36. package/external/pi-tools-suite/src/dcp/state.ts +59 -0
  37. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +4 -0
  38. package/external/pi-tools-suite/src/lib/rpc-session-state.ts +34 -0
  39. package/external/pi-tools-suite/src/todo/todo.ts +8 -3
  40. package/package.json +8 -4
@@ -1,5 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import type { ToolRecord } from "./state.js";
3
+ import { stripStaleDcpMetadataLines } from "./pruner-metadata.js";
3
4
 
4
5
  export interface ProviderToolResultEvidence {
5
6
  ids: Set<string>;
@@ -19,7 +20,7 @@ function signature(toolName: string, outputText: string): string {
19
20
  return createHash("sha256")
20
21
  .update(toolName)
21
22
  .update("\u0000")
22
- .update(outputText)
23
+ .update(stripStaleDcpMetadataLines(outputText))
23
24
  .digest("hex");
24
25
  }
25
26
 
@@ -6,6 +6,7 @@ import {
6
6
  extractBlockId,
7
7
  messageText,
8
8
  } from "./pruner-metadata.js";
9
+ import { stableMessageKeys } from "./pruner-message-ids.js";
9
10
 
10
11
  interface CandidateBoundary {
11
12
  id: string;
@@ -24,13 +25,27 @@ function isActiveBlockId(blockId: number, state: DcpState): boolean {
24
25
  return state.compressionBlocks.some((block) => block.id === blockId && block.active);
25
26
  }
26
27
 
27
- function findCurrentMessageId(msg: any, state: DcpState): string | undefined {
28
+ function findCurrentMessageId(msg: any, stableKey: string, state: DcpState): string | undefined {
28
29
  const role = msg?.role ?? "";
29
30
  const timestamp = msg?.timestamp;
30
31
  if (!Number.isFinite(timestamp)) return undefined;
31
32
 
32
33
  for (const [id, meta] of state.messageMetaSnapshot) {
33
- if (meta.timestamp === timestamp && meta.role === role && meta.blockId === undefined) return id;
34
+ if (meta.stableId === stableKey) return id;
35
+ }
36
+
37
+ // Backward compatibility for snapshots created before stable IDs were
38
+ // recorded. Never mix timestamp fallback with a modern stable-ID snapshot.
39
+ const isLegacySnapshot = [...state.messageMetaSnapshot.values()]
40
+ .every((meta) => meta.stableId === undefined);
41
+ if (!isLegacySnapshot) return undefined;
42
+
43
+ for (const [id, meta] of state.messageMetaSnapshot) {
44
+ if (
45
+ meta.timestamp === timestamp &&
46
+ meta.role === role &&
47
+ meta.blockId === undefined
48
+ ) return id;
34
49
  }
35
50
 
36
51
  for (const [id, ts] of state.messageIdSnapshot) {
@@ -42,6 +57,7 @@ function findCurrentMessageId(msg: any, state: DcpState): string | undefined {
42
57
 
43
58
  function resolveAddressableBoundaryId(
44
59
  msg: any,
60
+ stableKey: string,
45
61
  state: DcpState,
46
62
  options: { allowBlocks: boolean },
47
63
  ): { id: string; blockId?: number; text: string } | null {
@@ -55,8 +71,9 @@ function resolveAddressableBoundaryId(
55
71
 
56
72
  // Inline [dcp-id] markers are no longer injected into message content; the
57
73
  // snapshot rebuilt by injectMessageIds() is the sole addressability source.
58
- // Resolve the message id by matching its (timestamp, role) in the snapshot.
59
- const currentId = findCurrentMessageId(msg, state);
74
+ // Resolve the message ID by its persistent stable identity. Timestamp/role
75
+ // matching is retained only for legacy snapshots without stable IDs.
76
+ const currentId = findCurrentMessageId(msg, stableKey, state);
60
77
  if (currentId) return { id: currentId, text };
61
78
 
62
79
  return null;
@@ -73,15 +90,17 @@ export function detectCompressionCandidate(
73
90
  if (contextPercent < settings.minContextPercent) return null;
74
91
 
75
92
  const boundaries: CandidateBoundary[] = [];
76
- for (const msg of messages) {
77
- const boundary = resolveAddressableBoundaryId(msg, _state, { allowBlocks: true });
93
+ const stableKeys = stableMessageKeys(messages);
94
+ for (let index = 0; index < messages.length; index++) {
95
+ const msg = messages[index];
96
+ const boundary = resolveAddressableBoundaryId(msg, stableKeys[index]!, _state, { allowBlocks: true });
78
97
  if (!boundary) continue;
79
98
  if (!Number.isFinite(msg.timestamp)) continue;
80
99
  boundaries.push({
81
100
  id: boundary.id,
82
101
  role: msg.role ?? "",
83
102
  timestamp: msg.timestamp,
84
- tokenEstimate: estimateMessageTokens(msg),
103
+ tokenEstimate: _state.messageMetaSnapshot.get(boundary.id)?.tokenEstimate ?? estimateMessageTokens(msg),
85
104
  blockId: boundary.blockId,
86
105
  isSystemReminder: boundary.text.includes("<dcp-system-reminder>"),
87
106
  });
@@ -153,15 +172,17 @@ export function detectMessageCompressionCandidates(
153
172
  if (contextPercent < settings.minContextPercent) return [];
154
173
 
155
174
  const boundaries: CandidateBoundary[] = [];
156
- for (const msg of messages) {
157
- const boundary = resolveAddressableBoundaryId(msg, state, { allowBlocks: false });
175
+ const stableKeys = stableMessageKeys(messages);
176
+ for (let index = 0; index < messages.length; index++) {
177
+ const msg = messages[index];
178
+ const boundary = resolveAddressableBoundaryId(msg, stableKeys[index]!, state, { allowBlocks: false });
158
179
  if (!boundary || boundary.blockId !== undefined) continue;
159
180
  if (!Number.isFinite(msg.timestamp)) continue;
160
181
  boundaries.push({
161
182
  id: boundary.id,
162
183
  role: msg.role ?? "",
163
184
  timestamp: msg.timestamp,
164
- tokenEstimate: estimateMessageTokens(msg),
185
+ tokenEstimate: state.messageMetaSnapshot.get(boundary.id)?.tokenEstimate ?? estimateMessageTokens(msg),
165
186
  isSystemReminder: boundary.text.includes("<dcp-system-reminder>"),
166
187
  });
167
188
  }
@@ -1,16 +1,15 @@
1
1
  import type { DcpConfig } from "./config.js";
2
2
  import type { DcpState } from "./state.js";
3
3
  import { PASSTHROUGH_ROLES, estimateMessageTokens } from "./pruner-metadata.js";
4
- import { stableMessageId } from "./pruner-message-ids.js";
4
+ import { stableMessageKeys } from "./pruner-message-ids.js";
5
5
  import { writeDcpDebugLog } from "./debug-log.js";
6
6
 
7
- function messageMatchesBoundary(msg: any, messageIndex: number, stableId: string | undefined, timestamp: number): boolean {
8
- if (stableId && stableMessageId(msg, messageIndex) === stableId) return true;
9
- return msg.timestamp === timestamp;
10
- }
11
-
12
7
  function findBoundaryIndex(messages: any[], stableId: string | undefined, timestamp: number): number {
13
- return messages.findIndex((m, index) => messageMatchesBoundary(m, index, stableId, timestamp));
8
+ const stableKeys = stableId ? stableMessageKeys(messages) : [];
9
+ return messages.findIndex((message, index) => {
10
+ if (stableId && stableKeys[index] === stableId) return true;
11
+ return message.timestamp === timestamp;
12
+ });
14
13
  }
15
14
 
16
15
  export interface ReconcileInheritedBlocksResult {
@@ -1,3 +1,4 @@
1
+ import { createHash } from "node:crypto";
1
2
  import type { DcpState, MessageIdMeta } from "./state.js";
2
3
  import type { DcpConfig } from "./config.js";
3
4
  import {
@@ -6,6 +7,7 @@ import {
6
7
  estimateMessageTokens,
7
8
  extractBlockId,
8
9
  messageText,
10
+ stripStaleDcpMetadataLines,
9
11
  } from "./pruner-metadata.js";
10
12
 
11
13
  export interface InjectMessageIdsOptions {
@@ -13,6 +15,39 @@ export interface InjectMessageIdsOptions {
13
15
  config?: DcpConfig;
14
16
  }
15
17
 
18
+ const ID_CARRIER_ROLES = new Set(["user", "toolResult", "bashExecution"]);
19
+
20
+ function canonicalContentForStableId(msg: any): unknown {
21
+ if (!Array.isArray(msg?.content)) {
22
+ if (msg?.role === "assistant") return msg?.content ?? "";
23
+ return stripStaleDcpMetadataLines(typeof msg?.content === "string" ? msg.content : "");
24
+ }
25
+
26
+ if (msg?.role === "assistant") return msg.content;
27
+
28
+ return msg.content.flatMap((block: any) => {
29
+ if (!block || typeof block !== "object") return [block];
30
+ if (typeof block.text === "string") {
31
+ const text = stripStaleDcpMetadataLines(block.text);
32
+ return text.length > 0 ? [{ ...block, text }] : [];
33
+ }
34
+ if (typeof block.thinking === "string") {
35
+ const thinking = stripStaleDcpMetadataLines(block.thinking);
36
+ return thinking.length > 0 ? [{ ...block, thinking }] : [];
37
+ }
38
+ return [block];
39
+ });
40
+ }
41
+
42
+ function fallbackContentFingerprint(msg: any): string {
43
+ return createHash("sha256")
44
+ .update(String(msg?.role ?? ""))
45
+ .update("\u0000")
46
+ .update(JSON.stringify(canonicalContentForStableId(msg)))
47
+ .digest("hex")
48
+ .slice(0, 20);
49
+ }
50
+
16
51
  export function stableMessageId(msg: any, fallbackIndex = 0): string {
17
52
  const candidates = [
18
53
  msg?.id,
@@ -32,10 +67,29 @@ export function stableMessageId(msg: any, fallbackIndex = 0): string {
32
67
  return `tool:${msg.toolCallId.trim()}`;
33
68
  }
34
69
 
35
- if (Number.isFinite(msg?.timestamp)) return `ts:${msg.timestamp}`;
70
+ const blockId = extractBlockId(messageText(msg));
71
+ if (blockId !== undefined) return `block:${blockId}`;
72
+ if (Number.isFinite(msg?.timestamp)) {
73
+ return `ts:${msg.timestamp}:${fallbackContentFingerprint(msg)}`;
74
+ }
36
75
  return `idx:${fallbackIndex}`;
37
76
  }
38
77
 
78
+ /**
79
+ * Return deterministic per-occurrence identities for a message sequence.
80
+ * Session entry IDs and content-fingerprinted fallbacks are normally unique;
81
+ * the suffix keeps byte-identical legacy messages from sharing one mNNN ID.
82
+ */
83
+ export function stableMessageKeys(messages: any[]): string[] {
84
+ const occurrences = new Map<string, number>();
85
+ return messages.map((message, index) => {
86
+ const base = stableMessageId(message, index);
87
+ const occurrence = (occurrences.get(base) ?? 0) + 1;
88
+ occurrences.set(base, occurrence);
89
+ return occurrence === 1 ? base : `${base}#${occurrence}`;
90
+ });
91
+ }
92
+
39
93
  function priorityForMessage(tokenEstimate: number, config: DcpConfig | undefined): "low" | "medium" | "high" {
40
94
  const settings = config?.compress?.messageMode;
41
95
  const mediumTokens = Math.max(1, settings?.mediumTokens ?? 500);
@@ -46,140 +100,128 @@ function priorityForMessage(tokenEstimate: number, config: DcpConfig | undefined
46
100
  return "low";
47
101
  }
48
102
 
49
- function messageIdIndex(id: string): number | undefined {
50
- const match = /^m(\d+)$/.exec(id);
51
- if (!match) return undefined;
52
- const index = Number.parseInt(match[1]!, 10);
53
- return Number.isFinite(index) ? index : undefined;
54
- }
55
-
56
- function summarizeMessageIds(ids: string[]): string {
57
- if (ids.length === 0) return "none";
58
-
59
- const ranges: string[] = [];
60
- let start = ids[0]!;
61
- let previous = ids[0]!;
62
- let previousIndex = messageIdIndex(previous);
63
-
64
- const flush = () => {
65
- const startIndex = messageIdIndex(start);
66
- const endIndex = messageIdIndex(previous);
67
- if (
68
- startIndex !== undefined &&
69
- endIndex !== undefined &&
70
- endIndex - startIndex >= 2
71
- ) {
72
- ranges.push(`${start}..${previous}`);
73
- } else if (start === previous) {
74
- ranges.push(start);
75
- } else {
76
- ranges.push(start, previous);
77
- }
78
- };
79
-
80
- for (let i = 1; i < ids.length; i++) {
81
- const id = ids[i]!;
82
- const index = messageIdIndex(id);
83
- if (previousIndex !== undefined && index === previousIndex + 1) {
84
- previous = id;
85
- previousIndex = index;
86
- continue;
87
- }
103
+ function persistentMessageId(stableKey: string, state: DcpState): string {
104
+ const existing = state.messageIdsByStableId.get(stableKey);
105
+ if (existing) return existing;
88
106
 
89
- flush();
90
- start = id;
91
- previous = id;
92
- previousIndex = index;
93
- }
94
-
95
- flush();
96
- return ranges.join(", ");
107
+ const id = `m${String(state.nextMessageId++).padStart(3, "0")}`;
108
+ state.messageIdsByStableId.set(stableKey, id);
109
+ return id;
97
110
  }
98
111
 
99
- function compactPriorityHint(ids: string[], state: DcpState): string | undefined {
100
- const high: string[] = [];
101
- const medium: string[] = [];
102
- const blocks: string[] = [];
103
-
104
- for (const id of ids) {
105
- const meta = state.messageMetaSnapshot.get(id);
106
- if (!meta) continue;
107
- if (meta.priority === "high") high.push(id);
108
- else if (meta.priority === "medium") medium.push(id);
109
- if (meta.blockId !== undefined) blocks.push(`${id}=b${meta.blockId}`);
110
- }
111
-
112
- const parts: string[] = [];
113
- if (high.length > 0) parts.push(`high=${summarizeMessageIds(high)}`);
114
- if (medium.length > 0) parts.push(`medium=${summarizeMessageIds(medium)}`);
115
- if (blocks.length > 0) parts.push(`blocks=${blocks.join(",")}`);
116
- return parts.length > 0 ? `Hints: ${parts.join("; ")}` : undefined;
112
+ function carrierSelfLabel(role: string): string {
113
+ if (role === "toolResult") return "this tool result";
114
+ if (role === "bashExecution") return "this bash result";
115
+ return "this user message";
117
116
  }
118
117
 
119
- export function buildMessageIdControlText(state: DcpState): string | undefined {
120
- const ids = [...state.messageIdSnapshot.keys()];
121
- if (ids.length === 0) return undefined;
122
-
123
- const hint = compactPriorityHint(ids, state);
124
-
118
+ function buildCarrierControlText(
119
+ precedingAssistantIds: string[],
120
+ ownId: string,
121
+ ownRole: string,
122
+ blockId: number | undefined,
123
+ ): string {
124
+ const assignments = [
125
+ ...precedingAssistantIds.map((id) => `${id}=preceding assistant message`),
126
+ `${ownId}=${carrierSelfLabel(ownRole)}`,
127
+ ];
125
128
  return [
126
129
  "<dcp-message-ids>",
127
- "DCP metadata for the preceding conversation messages. IDs follow current message order; use only these IDs with compress; do not quote/output.",
128
- `Current raw message IDs: ${summarizeMessageIds(ids)}${ids.length > 2 ? ` (${ids.length} messages)` : ""}`,
129
- ...(hint ? [hint] : []),
130
+ `Stable DCP IDs (use with compress; do not quote/output): ${assignments.join("; ")}`,
131
+ ...(blockId === undefined ? [] : [`Active compressed block alias: b${blockId}`]),
130
132
  "</dcp-message-ids>",
131
133
  ].join("\n");
132
134
  }
133
135
 
136
+ function appendControlToCarrier(message: any, controlText: string): void {
137
+ const suffix = `\n\n${controlText}`;
138
+ if (typeof message.content === "string") {
139
+ message.content += suffix;
140
+ return;
141
+ }
142
+ if (Array.isArray(message.content)) {
143
+ message.content = [...message.content, { type: "text", text: suffix }];
144
+ return;
145
+ }
146
+ message.content = [{ type: "text", text: controlText }];
147
+ }
148
+
149
+ /**
150
+ * Build the current addressability snapshot and distribute immutable ID
151
+ * metadata over append-only user/tool-result carriers. Assistant messages are
152
+ * never modified: provider-signed reasoning and function-call items must replay
153
+ * byte-for-byte for Responses continuation.
154
+ */
134
155
  export function injectMessageIds(
135
156
  messages: any[],
136
157
  state: DcpState,
137
158
  options: InjectMessageIdsOptions = {},
138
159
  ): void {
139
- // Rebuild into local maps and assign once at the end, so readers (compress
140
- // tool, candidates, provider payload builder) never observe a transiently
141
- // empty snapshot mid-rebuild. The previous clear()-then-set loop exposed a
142
- // window where any concurrent reader saw `messageIdSnapshot` as empty,
143
- // which surfaced to the model as `Current raw message IDs: none`.
144
- const nextIdSnapshot = new Map<string, number>()
145
- const nextMetaSnapshot = new Map<string, MessageIdMeta>()
146
-
147
- let counter = 1;
160
+ const nextIdSnapshot = new Map<string, number>();
161
+ const nextMetaSnapshot = new Map<string, MessageIdMeta>();
162
+ const stableKeys = stableMessageKeys(messages);
163
+ const assignedIds = new Map<number, string>();
164
+ const assignedMeta = new Map<number, MessageIdMeta>();
148
165
 
149
166
  for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
150
167
  const msg = messages[messageIndex];
151
- const role: string = msg.role ?? "";
152
-
153
- // Skip PI-internal passthrough messages
154
- if (PASSTHROUGH_ROLES.has(role)) continue;
155
- // Skip non-eligible roles
156
- if (!ID_ELIGIBLE_ROLES.has(role)) continue;
157
-
158
- const id = "m" + String(counter).padStart(3, "0");
159
- counter++;
168
+ const role: string = msg?.role ?? "";
169
+ if (PASSTHROUGH_ROLES.has(role) || !ID_ELIGIBLE_ROLES.has(role)) continue;
170
+ if (!Number.isFinite(msg?.timestamp)) continue;
160
171
 
172
+ const stableKey = stableKeys[messageIndex]!;
173
+ const id = persistentMessageId(stableKey, state);
161
174
  const originalText = messageText(msg);
162
175
  const blockId = extractBlockId(originalText);
163
176
  const tokenEstimate = estimateMessageTokens(msg);
164
- const priority = priorityForMessage(tokenEstimate, options.config);
165
- const rawStableId = stableMessageId(msg, messageIndex);
166
-
167
- if (msg.timestamp !== undefined) {
168
- nextIdSnapshot.set(id, msg.timestamp);
169
- nextMetaSnapshot.set(id, {
170
- timestamp: msg.timestamp,
171
- stableId: rawStableId,
172
- role,
173
- blockId,
174
- toolCallId: typeof msg.toolCallId === "string" ? msg.toolCallId : undefined,
175
- toolName: typeof msg.toolName === "string" ? msg.toolName : undefined,
176
- text: originalText,
177
- tokenEstimate,
178
- priority,
179
- });
177
+
178
+ assignedIds.set(messageIndex, id);
179
+ assignedMeta.set(messageIndex, {
180
+ timestamp: msg.timestamp,
181
+ stableId: stableKey,
182
+ role,
183
+ blockId,
184
+ toolCallId: typeof msg.toolCallId === "string" ? msg.toolCallId : undefined,
185
+ toolName: typeof msg.toolName === "string" ? msg.toolName : undefined,
186
+ text: originalText,
187
+ tokenEstimate,
188
+ priority: priorityForMessage(tokenEstimate, options.config),
189
+ });
190
+ }
191
+
192
+ const publish = (messageIndex: number): void => {
193
+ const id = assignedIds.get(messageIndex);
194
+ const meta = assignedMeta.get(messageIndex);
195
+ if (!id || !meta) return;
196
+ nextIdSnapshot.set(id, meta.timestamp);
197
+ nextMetaSnapshot.set(id, meta);
198
+ };
199
+ const pendingAssistantIndexes: number[] = [];
200
+ for (let messageIndex = 0; messageIndex < messages.length; messageIndex++) {
201
+ const message = messages[messageIndex];
202
+ const role: string = message?.role ?? "";
203
+ const id = assignedIds.get(messageIndex);
204
+ if (!id) continue;
205
+
206
+ if (role === "assistant") {
207
+ pendingAssistantIndexes.push(messageIndex);
208
+ continue;
180
209
  }
210
+ if (!ID_CARRIER_ROLES.has(role)) continue;
211
+
212
+ for (const pendingIndex of pendingAssistantIndexes) publish(pendingIndex);
213
+ publish(messageIndex);
214
+ const pendingAssistantIds = pendingAssistantIndexes
215
+ .map((pendingIndex) => assignedIds.get(pendingIndex))
216
+ .filter((pendingId): pendingId is string => pendingId !== undefined);
217
+ const blockId = assignedMeta.get(messageIndex)?.blockId;
218
+ appendControlToCarrier(
219
+ message,
220
+ buildCarrierControlText(pendingAssistantIds, id, role, blockId),
221
+ );
222
+ pendingAssistantIndexes.length = 0;
181
223
  }
182
224
 
183
- state.messageIdSnapshot = nextIdSnapshot
184
- state.messageMetaSnapshot = nextMetaSnapshot
225
+ state.messageIdSnapshot = nextIdSnapshot;
226
+ state.messageMetaSnapshot = nextMetaSnapshot;
185
227
  }
@@ -182,7 +182,7 @@ export function extractMessageId(text: string): string | undefined {
182
182
  )?.[1];
183
183
  }
184
184
 
185
- function stripStaleDcpMetadataLines(text: string): string {
185
+ export function stripStaleDcpMetadataLines(text: string): string {
186
186
  if (!text.includes("<dcp-") && !text.includes("[dcp-")) return text;
187
187
 
188
188
  const lines = text.split("\n");
@@ -238,21 +238,29 @@ function stripStaleDcpMetadataLines(text: string): string {
238
238
 
239
239
  function stripStaleDcpMetadataFromAssistantBlock(block: any): any | undefined {
240
240
  if (!block || typeof block !== "object") return block;
241
- const next = { ...block };
241
+ const textCanChange = typeof block.text === "string" &&
242
+ (block.text.includes("<dcp-") || block.text.includes("[dcp-"));
243
+ const thinkingCanChange = typeof block.thinking === "string" &&
244
+ (block.thinking.includes("<dcp-") || block.thinking.includes("[dcp-"));
245
+ if (!textCanChange && !thinkingCanChange) return block;
246
+
247
+ let next = block;
242
248
  let touched = false;
243
249
 
244
- if (typeof next.text === "string") {
245
- const stripped = stripStaleDcpMetadataLines(next.text);
246
- if (stripped !== next.text) {
250
+ if (typeof block.text === "string") {
251
+ const stripped = stripStaleDcpMetadataLines(block.text);
252
+ if (stripped !== block.text) {
253
+ next = { ...next };
247
254
  touched = true;
248
255
  next.text = stripped;
249
256
  delete next.textSignature;
250
257
  }
251
258
  }
252
259
 
253
- if (typeof next.thinking === "string") {
254
- const stripped = stripStaleDcpMetadataLines(next.thinking);
255
- if (stripped !== next.thinking) {
260
+ if (typeof block.thinking === "string") {
261
+ const stripped = stripStaleDcpMetadataLines(block.thinking);
262
+ if (stripped !== block.thinking) {
263
+ if (!touched) next = { ...next };
256
264
  touched = true;
257
265
  next.thinking = stripped;
258
266
  delete next.thinkingSignature;
@@ -265,20 +273,23 @@ function stripStaleDcpMetadataFromAssistantBlock(block: any): any | undefined {
265
273
  }
266
274
 
267
275
  export function stripStaleDcpMetadataFromAssistantMessage(message: any): any {
268
- if (!message || typeof message !== "object" || message.role !== "assistant") return message;
269
- return stripStaleDcpMetadataFromMessage(message);
276
+ return message;
270
277
  }
271
278
 
272
279
  export function stripStaleDcpMetadataFromMessage(message: any): any {
273
280
  if (!message || typeof message !== "object") return message;
281
+ // Assistant content may contain provider-signed reasoning, text, or tool-call
282
+ // items. Even DCP-looking text must replay byte-for-byte once persisted.
283
+ if (message.role === "assistant") return message;
274
284
  if (typeof message.content === "string") {
275
- return { ...message, content: stripStaleDcpMetadataLines(message.content) };
285
+ const stripped = stripStaleDcpMetadataLines(message.content);
286
+ return stripped === message.content ? message : { ...message, content: stripped };
276
287
  }
277
288
  if (!Array.isArray(message.content)) return message;
278
- return {
279
- ...message,
280
- content: message.content
281
- .map(stripStaleDcpMetadataFromAssistantBlock)
282
- .filter((block: any) => block !== undefined),
283
- };
289
+ const content = message.content
290
+ .map(stripStaleDcpMetadataFromAssistantBlock)
291
+ .filter((block: any) => block !== undefined);
292
+ const unchanged = content.length === message.content.length &&
293
+ content.every((block: any, index: number) => block === message.content[index]);
294
+ return unchanged ? message : { ...message, content };
284
295
  }