@vanillagreen/pi-qol 1.1.1 → 1.3.0
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/README.md +2 -0
- package/extensions/qol/agent-end.ts +48 -0
- package/extensions/qol/agent-statusline.ts +52 -0
- package/extensions/qol/ansi.ts +115 -0
- package/extensions/qol/bridges.ts +78 -0
- package/extensions/qol/compaction.ts +264 -0
- package/extensions/qol/constants.ts +107 -0
- package/extensions/qol/context-usage.ts +492 -0
- package/extensions/qol/editor.ts +183 -0
- package/extensions/qol/handoff.ts +109 -0
- package/extensions/qol/images.ts +123 -0
- package/extensions/qol/notifications.ts +269 -0
- package/extensions/qol/pending-queue.ts +79 -0
- package/extensions/qol/permission-gate.ts +139 -0
- package/extensions/qol/session-rename.ts +323 -0
- package/extensions/qol/session-search/cache.ts +273 -0
- package/extensions/qol/session-search/component.ts +937 -0
- package/extensions/qol/session-search/context.ts +152 -0
- package/extensions/qol/session-search/index.ts +171 -0
- package/extensions/qol/session-search/search.ts +288 -0
- package/extensions/qol/session-search/types.ts +96 -0
- package/extensions/qol/settings.ts +74 -0
- package/extensions/qol/status-message.ts +41 -0
- package/extensions/qol/statusline.ts +219 -0
- package/extensions/qol/thinking-timer.ts +97 -0
- package/extensions/qol/util.ts +7 -0
- package/extensions/qol.ts +130 -4380
- package/package.json +11 -1
package/README.md
CHANGED
|
@@ -50,6 +50,7 @@ Restart Pi after installation.
|
|
|
50
50
|
| `/context` | Show inline context-window usage, model/context limit, and estimated category breakdowns. |
|
|
51
51
|
| `/search [query]` | Open previous-session search, optionally prefilled with a query. |
|
|
52
52
|
| `/search:refresh` | Refresh the session search cache. |
|
|
53
|
+
| `/search:resume-pending <id>` | Internal recovery command inserted into the editor when a resume/fork action must be confirmed by pressing Enter. |
|
|
53
54
|
| `/handoff <goal>` | Draft a focused handoff prompt for a new session. |
|
|
54
55
|
|
|
55
56
|
`/qol` and `/search` arguments support autocomplete.
|
|
@@ -63,6 +64,7 @@ Settings are exposed through `pi-extension-manager` under **QOL**.
|
|
|
63
64
|
- `replaceFooter`: hide Pi's default footer while QOL's compact statusline is active; default on.
|
|
64
65
|
- `compactPrompt`: use the compact `π` prompt editor; default on.
|
|
65
66
|
- `showSessionNameTitle`: show the named session above the prompt, or in tmux's pane title/border; default on.
|
|
67
|
+
- `showSessionNameWindow`: also rename the current tmux window to `π <session>` so it appears in the tmux status line. Disables `automatic-rename` for the window while active and restores it on shutdown. Requires `showSessionNameTitle`; default on.
|
|
66
68
|
- `inputBottomPaddingLines`: blank lines below the compact prompt; default `0`.
|
|
67
69
|
- Git display knobs: `gitRefreshTimeoutMs`, `showDirtyMarker`.
|
|
68
70
|
- If `pi-caveman` is loaded, QOL reads its bridge to show a Caveman icon in the statusline and to make `Alt+C` cycle Caveman modes. The Caveman package's `showStatusBadge` setting also controls this QOL badge.
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
|
|
3
|
+
export function textFromContent(content: unknown): string {
|
|
4
|
+
if (typeof content === "string") return content;
|
|
5
|
+
if (!Array.isArray(content)) return "";
|
|
6
|
+
return content
|
|
7
|
+
.map((part) => {
|
|
8
|
+
if (typeof part === "string") return part;
|
|
9
|
+
if (part && typeof part === "object" && (part as any).type === "text" && typeof (part as any).text === "string") return (part as any).text;
|
|
10
|
+
return "";
|
|
11
|
+
})
|
|
12
|
+
.filter(Boolean)
|
|
13
|
+
.join("\n");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export function lastAssistantTextFromAgentEnd(event: any, ctx: ExtensionContext): string {
|
|
17
|
+
const eventMessages = Array.isArray(event?.messages) ? event.messages : [];
|
|
18
|
+
for (let index = eventMessages.length - 1; index >= 0; index -= 1) {
|
|
19
|
+
const message = eventMessages[index];
|
|
20
|
+
if (message?.role === "assistant") return textFromContent(message.content);
|
|
21
|
+
}
|
|
22
|
+
const branch = ctx.sessionManager.getBranch?.() ?? [];
|
|
23
|
+
for (let index = branch.length - 1; index >= 0; index -= 1) {
|
|
24
|
+
const entry = branch[index] as any;
|
|
25
|
+
if (entry?.type === "message" && entry.message?.role === "assistant") return textFromContent(entry.message.content);
|
|
26
|
+
}
|
|
27
|
+
return "";
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function needsDirection(text: string): boolean {
|
|
31
|
+
return /\?\s*$|\b(let me know|tell me|which (one|option)|choose|confirm|approve|should i|would you like|do you want|need your input|awaiting|next step)\b/i.test(text);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function criticalInfo(text: string): string | undefined {
|
|
35
|
+
const match = text.match(/\b(critical|urgent|warning|blocked|cannot proceed|security|vulnerab|secret|credential|rate limit|context (overflow|full)|manual action required)\b/i);
|
|
36
|
+
if (!match) return undefined;
|
|
37
|
+
const line = text.split(/\r?\n/).find((candidate) => candidate.toLowerCase().includes(match[0].toLowerCase())) ?? text;
|
|
38
|
+
return line.trim();
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function taskStats(state: any): { completed: number; remaining: number; total: number } | undefined {
|
|
42
|
+
const tasks = Array.isArray(state?.tasks) ? state.tasks : undefined;
|
|
43
|
+
if (!tasks) return undefined;
|
|
44
|
+
const total = tasks.length;
|
|
45
|
+
const completed = tasks.filter((task: any) => task?.status === "completed").length;
|
|
46
|
+
const remaining = tasks.filter((task: any) => task?.status === "pending" || task?.status === "in_progress").length;
|
|
47
|
+
return { completed, remaining, total };
|
|
48
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { truncateToWidth } from "@earendil-works/pi-tui";
|
|
2
|
+
import { readPiAgentsStatuslineBridge } from "./bridges.js";
|
|
3
|
+
|
|
4
|
+
type AgentAsciiColor = "red" | "green" | "yellow" | "blue" | "magenta" | "cyan";
|
|
5
|
+
|
|
6
|
+
const AGENT_ASCII_COLOR_SEQUENCE: AgentAsciiColor[] = ["magenta", "green", "blue", "cyan", "yellow", "red"];
|
|
7
|
+
const AGENT_ASCII_BG: Record<AgentAsciiColor, number> = {
|
|
8
|
+
red: 41,
|
|
9
|
+
green: 42,
|
|
10
|
+
yellow: 43,
|
|
11
|
+
blue: 44,
|
|
12
|
+
magenta: 45,
|
|
13
|
+
cyan: 46,
|
|
14
|
+
};
|
|
15
|
+
|
|
16
|
+
function normalizeAgentAsciiColor(value: string | undefined): AgentAsciiColor | undefined {
|
|
17
|
+
const normalized = value?.trim().toLowerCase().replace(/[^a-z]/g, "");
|
|
18
|
+
switch (normalized) {
|
|
19
|
+
case "red": return "red";
|
|
20
|
+
case "green": return "green";
|
|
21
|
+
case "yellow":
|
|
22
|
+
case "orange": return "yellow";
|
|
23
|
+
case "blue": return "blue";
|
|
24
|
+
case "magenta":
|
|
25
|
+
case "purple":
|
|
26
|
+
case "violet": return "magenta";
|
|
27
|
+
case "cyan":
|
|
28
|
+
case "teal": return "cyan";
|
|
29
|
+
default: return undefined;
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function fallbackAgentAsciiColor(name: string): AgentAsciiColor {
|
|
34
|
+
let hash = 0;
|
|
35
|
+
for (const char of name) hash = ((hash * 31) + char.charCodeAt(0)) >>> 0;
|
|
36
|
+
return AGENT_ASCII_COLOR_SEQUENCE[hash % AGENT_ASCII_COLOR_SEQUENCE.length] ?? "magenta";
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function ansiAgentBg(color: AgentAsciiColor, text: string): string {
|
|
40
|
+
return `\x1b[30;${AGENT_ASCII_BG[color]}m${text}\x1b[39;49m`;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export function subagentStatuslineMarker(cwd: string, maxInnerWidth = 24): { plain: string; styled: string } | undefined {
|
|
44
|
+
const bridgeInfo = readPiAgentsStatuslineBridge()?.getCurrentSubagent(cwd);
|
|
45
|
+
const envName = process.env.PI_SUBAGENT_CHILD_AGENT?.trim();
|
|
46
|
+
const rawName = bridgeInfo?.name?.trim() || envName;
|
|
47
|
+
if (!rawName) return undefined;
|
|
48
|
+
const color = normalizeAgentAsciiColor(bridgeInfo?.color) ?? normalizeAgentAsciiColor(process.env.PI_SUBAGENT_CHILD_COLOR) ?? fallbackAgentAsciiColor(rawName);
|
|
49
|
+
const inner = truncateToWidth(rawName, Math.max(1, maxInnerWidth), "…");
|
|
50
|
+
const plain = ` ${inner} `;
|
|
51
|
+
return { plain, styled: ansiAgentBg(color, plain) };
|
|
52
|
+
}
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
const ANSI_PATTERN = /\x1B(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~]|\][^\x07]*(?:\x07|\x1B\\))/g;
|
|
2
|
+
|
|
3
|
+
export const ANSI_ESCAPE_RE = /\x1b\[[0-?]*[ -/]*[@-~]/g;
|
|
4
|
+
export const ANSI_GREEN_FG = "\x1b[32m";
|
|
5
|
+
export const ANSI_RED_FG = "\x1b[31m";
|
|
6
|
+
export const ANSI_YELLOW_FG = "\x1b[33m";
|
|
7
|
+
export const ANSI_FG_RESET = "\x1b[39m";
|
|
8
|
+
|
|
9
|
+
export function stripAnsi(text: string): string {
|
|
10
|
+
return text.replace(ANSI_PATTERN, "");
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function ansiGreen(text: string): string {
|
|
14
|
+
return `${ANSI_GREEN_FG}${text}${ANSI_FG_RESET}`;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function ansiRed(text: string): string {
|
|
18
|
+
return `${ANSI_RED_FG}${text}${ANSI_FG_RESET}`;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ansiYellow(text: string): string {
|
|
22
|
+
return `${ANSI_YELLOW_FG}${text}${ANSI_FG_RESET}`;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function oneLine(text: string): string {
|
|
26
|
+
return text
|
|
27
|
+
.replace(ANSI_ESCAPE_RE, "")
|
|
28
|
+
.replace(/[\r\n\t]+/g, " ")
|
|
29
|
+
.replace(/[\x00-\x08\x0b\x0c\x0e-\x1f\x7f]/g, " ")
|
|
30
|
+
.replace(/\s+/g, " ")
|
|
31
|
+
.trim();
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface VisibleMap {
|
|
35
|
+
text: string;
|
|
36
|
+
rawIndexByVisibleIndex: number[];
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface VisibleReplacement {
|
|
40
|
+
start: number;
|
|
41
|
+
end: number;
|
|
42
|
+
text: string;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function ansiSequenceEnd(input: string, start: number): number {
|
|
46
|
+
const introducer = input[start + 1];
|
|
47
|
+
if (introducer == null) return start + 1;
|
|
48
|
+
|
|
49
|
+
// OSC, DCS, APC, PM, SOS strings are zero-width and end in ST (ESC \\).
|
|
50
|
+
// OSC may also end in BEL.
|
|
51
|
+
if (introducer === "]" || introducer === "P" || introducer === "_" || introducer === "^" || introducer === "X") {
|
|
52
|
+
const st = input.indexOf("\x1b\\", start + 2);
|
|
53
|
+
const bell = introducer === "]" ? input.indexOf("\x07", start + 2) : -1;
|
|
54
|
+
if (bell >= 0 && (st < 0 || bell < st)) return bell + 1;
|
|
55
|
+
return st >= 0 ? st + 2 : input.length;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (introducer === "[") {
|
|
59
|
+
let index = start + 2;
|
|
60
|
+
while (index < input.length) {
|
|
61
|
+
const code = input.charCodeAt(index);
|
|
62
|
+
if (code >= 0x40 && code <= 0x7e) return index + 1;
|
|
63
|
+
index += 1;
|
|
64
|
+
}
|
|
65
|
+
return input.length;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return Math.min(start + 2, input.length);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function buildVisibleMap(input: string): VisibleMap {
|
|
72
|
+
const rawIndexByVisibleIndex: number[] = [0];
|
|
73
|
+
let text = "";
|
|
74
|
+
let index = 0;
|
|
75
|
+
|
|
76
|
+
while (index < input.length) {
|
|
77
|
+
if (input.charCodeAt(index) === 0x1b) {
|
|
78
|
+
index = ansiSequenceEnd(input, index);
|
|
79
|
+
continue;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
if (rawIndexByVisibleIndex[text.length] === undefined) rawIndexByVisibleIndex[text.length] = index;
|
|
83
|
+
text += input[index] ?? "";
|
|
84
|
+
index += 1;
|
|
85
|
+
rawIndexByVisibleIndex[text.length] = index;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (rawIndexByVisibleIndex[text.length] === undefined) rawIndexByVisibleIndex[text.length] = index;
|
|
89
|
+
return { rawIndexByVisibleIndex, text };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
export function applyVisibleReplacements(input: string, map: VisibleMap, replacements: VisibleReplacement[]): string {
|
|
93
|
+
if (replacements.length === 0) return input;
|
|
94
|
+
|
|
95
|
+
const sorted = replacements
|
|
96
|
+
.filter((replacement) => replacement.end > replacement.start)
|
|
97
|
+
.sort((a, b) => a.start - b.start || b.end - a.end);
|
|
98
|
+
|
|
99
|
+
let output = "";
|
|
100
|
+
let lastRawIndex = 0;
|
|
101
|
+
let lastVisibleIndex = 0;
|
|
102
|
+
|
|
103
|
+
for (const replacement of sorted) {
|
|
104
|
+
if (replacement.start < lastVisibleIndex) continue;
|
|
105
|
+
const rawStart = map.rawIndexByVisibleIndex[replacement.start];
|
|
106
|
+
const rawEnd = map.rawIndexByVisibleIndex[replacement.end];
|
|
107
|
+
if (rawStart == null || rawEnd == null || rawStart < lastRawIndex) continue;
|
|
108
|
+
|
|
109
|
+
output += input.slice(lastRawIndex, rawStart) + replacement.text;
|
|
110
|
+
lastRawIndex = rawEnd;
|
|
111
|
+
lastVisibleIndex = replacement.end;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
return output + input.slice(lastRawIndex);
|
|
115
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import {
|
|
2
|
+
CAVEMAN_BRIDGE_SYMBOL,
|
|
3
|
+
PI_AGENTS_STATUSLINE_SYMBOL,
|
|
4
|
+
QUESTION_SERVICE_SYMBOL,
|
|
5
|
+
VSTACK_MODAL_LOCK_SYMBOL,
|
|
6
|
+
} from "./constants.js";
|
|
7
|
+
|
|
8
|
+
export interface CavemanBridge {
|
|
9
|
+
isActive(): boolean;
|
|
10
|
+
getMode(): string;
|
|
11
|
+
getConfiguredMode?(cwd?: string): string;
|
|
12
|
+
getLastActiveMode(): string;
|
|
13
|
+
hasSessionOverride?(): boolean;
|
|
14
|
+
isStatusBadgeEnabled?(cwd?: string): boolean;
|
|
15
|
+
cycleMode?(cwd?: string): string;
|
|
16
|
+
setMode?(mode: string, cwd?: string): string | undefined;
|
|
17
|
+
subscribe(listener: () => void): () => void;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PiAgentsStatuslineBridge {
|
|
21
|
+
getCurrentSubagent(cwd?: string): { name: string; color?: string } | undefined;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface QuestionRequestLike {
|
|
25
|
+
header?: string;
|
|
26
|
+
question?: string;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface QuestionOpenedEventLike {
|
|
30
|
+
requestId?: string;
|
|
31
|
+
request?: QuestionRequestLike;
|
|
32
|
+
source?: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export interface QuestionServiceLike {
|
|
36
|
+
listPending(): unknown[];
|
|
37
|
+
subscribe(listener: (event: any) => void): () => void;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
interface VstackModalLock {
|
|
41
|
+
depth: number;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export function readCavemanBridge(): CavemanBridge | undefined {
|
|
45
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
46
|
+
const value = host[CAVEMAN_BRIDGE_SYMBOL];
|
|
47
|
+
return value && typeof value === "object" ? (value as CavemanBridge) : undefined;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export function readPiAgentsStatuslineBridge(): PiAgentsStatuslineBridge | undefined {
|
|
51
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
52
|
+
const value = host[PI_AGENTS_STATUSLINE_SYMBOL];
|
|
53
|
+
return value && typeof value === "object" && typeof (value as PiAgentsStatuslineBridge).getCurrentSubagent === "function"
|
|
54
|
+
? (value as PiAgentsStatuslineBridge)
|
|
55
|
+
: undefined;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
export function getQuestionService(): QuestionServiceLike | undefined {
|
|
59
|
+
const service = (globalThis as unknown as Record<PropertyKey, unknown>)[QUESTION_SERVICE_SYMBOL];
|
|
60
|
+
if (!service || typeof service !== "object") return undefined;
|
|
61
|
+
const candidate = service as Partial<QuestionServiceLike>;
|
|
62
|
+
if (typeof candidate.subscribe === "function" && typeof candidate.listPending === "function") return candidate as QuestionServiceLike;
|
|
63
|
+
return undefined;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
export function acquireVstackModalLock(): () => void {
|
|
67
|
+
const host = globalThis as unknown as Record<PropertyKey, unknown>;
|
|
68
|
+
const existing = host[VSTACK_MODAL_LOCK_SYMBOL] as VstackModalLock | undefined;
|
|
69
|
+
const lock = existing && typeof existing.depth === "number" ? existing : { depth: 0 };
|
|
70
|
+
host[VSTACK_MODAL_LOCK_SYMBOL] = lock;
|
|
71
|
+
lock.depth += 1;
|
|
72
|
+
let released = false;
|
|
73
|
+
return () => {
|
|
74
|
+
if (released) return;
|
|
75
|
+
released = true;
|
|
76
|
+
lock.depth = Math.max(0, lock.depth - 1);
|
|
77
|
+
};
|
|
78
|
+
}
|
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
import type { AgentMessage } from "@earendil-works/pi-agent-core";
|
|
2
|
+
import { complete, type Message } from "@earendil-works/pi-ai";
|
|
3
|
+
import { convertToLlm, serializeConversation, type ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
4
|
+
import {
|
|
5
|
+
DEFAULT_COMPACTION_MAX_TOKENS,
|
|
6
|
+
DEFAULT_COMPACTION_MODEL,
|
|
7
|
+
DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS,
|
|
8
|
+
QOL_COMPACTION_SYSTEM_PROMPT,
|
|
9
|
+
} from "./constants.js";
|
|
10
|
+
import { settingBoolean, settingNumber, settingString } from "./settings.js";
|
|
11
|
+
import { stringifyError } from "./util.js";
|
|
12
|
+
|
|
13
|
+
export type QolSummaryProfile = "concise" | "balanced" | "exhaustive";
|
|
14
|
+
export type QolSummaryPurpose = "compaction" | "branch-summary" | "session-search";
|
|
15
|
+
|
|
16
|
+
export function compactionNotify(ctx: ExtensionContext, message: string, level: "info" | "warning" | "error" = "info"): void {
|
|
17
|
+
if (ctx.hasUI && settingBoolean("compaction.notify", true, ctx.cwd)) ctx.ui.notify(message, level);
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function compactionProfile(cwd: string): QolSummaryProfile {
|
|
21
|
+
const value = settingString("compaction.profile", "balanced", cwd);
|
|
22
|
+
return value === "concise" || value === "exhaustive" ? value : "balanced";
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function compactionProfileInstructions(profile: QolSummaryProfile): string {
|
|
26
|
+
if (profile === "concise") return "Prefer a compact continuation summary. Include only decisions, current state, modified/read files, blockers, and concrete next steps.";
|
|
27
|
+
if (profile === "exhaustive") return "Be thorough. The summary may replace substantial conversation history, so preserve all relevant implementation details, alternatives considered, exact file paths, commands, errors, and pending work.";
|
|
28
|
+
return "Be complete but not verbose. Preserve enough detail for a future assistant to continue without the old transcript.";
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function stripThinkingForSummary(messages: Message[]): Message[] {
|
|
32
|
+
return messages.map((message) => {
|
|
33
|
+
if (message.role !== "assistant" || !Array.isArray(message.content)) return message;
|
|
34
|
+
return {
|
|
35
|
+
...message,
|
|
36
|
+
content: message.content.filter((part: any) => part?.type !== "thinking"),
|
|
37
|
+
};
|
|
38
|
+
});
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function serializeMessagesForSummary(messages: AgentMessage[]): string {
|
|
42
|
+
return serializeConversation(stripThinkingForSummary(convertToLlm(messages)));
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function customMessageContentToText(content: unknown): string {
|
|
46
|
+
if (typeof content === "string") return content;
|
|
47
|
+
if (!Array.isArray(content)) return "";
|
|
48
|
+
const parts: string[] = [];
|
|
49
|
+
for (const part of content) {
|
|
50
|
+
if (part?.type === "text" && typeof part.text === "string") parts.push(part.text);
|
|
51
|
+
else if (part?.type === "image") parts.push(`[image${typeof part.mimeType === "string" ? ` ${part.mimeType}` : ""}]`);
|
|
52
|
+
else if (part?.type) parts.push(`[${String(part.type)}]`);
|
|
53
|
+
}
|
|
54
|
+
return parts.join("\n").trim();
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function buildSummaryPrompt(options: {
|
|
58
|
+
conversationText: string;
|
|
59
|
+
customInstructions?: string;
|
|
60
|
+
previousSummary?: string;
|
|
61
|
+
profile: QolSummaryProfile;
|
|
62
|
+
purpose: QolSummaryPurpose;
|
|
63
|
+
}): string {
|
|
64
|
+
const purposeText = options.purpose === "branch-summary"
|
|
65
|
+
? "the branch being left during /tree navigation"
|
|
66
|
+
: options.purpose === "session-search"
|
|
67
|
+
? "the previous session being imported into the current context"
|
|
68
|
+
: "the conversation span being compacted";
|
|
69
|
+
const previous = options.previousSummary ? `<previous-summary>\n${options.previousSummary}\n</previous-summary>\n\n` : "";
|
|
70
|
+
const custom = options.customInstructions?.trim() ? `<custom-instructions>\n${options.customInstructions.trim()}\n</custom-instructions>\n\n` : "";
|
|
71
|
+
return `${custom}${previous}<conversation>\n${options.conversationText}\n</conversation>\n\nSummarize ${purposeText} for a coding agent that must continue the work.\n\n${compactionProfileInstructions(options.profile)}\n\nUse this markdown shape:\n\n## Goal\n[What the user is trying to accomplish]\n\n## Constraints & Preferences\n- [Requirements, style, safety, or user preferences]\n\n## Progress\n### Done\n- [x] [Completed work]\n\n### In Progress\n- [ ] [Current partial work]\n\n### Blocked\n- [Blockers or none]\n\n## Key Decisions\n- **[Decision]**: [Rationale]\n\n## Files & Commands\n- [Files read/modified and important commands/results]\n\n## Next Steps\n1. [Most important next action]\n\n## Critical Context\n- [Anything easy to lose but needed later]`;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
async function summarizeWithRemote(endpoint: string, systemPrompt: string, promptText: string, maxTokens: number, signal?: AbortSignal): Promise<string> {
|
|
75
|
+
const response = await fetch(endpoint, {
|
|
76
|
+
body: JSON.stringify({ maxTokens, prompt: promptText, systemPrompt }),
|
|
77
|
+
headers: { "content-type": "application/json" },
|
|
78
|
+
method: "POST",
|
|
79
|
+
signal,
|
|
80
|
+
});
|
|
81
|
+
const text = await response.text();
|
|
82
|
+
if (!response.ok) throw new Error(`Remote compaction endpoint returned ${response.status}: ${text.slice(0, 500)}`);
|
|
83
|
+
let parsed: unknown;
|
|
84
|
+
try {
|
|
85
|
+
parsed = JSON.parse(text);
|
|
86
|
+
} catch {
|
|
87
|
+
throw new Error("Remote compaction endpoint did not return JSON");
|
|
88
|
+
}
|
|
89
|
+
if (parsed && typeof parsed === "object") {
|
|
90
|
+
const record = parsed as Record<string, unknown>;
|
|
91
|
+
if (typeof record.summary === "string") return record.summary;
|
|
92
|
+
if (typeof record.text === "string") return record.text;
|
|
93
|
+
}
|
|
94
|
+
throw new Error("Remote compaction response missing summary");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function resolveConfiguredModel(ctx: ExtensionContext, configured: string): any | undefined {
|
|
98
|
+
if (!configured || configured === "current") return ctx.model;
|
|
99
|
+
const withoutThinking = configured.replace(/:(off|minimal|low|medium|high|xhigh)$/i, "");
|
|
100
|
+
const slash = withoutThinking.indexOf("/");
|
|
101
|
+
if (slash > 0) return ctx.modelRegistry.find(withoutThinking.slice(0, slash), withoutThinking.slice(slash + 1));
|
|
102
|
+
const providers = [ctx.model?.provider, "google", "openai", "anthropic", "mistral", "moonshot", "cloudflare-ai-gateway", "cloudflare-workers-ai"].filter((value): value is string => typeof value === "string");
|
|
103
|
+
for (const provider of providers) {
|
|
104
|
+
const model = ctx.modelRegistry.find(provider, withoutThinking);
|
|
105
|
+
if (model) return model;
|
|
106
|
+
}
|
|
107
|
+
return undefined;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
export function modelLabel(model: any): string {
|
|
111
|
+
return model ? `${model.provider}/${model.id}` : "unknown model";
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
export async function generateQolSummary(ctx: ExtensionContext, options: {
|
|
115
|
+
conversationText: string;
|
|
116
|
+
customInstructions?: string;
|
|
117
|
+
previousSummary?: string;
|
|
118
|
+
maxTokens?: number;
|
|
119
|
+
model?: string;
|
|
120
|
+
purpose: QolSummaryPurpose;
|
|
121
|
+
signal?: AbortSignal;
|
|
122
|
+
}): Promise<{ model: string; summary: string; via: "model" | "remote" }> {
|
|
123
|
+
const maxTokens = Math.max(256, Math.floor(options.maxTokens ?? settingNumber("compaction.maxTokens", DEFAULT_COMPACTION_MAX_TOKENS, ctx.cwd)));
|
|
124
|
+
const promptText = buildSummaryPrompt({
|
|
125
|
+
conversationText: options.conversationText,
|
|
126
|
+
customInstructions: options.customInstructions,
|
|
127
|
+
previousSummary: settingBoolean("compaction.includePreviousSummary", true, ctx.cwd) ? options.previousSummary : undefined,
|
|
128
|
+
profile: compactionProfile(ctx.cwd),
|
|
129
|
+
purpose: options.purpose,
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const remoteEndpoint = settingString("compaction.remoteEndpoint", "", ctx.cwd);
|
|
133
|
+
if (settingBoolean("compaction.remoteEnabled", false, ctx.cwd) && remoteEndpoint) {
|
|
134
|
+
try {
|
|
135
|
+
const summary = await summarizeWithRemote(remoteEndpoint, QOL_COMPACTION_SYSTEM_PROMPT, promptText, maxTokens, options.signal);
|
|
136
|
+
return { model: remoteEndpoint, summary, via: "remote" };
|
|
137
|
+
} catch (error) {
|
|
138
|
+
compactionNotify(ctx, `Remote compaction failed, trying model fallback: ${stringifyError(error)}`, "warning");
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const configuredModel = options.model ?? settingString("compaction.model", DEFAULT_COMPACTION_MODEL, ctx.cwd);
|
|
143
|
+
const model = resolveConfiguredModel(ctx, configuredModel);
|
|
144
|
+
if (!model) throw new Error(`Summary model not found: ${configuredModel}`);
|
|
145
|
+
const auth = await ctx.modelRegistry.getApiKeyAndHeaders(model);
|
|
146
|
+
if (!auth.ok) throw new Error(auth.error);
|
|
147
|
+
if (!auth.apiKey) throw new Error(`No API key for ${model.provider}`);
|
|
148
|
+
|
|
149
|
+
const message: Message = {
|
|
150
|
+
content: [{ text: promptText, type: "text" }],
|
|
151
|
+
role: "user",
|
|
152
|
+
timestamp: Date.now(),
|
|
153
|
+
};
|
|
154
|
+
const response = await complete(
|
|
155
|
+
model,
|
|
156
|
+
{ messages: [message], systemPrompt: QOL_COMPACTION_SYSTEM_PROMPT },
|
|
157
|
+
{ apiKey: auth.apiKey, headers: auth.headers, maxTokens, signal: options.signal },
|
|
158
|
+
);
|
|
159
|
+
const summary = response.content
|
|
160
|
+
.filter((content): content is { type: "text"; text: string } => content.type === "text")
|
|
161
|
+
.map((content) => content.text)
|
|
162
|
+
.join("\n")
|
|
163
|
+
.trim();
|
|
164
|
+
return { model: modelLabel(model), summary, via: "model" };
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export async function handleQolCompaction(event: any, ctx: ExtensionContext): Promise<any> {
|
|
168
|
+
if (!settingBoolean("compaction.customEnabled", false, ctx.cwd)) return undefined;
|
|
169
|
+
const preparation = event.preparation ?? {};
|
|
170
|
+
const messages = [...(preparation.messagesToSummarize ?? []), ...(preparation.turnPrefixMessages ?? [])];
|
|
171
|
+
if (messages.length === 0) return undefined;
|
|
172
|
+
const tokensBefore = typeof preparation.tokensBefore === "number" ? preparation.tokensBefore : 0;
|
|
173
|
+
compactionNotify(ctx, `QOL compaction: summarizing ${messages.length} message(s), ${tokensBefore.toLocaleString()} token(s).`, "info");
|
|
174
|
+
try {
|
|
175
|
+
const conversationText = serializeMessagesForSummary(messages);
|
|
176
|
+
const result = await generateQolSummary(ctx, {
|
|
177
|
+
conversationText,
|
|
178
|
+
customInstructions: event.customInstructions,
|
|
179
|
+
previousSummary: preparation.previousSummary,
|
|
180
|
+
purpose: "compaction",
|
|
181
|
+
signal: event.signal,
|
|
182
|
+
});
|
|
183
|
+
if (!result.summary.trim()) throw new Error("Compaction summary was empty");
|
|
184
|
+
compactionNotify(ctx, `QOL compaction complete via ${result.via}: ${result.model}`, "info");
|
|
185
|
+
return {
|
|
186
|
+
compaction: {
|
|
187
|
+
details: {
|
|
188
|
+
messageCount: messages.length,
|
|
189
|
+
model: result.model,
|
|
190
|
+
profile: compactionProfile(ctx.cwd),
|
|
191
|
+
source: "pi-qol",
|
|
192
|
+
via: result.via,
|
|
193
|
+
},
|
|
194
|
+
firstKeptEntryId: preparation.firstKeptEntryId,
|
|
195
|
+
summary: result.summary,
|
|
196
|
+
tokensBefore: preparation.tokensBefore,
|
|
197
|
+
},
|
|
198
|
+
};
|
|
199
|
+
} catch (error) {
|
|
200
|
+
if (event.signal?.aborted) return undefined;
|
|
201
|
+
compactionNotify(ctx, `QOL compaction failed: ${stringifyError(error)}`, "error");
|
|
202
|
+
return settingBoolean("compaction.fallbackToDefault", true, ctx.cwd) ? undefined : { cancel: true };
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function summarizeEntryForBranch(entry: any): string[] {
|
|
207
|
+
if (entry?.type === "message" && entry.message) return [serializeMessagesForSummary([entry.message])];
|
|
208
|
+
if (entry?.type === "compaction" && typeof entry.summary === "string") return [`[Compaction summary]: ${entry.summary}`];
|
|
209
|
+
if (entry?.type === "branch_summary" && typeof entry.summary === "string") return [`[Branch summary]: ${entry.summary}`];
|
|
210
|
+
if (entry?.type === "custom_message") return [`[Custom message${entry.customType ? `:${entry.customType}` : ""}]: ${customMessageContentToText(entry.content) || "[empty]"}`];
|
|
211
|
+
return [];
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
export async function handleQolBranchSummary(event: any, ctx: ExtensionContext): Promise<any> {
|
|
215
|
+
if (!settingBoolean("compaction.branchSummaryEnabled", false, ctx.cwd)) return undefined;
|
|
216
|
+
const preparation = event.preparation ?? {};
|
|
217
|
+
if (preparation.userWantsSummary !== true) return undefined;
|
|
218
|
+
const entries = Array.isArray(preparation.entriesToSummarize) ? preparation.entriesToSummarize : [];
|
|
219
|
+
const conversationText = entries.flatMap(summarizeEntryForBranch).join("\n\n").trim();
|
|
220
|
+
if (!conversationText) return undefined;
|
|
221
|
+
compactionNotify(ctx, `QOL branch summary: summarizing ${entries.length} entr${entries.length === 1 ? "y" : "ies"}.`, "info");
|
|
222
|
+
try {
|
|
223
|
+
const result = await generateQolSummary(ctx, {
|
|
224
|
+
conversationText,
|
|
225
|
+
customInstructions: event.customInstructions ?? preparation.customInstructions,
|
|
226
|
+
purpose: "branch-summary",
|
|
227
|
+
signal: event.signal,
|
|
228
|
+
});
|
|
229
|
+
if (!result.summary.trim()) throw new Error("Branch summary was empty");
|
|
230
|
+
return {
|
|
231
|
+
summary: {
|
|
232
|
+
details: { entryCount: entries.length, model: result.model, profile: compactionProfile(ctx.cwd), source: "pi-qol", via: result.via },
|
|
233
|
+
summary: result.summary,
|
|
234
|
+
},
|
|
235
|
+
};
|
|
236
|
+
} catch (error) {
|
|
237
|
+
if (event.signal?.aborted) return undefined;
|
|
238
|
+
compactionNotify(ctx, `QOL branch summary failed: ${stringifyError(error)}`, "error");
|
|
239
|
+
return undefined;
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
function contextUsage(ctx: ExtensionContext): { contextWindow?: number; tokens: number } | undefined {
|
|
244
|
+
const usage = ctx.getContextUsage?.() as { tokens?: unknown; contextWindow?: unknown } | undefined;
|
|
245
|
+
const tokens = Number(usage?.tokens);
|
|
246
|
+
if (!Number.isFinite(tokens) || tokens <= 0) return undefined;
|
|
247
|
+
const contextWindow = Number(usage?.contextWindow ?? ctx.model?.contextWindow);
|
|
248
|
+
return { contextWindow: Number.isFinite(contextWindow) && contextWindow > 0 ? contextWindow : undefined, tokens };
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
export function compactionTriggerReason(ctx: ExtensionContext): string | undefined {
|
|
252
|
+
const usage = contextUsage(ctx);
|
|
253
|
+
if (!usage) return undefined;
|
|
254
|
+
const tokenLimit = settingNumber("compaction.thresholdTokens", -1, ctx.cwd);
|
|
255
|
+
if (tokenLimit > 0 && usage.tokens >= tokenLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(tokenLimit).toLocaleString()} token limit`;
|
|
256
|
+
const percentLimit = settingNumber("compaction.thresholdPercent", -1, ctx.cwd);
|
|
257
|
+
if (percentLimit > 0 && usage.contextWindow) {
|
|
258
|
+
const percent = (usage.tokens / usage.contextWindow) * 100;
|
|
259
|
+
if (percent >= percentLimit) return `${percent.toFixed(1)}% context >= ${percentLimit}% limit`;
|
|
260
|
+
}
|
|
261
|
+
const idleLimit = settingNumber("compaction.idleThresholdTokens", DEFAULT_IDLE_COMPACTION_THRESHOLD_TOKENS, ctx.cwd);
|
|
262
|
+
if (usage.tokens >= idleLimit) return `${usage.tokens.toLocaleString()} tokens >= ${Math.floor(idleLimit).toLocaleString()} idle threshold`;
|
|
263
|
+
return undefined;
|
|
264
|
+
}
|