@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.
- package/README.md +16 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +6 -43
- package/dist/index.js.map +1 -1
- package/dist/lib/config.d.ts +1 -15
- package/dist/lib/config.d.ts.map +1 -1
- package/dist/lib/config.js +22 -95
- package/dist/lib/config.js.map +1 -1
- package/dist/lib/deduplicator.d.ts +0 -34
- package/dist/lib/deduplicator.d.ts.map +1 -1
- package/dist/lib/deduplicator.js +1 -127
- package/dist/lib/deduplicator.js.map +1 -1
- package/dist/lib/display-utils.d.ts +9 -0
- package/dist/lib/display-utils.d.ts.map +1 -0
- package/dist/lib/display-utils.js +71 -0
- package/dist/lib/display-utils.js.map +1 -0
- package/dist/lib/janitor.d.ts +2 -61
- package/dist/lib/janitor.d.ts.map +1 -1
- package/dist/lib/janitor.js +53 -195
- package/dist/lib/janitor.js.map +1 -1
- package/dist/lib/logger.d.ts +0 -24
- package/dist/lib/logger.d.ts.map +1 -1
- package/dist/lib/logger.js +3 -58
- package/dist/lib/logger.js.map +1 -1
- package/dist/lib/model-selector.d.ts +0 -31
- package/dist/lib/model-selector.d.ts.map +1 -1
- package/dist/lib/model-selector.js +0 -55
- package/dist/lib/model-selector.js.map +1 -1
- package/dist/lib/prompt.d.ts.map +1 -1
- package/dist/lib/prompt.js +3 -29
- package/dist/lib/prompt.js.map +1 -1
- package/dist/lib/tokenizer.d.ts +0 -23
- package/dist/lib/tokenizer.d.ts.map +1 -1
- package/dist/lib/tokenizer.js +0 -25
- package/dist/lib/tokenizer.js.map +1 -1
- package/dist/lib/version-checker.d.ts +0 -15
- package/dist/lib/version-checker.d.ts.map +1 -1
- package/dist/lib/version-checker.js +1 -19
- package/dist/lib/version-checker.js.map +1 -1
- package/package.json +1 -1
package/dist/lib/janitor.js
CHANGED
|
@@ -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
|
|
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,
|
|
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,
|
|
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();
|
|
99
|
-
const batchToolChildren = new Map();
|
|
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
|
|
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:
|
|
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
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
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;
|
|
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) {
|