pi-ui-extend 0.1.35 → 0.1.37

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 (80) hide show
  1. package/dist/app/app.d.ts +8 -0
  2. package/dist/app/app.js +48 -5
  3. package/dist/app/commands/command-controller.js +1 -0
  4. package/dist/app/commands/command-host.d.ts +1 -0
  5. package/dist/app/commands/command-model-actions.d.ts +1 -0
  6. package/dist/app/commands/command-model-actions.js +32 -0
  7. package/dist/app/commands/command-navigation-actions.js +3 -0
  8. package/dist/app/commands/command-registry.d.ts +1 -0
  9. package/dist/app/commands/command-registry.js +8 -0
  10. package/dist/app/commands/command-session-actions.d.ts +2 -0
  11. package/dist/app/commands/command-session-actions.js +81 -1
  12. package/dist/app/extensions/extension-actions-controller.d.ts +5 -1
  13. package/dist/app/extensions/extension-actions-controller.js +35 -2
  14. package/dist/app/input/input-controller.d.ts +2 -0
  15. package/dist/app/input/input-controller.js +50 -2
  16. package/dist/app/input/terminal-edit-shortcuts.d.ts +2 -0
  17. package/dist/app/input/terminal-edit-shortcuts.js +49 -0
  18. package/dist/app/input/voice-controller.js +1 -1
  19. package/dist/app/popup/popup-action-controller.d.ts +2 -3
  20. package/dist/app/popup/popup-action-controller.js +2 -5
  21. package/dist/app/rendering/message-content.js +4 -3
  22. package/dist/app/rendering/render-controller.js +21 -38
  23. package/dist/app/rendering/status-line-renderer.d.ts +1 -0
  24. package/dist/app/rendering/status-line-renderer.js +14 -2
  25. package/dist/app/runtime.js +12 -2
  26. package/dist/app/screen/mouse-controller.js +2 -0
  27. package/dist/app/session/session-event-controller.d.ts +7 -0
  28. package/dist/app/session/session-event-controller.js +10 -13
  29. package/dist/app/session/session-lifecycle-controller.d.ts +1 -0
  30. package/dist/app/session/session-lifecycle-controller.js +7 -0
  31. package/dist/app/session/tabs-controller.d.ts +1 -0
  32. package/dist/app/session/tabs-controller.js +1 -0
  33. package/dist/app/terminal/terminal-controller.js +1 -0
  34. package/dist/app/terminal/terminal-output-buffer.d.ts +8 -6
  35. package/dist/app/terminal/terminal-output-buffer.js +24 -16
  36. package/dist/app/workspace/workspace-actions-controller.d.ts +1 -0
  37. package/dist/app/workspace/workspace-actions-controller.js +1 -0
  38. package/dist/bundled-extensions/terminal-bell/index.js +118 -33
  39. package/dist/markdown-format.d.ts +1 -0
  40. package/dist/markdown-format.js +30 -16
  41. package/dist/schemas/pi-tools-suite-schema.d.ts +5 -0
  42. package/dist/schemas/pi-tools-suite-schema.js +5 -0
  43. package/dist/tool-renderers/apply-patch.js +6 -1
  44. package/dist/tool-renderers/patch-normalize.d.ts +24 -0
  45. package/dist/tool-renderers/patch-normalize.js +163 -0
  46. package/external/pi-tools-suite/README.md +3 -2
  47. package/external/pi-tools-suite/package.json +5 -5
  48. package/external/pi-tools-suite/src/antigravity-auth/index.ts +15 -2
  49. package/external/pi-tools-suite/src/antigravity-auth/status.ts +36 -19
  50. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +5 -2
  51. package/external/pi-tools-suite/src/async-subagents/commands.ts +12 -2
  52. package/external/pi-tools-suite/src/async-subagents/core/config.ts +8 -3
  53. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +63 -28
  54. package/external/pi-tools-suite/src/async-subagents/core/tool-guard.ts +9 -4
  55. package/external/pi-tools-suite/src/comment-checker/config.ts +98 -0
  56. package/external/pi-tools-suite/src/comment-checker/detect.ts +215 -0
  57. package/external/pi-tools-suite/src/comment-checker/index.ts +294 -0
  58. package/external/pi-tools-suite/src/dcp/commands.ts +29 -15
  59. package/external/pi-tools-suite/src/dcp/compress-tool.ts +111 -60
  60. package/external/pi-tools-suite/src/dcp/config.ts +10 -6
  61. package/external/pi-tools-suite/src/dcp/debug-log.ts +235 -0
  62. package/external/pi-tools-suite/src/dcp/index.ts +204 -27
  63. package/external/pi-tools-suite/src/dcp/prompts.ts +25 -28
  64. package/external/pi-tools-suite/src/dcp/pruner-candidates.ts +6 -10
  65. package/external/pi-tools-suite/src/dcp/pruner-compression-blocks.ts +19 -1
  66. package/external/pi-tools-suite/src/dcp/pruner-message-ids.ts +36 -58
  67. package/external/pi-tools-suite/src/dcp/pruner-metadata.ts +18 -0
  68. package/external/pi-tools-suite/src/dcp/pruner-nudge.ts +3 -3
  69. package/external/pi-tools-suite/src/dcp/pruner.ts +4 -2
  70. package/external/pi-tools-suite/src/dcp/state-persistence.ts +31 -2
  71. package/external/pi-tools-suite/src/dcp/state.ts +62 -4
  72. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +18 -0
  73. package/external/pi-tools-suite/src/index.ts +1 -0
  74. package/external/pi-tools-suite/src/model-tools/index.ts +11 -3
  75. package/external/pi-tools-suite/src/telegram-mirror/index.ts +1 -1
  76. package/external/pi-tools-suite/src/todo/index.ts +24 -0
  77. package/external/pi-tools-suite/src/tool-descriptions.ts +3 -3
  78. package/external/pi-tools-suite/src/usage/index.ts +18 -4
  79. package/package.json +4 -4
  80. package/schemas/pi-tools-suite.json +24 -0
@@ -0,0 +1,235 @@
1
+ import * as fs from "node:fs/promises"
2
+ import * as os from "node:os"
3
+ import * as path from "node:path"
4
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent"
5
+ import type { DcpConfig } from "./config.js"
6
+ import type { DcpState } from "./state.js"
7
+
8
+ const TRUE_ENV_RE = /^(1|true|yes|on)$/i
9
+ const FALSE_ENV_RE = /^(0|false|no|off)$/i
10
+ const MAX_IDS = 16
11
+ const DEFAULT_DEBUG_LOG_MAX_BYTES = 5 * 1024 * 1024 // 5 MB
12
+ const DEFAULT_DEBUG_LOG_MAX_BACKUPS = 3
13
+ const MIN_DEBUG_LOG_MAX_BACKUPS = 1
14
+
15
+ function truthyEnv(value: string | undefined): boolean | undefined {
16
+ if (value === undefined) return undefined
17
+ const trimmed = value.trim()
18
+ if (TRUE_ENV_RE.test(trimmed)) return true
19
+ if (FALSE_ENV_RE.test(trimmed)) return false
20
+ return undefined
21
+ }
22
+
23
+ export function dcpDebugEnabled(config: DcpConfig): boolean {
24
+ return truthyEnv(process.env.PI_DCP_DEBUG)
25
+ ?? truthyEnv(process.env.PI_TOOLS_SUITE_DCP_DEBUG)
26
+ ?? config.debug
27
+ }
28
+
29
+ function defaultLogPath(): string {
30
+ const agentDir = process.env.PI_AGENT_DIR || path.join(os.homedir(), ".pi", "agent")
31
+ return path.join(agentDir, "dcp-debug.jsonl")
32
+ }
33
+
34
+ function dcpDebugLogPath(): string {
35
+ const explicit = process.env.PI_DCP_DEBUG_LOG?.trim()
36
+ return explicit || defaultLogPath()
37
+ }
38
+
39
+ function positiveIntEnv(value: string | undefined): number | undefined {
40
+ if (value === undefined) return undefined
41
+ const parsed = Number.parseInt(value.trim(), 10)
42
+ return Number.isFinite(parsed) && parsed > 0 ? parsed : undefined
43
+ }
44
+
45
+ /** Maximum size the active debug log is allowed to reach before it is rotated. */
46
+ export function dcpDebugLogMaxBytes(config: DcpConfig): number {
47
+ return positiveIntEnv(process.env.PI_DCP_DEBUG_MAX_BYTES)
48
+ ?? config.debugLog?.maxBytes
49
+ ?? DEFAULT_DEBUG_LOG_MAX_BYTES
50
+ }
51
+
52
+ /** Number of rotated backups to keep (e.g. `.1`, `.2`, `.3`). */
53
+ export function dcpDebugLogMaxBackups(config: DcpConfig): number {
54
+ const value = positiveIntEnv(process.env.PI_DCP_DEBUG_MAX_BACKUPS)
55
+ ?? config.debugLog?.maxBackups
56
+ ?? DEFAULT_DEBUG_LOG_MAX_BACKUPS
57
+ return Math.max(MIN_DEBUG_LOG_MAX_BACKUPS, Math.floor(value))
58
+ }
59
+
60
+ function compactIds(ids: string[]): { count: number; head: string[]; tail: string[] } {
61
+ return {
62
+ count: ids.length,
63
+ head: ids.slice(0, MAX_IDS),
64
+ tail: ids.length > MAX_IDS ? ids.slice(-MAX_IDS) : [],
65
+ }
66
+ }
67
+
68
+ function safeError(error: unknown): string {
69
+ return error instanceof Error ? error.message : String(error)
70
+ }
71
+
72
+ function sessionInfo(ctx: ExtensionContext | undefined): Record<string, unknown> {
73
+ if (!ctx) return {}
74
+ const info: Record<string, unknown> = {}
75
+ try {
76
+ const header = (ctx as any).sessionManager?.getHeader?.()
77
+ if (header?.id) info.sessionId = header.id
78
+ if (header?.cwd) info.cwd = header.cwd
79
+ } catch (error) {
80
+ info.sessionInfoError = safeError(error)
81
+ }
82
+ try {
83
+ const name = (ctx as any).sessionManager?.getSessionName?.()
84
+ if (name) info.sessionName = name
85
+ } catch {
86
+ // Optional diagnostic only.
87
+ }
88
+ return info
89
+ }
90
+
91
+ export function summarizeDcpState(state: DcpState): Record<string, unknown> {
92
+ const rawIds = [...new Set([
93
+ ...state.messageIdSnapshot.keys(),
94
+ ...state.messageMetaSnapshot.keys(),
95
+ ])]
96
+ const activeBlocks = state.compressionBlocks
97
+ .filter((block) => block.active)
98
+ .sort((a, b) => a.id - b.id)
99
+ .map((block) => ({
100
+ id: `b${block.id}`,
101
+ topic: block.topic,
102
+ mode: block.mode,
103
+ startMessageId: block.startMessageId,
104
+ endMessageId: block.endMessageId,
105
+ anchorMessageId: block.anchorMessageId,
106
+ coveredBlockIds: block.coveredBlockIds ?? [],
107
+ summaryTokens: block.summaryTokenEstimate,
108
+ }))
109
+ const inactiveBlocks = state.compressionBlocks
110
+ .filter((block) => !block.active)
111
+ .sort((a, b) => a.id - b.id)
112
+ .slice(-MAX_IDS)
113
+ .map((block) => ({
114
+ id: `b${block.id}`,
115
+ topic: block.topic,
116
+ reason: block.deactivatedReason,
117
+ coveredBlockIds: block.coveredBlockIds ?? [],
118
+ }))
119
+
120
+ return {
121
+ rawIds: compactIds(rawIds),
122
+ activeBlocks,
123
+ blockCounts: {
124
+ active: activeBlocks.length,
125
+ inactive: state.compressionBlocks.length - activeBlocks.length,
126
+ total: state.compressionBlocks.length,
127
+ nextBlockId: state.nextBlockId,
128
+ },
129
+ inactiveBlocksTail: inactiveBlocks,
130
+ prunedTools: state.prunedToolIds.size,
131
+ nudgeAnchors: state.nudgeAnchors.map((anchor) => ({
132
+ id: anchor.id,
133
+ type: anchor.type,
134
+ anchorStableId: anchor.anchorStableId,
135
+ anchorTimestamp: anchor.anchorTimestamp,
136
+ })),
137
+ }
138
+ }
139
+
140
+ // Serializes all debug-log writes so rotation and appends never race.
141
+ let logWriteChain: Promise<void> = Promise.resolve()
142
+ const ensuredLogDirs = new Set<string>()
143
+
144
+ async function ensureLogDir(logPath: string): Promise<void> {
145
+ const dir = path.dirname(logPath)
146
+ if (ensuredLogDirs.has(dir)) return
147
+ await fs.mkdir(dir, { recursive: true })
148
+ ensuredLogDirs.add(dir)
149
+ }
150
+
151
+ /**
152
+ * When the active log has reached `maxBytes`, rotate numbered backups:
153
+ * drop `.N`, shift `.(N-1)`→`.N`, …, `.1`→`.2`, and rename the active file to
154
+ * `.1` so the next append starts a fresh file. Best-effort: fs errors are
155
+ * swallowed because debug logging must never affect the session.
156
+ */
157
+ async function rotateDebugLogIfNeeded(
158
+ logPath: string,
159
+ maxBytes: number,
160
+ maxBackups: number,
161
+ ): Promise<void> {
162
+ let stat: Awaited<ReturnType<typeof fs.stat>>
163
+ try {
164
+ stat = await fs.stat(logPath)
165
+ } catch {
166
+ return // active file does not exist yet; nothing to rotate
167
+ }
168
+ if (stat.size < maxBytes) return
169
+
170
+ const dir = path.dirname(logPath)
171
+ const base = path.basename(logPath)
172
+
173
+ // Drop the oldest backup (`base.N`) so the chain can shift up by one.
174
+ await fs.rm(path.join(dir, `${base}.${maxBackups}`), { force: true })
175
+ // Shift existing backups `base.i` → `base.(i+1)` from highest to lowest.
176
+ for (let i = maxBackups - 1; i >= 1; i--) {
177
+ await fs
178
+ .rename(path.join(dir, `${base}.${i}`), path.join(dir, `${base}.${i + 1}`))
179
+ .catch(() => {
180
+ // A missing intermediate backup is expected; ignore.
181
+ })
182
+ }
183
+ // Rotate the active file into `base.1`.
184
+ await fs.rename(logPath, path.join(dir, `${base}.1`)).catch(() => {
185
+ // If we cannot move the active file, truncate it so growth is bounded.
186
+ void fs.truncate(logPath, 0).catch(() => {})
187
+ })
188
+ }
189
+
190
+ async function appendDebugLogRecord(
191
+ logPath: string,
192
+ record: Record<string, unknown>,
193
+ maxBytes: number,
194
+ maxBackups: number,
195
+ ): Promise<void> {
196
+ await ensureLogDir(logPath)
197
+ await rotateDebugLogIfNeeded(logPath, maxBytes, maxBackups)
198
+ await fs.appendFile(logPath, `${JSON.stringify(record)}\n`, "utf8")
199
+ }
200
+
201
+ export function writeDcpDebugLog(
202
+ config: DcpConfig,
203
+ event: string,
204
+ details: Record<string, unknown> = {},
205
+ ctx?: ExtensionContext,
206
+ ): void {
207
+ if (!dcpDebugEnabled(config)) return
208
+
209
+ const record = {
210
+ ts: new Date().toISOString(),
211
+ event,
212
+ ...sessionInfo(ctx),
213
+ ...details,
214
+ }
215
+
216
+ const logPath = dcpDebugLogPath()
217
+ const maxBytes = dcpDebugLogMaxBytes(config)
218
+ const maxBackups = dcpDebugLogMaxBackups(config)
219
+
220
+ // Serialize writes so concurrent records append in order and rotation is safe.
221
+ logWriteChain = logWriteChain
222
+ .then(() => appendDebugLogRecord(logPath, record, maxBytes, maxBackups))
223
+ .catch(() => {
224
+ // Debug logging must never affect the session or tool outcome.
225
+ })
226
+ }
227
+
228
+ /**
229
+ * Resolves when all queued debug-log writes (and rotations) have settled. Useful
230
+ * for flushing before shutdown and for deterministic test assertions.
231
+ */
232
+ export function dcpDebugLogDrain(): Promise<void> {
233
+ return logWriteChain
234
+ }
235
+
@@ -9,10 +9,12 @@ import {
9
9
  resetState,
10
10
  createInputFingerprint,
11
11
  restoreState,
12
+ inheritCompressionBlocks,
12
13
  } from "./state.js"
13
14
  import {
14
15
  cleanupStaleDcpStateFiles,
15
16
  loadDcpState,
17
+ loadDcpStateFromSessionFile,
16
18
  resetDcpPersistenceDedup,
17
19
  saveDcpState,
18
20
  } from "./state-persistence.js"
@@ -32,12 +34,21 @@ import {
32
34
  detectMessageCompressionCandidates,
33
35
  appendConcreteNudgeGuidance,
34
36
  applyAnchoredNudges,
37
+ clearDcpNudgeAnchors,
35
38
  nudgeTypeLabel,
36
39
  upsertNudgeAnchor,
37
40
  getActiveSummaryTokenEstimate,
38
41
  resolveContextThresholds,
39
42
  estimateTokens,
40
43
  } from "./pruner.js"
44
+ import {
45
+ stripStaleDcpMetadataFromAssistantMessage,
46
+ stripStaleDcpMetadataFromMessage,
47
+ } from "./pruner-metadata.js"
48
+ import {
49
+ buildMessageIdControlText,
50
+ } from "./pruner-message-ids.js"
51
+ import { summarizeDcpState, writeDcpDebugLog } from "./debug-log.js"
41
52
  import type { DcpNudgeType } from "./pruner-types.js"
42
53
  import { registerCompressTool } from "./compress-tool.js"
43
54
  import { DCP_STATS_MESSAGE_TYPE, registerCommands } from "./commands.js"
@@ -88,13 +99,87 @@ function isUserVisibleOnlyMessage(message: any): boolean {
88
99
  return message.details?.userVisibleOnly === true
89
100
  }
90
101
 
91
- const DCP_CONTROL_PLANE_CUSTOM_TYPES = new Set(["dcp-state", "dcp-nudge"])
102
+ // Control-plane custom message types filtered out of the transcript.
103
+ // `dcp-message-ids` is retained only for backward-compat with logs written by
104
+ // the removed inline control-message path.
105
+ const DCP_CONTROL_PLANE_CUSTOM_TYPES = new Set(["dcp-state", "dcp-nudge", "dcp-message-ids"])
92
106
  const SUMMARY_BUFFER_MAX_CONTEXT_BONUS = 0.05
93
107
 
94
108
  function isDcpControlPlaneMessage(message: any): boolean {
95
109
  return message?.role === "custom" && DCP_CONTROL_PLANE_CUSTOM_TYPES.has(message.customType)
96
110
  }
97
111
 
112
+ const DCP_PROVIDER_CONTROL_HEADER = "DCP message ID control data (do not quote or output):"
113
+
114
+ function appendTextToContent(content: unknown, text: string): unknown {
115
+ if (typeof content === "string") return `${content}\n\n${text}`
116
+ if (Array.isArray(content)) {
117
+ const textType = content.some((part: any) => part?.type === "input_text") ? "input_text" : "text"
118
+ return [...content, { type: textType, text }]
119
+ }
120
+ return text
121
+ }
122
+
123
+ function appendDcpControlToMessages(messages: unknown, text: string): unknown {
124
+ if (!Array.isArray(messages)) return messages
125
+ const existingIndex = messages.findIndex((message: any) =>
126
+ message?.role === "system" || message?.role === "developer"
127
+ )
128
+ const block = `${DCP_PROVIDER_CONTROL_HEADER}\n${text}`
129
+ if (existingIndex >= 0) {
130
+ return messages.map((message: any, index) => index === existingIndex
131
+ ? { ...message, content: appendTextToContent(message.content, block) }
132
+ : message)
133
+ }
134
+ return [{ role: "system", content: block }, ...messages]
135
+ }
136
+
137
+ function appendDcpControlToAnthropicSystem(system: unknown, text: string): unknown {
138
+ const block = `${DCP_PROVIDER_CONTROL_HEADER}\n${text}`
139
+ if (typeof system === "string") return `${system}\n\n${block}`
140
+ if (Array.isArray(system)) return [...system, { type: "text", text: block }]
141
+ if (system === undefined || system === null) return [{ type: "text", text: block }]
142
+ return system
143
+ }
144
+
145
+ function appendDcpControlToGoogleSystemInstruction(systemInstruction: unknown, text: string): unknown {
146
+ const block = `${DCP_PROVIDER_CONTROL_HEADER}\n${text}`
147
+ if (typeof systemInstruction === "string") return `${systemInstruction}\n\n${block}`
148
+ if (systemInstruction === undefined || systemInstruction === null) return block
149
+ return systemInstruction
150
+ }
151
+
152
+ function appendDcpControlToProviderPayload(payload: unknown, text: string): unknown {
153
+ if (Array.isArray(payload)) return appendDcpControlToMessages(payload, text)
154
+ if (!payload || typeof payload !== "object") return payload
155
+ const record = payload as Record<string, unknown>
156
+
157
+ if ("system" in record) {
158
+ return { ...record, system: appendDcpControlToAnthropicSystem(record.system, text) }
159
+ }
160
+
161
+ if (Array.isArray(record.input)) {
162
+ return { ...record, input: appendDcpControlToMessages(record.input, text) }
163
+ }
164
+
165
+ if (Array.isArray(record.messages)) {
166
+ return { ...record, messages: appendDcpControlToMessages(record.messages, text) }
167
+ }
168
+
169
+ if (record.config && typeof record.config === "object") {
170
+ const config = record.config as Record<string, unknown>
171
+ return {
172
+ ...record,
173
+ config: {
174
+ ...config,
175
+ systemInstruction: appendDcpControlToGoogleSystemInstruction(config.systemInstruction, text),
176
+ },
177
+ }
178
+ }
179
+
180
+ return payload
181
+ }
182
+
98
183
  // ---------------------------------------------------------------------------
99
184
  // Module export
100
185
  // ---------------------------------------------------------------------------
@@ -150,7 +235,7 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
150
235
  registerCommands(pi, state, config)
151
236
 
152
237
  // ── 5. session_start: restore state from session entries ──────────────────
153
- pi.on("session_start", async (_event, ctx) => {
238
+ pi.on("session_start", async (event, ctx) => {
154
239
  // Reset to a clean slate first.
155
240
  resetState(state)
156
241
 
@@ -169,11 +254,37 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
169
254
  })
170
255
  restoreState(state, await loadDcpState(ctx))
171
256
 
257
+ // fork/resume/new sessions inherit the source conversation but get a fresh
258
+ // sidecar; inherit the previous session's compression blocks so they are
259
+ // not silently lost (which previously forced re-compressing all history).
260
+ if (state.compressionBlocks.length === 0 && event.previousSessionFile) {
261
+ try {
262
+ const inherited = await loadDcpStateFromSessionFile(event.previousSessionFile)
263
+ const added = inheritCompressionBlocks(state, inherited)
264
+ if (added > 0) {
265
+ writeDcpDebugLog(configForContext(ctx), "session_start.inherited_blocks", {
266
+ reason: event.reason,
267
+ previousSessionFile: event.previousSessionFile,
268
+ added,
269
+ totalBlocks: state.compressionBlocks.length,
270
+ }, ctx)
271
+ // Persist inherited state into this session's own sidecar so a later
272
+ // reload restores it directly.
273
+ await saveDcpState(ctx, state)
274
+ }
275
+ } catch {
276
+ // Inheritance is best-effort; never block session startup.
277
+ }
278
+ }
279
+
172
280
  // Headless by design: no extension status/footer/widgets are rendered.
173
281
  })
174
282
 
175
283
  // ── 6. session_shutdown: save state ───────────────────────────────────────
176
284
  pi.on("session_shutdown", async (_event, ctx) => {
285
+ // Force-flush: bypass the dedup hash so the final snapshot is always
286
+ // written, guaranteeing the next session_start can restore it.
287
+ resetDcpPersistenceDedup()
177
288
  await saveDcpState(ctx, state)
178
289
  })
179
290
 
@@ -191,6 +302,15 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
191
302
  }
192
303
  })
193
304
 
305
+ // ── 7b. message_end: never persist provider-echoed DCP control markers ─────
306
+ pi.on("message_end", async (event, ctx) => {
307
+ const effectiveConfig = configForContext(ctx)
308
+ if (!effectiveConfig.enabled || event.message?.role !== "assistant") return undefined
309
+
310
+ const sanitized = stripStaleDcpMetadataFromAssistantMessage(event.message)
311
+ return { message: sanitized }
312
+ })
313
+
194
314
  // ── 8. tool_call: record input args for dedup / purge fingerprinting ───────
195
315
  pi.on("tool_call", async (event, _ctx) => {
196
316
  if (!state.toolCalls.has(event.toolCallId)) {
@@ -247,10 +367,32 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
247
367
  // ── 10. context: apply pruning and inject nudges ──────────────────────────
248
368
  pi.on("context", async (event, ctx) => {
249
369
  const effectiveConfig = configForContext(ctx)
250
- const contextMessages = event.messages.filter((message: any) =>
251
- !isUserVisibleOnlyMessage(message) && !isDcpControlPlaneMessage(message)
252
- )
370
+ const contextMessages = event.messages
371
+ .filter((message: any) => !isUserVisibleOnlyMessage(message) && !isDcpControlPlaneMessage(message))
372
+ .map((message: any) => stripStaleDcpMetadataFromMessage(message))
373
+ const finishContext = (reason: string, messages: any[], details: Record<string, unknown> = {}) => {
374
+ writeDcpDebugLog(effectiveConfig, "context.result", {
375
+ reason,
376
+ inputMessages: event.messages.length,
377
+ filteredMessages: contextMessages.length,
378
+ outputMessages: messages.length,
379
+ messageIdControl: "provider-payload",
380
+ state: summarizeDcpState(state),
381
+ ...details,
382
+ }, ctx)
383
+ return { messages }
384
+ }
385
+
386
+ writeDcpDebugLog(effectiveConfig, "context.start", {
387
+ inputMessages: event.messages.length,
388
+ filteredMessages: contextMessages.length,
389
+ filteredDcpControlPlaneMessages: event.messages.length - contextMessages.length,
390
+ }, ctx)
253
391
  if (!effectiveConfig.enabled) {
392
+ writeDcpDebugLog(effectiveConfig, "context.disabled", {
393
+ inputMessages: event.messages.length,
394
+ filteredMessages: contextMessages.length,
395
+ }, ctx)
254
396
  return { messages: contextMessages }
255
397
  }
256
398
  annotateMessagesWithBranchEntryIds(contextMessages, ctx)
@@ -270,15 +412,9 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
270
412
  : undefined
271
413
 
272
414
  if (contextPercent === undefined) {
273
- if (state.manualMode) {
274
- state.nudgeAnchors = state.nudgeAnchors.filter((anchor) =>
275
- anchor.type === "context-strong" || anchor.type === "context-soft",
276
- )
277
- }
278
- applyAnchoredNudges(prunedMessages, state, (anchor) =>
279
- appendConcreteNudgeGuidance(baseNudgeText(anchor.type), candidate, messageCandidates, state),
280
- )
281
- return { messages: prunedMessages }
415
+ const clearedAnchors = clearDcpNudgeAnchors(state)
416
+ if (clearedAnchors > 0) await saveDcpState(ctx, state)
417
+ return finishContext("unknown-context-percent", prunedMessages, { clearedAnchors })
282
418
  }
283
419
 
284
420
  const ctxModel = (ctx as any).model
@@ -293,6 +429,18 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
293
429
  thresholds.maxContextPercent += Math.min(summaryBonus, SUMMARY_BUFFER_MAX_CONTEXT_BONUS)
294
430
  }
295
431
 
432
+ const contextLimitReached = contextPercent > thresholds.maxContextPercent
433
+ const routineNudgesAllowed = contextPercent > thresholds.minContextPercent
434
+ if (!contextLimitReached && !routineNudgesAllowed) {
435
+ const clearedAnchors = clearDcpNudgeAnchors(state)
436
+ if (clearedAnchors > 0) await saveDcpState(ctx, state)
437
+ return finishContext("below-threshold", prunedMessages, {
438
+ contextPercent,
439
+ thresholds,
440
+ clearedAnchors,
441
+ })
442
+ }
443
+
296
444
  let toolCallsSinceLastUser = 0
297
445
  for (let i = prunedMessages.length - 1; i >= 0; i--) {
298
446
  const msg = prunedMessages[i] as any
@@ -312,18 +460,28 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
312
460
  state.manualMode &&
313
461
  (nudgeType !== "context-strong" && nudgeType !== "context-soft")
314
462
 
315
- candidate = detectCompressionCandidate(
316
- prunedMessages,
317
- state,
318
- effectiveConfig,
319
- contextPercent,
320
- )
321
- messageCandidates = detectMessageCompressionCandidates(
322
- prunedMessages,
323
- state,
324
- effectiveConfig,
325
- contextPercent,
326
- )
463
+ if (!manualEmergencyOnly) {
464
+ candidate = detectCompressionCandidate(
465
+ prunedMessages,
466
+ state,
467
+ effectiveConfig,
468
+ contextPercent,
469
+ )
470
+ messageCandidates = detectMessageCompressionCandidates(
471
+ prunedMessages,
472
+ state,
473
+ effectiveConfig,
474
+ contextPercent,
475
+ )
476
+ writeDcpDebugLog(effectiveConfig, "context.candidates", {
477
+ contextPercent,
478
+ thresholds,
479
+ nudgeType,
480
+ candidate,
481
+ messageCandidates,
482
+ state: summarizeDcpState(state),
483
+ }, ctx)
484
+ }
327
485
 
328
486
  if (nudgeType && !manualEmergencyOnly) {
329
487
  const nudgeText = appendConcreteNudgeGuidance(
@@ -386,7 +544,26 @@ export default async function dcpModule(pi: ExtensionAPI): Promise<void> {
386
544
  appendConcreteNudgeGuidance(baseNudgeText(anchor.type), candidate, messageCandidates, state),
387
545
  )
388
546
 
389
- return { messages: prunedMessages }
547
+ return finishContext("complete", prunedMessages, {
548
+ candidate,
549
+ messageCandidates,
550
+ })
551
+ })
552
+
553
+ // ── 10b. before_provider_request: inject DCP IDs outside transcript ────────
554
+ pi.on("before_provider_request", async (event, ctx) => {
555
+ const effectiveConfig = configForContext(ctx)
556
+ if (!effectiveConfig.enabled) return undefined
557
+
558
+ const controlText = buildMessageIdControlText(state)
559
+ if (!controlText) return undefined
560
+
561
+ const payload = appendDcpControlToProviderPayload(event.payload, controlText)
562
+ writeDcpDebugLog(effectiveConfig, "provider_payload.message_ids", {
563
+ injected: payload !== event.payload,
564
+ state: summarizeDcpState(state),
565
+ }, ctx)
566
+ return payload === event.payload ? undefined : payload
390
567
  })
391
568
 
392
569
  // ── 11. agent_end: persist state after each agent run ────────────────────