feat(compaction): add dynamic context pruning as recovery stage
Implements DCP-style pruning strategies inspired by opencode-dynamic-context-pruning plugin: - Deduplication: removes duplicate tool calls (same tool + args) - Supersede writes: prunes write inputs when file subsequently read - Purge errors: removes old error tool inputs after N turns Integration: - Added as Stage 2.5 in compaction pipeline (after truncation, before summarize) - Configurable via experimental.dynamic_context_pruning - Opt-in by default (experimental feature) - Protected tools list prevents pruning critical tools Configuration: - Turn protection (default: 3 turns) - Per-strategy enable/disable - Aggressive/conservative modes for supersede writes - Configurable error purge threshold (default: 5 turns) - Toast notifications (off/minimal/detailed) Testing: - Added unit tests for deduplication signature creation - Type check passes - Schema regenerated Closes #271
This commit is contained in:
@@ -1383,6 +1383,97 @@
|
|||||||
"truncate_all_tool_outputs": {
|
"truncate_all_tool_outputs": {
|
||||||
"default": true,
|
"default": true,
|
||||||
"type": "boolean"
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"dynamic_context_pruning": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"notification": {
|
||||||
|
"default": "detailed",
|
||||||
|
"type": "string",
|
||||||
|
"enum": [
|
||||||
|
"off",
|
||||||
|
"minimal",
|
||||||
|
"detailed"
|
||||||
|
]
|
||||||
|
},
|
||||||
|
"turn_protection": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"turns": {
|
||||||
|
"default": 3,
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"protected_tools": {
|
||||||
|
"default": [
|
||||||
|
"task",
|
||||||
|
"todowrite",
|
||||||
|
"todoread",
|
||||||
|
"lsp_rename",
|
||||||
|
"lsp_code_action_resolve",
|
||||||
|
"session_read",
|
||||||
|
"session_write",
|
||||||
|
"session_search"
|
||||||
|
],
|
||||||
|
"type": "array",
|
||||||
|
"items": {
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"strategies": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"deduplication": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"supersede_writes": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"aggressive": {
|
||||||
|
"default": false,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"purge_errors": {
|
||||||
|
"type": "object",
|
||||||
|
"properties": {
|
||||||
|
"enabled": {
|
||||||
|
"default": true,
|
||||||
|
"type": "boolean"
|
||||||
|
},
|
||||||
|
"turns": {
|
||||||
|
"default": 5,
|
||||||
|
"type": "number",
|
||||||
|
"minimum": 1,
|
||||||
|
"maximum": 20
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -18,4 +18,5 @@ export type {
|
|||||||
HookName,
|
HookName,
|
||||||
SisyphusAgentConfig,
|
SisyphusAgentConfig,
|
||||||
ExperimentalConfig,
|
ExperimentalConfig,
|
||||||
|
DynamicContextPruningConfig,
|
||||||
} from "./schema"
|
} from "./schema"
|
||||||
|
|||||||
@@ -113,6 +113,42 @@ export const SisyphusAgentConfigSchema = z.object({
|
|||||||
replace_plan: z.boolean().optional(),
|
replace_plan: z.boolean().optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
|
export const DynamicContextPruningConfigSchema = z.object({
|
||||||
|
/** Enable dynamic context pruning (default: false) */
|
||||||
|
enabled: z.boolean().default(false),
|
||||||
|
/** Notification level: off, minimal, or detailed (default: detailed) */
|
||||||
|
notification: z.enum(["off", "minimal", "detailed"]).default("detailed"),
|
||||||
|
/** Turn protection - prevent pruning recent tool outputs */
|
||||||
|
turn_protection: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
turns: z.number().min(1).max(10).default(3),
|
||||||
|
}).optional(),
|
||||||
|
/** Tools that should never be pruned */
|
||||||
|
protected_tools: z.array(z.string()).default([
|
||||||
|
"task", "todowrite", "todoread",
|
||||||
|
"lsp_rename", "lsp_code_action_resolve",
|
||||||
|
"session_read", "session_write", "session_search",
|
||||||
|
]),
|
||||||
|
/** Pruning strategies configuration */
|
||||||
|
strategies: z.object({
|
||||||
|
/** Remove duplicate tool calls (same tool + same args) */
|
||||||
|
deduplication: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
}).optional(),
|
||||||
|
/** Prune write inputs when file subsequently read */
|
||||||
|
supersede_writes: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
/** Aggressive mode: prune any write if ANY subsequent read */
|
||||||
|
aggressive: z.boolean().default(false),
|
||||||
|
}).optional(),
|
||||||
|
/** Prune errored tool inputs after N turns */
|
||||||
|
purge_errors: z.object({
|
||||||
|
enabled: z.boolean().default(true),
|
||||||
|
turns: z.number().min(1).max(20).default(5),
|
||||||
|
}).optional(),
|
||||||
|
}).optional(),
|
||||||
|
})
|
||||||
|
|
||||||
export const ExperimentalConfigSchema = z.object({
|
export const ExperimentalConfigSchema = z.object({
|
||||||
aggressive_truncation: z.boolean().optional(),
|
aggressive_truncation: z.boolean().optional(),
|
||||||
auto_resume: z.boolean().optional(),
|
auto_resume: z.boolean().optional(),
|
||||||
@@ -122,6 +158,8 @@ export const ExperimentalConfigSchema = z.object({
|
|||||||
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
preemptive_compaction_threshold: z.number().min(0.5).max(0.95).optional(),
|
||||||
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
|
/** Truncate all tool outputs, not just whitelisted tools (default: true) */
|
||||||
truncate_all_tool_outputs: z.boolean().default(true),
|
truncate_all_tool_outputs: z.boolean().default(true),
|
||||||
|
/** Dynamic context pruning configuration */
|
||||||
|
dynamic_context_pruning: DynamicContextPruningConfigSchema.optional(),
|
||||||
})
|
})
|
||||||
|
|
||||||
export const OhMyOpenCodeConfigSchema = z.object({
|
export const OhMyOpenCodeConfigSchema = z.object({
|
||||||
@@ -144,5 +182,6 @@ export type AgentName = z.infer<typeof AgentNameSchema>
|
|||||||
export type HookName = z.infer<typeof HookNameSchema>
|
export type HookName = z.infer<typeof HookNameSchema>
|
||||||
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
export type SisyphusAgentConfig = z.infer<typeof SisyphusAgentConfigSchema>
|
||||||
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
export type ExperimentalConfig = z.infer<typeof ExperimentalConfigSchema>
|
||||||
|
export type DynamicContextPruningConfig = z.infer<typeof DynamicContextPruningConfigSchema>
|
||||||
|
|
||||||
export { McpNameSchema, type McpName } from "../mcp/types"
|
export { McpNameSchema, type McpName } from "../mcp/types"
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import type {
|
|||||||
} from "./types";
|
} from "./types";
|
||||||
import type { ExperimentalConfig } from "../../config";
|
import type { ExperimentalConfig } from "../../config";
|
||||||
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
import { FALLBACK_CONFIG, RETRY_CONFIG, TRUNCATE_CONFIG } from "./types";
|
||||||
|
import { executeDynamicContextPruning } from "./pruning-executor";
|
||||||
import {
|
import {
|
||||||
findLargestToolResult,
|
findLargestToolResult,
|
||||||
truncateToolResult,
|
truncateToolResult,
|
||||||
@@ -383,6 +384,40 @@ export async function executeCompact(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (experimental?.dynamic_context_pruning?.enabled) {
|
||||||
|
log("[auto-compact] attempting DCP before truncation", { sessionID });
|
||||||
|
|
||||||
|
try {
|
||||||
|
const pruningResult = await executeDynamicContextPruning(
|
||||||
|
sessionID,
|
||||||
|
experimental.dynamic_context_pruning,
|
||||||
|
client
|
||||||
|
);
|
||||||
|
|
||||||
|
if (pruningResult.itemsPruned > 0) {
|
||||||
|
log("[auto-compact] DCP successful, resuming", {
|
||||||
|
itemsPruned: pruningResult.itemsPruned,
|
||||||
|
tokensSaved: pruningResult.totalTokensSaved,
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(async () => {
|
||||||
|
try {
|
||||||
|
await (client as Client).session.prompt_async({
|
||||||
|
path: { sessionID },
|
||||||
|
body: { parts: [{ type: "text", text: "Continue" }] },
|
||||||
|
query: { directory },
|
||||||
|
});
|
||||||
|
} catch {}
|
||||||
|
}, 500);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("[auto-compact] DCP failed, continuing to truncation", {
|
||||||
|
error: String(error),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let skipSummarize = false;
|
let skipSummarize = false;
|
||||||
|
|
||||||
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
if (truncateState.truncateAttempt < TRUNCATE_CONFIG.maxTruncateAttempts) {
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
import { describe, test, expect } from "bun:test"
|
||||||
|
import { createToolSignature } from "./pruning-deduplication"
|
||||||
|
|
||||||
|
describe("createToolSignature", () => {
|
||||||
|
test("creates consistent signature for same input", () => {
|
||||||
|
const input1 = { filePath: "/foo/bar.ts", content: "hello" }
|
||||||
|
const input2 = { content: "hello", filePath: "/foo/bar.ts" }
|
||||||
|
|
||||||
|
const sig1 = createToolSignature("read", input1)
|
||||||
|
const sig2 = createToolSignature("read", input2)
|
||||||
|
|
||||||
|
expect(sig1).toBe(sig2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("creates different signature for different input", () => {
|
||||||
|
const input1 = { filePath: "/foo/bar.ts" }
|
||||||
|
const input2 = { filePath: "/foo/baz.ts" }
|
||||||
|
|
||||||
|
const sig1 = createToolSignature("read", input1)
|
||||||
|
const sig2 = createToolSignature("read", input2)
|
||||||
|
|
||||||
|
expect(sig1).not.toBe(sig2)
|
||||||
|
})
|
||||||
|
|
||||||
|
test("includes tool name in signature", () => {
|
||||||
|
const input = { filePath: "/foo/bar.ts" }
|
||||||
|
|
||||||
|
const sig1 = createToolSignature("read", input)
|
||||||
|
const sig2 = createToolSignature("write", input)
|
||||||
|
|
||||||
|
expect(sig1).not.toBe(sig2)
|
||||||
|
})
|
||||||
|
})
|
||||||
190
src/hooks/anthropic-auto-compact/pruning-deduplication.ts
Normal file
190
src/hooks/anthropic-auto-compact/pruning-deduplication.ts
Normal file
@@ -0,0 +1,190 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type { PruningState, ToolCallSignature } from "./pruning-types"
|
||||||
|
import { estimateTokens } from "./pruning-types"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export interface DeduplicationConfig {
|
||||||
|
enabled: boolean
|
||||||
|
protectedTools?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_STORAGE = join(
|
||||||
|
process.env.HOME || process.env.USERPROFILE || "",
|
||||||
|
".config",
|
||||||
|
"opencode",
|
||||||
|
"sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ToolPart {
|
||||||
|
type: string
|
||||||
|
callID?: string
|
||||||
|
tool?: string
|
||||||
|
state?: {
|
||||||
|
input?: unknown
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePart {
|
||||||
|
type: string
|
||||||
|
parts?: ToolPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function createToolSignature(toolName: string, input: unknown): string {
|
||||||
|
const sortedInput = sortObject(input)
|
||||||
|
return `${toolName}::${JSON.stringify(sortedInput)}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortObject(obj: unknown): unknown {
|
||||||
|
if (obj === null || obj === undefined) return obj
|
||||||
|
if (typeof obj !== "object") return obj
|
||||||
|
if (Array.isArray(obj)) return obj.map(sortObject)
|
||||||
|
|
||||||
|
const sorted: Record<string, unknown> = {}
|
||||||
|
const keys = Object.keys(obj as Record<string, unknown>).sort()
|
||||||
|
for (const key of keys) {
|
||||||
|
sorted[key] = sortObject((obj as Record<string, unknown>)[key])
|
||||||
|
}
|
||||||
|
return sorted
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageDir(sessionID: string): string | null {
|
||||||
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||||
|
|
||||||
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(directPath)) return directPath
|
||||||
|
|
||||||
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||||
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(sessionPath)) return sessionPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessages(sessionID: string): MessagePart[] {
|
||||||
|
const messageDir = getMessageDir(sessionID)
|
||||||
|
if (!messageDir) return []
|
||||||
|
|
||||||
|
const messages: MessagePart[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||||
|
const data = JSON.parse(content)
|
||||||
|
if (data.parts) {
|
||||||
|
messages.push(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeDeduplication(
|
||||||
|
sessionID: string,
|
||||||
|
state: PruningState,
|
||||||
|
config: DeduplicationConfig,
|
||||||
|
protectedTools: Set<string>
|
||||||
|
): number {
|
||||||
|
if (!config.enabled) return 0
|
||||||
|
|
||||||
|
const messages = readMessages(sessionID)
|
||||||
|
const signatures = new Map<string, ToolCallSignature[]>()
|
||||||
|
|
||||||
|
let currentTurn = 0
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.parts) continue
|
||||||
|
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === "step-start") {
|
||||||
|
currentTurn++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||||
|
|
||||||
|
if (protectedTools.has(part.tool)) continue
|
||||||
|
|
||||||
|
if (config.protectedTools?.includes(part.tool)) continue
|
||||||
|
|
||||||
|
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||||
|
|
||||||
|
const signature = createToolSignature(part.tool, part.state?.input)
|
||||||
|
|
||||||
|
if (!signatures.has(signature)) {
|
||||||
|
signatures.set(signature, [])
|
||||||
|
}
|
||||||
|
|
||||||
|
signatures.get(signature)!.push({
|
||||||
|
toolName: part.tool,
|
||||||
|
signature,
|
||||||
|
callID: part.callID,
|
||||||
|
turn: currentTurn,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!state.toolSignatures.has(signature)) {
|
||||||
|
state.toolSignatures.set(signature, [])
|
||||||
|
}
|
||||||
|
state.toolSignatures.get(signature)!.push({
|
||||||
|
toolName: part.tool,
|
||||||
|
signature,
|
||||||
|
callID: part.callID,
|
||||||
|
turn: currentTurn,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prunedCount = 0
|
||||||
|
let tokensSaved = 0
|
||||||
|
|
||||||
|
for (const [signature, calls] of signatures) {
|
||||||
|
if (calls.length > 1) {
|
||||||
|
const toPrune = calls.slice(0, -1)
|
||||||
|
|
||||||
|
for (const call of toPrune) {
|
||||||
|
state.toolIdsToPrune.add(call.callID)
|
||||||
|
prunedCount++
|
||||||
|
|
||||||
|
const output = findToolOutput(messages, call.callID)
|
||||||
|
if (output) {
|
||||||
|
tokensSaved += estimateTokens(output)
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-deduplication] pruned duplicate", {
|
||||||
|
tool: call.toolName,
|
||||||
|
callID: call.callID,
|
||||||
|
turn: call.turn,
|
||||||
|
signature: signature.substring(0, 100),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-deduplication] complete", {
|
||||||
|
prunedCount,
|
||||||
|
tokensSaved,
|
||||||
|
uniqueSignatures: signatures.size,
|
||||||
|
})
|
||||||
|
|
||||||
|
return prunedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolOutput(messages: MessagePart[], callID: string): string | null {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.parts) continue
|
||||||
|
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === "tool" && part.callID === callID && part.state?.output) {
|
||||||
|
return part.state.output
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
126
src/hooks/anthropic-auto-compact/pruning-executor.ts
Normal file
126
src/hooks/anthropic-auto-compact/pruning-executor.ts
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
import type { DynamicContextPruningConfig } from "../../config"
|
||||||
|
import type { PruningState, PruningResult } from "./pruning-types"
|
||||||
|
import { executeDeduplication } from "./pruning-deduplication"
|
||||||
|
import { executeSupersedeWrites } from "./pruning-supersede"
|
||||||
|
import { executePurgeErrors } from "./pruning-purge-errors"
|
||||||
|
import { applyPruning } from "./pruning-storage"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
const DEFAULT_PROTECTED_TOOLS = new Set([
|
||||||
|
"task",
|
||||||
|
"todowrite",
|
||||||
|
"todoread",
|
||||||
|
"lsp_rename",
|
||||||
|
"lsp_code_action_resolve",
|
||||||
|
"session_read",
|
||||||
|
"session_write",
|
||||||
|
"session_search",
|
||||||
|
])
|
||||||
|
|
||||||
|
function createPruningState(): PruningState {
|
||||||
|
return {
|
||||||
|
toolIdsToPrune: new Set<string>(),
|
||||||
|
currentTurn: 0,
|
||||||
|
fileOperations: new Map(),
|
||||||
|
toolSignatures: new Map(),
|
||||||
|
erroredTools: new Map(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function executeDynamicContextPruning(
|
||||||
|
sessionID: string,
|
||||||
|
config: DynamicContextPruningConfig,
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
client: any
|
||||||
|
): Promise<PruningResult> {
|
||||||
|
const state = createPruningState()
|
||||||
|
|
||||||
|
const protectedTools = new Set([
|
||||||
|
...DEFAULT_PROTECTED_TOOLS,
|
||||||
|
...(config.protected_tools || []),
|
||||||
|
])
|
||||||
|
|
||||||
|
log("[pruning-executor] starting DCP", {
|
||||||
|
sessionID,
|
||||||
|
notification: config.notification,
|
||||||
|
turnProtection: config.turn_protection,
|
||||||
|
})
|
||||||
|
|
||||||
|
let dedupCount = 0
|
||||||
|
let supersedeCount = 0
|
||||||
|
let purgeCount = 0
|
||||||
|
|
||||||
|
if (config.strategies?.deduplication?.enabled !== false) {
|
||||||
|
dedupCount = executeDeduplication(
|
||||||
|
sessionID,
|
||||||
|
state,
|
||||||
|
{ enabled: true },
|
||||||
|
protectedTools
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.strategies?.supersede_writes?.enabled !== false) {
|
||||||
|
supersedeCount = executeSupersedeWrites(
|
||||||
|
sessionID,
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
aggressive: config.strategies?.supersede_writes?.aggressive || false,
|
||||||
|
},
|
||||||
|
protectedTools
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.strategies?.purge_errors?.enabled !== false) {
|
||||||
|
purgeCount = executePurgeErrors(
|
||||||
|
sessionID,
|
||||||
|
state,
|
||||||
|
{
|
||||||
|
enabled: true,
|
||||||
|
turns: config.strategies?.purge_errors?.turns || 5,
|
||||||
|
},
|
||||||
|
protectedTools
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
const totalPruned = state.toolIdsToPrune.size
|
||||||
|
const tokensSaved = await applyPruning(sessionID, state)
|
||||||
|
|
||||||
|
log("[pruning-executor] DCP complete", {
|
||||||
|
totalPruned,
|
||||||
|
tokensSaved,
|
||||||
|
deduplication: dedupCount,
|
||||||
|
supersede: supersedeCount,
|
||||||
|
purge: purgeCount,
|
||||||
|
})
|
||||||
|
|
||||||
|
const result: PruningResult = {
|
||||||
|
itemsPruned: totalPruned,
|
||||||
|
totalTokensSaved: tokensSaved,
|
||||||
|
strategies: {
|
||||||
|
deduplication: dedupCount,
|
||||||
|
supersedeWrites: supersedeCount,
|
||||||
|
purgeErrors: purgeCount,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.notification !== "off" && totalPruned > 0) {
|
||||||
|
const message =
|
||||||
|
config.notification === "detailed"
|
||||||
|
? `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens). Dedup: ${dedupCount}, Supersede: ${supersedeCount}, Purge: ${purgeCount}`
|
||||||
|
: `Pruned ${totalPruned} tool outputs (~${Math.round(tokensSaved / 1000)}k tokens)`
|
||||||
|
|
||||||
|
await client.tui
|
||||||
|
.showToast({
|
||||||
|
body: {
|
||||||
|
title: "Dynamic Context Pruning",
|
||||||
|
message,
|
||||||
|
variant: "success",
|
||||||
|
duration: 3000,
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.catch(() => {})
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
|
}
|
||||||
158
src/hooks/anthropic-auto-compact/pruning-purge-errors.ts
Normal file
158
src/hooks/anthropic-auto-compact/pruning-purge-errors.ts
Normal file
@@ -0,0 +1,158 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type { PruningState, ErroredToolCall } from "./pruning-types"
|
||||||
|
import { estimateTokens } from "./pruning-types"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export interface PurgeErrorsConfig {
|
||||||
|
enabled: boolean
|
||||||
|
turns: number
|
||||||
|
protectedTools?: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_STORAGE = join(
|
||||||
|
process.env.HOME || process.env.USERPROFILE || "",
|
||||||
|
".config",
|
||||||
|
"opencode",
|
||||||
|
"sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ToolPart {
|
||||||
|
type: string
|
||||||
|
callID?: string
|
||||||
|
tool?: string
|
||||||
|
state?: {
|
||||||
|
input?: unknown
|
||||||
|
output?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePart {
|
||||||
|
type: string
|
||||||
|
parts?: ToolPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageDir(sessionID: string): string | null {
|
||||||
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||||
|
|
||||||
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(directPath)) return directPath
|
||||||
|
|
||||||
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||||
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(sessionPath)) return sessionPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessages(sessionID: string): MessagePart[] {
|
||||||
|
const messageDir = getMessageDir(sessionID)
|
||||||
|
if (!messageDir) return []
|
||||||
|
|
||||||
|
const messages: MessagePart[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||||
|
const data = JSON.parse(content)
|
||||||
|
if (data.parts) {
|
||||||
|
messages.push(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executePurgeErrors(
|
||||||
|
sessionID: string,
|
||||||
|
state: PruningState,
|
||||||
|
config: PurgeErrorsConfig,
|
||||||
|
protectedTools: Set<string>
|
||||||
|
): number {
|
||||||
|
if (!config.enabled) return 0
|
||||||
|
|
||||||
|
const messages = readMessages(sessionID)
|
||||||
|
|
||||||
|
let currentTurn = 0
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.parts) continue
|
||||||
|
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === "step-start") {
|
||||||
|
currentTurn++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
state.currentTurn = currentTurn
|
||||||
|
|
||||||
|
let turnCounter = 0
|
||||||
|
let prunedCount = 0
|
||||||
|
let tokensSaved = 0
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.parts) continue
|
||||||
|
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === "step-start") {
|
||||||
|
turnCounter++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||||
|
|
||||||
|
if (protectedTools.has(part.tool)) continue
|
||||||
|
|
||||||
|
if (config.protectedTools?.includes(part.tool)) continue
|
||||||
|
|
||||||
|
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||||
|
|
||||||
|
if (part.state?.status !== "error") continue
|
||||||
|
|
||||||
|
const turnAge = currentTurn - turnCounter
|
||||||
|
|
||||||
|
if (turnAge >= config.turns) {
|
||||||
|
state.toolIdsToPrune.add(part.callID)
|
||||||
|
prunedCount++
|
||||||
|
|
||||||
|
const input = part.state.input
|
||||||
|
if (input) {
|
||||||
|
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
const errorInfo: ErroredToolCall = {
|
||||||
|
callID: part.callID,
|
||||||
|
toolName: part.tool,
|
||||||
|
turn: turnCounter,
|
||||||
|
errorAge: turnAge,
|
||||||
|
}
|
||||||
|
|
||||||
|
state.erroredTools.set(part.callID, errorInfo)
|
||||||
|
|
||||||
|
log("[pruning-purge-errors] pruned old error", {
|
||||||
|
tool: part.tool,
|
||||||
|
callID: part.callID,
|
||||||
|
turn: turnCounter,
|
||||||
|
errorAge: turnAge,
|
||||||
|
threshold: config.turns,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-purge-errors] complete", {
|
||||||
|
prunedCount,
|
||||||
|
tokensSaved,
|
||||||
|
currentTurn,
|
||||||
|
threshold: config.turns,
|
||||||
|
})
|
||||||
|
|
||||||
|
return prunedCount
|
||||||
|
}
|
||||||
107
src/hooks/anthropic-auto-compact/pruning-storage.ts
Normal file
107
src/hooks/anthropic-auto-compact/pruning-storage.ts
Normal file
@@ -0,0 +1,107 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync, writeFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type { PruningState } from "./pruning-types"
|
||||||
|
import { estimateTokens } from "./pruning-types"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
const MESSAGE_STORAGE = join(
|
||||||
|
process.env.HOME || process.env.USERPROFILE || "",
|
||||||
|
".config",
|
||||||
|
"opencode",
|
||||||
|
"sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
function getMessageDir(sessionID: string): string | null {
|
||||||
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||||
|
|
||||||
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(directPath)) return directPath
|
||||||
|
|
||||||
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||||
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(sessionPath)) return sessionPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
interface ToolPart {
|
||||||
|
type: string
|
||||||
|
callID?: string
|
||||||
|
tool?: string
|
||||||
|
state?: {
|
||||||
|
input?: unknown
|
||||||
|
output?: string
|
||||||
|
status?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessageData {
|
||||||
|
parts?: ToolPart[]
|
||||||
|
[key: string]: unknown
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function applyPruning(
|
||||||
|
sessionID: string,
|
||||||
|
state: PruningState
|
||||||
|
): Promise<number> {
|
||||||
|
const messageDir = getMessageDir(sessionID)
|
||||||
|
if (!messageDir) {
|
||||||
|
log("[pruning-storage] message dir not found", { sessionID })
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
|
||||||
|
let totalTokensSaved = 0
|
||||||
|
let filesModified = 0
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||||
|
|
||||||
|
for (const file of files) {
|
||||||
|
const filePath = join(messageDir, file)
|
||||||
|
const content = readFileSync(filePath, "utf-8")
|
||||||
|
const data: MessageData = JSON.parse(content)
|
||||||
|
|
||||||
|
if (!data.parts) continue
|
||||||
|
|
||||||
|
let modified = false
|
||||||
|
|
||||||
|
for (const part of data.parts) {
|
||||||
|
if (part.type !== "tool" || !part.callID) continue
|
||||||
|
|
||||||
|
if (!state.toolIdsToPrune.has(part.callID)) continue
|
||||||
|
|
||||||
|
if (part.state?.input) {
|
||||||
|
const inputStr = JSON.stringify(part.state.input)
|
||||||
|
totalTokensSaved += estimateTokens(inputStr)
|
||||||
|
part.state.input = { __pruned: true, reason: "DCP" }
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.state?.output) {
|
||||||
|
totalTokensSaved += estimateTokens(part.state.output)
|
||||||
|
part.state.output = "[Content pruned by Dynamic Context Pruning]"
|
||||||
|
modified = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (modified) {
|
||||||
|
writeFileSync(filePath, JSON.stringify(data, null, 2), "utf-8")
|
||||||
|
filesModified++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
log("[pruning-storage] error applying pruning", {
|
||||||
|
sessionID,
|
||||||
|
error: String(error),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-storage] applied pruning", {
|
||||||
|
sessionID,
|
||||||
|
filesModified,
|
||||||
|
totalTokensSaved,
|
||||||
|
})
|
||||||
|
|
||||||
|
return totalTokensSaved
|
||||||
|
}
|
||||||
218
src/hooks/anthropic-auto-compact/pruning-supersede.ts
Normal file
218
src/hooks/anthropic-auto-compact/pruning-supersede.ts
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
import { existsSync, readdirSync, readFileSync } from "node:fs"
|
||||||
|
import { join } from "node:path"
|
||||||
|
import type { PruningState, FileOperation } from "./pruning-types"
|
||||||
|
import { estimateTokens } from "./pruning-types"
|
||||||
|
import { log } from "../../shared/logger"
|
||||||
|
|
||||||
|
export interface SupersedeWritesConfig {
|
||||||
|
enabled: boolean
|
||||||
|
aggressive: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const MESSAGE_STORAGE = join(
|
||||||
|
process.env.HOME || process.env.USERPROFILE || "",
|
||||||
|
".config",
|
||||||
|
"opencode",
|
||||||
|
"sessions"
|
||||||
|
)
|
||||||
|
|
||||||
|
interface ToolPart {
|
||||||
|
type: string
|
||||||
|
callID?: string
|
||||||
|
tool?: string
|
||||||
|
state?: {
|
||||||
|
input?: unknown
|
||||||
|
output?: string
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MessagePart {
|
||||||
|
type: string
|
||||||
|
parts?: ToolPart[]
|
||||||
|
}
|
||||||
|
|
||||||
|
function getMessageDir(sessionID: string): string | null {
|
||||||
|
if (!existsSync(MESSAGE_STORAGE)) return null
|
||||||
|
|
||||||
|
const directPath = join(MESSAGE_STORAGE, sessionID)
|
||||||
|
if (existsSync(directPath)) return directPath
|
||||||
|
|
||||||
|
for (const dir of readdirSync(MESSAGE_STORAGE)) {
|
||||||
|
const sessionPath = join(MESSAGE_STORAGE, dir, sessionID)
|
||||||
|
if (existsSync(sessionPath)) return sessionPath
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
function readMessages(sessionID: string): MessagePart[] {
|
||||||
|
const messageDir = getMessageDir(sessionID)
|
||||||
|
if (!messageDir) return []
|
||||||
|
|
||||||
|
const messages: MessagePart[] = []
|
||||||
|
|
||||||
|
try {
|
||||||
|
const files = readdirSync(messageDir).filter(f => f.endsWith(".json"))
|
||||||
|
for (const file of files) {
|
||||||
|
const content = readFileSync(join(messageDir, file), "utf-8")
|
||||||
|
const data = JSON.parse(content)
|
||||||
|
if (data.parts) {
|
||||||
|
messages.push(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
return []
|
||||||
|
}
|
||||||
|
|
||||||
|
return messages
|
||||||
|
}
|
||||||
|
|
||||||
|
function extractFilePath(toolName: string, input: unknown): string | null {
|
||||||
|
if (!input || typeof input !== "object") return null
|
||||||
|
|
||||||
|
const inputObj = input as Record<string, unknown>
|
||||||
|
|
||||||
|
if (toolName === "write" || toolName === "edit" || toolName === "read") {
|
||||||
|
if (typeof inputObj.filePath === "string") {
|
||||||
|
return inputObj.filePath
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
|
|
||||||
|
export function executeSupersedeWrites(
|
||||||
|
sessionID: string,
|
||||||
|
state: PruningState,
|
||||||
|
config: SupersedeWritesConfig,
|
||||||
|
protectedTools: Set<string>
|
||||||
|
): number {
|
||||||
|
if (!config.enabled) return 0
|
||||||
|
|
||||||
|
const messages = readMessages(sessionID)
|
||||||
|
const writesByFile = new Map<string, FileOperation[]>()
|
||||||
|
const readsByFile = new Map<string, number[]>()
|
||||||
|
|
||||||
|
let currentTurn = 0
|
||||||
|
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.parts) continue
|
||||||
|
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === "step-start") {
|
||||||
|
currentTurn++
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
if (part.type !== "tool" || !part.callID || !part.tool) continue
|
||||||
|
|
||||||
|
if (protectedTools.has(part.tool)) continue
|
||||||
|
|
||||||
|
if (state.toolIdsToPrune.has(part.callID)) continue
|
||||||
|
|
||||||
|
const filePath = extractFilePath(part.tool, part.state?.input)
|
||||||
|
if (!filePath) continue
|
||||||
|
|
||||||
|
if (part.tool === "write" || part.tool === "edit") {
|
||||||
|
if (!writesByFile.has(filePath)) {
|
||||||
|
writesByFile.set(filePath, [])
|
||||||
|
}
|
||||||
|
writesByFile.get(filePath)!.push({
|
||||||
|
callID: part.callID,
|
||||||
|
tool: part.tool,
|
||||||
|
filePath,
|
||||||
|
turn: currentTurn,
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!state.fileOperations.has(filePath)) {
|
||||||
|
state.fileOperations.set(filePath, [])
|
||||||
|
}
|
||||||
|
state.fileOperations.get(filePath)!.push({
|
||||||
|
callID: part.callID,
|
||||||
|
tool: part.tool,
|
||||||
|
filePath,
|
||||||
|
turn: currentTurn,
|
||||||
|
})
|
||||||
|
} else if (part.tool === "read") {
|
||||||
|
if (!readsByFile.has(filePath)) {
|
||||||
|
readsByFile.set(filePath, [])
|
||||||
|
}
|
||||||
|
readsByFile.get(filePath)!.push(currentTurn)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let prunedCount = 0
|
||||||
|
let tokensSaved = 0
|
||||||
|
|
||||||
|
for (const [filePath, writes] of writesByFile) {
|
||||||
|
const reads = readsByFile.get(filePath) || []
|
||||||
|
|
||||||
|
if (config.aggressive) {
|
||||||
|
for (const write of writes) {
|
||||||
|
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||||
|
if (superseded) {
|
||||||
|
state.toolIdsToPrune.add(write.callID)
|
||||||
|
prunedCount++
|
||||||
|
|
||||||
|
const input = findToolInput(messages, write.callID)
|
||||||
|
if (input) {
|
||||||
|
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-supersede] pruned superseded write", {
|
||||||
|
tool: write.tool,
|
||||||
|
callID: write.callID,
|
||||||
|
turn: write.turn,
|
||||||
|
filePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
if (writes.length > 1) {
|
||||||
|
for (const write of writes.slice(0, -1)) {
|
||||||
|
const superseded = reads.some(readTurn => readTurn > write.turn)
|
||||||
|
if (superseded) {
|
||||||
|
state.toolIdsToPrune.add(write.callID)
|
||||||
|
prunedCount++
|
||||||
|
|
||||||
|
const input = findToolInput(messages, write.callID)
|
||||||
|
if (input) {
|
||||||
|
tokensSaved += estimateTokens(JSON.stringify(input))
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-supersede] pruned superseded write (conservative)", {
|
||||||
|
tool: write.tool,
|
||||||
|
callID: write.callID,
|
||||||
|
turn: write.turn,
|
||||||
|
filePath,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
log("[pruning-supersede] complete", {
|
||||||
|
prunedCount,
|
||||||
|
tokensSaved,
|
||||||
|
filesTracked: writesByFile.size,
|
||||||
|
mode: config.aggressive ? "aggressive" : "conservative",
|
||||||
|
})
|
||||||
|
|
||||||
|
return prunedCount
|
||||||
|
}
|
||||||
|
|
||||||
|
function findToolInput(messages: MessagePart[], callID: string): unknown | null {
|
||||||
|
for (const msg of messages) {
|
||||||
|
if (!msg.parts) continue
|
||||||
|
|
||||||
|
for (const part of msg.parts) {
|
||||||
|
if (part.type === "tool" && part.callID === callID && part.state?.input) {
|
||||||
|
return part.state.input
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return null
|
||||||
|
}
|
||||||
44
src/hooks/anthropic-auto-compact/pruning-types.ts
Normal file
44
src/hooks/anthropic-auto-compact/pruning-types.ts
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
export interface ToolCallSignature {
|
||||||
|
toolName: string
|
||||||
|
signature: string
|
||||||
|
callID: string
|
||||||
|
turn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface FileOperation {
|
||||||
|
callID: string
|
||||||
|
tool: string
|
||||||
|
filePath: string
|
||||||
|
turn: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ErroredToolCall {
|
||||||
|
callID: string
|
||||||
|
toolName: string
|
||||||
|
turn: number
|
||||||
|
errorAge: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruningResult {
|
||||||
|
itemsPruned: number
|
||||||
|
totalTokensSaved: number
|
||||||
|
strategies: {
|
||||||
|
deduplication: number
|
||||||
|
supersedeWrites: number
|
||||||
|
purgeErrors: number
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PruningState {
|
||||||
|
toolIdsToPrune: Set<string>
|
||||||
|
currentTurn: number
|
||||||
|
fileOperations: Map<string, FileOperation[]>
|
||||||
|
toolSignatures: Map<string, ToolCallSignature[]>
|
||||||
|
erroredTools: Map<string, ErroredToolCall>
|
||||||
|
}
|
||||||
|
|
||||||
|
export const CHARS_PER_TOKEN = 4
|
||||||
|
|
||||||
|
export function estimateTokens(text: string): number {
|
||||||
|
return Math.ceil(text.length / CHARS_PER_TOKEN)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user