@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.
@@ -11,16 +11,16 @@ const preset = (
11
11
  ): CompactorConfig => ({
12
12
  ...structuredClone(DEFAULT_COMPACTOR_CONFIG),
13
13
  ...overrides,
14
- sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, ...(overrides.sessionGoals as any) },
15
- filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, ...(overrides.filesAndChanges as any) },
16
- commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, ...(overrides.commits as any) },
17
- outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, ...(overrides.outstandingContext as any) },
18
- userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, ...(overrides.userPreferences as any) },
19
- briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, ...(overrides.briefTranscript as any) },
20
- sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, ...(overrides.sessionContinuity as any) },
21
- fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, ...(overrides.fts5Index as any) },
22
- sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, ...(overrides.sandboxExecution as any) },
23
- toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay as any) },
14
+ sessionGoals: { ...DEFAULT_COMPACTOR_CONFIG.sessionGoals, ...(overrides.sessionGoals ?? {}) },
15
+ filesAndChanges: { ...DEFAULT_COMPACTOR_CONFIG.filesAndChanges, ...(overrides.filesAndChanges ?? {}) },
16
+ commits: { ...DEFAULT_COMPACTOR_CONFIG.commits, ...(overrides.commits ?? {}) },
17
+ outstandingContext: { ...DEFAULT_COMPACTOR_CONFIG.outstandingContext, ...(overrides.outstandingContext ?? {}) },
18
+ userPreferences: { ...DEFAULT_COMPACTOR_CONFIG.userPreferences, ...(overrides.userPreferences ?? {}) },
19
+ briefTranscript: { ...DEFAULT_COMPACTOR_CONFIG.briefTranscript, ...(overrides.briefTranscript ?? {}) },
20
+ sessionContinuity: { ...DEFAULT_COMPACTOR_CONFIG.sessionContinuity, ...(overrides.sessionContinuity ?? {}) },
21
+ fts5Index: { ...DEFAULT_COMPACTOR_CONFIG.fts5Index, ...(overrides.fts5Index ?? {}) },
22
+ sandboxExecution: { ...DEFAULT_COMPACTOR_CONFIG.sandboxExecution, ...(overrides.sandboxExecution ?? {}) },
23
+ toolDisplay: { ...DEFAULT_COMPACTOR_CONFIG.toolDisplay, ...(overrides.toolDisplay ?? {}) },
24
24
  });
25
25
 
26
26
  // Pipeline feature defaults per preset:
@@ -122,11 +122,11 @@ export class PolyglotExecutor {
122
122
  }
123
123
 
124
124
  return result;
125
- } catch (err: any) {
125
+ } catch (err: unknown) {
126
126
  try { rmSync(tmpDir, { recursive: true, force: true }); } catch { /* ignore */ }
127
127
  return {
128
128
  stdout: "",
129
- stderr: err?.message ?? String(err),
129
+ stderr: (err instanceof Error ? err.message : String(err)),
130
130
  exitCode: 1,
131
131
  timedOut: false,
132
132
  };
@@ -228,10 +228,10 @@ export class PolyglotExecutor {
228
228
  timeout: timeout / 2,
229
229
  stdio: ["ignore", "pipe", "pipe"],
230
230
  });
231
- } catch (err: any) {
231
+ } catch (err: unknown) {
232
232
  return {
233
233
  stdout: "",
234
- stderr: err?.stderr?.toString?.() ?? err?.message ?? "Rust compilation failed",
234
+ stderr: (err instanceof Error && 'stderr' in err ? String((err as Error & { stderr?: Buffer }).stderr) : '') || (err instanceof Error ? err.message : String(err)) || "Rust compilation failed",
235
235
  exitCode: 1,
236
236
  timedOut: false,
237
237
  };
package/src/index.ts CHANGED
@@ -9,7 +9,6 @@ import { registerCompactionHooks } from "./compaction/hooks.js";
9
9
  import { SessionDB, getWorktreeSuffix } from "./session/db.js";
10
10
  import { extractEventsFromToolResult } from "./session/extract.js";
11
11
  import { injectResumeSnapshot } from "./session/resume-inject.js";
12
- import { ContentStore } from "./store/index.js";
13
12
  import { PolyglotExecutor } from "./executor/executor.js";
14
13
  import { registerCommands } from "./commands/index.js";
15
14
  import { registerCompactorTools } from "./tools/register.js";
@@ -57,7 +56,6 @@ function isIndexTool(_name: string): boolean {
57
56
 
58
57
  export default function compactorExtension(pi: ExtensionAPI): void {
59
58
  let sessionDB: SessionDB | null = null;
60
- let contentStore: ContentStore | null = null;
61
59
  let executor: PolyglotExecutor | null = null;
62
60
  let config = loadConfig();
63
61
  let cachedBlocks: NormalizedBlock[] = [];
@@ -101,19 +99,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
101
99
  sessionDB = null;
102
100
  }
103
101
 
104
- // Initialize ContentStore independently — its failure shouldn't
105
- // prevent SessionDB commands from working.
106
- if (config.fts5Index.enabled) {
107
- try {
108
- const cs = new ContentStore();
109
- await cs.init();
110
- contentStore = cs;
111
- } catch {
112
- // Silently ignore — ContentStore init failure is handled gracefully.
113
- contentStore = null;
114
- }
115
- }
116
-
117
102
  executor = new PolyglotExecutor();
118
103
  };
119
104
 
@@ -127,7 +112,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
127
112
  // Commands registered inside session_start after init() when deps are ready
128
113
  const getCommandDeps = () => ({
129
114
  sessionDB,
130
- contentStore,
131
115
  getSessionId: () => currentSessionId,
132
116
  getBlocks: () => cachedBlocks,
133
117
  getCounters,
@@ -144,6 +128,17 @@ export default function compactorExtension(pi: ExtensionAPI): void {
144
128
 
145
129
  debug("session_start", { sessionId: fullSessionId, projectDir });
146
130
 
131
+ // Seed runtime counters from DB so they reflect prior usage
132
+ if (sessionDB) {
133
+ try {
134
+ const allTime = sessionDB.getAllTimeStats();
135
+ counters.sandboxRuns = allTime.allSandboxRuns;
136
+ counters.searchQueries = allTime.allSearchQueries;
137
+ } catch {
138
+ // Non-fatal: counter seeding from DB failed
139
+ }
140
+ }
141
+
147
142
  // Reset runtime stats for new session
148
143
  runtimeStats.bytesReturned = {};
149
144
  runtimeStats.bytesIndexed = 0;
@@ -159,7 +154,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
159
154
  if (sessionDB) {
160
155
  registerCompactorTools(pi, {
161
156
  sessionDB,
162
- contentStore,
163
157
  getSessionId: () => currentSessionId,
164
158
  getBlocks: () => cachedBlocks,
165
159
  getCounters,
@@ -170,7 +164,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
170
164
  registerCommands(pi, getCommandDeps());
171
165
 
172
166
  // Register info-screen group
173
- const infoRegistry = (globalThis as any).__unipi_info_registry;
167
+ const infoRegistry = globalThis.__unipi_info_registry;
174
168
  if (infoRegistry && sessionDB) {
175
169
  const sdb = sessionDB;
176
170
  const sid = () => currentSessionId;
@@ -193,7 +187,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
193
187
  dataProvider: async () => {
194
188
  try {
195
189
  const { getInfoScreenData } = await import("./info-screen.js");
196
- const data = await getInfoScreenData(sdb, sid(), runtimeStats);
190
+ const data = await getInfoScreenData(sdb, sid(), getCounters());
197
191
  return {
198
192
  tokensSaved: { value: data.tokensSaved.value, detail: data.tokensSaved.detail },
199
193
  costSaved: { value: data.costSaved.value, detail: data.costSaved.detail },
@@ -209,7 +203,7 @@ export default function compactorExtension(pi: ExtensionAPI): void {
209
203
  });
210
204
  }
211
205
 
212
- emitEvent(pi as any, UNIPI_EVENTS.MODULE_READY, {
206
+ emitEvent(pi, UNIPI_EVENTS.MODULE_READY, {
213
207
  name: MODULES.COMPACTOR,
214
208
  version: "0.1.0",
215
209
  commands: Object.values(COMPACTOR_COMMANDS),
@@ -218,10 +212,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
218
212
 
219
213
  debug("MODULE_READY", { commands: Object.values(COMPACTOR_COMMANDS), tools: Object.values(COMPACTOR_TOOLS) });
220
214
 
221
- if (config.fts5Index.mode === "auto" && contentStore) {
222
- // TODO: index project files
223
- }
224
-
225
215
  ctx.ui.notify("🗜️ Compactor ready", "info");
226
216
  });
227
217
 
@@ -237,7 +227,6 @@ export default function compactorExtension(pi: ExtensionAPI): void {
237
227
  const { join } = await import("node:path");
238
228
  const strategies: Array<{ key: string; config: CompactorStrategyConfig }> = [
239
229
  { key: "commits", config: config.commits },
240
- { key: "fts5Index", config: config.fts5Index },
241
230
  ];
242
231
  for (const { key, config: strat } of strategies) {
243
232
  if ((strat as any).autoDetect === "git") {
@@ -315,44 +304,44 @@ export default function compactorExtension(pi: ExtensionAPI): void {
315
304
  const compactionEntry = (event as any).compactionEntry;
316
305
  const tokensBefore = compactionEntry?.tokensBefore ?? 0;
317
306
 
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
-
322
307
  if (tokensBefore > 0) {
323
- // Use actual token count from Pi's compactionEntry
308
+ // Use actual token count from Pi's compactionEntry.
309
+ // Compaction typically keeps ~10-15% of original context.
324
310
  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);
311
+ const tokensAfter = (event as any).tokensAfter ?? Math.round(tokensBefore * 0.12);
327
312
  const charsKept = tokensAfter * 4;
328
313
  const messagesSummarized = Math.max(1, Math.round(tokensBefore / 500));
329
314
  counters.totalTokensCompacted += tokensBefore - tokensAfter;
330
315
  sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, messagesSummarized);
331
316
  } 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);
317
+ // tokensBefore unavailable use session event count as a rough heuristic.
318
+ // This happens when Pi's compaction doesn't report tokensBefore.
319
+ // We estimate from the number of events stored in this session.
320
+ try {
321
+ const eventCount = sessionDB.getEventCount(sessionId);
322
+ if (eventCount > 0) {
323
+ // Rough estimate: ~500 tokens per event on average
324
+ const estTokensBefore = eventCount * 500;
325
+ const estTokensAfter = Math.round(estTokensBefore * 0.12);
326
+ const charsBefore = estTokensBefore * 4;
327
+ const charsKept = estTokensAfter * 4;
328
+ counters.totalTokensCompacted += estTokensBefore - estTokensAfter;
329
+ sessionDB.addCompactionStats(sessionId, charsBefore, charsKept, eventCount);
330
+ }
331
+ } catch {
332
+ // Non-fatal: heuristic estimation failed
341
333
  }
342
334
  }
343
- debug("session_compact", { sessionId, tokensBefore, totalBytesProcessed, hasCompactionEntry: !!compactionEntry });
335
+ debug("session_compact", { sessionId, tokensBefore, hasCompactionEntry: !!compactionEntry });
344
336
  }
345
337
  });
346
338
 
347
339
  pi.on("session_shutdown", async (_event, _ctx) => {
348
340
  debug("session_shutdown");
349
- // WAL checkpoint: TRUNCATE on shutdown to keep DB file size down
350
- contentStore?.checkpointWAL("TRUNCATE");
351
341
  if (sessionDB) {
352
342
  sessionDB.cleanupOldSessions(7);
353
343
  }
354
344
  executor?.cleanupBackgrounded();
355
- contentStore?.close();
356
345
  sessionDB?.close();
357
346
  });
358
347
 
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Info-screen integration for @pi-unipi/compactor
3
3
  *
4
- * Budget-focused stats: tokensSaved, costSaved, pctReduction,
5
- * topTools, compactions, toolCalls.
4
+ * Stats driven by COMPACTION SAVINGS (the compactor's actual value),
5
+ * not sandbox/index diversion bytes.
6
+ *
7
+ * Data sources (in priority order):
8
+ * 1. Runtime counters (in-memory, current session only)
9
+ * 2. DB compaction stats (total_chars_before/kept in session_meta)
10
+ * 3. Session event counts (session_events table, always reliable)
6
11
  */
7
12
 
8
13
  import type { SessionDB } from "./session/db.js";
9
- import type { RuntimeStats, FullReport } from "./session/analytics.js";
10
- import { AnalyticsEngine, createMinimalDb } from "./session/analytics.js";
11
14
  import { getLastCompactionStats } from "./compaction/hooks.js";
12
15
  import { parseUsageStats } from "@pi-unipi/info-screen/usage-parser.js";
16
+ import type { RuntimeCounters } from "./types.js";
13
17
 
14
18
  export interface CompactorInfoData {
15
19
  tokensSaved: { value: string; detail: string };
@@ -45,7 +49,6 @@ function estimateCostPerToken(): number | null {
45
49
  const models = usage.byModelToday;
46
50
  const todayKeys = Object.keys(models);
47
51
  if (todayKeys.length > 0) {
48
- // Pick the model with most tokens today
49
52
  const topModel = todayKeys.reduce((a, b) => models[a].tokens > models[b].tokens ? a : b);
50
53
  const entry = models[topModel];
51
54
  if (entry.tokens > 0 && entry.cost > 0) {
@@ -70,40 +73,92 @@ function estimateCostPerToken(): number | null {
70
73
  export async function getInfoScreenData(
71
74
  sessionDB: SessionDB,
72
75
  sessionId: string,
73
- runtimeStats: RuntimeStats,
76
+ counters?: RuntimeCounters,
74
77
  ): Promise<CompactorInfoData> {
75
78
  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();
79
+ // ── Compaction savings (the compactor's actual value) ──
80
+ // Priority: in-memory counter DB per-session stats → DB all-time stats
81
+ let tokensSaved = counters?.totalTokensCompacted ?? 0;
82
+ let charsBefore = 0;
83
+ let charsKept = 0;
84
+
85
+ if (tokensSaved === 0) {
86
+ // Try DB per-session stats
87
+ const sessionStats = sessionDB.getSessionStats(sessionId);
88
+ if (sessionStats) {
89
+ charsBefore = (sessionStats as any).total_chars_before ?? 0;
90
+ charsKept = (sessionStats as any).total_chars_kept ?? 0;
91
+ tokensSaved = Math.round((charsBefore - charsKept) / 4);
92
+ }
93
+ }
81
94
 
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")
95
+ if (tokensSaved === 0) {
96
+ // Try DB all-time stats
97
+ const allTime = sessionDB.getAllTimeStats();
98
+ charsBefore = allTime.allCharsBefore;
99
+ charsKept = allTime.allCharsKept;
100
+ tokensSaved = Math.round((charsBefore - charsKept) / 4);
101
+ }
102
+
103
+ // ── Compaction count ──
104
+ let compactionCount = counters?.compactions ?? 0;
105
+ if (compactionCount === 0) {
106
+ const allTime = sessionDB.getAllTimeStats();
107
+ compactionCount = allTime.allCompactions;
108
+ }
109
+
110
+ // ── Compression ratio / pct reduction ──
111
+ let pctReduction = 0;
112
+ if (charsBefore > 0) {
113
+ pctReduction = Math.round((1 - charsKept / charsBefore) * 100);
114
+ }
115
+
116
+ // ── Tool call counts from session_events (always reliable) ──
117
+ // The session_events table captures every tool_result event, so this
118
+ // is an accurate count regardless of runtimeStats state.
119
+ interface ToolCountRow { category: string; cnt: number }
120
+ let toolCountRows: ToolCountRow[] = [];
121
+ let totalToolCalls = 0;
122
+ try {
123
+ const db = sessionDB.getDb();
124
+ if (db) {
125
+ toolCountRows = db.prepare(
126
+ "SELECT category, COUNT(*) as cnt FROM session_events WHERE session_id = ? GROUP BY category",
127
+ ).all(sessionId) as ToolCountRow[];
128
+ for (const row of toolCountRows) {
129
+ totalToolCalls += row.cnt;
130
+ }
131
+ }
132
+ } catch {
133
+ // Non-fatal: DB query failed, show zero
134
+ }
135
+
136
+ // Build per-tool breakdown for display
137
+ const toolBreakdown = toolCountRows.length > 0
138
+ ? toolCountRows
139
+ .sort((a, b) => b.cnt - a.cnt)
140
+ .map(r => ` ${r.category.padEnd(20)} ${String(r.cnt).padStart(5)} events`)
141
+ .join("\n")
93
142
  : "No tool calls yet";
94
143
 
95
- // Cost saved: tokensSaved × cost per token
144
+ const topCategory = toolCountRows.length > 0
145
+ ? toolCountRows.reduce((a, b) => a.cnt > b.cnt ? a : b)
146
+ : null;
147
+
148
+ const top5Detail = toolCountRows.length > 0
149
+ ? toolCountRows
150
+ .sort((a, b) => b.cnt - a.cnt)
151
+ .slice(0, 5)
152
+ .map(r => `${r.category}: ${r.cnt} events`)
153
+ .join("\n")
154
+ : "No tool calls yet";
155
+
156
+ // ── Cost saved estimate ──
96
157
  const costPerToken = estimateCostPerToken();
97
158
  const costSaved = costPerToken !== null ? tokensSaved * costPerToken : null;
98
159
 
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";
160
+ // ── Last compaction details ──
161
+ const compactStats = getLastCompactionStats();
107
162
 
108
163
  return {
109
164
  tokensSaved: {
@@ -117,24 +172,26 @@ export async function getInfoScreenData(
117
172
  : "Cost data unavailable for current model",
118
173
  },
119
174
  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`,
175
+ value: `${pctReduction}%`,
176
+ detail: charsBefore > 0
177
+ ? `${formatTokens(Math.round(charsBefore / 4))} before → ${formatTokens(Math.round(charsKept / 4))} after compaction`
178
+ : "No compaction data yet",
122
179
  },
123
180
  topTools: {
124
- value: topTool ? `${topTool.tool}: ${formatTokens(topTool.tokens)}` : "N/A",
181
+ value: topCategory ? `${topCategory.category}: ${topCategory.cnt}` : "N/A",
125
182
  detail: top5Detail,
126
183
  },
127
184
  compactions: {
128
- value: String(Math.max(report.continuity.compact_count, report.continuity.all_time_compact_count)),
185
+ value: String(compactionCount),
129
186
  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`
187
+ ? `Last: ${compactStats.totalMessages} messages (~${formatTokens(compactStats.tokensBefore)} tokens) → ${compactStats.kept} messages (~${formatTokens(compactStats.tokensAfterEst)} tokens)`
188
+ : compactionCount > 0
189
+ ? `${compactionCount} compaction(s) across all sessions`
133
190
  : "No compactions yet",
134
191
  },
135
192
  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" : ""}`,
193
+ value: String(totalToolCalls),
194
+ detail: `${totalToolCalls} events across ${toolCountRows.length} categor${toolCountRows.length !== 1 ? "ies" : "y"}`,
138
195
  },
139
196
  };
140
197
  } catch {
package/src/session/db.ts CHANGED
@@ -59,9 +59,9 @@ async function getSQLite() {
59
59
  }
60
60
 
61
61
  interface PreparedStatement {
62
- get(...args: any[]): any;
63
- all(...args: any[]): any[];
64
- run(...args: any[]): { changes: number };
62
+ get(...args: unknown[]): unknown;
63
+ all(...args: unknown[]): unknown[];
64
+ run(...args: unknown[]): { changes: number };
65
65
  }
66
66
 
67
67
  const MAX_EVENTS_PER_SESSION = 1000;
@@ -143,8 +143,8 @@ export class SessionDB {
143
143
  const safeAddColumn = (table: string, col: string, def: string) => {
144
144
  try {
145
145
  this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
146
- } catch (e: any) {
147
- if (e?.message?.includes("duplicate column")) return;
146
+ } catch (e: unknown) {
147
+ if (e instanceof Error && e.message.includes("duplicate column")) return;
148
148
  throw e;
149
149
  }
150
150
  };
@@ -156,6 +156,21 @@ export class SessionDB {
156
156
  safeAddColumn("session_events", "data_hash", "TEXT NOT NULL DEFAULT ''");
157
157
  this.db.pragma("user_version = 1");
158
158
  }
159
+
160
+ if (currentVersion < 2) {
161
+ // V2: Add sandbox/search counter columns for persistent stats
162
+ const safeAddColumn = (table: string, col: string, def: string) => {
163
+ try {
164
+ this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${col} ${def}`);
165
+ } catch (e: unknown) {
166
+ if (e instanceof Error && e.message.includes("duplicate column")) return;
167
+ throw e;
168
+ }
169
+ };
170
+ safeAddColumn("session_meta", "sandbox_runs", "INTEGER NOT NULL DEFAULT 0");
171
+ safeAddColumn("session_meta", "search_queries", "INTEGER NOT NULL DEFAULT 0");
172
+ this.db.pragma("user_version = 2");
173
+ }
159
174
  }
160
175
 
161
176
  private prepareStatements(): void {
@@ -171,10 +186,12 @@ export class SessionDB {
171
186
  p("evictLowestPriority", `DELETE FROM session_events WHERE id = (SELECT id FROM session_events WHERE session_id = ? ORDER BY priority ASC, id ASC LIMIT 1)`);
172
187
  p("updateMetaLastEvent", `UPDATE session_meta SET last_event_at = datetime('now'), event_count = event_count + 1 WHERE session_id = ?`);
173
188
  p("ensureSession", `INSERT OR IGNORE INTO session_meta (session_id, project_dir) VALUES (?, ?)`);
174
- p("getSessionStats", `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count, total_chars_before, total_chars_kept, total_messages_summarized FROM session_meta WHERE session_id = ?`);
189
+ p("getSessionStats", `SELECT session_id, project_dir, started_at, last_event_at, event_count, compact_count, total_chars_before, total_chars_kept, total_messages_summarized, sandbox_runs, search_queries FROM session_meta WHERE session_id = ?`);
175
190
  p("incrementCompactCount", `UPDATE session_meta SET compact_count = compact_count + 1 WHERE session_id = ?`);
176
191
  p("addCompactionStats", `UPDATE session_meta SET total_chars_before = total_chars_before + ?, total_chars_kept = total_chars_kept + ?, total_messages_summarized = total_messages_summarized + ? WHERE session_id = ?`);
177
- p("getAllTimeStats", `SELECT COALESCE(SUM(total_chars_before), 0) AS all_chars_before, COALESCE(SUM(total_chars_kept), 0) AS all_chars_kept, COALESCE(SUM(total_messages_summarized), 0) AS all_messages_summarized, COALESCE(SUM(compact_count), 0) AS all_compactions FROM session_meta`);
192
+ p("getAllTimeStats", `SELECT COALESCE(SUM(total_chars_before), 0) AS all_chars_before, COALESCE(SUM(total_chars_kept), 0) AS all_chars_kept, COALESCE(SUM(total_messages_summarized), 0) AS all_messages_summarized, COALESCE(SUM(compact_count), 0) AS all_compactions, COALESCE(SUM(sandbox_runs), 0) AS all_sandbox_runs, COALESCE(SUM(search_queries), 0) AS all_search_queries FROM session_meta`);
193
+ p("incrementSandboxRuns", `UPDATE session_meta SET sandbox_runs = sandbox_runs + 1 WHERE session_id = ?`);
194
+ p("incrementSearchQueries", `UPDATE session_meta SET search_queries = search_queries + 1 WHERE session_id = ?`);
178
195
  p("upsertResume", `INSERT INTO session_resume (session_id, snapshot, event_count) VALUES (?, ?, ?) ON CONFLICT(session_id) DO UPDATE SET snapshot = excluded.snapshot, event_count = excluded.event_count, created_at = datetime('now'), consumed = 0`);
179
196
  p("getResume", `SELECT snapshot, event_count, consumed FROM session_resume WHERE session_id = ?`);
180
197
  p("markResumeConsumed", `UPDATE session_resume SET consumed = 1 WHERE session_id = ?`);
@@ -248,17 +265,29 @@ export class SessionDB {
248
265
  this.stmt("addCompactionStats").run(charsBefore, charsKept, messagesSummarized, sessionId);
249
266
  }
250
267
 
251
- getAllTimeStats(): { allCharsBefore: number; allCharsKept: number; allMessagesSummarized: number; allCompactions: number } {
252
- if (!this.stmts) return { allCharsBefore: 0, allCharsKept: 0, allMessagesSummarized: 0, allCompactions: 0 };
253
- const row = this.stmt("getAllTimeStats").get() as { all_chars_before: number; all_chars_kept: number; all_messages_summarized: number; all_compactions: number };
268
+ getAllTimeStats(): { allCharsBefore: number; allCharsKept: number; allMessagesSummarized: number; allCompactions: number; allSandboxRuns: number; allSearchQueries: number } {
269
+ if (!this.stmts) return { allCharsBefore: 0, allCharsKept: 0, allMessagesSummarized: 0, allCompactions: 0, allSandboxRuns: 0, allSearchQueries: 0 };
270
+ const row = this.stmt("getAllTimeStats").get() as { all_chars_before: number; all_chars_kept: number; all_messages_summarized: number; all_compactions: number; all_sandbox_runs: number; all_search_queries: number };
254
271
  return {
255
272
  allCharsBefore: row?.all_chars_before ?? 0,
256
273
  allCharsKept: row?.all_chars_kept ?? 0,
257
274
  allMessagesSummarized: row?.all_messages_summarized ?? 0,
258
275
  allCompactions: row?.all_compactions ?? 0,
276
+ allSandboxRuns: row?.all_sandbox_runs ?? 0,
277
+ allSearchQueries: row?.all_search_queries ?? 0,
259
278
  };
260
279
  }
261
280
 
281
+ incrementSandboxRuns(sessionId: string): void {
282
+ if (!this.stmts) return;
283
+ this.stmt("incrementSandboxRuns").run(sessionId);
284
+ }
285
+
286
+ incrementSearchQueries(sessionId: string): void {
287
+ if (!this.stmts) return;
288
+ this.stmt("incrementSearchQueries").run(sessionId);
289
+ }
290
+
262
291
  upsertResume(sessionId: string, snapshot: string, eventCount?: number): void {
263
292
  if (!this.stmts) return;
264
293
  this.stmt("upsertResume").run(sessionId, snapshot, eventCount ?? 0);
@@ -294,7 +323,7 @@ export class SessionDB {
294
323
  }
295
324
 
296
325
  /** Expose the underlying db for AnalyticsEngine (read-only queries). Returns null if init failed. */
297
- getDb(): any { return this.db ?? null; }
326
+ getDb(): { prepare(sql: string): { get(...args: unknown[]): unknown; all(...args: unknown[]): unknown[]; run(...args: unknown[]): { changes: number } }; exec(sql: string): void; close(): void } | null { return this.db as ReturnType<typeof this.getDb> ?? null; }
298
327
 
299
328
  close(): void {
300
329
  try { this.db.close(); } catch { /* ignore */ }
@@ -73,6 +73,43 @@ export function extractEventsFromToolResult(result: ToolResult): SessionEvent[]
73
73
  });
74
74
  }
75
75
 
76
+ // Sandbox execution
77
+ if (["sandbox", "ctx_execute", "sandbox_file", "ctx_execute_file", "sandbox_batch", "ctx_batch_execute"].includes(result.toolName)) {
78
+ const language = String(result.toolInput.language ?? "unknown");
79
+ const code = String(result.toolInput.code ?? "").slice(0, 200);
80
+ events.push({
81
+ type: "sandbox_execution",
82
+ category: "sandbox",
83
+ data: `[${language}] ${code}`,
84
+ priority: EventPriority.LOW,
85
+ data_hash: "",
86
+ });
87
+ }
88
+
89
+ // Content search queries
90
+ if (["content_search", "ctx_search"].includes(result.toolName)) {
91
+ const query = String(result.toolInput.query ?? "").slice(0, 200);
92
+ events.push({
93
+ type: "content_search",
94
+ category: "search",
95
+ data: query,
96
+ priority: EventPriority.LOW,
97
+ data_hash: "",
98
+ });
99
+ }
100
+
101
+ // Content indexing
102
+ if (["content_index", "ctx_index", "content_fetch", "ctx_fetch_and_index"].includes(result.toolName)) {
103
+ const label = String(result.toolInput.label ?? result.toolInput.url ?? "").slice(0, 200);
104
+ events.push({
105
+ type: "content_indexed",
106
+ category: "index",
107
+ data: label,
108
+ priority: EventPriority.LOW,
109
+ data_hash: "",
110
+ });
111
+ }
112
+
76
113
  return events;
77
114
  }
78
115
 
@@ -1,10 +1,9 @@
1
1
  /**
2
- * ctx_batch_execute tool — atomic batch of commands + searches
2
+ * ctx_batch_execute tool — atomic batch of commands
3
3
  */
4
4
 
5
5
  import { PolyglotExecutor } from "../executor/executor.js";
6
- import type { ContentStore } from "../store/index.js";
7
- import type { Language, ExecResult, SearchResult } from "../types.js";
6
+ import type { Language, ExecResult } from "../types.js";
8
7
 
9
8
  export interface BatchCommand {
10
9
  type: "execute";
@@ -13,22 +12,15 @@ export interface BatchCommand {
13
12
  timeout?: number;
14
13
  }
15
14
 
16
- export interface BatchSearch {
17
- type: "search";
18
- query: string;
19
- limit?: number;
20
- }
21
-
22
- export type BatchItem = BatchCommand | BatchSearch;
15
+ export type BatchItem = BatchCommand;
23
16
 
24
17
  export interface BatchResult {
25
18
  results: Array<
26
- | { type: "execute"; result: ExecResult }
27
- | { type: "search"; results: SearchResult[] }
19
+ { type: "execute"; result: ExecResult }
28
20
  >;
29
21
  }
30
22
 
31
- export async function ctxBatchExecute(store: ContentStore, items: BatchItem[]): Promise<BatchResult> {
23
+ export async function ctxBatchExecute(items: BatchItem[]): Promise<BatchResult> {
32
24
  const results: BatchResult["results"] = [];
33
25
  const executor = new PolyglotExecutor();
34
26
 
@@ -40,9 +32,6 @@ export async function ctxBatchExecute(store: ContentStore, items: BatchItem[]):
40
32
  timeout: item.timeout ?? 30000,
41
33
  });
42
34
  results.push({ type: "execute", result });
43
- } else {
44
- const searchResults = await store.search(item.query, { limit: item.limit ?? 10 });
45
- results.push({ type: "search", results: searchResults });
46
35
  }
47
36
  }
48
37
 
@@ -5,7 +5,6 @@
5
5
  import { existsSync } from "node:fs";
6
6
  import { COMPACTOR_CONFIG_PATH } from "../config/manager.js";
7
7
  import type { SessionDB } from "../session/db.js";
8
- import type { ContentStore } from "../store/index.js";
9
8
 
10
9
  export interface DoctorResult {
11
10
  healthy: boolean;
@@ -18,7 +17,6 @@ export interface DoctorResult {
18
17
 
19
18
  export async function ctxDoctor(
20
19
  sessionDB: SessionDB,
21
- contentStore: ContentStore,
22
20
  ): Promise<DoctorResult> {
23
21
  const checks: DoctorResult["checks"] = [];
24
22
 
@@ -45,22 +43,6 @@ export async function ctxDoctor(
45
43
  });
46
44
  }
47
45
 
48
- // Content store check
49
- try {
50
- const stats = await contentStore.getStats();
51
- checks.push({
52
- name: "Content Store",
53
- status: "pass",
54
- message: `FTS5 index: ${stats.sources} sources, ${stats.chunks} chunks`,
55
- });
56
- } catch (err) {
57
- checks.push({
58
- name: "Content Store",
59
- status: "fail",
60
- message: `FTS5 error: ${err}`,
61
- });
62
- }
63
-
64
46
  // Runtime checks
65
47
  const runtimes = ["node", "python3", "bash"];
66
48
  for (const rt of runtimes) {