@tarquinen/opencode-dcp 0.2.8 → 0.3.1

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.
@@ -3,6 +3,7 @@ import { z } from "zod";
3
3
  import { buildAnalysisPrompt } from "./prompt";
4
4
  import { selectModel, extractModelFromSession } from "./model-selector";
5
5
  import { estimateTokensBatch, formatTokenCount } from "./tokenizer";
6
+ import { detectDuplicates, extractParameterKey } from "./deduplicator";
6
7
  export class Janitor {
7
8
  client;
8
9
  stateManager;
@@ -12,8 +13,10 @@ export class Janitor {
12
13
  modelCache;
13
14
  configModel;
14
15
  showModelErrorToasts;
16
+ pruningMode;
15
17
  constructor(client, stateManager, logger, toolParametersCache, protectedTools, modelCache, configModel, // Format: "provider/model"
16
- showModelErrorToasts = true // Whether to show toast for model errors
18
+ showModelErrorToasts = true, // Whether to show toast for model errors
19
+ pruningMode = "smart" // Pruning strategy
17
20
  ) {
18
21
  this.client = client;
19
22
  this.stateManager = stateManager;
@@ -23,6 +26,7 @@ export class Janitor {
23
26
  this.modelCache = modelCache;
24
27
  this.configModel = configModel;
25
28
  this.showModelErrorToasts = showModelErrorToasts;
29
+ this.pruningMode = pruningMode;
26
30
  }
27
31
  /**
28
32
  * Sends an ignored message to the session UI (user sees it, AI doesn't)
@@ -174,113 +178,186 @@ export class Janitor {
174
178
  alreadyPrunedIds: alreadyPrunedIds.slice(0, 5), // Show first 5 for brevity
175
179
  unprunedCount: unprunedToolCallIds.length
176
180
  });
177
- // Filter out protected tools from being considered for pruning
178
- const protectedToolCallIds = [];
179
- const prunableToolCallIds = unprunedToolCallIds.filter(id => {
180
- const metadata = toolMetadata.get(id);
181
- if (metadata && this.protectedTools.includes(metadata.tool)) {
182
- protectedToolCallIds.push(id);
183
- return false;
184
- }
185
- return true;
186
- });
187
- if (protectedToolCallIds.length > 0) {
188
- this.logger.debug("janitor", "Protected tools excluded from pruning", {
189
- sessionID,
190
- protectedCount: protectedToolCallIds.length,
191
- protectedTools: protectedToolCallIds.map(id => {
192
- const metadata = toolMetadata.get(id);
193
- return { id, tool: metadata?.tool };
194
- })
195
- });
196
- }
197
181
  // If there are no unpruned tool calls, skip analysis
198
- if (prunableToolCallIds.length === 0) {
199
- this.logger.debug("janitor", "No prunable tool calls found, skipping analysis", {
200
- sessionID,
201
- protectedCount: protectedToolCallIds.length
182
+ if (unprunedToolCallIds.length === 0) {
183
+ this.logger.debug("janitor", "No unpruned tool calls found, skipping analysis", {
184
+ sessionID
202
185
  });
203
186
  return;
204
187
  }
205
- // Select appropriate model with intelligent fallback
206
- // Try to get model from cache first, otherwise extractModelFromSession won't find it
207
- const cachedModelInfo = this.modelCache.get(sessionID);
208
- const sessionModelInfo = extractModelFromSession(sessionInfo, this.logger);
209
- const currentModelInfo = cachedModelInfo || sessionModelInfo;
210
- if (cachedModelInfo) {
211
- this.logger.debug("janitor", "Using cached model info", {
212
- sessionID,
213
- providerID: cachedModelInfo.providerID,
214
- modelID: cachedModelInfo.modelID
215
- });
216
- }
217
- const modelSelection = await selectModel(currentModelInfo, this.logger, this.configModel);
218
- 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", {
219
195
  sessionID,
220
- modelInfo: modelSelection.modelInfo,
221
- source: modelSelection.source,
222
- reason: modelSelection.reason
196
+ duplicatesFound: deduplicatedIds.length,
197
+ uniqueToolPatterns: deduplicationDetails.size
223
198
  });
224
- // Show toast if we had to fallback from a failed model
225
- if (modelSelection.failedModel && this.showModelErrorToasts) {
226
- try {
227
- await this.client.tui.showToast({
228
- body: {
229
- title: "DCP: Model fallback",
230
- message: `${modelSelection.failedModel.providerID}/${modelSelection.failedModel.modelID} failed\nUsing ${modelSelection.modelInfo.providerID}/${modelSelection.modelInfo.modelID}`,
231
- variant: "info",
232
- 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
+ });
233
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
+ // Replace already-pruned tool outputs to save tokens in janitor context
301
+ const allPrunedSoFar = [...alreadyPrunedIds, ...deduplicatedIds];
302
+ const sanitizedMessages = this.replacePrunedToolOutputs(messages, allPrunedSoFar);
303
+ this.logger.debug("janitor", "Sanitized messages for analysis", {
304
+ sessionID,
305
+ totalPrunedBeforeAnalysis: allPrunedSoFar.length,
306
+ prunedIds: allPrunedSoFar.slice(0, 5) // Show first 5
307
+ });
308
+ // Analyze which tool calls are obsolete
309
+ const result = await generateObject({
310
+ model: modelSelection.model,
311
+ schema: z.object({
312
+ pruned_tool_call_ids: z.array(z.string()),
313
+ reasoning: z.string(),
314
+ }),
315
+ prompt: buildAnalysisPrompt(prunableToolCallIds, sanitizedMessages, this.protectedTools)
234
316
  });
235
- this.logger.info("janitor", "Toast notification shown for model fallback", {
236
- failedModel: modelSelection.failedModel,
237
- selectedModel: modelSelection.modelInfo
317
+ // Filter LLM results to only include IDs that were actually candidates
318
+ // (LLM sometimes returns duplicate IDs that were already filtered out)
319
+ const rawLlmPrunedIds = result.object.pruned_tool_call_ids;
320
+ llmPrunedIds = rawLlmPrunedIds.filter(id => prunableToolCallIds.includes(id.toLowerCase()));
321
+ if (rawLlmPrunedIds.length !== llmPrunedIds.length) {
322
+ this.logger.warn("janitor", "LLM returned non-candidate IDs (filtered out)", {
323
+ sessionID,
324
+ rawCount: rawLlmPrunedIds.length,
325
+ filteredCount: llmPrunedIds.length,
326
+ invalidIds: rawLlmPrunedIds.filter(id => !prunableToolCallIds.includes(id.toLowerCase()))
327
+ });
328
+ }
329
+ this.logger.info("janitor", "LLM analysis complete", {
330
+ sessionID,
331
+ llmPrunedCount: llmPrunedIds.length,
332
+ reasoning: result.object.reasoning
238
333
  });
239
334
  }
240
- catch (toastError) {
241
- this.logger.error("janitor", "Failed to show toast notification", {
242
- error: toastError.message
335
+ else {
336
+ this.logger.info("janitor", "No prunable tools for LLM analysis", {
337
+ sessionID,
338
+ deduplicatedCount: deduplicatedIds.length,
339
+ protectedCount: protectedToolCallIds.length
243
340
  });
244
- // Don't fail the whole operation if toast fails
245
341
  }
246
342
  }
247
- else if (modelSelection.failedModel && !this.showModelErrorToasts) {
248
- this.logger.info("janitor", "Model fallback occurred but toast disabled by config", {
249
- failedModel: modelSelection.failedModel,
250
- selectedModel: modelSelection.modelInfo
343
+ else {
344
+ this.logger.info("janitor", "Skipping LLM analysis (auto mode)", {
345
+ sessionID,
346
+ deduplicatedCount: deduplicatedIds.length
251
347
  });
252
348
  }
253
- // Log comprehensive stats before AI call
254
- this.logger.info("janitor", "Preparing AI analysis", {
255
- sessionID,
256
- totalToolCallsInSession: toolCallIds.length,
257
- alreadyPrunedCount: alreadyPrunedIds.length,
258
- protectedToolsCount: protectedToolCallIds.length,
259
- candidatesForPruning: prunableToolCallIds.length,
260
- candidateTools: prunableToolCallIds.map(id => {
261
- const meta = toolMetadata.get(id);
262
- return meta ? `${meta.tool}[${id.substring(0, 12)}...]` : id.substring(0, 12) + '...';
263
- }).slice(0, 10), // Show first 10 for brevity
264
- batchToolCount: batchToolChildren.size,
265
- batchDetails: Array.from(batchToolChildren.entries()).map(([batchId, children]) => ({
266
- batchId: batchId.substring(0, 20) + '...',
267
- childCount: children.length
268
- }))
269
- });
270
- this.logger.debug("janitor", "Starting shadow inference", { sessionID });
271
- // Analyze which tool calls are obsolete
272
- const result = await generateObject({
273
- model: modelSelection.model,
274
- schema: z.object({
275
- pruned_tool_call_ids: z.array(z.string()),
276
- reasoning: z.string(),
277
- }),
278
- prompt: buildAnalysisPrompt(prunableToolCallIds, messages, this.protectedTools)
279
- });
349
+ // If mode is "auto", llmPrunedIds stays empty
350
+ // ============================================================
351
+ // PHASE 3: COMBINE & EXPAND
352
+ // ============================================================
353
+ const newlyPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
354
+ if (newlyPrunedIds.length === 0) {
355
+ this.logger.info("janitor", "No tools to prune", { sessionID });
356
+ return;
357
+ }
280
358
  // Expand batch tool IDs to include their children
281
- // Note: IDs are already normalized to lowercase when collected from messages
282
359
  const expandedPrunedIds = new Set();
283
- for (const prunedId of result.object.pruned_tool_call_ids) {
360
+ for (const prunedId of newlyPrunedIds) {
284
361
  const normalizedId = prunedId.toLowerCase();
285
362
  expandedPrunedIds.add(normalizedId);
286
363
  // If this is a batch tool, add all its children
@@ -296,15 +373,15 @@ export class Janitor {
296
373
  }
297
374
  }
298
375
  // Calculate which IDs are actually NEW (not already pruned)
299
- const newlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
376
+ const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id));
300
377
  // finalPrunedIds includes everything (new + already pruned) for logging
301
378
  const finalPrunedIds = Array.from(expandedPrunedIds);
302
379
  this.logger.info("janitor", "Analysis complete", {
303
380
  sessionID,
304
381
  prunedCount: finalPrunedIds.length,
305
- originalPrunedCount: result.object.pruned_tool_call_ids.length,
306
- prunedIds: finalPrunedIds,
307
- reasoning: result.object.reasoning
382
+ deduplicatedCount: deduplicatedIds.length,
383
+ llmPrunedCount: llmPrunedIds.length,
384
+ prunedIds: finalPrunedIds
308
385
  });
309
386
  this.logger.debug("janitor", "Pruning ID details", {
310
387
  sessionID,
@@ -312,158 +389,29 @@ export class Janitor {
312
389
  alreadyPrunedIds: alreadyPrunedIds,
313
390
  finalPrunedCount: finalPrunedIds.length,
314
391
  finalPrunedIds: finalPrunedIds,
315
- newlyPrunedCount: newlyPrunedIds.length,
316
- newlyPrunedIds: newlyPrunedIds
392
+ newlyPrunedCount: finalNewlyPrunedIds.length,
393
+ newlyPrunedIds: finalNewlyPrunedIds
317
394
  });
318
- // Calculate token savings from newly pruned tool outputs
319
- // Use accurate tokenization with gpt-tokenizer
320
- let totalTokensSaved = 0;
321
- const outputsToTokenize = [];
322
- for (const prunedId of newlyPrunedIds) {
323
- const output = toolOutputs.get(prunedId);
324
- if (output) {
325
- outputsToTokenize.push(output);
326
- }
395
+ // ============================================================
396
+ // PHASE 4: NOTIFICATION
397
+ // ============================================================
398
+ if (this.pruningMode === "auto") {
399
+ await this.sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, toolOutputs);
327
400
  }
328
- if (outputsToTokenize.length > 0) {
329
- // Use batch tokenization for efficiency
330
- const tokenCounts = estimateTokensBatch(outputsToTokenize, this.logger);
331
- totalTokensSaved = tokenCounts.reduce((sum, count) => sum + count, 0);
332
- this.logger.debug("janitor", "Token estimation complete", {
333
- sessionID,
334
- outputCount: outputsToTokenize.length,
335
- totalTokens: totalTokensSaved,
336
- avgTokensPerOutput: Math.round(totalTokensSaved / outputsToTokenize.length)
337
- });
401
+ else {
402
+ await this.sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, toolOutputs);
338
403
  }
339
- const estimatedTokensSaved = totalTokensSaved;
404
+ // ============================================================
405
+ // PHASE 5: STATE UPDATE
406
+ // ============================================================
340
407
  // Merge newly pruned IDs with existing ones (using expanded IDs)
341
408
  const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])];
342
409
  await this.stateManager.set(sessionID, allPrunedIds);
343
410
  this.logger.debug("janitor", "Updated state manager", {
344
411
  sessionID,
345
412
  totalPrunedCount: allPrunedIds.length,
346
- newlyPrunedCount: newlyPrunedIds.length
413
+ newlyPrunedCount: finalNewlyPrunedIds.length
347
414
  });
348
- // Show toast notification if we pruned anything NEW
349
- if (newlyPrunedIds.length > 0) {
350
- try {
351
- // Helper function to shorten paths for display
352
- const shortenPath = (path) => {
353
- // Replace home directory with ~
354
- const homeDir = require('os').homedir();
355
- if (path.startsWith(homeDir)) {
356
- path = '~' + path.slice(homeDir.length);
357
- }
358
- // Shorten node_modules paths: show package + file only
359
- const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/);
360
- if (nodeModulesMatch) {
361
- return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
362
- }
363
- return path;
364
- };
365
- // Helper function to truncate long strings
366
- const truncate = (str, maxLen = 60) => {
367
- if (str.length <= maxLen)
368
- return str;
369
- return str.slice(0, maxLen - 3) + '...';
370
- };
371
- // Build a summary of pruned tools by grouping them
372
- const toolsSummary = new Map(); // tool name -> [parameters]
373
- for (const prunedId of newlyPrunedIds) {
374
- const metadata = toolMetadata.get(prunedId);
375
- if (metadata) {
376
- const toolName = metadata.tool;
377
- if (!toolsSummary.has(toolName)) {
378
- toolsSummary.set(toolName, []);
379
- }
380
- this.logger.debug("janitor", "Processing pruned tool metadata", {
381
- sessionID,
382
- prunedId,
383
- toolName,
384
- parameters: metadata.parameters
385
- });
386
- // Extract meaningful parameter info based on tool type
387
- let paramInfo = "";
388
- if (metadata.parameters) {
389
- // For read tool, show filePath
390
- if (toolName === "read" && metadata.parameters.filePath) {
391
- paramInfo = truncate(shortenPath(metadata.parameters.filePath), 80);
392
- }
393
- // For list tool, show path
394
- else if (toolName === "list" && metadata.parameters.path) {
395
- paramInfo = truncate(shortenPath(metadata.parameters.path), 80);
396
- }
397
- // For bash/command tools, prefer description over command
398
- else if (toolName === "bash") {
399
- if (metadata.parameters.description) {
400
- paramInfo = truncate(metadata.parameters.description, 80);
401
- }
402
- else if (metadata.parameters.command) {
403
- paramInfo = truncate(metadata.parameters.command, 80);
404
- }
405
- }
406
- // For other tools, show the first relevant parameter
407
- else if (metadata.parameters.path) {
408
- paramInfo = truncate(shortenPath(metadata.parameters.path), 80);
409
- }
410
- else if (metadata.parameters.pattern) {
411
- paramInfo = truncate(metadata.parameters.pattern, 80);
412
- }
413
- else if (metadata.parameters.command) {
414
- paramInfo = truncate(metadata.parameters.command, 80);
415
- }
416
- }
417
- if (paramInfo) {
418
- toolsSummary.get(toolName).push(paramInfo);
419
- }
420
- }
421
- else {
422
- this.logger.warn("janitor", "No metadata found for pruned tool", {
423
- sessionID,
424
- prunedId
425
- });
426
- }
427
- }
428
- // Format the message with tool details using improved UI
429
- const toolText = newlyPrunedIds.length === 1 ? 'tool' : 'tools';
430
- const tokensFormatted = formatTokenCount(estimatedTokensSaved);
431
- let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${newlyPrunedIds.length} ${toolText} pruned)\n`;
432
- for (const [toolName, params] of toolsSummary.entries()) {
433
- if (params.length > 0) {
434
- message += `\n${toolName} (${params.length}):\n`;
435
- for (const param of params) {
436
- message += ` ${param}\n`;
437
- }
438
- }
439
- else {
440
- // For tools with no specific params (like batch), just show the tool name and count
441
- const count = newlyPrunedIds.filter(id => {
442
- const m = toolMetadata.get(id);
443
- return m && m.tool === toolName;
444
- }).length;
445
- if (count > 0) {
446
- message += `\n${toolName} (${count})\n`;
447
- }
448
- }
449
- }
450
- // Send as an ignored message (user sees, AI doesn't)
451
- await this.sendIgnoredMessage(sessionID, message.trim());
452
- this.logger.info("janitor", "Pruning notification sent", {
453
- sessionID,
454
- prunedCount: newlyPrunedIds.length,
455
- estimatedTokensSaved,
456
- toolsSummary: Array.from(toolsSummary.entries())
457
- });
458
- }
459
- catch (toastError) {
460
- this.logger.error("janitor", "Failed to show toast notification", {
461
- sessionID,
462
- error: toastError.message
463
- });
464
- // Don't fail the whole pruning operation if toast fails
465
- }
466
- }
467
415
  }
468
416
  catch (error) {
469
417
  this.logger.error("janitor", "Analysis failed", {
@@ -475,5 +423,200 @@ export class Janitor {
475
423
  // Silently fail and try again on next idle event
476
424
  }
477
425
  }
426
+ /**
427
+ * Helper function to shorten paths for display
428
+ */
429
+ shortenPath(path) {
430
+ // Replace home directory with ~
431
+ const homeDir = require('os').homedir();
432
+ if (path.startsWith(homeDir)) {
433
+ path = '~' + path.slice(homeDir.length);
434
+ }
435
+ // Shorten node_modules paths: show package + file only
436
+ const nodeModulesMatch = path.match(/node_modules\/(@[^\/]+\/[^\/]+|[^\/]+)\/(.*)/);
437
+ if (nodeModulesMatch) {
438
+ return `${nodeModulesMatch[1]}/${nodeModulesMatch[2]}`;
439
+ }
440
+ return path;
441
+ }
442
+ /**
443
+ * Replace pruned tool outputs with placeholder text to save tokens in janitor context
444
+ * This applies the same replacement logic as the global fetch wrapper, but for the
445
+ * janitor's shadow inference to avoid sending already-pruned content to the LLM
446
+ */
447
+ replacePrunedToolOutputs(messages, prunedIds) {
448
+ if (prunedIds.length === 0)
449
+ return messages;
450
+ const prunedIdsSet = new Set(prunedIds.map(id => id.toLowerCase()));
451
+ return messages.map(msg => {
452
+ if (!msg.parts)
453
+ return msg;
454
+ return {
455
+ ...msg,
456
+ parts: msg.parts.map((part) => {
457
+ if (part.type === 'tool' &&
458
+ part.callID &&
459
+ prunedIdsSet.has(part.callID.toLowerCase()) &&
460
+ part.state?.output) {
461
+ // Replace with the same placeholder used by the global fetch wrapper
462
+ return {
463
+ ...part,
464
+ state: {
465
+ ...part.state,
466
+ output: '[Output removed to save context - information superseded or no longer needed]'
467
+ }
468
+ };
469
+ }
470
+ return part;
471
+ })
472
+ };
473
+ });
474
+ }
475
+ /**
476
+ * Helper function to calculate token savings from tool outputs
477
+ */
478
+ calculateTokensSaved(prunedIds, toolOutputs) {
479
+ const outputsToTokenize = [];
480
+ for (const prunedId of prunedIds) {
481
+ const output = toolOutputs.get(prunedId);
482
+ if (output) {
483
+ outputsToTokenize.push(output);
484
+ }
485
+ }
486
+ if (outputsToTokenize.length > 0) {
487
+ // Use batch tokenization for efficiency
488
+ const tokenCounts = estimateTokensBatch(outputsToTokenize, this.logger);
489
+ return tokenCounts.reduce((sum, count) => sum + count, 0);
490
+ }
491
+ return 0;
492
+ }
493
+ /**
494
+ * Build a summary of tools by grouping them
495
+ * Uses shared extractParameterKey logic for consistent parameter extraction
496
+ */
497
+ buildToolsSummary(prunedIds, toolMetadata) {
498
+ const toolsSummary = new Map();
499
+ // Helper function to truncate long strings
500
+ const truncate = (str, maxLen = 60) => {
501
+ if (str.length <= maxLen)
502
+ return str;
503
+ return str.slice(0, maxLen - 3) + '...';
504
+ };
505
+ for (const prunedId of prunedIds) {
506
+ const metadata = toolMetadata.get(prunedId);
507
+ if (metadata) {
508
+ const toolName = metadata.tool;
509
+ if (!toolsSummary.has(toolName)) {
510
+ toolsSummary.set(toolName, []);
511
+ }
512
+ // Use shared parameter extraction logic
513
+ const paramKey = extractParameterKey(metadata);
514
+ if (paramKey) {
515
+ // Apply path shortening and truncation for display
516
+ const displayKey = truncate(this.shortenPath(paramKey), 80);
517
+ toolsSummary.get(toolName).push(displayKey);
518
+ }
519
+ }
520
+ }
521
+ return toolsSummary;
522
+ }
523
+ /**
524
+ * Auto mode notification - shows only deduplication results
525
+ */
526
+ async sendAutoModeNotification(sessionID, deduplicatedIds, deduplicationDetails, toolOutputs) {
527
+ if (deduplicatedIds.length === 0)
528
+ return;
529
+ // Calculate token savings
530
+ const tokensSaved = this.calculateTokensSaved(deduplicatedIds, toolOutputs);
531
+ const tokensFormatted = formatTokenCount(tokensSaved);
532
+ const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools';
533
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)\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
+ // Display grouped results
547
+ for (const [toolName, items] of grouped.entries()) {
548
+ const totalDupes = items.reduce((sum, item) => sum + (item.count - 1), 0);
549
+ message += `\n${toolName} (${totalDupes} duplicate${totalDupes > 1 ? 's' : ''}):\n`;
550
+ for (const item of items.slice(0, 5)) {
551
+ const dupeCount = item.count - 1;
552
+ message += ` ${item.key} (${dupeCount}Ɨ duplicate)\n`;
553
+ }
554
+ if (items.length > 5) {
555
+ message += ` ... and ${items.length - 5} more\n`;
556
+ }
557
+ }
558
+ await this.sendIgnoredMessage(sessionID, message.trim());
559
+ }
560
+ /**
561
+ * Smart mode notification - shows both deduplication and LLM analysis results
562
+ */
563
+ async sendSmartModeNotification(sessionID, deduplicatedIds, deduplicationDetails, llmPrunedIds, toolMetadata, toolOutputs) {
564
+ const totalPruned = deduplicatedIds.length + llmPrunedIds.length;
565
+ if (totalPruned === 0)
566
+ return;
567
+ // Calculate token savings
568
+ const allPrunedIds = [...deduplicatedIds, ...llmPrunedIds];
569
+ const tokensSaved = this.calculateTokensSaved(allPrunedIds, toolOutputs);
570
+ const tokensFormatted = formatTokenCount(tokensSaved);
571
+ let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)\n`;
572
+ // Section 1: Deduplicated tools
573
+ if (deduplicatedIds.length > 0 && deduplicationDetails) {
574
+ message += `\nšŸ“¦ Duplicates removed (${deduplicatedIds.length}):\n`;
575
+ // Group by tool type
576
+ const grouped = new Map();
577
+ for (const [_, details] of deduplicationDetails) {
578
+ const { toolName, parameterKey, duplicateCount } = details;
579
+ if (!grouped.has(toolName)) {
580
+ grouped.set(toolName, []);
581
+ }
582
+ grouped.get(toolName).push({
583
+ count: duplicateCount,
584
+ key: this.shortenPath(parameterKey)
585
+ });
586
+ }
587
+ for (const [toolName, items] of grouped.entries()) {
588
+ message += ` ${toolName}:\n`;
589
+ for (const item of items) {
590
+ const removedCount = item.count - 1; // Total occurrences minus the one we kept
591
+ message += ` ${item.key} (${removedCount}Ɨ duplicate)\n`;
592
+ }
593
+ }
594
+ }
595
+ // Section 2: LLM-pruned tools
596
+ if (llmPrunedIds.length > 0) {
597
+ message += `\nšŸ¤– LLM analysis (${llmPrunedIds.length}):\n`;
598
+ // Use buildToolsSummary logic
599
+ const toolsSummary = this.buildToolsSummary(llmPrunedIds, toolMetadata);
600
+ for (const [toolName, params] of toolsSummary.entries()) {
601
+ if (params.length > 0) {
602
+ message += ` ${toolName} (${params.length}):\n`;
603
+ for (const param of params) {
604
+ message += ` ${param}\n`;
605
+ }
606
+ }
607
+ else {
608
+ // For tools with no specific params (like batch), just show the tool name and count
609
+ const count = llmPrunedIds.filter(id => {
610
+ const m = toolMetadata.get(id);
611
+ return m && m.tool === toolName;
612
+ }).length;
613
+ if (count > 0) {
614
+ message += ` ${toolName} (${count})\n`;
615
+ }
616
+ }
617
+ }
618
+ }
619
+ await this.sendIgnoredMessage(sessionID, message.trim());
620
+ }
478
621
  }
479
622
  //# sourceMappingURL=janitor.js.map