@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.
- package/README.md +73 -49
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +79 -15
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +1 -0
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +19 -7
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/deduplicator.d.ts +50 -0
- package/dist/lib/deduplicator.d.ts.map +1 -0
- package/dist/lib/deduplicator.js +181 -0
- package/dist/lib/deduplicator.js.map +1 -0
- package/dist/lib/janitor.d.ts +30 -1
- package/dist/lib/janitor.d.ts.map +1 -1
- package/dist/lib/janitor.js +380 -237
- package/dist/lib/janitor.js.map +1 -1
- package/dist/lib/logger.d.ts +8 -1
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +43 -1
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/prompt.d.ts.map +1 -1
- package/dist/lib/prompt.js +77 -5
- package/dist/lib/prompt.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/janitor.js
CHANGED
|
@@ -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 (
|
|
199
|
-
this.logger.debug("janitor", "No
|
|
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
|
-
//
|
|
206
|
-
//
|
|
207
|
-
|
|
208
|
-
const
|
|
209
|
-
const
|
|
210
|
-
|
|
211
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
reason: modelSelection.reason
|
|
196
|
+
duplicatesFound: deduplicatedIds.length,
|
|
197
|
+
uniqueToolPatterns: deduplicationDetails.size
|
|
223
198
|
});
|
|
224
|
-
//
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
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
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|
-
|
|
241
|
-
this.logger.
|
|
242
|
-
|
|
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
|
|
248
|
-
this.logger.info("janitor", "
|
|
249
|
-
|
|
250
|
-
|
|
343
|
+
else {
|
|
344
|
+
this.logger.info("janitor", "Skipping LLM analysis (auto mode)", {
|
|
345
|
+
sessionID,
|
|
346
|
+
deduplicatedCount: deduplicatedIds.length
|
|
251
347
|
});
|
|
252
348
|
}
|
|
253
|
-
//
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
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
|
|
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
|
|
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
|
-
|
|
306
|
-
|
|
307
|
-
|
|
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:
|
|
316
|
-
newlyPrunedIds:
|
|
392
|
+
newlyPrunedCount: finalNewlyPrunedIds.length,
|
|
393
|
+
newlyPrunedIds: finalNewlyPrunedIds
|
|
317
394
|
});
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
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
|
-
|
|
329
|
-
|
|
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
|
-
|
|
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:
|
|
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
|