From d72fb98f53bd94f58f20431b909ae1f57a0ef45f Mon Sep 17 00:00:00 2001 From: Nikolay Malkovsky Date: Fri, 31 Jul 2026 15:41:22 +0300 Subject: [PATCH] feat: optional learning pass prior to compression --- README.md | 7 ++++ dcp.schema.json | 28 ++++++++++++++- lib/compress/message.ts | 7 +++- lib/compress/range.ts | 5 ++- lib/config.ts | 48 +++++++++++++++++++++++++ lib/prompts/extensions/learning.ts | 57 ++++++++++++++++++++++++++++++ tests/compression-learning.test.ts | 51 ++++++++++++++++++++++++++ tests/config-learning.test.ts | 23 ++++++++++++ 8 files changed, 223 insertions(+), 3 deletions(-) create mode 100644 lib/prompts/extensions/learning.ts create mode 100644 tests/compression-learning.test.ts create mode 100644 tests/config-learning.test.ts diff --git a/README.md b/README.md index d2c068c8..7c01141a 100644 --- a/README.md +++ b/README.md @@ -162,6 +162,13 @@ Each level overrides the previous, so project settings take priority over global // Preserve your messages during compression. // Warning: large copy-pasted prompts will never be compressed away "protectUserMessages": false, + // Optional durable-learning pass before compression + "learning": { + // Disabled by default because learning may edit project guidance + "enabled": false, + // Show progress messages before and after learning + "notifications": true, + }, }, // Automatic pruning strategies "strategies": { diff --git a/dcp.schema.json b/dcp.schema.json index 39f2df53..fd918a55 100644 --- a/dcp.schema.json +++ b/dcp.schema.json @@ -246,6 +246,28 @@ "type": "boolean", "default": false, "description": "When enabled, your messages are never lost during compression" + }, + "learning": { + "type": "object", + "description": "Optional learning pass before compression that extracts durable codebase knowledge", + "additionalProperties": false, + "required": ["enabled", "notifications"], + "properties": { + "enabled": { + "type": "boolean", + "default": false, + "description": "Run a durable-learning pass before invoking the compress tool" + }, + "notifications": { + "type": "boolean", + "default": true, + "description": "Show progress messages before and after the learning pass" + } + }, + "default": { + "enabled": false, + "notifications": true + } } }, "default": { @@ -260,7 +282,11 @@ "nudgeForce": "soft", "protectedTools": [], "protectTags": false, - "protectUserMessages": false + "protectUserMessages": false, + "learning": { + "enabled": false, + "notifications": true + } } }, "strategies": { diff --git a/lib/compress/message.ts b/lib/compress/message.ts index d6bf8874..2743334a 100644 --- a/lib/compress/message.ts +++ b/lib/compress/message.ts @@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin" import type { ToolContext } from "./types" import { countTokens } from "../token-utils" import { MESSAGE_FORMAT_EXTENSION } from "../prompts/extensions/tool" +import { appendCompressionLearning } from "../prompts/extensions/learning" import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils" import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline" import { appendProtectedPromptInfo, appendProtectedTools } from "./protected-content" @@ -43,7 +44,11 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType): ValidationErro }) } + if (compress.learning !== undefined) { + if ( + typeof compress.learning !== "object" || + compress.learning === null || + Array.isArray(compress.learning) + ) { + errors.push({ + key: "compress.learning", + expected: "object", + actual: typeof compress.learning, + }) + } else { + if (typeof compress.learning.enabled !== "boolean") { + errors.push({ + key: "compress.learning.enabled", + expected: "boolean", + actual: typeof compress.learning.enabled, + }) + } + + if (typeof compress.learning.notifications !== "boolean") { + errors.push({ + key: "compress.learning.notifications", + expected: "boolean", + actual: typeof compress.learning.notifications, + }) + } + } + } + if ( typeof compress.iterationNudgeThreshold === "number" && compress.iterationNudgeThreshold < 1 @@ -689,6 +728,10 @@ const defaultConfig: PluginConfig = { protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS], protectTags: false, protectUserMessages: false, + learning: { + enabled: false, + notifications: true, + }, }, strategies: { deduplication: { @@ -855,6 +898,10 @@ function mergeCompress( protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])], protectTags: override.protectTags ?? base.protectTags, protectUserMessages: override.protectUserMessages ?? base.protectUserMessages, + learning: { + enabled: override.learning?.enabled ?? base.learning.enabled, + notifications: override.learning?.notifications ?? base.learning.notifications, + }, } } @@ -915,6 +962,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig { modelMaxLimits: { ...config.compress.modelMaxLimits }, modelMinLimits: { ...config.compress.modelMinLimits }, protectedTools: [...config.compress.protectedTools], + learning: { ...config.compress.learning }, }, strategies: { deduplication: { diff --git a/lib/prompts/extensions/learning.ts b/lib/prompts/extensions/learning.ts new file mode 100644 index 00000000..3b89ff99 --- /dev/null +++ b/lib/prompts/extensions/learning.ts @@ -0,0 +1,57 @@ +import type { CompressionLearningConfig } from "../../config" + +const DURABLE_LEARNING_CRITERIA = `Extract only new, non-obvious, durable codebase knowledge: + +- hidden relationships between files or modules; +- execution paths that differ from how the code appears; +- non-obvious configuration, environment variables, or flags; +- debugging breakthroughs when errors were misleading; +- API or tool quirks and their workarounds; +- useful build or test commands not already documented; +- architectural decisions and constraints; +- files that must change together. + +Do not treat obvious documented facts, standard language or framework behavior, existing repository guidance, verbose explanations, or session-specific details as durable learning.` + +function renderNotificationInstructions(): string { + return `Before starting the learning pass, send the user this exact progress message: + +\`Initialized pre-compression learning.\` + +After the learning pass, send one concise progress message before invoking the compress tool: + +- If durable learning was found, start with \`Learning is finished.\` and briefly list the insights and any files changed. +- If there was no durable learning, send exactly \`Learning is finished. Nothing to extract.\` + +These messages must not interrupt compression or ask the user for input.` +} + +export function appendCompressionLearning( + prompt: string, + config?: CompressionLearningConfig, +): string { + if (!config?.enabled) { + return prompt + } + + const sections = [ + prompt.trim(), + `LEARN BEFORE COMPRESSION + +Before invoking the compress tool, review the selected closed context for durable codebase learning. + +First follow any learning policy present in the system or project instructions. Read the applicable repository guidance before deciding what to extract or where to persist it. Treat the criteria below as defaults where project policy is silent; do not override more specific project learning rules. + +${DURABLE_LEARNING_CRITERIA} + +When genuine new learning exists, determine its narrowest applicable directory and follow the project's persistence policy. If the project does not specify a destination, read the relevant existing AGENTS.md files and persist each insight in 1-3 concise lines in the nearest appropriate AGENTS.md before compressing. Do not create or edit guidance files when there is no durable new learning. + +Do not manufacture learning or delay necessary compression. The compression summary must still preserve all session state needed to continue; persisted guidance complements rather than replaces that summary.`, + ] + + if (config.notifications) { + sections.push(renderNotificationInstructions()) + } + + return sections.join("\n\n") +} diff --git a/tests/compression-learning.test.ts b/tests/compression-learning.test.ts new file mode 100644 index 00000000..53f727ea --- /dev/null +++ b/tests/compression-learning.test.ts @@ -0,0 +1,51 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { appendCompressionLearning } from "../lib/prompts/extensions/learning" +import type { CompressionLearningConfig } from "../lib/config" + +const BASE_PROMPT = "Custom compression instructions." + +function config(overrides: Partial = {}): CompressionLearningConfig { + return { + enabled: true, + notifications: true, + ...overrides, + } +} + +test("disabled compression learning leaves the effective prompt unchanged", () => { + const result = appendCompressionLearning(BASE_PROMPT, config({ enabled: false })) + + assert.equal(result, BASE_PROMPT) +}) + +test("learning appends durable criteria and project-policy guidance", () => { + const result = appendCompressionLearning(BASE_PROMPT, config()) + + assert.ok(result.startsWith(BASE_PROMPT)) + assert.match(result, /LEARN BEFORE COMPRESSION/) + assert.match(result, /hidden relationships between files or modules/) + assert.match(result, /follow any learning policy present in the system or project instructions/) + assert.match(result, /do not override more specific project learning rules/) + assert.match(result, /Initialized pre-compression learning\./) +}) + +test("learning persists findings according to project policy", () => { + const result = appendCompressionLearning(BASE_PROMPT, config()) + + assert.match(result, /narrowest applicable directory/) + assert.match(result, /follow the project's persistence policy/) + assert.match(result, /nearest appropriate AGENTS\.md/) + assert.match( + result, + /Do not create or edit guidance files when there is no durable new learning/, + ) +}) + +test("notifications can be disabled independently", () => { + const result = appendCompressionLearning(BASE_PROMPT, config({ notifications: false })) + + assert.match(result, /LEARN BEFORE COMPRESSION/) + assert.doesNotMatch(result, /Initialized pre-compression learning\./) + assert.doesNotMatch(result, /Learning is finished/) +}) diff --git a/tests/config-learning.test.ts b/tests/config-learning.test.ts new file mode 100644 index 00000000..6913684e --- /dev/null +++ b/tests/config-learning.test.ts @@ -0,0 +1,23 @@ +import assert from "node:assert/strict" +import test from "node:test" +import { readFileSync } from "node:fs" + +const schema = JSON.parse(readFileSync(new URL("../dcp.schema.json", import.meta.url), "utf-8")) +const learningSchema = schema.properties.compress.properties.learning + +test("compression learning schema is opt-in and side-effect free by default", () => { + assert.deepEqual(learningSchema.default, { + enabled: false, + notifications: true, + }) +}) + +test("compression learning schema accepts only documented settings", () => { + assert.deepEqual(Object.keys(learningSchema.properties).sort(), ["enabled", "notifications"]) + assert.deepEqual(learningSchema.required.sort(), ["enabled", "notifications"]) + assert.equal(learningSchema.additionalProperties, false) +}) + +test("compression defaults include learning settings", () => { + assert.deepEqual(schema.properties.compress.default.learning, learningSchema.default) +})