pi-ui-extend 1.0.37 → 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 (44) hide show
  1. package/README.md +1 -0
  2. package/dist/app/extensions/extension-ui-controller.js +3 -0
  3. package/dist/app/rendering/extension-entry-renderer.js +2 -0
  4. package/dist/bundled-extensions/question/contract.d.ts +3 -0
  5. package/dist/bundled-extensions/question/contract.js +24 -0
  6. package/dist/bundled-extensions/question/desktop.d.ts +11 -0
  7. package/dist/bundled-extensions/question/desktop.js +143 -0
  8. package/dist/bundled-extensions/question/index.d.ts +1 -0
  9. package/dist/bundled-extensions/question/index.js +5 -1
  10. package/dist/bundled-extensions/question/render.js +6 -0
  11. package/dist/bundled-extensions/question/result.js +71 -9
  12. package/dist/bundled-extensions/question/tool-description.js +4 -3
  13. package/dist/bundled-extensions/question/tui.js +127 -12
  14. package/dist/bundled-extensions/question/types.d.ts +23 -2
  15. package/dist/tool-renderers/question.js +20 -1
  16. package/docs/desktop-markdown-media.md +77 -0
  17. package/docs/desktop-mvp.md +134 -0
  18. package/docs/desktop-task-manager.md +124 -0
  19. package/external/pi-tools-suite/package.json +3 -3
  20. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +9 -1
  21. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +41 -2
  22. package/external/pi-tools-suite/src/async-subagents/core/config.ts +13 -1
  23. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -2
  24. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/SKILL.md +65 -22
  25. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/references/qa-design.md +23 -8
  26. package/external/pi-tools-suite/src/async-subagents/private-skills/browser-qa/scripts/browser-qa-runner.mjs +180 -41
  27. package/external/pi-tools-suite/src/async-subagents/subagent-overlay.ts +1 -1
  28. package/external/pi-tools-suite/src/dcp/auto-compress.ts +1 -1
  29. package/external/pi-tools-suite/src/dcp/compression-blocks.ts +1 -51
  30. package/external/pi-tools-suite/src/dcp/debug-log.ts +6 -0
  31. package/external/pi-tools-suite/src/dcp/index.ts +27 -125
  32. package/external/pi-tools-suite/src/dcp/prompts.ts +2 -2
  33. package/external/pi-tools-suite/src/dcp/provider-tool-results.ts +2 -1
  34. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +31 -10
  35. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +6 -7
  36. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +153 -111
  37. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +28 -17
  38. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +61 -27
  39. package/external/pi-tools-suite/src/dcp/pruner.ts +21 -13
  40. package/external/pi-tools-suite/src/dcp/state.ts +59 -0
  41. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +4 -0
  42. package/external/pi-tools-suite/src/lib/rpc-session-state.ts +34 -0
  43. package/external/pi-tools-suite/src/todo/todo.ts +8 -3
  44. package/package.json +10 -4
@@ -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
  }
@@ -7,7 +7,7 @@ import type {
7
7
  NudgeThresholds,
8
8
  } from "./pruner-types.js";
9
9
  import { extractBlockId, messageText } from "./pruner-metadata.js";
10
- import { stableMessageId } from "./pruner-message-ids.js";
10
+ import { stableMessageKeys } from "./pruner-message-ids.js";
11
11
 
12
12
  function coercePercentThreshold(value: number | string | undefined, fallback: number): number {
13
13
  if (typeof value === "number") return Number.isFinite(value) ? value : fallback;
@@ -56,6 +56,7 @@ function isRealAnchorCandidate(msg: any): boolean {
56
56
  }
57
57
 
58
58
  function findAnchorMessage(messages: any[]): { msg: any; index: number; stableId: string; timestamp: number; role: string } | null {
59
+ const stableKeys = stableMessageKeys(messages);
59
60
  // Prefer the latest real user message: it gives the reminder direct user-like
60
61
  // salience without creating a new synthetic message at the end of context.
61
62
  for (let i = messages.length - 1; i >= 0; i--) {
@@ -65,7 +66,7 @@ function findAnchorMessage(messages: any[]): { msg: any; index: number; stableId
65
66
  return {
66
67
  msg,
67
68
  index: i,
68
- stableId: stableMessageId(msg, i),
69
+ stableId: stableKeys[i]!,
69
70
  timestamp: msg.timestamp,
70
71
  role: msg.role,
71
72
  };
@@ -79,7 +80,7 @@ function findAnchorMessage(messages: any[]): { msg: any; index: number; stableId
79
80
  return {
80
81
  msg,
81
82
  index: i,
82
- stableId: stableMessageId(msg, i),
83
+ stableId: stableKeys[i]!,
83
84
  timestamp: msg.timestamp,
84
85
  role: msg.role,
85
86
  };
@@ -88,8 +89,8 @@ function findAnchorMessage(messages: any[]): { msg: any; index: number; stableId
88
89
  return null;
89
90
  }
90
91
 
91
- function anchorMatchesMessage(anchor: DcpNudgeAnchor, msg: any, index: number): boolean {
92
- if (anchor.anchorStableId && stableMessageId(msg, index) === anchor.anchorStableId) return true;
92
+ function anchorMatchesMessage(anchor: DcpNudgeAnchor, msg: any, stableKey: string): boolean {
93
+ if (anchor.anchorStableId && stableKey === anchor.anchorStableId) return true;
93
94
  return msg?.timestamp === anchor.anchorTimestamp;
94
95
  }
95
96
 
@@ -207,34 +208,33 @@ export function upsertNudgeAnchor(
207
208
  messages: any[],
208
209
  state: DcpState,
209
210
  type: DcpNudgeType,
210
- options: { contextPercent?: number } = {},
211
+ options: { contextPercent?: number; renderedReminder?: string } = {},
211
212
  ): { anchor: DcpNudgeAnchor | null; created: boolean; updated: boolean } {
212
- const target = findAnchorMessage(messages);
213
- if (!target) {
214
- // The caller will append one synthetic tail reminder. Drop persisted
215
- // anchors first so applyAnchoredNudges cannot render a second reminder.
216
- state.nudgeAnchors = [];
217
- state.lastNudge = undefined;
218
- return { anchor: null, created: false, updated: false };
219
- }
220
-
221
- const key = `${target.stableId}|${target.timestamp}`;
213
+ const stableKeys = stableMessageKeys(messages);
222
214
  let existing: DcpNudgeAnchor | null = null;
223
215
  for (const anchor of state.nudgeAnchors) {
224
- const anchorKey = `${anchor.anchorStableId ?? ""}|${anchor.anchorTimestamp}`;
225
- if (anchorKey !== key) continue;
216
+ const stillPresent = messages.some((message, index) =>
217
+ isRealAnchorCandidate(message) && anchorMatchesMessage(anchor, message, stableKeys[index]!),
218
+ );
219
+ if (!stillPresent) continue;
226
220
  if (!existing || isNewerAnchor(anchor, existing)) existing = anchor;
227
221
  }
228
222
 
229
223
  const now = Date.now();
230
224
  if (existing) {
231
- // Older sidecars may contain several anchors. Keep only the anchor for the
232
- // current target so every subsequent context pass has singleton state.
225
+ // Keep the original carrier and frozen text. Moving a reminder to the
226
+ // newest user message would remove bytes from an old provider-cache prefix.
233
227
  state.nudgeAnchors = [existing];
234
228
  const shouldUpgrade = typePriority(type) > typePriority(existing.type);
235
- if (shouldUpgrade) existing.type = type;
236
- existing.updatedAt = now;
237
- existing.contextPercent = options.contextPercent ?? existing.contextPercent;
229
+ if (shouldUpgrade) {
230
+ existing.type = type;
231
+ existing.contextPercent = options.contextPercent ?? existing.contextPercent;
232
+ existing.renderedReminder = options.renderedReminder ?? existing.renderedReminder;
233
+ existing.updatedAt = now;
234
+ } else if (!existing.renderedReminder && options.renderedReminder) {
235
+ // One-time migration for anchors persisted before frozen rendering.
236
+ existing.renderedReminder = options.renderedReminder;
237
+ }
238
238
  state.lastNudge = {
239
239
  type: existing.type,
240
240
  anchorId: existing.id,
@@ -246,6 +246,15 @@ export function upsertNudgeAnchor(
246
246
  return { anchor: existing, created: false, updated: shouldUpgrade };
247
247
  }
248
248
 
249
+ const target = findAnchorMessage(messages);
250
+ if (!target) {
251
+ // The caller will append one synthetic tail reminder. Drop persisted
252
+ // anchors first so applyAnchoredNudges cannot render a second reminder.
253
+ state.nudgeAnchors = [];
254
+ state.lastNudge = undefined;
255
+ return { anchor: null, created: false, updated: false };
256
+ }
257
+
249
258
  const anchor: DcpNudgeAnchor = {
250
259
  id: state.nextNudgeAnchorId++,
251
260
  type,
@@ -254,6 +263,7 @@ export function upsertNudgeAnchor(
254
263
  anchorRole: target.role,
255
264
  turnIndex: state.currentTurn,
256
265
  contextPercent: options.contextPercent,
266
+ renderedReminder: options.renderedReminder,
257
267
  createdAt: now,
258
268
  updatedAt: now,
259
269
  };
@@ -275,12 +285,16 @@ export function applyAnchoredNudges(
275
285
  messages: any[],
276
286
  state: DcpState,
277
287
  render: (anchor: DcpNudgeAnchor) => string,
278
- ): void {
279
- if (state.nudgeAnchors.length === 0) return;
288
+ ): { rendered: boolean; stateChanged: boolean } {
289
+ if (state.nudgeAnchors.length === 0) return { rendered: false, stateChanged: false };
280
290
 
291
+ const stableKeys = stableMessageKeys(messages);
292
+ const priorAnchors = state.nudgeAnchors;
281
293
  let selected: { anchor: DcpNudgeAnchor; index: number } | null = null;
282
294
  for (const anchor of state.nudgeAnchors) {
283
- const index = messages.findIndex((msg, messageIndex) => anchorMatchesMessage(anchor, msg, messageIndex));
295
+ const index = messages.findIndex((msg, messageIndex) =>
296
+ anchorMatchesMessage(anchor, msg, stableKeys[messageIndex]!),
297
+ );
284
298
  if (index === -1) continue;
285
299
  if (!selected || isNewerAnchor(anchor, selected.anchor)) {
286
300
  selected = { anchor, index };
@@ -290,7 +304,27 @@ export function applyAnchoredNudges(
290
304
  // Defensive migration for persisted pre-singleton state: render only the
291
305
  // newest valid anchor and discard every stale predecessor.
292
306
  state.nudgeAnchors = selected ? [selected.anchor] : [];
293
- if (selected) appendTextToMessage(messages[selected.index], render(selected.anchor));
307
+ const anchorSetChanged = state.nudgeAnchors.length !== priorAnchors.length ||
308
+ state.nudgeAnchors.some((anchor, index) => anchor !== priorAnchors[index]);
309
+ if (!selected) return { rendered: false, stateChanged: anchorSetChanged };
310
+
311
+ const materializedReminder = selected.anchor.renderedReminder === undefined;
312
+ const reminder = selected.anchor.renderedReminder ?? render(selected.anchor);
313
+ if (materializedReminder) selected.anchor.renderedReminder = reminder;
314
+ const anchorMessage = messages[selected.index];
315
+ if (anchorMessage?.role === "assistant") {
316
+ messages.splice(selected.index + 1, 0, {
317
+ role: "user",
318
+ content: reminder,
319
+ timestamp: selected.anchor.createdAt,
320
+ });
321
+ } else {
322
+ appendTextToMessage(anchorMessage, reminder);
323
+ }
324
+ return {
325
+ rendered: true,
326
+ stateChanged: anchorSetChanged || materializedReminder,
327
+ };
294
328
  }
295
329
 
296
330
  export function clearDcpNudgeAnchors(state: DcpState): number {
@@ -1,7 +1,7 @@
1
1
  import type { DcpConfig } from "./config.js";
2
2
  import type { DcpState } from "./state.js";
3
3
  import { applyCompressionBlocks, repairOrphanedToolPairs, syncCompressionBlocks } from "./pruner-compression-blocks.js";
4
- import { stripStaleDcpMetadataFromAssistantMessage } from "./pruner-metadata.js";
4
+ import { stripStaleDcpMetadataFromMessage } from "./pruner-metadata.js";
5
5
  import { injectMessageIds } from "./pruner-message-ids.js";
6
6
  import {
7
7
  applyAutoToolOutputPruning,
@@ -63,7 +63,7 @@ export function applyPruning(
63
63
  typeof contentBlock === "object" && contentBlock !== null ? { ...contentBlock } : contentBlock,
64
64
  );
65
65
  }
66
- return stripStaleDcpMetadataFromAssistantMessage(clone);
66
+ return stripStaleDcpMetadataFromMessage(clone);
67
67
  });
68
68
 
69
69
  // 1. Count user turns → update state.currentTurn. Do this before inserting
@@ -79,21 +79,29 @@ export function applyPruning(
79
79
  // expansion logic could not catch (e.g. multi-block interactions, pre-broken state).
80
80
  repairOrphanedToolPairs(msgs);
81
81
 
82
- // 3. Apply deduplication
83
- applyDeduplication(msgs, state, config);
84
-
85
- // 4. Apply error purging
86
- applyErrorPurging(msgs, state, config);
87
-
88
- // 5. Apply autonomous policy pruning for old/large/stale tool outputs
89
- applyAutoToolOutputPruning(msgs, state, config);
82
+ // 3-5. Discover new automatic pruning decisions only at stable checkpoints.
83
+ // Rewriting an old result after every same-turn duplicate breaks provider
84
+ // continuation repeatedly. A new user turn or compression block already
85
+ // establishes a natural history boundary where one prefix rebuild is
86
+ // acceptable. Branches that move the turn count backwards also reopen the
87
+ // checkpoint instead of suppressing pruning indefinitely.
88
+ const newestBlockId = Math.max(0, state.nextBlockId - 1);
89
+ const automaticPruneCheckpoint =
90
+ state.currentTurn !== state.lastAutomaticPruneTurn ||
91
+ newestBlockId !== state.lastAutomaticPruneBlockId;
92
+ if (automaticPruneCheckpoint) {
93
+ applyDeduplication(msgs, state, config);
94
+ applyErrorPurging(msgs, state, config);
95
+ applyAutoToolOutputPruning(msgs, state, config);
96
+ state.lastAutomaticPruneTurn = state.currentTurn;
97
+ state.lastAutomaticPruneBlockId = newestBlockId;
98
+ }
90
99
 
91
100
  // 6. Apply explicit tool output pruning (prunedToolIds)
92
101
  applyToolOutputPruning(msgs, state);
93
102
 
94
- // 7. Refresh message ID snapshots used by the compress tool. Markers are
95
- // never injected into provider-visible content; the before_provider_request
96
- // hook delivers the id map as a hidden control payload.
103
+ // 7. Refresh message ID snapshots and append stable distributed metadata to
104
+ // user/tool-result carriers. Assistant items remain byte-stable.
97
105
  injectMessageIds(msgs, state, { config });
98
106
 
99
107
  // 8. state.messageIdSnapshot/messageMetaSnapshot are already updated by injectMessageIds