Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -27,3 +27,6 @@ Thumbs.db

# OpenCode
.opencode/

# Tests (local development only)
tests/
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -78,7 +78,7 @@ If you want to ensure a specific version is always used or update your version,
```json
{
"plugin": [
"@tarquinen/[email protected].10"
"@tarquinen/[email protected].12"
]
}
```
Expand Down
4 changes: 4 additions & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import type { Plugin } from "@opencode-ai/plugin"
import { getConfig } from "./lib/config"
import { Logger } from "./lib/logger"
import { Janitor, type SessionStats } from "./lib/janitor"
import { checkForUpdates } from "./lib/version-checker"

/**
* Checks if a session is a subagent (child session)
Expand Down Expand Up @@ -145,6 +146,9 @@ const plugin: Plugin = (async (ctx) => {
model: config.model || "auto"
})

// Check for updates on launch (fire and forget)
checkForUpdates(ctx.client).catch(() => {})

return {
/**
* Event Hook: Triggers janitor analysis when session becomes idle
Expand Down
38 changes: 19 additions & 19 deletions lib/deduplicator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,35 +27,35 @@ export function detectDuplicates(
protectedTools: string[]
): DuplicateDetectionResult {
const signatureMap = new Map<string, string[]>()

// Filter out protected tools before processing
const deduplicatableIds = unprunedToolCallIds.filter(id => {
const metadata = toolMetadata.get(id)
return !metadata || !protectedTools.includes(metadata.tool)
})

// Build map of signature -> [ids in chronological order]
for (const id of deduplicatableIds) {
const metadata = toolMetadata.get(id)
if (!metadata) continue

const signature = createToolSignature(metadata.tool, metadata.parameters)
if (!signatureMap.has(signature)) {
signatureMap.set(signature, [])
}
signatureMap.get(signature)!.push(id)
}

// Identify duplicates (keep only last occurrence)
const duplicateIds: string[] = []
const deduplicationDetails = new Map()

for (const [signature, ids] of signatureMap.entries()) {
if (ids.length > 1) {
const metadata = toolMetadata.get(ids[0])!
const idsToRemove = ids.slice(0, -1) // All except last
duplicateIds.push(...idsToRemove)

deduplicationDetails.set(signature, {
toolName: metadata.tool,
parameterKey: extractParameterKey(metadata),
Expand All @@ -65,7 +65,7 @@ export function detectDuplicates(
})
}
}

return { duplicateIds, deduplicationDetails }
}

Expand All @@ -75,7 +75,7 @@ export function detectDuplicates(
*/
function createToolSignature(tool: string, parameters?: any): string {
if (!parameters) return tool

// Normalize parameters for consistent comparison
const normalized = normalizeParameters(parameters)
const sorted = sortObjectKeys(normalized)
Expand All @@ -91,7 +91,7 @@ function createToolSignature(tool: string, parameters?: any): string {
function normalizeParameters(params: any): any {
if (typeof params !== 'object' || params === null) return params
if (Array.isArray(params)) return params

const normalized: any = {}
for (const [key, value] of Object.entries(params)) {
if (value !== undefined && value !== null) {
Expand All @@ -107,7 +107,7 @@ function normalizeParameters(params: any): any {
function sortObjectKeys(obj: any): any {
if (typeof obj !== 'object' || obj === null) return obj
if (Array.isArray(obj)) return obj.map(sortObjectKeys)

const sorted: any = {}
for (const key of Object.keys(obj).sort()) {
sorted[key] = sortObjectKeys(obj[key])
Expand Down Expand Up @@ -136,9 +136,9 @@ function sortObjectKeys(obj: any): any {
*/
export function extractParameterKey(metadata: { tool: string, parameters?: any }): string {
if (!metadata.parameters) return ''

const { tool, parameters } = metadata

// ===== File Operation Tools =====
if (tool === "read" && parameters.filePath) {
return parameters.filePath
Expand All @@ -149,7 +149,7 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any }
if (tool === "edit" && parameters.filePath) {
return parameters.filePath
}

// ===== Directory/Search Tools =====
if (tool === "list") {
// path is optional, defaults to current directory
Expand All @@ -170,17 +170,17 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any }
}
return '(unknown pattern)'
}

// ===== Execution Tools =====
if (tool === "bash") {
if (parameters.description) return parameters.description
if (parameters.command) {
return parameters.command.length > 50
return parameters.command.length > 50
? parameters.command.substring(0, 50) + "..."
: parameters.command
}
}

// ===== Web Tools =====
if (tool === "webfetch" && parameters.url) {
return parameters.url
Expand All @@ -191,7 +191,7 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any }
if (tool === "codesearch" && parameters.query) {
return `"${parameters.query}"`
}

// ===== Todo Tools =====
// Note: Todo tools are stateful and in protectedTools by default
if (tool === "todowrite") {
Expand All @@ -200,7 +200,7 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any }
if (tool === "todoread") {
return "read todo list"
}

// ===== Agent/Task Tools =====
// Note: task is in protectedTools by default
if (tool === "task" && parameters.description) {
Expand All @@ -210,7 +210,7 @@ export function extractParameterKey(metadata: { tool: string, parameters?: any }
if (tool === "batch") {
return `${parameters.tool_calls?.length || 0} parallel tools`
}

// ===== Fallback =====
// For unknown tools, custom tools, or tools without extractable keys
// Check if parameters is empty or only has empty values
Expand Down
42 changes: 21 additions & 21 deletions lib/janitor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,7 +228,7 @@ export class Janitor {
// Filter LLM results to only include IDs that were actually candidates
// (LLM sometimes returns duplicate IDs that were already filtered out)
const rawLlmPrunedIds = result.object.pruned_tool_call_ids
llmPrunedIds = rawLlmPrunedIds.filter(id =>
llmPrunedIds = rawLlmPrunedIds.filter(id =>
prunableToolCallIds.includes(id.toLowerCase())
)

Expand Down Expand Up @@ -263,7 +263,7 @@ export class Janitor {

// Calculate which IDs are actually NEW (not already pruned)
const finalNewlyPrunedIds = Array.from(expandedPrunedIds).filter(id => !alreadyPrunedIds.includes(id))

// finalPrunedIds includes everything (new + already pruned) for logging
const finalPrunedIds = Array.from(expandedPrunedIds)

Expand Down Expand Up @@ -338,16 +338,16 @@ export class Janitor {
const shortenedPath = this.shortenSinglePath(pathPart)
return `${prefix} in ${shortenedPath}`
}

return this.shortenSinglePath(input)
}

/**
* Shorten a single path string
*/
private shortenSinglePath(path: string): string {
const homeDir = require('os').homedir()

// Strip working directory FIRST (before ~ replacement) for cleaner relative paths
if (this.workingDirectory) {
if (path.startsWith(this.workingDirectory + '/')) {
Expand All @@ -358,7 +358,7 @@ export class Janitor {
return '.'
}
}

// Replace home directory with ~
if (path.startsWith(homeDir)) {
path = '~' + path.slice(homeDir.length)
Expand All @@ -375,7 +375,7 @@ export class Janitor {
const workingDirWithTilde = this.workingDirectory.startsWith(homeDir)
? '~' + this.workingDirectory.slice(homeDir.length)
: null

if (workingDirWithTilde && path.startsWith(workingDirWithTilde + '/')) {
return path.slice(workingDirWithTilde.length + 1)
}
Expand All @@ -396,15 +396,15 @@ export class Janitor {
if (prunedIds.length === 0) return messages

const prunedIdsSet = new Set(prunedIds.map(id => id.toLowerCase()))

return messages.map(msg => {
if (!msg.parts) return msg

return {
...msg,
parts: msg.parts.map((part: any) => {
if (part.type === 'tool' &&
part.callID &&
if (part.type === 'tool' &&
part.callID &&
prunedIdsSet.has(part.callID.toLowerCase()) &&
part.state?.output) {
// Replace with the same placeholder used by the global fetch wrapper
Expand All @@ -427,20 +427,20 @@ export class Janitor {
*/
private async calculateTokensSaved(prunedIds: string[], toolOutputs: Map<string, string>): Promise<number> {
const outputsToTokenize: string[] = []

for (const prunedId of prunedIds) {
const output = toolOutputs.get(prunedId)
if (output) {
outputsToTokenize.push(output)
}
}

if (outputsToTokenize.length > 0) {
// Use batch tokenization for efficiency (lazy loads gpt-tokenizer)
const tokenCounts = await estimateTokensBatch(outputsToTokenize)
return tokenCounts.reduce((sum, count) => sum + count, 0)
}

return 0
}

Expand Down Expand Up @@ -501,7 +501,7 @@ export class Janitor {
const toolText = totalPruned === 1 ? 'tool' : 'tools'

let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} ${toolText} pruned)`

// Add session totals if there's been more than one pruning run
if (sessionStats.totalToolsPruned > totalPruned) {
message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`
Expand Down Expand Up @@ -536,15 +536,15 @@ export class Janitor {

const toolText = deduplicatedIds.length === 1 ? 'tool' : 'tools'
let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${deduplicatedIds.length} duplicate ${toolText} removed)`

// Add session totals if there's been more than one pruning run
if (sessionStats.totalToolsPruned > deduplicatedIds.length) {
message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`
}
message += '\n'

// Group by tool type
const grouped = new Map<string, Array<{count: number, key: string}>>()
const grouped = new Map<string, Array<{ count: number, key: string }>>()

for (const [_, details] of deduplicationDetails) {
const { toolName, parameterKey, duplicateCount } = details
Expand Down Expand Up @@ -603,7 +603,7 @@ export class Janitor {
const tokensFormatted = formatTokenCount(tokensSaved)

let message = `🧹 DCP: Saved ~${tokensFormatted} tokens (${totalPruned} tool${totalPruned > 1 ? 's' : ''} pruned)`

// Add session totals if there's been more than one pruning run
if (sessionStats.totalToolsPruned > totalPruned) {
message += ` │ Session: ~${formatTokenCount(sessionStats.totalTokensSaved)} tokens, ${sessionStats.totalToolsPruned} tools`
Expand All @@ -615,7 +615,7 @@ export class Janitor {
message += `\n📦 Duplicates removed (${deduplicatedIds.length}):\n`

// Group by tool type
const grouped = new Map<string, Array<{count: number, key: string}>>()
const grouped = new Map<string, Array<{ count: number, key: string }>>()

for (const [_, details] of deduplicationDetails) {
const { toolName, parameterKey, duplicateCount } = details
Expand Down Expand Up @@ -652,15 +652,15 @@ export class Janitor {
}
}
}

// Handle any tools that weren't found in metadata (edge case)
const foundToolNames = new Set(toolsSummary.keys())
const missingTools = llmPrunedIds.filter(id => {
const normalizedId = id.toLowerCase()
const metadata = toolMetadata.get(normalizedId)
return !metadata || !foundToolNames.has(metadata.tool)
})

if (missingTools.length > 0) {
message += ` (${missingTools.length} tool${missingTools.length > 1 ? 's' : ''} with unknown metadata)\n`
}
Expand Down
Loading