pi-ui-extend 0.1.38 → 0.1.41

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (64) hide show
  1. package/dist/app/app.d.ts +0 -1
  2. package/dist/app/app.js +28 -21
  3. package/dist/app/constants.js +1 -1
  4. package/dist/app/input/input-action-controller.d.ts +1 -0
  5. package/dist/app/input/input-action-controller.js +3 -0
  6. package/dist/app/input/input-controller.d.ts +1 -0
  7. package/dist/app/input/input-controller.js +40 -12
  8. package/dist/app/model/model-usage-status.js +4 -2
  9. package/dist/app/process.js +11 -0
  10. package/dist/app/rendering/conversation-tool-renderer.js +4 -6
  11. package/dist/app/session/request-history.js +2 -0
  12. package/dist/app/session/session-event-controller.d.ts +13 -0
  13. package/dist/app/session/session-event-controller.js +27 -0
  14. package/dist/app/session/tabs-controller.d.ts +8 -0
  15. package/dist/app/session/tabs-controller.js +37 -6
  16. package/dist/app/workspace/workspace-actions-controller.d.ts +1 -0
  17. package/dist/app/workspace/workspace-actions-controller.js +2 -1
  18. package/dist/bundled-extensions/terminal-bell/index.js +55 -1
  19. package/dist/config.js +1 -1
  20. package/dist/default-pix-config.js +1 -1
  21. package/dist/markdown-format.js +14 -25
  22. package/dist/terminal-width.d.ts +14 -0
  23. package/dist/terminal-width.js +31 -2
  24. package/dist/theme.js +2 -2
  25. package/external/pi-tools-suite/README.md +34 -9
  26. package/external/pi-tools-suite/package.json +3 -3
  27. package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +35 -21
  28. package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
  29. package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +2 -2
  30. package/external/pi-tools-suite/src/async-subagents/core/config.ts +70 -12
  31. package/external/pi-tools-suite/src/async-subagents/core/routing.ts +1 -1
  32. package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +1 -1
  33. package/external/pi-tools-suite/src/async-subagents/core/types.ts +1 -1
  34. package/external/pi-tools-suite/src/async-subagents/index.ts +6 -6
  35. package/external/pi-tools-suite/src/async-subagents/lib.ts +1 -1
  36. package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +4 -2
  37. package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
  38. package/external/pi-tools-suite/src/{glm-coding-discipline → coding-discipline}/index.ts +17 -8
  39. package/external/pi-tools-suite/src/config.ts +1 -1
  40. package/external/pi-tools-suite/src/dcp/auto-compress.ts +368 -0
  41. package/external/pi-tools-suite/src/dcp/compress-tool.ts +3 -0
  42. package/external/pi-tools-suite/src/dcp/config.ts +23 -0
  43. package/external/pi-tools-suite/src/dcp/index.ts +112 -7
  44. package/external/pi-tools-suite/src/dcp/prompts.ts +8 -0
  45. package/external/pi-tools-suite/src/dcp/state.ts +41 -0
  46. package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +30 -22
  47. package/external/pi-tools-suite/src/index.ts +2 -1
  48. package/external/pi-tools-suite/src/session-name/index.ts +37 -0
  49. package/external/pi-tools-suite/src/tool-descriptions.ts +16 -4
  50. package/package.json +4 -4
  51. package/skills/skill-creator/SKILL.md +36 -40
  52. package/skills/skill-creator/eval-viewer/viewer.html +2 -2
  53. package/skills/skill-creator/references/schemas.md +1 -1
  54. package/skills/skill-creator/scripts/__pycache__/__init__.cpython-314.pyc +0 -0
  55. package/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-314.pyc +0 -0
  56. package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-314.pyc +0 -0
  57. package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-314.pyc +0 -0
  58. package/skills/skill-creator/scripts/__pycache__/package_skill.cpython-314.pyc +0 -0
  59. package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-314.pyc +0 -0
  60. package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-314.pyc +0 -0
  61. package/skills/skill-creator/scripts/__pycache__/utils.cpython-314.pyc +0 -0
  62. package/skills/skill-creator/scripts/generate_report.py +1 -1
  63. package/skills/skill-creator/scripts/improve_description.py +14 -24
  64. package/skills/skill-creator/scripts/run_eval.py +89 -82
@@ -351,7 +351,7 @@ function visionCapabilityPrompt(event: unknown, ctx: unknown): string | undefine
351
351
  : "The current parent model cannot inspect images/screenshots directly.";
352
352
  const delegation = subagentsAvailable
353
353
  ? visionSubagentDelegationText(bridge?.attachments ?? [])
354
- : "If visual understanding is required, ask the user to switch to a vision-capable model or provide a path that can be inspected by a vision-capable helper.";
354
+ : "If visual understanding is required, use the lookup tool if available; otherwise ask the user to switch to a vision-capable model or provide an inspectable image path.";
355
355
  const bridgeWarning = visionBridgeWarning(bridge);
356
356
  return [
357
357
  "Vision capability constraint:",
@@ -360,8 +360,8 @@ function visionCapabilityPrompt(event: unknown, ctx: unknown): string | undefine
360
360
  bridgeWarning,
361
361
  delegation,
362
362
  bridge?.attachments.length
363
- ? "Use those bridged paths exactly as imagePaths if delegating."
364
- : "If an image only arrived as an attachment and no local file path/reference is available to subagents, ask the user for a file path or to switch the parent model to one with image input support.",
363
+ ? "Use those bridged paths exactly as lookup imagePaths if needed."
364
+ : "If an image only arrived as an attachment and no local file path/reference is available to lookup, ask the user for a file path or to switch the parent model to one with image input support.",
365
365
  ].filter(Boolean).join(" ");
366
366
  }
367
367
 
@@ -380,16 +380,16 @@ function visionCapableParentPrompt(event: unknown): string | undefined {
380
380
  "Vision capability note:",
381
381
  "The current parent model supports image input.",
382
382
  "If the user provided image attachments or local image file paths, inspect them directly first; for local paths, use the read tool on the image path.",
383
- "Do not delegate to a vision sub-agent solely to gain visual access; use a vision sub-agent only when the user explicitly asks to delegate/parallelize or a separate visual review is useful.",
383
+ "Do not delegate solely to gain visual access; use lookup for focused visual checks and subagents only for broader independent tracks.",
384
384
  ].join(" ");
385
385
  }
386
386
 
387
387
  function visionSubagentDelegationText(attachments: BridgedImageAttachment[]): string {
388
388
  if (attachments.length === 0) {
389
- return "If visual understanding is required, delegate to the subagents tool with subagentType='vision' plus imagePaths/focus when the image is available as a local file path.";
389
+ return "If visual understanding is required, use the lookup tool with imagePaths/focus when the image is available as a local file path.";
390
390
  }
391
391
  const imagePaths = attachments.map((attachment) => attachment.relativePath);
392
- return `Attached images were saved for vision delegation. If visual understanding is required, delegate to the subagents tool with subagentType='vision' and imagePaths=${JSON.stringify(imagePaths)} plus a focused task/focus.`;
392
+ return `Attached images were saved for lookup. If visual understanding is required, call lookup with imagePaths=${JSON.stringify(imagePaths)} and a focused question.`;
393
393
  }
394
394
 
395
395
  function visionBridgeWarning(bridge: BridgeImageAttachmentsResult | undefined): string | undefined {
@@ -12,7 +12,7 @@ export type {
12
12
  } from "./core/types.js";
13
13
 
14
14
  export { createRunDir, getRunRoot, resolveRunDir, validateBasename } from "./core/paths.js";
15
- export type { CopySubagentConfigSampleResult, ResolvedAgentTaskConfig, ResolvedSubagentRoutingConfig, ResolveAgentTaskOptions, SubagentConfig, SubagentPreset, SubagentRoutingConfig, SubagentTypeConfig, SubagentVisionConfig } from "./core/config.js";
15
+ export type { CopySubagentConfigSampleResult, ModelByParentEntry, ResolvedAgentTaskConfig, ResolvedSubagentRoutingConfig, ResolveAgentTaskOptions, SubagentConfig, SubagentPreset, SubagentRoutingConfig, SubagentTypeConfig, SubagentVisionConfig } from "./core/config.js";
16
16
  export {
17
17
  configFiles,
18
18
  copySubagentConfigSample,
@@ -167,12 +167,12 @@ const AgentTaskSchema = Type.Object({
167
167
  id: Type.Optional(Type.String({ description: "Short identifier for this agent (used as directory name). If omitted, the spawn action assigns agent-1, agent-2, etc." })),
168
168
  task: Type.String({ description: "Focused task description for the sub-agent" }),
169
169
  scope: Type.Optional(Type.String({ description: "Relevant files/areas for this task" })),
170
- subagentType: Type.Optional(Type.String({ description: "Logical sub-agent type/profile from config. Usually omit this so the router selects from the current config; set only for an explicit user-requested role, vision/image handling, deterministic tests, or another concrete override." })),
170
+ subagentType: Type.Optional(Type.String({ description: "Logical sub-agent type/profile from config. Usually omit this so the router selects from the current config; set only for an explicit user-requested role, deterministic tests, or another concrete override." })),
171
171
  model: Type.Optional(Type.String({ description: "Explicit model override for this sub-agent. Prefer subagentType for reusable routing." })),
172
172
  thinking: Type.Optional(Type.String({ description: "Per-agent thinking level override (off, minimal, low, medium, high, xhigh)." })),
173
173
  promptAppend: Type.Optional(Type.String({ description: "Extra prompt instructions appended after the generated/type prompt." })),
174
174
  promptOverride: Type.Optional(Type.String({ description: "Full prompt replacement for this sub-agent. Prefer configuring this per subagentType." })),
175
- focus: Type.Optional(Type.String({ description: "For vision sub-agents: what to pay special attention to while inspecting attached images." })),
175
+ focus: Type.Optional(Type.String({ description: "Optional focus/attention instructions for attached images or scoped inspection." })),
176
176
  attention: Type.Optional(Type.String({ description: "Alias for focus, accepted for compatibility." })),
177
177
  imagePaths: Type.Optional(Type.Array(Type.String(), { description: "Local image paths to attach to this sub-agent prompt (jpg, png, gif, or webp). Relative paths resolve from cwd." })),
178
178
  tools: Type.Optional(Type.Array(Type.String(), { description: "Tool names to enable (e.g. ['read','grep','bash'])" })),
@@ -237,12 +237,14 @@ export function registerSpawnTool(
237
237
  }
238
238
  const routed = await routeSubagentTasks(normalized.tasks ?? [], config, ctx as any, signal ?? undefined);
239
239
  const timeoutMs = timeoutMsFromSeconds(params.timeoutSeconds);
240
+ const parentModel = currentModelRef((ctx as { model?: unknown }).model);
240
241
  const resolvedTasks = routed.tasks.map((task) => applySessionModelFallback(
241
242
  resolveAgentTaskConfig(task, config, {
242
243
  preset: activePreset,
243
244
  thinking: params.thinking,
244
245
  extraArgs: Array.isArray(params.extraArgs) ? params.extraArgs : [],
245
246
  forcedModel,
247
+ parentModel,
246
248
  timeoutMs,
247
249
  }),
248
250
  ));
@@ -19,12 +19,12 @@ const AgentTaskSchema = Type.Object({
19
19
  id: Type.Optional(Type.String({ description: "Short identifier for this agent (used as directory name). If omitted, assigns agent-1, agent-2, etc." })),
20
20
  task: Type.String({ description: "Focused task description for the sub-agent" }),
21
21
  scope: Type.Optional(Type.String({ description: "Relevant files/areas for this task" })),
22
- subagentType: Type.Optional(Type.String({ description: "Logical sub-agent type/profile from config. Usually omit this so the router selects from the current config; set only for an explicit user-requested role, vision/image handling, deterministic tests, or another concrete override." })),
22
+ subagentType: Type.Optional(Type.String({ description: "Logical sub-agent type/profile from config. Usually omit this so the router selects from the current config; set only for an explicit user-requested role, deterministic tests, or another concrete override." })),
23
23
  model: Type.Optional(Type.String({ description: "Explicit model override for this sub-agent. Prefer subagentType for reusable routing." })),
24
24
  thinking: Type.Optional(Type.String({ description: "Per-agent thinking level override (off, minimal, low, medium, high, xhigh)." })),
25
25
  promptAppend: Type.Optional(Type.String({ description: "Extra prompt instructions appended after the generated/type prompt." })),
26
26
  promptOverride: Type.Optional(Type.String({ description: "Full prompt replacement for this sub-agent. Prefer configuring this per subagentType." })),
27
- focus: Type.Optional(Type.String({ description: "For vision sub-agents: what to pay special attention to while inspecting attached images." })),
27
+ focus: Type.Optional(Type.String({ description: "Optional focus/attention instructions for attached images or scoped inspection." })),
28
28
  attention: Type.Optional(Type.String({ description: "Alias for focus, accepted for compatibility." })),
29
29
  imagePaths: Type.Optional(Type.Array(Type.String(), { description: "Local image paths to attach to this sub-agent prompt (jpg, png, gif, or webp). Relative paths resolve from cwd." })),
30
30
  tools: Type.Optional(Type.Array(Type.String(), { description: "Tool names to enable (e.g. ['read','grep','bash'])" })),
@@ -59,7 +59,7 @@ const LOOKUP_TOOL_PARAMS = Type.Object(
59
59
  );
60
60
 
61
61
  const QUALITY_DISCIPLINE_LINES = [
62
- "GLM TOOL-ONLY CODING AGENT CONTRACT.",
62
+ "TOOL-ONLY CODING AGENT CONTRACT.",
63
63
  "",
64
64
  "This contract controls the assistant output channel. Follow it literally.",
65
65
  "Treat every user coding request as a tool-driven task, not a chat conversation.",
@@ -126,9 +126,14 @@ const QUALITY_DISCIPLINE_LINES = [
126
126
  "While WORKING, this behavior is internal and expressed only through tool choices, not prose.",
127
127
  "",
128
128
  "Maintain these invariants:",
129
- "- preserve existing behavior unless the user asked to change it;",
130
- "- make minimal, localized changes;",
131
- "- respect project conventions already present in nearby code;",
129
+ "- make the smallest correct change;",
130
+ "- keep diffs local; no unrelated refactors, renames, moves, reformatting, or dependency changes;",
131
+ "- inspect code before editing; do not invent APIs, files, commands, or behavior;",
132
+ "- before non-trivial edits, know the verification path;",
133
+ "- for bugs, prefer a failing test or repro first; then make the minimal fix; then verify;",
134
+ "- high-risk changes need a short spec before coding: goal, scope, behavior, risks, verification;",
135
+ "- high-risk includes security, privacy, auth/authz, data/schema/migrations, public APIs, external integrations, payments, jobs, concurrency, and irreversible or cross-cutting changes;",
136
+ "- follow nearby conventions; preserve existing behavior unless explicitly changing it;",
132
137
  "- handle edge cases, errors, cancellation, and async behavior;",
133
138
  "- avoid blocking UI/event loops;",
134
139
  "- avoid duplicate state, duplicate prompts, and repeated side effects.",
@@ -147,6 +152,9 @@ const FINAL_DISCIPLINE_LINES = [
147
152
  "",
148
153
  "When uncertain, test or inspect instead of assuming.",
149
154
  "If blocked by missing required information, ask exactly one concise question.",
155
+ "Verify every non-trivial change. Never claim tests passed unless they were actually run.",
156
+ "Report: what changed, what was verified, what was not verified, and any risks.",
157
+ "Ask at most one blocking question; otherwise proceed with grounded best effort.",
150
158
  ];
151
159
 
152
160
  const SILENCE_REMINDER_TEXT = [
@@ -175,7 +183,7 @@ const LOOKUP_SYSTEM_PROMPT = [
175
183
  "Return concise factual observations and practical implications for the parent agent.",
176
184
  ].join("\n");
177
185
 
178
- export default function glmCodingDiscipline(pi: ExtensionAPI) {
186
+ export default function codingDiscipline(pi: ExtensionAPI) {
179
187
  let selectedModelRef: string | undefined;
180
188
  let lookupRegistered = false;
181
189
  let silenceViolationCount = 0;
@@ -226,8 +234,9 @@ export default function glmCodingDiscipline(pi: ExtensionAPI) {
226
234
 
227
235
  pi.on("before_provider_request", async (event: { payload?: unknown }, ctx: unknown) => {
228
236
  const modelRef = modelRefFromPayload(event.payload) ?? selectedModelRef ?? modelRefFromContext(ctx);
229
- if (!isGlmModel(modelRef)) return undefined;
230
- return injectCodingDisciplineIntoPayload(event.payload, { lookupEnabled: Boolean(lookupModelFromConfig(contextCwd(ctx))) });
237
+ return injectCodingDisciplineIntoPayload(event.payload, {
238
+ lookupEnabled: isGlmModel(modelRef) && Boolean(lookupModelFromConfig(contextCwd(ctx))),
239
+ });
231
240
  });
232
241
 
233
242
  pi.on("context", async (event: { messages?: unknown[] }, ctx: unknown) => {
@@ -454,7 +463,7 @@ function createSilenceReminderMessage() {
454
463
  }
455
464
 
456
465
  function lookupModelFromConfig(cwd?: string): string | undefined {
457
- return loadPiToolsSuiteConfig(["glm-coding-discipline"], { cwd: cwd ?? process.cwd() }).lookupModel;
466
+ return loadPiToolsSuiteConfig(["coding-discipline"], { cwd: cwd ?? process.cwd() }).lookupModel;
458
467
  }
459
468
 
460
469
  function buildLookupPrompt(params: LookupParams, recentContext: string, imageCount: number, warnings: string[]): string {
@@ -15,7 +15,7 @@ export interface PiToolsSuiteConfig {
15
15
  enabled: boolean;
16
16
  disabledModules: string[];
17
17
  todoThinking: boolean;
18
- /** Vision-capable model used by the GLM lookup tool; unset disables lookup. */
18
+ /** Vision-capable model used by the coding-discipline lookup tool; unset disables lookup. */
19
19
  lookupModel?: string;
20
20
  telegramMirror?: TelegramMirrorConfig;
21
21
  }
@@ -0,0 +1,368 @@
1
+ // ---------------------------------------------------------------------------
2
+ // Dynamic Context Pruning (DCP) — auto-compress fallback
3
+ //
4
+ // When a model ignores repeated context-strong nudges above the emergency
5
+ // threshold (observed with gpt-5.5 in session 019edfe3: 59 strong nudges,
6
+ // 0 compress calls), DCP creates a compression block itself instead of
7
+ // waiting for the model. This is the model-independent safety net.
8
+ //
9
+ // Lossy and irreversible within a session; disabled by default and gated by a
10
+ // patience counter + the emergency threshold. The summary can be produced
11
+ // either by a deterministic programmatic digest (default) or by a configured
12
+ // list of summarizer models (e.g. a cheap model like zai/glm-5.2), with
13
+ // automatic fallback to the programmatic digest on any failure/timeout.
14
+ // ---------------------------------------------------------------------------
15
+
16
+ import { complete } from "@earendil-works/pi-ai"
17
+ import type { Model, Api } from "@earendil-works/pi-ai"
18
+ import type { DcpState } from "./state.js"
19
+ import type { DcpConfig } from "./config.js"
20
+ import type { CompressionCandidate } from "./pruner-types.js"
21
+ import {
22
+ createRangeCompressionBlock,
23
+ resolveAnchorBoundary,
24
+ } from "./compression-blocks.js"
25
+
26
+ /**
27
+ * Pure decision: should the auto-compress fallback fire this pass?
28
+ *
29
+ * Fires when ALL hold:
30
+ * - the master switch `autoCompress.enabled` is on,
31
+ * - the model has ignored at least `patience` consecutive context-strong
32
+ * nudges (`consecutiveIgnoredStrongNudges > patience` — the model gets
33
+ * `patience` genuine strong chances before DCP takes over),
34
+ * - context is still above the emergency threshold (maxContextPercent),
35
+ * - a safe compression candidate exists outside the recent turns.
36
+ */
37
+ export function decideAutoCompress(
38
+ state: DcpState,
39
+ config: DcpConfig,
40
+ contextPercent: number,
41
+ maxContextPercent: number,
42
+ candidate: CompressionCandidate | null,
43
+ ): { shouldFire: boolean; reason: string } {
44
+ const settings = config.compress.autoCompress
45
+ if (!settings?.enabled) return { shouldFire: false, reason: "disabled" }
46
+ if (state.consecutiveIgnoredStrongNudges <= settings.patience) {
47
+ return { shouldFire: false, reason: "below-patience" }
48
+ }
49
+ if (!(contextPercent > maxContextPercent)) {
50
+ return { shouldFire: false, reason: "below-emergency-threshold" }
51
+ }
52
+ if (!candidate) return { shouldFire: false, reason: "no-candidate" }
53
+ return { shouldFire: true, reason: "ignored-strongs" }
54
+ }
55
+
56
+ /** Flatten a single message's content blocks into plain text. */
57
+ function messageToText(message: any): string {
58
+ const content = message?.content
59
+ if (typeof content === "string") return content
60
+ if (!Array.isArray(content)) return ""
61
+ return content
62
+ .map((block: any) => {
63
+ if (typeof block === "string") return block
64
+ if (block?.type === "text") return block.text ?? ""
65
+ if (block?.type === "toolCall") {
66
+ const name = block.name ?? block.function?.name ?? "tool"
67
+ return `[tool call: ${name}]`
68
+ }
69
+ if (block?.type === "toolResult" || block?.role === "toolResult") {
70
+ return block.text ?? ""
71
+ }
72
+ return ""
73
+ })
74
+ .join("\n")
75
+ .trim()
76
+ }
77
+
78
+ /** Extract a short tool-usage digest from messages in the range. */
79
+ function toolUsageDigest(messages: any[]): string {
80
+ const counts = new Map<string, number>()
81
+ for (const msg of messages) {
82
+ const content = msg?.content
83
+ if (!Array.isArray(content)) continue
84
+ for (const block of content) {
85
+ if (block?.type === "toolCall" && typeof block.name === "string") {
86
+ counts.set(block.name, (counts.get(block.name) ?? 0) + 1)
87
+ }
88
+ }
89
+ }
90
+ if (counts.size === 0) return ""
91
+ const entries = [...counts.entries()].sort((a, b) => b[1] - a[1])
92
+ return entries.map(([name, n]) => `${name}×${n}`).join(", ")
93
+ }
94
+
95
+ /**
96
+ * Deterministic, model-free summary of the compressed range. Deliberately
97
+ * short: `createRangeCompressionBlock` appends protected user messages and
98
+ * protected tool outputs on top of this, so the digest itself only needs to
99
+ * label the slice and record the tool-call shape.
100
+ */
101
+ export function buildProgrammaticSummary(
102
+ topic: string,
103
+ candidate: CompressionCandidate,
104
+ messagesInRange: any[],
105
+ ): string {
106
+ const toolDigest = toolUsageDigest(messagesInRange)
107
+ const lines = [
108
+ `[Auto-compressed by DCP — model did not compress after repeated context-strong nudges]`,
109
+ `Topic: ${topic}`,
110
+ `Range: ${candidate.startId}..${candidate.endId} (${candidate.messageCount} messages, ~${candidate.estimatedTokens} tokens)`,
111
+ ]
112
+ if (toolDigest) lines.push(`Tool calls in range: ${toolDigest}`)
113
+ lines.push(
114
+ `This slice was summarized automatically to protect the context window. Protected user messages and tool outputs are preserved below by the compression block.`,
115
+ )
116
+ return lines.join("\n")
117
+ }
118
+
119
+ const SUMMARIZER_SYSTEM_PROMPT = `You summarize a slice of a coding agent's conversation so it can replace the raw messages in context. Produce a dense, continuation-focused summary: preserve user intent, decisions made, files/symbols changed or inspected, exact errors still actionable, verification status, and next steps. Drop full logs, repeated output, and incidental detail. Be concise (roughly 4-10 bullets). Output ONLY the summary text, no preamble.`
120
+
121
+ /** Outcome of one summarizer-model attempt, surfaced in DCP debug logs. */
122
+ export interface ModelSummaryAttempt {
123
+ ref: string
124
+ outcome: "ok" | "no-model" | "no-auth" | "empty" | "error"
125
+ error?: string
126
+ }
127
+
128
+ /** Result of {@link generateModelSummary}: optional text plus per-model attempts. */
129
+ export interface ModelSummaryResult {
130
+ text?: string
131
+ /** Model ref that produced {@link text}, if any. */
132
+ usedModelRef?: string
133
+ /** One entry per model ref tried, in order, for debug visibility. */
134
+ attempts: ModelSummaryAttempt[]
135
+ }
136
+
137
+ /**
138
+ * Try to produce a model-generated summary by calling each model in
139
+ * `modelRefs` in order. On success returns `{ text, usedModelRef, attempts }`;
140
+ * if every model fails, returns `{ attempts }` with `text` undefined so the
141
+ * caller falls back to the programmatic digest while still recording which
142
+ * models were tried and why.
143
+ *
144
+ * Never throws: a summarizer failure must never block the agent — the
145
+ * programmatic digest is always available as a floor.
146
+ */
147
+ export async function generateModelSummary(
148
+ modelRefs: string[],
149
+ modelRegistry: any,
150
+ signal: AbortSignal | undefined,
151
+ topic: string,
152
+ messagesInRange: any[],
153
+ timeoutMs: number,
154
+ ): Promise<ModelSummaryResult> {
155
+ const attempts: ModelSummaryAttempt[] = []
156
+ if (!modelRefs || modelRefs.length === 0) return { attempts }
157
+ if (!modelRegistry || typeof modelRegistry.find !== "function" || typeof modelRegistry.getApiKeyAndHeaders !== "function") {
158
+ return { attempts }
159
+ }
160
+
161
+ // Build a compact transcript from the range. Cap token budget so the
162
+ // summarizer call stays cheap and bounded.
163
+ const transcript = messagesInRange
164
+ .map((msg, i) => {
165
+ const role = msg?.role ?? "message"
166
+ return `### ${role} #${i + 1}\n${messageToText(msg)}`
167
+ })
168
+ .join("\n\n")
169
+ const userPrompt = `Summarize this conversation slice (topic: ${topic}).\n\nTranscript:\n${transcript}`
170
+
171
+ let lastError: unknown
172
+ for (const ref of modelRefs) {
173
+ const parsed = parseModelRef(ref)
174
+ if (!parsed) continue
175
+ const model: Model<Api> | undefined = modelRegistry.find(parsed.provider, parsed.id)
176
+ if (!model) {
177
+ attempts.push({ ref, outcome: "no-model" })
178
+ continue
179
+ }
180
+
181
+ let auth
182
+ try {
183
+ auth = await modelRegistry.getApiKeyAndHeaders(model)
184
+ } catch (error) {
185
+ lastError = error
186
+ attempts.push({ ref, outcome: "no-auth", error: error instanceof Error ? error.message : String(error) })
187
+ continue
188
+ }
189
+ if (!auth?.ok || !auth.apiKey) {
190
+ attempts.push({ ref, outcome: "no-auth" })
191
+ continue
192
+ }
193
+
194
+ // Combine the agent signal with a local timeout so a slow summarizer
195
+ // cannot stall the context event indefinitely.
196
+ const controller = new AbortController()
197
+ const timer = setTimeout(() => controller.abort(), Math.max(1000, timeoutMs))
198
+ const onParentAbort = () => controller.abort()
199
+ if (signal) {
200
+ if (signal.aborted) controller.abort()
201
+ else signal.addEventListener("abort", onParentAbort, { once: true })
202
+ }
203
+
204
+ try {
205
+ const result = await complete(
206
+ model,
207
+ { systemPrompt: SUMMARIZER_SYSTEM_PROMPT, messages: [{ role: "user", content: userPrompt, timestamp: Date.now() }] },
208
+ {
209
+ apiKey: auth.apiKey,
210
+ headers: auth.headers,
211
+ env: auth.env,
212
+ signal: controller.signal,
213
+ maxRetries: 0,
214
+ } as any,
215
+ )
216
+ const text = extractAssistantText(result)
217
+ if (text) {
218
+ attempts.push({ ref, outcome: "ok" })
219
+ return { text, usedModelRef: ref, attempts }
220
+ }
221
+ attempts.push({ ref, outcome: "empty" })
222
+ } catch (error) {
223
+ lastError = error
224
+ attempts.push({ ref, outcome: "error", error: error instanceof Error ? error.message : String(error) })
225
+ // try next model in the fallback list
226
+ } finally {
227
+ clearTimeout(timer)
228
+ if (signal) signal.removeEventListener("abort", onParentAbort)
229
+ }
230
+ }
231
+
232
+ if (lastError) {
233
+ // Swallowed on purpose: callers use the programmatic digest floor.
234
+ }
235
+ return { attempts }
236
+ }
237
+
238
+ function extractAssistantText(result: any): string | undefined {
239
+ const content = result?.content
240
+ if (!Array.isArray(content)) return undefined
241
+ const text = content
242
+ .filter((block: any) => block?.type === "text" && typeof block.text === "string")
243
+ .map((block: any) => block.text)
244
+ .join("\n")
245
+ .trim()
246
+ return text.length > 0 ? text : undefined
247
+ }
248
+
249
+ function parseModelRef(ref: string): { provider: string; id: string } | undefined {
250
+ const trimmed = ref.trim()
251
+ const slash = trimmed.lastIndexOf("/")
252
+ if (slash <= 0 || slash === trimmed.length - 1) return undefined
253
+ return { provider: trimmed.slice(0, slash), id: trimmed.slice(slash + 1) }
254
+ }
255
+
256
+ export interface CreateAutoCompressionBlockOptions {
257
+ candidate: CompressionCandidate
258
+ topic: string
259
+ state: DcpState
260
+ config: DcpConfig
261
+ messages: any[]
262
+ modelRegistry?: any
263
+ signal?: AbortSignal
264
+ }
265
+
266
+ export interface AutoCompressionResult {
267
+ blockId: number
268
+ summaryMode: "programmatic" | "model" | "programmatic_fallback"
269
+ summaryTokens: number
270
+ removedTokenEstimate: number
271
+ /** Model ref that produced the summary; set only when `summaryMode === "model"`. */
272
+ summarizerModelRef?: string
273
+ /** Per-model attempts, surfaced for DCP debug visibility on fallback. */
274
+ summarizerAttempts?: ModelSummaryAttempt[]
275
+ }
276
+
277
+ /**
278
+ * Create the auto-compression block. Selects the summary source based on
279
+ * `config.compress.autoCompress.summarizerModel`: empty → programmatic digest;
280
+ * non-empty → model summary with programmatic fallback. Then delegates block
281
+ * creation to the shared `createRangeCompressionBlock` path so protected
282
+ * content (user messages, tool outputs, prompt info) is handled identically to
283
+ * a model-initiated compress.
284
+ */
285
+ export async function createAutoCompressionBlock(
286
+ options: CreateAutoCompressionBlockOptions,
287
+ ): Promise<AutoCompressionResult> {
288
+ const { candidate, topic, state, config, messages, modelRegistry, signal } = options
289
+ const settings = config.compress.autoCompress
290
+
291
+ // Resolve candidate message IDs (mNNN) to timestamps via the snapshot.
292
+ const startMeta = state.messageMetaSnapshot.get(candidate.startId)
293
+ const endMeta = state.messageMetaSnapshot.get(candidate.endId)
294
+ const rawStart = startMeta?.timestamp ?? state.messageIdSnapshot.get(candidate.startId)
295
+ const rawEnd = endMeta?.timestamp ?? state.messageIdSnapshot.get(candidate.endId)
296
+
297
+ if (!Number.isFinite(rawStart) || !Number.isFinite(rawEnd)) {
298
+ throw new Error(
299
+ `Auto-compress candidate ${candidate.startId}..${candidate.endId} did not resolve to finite timestamps`,
300
+ )
301
+ }
302
+ const startTimestamp: number = rawStart as number
303
+ const endTimestamp: number = rawEnd as number
304
+
305
+ const messagesInRange = messages.filter(
306
+ (msg) =>
307
+ Number.isFinite(msg?.timestamp) && msg.timestamp >= startTimestamp && msg.timestamp <= endTimestamp,
308
+ )
309
+
310
+ // Summary source selection. `summaryMode` distinguishes three cases so the
311
+ // DCP debug log can tell a real model summary from a programmatic fallback
312
+ // caused by summarizer failure:
313
+ // - "model": a configured model produced the summary.
314
+ // - "programmatic": no summarizer models configured (floor by design).
315
+ // - "programmatic_fallback": models were configured but all failed/empty.
316
+ let summary = buildProgrammaticSummary(topic, candidate, messagesInRange)
317
+ let summaryMode: "programmatic" | "model" | "programmatic_fallback" = "programmatic"
318
+ let summarizerModelRef: string | undefined
319
+ let summarizerAttempts: ModelSummaryAttempt[] | undefined
320
+
321
+ const modelRefs = settings.summarizerModel
322
+ if (modelRefs.length > 0) {
323
+ const modelResult = await generateModelSummary(
324
+ modelRefs,
325
+ modelRegistry,
326
+ signal,
327
+ topic,
328
+ messagesInRange,
329
+ settings.timeoutMs,
330
+ )
331
+ summarizerAttempts = modelResult.attempts.length > 0 ? modelResult.attempts : undefined
332
+ if (modelResult.text) {
333
+ summary = modelResult.text
334
+ summaryMode = "model"
335
+ summarizerModelRef = modelResult.usedModelRef
336
+ } else {
337
+ // All configured models failed or returned empty — fall back to the
338
+ // programmatic digest, but mark the mode distinctly so the fallback
339
+ // is visible in DCP debug logs.
340
+ summaryMode = "programmatic_fallback"
341
+ }
342
+ }
343
+
344
+ const anchor = resolveAnchorBoundary(endTimestamp, state)
345
+ const created = createRangeCompressionBlock({
346
+ topic,
347
+ summary,
348
+ startTimestamp,
349
+ endTimestamp,
350
+ startMessageId: startMeta?.stableId,
351
+ endMessageId: endMeta?.stableId,
352
+ anchorTimestamp: anchor.timestamp,
353
+ anchorMessageId: anchor.stableId,
354
+ createdByToolCallId: undefined,
355
+ state,
356
+ config,
357
+ mode: "range",
358
+ })
359
+
360
+ return {
361
+ blockId: created.block.id,
362
+ summaryMode,
363
+ summaryTokens: created.summaryTokenEstimate,
364
+ removedTokenEstimate: created.removedTokenEstimate,
365
+ summarizerModelRef,
366
+ summarizerAttempts,
367
+ }
368
+ }
@@ -355,6 +355,9 @@ export function registerCompressTool(
355
355
  }
356
356
 
357
357
  const clearedNudgeAnchors = newBlockIds.length > 0 ? clearDcpNudgeAnchors(state) : 0
358
+ if (newBlockIds.length > 0) {
359
+ state.consecutiveIgnoredStrongNudges = 0
360
+ }
358
361
  if (clearedNudgeAnchors > 0) {
359
362
  try {
360
363
  pi.appendEntry("dcp-nudge", {
@@ -49,6 +49,23 @@ export interface DcpConfig {
49
49
  highTokens: number
50
50
  maxSuggestions: number
51
51
  }
52
+ /**
53
+ * Auto-compress fallback: when the model ignores repeated context-strong
54
+ * nudges above the emergency threshold, DCP creates a compression block
55
+ * itself (without waiting for the model). Lossy and irreversible within a
56
+ * session — disabled by default; opt in via config.
57
+ */
58
+ autoCompress: {
59
+ enabled: boolean
60
+ /** Number of context-strong nudges emitted (and ignored) before DCP
61
+ * auto-compresses. The model gets `patience` genuine strong chances. */
62
+ patience: number
63
+ /** Models to try, in order, when producing a model-generated summary.
64
+ * Empty array → deterministic programmatic digest (no model call). */
65
+ summarizerModel: string[]
66
+ /** Hard ceiling in ms for a single summarizer model call. */
67
+ timeoutMs: number
68
+ }
52
69
  }
53
70
  strategies: {
54
71
  deduplication: {
@@ -120,6 +137,12 @@ const DEFAULT_CONFIG: DcpConfig = {
120
137
  highTokens: 5000,
121
138
  maxSuggestions: 5,
122
139
  },
140
+ autoCompress: {
141
+ enabled: false,
142
+ patience: 2,
143
+ summarizerModel: [],
144
+ timeoutMs: 20000,
145
+ },
123
146
  },
124
147
  strategies: {
125
148
  deduplication: {