@pi-unipi/compactor 0.1.7 → 0.2.1

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.
@@ -3,6 +3,14 @@
3
3
  *
4
4
  * Commands perform real work by calling tool implementations directly.
5
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.
6
14
  */
7
15
 
8
16
  import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
@@ -13,20 +21,25 @@ import { compactTool } from "../tools/compact.js";
13
21
  import { vccRecall } from "../tools/vcc-recall.js";
14
22
  import { ctxStats } from "../tools/ctx-stats.js";
15
23
  import { ctxDoctor } from "../tools/ctx-doctor.js";
16
- import { ctxIndex } from "../tools/ctx-index.js";
17
24
  import { ctxSearch } from "../tools/ctx-search.js";
18
25
  import { ContentStore } from "../store/index.js";
19
26
  import type { SessionDB } from "../session/db.js";
20
- import type { NormalizedBlock } from "../types.js";
27
+ import type { NormalizedBlock, RuntimeCounters } from "../types.js";
21
28
 
22
29
  export interface CommandDeps {
23
30
  sessionDB: SessionDB | null;
24
31
  contentStore: ContentStore | null;
25
32
  getSessionId: () => string;
26
33
  getBlocks: () => NormalizedBlock[];
34
+ getCounters?: () => RuntimeCounters;
35
+ }
36
+
37
+ function deprecationLog(oldName: string, newName: string): void {
38
+ console.error(`[compactor] DEPRECATED: Command "${oldName}" used — use "${newName}" instead.`);
27
39
  }
28
40
 
29
41
  export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
42
+ // ── /unipi:compact ──────────────────────────────────
30
43
  pi.registerCommand("unipi:compact", {
31
44
  description: "Trigger manual compaction with stats",
32
45
  handler: async (_args: string, ctx: any) => {
@@ -39,31 +52,42 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
39
52
  },
40
53
  });
41
54
 
42
- pi.registerCommand("unipi:compact-recall", {
55
+ // ── /unipi:session-recall (new) ─────────────────────
56
+ const sessionRecallHandler = async (args: string, ctx: any) => {
57
+ const query = args.trim();
58
+ if (!query) {
59
+ ctx.ui.notify("Usage: /unipi:session-recall <query>", "warning");
60
+ return;
61
+ }
62
+ const blocks = deps?.getBlocks() ?? [];
63
+ if (blocks.length === 0) {
64
+ ctx.ui.notify("No session history available for search.", "warning");
65
+ return;
66
+ }
67
+ const result = vccRecall(blocks, { query, limit: 10 });
68
+ if (result.hits.length === 0) {
69
+ ctx.ui.notify(`No results for "${query}".`, "info");
70
+ return;
71
+ }
72
+ const lines = result.hits.map(
73
+ (h, i) => `[${i + 1}] score=${h.score.toFixed(2)} kind=${h.kind}\n${h.text.slice(0, 200)}`,
74
+ );
75
+ ctx.ui.notify(`Found ${result.total} results:\n${lines.join("\n\n")}`, "info");
76
+ };
77
+ pi.registerCommand("unipi:session-recall", {
43
78
  description: "Search session history (BM25 or regex)",
79
+ handler: sessionRecallHandler,
80
+ });
81
+ // Deprecated alias
82
+ pi.registerCommand("unipi:compact-recall", {
83
+ description: "(DEPRECATED) Search session history — use /unipi:session-recall instead",
44
84
  handler: async (args: string, ctx: any) => {
45
- const query = args.trim();
46
- if (!query) {
47
- ctx.ui.notify("Usage: /unipi:compact-recall <query>", "warning");
48
- return;
49
- }
50
- const blocks = deps?.getBlocks() ?? [];
51
- if (blocks.length === 0) {
52
- ctx.ui.notify("No session history available for search.", "warning");
53
- return;
54
- }
55
- const result = vccRecall(blocks, { query, limit: 10 });
56
- if (result.hits.length === 0) {
57
- ctx.ui.notify(`No results for "${query}".`, "info");
58
- return;
59
- }
60
- const lines = result.hits.map(
61
- (h, i) => `[${i + 1}] score=${h.score.toFixed(2)} kind=${h.kind}\n${h.text.slice(0, 200)}`,
62
- );
63
- ctx.ui.notify(`Found ${result.total} results:\n${lines.join("\n\n")}`, "info");
85
+ deprecationLog("/unipi:compact-recall", "/unipi:session-recall");
86
+ return sessionRecallHandler(args, ctx);
64
87
  },
65
88
  });
66
89
 
90
+ // ── /unipi:compact-stats ─────────────────────────────
67
91
  pi.registerCommand("unipi:compact-stats", {
68
92
  description: "Show context savings dashboard",
69
93
  handler: async (_args: string, ctx: any) => {
@@ -72,7 +96,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
72
96
  return;
73
97
  }
74
98
  try {
75
- const stats = await ctxStats(deps.sessionDB, deps.contentStore, deps.getSessionId());
99
+ const stats = await ctxStats(deps.sessionDB, deps.contentStore, deps.getSessionId(), deps.getCounters?.());
76
100
  const lines = [
77
101
  "📊 Compactor Stats",
78
102
  `Session events: ${stats.sessionEvents}`,
@@ -89,6 +113,7 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
89
113
  },
90
114
  });
91
115
 
116
+ // ── /unipi:compact-doctor ────────────────────────────
92
117
  pi.registerCommand("unipi:compact-doctor", {
93
118
  description: "Run diagnostics checklist",
94
119
  handler: async (_args: string, ctx: any) => {
@@ -111,12 +136,14 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
111
136
  },
112
137
  });
113
138
 
139
+ // ── /unipi:compact-settings ──────────────────────────
114
140
  pi.registerCommand("unipi:compact-settings", {
115
141
  description: "Open TUI settings overlay",
116
142
  handler: async (_args: string, ctx: any) => {
117
143
  try {
144
+ const cwd = (ctx as any).cwd ?? process.cwd();
118
145
  const { renderSettingsOverlay } = await import("../tui/settings-overlay.js");
119
- const result = await ctx.ui.custom(renderSettingsOverlay());
146
+ const result = await ctx.ui.custom(renderSettingsOverlay(cwd));
120
147
  if (result) {
121
148
  ctx.ui.notify("Settings saved.", "info");
122
149
  } else {
@@ -128,12 +155,13 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
128
155
  },
129
156
  });
130
157
 
158
+ // ── /unipi:compact-preset ────────────────────────────
131
159
  pi.registerCommand("unipi:compact-preset", {
132
- description: "Apply quick preset (opencode/balanced/verbose/minimal)",
160
+ description: "Apply quick preset (precise/balanced/thorough/lean)",
133
161
  handler: async (args: string, ctx: any) => {
134
162
  const presetName = parsePreset(args.trim());
135
163
  if (!presetName) {
136
- ctx.ui.notify("Unknown preset. Use: opencode, balanced, verbose, minimal", "error");
164
+ ctx.ui.notify("Unknown preset. Use: precise, balanced, thorough, lean", "error");
137
165
  return;
138
166
  }
139
167
  const config = applyPreset(presetName);
@@ -146,103 +174,152 @@ export function registerCommands(pi: ExtensionAPI, deps?: CommandDeps): void {
146
174
  },
147
175
  });
148
176
 
149
- pi.registerCommand("unipi:compact-index", {
150
- description: "Index current project files into FTS5",
151
- handler: async (_args: string, ctx: any) => {
152
- if (!deps?.contentStore) {
153
- ctx.ui.notify("Content store not initialized. Enable fts5Index in config.", "warning");
154
- return;
155
- }
156
- try {
157
- const cwd = (ctx as any).cwd ?? process.cwd();
158
- const { readdirSync, readFileSync, statSync } = await import("node:fs");
159
- const { join, relative, extname } = await import("node:path");
160
-
161
- const indexable = [".md", ".txt", ".ts", ".js", ".json", ".py", ".sh"];
162
- const files: string[] = [];
163
-
164
- const walk = (dir: string, depth = 0) => {
165
- if (depth > 3) return;
166
- try {
167
- for (const entry of readdirSync(dir, { withFileTypes: true })) {
168
- if (entry.name.startsWith(".") || entry.name === "node_modules") continue;
169
- const full = join(dir, entry.name);
170
- if (entry.isDirectory()) {
171
- walk(full, depth + 1);
172
- } else if (indexable.includes(extname(entry.name))) {
173
- files.push(full);
174
- }
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);
175
201
  }
176
- } catch {
177
- // skip unreadable dirs
178
- }
179
- };
180
-
181
- walk(cwd);
182
- let totalChunks = 0;
183
- for (const file of files.slice(0, 100)) {
184
- try {
185
- const content = readFileSync(file, "utf-8");
186
- if (content.length < 50) continue;
187
- const ext = extname(file);
188
- const ct = ext === ".md" ? "markdown" : ext === ".json" ? "json" : "plain";
189
- const result = await deps.contentStore.index(relative(cwd, file), content, {
190
- contentType: ct,
191
- source: file,
192
- });
193
- totalChunks += result.totalChunks;
194
- } catch {
195
- // skip unreadable files
196
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
197
223
  }
198
- ctx.ui.notify(`Indexed ${Math.min(files.length, 100)} files (${totalChunks} chunks).`, "info");
199
- } catch (err) {
200
- ctx.ui.notify(`Index error: ${err}`, "error");
201
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);
202
239
  },
203
240
  });
204
241
 
205
- pi.registerCommand("unipi:compact-search", {
206
- description: "Search indexed content",
207
- handler: async (args: string, ctx: any) => {
208
- const query = args.trim();
209
- if (!query) {
210
- ctx.ui.notify("Usage: /unipi:compact-search <query>", "warning");
211
- return;
212
- }
213
- if (!deps?.contentStore) {
214
- ctx.ui.notify("Content store not initialized.", "warning");
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");
215
257
  return;
216
258
  }
217
- try {
218
- const results = await ctxSearch({ query, limit: 10 });
219
- if (results.length === 0) {
220
- ctx.ui.notify(`No results for "${query}".`, "info");
221
- return;
222
- }
223
- const lines = results.map(
224
- (r, i) => `[${i + 1}] ${r.title} (rank: ${r.rank.toFixed(3)})\n${r.content.slice(0, 200)}`,
225
- );
226
- ctx.ui.notify(`Found ${results.length} results:\n${lines.join("\n\n")}`, "info");
227
- } catch (err) {
228
- ctx.ui.notify(`Search error: ${err}`, "error");
229
- }
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);
230
276
  },
231
277
  });
232
278
 
233
- pi.registerCommand("unipi:compact-purge", {
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", {
234
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
+ // ── /unipi:compact-help ──────────────────────────────
305
+ pi.registerCommand("unipi:compact-help", {
306
+ description: "Show detailed compactor documentation (tier-2 skill)",
235
307
  handler: async (_args: string, ctx: any) => {
236
- if (!deps?.contentStore) {
237
- ctx.ui.notify("Content store not initialized.", "warning");
238
- return;
239
- }
240
- try {
241
- await deps.contentStore.purge();
242
- ctx.ui.notify("All indexed content purged.", "info");
243
- } catch (err) {
244
- ctx.ui.notify(`Purge error: ${err}`, "error");
245
- }
308
+ // Load tier-2 skill content — delegates to skill loading system
309
+ ctx.ui.notify(
310
+ "🗜️ Compactor Help — Use your compactor-detail skill for full documentation.\n" +
311
+ "Quick commands:\n" +
312
+ " /unipi:compact — trigger compaction\n" +
313
+ " /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
+ " /unipi:compact-stats — view stats\n" +
318
+ " /unipi:compact-doctor — run diagnostics\n" +
319
+ " /unipi:compact-settings — TUI settings\n" +
320
+ " /unipi:compact-preset <name> — apply preset",
321
+ "info",
322
+ );
246
323
  },
247
324
  });
248
325
  }
@@ -17,9 +17,10 @@ const NOISE_STRINGS = [
17
17
 
18
18
  const XML_WRAPPER_RE = /<(system-reminder|ide_opened_file|command-message|context-window-usage)[^>]*>[\s\S]*?<\/\1>/g;
19
19
 
20
- const isNoiseUserBlock = (text: string): boolean => {
20
+ const isNoiseUserBlock = (text: string, extraPatterns: string[] = []): boolean => {
21
21
  const trimmed = text.trim();
22
22
  if (NOISE_STRINGS.some((s) => trimmed.includes(s))) return true;
23
+ if (extraPatterns.length > 0 && extraPatterns.some((p) => trimmed.includes(p))) return true;
23
24
  const stripped = trimmed.replace(XML_WRAPPER_RE, "").trim();
24
25
  return stripped.length === 0;
25
26
  };
@@ -27,14 +28,14 @@ const isNoiseUserBlock = (text: string): boolean => {
27
28
  const cleanUserText = (text: string): string =>
28
29
  text.replace(XML_WRAPPER_RE, "").trim();
29
30
 
30
- export const filterNoise = (blocks: NormalizedBlock[]): NormalizedBlock[] => {
31
+ export const filterNoise = (blocks: NormalizedBlock[], extraPatterns?: string[]): NormalizedBlock[] => {
31
32
  const out: NormalizedBlock[] = [];
32
33
  for (const b of blocks) {
33
34
  if (b.kind === "thinking") continue;
34
35
  if (b.kind === "tool_call" && NOISE_TOOLS.has(b.name)) continue;
35
36
  if (b.kind === "tool_result" && NOISE_TOOLS.has(b.name)) continue;
36
37
  if (b.kind === "user") {
37
- if (isNoiseUserBlock(b.text)) continue;
38
+ if (isNoiseUserBlock(b.text, extraPatterns)) continue;
38
39
  const cleaned = cleanUserText(b.text);
39
40
  if (!cleaned) continue;
40
41
  out.push({ kind: "user", text: cleaned });
@@ -8,6 +8,7 @@ import { compile } from "./summarize.js";
8
8
  import { loadConfig } from "../config/manager.js";
9
9
  import { buildOwnCut } from "./cut.js";
10
10
  import type { CompactionStats } from "../types.js";
11
+ import type { SessionDB } from "../session/db.js";
11
12
 
12
13
  export const COMPACTOR_INSTRUCTION = "__compactor__";
13
14
 
@@ -50,7 +51,10 @@ const REASON_MESSAGES: Record<import("./cut.js").OwnCutCancelReason, string> = {
50
51
  no_user_message: "compactor: Cannot compact — no user message found",
51
52
  };
52
53
 
53
- export function registerCompactionHooks(pi: ExtensionAPI): void {
54
+ export function registerCompactionHooks(
55
+ pi: ExtensionAPI,
56
+ deps?: { getSessionDB?: () => SessionDB | null; getSessionId?: () => string },
57
+ ): void {
54
58
  pi.on("session_before_compact", (event, ctx) => {
55
59
  const { preparation, branchEntries, customInstructions } = event;
56
60
  const config = loadConfig();
@@ -96,6 +100,23 @@ export function registerCompactionHooks(pi: ExtensionAPI): void {
96
100
  keptTokensEst: Math.round(keptChars / 4),
97
101
  };
98
102
 
103
+ // Persist cumulative compaction stats
104
+ const sessionDB = deps?.getSessionDB?.();
105
+ if (sessionDB && deps?.getSessionId) {
106
+ try {
107
+ const sessionId = deps.getSessionId();
108
+ const charsBefore = agentMessages.reduce((sum: number, msg: any) => {
109
+ const c = msg.message?.content;
110
+ if (typeof c === "string") return sum + c.length;
111
+ if (Array.isArray(c)) return sum + c.reduce((s: number, p: any) => s + (p.text?.length ?? 0), 0);
112
+ return sum;
113
+ }, 0);
114
+ sessionDB.addCompactionStats(sessionId, charsBefore, keptChars, agentMessages.length);
115
+ } catch {
116
+ // non-fatal
117
+ }
118
+ }
119
+
99
120
  dbg(config.debug, "compile", { messageCount: messages.length, hasPrevSummary: !!preparation.previousSummary });
100
121
  const summary = compile({
101
122
  messages,
@@ -1,8 +1,11 @@
1
1
  /**
2
2
  * BM25-lite search over normalized message blocks
3
+ *
4
+ * Includes module-level index cache for fast repeated queries.
3
5
  */
4
6
 
5
7
  import type { NormalizedBlock } from "../types.js";
8
+ import { createHash } from "node:crypto";
6
9
 
7
10
  interface SearchDoc {
8
11
  id: number;
@@ -63,6 +66,23 @@ export interface SearchHit {
63
66
  kind: string;
64
67
  }
65
68
 
69
+ // Module-level index cache
70
+ let cachedIndexHash = "";
71
+ let cachedDocs: SearchDoc[] = [];
72
+ let cachedIndex: Map<string, number[]> | null = null;
73
+ let cachedDocCount = 0;
74
+ let cachedAvgDocLen = 0;
75
+ let cachedDocLens: Map<number, number> = new Map();
76
+
77
+ export function invalidateSearchCache(): void {
78
+ cachedIndexHash = "";
79
+ cachedDocs = [];
80
+ cachedIndex = null;
81
+ cachedDocCount = 0;
82
+ cachedAvgDocLen = 0;
83
+ cachedDocLens = new Map();
84
+ }
85
+
66
86
  export function searchEntries(
67
87
  blocks: NormalizedBlock[],
68
88
  query: string,
@@ -74,10 +94,37 @@ export function searchEntries(
74
94
  kind: b.kind,
75
95
  }));
76
96
 
77
- const index = buildIndex(docs);
78
- const docCount = docs.length;
79
- const docLens = new Map(docs.map((d) => [d.id, tokenize(d.text).length]));
80
- const avgDocLen = docCount > 0 ? [...docLens.values()].reduce((a, b) => a + b, 0) / docCount : 1;
97
+ // Compute content hash to detect blocks change
98
+ const hashSource = docs.length > 0
99
+ ? `${docs.length}:${docs[0].text.slice(0, 80)}:${docs[docs.length - 1].text.slice(-80)}`
100
+ : "empty";
101
+ const currentHash = createHash("sha256").update(hashSource).digest("hex");
102
+
103
+ // Use cached index if blocks haven't changed
104
+ let index: Map<string, number[]>;
105
+ let docCount: number;
106
+ let avgDocLen: number;
107
+ let docLens: Map<number, number>;
108
+
109
+ if (currentHash === cachedIndexHash && cachedIndex) {
110
+ index = cachedIndex;
111
+ docCount = cachedDocCount;
112
+ avgDocLen = cachedAvgDocLen;
113
+ docLens = cachedDocLens;
114
+ } else {
115
+ index = buildIndex(docs);
116
+ docCount = docs.length;
117
+ docLens = new Map(docs.map((d) => [d.id, tokenize(d.text).length]));
118
+ avgDocLen = docCount > 0 ? [...docLens.values()].reduce((a, b) => a + b, 0) / docCount : 1;
119
+
120
+ // Update cache
121
+ cachedIndexHash = currentHash;
122
+ cachedDocs = docs;
123
+ cachedIndex = index;
124
+ cachedDocCount = docCount;
125
+ cachedAvgDocLen = avgDocLen;
126
+ cachedDocLens = docLens;
127
+ }
81
128
 
82
129
  const queryTokens = tokenize(query);
83
130
  if (queryTokens.length === 0) return [];
@@ -10,6 +10,11 @@ import { DEFAULT_COMPACTOR_CONFIG } from "./schema.js";
10
10
 
11
11
  export const COMPACTOR_CONFIG_PATH = join(homedir(), ".unipi", "config", "compactor", "config.json");
12
12
 
13
+ /** Return the per-project config path for a given project directory. */
14
+ export function projectConfigPath(cwd: string): string {
15
+ return join(cwd, ".unipi", "config", "compactor.json");
16
+ }
17
+
13
18
  const readJson = (path: string): Record<string, unknown> | null => {
14
19
  try {
15
20
  return JSON.parse(readFileSync(path, "utf-8"));
@@ -18,23 +23,66 @@ const readJson = (path: string): Record<string, unknown> | null => {
18
23
  }
19
24
  };
20
25
 
26
+ /** Deep merge project overrides into global config. */
27
+ function deepMerge<T extends Record<string, any>>(base: T, override: Partial<T>): T {
28
+ const result = { ...base };
29
+ for (const key of Object.keys(override) as (keyof T)[]) {
30
+ const baseVal = result[key];
31
+ const overrideVal = override[key];
32
+ if (
33
+ overrideVal !== undefined &&
34
+ typeof overrideVal === "object" &&
35
+ !Array.isArray(overrideVal) &&
36
+ overrideVal !== null &&
37
+ typeof baseVal === "object" &&
38
+ !Array.isArray(baseVal) &&
39
+ baseVal !== null
40
+ ) {
41
+ (result as any)[key] = deepMerge(baseVal as any, overrideVal as any);
42
+ } else if (overrideVal !== undefined) {
43
+ (result as any)[key] = overrideVal;
44
+ }
45
+ }
46
+ return result;
47
+ }
48
+
21
49
  /**
22
50
  * Load compactor config from disk with defaults fallback.
51
+ * Supports per-project overrides at <cwd>/.unipi/config/compactor.json.
23
52
  */
24
- export function loadConfig(): CompactorConfig {
53
+ export function loadConfig(cwd?: string): CompactorConfig {
25
54
  const parsed = readJson(COMPACTOR_CONFIG_PATH);
26
- if (!parsed || typeof parsed !== "object") return structuredClone(DEFAULT_COMPACTOR_CONFIG);
27
- return migrateConfig(parsed as Partial<CompactorConfig>);
55
+ let config: CompactorConfig;
56
+ if (!parsed || typeof parsed !== "object") {
57
+ config = structuredClone(DEFAULT_COMPACTOR_CONFIG);
58
+ } else {
59
+ config = migrateConfig(parsed as Partial<CompactorConfig>);
60
+ }
61
+
62
+ // Apply per-project overrides if cwd is provided and project config exists
63
+ if (cwd) {
64
+ const projPath = projectConfigPath(cwd);
65
+ const projOverride = readJson(projPath);
66
+ if (projOverride && typeof projOverride === "object") {
67
+ config = deepMerge(config, projOverride as Partial<CompactorConfig>);
68
+ }
69
+ }
70
+
71
+ return config;
28
72
  }
29
73
 
30
74
  /**
31
75
  * Save config to disk with schema validation.
76
+ * If perProject is true, saves to <cwd>/.unipi/config/compactor.json instead of global.
32
77
  */
33
- export function saveConfig(config: CompactorConfig): { success: boolean; error?: string } {
78
+ export function saveConfig(config: CompactorConfig, opts?: { perProject?: boolean; cwd?: string }): { success: boolean; error?: string } {
34
79
  try {
35
- const dir = dirname(COMPACTOR_CONFIG_PATH);
80
+ const targetPath = (opts?.perProject && opts?.cwd)
81
+ ? projectConfigPath(opts.cwd)
82
+ : COMPACTOR_CONFIG_PATH;
83
+ const dir = dirname(targetPath);
36
84
  if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
37
- writeFileSync(COMPACTOR_CONFIG_PATH, `${JSON.stringify(config, null, 2)}\n`);
85
+ writeFileSync(targetPath, `${JSON.stringify(config, null, 2)}\n`);
38
86
  return { success: true };
39
87
  } catch (err) {
40
88
  return { success: false, error: String(err) };
@@ -67,6 +115,7 @@ export function migrateConfig(partial: Partial<CompactorConfig>): CompactorConfi
67
115
  fts5Index: mergeStrategy("fts5Index", defaults.fts5Index, partial.fts5Index),
68
116
  sandboxExecution: mergeStrategy("sandboxExecution", defaults.sandboxExecution, partial.sandboxExecution),
69
117
  toolDisplay: mergeStrategy("toolDisplay", defaults.toolDisplay, partial.toolDisplay),
118
+ pipeline: mergeStrategy("pipeline", defaults.pipeline, (partial as any).pipeline) as any,
70
119
  overrideDefaultCompaction: partial.overrideDefaultCompaction ?? defaults.overrideDefaultCompaction,
71
120
  debug: partial.debug ?? defaults.debug,
72
121
  showTruncationHints: partial.showTruncationHints ?? defaults.showTruncationHints,