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,224 @@
1
+ import type { SessionState, WithParts } from "./state"
2
+ import type { Logger } from "./logger"
3
+ import type { PluginConfig } from "./config"
4
+ import { syncToolCache } from "./state/tool-cache"
5
+ import { deduplicate, supersedeWrites, purgeErrors } from "./strategies"
6
+ import { prune, insertPruneToolContext } from "./messages"
7
+ import { buildToolIdList, isIgnoredUserMessage } from "./messages/utils"
8
+ import { checkSession } from "./state"
9
+ import { renderSystemPrompt } from "./prompts"
10
+ import { handleStatsCommand } from "./commands/stats"
11
+ import { handleContextCommand } from "./commands/context"
12
+ import { handleHelpCommand } from "./commands/help"
13
+ import { handleSweepCommand } from "./commands/sweep"
14
+ import { handleManualToggleCommand, handleManualTriggerCommand } from "./commands/manual"
15
+ import { ensureSessionInitialized } from "./state/state"
16
+ import { getCurrentParams } from "./strategies/utils"
17
+
18
+ const INTERNAL_AGENT_SIGNATURES = [
19
+ "You are a title generator",
20
+ "You are a helpful AI assistant tasked with summarizing conversations",
21
+ "Summarize what was done in this conversation",
22
+ ]
23
+
24
+ function applyPendingManualTriggerPrompt(
25
+ state: SessionState,
26
+ messages: WithParts[],
27
+ logger: Logger,
28
+ ): void {
29
+ const pending = state.pendingManualTrigger
30
+ if (!pending) {
31
+ return
32
+ }
33
+
34
+ if (!state.sessionId || pending.sessionId !== state.sessionId) {
35
+ state.pendingManualTrigger = null
36
+ return
37
+ }
38
+
39
+ for (let i = messages.length - 1; i >= 0; i--) {
40
+ const msg = messages[i]!
41
+ if (msg.info.role !== "user" || isIgnoredUserMessage(msg)) {
42
+ continue
43
+ }
44
+
45
+ for (const part of msg.parts) {
46
+ if (part.type !== "text" || part.ignored || part.synthetic) {
47
+ continue
48
+ }
49
+
50
+ part.text = pending.prompt
51
+ state.pendingManualTrigger = null
52
+ logger.debug("Applied pending manual trigger prompt", { sessionId: pending.sessionId })
53
+ return
54
+ }
55
+ }
56
+
57
+ state.pendingManualTrigger = null
58
+ }
59
+
60
+ export function createSystemPromptHandler(
61
+ state: SessionState,
62
+ logger: Logger,
63
+ config: PluginConfig,
64
+ ) {
65
+ return async (
66
+ input: { sessionID?: string; model: { limit: { context: number } } },
67
+ output: { system: string[] },
68
+ ) => {
69
+ if (input.model?.limit?.context) {
70
+ state.modelContextLimit = input.model.limit.context
71
+ logger.debug("Cached model context limit", { limit: state.modelContextLimit })
72
+ }
73
+
74
+ if (state.isSubAgent) {
75
+ return
76
+ }
77
+
78
+ const systemText = output.system.join("\n")
79
+ if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) {
80
+ logger.info("Skipping DCP system prompt injection for internal agent")
81
+ return
82
+ }
83
+
84
+ const flags = {
85
+ prune: config.tools.prune.permission !== "deny",
86
+ distill: config.tools.distill.permission !== "deny",
87
+ compress: config.tools.compress.permission !== "deny",
88
+ manual: state.manualMode,
89
+ }
90
+
91
+ if (!flags.prune && !flags.distill && !flags.compress) {
92
+ return
93
+ }
94
+
95
+ output.system.push(renderSystemPrompt(flags))
96
+ }
97
+ }
98
+
99
+ export function createChatMessageTransformHandler(
100
+ client: any,
101
+ state: SessionState,
102
+ logger: Logger,
103
+ config: PluginConfig,
104
+ ) {
105
+ return async (input: {}, output: { messages: WithParts[] }) => {
106
+ await checkSession(client, state, logger, output.messages, config.manualMode.enabled)
107
+
108
+ if (state.isSubAgent) {
109
+ return
110
+ }
111
+
112
+ syncToolCache(state, config, logger, output.messages)
113
+ buildToolIdList(state, output.messages, logger)
114
+
115
+ deduplicate(state, logger, config, output.messages)
116
+ supersedeWrites(state, logger, config, output.messages)
117
+ purgeErrors(state, logger, config, output.messages)
118
+
119
+ prune(state, logger, config, output.messages)
120
+ insertPruneToolContext(state, config, logger, output.messages)
121
+
122
+ applyPendingManualTriggerPrompt(state, output.messages, logger)
123
+
124
+ if (state.sessionId) {
125
+ await logger.saveContext(state.sessionId, output.messages)
126
+ }
127
+ }
128
+ }
129
+
130
+ export function createCommandExecuteHandler(
131
+ client: any,
132
+ state: SessionState,
133
+ logger: Logger,
134
+ config: PluginConfig,
135
+ workingDirectory: string,
136
+ ) {
137
+ return async (
138
+ input: { command: string; sessionID: string; arguments: string },
139
+ output: { parts: any[] },
140
+ ) => {
141
+ if (!config.commands.enabled) {
142
+ return
143
+ }
144
+
145
+ if (input.command === "dcp") {
146
+ const messagesResponse = await client.session.messages({
147
+ path: { id: input.sessionID },
148
+ })
149
+ const messages = (messagesResponse.data || messagesResponse) as WithParts[]
150
+
151
+ await ensureSessionInitialized(
152
+ client,
153
+ state,
154
+ input.sessionID,
155
+ logger,
156
+ messages,
157
+ config.manualMode.enabled,
158
+ )
159
+
160
+ const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean)
161
+ const subcommand = args[0]?.toLowerCase() || ""
162
+ const subArgs = args.slice(1)
163
+
164
+ const commandCtx = {
165
+ client,
166
+ state,
167
+ config,
168
+ logger,
169
+ sessionId: input.sessionID,
170
+ messages,
171
+ }
172
+
173
+ if (subcommand === "context") {
174
+ await handleContextCommand(commandCtx)
175
+ throw new Error("__DCP_CONTEXT_HANDLED__")
176
+ }
177
+
178
+ if (subcommand === "stats") {
179
+ await handleStatsCommand(commandCtx)
180
+ throw new Error("__DCP_STATS_HANDLED__")
181
+ }
182
+
183
+ if (subcommand === "sweep") {
184
+ await handleSweepCommand({
185
+ ...commandCtx,
186
+ args: subArgs,
187
+ workingDirectory,
188
+ })
189
+ throw new Error("__DCP_SWEEP_HANDLED__")
190
+ }
191
+
192
+ if (subcommand === "manual") {
193
+ await handleManualToggleCommand(commandCtx, subArgs[0]?.toLowerCase())
194
+ throw new Error("__DCP_MANUAL_HANDLED__")
195
+ }
196
+
197
+ if (
198
+ (subcommand === "prune" || subcommand === "distill" || subcommand === "compress") &&
199
+ config.tools[subcommand].permission !== "deny"
200
+ ) {
201
+ const userFocus = subArgs.join(" ").trim()
202
+ const prompt = await handleManualTriggerCommand(commandCtx, subcommand, userFocus)
203
+ if (!prompt) {
204
+ throw new Error("__DCP_MANUAL_TRIGGER_BLOCKED__")
205
+ }
206
+
207
+ state.pendingManualTrigger = {
208
+ sessionId: input.sessionID,
209
+ prompt,
210
+ }
211
+ const rawArgs = (input.arguments || "").trim()
212
+ output.parts.length = 0
213
+ output.parts.push({
214
+ type: "text",
215
+ text: rawArgs ? `/dcp ${rawArgs}` : `/dcp ${subcommand}`,
216
+ })
217
+ return
218
+ }
219
+
220
+ await handleHelpCommand(commandCtx)
221
+ throw new Error("__DCP_HELP_HANDLED__")
222
+ }
223
+ }
224
+ }
@@ -0,0 +1,123 @@
1
+ import type { PluginInput, Hooks } from "@opencode-ai/plugin"
2
+ import { getConfig } from "./config"
3
+ import { Logger } from "./logger"
4
+ import { createSessionState } from "./state"
5
+ import { createPruneTool, createDistillTool, createCompressTool } from "./strategies"
6
+ import {
7
+ createChatMessageTransformHandler,
8
+ createCommandExecuteHandler,
9
+ createSystemPromptHandler,
10
+ } from "./hooks"
11
+ import { configureClientAuth, isSecureMode } from "./auth"
12
+
13
+ export async function createDcpPlugin(ctx: PluginInput): Promise<Partial<Hooks>> {
14
+ const config = getConfig(ctx)
15
+
16
+ if (!config.enabled) {
17
+ return {}
18
+ }
19
+
20
+ const logger = new Logger(config.debug)
21
+ const state = createSessionState()
22
+
23
+ if (isSecureMode()) {
24
+ configureClientAuth(ctx.client)
25
+ }
26
+
27
+ logger.info("DCP initialized", {
28
+ strategies: config.strategies,
29
+ })
30
+
31
+ return {
32
+ "experimental.chat.system.transform": createSystemPromptHandler(state, logger, config),
33
+
34
+ "experimental.chat.messages.transform": createChatMessageTransformHandler(
35
+ ctx.client,
36
+ state,
37
+ logger,
38
+ config,
39
+ ) as any,
40
+ "chat.message": async (
41
+ input: {
42
+ sessionID: string
43
+ agent?: string
44
+ model?: { providerID: string; modelID: string }
45
+ messageID?: string
46
+ variant?: string
47
+ },
48
+ _output: any,
49
+ ) => {
50
+ state.variant = input.variant
51
+ logger.debug("Cached variant from chat.message hook", { variant: input.variant })
52
+ },
53
+ "command.execute.before": createCommandExecuteHandler(
54
+ ctx.client,
55
+ state,
56
+ logger,
57
+ config,
58
+ ctx.directory,
59
+ ),
60
+ tool: {
61
+ ...(config.tools.distill.permission !== "deny" && {
62
+ distill: createDistillTool({
63
+ client: ctx.client,
64
+ state,
65
+ logger,
66
+ config,
67
+ workingDirectory: ctx.directory,
68
+ }),
69
+ }),
70
+ ...(config.tools.compress.permission !== "deny" && {
71
+ compress: createCompressTool({
72
+ client: ctx.client,
73
+ state,
74
+ logger,
75
+ config,
76
+ workingDirectory: ctx.directory,
77
+ }),
78
+ }),
79
+ ...(config.tools.prune.permission !== "deny" && {
80
+ prune: createPruneTool({
81
+ client: ctx.client,
82
+ state,
83
+ logger,
84
+ config,
85
+ workingDirectory: ctx.directory,
86
+ }),
87
+ }),
88
+ },
89
+ config: async (opencodeConfig: any) => {
90
+ if (config.commands.enabled) {
91
+ opencodeConfig.command ??= {}
92
+ opencodeConfig.command["dcp"] = {
93
+ template: "",
94
+ description: "Show available DCP commands",
95
+ }
96
+ }
97
+
98
+ const toolsToAdd: string[] = []
99
+ if (config.tools.distill.permission !== "deny") toolsToAdd.push("distill")
100
+ if (config.tools.compress.permission !== "deny") toolsToAdd.push("compress")
101
+ if (config.tools.prune.permission !== "deny") toolsToAdd.push("prune")
102
+
103
+ if (toolsToAdd.length > 0) {
104
+ const existingPrimaryTools = opencodeConfig.experimental?.primary_tools ?? []
105
+ opencodeConfig.experimental = {
106
+ ...opencodeConfig.experimental,
107
+ primary_tools: [...existingPrimaryTools, ...toolsToAdd],
108
+ }
109
+ logger.info(
110
+ `Added ${toolsToAdd.map((t) => `'${t}'`).join(" and ")} to experimental.primary_tools via config mutation`,
111
+ )
112
+ }
113
+
114
+ const permission = opencodeConfig.permission ?? {}
115
+ opencodeConfig.permission = {
116
+ ...permission,
117
+ distill: config.tools.distill.permission,
118
+ compress: config.tools.compress.permission,
119
+ prune: config.tools.prune.permission,
120
+ } as typeof permission
121
+ },
122
+ }
123
+ }
@@ -0,0 +1,211 @@
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
+ public enabled: boolean
9
+
10
+ constructor(enabled: boolean) {
11
+ this.enabled = enabled
12
+ const configHome = process.env.XDG_CONFIG_HOME || join(homedir(), ".config")
13
+ this.logDir = join(configHome, "opencode", "logs", "dcp")
14
+ }
15
+
16
+ private async ensureLogDir() {
17
+ if (!existsSync(this.logDir)) {
18
+ await mkdir(this.logDir, { recursive: true })
19
+ }
20
+ }
21
+
22
+ private formatData(data?: any): string {
23
+ if (!data) return ""
24
+
25
+ const parts: string[] = []
26
+ for (const [key, value] of Object.entries(data)) {
27
+ if (value === undefined || value === null) continue
28
+
29
+ // Format arrays compactly
30
+ if (Array.isArray(value)) {
31
+ if (value.length === 0) continue
32
+ parts.push(
33
+ `${key}=[${value.slice(0, 3).join(",")}${value.length > 3 ? `...+${value.length - 3}` : ""}]`,
34
+ )
35
+ } else if (typeof value === "object") {
36
+ const str = JSON.stringify(value)
37
+ if (str.length < 50) {
38
+ parts.push(`${key}=${str}`)
39
+ }
40
+ } else {
41
+ parts.push(`${key}=${value}`)
42
+ }
43
+ }
44
+ return parts.join(" ")
45
+ }
46
+
47
+ private getCallerFile(skipFrames: number = 3): string {
48
+ const originalPrepareStackTrace = Error.prepareStackTrace
49
+ try {
50
+ const err = new Error()
51
+ Error.prepareStackTrace = (_, stack) => stack
52
+ const stack = err.stack as unknown as NodeJS.CallSite[]
53
+ Error.prepareStackTrace = originalPrepareStackTrace
54
+
55
+ // Skip specified number of frames to get to actual caller
56
+ for (let i = skipFrames; i < stack.length; i++) {
57
+ const filename = stack[i]?.getFileName()
58
+ if (filename && !filename.includes("/logger.")) {
59
+ // Extract just the filename without path and extension
60
+ const match = filename.match(/([^/\\]+)\.[tj]s$/)
61
+ return match ? match[1]! : filename
62
+ }
63
+ }
64
+ return "unknown"
65
+ } catch {
66
+ return "unknown"
67
+ }
68
+ }
69
+
70
+ private async write(level: string, component: string, message: string, data?: any) {
71
+ if (!this.enabled) return
72
+
73
+ try {
74
+ await this.ensureLogDir()
75
+
76
+ const timestamp = new Date().toISOString()
77
+ const dataStr = this.formatData(data)
78
+
79
+ const logLine = `${timestamp} ${level.padEnd(5)} ${component}: ${message}${dataStr ? " | " + dataStr : ""}\n`
80
+
81
+ const dailyLogDir = join(this.logDir, "daily")
82
+ if (!existsSync(dailyLogDir)) {
83
+ await mkdir(dailyLogDir, { recursive: true })
84
+ }
85
+
86
+ const logFile = join(dailyLogDir, `${new Date().toISOString().split("T")[0]!}.log`)
87
+ await writeFile(logFile, logLine, { flag: "a" })
88
+ } catch (error) {}
89
+ }
90
+
91
+ info(message: string, data?: any) {
92
+ const component = this.getCallerFile(2)
93
+ return this.write("INFO", component, message, data)
94
+ }
95
+
96
+ debug(message: string, data?: any) {
97
+ const component = this.getCallerFile(2)
98
+ return this.write("DEBUG", component, message, data)
99
+ }
100
+
101
+ warn(message: string, data?: any) {
102
+ const component = this.getCallerFile(2)
103
+ return this.write("WARN", component, message, data)
104
+ }
105
+
106
+ error(message: string, data?: any) {
107
+ const component = this.getCallerFile(2)
108
+ return this.write("ERROR", component, message, data)
109
+ }
110
+
111
+ /**
112
+ * Strips unnecessary metadata from messages for cleaner debug logs.
113
+ *
114
+ * Removed:
115
+ * - All IDs (id, sessionID, messageID, parentID, callID on parts)
116
+ * - summary, path, cost, model, agent, mode, finish, providerID, modelID
117
+ * - step-start and step-finish parts entirely
118
+ * - snapshot fields
119
+ * - ignored text parts
120
+ *
121
+ * Kept:
122
+ * - role, time (created only), tokens (input, output, reasoning, cache)
123
+ * - text, reasoning, tool parts with content
124
+ * - tool calls with: tool, callID, input, output
125
+ */
126
+ private minimizeForDebug(messages: any[]): any[] {
127
+ return messages.map((msg) => {
128
+ const minimized: any = {
129
+ role: msg.info?.role,
130
+ }
131
+
132
+ if (msg.info?.time?.created) {
133
+ minimized.time = msg.info.time.created
134
+ }
135
+
136
+ if (msg.info?.tokens) {
137
+ minimized.tokens = {
138
+ input: msg.info.tokens.input,
139
+ output: msg.info.tokens.output,
140
+ reasoning: msg.info.tokens.reasoning,
141
+ cache: msg.info.tokens.cache,
142
+ }
143
+ }
144
+
145
+ if (msg.parts) {
146
+ minimized.parts = msg.parts
147
+ .map((part: any) => {
148
+ if (part.type === "step-start" || part.type === "step-finish") {
149
+ return null
150
+ }
151
+
152
+ if (part.type === "text") {
153
+ if (part.ignored) return null
154
+ return { type: "text", text: part.text }
155
+ }
156
+
157
+ if (part.type === "reasoning") {
158
+ return {
159
+ type: "reasoning",
160
+ text: part.text,
161
+ }
162
+ }
163
+
164
+ if (part.type === "tool") {
165
+ const toolPart: any = {
166
+ type: "tool",
167
+ tool: part.tool,
168
+ callID: part.callID,
169
+ }
170
+
171
+ if (part.state?.status) {
172
+ toolPart.status = part.state.status
173
+ }
174
+ if (part.state?.input) {
175
+ toolPart.input = part.state.input
176
+ }
177
+ if (part.state?.output) {
178
+ toolPart.output = part.state.output
179
+ }
180
+ if (part.state?.error) {
181
+ toolPart.error = part.state.error
182
+ }
183
+
184
+ return toolPart
185
+ }
186
+
187
+ return null
188
+ })
189
+ .filter(Boolean)
190
+ }
191
+
192
+ return minimized
193
+ })
194
+ }
195
+
196
+ async saveContext(sessionId: string, messages: any[]) {
197
+ if (!this.enabled) return
198
+
199
+ try {
200
+ const contextDir = join(this.logDir, "context", sessionId)
201
+ if (!existsSync(contextDir)) {
202
+ await mkdir(contextDir, { recursive: true })
203
+ }
204
+
205
+ const minimized = this.minimizeForDebug(messages)
206
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
207
+ const contextFile = join(contextDir, `${timestamp}.json`)
208
+ await writeFile(contextFile, JSON.stringify(minimized, null, 2))
209
+ } catch (error) {}
210
+ }
211
+ }
@@ -0,0 +1,2 @@
1
+ export { prune } from "./prune"
2
+ export { insertPruneToolContext } from "./inject"