@tarquinen/opencode-dcp 0.3.9 → 0.3.11

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.
@@ -1,8 +1,12 @@
1
1
  import type { Logger } from "./logger";
2
- import type { StateManager } from "./state";
2
+ export interface SessionStats {
3
+ totalToolsPruned: number;
4
+ totalTokensSaved: number;
5
+ }
3
6
  export declare class Janitor {
4
7
  private client;
5
- private stateManager;
8
+ private prunedIdsState;
9
+ private statsState;
6
10
  private logger;
7
11
  private toolParametersCache;
8
12
  private protectedTools;
@@ -12,7 +16,7 @@ export declare class Janitor {
12
16
  private pruningMode;
13
17
  private pruningSummary;
14
18
  private workingDirectory?;
15
- constructor(client: any, stateManager: StateManager, logger: Logger, toolParametersCache: Map<string, any>, protectedTools: string[], modelCache: Map<string, {
19
+ constructor(client: any, prunedIdsState: Map<string, string[]>, statsState: Map<string, SessionStats>, logger: Logger, toolParametersCache: Map<string, any>, protectedTools: string[], modelCache: Map<string, {
16
20
  providerID: string;
17
21
  modelID: string;
18
22
  }>, configModel?: string | undefined, // Format: "provider/model"
@@ -29,6 +33,10 @@ export declare class Janitor {
29
33
  * Helper function to shorten paths for display
30
34
  */
31
35
  private shortenPath;
36
+ /**
37
+ * Shorten a single path string
38
+ */
39
+ private shortenSinglePath;
32
40
  /**
33
41
  * Replace pruned tool outputs with placeholder text to save tokens in janitor context
34
42
  * This applies the same replacement logic as the global fetch wrapper, but for the
@@ -1 +1 @@
1
- {"version":3,"file":"janitor.d.ts","sourceRoot":"","sources":["../../lib/janitor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AACtC,OAAO,KAAK,EAAE,YAAY,EAAE,MAAM,SAAS,CAAA;AAM3C,qBAAa,OAAO;IAEZ,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,YAAY;IACpB,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,mBAAmB;IAC3B,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,UAAU;IAClB,OAAO,CAAC,WAAW,CAAC;IACpB,OAAO,CAAC,oBAAoB;IAC5B,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,gBAAgB,CAAC;gBAVjB,MAAM,EAAE,GAAG,EACX,YAAY,EAAE,YAAY,EAC1B,MAAM,EAAE,MAAM,EACd,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,EACrC,cAAc,EAAE,MAAM,EAAE,EACxB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,EAChE,WAAW,CAAC,EAAE,MAAM,YAAA,EAAE,2BAA2B;IACjD,oBAAoB,GAAE,OAAc,EAAE,yCAAyC;IAC/E,WAAW,GAAE,MAAM,GAAG,OAAiB,EAAE,mBAAmB;IAC5D,cAAc,GAAE,KAAK,GAAG,SAAS,GAAG,UAAuB,EAAE,0BAA0B;IACvF,gBAAgB,CAAC,EAAE,MAAM,YAAA;IAGrC;;OAEG;YACW,kBAAkB;IA4B1B,GAAG,CAAC,SAAS,EAAE,MAAM;IAua3B;;OAEG;IACH,OAAO,CAAC,WAAW;IAiCnB;;;;OAIG;IACH,OAAO,CAAC,wBAAwB;IA8BhC;;OAEG;YACW,oBAAoB;IAmBlC;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAoCzB;;OAEG;YACW,uBAAuB;IAiBrC;;OAEG;YACW,wBAAwB;IAyDtC;;OAEG;YACW,yBAAyB;CAuF1C"}
1
+ {"version":3,"file":"janitor.d.ts","sourceRoot":"","sources":["../../lib/janitor.ts"],"names":[],"mappings":"AACA,OAAO,KAAK,EAAE,MAAM,EAAE,MAAM,UAAU,CAAA;AAMtC,MAAM,WAAW,YAAY;IACzB,gBAAgB,EAAE,MAAM,CAAA;IACxB,gBAAgB,EAAE,MAAM,CAAA;CAC3B;AAED,qBAAa,OAAO;IAEZ,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,UAAU;IAClB,OAAO,CAAC,MAAM;IACd,OAAO,CAAC,mBAAmB;IAC3B,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,UAAU;IAClB,OAAO,CAAC,WAAW,CAAC;IACpB,OAAO,CAAC,oBAAoB;IAC5B,OAAO,CAAC,WAAW;IACnB,OAAO,CAAC,cAAc;IACtB,OAAO,CAAC,gBAAgB,CAAC;gBAXjB,MAAM,EAAE,GAAG,EACX,cAAc,EAAE,GAAG,CAAC,MAAM,EAAE,MAAM,EAAE,CAAC,EACrC,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EACrC,MAAM,EAAE,MAAM,EACd,mBAAmB,EAAE,GAAG,CAAC,MAAM,EAAE,GAAG,CAAC,EACrC,cAAc,EAAE,MAAM,EAAE,EACxB,UAAU,EAAE,GAAG,CAAC,MAAM,EAAE;QAAE,UAAU,EAAE,MAAM,CAAC;QAAC,OAAO,EAAE,MAAM,CAAA;KAAE,CAAC,EAChE,WAAW,CAAC,EAAE,MAAM,YAAA,EAAE,2BAA2B;IACjD,oBAAoB,GAAE,OAAc,EAAE,yCAAyC;IAC/E,WAAW,GAAE,MAAM,GAAG,OAAiB,EAAE,mBAAmB;IAC5D,cAAc,GAAE,KAAK,GAAG,SAAS,GAAG,UAAuB,EAAE,0BAA0B;IACvF,gBAAgB,CAAC,EAAE,MAAM,YAAA;IAGrC;;OAEG;YACW,kBAAkB;IAsB1B,GAAG,CAAC,SAAS,EAAE,MAAM;IAkR3B;;OAEG;IACH,OAAO,CAAC,WAAW;IAcnB;;OAEG;IACH,OAAO,CAAC,iBAAiB;IA0CzB;;;;OAIG;IACH,OAAO,CAAC,wBAAwB;IA8BhC;;OAEG;YACW,oBAAoB;IAmBlC;;;;;OAKG;IACH,OAAO,CAAC,iBAAiB;IAoCzB;;OAEG;YACW,uBAAuB;IAqBrC;;OAEG;YACW,wBAAwB;IA8DtC;;OAEG;YACW,yBAAyB;CA0F1C"}
@@ -5,7 +5,8 @@ import { estimateTokensBatch, formatTokenCount } from "./tokenizer";
5
5
  import { detectDuplicates, extractParameterKey } from "./deduplicator";
6
6
  export class Janitor {
7
7
  client;
8
- stateManager;
8
+ prunedIdsState;
9
+ statsState;
9
10
  logger;
10
11
  toolParametersCache;
11
12
  protectedTools;
@@ -15,14 +16,15 @@ export class Janitor {
15
16
  pruningMode;
16
17
  pruningSummary;
17
18
  workingDirectory;
18
- constructor(client, stateManager, logger, toolParametersCache, protectedTools, modelCache, configModel, // Format: "provider/model"
19
+ constructor(client, prunedIdsState, statsState, logger, toolParametersCache, protectedTools, modelCache, configModel, // Format: "provider/model"
19
20
  showModelErrorToasts = true, // Whether to show toast for model errors
20
21
  pruningMode = "smart", // Pruning strategy
21
22
  pruningSummary = "detailed", // UI summary display mode
22
23
  workingDirectory // Current working directory for relative path display
23
24
  ) {
24
25
  this.client = client;
25
- this.stateManager = stateManager;
26
+ this.prunedIdsState = prunedIdsState;
27
+ this.statsState = statsState;
26
28
  this.logger = logger;
27
29
  this.toolParametersCache = toolParametersCache;
28
30
  this.protectedTools = protectedTools;
@@ -51,24 +53,16 @@ export class Janitor {
51
53
  }]
52
54
  }
53
55
  });
54
- this.logger.debug("janitor", "Sent ignored message to session", {
55
- sessionID,
56
- textLength: text.length
57
- });
58
56
  }
59
57
  catch (error) {
60
- this.logger.error("janitor", "Failed to send ignored message", {
61
- sessionID,
58
+ this.logger.error("janitor", "Failed to send notification", {
62
59
  error: error.message
63
60
  });
64
- // Don't fail the operation if sending the message fails
65
61
  }
66
62
  }
67
63
  async run(sessionID) {
68
- this.logger.info("janitor", "Starting analysis", { sessionID });
69
64
  try {
70
65
  // Fetch session info and messages from OpenCode API
71
- this.logger.debug("janitor", "Fetching session info and messages", { sessionID });
72
66
  const [sessionInfoResponse, messagesResponse] = await Promise.all([
73
67
  this.client.session.get({ path: { id: sessionID } }),
74
68
  this.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } })
@@ -76,16 +70,8 @@ export class Janitor {
76
70
  const sessionInfo = sessionInfoResponse.data;
77
71
  // Handle the response format - it should be { data: Array<{info, parts}> } or just the array
78
72
  const messages = messagesResponse.data || messagesResponse;
79
- this.logger.debug("janitor", "Retrieved messages", {
80
- sessionID,
81
- messageCount: messages.length
82
- });
83
73
  // If there are no messages or very few, skip analysis
84
74
  if (!messages || messages.length < 3) {
85
- this.logger.debug("janitor", "Too few messages to analyze, skipping", {
86
- sessionID,
87
- messageCount: messages?.length || 0
88
- });
89
75
  return;
90
76
  }
91
77
  // Extract tool call IDs from the session and track their output sizes
@@ -111,83 +97,32 @@ export class Janitor {
111
97
  tool: part.tool,
112
98
  parameters: parameters
113
99
  });
114
- // Debug: log what we're storing
115
- if (normalizedId.startsWith('prt_') || part.tool === "read" || part.tool === "list") {
116
- this.logger.debug("janitor", "Storing tool metadata", {
117
- sessionID,
118
- callID: normalizedId,
119
- tool: part.tool,
120
- hasParameters: !!parameters,
121
- hasCached: !!cachedData,
122
- parameters: parameters
123
- });
124
- }
125
100
  // Track the output content for size calculation
126
101
  if (part.state?.status === "completed" && part.state.output) {
127
102
  toolOutputs.set(normalizedId, part.state.output);
128
103
  }
129
104
  // Check if this is a batch tool by looking at the tool name
130
105
  if (part.tool === "batch") {
131
- const batchId = normalizedId;
132
- currentBatchId = batchId;
133
- batchToolChildren.set(batchId, []);
134
- this.logger.debug("janitor", "Found batch tool", {
135
- sessionID,
136
- batchID: currentBatchId
137
- });
106
+ currentBatchId = normalizedId;
107
+ batchToolChildren.set(normalizedId, []);
138
108
  }
139
109
  // If we're inside a batch and this is a prt_ (parallel) tool call, it's a child
140
110
  else if (currentBatchId && normalizedId.startsWith('prt_')) {
141
- const children = batchToolChildren.get(currentBatchId);
142
- children.push(normalizedId);
143
- this.logger.debug("janitor", "Added child to batch tool", {
144
- sessionID,
145
- batchID: currentBatchId,
146
- childID: normalizedId,
147
- totalChildren: children.length
148
- });
111
+ batchToolChildren.get(currentBatchId).push(normalizedId);
149
112
  }
150
113
  // If we hit a non-batch, non-prt_ tool, we're out of the batch
151
114
  else if (currentBatchId && !normalizedId.startsWith('prt_')) {
152
- this.logger.debug("janitor", "Batch tool ended", {
153
- sessionID,
154
- batchID: currentBatchId,
155
- totalChildren: batchToolChildren.get(currentBatchId).length
156
- });
157
115
  currentBatchId = null;
158
116
  }
159
117
  }
160
118
  }
161
119
  }
162
120
  }
163
- // Log summary of batch tools found
164
- if (batchToolChildren.size > 0) {
165
- this.logger.debug("janitor", "Batch tool summary", {
166
- sessionID,
167
- batchCount: batchToolChildren.size,
168
- batches: Array.from(batchToolChildren.entries()).map(([id, children]) => ({
169
- batchID: id,
170
- childCount: children.length,
171
- childIDs: children
172
- }))
173
- });
174
- }
175
121
  // Get already pruned IDs to filter them out
176
- const alreadyPrunedIds = await this.stateManager.get(sessionID);
122
+ const alreadyPrunedIds = this.prunedIdsState.get(sessionID) ?? [];
177
123
  const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id));
178
- this.logger.debug("janitor", "Found tool calls in session", {
179
- sessionID,
180
- toolCallCount: toolCallIds.length,
181
- toolCallIds,
182
- alreadyPrunedCount: alreadyPrunedIds.length,
183
- alreadyPrunedIds: alreadyPrunedIds.slice(0, 5), // Show first 5 for brevity
184
- unprunedCount: unprunedToolCallIds.length
185
- });
186
124
  // If there are no unpruned tool calls, skip analysis
187
125
  if (unprunedToolCallIds.length === 0) {
188
- this.logger.debug("janitor", "No unpruned tool calls found, skipping analysis", {
189
- sessionID
190
- });
191
126
  return;
192
127
  }
193
128
  // ============================================================
@@ -196,11 +131,11 @@ export class Janitor {
196
131
  const dedupeResult = detectDuplicates(toolMetadata, unprunedToolCallIds, this.protectedTools);
197
132
  const deduplicatedIds = dedupeResult.duplicateIds;
198
133
  const deduplicationDetails = dedupeResult.deduplicationDetails;
199
- this.logger.info("janitor", "Duplicate detection complete", {
200
- sessionID,
201
- duplicatesFound: deduplicatedIds.length,
202
- uniqueToolPatterns: deduplicationDetails.size
203
- });
134
+ // Calculate candidates available for pruning (excludes protected tools)
135
+ const candidateCount = unprunedToolCallIds.filter(id => {
136
+ const metadata = toolMetadata.get(id);
137
+ return !metadata || !this.protectedTools.includes(metadata.tool);
138
+ }).length;
204
139
  // ============================================================
205
140
  // PHASE 2: LLM ANALYSIS (only runs in "smart" mode)
206
141
  // ============================================================
@@ -220,39 +155,15 @@ export class Janitor {
220
155
  }
221
156
  return true;
222
157
  });
223
- if (protectedToolCallIds.length > 0) {
224
- this.logger.debug("janitor", "Protected tools excluded from pruning", {
225
- sessionID,
226
- protectedCount: protectedToolCallIds.length,
227
- protectedTools: protectedToolCallIds.map(id => {
228
- const metadata = toolMetadata.get(id);
229
- return { id, tool: metadata?.tool };
230
- })
231
- });
232
- }
233
158
  // Run LLM analysis only if there are prunable tools
234
159
  if (prunableToolCallIds.length > 0) {
235
- this.logger.info("janitor", "Starting LLM analysis", {
236
- sessionID,
237
- candidateCount: prunableToolCallIds.length
238
- });
239
160
  // Select appropriate model with intelligent fallback
240
161
  const cachedModelInfo = this.modelCache.get(sessionID);
241
162
  const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger);
242
163
  const currentModelInfo = cachedModelInfo || sessionModelInfo;
243
- if (cachedModelInfo) {
244
- this.logger.debug("janitor", "Using cached model info", {
245
- sessionID,
246
- providerID: cachedModelInfo.providerID,
247
- modelID: cachedModelInfo.modelID
248
- });
249
- }
250
164
  const modelSelection = await selectModel(currentModelInfo, this.logger, this.configModel, this.workingDirectory);
251
- this.logger.info("janitor", "Model selected for analysis", {
252
- sessionID,
253
- modelInfo: modelSelection.modelInfo,
254
- source: modelSelection.source,
255
- reason: modelSelection.reason
165
+ this.logger.info("janitor", `Model: ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, {
166
+ source: modelSelection.source
256
167
  });
257
168
  // Show toast if we had to fallback from a failed model
258
169
  if (modelSelection.failedModel && this.showModelErrorToasts) {
@@ -265,53 +176,27 @@ export class Janitor {
265
176
  duration: 5000
266
177
  }
267
178
  });
268
- this.logger.info("janitor", "Toast notification shown for model fallback", {
269
- failedModel: modelSelection.failedModel,
270
- selectedModel: modelSelection.modelInfo
271
- });
272
179
  }
273
180
  catch (toastError) {
274
- this.logger.error("janitor", "Failed to show toast notification", {
275
- error: toastError.message
276
- });
277
181
  // Don't fail the whole operation if toast fails
278
182
  }
279
183
  }
280
- else if (modelSelection.failedModel && !this.showModelErrorToasts) {
281
- this.logger.info("janitor", "Model fallback occurred but toast disabled by config", {
282
- failedModel: modelSelection.failedModel,
283
- selectedModel: modelSelection.modelInfo
284
- });
285
- }
286
- // Log comprehensive stats before AI call
287
- this.logger.info("janitor", "Preparing AI analysis", {
288
- sessionID,
289
- totalToolCallsInSession: toolCallIds.length,
290
- alreadyPrunedCount: alreadyPrunedIds.length,
291
- deduplicatedCount: deduplicatedIds.length,
292
- protectedToolsCount: protectedToolCallIds.length,
293
- candidatesForPruning: prunableToolCallIds.length,
294
- candidateTools: prunableToolCallIds.map(id => {
295
- const meta = toolMetadata.get(id);
296
- return meta ? `${meta.tool}[${id.substring(0, 12)}...]` : id.substring(0, 12) + '...';
297
- }).slice(0, 10), // Show first 10 for brevity
298
- batchToolCount: batchToolChildren.size,
299
- batchDetails: Array.from(batchToolChildren.entries()).map(([batchId, children]) => ({
300
- batchId: batchId.substring(0, 20) + '...',
301
- childCount: children.length
302
- }))
303
- });
304
- this.logger.debug("janitor", "Starting shadow inference", { sessionID });
184
+ // Lazy import - only load the 2.8MB ai package when actually needed
185
+ const { generateObject } = await import('ai');
305
186
  // Replace already-pruned tool outputs to save tokens in janitor context
306
187
  const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds];
307
188
  const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar);
308
- this.logger.debug("janitor", "Sanitized messages for analysis", {
189
+ // Build the prompt for analysis
190
+ const analysisPrompt = buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, this.protectedTools, allPrunedSoFar, protectedToolCallIds);
191
+ // Save janitor shadow context directly (auth providers may bypass globalThis.fetch)
192
+ await this.logger.saveWrappedContext("janitor-shadow", [{ role: "user", content: analysisPrompt }], {
309
193
  sessionID,
310
- totalPrunedBeforeAnalysis: allPrunedSoFar.length,
311
- prunedIds: allPrunedSoFar.slice(0, 5) // Show first 5
194
+ modelProvider: modelSelection.modelInfo.providerID,
195
+ modelID: modelSelection.modelInfo.modelID,
196
+ candidateToolCount: prunableToolCallIds.length,
197
+ alreadyPrunedCount: allPrunedSoFar.length,
198
+ protectedToolCount: protectedToolCallIds.length
312
199
  });
313
- // Lazy import - only load the 2.8MB ai package when actually needed
314
- const { generateObject } = await import('ai');
315
200
  // Analyze which tool calls are obsolete
316
201
  const result = await generateObject({
317
202
  model: modelSelection.model,
@@ -319,47 +204,23 @@ export class Janitor {
319
204
  pruned_tool_call_ids: z.array(z.string()),
320
205
  reasoning: z.string(),
321
206
  }),
322
- prompt: buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, this.protectedTools, allPrunedSoFar, protectedToolCallIds)
207
+ prompt: analysisPrompt
323
208
  });
324
209
  // Filter LLM results to only include IDs that were actually candidates
325
210
  // (LLM sometimes returns duplicate IDs that were already filtered out)
326
211
  const rawLlmPrunedIds = result.object.pruned_tool_call_ids;
327
212
  llmPrunedIds = rawLlmPrunedIds.filter(id => prunableToolCallIds.includes(id.toLowerCase()));
328
- if (rawLlmPrunedIds.length !== llmPrunedIds.length) {
329
- this.logger.warn("janitor", "LLM returned non-candidate IDs (filtered out)", {
330
- sessionID,
331
- rawCount: rawLlmPrunedIds.length,
332
- filteredCount: llmPrunedIds.length,
333
- invalidIds: rawLlmPrunedIds.filter(id => !prunableToolCallIds.includes(id.toLowerCase()))
334
- });
213
+ if (llmPrunedIds.length > 0) {
214
+ const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
215
+ this.logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`);
335
216
  }
336
- this.logger.info("janitor", "LLM analysis complete", {
337
- sessionID,
338
- llmPrunedCount: llmPrunedIds.length,
339
- reasoning: result.object.reasoning
340
- });
341
- }
342
- else {
343
- this.logger.info("janitor", "No prunable tools for LLM analysis", {
344
- sessionID,
345
- deduplicatedCount: deduplicatedIds.length,
346
- protectedCount: protectedToolCallIds.length
347
- });
348
217
  }
349
218
  }
350
- else {
351
- this.logger.info("janitor", "Skipping LLM analysis (auto mode)", {
352
- sessionID,
353
- deduplicatedCount: deduplicatedIds.length
354
- });
355
- }
356
- // If mode is "auto", llmPrunedIds stays empty
357
219
  // ============================================================
358
220
  // PHASE 3: COMBINE & EXPAND
359
221
  // ============================================================
360
222
  const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
361
223
  if (newlyPrunedIds.length === 0) {
362
- this.logger.info("janitor", "No tools to prune", { sessionID });
363
224
  return;
364
225
  }
365
226
  // Expand batch tool IDs to include their children
@@ -370,12 +231,6 @@ export class Janitor {
370
231
  // If this is a batch tool, add all its children
371
232
  const children = batchToolChildren.get(normalizedId);
372
233
  if (children) {
373
- this.logger.debug("janitor", "Expanding batch tool to include children", {
374
- sessionID,
375
- batchID: normalizedId,
376
- childCount: children.length,
377
- childIDs: children
378
- });
379
234
  children.forEach(childId => expandedPrunedIds.add(childId));
380
235
  }
381
236
  }
@@ -383,48 +238,41 @@ export class Janitor {
383
238
  const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
384
239
  // finalPrunedIds includes everything (new + already pruned) for logging
385
240
  const finalPrunedIds = Array.from(expandedPrunedIds);
386
- this.logger.info("janitor", "Analysis complete", {
387
- sessionID,
388
- prunedCount: finalPrunedIds.length,
389
- deduplicatedCount: deduplicatedIds.length,
390
- llmPrunedCount: llmPrunedIds.length,
391
- prunedIds: finalPrunedIds
392
- });
393
- this.logger.debug("janitor", "Pruning ID details", {
394
- sessionID,
395
- alreadyPrunedCount: alreadyPrunedIds.length,
396
- alreadyPrunedIds: alreadyPrunedIds,
397
- finalPrunedCount: finalPrunedIds.length,
398
- finalPrunedIds: finalPrunedIds,
399
- newlyPrunedCount: finalNewlyPrunedIds.length,
400
- newlyPrunedIds: finalNewlyPrunedIds
401
- });
402
241
  // ============================================================
403
242
  // PHASE 4: NOTIFICATION
404
243
  // ============================================================
244
+ // Calculate token savings once (used by both notification and log)
245
+ const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs);
246
+ // Accumulate session stats (for showing cumulative totals in UI)
247
+ const currentStats = this.statsState.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 };
248
+ const sessionStats = {
249
+ totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length,
250
+ totalTokensSaved: currentStats.totalTokensSaved + tokensSaved
251
+ };
252
+ this.statsState.set(sessionID, sessionStats);
405
253
  if (this.pruningMode === "auto") {
406
- await this.sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, toolOutputs);
254
+ await this.sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, tokensSaved, sessionStats);
407
255
  }
408
256
  else {
409
- await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, toolOutputs);
257
+ await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, tokensSaved, sessionStats);
410
258
  }
411
259
  // ============================================================
412
260
  // PHASE 5: STATE UPDATE
413
261
  // ============================================================
414
262
  // Merge newly pruned IDs with existing ones (using expanded IDs)
415
263
  const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])];
416
- await this.stateManager.set(sessionID, allPrunedIds);
417
- this.logger.debug("janitor", "Updated state manager", {
418
- sessionID,
419
- totalPrunedCount: allPrunedIds.length,
420
- newlyPrunedCount: finalNewlyPrunedIds.length
421
- });
264
+ this.prunedIdsState.set(sessionID, allPrunedIds);
265
+ // Log final summary
266
+ // Format: "Pruned 5/5 tools (~4.2K tokens), 0 kept" or with breakdown if both duplicate and llm
267
+ const prunedCount = finalNewlyPrunedIds.length;
268
+ const keptCount = candidateCount - prunedCount;
269
+ const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0;
270
+ const breakdown = hasBoth ? ` (${deduplicatedIds.length} duplicate, ${llmPrunedIds.length} llm)` : "";
271
+ this.logger.info("janitor", `Pruned ${prunedCount}/${candidateCount} tools${breakdown}, ${keptCount} kept (~${formatTokenCount(tokensSaved)} tokens)`);
422
272
  }
423
273
  catch (error) {
424
274
  this.logger.error("janitor", "Analysis failed", {
425
- sessionID,
426
- error: error.message,
427
- stack: error.stack
275
+ error: error.message
428
276
  });
429
277
  // Don't throw - this is a fire-and-forget background process
430
278
  // Silently fail and try again on next idle event
@@ -433,9 +281,34 @@ export class Janitor {
433
281
  /**
434
282
  * Helper function to shorten paths for display
435
283
  */
436
- shortenPath(path) {
437
- // Replace home directory with ~
284
+ shortenPath(input) {
285
+ // Handle compound strings like: "pattern" in /absolute/path
286
+ // Extract and shorten just the path portion
287
+ const inPathMatch = input.match(/^(.+) in (.+)$/);
288
+ if (inPathMatch) {
289
+ const prefix = inPathMatch[1];
290
+ const pathPart = inPathMatch[2];
291
+ const shortenedPath = this.shortenSinglePath(pathPart);
292
+ return `${prefix} in ${shortenedPath}`;
293
+ }
294
+ return this.shortenSinglePath(input);
295
+ }
296
+ /**
297
+ * Shorten a single path string
298
+ */
299
+ shortenSinglePath(path) {
438
300
  const homeDir = require('os').homedir();
301
+ // Strip working directory FIRST (before ~ replacement) for cleaner relative paths
302
+ if (this.workingDirectory) {
303
+ if (path.startsWith(this.workingDirectory + '/')) {
304
+ return path.slice(this.workingDirectory.length + 1);
305
+ }
306
+ // Exact match (the directory itself)
307
+ if (path === this.workingDirectory) {
308
+ return '.';
309
+ }
310
+ }
311
+ // Replace home directory with ~
439
312
  if (path.startsWith(homeDir)) {
440
313
  path = '~' + path.slice(homeDir.length);
441
314
  }
@@ -444,19 +317,17 @@ export class Janitor {
444
317
  if (nodeModulesMatch) {
445
318
  return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
446
319
  }
447
- // Strip working directory to show relative paths
320
+ // Try matching against ~ version of working directory (for paths already with ~)
448
321
  if (this.workingDirectory) {
449
- // Try to match against the absolute working directory first
450
- if (path.startsWith(this.workingDirectory + '/')) {
451
- return path.slice(this.workingDirectory.length + 1);
452
- }
453
- // Also try matching against ~ version of working directory
454
322
  const workingDirWithTilde = this.workingDirectory.startsWith(homeDir)
455
323
  ? '~' + this.workingDirectory.slice(homeDir.length)
456
324
  : null;
457
325
  if (workingDirWithTilde && path.startsWith(workingDirWithTilde + '/')) {
458
326
  return path.slice(workingDirWithTilde.length + 1);
459
327
  }
328
+ if (workingDirWithTilde && path === workingDirWithTilde) {
329
+ return '.';
330
+ }
460
331
  }
461
332
  return path;
462
333
  }
@@ -506,7 +377,7 @@ export class Janitor {
506
377
  }
507
378
  if (outputsToTokenize.length > 0) {
508
379
  // Use batch tokenization for efficiency (lazy loads gpt-tokenizer)
509
- const tokenCounts = await estimateTokensBatch(outputsToTokenize, this.logger);
380
+ const tokenCounts = await estimateTokensBatch(outputsToTokenize);
510
381
  return tokenCounts.reduce((sum, count) => sum + count, 0);
511
382
  }
512
383
  return 0;
@@ -553,19 +424,22 @@ export class Janitor {
553
424
  /**
554
425
  * Send minimal summary notification (just tokens saved and count)
555
426
  */
556
- async sendMinimalNotification(sessionID, totalPruned, toolOutputs, prunedIds) {
427
+ async sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats) {
557
428
  if (totalPruned === 0)
558
429
  return;
559
- const tokensSaved = await this.calculateTokensSaved(prunedIds, toolOutputs);
560
430
  const tokensFormatted = formatTokenCount(tokensSaved);
561
431
  const toolText = totalPruned === 1 ? 'tool' : 'tools';
562
- const message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)`;
432
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)`;
433
+ // Add session totals if there's been more than one pruning run
434
+ if (sessionStats.totalToolsPruned > totalPruned) {
435
+ message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
436
+ }
563
437
  await this.sendIgnoredMessage(sessionID, message);
564
438
  }
565
439
  /**
566
440
  * Auto mode notification - shows only deduplication results
567
441
  */
568
- async sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, toolOutputs) {
442
+ async sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, tokensSaved, sessionStats) {
569
443
  if (deduplicatedIds.length === 0)
570
444
  return;
571
445
  // Check if notifications are disabled
@@ -573,15 +447,18 @@ export class Janitor {
573
447
  return;
574
448
  // Send minimal notification if configured
575
449
  if (this.pruningSummary === 'minimal') {
576
- await this.sendMinimalNotification(sessionID, deduplicatedIds.length, toolOutputs, deduplicatedIds);
450
+ await this.sendMinimalNotification(sessionID, deduplicatedIds.length, tokensSaved, sessionStats);
577
451
  return;
578
452
  }
579
453
  // Otherwise send detailed notification
580
- // Calculate token savings
581
- const tokensSaved = await this.calculateTokensSaved(deduplicatedIds, toolOutputs);
582
454
  const tokensFormatted = formatTokenCount(tokensSaved);
583
455
  const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools';
584
- let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)\n`;
456
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)`;
457
+ // Add session totals if there's been more than one pruning run
458
+ if (sessionStats.totalToolsPruned > deduplicatedIds.length) {
459
+ message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
460
+ }
461
+ message += '\n';
585
462
  // Group by tool type
586
463
  const grouped = new Map();
587
464
  for (const [_, details] of deduplicationDetails) {
@@ -611,7 +488,7 @@ export class Janitor {
611
488
  /**
612
489
  * Smart mode notification - shows both deduplication and LLM analysis results
613
490
  */
614
- async sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, toolOutputs) {
491
+ async sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, tokensSaved, sessionStats) {
615
492
  const totalPruned = deduplicatedIds.length + llmPrunedIds.length;
616
493
  if (totalPruned === 0)
617
494
  return;
@@ -620,16 +497,17 @@ export class Janitor {
620
497
  return;
621
498
  // Send minimal notification if configured
622
499
  if (this.pruningSummary === 'minimal') {
623
- const allPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
624
- await this.sendMinimalNotification(sessionID, totalPruned, toolOutputs, allPrunedIds);
500
+ await this.sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats);
625
501
  return;
626
502
  }
627
503
  // Otherwise send detailed notification
628
- // Calculate token savings
629
- const allPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
630
- const tokensSaved = await this.calculateTokensSaved(allPrunedIds, toolOutputs);
631
504
  const tokensFormatted = formatTokenCount(tokensSaved);
632
- let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)\n`;
505
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)`;
506
+ // Add session totals if there's been more than one pruning run
507
+ if (sessionStats.totalToolsPruned > totalPruned) {
508
+ message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
509
+ }
510
+ message += '\n';
633
511
  // Section 1: Deduplicated tools
634
512
  if (deduplicatedIds.length > 0 && deduplicationDetails) {
635
513
  message += `\nšŸ“¦ Duplicates removed (${deduplicatedIds.length}):\n`;