Skip to content

Commit d72fb98

Browse files
committed
feat: optional learning pass prior to compression
1 parent 85b6f5c commit d72fb98

8 files changed

Lines changed: 223 additions & 3 deletions

File tree

README.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -162,6 +162,13 @@ Each level overrides the previous, so project settings take priority over global
162162
// Preserve your messages during compression.
163163
// Warning: large copy-pasted prompts will never be compressed away
164164
"protectUserMessages": false,
165+
// Optional durable-learning pass before compression
166+
"learning": {
167+
// Disabled by default because learning may edit project guidance
168+
"enabled": false,
169+
// Show progress messages before and after learning
170+
"notifications": true,
171+
},
165172
},
166173
// Automatic pruning strategies
167174
"strategies": {

dcp.schema.json

Lines changed: 27 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -246,6 +246,28 @@
246246
"type": "boolean",
247247
"default": false,
248248
"description": "When enabled, your messages are never lost during compression"
249+
},
250+
"learning": {
251+
"type": "object",
252+
"description": "Optional learning pass before compression that extracts durable codebase knowledge",
253+
"additionalProperties": false,
254+
"required": ["enabled", "notifications"],
255+
"properties": {
256+
"enabled": {
257+
"type": "boolean",
258+
"default": false,
259+
"description": "Run a durable-learning pass before invoking the compress tool"
260+
},
261+
"notifications": {
262+
"type": "boolean",
263+
"default": true,
264+
"description": "Show progress messages before and after the learning pass"
265+
}
266+
},
267+
"default": {
268+
"enabled": false,
269+
"notifications": true
270+
}
249271
}
250272
},
251273
"default": {
@@ -260,7 +282,11 @@
260282
"nudgeForce": "soft",
261283
"protectedTools": [],
262284
"protectTags": false,
263-
"protectUserMessages": false
285+
"protectUserMessages": false,
286+
"learning": {
287+
"enabled": false,
288+
"notifications": true
289+
}
264290
}
265291
},
266292
"strategies": {

lib/compress/message.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
22
import type { ToolContext } from "./types"
33
import { countTokens } from "../token-utils"
44
import { MESSAGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
5+
import { appendCompressionLearning } from "../prompts/extensions/learning"
56
import { formatIssues, formatResult, resolveMessages, validateArgs } from "./message-utils"
67
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
78
import { appendProtectedPromptInfo, appendProtectedTools } from "./protected-content"
@@ -43,7 +44,11 @@ export function createCompressMessageTool(ctx: ToolContext): ReturnType<typeof t
4344
const runtimePrompts = ctx.prompts.getRuntimePrompts()
4445

4546
return tool({
46-
description: runtimePrompts.compressMessage + MESSAGE_FORMAT_EXTENSION,
47+
description:
48+
appendCompressionLearning(
49+
runtimePrompts.compressMessage,
50+
ctx.config.compress.learning,
51+
) + MESSAGE_FORMAT_EXTENSION,
4752
args: buildSchema(),
4853
async execute(args, toolCtx) {
4954
const input = args as CompressMessageToolArgs

lib/compress/range.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { tool } from "@opencode-ai/plugin"
22
import type { ToolContext } from "./types"
33
import { countTokens } from "../token-utils"
44
import { RANGE_FORMAT_EXTENSION } from "../prompts/extensions/tool"
5+
import { appendCompressionLearning } from "../prompts/extensions/learning"
56
import { finalizeSession, prepareSession, type NotificationEntry } from "./pipeline"
67
import {
78
appendProtectedPromptInfo,
@@ -58,7 +59,9 @@ export function createCompressRangeTool(ctx: ToolContext): ReturnType<typeof too
5859
const runtimePrompts = ctx.prompts.getRuntimePrompts()
5960

6061
return tool({
61-
description: runtimePrompts.compressRange + RANGE_FORMAT_EXTENSION,
62+
description:
63+
appendCompressionLearning(runtimePrompts.compressRange, ctx.config.compress.learning) +
64+
RANGE_FORMAT_EXTENSION,
6265
args: buildSchema(),
6366
async execute(args, toolCtx) {
6467
const input = args as CompressRangeToolArgs

lib/config.ts

Lines changed: 48 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,11 @@ export interface Deduplication {
1212
protectedTools: string[]
1313
}
1414

15+
export interface CompressionLearningConfig {
16+
enabled: boolean
17+
notifications: boolean
18+
}
19+
1520
export interface CompressConfig {
1621
mode: CompressMode
1722
permission: Permission
@@ -27,6 +32,7 @@ export interface CompressConfig {
2732
protectedTools: string[]
2833
protectTags: boolean
2934
protectUserMessages: boolean
35+
learning: CompressionLearningConfig
3036
}
3137

3238
export interface Commands {
@@ -126,6 +132,9 @@ export const VALID_CONFIG_KEYS = new Set([
126132
"compress.protectedTools",
127133
"compress.protectTags",
128134
"compress.protectUserMessages",
135+
"compress.learning",
136+
"compress.learning.enabled",
137+
"compress.learning.notifications",
129138
"strategies",
130139
"strategies.deduplication",
131140
"strategies.deduplication.enabled",
@@ -443,6 +452,36 @@ export function validateConfigTypes(config: Record<string, any>): ValidationErro
443452
})
444453
}
445454

455+
if (compress.learning !== undefined) {
456+
if (
457+
typeof compress.learning !== "object" ||
458+
compress.learning === null ||
459+
Array.isArray(compress.learning)
460+
) {
461+
errors.push({
462+
key: "compress.learning",
463+
expected: "object",
464+
actual: typeof compress.learning,
465+
})
466+
} else {
467+
if (typeof compress.learning.enabled !== "boolean") {
468+
errors.push({
469+
key: "compress.learning.enabled",
470+
expected: "boolean",
471+
actual: typeof compress.learning.enabled,
472+
})
473+
}
474+
475+
if (typeof compress.learning.notifications !== "boolean") {
476+
errors.push({
477+
key: "compress.learning.notifications",
478+
expected: "boolean",
479+
actual: typeof compress.learning.notifications,
480+
})
481+
}
482+
}
483+
}
484+
446485
if (
447486
typeof compress.iterationNudgeThreshold === "number" &&
448487
compress.iterationNudgeThreshold < 1
@@ -689,6 +728,10 @@ const defaultConfig: PluginConfig = {
689728
protectedTools: [...COMPRESS_DEFAULT_PROTECTED_TOOLS],
690729
protectTags: false,
691730
protectUserMessages: false,
731+
learning: {
732+
enabled: false,
733+
notifications: true,
734+
},
692735
},
693736
strategies: {
694737
deduplication: {
@@ -855,6 +898,10 @@ function mergeCompress(
855898
protectedTools: [...new Set([...base.protectedTools, ...(override.protectedTools ?? [])])],
856899
protectTags: override.protectTags ?? base.protectTags,
857900
protectUserMessages: override.protectUserMessages ?? base.protectUserMessages,
901+
learning: {
902+
enabled: override.learning?.enabled ?? base.learning.enabled,
903+
notifications: override.learning?.notifications ?? base.learning.notifications,
904+
},
858905
}
859906
}
860907

@@ -915,6 +962,7 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
915962
modelMaxLimits: { ...config.compress.modelMaxLimits },
916963
modelMinLimits: { ...config.compress.modelMinLimits },
917964
protectedTools: [...config.compress.protectedTools],
965+
learning: { ...config.compress.learning },
918966
},
919967
strategies: {
920968
deduplication: {

lib/prompts/extensions/learning.ts

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
import type { CompressionLearningConfig } from "../../config"
2+
3+
const DURABLE_LEARNING_CRITERIA = `Extract only new, non-obvious, durable codebase knowledge:
4+
5+
- hidden relationships between files or modules;
6+
- execution paths that differ from how the code appears;
7+
- non-obvious configuration, environment variables, or flags;
8+
- debugging breakthroughs when errors were misleading;
9+
- API or tool quirks and their workarounds;
10+
- useful build or test commands not already documented;
11+
- architectural decisions and constraints;
12+
- files that must change together.
13+
14+
Do not treat obvious documented facts, standard language or framework behavior, existing repository guidance, verbose explanations, or session-specific details as durable learning.`
15+
16+
function renderNotificationInstructions(): string {
17+
return `Before starting the learning pass, send the user this exact progress message:
18+
19+
\`Initialized pre-compression learning.\`
20+
21+
After the learning pass, send one concise progress message before invoking the compress tool:
22+
23+
- If durable learning was found, start with \`Learning is finished.\` and briefly list the insights and any files changed.
24+
- If there was no durable learning, send exactly \`Learning is finished. Nothing to extract.\`
25+
26+
These messages must not interrupt compression or ask the user for input.`
27+
}
28+
29+
export function appendCompressionLearning(
30+
prompt: string,
31+
config?: CompressionLearningConfig,
32+
): string {
33+
if (!config?.enabled) {
34+
return prompt
35+
}
36+
37+
const sections = [
38+
prompt.trim(),
39+
`LEARN BEFORE COMPRESSION
40+
41+
Before invoking the compress tool, review the selected closed context for durable codebase learning.
42+
43+
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.
44+
45+
${DURABLE_LEARNING_CRITERIA}
46+
47+
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.
48+
49+
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.`,
50+
]
51+
52+
if (config.notifications) {
53+
sections.push(renderNotificationInstructions())
54+
}
55+
56+
return sections.join("\n\n")
57+
}

tests/compression-learning.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { appendCompressionLearning } from "../lib/prompts/extensions/learning"
4+
import type { CompressionLearningConfig } from "../lib/config"
5+
6+
const BASE_PROMPT = "Custom compression instructions."
7+
8+
function config(overrides: Partial<CompressionLearningConfig> = {}): CompressionLearningConfig {
9+
return {
10+
enabled: true,
11+
notifications: true,
12+
...overrides,
13+
}
14+
}
15+
16+
test("disabled compression learning leaves the effective prompt unchanged", () => {
17+
const result = appendCompressionLearning(BASE_PROMPT, config({ enabled: false }))
18+
19+
assert.equal(result, BASE_PROMPT)
20+
})
21+
22+
test("learning appends durable criteria and project-policy guidance", () => {
23+
const result = appendCompressionLearning(BASE_PROMPT, config())
24+
25+
assert.ok(result.startsWith(BASE_PROMPT))
26+
assert.match(result, /LEARN BEFORE COMPRESSION/)
27+
assert.match(result, /hidden relationships between files or modules/)
28+
assert.match(result, /follow any learning policy present in the system or project instructions/)
29+
assert.match(result, /do not override more specific project learning rules/)
30+
assert.match(result, /Initialized pre-compression learning\./)
31+
})
32+
33+
test("learning persists findings according to project policy", () => {
34+
const result = appendCompressionLearning(BASE_PROMPT, config())
35+
36+
assert.match(result, /narrowest applicable directory/)
37+
assert.match(result, /follow the project's persistence policy/)
38+
assert.match(result, /nearest appropriate AGENTS\.md/)
39+
assert.match(
40+
result,
41+
/Do not create or edit guidance files when there is no durable new learning/,
42+
)
43+
})
44+
45+
test("notifications can be disabled independently", () => {
46+
const result = appendCompressionLearning(BASE_PROMPT, config({ notifications: false }))
47+
48+
assert.match(result, /LEARN BEFORE COMPRESSION/)
49+
assert.doesNotMatch(result, /Initialized pre-compression learning\./)
50+
assert.doesNotMatch(result, /Learning is finished/)
51+
})

tests/config-learning.test.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import assert from "node:assert/strict"
2+
import test from "node:test"
3+
import { readFileSync } from "node:fs"
4+
5+
const schema = JSON.parse(readFileSync(new URL("../dcp.schema.json", import.meta.url), "utf-8"))
6+
const learningSchema = schema.properties.compress.properties.learning
7+
8+
test("compression learning schema is opt-in and side-effect free by default", () => {
9+
assert.deepEqual(learningSchema.default, {
10+
enabled: false,
11+
notifications: true,
12+
})
13+
})
14+
15+
test("compression learning schema accepts only documented settings", () => {
16+
assert.deepEqual(Object.keys(learningSchema.properties).sort(), ["enabled", "notifications"])
17+
assert.deepEqual(learningSchema.required.sort(), ["enabled", "notifications"])
18+
assert.equal(learningSchema.additionalProperties, false)
19+
})
20+
21+
test("compression defaults include learning settings", () => {
22+
assert.deepEqual(schema.properties.compress.default.learning, learningSchema.default)
23+
})

0 commit comments

Comments
 (0)