openrecall 0.2.2 → 0.4.0
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/package.json +8 -2
- package/src/agent.ts +97 -11
- package/src/dcp/auth.ts +37 -0
- package/src/dcp/commands/context.ts +265 -0
- package/src/dcp/commands/help.ts +73 -0
- package/src/dcp/commands/manual.ts +131 -0
- package/src/dcp/commands/stats.ts +73 -0
- package/src/dcp/commands/sweep.ts +263 -0
- package/src/dcp/config.ts +981 -0
- package/src/dcp/hooks.ts +224 -0
- package/src/dcp/index.ts +123 -0
- package/src/dcp/logger.ts +211 -0
- package/src/dcp/messages/index.ts +2 -0
- package/src/dcp/messages/inject.ts +316 -0
- package/src/dcp/messages/prune.ts +217 -0
- package/src/dcp/messages/utils.ts +269 -0
- package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
- package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
- package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
- package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
- package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
- package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
- package/src/dcp/prompts/index.ts +59 -0
- package/src/dcp/protected-file-patterns.ts +113 -0
- package/src/dcp/shared-utils.ts +26 -0
- package/src/dcp/state/index.ts +3 -0
- package/src/dcp/state/persistence.ts +196 -0
- package/src/dcp/state/state.ts +143 -0
- package/src/dcp/state/tool-cache.ts +112 -0
- package/src/dcp/state/types.ts +55 -0
- package/src/dcp/state/utils.ts +55 -0
- package/src/dcp/strategies/deduplication.ts +123 -0
- package/src/dcp/strategies/index.ts +4 -0
- package/src/dcp/strategies/purge-errors.ts +84 -0
- package/src/dcp/strategies/supersede-writes.ts +115 -0
- package/src/dcp/strategies/utils.ts +135 -0
- package/src/dcp/tools/compress.ts +218 -0
- package/src/dcp/tools/distill.ts +60 -0
- package/src/dcp/tools/index.ts +4 -0
- package/src/dcp/tools/prune-shared.ts +174 -0
- package/src/dcp/tools/prune.ts +36 -0
- package/src/dcp/tools/types.ts +11 -0
- package/src/dcp/tools/utils.ts +244 -0
- package/src/dcp/ui/notification.ts +273 -0
- package/src/dcp/ui/utils.ts +133 -0
- package/src/index.ts +101 -49
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { SessionState, WithParts } from "./state"
|
|
2
|
+
import { isIgnoredUserMessage } from "./messages/utils"
|
|
3
|
+
|
|
4
|
+
export const isMessageCompacted = (state: SessionState, msg: WithParts): boolean => {
|
|
5
|
+
if (msg.info.time.created < state.lastCompaction) {
|
|
6
|
+
return true
|
|
7
|
+
}
|
|
8
|
+
if (state.prune.messages.has(msg.info.id)) {
|
|
9
|
+
return true
|
|
10
|
+
}
|
|
11
|
+
return false
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export const getLastUserMessage = (
|
|
15
|
+
messages: WithParts[],
|
|
16
|
+
startIndex?: number,
|
|
17
|
+
): WithParts | null => {
|
|
18
|
+
const start = startIndex ?? messages.length - 1
|
|
19
|
+
for (let i = start; i >= 0; i--) {
|
|
20
|
+
const msg = messages[i]!
|
|
21
|
+
if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
|
|
22
|
+
return msg
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return null
|
|
26
|
+
}
|
|
@@ -0,0 +1,196 @@
|
|
|
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 { SessionState, SessionStats, CompressSummary } from "./types"
|
|
12
|
+
import type { Logger } from "../logger"
|
|
13
|
+
|
|
14
|
+
/** Prune state as stored on disk */
|
|
15
|
+
export interface PersistedPrune {
|
|
16
|
+
// New format: tool/message IDs with token counts
|
|
17
|
+
tools?: Record<string, number>
|
|
18
|
+
messages?: Record<string, number>
|
|
19
|
+
// Legacy format: plain ID arrays (backward compatibility)
|
|
20
|
+
toolIds?: string[]
|
|
21
|
+
messageIds?: string[]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface PersistedSessionState {
|
|
25
|
+
sessionName?: string
|
|
26
|
+
prune: PersistedPrune
|
|
27
|
+
compressSummaries: CompressSummary[]
|
|
28
|
+
stats: SessionStats
|
|
29
|
+
lastUpdated: string
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const STORAGE_DIR = join(
|
|
33
|
+
process.env.XDG_DATA_HOME || join(homedir(), ".local", "share"),
|
|
34
|
+
"opencode",
|
|
35
|
+
"storage",
|
|
36
|
+
"plugin",
|
|
37
|
+
"dcp",
|
|
38
|
+
)
|
|
39
|
+
|
|
40
|
+
async function ensureStorageDir(): Promise<void> {
|
|
41
|
+
if (!existsSync(STORAGE_DIR)) {
|
|
42
|
+
await fs.mkdir(STORAGE_DIR, { recursive: true })
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function getSessionFilePath(sessionId: string): string {
|
|
47
|
+
return join(STORAGE_DIR, `${sessionId}.json`)
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function saveSessionState(
|
|
51
|
+
sessionState: SessionState,
|
|
52
|
+
logger: Logger,
|
|
53
|
+
sessionName?: string,
|
|
54
|
+
): Promise<void> {
|
|
55
|
+
try {
|
|
56
|
+
if (!sessionState.sessionId) {
|
|
57
|
+
return
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
await ensureStorageDir()
|
|
61
|
+
|
|
62
|
+
const state: PersistedSessionState = {
|
|
63
|
+
sessionName: sessionName,
|
|
64
|
+
prune: {
|
|
65
|
+
tools: Object.fromEntries(sessionState.prune.tools),
|
|
66
|
+
messages: Object.fromEntries(sessionState.prune.messages),
|
|
67
|
+
},
|
|
68
|
+
compressSummaries: sessionState.compressSummaries,
|
|
69
|
+
stats: sessionState.stats,
|
|
70
|
+
lastUpdated: new Date().toISOString(),
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
const filePath = getSessionFilePath(sessionState.sessionId)
|
|
74
|
+
const content = JSON.stringify(state, null, 2)
|
|
75
|
+
await fs.writeFile(filePath, content, "utf-8")
|
|
76
|
+
|
|
77
|
+
logger.info("Saved session state to disk", {
|
|
78
|
+
sessionId: sessionState.sessionId,
|
|
79
|
+
totalTokensSaved: state.stats.totalPruneTokens,
|
|
80
|
+
})
|
|
81
|
+
} catch (error: any) {
|
|
82
|
+
logger.error("Failed to save session state", {
|
|
83
|
+
sessionId: sessionState.sessionId,
|
|
84
|
+
error: error?.message,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
export async function loadSessionState(
|
|
90
|
+
sessionId: string,
|
|
91
|
+
logger: Logger,
|
|
92
|
+
): Promise<PersistedSessionState | null> {
|
|
93
|
+
try {
|
|
94
|
+
const filePath = getSessionFilePath(sessionId)
|
|
95
|
+
|
|
96
|
+
if (!existsSync(filePath)) {
|
|
97
|
+
return null
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
101
|
+
const state = JSON.parse(content) as PersistedSessionState
|
|
102
|
+
|
|
103
|
+
const hasNewFormat = state?.prune?.tools && typeof state.prune.tools === "object"
|
|
104
|
+
const hasLegacyFormat = Array.isArray(state?.prune?.toolIds)
|
|
105
|
+
if (!state || !state.prune || (!hasNewFormat && !hasLegacyFormat) || !state.stats) {
|
|
106
|
+
logger.warn("Invalid session state file, ignoring", {
|
|
107
|
+
sessionId: sessionId,
|
|
108
|
+
})
|
|
109
|
+
return null
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
if (Array.isArray(state.compressSummaries)) {
|
|
113
|
+
const validSummaries = state.compressSummaries.filter(
|
|
114
|
+
(s): s is CompressSummary =>
|
|
115
|
+
s !== null &&
|
|
116
|
+
typeof s === "object" &&
|
|
117
|
+
typeof s.anchorMessageId === "string" &&
|
|
118
|
+
typeof s.summary === "string",
|
|
119
|
+
)
|
|
120
|
+
if (validSummaries.length !== state.compressSummaries.length) {
|
|
121
|
+
logger.warn("Filtered out malformed compressSummaries entries", {
|
|
122
|
+
sessionId: sessionId,
|
|
123
|
+
original: state.compressSummaries.length,
|
|
124
|
+
valid: validSummaries.length,
|
|
125
|
+
})
|
|
126
|
+
}
|
|
127
|
+
state.compressSummaries = validSummaries
|
|
128
|
+
} else {
|
|
129
|
+
state.compressSummaries = []
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
logger.info("Loaded session state from disk", {
|
|
133
|
+
sessionId: sessionId,
|
|
134
|
+
})
|
|
135
|
+
|
|
136
|
+
return state
|
|
137
|
+
} catch (error: any) {
|
|
138
|
+
logger.warn("Failed to load session state", {
|
|
139
|
+
sessionId: sessionId,
|
|
140
|
+
error: error?.message,
|
|
141
|
+
})
|
|
142
|
+
return null
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export interface AggregatedStats {
|
|
147
|
+
totalTokens: number
|
|
148
|
+
totalTools: number
|
|
149
|
+
totalMessages: number
|
|
150
|
+
sessionCount: number
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
export async function loadAllSessionStats(logger: Logger): Promise<AggregatedStats> {
|
|
154
|
+
const result: AggregatedStats = {
|
|
155
|
+
totalTokens: 0,
|
|
156
|
+
totalTools: 0,
|
|
157
|
+
totalMessages: 0,
|
|
158
|
+
sessionCount: 0,
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
try {
|
|
162
|
+
if (!existsSync(STORAGE_DIR)) {
|
|
163
|
+
return result
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
const files = await fs.readdir(STORAGE_DIR)
|
|
167
|
+
const jsonFiles = files.filter((f) => f.endsWith(".json"))
|
|
168
|
+
|
|
169
|
+
for (const file of jsonFiles) {
|
|
170
|
+
try {
|
|
171
|
+
const filePath = join(STORAGE_DIR, file)
|
|
172
|
+
const content = await fs.readFile(filePath, "utf-8")
|
|
173
|
+
const state = JSON.parse(content) as PersistedSessionState
|
|
174
|
+
|
|
175
|
+
if (state?.stats?.totalPruneTokens && state?.prune) {
|
|
176
|
+
result.totalTokens += state.stats.totalPruneTokens
|
|
177
|
+
result.totalTools += state.prune.tools
|
|
178
|
+
? Object.keys(state.prune.tools).length
|
|
179
|
+
: (state.prune.toolIds?.length ?? 0)
|
|
180
|
+
result.totalMessages += state.prune.messages
|
|
181
|
+
? Object.keys(state.prune.messages).length
|
|
182
|
+
: (state.prune.messageIds?.length ?? 0)
|
|
183
|
+
result.sessionCount++
|
|
184
|
+
}
|
|
185
|
+
} catch {
|
|
186
|
+
// Skip invalid files
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
logger.debug("Loaded all-time stats", result)
|
|
191
|
+
} catch (error: any) {
|
|
192
|
+
logger.warn("Failed to load all-time stats", { error: error?.message })
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
return result
|
|
196
|
+
}
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import type { SessionState, ToolParameterEntry, WithParts } from "./types"
|
|
2
|
+
import type { Logger } from "../logger"
|
|
3
|
+
import { loadSessionState } from "./persistence"
|
|
4
|
+
import {
|
|
5
|
+
isSubAgentSession,
|
|
6
|
+
findLastCompactionTimestamp,
|
|
7
|
+
countTurns,
|
|
8
|
+
resetOnCompaction,
|
|
9
|
+
loadPruneMap,
|
|
10
|
+
} from "./utils"
|
|
11
|
+
import { getLastUserMessage } from "../shared-utils"
|
|
12
|
+
|
|
13
|
+
export const checkSession = async (
|
|
14
|
+
client: any,
|
|
15
|
+
state: SessionState,
|
|
16
|
+
logger: Logger,
|
|
17
|
+
messages: WithParts[],
|
|
18
|
+
manualModeDefault: boolean,
|
|
19
|
+
): Promise<void> => {
|
|
20
|
+
const lastUserMessage = getLastUserMessage(messages)
|
|
21
|
+
if (!lastUserMessage) {
|
|
22
|
+
return
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
const lastSessionId = lastUserMessage.info.sessionID
|
|
26
|
+
|
|
27
|
+
if (state.sessionId === null || state.sessionId !== lastSessionId) {
|
|
28
|
+
logger.info(`Session changed: ${state.sessionId} -> ${lastSessionId}`)
|
|
29
|
+
try {
|
|
30
|
+
await ensureSessionInitialized(
|
|
31
|
+
client,
|
|
32
|
+
state,
|
|
33
|
+
lastSessionId,
|
|
34
|
+
logger,
|
|
35
|
+
messages,
|
|
36
|
+
manualModeDefault,
|
|
37
|
+
)
|
|
38
|
+
} catch (err: any) {
|
|
39
|
+
logger.error("Failed to initialize session state", { error: err.message })
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const lastCompactionTimestamp = findLastCompactionTimestamp(messages)
|
|
44
|
+
if (lastCompactionTimestamp > state.lastCompaction) {
|
|
45
|
+
state.lastCompaction = lastCompactionTimestamp
|
|
46
|
+
resetOnCompaction(state)
|
|
47
|
+
logger.info("Detected compaction - reset stale state", {
|
|
48
|
+
timestamp: lastCompactionTimestamp,
|
|
49
|
+
})
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
state.currentTurn = countTurns(state, messages)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function createSessionState(): SessionState {
|
|
56
|
+
return {
|
|
57
|
+
sessionId: null,
|
|
58
|
+
isSubAgent: false,
|
|
59
|
+
manualMode: false,
|
|
60
|
+
pendingManualTrigger: null,
|
|
61
|
+
prune: {
|
|
62
|
+
tools: new Map<string, number>(),
|
|
63
|
+
messages: new Map<string, number>(),
|
|
64
|
+
},
|
|
65
|
+
compressSummaries: [],
|
|
66
|
+
stats: {
|
|
67
|
+
pruneTokenCounter: 0,
|
|
68
|
+
totalPruneTokens: 0,
|
|
69
|
+
},
|
|
70
|
+
toolParameters: new Map<string, ToolParameterEntry>(),
|
|
71
|
+
toolIdList: [],
|
|
72
|
+
nudgeCounter: 0,
|
|
73
|
+
lastToolPrune: false,
|
|
74
|
+
lastCompaction: 0,
|
|
75
|
+
currentTurn: 0,
|
|
76
|
+
variant: undefined,
|
|
77
|
+
modelContextLimit: undefined,
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export function resetSessionState(state: SessionState): void {
|
|
82
|
+
state.sessionId = null
|
|
83
|
+
state.isSubAgent = false
|
|
84
|
+
state.manualMode = false
|
|
85
|
+
state.pendingManualTrigger = null
|
|
86
|
+
state.prune = {
|
|
87
|
+
tools: new Map<string, number>(),
|
|
88
|
+
messages: new Map<string, number>(),
|
|
89
|
+
}
|
|
90
|
+
state.compressSummaries = []
|
|
91
|
+
state.stats = {
|
|
92
|
+
pruneTokenCounter: 0,
|
|
93
|
+
totalPruneTokens: 0,
|
|
94
|
+
}
|
|
95
|
+
state.toolParameters.clear()
|
|
96
|
+
state.toolIdList = []
|
|
97
|
+
state.nudgeCounter = 0
|
|
98
|
+
state.lastToolPrune = false
|
|
99
|
+
state.lastCompaction = 0
|
|
100
|
+
state.currentTurn = 0
|
|
101
|
+
state.variant = undefined
|
|
102
|
+
state.modelContextLimit = undefined
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
export async function ensureSessionInitialized(
|
|
106
|
+
client: any,
|
|
107
|
+
state: SessionState,
|
|
108
|
+
sessionId: string,
|
|
109
|
+
logger: Logger,
|
|
110
|
+
messages: WithParts[],
|
|
111
|
+
manualModeDefault: boolean,
|
|
112
|
+
): Promise<void> {
|
|
113
|
+
if (state.sessionId === sessionId) {
|
|
114
|
+
return
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// logger.info("session ID = " + sessionId)
|
|
118
|
+
// logger.info("Initializing session state", { sessionId: sessionId })
|
|
119
|
+
|
|
120
|
+
resetSessionState(state)
|
|
121
|
+
state.manualMode = manualModeDefault
|
|
122
|
+
state.sessionId = sessionId
|
|
123
|
+
|
|
124
|
+
const isSubAgent = await isSubAgentSession(client, sessionId)
|
|
125
|
+
state.isSubAgent = isSubAgent
|
|
126
|
+
// logger.info("isSubAgent = " + isSubAgent)
|
|
127
|
+
|
|
128
|
+
state.lastCompaction = findLastCompactionTimestamp(messages)
|
|
129
|
+
state.currentTurn = countTurns(state, messages)
|
|
130
|
+
|
|
131
|
+
const persisted = await loadSessionState(sessionId, logger)
|
|
132
|
+
if (persisted === null) {
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
state.prune.tools = loadPruneMap(persisted.prune.tools, persisted.prune.toolIds)
|
|
137
|
+
state.prune.messages = loadPruneMap(persisted.prune.messages, persisted.prune.messageIds)
|
|
138
|
+
state.compressSummaries = persisted.compressSummaries || []
|
|
139
|
+
state.stats = {
|
|
140
|
+
pruneTokenCounter: persisted.stats?.pruneTokenCounter || 0,
|
|
141
|
+
totalPruneTokens: persisted.stats?.totalPruneTokens || 0,
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
import type { SessionState, ToolStatus, WithParts } from "./index"
|
|
2
|
+
import type { Logger } from "../logger"
|
|
3
|
+
import type { PluginConfig } from "../config"
|
|
4
|
+
import { isMessageCompacted } from "../shared-utils"
|
|
5
|
+
import { countToolTokens } from "../strategies/utils"
|
|
6
|
+
|
|
7
|
+
const MAX_TOOL_CACHE_SIZE = 1000
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* Sync tool parameters from session messages.
|
|
11
|
+
*/
|
|
12
|
+
export function syncToolCache(
|
|
13
|
+
state: SessionState,
|
|
14
|
+
config: PluginConfig,
|
|
15
|
+
logger: Logger,
|
|
16
|
+
messages: WithParts[],
|
|
17
|
+
): void {
|
|
18
|
+
try {
|
|
19
|
+
logger.info("Syncing tool parameters from OpenCode messages")
|
|
20
|
+
|
|
21
|
+
state.nudgeCounter = 0
|
|
22
|
+
let turnCounter = 0
|
|
23
|
+
|
|
24
|
+
for (const msg of messages) {
|
|
25
|
+
if (isMessageCompacted(state, msg)) {
|
|
26
|
+
continue
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
if (part.type === "step-start") {
|
|
32
|
+
turnCounter++
|
|
33
|
+
continue
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
if (part.type !== "tool" || !part.callID) {
|
|
37
|
+
continue
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const turnProtectionEnabled = config.turnProtection.enabled
|
|
41
|
+
const turnProtectionTurns = config.turnProtection.turns
|
|
42
|
+
const isProtectedByTurn =
|
|
43
|
+
turnProtectionEnabled &&
|
|
44
|
+
turnProtectionTurns > 0 &&
|
|
45
|
+
state.currentTurn - turnCounter < turnProtectionTurns
|
|
46
|
+
|
|
47
|
+
if (part.tool === "distill" || part.tool === "compress" || part.tool === "prune") {
|
|
48
|
+
state.nudgeCounter = 0
|
|
49
|
+
state.lastToolPrune = true
|
|
50
|
+
} else {
|
|
51
|
+
state.lastToolPrune = false
|
|
52
|
+
const allProtectedTools = config.tools.settings.protectedTools
|
|
53
|
+
if (!allProtectedTools.includes(part.tool) && !isProtectedByTurn) {
|
|
54
|
+
state.nudgeCounter++
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
if (state.toolParameters.has(part.callID)) {
|
|
59
|
+
continue
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (isProtectedByTurn) {
|
|
63
|
+
continue
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
const allProtectedTools = config.tools.settings.protectedTools
|
|
67
|
+
const isProtectedTool = allProtectedTools.includes(part.tool)
|
|
68
|
+
const tokenCount = isProtectedTool ? undefined : countToolTokens(part)
|
|
69
|
+
|
|
70
|
+
state.toolParameters.set(part.callID, {
|
|
71
|
+
tool: part.tool,
|
|
72
|
+
parameters: part.state?.input ?? {},
|
|
73
|
+
status: part.state.status as ToolStatus | undefined,
|
|
74
|
+
error: part.state.status === "error" ? part.state.error : undefined,
|
|
75
|
+
turn: turnCounter,
|
|
76
|
+
tokenCount,
|
|
77
|
+
})
|
|
78
|
+
logger.info(
|
|
79
|
+
`Cached tool id: ${part.callID} (turn ${turnCounter}${tokenCount !== undefined ? `, ~${tokenCount} tokens` : ""})`,
|
|
80
|
+
)
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
logger.info(
|
|
85
|
+
`Synced cache - size: ${state.toolParameters.size}, currentTurn: ${state.currentTurn}, nudgeCounter: ${state.nudgeCounter}`,
|
|
86
|
+
)
|
|
87
|
+
trimToolParametersCache(state)
|
|
88
|
+
} catch (error) {
|
|
89
|
+
logger.warn("Failed to sync tool parameters from OpenCode", {
|
|
90
|
+
error: error instanceof Error ? error.message : String(error),
|
|
91
|
+
})
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Trim the tool parameters cache to prevent unbounded memory growth.
|
|
97
|
+
* Uses FIFO eviction - removes oldest entries first.
|
|
98
|
+
*/
|
|
99
|
+
export function trimToolParametersCache(state: SessionState): void {
|
|
100
|
+
if (state.toolParameters.size <= MAX_TOOL_CACHE_SIZE) {
|
|
101
|
+
return
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
const keysToRemove = Array.from(state.toolParameters.keys()).slice(
|
|
105
|
+
0,
|
|
106
|
+
state.toolParameters.size - MAX_TOOL_CACHE_SIZE,
|
|
107
|
+
)
|
|
108
|
+
|
|
109
|
+
for (const key of keysToRemove) {
|
|
110
|
+
state.toolParameters.delete(key)
|
|
111
|
+
}
|
|
112
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { Message, Part } from "@opencode-ai/sdk/v2"
|
|
2
|
+
|
|
3
|
+
export interface WithParts {
|
|
4
|
+
info: Message
|
|
5
|
+
parts: Part[]
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
export type ToolStatus = "pending" | "running" | "completed" | "error"
|
|
9
|
+
|
|
10
|
+
export interface ToolParameterEntry {
|
|
11
|
+
tool: string
|
|
12
|
+
parameters: any
|
|
13
|
+
status?: ToolStatus
|
|
14
|
+
error?: string
|
|
15
|
+
turn: number
|
|
16
|
+
tokenCount?: number
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export interface SessionStats {
|
|
20
|
+
pruneTokenCounter: number
|
|
21
|
+
totalPruneTokens: number
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export interface CompressSummary {
|
|
25
|
+
anchorMessageId: string
|
|
26
|
+
summary: string
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface Prune {
|
|
30
|
+
tools: Map<string, number>
|
|
31
|
+
messages: Map<string, number>
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface PendingManualTrigger {
|
|
35
|
+
sessionId: string
|
|
36
|
+
prompt: string
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface SessionState {
|
|
40
|
+
sessionId: string | null
|
|
41
|
+
isSubAgent: boolean
|
|
42
|
+
manualMode: boolean
|
|
43
|
+
pendingManualTrigger: PendingManualTrigger | null
|
|
44
|
+
prune: Prune
|
|
45
|
+
compressSummaries: CompressSummary[]
|
|
46
|
+
stats: SessionStats
|
|
47
|
+
toolParameters: Map<string, ToolParameterEntry>
|
|
48
|
+
toolIdList: string[]
|
|
49
|
+
nudgeCounter: number
|
|
50
|
+
lastToolPrune: boolean
|
|
51
|
+
lastCompaction: number
|
|
52
|
+
currentTurn: number
|
|
53
|
+
variant: string | undefined
|
|
54
|
+
modelContextLimit: number | undefined
|
|
55
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { SessionState, WithParts } from "./types"
|
|
2
|
+
import { isMessageCompacted } from "../shared-utils"
|
|
3
|
+
|
|
4
|
+
export async function isSubAgentSession(client: any, sessionID: string): Promise<boolean> {
|
|
5
|
+
try {
|
|
6
|
+
const result = await client.session.get({ path: { id: sessionID } })
|
|
7
|
+
return !!result.data?.parentID
|
|
8
|
+
} catch (error: any) {
|
|
9
|
+
return false
|
|
10
|
+
}
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function findLastCompactionTimestamp(messages: WithParts[]): number {
|
|
14
|
+
for (let i = messages.length - 1; i >= 0; i--) {
|
|
15
|
+
const msg = messages[i]!
|
|
16
|
+
if (msg.info.role === "assistant" && msg.info.summary === true) {
|
|
17
|
+
return msg.info.time.created
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return 0
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function countTurns(state: SessionState, messages: WithParts[]): number {
|
|
24
|
+
let turnCount = 0
|
|
25
|
+
for (const msg of messages) {
|
|
26
|
+
if (isMessageCompacted(state, msg)) {
|
|
27
|
+
continue
|
|
28
|
+
}
|
|
29
|
+
const parts = Array.isArray(msg.parts) ? msg.parts : []
|
|
30
|
+
for (const part of parts) {
|
|
31
|
+
if (part.type === "step-start") {
|
|
32
|
+
turnCount++
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
}
|
|
36
|
+
return turnCount
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export function loadPruneMap(
|
|
40
|
+
obj?: Record<string, number>,
|
|
41
|
+
legacyArr?: string[],
|
|
42
|
+
): Map<string, number> {
|
|
43
|
+
if (obj) return new Map(Object.entries(obj))
|
|
44
|
+
if (legacyArr) return new Map(legacyArr.map((id) => [id, 0]))
|
|
45
|
+
return new Map()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function resetOnCompaction(state: SessionState): void {
|
|
49
|
+
state.toolParameters.clear()
|
|
50
|
+
state.prune.tools = new Map<string, number>()
|
|
51
|
+
state.prune.messages = new Map<string, number>()
|
|
52
|
+
state.compressSummaries = []
|
|
53
|
+
state.nudgeCounter = 0
|
|
54
|
+
state.lastToolPrune = false
|
|
55
|
+
}
|