pi-smart-compact 7.5.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/CHANGELOG.md +72 -0
- package/LICENSE +21 -0
- package/README.md +200 -0
- package/package.json +42 -0
- package/src/constants.ts +140 -0
- package/src/core.ts +360 -0
- package/src/index.ts +175 -0
- package/src/phases/explore.ts +371 -0
- package/src/phases/synthesize.ts +184 -0
- package/src/phases/verify.ts +191 -0
- package/src/types.ts +176 -0
- package/src/ui/overlays.ts +329 -0
- package/src/utils/cache.ts +145 -0
- package/src/utils/damage.ts +153 -0
- package/src/utils/extraction.ts +259 -0
- package/src/utils/fingerprint.ts +190 -0
- package/src/utils/helpers.ts +161 -0
- package/src/utils/message-blocks.ts +21 -0
- package/src/utils/pruning.ts +147 -0
- package/src/utils/tokens.ts +63 -0
|
@@ -0,0 +1,329 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TUI overlays: model/profile selection, progress, result screen.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import { DynamicBorder } from "@earendil-works/pi-coding-agent";
|
|
7
|
+
import { Container, type SelectItem, SelectList, Text } from "@earendil-works/pi-tui";
|
|
8
|
+
import type { Model, Api } from "@earendil-works/pi-ai";
|
|
9
|
+
import type {
|
|
10
|
+
CompressionProfile, ModelOption, ProgressState,
|
|
11
|
+
SmartCompactDetails, StructuredExtraction,
|
|
12
|
+
} from "../types.ts";
|
|
13
|
+
import { getMetricsSummary } from "../utils/cache.ts";
|
|
14
|
+
import { getProviderCaps } from "../utils/tokens.ts";
|
|
15
|
+
import { trackedComplete, cacheOpts } from "../utils/cache.ts";
|
|
16
|
+
import path from "node:path";
|
|
17
|
+
|
|
18
|
+
export function renderContextBar(theme: any, pct: number, tokens: number, barLen = 24): string {
|
|
19
|
+
const clamped = Math.min(Math.max(pct, 0), 100);
|
|
20
|
+
const filled = Math.min(barLen, Math.round((clamped / 100) * barLen));
|
|
21
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
|
|
22
|
+
const color = clamped > 80 ? "error" : clamped > 50 ? "warning" : "success";
|
|
23
|
+
return theme.fg("text", " Context: ") + theme.fg(color, bar) + theme.fg("text", " " + clamped + "%") + theme.fg("dim", " (" + (tokens ?? 0).toLocaleString() + "t)");
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function renderTokenBar(theme: any, before: number, after: number, label: string, barLen = 30): string {
|
|
27
|
+
const ratio = before > 0 ? after / before : 0;
|
|
28
|
+
const savedPct = Math.round((1 - ratio) * 100);
|
|
29
|
+
const filled = Math.min(barLen, Math.round(ratio * barLen));
|
|
30
|
+
const bar = "\u2588".repeat(filled) + "\u2591".repeat(barLen - filled);
|
|
31
|
+
const savedColor = savedPct >= 50 ? "success" : savedPct >= 25 ? "warning" : "error";
|
|
32
|
+
return theme.fg("text", " " + label + ": ") + theme.fg(savedColor, bar) + theme.fg("text", " " + (after ?? 0).toLocaleString() + "t") + theme.fg(savedColor, " (saved " + savedPct + "%)");
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const _toolSupportCache = new Map<string, boolean>();
|
|
36
|
+
|
|
37
|
+
export async function probeToolSupport(model: Model<Api>, auth: { apiKey: string; headers?: Record<string, string> }): Promise<boolean> {
|
|
38
|
+
const key = model.provider + "/" + model.id;
|
|
39
|
+
if (_toolSupportCache.has(key)) return _toolSupportCache.get(key)!;
|
|
40
|
+
const caps = getProviderCaps(model.provider);
|
|
41
|
+
if (caps.supportsTools === true) { _toolSupportCache.set(key, true); return true; }
|
|
42
|
+
try {
|
|
43
|
+
await trackedComplete("probe", model, {
|
|
44
|
+
messages: [{ role: "user", content: [{ type: "text", text: "Reply with exactly: ok" }] }],
|
|
45
|
+
tools: [{ name: "test_tool", description: "test", parameters: { type: "object", properties: {} } }],
|
|
46
|
+
}, cacheOpts({ apiKey: auth.apiKey, headers: auth.headers, maxTokens: 10, cacheRetention: "none" }));
|
|
47
|
+
_toolSupportCache.set(key, true);
|
|
48
|
+
return true;
|
|
49
|
+
} catch {
|
|
50
|
+
_toolSupportCache.set(key, false);
|
|
51
|
+
return false;
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export async function selectModel(
|
|
56
|
+
ctx: ExtensionCommandContext,
|
|
57
|
+
opts: { contextTokens: number; contextPercent: number; currentModel: string; defaultModelIndex: number },
|
|
58
|
+
): Promise<ModelOption | null> {
|
|
59
|
+
const available = ctx.modelRegistry.getAvailable();
|
|
60
|
+
const options: ModelOption[] = available.map(m => ({
|
|
61
|
+
value: m.provider + "/" + m.id,
|
|
62
|
+
label: m.provider + "/" + m.id + (m.contextWindow >= 200000 ? " (" + Math.round(m.contextWindow / 1000) + "K)" : ""),
|
|
63
|
+
model: m,
|
|
64
|
+
supportsTools: true,
|
|
65
|
+
}));
|
|
66
|
+
const items: SelectItem[] = options.map((o, i) => ({
|
|
67
|
+
value: "model:" + i,
|
|
68
|
+
label: o.label,
|
|
69
|
+
description: i === opts.defaultModelIndex ? "\u2190 session model" : undefined,
|
|
70
|
+
}));
|
|
71
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
72
|
+
const c = new Container();
|
|
73
|
+
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
74
|
+
c.addChild(new Text(theme.fg("accent", theme.bold(" \uD83D\uDD0D Smart Compact \u2014 Step 1/2")), 1, 0));
|
|
75
|
+
c.addChild(new Text(theme.fg("dim", " Architecture: EESV (Extract \u2192 Explore \u2192 Synthesize \u2192 Verify)"), 0, 0));
|
|
76
|
+
c.addChild(new Text("", 0, 0));
|
|
77
|
+
c.addChild(new Text(renderContextBar(theme, opts.contextPercent, opts.contextTokens), 0, 0));
|
|
78
|
+
c.addChild(new Text(theme.fg("dim", " Session: " + opts.currentModel), 0, 0));
|
|
79
|
+
c.addChild(new Text("", 0, 0));
|
|
80
|
+
c.addChild(new Text(theme.fg("text", " Select model for compaction:"), 1, 0));
|
|
81
|
+
c.addChild(new Text("", 0, 0));
|
|
82
|
+
const sel = new SelectList(items, Math.min(items.length, 12), {
|
|
83
|
+
selectedPrefix: t => theme.fg("accent", t),
|
|
84
|
+
selectedText: t => theme.fg("accent", t),
|
|
85
|
+
description: t => theme.fg("muted", t),
|
|
86
|
+
scrollInfo: t => theme.fg("dim", t),
|
|
87
|
+
noMatch: t => theme.fg("warning", t),
|
|
88
|
+
});
|
|
89
|
+
sel.selectedIndex = opts.defaultModelIndex;
|
|
90
|
+
sel.onSelect = item => done(item.value);
|
|
91
|
+
sel.onCancel = () => done(null);
|
|
92
|
+
c.addChild(sel);
|
|
93
|
+
c.addChild(new Text("", 0, 0));
|
|
94
|
+
c.addChild(new Text(theme.fg("dim", " \u2191\u2193 navigate \u2022 enter select \u2022 esc cancel"), 0, 0));
|
|
95
|
+
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
96
|
+
return {
|
|
97
|
+
render: (w: number) => c.render(w),
|
|
98
|
+
invalidate: () => c.invalidate(),
|
|
99
|
+
handleInput: (d: string) => { sel.handleInput(d); tui.requestRender(); },
|
|
100
|
+
};
|
|
101
|
+
});
|
|
102
|
+
if (!result?.startsWith("model:")) return null;
|
|
103
|
+
return options[parseInt(result.slice(6), 10)] ?? null;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function selectProfile(
|
|
107
|
+
ctx: ExtensionCommandContext,
|
|
108
|
+
selectedModel: ModelOption,
|
|
109
|
+
opts: { contextTokens: number; contextPercent: number },
|
|
110
|
+
): Promise<CompressionProfile | null> {
|
|
111
|
+
const estAfter = (budget: number, keep: number) => budget + Math.min(opts.contextTokens, keep);
|
|
112
|
+
const profiles: { value: CompressionProfile; label: string; desc: string; budget: number; keep: number }[] = [
|
|
113
|
+
{ value: "light", label: "\u2601\uFE0F Light", desc: "Max detail", budget: 10000, keep: 30000 },
|
|
114
|
+
{ value: "balanced", label: "\u2696\uFE0F Balanced", desc: "Recommended", budget: 6000, keep: 20000 },
|
|
115
|
+
{ value: "aggressive", label: "\uD83D\uDD25 Aggressive", desc: "Minimal", budget: 3000, keep: 10000 },
|
|
116
|
+
];
|
|
117
|
+
const items: SelectItem[] = profiles.map(p => {
|
|
118
|
+
const after = estAfter(p.budget, p.keep);
|
|
119
|
+
const pct = opts.contextTokens > 0 ? Math.round((1 - after / opts.contextTokens) * 100) : 0;
|
|
120
|
+
return { value: p.value, label: p.label, description: p.desc + " \u2014 est. ~" + after.toLocaleString() + "t after (save ~" + pct + "%)" };
|
|
121
|
+
});
|
|
122
|
+
const result = await ctx.ui.custom<string | null>((tui, theme, _kb, done) => {
|
|
123
|
+
const c = new Container();
|
|
124
|
+
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
125
|
+
c.addChild(new Text(theme.fg("accent", theme.bold(" \uD83D\uDD0D Smart Compact \u2014 Step 2/2")), 1, 0));
|
|
126
|
+
c.addChild(new Text("", 0, 0));
|
|
127
|
+
c.addChild(new Text(theme.fg("dim", " Model: " + selectedModel.label), 0, 0));
|
|
128
|
+
c.addChild(new Text(renderContextBar(theme, opts.contextPercent, opts.contextTokens), 0, 0));
|
|
129
|
+
c.addChild(new Text("", 0, 0));
|
|
130
|
+
c.addChild(new Text(theme.fg("text", " Select compression profile:"), 1, 0));
|
|
131
|
+
c.addChild(new Text("", 0, 0));
|
|
132
|
+
const sel = new SelectList(items, 3, {
|
|
133
|
+
selectedPrefix: t => theme.fg("accent", t),
|
|
134
|
+
selectedText: t => theme.fg("accent", t),
|
|
135
|
+
description: t => theme.fg("muted", t),
|
|
136
|
+
scrollInfo: t => theme.fg("dim", t),
|
|
137
|
+
noMatch: t => theme.fg("warning", t),
|
|
138
|
+
});
|
|
139
|
+
sel.selectedIndex = 1;
|
|
140
|
+
sel.onSelect = item => done(item.value);
|
|
141
|
+
sel.onCancel = () => done(null);
|
|
142
|
+
c.addChild(sel);
|
|
143
|
+
c.addChild(new Text("", 0, 0));
|
|
144
|
+
c.addChild(new Text(theme.fg("dim", " \u2191\u2193 navigate \u2022 enter select \u2022 esc cancel"), 0, 0));
|
|
145
|
+
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
146
|
+
return {
|
|
147
|
+
render: (w: number) => c.render(w),
|
|
148
|
+
invalidate: () => c.invalidate(),
|
|
149
|
+
handleInput: (d: string) => { sel.handleInput(d); tui.requestRender(); },
|
|
150
|
+
};
|
|
151
|
+
});
|
|
152
|
+
if (!result) return null;
|
|
153
|
+
return profiles.find(p => p.value === result)?.value ?? null;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
export function showProgressOverlay(ctx: ExtensionCommandContext, state: ProgressState): void {
|
|
157
|
+
const phaseNames = ["Extract", "Explore", "Synthesize", "Verify"];
|
|
158
|
+
const progress = Math.round((state.phase / 4) * 100);
|
|
159
|
+
const name = phaseNames[state.phase - 1] ?? "?";
|
|
160
|
+
const detail = state.detail ? " (" + state.detail + ")" : "";
|
|
161
|
+
const type: "info" | "success" = state.phase >= 4 ? "success" : "info";
|
|
162
|
+
ctx.ui.notify("EESV [" + progress + "%] Phase " + state.phase + "/4: " + name + detail, type);
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export async function showResultScreen(
|
|
166
|
+
ctx: ExtensionCommandContext,
|
|
167
|
+
details: SmartCompactDetails,
|
|
168
|
+
extraction: StructuredExtraction,
|
|
169
|
+
): Promise<void> {
|
|
170
|
+
await ctx.ui.custom<void>((tui, theme, _kb, done) => {
|
|
171
|
+
const c = new Container();
|
|
172
|
+
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
173
|
+
c.addChild(new Text(theme.fg("accent", theme.bold(" \u2705 Smart Compact Complete")), 1, 0));
|
|
174
|
+
c.addChild(new Text("", 0, 0));
|
|
175
|
+
|
|
176
|
+
const estimatedAfter = (details.tokensBefore ?? 0) - (details.tokensSaved ?? 0);
|
|
177
|
+
c.addChild(new Text(renderTokenBar(theme, details.tokensBefore, estimatedAfter, "Result "), 0, 0));
|
|
178
|
+
c.addChild(new Text(theme.fg("dim", " Before: " + (details.tokensBefore ?? 0).toLocaleString() + "t \u2192 After: ~" + estimatedAfter.toLocaleString() + "t \u2192 Saved: " + (details.tokensSaved ?? 0).toLocaleString() + "t"), 0, 0));
|
|
179
|
+
c.addChild(new Text("", 0, 0));
|
|
180
|
+
|
|
181
|
+
const methodColors: Record<string, string> = { eesv: "accent", "single-pass": "success", heuristic: "warning" };
|
|
182
|
+
const methodColor = methodColors[details.method] ?? "text";
|
|
183
|
+
c.addChild(new Text(
|
|
184
|
+
theme.fg("text", " Method: ") +
|
|
185
|
+
theme.fg(methodColor, details.method.toUpperCase()) +
|
|
186
|
+
theme.fg("dim", " \u2022 " + details.llmCalls + " LLM call(s) \u2022 Profile: " + details.profile),
|
|
187
|
+
0, 0));
|
|
188
|
+
if (details.model) {
|
|
189
|
+
c.addChild(new Text(theme.fg("dim", " Model: " + details.model), 0, 0));
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
const scoreColor = details.qualityScore >= 80 ? "success" : details.qualityScore >= 50 ? "warning" : "error";
|
|
193
|
+
c.addChild(new Text(theme.fg("text", " Quality: ") + theme.fg(scoreColor, details.qualityScore + "/100"), 0, 0));
|
|
194
|
+
c.addChild(new Text("", 0, 0));
|
|
195
|
+
|
|
196
|
+
c.addChild(new Text(theme.fg("text", theme.bold(" \uD83D\uDCCB Extraction")), 0, 0));
|
|
197
|
+
const ms = getMetricsSummary();
|
|
198
|
+
if (ms.totalCalls > 0) {
|
|
199
|
+
const cachePct = Math.round(ms.cacheHitRate * 100);
|
|
200
|
+
const cacheColor = cachePct >= 50 ? "success" : cachePct >= 20 ? "warning" : "dim";
|
|
201
|
+
c.addChild(new Text(
|
|
202
|
+
theme.fg("dim", " LLM: ") +
|
|
203
|
+
theme.fg("text", ms.totalCalls + " calls") +
|
|
204
|
+
theme.fg("dim", " \u2022 ") +
|
|
205
|
+
theme.fg("text", ms.totalInput.toLocaleString() + "t in") +
|
|
206
|
+
theme.fg("dim", " \u2022 ") +
|
|
207
|
+
theme.fg(cacheColor, cachePct + "% cache hit") +
|
|
208
|
+
theme.fg("dim", " \u2022 ") +
|
|
209
|
+
theme.fg("dim", ms.avgLatency + "ms avg"),
|
|
210
|
+
0, 0));
|
|
211
|
+
}
|
|
212
|
+
const modFiles = details.modifiedFiles;
|
|
213
|
+
const errCount = extraction.errors.length;
|
|
214
|
+
const resolvedErr = extraction.errors.filter(e => e.resolved).length;
|
|
215
|
+
const unresolvedErr = errCount - resolvedErr;
|
|
216
|
+
c.addChild(new Text(
|
|
217
|
+
theme.fg("dim", " Files: ") +
|
|
218
|
+
theme.fg("success", modFiles.length + " modified") +
|
|
219
|
+
theme.fg("dim", " \u2022 ") +
|
|
220
|
+
theme.fg("text", details.readFiles.length + " read") +
|
|
221
|
+
theme.fg("dim", " \u2022 ") +
|
|
222
|
+
theme.fg("text", details.totalMessages + " messages"),
|
|
223
|
+
0, 0));
|
|
224
|
+
if (errCount > 0) {
|
|
225
|
+
c.addChild(new Text(
|
|
226
|
+
theme.fg("dim", " Errors: ") +
|
|
227
|
+
theme.fg("warning", errCount + " total") +
|
|
228
|
+
theme.fg("dim", " \u2022 ") +
|
|
229
|
+
theme.fg("success", resolvedErr + " resolved") +
|
|
230
|
+
theme.fg("dim", " \u2022 ") +
|
|
231
|
+
theme.fg("error", unresolvedErr + " unresolved"),
|
|
232
|
+
0, 0));
|
|
233
|
+
}
|
|
234
|
+
if (extraction.decisions.length > 0) {
|
|
235
|
+
const expD = extraction.decisions.filter(d => d.type === "explicit").length;
|
|
236
|
+
const impD = extraction.decisions.filter(d => d.type === "implicit").length;
|
|
237
|
+
c.addChild(new Text(theme.fg("dim", " Decisions: " + extraction.decisions.length + " (" + expD + " explicit, " + impD + " implicit)"), 0, 0));
|
|
238
|
+
}
|
|
239
|
+
if (extraction.constraints.length > 0) {
|
|
240
|
+
const reqC = extraction.constraints.filter(cc => cc.category === "requirement").length;
|
|
241
|
+
const proC = extraction.constraints.filter(cc => cc.category === "prohibition").length;
|
|
242
|
+
const preC = extraction.constraints.filter(cc => cc.category === "preference").length;
|
|
243
|
+
c.addChild(new Text(theme.fg("dim", " Constraints: " + extraction.constraints.length + " (" + reqC + " req, " + proC + " prohibit, " + preC + " pref)"), 0, 0));
|
|
244
|
+
}
|
|
245
|
+
c.addChild(new Text("", 0, 0));
|
|
246
|
+
|
|
247
|
+
if (modFiles.length > 0) {
|
|
248
|
+
c.addChild(new Text(theme.fg("text", theme.bold(" \uD83D\uDCC1 Modified Files")), 0, 0));
|
|
249
|
+
const maxShow = 8;
|
|
250
|
+
for (let i = 0; i < Math.min(modFiles.length, maxShow); i++) {
|
|
251
|
+
const f = modFiles[i];
|
|
252
|
+
const fc = extraction.modifiedFiles.find(e => e.path === f);
|
|
253
|
+
const count = fc ? " (" + fc.toolCalls + "x)" : "";
|
|
254
|
+
c.addChild(new Text(theme.fg("success", " \u270E ") + theme.fg("text", path.basename(f)) + theme.fg("dim", count + " \u2192 " + f), 0, 0));
|
|
255
|
+
}
|
|
256
|
+
if (modFiles.length > maxShow) {
|
|
257
|
+
c.addChild(new Text(theme.fg("dim", " + " + (modFiles.length - maxShow) + " more"), 0, 0));
|
|
258
|
+
}
|
|
259
|
+
c.addChild(new Text("", 0, 0));
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (details.topics.length > 0) {
|
|
263
|
+
c.addChild(new Text(theme.fg("text", theme.bold(" \uD83D\uDCE6 Topics")), 0, 0));
|
|
264
|
+
const maxTopics = 10;
|
|
265
|
+
for (let i = 0; i < Math.min(details.topics.length, maxTopics); i++) {
|
|
266
|
+
c.addChild(new Text(theme.fg("dim", " " + (i + 1) + ". " + details.topics[i]), 0, 0));
|
|
267
|
+
}
|
|
268
|
+
if (details.topics.length > maxTopics) {
|
|
269
|
+
c.addChild(new Text(theme.fg("dim", " + " + (details.topics.length - maxTopics) + " more"), 0, 0));
|
|
270
|
+
}
|
|
271
|
+
c.addChild(new Text("", 0, 0));
|
|
272
|
+
}
|
|
273
|
+
|
|
274
|
+
c.addChild(new Text(theme.fg("text", theme.bold(" \uD83D\uDD0D Verification")), 0, 0));
|
|
275
|
+
if (details.verified) {
|
|
276
|
+
c.addChild(new Text(theme.fg("success", " \u2705 All facts verified \u2014 no gaps detected"), 0, 0));
|
|
277
|
+
} else if (details.gaps.length > 0) {
|
|
278
|
+
c.addChild(new Text(theme.fg("warning", " \u26A0\uFE0F " + details.gaps.length + " gap(s) patched:"), 0, 0));
|
|
279
|
+
for (const g of details.gaps.slice(0, 5)) {
|
|
280
|
+
c.addChild(new Text(theme.fg("dim", " \u2022 " + g), 0, 0));
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
c.addChild(new Text("", 0, 0));
|
|
284
|
+
|
|
285
|
+
c.addChild(new Text(theme.fg("text", theme.bold(" \uD83D\uDD04 Pipeline")), 0, 0));
|
|
286
|
+
const phase1Status = theme.fg("success", "\u2713");
|
|
287
|
+
const phase2Status = details.explorationRounds > 0
|
|
288
|
+
? theme.fg("success", "\u2713 " + details.explorationRounds + " rounds")
|
|
289
|
+
: theme.fg("warning", "\u26A0 skipped");
|
|
290
|
+
const phase2Bounds = details.explorationBoundaries > 0
|
|
291
|
+
? theme.fg("text", " (" + details.explorationBoundaries + " boundaries)")
|
|
292
|
+
: theme.fg("dim", " (heuristic fallback)");
|
|
293
|
+
const phase4Status = details.verified
|
|
294
|
+
? theme.fg("success", "\u2713 verified")
|
|
295
|
+
: details.gaps.length > 0
|
|
296
|
+
? theme.fg("warning", "\u2713 patched (" + details.gaps.length + " gaps)")
|
|
297
|
+
: theme.fg("dim", "\u2014");
|
|
298
|
+
c.addChild(new Text(theme.fg("dim", " Phase 1 Extract: ") + phase1Status, 0, 0));
|
|
299
|
+
c.addChild(new Text(theme.fg("dim", " Phase 2 Explore: ") + phase2Status + phase2Bounds, 0, 0));
|
|
300
|
+
c.addChild(new Text(theme.fg("dim", " Phase 3 Synthesize: ") + theme.fg("success", "\u2713 " + details.chunkCount + " chunks"), 0, 0));
|
|
301
|
+
c.addChild(new Text(theme.fg("dim", " Phase 4 Verify: ") + phase4Status, 0, 0));
|
|
302
|
+
c.addChild(new Text("", 0, 0));
|
|
303
|
+
|
|
304
|
+
if (details.backupPath) {
|
|
305
|
+
c.addChild(new Text(theme.fg("dim", " \uD83D\uDCBE Backup: " + details.backupPath), 0, 0));
|
|
306
|
+
c.addChild(new Text("", 0, 0));
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
c.addChild(new Text(theme.fg("dim", " Press any key to close"), 0, 0));
|
|
310
|
+
c.addChild(new DynamicBorder((s: string) => theme.fg("accent", s)));
|
|
311
|
+
|
|
312
|
+
return {
|
|
313
|
+
render: (w: number) => c.render(w),
|
|
314
|
+
invalidate: () => c.invalidate(),
|
|
315
|
+
handleInput: (_d: string) => done(undefined),
|
|
316
|
+
};
|
|
317
|
+
}, { overlay: true, overlayOptions: { width: "70%", anchor: "center", maxHeight: "80%" } });
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
export async function showCompactUI(
|
|
321
|
+
ctx: ExtensionCommandContext,
|
|
322
|
+
opts: { contextTokens: number; contextPercent: number; currentModel: string; defaultModelIndex: number },
|
|
323
|
+
): Promise<{ model: ModelOption; profile: CompressionProfile } | null> {
|
|
324
|
+
const selectedModel = await selectModel(ctx, opts);
|
|
325
|
+
if (!selectedModel) return null;
|
|
326
|
+
const selectedProfile = await selectProfile(ctx, selectedModel, { contextTokens: opts.contextTokens, contextPercent: opts.contextPercent });
|
|
327
|
+
if (!selectedProfile) return null;
|
|
328
|
+
return { model: selectedModel, profile: selectedProfile };
|
|
329
|
+
}
|
|
@@ -0,0 +1,145 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Extraction cache, metrics, and cache-aware LLM options.
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import fs from "node:fs";
|
|
6
|
+
import path from "node:path";
|
|
7
|
+
import crypto from "node:crypto";
|
|
8
|
+
import type { LLMCallMetric, StructuredExtraction, CachedExtraction, CacheAwareOptions } from "../types.ts";
|
|
9
|
+
import { estimateTokens, calibrateFromResponse } from "./tokens.ts";
|
|
10
|
+
import { complete, type Model, type Api } from "@earendil-works/pi-ai";
|
|
11
|
+
|
|
12
|
+
const CACHE_DIR = path.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache");
|
|
13
|
+
|
|
14
|
+
// ── Session ID ──
|
|
15
|
+
let _compactSessionId: string | null = null;
|
|
16
|
+
|
|
17
|
+
export function getCompactSessionId(): string {
|
|
18
|
+
if (!_compactSessionId) {
|
|
19
|
+
_compactSessionId = "sc-" + Date.now().toString(36) + "-" + crypto.randomBytes(4).toString("hex");
|
|
20
|
+
}
|
|
21
|
+
return _compactSessionId;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function resetCompactSessionId(): void {
|
|
25
|
+
_compactSessionId = null;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
// ── Cache Options ──
|
|
29
|
+
export function cacheOpts(opts: CacheAwareOptions): CacheAwareOptions & { sessionId?: string } {
|
|
30
|
+
const retention = opts.cacheRetention ?? "short";
|
|
31
|
+
if (retention === "none") {
|
|
32
|
+
return { ...opts, cacheRetention: "none" as const };
|
|
33
|
+
}
|
|
34
|
+
return { ...opts, sessionId: getCompactSessionId(), cacheRetention: "short" as const };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
// ── Metrics ──
|
|
38
|
+
const _metrics: LLMCallMetric[] = [];
|
|
39
|
+
|
|
40
|
+
export function resetMetrics(): void { _metrics.length = 0; }
|
|
41
|
+
export function recordMetric(m: LLMCallMetric): void { _metrics.push(m); if (_metrics.length > 200) _metrics.splice(0, _metrics.length - 100); }
|
|
42
|
+
export function getMetrics(): LLMCallMetric[] { return [..._metrics]; }
|
|
43
|
+
|
|
44
|
+
export function getMetricsSummary(): { totalCalls: number; totalInput: number; totalOutput: number; totalCacheHit: number; avgLatency: number; cacheHitRate: number } {
|
|
45
|
+
const n = _metrics.length;
|
|
46
|
+
if (!n) return { totalCalls: 0, totalInput: 0, totalOutput: 0, totalCacheHit: 0, avgLatency: 0, cacheHitRate: 0 };
|
|
47
|
+
const totalInput = _metrics.reduce((s, m) => s + m.inputTokens, 0);
|
|
48
|
+
const totalOutput = _metrics.reduce((s, m) => s + m.outputTokens, 0);
|
|
49
|
+
const totalCacheHit = _metrics.reduce((s, m) => s + m.cacheHitTokens, 0);
|
|
50
|
+
const avgLatency = _metrics.reduce((s, m) => s + m.latencyMs, 0) / n;
|
|
51
|
+
return {
|
|
52
|
+
totalCalls: n, totalInput, totalOutput, totalCacheHit,
|
|
53
|
+
avgLatency: Math.round(avgLatency),
|
|
54
|
+
cacheHitRate: totalInput > 0 ? totalCacheHit / totalInput : 0,
|
|
55
|
+
};
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// ── Tracked complete wrapper ──
|
|
59
|
+
export async function trackedComplete(
|
|
60
|
+
phase: LLMCallMetric["phase"],
|
|
61
|
+
model: Model<Api>,
|
|
62
|
+
reqBody: Record<string, unknown>,
|
|
63
|
+
opts: CacheAwareOptions,
|
|
64
|
+
): Promise<Record<string, unknown>> {
|
|
65
|
+
const start = Date.now();
|
|
66
|
+
try {
|
|
67
|
+
const resp = await complete(model, reqBody, opts) as Record<string, unknown>;
|
|
68
|
+
const latency = Date.now() - start;
|
|
69
|
+
const usage = (resp as any).usage ?? {};
|
|
70
|
+
const inputT = usage.input_tokens ?? usage.prompt_tokens ?? 0;
|
|
71
|
+
const outputT = usage.output_tokens ?? usage.completion_tokens ?? 0;
|
|
72
|
+
const cacheT = usage.cache_read_input_tokens ?? usage.prompt_tokens_details?.cached_tokens ?? 0;
|
|
73
|
+
recordMetric({
|
|
74
|
+
phase, model: model.id, inputTokens: inputT, outputTokens: outputT,
|
|
75
|
+
cacheHitTokens: cacheT, latencyMs: latency, success: true,
|
|
76
|
+
});
|
|
77
|
+
if (inputT > 0 && reqBody.messages) {
|
|
78
|
+
const rawText = JSON.stringify(reqBody.messages);
|
|
79
|
+
calibrateFromResponse(estimateTokens(rawText), inputT, model.provider);
|
|
80
|
+
}
|
|
81
|
+
return resp;
|
|
82
|
+
} catch (err) {
|
|
83
|
+
recordMetric({
|
|
84
|
+
phase, model: model.id, inputTokens: 0, outputTokens: 0,
|
|
85
|
+
cacheHitTokens: 0, latencyMs: Date.now() - start, success: false,
|
|
86
|
+
});
|
|
87
|
+
throw err;
|
|
88
|
+
}
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
// ── Extraction Cache ──
|
|
92
|
+
|
|
93
|
+
function getCachePath(sessionId: string): string {
|
|
94
|
+
return path.join(CACHE_DIR, "compact-extraction-" + sessionId.replace(/[^a-zA-Z0-9-]/g, "_") + ".json");
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
export function saveCachedExtraction(sessionId: string, extraction: StructuredExtraction, msgCount: number): void {
|
|
98
|
+
try {
|
|
99
|
+
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
100
|
+
const cached: CachedExtraction = {
|
|
101
|
+
lastMessageIndex: msgCount - 1, extraction, messageCount: msgCount, timestamp: Date.now(),
|
|
102
|
+
};
|
|
103
|
+
fs.writeFileSync(getCachePath(sessionId), JSON.stringify(cached));
|
|
104
|
+
} catch { /* best effort */ }
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export function loadCachedExtraction(sessionId: string): CachedExtraction | null {
|
|
108
|
+
try {
|
|
109
|
+
const fp = getCachePath(sessionId);
|
|
110
|
+
if (!fs.existsSync(fp)) return null;
|
|
111
|
+
const cached = JSON.parse(fs.readFileSync(fp, "utf8")) as CachedExtraction;
|
|
112
|
+
if (Date.now() - cached.timestamp > 3600000) return null; // 1hr TTL
|
|
113
|
+
return cached;
|
|
114
|
+
} catch { return null; }
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
export function mergeExtractions(base: StructuredExtraction, delta: StructuredExtraction, baseMsgCount: number): StructuredExtraction {
|
|
118
|
+
return {
|
|
119
|
+
modifiedFiles: [...base.modifiedFiles, ...delta.modifiedFiles],
|
|
120
|
+
readFiles: [...new Set([...base.readFiles, ...delta.readFiles])],
|
|
121
|
+
deletedFiles: [...new Set([...base.deletedFiles, ...delta.deletedFiles])],
|
|
122
|
+
errors: [...base.errors, ...delta.errors],
|
|
123
|
+
decisions: [...base.decisions, ...delta.decisions],
|
|
124
|
+
constraints: [...base.constraints, ...delta.constraints],
|
|
125
|
+
topics: [...base.topics, ...delta.topics],
|
|
126
|
+
timeline: [...base.timeline, ...delta.timeline],
|
|
127
|
+
mainGoal: delta.mainGoal ?? base.mainGoal,
|
|
128
|
+
lastUserMessages: delta.lastUserMessages.length > 0 ? delta.lastUserMessages : base.lastUserMessages,
|
|
129
|
+
lastErrors: delta.lastErrors.length > 0 ? delta.lastErrors : base.lastErrors,
|
|
130
|
+
messageCount: baseMsgCount + delta.messageCount,
|
|
131
|
+
};
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
// ── Metrics log ──
|
|
135
|
+
export function appendMetricsLog(sessionId: string): void {
|
|
136
|
+
try {
|
|
137
|
+
if (!fs.existsSync(CACHE_DIR)) fs.mkdirSync(CACHE_DIR, { recursive: true });
|
|
138
|
+
const logPath = path.join(CACHE_DIR, "compact-metrics.jsonl");
|
|
139
|
+
const entry = { ts: new Date().toISOString(), sessionId, ...getMetricsSummary() };
|
|
140
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
141
|
+
} catch { /* best effort */ }
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
// ── Backup ──
|
|
145
|
+
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Post-compaction regression signal detection.
|
|
3
|
+
* Monitors agent behavior after compaction to detect quality issues.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import type { LlmMessage, StructuredExtraction, SmartCompactDetails } from "../types.ts";
|
|
7
|
+
import { extractText } from "./extraction.ts";
|
|
8
|
+
|
|
9
|
+
export interface RegressionSignal {
|
|
10
|
+
type: "re-read" | "re-question" | "contradiction" | "user-complaint";
|
|
11
|
+
severity: "low" | "medium" | "high";
|
|
12
|
+
detail: string;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export interface DamageReport {
|
|
16
|
+
signals: RegressionSignal[];
|
|
17
|
+
damageScore: number; // 0 = no damage, 100 = severe damage
|
|
18
|
+
summary: string;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
// User complaint patterns indicating compaction may have lost important info
|
|
22
|
+
const COMPLAINT_PATTERNS = [
|
|
23
|
+
/(?:I already (?:told|said|mentioned|explained) you|(?:we|I) (?:already|just) (?:discussed|went over|covered) this|you forgot|you lost|nerede kaldı|hatırlamıyor|unuttun)/i,
|
|
24
|
+
/(?:that'?s? not (?:what I|right)|that'?s? wrong|yanlış|hayır değil|no that'|that doesn'?t match)/i,
|
|
25
|
+
/(?:go back to|return to|(?:look|check) again|tekrar bak|geri dön)/i,
|
|
26
|
+
];
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Detect regression signals in messages AFTER compaction.
|
|
30
|
+
* Called with the post-compaction messages (typically 5-20 messages).
|
|
31
|
+
*
|
|
32
|
+
* @param postMessages Messages after compaction was applied
|
|
33
|
+
* @param extraction The extraction from the compacted section
|
|
34
|
+
* @param details The compaction details
|
|
35
|
+
*/
|
|
36
|
+
export function detectDamage(
|
|
37
|
+
postMessages: LlmMessage[],
|
|
38
|
+
extraction: StructuredExtraction,
|
|
39
|
+
details: SmartCompactDetails,
|
|
40
|
+
): DamageReport {
|
|
41
|
+
const signals: RegressionSignal[] = [];
|
|
42
|
+
const compactedFiles = new Set(extraction.modifiedFiles.map(f => f.path.toLowerCase()));
|
|
43
|
+
const compactedReadFiles = new Set(extraction.readFiles.map(f => f.toLowerCase()));
|
|
44
|
+
const compactedDecisions = extraction.decisions.filter(d => d.type === "explicit");
|
|
45
|
+
|
|
46
|
+
for (let i = 0; i < postMessages.length; i++) {
|
|
47
|
+
const msg = postMessages[i];
|
|
48
|
+
const text = extractText(msg.content).toLowerCase();
|
|
49
|
+
|
|
50
|
+
// ── Re-read detection: agent reads files that were in the compacted section ──
|
|
51
|
+
if (msg.role === "assistant") {
|
|
52
|
+
const blocks = (msg.content ?? []) as unknown[];
|
|
53
|
+
for (const b of blocks) {
|
|
54
|
+
const block = b as { type?: string; name?: string; arguments?: Record<string, unknown> };
|
|
55
|
+
if (block?.type === "toolCall" && (block.name === "read" || block.name === "bash")) {
|
|
56
|
+
const fp = (block.arguments?.path ?? block.arguments?.file_path) as string | undefined;
|
|
57
|
+
if (fp) {
|
|
58
|
+
const fpLower = fp.toLowerCase();
|
|
59
|
+
if (compactedFiles.has(fpLower) || compactedReadFiles.has(fpLower)) {
|
|
60
|
+
signals.push({
|
|
61
|
+
type: "re-read",
|
|
62
|
+
severity: "medium",
|
|
63
|
+
detail: "Agent re-read compacted file: " + fp,
|
|
64
|
+
});
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// ── Re-question detection: user re-asks about compacted topics ──
|
|
72
|
+
if (msg.role === "user") {
|
|
73
|
+
for (const pattern of COMPLAINT_PATTERNS) {
|
|
74
|
+
if (pattern.test(text)) {
|
|
75
|
+
signals.push({
|
|
76
|
+
type: "user-complaint",
|
|
77
|
+
severity: "high",
|
|
78
|
+
detail: "User complaint after compaction: \"" + text.slice(0, 100) + "\"",
|
|
79
|
+
});
|
|
80
|
+
break;
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// Check if user re-asks about compacted decisions
|
|
85
|
+
for (const d of compactedDecisions) {
|
|
86
|
+
const decWords = d.summary.toLowerCase().split(/\s+/).filter(w => w.length > 4).slice(0, 3);
|
|
87
|
+
if (decWords.length >= 2 && decWords.some(w => text.includes(w))) {
|
|
88
|
+
// User mentions the same topic — possible re-question
|
|
89
|
+
// But could also be continuation, so lower severity
|
|
90
|
+
signals.push({
|
|
91
|
+
type: "re-question",
|
|
92
|
+
severity: "low",
|
|
93
|
+
detail: "User mentions compacted decision topic: " + d.summary.slice(0, 80),
|
|
94
|
+
});
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Calculate damage score
|
|
101
|
+
let damageScore = 0;
|
|
102
|
+
for (const s of signals) {
|
|
103
|
+
if (s.severity === "high") damageScore += 25;
|
|
104
|
+
else if (s.severity === "medium") damageScore += 10;
|
|
105
|
+
else damageScore += 3;
|
|
106
|
+
}
|
|
107
|
+
damageScore = Math.min(100, damageScore);
|
|
108
|
+
|
|
109
|
+
// Build summary
|
|
110
|
+
const parts: string[] = [];
|
|
111
|
+
const reReads = signals.filter(s => s.type === "re-read").length;
|
|
112
|
+
const complaints = signals.filter(s => s.type === "user-complaint").length;
|
|
113
|
+
const reQuestions = signals.filter(s => s.type === "re-question").length;
|
|
114
|
+
if (reReads) parts.push(reReads + " re-read(s)");
|
|
115
|
+
if (complaints) parts.push(complaints + " user complaint(s)");
|
|
116
|
+
if (reQuestions) parts.push(reQuestions + " re-question(s)");
|
|
117
|
+
|
|
118
|
+
return {
|
|
119
|
+
signals,
|
|
120
|
+
damageScore,
|
|
121
|
+
summary: parts.length
|
|
122
|
+
? "Damage score: " + damageScore + "/100 — " + parts.join(", ")
|
|
123
|
+
: "No regression signals detected (score: 0)",
|
|
124
|
+
};
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Save a damage report to the metrics log for future analysis.
|
|
129
|
+
*/
|
|
130
|
+
export function logDamageReport(
|
|
131
|
+
sessionId: string,
|
|
132
|
+
report: DamageReport,
|
|
133
|
+
details: SmartCompactDetails,
|
|
134
|
+
): void {
|
|
135
|
+
try {
|
|
136
|
+
const fs = require("fs");
|
|
137
|
+
const path = require("path");
|
|
138
|
+
const dir = path.join(process.env.HOME ?? "/tmp", ".pi", "agent", ".cache", "smart-compact");
|
|
139
|
+
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
|
140
|
+
const logPath = path.join(dir, "damage-reports.jsonl");
|
|
141
|
+
const entry = {
|
|
142
|
+
ts: new Date().toISOString(),
|
|
143
|
+
sessionId,
|
|
144
|
+
method: details.method,
|
|
145
|
+
profile: details.profile,
|
|
146
|
+
qualityScore: details.qualityScore,
|
|
147
|
+
damageScore: report.damageScore,
|
|
148
|
+
signals: report.signals.length,
|
|
149
|
+
summary: report.summary,
|
|
150
|
+
};
|
|
151
|
+
fs.appendFileSync(logPath, JSON.stringify(entry) + "\n");
|
|
152
|
+
} catch { /* best effort */ }
|
|
153
|
+
}
|