pi-ui-extend 0.1.2 → 0.1.4

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 (58) hide show
  1. package/README.md +14 -3
  2. package/bin/pix.mjs +24 -1
  3. package/dist/app/app.d.ts +0 -1
  4. package/dist/app/app.js +4 -7
  5. package/dist/app/cli.js +3 -1
  6. package/dist/app/clipboard.d.ts +2 -0
  7. package/dist/app/clipboard.js +54 -1
  8. package/dist/app/conversation-entry-renderer.d.ts +0 -1
  9. package/dist/app/conversation-entry-renderer.js +2 -6
  10. package/dist/app/conversation-tool-renderer.js +2 -3
  11. package/dist/app/conversation-viewport.d.ts +0 -1
  12. package/dist/app/conversation-viewport.js +0 -1
  13. package/dist/app/dcp-stats.js +143 -14
  14. package/dist/app/install.d.ts +10 -0
  15. package/dist/app/install.js +135 -0
  16. package/dist/app/mouse-controller.d.ts +6 -6
  17. package/dist/app/mouse-controller.js +19 -1
  18. package/dist/app/nerd-font-controller.d.ts +6 -0
  19. package/dist/app/nerd-font-controller.js +98 -17
  20. package/dist/app/render-controller.js +5 -4
  21. package/dist/app/startup-checks.js +10 -7
  22. package/dist/app/toast-controller.d.ts +5 -2
  23. package/dist/app/toast-controller.js +7 -4
  24. package/dist/app/toast-renderer.d.ts +3 -0
  25. package/dist/app/toast-renderer.js +72 -11
  26. package/dist/app/types.d.ts +8 -4
  27. package/dist/config.d.ts +0 -3
  28. package/dist/config.js +0 -79
  29. package/dist/default-pix-config.js +2 -2
  30. package/dist/markdown-format.js +18 -1
  31. package/dist/ui.d.ts +5 -1
  32. package/dist/ui.js +2 -2
  33. package/external/pi-tools-suite/README.md +4 -4
  34. package/external/pi-tools-suite/licenses/opencode-dynamic-context-pruning-AGPL-3.0.txt +619 -0
  35. package/external/pi-tools-suite/package.json +1 -1
  36. package/external/pi-tools-suite/src/config.ts +5 -1
  37. package/external/pi-tools-suite/src/{compress → dcp}/config.ts +10 -70
  38. package/external/pi-tools-suite/src/{compress → dcp}/index.ts +16 -66
  39. package/external/pi-tools-suite/src/dcp/ui.ts +45 -0
  40. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +3 -2
  41. package/external/pi-tools-suite/src/index.ts +1 -1
  42. package/external/pi-tools-suite/src/tool-descriptions.ts +1 -1
  43. package/package.json +1 -1
  44. package/external/pi-tools-suite/src/compress/dcp-tui-filter.ts +0 -498
  45. package/external/pi-tools-suite/src/compress/ui.ts +0 -308
  46. /package/external/pi-tools-suite/src/{compress → dcp}/commands.ts +0 -0
  47. /package/external/pi-tools-suite/src/{compress → dcp}/compress-tool.ts +0 -0
  48. /package/external/pi-tools-suite/src/{compress → dcp}/compression-blocks.ts +0 -0
  49. /package/external/pi-tools-suite/src/{compress → dcp}/prompts.ts +0 -0
  50. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-candidates.ts +0 -0
  51. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-compression-blocks.ts +0 -0
  52. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-message-ids.ts +0 -0
  53. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-metadata.ts +0 -0
  54. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-nudge.ts +0 -0
  55. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-tools.ts +0 -0
  56. /package/external/pi-tools-suite/src/{compress → dcp}/pruner-types.ts +0 -0
  57. /package/external/pi-tools-suite/src/{compress → dcp}/pruner.ts +0 -0
  58. /package/external/pi-tools-suite/src/{compress → dcp}/state.ts +0 -0
@@ -1,498 +0,0 @@
1
- // ---------------------------------------------------------------------------
2
- // DCP TUI Filter — strips DCP metadata tags from assistant chat messages
3
- // ---------------------------------------------------------------------------
4
- // The DCP context handler injects markdown reference marker lines such as
5
- // [dcp-id]: # (m001) plus <dcp-system-reminder> tags into the context copy
6
- // sent to the LLM. The LLM sometimes echoes this metadata back. This module
7
- // strips them from streaming/displayed assistant messages so the TUI stays clean.
8
- //
9
- // We filter ASSISTANT messages only. User and toolResult messages are not
10
- // mutated because message_end replacement persists to the session — stripping
11
- // real content that happens to contain DCP-like tags would be irreversible.
12
- // DCP tags in user/toolResult messages are injected only into context copies
13
- // (by injectMessageIds) and are never stored, so they never reach the TUI.
14
- //
15
- // Tags are re-injected on every context event (before each LLM call),
16
- // so stripping from stored messages is safe — the agent always sees them.
17
- // ---------------------------------------------------------------------------
18
-
19
- import {
20
- AssistantMessageComponent,
21
- BashExecutionComponent,
22
- CustomMessageComponent,
23
- type ExtensionAPI,
24
- ToolExecutionComponent,
25
- UserMessageComponent,
26
- } from "@mariozechner/pi-coding-agent"
27
-
28
- // ---------------------------------------------------------------------------
29
- // Regex patterns — broad opencode-style catch-all for ANY <dcp*> tag
30
- // ---------------------------------------------------------------------------
31
-
32
- /** Matches any paired DCP tag: <dcp-anything>...</dcp-anything> (non-greedy). */
33
- const DCP_PAIRED_TAG_RE = /<dcp[^>]*>[\s\S]*?<\/dcp[^>]*>/gi
34
-
35
- /** Matches any unpaired/orphan opening or closing DCP tag fragment. */
36
- const DCP_UNPAIRED_TAG_RE = /<\/?dcp[^>]*>/gi
37
-
38
- /** Matches markdown reference marker lines such as `[dcp-id]: # (m156)`. */
39
- const DCP_MARKDOWN_REF_LINE_RE = /^\s*\[dcp[^\]]*\]:\s*#(?:\s*\([^)]*\)|\s+"[^"]*"|\s+'[^']*')?(?:\s+priority=(?:low|medium|high))?\s*$/gim
40
-
41
- /** Matches malformed/truncated DCP markdown marker lines at line start. */
42
- const DCP_MARKDOWN_REF_FRAGMENT_LINE_RE = /^\s*\[dcp[^\n]*$/gim
43
-
44
- /**
45
- * Streaming only: matches a DCP markdown reference marker line that has started
46
- * but may not be complete yet, e.g. `[dcp-id]:`, `[dcp-id]: # (m023`, or
47
- * legacy `[dcp-id]: # (m023) priority=` at the end of the current assistant text.
48
- */
49
- const DCP_MARKDOWN_REF_TO_END_RE = /(^|\n)\s*\[dcp[^\n]*$/i
50
-
51
- /**
52
- * Streaming only: matches an open DCP tag followed by content to the end
53
- * of text, where the closing tag hasn't arrived yet.
54
- * e.g. `<dcp-id>m156` or `<dcp-foo>some content here`
55
- *
56
- * IMPORTANT: must be applied BEFORE DCP_UNPAIRED_TAG_RE in streaming mode,
57
- * because unpaired would strip just the `<dcp-id>` part, leaving `m156`
58
- * stranded in the output.
59
- */
60
- const DCP_OPEN_TAG_TO_END_RE = /<dcp[^>]*>[\s\S]*$/gi
61
-
62
- /**
63
- * Streaming only: matches an incomplete DCP tag prefix at the end of text.
64
- *
65
- * Providers can split a tag at any byte/token boundary, so the UI may briefly
66
- * see `<`, `<d`, `<dc`, `<dcp`, or `<dcp-id` before the full opening tag is
67
- * available. Hide those suffixes during streaming only.
68
- */
69
- const DCP_INCOMPLETE_OPEN_RE = /(?:<|<\/?d|<\/?dc|<\/?dcp(?:[^<>\n]*)?)$/i
70
-
71
- /** Case-insensitive quick check — is there anything DCP-shaped in this text? */
72
- const DCP_QUICK_CHECK_RE = /<\/?d(?:c(?:p)?)?|\[dcp|dcp/i
73
-
74
- type StreamTextKind = "text" | "thinking"
75
-
76
- const RENDER_PATCH_FLAG = Symbol.for("pi-tools-suite.dcpTuiFilter.renderPatch")
77
- const DISPLAY_RENDER_PATCH_FLAG = Symbol.for("pi-tools-suite.dcpTuiFilter.displayRenderPatch")
78
-
79
- const ANSI_RE = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g
80
- const RENDERED_DCP_METADATA_RE = /\[dcp[^\]]*\]:\s*#|<\/?dcp-system-reminder\b/i
81
- const RENDERED_DCP_CONTINUATION_RE = /^(?:#\s*)?\(?(?:m\d+|b\d+)\)?(?:\s+priority=(?:low|medium|high)\)?)?$|^priority=?$|^(?:low|medium|high)\)?$/i
82
-
83
- // ---------------------------------------------------------------------------
84
- // Tag stripping
85
- // ---------------------------------------------------------------------------
86
-
87
- /**
88
- * Strip all DCP metadata tags from a text string.
89
- *
90
- * Uses two broad regexes (matching opencode's approach):
91
- * 1. Paired tags: <dcp-anything>content</dcp-anything>
92
- * 2. Unpaired/orphan: any remaining <dcp...> or </dcp...> fragments
93
- *
94
- * During streaming, also hides:
95
- * - Open DCP tags with trailing content (no closing tag yet)
96
- * - Incomplete tag prefixes at end of text
97
- *
98
- * Returns the cleaned text, or the original if no tags were found.
99
- */
100
- export function stripDcpTags(
101
- text: string,
102
- options: { streaming?: boolean } = {},
103
- ): string {
104
- if (!text || !DCP_QUICK_CHECK_RE.test(text)) return text
105
-
106
- // 1. Strip markdown reference marker lines, then fully paired XML tags
107
- let cleaned = text
108
- .replace(DCP_MARKDOWN_REF_LINE_RE, "")
109
- .replace(DCP_MARKDOWN_REF_FRAGMENT_LINE_RE, "")
110
- .replace(DCP_PAIRED_TAG_RE, "")
111
-
112
- if (options.streaming) {
113
- // 2. In streaming: strip open tags to end of text BEFORE unpaired,
114
- // so that `<dcp-id>m156` is removed as a whole unit rather than
115
- // having unpaired strip just `<dcp-id>` and leaving `m156` stranded
116
- cleaned = cleaned
117
- .replace(DCP_MARKDOWN_REF_TO_END_RE, "$1")
118
- .replace(DCP_OPEN_TAG_TO_END_RE, "")
119
- }
120
-
121
- // 3. Strip any remaining orphan opening/closing tags
122
- cleaned = cleaned.replace(DCP_UNPAIRED_TAG_RE, "")
123
-
124
- if (options.streaming) {
125
- // 4. Strip incomplete tag prefixes at end of streaming text
126
- cleaned = cleaned.replace(DCP_INCOMPLETE_OPEN_RE, "")
127
- }
128
-
129
- return cleaned
130
- .replace(/\n{3,}/g, "\n\n") // collapse excessive blank lines
131
- .trimEnd()
132
- }
133
-
134
- export function stripDcpRenderedLines(lines: string[]): string[] {
135
- const cleaned: string[] = []
136
- let droppingWrappedMetadata = false
137
-
138
- for (const line of lines) {
139
- const plain = line.replace(ANSI_RE, "").trim()
140
- if (RENDERED_DCP_METADATA_RE.test(plain)) {
141
- droppingWrappedMetadata = true
142
- continue
143
- }
144
-
145
- if (droppingWrappedMetadata && RENDERED_DCP_CONTINUATION_RE.test(plain)) {
146
- continue
147
- }
148
-
149
- droppingWrappedMetadata = false
150
- cleaned.push(line)
151
- }
152
-
153
- return cleaned
154
- }
155
-
156
- // ---------------------------------------------------------------------------
157
- // Content block helpers
158
- // ---------------------------------------------------------------------------
159
-
160
- /**
161
- * Strip DCP tags from a single content block (text or thinking).
162
- * Returns [block, modified] tuple. Block is undefined if it should be dropped.
163
- */
164
- function stripBlock(block: any, streaming = false): [any, boolean] {
165
- if (!block || typeof block !== "object") return [block, false]
166
-
167
- let modified = false
168
- let next = block
169
-
170
- // Text blocks
171
- if (typeof block.text === "string") {
172
- const cleaned = stripDcpTags(block.text, { streaming })
173
- if (cleaned !== block.text) {
174
- modified = true
175
- if (cleaned.trim() === "" && block.type === "text") return [undefined, true]
176
- next = { ...block, text: cleaned }
177
- }
178
- }
179
-
180
- // Thinking blocks
181
- if (typeof block.thinking === "string") {
182
- const cleaned = stripDcpTags(block.thinking, { streaming })
183
- if (cleaned !== block.thinking) {
184
- modified = true
185
- if (cleaned.trim() === "" && block.type === "thinking") return [undefined, true]
186
- next = { ...next, thinking: cleaned }
187
- }
188
- }
189
-
190
- // Delete stale signatures when content was modified. Providers verify
191
- // textSignature/thinkingSignature against the exact content — a mismatch
192
- // causes rejection. Mirrors the existing DCP metadata stripper pattern
193
- // in pruner-metadata.ts:stripStaleDcpMetadataFromAssistantBlock.
194
- if (modified) {
195
- if ("textSignature" in next) {
196
- const { textSignature: _, ...rest } = next
197
- next = rest
198
- }
199
- if ("thinkingSignature" in next) {
200
- const { thinkingSignature: _, ...rest } = next
201
- next = rest
202
- }
203
- }
204
-
205
- return [next, modified]
206
- }
207
-
208
- /**
209
- * Strip DCP tags from an assistant message's content.
210
- * Returns the cleaned message (shallow copy if modified, original if clean).
211
- */
212
- function stripDcpFromAssistantMessage(message: any, streaming = false): any {
213
- if (!message || typeof message !== "object") return message
214
-
215
- const content = message.content
216
- if (!content) return message
217
-
218
- // String content
219
- if (typeof content === "string") {
220
- const cleaned = stripDcpTags(content, { streaming })
221
- if (cleaned === content) return message
222
- return { ...message, content: cleaned }
223
- }
224
-
225
- // Array content (Anthropic-style content blocks)
226
- if (!Array.isArray(content)) return message
227
-
228
- let modified = false
229
- const newContent = content
230
- .map((block: any) => {
231
- const [cleaned, wasModified] = stripBlock(block, streaming)
232
- if (wasModified) modified = true
233
- return cleaned
234
- })
235
- .filter((block: any) => block !== undefined)
236
-
237
- if (!modified) return message
238
- return { ...message, content: newContent }
239
- }
240
-
241
- /**
242
- * Mutate the shallow event message object that Pi sends to TUI/listeners.
243
- *
244
- * `message_update` has no return-value replacement API, but Pi emits extension
245
- * events before TUI listeners and passes the same shallow event message object
246
- * onward. Replacing `message.content` here cleans the rendered stream without
247
- * mutating the provider's raw partial/final assistant message object.
248
- */
249
- function stripDcpFromAssistantMessageInPlace(message: any, streaming = false): boolean {
250
- const cleaned = stripDcpFromAssistantMessage(message, streaming)
251
- if (cleaned === message) return false
252
-
253
- Object.assign(message as Record<string, unknown>, cleaned)
254
- return true
255
- }
256
-
257
- /**
258
- * Patch Pi's assistant renderer as a final display-only safety net.
259
- *
260
- * Extension `message_update` handlers can sanitize the event message, but some
261
- * live/render paths have historically shown raw text before or instead of the
262
- * sanitized event object. Patching AssistantMessageComponent means any
263
- * assistant text/thinking content is stripped immediately before TUI rendering,
264
- * without mutating the stored session message or the provider-visible context.
265
- */
266
- function registerAssistantRenderPatch(): void {
267
- const prototype = AssistantMessageComponent?.prototype as Record<string | symbol, any> | undefined
268
- if (!prototype || prototype[RENDER_PATCH_FLAG]) return
269
-
270
- const originalUpdateContent = prototype.updateContent
271
- if (typeof originalUpdateContent !== "function") return
272
-
273
- Object.defineProperty(prototype, RENDER_PATCH_FLAG, {
274
- value: true,
275
- enumerable: false,
276
- configurable: false,
277
- })
278
-
279
- prototype.updateContent = function updateContentWithDcpFilter(message: any): void {
280
- const cleaned = stripDcpFromAssistantMessage(message, true)
281
- return originalUpdateContent.call(this, cleaned)
282
- }
283
- }
284
-
285
- function patchRenderedLines(componentClass: unknown): void {
286
- const prototype = (componentClass as any)?.prototype as Record<string | symbol, any> | undefined
287
- if (!prototype || prototype[DISPLAY_RENDER_PATCH_FLAG]) return
288
-
289
- const originalRender = prototype.render
290
- if (typeof originalRender !== "function") return
291
-
292
- Object.defineProperty(prototype, DISPLAY_RENDER_PATCH_FLAG, {
293
- value: true,
294
- enumerable: false,
295
- configurable: false,
296
- })
297
-
298
- prototype.render = function renderWithDcpLineFilter(width: number): string[] {
299
- const lines = originalRender.call(this, width)
300
- return Array.isArray(lines) ? stripDcpRenderedLines(lines) : lines
301
- }
302
- }
303
-
304
- function registerDisplayRenderPatches(): void {
305
- patchRenderedLines(UserMessageComponent)
306
- patchRenderedLines(CustomMessageComponent)
307
- patchRenderedLines(ToolExecutionComponent)
308
- patchRenderedLines(BashExecutionComponent)
309
- }
310
-
311
- function getAssistantBlockString(message: any, contentIndex: number, kind: StreamTextKind): string | undefined {
312
- const content = message?.content
313
- if (!Array.isArray(content)) return undefined
314
-
315
- const block = content[contentIndex]
316
- if (!block || typeof block !== "object") return undefined
317
-
318
- const value = block[kind]
319
- return typeof value === "string" ? value : undefined
320
- }
321
-
322
- function streamStateKey(kind: StreamTextKind, contentIndex: number): string {
323
- return `${kind}:${contentIndex}`
324
- }
325
-
326
- /**
327
- * Keep low-level streaming events in sync with the sanitized display message.
328
- *
329
- * The Pi TUI reads `event.message`, but RPC/proxy clients may render from
330
- * `assistantMessageEvent.delta` or `.partial`. If those fields keep the raw
331
- * provider chunks, DCP tags can still flash even though the TUI message copy is
332
- * clean. Mutate nested assistant event fields in place: AgentSession passes the
333
- * same nested `assistantMessageEvent` object to extension handlers and TUI
334
- * listeners, but replacing the top-level extension event object does not flow
335
- * back to the original AgentSession event.
336
- */
337
- function sanitizeAssistantMessageEvent(event: any, streamTextByKey: Map<string, string>): void {
338
- const assistantEvent = event?.assistantMessageEvent
339
- if (!assistantEvent || typeof assistantEvent !== "object") return
340
-
341
- const rawPartial = assistantEvent.partial
342
- const cleanedPartial = stripDcpFromAssistantMessage(rawPartial, true)
343
-
344
- if (cleanedPartial !== rawPartial) {
345
- assistantEvent.partial = cleanedPartial
346
- }
347
-
348
- const type = assistantEvent.type
349
- if (
350
- (type === "text_start" || type === "text_delta" || type === "text_end") &&
351
- typeof assistantEvent.contentIndex === "number"
352
- ) {
353
- sanitizeTextStreamEvent(
354
- assistantEvent,
355
- cleanedPartial,
356
- "text",
357
- streamTextByKey,
358
- )
359
- } else if (
360
- (type === "thinking_start" || type === "thinking_delta" || type === "thinking_end") &&
361
- typeof assistantEvent.contentIndex === "number"
362
- ) {
363
- sanitizeTextStreamEvent(
364
- assistantEvent,
365
- cleanedPartial,
366
- "thinking",
367
- streamTextByKey,
368
- )
369
- } else if (type === "start") {
370
- streamTextByKey.clear()
371
- }
372
- }
373
-
374
- function sanitizeTextStreamEvent(
375
- assistantEvent: any,
376
- cleanedPartial: any,
377
- kind: StreamTextKind,
378
- streamTextByKey: Map<string, string>,
379
- ): void {
380
- const contentIndex = assistantEvent.contentIndex as number
381
- const key = streamStateKey(kind, contentIndex)
382
- const cleanedFullText = getAssistantBlockString(cleanedPartial, contentIndex, kind) ?? ""
383
-
384
- if (assistantEvent.type === `${kind}_start`) {
385
- streamTextByKey.set(key, cleanedFullText)
386
- return
387
- }
388
-
389
- if (assistantEvent.type === `${kind}_delta`) {
390
- const previousText = streamTextByKey.get(key) ?? ""
391
- const cleanedDelta = cleanedFullText.startsWith(previousText)
392
- ? cleanedFullText.slice(previousText.length)
393
- : cleanedFullText
394
-
395
- streamTextByKey.set(key, cleanedFullText)
396
-
397
- if (cleanedDelta !== assistantEvent.delta) {
398
- assistantEvent.delta = cleanedDelta
399
- }
400
- return
401
- }
402
-
403
- if (assistantEvent.type === `${kind}_end`) {
404
- streamTextByKey.set(key, cleanedFullText)
405
- if (typeof assistantEvent.content === "string") {
406
- const cleanedContent = stripDcpTags(assistantEvent.content)
407
- if (cleanedContent !== assistantEvent.content) {
408
- assistantEvent.content = cleanedContent
409
- }
410
- }
411
- }
412
- }
413
-
414
- /**
415
- * Best-effort in-memory cleanup for assistant messages that were persisted by
416
- * older filter versions before `message_end` stripping existed/worked. This is
417
- * intentionally assistant-only for the same safety reasons as the live filter.
418
- */
419
- function scrubAssistantMessagesInSessionHistory(sessionManager: any): void {
420
- const entries = sessionManager?.getEntries?.()
421
- if (!Array.isArray(entries)) return
422
-
423
- for (const entry of entries) {
424
- if (!entry || entry.type !== "message") continue
425
-
426
- const msg = entry.message
427
- if (!msg || msg.role !== "assistant") continue
428
-
429
- const cleaned = stripDcpFromAssistantMessage(msg)
430
- if (cleaned !== msg) {
431
- Object.assign(msg as Record<string, unknown>, cleaned)
432
- }
433
- }
434
- }
435
-
436
- // ---------------------------------------------------------------------------
437
- // Extension hook
438
- // ---------------------------------------------------------------------------
439
-
440
- /**
441
- * Register the DCP TUI filter on the given extension API.
442
- *
443
- * Hooks into both streaming `message_update` and finalized `message_end`:
444
- * - `message_update` prevents tags from flashing in the TUI while tokens stream
445
- * - `message_end` keeps stored assistant messages clean
446
- *
447
- * We only filter assistant messages because:
448
- * - DCP tags in user/toolResult are only added to context copies, never stored
449
- * - Mutating user/toolResult via message_end persists permanently and could
450
- * corrupt legitimate content that happens to contain DCP-like text
451
- * - The LLM is the only source of echoed DCP tags in stored messages
452
- */
453
- export function registerTuiFilter(pi: ExtensionAPI): void {
454
- registerAssistantRenderPatch()
455
- registerDisplayRenderPatches()
456
-
457
- const streamTextByKey = new Map<string, string>()
458
-
459
- pi.on("session_start", async (_event, ctx) => {
460
- scrubAssistantMessagesInSessionHistory(ctx.sessionManager)
461
- streamTextByKey.clear()
462
- })
463
-
464
- pi.on("message_start", async (event, _ctx) => {
465
- const role: string = (event.message as any)?.role ?? ""
466
- if (role === "assistant") {
467
- streamTextByKey.clear()
468
- }
469
- })
470
-
471
- pi.on("message_update", async (event, _ctx) => {
472
- const msg = event.message
473
- if (!msg || typeof msg !== "object") return
474
-
475
- const role: string = (msg as any).role ?? ""
476
- if (role !== "assistant") return
477
-
478
- stripDcpFromAssistantMessageInPlace(msg, true)
479
- sanitizeAssistantMessageEvent(event, streamTextByKey)
480
- })
481
-
482
- pi.on("message_end", async (event, _ctx) => {
483
- streamTextByKey.clear()
484
-
485
- const msg = event.message
486
- if (!msg || typeof msg !== "object") return
487
-
488
- // Assistant-only: DCP tags only appear in stored assistant messages
489
- // when the LLM echoes them back from the context
490
- const role: string = (msg as any).role ?? ""
491
- if (role !== "assistant") return
492
-
493
- const cleaned = stripDcpFromAssistantMessage(msg)
494
- if (cleaned !== msg) {
495
- return { message: cleaned }
496
- }
497
- })
498
- }