diff --git a/index.ts b/index.ts index 3a53fdb..530862f 100644 --- a/index.ts +++ b/index.ts @@ -5,6 +5,7 @@ import { createCompressRangeTool, createDecompressTool, createSearchContextTool, + createSetCompressionProfileTool, } from "./lib/compress" import { compressDisabledByOpencode, @@ -91,6 +92,7 @@ const server: Plugin = (async (ctx) => { : createCompressRangeTool(compressToolContext), decompress: createDecompressTool(compressToolContext), search_context: createSearchContextTool(compressToolContext), + set_compression_profile: createSetCompressionProfileTool(compressToolContext), }), }, config: async (opencodeConfig) => { @@ -111,7 +113,7 @@ const server: Plugin = (async (ctx) => { const toolsToAdd: string[] = [] if (config.compress.permission !== "deny" && !config.experimental.allowSubAgents) { - toolsToAdd.push("compress", "decompress", "search_context") + toolsToAdd.push("compress", "decompress", "search_context", "set_compression_profile") } if (toolsToAdd.length > 0) { diff --git a/lib/compress/index.ts b/lib/compress/index.ts index ce90ffe..6c2439d 100644 --- a/lib/compress/index.ts +++ b/lib/compress/index.ts @@ -3,3 +3,5 @@ export { createCompressMessageTool } from "./message" export { createCompressRangeTool } from "./range" export { createDecompressTool } from "./decompress" export { createSearchContextTool } from "./search" +export { createSetCompressionProfileTool, getSessionProfile, PROFILES } from "./profile" +export type { CompressionProfile } from "./profile" diff --git a/lib/compress/profile.ts b/lib/compress/profile.ts new file mode 100644 index 0000000..5bec9ff --- /dev/null +++ b/lib/compress/profile.ts @@ -0,0 +1,110 @@ +import { tool } from "@opencode-ai/plugin" +import type { ToolContext } from "./types" +import * as fs from "fs" +import * as path from "path" + +export type CompressionProfile = "aggressive" | "balanced" | "conservative" + +export const PROFILES: Record = { + aggressive: { + normalHint: "Compress frequently — keep only final outcomes and key results. Context is disposable for short tasks.", + summaryLimit: 100, + protectedItems: "Protect only user instructions.", + compressFrequency: 4, + largeOutputThreshold: 2000, + }, + balanced: { + normalHint: "After completing a task or sub-task, compress its tool outputs into summaries. Do NOT compress content you're actively using for an ongoing task.", + summaryLimit: 200, + protectedItems: "Protect user instructions, key decisions, file paths, and important findings.", + compressFrequency: 6, + largeOutputThreshold: 5000, + }, + conservative: { + normalHint: "Compress ONLY verbose logs and obvious duplicates. NEVER compress experiment results, metrics, architectural decisions, code structure, or file paths.", + summaryLimit: 400, + protectedItems: "Protect everything in balanced PLUS: experiment results (PPL, accuracy, loss), metrics, code structure, previous experiment comparisons.", + compressFrequency: 10, + largeOutputThreshold: 10000, + }, +} + +export function getSessionConfigPath(sessionId: string): string { + const configDir = path.join(process.env.HOME || "~", ".config", "opencode", "acp-status") + return path.join(configDir, `${sessionId}.json`) +} + +export function getSessionProfile(sessionId: string, defaultProfile: CompressionProfile): CompressionProfile { + try { + const filePath = getSessionConfigPath(sessionId) + if (fs.existsSync(filePath)) { + const data = JSON.parse(fs.readFileSync(filePath, "utf-8")) + if (data.compressionProfile && data.compressionProfile in PROFILES) { + return data.compressionProfile as CompressionProfile + } + } + } catch {} + return defaultProfile +} + +export function setSessionProfile(sessionId: string, profile: CompressionProfile): void { + const filePath = getSessionConfigPath(sessionId) + const configDir = path.dirname(filePath) + if (!fs.existsSync(configDir)) { + fs.mkdirSync(configDir, { recursive: true }) + } + + let existing: Record = {} + try { + if (fs.existsSync(filePath)) { + existing = JSON.parse(fs.readFileSync(filePath, "utf-8")) + } + } catch {} + + existing.compressionProfile = profile + fs.writeFileSync(filePath, JSON.stringify(existing, null, 2), "utf-8") +} + +export function createSetCompressionProfileTool(ctx: ToolContext): ReturnType { + return tool({ + description: "Set the compression profile for this session. Use 'conservative' for long experiments, 'aggressive' for short tasks, 'balanced' for general development.", + args: { + profile: tool.schema + .string() + .describe("Compression profile: 'aggressive' (short tasks), 'balanced' (default), 'conservative' (long experiments)") + .optional(), + }, + async execute(args, toolCtx) { + const sessionId = ctx.state.sessionId || toolCtx?.sessionID || "unknown" + + if (!args.profile) { + const current = getSessionProfile(sessionId, "balanced") + const config = PROFILES[current] + return `Current compression profile: ${current}\n` + + `Summary limit: ${config.summaryLimit} chars\n` + + `Compress frequency: every ${config.compressFrequency} turns\n` + + `Available: aggressive, balanced, conservative` + } + + const profile = args.profile as CompressionProfile + if (!(profile in PROFILES)) { + throw new Error(`Invalid profile: ${profile}. Use: aggressive, balanced, or conservative`) + } + + setSessionProfile(sessionId, profile) + const config = PROFILES[profile] + + return `✅ Compression profile set to: ${profile}\n` + + `Summary limit: ${config.summaryLimit} chars\n` + + `Compress frequency: every ${config.compressFrequency} turns\n` + + `Large output threshold: ${config.largeOutputThreshold} chars\n` + + `${config.normalHint}` + }, + }) +} diff --git a/lib/config-validation.ts b/lib/config-validation.ts index 163c234..52d470b 100644 --- a/lib/config-validation.ts +++ b/lib/config-validation.ts @@ -53,6 +53,7 @@ export const VALID_CONFIG_KEYS = new Set([ "gc.batchCleanup.lowThreshold", "gc.batchCleanup.highThreshold", "gc.batchCleanup.forceThreshold", + "compressionProfile", "strategies", "strategies.deduplication", "strategies.deduplication.enabled", @@ -107,6 +108,17 @@ export function validateConfigTypes(config: Record): ValidationErro errors.push({ key: "debug", expected: "boolean", actual: typeof config.debug }) } + if (config.compressionProfile !== undefined) { + const validProfiles = ["aggressive", "balanced", "conservative"] + if (!validProfiles.includes(config.compressionProfile)) { + errors.push({ + key: "compressionProfile", + expected: '"aggressive" | "balanced" | "conservative"', + actual: JSON.stringify(config.compressionProfile), + }) + } + } + if (config.pruneNotification !== undefined) { const validValues = ["off", "minimal", "detailed"] if (!validValues.includes(config.pruneNotification)) { diff --git a/lib/config.ts b/lib/config.ts index 4c7af04..2a7900e 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -3,6 +3,7 @@ import { join, dirname } from "path" import { homedir } from "os" import { parse } from "jsonc-parser/lib/esm/main.js" import type { PluginInput } from "@opencode-ai/plugin" +import type { CompressionProfile } from "./compress/profile" import { VALID_CONFIG_KEYS, getInvalidConfigKeys, validateConfigTypes, type ValidationError } from "./config-validation" @@ -89,6 +90,7 @@ export interface PluginConfig { protectedFilePatterns: string[] compress: CompressConfig gc: GCConfig + compressionProfile: CompressionProfile strategies: { deduplication: Deduplication purgeErrors: PurgeErrors @@ -201,6 +203,7 @@ const defaultConfig: PluginConfig = { maxSummaryLengthHard: 800, minCompressRange: 2000, }, + compressionProfile: "balanced", strategies: { deduplication: { enabled: true, @@ -500,8 +503,11 @@ function mergeGC(base: GCConfig, override?: Partial): GCConfig { } } -function mergeLayer(config: PluginConfig, data: Record): PluginConfig { - return { +function isValidCompressionProfile(value: unknown): value is CompressionProfile { + return value === "aggressive" || value === "balanced" || value === "conservative" +} + +function mergeLayer(config: PluginConfig, data: Record): PluginConfig { return { enabled: data.enabled ?? config.enabled, autoUpdate: data.autoUpdate ?? config.autoUpdate, debug: data.debug ?? config.debug, @@ -519,6 +525,9 @@ function mergeLayer(config: PluginConfig, data: Record): PluginConf ], compress: mergeCompress(config.compress, data.compress as CompressOverride), gc: mergeGC(config.gc, data.gc as Partial), + compressionProfile: isValidCompressionProfile(data.compressionProfile) + ? data.compressionProfile + : config.compressionProfile, strategies: mergeStrategies(config.strategies, data.strategies as any), } } diff --git a/lib/hooks.ts b/lib/hooks.ts index 3006079..005c9bb 100644 --- a/lib/hooks.ts +++ b/lib/hooks.ts @@ -16,6 +16,7 @@ import { computeInputBudget, } from "./messages" import { renderSystemPrompt, type PromptStore } from "./prompts" +import { buildSystemPrompt } from "./prompts/system" import { buildProtectedToolsExtension } from "./prompts/extensions/system" import { applyPendingCompressionDurations, @@ -23,6 +24,7 @@ import { consumeCompressionStart, resolveCompressionDuration, } from "./compress/timing" +import { getSessionProfile } from "./compress/profile" import { filterMessages, filterMessagesInPlace } from "./messages/shape" import { getLastUserMessage } from "./messages/query" import { @@ -107,8 +109,12 @@ export function createSystemPromptHandler( prompts.reload() const runtimePrompts = prompts.getRuntimePrompts() + const effectiveProfile = state.sessionId + ? getSessionProfile(state.sessionId, config.compressionProfile) + : config.compressionProfile + const profileSystem = `\n${buildSystemPrompt(effectiveProfile).trim()}\n` const newPrompt = renderSystemPrompt( - runtimePrompts, + { ...runtimePrompts, system: profileSystem }, buildProtectedToolsExtension(config.compress.protectedTools), !!state.manualMode, state.isSubAgent && config.experimental.allowSubAgents, diff --git a/lib/prompts/system.ts b/lib/prompts/system.ts index 34cb829..01eef0d 100644 --- a/lib/prompts/system.ts +++ b/lib/prompts/system.ts @@ -1,11 +1,23 @@ -export const SYSTEM = ` +import type { CompressionProfile } from "../compress/profile" +import { PROFILES } from "../compress/profile" + +export function buildSystemPrompt(profile: CompressionProfile = "balanced"): string { + const config = PROFILES[profile] + return ` You operate in a context-constrained environment. Context management helps preserve retrieval quality, but your primary goal is completing the task at hand. Do not let context management distract from the actual work. -The tools you have for context management are \`compress\`, \`decompress\`, and \`search_context\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`search_context\` searches compressed block summaries (and visible messages) to locate relevant content before you decompress. +The tools you have for context management are \`compress\`, \`decompress\`, \`search_context\`, and \`set_compression_profile\`. \`compress\` replaces older conversation content with technical summaries you produce. \`decompress\` restores previously compressed content when you need exact details. \`search_context\` searches compressed block summaries (and visible messages) to locate relevant content before you decompress. \`set_compression_profile\` adjusts how aggressively this session compresses content. \`\` and \`\` tags are environment-injected metadata. Do not output them. +ACTIVE COMPRESSION MODE: ${profile.toUpperCase()} + +${config.normalHint} + +Summary limit: ${config.summaryLimit} chars. +${config.protectedItems} + COMPRESSION PHILOSOPHY Compression replaces raw conversation content with dense summaries. When used correctly, it keeps your context sharp and focused. When used carelessly, it destroys information you need. @@ -70,3 +82,6 @@ Use \`search_context\` to find relevant compressed content before decompressing Use \`compress\` and \`decompress\` deliberately with quality-first summaries. Prioritize stale content intelligently to maintain a high-signal context window. ` +} + +export const SYSTEM = buildSystemPrompt("balanced")