@tarquinen/opencode-dcp 0.2.7 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,6 +2,8 @@ import { generateObject } from "ai";
2
2
  import { z } from "zod";
3
3
  import { buildAnalysisPrompt } from "./prompt";
4
4
  import { selectModel, extractModelFromSession } from "./model-selector";
5
+ import { estimateTokensBatch, formatTokenCount } from "./tokenizer";
6
+ import { detectDuplicates, extractParameterKey } from "./deduplicator";
5
7
  export class Janitor {
6
8
  client;
7
9
  stateManager;
@@ -11,8 +13,10 @@ export class Janitor {
11
13
  modelCache;
12
14
  configModel;
13
15
  showModelErrorToasts;
16
+ pruningMode;
14
17
  constructor(client, stateManager, logger, toolParametersCache, protectedTools, modelCache, configModel, // Format: "provider/model"
15
- showModelErrorToasts = true // Whether to show toast for model errors
18
+ showModelErrorToasts = true, // Whether to show toast for model errors
19
+ pruningMode = "smart" // Pruning strategy
16
20
  ) {
17
21
  this.client = client;
18
22
  this.stateManager = stateManager;
@@ -22,6 +26,38 @@ export class Janitor {
22
26
  this.modelCache = modelCache;
23
27
  this.configModel = configModel;
24
28
  this.showModelErrorToasts = showModelErrorToasts;
29
+ this.pruningMode = pruningMode;
30
+ }
31
+ /**
32
+ * Sends an ignored message to the session UI (user sees it, AI doesn't)
33
+ */
34
+ async sendIgnoredMessage(sessionID, text) {
35
+ try {
36
+ await this.client.session.prompt({
37
+ path: {
38
+ id: sessionID
39
+ },
40
+ body: {
41
+ noReply: true, // Don't wait for AI response
42
+ parts: [{
43
+ type: 'text',
44
+ text: text,
45
+ ignored: true
46
+ }]
47
+ }
48
+ });
49
+ this.logger.debug("janitor", "Sent ignored message to session", {
50
+ sessionID,
51
+ textLength: text.length
52
+ });
53
+ }
54
+ catch (error) {
55
+ this.logger.error("janitor", "Failed to send ignored message", {
56
+ sessionID,
57
+ error: error.message
58
+ });
59
+ // Don't fail the operation if sending the message fails
60
+ }
25
61
  }
26
62
  async run(sessionID) {
27
63
  this.logger.info("janitor", "Starting analysis", { sessionID });
@@ -142,113 +178,178 @@ export class Janitor {
142
178
  alreadyPrunedIds: alreadyPrunedIds.slice(0, 5), // Show first 5 for brevity
143
179
  unprunedCount: unprunedToolCallIds.length
144
180
  });
145
- // Filter out protected tools from being considered for pruning
146
- const protectedToolCallIds = [];
147
- const prunableToolCallIds = unprunedToolCallIds.filter(id => {
148
- const metadata = toolMetadata.get(id);
149
- if (metadata && this.protectedTools.includes(metadata.tool)) {
150
- protectedToolCallIds.push(id);
151
- return false;
152
- }
153
- return true;
154
- });
155
- if (protectedToolCallIds.length > 0) {
156
- this.logger.debug("janitor", "Protected tools excluded from pruning", {
157
- sessionID,
158
- protectedCount: protectedToolCallIds.length,
159
- protectedTools: protectedToolCallIds.map(id => {
160
- const metadata = toolMetadata.get(id);
161
- return { id, tool: metadata?.tool };
162
- })
163
- });
164
- }
165
181
  // If there are no unpruned tool calls, skip analysis
166
- if (prunableToolCallIds.length === 0) {
167
- this.logger.debug("janitor", "No prunable tool calls found, skipping analysis", {
168
- sessionID,
169
- protectedCount: protectedToolCallIds.length
182
+ if (unprunedToolCallIds.length === 0) {
183
+ this.logger.debug("janitor", "No unpruned tool calls found, skipping analysis", {
184
+ sessionID
170
185
  });
171
186
  return;
172
187
  }
173
- // Select appropriate model with intelligent fallback
174
- // Try to get model from cache first, otherwise extractModelFromSession won't find it
175
- const cachedModelInfo = this.modelCache.get(sessionID);
176
- const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger);
177
- const currentModelInfo = cachedModelInfo || sessionModelInfo;
178
- if (cachedModelInfo) {
179
- this.logger.debug("janitor", "Using cached model info", {
180
- sessionID,
181
- providerID: cachedModelInfo.providerID,
182
- modelID: cachedModelInfo.modelID
183
- });
184
- }
185
- const modelSelection = await selectModel(currentModelInfo, this.logger, this.configModel);
186
- this.logger.info("janitor", "Model selected for analysis", {
188
+ // ============================================================
189
+ // PHASE 1: DUPLICATE DETECTION (runs for both modes)
190
+ // ============================================================
191
+ const dedupeResult = detectDuplicates(toolMetadata, unprunedToolCallIds, this.protectedTools);
192
+ const deduplicatedIds = dedupeResult.duplicateIds;
193
+ const deduplicationDetails = dedupeResult.deduplicationDetails;
194
+ this.logger.info("janitor", "Duplicate detection complete", {
187
195
  sessionID,
188
- modelInfo: modelSelection.modelInfo,
189
- source: modelSelection.source,
190
- reason: modelSelection.reason
196
+ duplicatesFound: deduplicatedIds.length,
197
+ uniqueToolPatterns: deduplicationDetails.size
191
198
  });
192
- // Show toast if we had to fallback from a failed model
193
- if (modelSelection.failedModel && this.showModelErrorToasts) {
194
- try {
195
- await this.client.tui.showToast({
196
- body: {
197
- title: "DCP: Model fallback",
198
- message: `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`,
199
- variant: "info",
200
- duration: 5000
199
+ // ============================================================
200
+ // PHASE 2: LLM ANALYSIS (only runs in "smart" mode)
201
+ // ============================================================
202
+ let llmPrunedIds = [];
203
+ if (this.pruningMode === "smart") {
204
+ // Filter out duplicates and protected tools
205
+ const protectedToolCallIds = [];
206
+ const prunableToolCallIds = unprunedToolCallIds.filter(id => {
207
+ // Skip already deduplicated
208
+ if (deduplicatedIds.includes(id))
209
+ return false;
210
+ // Skip protected tools
211
+ const metadata = toolMetadata.get(id);
212
+ if (metadata && this.protectedTools.includes(metadata.tool)) {
213
+ protectedToolCallIds.push(id);
214
+ return false;
215
+ }
216
+ return true;
217
+ });
218
+ if (protectedToolCallIds.length > 0) {
219
+ this.logger.debug("janitor", "Protected tools excluded from pruning", {
220
+ sessionID,
221
+ protectedCount: protectedToolCallIds.length,
222
+ protectedTools: protectedToolCallIds.map(id => {
223
+ const metadata = toolMetadata.get(id);
224
+ return { id, tool: metadata?.tool };
225
+ })
226
+ });
227
+ }
228
+ // Run LLM analysis only if there are prunable tools
229
+ if (prunableToolCallIds.length > 0) {
230
+ this.logger.info("janitor", "Starting LLM analysis", {
231
+ sessionID,
232
+ candidateCount: prunableToolCallIds.length
233
+ });
234
+ // Select appropriate model with intelligent fallback
235
+ const cachedModelInfo = this.modelCache.get(sessionID);
236
+ const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger);
237
+ const currentModelInfo = cachedModelInfo || sessionModelInfo;
238
+ if (cachedModelInfo) {
239
+ this.logger.debug("janitor", "Using cached model info", {
240
+ sessionID,
241
+ providerID: cachedModelInfo.providerID,
242
+ modelID: cachedModelInfo.modelID
243
+ });
244
+ }
245
+ const modelSelection = await selectModel(currentModelInfo, this.logger, this.configModel);
246
+ this.logger.info("janitor", "Model selected for analysis", {
247
+ sessionID,
248
+ modelInfo: modelSelection.modelInfo,
249
+ source: modelSelection.source,
250
+ reason: modelSelection.reason
251
+ });
252
+ // Show toast if we had to fallback from a failed model
253
+ if (modelSelection.failedModel && this.showModelErrorToasts) {
254
+ try {
255
+ await this.client.tui.showToast({
256
+ body: {
257
+ title: "DCP: Model fallback",
258
+ message: `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`,
259
+ variant: "info",
260
+ duration: 5000
261
+ }
262
+ });
263
+ this.logger.info("janitor", "Toast notification shown for model fallback", {
264
+ failedModel: modelSelection.failedModel,
265
+ selectedModel: modelSelection.modelInfo
266
+ });
201
267
  }
268
+ catch (toastError) {
269
+ this.logger.error("janitor", "Failed to show toast notification", {
270
+ error: toastError.message
271
+ });
272
+ // Don't fail the whole operation if toast fails
273
+ }
274
+ }
275
+ else if (modelSelection.failedModel && !this.showModelErrorToasts) {
276
+ this.logger.info("janitor", "Model fallback occurred but toast disabled by config", {
277
+ failedModel: modelSelection.failedModel,
278
+ selectedModel: modelSelection.modelInfo
279
+ });
280
+ }
281
+ // Log comprehensive stats before AI call
282
+ this.logger.info("janitor", "Preparing AI analysis", {
283
+ sessionID,
284
+ totalToolCallsInSession: toolCallIds.length,
285
+ alreadyPrunedCount: alreadyPrunedIds.length,
286
+ deduplicatedCount: deduplicatedIds.length,
287
+ protectedToolsCount: protectedToolCallIds.length,
288
+ candidatesForPruning: prunableToolCallIds.length,
289
+ candidateTools: prunableToolCallIds.map(id => {
290
+ const meta = toolMetadata.get(id);
291
+ return meta ? `${meta.tool}[${id.substring(0, 12)}...]` : id.substring(0, 12) + '...';
292
+ }).slice(0, 10), // Show first 10 for brevity
293
+ batchToolCount: batchToolChildren.size,
294
+ batchDetails: Array.from(batchToolChildren.entries()).map(([batchId, children]) => ({
295
+ batchId: batchId.substring(0, 20) + '...',
296
+ childCount: children.length
297
+ }))
298
+ });
299
+ this.logger.debug("janitor", "Starting shadow inference", { sessionID });
300
+ // Analyze which tool calls are obsolete
301
+ const result = await generateObject({
302
+ model: modelSelection.model,
303
+ schema: z.object({
304
+ pruned_tool_call_ids: z.array(z.string()),
305
+ reasoning: z.string(),
306
+ }),
307
+ prompt: buildAnalysisPrompt(prunableToolCallIds, messages, this.protectedTools)
202
308
  });
203
- this.logger.info("janitor", "Toast notification shown for model fallback", {
204
- failedModel: modelSelection.failedModel,
205
- selectedModel: modelSelection.modelInfo
309
+ // Filter LLM results to only include IDs that were actually candidates
310
+ // (LLM sometimes returns duplicate IDs that were already filtered out)
311
+ const rawLlmPrunedIds = result.object.pruned_tool_call_ids;
312
+ llmPrunedIds = rawLlmPrunedIds.filter(id => prunableToolCallIds.includes(id.toLowerCase()));
313
+ if (rawLlmPrunedIds.length !== llmPrunedIds.length) {
314
+ this.logger.warn("janitor", "LLM returned non-candidate IDs (filtered out)", {
315
+ sessionID,
316
+ rawCount: rawLlmPrunedIds.length,
317
+ filteredCount: llmPrunedIds.length,
318
+ invalidIds: rawLlmPrunedIds.filter(id => !prunableToolCallIds.includes(id.toLowerCase()))
319
+ });
320
+ }
321
+ this.logger.info("janitor", "LLM analysis complete", {
322
+ sessionID,
323
+ llmPrunedCount: llmPrunedIds.length,
324
+ reasoning: result.object.reasoning
206
325
  });
207
326
  }
208
- catch (toastError) {
209
- this.logger.error("janitor", "Failed to show toast notification", {
210
- error: toastError.message
327
+ else {
328
+ this.logger.info("janitor", "No prunable tools for LLM analysis", {
329
+ sessionID,
330
+ deduplicatedCount: deduplicatedIds.length,
331
+ protectedCount: protectedToolCallIds.length
211
332
  });
212
- // Don't fail the whole operation if toast fails
213
333
  }
214
334
  }
215
- else if (modelSelection.failedModel && !this.showModelErrorToasts) {
216
- this.logger.info("janitor", "Model fallback occurred but toast disabled by config", {
217
- failedModel: modelSelection.failedModel,
218
- selectedModel: modelSelection.modelInfo
335
+ else {
336
+ this.logger.info("janitor", "Skipping LLM analysis (auto mode)", {
337
+ sessionID,
338
+ deduplicatedCount: deduplicatedIds.length
219
339
  });
220
340
  }
221
- // Log comprehensive stats before AI call
222
- this.logger.info("janitor", "Preparing AI analysis", {
223
- sessionID,
224
- totalToolCallsInSession: toolCallIds.length,
225
- alreadyPrunedCount: alreadyPrunedIds.length,
226
- protectedToolsCount: protectedToolCallIds.length,
227
- candidatesForPruning: prunableToolCallIds.length,
228
- candidateTools: prunableToolCallIds.map(id => {
229
- const meta = toolMetadata.get(id);
230
- return meta ? `${meta.tool}[${id.substring(0, 12)}...]` : id.substring(0, 12) + '...';
231
- }).slice(0, 10), // Show first 10 for brevity
232
- batchToolCount: batchToolChildren.size,
233
- batchDetails: Array.from(batchToolChildren.entries()).map(([batchId, children]) => ({
234
- batchId: batchId.substring(0, 20) + '...',
235
- childCount: children.length
236
- }))
237
- });
238
- this.logger.debug("janitor", "Starting shadow inference", { sessionID });
239
- // Analyze which tool calls are obsolete
240
- const result = await generateObject({
241
- model: modelSelection.model,
242
- schema: z.object({
243
- pruned_tool_call_ids: z.array(z.string()),
244
- reasoning: z.string(),
245
- }),
246
- prompt: buildAnalysisPrompt(prunableToolCallIds, messages, this.protectedTools)
247
- });
341
+ // If mode is "auto", llmPrunedIds stays empty
342
+ // ============================================================
343
+ // PHASE 3: COMBINE & EXPAND
344
+ // ============================================================
345
+ const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
346
+ if (newlyPrunedIds.length === 0) {
347
+ this.logger.info("janitor", "No tools to prune", { sessionID });
348
+ return;
349
+ }
248
350
  // Expand batch tool IDs to include their children
249
- // Note: IDs are already normalized to lowercase when collected from messages
250
351
  const expandedPrunedIds = new Set();
251
- for (const prunedId of result.object.pruned_tool_call_ids) {
352
+ for (const prunedId of newlyPrunedIds) {
252
353
  const normalizedId = prunedId.toLowerCase();
253
354
  expandedPrunedIds.add(normalizedId);
254
355
  // If this is a batch tool, add all its children
@@ -264,15 +365,15 @@ export class Janitor {
264
365
  }
265
366
  }
266
367
  // Calculate which IDs are actually NEW (not already pruned)
267
- const newlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
368
+ const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
268
369
  // finalPrunedIds includes everything (new + already pruned) for logging
269
370
  const finalPrunedIds = Array.from(expandedPrunedIds);
270
371
  this.logger.info("janitor", "Analysis complete", {
271
372
  sessionID,
272
373
  prunedCount: finalPrunedIds.length,
273
- originalPrunedCount: result.object.pruned_tool_call_ids.length,
274
- prunedIds: finalPrunedIds,
275
- reasoning: result.object.reasoning
374
+ deduplicatedCount: deduplicatedIds.length,
375
+ llmPrunedCount: llmPrunedIds.length,
376
+ prunedIds: finalPrunedIds
276
377
  });
277
378
  this.logger.debug("janitor", "Pruning ID details", {
278
379
  sessionID,
@@ -280,153 +381,29 @@ export class Janitor {
280
381
  alreadyPrunedIds: alreadyPrunedIds,
281
382
  finalPrunedCount: finalPrunedIds.length,
282
383
  finalPrunedIds: finalPrunedIds,
283
- newlyPrunedCount: newlyPrunedIds.length,
284
- newlyPrunedIds: newlyPrunedIds
384
+ newlyPrunedCount: finalNewlyPrunedIds.length,
385
+ newlyPrunedIds: finalNewlyPrunedIds
285
386
  });
286
- // Calculate approximate size saved from newly pruned tool outputs
287
- let totalCharsSaved = 0;
288
- for (const prunedId of newlyPrunedIds) {
289
- const output = toolOutputs.get(prunedId);
290
- if (output) {
291
- totalCharsSaved += output.length;
292
- }
387
+ // ============================================================
388
+ // PHASE 4: NOTIFICATION
389
+ // ============================================================
390
+ if (this.pruningMode === "auto") {
391
+ await this.sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, toolMetadata, toolOutputs);
392
+ }
393
+ else {
394
+ await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, toolOutputs);
293
395
  }
294
- // Rough token estimate (1 token ā‰ˆ 4 characters for English text)
295
- const estimatedTokensSaved = Math.round(totalCharsSaved / 4);
396
+ // ============================================================
397
+ // PHASE 5: STATE UPDATE
398
+ // ============================================================
296
399
  // Merge newly pruned IDs with existing ones (using expanded IDs)
297
400
  const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])];
298
401
  await this.stateManager.set(sessionID, allPrunedIds);
299
402
  this.logger.debug("janitor", "Updated state manager", {
300
403
  sessionID,
301
404
  totalPrunedCount: allPrunedIds.length,
302
- newlyPrunedCount: newlyPrunedIds.length
405
+ newlyPrunedCount: finalNewlyPrunedIds.length
303
406
  });
304
- // Show toast notification if we pruned anything NEW
305
- if (newlyPrunedIds.length > 0) {
306
- try {
307
- // Helper function to shorten paths for display
308
- const shortenPath = (path) => {
309
- // Replace home directory with ~
310
- const homeDir = require('os').homedir();
311
- if (path.startsWith(homeDir)) {
312
- path = '~' + path.slice(homeDir.length);
313
- }
314
- // Shorten node_modules paths: show package + file only
315
- const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/);
316
- if (nodeModulesMatch) {
317
- return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
318
- }
319
- return path;
320
- };
321
- // Helper function to truncate long strings
322
- const truncate = (str, maxLen = 60) => {
323
- if (str.length <= maxLen)
324
- return str;
325
- return str.slice(0, maxLen - 3) + '...';
326
- };
327
- // Build a summary of pruned tools by grouping them
328
- const toolsSummary = new Map(); // tool name -> [parameters]
329
- for (const prunedId of newlyPrunedIds) {
330
- const metadata = toolMetadata.get(prunedId);
331
- if (metadata) {
332
- const toolName = metadata.tool;
333
- if (!toolsSummary.has(toolName)) {
334
- toolsSummary.set(toolName, []);
335
- }
336
- this.logger.debug("janitor", "Processing pruned tool metadata", {
337
- sessionID,
338
- prunedId,
339
- toolName,
340
- parameters: metadata.parameters
341
- });
342
- // Extract meaningful parameter info based on tool type
343
- let paramInfo = "";
344
- if (metadata.parameters) {
345
- // For read tool, show filePath
346
- if (toolName === "read" && metadata.parameters.filePath) {
347
- paramInfo = truncate(shortenPath(metadata.parameters.filePath), 50);
348
- }
349
- // For list tool, show path
350
- else if (toolName === "list" && metadata.parameters.path) {
351
- paramInfo = truncate(shortenPath(metadata.parameters.path), 50);
352
- }
353
- // For bash/command tools, prefer description over command
354
- else if (toolName === "bash") {
355
- if (metadata.parameters.description) {
356
- paramInfo = truncate(metadata.parameters.description, 50);
357
- }
358
- else if (metadata.parameters.command) {
359
- paramInfo = truncate(metadata.parameters.command, 50);
360
- }
361
- }
362
- // For other tools, show the first relevant parameter
363
- else if (metadata.parameters.path) {
364
- paramInfo = truncate(shortenPath(metadata.parameters.path), 50);
365
- }
366
- else if (metadata.parameters.pattern) {
367
- paramInfo = truncate(metadata.parameters.pattern, 50);
368
- }
369
- else if (metadata.parameters.command) {
370
- paramInfo = truncate(metadata.parameters.command, 50);
371
- }
372
- }
373
- if (paramInfo) {
374
- toolsSummary.get(toolName).push(paramInfo);
375
- }
376
- }
377
- else {
378
- this.logger.warn("janitor", "No metadata found for pruned tool", {
379
- sessionID,
380
- prunedId
381
- });
382
- }
383
- }
384
- // Format the message with tool details
385
- const toolText = newlyPrunedIds.length === 1 ? 'tool' : 'tools';
386
- const title = `Pruned ${newlyPrunedIds.length} ${toolText} from context`;
387
- let message = `~${estimatedTokensSaved.toLocaleString()} tokens saved\n`;
388
- for (const [toolName, params] of toolsSummary.entries()) {
389
- if (params.length > 0) {
390
- message += `\n${toolName} (${params.length}):\n`;
391
- for (const param of params) {
392
- message += ` ${param}\n`;
393
- }
394
- }
395
- else {
396
- // For tools with no specific params (like batch), just show the tool name and count
397
- const count = newlyPrunedIds.filter(id => {
398
- const m = toolMetadata.get(id);
399
- return m && m.tool === toolName;
400
- }).length;
401
- if (count > 0) {
402
- message += `\n${toolName} (${count})\n`;
403
- }
404
- }
405
- }
406
- await this.client.tui.showToast({
407
- body: {
408
- title: title,
409
- message: message.trim(),
410
- variant: "success",
411
- duration: 8000 // Longer duration since we're showing more info
412
- }
413
- });
414
- this.logger.info("janitor", "Toast notification shown", {
415
- sessionID,
416
- prunedCount: newlyPrunedIds.length,
417
- estimatedTokensSaved,
418
- totalCharsSaved,
419
- toolsSummary: Array.from(toolsSummary.entries())
420
- });
421
- }
422
- catch (toastError) {
423
- this.logger.error("janitor", "Failed to show toast notification", {
424
- sessionID,
425
- error: toastError.message
426
- });
427
- // Don't fail the whole pruning operation if toast fails
428
- }
429
- }
430
407
  }
431
408
  catch (error) {
432
409
  this.logger.error("janitor", "Analysis failed", {
@@ -438,5 +415,167 @@ export class Janitor {
438
415
  // Silently fail and try again on next idle event
439
416
  }
440
417
  }
418
+ /**
419
+ * Helper function to shorten paths for display
420
+ */
421
+ shortenPath(path) {
422
+ // Replace home directory with ~
423
+ const homeDir = require('os').homedir();
424
+ if (path.startsWith(homeDir)) {
425
+ path = '~' + path.slice(homeDir.length);
426
+ }
427
+ // Shorten node_modules paths: show package + file only
428
+ const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/);
429
+ if (nodeModulesMatch) {
430
+ return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
431
+ }
432
+ return path;
433
+ }
434
+ /**
435
+ * Helper function to calculate token savings from tool outputs
436
+ */
437
+ calculateTokensSaved(prunedIds, toolOutputs) {
438
+ const outputsToTokenize = [];
439
+ for (const prunedId of prunedIds) {
440
+ const output = toolOutputs.get(prunedId);
441
+ if (output) {
442
+ outputsToTokenize.push(output);
443
+ }
444
+ }
445
+ if (outputsToTokenize.length > 0) {
446
+ // Use batch tokenization for efficiency
447
+ const tokenCounts = estimateTokensBatch(outputsToTokenize, this.logger);
448
+ return tokenCounts.reduce((sum, count) => sum + count, 0);
449
+ }
450
+ return 0;
451
+ }
452
+ /**
453
+ * Build a summary of tools by grouping them
454
+ * Uses shared extractParameterKey logic for consistent parameter extraction
455
+ */
456
+ buildToolsSummary(prunedIds, toolMetadata) {
457
+ const toolsSummary = new Map();
458
+ // Helper function to truncate long strings
459
+ const truncate = (str, maxLen = 60) => {
460
+ if (str.length <= maxLen)
461
+ return str;
462
+ return str.slice(0, maxLen - 3) + '...';
463
+ };
464
+ for (const prunedId of prunedIds) {
465
+ const metadata = toolMetadata.get(prunedId);
466
+ if (metadata) {
467
+ const toolName = metadata.tool;
468
+ if (!toolsSummary.has(toolName)) {
469
+ toolsSummary.set(toolName, []);
470
+ }
471
+ // Use shared parameter extraction logic
472
+ const paramKey = extractParameterKey(metadata);
473
+ if (paramKey) {
474
+ // Apply path shortening and truncation for display
475
+ const displayKey = truncate(this.shortenPath(paramKey), 80);
476
+ toolsSummary.get(toolName).push(displayKey);
477
+ }
478
+ }
479
+ }
480
+ return toolsSummary;
481
+ }
482
+ /**
483
+ * Auto mode notification - shows only deduplication results
484
+ */
485
+ async sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, toolMetadata, toolOutputs) {
486
+ if (deduplicatedIds.length === 0)
487
+ return;
488
+ // Calculate token savings
489
+ const tokensSaved = this.calculateTokensSaved(deduplicatedIds, toolOutputs);
490
+ const tokensFormatted = formatTokenCount(tokensSaved);
491
+ const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools';
492
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)\n`;
493
+ // Group by tool type
494
+ const grouped = new Map();
495
+ for (const [_, details] of deduplicationDetails) {
496
+ const { toolName, parameterKey, duplicateCount } = details;
497
+ if (!grouped.has(toolName)) {
498
+ grouped.set(toolName, []);
499
+ }
500
+ grouped.get(toolName).push({
501
+ count: duplicateCount,
502
+ key: this.shortenPath(parameterKey)
503
+ });
504
+ }
505
+ // Display grouped results
506
+ for (const [toolName, items] of grouped.entries()) {
507
+ const totalDupes = items.reduce((sum, item) => sum + (item.count - 1), 0);
508
+ message += `\n${toolName} (${totalDupes} duplicate${totalDupes > 1 ? 's' : ''}):\n`;
509
+ for (const item of items.slice(0, 5)) {
510
+ const dupeCount = item.count - 1;
511
+ message += ` ${item.key} (${dupeCount}Ɨ duplicate)\n`;
512
+ }
513
+ if (items.length > 5) {
514
+ message += ` ... and ${items.length - 5} more\n`;
515
+ }
516
+ }
517
+ await this.sendIgnoredMessage(sessionID, message.trim());
518
+ }
519
+ /**
520
+ * Smart mode notification - shows both deduplication and LLM analysis results
521
+ */
522
+ async sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, toolOutputs) {
523
+ const totalPruned = deduplicatedIds.length + llmPrunedIds.length;
524
+ if (totalPruned === 0)
525
+ return;
526
+ // Calculate token savings
527
+ const allPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
528
+ const tokensSaved = this.calculateTokensSaved(allPrunedIds, toolOutputs);
529
+ const tokensFormatted = formatTokenCount(tokensSaved);
530
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)\n`;
531
+ // Section 1: Deduplicated tools
532
+ if (deduplicatedIds.length > 0 && deduplicationDetails) {
533
+ message += `\nšŸ“¦ Duplicates removed (${deduplicatedIds.length}):\n`;
534
+ // Group by tool type
535
+ const grouped = new Map();
536
+ for (const [_, details] of deduplicationDetails) {
537
+ const { toolName, parameterKey, duplicateCount } = details;
538
+ if (!grouped.has(toolName)) {
539
+ grouped.set(toolName, []);
540
+ }
541
+ grouped.get(toolName).push({
542
+ count: duplicateCount,
543
+ key: this.shortenPath(parameterKey)
544
+ });
545
+ }
546
+ for (const [toolName, items] of grouped.entries()) {
547
+ message += ` ${toolName}:\n`;
548
+ for (const item of items) {
549
+ const removedCount = item.count - 1; // Total occurrences minus the one we kept
550
+ message += ` ${item.key} (${removedCount}Ɨ duplicate)\n`;
551
+ }
552
+ }
553
+ }
554
+ // Section 2: LLM-pruned tools
555
+ if (llmPrunedIds.length > 0) {
556
+ message += `\nšŸ¤– LLM analysis (${llmPrunedIds.length}):\n`;
557
+ // Use buildToolsSummary logic
558
+ const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata);
559
+ for (const [toolName, params] of toolsSummary.entries()) {
560
+ if (params.length > 0) {
561
+ message += ` ${toolName} (${params.length}):\n`;
562
+ for (const param of params) {
563
+ message += ` ${param}\n`;
564
+ }
565
+ }
566
+ else {
567
+ // For tools with no specific params (like batch), just show the tool name and count
568
+ const count = llmPrunedIds.filter(id => {
569
+ const m = toolMetadata.get(id);
570
+ return m && m.tool === toolName;
571
+ }).length;
572
+ if (count > 0) {
573
+ message += ` ${toolName} (${count})\n`;
574
+ }
575
+ }
576
+ }
577
+ }
578
+ await this.sendIgnoredMessage(sessionID, message.trim());
579
+ }
441
580
  }
442
581
  //# sourceMappingURL=janitor.js.map