Skip to content

Commit 8a6d7c2

Browse files
authored
Merge pull request #161 from Opencode-DCP/dev
merge branch "dev" into branch "master"
2 parents 6989fb6 + d8633b8 commit 8a6d7c2

File tree

6 files changed

+142
-10
lines changed

6 files changed

+142
-10
lines changed

README.md

Lines changed: 6 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,12 +27,12 @@ DCP uses multiple strategies to reduce context size:
2727

2828
**Deduplication** — Identifies repeated tool calls (e.g., reading the same file multiple times) and keeps only the most recent output. Runs automatically on every request with zero LLM cost.
2929

30+
**Supersede Writes** — Prunes write tool inputs for files that have subsequently been read. When a file is written and later read, the original write content becomes redundant since the current file state is captured in the read result. Runs automatically on every request with zero LLM cost.
31+
3032
**Prune Tool** — Exposes a `prune` tool that the AI can call to manually trigger pruning when it determines context cleanup is needed.
3133

3234
**On Idle Analysis** — Uses a language model to semantically analyze conversation context during idle periods and identify tool outputs that are no longer relevant.
3335

34-
*More strategies coming soon.*
35-
3636
Your session history is never modified. DCP replaces pruned outputs with a placeholder before sending requests to your LLM.
3737

3838
## Impact on Prompt Caching
@@ -68,6 +68,10 @@ DCP uses its own config file:
6868
// Additional tools to protect from pruning
6969
"protectedTools": []
7070
},
71+
// Prune write tool inputs when the file has been subsequently read
72+
"supersedeWrites": {
73+
"enabled": true
74+
},
7175
// Exposes a prune tool to your LLM to call when it determines pruning is necessary
7276
"pruneTool": {
7377
"enabled": true,

lib/config.ts

Lines changed: 31 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,10 @@ export interface PruneTool {
2828
nudge: PruneToolNudge
2929
}
3030

31+
export interface SupersedeWrites {
32+
enabled: boolean
33+
}
34+
3135
export interface PluginConfig {
3236
enabled: boolean
3337
debug: boolean
@@ -36,6 +40,7 @@ export interface PluginConfig {
3640
deduplication: Deduplication
3741
onIdle: OnIdle
3842
pruneTool: PruneTool
43+
supersedeWrites: SupersedeWrites
3944
}
4045
}
4146

@@ -53,6 +58,9 @@ export const VALID_CONFIG_KEYS = new Set([
5358
'strategies.deduplication',
5459
'strategies.deduplication.enabled',
5560
'strategies.deduplication.protectedTools',
61+
// strategies.supersedeWrites
62+
'strategies.supersedeWrites',
63+
'strategies.supersedeWrites.enabled',
5664
// strategies.onIdle
5765
'strategies.onIdle',
5866
'strategies.onIdle.enabled',
@@ -66,7 +74,7 @@ export const VALID_CONFIG_KEYS = new Set([
6674
'strategies.pruneTool.protectedTools',
6775
'strategies.pruneTool.nudge',
6876
'strategies.pruneTool.nudge.enabled',
69-
'strategies.pruneTool.nudge.frequency',
77+
'strategies.pruneTool.nudge.frequency'
7078
])
7179

7280
// Extract all key paths from a config object for validation
@@ -159,6 +167,13 @@ function validateConfigTypes(config: Record<string, any>): ValidationError[] {
159167
}
160168
}
161169
}
170+
171+
// supersedeWrites
172+
if (strategies.supersedeWrites) {
173+
if (strategies.supersedeWrites.enabled !== undefined && typeof strategies.supersedeWrites.enabled !== 'boolean') {
174+
errors.push({ key: 'strategies.supersedeWrites.enabled', expected: 'boolean', actual: typeof strategies.supersedeWrites.enabled })
175+
}
176+
}
162177
}
163178

164179
return errors
@@ -219,6 +234,9 @@ const defaultConfig: PluginConfig = {
219234
enabled: true,
220235
protectedTools: [...DEFAULT_PROTECTED_TOOLS]
221236
},
237+
supersedeWrites: {
238+
enabled: true
239+
},
222240
pruneTool: {
223241
enabled: true,
224242
protectedTools: [...DEFAULT_PROTECTED_TOOLS],
@@ -255,15 +273,14 @@ function findOpencodeDir(startDir: string): string | null {
255273
}
256274

257275
function getConfigPaths(ctx?: PluginInput): { global: string | null, configDir: string | null, project: string | null} {
258-
259276
// Global: ~/.config/opencode/dcp.jsonc|json
260277
let globalPath: string | null = null
261278
if (existsSync(GLOBAL_CONFIG_PATH_JSONC)) {
262279
globalPath = GLOBAL_CONFIG_PATH_JSONC
263280
} else if (existsSync(GLOBAL_CONFIG_PATH_JSON)) {
264281
globalPath = GLOBAL_CONFIG_PATH_JSON
265282
}
266-
283+
267284
// Custom config directory: $OPENCODE_CONFIG_DIR/dcp.jsonc|json
268285
let configDirPath: string | null = null
269286
const opencodeConfigDir = process.env.OPENCODE_CONFIG_DIR
@@ -276,7 +293,7 @@ function getConfigPaths(ctx?: PluginInput): { global: string | null, configDir:
276293
configDirPath = configJson
277294
}
278295
}
279-
296+
280297
// Project: <project>/.opencode/dcp.jsonc|json
281298
let projectPath: string | null = null
282299
if (ctx?.directory) {
@@ -315,6 +332,10 @@ function createDefaultConfig(): void {
315332
// Additional tools to protect from pruning
316333
"protectedTools": []
317334
},
335+
// Prune write tool inputs when the file has been subsequently read
336+
"supersedeWrites": {
337+
"enabled": true
338+
},
318339
// Exposes a prune tool to your LLM to call when it determines pruning is necessary
319340
"pruneTool": {
320341
"enabled": true,
@@ -409,6 +430,9 @@ function mergeStrategies(
409430
enabled: override.pruneTool?.nudge?.enabled ?? base.pruneTool.nudge.enabled,
410431
frequency: override.pruneTool?.nudge?.frequency ?? base.pruneTool.nudge.frequency
411432
}
433+
},
434+
supersedeWrites: {
435+
enabled: override.supersedeWrites?.enabled ?? base.supersedeWrites.enabled
412436
}
413437
}
414438
}
@@ -429,6 +453,9 @@ function deepCloneConfig(config: PluginConfig): PluginConfig {
429453
...config.strategies.pruneTool,
430454
protectedTools: [...config.strategies.pruneTool.protectedTools],
431455
nudge: { ...config.strategies.pruneTool.nudge }
456+
},
457+
supersedeWrites: {
458+
...config.strategies.supersedeWrites
432459
}
433460
}
434461
}

lib/hooks.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import type { SessionState, WithParts } from "./state"
22
import type { Logger } from "./logger"
33
import type { PluginConfig } from "./config"
44
import { syncToolCache } from "./state/tool-cache"
5-
import { deduplicate } from "./strategies"
5+
import { deduplicate, supersedeWrites } from "./strategies"
66
import { prune, insertPruneToolContext } from "./messages"
77
import { checkSession } from "./state"
88
import { runOnIdle } from "./strategies/on-idle"
@@ -27,6 +27,7 @@ export function createChatMessageTransformHandler(
2727
syncToolCache(state, config, logger, output.messages);
2828

2929
deduplicate(state, logger, config, output.messages)
30+
supersedeWrites(state, logger, config, output.messages)
3031

3132
prune(state, logger, config, output.messages)
3233

lib/state/state.ts

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -84,21 +84,20 @@ export async function ensureSessionInitialized(
8484
logger.info("session ID = " + sessionId)
8585
logger.info("Initializing session state", { sessionId: sessionId })
8686

87-
// Clear previous session data
8887
resetSessionState(state)
8988
state.sessionId = sessionId
9089

9190
const isSubAgent = await isSubAgentSession(client, sessionId)
9291
state.isSubAgent = isSubAgent
9392
logger.info("isSubAgent = " + isSubAgent)
9493

95-
// Load session data from storage
94+
state.lastCompaction = findLastCompactionTimestamp(messages)
95+
9696
const persisted = await loadSessionState(sessionId, logger)
9797
if (persisted === null) {
9898
return;
9999
}
100100

101-
// Populate state with loaded data
102101
state.prune = {
103102
toolIds: persisted.prune.toolIds || []
104103
}

lib/strategies/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export { deduplicate } from "./deduplication"
22
export { runOnIdle } from "./on-idle"
33
export { createPruneTool } from "./prune-tool"
4+
export { supersedeWrites } from "./supersede-writes"

lib/strategies/supersede-writes.ts

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import { PluginConfig } from "../config"
2+
import { Logger } from "../logger"
3+
import type { SessionState, WithParts } from "../state"
4+
import { buildToolIdList } from "../messages/utils"
5+
import { calculateTokensSaved } from "./utils"
6+
7+
/**
8+
* Supersede Writes strategy - prunes write tool inputs for files that have
9+
* subsequently been read. When a file is written and later read, the original
10+
* write content becomes redundant since the current file state is captured
11+
* in the read result.
12+
*
13+
* Modifies the session state in place to add pruned tool call IDs.
14+
*/
15+
export const supersedeWrites = (
16+
state: SessionState,
17+
logger: Logger,
18+
config: PluginConfig,
19+
messages: WithParts[]
20+
): void => {
21+
if (!config.strategies.supersedeWrites.enabled) {
22+
return
23+
}
24+
25+
// Build list of all tool call IDs from messages (chronological order)
26+
const allToolIds = buildToolIdList(state, messages, logger)
27+
if (allToolIds.length === 0) {
28+
return
29+
}
30+
31+
// Filter out IDs already pruned
32+
const alreadyPruned = new Set(state.prune.toolIds)
33+
34+
const unprunedIds = allToolIds.filter(id => !alreadyPruned.has(id))
35+
if (unprunedIds.length === 0) {
36+
return
37+
}
38+
39+
// Track write tools by file path: filePath -> [{ id, index }]
40+
// We track index to determine chronological order
41+
const writesByFile = new Map<string, { id: string, index: number }[]>()
42+
43+
// Track read file paths with their index
44+
const readsByFile = new Map<string, number[]>()
45+
46+
for (let i = 0; i < allToolIds.length; i++) {
47+
const id = allToolIds[i]
48+
const metadata = state.toolParameters.get(id)
49+
if (!metadata) {
50+
continue
51+
}
52+
53+
const filePath = metadata.parameters?.filePath
54+
if (!filePath) {
55+
continue
56+
}
57+
58+
if (metadata.tool === 'write') {
59+
if (!writesByFile.has(filePath)) {
60+
writesByFile.set(filePath, [])
61+
}
62+
writesByFile.get(filePath)!.push({ id, index: i })
63+
} else if (metadata.tool === 'read') {
64+
if (!readsByFile.has(filePath)) {
65+
readsByFile.set(filePath, [])
66+
}
67+
readsByFile.get(filePath)!.push(i)
68+
}
69+
}
70+
71+
// Find writes that are superseded by subsequent reads
72+
const newPruneIds: string[] = []
73+
74+
for (const [filePath, writes] of writesByFile.entries()) {
75+
const reads = readsByFile.get(filePath)
76+
if (!reads || reads.length === 0) {
77+
continue
78+
}
79+
80+
// For each write, check if there's a read that comes after it
81+
for (const write of writes) {
82+
// Skip if already pruned
83+
if (alreadyPruned.has(write.id)) {
84+
continue
85+
}
86+
87+
// Check if any read comes after this write
88+
const hasSubsequentRead = reads.some(readIndex => readIndex > write.index)
89+
if (hasSubsequentRead) {
90+
newPruneIds.push(write.id)
91+
}
92+
}
93+
}
94+
95+
if (newPruneIds.length > 0) {
96+
state.stats.totalPruneTokens += calculateTokensSaved(state, messages, newPruneIds)
97+
state.prune.toolIds.push(...newPruneIds)
98+
logger.debug(`Marked ${newPruneIds.length} superseded write tool calls for pruning`)
99+
}
100+
}

0 commit comments

Comments
 (0)