@pi-unipi/compactor 0.2.2 → 2.0.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.
@@ -4,13 +4,20 @@
4
4
 
5
5
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
6
6
  import { convertToLlm } from "@mariozechner/pi-coding-agent";
7
+ import type {
8
+ SessionEntry,
9
+ SessionMessageEntry,
10
+ SessionBeforeCompactEvent,
11
+ SessionCompactEvent,
12
+ } from "@mariozechner/pi-coding-agent";
13
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
7
14
  import { compile } from "./summarize.js";
8
15
  import { loadConfig } from "../config/manager.js";
9
- import { buildOwnCut } from "./cut.js";
16
+ import { buildOwnCut, type OwnCutResult } from "./cut.js";
10
17
  import type { CompactionStats } from "../types.js";
11
18
  import type { SessionDB } from "../session/db.js";
12
19
 
13
- export const COMPACTOR_INSTRUCTION = "__compactor__";
20
+ import { COMPACTOR_INSTRUCTION } from "@pi-unipi/core";
14
21
 
15
22
  let lastStats: CompactionStats | null = null;
16
23
  let lastCompactWasCompactor = false;
@@ -26,76 +33,105 @@ const dbg = (_debug: boolean, _event: string, _data?: Record<string, unknown>) =
26
33
  return;
27
34
  };
28
35
 
29
- const previewContent = (content: unknown): string => {
30
- if (typeof content === "string") return content.slice(0, 300);
31
- if (Array.isArray(content)) {
32
- return content
33
- .map((c: any) => {
34
- if (c?.type === "text") return c.text ?? "";
35
- if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
36
- if (c?.type === "thinking") return `[thinking]`;
37
- if (c?.type === "image") return `[image:${c.mimeType}]`;
38
- return `[${c?.type ?? "unknown"}]`;
39
- })
40
- .join("\n")
41
- .slice(0, 300);
42
- }
43
- return "";
44
- };
45
-
46
36
  const REASON_MESSAGES: Record<import("./cut.js").OwnCutCancelReason, string> = {
47
37
  no_live_messages: "compactor: Nothing to compact (no live messages)",
48
38
  too_few_live_messages: "compactor: Too few messages to compact",
49
39
  no_user_message: "compactor: Cannot compact — no user message found",
50
40
  };
51
41
 
42
+ /** Count chars in a content part array (TextContent, ToolCall, ToolResult, etc.) */
43
+ function contentPartsChars(parts: Array<{ text?: string; name?: string; input?: unknown; content?: unknown }>): number {
44
+ return parts.reduce((s: number, p) => {
45
+ if (p.text) return s + p.text.length;
46
+ if (p.name) {
47
+ // ToolCall
48
+ const inputStr = typeof p.input === "string" ? p.input : JSON.stringify(p.input ?? "");
49
+ return s + p.name.length + inputStr.length;
50
+ }
51
+ if (p.content !== undefined) {
52
+ // ToolResult
53
+ const contentStr = typeof p.content === "string" ? p.content : JSON.stringify(p.content ?? "");
54
+ return s + contentStr.length;
55
+ }
56
+ return s;
57
+ }, 0);
58
+ }
59
+
60
+ /** Estimate char count for an AgentMessage (unwrapped — has role + content directly) */
61
+ function messageChars(msg: AgentMessage): number {
62
+ const c = (msg as { content: unknown }).content;
63
+ if (typeof c === "string") return c.length;
64
+ if (Array.isArray(c)) return contentPartsChars(c as Array<{ text?: string; name?: string; input?: unknown; content?: unknown }>);
65
+ return 0;
66
+ }
67
+
68
+ /** Estimate char count for a SessionMessageEntry's message */
69
+ function entryMessageChars(entry: SessionMessageEntry): number {
70
+ return messageChars(entry.message);
71
+ }
72
+
73
+ /** Filter entries to only SessionMessageEntry */
74
+ function filterMessageEntries(entries: SessionEntry[]): SessionMessageEntry[] {
75
+ return entries.filter((e): e is SessionMessageEntry => e.type === "message");
76
+ }
77
+
52
78
  export function registerCompactionHooks(
53
79
  pi: ExtensionAPI,
54
80
  deps?: { getSessionDB?: () => SessionDB | null; getSessionId?: () => string },
55
81
  ): void {
56
- pi.on("session_before_compact", (event, ctx) => {
82
+ pi.on("session_before_compact", (event: SessionBeforeCompactEvent, ctx) => {
57
83
  const { preparation, branchEntries, customInstructions } = event;
58
84
  const config = loadConfig();
59
- dbg(config.debug, "session_before_compact:enter", { entryCount: (branchEntries as any[])?.length, hasPrevSummary: !!preparation?.previousSummary, isCompactor: customInstructions === COMPACTOR_INSTRUCTION });
85
+ const isCompactor = customInstructions?.startsWith(COMPACTOR_INSTRUCTION) ?? false;
86
+ dbg(config.debug, "session_before_compact:enter", {
87
+ entryCount: branchEntries.length,
88
+ hasPrevSummary: !!preparation?.previousSummary,
89
+ isCompactor,
90
+ });
60
91
 
61
- const isCompactor = customInstructions === COMPACTOR_INSTRUCTION;
62
92
  if (!isCompactor && !config.overrideDefaultCompaction) {
63
93
  dbg(config.debug, "session_before_compact:skip", { reason: "not_compactor_and_no_override" });
64
94
  return;
65
95
  }
66
96
 
67
- const ownCut = buildOwnCut(branchEntries as any[]);
68
- dbg(config.debug, "buildOwnCut", { ok: ownCut.ok, reason: !ownCut.ok ? (ownCut as any).reason : undefined });
97
+ const ownCut: OwnCutResult = buildOwnCut(branchEntries);
98
+ dbg(config.debug, "buildOwnCut", {
99
+ ok: ownCut.ok,
100
+ reason: !ownCut.ok ? (ownCut as { ok: false; reason: string }).reason : undefined,
101
+ });
69
102
  if (!ownCut.ok) {
70
103
  try {
71
- ctx?.ui?.notify?.(REASON_MESSAGES[ownCut.reason], "warning");
104
+ ctx?.ui?.notify?.(REASON_MESSAGES[(ownCut as { ok: false; reason: import("./cut.js").OwnCutCancelReason }).reason], "warning");
72
105
  } catch {}
73
106
  return { cancel: true };
74
107
  }
75
108
 
76
- const agentMessages = ownCut.messages;
77
- const firstKeptEntryId = ownCut.firstKeptEntryId;
109
+ const { messages: agentMessages, firstKeptEntryId } = ownCut;
78
110
  const messages = convertToLlm(agentMessages);
79
111
 
80
- const keptIdx = (branchEntries as any[]).findIndex((e: any) => e.id === firstKeptEntryId);
81
- const keptEntries = keptIdx >= 0
82
- ? (branchEntries as any[]).slice(keptIdx).filter((e: any) => e.type === "message")
112
+ // Find kept entries (from cut point onward)
113
+ const keptIdx = branchEntries.findIndex((e: SessionEntry) => e.id === firstKeptEntryId);
114
+ const keptMessageEntries: SessionMessageEntry[] = keptIdx >= 0
115
+ ? filterMessageEntries(branchEntries.slice(keptIdx))
83
116
  : [];
84
- const keptChars = keptEntries.reduce((sum: number, e: any) => {
85
- const c = e.message?.content;
86
- if (typeof c === "string") return sum + c.length;
87
- if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => {
88
- if (p.text) return s + p.text.length;
89
- if (p.type === "toolCall") return s + (p.name?.length ?? 0) + (typeof p.input === "string" ? p.input.length : JSON.stringify(p.input ?? "").length);
90
- if (p.type === "toolResult") return s + (typeof p.content === "string" ? p.content.length : JSON.stringify(p.content ?? "").length);
91
- return s;
92
- }, 0);
93
- return sum;
94
- }, 0);
117
+
118
+ // Compute char estimates for proportional token estimation
119
+ const summarizedChars = agentMessages.reduce((sum, msg) => sum + messageChars(msg), 0);
120
+ const keptChars = keptMessageEntries.reduce((sum, e) => sum + entryMessageChars(e), 0);
121
+ const totalChars = summarizedChars + keptChars;
122
+
123
+ // Use Pi's real token count for "before", estimate "after" proportionally
124
+ const tokensBefore = preparation.tokensBefore;
125
+ const tokensAfterEst = totalChars > 0
126
+ ? Math.round(tokensBefore * keptChars / totalChars)
127
+ : 0;
128
+
95
129
  lastStats = {
96
130
  summarized: agentMessages.length,
97
- kept: keptEntries.length,
98
- keptTokensEst: Math.round(keptChars / 4),
131
+ kept: keptMessageEntries.length,
132
+ totalMessages: agentMessages.length + keptMessageEntries.length,
133
+ tokensBefore,
134
+ tokensAfterEst,
99
135
  };
100
136
 
101
137
  // Persist cumulative compaction stats
@@ -103,13 +139,7 @@ export function registerCompactionHooks(
103
139
  if (sessionDB && deps?.getSessionId) {
104
140
  try {
105
141
  const sessionId = deps.getSessionId();
106
- const charsBefore = agentMessages.reduce((sum: number, msg: any) => {
107
- const c = msg.message?.content;
108
- if (typeof c === "string") return sum + c.length;
109
- if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => s + (p.text?.length ?? 0), 0);
110
- return sum;
111
- }, 0);
112
- sessionDB.addCompactionStats(sessionId, charsBefore, keptChars, agentMessages.length);
142
+ sessionDB.addCompactionStats(sessionId, summarizedChars, keptChars, agentMessages.length);
113
143
  } catch {
114
144
  // non-fatal
115
145
  }
@@ -154,7 +184,7 @@ export function registerCompactionHooks(
154
184
  };
155
185
  });
156
186
 
157
- pi.on("session_compact", (event, ctx) => {
187
+ pi.on("session_compact", (event: SessionCompactEvent, ctx) => {
158
188
  const config = loadConfig();
159
189
  dbg(config.debug, "session_compact", { fromExtension: event.fromExtension, lastCompactWasCompactor });
160
190
  if (!event.fromExtension) return;
@@ -164,7 +194,7 @@ export function registerCompactionHooks(
164
194
  setTimeout(() => {
165
195
  try {
166
196
  ctx?.ui?.notify?.(
167
- `compactor: ${stats.summarized} source entries processed; tail kept ${stats.kept} (~${formatTokens(stats.keptTokensEst)} tok).`,
197
+ `Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
168
198
  "info",
169
199
  );
170
200
  } catch {}
@@ -12,7 +12,7 @@ export interface LineageRange {
12
12
  * the most recent compaction boundary.
13
13
  */
14
14
  export function getRecallScope(
15
- branchEntries: any[],
15
+ branchEntries: Array<{ type: string; [key: string]: unknown }>,
16
16
  opts?: { expand?: boolean },
17
17
  ): LineageRange {
18
18
  let lastCompactionIdx = -1;
File without changes
@@ -11,16 +11,16 @@ const preset = (
11
11
  ): CompactorConfig => ({
12
12
  ...structuredClone(DEFAULT_COMPACTOR_CONFIG),
13
13
  ...overrides,
14
- sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, ...(overrides.sessionGoals as any) },
15
- filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, ...(overrides.filesAndChanges as any) },
16
- commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, ...(overrides.commits as any) },
17
- outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, ...(overrides.outstandingContext as any) },
18
- userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, ...(overrides.userPreferences as any) },
19
- briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, ...(overrides.briefTranscript as any) },
20
- sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, ...(overrides.sessionContinuity as any) },
21
- fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, ...(overrides.fts5Index as any) },
22
- sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, ...(overrides.sandboxExecution as any) },
23
- toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay as any) },
14
+ sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, ...(overrides.sessionGoals ?? {}) },
15
+ filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, ...(overrides.filesAndChanges ?? {}) },
16
+ commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, ...(overrides.commits ?? {}) },
17
+ outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, ...(overrides.outstandingContext ?? {}) },
18
+ userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, ...(overrides.userPreferences ?? {}) },
19
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, ...(overrides.briefTranscript ?? {}) },
20
+ sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, ...(overrides.sessionContinuity ?? {}) },
21
+ fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, ...(overrides.fts5Index ?? {}) },
22
+ sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, ...(overrides.sandboxExecution ?? {}) },
23
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay ?? {}) },
24
24
  });
25
25
 
26
26
  // Pipeline feature defaults per preset:
@@ -122,11 +122,11 @@ export class PolyglotExecutor {
122
122
  }
123
123
 
124
124
  return result;
125
- } catch (err: any) {
125
+ } catch (err: unknown) {
126
126
  try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
127
127
  return {
128
128
  stdout: "",
129
- stderr: err?.message ?? String(err),
129
+ stderr: (err instanceof Error ? err.message : String(err)),
130
130
  exitCode: 1,
131
131
  timedOut: false,
132
132
  };
@@ -228,10 +228,10 @@ export class PolyglotExecutor {
228
228
  timeout: timeout / 2,
229
229
  stdio: ["ignore", "pipe", "pipe"],
230
230
  });
231
- } catch (err: any) {
231
+ } catch (err: unknown) {
232
232
  return {
233
233
  stdout: "",
234
- stderr: err?.stderr?.toString?.() ?? err?.message ?? "Rust compilation failed",
234
+ stderr: (err instanceof Error && 'stderr' in err ? String((err as Error & { stderr?: Buffer }).stderr) : '') || (err instanceof Error ? err.message : String(err)) || "Rust compilation failed",
235
235
  exitCode: 1,
236
236
  timedOut: false,
237
237
  };
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ import { registerCompactionHooks } from "./compaction/hooks.js";
9
9
  import { SessionDB, getWorktreeSuffix } from "./session/db.js";
10
10
  import { extractEventsFromToolResult } from "./session/extract.js";
11
11
  import { injectResumeSnapshot } from "./session/resume-inject.js";
12
- import { ContentStore } from "./store/index.js";
13
12
  import { PolyglotExecutor } from "./executor/executor.js";
14
13
  import { registerCommands } from "./commands/index.js";
15
14
  import { registerCompactorTools } from "./tools/register.js";
@@ -57,7 +56,6 @@ function isIndexTool(_name: string): boolean {
57
56
 
58
57
  export default function compactorExtension(pi: ExtensionAPI): void {
59
58
  let sessionDB: SessionDB | null = null;
60
- let contentStore: ContentStore | null = null;
61
59
  let executor: PolyglotExecutor | null = null;
62
60
  let config = loadConfig();
63
61
  let cachedBlocks: NormalizedBlock[] = [];
@@ -101,28 +99,19 @@ export default function compactorExtension(pi: ExtensionAPI): void {
101
99
  sessionDB = null;
102
100
  }
103
101
 
104
- // Initialize ContentStore independently — its failure shouldn't
105
- // prevent SessionDB commands from working.
106
- if (config.fts5Index.enabled) {
107
- try {
108
- const cs = new ContentStore();
109
- await cs.init();
110
- contentStore = cs;
111
- } catch {
112
- // Silently ignore — ContentStore init failure is handled gracefully.
113
- contentStore = null;
114
- }
115
- }
116
-
117
102
  executor = new PolyglotExecutor();
118
103
  };
119
104
 
120
- registerCompactionHooks(pi);
105
+ // Register compaction hooks with lazy deps — sessionDB/sessionId may not be
106
+ // available at registration time, but will be by the time events fire.
107
+ registerCompactionHooks(pi, {
108
+ getSessionDB: () => sessionDB,
109
+ getSessionId: () => currentSessionId,
110
+ });
121
111
 
122
112
  // Commands registered inside session_start after init() when deps are ready
123
113
  const getCommandDeps = () => ({
124
114
  sessionDB,
125
- contentStore,
126
115
  getSessionId: () => currentSessionId,
127
116
  getBlocks: () => cachedBlocks,
128
117
  getCounters,
@@ -139,6 +128,17 @@ export default function compactorExtension(pi: ExtensionAPI): void {
139
128
 
140
129
  debug("session_start", { sessionId: fullSessionId, projectDir });
141
130
 
131
+ // Seed runtime counters from DB so they reflect prior usage
132
+ if (sessionDB) {
133
+ try {
134
+ const allTime = sessionDB.getAllTimeStats();
135
+ counters.sandboxRuns = allTime.allSandboxRuns;
136
+ counters.searchQueries = allTime.allSearchQueries;
137
+ } catch {
138
+ // Non-fatal: counter seeding from DB failed
139
+ }
140
+ }
141
+
142
142
  // Reset runtime stats for new session
143
143
  runtimeStats.bytesReturned = {};
144
144
  runtimeStats.bytesIndexed = 0;
@@ -154,7 +154,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
154
154
  if (sessionDB) {
155
155
  registerCompactorTools(pi, {
156
156
  sessionDB,
157
- contentStore,
158
157
  getSessionId: () => currentSessionId,
159
158
  getBlocks: () => cachedBlocks,
160
159
  getCounters,
@@ -165,7 +164,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
165
164
  registerCommands(pi, getCommandDeps());
166
165
 
167
166
  // Register info-screen group
168
- const infoRegistry = (globalThis as any).__unipi_info_registry;
167
+ const infoRegistry = globalThis.__unipi_info_registry;
169
168
  if (infoRegistry && sessionDB) {
170
169
  const sdb = sessionDB;
171
170
  const sid = () => currentSessionId;
@@ -188,7 +187,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
188
187
  dataProvider: async () => {
189
188
  try {
190
189
  const { getInfoScreenData } = await import("./info-screen.js");
191
- const data = await getInfoScreenData(sdb, sid(), runtimeStats);
190
+ const data = await getInfoScreenData(sdb, sid(), getCounters());
192
191
  return {
193
192
  tokensSaved: { value: data.tokensSaved.value, detail: data.tokensSaved.detail },
194
193
  costSaved: { value: data.costSaved.value, detail: data.costSaved.detail },
@@ -204,7 +203,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
204
203
  });
205
204
  }
206
205
 
207
- emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
206
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
208
207
  name: MODULES.COMPACTOR,
209
208
  version: "0.1.0",
210
209
  commands: Object.values(COMPACTOR_COMMANDS),
@@ -213,10 +212,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
213
212
 
214
213
  debug("MODULE_READY", { commands: Object.values(COMPACTOR_COMMANDS), tools: Object.values(COMPACTOR_TOOLS) });
215
214
 
216
- if (config.fts5Index.mode === "auto" && contentStore) {
217
- // TODO: index project files
218
- }
219
-
220
215
  ctx.ui.notify("🗜️ Compactor ready", "info");
221
216
  });
222
217
 
@@ -232,7 +227,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
232
227
  const { join } = await import("node:path");
233
228
  const strategies: Array<{ key: string; config: CompactorStrategyConfig }> = [
234
229
  { key: "commits", config: config.commits },
235
- { key: "fts5Index", config: config.fts5Index },
236
230
  ];
237
231
  for (const { key, config: strat } of strategies) {
238
232
  if ((strat as any).autoDetect === "git") {
@@ -283,7 +277,9 @@ export default function compactorExtension(pi: ExtensionAPI): void {
283
277
 
284
278
  pi.on("session_before_compact", async (event, _ctx) => {
285
279
  if (sessionDB) {
286
- const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
280
+ // Use closure currentSessionId Pi's session_before_compact event
281
+ // does not include sessionId at the top level.
282
+ const sessionId = currentSessionId;
287
283
  const events = sessionDB.getEvents(sessionId, { limit: 1000 });
288
284
  const stats = sessionDB.getSessionStats(sessionId);
289
285
  debug("session_before_compact", { sessionId, eventCount: events.length, compactCount: stats?.compact_count ?? 0 });
@@ -297,44 +293,55 @@ export default function compactorExtension(pi: ExtensionAPI): void {
297
293
 
298
294
  pi.on("session_compact", async (event, _ctx) => {
299
295
  if (sessionDB) {
300
- const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
296
+ // Use closure currentSessionId Pi's session_compact event does not
297
+ // include sessionId at the top level (it's inside compactionEntry).
298
+ const sessionId = currentSessionId;
301
299
  sessionDB.incrementCompactCount(sessionId);
302
300
  counters.compactions++;
303
301
 
304
- // Use actual runtimeStats for byte measurement instead of heuristic
305
- const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce((s, b) => s + b, 0);
306
- const totalBytesProcessed = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed + totalBytesReturned;
307
- // charsBefore = total bytes processed by all tools (proxy for context window usage)
308
- // charsKept = bytes that stayed in context (bytesReturned, minus what compaction removed)
309
- const tokensBefore = (event as any).tokensBefore ?? 0;
310
- if (totalBytesProcessed > 0 && tokensBefore > 0) {
311
- // Use actual token count from Pi, estimate chars from it
302
+ // Pi's session_compact event structure: { compactionEntry, fromExtension }
303
+ // tokensBefore is inside compactionEntry, not at event root.
304
+ const compactionEntry = (event as any).compactionEntry;
305
+ const tokensBefore = compactionEntry?.tokensBefore ?? 0;
306
+
307
+ if (tokensBefore > 0) {
308
+ // Use actual token count from Pi's compactionEntry.
309
+ // Compaction typically keeps ~10-15% of original context.
312
310
  const charsBefore = tokensBefore * 4;
313
- // Estimate kept chars: proportional to what remains after compaction
314
- const tokensAfter = (event as any).tokensAfter ?? Math.round(tokensBefore * 0.15);
311
+ const tokensAfter = (event as any).tokensAfter ?? Math.round(tokensBefore * 0.12);
315
312
  const charsKept = tokensAfter * 4;
316
313
  const messagesSummarized = Math.max(1, Math.round(tokensBefore / 500));
317
314
  counters.totalTokensCompacted += tokensBefore - tokensAfter;
318
315
  sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, messagesSummarized);
319
- } else if (tokensBefore > 0) {
320
- // Fallback: only tokensBefore available, use conservative estimate
321
- const tokensAfter = (event as any).tokensAfter ?? Math.round(tokensBefore * 0.15);
322
- counters.totalTokensCompacted += tokensBefore - tokensAfter;
323
- sessionDB.addCompactionStats(sessionId, tokensBefore * 4, tokensAfter * 4, 1);
316
+ } else {
317
+ // tokensBefore unavailable use session event count as a rough heuristic.
318
+ // This happens when Pi's compaction doesn't report tokensBefore.
319
+ // We estimate from the number of events stored in this session.
320
+ try {
321
+ const eventCount = sessionDB.getEventCount(sessionId);
322
+ if (eventCount > 0) {
323
+ // Rough estimate: ~500 tokens per event on average
324
+ const estTokensBefore = eventCount * 500;
325
+ const estTokensAfter = Math.round(estTokensBefore * 0.12);
326
+ const charsBefore = estTokensBefore * 4;
327
+ const charsKept = estTokensAfter * 4;
328
+ counters.totalTokensCompacted += estTokensBefore - estTokensAfter;
329
+ sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, eventCount);
330
+ }
331
+ } catch {
332
+ // Non-fatal: heuristic estimation failed
333
+ }
324
334
  }
325
- debug("session_compact", { sessionId, tokensBefore, totalBytesProcessed });
335
+ debug("session_compact", { sessionId, tokensBefore, hasCompactionEntry: !!compactionEntry });
326
336
  }
327
337
  });
328
338
 
329
339
  pi.on("session_shutdown", async (_event, _ctx) => {
330
340
  debug("session_shutdown");
331
- // WAL checkpoint: TRUNCATE on shutdown to keep DB file size down
332
- contentStore?.checkpointWAL("TRUNCATE");
333
341
  if (sessionDB) {
334
342
  sessionDB.cleanupOldSessions(7);
335
343
  }
336
344
  executor?.cleanupBackgrounded();
337
- contentStore?.close();
338
345
  sessionDB?.close();
339
346
  });
340
347
 
@@ -412,7 +419,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
412
419
 
413
420
  pi.on("tool_result", async (event, _ctx) => {
414
421
  if (!sessionDB) return;
415
- const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
422
+ // Use closure currentSessionId tool_result events use the same session
423
+ const sessionId = currentSessionId;
416
424
  const toolNameRaw = (event as any).toolName ?? "";
417
425
  const isError = (event as any).isError ?? false;
418
426