@pi-unipi/compactor 0.2.1 → 0.2.3

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
@@ -1,14 +1,8 @@
1
1
  # @pi-unipi/compactor
2
2
 
3
- Context engine for Pi coding agent. Fuses zero-LLM compaction, session continuity, sandbox execution, FTS5 search, and tool display optimization into a single cohesive package.
3
+ Context engine that keeps sessions lean. Compacts conversations, indexes code, searches history, and runs sandboxed code all without burning LLM tokens on compaction.
4
4
 
5
- ## Features
6
-
7
- - **Zero-LLM Compaction** — 6-stage pipeline (normalize → filter → build sections → brief → format → merge) achieves 95%+ token reduction with zero API cost
8
- - **Session Continuity** — XML resume snapshots survive compaction, preserving context across session boundaries
9
- - **Sandbox Execution** — 11 languages with process isolation, security hardening, and output capping
10
- - **FTS5 Search** — Full-text search over indexed content with auto-chunking
11
- - **Tool Display** — Mode-aware rendering for read, grep, find, ls, bash, edit, write tools
5
+ The zero-LLM pipeline compresses context through 6 stages (normalize, filter, build sections, brief, format, merge) to hit 95%+ token reduction at zero API cost. Session continuity preserves context across compaction boundaries with XML resume snapshots.
12
6
 
13
7
  ## Commands
14
8
 
@@ -25,6 +19,12 @@ Context engine for Pi coding agent. Fuses zero-LLM compaction, session continuit
25
19
  | `/unipi:compact-preset <name>` | Apply quick preset |
26
20
  | `/unipi:compact-help` | Show detailed documentation |
27
21
 
22
+ ## Special Triggers
23
+
24
+ Compactor tools are available to the main agent when installed. All workflow skills can use compactor tools for context management.
25
+
26
+ Compactor registers with the info-screen dashboard, showing compaction count, tokens saved, compression ratio, and indexed documents. The footer subscribes to `COMPACTOR_STATSUPDATED` events to display compaction stats in the status bar.
27
+
28
28
  ## Agent Tools
29
29
 
30
30
  | Tool | Family | Description |
@@ -34,19 +34,19 @@ Context engine for Pi coding agent. Fuses zero-LLM compaction, session continuit
34
34
  | `sandbox` | sandbox | Run code in sandbox (11 languages) |
35
35
  | `sandbox_file` | sandbox | Execute file via FILE_CONTENT |
36
36
  | `sandbox_batch` | sandbox | Atomic batch of commands + searches |
37
- | `content_index` | content | Chunk content FTS5 index |
37
+ | `content_index` | content | Chunk content to FTS5 index |
38
38
  | `content_search` | content | Query indexed content |
39
- | `content_fetch` | content | Fetch URL index |
39
+ | `content_fetch` | content | Fetch URL and index |
40
40
  | `compactor_stats` | compactor | Context savings dashboard |
41
41
  | `compactor_doctor` | compactor | Diagnostics checklist |
42
42
  | `context_budget` | compactor | Estimate remaining context window |
43
43
 
44
44
  ## Two-Tier Skills
45
45
 
46
- - **Tier 1** (`compactor`): ~175 tokens, always loaded. Routing + critical rules + Ralph awareness.
46
+ - **Tier 1** (`compactor`): ~175 tokens, always loaded. Routing and critical rules.
47
47
  - **Tier 2** (`compactor-detail`): On-demand. Full tool reference, anti-patterns, sandbox languages, FTS5 modes, workflows.
48
48
 
49
- ## Configuration
49
+ ## Configurables
50
50
 
51
51
  Config lives at `~/.unipi/config/compactor/config.json`. Per-project overrides at `<project>/.unipi/config/compactor.json`.
52
52
 
@@ -78,7 +78,7 @@ Tabbed settings interface (Presets / Strategies / Pipeline):
78
78
  - `/` key opens search filter in Strategies tab
79
79
  - Preset selection shows 3-line preview
80
80
  - Per-project override checkbox (`o` key)
81
- - Keyboard: `←→` cycle modes, `Space` toggle, `s` save, `Esc` cancel
81
+ - Keyboard: left/right cycle modes, Space toggle, `s` save, Esc cancel
82
82
 
83
83
  ## Architecture
84
84
 
@@ -95,18 +95,6 @@ Tabbed settings interface (Presets / Strategies / Pipeline):
95
95
  └─────────────────────┘
96
96
  ```
97
97
 
98
- ## Installation
99
-
100
- Included in `@pi-unipi/unipi` metapackage. To use standalone:
101
-
102
- ```json
103
- {
104
- "pi": {
105
- "extensions": ["node_modules/@pi-unipi/compactor/src/index.ts"]
106
- }
107
- }
108
- ```
109
-
110
98
  ## License
111
99
 
112
100
  MIT
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-unipi/compactor",
3
- "version": "0.2.1",
3
+ "version": "0.2.3",
4
4
  "description": "Context engine for Pi — zero-LLM compaction, session continuity, sandbox execution, FTS5 search, and tool display optimization",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -38,7 +38,8 @@
38
38
  "access": "public"
39
39
  },
40
40
  "dependencies": {
41
- "@pi-unipi/core": "*"
41
+ "@pi-unipi/core": "*",
42
+ "@pi-unipi/info-screen": "*"
42
43
  },
43
44
  "peerDependencies": {
44
45
  "@mariozechner/pi-coding-agent": "*",
@@ -34,8 +34,8 @@ export interface CommandDeps {
34
34
  getCounters?: () => RuntimeCounters;
35
35
  }
36
36
 
37
- function deprecationLog(oldName: string, newName: string): void {
38
- console.error(`[compactor] DEPRECATED: Command "${oldName}" used use "${newName}" instead.`);
37
+ function deprecationLog(_oldName: string, _newName: string): void {
38
+ // Deprecation logging disabledwas writing to stdout causing TUI rendering issues.
39
39
  }
40
40
 
41
41
  export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
@@ -21,11 +21,9 @@ const formatTokens = (n: number): string => {
21
21
  return String(n);
22
22
  };
23
23
 
24
- const dbg = (debug: boolean, event: string, data?: Record<string, unknown>) => {
25
- if (!debug) return;
26
- const ts = new Date().toISOString().slice(11, 23);
27
- const details = data ? " " + JSON.stringify(data) : "";
28
- console.error(`[compactor:${ts}] ${event}${details}`);
24
+ const dbg = (_debug: boolean, _event: string, _data?: Record<string, unknown>) => {
25
+ // Debug logging disabled — was writing to stdout causing TUI rendering issues.
26
+ return;
29
27
  };
30
28
 
31
29
  const previewContent = (content: unknown): string => {
@@ -58,7 +58,7 @@ export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
58
58
  mmapPragma: false,
59
59
  customNoisePatterns: [],
60
60
  },
61
- overrideDefaultCompaction: false,
61
+ overrideDefaultCompaction: true,
62
62
  debug: false,
63
63
  showTruncationHints: true,
64
64
  };
@@ -3,6 +3,7 @@
3
3
  */
4
4
 
5
5
  import type { DiffLayout } from "../types.js";
6
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
6
7
 
7
8
  export function selectDiffLayout(
8
9
  terminalWidth: number,
@@ -12,9 +13,13 @@ export function selectDiffLayout(
12
13
  return terminalWidth >= 100 ? "split" : "unified";
13
14
  }
14
15
 
16
+ /** Clamp text to maxWidth visible columns, ANSI-aware */
15
17
  export function clampWidth(text: string, maxWidth: number): string {
16
18
  return text
17
19
  .split("\n")
18
- .map((line) => (line.length > maxWidth ? line.slice(0, maxWidth - 3) + "..." : line))
20
+ .map((line) => {
21
+ if (visibleWidth(line) <= maxWidth) return line;
22
+ return truncateToWidth(line, maxWidth, "…");
23
+ })
19
24
  .join("\n");
20
25
  }
@@ -3,6 +3,8 @@
3
3
  * syntax highlighting, and Nerd Font detection
4
4
  */
5
5
 
6
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
7
+
6
8
  export type DiffLayout = "auto" | "split" | "unified";
7
9
  export type DiffIndicator = "bars" | "classic" | "nerd" | "none";
8
10
 
@@ -150,16 +152,28 @@ function indicatorChar(type: DiffLine["type"], style: DiffIndicator): string {
150
152
  return type === "add" ? "+ " : type === "remove" ? "- " : " ";
151
153
  }
152
154
 
153
- function renderUnified(diff: DiffLine[], indicator: DiffIndicator): string {
155
+ function renderUnified(
156
+ diff: DiffLine[],
157
+ indicator: DiffIndicator,
158
+ maxWidth?: number,
159
+ ): string {
154
160
  return diff.map((line) => {
155
161
  const prefix = indicator === "bars"
156
162
  ? (line.type === "add" ? "│ " : line.type === "remove" ? "│ " : " ")
157
163
  : indicatorChar(line.type, indicator);
158
- return prefix + line.text;
164
+ const rendered = prefix + line.text;
165
+ if (maxWidth && visibleWidth(rendered) > maxWidth) {
166
+ return truncateToWidth(rendered, maxWidth, "…");
167
+ }
168
+ return rendered;
159
169
  }).join("\n");
160
170
  }
161
171
 
162
- function renderSplit(diff: DiffLine[], indicator: DiffIndicator): string {
172
+ function renderSplit(
173
+ diff: DiffLine[],
174
+ indicator: DiffIndicator,
175
+ maxW?: number,
176
+ ): string {
163
177
  const left: string[] = [];
164
178
  const right: string[] = [];
165
179
 
@@ -176,12 +190,24 @@ function renderSplit(diff: DiffLine[], indicator: DiffIndicator): string {
176
190
  }
177
191
  }
178
192
 
179
- const maxWidth = Math.max(...left.map((l) => l.length), 40);
193
+ const halfW = maxW ? Math.floor(maxW / 2) - 2 : 40;
194
+ const colW = Math.max(
195
+ ...left.map((l) => visibleWidth(l)),
196
+ ...right.map((l) => visibleWidth(l)),
197
+ Math.min(halfW, 40),
198
+ );
180
199
  const result: string[] = [];
181
200
  for (let i = 0; i < left.length; i++) {
182
- const l = left[i].padEnd(maxWidth);
201
+ const lTrunc = visibleWidth(left[i]) > colW
202
+ ? truncateToWidth(left[i], colW, "…")
203
+ : left[i].padEnd(colW);
183
204
  const sep = left[i] && right[i] ? " │ " : " ";
184
- result.push(l + sep + right[i]);
205
+ let rLine = right[i];
206
+ if (maxW && visibleWidth(lTrunc + sep + rLine) > maxW) {
207
+ const rBudget = maxW - visibleWidth(lTrunc + sep);
208
+ rLine = truncateToWidth(rLine, Math.max(1, rBudget), "…");
209
+ }
210
+ result.push(lTrunc + sep + rLine);
185
211
  }
186
212
 
187
213
  return result.join("\n");
@@ -229,10 +255,10 @@ export function renderDiff(
229
255
  }
230
256
 
231
257
  if (effectiveLayout === "split") {
232
- return renderSplit(highlightedDiff, effectiveIndicator);
258
+ return renderSplit(highlightedDiff, effectiveIndicator, maxWidth);
233
259
  }
234
260
 
235
- return renderUnified(highlightedDiff, effectiveIndicator);
261
+ return renderUnified(highlightedDiff, effectiveIndicator, maxWidth);
236
262
  }
237
263
 
238
264
  export function renderEditDiffResult(
@@ -0,0 +1,83 @@
1
+ /**
2
+ * Diff width safety — truncate diff lines to terminal width
3
+ *
4
+ * Pi's renderDiff() in diff.js produces lines without width
5
+ * truncation. When a diff line's visible content exceeds the
6
+ * terminal width, the TUI crashes with:
7
+ * "Rendered line N exceeds terminal width (X > Y)"
8
+ *
9
+ * This module provides clampDiffToWidth() which truncates
10
+ * each diff line to a safe width, preventing TUI crashes.
11
+ *
12
+ * The diff format from pi's generateDiffString() is:
13
+ * [+/-/ ]LINE_NUM CONTENT
14
+ * e.g.: "+ 38 │ The root cause is a **compound failure**..."
15
+ *
16
+ * We detect the terminal width from process.stdout and clamp
17
+ * each line, accounting for the rendering overhead of the
18
+ * edit tool's Box nesting (approx 4-6 chars of padding).
19
+ */
20
+
21
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
22
+
23
+ /** Rendering overhead from Box nesting in edit tool components */
24
+ const RENDER_OVERHEAD = 6;
25
+
26
+ /** Minimum useful line width (don't clamp below this) */
27
+ const MIN_LINE_WIDTH = 20;
28
+
29
+ /**
30
+ * Get the terminal width with fallback.
31
+ * Uses process.stdout.columns (updated on resize).
32
+ */
33
+ function getTerminalWidth(): number {
34
+ return process.stdout?.columns ?? 80;
35
+ }
36
+
37
+ /**
38
+ * Clamp a diff string so every line fits within terminal width.
39
+ * Preserves the diff prefix (+/-/ ) and line number while
40
+ * truncating the content portion.
41
+ *
42
+ * @param diff - The diff string from generateDiffString()
43
+ * @param maxWidth - Override for terminal width (for testing)
44
+ * @returns The clamped diff string (may be same reference if no clamping needed)
45
+ */
46
+ export function clampDiffToWidth(
47
+ diff: string,
48
+ maxWidth?: number,
49
+ ): string {
50
+ const termW = maxWidth ?? getTerminalWidth();
51
+ const safeW = Math.max(MIN_LINE_WIDTH, termW - RENDER_OVERHEAD);
52
+
53
+ const lines = diff.split("\n");
54
+ let changed = false;
55
+
56
+ const result = lines.map((line) => {
57
+ const vw = visibleWidth(line);
58
+ if (vw <= safeW) return line;
59
+
60
+ changed = true;
61
+
62
+ // Try to preserve the diff prefix (+/-/ ) and line number
63
+ // Format: [+/-/ ]LINE_NUM CONTENT
64
+ // The prefix and line number are critical for readability
65
+ const prefixMatch = line.match(/^([+\- ])\s*(\d*)\s/);
66
+ if (prefixMatch) {
67
+ const prefixLen = prefixMatch[0].length;
68
+ // Calculate how much content we can keep
69
+ const contentBudget = safeW - prefixLen;
70
+ if (contentBudget >= MIN_LINE_WIDTH) {
71
+ const prefix = line.slice(0, prefixLen);
72
+ const content = line.slice(prefixLen);
73
+ const truncated = truncateToWidth(content, contentBudget, "…");
74
+ return prefix + truncated;
75
+ }
76
+ }
77
+
78
+ // Fallback: truncate the entire line
79
+ return truncateToWidth(line, safeW, "…");
80
+ });
81
+
82
+ return changed ? result.join("\n") : diff;
83
+ }
@@ -1,11 +1,23 @@
1
1
  /**
2
2
  * Line width safety — width clamping with collapsed hints
3
+ *
4
+ * Uses ANSI-aware visibleWidth measurement from pi-tui to properly
5
+ * handle lines containing escape codes. Falls back to raw-length
6
+ * measurement when pi-tui is unavailable.
3
7
  */
4
8
 
9
+ import { visibleWidth, truncateToWidth } from "@mariozechner/pi-tui";
10
+
11
+ /**
12
+ * Clamp each line to maxWidth visible columns.
13
+ * Uses pi-tui's visibleWidth() for ANSI-aware measurement and
14
+ * truncateToWidth() for ANSI-safe truncation.
15
+ */
5
16
  export function clampLineWidth(lines: string[], maxWidth: number): string[] {
6
17
  return lines.map((line) => {
7
- if (line.length <= maxWidth) return line;
8
- return line.slice(0, maxWidth - 3) + "...";
18
+ const vw = visibleWidth(line);
19
+ if (vw <= maxWidth) return line;
20
+ return truncateToWidth(line, maxWidth, "…");
9
21
  });
10
22
  }
11
23
 
package/src/index.ts CHANGED
@@ -16,17 +16,45 @@ import { registerCompactorTools } from "./tools/register.js";
16
16
  import { normalizeMessages } from "./compaction/normalize.js";
17
17
  import { filterNoise } from "./compaction/filter-noise.js";
18
18
  import type { NormalizedBlock, CompactorStrategyConfig, RuntimeCounters } from "./types.js";
19
+ import type { RuntimeStats } from "./session/analytics.js";
19
20
 
20
21
  /** Debug logger — only logs when config.debug === true */
21
22
  function createDebugLogger(getConfig: () => { debug: boolean }) {
22
- return (event: string, data?: Record<string, unknown>) => {
23
- if (!getConfig().debug) return;
24
- const ts = new Date().toISOString().slice(11, 23);
25
- const details = data ? " " + JSON.stringify(data) : "";
26
- console.error(`[compactor:${ts}] ${event}${details}`);
23
+ return (_event: string, _data?: Record<string, unknown>) => {
24
+ // Debug logging disabled — was writing to stdout causing TUI rendering issues.
25
+ return;
27
26
  };
28
27
  }
29
28
 
29
+ /** Measure byte size of a tool_result event's response content. */
30
+ function measureResponseBytes(event: any): number {
31
+ try {
32
+ const content = event.content;
33
+ if (typeof content === "string") return Buffer.byteLength(content, "utf-8");
34
+ if (Array.isArray(content)) {
35
+ return content.reduce((sum: number, block: any) => {
36
+ if (typeof block?.text === "string") return sum + Buffer.byteLength(block.text, "utf-8");
37
+ if (typeof block === "string") return sum + Buffer.byteLength(block, "utf-8");
38
+ return sum;
39
+ }, 0);
40
+ }
41
+ if (event.output && typeof event.output === "string") return Buffer.byteLength(event.output, "utf-8");
42
+ } catch {
43
+ // Non-blocking: byte measurement errors silently skipped
44
+ }
45
+ return 0;
46
+ }
47
+
48
+ /** Check if a tool is a sandbox tool (output stays in sandbox, not context). */
49
+ function isSandboxTool(name: string): boolean {
50
+ return name === "bash" || name === "Bash";
51
+ }
52
+
53
+ /** Check if a tool is an index tool (content goes to FTS5, not context). Future-proofing. */
54
+ function isIndexTool(_name: string): boolean {
55
+ return false;
56
+ }
57
+
30
58
  export default function compactorExtension(pi: ExtensionAPI): void {
31
59
  let sessionDB: SessionDB | null = null;
32
60
  let contentStore: ContentStore | null = null;
@@ -43,6 +71,16 @@ export default function compactorExtension(pi: ExtensionAPI): void {
43
71
  };
44
72
  const getCounters = () => counters;
45
73
 
74
+ const runtimeStats: RuntimeStats = {
75
+ bytesReturned: {},
76
+ bytesIndexed: 0,
77
+ bytesSandboxed: 0,
78
+ calls: {},
79
+ sessionStart: Date.now(),
80
+ cacheHits: 0,
81
+ cacheBytesSaved: 0,
82
+ };
83
+
46
84
  const debug = createDebugLogger(() => config);
47
85
 
48
86
  const init = async () => {
@@ -58,8 +96,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
58
96
  const db = new SessionDB();
59
97
  await db.init();
60
98
  sessionDB = db;
61
- } catch (err) {
62
- console.error(`[compactor] SessionDB init failed: ${String(err)}`);
99
+ } catch {
100
+ // Silently ignore — SessionDB init failure is handled gracefully.
63
101
  sessionDB = null;
64
102
  }
65
103
 
@@ -70,8 +108,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
70
108
  const cs = new ContentStore();
71
109
  await cs.init();
72
110
  contentStore = cs;
73
- } catch (err) {
74
- console.error(`[compactor] ContentStore init failed: ${String(err)}`);
111
+ } catch {
112
+ // Silently ignore — ContentStore init failure is handled gracefully.
75
113
  contentStore = null;
76
114
  }
77
115
  }
@@ -79,7 +117,12 @@ export default function compactorExtension(pi: ExtensionAPI): void {
79
117
  executor = new PolyglotExecutor();
80
118
  };
81
119
 
82
- registerCompactionHooks(pi);
120
+ // Register compaction hooks with lazy deps — sessionDB/sessionId may not be
121
+ // available at registration time, but will be by the time events fire.
122
+ registerCompactionHooks(pi, {
123
+ getSessionDB: () => sessionDB,
124
+ getSessionId: () => currentSessionId,
125
+ });
83
126
 
84
127
  // Commands registered inside session_start after init() when deps are ready
85
128
  const getCommandDeps = () => ({
@@ -101,6 +144,15 @@ export default function compactorExtension(pi: ExtensionAPI): void {
101
144
 
102
145
  debug("session_start", { sessionId: fullSessionId, projectDir });
103
146
 
147
+ // Reset runtime stats for new session
148
+ runtimeStats.bytesReturned = {};
149
+ runtimeStats.bytesIndexed = 0;
150
+ runtimeStats.bytesSandboxed = 0;
151
+ runtimeStats.calls = {};
152
+ runtimeStats.sessionStart = Date.now();
153
+ runtimeStats.cacheHits = 0;
154
+ runtimeStats.cacheBytesSaved = 0;
155
+
104
156
  sessionDB?.ensureSession(fullSessionId, projectDir);
105
157
 
106
158
  // Register all compactor tools with Pi (deps now have live sessionDB)
@@ -119,9 +171,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
119
171
 
120
172
  // Register info-screen group
121
173
  const infoRegistry = (globalThis as any).__unipi_info_registry;
122
- if (infoRegistry && sessionDB && contentStore) {
174
+ if (infoRegistry && sessionDB) {
123
175
  const sdb = sessionDB;
124
- const cs = contentStore;
125
176
  const sid = () => currentSessionId;
126
177
  infoRegistry.registerGroup({
127
178
  id: "compactor",
@@ -131,23 +182,25 @@ export default function compactorExtension(pi: ExtensionAPI): void {
131
182
  config: {
132
183
  showByDefault: true,
133
184
  stats: [
134
- { id: "sessionEvents", label: "Session events", show: true },
185
+ { id: "tokensSaved", label: "Tokens saved", show: true },
186
+ { id: "costSaved", label: "Cost saved", show: true },
187
+ { id: "pctReduction", label: "% Reduction", show: true },
188
+ { id: "topTools", label: "Top tools", show: true },
135
189
  { id: "compactions", label: "Compactions", show: true },
136
- { id: "tokensSaved", label: "Tokens compacted", show: true },
137
- { id: "compressionRatio", label: "Compression ratio", show: true },
138
- { id: "indexedDocs", label: "Indexed docs", show: true },
190
+ { id: "toolCalls", label: "Tool calls", show: true },
139
191
  ],
140
192
  },
141
193
  dataProvider: async () => {
142
194
  try {
143
195
  const { getInfoScreenData } = await import("./info-screen.js");
144
- const data = await getInfoScreenData(sdb, cs, sid(), counters);
196
+ const data = await getInfoScreenData(sdb, sid(), runtimeStats);
145
197
  return {
146
- sessionEvents: { value: data.sessionEvents.value, detail: data.sessionEvents.detail },
147
- compactions: { value: data.compactions.value, detail: data.compactions.detail },
148
198
  tokensSaved: { value: data.tokensSaved.value, detail: data.tokensSaved.detail },
149
- compressionRatio: { value: data.compressionRatio.value, detail: data.compressionRatio.detail },
150
- indexedDocs: { value: data.indexedDocs.value, detail: data.indexedDocs.detail },
199
+ costSaved: { value: data.costSaved.value, detail: data.costSaved.detail },
200
+ pctReduction: { value: data.pctReduction.value, detail: data.pctReduction.detail },
201
+ topTools: { value: data.topTools.value, detail: data.topTools.detail },
202
+ compactions: { value: data.compactions.value, detail: data.compactions.detail },
203
+ toolCalls: { value: data.toolCalls.value, detail: data.toolCalls.detail },
151
204
  };
152
205
  } catch {
153
206
  return {};
@@ -235,7 +288,9 @@ export default function compactorExtension(pi: ExtensionAPI): void {
235
288
 
236
289
  pi.on("session_before_compact", async (event, _ctx) => {
237
290
  if (sessionDB) {
238
- const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
291
+ // Use closure currentSessionId Pi's session_before_compact event
292
+ // does not include sessionId at the top level.
293
+ const sessionId = currentSessionId;
239
294
  const events = sessionDB.getEvents(sessionId, { limit: 1000 });
240
295
  const stats = sessionDB.getSessionStats(sessionId);
241
296
  debug("session_before_compact", { sessionId, eventCount: events.length, compactCount: stats?.compact_count ?? 0 });
@@ -249,14 +304,43 @@ export default function compactorExtension(pi: ExtensionAPI): void {
249
304
 
250
305
  pi.on("session_compact", async (event, _ctx) => {
251
306
  if (sessionDB) {
252
- const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
307
+ // Use closure currentSessionId Pi's session_compact event does not
308
+ // include sessionId at the top level (it's inside compactionEntry).
309
+ const sessionId = currentSessionId;
253
310
  sessionDB.incrementCompactCount(sessionId);
254
311
  counters.compactions++;
255
- const tokensBefore = (event as any).tokensBefore ?? 0;
312
+
313
+ // Pi's session_compact event structure: { compactionEntry, fromExtension }
314
+ // tokensBefore is inside compactionEntry, not at event root.
315
+ const compactionEntry = (event as any).compactionEntry;
316
+ const tokensBefore = compactionEntry?.tokensBefore ?? 0;
317
+
318
+ // Use actual runtimeStats for byte measurement instead of heuristic
319
+ const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce((s, b) => s + b, 0);
320
+ const totalBytesProcessed = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed + totalBytesReturned;
321
+
256
322
  if (tokensBefore > 0) {
257
- counters.totalTokensCompacted += Math.round(tokensBefore * 0.85); // rough estimate
323
+ // Use actual token count from Pi's compactionEntry
324
+ const charsBefore = tokensBefore * 4;
325
+ // Estimate kept chars: proportional to what remains after compaction
326
+ const tokensAfter = (event as any).tokensAfter ?? Math.round(tokensBefore * 0.15);
327
+ const charsKept = tokensAfter * 4;
328
+ const messagesSummarized = Math.max(1, Math.round(tokensBefore / 500));
329
+ counters.totalTokensCompacted += tokensBefore - tokensAfter;
330
+ sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, messagesSummarized);
331
+ } else {
332
+ // Fallback: estimate from runtime byte stats when tokensBefore unavailable
333
+ if (totalBytesProcessed > 0) {
334
+ const charsBefore = totalBytesProcessed;
335
+ const charsKept = totalBytesReturned;
336
+ const messagesSummarized = Math.max(1, Math.round(totalBytesProcessed / 2000));
337
+ const estTokensBefore = Math.round(totalBytesProcessed / 4);
338
+ const estTokensAfter = Math.round(totalBytesReturned / 4);
339
+ counters.totalTokensCompacted += Math.max(0, estTokensBefore - estTokensAfter);
340
+ sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, messagesSummarized);
341
+ }
258
342
  }
259
- debug("session_compact", { sessionId });
343
+ debug("session_compact", { sessionId, tokensBefore, totalBytesProcessed, hasCompactionEntry: !!compactionEntry });
260
344
  }
261
345
  });
262
346
 
@@ -346,7 +430,8 @@ export default function compactorExtension(pi: ExtensionAPI): void {
346
430
 
347
431
  pi.on("tool_result", async (event, _ctx) => {
348
432
  if (!sessionDB) return;
349
- const sessionId = `${(event as any).sessionId ?? "default"}${getWorktreeSuffix()}`;
433
+ // Use closure currentSessionId tool_result events use the same session
434
+ const sessionId = currentSessionId;
350
435
  const toolNameRaw = (event as any).toolName ?? "";
351
436
  const isError = (event as any).isError ?? false;
352
437
 
@@ -365,6 +450,24 @@ export default function compactorExtension(pi: ExtensionAPI): void {
365
450
  debug("event_stored", { category: ev.category, type: ev.type });
366
451
  }
367
452
 
453
+ // Track byte consumption per tool for analytics
454
+ try {
455
+ const responseBytes = measureResponseBytes(event);
456
+ if (responseBytes > 0) {
457
+ const tName = (event as any).toolName ?? "unknown";
458
+ runtimeStats.calls[tName] = (runtimeStats.calls[tName] || 0) + 1;
459
+ runtimeStats.bytesReturned[tName] = (runtimeStats.bytesReturned[tName] || 0) + responseBytes;
460
+ if (isSandboxTool(tName)) {
461
+ runtimeStats.bytesSandboxed += responseBytes;
462
+ }
463
+ if (isIndexTool(tName)) {
464
+ runtimeStats.bytesIndexed += responseBytes;
465
+ }
466
+ }
467
+ } catch {
468
+ // Non-blocking: byte tracking errors silently skipped
469
+ }
470
+
368
471
  // Apply display overrides for built-in tools
369
472
  const toolName = (event as any).toolName ?? "";
370
473
  const td = config.toolDisplay;
@@ -385,6 +488,30 @@ export default function compactorExtension(pi: ExtensionAPI): void {
385
488
  } catch {
386
489
  // Non-fatal: display override failed
387
490
  }
491
+
492
+ // Width-safe diff truncation for edit/write tool results.
493
+ // Pi's renderDiff() does not truncate lines to terminal width,
494
+ // causing TUI crashes on narrow terminals. We truncate the
495
+ // diff string in details.diff before it reaches the TUI.
496
+ const diffToolNames = ["edit", "Edit", "write", "Write"];
497
+ if (diffToolNames.includes(toolName)) {
498
+ try {
499
+ const details = (event as any).details as
500
+ { diff?: string } | undefined;
501
+ if (details?.diff) {
502
+ const { clampDiffToWidth } = await import(
503
+ "./display/diff-width-safety.js"
504
+ );
505
+ const clamped = clampDiffToWidth(details.diff);
506
+ if (clamped !== details.diff) {
507
+ debug("diff_width_clamped", { toolName });
508
+ return { details: { ...details, diff: clamped } } as any;
509
+ }
510
+ }
511
+ } catch (err) {
512
+ debug("diff_width_clamp_error", { error: String(err) });
513
+ }
514
+ }
388
515
  });
389
516
 
390
517
  pi.on("message_update", async (event, _ctx) => {
@@ -1,66 +1,151 @@
1
1
  /**
2
2
  * Info-screen integration for @pi-unipi/compactor
3
+ *
4
+ * Budget-focused stats: tokensSaved, costSaved, pctReduction,
5
+ * topTools, compactions, toolCalls.
3
6
  */
4
7
 
5
8
  import type { SessionDB } from "./session/db.js";
6
- import type { ContentStore } from "./store/index.js";
7
- import type { RuntimeCounters } from "./types.js";
9
+ import type { RuntimeStats, FullReport } from "./session/analytics.js";
10
+ import { AnalyticsEngine, createMinimalDb } from "./session/analytics.js";
8
11
  import { getLastCompactionStats } from "./compaction/hooks.js";
12
+ import { parseUsageStats } from "@pi-unipi/info-screen/usage-parser.js";
9
13
 
10
- export interface InfoScreenData {
11
- sessionEvents: { value: string; detail: string };
12
- compactions: { value: string; detail: string };
14
+ export interface CompactorInfoData {
13
15
  tokensSaved: { value: string; detail: string };
14
- compressionRatio: { value: string; detail: string };
15
- indexedDocs: { value: string; detail: string };
16
- sandboxExecutions: { value: string; detail: string };
17
- searchQueries: { value: string; detail: string };
16
+ costSaved: { value: string; detail: string };
17
+ pctReduction: { value: string; detail: string };
18
+ topTools: { value: string; detail: string };
19
+ compactions: { value: string; detail: string };
20
+ toolCalls: { value: string; detail: string };
21
+ }
22
+
23
+ /** Format token count for display (e.g., "12.4k", "1.2M"). */
24
+ function formatTokens(n: number): string {
25
+ if (n < 1000) return String(n);
26
+ if (n < 10_000) return `${(n / 1000).toFixed(1)}k`;
27
+ if (n < 1_000_000) return `${Math.round(n / 1000)}k`;
28
+ if (n < 10_000_000) return `${(n / 1_000_000).toFixed(1)}M`;
29
+ return `${Math.round(n / 1_000_000)}M`;
30
+ }
31
+
32
+ /** Format cost for display (e.g., "$0.34", "<$0.01"). */
33
+ function formatCost(n: number): string {
34
+ if (n === 0) return "$0.00";
35
+ if (n < 0.01) return "<$0.01";
36
+ if (n < 1) return `$${n.toFixed(2)}`;
37
+ return `$${n.toFixed(2)}`;
38
+ }
39
+
40
+ /** Estimate cost per token for the most-used model in the current session. */
41
+ function estimateCostPerToken(): number | null {
42
+ try {
43
+ const usage = parseUsageStats();
44
+ // Use today's most-used model if available, otherwise all-time
45
+ const models = usage.byModelToday;
46
+ const todayKeys = Object.keys(models);
47
+ if (todayKeys.length > 0) {
48
+ // Pick the model with most tokens today
49
+ const topModel = todayKeys.reduce((a, b) => models[a].tokens > models[b].tokens ? a : b);
50
+ const entry = models[topModel];
51
+ if (entry.tokens > 0 && entry.cost > 0) {
52
+ return entry.cost / entry.tokens;
53
+ }
54
+ }
55
+ // Fall back to all-time model data
56
+ const allKeys = Object.keys(usage.byModel);
57
+ if (allKeys.length > 0) {
58
+ const topModel = allKeys.reduce((a, b) => usage.byModel[a].tokens > usage.byModel[b].tokens ? a : b);
59
+ const entry = usage.byModel[topModel];
60
+ if (entry.tokens > 0 && entry.cost > 0) {
61
+ return entry.cost / entry.tokens;
62
+ }
63
+ }
64
+ return null;
65
+ } catch {
66
+ return null;
67
+ }
18
68
  }
19
69
 
20
70
  export async function getInfoScreenData(
21
71
  sessionDB: SessionDB,
22
- contentStore: ContentStore,
23
72
  sessionId: string,
24
- counters?: RuntimeCounters,
25
- ): Promise<InfoScreenData> {
26
- const stats = sessionDB.getSessionStats(sessionId);
27
- const compactStats = getLastCompactionStats();
28
- const storeStats = await contentStore.getStats();
73
+ runtimeStats: RuntimeStats,
74
+ ): Promise<CompactorInfoData> {
75
+ try {
76
+ const db = sessionDB.getDb();
77
+ const adapter = db ?? createMinimalDb();
78
+ const engine = new AnalyticsEngine(adapter);
79
+ const report = engine.queryAll(runtimeStats);
80
+ const compactStats = getLastCompactionStats();
81
+
82
+ // Tokens saved: bytes kept out of context / 4
83
+ const tokensSaved = Math.round(report.savings.kept_out / 4);
84
+
85
+ // Per-tool breakdown table for tokensSaved detail
86
+ const toolsWithCalls = report.savings.by_tool
87
+ .filter(t => t.calls > 0)
88
+ .sort((a, b) => b.tokens - a.tokens);
89
+ const toolBreakdown = toolsWithCalls.length > 0
90
+ ? toolsWithCalls.map(t =>
91
+ ` ${t.tool.padEnd(20)} ${String(t.calls).padStart(4)} calls ${formatTokens(t.tokens).padStart(8)} tok`
92
+ ).join("\n")
93
+ : "No tool calls yet";
94
+
95
+ // Cost saved: tokensSaved × cost per token
96
+ const costPerToken = estimateCostPerToken();
97
+ const costSaved = costPerToken !== null ? tokensSaved * costPerToken : null;
98
+
99
+ // Top consuming tool
100
+ const topTool = toolsWithCalls[0];
101
+ const top5Tools = toolsWithCalls.slice(0, 5);
102
+ const top5Detail = top5Tools.length > 0
103
+ ? top5Tools.map(t =>
104
+ `${t.tool}: ${formatTokens(t.tokens)} (${t.calls} calls)`
105
+ ).join("\n")
106
+ : "No tool calls yet";
29
107
 
30
- return {
31
- sessionEvents: {
32
- value: String(stats?.event_count ?? 0),
33
- detail: "Session events tracked",
34
- },
35
- compactions: {
36
- value: String(counters?.compactions ?? stats?.compact_count ?? 0),
37
- detail: compactStats ? `Last: ${compactStats.summarized} msgs` : "No compactions yet",
38
- },
39
- tokensSaved: {
40
- value: counters?.totalTokensCompacted
41
- ? `~${counters.totalTokensCompacted}`
42
- : compactStats
43
- ? `~${compactStats.keptTokensEst}`
44
- : "0",
45
- detail: "Estimated tokens kept",
46
- },
47
- compressionRatio: {
48
- value: compactStats && compactStats.summarized > 0
49
- ? `${Math.round(compactStats.summarized / Math.max(compactStats.kept, 1))}:1`
50
- : "N/A",
51
- detail: "Compression ratio",
52
- },
53
- indexedDocs: {
54
- value: String(storeStats.sources),
55
- detail: `${storeStats.chunks} chunks indexed`,
56
- },
57
- sandboxExecutions: {
58
- value: String(counters?.sandboxRuns ?? 0),
59
- detail: "Sandbox runs this session",
60
- },
61
- searchQueries: {
62
- value: String(counters?.searchQueries ?? 0),
63
- detail: "Search queries this session",
64
- },
65
- };
108
+ return {
109
+ tokensSaved: {
110
+ value: formatTokens(tokensSaved),
111
+ detail: toolBreakdown,
112
+ },
113
+ costSaved: {
114
+ value: costSaved !== null ? formatCost(costSaved) : "N/A",
115
+ detail: costSaved !== null
116
+ ? `~${formatTokens(tokensSaved)} tokens × $${(costPerToken! * 1_000_000).toFixed(2)}/M tokens`
117
+ : "Cost data unavailable for current model",
118
+ },
119
+ pctReduction: {
120
+ value: `${report.savings.pct}%`,
121
+ detail: `${formatTokens(Math.round(report.savings.processed_kb * 1024 / 4))} processed → ${formatTokens(Math.round(report.savings.entered_kb * 1024 / 4))} entered context`,
122
+ },
123
+ topTools: {
124
+ value: topTool ? `${topTool.tool}: ${formatTokens(topTool.tokens)}` : "N/A",
125
+ detail: top5Detail,
126
+ },
127
+ compactions: {
128
+ value: String(Math.max(report.continuity.compact_count, report.continuity.all_time_compact_count)),
129
+ detail: compactStats
130
+ ? `Last: ${compactStats.summarized} msgs summarized, ${compactStats.kept} kept (~${formatTokens(compactStats.keptTokensEst)} tok)`
131
+ : report.continuity.all_time_compact_count > 0
132
+ ? `${report.continuity.all_time_compact_count} compaction(s) across all sessions`
133
+ : "No compactions yet",
134
+ },
135
+ toolCalls: {
136
+ value: String(report.savings.total_calls),
137
+ detail: `${report.savings.total_calls} total tool calls across ${toolsWithCalls.length} tool${toolsWithCalls.length !== 1 ? "s" : ""}`,
138
+ },
139
+ };
140
+ } catch {
141
+ // Never throw from dataProvider — return zeroed stats
142
+ return {
143
+ tokensSaved: { value: "0", detail: "No data" },
144
+ costSaved: { value: "N/A", detail: "No data" },
145
+ pctReduction: { value: "0%", detail: "No data" },
146
+ topTools: { value: "N/A", detail: "No data" },
147
+ compactions: { value: "0", detail: "No data" },
148
+ toolCalls: { value: "0", detail: "No data" },
149
+ };
150
+ }
66
151
  }
@@ -0,0 +1,205 @@
1
+ /**
2
+ * AnalyticsEngine — Runtime savings + session continuity reporting.
3
+ *
4
+ * Ported from context-mode's AnalyticsEngine, trimmed to budget-focused stats.
5
+ * Omits: formatReport(), categoryLabels, categoryHints, ThinkInCodeComparison,
6
+ * SandboxIO, dataBar(), visual formatting helpers.
7
+ *
8
+ * Usage:
9
+ * const engine = new AnalyticsEngine(sessionDb);
10
+ * const report = engine.queryAll(runtimeStats);
11
+ */
12
+
13
+ // ─────────────────────────────────────────────────────────
14
+ // Types
15
+ // ─────────────────────────────────────────────────────────
16
+
17
+ /** Database adapter — anything with a prepare() method (better-sqlite3, bun:sqlite, etc.) */
18
+ export interface DatabaseAdapter {
19
+ prepare(sql: string): {
20
+ run(...params: unknown[]): unknown;
21
+ get(...params: unknown[]): unknown;
22
+ all(...params: unknown[]): unknown[];
23
+ };
24
+ }
25
+
26
+ /** Context savings result */
27
+ export interface ContextSavings {
28
+ rawBytes: number;
29
+ contextBytes: number;
30
+ savedBytes: number;
31
+ savedPercent: number;
32
+ }
33
+
34
+ /** Runtime stats tracked during a live session. */
35
+ export interface RuntimeStats {
36
+ bytesReturned: Record<string, number>;
37
+ bytesIndexed: number;
38
+ bytesSandboxed: number;
39
+ calls: Record<string, number>;
40
+ sessionStart: number;
41
+ cacheHits: number;
42
+ cacheBytesSaved: number;
43
+ }
44
+
45
+ /** Unified report combining runtime stats, DB analytics, and continuity data. */
46
+ export interface FullReport {
47
+ /** Runtime context savings */
48
+ savings: {
49
+ processed_kb: number;
50
+ entered_kb: number;
51
+ saved_kb: number;
52
+ pct: number;
53
+ savings_ratio: number;
54
+ by_tool: Array<{ tool: string; calls: number; context_kb: number; tokens: number }>;
55
+ total_calls: number;
56
+ total_bytes_returned: number;
57
+ kept_out: number;
58
+ total_processed: number;
59
+ };
60
+ /** Session metadata from SessionDB */
61
+ session: {
62
+ id: string;
63
+ uptime_min: string;
64
+ };
65
+ /** Session continuity data */
66
+ continuity: {
67
+ total_events: number;
68
+ compact_count: number;
69
+ all_time_compact_count: number;
70
+ resume_ready: boolean;
71
+ };
72
+ /** Persistent project memory — all events across all sessions */
73
+ projectMemory: {
74
+ total_events: number;
75
+ session_count: number;
76
+ };
77
+ }
78
+
79
+ // ─────────────────────────────────────────────────────────
80
+ // AnalyticsEngine
81
+ // ─────────────────────────────────────────────────────────
82
+
83
+ export class AnalyticsEngine {
84
+ private readonly db: DatabaseAdapter;
85
+
86
+ constructor(db: DatabaseAdapter) {
87
+ this.db = db;
88
+ }
89
+
90
+ /**
91
+ * Build a FullReport by merging runtime stats (passed in)
92
+ * with continuity data from the DB.
93
+ */
94
+ queryAll(runtimeStats: RuntimeStats): FullReport {
95
+ // ── Resolve latest session ID ──
96
+ const latestSession = this.db.prepare(
97
+ "SELECT session_id FROM session_meta ORDER BY started_at DESC LIMIT 1",
98
+ ).get() as { session_id: string } | undefined;
99
+ const sid = latestSession?.session_id ?? "";
100
+
101
+ // ── Runtime savings ──
102
+ const totalBytesReturned = Object.values(runtimeStats.bytesReturned).reduce(
103
+ (sum, b) => sum + b, 0,
104
+ );
105
+ const totalCalls = Object.values(runtimeStats.calls).reduce(
106
+ (sum, c) => sum + c, 0,
107
+ );
108
+ const keptOut = runtimeStats.bytesIndexed + runtimeStats.bytesSandboxed;
109
+ const totalProcessed = keptOut + totalBytesReturned;
110
+ const savingsRatio = totalProcessed / Math.max(totalBytesReturned, 1);
111
+ const reductionPct = totalProcessed > 0
112
+ ? Math.round((1 - totalBytesReturned / totalProcessed) * 100)
113
+ : 0;
114
+
115
+ const toolNames = new Set([
116
+ ...Object.keys(runtimeStats.calls),
117
+ ...Object.keys(runtimeStats.bytesReturned),
118
+ ]);
119
+ const byTool = Array.from(toolNames).sort().map((tool) => ({
120
+ tool,
121
+ calls: runtimeStats.calls[tool] || 0,
122
+ context_kb: Math.round((runtimeStats.bytesReturned[tool] || 0) / 1024 * 10) / 10,
123
+ tokens: Math.round((runtimeStats.bytesReturned[tool] || 0) / 4),
124
+ }));
125
+
126
+ const uptimeMs = Date.now() - runtimeStats.sessionStart;
127
+ const uptimeMin = (uptimeMs / 60_000).toFixed(1);
128
+
129
+ // ── Continuity data (scoped to current session) ──
130
+ const eventTotal = (this.db.prepare(
131
+ "SELECT COUNT(*) as cnt FROM session_events WHERE session_id = ?",
132
+ ).get(sid) as { cnt: number }).cnt;
133
+
134
+ const meta = this.db.prepare(
135
+ "SELECT compact_count FROM session_meta WHERE session_id = ?",
136
+ ).get(sid) as { compact_count: number } | undefined;
137
+ const compactCount = meta?.compact_count ?? 0;
138
+
139
+ // ── All-time compaction count across all sessions ──
140
+ const allTimeCompactions = (this.db.prepare(
141
+ "SELECT COALESCE(SUM(compact_count), 0) as total FROM session_meta",
142
+ ).get() as { total: number }).total;
143
+
144
+ const resume = this.db.prepare(
145
+ "SELECT event_count, consumed FROM session_resume WHERE session_id = ? ORDER BY created_at DESC LIMIT 1",
146
+ ).get(sid) as { event_count: number; consumed: number } | undefined;
147
+ const resumeReady = resume ? !resume.consumed : false;
148
+
149
+ // ── Project-wide persistent memory (all sessions, no session_id filter) ──
150
+ const projectTotals = this.db.prepare(
151
+ "SELECT COUNT(*) as cnt, COUNT(DISTINCT session_id) as sessions FROM session_events",
152
+ ).get() as { cnt: number; sessions: number };
153
+
154
+ return {
155
+ savings: {
156
+ processed_kb: Math.round(totalProcessed / 1024 * 10) / 10,
157
+ entered_kb: Math.round(totalBytesReturned / 1024 * 10) / 10,
158
+ saved_kb: Math.round(keptOut / 1024 * 10) / 10,
159
+ pct: reductionPct,
160
+ savings_ratio: Math.round(savingsRatio * 10) / 10,
161
+ by_tool: byTool,
162
+ total_calls: totalCalls,
163
+ total_bytes_returned: totalBytesReturned,
164
+ kept_out: keptOut,
165
+ total_processed: totalProcessed,
166
+ },
167
+ session: {
168
+ id: sid,
169
+ uptime_min: uptimeMin,
170
+ },
171
+ continuity: {
172
+ total_events: eventTotal,
173
+ compact_count: compactCount,
174
+ all_time_compact_count: allTimeCompactions,
175
+ resume_ready: resumeReady,
176
+ },
177
+ projectMemory: {
178
+ total_events: projectTotals.cnt,
179
+ session_count: projectTotals.sessions,
180
+ },
181
+ };
182
+ }
183
+ }
184
+
185
+ // ─────────────────────────────────────────────────────────
186
+ // createMinimalDb — in-memory SQLite fallback
187
+ // ─────────────────────────────────────────────────────────
188
+
189
+ /**
190
+ * Create a minimal in-memory DatabaseAdapter for when SessionDB is unavailable.
191
+ * Returns zeroed/empty results for all queries.
192
+ */
193
+ export function createMinimalDb(): DatabaseAdapter {
194
+ // Use an in-memory SQLite database with the expected schema
195
+ // so AnalyticsEngine queries don't fail.
196
+ const emptyStmt = {
197
+ run: (..._params: unknown[]) => {},
198
+ get: (..._params: unknown[]) => ({ cnt: 0, sessions: 0, compact_count: 0, total: 0, session_id: "", event_count: 0, consumed: 1 }),
199
+ all: (..._params: unknown[]) => [] as unknown[],
200
+ };
201
+
202
+ return {
203
+ prepare: (_sql: string) => emptyStmt,
204
+ };
205
+ }
package/src/session/db.ts CHANGED
@@ -293,6 +293,9 @@ export class SessionDB {
293
293
  return oldSessions.length;
294
294
  }
295
295
 
296
+ /** Expose the underlying db for AnalyticsEngine (read-only queries). Returns null if init failed. */
297
+ getDb(): any { return this.db ?? null; }
298
+
296
299
  close(): void {
297
300
  try { this.db.close(); } catch { /* ignore */ }
298
301
  }
@@ -26,10 +26,34 @@ export async function ctxStats(
26
26
  const sessionStats = sessionDB.getSessionStats(sessionId);
27
27
  const storeStats = await contentStore.getStats();
28
28
 
29
+ // Compute tokensSaved: prefer in-memory counters (current session),
30
+ // fall back to per-session DB stats, then all-time DB stats.
31
+ let tokensSaved = counters?.totalTokensCompacted ?? 0;
32
+ if (tokensSaved === 0 && sessionStats) {
33
+ const sessionCharsBefore = (sessionStats as any).total_chars_before ?? 0;
34
+ const sessionCharsKept = (sessionStats as any).total_chars_kept ?? 0;
35
+ tokensSaved = Math.round((sessionCharsBefore - sessionCharsKept) / 4);
36
+ }
37
+ if (tokensSaved === 0) {
38
+ const allTime = sessionDB.getAllTimeStats();
39
+ tokensSaved = Math.round((allTime.allCharsBefore - allTime.allCharsKept) / 4);
40
+ }
41
+
42
+ // Compute compactions: prefer in-memory counter (current session),
43
+ // fall back to per-session DB, then all-time DB.
44
+ let compactions = counters?.compactions ?? 0;
45
+ if (compactions === 0) {
46
+ compactions = sessionStats?.compact_count ?? 0;
47
+ }
48
+ if (compactions === 0) {
49
+ const allTime = sessionDB.getAllTimeStats();
50
+ compactions = allTime.allCompactions;
51
+ }
52
+
29
53
  return {
30
54
  sessionEvents: sessionStats?.event_count ?? 0,
31
- compactions: counters?.compactions ?? sessionStats?.compact_count ?? 0,
32
- tokensSaved: counters?.totalTokensCompacted ?? 0,
55
+ compactions,
56
+ tokensSaved,
33
57
  compressionRatio: "N/A",
34
58
  indexedDocs: storeStats.sources,
35
59
  indexedChunks: storeStats.chunks,
@@ -137,8 +137,8 @@ function jsonResult(data: unknown, label?: string): any {
137
137
  }
138
138
 
139
139
  /** Log a deprecation warning when old tool names are used. */
140
- function deprecationLog(oldName: string, newName: string): void {
141
- console.error(`[compactor] DEPRECATED: Tool "${oldName}" used use "${newName}" instead.`);
140
+ function deprecationLog(_oldName: string, _newName: string): void {
141
+ // Deprecation logging disabledwas writing to stdout causing TUI rendering issues.
142
142
  }
143
143
 
144
144
  // --- Old schema names for backward compat aliases ---
package/src/types.ts CHANGED
@@ -283,11 +283,10 @@ export interface RuntimeCounters {
283
283
  // ─────────────────────────────────────────────────────────
284
284
 
285
285
  export interface CompactorInfoData {
286
- sessionEvents: { value: string; detail: string };
287
- compactions: { value: string; detail: string };
288
286
  tokensSaved: { value: string; detail: string };
289
- compressionRatio: { value: string; detail: string };
290
- indexedDocs: { value: string; detail: string };
291
- sandboxExecutions: { value: string; detail: string };
292
- searchQueries: { value: string; detail: string };
287
+ costSaved: { value: string; detail: string };
288
+ pctReduction: { value: string; detail: string };
289
+ topTools: { value: string; detail: string };
290
+ compactions: { value: string; detail: string };
291
+ toolCalls: { value: string; detail: string };
293
292
  }