@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/analysis/tokens.ts +225 -0
- package/lib/config.ts +1071 -0
- package/lib/logger.ts +235 -0
- package/lib/messages/query.ts +56 -0
- package/lib/state/index.ts +4 -0
- package/lib/state/persistence.ts +260 -0
- package/lib/state/state.ts +180 -0
- package/lib/state/tool-cache.ts +98 -0
- package/lib/state/types.ts +108 -0
- package/lib/state/utils.ts +310 -0
- package/lib/token-utils.ts +162 -0
- package/package.json +17 -20
- package/tui/data/context.ts +177 -0
- package/tui/index.tsx +34 -0
- package/tui/routes/summary.tsx +175 -0
- package/tui/shared/names.ts +9 -0
- package/tui/shared/theme.ts +58 -0
- package/tui/shared/types.ts +38 -0
- package/tui/slots/sidebar-content.tsx +502 -0
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,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
|
+
}
|