Skip to content
Open
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
7 changes: 7 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
28 changes: 27 additions & 1 deletion dcp.schema.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"],

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Allow partial learning overrides

When a user sets only compress.learning.enabled, or a project layer overrides only notifications, this requirement makes an otherwise usable layered configuration schema-invalid; validateConfigTypes likewise reports the omitted sibling as having type undefined, even though mergeCompress explicitly supplies both missing values from the previous/default layer. Remove the requirement and validate each property only when present so users can override these settings independently like the other nested configuration fields.

Useful? React with 👍 / 👎.

"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": {
Expand All @@ -260,7 +282,11 @@
"nudgeForce": "soft",
"protectedTools": [],
"protectTags": false,
"protectUserMessages": false
"protectUserMessages": false,
"learning": {
"enabled": false,
"notifications": true
}
}
},
"strategies": {
Expand Down
7 changes: 6 additions & 1 deletion lib/compress/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down Expand Up @@ -43,7 +44,11 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
const runtimePrompts = ctx.prompts.getRuntimePrompts()

return tool({
description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
description:
appendCompressionLearning(
runtimePrompts.compressMessage,
ctx.config.compress.learning,
) + MESSAGE_FORMAT_EXTENSION,
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressMessageToolArgs
Expand Down
5 changes: 4 additions & 1 deletion lib/compress/range.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
import type { ToolContext } from "./types"
import { countTokens } from "../token-utils"
import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
import { appendCompressionLearning } from "../prompts/extensions/learning"
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
import {
appendProtectedPromptInfo,
Expand Down Expand Up @@ -58,7 +59,9 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
const runtimePrompts = ctx.prompts.getRuntimePrompts()

return tool({
description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
description:
appendCompressionLearning(runtimePrompts.compressRange, ctx.config.compress.learning) +
RANGE_FORMAT_EXTENSION,
args: buildSchema(),
async execute(args, toolCtx) {
const input = args as CompressRangeToolArgs
Expand Down
48 changes: 48 additions & 0 deletions lib/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ export interface Deduplication {
protectedTools: string[]
}

export interface CompressionLearningConfig {
enabled: boolean
notifications: boolean
}

export interface CompressConfig {
mode: CompressMode
permission: Permission
Expand All @@ -27,6 +32,7 @@ export interface CompressConfig {
protectedTools: string[]
protectTags: boolean
protectUserMessages: boolean
learning: CompressionLearningConfig
}

export interface Commands {
Expand Down Expand Up @@ -126,6 +132,9 @@ export const VALID_CONFIG_KEYS = new Set([
"compress.protectedTools",
"compress.protectTags",
"compress.protectUserMessages",
"compress.learning",
"compress.learning.enabled",
"compress.learning.notifications",
"strategies",
"strategies.deduplication",
"strategies.deduplication.enabled",
Expand Down Expand Up @@ -443,6 +452,36 @@ export function validateConfigTypes(config: Record<string, any>): 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
Expand Down Expand Up @@ -689,6 +728,10 @@ const defaultConfig: PluginConfig = {
protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
protectTags: false,
protectUserMessages: false,
learning: {
enabled: false,
notifications: true,
},
},
strategies: {
deduplication: {
Expand Down Expand Up @@ -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,
},
}
}

Expand Down Expand Up @@ -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: {
Expand Down
57 changes: 57 additions & 0 deletions lib/prompts/extensions/learning.ts
Original file line number Diff line number Diff line change
@@ -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")
}
51 changes: 51 additions & 0 deletions tests/compression-learning.test.ts
Original file line number Diff line number Diff line change
@@ -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> = {}): 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/)
})
23 changes: 23 additions & 0 deletions tests/config-learning.test.ts
Original file line number Diff line number Diff line change
@@ -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)
})