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,316 @@
1
+ import type { SessionState, WithParts } from "../state"
2
+ import type { Logger } from "../logger"
3
+ import type { PluginConfig } from "../config"
4
+ import type { UserMessage } from "@opencode-ai/sdk/v2"
5
+ import { renderNudge, renderCompressNudge } from "../prompts"
6
+ import {
7
+ extractParameterKey,
8
+ createSyntheticTextPart,
9
+ createSyntheticToolPart,
10
+ isIgnoredUserMessage,
11
+ } from "./utils"
12
+ import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
13
+ import { getLastUserMessage, isMessageCompacted } from "../shared-utils"
14
+ import { getCurrentTokenUsage } from "../strategies/utils"
15
+
16
+ function parsePercentageString(value: string, total: number): number | undefined {
17
+ if (!value.endsWith("%")) return undefined
18
+ const percent = parseFloat(value.slice(0, -1))
19
+
20
+ if (isNaN(percent)) {
21
+ return undefined
22
+ }
23
+
24
+ const roundedPercent = Math.round(percent)
25
+ const clampedPercent = Math.max(0, Math.min(100, roundedPercent))
26
+
27
+ return Math.round((clampedPercent / 100) * total)
28
+ }
29
+
30
+ // XML wrappers
31
+ export const wrapPrunableTools = (content: string): string => {
32
+ return `<prunable-tools>
33
+ The following tools have been invoked and are available for pruning. This list does not mandate immediate action. Consider your current goals and the resources you need before pruning valuable tool inputs or outputs. Consolidate your prunes for efficiency; it is rarely worth pruning a single tiny tool output. Keep the context free of noise.
34
+ ${content}
35
+ </prunable-tools>`
36
+ }
37
+
38
+ export const wrapCompressContext = (messageCount: number): string => `<compress-context>
39
+ Compress available. Conversation: ${messageCount} messages.
40
+ Compress collapses completed task sequences or exploration phases into summaries.
41
+ Uses text boundaries [startString, endString, topic, summary].
42
+ </compress-context>`
43
+
44
+ export const wrapCooldownMessage = (flags: {
45
+ prune: boolean
46
+ distill: boolean
47
+ compress: boolean
48
+ }): string => {
49
+ const enabledTools: string[] = []
50
+ if (flags.distill) enabledTools.push("distill")
51
+ if (flags.compress) enabledTools.push("compress")
52
+ if (flags.prune) enabledTools.push("prune")
53
+
54
+ let toolName: string
55
+ if (enabledTools.length === 0) {
56
+ toolName = "pruning tools"
57
+ } else if (enabledTools.length === 1) {
58
+ toolName = `${enabledTools[0]} tool`
59
+ } else {
60
+ const last = enabledTools.pop()
61
+ toolName = `${enabledTools.join(", ")} or ${last} tools`
62
+ }
63
+
64
+ return `<context-info>
65
+ Context management was just performed. Do NOT use the ${toolName} again. A fresh list will be available after your next tool use.
66
+ </context-info>`
67
+ }
68
+
69
+ const resolveContextLimit = (
70
+ config: PluginConfig,
71
+ state: SessionState,
72
+ providerId: string | undefined,
73
+ modelId: string | undefined,
74
+ ): number | undefined => {
75
+ const modelLimits = config.tools.settings.modelLimits
76
+ const contextLimit = config.tools.settings.contextLimit
77
+
78
+ if (modelLimits) {
79
+ const providerModelId =
80
+ providerId !== undefined && modelId !== undefined
81
+ ? `${providerId}/${modelId}`
82
+ : undefined
83
+ const limit = providerModelId !== undefined ? modelLimits[providerModelId] : undefined
84
+
85
+ if (limit !== undefined) {
86
+ if (typeof limit === "string" && limit.endsWith("%")) {
87
+ if (state.modelContextLimit === undefined) {
88
+ return undefined
89
+ }
90
+ return parsePercentageString(limit, state.modelContextLimit)
91
+ }
92
+ return typeof limit === "number" ? limit : undefined
93
+ }
94
+ }
95
+
96
+ if (typeof contextLimit === "string") {
97
+ if (contextLimit.endsWith("%")) {
98
+ if (state.modelContextLimit === undefined) {
99
+ return undefined
100
+ }
101
+ return parsePercentageString(contextLimit, state.modelContextLimit)
102
+ }
103
+ return undefined
104
+ }
105
+
106
+ return contextLimit
107
+ }
108
+
109
+ const shouldInjectCompressNudge = (
110
+ config: PluginConfig,
111
+ state: SessionState,
112
+ messages: WithParts[],
113
+ providerId: string | undefined,
114
+ modelId: string | undefined,
115
+ ): boolean => {
116
+ if (config.tools.compress.permission === "deny") {
117
+ return false
118
+ }
119
+
120
+ const lastAssistant = messages.findLast((msg) => msg.info.role === "assistant")
121
+ if (lastAssistant) {
122
+ const parts = Array.isArray(lastAssistant.parts) ? lastAssistant.parts : []
123
+ const hasDcpTool = parts.some(
124
+ (part) =>
125
+ part.type === "tool" &&
126
+ part.state.status === "completed" &&
127
+ (part.tool === "compress" || part.tool === "prune" || part.tool === "distill"),
128
+ )
129
+ if (hasDcpTool) {
130
+ return false
131
+ }
132
+ }
133
+
134
+ const contextLimit = resolveContextLimit(config, state, providerId, modelId)
135
+ if (contextLimit === undefined) {
136
+ return false
137
+ }
138
+
139
+ const currentTokens = getCurrentTokenUsage(messages)
140
+ return currentTokens > contextLimit
141
+ }
142
+
143
+ const getNudgeString = (config: PluginConfig): string => {
144
+ const flags = {
145
+ prune: config.tools.prune.permission !== "deny",
146
+ distill: config.tools.distill.permission !== "deny",
147
+ compress: config.tools.compress.permission !== "deny",
148
+ manual: false,
149
+ }
150
+
151
+ if (!flags.prune && !flags.distill && !flags.compress) {
152
+ return ""
153
+ }
154
+
155
+ return renderNudge(flags)
156
+ }
157
+
158
+ const getCooldownMessage = (config: PluginConfig): string => {
159
+ return wrapCooldownMessage({
160
+ prune: config.tools.prune.permission !== "deny",
161
+ distill: config.tools.distill.permission !== "deny",
162
+ compress: config.tools.compress.permission !== "deny",
163
+ })
164
+ }
165
+
166
+ const buildCompressContext = (state: SessionState, messages: WithParts[]): string => {
167
+ const messageCount = messages.filter((msg) => !isMessageCompacted(state, msg)).length
168
+ return wrapCompressContext(messageCount)
169
+ }
170
+
171
+ export const buildPrunableToolsList = (
172
+ state: SessionState,
173
+ config: PluginConfig,
174
+ logger: Logger,
175
+ ): string => {
176
+ const lines: string[] = []
177
+ const toolIdList = state.toolIdList
178
+
179
+ state.toolParameters.forEach((toolParameterEntry, toolCallId) => {
180
+ if (state.prune.tools.has(toolCallId)) {
181
+ return
182
+ }
183
+
184
+ const allProtectedTools = config.tools.settings.protectedTools
185
+ if (allProtectedTools.includes(toolParameterEntry.tool)) {
186
+ return
187
+ }
188
+
189
+ const filePaths = getFilePathsFromParameters(
190
+ toolParameterEntry.tool,
191
+ toolParameterEntry.parameters,
192
+ )
193
+ if (isProtected(filePaths, config.protectedFilePatterns)) {
194
+ return
195
+ }
196
+
197
+ const numericId = toolIdList.indexOf(toolCallId)
198
+ if (numericId === -1) {
199
+ logger.warn(`Tool in cache but not in toolIdList - possible stale entry`, {
200
+ toolCallId,
201
+ tool: toolParameterEntry.tool,
202
+ })
203
+ return
204
+ }
205
+ const paramKey = extractParameterKey(toolParameterEntry.tool, toolParameterEntry.parameters)
206
+ const description = paramKey
207
+ ? `${toolParameterEntry.tool}, ${paramKey}`
208
+ : toolParameterEntry.tool
209
+ const tokenSuffix =
210
+ toolParameterEntry.tokenCount !== undefined
211
+ ? ` (~${toolParameterEntry.tokenCount} tokens)`
212
+ : ""
213
+ lines.push(`${numericId}: ${description}${tokenSuffix}`)
214
+ logger.debug(
215
+ `Prunable tool found - ID: ${numericId}, Tool: ${toolParameterEntry.tool}, Call ID: ${toolCallId}`,
216
+ )
217
+ })
218
+
219
+ if (lines.length === 0) {
220
+ return ""
221
+ }
222
+
223
+ return wrapPrunableTools(lines.join("\n"))
224
+ }
225
+
226
+ export const insertPruneToolContext = (
227
+ state: SessionState,
228
+ config: PluginConfig,
229
+ logger: Logger,
230
+ messages: WithParts[],
231
+ ): void => {
232
+ if (state.manualMode || state.pendingManualTrigger) {
233
+ return
234
+ }
235
+
236
+ const pruneEnabled = config.tools.prune.permission !== "deny"
237
+ const distillEnabled = config.tools.distill.permission !== "deny"
238
+ const compressEnabled = config.tools.compress.permission !== "deny"
239
+
240
+ if (!pruneEnabled && !distillEnabled && !compressEnabled) {
241
+ return
242
+ }
243
+
244
+ const pruneOrDistillEnabled = pruneEnabled || distillEnabled
245
+ const contentParts: string[] = []
246
+ const lastUserMessage = getLastUserMessage(messages)
247
+ const providerId = lastUserMessage
248
+ ? (lastUserMessage.info as UserMessage).model.providerID
249
+ : undefined
250
+ const modelId = lastUserMessage
251
+ ? (lastUserMessage.info as UserMessage).model.modelID
252
+ : undefined
253
+
254
+ if (state.lastToolPrune) {
255
+ logger.debug("Last tool was prune - injecting cooldown message")
256
+ contentParts.push(getCooldownMessage(config))
257
+ } else {
258
+ if (pruneOrDistillEnabled) {
259
+ const prunableToolsList = buildPrunableToolsList(state, config, logger)
260
+ if (prunableToolsList) {
261
+ // logger.debug("prunable-tools: \n" + prunableToolsList)
262
+ contentParts.push(prunableToolsList)
263
+ }
264
+ }
265
+
266
+ if (compressEnabled) {
267
+ const compressContext = buildCompressContext(state, messages)
268
+ // logger.debug("compress-context: \n" + compressContext)
269
+ contentParts.push(compressContext)
270
+ }
271
+
272
+ if (shouldInjectCompressNudge(config, state, messages, providerId, modelId)) {
273
+ logger.info("Inserting compress nudge - token usage exceeds contextLimit")
274
+ contentParts.push(renderCompressNudge())
275
+ } else if (
276
+ config.tools.settings.nudgeEnabled &&
277
+ state.nudgeCounter >= config.tools.settings.nudgeFrequency
278
+ ) {
279
+ logger.info("Inserting prune nudge message")
280
+ contentParts.push(getNudgeString(config))
281
+ }
282
+ }
283
+
284
+ if (contentParts.length === 0) {
285
+ return
286
+ }
287
+
288
+ const combinedContent = contentParts.join("\n")
289
+
290
+ if (!lastUserMessage) {
291
+ return
292
+ }
293
+
294
+ const userInfo = lastUserMessage.info as UserMessage
295
+
296
+ const lastNonIgnoredMessage = messages.findLast(
297
+ (msg) => !(msg.info.role === "user" && isIgnoredUserMessage(msg)),
298
+ )
299
+
300
+ if (!lastNonIgnoredMessage) {
301
+ return
302
+ }
303
+
304
+ // When following a user message, append a synthetic text part since models like Claude
305
+ // expect assistant turns to start with reasoning parts which cannot be easily faked.
306
+ // For all other cases, append a synthetic tool part to the last message which works
307
+ // across all models without disrupting their behavior.
308
+ if (lastNonIgnoredMessage.info.role === "user") {
309
+ const textPart = createSyntheticTextPart(lastNonIgnoredMessage, combinedContent)
310
+ lastNonIgnoredMessage.parts.push(textPart)
311
+ } else {
312
+ const modelID = userInfo.model?.modelID || ""
313
+ const toolPart = createSyntheticToolPart(lastNonIgnoredMessage, combinedContent, modelID)
314
+ lastNonIgnoredMessage.parts.push(toolPart)
315
+ }
316
+ }
@@ -0,0 +1,217 @@
1
+ import type { SessionState, WithParts } from "../state"
2
+ import type { Logger } from "../logger"
3
+ import type { PluginConfig } from "../config"
4
+ import { isMessageCompacted, getLastUserMessage } from "../shared-utils"
5
+ import { createSyntheticUserMessage } from "./utils"
6
+ import type { UserMessage } from "@opencode-ai/sdk/v2"
7
+
8
+ const PRUNED_TOOL_OUTPUT_REPLACEMENT =
9
+ "[Output removed to save context - information superseded or no longer needed]"
10
+ const PRUNED_TOOL_ERROR_INPUT_REPLACEMENT = "[input removed due to failed tool call]"
11
+ const PRUNED_QUESTION_INPUT_REPLACEMENT = "[questions removed - see output for user's answers]"
12
+ const PRUNED_COMPRESS_INPUT_REPLACEMENT =
13
+ "[compress content removed - topic retained for reference]"
14
+
15
+ export const prune = (
16
+ state: SessionState,
17
+ logger: Logger,
18
+ config: PluginConfig,
19
+ messages: WithParts[],
20
+ ): void => {
21
+ filterCompressedRanges(state, logger, messages)
22
+ pruneFullTool(state, logger, messages)
23
+ pruneToolOutputs(state, logger, messages)
24
+ pruneToolInputs(state, logger, messages)
25
+ pruneToolErrors(state, logger, messages)
26
+ }
27
+
28
+ const pruneFullTool = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
29
+ const messagesToRemove: string[] = []
30
+
31
+ for (const msg of messages) {
32
+ if (isMessageCompacted(state, msg)) {
33
+ continue
34
+ }
35
+
36
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
37
+ const partsToRemove: string[] = []
38
+
39
+ for (const part of parts) {
40
+ if (part.type !== "tool") {
41
+ continue
42
+ }
43
+
44
+ if (!state.prune.tools.has(part.callID)) {
45
+ continue
46
+ }
47
+ if (part.tool !== "edit" && part.tool !== "write") {
48
+ continue
49
+ }
50
+
51
+ partsToRemove.push(part.callID)
52
+ }
53
+
54
+ if (partsToRemove.length === 0) {
55
+ continue
56
+ }
57
+
58
+ msg.parts = parts.filter(
59
+ (part) => part.type !== "tool" || !partsToRemove.includes(part.callID),
60
+ )
61
+
62
+ if (msg.parts.length === 0) {
63
+ messagesToRemove.push(msg.info.id)
64
+ }
65
+ }
66
+
67
+ if (messagesToRemove.length > 0) {
68
+ const result = messages.filter((msg) => !messagesToRemove.includes(msg.info.id))
69
+ messages.length = 0
70
+ messages.push(...result)
71
+ }
72
+ }
73
+
74
+ const pruneToolOutputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
75
+ for (const msg of messages) {
76
+ if (isMessageCompacted(state, msg)) {
77
+ continue
78
+ }
79
+
80
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
81
+ for (const part of parts) {
82
+ if (part.type !== "tool") {
83
+ continue
84
+ }
85
+ if (!state.prune.tools.has(part.callID)) {
86
+ continue
87
+ }
88
+ if (part.state.status !== "completed") {
89
+ continue
90
+ }
91
+ if (part.tool === "question" || part.tool === "edit" || part.tool === "write") {
92
+ continue
93
+ }
94
+
95
+ part.state.output = PRUNED_TOOL_OUTPUT_REPLACEMENT
96
+ }
97
+ }
98
+ }
99
+
100
+ const pruneToolInputs = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
101
+ for (const msg of messages) {
102
+ if (isMessageCompacted(state, msg)) {
103
+ continue
104
+ }
105
+
106
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
107
+ for (const part of parts) {
108
+ if (part.type !== "tool") {
109
+ continue
110
+ }
111
+ if (part.tool === "compress" && part.state.status === "completed") {
112
+ if (part.state.input?.content !== undefined) {
113
+ part.state.input.content = PRUNED_COMPRESS_INPUT_REPLACEMENT
114
+ }
115
+ continue
116
+ }
117
+
118
+ if (!state.prune.tools.has(part.callID)) {
119
+ continue
120
+ }
121
+ if (part.state.status !== "completed") {
122
+ continue
123
+ }
124
+ if (part.tool !== "question") {
125
+ continue
126
+ }
127
+
128
+ if (part.state.input?.questions !== undefined) {
129
+ part.state.input.questions = PRUNED_QUESTION_INPUT_REPLACEMENT
130
+ }
131
+ }
132
+ }
133
+ }
134
+
135
+ const pruneToolErrors = (state: SessionState, logger: Logger, messages: WithParts[]): void => {
136
+ for (const msg of messages) {
137
+ if (isMessageCompacted(state, msg)) {
138
+ continue
139
+ }
140
+
141
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
142
+ for (const part of parts) {
143
+ if (part.type !== "tool") {
144
+ continue
145
+ }
146
+ if (!state.prune.tools.has(part.callID)) {
147
+ continue
148
+ }
149
+ if (part.state.status !== "error") {
150
+ continue
151
+ }
152
+
153
+ // Prune all string inputs for errored tools
154
+ const input = part.state.input
155
+ if (input && typeof input === "object") {
156
+ for (const key of Object.keys(input)) {
157
+ if (typeof input[key] === "string") {
158
+ input[key] = PRUNED_TOOL_ERROR_INPUT_REPLACEMENT
159
+ }
160
+ }
161
+ }
162
+ }
163
+ }
164
+ }
165
+
166
+ const filterCompressedRanges = (
167
+ state: SessionState,
168
+ logger: Logger,
169
+ messages: WithParts[],
170
+ ): void => {
171
+ if (!state.prune.messages?.size) {
172
+ return
173
+ }
174
+
175
+ const result: WithParts[] = []
176
+
177
+ for (const msg of messages) {
178
+ const msgId = msg.info.id
179
+
180
+ // Check if there's a summary to inject at this anchor point
181
+ const summary = state.compressSummaries?.find((s) => s.anchorMessageId === msgId)
182
+ if (summary) {
183
+ // Find user message for variant and as base for synthetic message
184
+ const msgIndex = messages.indexOf(msg)
185
+ const userMessage = getLastUserMessage(messages, msgIndex)
186
+
187
+ if (userMessage) {
188
+ const userInfo = userMessage.info as UserMessage
189
+ const summaryContent = summary.summary
190
+ result.push(
191
+ createSyntheticUserMessage(userMessage, summaryContent, userInfo.variant),
192
+ )
193
+
194
+ logger.info("Injected compress summary", {
195
+ anchorMessageId: msgId,
196
+ summaryLength: summary.summary.length,
197
+ })
198
+ } else {
199
+ logger.warn("No user message found for compress summary", {
200
+ anchorMessageId: msgId,
201
+ })
202
+ }
203
+ }
204
+
205
+ // Skip messages that are in the prune list
206
+ if (state.prune.messages.has(msgId)) {
207
+ continue
208
+ }
209
+
210
+ // Normal message, include it
211
+ result.push(msg)
212
+ }
213
+
214
+ // Replace messages array contents
215
+ messages.length = 0
216
+ messages.push(...result)
217
+ }