Skip to content

Commit 5227432

Browse files
authored
Merge pull request #44 from Tarquinen/feat/persist-state-across-restarts
feat: persist pruned state across OpenCode restarts
2 parents eb37c5e + df502b9 commit 5227432

File tree

8 files changed

+177
-25
lines changed

8 files changed

+177
-25
lines changed

index.ts

Lines changed: 1 addition & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,12 +28,9 @@ const plugin: Plugin = (async (ctx) => {
2828

2929
const janitor = new Janitor(
3030
ctx.client,
31-
state.prunedIds,
32-
state.stats,
31+
state,
3332
logger,
34-
state.toolParameters,
3533
config.protectedTools,
36-
state.model,
3734
config.model,
3835
config.showModelErrorToasts,
3936
config.strictModelSelection,

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: 24 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,14 @@
11
import { z } from "zod"
22
import type { Logger } from "./logger"
33
import type { PruningStrategy } from "./config"
4+
import type { PluginState } from "./state"
45
import { buildAnalysisPrompt } from "./prompt"
56
import { selectModel, extractModelFromSession } from "./model-selector"
67
import { estimateTokensBatch, formatTokenCount } from "./tokenizer"
78
import { detectDuplicates } from "./deduplicator"
89
import { extractParameterKey } from "./display-utils"
10+
import { saveSessionState } from "./state-persistence"
11+
import { ensureSessionRestored } from "./state"
912

1013
export interface SessionStats {
1114
totalToolsPruned: number
@@ -29,20 +32,28 @@ export interface PruningOptions {
2932
}
3033

3134
export class Janitor {
35+
private prunedIdsState: Map<string, string[]>
36+
private statsState: Map<string, SessionStats>
37+
private toolParametersCache: Map<string, any>
38+
private modelCache: Map<string, { providerID: string; modelID: string }>
39+
3240
constructor(
3341
private client: any,
34-
private prunedIdsState: Map<string, string[]>,
35-
private statsState: Map<string, SessionStats>,
42+
private state: PluginState,
3643
private logger: Logger,
37-
private toolParametersCache: Map<string, any>,
3844
private protectedTools: string[],
39-
private modelCache: Map<string, { providerID: string; modelID: string }>,
4045
private configModel?: string,
4146
private showModelErrorToasts: boolean = true,
4247
private strictModelSelection: boolean = false,
4348
private pruningSummary: "off" | "minimal" | "detailed" = "detailed",
4449
private workingDirectory?: string
45-
) { }
50+
) {
51+
// Bind state references for convenience
52+
this.prunedIdsState = state.prunedIds
53+
this.statsState = state.stats
54+
this.toolParametersCache = state.toolParameters
55+
this.modelCache = state.model
56+
}
4657

4758
private async sendIgnoredMessage(sessionID: string, text: string, agent?: string) {
4859
try {
@@ -85,6 +96,9 @@ export class Janitor {
8596
return null
8697
}
8798

99+
// Ensure persisted state is restored before processing
100+
await ensureSessionRestored(this.state, sessionID, this.logger)
101+
88102
const [sessionInfoResponse, messagesResponse] = await Promise.all([
89103
this.client.session.get({ path: { id: sessionID } }),
90104
this.client.session.messages({ path: { id: sessionID }, query: { limit: 100 } })
@@ -97,8 +111,6 @@ export class Janitor {
97111
return null
98112
}
99113

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

345+
const sessionName = sessionInfo?.title
346+
saveSessionState(sessionID, new Set(allPrunedIds), sessionStats, this.logger, sessionName).catch(err => {
347+
this.logger.error("janitor", "Failed to persist state", { error: err.message })
348+
})
349+
333350
const prunedCount = finalNewlyPrunedIds.length
334351
const keptCount = candidateCount - prunedCount
335352
const hasBoth = deduplicatedIds.length > 0 && llmPrunedIds.length > 0

lib/state-persistence.ts

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

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)