Skip to content

Commit 765e8e4

Browse files
committed
feat: persist pruned tool IDs across OpenCode restarts
Adds state persistence so pruned tool IDs survive OpenCode restarts. State is saved per-session to ~/.local/share/opencode/storage/plugin/dcp/{sessionId}.json Closes #16
1 parent eb37c5e commit 765e8e4

File tree

7 files changed

+158
-16
lines changed

7 files changed

+158
-16
lines changed

lib/fetch-wrapper/gemini.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -53,7 +53,7 @@ export async function handleGemini(
5353
return { modified, body }
5454
}
5555

56-
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)
56+
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state, ctx.logger)
5757

5858
if (allPrunedIds.size === 0) {
5959
return { modified, body }

lib/fetch-wrapper/openai-chat.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -61,7 +61,7 @@ export async function handleOpenAIChatAndAnthropic(
6161
return false
6262
})
6363

64-
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)
64+
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state, ctx.logger)
6565

6666
if (toolMessages.length === 0 || allPrunedIds.size === 0) {
6767
return { modified, body }

lib/fetch-wrapper/openai-responses.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,7 +55,7 @@ export async function handleOpenAIResponses(
5555
return { modified, body }
5656
}
5757

58-
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state)
58+
const { allSessions, allPrunedIds } = await getAllPrunedIds(ctx.client, ctx.state, ctx.logger)
5959

6060
if (allPrunedIds.size === 0) {
6161
return { modified, body }

lib/fetch-wrapper/types.ts

Lines changed: 8 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { PluginState } from "../state"
1+
import { type PluginState, ensureSessionRestored } from "../state"
22
import type { Logger } from "../logger"
33
import type { ToolTracker } from "../synth-instruction"
44
import type { PluginConfig } from "../config"
@@ -36,22 +36,19 @@ export interface PrunedIdData {
3636
allPrunedIds: Set<string>
3737
}
3838

39-
/**
40-
* Get all pruned IDs across all non-subagent sessions.
41-
*/
4239
export async function getAllPrunedIds(
4340
client: any,
44-
state: PluginState
41+
state: PluginState,
42+
logger?: Logger
4543
): Promise<PrunedIdData> {
4644
const allSessions = await client.session.list()
4745
const allPrunedIds = new Set<string>()
4846

49-
if (allSessions.data) {
50-
for (const session of allSessions.data) {
51-
if (session.parentID) continue
52-
const prunedIds = state.prunedIds.get(session.id) ?? []
53-
prunedIds.forEach((id: string) => allPrunedIds.add(id))
54-
}
47+
const currentSession = getMostRecentActiveSession(allSessions)
48+
if (currentSession) {
49+
await ensureSessionRestored(state, currentSession.id, logger)
50+
const prunedIds = state.prunedIds.get(currentSession.id) ?? []
51+
prunedIds.forEach((id: string) => allPrunedIds.add(id))
5552
}
5653

5754
return { allSessions, allPrunedIds }

lib/janitor.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import { selectModel, extractModelFromSession } from "./model-selector"
66
import { estimateTokensBatch, formatTokenCount } from "./tokenizer"
77
import { detectDuplicates } from "./deduplicator"
88
import { extractParameterKey } from "./display-utils"
9+
import { saveSessionState } from "./state-persistence"
910

1011
export interface SessionStats {
1112
totalToolsPruned: number
@@ -97,8 +98,6 @@ export class Janitor {
9798
return null
9899
}
99100

100-
// Extract the current agent from the last user message to preserve agent context
101-
// Following the same pattern as OpenCode's server.ts
102101
let currentAgent: string | undefined = undefined
103102
for (let i = messages.length - 1; i >= 0; i--) {
104103
const msg = messages[i]
@@ -330,6 +329,10 @@ export class Janitor {
330329
const allPrunedIds = [...new Set([...alreadyPrunedIds, ...finalPrunedIds])]
331330
this.prunedIdsState.set(sessionID, allPrunedIds)
332331

332+
saveSessionState(sessionID, new Set(allPrunedIds), sessionStats, this.logger).catch(err => {
333+
this.logger.error("janitor", "Failed to persist state", { error: err.message })
334+
})
335+
333336
const prunedCount = finalNewlyPrunedIds.length
334337
const keptCount = candidateCount - prunedCount
335338
const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0

lib/state-persistence.ts

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
/**
2+
* State persistence module for DCP plugin.
3+
* Persists pruned tool IDs across sessions so they survive OpenCode restarts.
4+
* Storage location: ~/.local/share/opencode/storage/plugin/dcp/{sessionId}.json
5+
*/
6+
7+
import * as fs from "fs/promises";
8+
import { existsSync } from "fs";
9+
import { homedir } from "os";
10+
import { join } from "path";
11+
import type { SessionStats } from "./janitor";
12+
import type { Logger } from "./logger";
13+
14+
export interface PersistedSessionState {
15+
prunedIds: string[];
16+
stats: SessionStats;
17+
lastUpdated: string;
18+
}
19+
20+
const STORAGE_DIR = join(
21+
homedir(),
22+
".local",
23+
"share",
24+
"opencode",
25+
"storage",
26+
"plugin",
27+
"dcp"
28+
);
29+
30+
async function ensureStorageDir(): Promise<void> {
31+
if (!existsSync(STORAGE_DIR)) {
32+
await fs.mkdir(STORAGE_DIR, { recursive: true });
33+
}
34+
}
35+
36+
function getSessionFilePath(sessionId: string): string {
37+
return join(STORAGE_DIR, `${sessionId}.json`);
38+
}
39+
40+
export async function saveSessionState(
41+
sessionId: string,
42+
prunedIds: Set<string>,
43+
stats: SessionStats,
44+
logger?: Logger
45+
): Promise<void> {
46+
try {
47+
await ensureStorageDir();
48+
49+
const state: PersistedSessionState = {
50+
prunedIds: Array.from(prunedIds),
51+
stats,
52+
lastUpdated: new Date().toISOString(),
53+
};
54+
55+
const filePath = getSessionFilePath(sessionId);
56+
const content = JSON.stringify(state, null, 2);
57+
await fs.writeFile(filePath, content, "utf-8");
58+
59+
logger?.info("persist", "Saved session state to disk", {
60+
sessionId: sessionId.slice(0, 8),
61+
prunedIds: prunedIds.size,
62+
totalTokensSaved: stats.totalTokensSaved,
63+
});
64+
} catch (error: any) {
65+
logger?.error("persist", "Failed to save session state", {
66+
sessionId: sessionId.slice(0, 8),
67+
error: error?.message,
68+
});
69+
}
70+
}
71+
72+
export async function loadSessionState(
73+
sessionId: string,
74+
logger?: Logger
75+
): Promise<PersistedSessionState | null> {
76+
try {
77+
const filePath = getSessionFilePath(sessionId);
78+
79+
if (!existsSync(filePath)) {
80+
return null;
81+
}
82+
83+
const content = await fs.readFile(filePath, "utf-8");
84+
const state = JSON.parse(content) as PersistedSessionState;
85+
86+
if (!state || !Array.isArray(state.prunedIds) || !state.stats) {
87+
logger?.warn("persist", "Invalid session state file, ignoring", {
88+
sessionId: sessionId.slice(0, 8),
89+
});
90+
return null;
91+
}
92+
93+
logger?.info("persist", "Loaded session state from disk", {
94+
sessionId: sessionId.slice(0, 8),
95+
prunedIds: state.prunedIds.length,
96+
totalTokensSaved: state.stats.totalTokensSaved,
97+
});
98+
99+
return state;
100+
} catch (error: any) {
101+
logger?.warn("persist", "Failed to load session state", {
102+
sessionId: sessionId.slice(0, 8),
103+
error: error?.message,
104+
});
105+
return null;
106+
}
107+
}
108+
109+
export function getStorageDir(): string {
110+
return STORAGE_DIR;
111+
}

lib/state.ts

Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
import type { SessionStats } from "./janitor"
2+
import type { Logger } from "./logger"
3+
import { loadSessionState } from "./state-persistence"
24

35
/**
46
* Centralized state management for the DCP plugin.
@@ -18,6 +20,8 @@ export interface PluginState {
1820
* Key: sessionID, Value: Map<positionKey, toolCallId> where positionKey is "toolName:index"
1921
*/
2022
googleToolCallMapping: Map<string, Map<string, string>>
23+
/** Set of session IDs that have been restored from disk */
24+
restoredSessions: Set<string>
2125
}
2226

2327
export interface ToolParameterEntry {
@@ -40,5 +44,32 @@ export function createPluginState(): PluginState {
4044
toolParameters: new Map(),
4145
model: new Map(),
4246
googleToolCallMapping: new Map(),
47+
restoredSessions: new Set(),
48+
}
49+
}
50+
51+
export async function ensureSessionRestored(
52+
state: PluginState,
53+
sessionId: string,
54+
logger?: Logger
55+
): Promise<void> {
56+
if (state.restoredSessions.has(sessionId)) {
57+
return
58+
}
59+
60+
state.restoredSessions.add(sessionId)
61+
62+
const persisted = await loadSessionState(sessionId, logger)
63+
if (persisted) {
64+
if (!state.prunedIds.has(sessionId)) {
65+
state.prunedIds.set(sessionId, persisted.prunedIds)
66+
logger?.info("persist", "Restored prunedIds from disk", {
67+
sessionId: sessionId.slice(0, 8),
68+
count: persisted.prunedIds.length,
69+
})
70+
}
71+
if (!state.stats.has(sessionId)) {
72+
state.stats.set(sessionId, persisted.stats)
73+
}
4374
}
4475
}

0 commit comments

Comments
 (0)