@tarquinen/opencode-dcp 0.3.16 โ†’ 0.3.18

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