@tarquinen/opencode-dcp 0.3.15 โ†’ 0.3.17

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.
@@ -13,13 +13,10 @@ export class Janitor {
13
13
  modelCache;
14
14
  configModel;
15
15
  showModelErrorToasts;
16
+ strictModelSelection;
16
17
  pruningSummary;
17
18
  workingDirectory;
18
- constructor(client, prunedIdsState, statsState, logger, toolParametersCache, protectedTools, modelCache, configModel, // Format: "provider/model"
19
- showModelErrorToasts = true, // Whether to show toast for model errors
20
- pruningSummary = "detailed", // UI summary display mode
21
- workingDirectory // Current working directory for relative path display
22
- ) {
19
+ constructor(client, prunedIdsState, statsState, logger, toolParametersCache, protectedTools, modelCache, configModel, showModelErrorToasts = true, strictModelSelection = false, pruningSummary = "detailed", workingDirectory) {
23
20
  this.client = client;
24
21
  this.prunedIdsState = prunedIdsState;
25
22
  this.statsState = statsState;
@@ -29,20 +26,16 @@ export class Janitor {
29
26
  this.modelCache = modelCache;
30
27
  this.configModel = configModel;
31
28
  this.showModelErrorToasts = showModelErrorToasts;
29
+ this.strictModelSelection = strictModelSelection;
32
30
  this.pruningSummary = pruningSummary;
33
31
  this.workingDirectory = workingDirectory;
34
32
  }
35
- /**
36
- * Sends an ignored message to the session UI (user sees it, AI doesn't)
37
- */
38
33
  async sendIgnoredMessage(sessionID, text) {
39
34
  try {
40
35
  await this.client.session.prompt({
41
- path: {
42
- id: sessionID
43
- },
36
+ path: { id: sessionID },
44
37
  body: {
45
- noReply: true, // Don't wait for AI response
38
+ noReply: true,
46
39
  parts: [{
47
40
  type: 'text',
48
41
  text: text,
@@ -52,82 +45,56 @@ export class Janitor {
52
45
  });
53
46
  }
54
47
  catch (error) {
55
- this.logger.error("janitor", "Failed to send notification", {
56
- error: error.message
57
- });
48
+ this.logger.error("janitor", "Failed to send notification", { error: error.message });
58
49
  }
59
50
  }
60
- /**
61
- * Convenience method for idle-triggered pruning (sends notification automatically)
62
- */
63
51
  async runOnIdle(sessionID, strategies) {
64
52
  await this.runWithStrategies(sessionID, strategies, { trigger: 'idle' });
65
- // Notification is handled inside runWithStrategies
66
53
  }
67
- /**
68
- * Convenience method for tool-triggered pruning (returns result for tool output)
69
- */
70
54
  async runForTool(sessionID, strategies, reason) {
71
55
  return await this.runWithStrategies(sessionID, strategies, { trigger: 'tool', reason });
72
56
  }
73
- /**
74
- * Core pruning method that accepts strategies and options
75
- */
76
57
  async runWithStrategies(sessionID, strategies, options) {
77
58
  try {
78
- // Skip if no strategies configured
79
59
  if (strategies.length === 0) {
80
60
  return null;
81
61
  }
82
- // Fetch session info and messages from OpenCode API
83
62
  const [sessionInfoResponse, messagesResponse] = await Promise.all([
84
63
  this.client.session.get({ path: { id: sessionID } }),
85
64
  this.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } })
86
65
  ]);
87
66
  const sessionInfo = sessionInfoResponse.data;
88
- // Handle the response format - it should be { data: Array<{info, parts}> } or just the array
89
67
  const messages = messagesResponse.data || messagesResponse;
90
- // If there are no messages or very few, skip analysis
91
68
  if (!messages || messages.length < 3) {
92
69
  return null;
93
70
  }
94
- // Extract tool call IDs from the session and track their output sizes
95
- // Also track batch tool relationships and tool metadata
96
71
  const toolCallIds = [];
97
72
  const toolOutputs = new Map();
98
- const toolMetadata = new Map(); // callID -> {tool, parameters}
99
- const batchToolChildren = new Map(); // batchID -> [childIDs]
73
+ const toolMetadata = new Map();
74
+ const batchToolChildren = new Map();
100
75
  let currentBatchId = null;
101
76
  for (const msg of messages) {
102
77
  if (msg.parts) {
103
78
  for (const part of msg.parts) {
104
79
  if (part.type === "tool" && part.callID) {
105
- // Normalize tool call IDs to lowercase for consistent comparison
106
80
  const normalizedId = part.callID.toLowerCase();
107
81
  toolCallIds.push(normalizedId);
108
- // Try to get parameters from cache first, fall back to part.parameters
109
- // Cache might have either case, so check both
110
82
  const cachedData = this.toolParametersCache.get(part.callID) || this.toolParametersCache.get(normalizedId);
111
83
  const parameters = cachedData?.parameters || part.parameters;
112
- // Track tool metadata (name and parameters)
113
84
  toolMetadata.set(normalizedId, {
114
85
  tool: part.tool,
115
86
  parameters: parameters
116
87
  });
117
- // Track the output content for size calculation
118
88
  if (part.state?.status === "completed" && part.state.output) {
119
89
  toolOutputs.set(normalizedId, part.state.output);
120
90
  }
121
- // Check if this is a batch tool by looking at the tool name
122
91
  if (part.tool === "batch") {
123
92
  currentBatchId = normalizedId;
124
93
  batchToolChildren.set(normalizedId, []);
125
94
  }
126
- // If we're inside a batch and this is a prt_ (parallel) tool call, it's a child
127
95
  else if (currentBatchId && normalizedId.startsWith('prt_')) {
128
96
  batchToolChildren.get(currentBatchId).push(normalizedId);
129
97
  }
130
- // If we hit a non-batch, non-prt_ tool, we're out of the batch
131
98
  else if (currentBatchId && !normalizedId.startsWith('prt_')) {
132
99
  currentBatchId = null;
133
100
  }
@@ -135,16 +102,12 @@ export class Janitor {
135
102
  }
136
103
  }
137
104
  }
138
- // Get already pruned IDs to filter them out
139
105
  const alreadyPrunedIds = this.prunedIdsState.get(sessionID) ?? [];
140
106
  const unprunedToolCallIds = toolCallIds.filter(id => !alreadyPrunedIds.includes(id));
141
- // If there are no unpruned tool calls, skip analysis
142
107
  if (unprunedToolCallIds.length === 0) {
143
108
  return null;
144
109
  }
145
- // ============================================================
146
- // PHASE 1: DUPLICATE DETECTION (if enabled)
147
- // ============================================================
110
+ // PHASE 1: DUPLICATE DETECTION
148
111
  let deduplicatedIds = [];
149
112
  let deduplicationDetails = new Map();
150
113
  if (strategies.includes('deduplication')) {
@@ -152,23 +115,17 @@ export class Janitor {
152
115
  deduplicatedIds = dedupeResult.duplicateIds;
153
116
  deduplicationDetails = dedupeResult.deduplicationDetails;
154
117
  }
155
- // Calculate candidates available for pruning (excludes protected tools)
156
118
  const candidateCount = unprunedToolCallIds.filter(id => {
157
119
  const metadata = toolMetadata.get(id);
158
120
  return !metadata || !this.protectedTools.includes(metadata.tool);
159
121
  }).length;
160
- // ============================================================
161
- // PHASE 2: LLM ANALYSIS (if enabled)
162
- // ============================================================
122
+ // PHASE 2: LLM ANALYSIS
163
123
  let llmPrunedIds = [];
164
124
  if (strategies.includes('ai-analysis')) {
165
- // Filter out duplicates and protected tools
166
125
  const protectedToolCallIds = [];
167
126
  const prunableToolCallIds = unprunedToolCallIds.filter(id => {
168
- // Skip already deduplicated
169
127
  if (deduplicatedIds.includes(id))
170
128
  return false;
171
- // Skip protected tools
172
129
  const metadata = toolMetadata.get(id);
173
130
  if (metadata && this.protectedTools.includes(metadata.tool)) {
174
131
  protectedToolCallIds.push(id);
@@ -176,9 +133,7 @@ export class Janitor {
176
133
  }
177
134
  return true;
178
135
  });
179
- // Run LLM analysis only if there are prunable tools
180
136
  if (prunableToolCallIds.length > 0) {
181
- // Select appropriate model with intelligent fallback
182
137
  const cachedModelInfo = this.modelCache.get(sessionID);
183
138
  const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger);
184
139
  const currentModelInfo = cachedModelInfo || sessionModelInfo;
@@ -186,114 +141,101 @@ export class Janitor {
186
141
  this.logger.info("janitor", `Model: ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`, {
187
142
  source: modelSelection.source
188
143
  });
189
- // Show toast if we had to fallback from a failed model
190
144
  if (modelSelection.failedModel && this.showModelErrorToasts) {
145
+ const skipAi = modelSelection.source === 'fallback' && this.strictModelSelection;
191
146
  try {
192
147
  await this.client.tui.showToast({
193
148
  body: {
194
- title: "DCP: Model fallback",
195
- message: `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`,
149
+ title: skipAi ? "DCP: AI analysis skipped" : "DCP: Model fallback",
150
+ message: skipAi
151
+ ? `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nAI analysis skipped (strictModelSelection enabled)`
152
+ : `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`,
196
153
  variant: "info",
197
154
  duration: 5000
198
155
  }
199
156
  });
200
157
  }
201
158
  catch (toastError) {
202
- // Don't fail the whole operation if toast fails
203
159
  }
204
160
  }
205
- // Lazy import - only load the 2.8MB ai package when actually needed
206
- const { generateObject } = await import('ai');
207
- // Replace already-pruned tool outputs to save tokens in janitor context
208
- const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds];
209
- const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar);
210
- // Build the prompt for analysis (pass reason if provided)
211
- const analysisPrompt = buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, allPrunedSoFar, protectedToolCallIds, options.reason);
212
- // Save janitor shadow context directly (auth providers may bypass globalThis.fetch)
213
- await this.logger.saveWrappedContext("janitor-shadow", [{ role: "user", content: analysisPrompt }], {
214
- sessionID,
215
- modelProvider: modelSelection.modelInfo.providerID,
216
- modelID: modelSelection.modelInfo.modelID,
217
- candidateToolCount: prunableToolCallIds.length,
218
- alreadyPrunedCount: allPrunedSoFar.length,
219
- protectedToolCount: protectedToolCallIds.length,
220
- trigger: options.trigger,
221
- reason: options.reason
222
- });
223
- // Analyze which tool calls are obsolete
224
- const result = await generateObject({
225
- model: modelSelection.model,
226
- schema: z.object({
227
- pruned_tool_call_ids: z.array(z.string()),
228
- reasoning: z.string(),
229
- }),
230
- prompt: analysisPrompt
231
- });
232
- // Filter LLM results to only include IDs that were actually candidates
233
- // (LLM sometimes returns duplicate IDs that were already filtered out)
234
- const rawLlmPrunedIds = result.object.pruned_tool_call_ids;
235
- llmPrunedIds = rawLlmPrunedIds.filter(id => prunableToolCallIds.includes(id.toLowerCase()));
236
- if (llmPrunedIds.length > 0) {
237
- const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
238
- this.logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`);
161
+ if (modelSelection.source === 'fallback' && this.strictModelSelection) {
162
+ this.logger.info("janitor", "Skipping AI analysis (fallback model, strictModelSelection enabled)");
163
+ }
164
+ else {
165
+ const { generateObject } = await import('ai');
166
+ const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds];
167
+ const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar);
168
+ const analysisPrompt = buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, allPrunedSoFar, protectedToolCallIds, options.reason);
169
+ await this.logger.saveWrappedContext("janitor-shadow", [{ role: "user", content: analysisPrompt }], {
170
+ sessionID,
171
+ modelProvider: modelSelection.modelInfo.providerID,
172
+ modelID: modelSelection.modelInfo.modelID,
173
+ candidateToolCount: prunableToolCallIds.length,
174
+ alreadyPrunedCount: allPrunedSoFar.length,
175
+ protectedToolCount: protectedToolCallIds.length,
176
+ trigger: options.trigger,
177
+ reason: options.reason
178
+ });
179
+ const result = await generateObject({
180
+ model: modelSelection.model,
181
+ schema: z.object({
182
+ pruned_tool_call_ids: z.array(z.string()),
183
+ reasoning: z.string(),
184
+ }),
185
+ prompt: analysisPrompt
186
+ });
187
+ const rawLlmPrunedIds = result.object.pruned_tool_call_ids;
188
+ llmPrunedIds = rawLlmPrunedIds.filter(id => prunableToolCallIds.includes(id.toLowerCase()));
189
+ if (llmPrunedIds.length > 0) {
190
+ const reasoning = result.object.reasoning.replace(/\n+/g, ' ').replace(/\s+/g, ' ').trim();
191
+ this.logger.info("janitor", `LLM reasoning: ${reasoning.substring(0, 200)}${reasoning.length > 200 ? '...' : ''}`);
192
+ }
239
193
  }
240
194
  }
241
195
  }
242
- // ============================================================
243
196
  // PHASE 3: COMBINE & EXPAND
244
- // ============================================================
245
197
  const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
246
198
  if (newlyPrunedIds.length === 0) {
247
199
  return null;
248
200
  }
249
- // Expand batch tool IDs to include their children
250
- const expandedPrunedIds = new Set();
251
- for (const prunedId of newlyPrunedIds) {
252
- const normalizedId = prunedId.toLowerCase();
253
- expandedPrunedIds.add(normalizedId);
254
- // If this is a batch tool, add all its children
255
- const children = batchToolChildren.get(normalizedId);
256
- if (children) {
257
- children.forEach(childId => expandedPrunedIds.add(childId));
201
+ const expandBatchIds = (ids) => {
202
+ const expanded = new Set();
203
+ for (const id of ids) {
204
+ const normalizedId = id.toLowerCase();
205
+ expanded.add(normalizedId);
206
+ const children = batchToolChildren.get(normalizedId);
207
+ if (children) {
208
+ children.forEach(childId => expanded.add(childId));
209
+ }
258
210
  }
259
- }
260
- // Calculate which IDs are actually NEW (not already pruned)
211
+ return Array.from(expanded);
212
+ };
213
+ const expandedPrunedIds = new Set(expandBatchIds(newlyPrunedIds));
214
+ const expandedLlmPrunedIds = expandBatchIds(llmPrunedIds);
261
215
  const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
262
- // finalPrunedIds includes everything (new + already pruned) for logging
263
216
  const finalPrunedIds = Array.from(expandedPrunedIds);
264
- // ============================================================
265
217
  // PHASE 4: CALCULATE STATS & NOTIFICATION
266
- // ============================================================
267
- // Calculate token savings once (used by both notification and log)
268
218
  const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs);
269
- // Accumulate session stats (for showing cumulative totals in UI)
270
219
  const currentStats = this.statsState.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 };
271
220
  const sessionStats = {
272
221
  totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length,
273
222
  totalTokensSaved: currentStats.totalTokensSaved + tokensSaved
274
223
  };
275
224
  this.statsState.set(sessionID, sessionStats);
276
- // Determine notification mode based on which strategies ran
277
225
  const hasLlmAnalysis = strategies.includes('ai-analysis');
278
226
  if (hasLlmAnalysis) {
279
- await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, tokensSaved, sessionStats);
227
+ await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, expandedLlmPrunedIds, toolMetadata, tokensSaved, sessionStats);
280
228
  }
281
229
  else {
282
230
  await this.sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, tokensSaved, sessionStats);
283
231
  }
284
- // ============================================================
285
232
  // PHASE 5: STATE UPDATE
286
- // ============================================================
287
- // Merge newly pruned IDs with existing ones (using expanded IDs)
288
233
  const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])];
289
234
  this.prunedIdsState.set(sessionID, allPrunedIds);
290
- // Log final summary
291
- // Format: "Pruned 5/5 tools (~4.2K tokens), 0 kept" or with breakdown if both duplicate and llm
292
235
  const prunedCount = finalNewlyPrunedIds.length;
293
236
  const keptCount = candidateCount - prunedCount;
294
237
  const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0;
295
238
  const breakdown = hasBoth ? ` (${deduplicatedIds.length} duplicate, ${llmPrunedIds.length} llm)` : "";
296
- // Build log metadata
297
239
  const logMeta = { trigger: options.trigger };
298
240
  if (options.reason) {
299
241
  logMeta.reason = options.reason;
@@ -303,7 +245,7 @@ export class Janitor {
303
245
  prunedCount: finalNewlyPrunedIds.length,
304
246
  tokensSaved,
305
247
  deduplicatedIds,
306
- llmPrunedIds,
248
+ llmPrunedIds: expandedLlmPrunedIds,
307
249
  deduplicationDetails,
308
250
  toolMetadata,
309
251
  sessionStats
@@ -314,17 +256,10 @@ export class Janitor {
314
256
  error: error.message,
315
257
  trigger: options.trigger
316
258
  });
317
- // Don't throw - this is a fire-and-forget background process
318
- // Silently fail and try again on next idle event
319
259
  return null;
320
260
  }
321
261
  }
322
- /**
323
- * Helper function to shorten paths for display
324
- */
325
262
  shortenPath(input) {
326
- // Handle compound strings like: "pattern" in /absolute/path
327
- // Extract and shorten just the path portion
328
263
  const inPathMatch = input.match(/^(.+) in (.+)$/);
329
264
  if (inPathMatch) {
330
265
  const prefix = inPathMatch[1];
@@ -334,31 +269,23 @@ export class Janitor {
334
269
  }
335
270
  return this.shortenSinglePath(input);
336
271
  }
337
- /**
338
- * Shorten a single path string
339
- */
340
272
  shortenSinglePath(path) {
341
273
  const homeDir = require('os').homedir();
342
- // Strip working directory FIRST (before ~ replacement) for cleaner relative paths
343
274
  if (this.workingDirectory) {
344
275
  if (path.startsWith(this.workingDirectory + '/')) {
345
276
  return path.slice(this.workingDirectory.length + 1);
346
277
  }
347
- // Exact match (the directory itself)
348
278
  if (path === this.workingDirectory) {
349
279
  return '.';
350
280
  }
351
281
  }
352
- // Replace home directory with ~
353
282
  if (path.startsWith(homeDir)) {
354
283
  path = '~' + path.slice(homeDir.length);
355
284
  }
356
- // Shorten node_modules paths: show package + file only
357
285
  const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/);
358
286
  if (nodeModulesMatch) {
359
287
  return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
360
288
  }
361
- // Try matching against ~ version of working directory (for paths already with ~)
362
289
  if (this.workingDirectory) {
363
290
  const workingDirWithTilde = this.workingDirectory.startsWith(homeDir)
364
291
  ? '~' + this.workingDirectory.slice(homeDir.length)
@@ -372,11 +299,6 @@ export class Janitor {
372
299
  }
373
300
  return path;
374
301
  }
375
- /**
376
- * Replace pruned tool outputs with placeholder text to save tokens in janitor context
377
- * This applies the same replacement logic as the global fetch wrapper, but for the
378
- * janitor's shadow inference to avoid sending already-pruned content to the LLM
379
- */
380
302
  replacePrunedToolOutputs(messages, prunedIds) {
381
303
  if (prunedIds.length === 0)
382
304
  return messages;
@@ -391,7 +313,6 @@ export class Janitor {
391
313
  part.callID &&
392
314
  prunedIdsSet.has(part.callID.toLowerCase()) &&
393
315
  part.state?.output) {
394
- // Replace with the same placeholder used by the global fetch wrapper
395
316
  return {
396
317
  ...part,
397
318
  state: {
@@ -405,9 +326,6 @@ export class Janitor {
405
326
  };
406
327
  });
407
328
  }
408
- /**
409
- * Helper function to calculate token savings from tool outputs
410
- */
411
329
  async calculateTokensSaved(prunedIds, toolOutputs) {
412
330
  const outputsToTokenize = [];
413
331
  for (const prunedId of prunedIds) {
@@ -417,59 +335,46 @@ export class Janitor {
417
335
  }
418
336
  }
419
337
  if (outputsToTokenize.length > 0) {
420
- // Use batch tokenization for efficiency (lazy loads gpt-tokenizer)
421
338
  const tokenCounts = await estimateTokensBatch(outputsToTokenize);
422
339
  return tokenCounts.reduce((sum, count) => sum + count, 0);
423
340
  }
424
341
  return 0;
425
342
  }
426
- /**
427
- * Build a summary of tools by grouping them
428
- * Uses shared extractParameterKey logic for consistent parameter extraction
429
- *
430
- * Note: prunedIds may be in original case (from LLM) but toolMetadata uses lowercase keys
431
- */
432
343
  buildToolsSummary(prunedIds, toolMetadata) {
433
344
  const toolsSummary = new Map();
434
- // Helper function to truncate long strings
435
345
  const truncate = (str, maxLen = 60) => {
436
346
  if (str.length <= maxLen)
437
347
  return str;
438
348
  return str.slice(0, maxLen - 3) + '...';
439
349
  };
440
350
  for (const prunedId of prunedIds) {
441
- // Normalize ID to lowercase for lookup (toolMetadata uses lowercase keys)
442
351
  const normalizedId = prunedId.toLowerCase();
443
352
  const metadata = toolMetadata.get(normalizedId);
444
353
  if (metadata) {
445
354
  const toolName = metadata.tool;
355
+ if (toolName === 'batch')
356
+ continue;
446
357
  if (!toolsSummary.has(toolName)) {
447
358
  toolsSummary.set(toolName, []);
448
359
  }
449
- // Use shared parameter extraction logic
450
360
  const paramKey = extractParameterKey(metadata);
451
361
  if (paramKey) {
452
- // Apply path shortening and truncation for display
453
362
  const displayKey = truncate(this.shortenPath(paramKey), 80);
454
363
  toolsSummary.get(toolName).push(displayKey);
455
364
  }
456
365
  else {
457
- // For tools with no extractable parameter key, add a placeholder
458
- // This ensures the tool still shows up in the summary
459
366
  toolsSummary.get(toolName).push('(default)');
460
367
  }
461
368
  }
462
369
  }
463
370
  return toolsSummary;
464
371
  }
465
- /**
466
- * Group deduplication details by tool type
467
- * Shared helper used by notifications and tool output formatting
468
- */
469
372
  groupDeduplicationDetails(deduplicationDetails) {
470
373
  const grouped = new Map();
471
374
  for (const [_, details] of deduplicationDetails) {
472
375
  const { toolName, parameterKey, duplicateCount } = details;
376
+ if (toolName === 'batch')
377
+ continue;
473
378
  if (!grouped.has(toolName)) {
474
379
  grouped.set(toolName, []);
475
380
  }
@@ -480,10 +385,6 @@ export class Janitor {
480
385
  }
481
386
  return grouped;
482
387
  }
483
- /**
484
- * Format grouped deduplication results as lines
485
- * Shared helper for building deduplication summaries
486
- */
487
388
  formatDeduplicationLines(grouped, indent = ' ') {
488
389
  const lines = [];
489
390
  for (const [toolName, items] of grouped.entries()) {
@@ -494,10 +395,6 @@ export class Janitor {
494
395
  }
495
396
  return lines;
496
397
  }
497
- /**
498
- * Format tool summary (from buildToolsSummary) as lines
499
- * Shared helper for building LLM-pruned summaries
500
- */
501
398
  formatToolSummaryLines(toolsSummary, indent = ' ') {
502
399
  const lines = [];
503
400
  for (const [toolName, params] of toolsSummary.entries()) {
@@ -513,47 +410,34 @@ export class Janitor {
513
410
  }
514
411
  return lines;
515
412
  }
516
- /**
517
- * Send minimal summary notification (just tokens saved and count)
518
- */
519
413
  async sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats) {
520
414
  if (totalPruned === 0)
521
415
  return;
522
416
  const tokensFormatted = formatTokenCount(tokensSaved);
523
417
  const toolText = totalPruned === 1 ? 'tool' : 'tools';
524
418
  let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)`;
525
- // Add session totals if there's been more than one pruning run
526
419
  if (sessionStats.totalToolsPruned > totalPruned) {
527
420
  message += ` โ”‚ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
528
421
  }
529
422
  await this.sendIgnoredMessage(sessionID, message);
530
423
  }
531
- /**
532
- * Auto mode notification - shows only deduplication results
533
- */
534
424
  async sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, tokensSaved, sessionStats) {
535
425
  if (deduplicatedIds.length === 0)
536
426
  return;
537
- // Check if notifications are disabled
538
427
  if (this.pruningSummary === 'off')
539
428
  return;
540
- // Send minimal notification if configured
541
429
  if (this.pruningSummary === 'minimal') {
542
430
  await this.sendMinimalNotification(sessionID, deduplicatedIds.length, tokensSaved, sessionStats);
543
431
  return;
544
432
  }
545
- // Otherwise send detailed notification
546
433
  const tokensFormatted = formatTokenCount(tokensSaved);
547
434
  const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools';
548
435
  let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)`;
549
- // Add session totals if there's been more than one pruning run
550
436
  if (sessionStats.totalToolsPruned > deduplicatedIds.length) {
551
437
  message += ` โ”‚ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
552
438
  }
553
439
  message += '\n';
554
- // Group by tool type using shared helper
555
440
  const grouped = this.groupDeduplicationDetails(deduplicationDetails);
556
- // Display grouped results (with UI-specific formatting: total dupes header, limit to 5)
557
441
  for (const [toolName, items] of grouped.entries()) {
558
442
  const totalDupes = items.reduce((sum, item) => sum + (item.count - 1), 0);
559
443
  message += `\n${toolName} (${totalDupes} duplicate${totalDupes > 1 ? 's' : ''}):\n`;
@@ -567,22 +451,16 @@ export class Janitor {
567
451
  }
568
452
  await this.sendIgnoredMessage(sessionID, message.trim());
569
453
  }
570
- /**
571
- * Format pruning result for tool output (returned to AI)
572
- * Uses shared helpers for consistency with UI notifications
573
- */
574
454
  formatPruningResultForTool(result) {
575
455
  const lines = [];
576
456
  lines.push(`Context pruning complete. Pruned ${result.prunedCount} tool outputs.`);
577
457
  lines.push('');
578
- // Section 1: Deduplicated tools
579
458
  if (result.deduplicatedIds.length > 0 && result.deduplicationDetails.size > 0) {
580
459
  lines.push(`Duplicates removed (${result.deduplicatedIds.length}):`);
581
460
  const grouped = this.groupDeduplicationDetails(result.deduplicationDetails);
582
461
  lines.push(...this.formatDeduplicationLines(grouped));
583
462
  lines.push('');
584
463
  }
585
- // Section 2: LLM-pruned tools
586
464
  if (result.llmPrunedIds.length > 0) {
587
465
  lines.push(`Semantically pruned (${result.llmPrunedIds.length}):`);
588
466
  const toolsSummary = this.buildToolsSummary(result.llmPrunedIds, result.toolMetadata);
@@ -590,42 +468,33 @@ export class Janitor {
590
468
  }
591
469
  return lines.join('\n').trim();
592
470
  }
593
- /**
594
- * Smart mode notification - shows both deduplication and LLM analysis results
595
- */
596
471
  async sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, tokensSaved, sessionStats) {
597
472
  const totalPruned = deduplicatedIds.length + llmPrunedIds.length;
598
473
  if (totalPruned === 0)
599
474
  return;
600
- // Check if notifications are disabled
601
475
  if (this.pruningSummary === 'off')
602
476
  return;
603
- // Send minimal notification if configured
604
477
  if (this.pruningSummary === 'minimal') {
605
478
  await this.sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats);
606
479
  return;
607
480
  }
608
- // Otherwise send detailed notification
609
481
  const tokensFormatted = formatTokenCount(tokensSaved);
610
482
  let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)`;
611
- // Add session totals if there's been more than one pruning run
612
483
  if (sessionStats.totalToolsPruned > totalPruned) {
613
484
  message += ` โ”‚ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
614
485
  }
615
486
  message += '\n';
616
- // Section 1: Deduplicated tools
617
487
  if (deduplicatedIds.length > 0 && deduplicationDetails) {
618
488
  message += `\n๐Ÿ“ฆ Duplicates removed (${deduplicatedIds.length}):\n`;
619
489
  const grouped = this.groupDeduplicationDetails(deduplicationDetails);
620
490
  for (const [toolName, items] of grouped.entries()) {
621
491
  message += ` ${toolName}:\n`;
622
492
  for (const item of items) {
623
- const removedCount = item.count - 1; // Total occurrences minus the one we kept
493
+ const removedCount = item.count - 1;
624
494
  message += ` ${item.key} (${removedCount}ร— duplicate)\n`;
625
495
  }
626
496
  }
627
497
  }
628
- // Section 2: LLM-pruned tools
629
498
  if (llmPrunedIds.length > 0) {
630
499
  message += `\n๐Ÿค– LLM analysis (${llmPrunedIds.length}):\n`;
631
500
  const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata);
@@ -637,7 +506,6 @@ export class Janitor {
637
506
  }
638
507
  }
639
508
  }
640
- // Handle any tools that weren't found in metadata (edge case)
641
509
  const foundToolNames = new Set(toolsSummary.keys());
642
510
  const missingTools = llmPrunedIds.filter(id => {
643
511
  const normalizedId = id.toLowerCase();