@pi-unipi/compactor 0.2.3 → 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.
package/README.md CHANGED
@@ -8,7 +8,7 @@ The zero-LLM pipeline compresses context through 6 stages (normalize, filter, bu
8
8
 
9
9
  | Command | Description |
10
10
  |---------|-------------|
11
- | `/unipi:compact` | Manual compaction with stats |
11
+ | `/unipi:lossless-compact` | Immediate zero-LLM compaction with structured summary |
12
12
  | `/unipi:session-recall` | Search session history (BM25 or regex) |
13
13
  | `/unipi:content-index` | Index current project into FTS5 |
14
14
  | `/unipi:content-search` | Search indexed content |
@@ -19,6 +19,8 @@ The zero-LLM pipeline compresses context through 6 stages (normalize, filter, bu
19
19
  | `/unipi:compact-preset <name>` | Apply quick preset |
20
20
  | `/unipi:compact-help` | Show detailed documentation |
21
21
 
22
+ > **Note:** `/unipi:compact` still works as a deprecated alias for `/unipi:lossless-compact`.
23
+
22
24
  ## Special Triggers
23
25
 
24
26
  Compactor tools are available to the main agent when installed. All workflow skills can use compactor tools for context management.
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "0.2.3",
4
- "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, FTS5 search, and tool display optimization",
3
+ "version": "2.0.0",
4
+ "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, and tool display optimization",
5
5
  "type": "module",
6
6
  "license": "MIT",
7
7
  "author": "Neuron Mr White",
@@ -2,33 +2,22 @@
2
2
  * All /unipi:compact-* commands
3
3
  *
4
4
  * Commands perform real work by calling tool implementations directly.
5
- * Dependencies (sessionDB, contentStore, sessionId) are injected at registration time.
6
- *
7
- * New command names (v0.2.0):
8
- * /unipi:session-recall (was /unipi:compact-recall)
9
- * /unipi:content-index (was /unipi:compact-index)
10
- * /unipi:content-search (was /unipi:compact-search)
11
- * /unipi:content-purge (was /unipi:compact-purge)
12
- *
13
- * Old names kept as deprecated aliases for backward compatibility.
5
+ * Dependencies (sessionDB, sessionId) are injected at registration time.
14
6
  */
15
7
 
16
- import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
8
+ import type { ExtensionAPI, ExtensionCommandContext } from "@mariozechner/pi-coding-agent";
17
9
  import { loadConfig, saveConfig } from "../config/manager.js";
18
10
  import { applyPreset, parsePreset } from "../config/presets.js";
11
+ import { COMPACTOR_INSTRUCTION } from "@pi-unipi/core";
19
12
  import { getLastCompactionStats } from "../compaction/hooks.js";
20
- import { compactTool } from "../tools/compact.js";
21
13
  import { vccRecall } from "../tools/vcc-recall.js";
22
14
  import { ctxStats } from "../tools/ctx-stats.js";
23
15
  import { ctxDoctor } from "../tools/ctx-doctor.js";
24
- import { ctxSearch } from "../tools/ctx-search.js";
25
- import { ContentStore } from "../store/index.js";
26
16
  import type { SessionDB } from "../session/db.js";
27
17
  import type { NormalizedBlock, RuntimeCounters } from "../types.js";
28
18
 
29
19
  export interface CommandDeps {
30
20
  sessionDB: SessionDB | null;
31
- contentStore: ContentStore | null;
32
21
  getSessionId: () => string;
33
22
  getBlocks: () => NormalizedBlock[];
34
23
  getCounters?: () => RuntimeCounters;
@@ -38,22 +27,70 @@ function deprecationLog(_oldName: string, _newName: string): void {
38
27
  // Deprecation logging disabled — was writing to stdout causing TUI rendering issues.
39
28
  }
40
29
 
30
+ const formatTokens = (n: number): string => {
31
+ if (n >= 1000) return `${(n / 1000).toFixed(1)}k`;
32
+ return String(n);
33
+ };
34
+
41
35
  export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
42
- // ── /unipi:compact ──────────────────────────────────
36
+ // ── /unipi:lossless-compact ──────────────────────────
37
+ pi.registerCommand("unipi:lossless-compact", {
38
+ description: "Immediate zero-LLM compaction — structured summary with full recall",
39
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
40
+ ctx.compact({
41
+ customInstructions: COMPACTOR_INSTRUCTION,
42
+ onComplete: () => {
43
+ const stats = getLastCompactionStats();
44
+ if (stats) {
45
+ ctx.ui.notify(
46
+ `Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) → ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
47
+ "info",
48
+ );
49
+ } else {
50
+ ctx.ui.notify("Compaction completed.", "info");
51
+ }
52
+ },
53
+ onError: (err: Error) => {
54
+ if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
55
+ ctx.ui.notify("Nothing to compact.", "info");
56
+ } else {
57
+ ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
58
+ }
59
+ },
60
+ });
61
+ },
62
+ });
63
+ // Deprecated alias — old name
43
64
  pi.registerCommand("unipi:compact", {
44
- description: "Trigger manual compaction with stats",
45
- handler: async (_args: string, ctx: any) => {
46
- const result = compactTool();
47
- const stats = getLastCompactionStats();
48
- const msg = stats
49
- ? `🗜️ Compaction: ${stats.summarized} summarized, ${stats.kept} kept (~${stats.keptTokensEst} tok)\n${result.message}`
50
- : `🗜️ ${result.message}`;
51
- ctx.ui.notify(msg, "info");
65
+ description: "(DEPRECATED) Use /unipi:lossless-compact instead",
66
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
67
+ deprecationLog("/unipi:compact", "/unipi:lossless-compact");
68
+ ctx.compact({
69
+ customInstructions: COMPACTOR_INSTRUCTION,
70
+ onComplete: () => {
71
+ const stats = getLastCompactionStats();
72
+ if (stats) {
73
+ ctx.ui.notify(
74
+ `Compacted ${stats.totalMessages} messages (~${formatTokens(stats.tokensBefore)} tokens) → ${stats.kept} messages (~${formatTokens(stats.tokensAfterEst)} tokens)`,
75
+ "info",
76
+ );
77
+ } else {
78
+ ctx.ui.notify("Compaction completed.", "info");
79
+ }
80
+ },
81
+ onError: (err: Error) => {
82
+ if (err.message === "Compaction cancelled" || err.message === "Already compacted") {
83
+ ctx.ui.notify("Nothing to compact.", "info");
84
+ } else {
85
+ ctx.ui.notify(`Compaction failed: ${err.message}`, "error");
86
+ }
87
+ },
88
+ });
52
89
  },
53
90
  });
54
91
 
55
92
  // ── /unipi:session-recall (new) ─────────────────────
56
- const sessionRecallHandler = async (args: string, ctx: any) => {
93
+ const sessionRecallHandler = async (args: string, ctx: ExtensionCommandContext) => {
57
94
  const query = args.trim();
58
95
  if (!query) {
59
96
  ctx.ui.notify("Usage: /unipi:session-recall <query>", "warning");
@@ -81,7 +118,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
81
118
  // Deprecated alias
82
119
  pi.registerCommand("unipi:compact-recall", {
83
120
  description: "(DEPRECATED) Search session history — use /unipi:session-recall instead",
84
- handler: async (args: string, ctx: any) => {
121
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
85
122
  deprecationLog("/unipi:compact-recall", "/unipi:session-recall");
86
123
  return sessionRecallHandler(args, ctx);
87
124
  },
@@ -90,19 +127,18 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
90
127
  // ── /unipi:compact-stats ─────────────────────────────
91
128
  pi.registerCommand("unipi:compact-stats", {
92
129
  description: "Show context savings dashboard",
93
- handler: async (_args: string, ctx: any) => {
94
- if (!deps?.sessionDB || !deps?.contentStore) {
130
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
131
+ if (!deps?.sessionDB) {
95
132
  ctx.ui.notify("Compactor services not initialized.", "error");
96
133
  return;
97
134
  }
98
135
  try {
99
- const stats = await ctxStats(deps.sessionDB, deps.contentStore, deps.getSessionId(), deps.getCounters?.());
136
+ const stats = await ctxStats(deps.sessionDB, deps.getSessionId(), deps.getCounters?.());
100
137
  const lines = [
101
138
  "📊 Compactor Stats",
102
139
  `Session events: ${stats.sessionEvents}`,
103
140
  `Compactions: ${stats.compactions}`,
104
141
  `Tokens saved: ${stats.tokensSaved}`,
105
- `Indexed docs: ${stats.indexedDocs} (${stats.indexedChunks} chunks)`,
106
142
  `Sandbox runs: ${stats.sandboxRuns}`,
107
143
  `Search queries: ${stats.searchQueries}`,
108
144
  ];
@@ -116,13 +152,13 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
116
152
  // ── /unipi:compact-doctor ────────────────────────────
117
153
  pi.registerCommand("unipi:compact-doctor", {
118
154
  description: "Run diagnostics checklist",
119
- handler: async (_args: string, ctx: any) => {
120
- if (!deps?.sessionDB || !deps?.contentStore) {
155
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
156
+ if (!deps?.sessionDB) {
121
157
  ctx.ui.notify("Compactor services not initialized.", "error");
122
158
  return;
123
159
  }
124
160
  try {
125
- const result = await ctxDoctor(deps.sessionDB, deps.contentStore);
161
+ const result = await ctxDoctor(deps.sessionDB);
126
162
  const icon = (s: string) => (s === "pass" ? "✅" : s === "warn" ? "⚠️" : "❌");
127
163
  const lines = [
128
164
  result.healthy ? "🩺 All checks passed" : "🩺 Issues found",
@@ -139,7 +175,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
139
175
  // ── /unipi:compact-settings ──────────────────────────
140
176
  pi.registerCommand("unipi:compact-settings", {
141
177
  description: "Open TUI settings overlay",
142
- handler: async (_args: string, ctx: any) => {
178
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
143
179
  try {
144
180
  const cwd = (ctx as any).cwd ?? process.cwd();
145
181
  const { renderSettingsOverlay } = await import("../tui/settings-overlay.js");
@@ -158,7 +194,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
158
194
  // ── /unipi:compact-preset ────────────────────────────
159
195
  pi.registerCommand("unipi:compact-preset", {
160
196
  description: "Apply quick preset (precise/balanced/thorough/lean)",
161
- handler: async (args: string, ctx: any) => {
197
+ handler: async (args: string, ctx: ExtensionCommandContext) => {
162
198
  const presetName = parsePreset(args.trim());
163
199
  if (!presetName) {
164
200
  ctx.ui.notify("Unknown preset. Use: precise, balanced, thorough, lean", "error");
@@ -174,150 +210,24 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
174
210
  },
175
211
  });
176
212
 
177
- // ── /unipi:content-index (new) / /unipi:compact-index (deprecated) ──
178
- const contentIndexHandler = async (_args: string, ctx: any) => {
179
- if (!deps?.contentStore) {
180
- ctx.ui.notify("Content store not initialized. Enable fts5Index in config.", "warning");
181
- return;
182
- }
183
- try {
184
- const cwd = (ctx as any).cwd ?? process.cwd();
185
- const { readdirSync, readFileSync, statSync } = await import("node:fs");
186
- const { join, relative, extname } = await import("node:path");
187
-
188
- const indexable = [".md", ".txt", ".ts", ".js", ".json", ".py", ".sh"];
189
- const files: string[] = [];
190
-
191
- const walk = (dir: string, depth = 0) => {
192
- if (depth > 3) return;
193
- try {
194
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
195
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
196
- const full = join(dir, entry.name);
197
- if (entry.isDirectory()) {
198
- walk(full, depth + 1);
199
- } else if (indexable.includes(extname(entry.name))) {
200
- files.push(full);
201
- }
202
- }
203
- } catch {
204
- // skip unreadable dirs
205
- }
206
- };
207
-
208
- walk(cwd);
209
- let totalChunks = 0;
210
- for (const file of files.slice(0, 100)) {
211
- try {
212
- const content = readFileSync(file, "utf-8");
213
- if (content.length < 50) continue;
214
- const ext = extname(file);
215
- const ct = ext === ".md" ? "markdown" : ext === ".json" ? "json" : "plain";
216
- const result = await deps.contentStore!.index(relative(cwd, file), content, {
217
- contentType: ct,
218
- source: file,
219
- });
220
- totalChunks += result.totalChunks;
221
- } catch {
222
- // skip unreadable files
223
- }
224
- }
225
- ctx.ui.notify(`Indexed ${Math.min(files.length, 100)} files (${totalChunks} chunks).`, "info");
226
- } catch (err) {
227
- ctx.ui.notify(`Index error: ${err}`, "error");
228
- }
229
- };
230
- pi.registerCommand("unipi:content-index", {
231
- description: "Index current project files into FTS5",
232
- handler: contentIndexHandler,
233
- });
234
- pi.registerCommand("unipi:compact-index", {
235
- description: "(DEPRECATED) Index project files — use /unipi:content-index instead",
236
- handler: async (args: string, ctx: any) => {
237
- deprecationLog("/unipi:compact-index", "/unipi:content-index");
238
- return contentIndexHandler(args, ctx);
239
- },
240
- });
241
-
242
- // ── /unipi:content-search (new) / /unipi:compact-search (deprecated) ──
243
- const contentSearchHandler = async (args: string, ctx: any) => {
244
- const query = args.trim();
245
- if (!query) {
246
- ctx.ui.notify("Usage: /unipi:content-search <query>", "warning");
247
- return;
248
- }
249
- if (!deps?.contentStore) {
250
- ctx.ui.notify("Content store not initialized.", "warning");
251
- return;
252
- }
253
- try {
254
- const results = await ctxSearch(deps.contentStore!, { query, limit: 10 });
255
- if (results.length === 0) {
256
- ctx.ui.notify(`No results for "${query}".`, "info");
257
- return;
258
- }
259
- const lines = results.map(
260
- (r, i) => `[${i + 1}] ${r.title} (rank: ${r.rank.toFixed(3)})\n${r.content.slice(0, 200)}`,
261
- );
262
- ctx.ui.notify(`Found ${results.length} results:\n${lines.join("\n\n")}`, "info");
263
- } catch (err) {
264
- ctx.ui.notify(`Search error: ${err}`, "error");
265
- }
266
- };
267
- pi.registerCommand("unipi:content-search", {
268
- description: "Search indexed content",
269
- handler: contentSearchHandler,
270
- });
271
- pi.registerCommand("unipi:compact-search", {
272
- description: "(DEPRECATED) Search indexed content — use /unipi:content-search instead",
273
- handler: async (args: string, ctx: any) => {
274
- deprecationLog("/unipi:compact-search", "/unipi:content-search");
275
- return contentSearchHandler(args, ctx);
276
- },
277
- });
278
-
279
- // ── /unipi:content-purge (new) / /unipi:compact-purge (deprecated) ──
280
- const contentPurgeHandler = async (_args: string, ctx: any) => {
281
- if (!deps?.contentStore) {
282
- ctx.ui.notify("Content store not initialized.", "warning");
283
- return;
284
- }
285
- try {
286
- await deps.contentStore!.purge();
287
- ctx.ui.notify("All indexed content purged.", "info");
288
- } catch (err) {
289
- ctx.ui.notify(`Purge error: ${err}`, "error");
290
- }
291
- };
292
- pi.registerCommand("unipi:content-purge", {
293
- description: "Wipe all indexed content from FTS5",
294
- handler: contentPurgeHandler,
295
- });
296
- pi.registerCommand("unipi:compact-purge", {
297
- description: "(DEPRECATED) Wipe indexed content — use /unipi:content-purge instead",
298
- handler: async (args: string, ctx: any) => {
299
- deprecationLog("/unipi:compact-purge", "/unipi:content-purge");
300
- return contentPurgeHandler(args, ctx);
301
- },
302
- });
303
-
304
213
  // ── /unipi:compact-help ──────────────────────────────
305
214
  pi.registerCommand("unipi:compact-help", {
306
215
  description: "Show detailed compactor documentation (tier-2 skill)",
307
- handler: async (_args: string, ctx: any) => {
308
- // Load tier-2 skill content — delegates to skill loading system
216
+ handler: async (_args: string, ctx: ExtensionCommandContext) => {
309
217
  ctx.ui.notify(
310
- "🗜️ Compactor Help — Use your compactor-detail skill for full documentation.\n" +
218
+ "🗜️ Compactor Help\n" +
311
219
  "Quick commands:\n" +
312
- " /unipi:compact — trigger compaction\n" +
220
+ " /unipi:lossless-compact — trigger immediate compaction\n" +
313
221
  " /unipi:session-recall <query> — search session history\n" +
314
- " /unipi:content-index — index project files\n" +
315
- " /unipi:content-search <query> — search indexed content\n" +
316
- " /unipi:content-purge — wipe indexed content\n" +
317
222
  " /unipi:compact-stats — view stats\n" +
318
223
  " /unipi:compact-doctor — run diagnostics\n" +
319
224
  " /unipi:compact-settings — TUI settings\n" +
320
- " /unipi:compact-preset <name> — apply preset",
225
+ " /unipi:compact-preset <name> — apply preset\n" +
226
+ "\n" +
227
+ "Content indexing has moved to @pi-unipi/cocoindex:\n" +
228
+ " /unipi:cocoindex-init — initialize pipeline\n" +
229
+ " /unipi:cocoindex-update — index project files\n" +
230
+ " cocoindex_search — search indexed content",
321
231
  "info",
322
232
  );
323
233
  },
@@ -7,8 +7,8 @@ export function textOf(content: unknown): string {
7
7
  if (typeof content === "string") return content;
8
8
  if (Array.isArray(content)) {
9
9
  return content
10
- .map((c: any) => {
11
- if (c?.type === "text") return c.text ?? "";
10
+ .map((c: Record<string, unknown>) => {
11
+ if (c?.type === "text") return (c.text as string) ?? "";
12
12
  if (c?.type === "toolCall") return `[toolCall:${c.name}]`;
13
13
  if (c?.type === "thinking") return "[thinking]";
14
14
  if (c?.type === "image") return `[image:${c.mimeType}]`;
@@ -7,28 +7,32 @@ export type OwnCutCancelReason =
7
7
  | "too_few_live_messages"
8
8
  | "no_user_message";
9
9
 
10
+ import type { SessionEntry, SessionMessageEntry, CompactionEntry } from "@mariozechner/pi-coding-agent";
11
+ import type { AgentMessage } from "@mariozechner/pi-agent-core";
12
+
10
13
  export type OwnCutResult =
11
- | { ok: true; messages: any[]; firstKeptEntryId: string; compactAll: boolean }
14
+ | { ok: true; messages: AgentMessage[]; firstKeptEntryId: string; compactAll: boolean }
12
15
  | { ok: false; reason: OwnCutCancelReason };
13
16
 
14
17
  interface EntryWithMessage {
15
- entry: { id: string; type: string };
16
- message: { role: string; content: unknown };
18
+ entry: SessionEntry;
19
+ message: AgentMessage;
17
20
  }
18
21
 
19
- export function buildOwnCut(branchEntries: any[]): OwnCutResult {
22
+ export function buildOwnCut(branchEntries: SessionEntry[]): OwnCutResult {
20
23
  let lastCompactionIdx = -1;
21
24
  let lastKeptId: string | undefined;
22
25
  for (let i = branchEntries.length - 1; i >= 0; i--) {
23
26
  if (branchEntries[i].type === "compaction") {
24
27
  lastCompactionIdx = i;
25
- lastKeptId = branchEntries[i].firstKeptEntryId;
28
+ const ce = branchEntries[i] as CompactionEntry;
29
+ lastKeptId = ce.firstKeptEntryId;
26
30
  break;
27
31
  }
28
32
  }
29
33
 
30
34
  const hasPriorCompaction = lastCompactionIdx >= 0;
31
- const hasValidKeptId = !!lastKeptId && branchEntries.some((e: any) => e.id === lastKeptId);
35
+ const hasValidKeptId = !!lastKeptId && branchEntries.some((e) => e.id === lastKeptId);
32
36
  const orphanRecovery = hasPriorCompaction && !hasValidKeptId;
33
37
 
34
38
  const liveMessages: EntryWithMessage[] = [];
@@ -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