@tarquinen/opencode-dcp 0.3.16 โ†’ 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,73 +141,68 @@ 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
- // Helper to expand batch tool IDs to include their children
250
201
  const expandBatchIds = (ids) => {
251
202
  const expanded = new Set();
252
203
  for (const id of ids) {
253
204
  const normalizedId = id.toLowerCase();
254
205
  expanded.add(normalizedId);
255
- // If this is a batch tool, add all its children
256
206
  const children = batchToolChildren.get(normalizedId);
257
207
  if (children) {
258
208
  children.forEach(childId => expanded.add(childId));
@@ -260,27 +210,18 @@ export class Janitor {
260
210
  }
261
211
  return Array.from(expanded);
262
212
  };
263
- // Expand batch tool IDs to include their children
264
213
  const expandedPrunedIds = new Set(expandBatchIds(newlyPrunedIds));
265
- // Expand llmPrunedIds for UI display (so batch children show instead of "unknown metadata")
266
214
  const expandedLlmPrunedIds = expandBatchIds(llmPrunedIds);
267
- // Calculate which IDs are actually NEW (not already pruned)
268
215
  const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
269
- // finalPrunedIds includes everything (new + already pruned) for logging
270
216
  const finalPrunedIds = Array.from(expandedPrunedIds);
271
- // ============================================================
272
217
  // PHASE 4: CALCULATE STATS & NOTIFICATION
273
- // ============================================================
274
- // Calculate token savings once (used by both notification and log)
275
218
  const tokensSaved = await this.calculateTokensSaved(finalNewlyPrunedIds, toolOutputs);
276
- // Accumulate session stats (for showing cumulative totals in UI)
277
219
  const currentStats = this.statsState.get(sessionID) ?? { totalToolsPruned: 0, totalTokensSaved: 0 };
278
220
  const sessionStats = {
279
221
  totalToolsPruned: currentStats.totalToolsPruned + finalNewlyPrunedIds.length,
280
222
  totalTokensSaved: currentStats.totalTokensSaved + tokensSaved
281
223
  };
282
224
  this.statsState.set(sessionID, sessionStats);
283
- // Determine notification mode based on which strategies ran
284
225
  const hasLlmAnalysis = strategies.includes('ai-analysis');
285
226
  if (hasLlmAnalysis) {
286
227
  await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, expandedLlmPrunedIds, toolMetadata, tokensSaved, sessionStats);
@@ -288,19 +229,13 @@ export class Janitor {
288
229
  else {
289
230
  await this.sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, tokensSaved, sessionStats);
290
231
  }
291
- // ============================================================
292
232
  // PHASE 5: STATE UPDATE
293
- // ============================================================
294
- // Merge newly pruned IDs with existing ones (using expanded IDs)
295
233
  const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])];
296
234
  this.prunedIdsState.set(sessionID, allPrunedIds);
297
- // Log final summary
298
- // Format: "Pruned 5/5 tools (~4.2K tokens), 0 kept" or with breakdown if both duplicate and llm
299
235
  const prunedCount = finalNewlyPrunedIds.length;
300
236
  const keptCount = candidateCount - prunedCount;
301
237
  const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0;
302
238
  const breakdown = hasBoth ? ` (${deduplicatedIds.length} duplicate, ${llmPrunedIds.length} llm)` : "";
303
- // Build log metadata
304
239
  const logMeta = { trigger: options.trigger };
305
240
  if (options.reason) {
306
241
  logMeta.reason = options.reason;
@@ -321,17 +256,10 @@ export class Janitor {
321
256
  error: error.message,
322
257
  trigger: options.trigger
323
258
  });
324
- // Don't throw - this is a fire-and-forget background process
325
- // Silently fail and try again on next idle event
326
259
  return null;
327
260
  }
328
261
  }
329
- /**
330
- * Helper function to shorten paths for display
331
- */
332
262
  shortenPath(input) {
333
- // Handle compound strings like: "pattern" in /absolute/path
334
- // Extract and shorten just the path portion
335
263
  const inPathMatch = input.match(/^(.+) in (.+)$/);
336
264
  if (inPathMatch) {
337
265
  const prefix = inPathMatch[1];
@@ -341,31 +269,23 @@ export class Janitor {
341
269
  }
342
270
  return this.shortenSinglePath(input);
343
271
  }
344
- /**
345
- * Shorten a single path string
346
- */
347
272
  shortenSinglePath(path) {
348
273
  const homeDir = require('os').homedir();
349
- // Strip working directory FIRST (before ~ replacement) for cleaner relative paths
350
274
  if (this.workingDirectory) {
351
275
  if (path.startsWith(this.workingDirectory + '/')) {
352
276
  return path.slice(this.workingDirectory.length + 1);
353
277
  }
354
- // Exact match (the directory itself)
355
278
  if (path === this.workingDirectory) {
356
279
  return '.';
357
280
  }
358
281
  }
359
- // Replace home directory with ~
360
282
  if (path.startsWith(homeDir)) {
361
283
  path = '~' + path.slice(homeDir.length);
362
284
  }
363
- // Shorten node_modules paths: show package + file only
364
285
  const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/);
365
286
  if (nodeModulesMatch) {
366
287
  return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
367
288
  }
368
- // Try matching against ~ version of working directory (for paths already with ~)
369
289
  if (this.workingDirectory) {
370
290
  const workingDirWithTilde = this.workingDirectory.startsWith(homeDir)
371
291
  ? '~' + this.workingDirectory.slice(homeDir.length)
@@ -379,11 +299,6 @@ export class Janitor {
379
299
  }
380
300
  return path;
381
301
  }
382
- /**
383
- * Replace pruned tool outputs with placeholder text to save tokens in janitor context
384
- * This applies the same replacement logic as the global fetch wrapper, but for the
385
- * janitor's shadow inference to avoid sending already-pruned content to the LLM
386
- */
387
302
  replacePrunedToolOutputs(messages, prunedIds) {
388
303
  if (prunedIds.length === 0)
389
304
  return messages;
@@ -398,7 +313,6 @@ export class Janitor {
398
313
  part.callID &&
399
314
  prunedIdsSet.has(part.callID.toLowerCase()) &&
400
315
  part.state?.output) {
401
- // Replace with the same placeholder used by the global fetch wrapper
402
316
  return {
403
317
  ...part,
404
318
  state: {
@@ -412,9 +326,6 @@ export class Janitor {
412
326
  };
413
327
  });
414
328
  }
415
- /**
416
- * Helper function to calculate token savings from tool outputs
417
- */
418
329
  async calculateTokensSaved(prunedIds, toolOutputs) {
419
330
  const outputsToTokenize = [];
420
331
  for (const prunedId of prunedIds) {
@@ -424,63 +335,44 @@ export class Janitor {
424
335
  }
425
336
  }
426
337
  if (outputsToTokenize.length > 0) {
427
- // Use batch tokenization for efficiency (lazy loads gpt-tokenizer)
428
338
  const tokenCounts = await estimateTokensBatch(outputsToTokenize);
429
339
  return tokenCounts.reduce((sum, count) => sum + count, 0);
430
340
  }
431
341
  return 0;
432
342
  }
433
- /**
434
- * Build a summary of tools by grouping them
435
- * Uses shared extractParameterKey logic for consistent parameter extraction
436
- *
437
- * Note: prunedIds may be in original case (from LLM) but toolMetadata uses lowercase keys
438
- */
439
343
  buildToolsSummary(prunedIds, toolMetadata) {
440
344
  const toolsSummary = new Map();
441
- // Helper function to truncate long strings
442
345
  const truncate = (str, maxLen = 60) => {
443
346
  if (str.length <= maxLen)
444
347
  return str;
445
348
  return str.slice(0, maxLen - 3) + '...';
446
349
  };
447
350
  for (const prunedId of prunedIds) {
448
- // Normalize ID to lowercase for lookup (toolMetadata uses lowercase keys)
449
351
  const normalizedId = prunedId.toLowerCase();
450
352
  const metadata = toolMetadata.get(normalizedId);
451
353
  if (metadata) {
452
354
  const toolName = metadata.tool;
453
- // Skip 'batch' tool in UI summary - it's a wrapper and its children are shown individually
454
355
  if (toolName === 'batch')
455
356
  continue;
456
357
  if (!toolsSummary.has(toolName)) {
457
358
  toolsSummary.set(toolName, []);
458
359
  }
459
- // Use shared parameter extraction logic
460
360
  const paramKey = extractParameterKey(metadata);
461
361
  if (paramKey) {
462
- // Apply path shortening and truncation for display
463
362
  const displayKey = truncate(this.shortenPath(paramKey), 80);
464
363
  toolsSummary.get(toolName).push(displayKey);
465
364
  }
466
365
  else {
467
- // For tools with no extractable parameter key, add a placeholder
468
- // This ensures the tool still shows up in the summary
469
366
  toolsSummary.get(toolName).push('(default)');
470
367
  }
471
368
  }
472
369
  }
473
370
  return toolsSummary;
474
371
  }
475
- /**
476
- * Group deduplication details by tool type
477
- * Shared helper used by notifications and tool output formatting
478
- */
479
372
  groupDeduplicationDetails(deduplicationDetails) {
480
373
  const grouped = new Map();
481
374
  for (const [_, details] of deduplicationDetails) {
482
375
  const { toolName, parameterKey, duplicateCount } = details;
483
- // Skip 'batch' tool in UI summary - it's a wrapper and its children are shown individually
484
376
  if (toolName === 'batch')
485
377
  continue;
486
378
  if (!grouped.has(toolName)) {
@@ -493,10 +385,6 @@ export class Janitor {
493
385
  }
494
386
  return grouped;
495
387
  }
496
- /**
497
- * Format grouped deduplication results as lines
498
- * Shared helper for building deduplication summaries
499
- */
500
388
  formatDeduplicationLines(grouped, indent = ' ') {
501
389
  const lines = [];
502
390
  for (const [toolName, items] of grouped.entries()) {
@@ -507,10 +395,6 @@ export class Janitor {
507
395
  }
508
396
  return lines;
509
397
  }
510
- /**
511
- * Format tool summary (from buildToolsSummary) as lines
512
- * Shared helper for building LLM-pruned summaries
513
- */
514
398
  formatToolSummaryLines(toolsSummary, indent = ' ') {
515
399
  const lines = [];
516
400
  for (const [toolName, params] of toolsSummary.entries()) {
@@ -526,47 +410,34 @@ export class Janitor {
526
410
  }
527
411
  return lines;
528
412
  }
529
- /**
530
- * Send minimal summary notification (just tokens saved and count)
531
- */
532
413
  async sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats) {
533
414
  if (totalPruned === 0)
534
415
  return;
535
416
  const tokensFormatted = formatTokenCount(tokensSaved);
536
417
  const toolText = totalPruned === 1 ? 'tool' : 'tools';
537
418
  let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)`;
538
- // Add session totals if there's been more than one pruning run
539
419
  if (sessionStats.totalToolsPruned > totalPruned) {
540
420
  message += ` โ”‚ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
541
421
  }
542
422
  await this.sendIgnoredMessage(sessionID, message);
543
423
  }
544
- /**
545
- * Auto mode notification - shows only deduplication results
546
- */
547
424
  async sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, tokensSaved, sessionStats) {
548
425
  if (deduplicatedIds.length === 0)
549
426
  return;
550
- // Check if notifications are disabled
551
427
  if (this.pruningSummary === 'off')
552
428
  return;
553
- // Send minimal notification if configured
554
429
  if (this.pruningSummary === 'minimal') {
555
430
  await this.sendMinimalNotification(sessionID, deduplicatedIds.length, tokensSaved, sessionStats);
556
431
  return;
557
432
  }
558
- // Otherwise send detailed notification
559
433
  const tokensFormatted = formatTokenCount(tokensSaved);
560
434
  const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools';
561
435
  let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)`;
562
- // Add session totals if there's been more than one pruning run
563
436
  if (sessionStats.totalToolsPruned > deduplicatedIds.length) {
564
437
  message += ` โ”‚ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
565
438
  }
566
439
  message += '\n';
567
- // Group by tool type using shared helper
568
440
  const grouped = this.groupDeduplicationDetails(deduplicationDetails);
569
- // Display grouped results (with UI-specific formatting: total dupes header, limit to 5)
570
441
  for (const [toolName, items] of grouped.entries()) {
571
442
  const totalDupes = items.reduce((sum, item) => sum + (item.count - 1), 0);
572
443
  message += `\n${toolName} (${totalDupes} duplicate${totalDupes > 1 ? 's' : ''}):\n`;
@@ -580,22 +451,16 @@ export class Janitor {
580
451
  }
581
452
  await this.sendIgnoredMessage(sessionID, message.trim());
582
453
  }
583
- /**
584
- * Format pruning result for tool output (returned to AI)
585
- * Uses shared helpers for consistency with UI notifications
586
- */
587
454
  formatPruningResultForTool(result) {
588
455
  const lines = [];
589
456
  lines.push(`Context pruning complete. Pruned ${result.prunedCount} tool outputs.`);
590
457
  lines.push('');
591
- // Section 1: Deduplicated tools
592
458
  if (result.deduplicatedIds.length > 0 && result.deduplicationDetails.size > 0) {
593
459
  lines.push(`Duplicates removed (${result.deduplicatedIds.length}):`);
594
460
  const grouped = this.groupDeduplicationDetails(result.deduplicationDetails);
595
461
  lines.push(...this.formatDeduplicationLines(grouped));
596
462
  lines.push('');
597
463
  }
598
- // Section 2: LLM-pruned tools
599
464
  if (result.llmPrunedIds.length > 0) {
600
465
  lines.push(`Semantically pruned (${result.llmPrunedIds.length}):`);
601
466
  const toolsSummary = this.buildToolsSummary(result.llmPrunedIds, result.toolMetadata);
@@ -603,42 +468,33 @@ export class Janitor {
603
468
  }
604
469
  return lines.join('\n').trim();
605
470
  }
606
- /**
607
- * Smart mode notification - shows both deduplication and LLM analysis results
608
- */
609
471
  async sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, tokensSaved, sessionStats) {
610
472
  const totalPruned = deduplicatedIds.length + llmPrunedIds.length;
611
473
  if (totalPruned === 0)
612
474
  return;
613
- // Check if notifications are disabled
614
475
  if (this.pruningSummary === 'off')
615
476
  return;
616
- // Send minimal notification if configured
617
477
  if (this.pruningSummary === 'minimal') {
618
478
  await this.sendMinimalNotification(sessionID, totalPruned, tokensSaved, sessionStats);
619
479
  return;
620
480
  }
621
- // Otherwise send detailed notification
622
481
  const tokensFormatted = formatTokenCount(tokensSaved);
623
482
  let message = `๐Ÿงน DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)`;
624
- // Add session totals if there's been more than one pruning run
625
483
  if (sessionStats.totalToolsPruned > totalPruned) {
626
484
  message += ` โ”‚ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`;
627
485
  }
628
486
  message += '\n';
629
- // Section 1: Deduplicated tools
630
487
  if (deduplicatedIds.length > 0 && deduplicationDetails) {
631
488
  message += `\n๐Ÿ“ฆ Duplicates removed (${deduplicatedIds.length}):\n`;
632
489
  const grouped = this.groupDeduplicationDetails(deduplicationDetails);
633
490
  for (const [toolName, items] of grouped.entries()) {
634
491
  message += ` ${toolName}:\n`;
635
492
  for (const item of items) {
636
- const removedCount = item.count - 1; // Total occurrences minus the one we kept
493
+ const removedCount = item.count - 1;
637
494
  message += ` ${item.key} (${removedCount}ร— duplicate)\n`;
638
495
  }
639
496
  }
640
497
  }
641
- // Section 2: LLM-pruned tools
642
498
  if (llmPrunedIds.length > 0) {
643
499
  message += `\n๐Ÿค– LLM analysis (${llmPrunedIds.length}):\n`;
644
500
  const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata);
@@ -650,7 +506,6 @@ export class Janitor {
650
506
  }
651
507
  }
652
508
  }
653
- // Handle any tools that weren't found in metadata (edge case)
654
509
  const foundToolNames = new Set(toolsSummary.keys());
655
510
  const missingTools = llmPrunedIds.filter(id => {
656
511
  const normalizedId = id.toLowerCase();