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.
- package/dist/app/app.d.ts +0 -1
- package/dist/app/app.js +28 -21
- package/dist/app/constants.js +1 -1
- package/dist/app/input/input-action-controller.d.ts +1 -0
- package/dist/app/input/input-action-controller.js +3 -0
- package/dist/app/input/input-controller.d.ts +1 -0
- package/dist/app/input/input-controller.js +40 -12
- package/dist/app/model/model-usage-status.js +4 -2
- package/dist/app/process.js +11 -0
- package/dist/app/rendering/conversation-tool-renderer.js +4 -6
- package/dist/app/session/request-history.js +2 -0
- package/dist/app/session/session-event-controller.d.ts +13 -0
- package/dist/app/session/session-event-controller.js +27 -0
- package/dist/app/session/tabs-controller.d.ts +8 -0
- package/dist/app/session/tabs-controller.js +37 -6
- package/dist/app/workspace/workspace-actions-controller.d.ts +1 -0
- package/dist/app/workspace/workspace-actions-controller.js +2 -1
- package/dist/bundled-extensions/terminal-bell/index.js +55 -1
- package/dist/config.js +1 -1
- package/dist/default-pix-config.js +1 -1
- package/dist/markdown-format.js +14 -25
- package/dist/terminal-width.d.ts +14 -0
- package/dist/terminal-width.js +31 -2
- package/dist/theme.js +2 -2
- package/external/pi-tools-suite/README.md +34 -9
- package/external/pi-tools-suite/package.json +3 -3
- package/external/pi-tools-suite/src/async-subagents/async-subagents.sample.jsonc +35 -21
- package/external/pi-tools-suite/src/async-subagents/commands.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/agent-strategy.ts +2 -2
- package/external/pi-tools-suite/src/async-subagents/core/config.ts +70 -12
- package/external/pi-tools-suite/src/async-subagents/core/routing.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/spawn.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/core/types.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/index.ts +6 -6
- package/external/pi-tools-suite/src/async-subagents/lib.ts +1 -1
- package/external/pi-tools-suite/src/async-subagents/tools/spawn.ts +4 -2
- package/external/pi-tools-suite/src/async-subagents/tools/subagents.ts +2 -2
- package/external/pi-tools-suite/src/{glm-coding-discipline → coding-discipline}/index.ts +17 -8
- package/external/pi-tools-suite/src/config.ts +1 -1
- package/external/pi-tools-suite/src/dcp/auto-compress.ts +368 -0
- package/external/pi-tools-suite/src/dcp/compress-tool.ts +3 -0
- package/external/pi-tools-suite/src/dcp/config.ts +23 -0
- package/external/pi-tools-suite/src/dcp/index.ts +112 -7
- package/external/pi-tools-suite/src/dcp/prompts.ts +8 -0
- package/external/pi-tools-suite/src/dcp/state.ts +41 -0
- package/external/pi-tools-suite/src/default-pi-tools-suite-config.ts +30 -22
- package/external/pi-tools-suite/src/index.ts +2 -1
- package/external/pi-tools-suite/src/session-name/index.ts +37 -0
- package/external/pi-tools-suite/src/tool-descriptions.ts +16 -4
- package/package.json +4 -4
- package/skills/skill-creator/SKILL.md +36 -40
- package/skills/skill-creator/eval-viewer/viewer.html +2 -2
- package/skills/skill-creator/references/schemas.md +1 -1
- package/skills/skill-creator/scripts/__pycache__/__init__.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/aggregate_benchmark.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/generate_report.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/improve_description.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/package_skill.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_eval.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/run_loop.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/__pycache__/utils.cpython-314.pyc +0 -0
- package/skills/skill-creator/scripts/generate_report.py +1 -1
- package/skills/skill-creator/scripts/improve_description.py +14 -24
- 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
|
|
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
|
-
|
|
364
|
-
|
|
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
|
|
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,
|
|
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
|
|
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,
|
|
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: "
|
|
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,
|
|
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: "
|
|
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
|
-
"
|
|
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
|
-
"-
|
|
130
|
-
"-
|
|
131
|
-
"-
|
|
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
|
|
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
|
-
|
|
230
|
-
|
|
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(["
|
|
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
|
|
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: {
|