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.
Files changed (46) hide show
  1. package/package.json +8 -2
  2. package/src/agent.ts +97 -11
  3. package/src/dcp/auth.ts +37 -0
  4. package/src/dcp/commands/context.ts +265 -0
  5. package/src/dcp/commands/help.ts +73 -0
  6. package/src/dcp/commands/manual.ts +131 -0
  7. package/src/dcp/commands/stats.ts +73 -0
  8. package/src/dcp/commands/sweep.ts +263 -0
  9. package/src/dcp/config.ts +981 -0
  10. package/src/dcp/hooks.ts +224 -0
  11. package/src/dcp/index.ts +123 -0
  12. package/src/dcp/logger.ts +211 -0
  13. package/src/dcp/messages/index.ts +2 -0
  14. package/src/dcp/messages/inject.ts +316 -0
  15. package/src/dcp/messages/prune.ts +217 -0
  16. package/src/dcp/messages/utils.ts +269 -0
  17. package/src/dcp/prompts/_codegen/compress-nudge.generated.ts +15 -0
  18. package/src/dcp/prompts/_codegen/compress.generated.ts +56 -0
  19. package/src/dcp/prompts/_codegen/distill.generated.ts +33 -0
  20. package/src/dcp/prompts/_codegen/nudge.generated.ts +17 -0
  21. package/src/dcp/prompts/_codegen/prune.generated.ts +23 -0
  22. package/src/dcp/prompts/_codegen/system.generated.ts +57 -0
  23. package/src/dcp/prompts/index.ts +59 -0
  24. package/src/dcp/protected-file-patterns.ts +113 -0
  25. package/src/dcp/shared-utils.ts +26 -0
  26. package/src/dcp/state/index.ts +3 -0
  27. package/src/dcp/state/persistence.ts +196 -0
  28. package/src/dcp/state/state.ts +143 -0
  29. package/src/dcp/state/tool-cache.ts +112 -0
  30. package/src/dcp/state/types.ts +55 -0
  31. package/src/dcp/state/utils.ts +55 -0
  32. package/src/dcp/strategies/deduplication.ts +123 -0
  33. package/src/dcp/strategies/index.ts +4 -0
  34. package/src/dcp/strategies/purge-errors.ts +84 -0
  35. package/src/dcp/strategies/supersede-writes.ts +115 -0
  36. package/src/dcp/strategies/utils.ts +135 -0
  37. package/src/dcp/tools/compress.ts +218 -0
  38. package/src/dcp/tools/distill.ts +60 -0
  39. package/src/dcp/tools/index.ts +4 -0
  40. package/src/dcp/tools/prune-shared.ts +174 -0
  41. package/src/dcp/tools/prune.ts +36 -0
  42. package/src/dcp/tools/types.ts +11 -0
  43. package/src/dcp/tools/utils.ts +244 -0
  44. package/src/dcp/ui/notification.ts +273 -0
  45. package/src/dcp/ui/utils.ts +133 -0
  46. 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,3 @@
1
+ export * from "./persistence"
2
+ export * from "./types"
3
+ export * from "./state"
@@ -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
+ }