@tarquinen/opencode-dcp 3.2.0-beta0 → 3.2.2-beta0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/lib/logger.ts ADDED
@@ -0,0 +1,235 @@
1
+ import { writeFile, mkdir } from "fs/promises"
2
+ import { join } from "path"
3
+ import { existsSync } from "fs"
4
+ import { homedir } from "os"
5
+
6
+ export class Logger {
7
+ private logDir: string
8
+ private scope?: string
9
+ public enabled: boolean
10
+
11
+ constructor(enabled: boolean, scope?: string) {
12
+ this.enabled = enabled
13
+ this.scope = scope?.replace(/[^A-Za-z0-9._-]/g, "_")
14
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
15
+ this.logDir = this.scope
16
+ ? join(configHome, "opencode", "logs", "dcp", this.scope)
17
+ : join(configHome, "opencode", "logs", "dcp")
18
+ }
19
+
20
+ private async ensureLogDir() {
21
+ if (!existsSync(this.logDir)) {
22
+ await mkdir(this.logDir, { recursive: true })
23
+ }
24
+ }
25
+
26
+ private formatData(data?: any): string {
27
+ if (!data) return ""
28
+
29
+ const parts: string[] = []
30
+ for (const [key, value] of Object.entries(data)) {
31
+ if (value === undefined || value === null) continue
32
+
33
+ // Format arrays compactly
34
+ if (Array.isArray(value)) {
35
+ if (value.length === 0) continue
36
+ parts.push(
37
+ `${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`,
38
+ )
39
+ } else if (typeof value === "object") {
40
+ const str = JSON.stringify(value)
41
+ if (str.length < 50) {
42
+ parts.push(`${key}=${str}`)
43
+ }
44
+ } else {
45
+ parts.push(`${key}=${value}`)
46
+ }
47
+ }
48
+ return parts.join(" ")
49
+ }
50
+
51
+ private getCallerFile(skipFrames: number = 3): string {
52
+ const originalPrepareStackTrace = Error.prepareStackTrace
53
+ try {
54
+ const err = new Error()
55
+ Error.prepareStackTrace = (_, stack) => stack
56
+ const stack = err.stack as unknown as NodeJS.CallSite[]
57
+ Error.prepareStackTrace = originalPrepareStackTrace
58
+
59
+ // Skip specified number of frames to get to actual caller
60
+ for (let i = skipFrames; i < stack.length; i++) {
61
+ const filename = stack[i]?.getFileName()
62
+ if (filename && !filename.includes("/logger.")) {
63
+ // Extract just the filename without path and extension
64
+ const match = filename.match(/([^/\\]+)\.[tj]s$/)
65
+ return match ? match[1] : filename
66
+ }
67
+ }
68
+ return "unknown"
69
+ } catch {
70
+ return "unknown"
71
+ }
72
+ }
73
+
74
+ private async write(level: string, component: string, message: string, data?: any) {
75
+ if (!this.enabled) return
76
+
77
+ try {
78
+ await this.ensureLogDir()
79
+
80
+ const timestamp = new Date().toISOString()
81
+ const dataStr = this.formatData(data)
82
+
83
+ const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? " | " + dataStr : ""}\n`
84
+
85
+ const logFile = this.scope
86
+ ? join(this.logDir, `${new Date().toISOString().split("T")[0]}.log`)
87
+ : join(this.logDir, "daily", `${new Date().toISOString().split("T")[0]}.log`)
88
+
89
+ if (!this.scope) {
90
+ const dailyLogDir = join(this.logDir, "daily")
91
+ if (!existsSync(dailyLogDir)) {
92
+ await mkdir(dailyLogDir, { recursive: true })
93
+ }
94
+ }
95
+
96
+ await writeFile(logFile, logLine, { flag: "a" })
97
+ } catch (error) {}
98
+ }
99
+
100
+ info(message: string, data?: any) {
101
+ const component = this.getCallerFile(2)
102
+ return this.write("INFO", component, message, data)
103
+ }
104
+
105
+ debug(message: string, data?: any) {
106
+ const component = this.getCallerFile(2)
107
+ return this.write("DEBUG", component, message, data)
108
+ }
109
+
110
+ warn(message: string, data?: any) {
111
+ const component = this.getCallerFile(2)
112
+ return this.write("WARN", component, message, data)
113
+ }
114
+
115
+ error(message: string, data?: any) {
116
+ const component = this.getCallerFile(2)
117
+ return this.write("ERROR", component, message, data)
118
+ }
119
+
120
+ /**
121
+ * Strips unnecessary metadata from messages for cleaner debug logs.
122
+ *
123
+ * Removed:
124
+ * - All IDs (id, sessionID, messageID, parentID)
125
+ * - summary, path, cost, model, agent, mode, finish, providerID, modelID
126
+ * - step-start and step-finish parts entirely
127
+ * - snapshot fields
128
+ * - ignored text parts
129
+ *
130
+ * Kept:
131
+ * - role, time (created only), tokens (input, output, reasoning, cache)
132
+ * - text, reasoning, tool parts with content
133
+ * - tool calls with: tool, callID, input, output, metadata
134
+ */
135
+ private minimizeForDebug(messages: any[]): any[] {
136
+ return messages.map((msg) => {
137
+ const minimized: any = {
138
+ role: msg.info?.role,
139
+ }
140
+
141
+ if (msg.info?.time?.created) {
142
+ minimized.time = msg.info.time.created
143
+ }
144
+
145
+ if (msg.info?.tokens) {
146
+ minimized.tokens = {
147
+ input: msg.info.tokens.input,
148
+ output: msg.info.tokens.output,
149
+ reasoning: msg.info.tokens.reasoning,
150
+ cache: msg.info.tokens.cache,
151
+ }
152
+ }
153
+
154
+ if (msg.parts) {
155
+ minimized.parts = msg.parts
156
+ .map((part: any) => {
157
+ if (part.type === "step-start" || part.type === "step-finish") {
158
+ return null
159
+ }
160
+
161
+ if (part.type === "text") {
162
+ if (part.ignored) return null
163
+ const textPart: any = { type: "text", text: part.text }
164
+ if (part.metadata) textPart.metadata = part.metadata
165
+ return textPart
166
+ }
167
+
168
+ if (part.type === "reasoning") {
169
+ const reasoningPart: any = { type: "reasoning", text: part.text }
170
+ if (part.metadata) reasoningPart.metadata = part.metadata
171
+ return reasoningPart
172
+ }
173
+
174
+ if (part.type === "tool") {
175
+ const toolPart: any = {
176
+ type: "tool",
177
+ tool: part.tool,
178
+ callID: part.callID,
179
+ }
180
+
181
+ if (part.state?.status) {
182
+ toolPart.status = part.state.status
183
+ }
184
+ if (part.state?.input) {
185
+ toolPart.input = part.state.input
186
+ }
187
+ if (part.state?.output) {
188
+ toolPart.output = part.state.output
189
+ }
190
+ if (part.state?.error) {
191
+ toolPart.error = part.state.error
192
+ }
193
+ if (part.metadata) {
194
+ toolPart.metadata = part.metadata
195
+ }
196
+ if (part.state?.metadata) {
197
+ toolPart.metadata = {
198
+ ...(toolPart.metadata || {}),
199
+ ...part.state.metadata,
200
+ }
201
+ }
202
+ if (part.state?.title) {
203
+ toolPart.title = part.state.title
204
+ }
205
+
206
+ return toolPart
207
+ }
208
+
209
+ return null
210
+ })
211
+ .filter(Boolean)
212
+ }
213
+
214
+ return minimized
215
+ })
216
+ }
217
+
218
+ async saveContext(sessionId: string, messages: any[]) {
219
+ if (!this.enabled) return
220
+
221
+ try {
222
+ const contextDir = join(this.logDir, "context", sessionId)
223
+ if (!existsSync(contextDir)) {
224
+ await mkdir(contextDir, { recursive: true })
225
+ }
226
+
227
+ const minimized = this.minimizeForDebug(messages).filter(
228
+ (msg) => msg.parts && msg.parts.length > 0,
229
+ )
230
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
231
+ const contextFile = join(contextDir, `${timestamp}.json`)
232
+ await writeFile(contextFile, JSON.stringify(minimized, null, 2))
233
+ } catch (error) {}
234
+ }
235
+ }
@@ -0,0 +1,56 @@
1
+ import type { PluginConfig } from "../config"
2
+ import type { WithParts } from "../state"
3
+
4
+ export const getLastUserMessage = (
5
+ messages: WithParts[],
6
+ startIndex?: number,
7
+ ): WithParts | null => {
8
+ const start = startIndex ?? messages.length - 1
9
+ for (let i = start; i >= 0; i--) {
10
+ const msg = messages[i]
11
+ if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
12
+ return msg
13
+ }
14
+ }
15
+ return null
16
+ }
17
+
18
+ export const messageHasCompress = (message: WithParts): boolean => {
19
+ if (message.info.role !== "assistant") {
20
+ return false
21
+ }
22
+
23
+ const parts = Array.isArray(message.parts) ? message.parts : []
24
+ return parts.some(
25
+ (part) =>
26
+ part.type === "tool" && part.tool === "compress" && part.state?.status === "completed",
27
+ )
28
+ }
29
+
30
+ export const isIgnoredUserMessage = (message: WithParts): boolean => {
31
+ if (message.info.role !== "user") {
32
+ return false
33
+ }
34
+
35
+ const parts = Array.isArray(message.parts) ? message.parts : []
36
+ if (parts.length === 0) {
37
+ return true
38
+ }
39
+
40
+ for (const part of parts) {
41
+ if (!(part as any).ignored) {
42
+ return false
43
+ }
44
+ }
45
+
46
+ return true
47
+ }
48
+
49
+ export function isProtectedUserMessage(config: PluginConfig, message: WithParts): boolean {
50
+ return (
51
+ config.compress.mode === "message" &&
52
+ config.compress.protectUserMessages &&
53
+ message.info.role === "user" &&
54
+ !isIgnoredUserMessage(message)
55
+ )
56
+ }
@@ -0,0 +1,4 @@
1
+ export * from "./persistence"
2
+ export * from "./types"
3
+ export * from "./state"
4
+ export * from "./tool-cache"
@@ -0,0 +1,260 @@
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 { CompressionBlock, PrunedMessageEntry, SessionState, SessionStats } from "./types"
12
+ import type { Logger } from "../logger"
13
+
14
+ /** Prune state as stored on disk */
15
+ export interface PersistedPruneMessagesState {
16
+ byMessageId: Record<string, PrunedMessageEntry>
17
+ blocksById: Record<string, CompressionBlock>
18
+ activeBlockIds: number[]
19
+ activeByAnchorMessageId: Record<string, number>
20
+ nextBlockId: number
21
+ nextRunId: number
22
+ }
23
+
24
+ export interface PersistedPrune {
25
+ tools?: Record<string, number>
26
+ messages?: PersistedPruneMessagesState
27
+ }
28
+
29
+ export interface PersistedNudges {
30
+ contextLimitAnchors: string[]
31
+ turnNudgeAnchors?: string[]
32
+ iterationNudgeAnchors?: string[]
33
+ }
34
+
35
+ export interface PersistedSessionState {
36
+ sessionName?: string
37
+ prune: PersistedPrune
38
+ nudges: PersistedNudges
39
+ stats: SessionStats
40
+ lastUpdated: string
41
+ }
42
+
43
+ const STORAGE_DIR = join(
44
+ process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
45
+ "opencode",
46
+ "storage",
47
+ "plugin",
48
+ "dcp",
49
+ )
50
+
51
+ async function ensureStorageDir(): Promise<void> {
52
+ if (!existsSync(STORAGE_DIR)) {
53
+ await fs.mkdir(STORAGE_DIR, { recursive: true })
54
+ }
55
+ }
56
+
57
+ function getSessionFilePath(sessionId: string): string {
58
+ return join(STORAGE_DIR, `${sessionId}.json`)
59
+ }
60
+
61
+ export async function saveSessionState(
62
+ sessionState: SessionState,
63
+ logger: Logger,
64
+ sessionName?: string,
65
+ ): Promise<void> {
66
+ try {
67
+ if (!sessionState.sessionId) {
68
+ return
69
+ }
70
+
71
+ await ensureStorageDir()
72
+
73
+ const state: PersistedSessionState = {
74
+ sessionName: sessionName,
75
+ prune: {
76
+ tools: Object.fromEntries(sessionState.prune.tools),
77
+ messages: {
78
+ byMessageId: Object.fromEntries(sessionState.prune.messages.byMessageId),
79
+ blocksById: Object.fromEntries(
80
+ Array.from(sessionState.prune.messages.blocksById.entries()).map(
81
+ ([blockId, block]) => [String(blockId), block],
82
+ ),
83
+ ),
84
+ activeBlockIds: Array.from(sessionState.prune.messages.activeBlockIds),
85
+ activeByAnchorMessageId: Object.fromEntries(
86
+ sessionState.prune.messages.activeByAnchorMessageId,
87
+ ),
88
+ nextBlockId: sessionState.prune.messages.nextBlockId,
89
+ nextRunId: sessionState.prune.messages.nextRunId,
90
+ },
91
+ },
92
+ nudges: {
93
+ contextLimitAnchors: Array.from(sessionState.nudges.contextLimitAnchors),
94
+ turnNudgeAnchors: Array.from(sessionState.nudges.turnNudgeAnchors),
95
+ iterationNudgeAnchors: Array.from(sessionState.nudges.iterationNudgeAnchors),
96
+ },
97
+ stats: sessionState.stats,
98
+ lastUpdated: new Date().toISOString(),
99
+ }
100
+
101
+ const filePath = getSessionFilePath(sessionState.sessionId)
102
+ const content = JSON.stringify(state, null, 2)
103
+ await fs.writeFile(filePath, content, "utf-8")
104
+
105
+ logger.info("Saved session state to disk", {
106
+ sessionId: sessionState.sessionId,
107
+ totalTokensSaved: state.stats.totalPruneTokens,
108
+ })
109
+ } catch (error: any) {
110
+ logger.error("Failed to save session state", {
111
+ sessionId: sessionState.sessionId,
112
+ error: error?.message,
113
+ })
114
+ }
115
+ }
116
+
117
+ export async function loadSessionState(
118
+ sessionId: string,
119
+ logger: Logger,
120
+ ): Promise<PersistedSessionState | null> {
121
+ try {
122
+ const filePath = getSessionFilePath(sessionId)
123
+
124
+ if (!existsSync(filePath)) {
125
+ return null
126
+ }
127
+
128
+ const content = await fs.readFile(filePath, "utf-8")
129
+ const state = JSON.parse(content) as PersistedSessionState
130
+
131
+ const hasPruneTools = state?.prune?.tools && typeof state.prune.tools === "object"
132
+ const hasPruneMessages = state?.prune?.messages && typeof state.prune.messages === "object"
133
+ const hasNudgeFormat = state?.nudges && typeof state.nudges === "object"
134
+ if (
135
+ !state ||
136
+ !state.prune ||
137
+ !hasPruneTools ||
138
+ !hasPruneMessages ||
139
+ !state.stats ||
140
+ !hasNudgeFormat
141
+ ) {
142
+ logger.warn("Invalid session state file, ignoring", {
143
+ sessionId: sessionId,
144
+ })
145
+ return null
146
+ }
147
+
148
+ const rawContextLimitAnchors = Array.isArray(state.nudges.contextLimitAnchors)
149
+ ? state.nudges.contextLimitAnchors
150
+ : []
151
+ const validAnchors = rawContextLimitAnchors.filter(
152
+ (entry): entry is string => typeof entry === "string",
153
+ )
154
+ const dedupedAnchors = [...new Set(validAnchors)]
155
+ if (validAnchors.length !== rawContextLimitAnchors.length) {
156
+ logger.warn("Filtered out malformed contextLimitAnchors entries", {
157
+ sessionId: sessionId,
158
+ original: rawContextLimitAnchors.length,
159
+ valid: validAnchors.length,
160
+ })
161
+ }
162
+ state.nudges.contextLimitAnchors = dedupedAnchors
163
+
164
+ const rawTurnNudgeAnchors = Array.isArray(state.nudges.turnNudgeAnchors)
165
+ ? state.nudges.turnNudgeAnchors
166
+ : []
167
+ const validSoftAnchors = rawTurnNudgeAnchors.filter(
168
+ (entry): entry is string => typeof entry === "string",
169
+ )
170
+ const dedupedSoftAnchors = [...new Set(validSoftAnchors)]
171
+ if (validSoftAnchors.length !== rawTurnNudgeAnchors.length) {
172
+ logger.warn("Filtered out malformed turnNudgeAnchors entries", {
173
+ sessionId: sessionId,
174
+ original: rawTurnNudgeAnchors.length,
175
+ valid: validSoftAnchors.length,
176
+ })
177
+ }
178
+ state.nudges.turnNudgeAnchors = dedupedSoftAnchors
179
+
180
+ const rawIterationNudgeAnchors = Array.isArray(state.nudges.iterationNudgeAnchors)
181
+ ? state.nudges.iterationNudgeAnchors
182
+ : []
183
+ const validIterationAnchors = rawIterationNudgeAnchors.filter(
184
+ (entry): entry is string => typeof entry === "string",
185
+ )
186
+ const dedupedIterationAnchors = [...new Set(validIterationAnchors)]
187
+ if (validIterationAnchors.length !== rawIterationNudgeAnchors.length) {
188
+ logger.warn("Filtered out malformed iterationNudgeAnchors entries", {
189
+ sessionId: sessionId,
190
+ original: rawIterationNudgeAnchors.length,
191
+ valid: validIterationAnchors.length,
192
+ })
193
+ }
194
+ state.nudges.iterationNudgeAnchors = dedupedIterationAnchors
195
+
196
+ logger.info("Loaded session state from disk", {
197
+ sessionId: sessionId,
198
+ })
199
+
200
+ return state
201
+ } catch (error: any) {
202
+ logger.warn("Failed to load session state", {
203
+ sessionId: sessionId,
204
+ error: error?.message,
205
+ })
206
+ return null
207
+ }
208
+ }
209
+
210
+ export interface AggregatedStats {
211
+ totalTokens: number
212
+ totalTools: number
213
+ totalMessages: number
214
+ sessionCount: number
215
+ }
216
+
217
+ export async function loadAllSessionStats(logger: Logger): Promise<AggregatedStats> {
218
+ const result: AggregatedStats = {
219
+ totalTokens: 0,
220
+ totalTools: 0,
221
+ totalMessages: 0,
222
+ sessionCount: 0,
223
+ }
224
+
225
+ try {
226
+ if (!existsSync(STORAGE_DIR)) {
227
+ return result
228
+ }
229
+
230
+ const files = await fs.readdir(STORAGE_DIR)
231
+ const jsonFiles = files.filter((f) => f.endsWith(".json"))
232
+
233
+ for (const file of jsonFiles) {
234
+ try {
235
+ const filePath = join(STORAGE_DIR, file)
236
+ const content = await fs.readFile(filePath, "utf-8")
237
+ const state = JSON.parse(content) as PersistedSessionState
238
+
239
+ if (state?.stats?.totalPruneTokens && state?.prune) {
240
+ result.totalTokens += state.stats.totalPruneTokens
241
+ result.totalTools += state.prune.tools
242
+ ? Object.keys(state.prune.tools).length
243
+ : 0
244
+ result.totalMessages += state.prune.messages?.byMessageId
245
+ ? Object.keys(state.prune.messages.byMessageId).length
246
+ : 0
247
+ result.sessionCount++
248
+ }
249
+ } catch {
250
+ // Skip invalid files
251
+ }
252
+ }
253
+
254
+ logger.debug("Loaded all-time stats", result)
255
+ } catch (error: any) {
256
+ logger.warn("Failed to load all-time stats", { error: error?.message })
257
+ }
258
+
259
+ return result
260
+ }