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/src/core.ts ADDED
@@ -0,0 +1,360 @@
1
+ /**
2
+ * Core EESV pipeline runner.
3
+ */
4
+
5
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
6
+ import { convertToLlm, serializeConversation } from "@earendil-works/pi-coding-agent";
7
+ import type { Model, Api } from "@earendil-works/pi-ai";
8
+ import type {
9
+ CompressionProfile, PendingCompaction, LlmMessage, StructuredExtraction,
10
+ ExplorationReport, SmartCompactDetails, ChunkSummary,
11
+ } from "./types.ts";
12
+ import { PROFILES } from "./constants.ts";
13
+ import { estimateTokens, getProviderCaps } from "./utils/tokens.ts";
14
+ import {
15
+ resetCompactSessionId, resetMetrics, appendMetricsLog, getMetricsSummary,
16
+ saveCachedExtraction, loadCachedExtraction, mergeExtractions, cacheOpts,
17
+ } from "./utils/cache.ts";
18
+ import { extractStructured } from "./utils/extraction.ts";
19
+ import { pruneRedundant } from "./utils/pruning.ts";
20
+ import { deriveProjectId, loadProjectFingerprint, saveProjectFingerprint, buildProjectContext } from "./utils/fingerprint.ts";
21
+ import { detectDamage, logDamageReport } from "./utils/damage.ts";
22
+ import {
23
+ loadConfig, backupConversation, getPreviousCompactionContext,
24
+ smartKeepBoundary, createBatches,
25
+ } from "./utils/helpers.ts";
26
+ import { extractText } from "./utils/extraction.ts";
27
+ import { exploreConversation, shouldExplore } from "./phases/explore.ts";
28
+ import { chunkLlmMessages, singlePassCompact, summarizeBatch, assembleLLM, assembleFallback } from "./phases/synthesize.ts";
29
+ import { verifySummary, patchSummary, patchDeterministic } from "./phases/verify.ts";
30
+ import { showProgressOverlay, showResultScreen } from "./ui/overlays.ts";
31
+
32
+ export async function runSmartCompact(
33
+ ctx: ExtensionCommandContext,
34
+ summaryModel: Model<Api>, segModel: Model<Api>,
35
+ profile: CompressionProfile,
36
+ verbose: boolean, dryRun: boolean,
37
+ pendingRef: { value: PendingCompaction | null; createdAt: number },
38
+ isRunning: { value: boolean },
39
+ autoTriggered: boolean,
40
+ userNote?: string,
41
+ skipCompact?: boolean,
42
+ ): Promise<void> {
43
+ if (isRunning.value) return;
44
+ isRunning.value = true;
45
+ const pipelineStart = Date.now();
46
+ resetCompactSessionId();
47
+ resetMetrics();
48
+
49
+ if (!summaryModel || !segModel) { isRunning.value = false; if (!autoTriggered) ctx.ui.notify("Model resolve failed", "error"); return; }
50
+ try {
51
+ const config = loadConfig();
52
+ const pc = { ...PROFILES[profile], ...(config.profiles?.[profile] ?? {}) };
53
+ const auth = await ctx.modelRegistry.getApiKeyAndHeaders(summaryModel);
54
+ const segAuth = segModel !== summaryModel ? await ctx.modelRegistry.getApiKeyAndHeaders(segModel) : auth;
55
+ if ((!auth.ok || !auth.apiKey) || (!segAuth.ok || !segAuth.apiKey)) { isRunning.value = false; if (!autoTriggered) ctx.ui.notify("Auth failed", "error"); return; }
56
+
57
+ const usage = ctx.getContextUsage();
58
+ const totalTokens = usage?.tokens ?? 0;
59
+ if (!totalTokens || totalTokens < 5000) { isRunning.value = false; if (!autoTriggered) ctx.ui.notify("Context OK or unknown", "info"); return; }
60
+
61
+ const notify = (msg: string, type: "info" | "success" | "warning" | "error" = "info") => { ctx.ui.notify(msg, type); };
62
+ const ctrl = new AbortController();
63
+ const signal = ctrl.signal;
64
+ const modelLabel = summaryModel.provider + "/" + summaryModel.id;
65
+ notify("Smart compact: " + modelLabel + ", " + profile + ", tokens=" + totalTokens, "info");
66
+ notify("EESV Compact (" + modelLabel + ", " + profile + ") — " + (totalTokens ?? 0).toLocaleString() + "t", "info");
67
+
68
+ const branch = ctx.sessionManager.getBranch();
69
+ interface SessionMessageEntry { type: "message"; id: string; message: unknown }
70
+ const msgs = branch.filter((e: SessionMessageEntry): e is SessionMessageEntry => e.type === "message" && e.message != null);
71
+ if (msgs.length < 3) { isRunning.value = false; return; }
72
+
73
+ let accTokens = 0, keepFrom = msgs.length;
74
+ for (let i = msgs.length - 1; i >= 0; i--) {
75
+ // Use extractText for content instead of JSON.stringify to avoid metadata overhead
76
+ const msg = msgs[i].message as any;
77
+ const contentText = msg?.content ? (typeof msg.content === "string" ? msg.content : JSON.stringify(msg.content)) : "";
78
+ accTokens += estimateTokens(contentText);
79
+ if (accTokens >= pc.keepRecentTokens) { keepFrom = i; break; }
80
+ }
81
+ keepFrom = smartKeepBoundary(msgs, keepFrom);
82
+
83
+ const toCompact = msgs.slice(0, keepFrom);
84
+ if (!toCompact.length) { isRunning.value = false; return; }
85
+ const firstKeptId = msgs[keepFrom]?.id ?? msgs[msgs.length - 1]?.id ?? "";
86
+
87
+ if (!autoTriggered) {
88
+ showProgressOverlay(ctx, { phase: 1, phaseName: "Extract", detail: "Preparing...", model: modelLabel, profile });
89
+ }
90
+
91
+ const llmMessages = convertToLlm(toCompact.map(e => e.message)) as LlmMessage[];
92
+
93
+ // ── Pre-compaction redundancy pruning ──
94
+ const pruning = pruneRedundant(llmMessages);
95
+ if (pruning.prunedCount > 0) {
96
+ notify("Pruning: " + pruning.prunedCount + " msgs removed (" + pruning.reasons.map(r => r.count + "x " + r.reason).join(", ") + ")", "info");
97
+ }
98
+ const prunedMessages = pruning.messages;
99
+ const convText = serializeConversation(prunedMessages);
100
+ const convTokens = estimateTokens(convText);
101
+
102
+ const sessionId = ctx.sessionManager.getSessionId?.() ?? "unknown";
103
+ const backupPath = backupConversation(convText, sessionId);
104
+ const prevContext = getPreviousCompactionContext(branch);
105
+
106
+ // ── Project fingerprint (cross-session context) ──
107
+ const projectId = deriveProjectId(extraction);
108
+ const fingerprint = loadProjectFingerprint(projectId);
109
+ if (fingerprint) {
110
+ notify("Project: " + fingerprint.language + (fingerprint.framework ? "/" + fingerprint.framework : "") + " (" + fingerprint.sessionCount + " sessions)", "info");
111
+ }
112
+ const projectCtx = buildProjectContext(fingerprint);
113
+
114
+ // Phase 1
115
+ const cachedExt = loadCachedExtraction(sessionId);
116
+ let extraction: StructuredExtraction;
117
+ if (cachedExt && cachedExt.lastMessageIndex < llmMessages.length - 1) {
118
+ const newMsgs = llmMessages.slice(cachedExt.lastMessageIndex + 1);
119
+ const delta = extractStructured(newMsgs, pc);
120
+ extraction = mergeExtractions(cachedExt.extraction, delta, cachedExt.messageCount);
121
+ notify("Phase 1 Incremental: " + (cachedExt.lastMessageIndex + 1) + " cached + " + newMsgs.length + " new messages", "info");
122
+ } else {
123
+ extraction = extractStructured(llmMessages, pc);
124
+ notify("Phase 1 Full: " + extraction.modifiedFiles.length + " files, " + extraction.errors.length + " errors", "info");
125
+ }
126
+ saveCachedExtraction(sessionId, extraction, llmMessages.length);
127
+
128
+ let finalSummary: string;
129
+ let method: string;
130
+ let llmCalls = 0;
131
+ let summaries: ChunkSummary[] = [];
132
+ let explorationReport: ExplorationReport | null = null;
133
+ let explorationRounds = 0;
134
+ let chunkCount = 0;
135
+
136
+ if (convTokens < pc.singlePassMaxTokens) {
137
+ if (!autoTriggered) showProgressOverlay(ctx, { phase: 2, phaseName: "Explore", detail: "Single-pass (" + convTokens.toLocaleString() + "t)", model: modelLabel, profile, extraction });
138
+ try {
139
+ const r = await singlePassCompact(convText, extraction, null, prevContext + projectCtx, summaryModel, { apiKey: auth.apiKey, headers: auth.headers }, signal);
140
+ finalSummary = r.summary; method = "single-pass"; llmCalls = r.llmCalls;
141
+ } catch (err) {
142
+ notify("Single-pass failed: " + (err instanceof Error ? err.message : String(err)), "warning");
143
+ finalSummary = assembleFallback([], extraction);
144
+ method = "heuristic"; llmCalls = 0;
145
+ }
146
+ } else {
147
+ // Adaptive exploration gate: skip explore for simple sessions
148
+ const needsExploration = shouldExplore(extraction);
149
+ if (needsExploration) {
150
+ if (!autoTriggered) showProgressOverlay(ctx, { phase: 2, phaseName: "Explore", detail: "Exploring...", model: modelLabel, profile, extraction });
151
+ try {
152
+ const expResult = await exploreConversation(llmMessages, extraction, segModel, { apiKey: segAuth.apiKey, headers: segAuth.headers }, prevContext || undefined, userNote, signal, 8, notify);
153
+ explorationReport = expResult.report;
154
+ explorationRounds = expResult.rounds;
155
+ notify("Phase 2 Explore: " + expResult.rounds + " rounds, " + explorationReport.boundaries.length + " boundaries" + (expResult.toolSupported ? "" : " (no tool support)"), "info");
156
+ } catch (err) {
157
+ notify("Phase 2 Explore: failed - " + (err instanceof Error ? err.message : String(err)), "warning");
158
+ }
159
+ } else {
160
+ notify("Phase 2 Explore: skipped (simple session: " + extraction.topics.length + " topics, " + extraction.errors.filter(e => !e.resolved).length + " unresolved errors)", "info");
161
+ }
162
+
163
+ let boundaries: import("./types.ts").TopicBoundary[];
164
+ if (explorationReport?.boundaries.length) {
165
+ // Merge LLM boundaries with heuristic boundaries — don't discard heuristics
166
+ const llmBounds = explorationReport.boundaries.filter(b => b.confidence >= 0.4);
167
+ const heuristicBounds = extraction.topics.map(t => ({
168
+ afterIndex: t.endIndex,
169
+ topic: t.primaryFile ? "Working on " + t.primaryFile.split("/").pop() : "Segment",
170
+ priority: t.errorDensity > 2 ? "high" as const : "normal" as const,
171
+ confidence: 0.6,
172
+ }));
173
+ if (llmBounds.length > 0) {
174
+ // LLM boundaries are primary; fill gaps with heuristic boundaries
175
+ const merged = [...llmBounds];
176
+ for (const hb of heuristicBounds) {
177
+ const nearby = merged.find(m => Math.abs(m.afterIndex - hb.afterIndex) <= 3);
178
+ if (!nearby) merged.push(hb);
179
+ }
180
+ boundaries = merged.sort((a, b) => a.afterIndex - b.afterIndex);
181
+ } else {
182
+ boundaries = heuristicBounds;
183
+ }
184
+ } else {
185
+ boundaries = extraction.topics.map(t => ({
186
+ afterIndex: t.endIndex,
187
+ topic: t.primaryFile ? "Working on " + t.primaryFile.split("/").pop() : "Segment",
188
+ priority: t.errorDensity > 2 ? "high" as const : "normal" as const,
189
+ confidence: 0.6,
190
+ }));
191
+ }
192
+
193
+ const chunks = chunkLlmMessages(llmMessages, boundaries, pc);
194
+ chunkCount = chunks.length;
195
+ notify("Chunked: " + chunkCount + " chunks", "info");
196
+
197
+ const batches = createBatches(chunks, pc.batchMaxTokens);
198
+ const totalBatches = batches.length;
199
+ if (!autoTriggered) showProgressOverlay(ctx, { phase: 3, phaseName: "Synthesize", detail: "0/" + totalBatches + " batches", model: modelLabel, profile, extraction, totalBatches });
200
+
201
+ const caps = getProviderCaps(summaryModel.provider);
202
+ const concurrency = caps.concurrencyLimit;
203
+
204
+ if (totalBatches <= 1) {
205
+ try {
206
+ summaries.push(...await summarizeBatch(batches[0], extraction, summaryModel, { apiKey: auth.apiKey, headers: auth.headers }, signal));
207
+ } catch (err) {
208
+ summaries.push(...batches[0].map(ch => ({
209
+ topic: ch.topic, startIndex: ch.startIndex, endIndex: ch.endIndex,
210
+ summary: "[Failed] " + ch.messages.map((m: any) => extractText(m.content)).join("\n").slice(0, 300),
211
+ keyDecisions: [] as string[], filesModified: [] as string[], filesRead: [] as string[], priority: ch.priority as ChunkSummary["priority"],
212
+ })));
213
+ }
214
+ } else {
215
+ const results: ChunkSummary[][] = new Array(totalBatches);
216
+ const errors: (Error | null)[] = new Array(totalBatches).fill(null);
217
+ let completed = 0;
218
+
219
+ for (let wave = 0; wave < totalBatches; wave += concurrency) {
220
+ const waveBatches = batches.slice(wave, Math.min(wave + concurrency, totalBatches));
221
+ const wavePromises = waveBatches.map(async (batch, i) => {
222
+ const idx = wave + i;
223
+ try {
224
+ results[idx] = await summarizeBatch(batch, extraction, summaryModel, { apiKey: auth.apiKey, headers: auth.headers }, signal);
225
+ } catch (err) {
226
+ errors[idx] = err instanceof Error ? err : new Error(String(err));
227
+ results[idx] = batch.map(ch => ({
228
+ topic: ch.topic, startIndex: ch.startIndex, endIndex: ch.endIndex,
229
+ summary: "[Failed] " + ch.messages.map((m: any) => extractText(m.content)).join("\n").slice(0, 300),
230
+ keyDecisions: [] as string[], filesModified: [] as string[], filesRead: [] as string[], priority: ch.priority as ChunkSummary["priority"],
231
+ }));
232
+ }
233
+ completed++;
234
+ if (!autoTriggered) showProgressOverlay(ctx, { phase: 3, phaseName: "Synthesize", detail: completed + "/" + totalBatches + " batches", model: modelLabel, profile, extraction, totalBatches, currentBatch: completed });
235
+ });
236
+ await Promise.all(wavePromises);
237
+ }
238
+ for (const r of results) if (r) summaries.push(...r);
239
+ for (let i = 0; i < errors.length; i++) if (errors[i]) notify("Batch " + (i + 1) + " failed: " + errors[i]!.message, "warning");
240
+ }
241
+
242
+ if (!autoTriggered) showProgressOverlay(ctx, { phase: 3, phaseName: "Synthesize", detail: "Assembling...", model: modelLabel, profile, extraction, totalBatches: batches.length });
243
+ let assemblyCalls = 1;
244
+ try {
245
+ const r = await assembleLLM(summaries, extraction, explorationReport, summaryModel, { apiKey: auth.apiKey, headers: auth.headers }, pc.summaryBudgetTokens, prevContext, signal);
246
+ if (r?.startsWith("##")) finalSummary = r; else throw new Error("bad");
247
+ } catch {
248
+ finalSummary = assembleFallback(summaries, extraction); assemblyCalls = 0;
249
+ }
250
+
251
+ method = "eesv";
252
+ llmCalls = explorationRounds + batches.length + assemblyCalls;
253
+ }
254
+
255
+ if (!autoTriggered) showProgressOverlay(ctx, { phase: 4, phaseName: "Verify", detail: "Checking...", model: modelLabel, profile, extraction, explorationRounds });
256
+ const verification = verifySummary(finalSummary, extraction);
257
+ if (!verification.ok) {
258
+ if (verification.score < 85) {
259
+ // Deterministic patch first (zero LLM cost)
260
+ notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + ", applying deterministic patch", "warning");
261
+ finalSummary = patchDeterministic(finalSummary, verification.gaps, extraction);
262
+ // Re-verify after patch — only use LLM patch if still bad
263
+ const recheck = verifySummary(finalSummary, extraction);
264
+ if (!recheck.ok && recheck.score < 75) {
265
+ notify("Phase 4 Verify: deterministic patch insufficient (score=" + recheck.score + "), trying LLM patch", "warning");
266
+ try {
267
+ finalSummary = await patchSummary(finalSummary, recheck.gaps, summaryModel, { apiKey: auth.apiKey, headers: auth.headers }, signal);
268
+ llmCalls++;
269
+ } catch { /* accept deterministic patch as-is */ }
270
+ }
271
+ } else {
272
+ notify("Phase 4 Verify: " + verification.gaps.length + " gap(s), score=" + verification.score + " ≥ 85 — skipping patch", "info");
273
+ }
274
+ }
275
+
276
+ const detModified = extraction.modifiedFiles.map(f => f.path);
277
+ const detRead = extraction.readFiles;
278
+ const estimatedAfter = estimateTokens(finalSummary) + accTokens;
279
+ const tokensSaved = Math.max(0, totalTokens - estimatedAfter);
280
+
281
+ const pipelineInfo = method === "eesv"
282
+ ? "EESV: Extract > Explore (" + explorationRounds + "r) > Synthesize (" + (chunkCount || 1) + " chunks) > Verify (" + (verification.ok ? "pass" : verification.gaps.length + " gaps") + ")"
283
+ : method + " (" + (chunkCount || 1) + " chunks, " + llmCalls + " calls)";
284
+ const pipelineMs = Date.now() - pipelineStart;
285
+ const durationStr = pipelineMs < 1000 ? pipelineMs + "ms" : (pipelineMs / 1000).toFixed(1) + "s";
286
+ notify("Done: " + pipelineInfo + " — saved " + (tokensSaved ?? 0).toLocaleString() + "t (" + durationStr + ")", "success");
287
+
288
+ const details: SmartCompactDetails = {
289
+ method: method as SmartCompactDetails["method"],
290
+ chunkCount: chunkCount || 1,
291
+ topics: summaries.length ? summaries.map(s => s.topic) : [method],
292
+ readFiles: detRead, modifiedFiles: detModified,
293
+ totalMessages: toCompact.length, totalTokensSummarized: convTokens,
294
+ llmCalls, profile, backupPath, tokensSaved,
295
+ verified: verification.ok, gaps: verification.gaps,
296
+ explorationRounds, explorationBoundaries: explorationReport?.boundaries.length ?? 0,
297
+ model: modelLabel, qualityScore: verification.score,
298
+ tokensBefore: totalTokens,
299
+ };
300
+
301
+ if (dryRun) {
302
+ notify("DRY RUN (" + method + ", " + profile + ") — " + toCompact.length + " msgs, " + llmCalls + " calls", "info");
303
+ return;
304
+ }
305
+
306
+ pendingRef.value = { summary: finalSummary, firstKeptEntryId: firstKeptId, tokensBefore: totalTokens, details };
307
+ pendingRef.createdAt = Date.now();
308
+
309
+ // ── Save project fingerprint for cross-session context ──
310
+ saveProjectFingerprint(projectId, extraction);
311
+
312
+ appendMetricsLog(sessionId);
313
+
314
+ // ── Damage detection: check if previous compaction caused issues ──
315
+ // This reads post-compaction messages from the current branch to detect regression
316
+ try {
317
+ const postCompactMsgs = msgs.slice(keepFrom).map(e => convertToLlm([e.message])).flat().map((m: any) => m as LlmMessage);
318
+ if (postCompactMsgs.length > 2) {
319
+ // Only detect if there are enough post-compaction messages
320
+ const lastCompaction = branch.filter((e: any) => e.type === "compaction").slice(-1)[0] as any;
321
+ if (lastCompaction?.details) {
322
+ const prevDetails = lastCompaction.details as SmartCompactDetails;
323
+ const prevExtraction = extractStructured(postCompactMsgs.slice(0, Math.min(15, postCompactMsgs.length)), pc);
324
+ const damage = detectDamage(postCompactMsgs.slice(0, Math.min(15, postCompactMsgs.length)), prevExtraction, prevDetails);
325
+ if (damage.damageScore > 0) {
326
+ notify("Previous compaction damage: " + damage.summary, "warning");
327
+ }
328
+ logDamageReport(sessionId, damage, prevDetails);
329
+ }
330
+ }
331
+ } catch { /* damage detection is best effort */ }
332
+ const ms = getMetricsSummary();
333
+ if (ms.totalCalls > 0) {
334
+ notify("Metrics: " + ms.totalCalls + " calls, " + ms.totalInput + "t in, " + ms.totalOutput + "t out, cache " + Math.round(ms.cacheHitRate * 100) + "%, " + ms.avgLatency + "ms avg", "info");
335
+ }
336
+ if (!autoTriggered) {
337
+ try {
338
+ const timeout = new Promise<void>(resolve => setTimeout(resolve, 5000));
339
+ await Promise.race([showResultScreen(ctx, details, extraction), timeout]);
340
+ } catch {
341
+ notify("Result screen skipped", "info");
342
+ }
343
+ }
344
+
345
+ if (!skipCompact) {
346
+ ctx.compact({
347
+ customInstructions: "Use pre-computed smart summary from /smart-compact",
348
+ onComplete: () => { if (!autoTriggered) ctx.ui.notify("Applied \u2713", "success"); },
349
+ onError: e => { if (!autoTriggered) ctx.ui.notify("Failed: " + e.message, "error"); },
350
+ });
351
+ }
352
+ } finally {
353
+ isRunning.value = false;
354
+ const pipelineMs = Date.now() - pipelineStart;
355
+ if (autoTriggered) {
356
+ ctx.ui.notify("Compaction completed in " + (pipelineMs < 1000 ? pipelineMs + "ms" : (pipelineMs / 1000).toFixed(1) + "s"), "info");
357
+ }
358
+ }
359
+ }
360
+
package/src/index.ts ADDED
@@ -0,0 +1,175 @@
1
+ /**
2
+ * Smart Compact Extension for Pi Coding Agent v7.3.2 (EESV Architecture)
3
+ *
4
+ * Architecture: Extract -> Explore -> Synthesize -> Verify
5
+ */
6
+
7
+ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
8
+ import type { Model, Api } from "@earendil-works/pi-ai";
9
+ import type { CompressionProfile, PendingCompaction } from "./types.ts";
10
+ import { VERSION } from "./constants.ts";
11
+ import { loadConfig } from "./utils/helpers.ts";
12
+ import { runSmartCompact } from "./core.ts";
13
+ import { showCompactUI } from "./ui/overlays.ts";
14
+
15
+ function resolveModelArg(ctx: ExtensionCommandContext, modelArg: string): Model<Api> | undefined {
16
+ const [p, ...r] = modelArg.split("/");
17
+ return ctx.modelRegistry.find(p, r.join("/"));
18
+ }
19
+
20
+ function resolveModels(
21
+ ctx: ExtensionCommandContext,
22
+ primary: Model<Api> | undefined,
23
+ config: ReturnType<typeof loadConfig>,
24
+ ): { segModel: Model<Api> | undefined; sumModel: Model<Api> | undefined } {
25
+ const fallback = primary ?? ctx.model;
26
+ const available = ctx.modelRegistry.getAvailable();
27
+ let sumModel = fallback;
28
+
29
+ const configuredSumModels = [config.summaryModel].filter(Boolean) as string[];
30
+ for (const modelId of configuredSumModels) {
31
+ const [p, ...r] = modelId.split("/");
32
+ const found = ctx.modelRegistry.find(p, r.join("/"));
33
+ if (found) { sumModel = found; break; }
34
+ }
35
+ if (sumModel === fallback && !fallback) sumModel = available[0];
36
+
37
+ let segModel = sumModel;
38
+ if (config.segmentationModel) {
39
+ const [p, ...r] = config.segmentationModel.split("/");
40
+ segModel = ctx.modelRegistry.find(p, r.join("/")) ?? sumModel;
41
+ }
42
+
43
+ return { segModel, sumModel };
44
+ }
45
+
46
+ export default function smartCompactExtension(pi: ExtensionAPI) {
47
+ const pendingRef: { value: PendingCompaction | null; createdAt: number } = { value: null, createdAt: 0 };
48
+ const isRunning: { value: boolean } = { value: false };
49
+ const PENDING_TTL_MS = 5 * 60 * 1000;
50
+
51
+ pi.registerCommand("smart-compact", {
52
+ description: "EESV smart compaction v" + VERSION + ". Usage: /smart-compact [model] [light|balanced|aggressive] [verbose|debug|dry-run] [note]",
53
+ getArgumentCompletions: (prefix: string) => {
54
+ const m = ["verbose", "debug", "dry-run", "light", "balanced", "aggressive"].filter(o => o.startsWith(prefix)).map(o => ({ value: o, label: o }));
55
+ return m.length ? m : null;
56
+ },
57
+ handler: async (args, ctx) => {
58
+ try {
59
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
60
+ const flags = tokens.map(t => t.toLowerCase());
61
+ const verbose = flags.includes("verbose") || flags.includes("debug");
62
+ const dryRun = flags.includes("dry-run");
63
+ const modelArg = tokens.find(t => t.includes("/"));
64
+ const profileArg = tokens.find(t => ["light", "balanced", "aggressive"].includes(t)) as CompressionProfile | undefined;
65
+ const profile = profileArg ?? loadConfig().profile;
66
+
67
+ if (!tokens.length) {
68
+ const usage = ctx.getContextUsage();
69
+ const totalTokens = usage?.tokens ?? 0;
70
+ const pct = ctx.model && totalTokens ? Math.round((totalTokens / ctx.model.contextWindow) * 100) : 0;
71
+ if (!totalTokens || totalTokens < 5000) { ctx.ui.notify("Context OK or unknown", "info"); return; }
72
+ const cur = ctx.model;
73
+ const avail = ctx.modelRegistry.getAvailable();
74
+ const opts = avail.map(m => ({ value: m.provider + "/" + m.id, label: m.provider + "/" + m.id + (m.contextWindow >= 200000 ? " (" + Math.round(m.contextWindow / 1000) + "K)" : ""), model: m }));
75
+ const defIdx = cur ? opts.findIndex(o => o.value === cur.provider + "/" + cur.id) : 0;
76
+ const selected = await showCompactUI(ctx, { contextTokens: totalTokens, contextPercent: pct, currentModel: cur ? cur.provider + "/" + cur.id : "?", defaultModelIndex: defIdx >= 0 ? defIdx : 0 });
77
+ if (!selected) { ctx.ui.notify("Cancelled", "info"); return; }
78
+ const { segModel, sumModel } = resolveModels(ctx, selected.model.model, loadConfig());
79
+ if (!sumModel) { ctx.ui.notify("Could not resolve model", "error"); return; }
80
+ await runSmartCompact(ctx, sumModel, segModel ?? sumModel, selected.profile, false, false, pendingRef, isRunning, false);
81
+ return;
82
+ }
83
+
84
+ const { segModel, sumModel } = resolveModels(ctx, modelArg ? resolveModelArg(ctx, modelArg) : ctx.model, loadConfig());
85
+ if (!sumModel) { ctx.ui.notify("Could not resolve model", "error"); return; }
86
+ const note = extractUserNote(args);
87
+ await runSmartCompact(ctx, sumModel, segModel ?? sumModel, profile, verbose, dryRun, pendingRef, isRunning, false, note);
88
+ } catch (error) {
89
+ const msg = error instanceof Error ? error.message + "\n" + error.stack : String(error);
90
+ ctx.ui.notify("smart-compact error: " + msg, "error");
91
+ }
92
+ },
93
+ });
94
+
95
+ pi.on("session_before_compact", async (_event, ctx) => {
96
+ if (pendingRef.value) {
97
+ const age = Date.now() - pendingRef.createdAt;
98
+ if (age > PENDING_TTL_MS) {
99
+ pendingRef.value = null;
100
+ pendingRef.createdAt = 0;
101
+ } else {
102
+ const c = pendingRef.value;
103
+ pendingRef.value = null;
104
+ pendingRef.createdAt = 0;
105
+ return { compaction: { summary: c.summary, firstKeptEntryId: c.firstKeptEntryId, tokensBefore: c.tokensBefore, details: c.details } };
106
+ }
107
+ }
108
+ const config = loadConfig();
109
+ if (!config.autoTrigger) return;
110
+ try {
111
+ const usage = ctx.getContextUsage();
112
+ const totalTokens = usage?.tokens ?? 0;
113
+ if (!totalTokens || totalTokens < 5000) return;
114
+ const cur = ctx.model;
115
+ if (!cur) return;
116
+ const { segModel, sumModel } = resolveModels(ctx, cur, config);
117
+ if (!sumModel) return;
118
+ if (!isRunning.value) {
119
+ await runSmartCompact(ctx, sumModel, segModel ?? sumModel, config.profile, false, false, pendingRef, isRunning, true);
120
+ if (pendingRef.value) {
121
+ const c = pendingRef.value;
122
+ pendingRef.value = null;
123
+ pendingRef.createdAt = 0;
124
+ return { compaction: { summary: c.summary, firstKeptEntryId: c.firstKeptEntryId, tokensBefore: c.tokensBefore, details: c.details } };
125
+ }
126
+ }
127
+ } catch { /* silent */ }
128
+ });
129
+
130
+ pi.registerTool({
131
+ name: "smart_compact", label: "Smart Compact",
132
+ description: "EESV smart compaction v" + VERSION + " with deterministic extraction, exploration, and verification.",
133
+ promptSnippet: "Smart compaction",
134
+ promptGuidelines: ["Use for long conversations.", "Prefer over default compact."],
135
+ parameters: {
136
+ type: "object",
137
+ properties: {
138
+ profile: { type: "string", description: "light, balanced, or aggressive" },
139
+ verbose: { type: "boolean" },
140
+ dry_run: { type: "boolean" },
141
+ },
142
+ },
143
+ async execute(_id, params, _sig, _onUp, ctx) {
144
+ const profile = (params.profile === "light" || params.profile === "balanced" || params.profile === "aggressive") ? params.profile : undefined;
145
+ const verbose = !!params.verbose;
146
+ const dryRun = !!params.dry_run;
147
+ const config = loadConfig();
148
+ const resolvedProfile = profile ?? config.profile;
149
+ const cur = ('model' in ctx) ? (ctx as any).model : undefined;
150
+ const { segModel, sumModel } = resolveModels(ctx as ExtensionCommandContext, cur, config);
151
+ if (!sumModel) {
152
+ return { content: [{ type: "text", text: "Error: Could not resolve model." }] };
153
+ }
154
+ try {
155
+ const toolStart = Date.now();
156
+ await runSmartCompact(ctx as ExtensionCommandContext, sumModel, segModel ?? sumModel, resolvedProfile, verbose, dryRun, pendingRef, isRunning, true, undefined, true);
157
+ const toolSecs = ((Date.now() - toolStart) / 1000).toFixed(1);
158
+ if (pendingRef.value) {
159
+ return { content: [{ type: "text", text: "Smart summary generated (" + resolvedProfile + "). Tokens: " + (pendingRef.value.tokensBefore ?? "?") + " -> " + (pendingRef.value.summary?.length ?? 0) + " chars (" + toolSecs + "s).\n\nNow run tree compact to apply — the session_before_compact hook will use this summary.\nTTL: " + Math.round(PENDING_TTL_MS / 60000) + " minutes." }] };
160
+ }
161
+ return { content: [{ type: "text", text: "Compaction finished (" + resolvedProfile + ") but no summary was generated." }] };
162
+ } catch (error) {
163
+ const msg = error instanceof Error ? error.message : String(error);
164
+ return { content: [{ type: "text", text: "Compaction error: " + msg }] };
165
+ }
166
+ },
167
+ });
168
+ }
169
+
170
+ function extractUserNote(args: string): string | undefined {
171
+ const SKIP = new Set(["verbose", "debug", "dry-run", "light", "balanced", "aggressive"]);
172
+ const tokens = args.trim().split(/\s+/).filter(Boolean);
173
+ const nonFlags = tokens.filter(t => !t.includes("/") && !SKIP.has(t.toLowerCase()));
174
+ return nonFlags.length > 0 ? nonFlags.join(" ") : undefined;
175
+ }