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,60 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import type { PruneToolContext } from "./types"
3
+ import { executePruneOperation } from "./prune-shared"
4
+ import type { PruneReason } from "../ui/notification"
5
+ import { loadPrompt } from "../prompts"
6
+
7
+ const DISTILL_TOOL_DESCRIPTION = loadPrompt("distill-tool-spec")
8
+
9
+ export function createDistillTool(ctx: PruneToolContext): ReturnType<typeof tool> {
10
+ return tool({
11
+ description: DISTILL_TOOL_DESCRIPTION,
12
+ args: {
13
+ targets: tool.schema
14
+ .array(
15
+ tool.schema.object({
16
+ id: tool.schema
17
+ .string()
18
+ .describe("Numeric ID from the <prunable-tools> list"),
19
+ distillation: tool.schema
20
+ .string()
21
+ .describe("Complete technical distillation for this tool output"),
22
+ }),
23
+ )
24
+ .describe("Tool outputs to distill, each pairing an ID with its distillation"),
25
+ },
26
+ async execute(args, toolCtx) {
27
+ if (!args.targets || !Array.isArray(args.targets) || args.targets.length === 0) {
28
+ ctx.logger.debug("Distill tool called without targets: " + JSON.stringify(args))
29
+ throw new Error("Missing targets. Provide at least one { id, distillation } entry.")
30
+ }
31
+
32
+ for (const target of args.targets) {
33
+ if (!target.id || typeof target.id !== "string" || target.id.trim() === "") {
34
+ ctx.logger.debug("Distill target missing id: " + JSON.stringify(target))
35
+ throw new Error(
36
+ "Each target must have an id (numeric string from <prunable-tools>).",
37
+ )
38
+ }
39
+ if (!target.distillation || typeof target.distillation !== "string") {
40
+ ctx.logger.debug(
41
+ "Distill target missing distillation: " + JSON.stringify(target),
42
+ )
43
+ throw new Error("Each target must have a distillation string.")
44
+ }
45
+ }
46
+
47
+ const ids = args.targets.map((t) => t.id)
48
+ const distillations = args.targets.map((t) => t.distillation)
49
+
50
+ return executePruneOperation(
51
+ ctx,
52
+ toolCtx,
53
+ ids,
54
+ "extraction" as PruneReason,
55
+ "Distill",
56
+ distillations,
57
+ )
58
+ },
59
+ })
60
+ }
@@ -0,0 +1,4 @@
1
+ export type { PruneToolContext } from "./types"
2
+ export { createPruneTool } from "./prune"
3
+ export { createDistillTool } from "./distill"
4
+ export { createCompressTool } from "./compress"
@@ -0,0 +1,174 @@
1
+ import type { SessionState, ToolParameterEntry, WithParts } from "../state"
2
+ import type { PluginConfig } from "../config"
3
+ import type { Logger } from "../logger"
4
+ import type { PruneToolContext } from "./types"
5
+ import { syncToolCache } from "../state/tool-cache"
6
+ import type { PruneReason } from "../ui/notification"
7
+ import { sendUnifiedNotification } from "../ui/notification"
8
+ import { formatPruningResultForTool } from "../ui/utils"
9
+ import { ensureSessionInitialized } from "../state"
10
+ import { saveSessionState } from "../state/persistence"
11
+ import { getTotalToolTokens, getCurrentParams } from "../strategies/utils"
12
+ import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
13
+ import { buildToolIdList } from "../messages/utils"
14
+
15
+ // Shared logic for executing prune operations.
16
+ export async function executePruneOperation(
17
+ ctx: PruneToolContext,
18
+ toolCtx: { sessionID: string },
19
+ ids: string[],
20
+ reason: PruneReason,
21
+ toolName: string,
22
+ distillation?: string[],
23
+ ): Promise<string> {
24
+ const { client, state, logger, config, workingDirectory } = ctx
25
+ const sessionId = toolCtx.sessionID
26
+
27
+ logger.info(`${toolName} tool invoked`)
28
+ logger.info(JSON.stringify(reason ? { ids, reason } : { ids }))
29
+
30
+ if (!ids || ids.length === 0) {
31
+ logger.debug(`${toolName} tool called but ids is empty or undefined`)
32
+ throw new Error(
33
+ `No IDs provided. Check the <prunable-tools> list for available IDs to ${toolName.toLowerCase()}.`,
34
+ )
35
+ }
36
+
37
+ const numericToolIds: number[] = ids
38
+ .map((id) => parseInt(id, 10))
39
+ .filter((n): n is number => !isNaN(n))
40
+
41
+ if (numericToolIds.length === 0) {
42
+ logger.debug(`No numeric tool IDs provided for ${toolName}: ` + JSON.stringify(ids))
43
+ throw new Error("No numeric IDs provided. Format: ids: [id1, id2, ...]")
44
+ }
45
+
46
+ // Fetch messages to calculate tokens and find current agent
47
+ const messagesResponse = await client.session.messages({
48
+ path: { id: sessionId },
49
+ })
50
+ const messages: WithParts[] = messagesResponse.data || messagesResponse
51
+
52
+ // These 3 are probably not needed as they should always be set in the message
53
+ // transform handler, but in case something causes state to reset, this is a safety net
54
+ await ensureSessionInitialized(
55
+ ctx.client,
56
+ state,
57
+ sessionId,
58
+ logger,
59
+ messages,
60
+ config.manualMode.enabled,
61
+ )
62
+ syncToolCache(state, config, logger, messages)
63
+ buildToolIdList(state, messages, logger)
64
+
65
+ const currentParams = getCurrentParams(state, messages, logger)
66
+
67
+ const toolIdList = state.toolIdList
68
+
69
+ const validNumericIds: number[] = []
70
+ const skippedIds: string[] = []
71
+
72
+ // Validate and filter IDs
73
+ for (const index of numericToolIds) {
74
+ // Validate that index is within bounds
75
+ if (index < 0 || index >= toolIdList.length) {
76
+ logger.debug(`Rejecting prune request - index out of bounds: ${index}`)
77
+ skippedIds.push(index.toString())
78
+ continue
79
+ }
80
+
81
+ const id = toolIdList[index]!
82
+ const metadata = state.toolParameters.get(id)
83
+
84
+ // Validate that all IDs exist in cache and aren't protected
85
+ // (rejects hallucinated IDs and turn-protected tools not shown in <prunable-tools>)
86
+ if (!metadata) {
87
+ logger.debug(
88
+ "Rejecting prune request - ID not in cache (turn-protected or hallucinated)",
89
+ { index, id },
90
+ )
91
+ skippedIds.push(index.toString())
92
+ continue
93
+ }
94
+
95
+ const allProtectedTools = config.tools.settings.protectedTools
96
+ if (allProtectedTools.includes(metadata.tool)) {
97
+ logger.debug("Rejecting prune request - protected tool", {
98
+ index,
99
+ id,
100
+ tool: metadata.tool,
101
+ })
102
+ skippedIds.push(index.toString())
103
+ continue
104
+ }
105
+
106
+ const filePaths = getFilePathsFromParameters(metadata.tool, metadata.parameters)
107
+ if (isProtected(filePaths, config.protectedFilePatterns)) {
108
+ logger.debug("Rejecting prune request - protected file path", {
109
+ index,
110
+ id,
111
+ tool: metadata.tool,
112
+ filePaths,
113
+ })
114
+ skippedIds.push(index.toString())
115
+ continue
116
+ }
117
+
118
+ validNumericIds.push(index)
119
+ }
120
+
121
+ if (validNumericIds.length === 0) {
122
+ const errorMsg =
123
+ skippedIds.length > 0
124
+ ? `Invalid IDs provided: [${skippedIds.join(", ")}]. Only use numeric IDs from the <prunable-tools> list.`
125
+ : `No valid IDs provided to ${toolName.toLowerCase()}.`
126
+ throw new Error(errorMsg)
127
+ }
128
+
129
+ const pruneToolIds: string[] = validNumericIds.map((index) => toolIdList[index]!)
130
+ for (const id of pruneToolIds) {
131
+ const entry = state.toolParameters.get(id)
132
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
133
+ }
134
+
135
+ const toolMetadata = new Map<string, ToolParameterEntry>()
136
+ for (const id of pruneToolIds) {
137
+ const toolParameters = state.toolParameters.get(id)
138
+ if (toolParameters) {
139
+ toolMetadata.set(id, toolParameters)
140
+ } else {
141
+ logger.debug("No metadata found for ID", { id })
142
+ }
143
+ }
144
+
145
+ state.stats.pruneTokenCounter += getTotalToolTokens(state, pruneToolIds)
146
+
147
+ await sendUnifiedNotification(
148
+ client,
149
+ logger,
150
+ config,
151
+ state,
152
+ sessionId,
153
+ pruneToolIds,
154
+ toolMetadata,
155
+ reason,
156
+ currentParams,
157
+ workingDirectory,
158
+ distillation,
159
+ )
160
+
161
+ state.stats.totalPruneTokens += state.stats.pruneTokenCounter
162
+ state.stats.pruneTokenCounter = 0
163
+ state.nudgeCounter = 0
164
+
165
+ saveSessionState(state, logger).catch((err) =>
166
+ logger.error("Failed to persist state", { error: err.message }),
167
+ )
168
+
169
+ let result = formatPruningResultForTool(pruneToolIds, toolMetadata, workingDirectory)
170
+ if (skippedIds.length > 0) {
171
+ result += `\n\nNote: ${skippedIds.length} IDs were skipped (invalid, protected, or missing metadata): ${skippedIds.join(", ")}`
172
+ }
173
+ return result
174
+ }
@@ -0,0 +1,36 @@
1
+ import { tool } from "@opencode-ai/plugin"
2
+ import type { PruneToolContext } from "./types"
3
+ import { executePruneOperation } from "./prune-shared"
4
+ import type { PruneReason } from "../ui/notification"
5
+ import { loadPrompt } from "../prompts"
6
+
7
+ const PRUNE_TOOL_DESCRIPTION = loadPrompt("prune-tool-spec")
8
+
9
+ export function createPruneTool(ctx: PruneToolContext): ReturnType<typeof tool> {
10
+ return tool({
11
+ description: PRUNE_TOOL_DESCRIPTION,
12
+ args: {
13
+ ids: tool.schema
14
+ .array(tool.schema.string())
15
+ .describe("Numeric IDs as strings from the <prunable-tools> list to prune"),
16
+ },
17
+ async execute(args, toolCtx) {
18
+ if (!args.ids || !Array.isArray(args.ids) || args.ids.length === 0) {
19
+ ctx.logger.debug("Prune tool called without ids: " + JSON.stringify(args))
20
+ throw new Error("Missing ids. You must provide at least one ID to prune.")
21
+ }
22
+
23
+ if (!args.ids.every((id) => typeof id === "string" && id.trim() !== "")) {
24
+ ctx.logger.debug("Prune tool called with invalid ids: " + JSON.stringify(args))
25
+ throw new Error(
26
+ 'Invalid ids. All IDs must be numeric strings (e.g., "1", "23") from the <prunable-tools> list.',
27
+ )
28
+ }
29
+
30
+ const numericIds = args.ids
31
+ const reason = "noise"
32
+
33
+ return executePruneOperation(ctx, toolCtx, numericIds, reason, "Prune")
34
+ },
35
+ })
36
+ }
@@ -0,0 +1,11 @@
1
+ import type { SessionState } from "../state"
2
+ import type { PluginConfig } from "../config"
3
+ import type { Logger } from "../logger"
4
+
5
+ export interface PruneToolContext {
6
+ client: any
7
+ state: SessionState
8
+ logger: Logger
9
+ config: PluginConfig
10
+ workingDirectory: string
11
+ }
@@ -0,0 +1,244 @@
1
+ import { partial_ratio } from "fuzzball"
2
+ import type { WithParts } from "../state"
3
+ import type { Logger } from "../logger"
4
+ import { isIgnoredUserMessage } from "../messages/utils"
5
+
6
+ export interface FuzzyConfig {
7
+ minScore: number
8
+ minGap: number
9
+ }
10
+
11
+ export const DEFAULT_FUZZY_CONFIG: FuzzyConfig = {
12
+ minScore: 95,
13
+ minGap: 15,
14
+ }
15
+
16
+ interface MatchResult {
17
+ messageId: string
18
+ messageIndex: number
19
+ score: number
20
+ matchType: "exact" | "fuzzy"
21
+ }
22
+
23
+ function extractMessageContent(msg: WithParts): string {
24
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
25
+ let content = ""
26
+
27
+ for (const part of parts) {
28
+ const p = part as Record<string, unknown>
29
+ if ((part as any).ignored) {
30
+ continue
31
+ }
32
+
33
+ switch (part.type) {
34
+ case "text":
35
+ case "reasoning":
36
+ if (typeof p.text === "string") {
37
+ content += " " + p.text
38
+ }
39
+ break
40
+
41
+ case "tool": {
42
+ const state = p.state as Record<string, unknown> | undefined
43
+ if (!state) break
44
+
45
+ // Include tool output (completed or error)
46
+ if (state.status === "completed" && typeof state.output === "string") {
47
+ content += " " + state.output
48
+ } else if (state.status === "error" && typeof state.error === "string") {
49
+ content += " " + state.error
50
+ }
51
+
52
+ // Include tool input
53
+ if (state.input) {
54
+ content +=
55
+ " " +
56
+ (typeof state.input === "string"
57
+ ? state.input
58
+ : JSON.stringify(state.input))
59
+ }
60
+ break
61
+ }
62
+
63
+ case "compaction":
64
+ if (typeof p.summary === "string") {
65
+ content += " " + p.summary
66
+ }
67
+ break
68
+
69
+ case "subtask":
70
+ if (typeof p.summary === "string") {
71
+ content += " " + p.summary
72
+ }
73
+ if (typeof p.result === "string") {
74
+ content += " " + p.result
75
+ }
76
+ break
77
+ }
78
+ }
79
+
80
+ return content
81
+ }
82
+
83
+ function findExactMatches(messages: WithParts[], searchString: string): MatchResult[] {
84
+ const matches: MatchResult[] = []
85
+
86
+ for (let i = 0; i < messages.length; i++) {
87
+ const msg = messages[i]!
88
+ if (isIgnoredUserMessage(msg)) {
89
+ continue
90
+ }
91
+ const content = extractMessageContent(msg)
92
+ if (content.includes(searchString)) {
93
+ matches.push({
94
+ messageId: msg.info.id,
95
+ messageIndex: i,
96
+ score: 100,
97
+ matchType: "exact",
98
+ })
99
+ }
100
+ }
101
+
102
+ return matches
103
+ }
104
+
105
+ function findFuzzyMatches(
106
+ messages: WithParts[],
107
+ searchString: string,
108
+ minScore: number,
109
+ ): MatchResult[] {
110
+ const matches: MatchResult[] = []
111
+
112
+ for (let i = 0; i < messages.length; i++) {
113
+ const msg = messages[i]!
114
+ if (isIgnoredUserMessage(msg)) {
115
+ continue
116
+ }
117
+ const content = extractMessageContent(msg)
118
+ const score = partial_ratio(searchString, content)
119
+ if (score >= minScore) {
120
+ matches.push({
121
+ messageId: msg.info.id,
122
+ messageIndex: i,
123
+ score,
124
+ matchType: "fuzzy",
125
+ })
126
+ }
127
+ }
128
+
129
+ return matches
130
+ }
131
+
132
+ export function findStringInMessages(
133
+ messages: WithParts[],
134
+ searchString: string,
135
+ logger: Logger,
136
+ stringType: "startString" | "endString",
137
+ fuzzyConfig: FuzzyConfig = DEFAULT_FUZZY_CONFIG,
138
+ ): { messageId: string; messageIndex: number } {
139
+ const searchableMessages = messages.length > 1 ? messages.slice(0, -1) : messages
140
+ const lastMessage = messages.length > 0 ? messages[messages.length - 1] : undefined
141
+
142
+ const exactMatches = findExactMatches(searchableMessages, searchString)
143
+
144
+ if (exactMatches.length === 1) {
145
+ return { messageId: exactMatches[0]!.messageId, messageIndex: exactMatches[0]!.messageIndex }
146
+ }
147
+
148
+ if (exactMatches.length > 1) {
149
+ throw new Error(
150
+ `Found multiple matches for ${stringType}. ` +
151
+ `Provide more surrounding context to uniquely identify the intended match.`,
152
+ )
153
+ }
154
+
155
+ const fuzzyMatches = findFuzzyMatches(searchableMessages, searchString, fuzzyConfig.minScore)
156
+
157
+ if (fuzzyMatches.length === 0) {
158
+ if (lastMessage && !isIgnoredUserMessage(lastMessage)) {
159
+ const lastMsgContent = extractMessageContent(lastMessage)
160
+ const lastMsgIndex = messages.length - 1
161
+ if (lastMsgContent.includes(searchString)) {
162
+ // logger.info(
163
+ // `${stringType} found in last message (last resort) at index ${lastMsgIndex}`,
164
+ // )
165
+ return {
166
+ messageId: lastMessage.info.id,
167
+ messageIndex: lastMsgIndex,
168
+ }
169
+ }
170
+ }
171
+
172
+ throw new Error(
173
+ `${stringType} not found in conversation. ` +
174
+ `Make sure the string exists and is spelled exactly as it appears.`,
175
+ )
176
+ }
177
+
178
+ fuzzyMatches.sort((a, b) => b.score - a.score)
179
+
180
+ const best = fuzzyMatches[0]!
181
+ const secondBest = fuzzyMatches[1]
182
+
183
+ // Log fuzzy match candidates
184
+ // logger.info(
185
+ // `Fuzzy match for ${stringType}: best=${best.score}% (msg ${best.messageIndex})` +
186
+ // (secondBest
187
+ // ? `, secondBest=${secondBest.score}% (msg ${secondBest.messageIndex})`
188
+ // : ""),
189
+ // )
190
+
191
+ // Check confidence gap - best must be significantly better than second best
192
+ if (secondBest && best.score - secondBest.score < fuzzyConfig.minGap) {
193
+ throw new Error(
194
+ `Found multiple matches for ${stringType}. ` +
195
+ `Provide more unique surrounding context to disambiguate.`,
196
+ )
197
+ }
198
+
199
+ logger.info(
200
+ `Fuzzy matched ${stringType} with ${best.score}% confidence at message index ${best.messageIndex}`,
201
+ )
202
+
203
+ return { messageId: best.messageId, messageIndex: best.messageIndex }
204
+ }
205
+
206
+ export function collectToolIdsInRange(
207
+ messages: WithParts[],
208
+ startIndex: number,
209
+ endIndex: number,
210
+ ): string[] {
211
+ const toolIds: string[] = []
212
+
213
+ for (let i = startIndex; i <= endIndex; i++) {
214
+ const msg = messages[i]!
215
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
216
+
217
+ for (const part of parts) {
218
+ if (part.type === "tool" && part.callID) {
219
+ if (!toolIds.includes(part.callID)) {
220
+ toolIds.push(part.callID)
221
+ }
222
+ }
223
+ }
224
+ }
225
+
226
+ return toolIds
227
+ }
228
+
229
+ export function collectMessageIdsInRange(
230
+ messages: WithParts[],
231
+ startIndex: number,
232
+ endIndex: number,
233
+ ): string[] {
234
+ const messageIds: string[] = []
235
+
236
+ for (let i = startIndex; i <= endIndex; i++) {
237
+ const msgId = messages[i]!.info.id
238
+ if (!messageIds.includes(msgId)) {
239
+ messageIds.push(msgId)
240
+ }
241
+ }
242
+
243
+ return messageIds
244
+ }