@pi-unipi/compactor 0.1.7 → 0.2.2

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.
@@ -2,6 +2,7 @@
2
2
  * Preset definitions + detection for compactor config
3
3
  */
4
4
 
5
+ import { createHash } from "node:crypto";
5
6
  import type { CompactorConfig, CompactorPreset } from "../types.js";
6
7
  import { DEFAULT_COMPACTOR_CONFIG } from "./schema.js";
7
8
 
@@ -22,15 +23,43 @@ const preset = (
22
23
  toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay as any) },
23
24
  });
24
25
 
26
+ // Pipeline feature defaults per preset:
27
+ // precise: ttlCache+mmap on, rest off
28
+ // balanced: all on
29
+ // thorough: all on
30
+ // lean: all off
31
+
25
32
  export const PRESET_CONFIGS: Record<CompactorPreset, CompactorConfig> = {
26
- opencode: preset({
33
+ // New preset names
34
+ precise: preset({
27
35
  toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "opencode" },
28
36
  }),
37
+ thorough: preset({
38
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "full" },
39
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "verbose" },
40
+ }),
41
+ lean: preset({
42
+ sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, enabled: true, mode: "brief" },
43
+ filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, enabled: true, mode: "modified-only" },
44
+ commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, enabled: false, mode: "off" },
45
+ outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, enabled: true, mode: "critical-only" },
46
+ userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, enabled: false, mode: "off" },
47
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, enabled: true, mode: "minimal" },
48
+ sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, enabled: false, mode: "off" },
49
+ fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, enabled: false, mode: "off" },
50
+ sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, enabled: false, mode: "off" },
51
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, enabled: true, mode: "opencode" },
52
+ }),
29
53
  balanced: preset({
30
54
  briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "compact" },
31
55
  toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "balanced" },
32
56
  fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, mode: "auto" },
33
57
  }),
58
+
59
+ // Backward-compat aliases — map old names to new
60
+ opencode: preset({
61
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "opencode" },
62
+ }),
34
63
  verbose: preset({
35
64
  briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, mode: "full" },
36
65
  toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, mode: "verbose" },
@@ -50,7 +79,24 @@ export const PRESET_CONFIGS: Record<CompactorPreset, CompactorConfig> = {
50
79
  custom: structuredClone(DEFAULT_COMPACTOR_CONFIG),
51
80
  };
52
81
 
82
+ // Pre-computed identity hashes for fast preset detection
83
+ const presetHashes = new Map<string, string>();
84
+
85
+ function presetHash(config: CompactorConfig): string {
86
+ return createHash("sha256").update(JSON.stringify(config)).digest("hex");
87
+ }
88
+
89
+ // Compute hashes once at module load
90
+ for (const name of ["precise", "balanced", "thorough", "lean"] as const) {
91
+ presetHashes.set(name, presetHash(PRESET_CONFIGS[name]));
92
+ }
93
+
53
94
  function configsEqual(a: CompactorConfig, b: CompactorConfig): boolean {
95
+ // Fast path: hash comparison
96
+ const aHash = presetHash(a);
97
+ const bHash = presetHash(b);
98
+ if (aHash !== bHash) return false;
99
+ // Defensive: confirm with full comparison
54
100
  return JSON.stringify(a) === JSON.stringify(b);
55
101
  }
56
102
 
@@ -58,8 +104,11 @@ function configsEqual(a: CompactorConfig, b: CompactorConfig): boolean {
58
104
  * Detect which preset a config matches, or "custom".
59
105
  */
60
106
  export function detectPreset(config: CompactorConfig): CompactorPreset {
61
- for (const name of ["opencode", "balanced", "verbose", "minimal"] as const) {
62
- if (configsEqual(config, PRESET_CONFIGS[name])) return name;
107
+ const configHash = presetHash(config);
108
+ for (const name of ["precise", "balanced", "thorough", "lean"] as const) {
109
+ if (presetHashes.get(name) === configHash && configsEqual(config, PRESET_CONFIGS[name])) {
110
+ return name;
111
+ }
63
112
  }
64
113
  return "custom";
65
114
  }
@@ -71,13 +120,28 @@ export function applyPreset(name: CompactorPreset): CompactorConfig {
71
120
  return structuredClone(PRESET_CONFIGS[name]);
72
121
  }
73
122
 
123
+ // Old → new preset name mapping for backward compatibility
124
+ const OLD_TO_NEW: Record<string, CompactorPreset> = {
125
+ opencode: "precise",
126
+ verbose: "thorough",
127
+ minimal: "lean",
128
+ };
129
+
74
130
  /**
75
- * Parse a preset name (case-insensitive).
131
+ * Parse a preset name (case-insensitive). Old names are mapped to new with deprecation.
76
132
  */
77
133
  export function parsePreset(raw: string): CompactorPreset | undefined {
78
134
  const normalized = raw.trim().toLowerCase();
79
- if (normalized === "opencode" || normalized === "balanced" || normalized === "verbose" || normalized === "minimal" || normalized === "custom") {
135
+
136
+ // Check new names first
137
+ if (normalized === "precise" || normalized === "balanced" || normalized === "thorough" || normalized === "lean" || normalized === "custom") {
80
138
  return normalized;
81
139
  }
140
+
141
+ // Map old names to new (backward compat)
142
+ if (OLD_TO_NEW[normalized]) {
143
+ return OLD_TO_NEW[normalized];
144
+ }
145
+
82
146
  return undefined;
83
147
  }
@@ -49,7 +49,16 @@ export const DEFAULT_COMPACTOR_CONFIG: CompactorConfig = {
49
49
  showBashSpinner: true,
50
50
  showPendingPreviews: true,
51
51
  },
52
- overrideDefaultCompaction: false,
52
+ pipeline: {
53
+ ttlCache: false,
54
+ autoInjection: false,
55
+ proximityReranking: false,
56
+ timelineSort: false,
57
+ progressiveThrottling: false,
58
+ mmapPragma: false,
59
+ customNoisePatterns: [],
60
+ },
61
+ overrideDefaultCompaction: true,
53
62
  debug: false,
54
63
  showTruncationHints: true,
55
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