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,73 @@
1
+ /**
2
+ * DCP Stats command handler.
3
+ * Shows pruning statistics for the current session and all-time totals.
4
+ */
5
+
6
+ import type { Logger } from "../logger"
7
+ import type { SessionState, WithParts } from "../state"
8
+ import { sendIgnoredMessage } from "../ui/notification"
9
+ import { formatTokenCount } from "../ui/utils"
10
+ import { loadAllSessionStats, type AggregatedStats } from "../state/persistence"
11
+ import { getCurrentParams } from "../strategies/utils"
12
+
13
+ export interface StatsCommandContext {
14
+ client: any
15
+ state: SessionState
16
+ logger: Logger
17
+ sessionId: string
18
+ messages: WithParts[]
19
+ }
20
+
21
+ function formatStatsMessage(
22
+ sessionTokens: number,
23
+ sessionTools: number,
24
+ sessionMessages: number,
25
+ allTime: AggregatedStats,
26
+ ): string {
27
+ const lines: string[] = []
28
+
29
+ lines.push("╭───────────────────────────────────────────────────────────╮")
30
+ lines.push("│ DCP Statistics │")
31
+ lines.push("╰───────────────────────────────────────────────────────────╯")
32
+ lines.push("")
33
+ lines.push("Session:")
34
+ lines.push("─".repeat(60))
35
+ lines.push(` Tokens pruned: ~${formatTokenCount(sessionTokens)}`)
36
+ lines.push(` Tools pruned: ${sessionTools}`)
37
+ lines.push(` Messages pruned: ${sessionMessages}`)
38
+ lines.push("")
39
+ lines.push("All-time:")
40
+ lines.push("─".repeat(60))
41
+ lines.push(` Tokens saved: ~${formatTokenCount(allTime.totalTokens)}`)
42
+ lines.push(` Tools pruned: ${allTime.totalTools}`)
43
+ lines.push(` Messages pruned: ${allTime.totalMessages}`)
44
+ lines.push(` Sessions: ${allTime.sessionCount}`)
45
+
46
+ return lines.join("\n")
47
+ }
48
+
49
+ export async function handleStatsCommand(ctx: StatsCommandContext): Promise<void> {
50
+ const { client, state, logger, sessionId, messages } = ctx
51
+
52
+ // Session stats from in-memory state
53
+ const sessionTokens = state.stats.totalPruneTokens
54
+ const sessionTools = state.prune.tools.size
55
+ const sessionMessages = state.prune.messages.size
56
+
57
+ // All-time stats from storage files
58
+ const allTime = await loadAllSessionStats(logger)
59
+
60
+ const message = formatStatsMessage(sessionTokens, sessionTools, sessionMessages, allTime)
61
+
62
+ const params = getCurrentParams(state, messages, logger)
63
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
64
+
65
+ logger.info("Stats command executed", {
66
+ sessionTokens,
67
+ sessionTools,
68
+ sessionMessages,
69
+ allTimeTokens: allTime.totalTokens,
70
+ allTimeTools: allTime.totalTools,
71
+ allTimeMessages: allTime.totalMessages,
72
+ })
73
+ }
@@ -0,0 +1,263 @@
1
+ /**
2
+ * DCP Sweep command handler.
3
+ * Prunes tool outputs since the last user message, or the last N tools.
4
+ *
5
+ * Usage:
6
+ * /dcp sweep - Prune all tools since the previous user message
7
+ * /dcp sweep 10 - Prune the last 10 tools
8
+ */
9
+
10
+ import type { Logger } from "../logger"
11
+ import type { SessionState, WithParts, ToolParameterEntry } from "../state"
12
+ import type { PluginConfig } from "../config"
13
+ import { sendIgnoredMessage } from "../ui/notification"
14
+ import { formatPrunedItemsList } from "../ui/utils"
15
+ import { getCurrentParams, getTotalToolTokens } from "../strategies/utils"
16
+ import { buildToolIdList, isIgnoredUserMessage } from "../messages/utils"
17
+ import { saveSessionState } from "../state/persistence"
18
+ import { isMessageCompacted } from "../shared-utils"
19
+ import { getFilePathsFromParameters, isProtected } from "../protected-file-patterns"
20
+ import { syncToolCache } from "../state/tool-cache"
21
+
22
+ export interface SweepCommandContext {
23
+ client: any
24
+ state: SessionState
25
+ config: PluginConfig
26
+ logger: Logger
27
+ sessionId: string
28
+ messages: WithParts[]
29
+ args: string[]
30
+ workingDirectory: string
31
+ }
32
+
33
+ function findLastUserMessageIndex(messages: WithParts[]): number {
34
+ for (let i = messages.length - 1; i >= 0; i--) {
35
+ const msg = messages[i]!
36
+ if (msg.info.role === "user" && !isIgnoredUserMessage(msg)) {
37
+ return i
38
+ }
39
+ }
40
+
41
+ return -1
42
+ }
43
+
44
+ function collectToolIdsAfterIndex(
45
+ state: SessionState,
46
+ messages: WithParts[],
47
+ afterIndex: number,
48
+ ): string[] {
49
+ const toolIds: string[] = []
50
+
51
+ for (let i = afterIndex + 1; i < messages.length; i++) {
52
+ const msg = messages[i]!
53
+ if (isMessageCompacted(state, msg)) {
54
+ continue
55
+ }
56
+ const parts = Array.isArray(msg.parts) ? msg.parts : []
57
+ if (parts.length > 0) {
58
+ for (const part of parts) {
59
+ if (part.type === "tool" && part.callID && part.tool) {
60
+ toolIds.push(part.callID)
61
+ }
62
+ }
63
+ }
64
+ }
65
+
66
+ return toolIds
67
+ }
68
+
69
+ function formatNoUserMessage(): string {
70
+ const lines: string[] = []
71
+
72
+ lines.push("╭───────────────────────────────────────────────────────────╮")
73
+ lines.push("│ DCP Sweep │")
74
+ lines.push("╰───────────────────────────────────────────────────────────╯")
75
+ lines.push("")
76
+ lines.push("Nothing swept: no user message found.")
77
+
78
+ return lines.join("\n")
79
+ }
80
+
81
+ function formatSweepMessage(
82
+ toolCount: number,
83
+ tokensSaved: number,
84
+ mode: "since-user" | "last-n",
85
+ toolIds: string[],
86
+ toolMetadata: Map<string, ToolParameterEntry>,
87
+ workingDirectory?: string,
88
+ skippedProtected?: number,
89
+ ): string {
90
+ const lines: string[] = []
91
+
92
+ lines.push("╭───────────────────────────────────────────────────────────╮")
93
+ lines.push("│ DCP Sweep │")
94
+ lines.push("╰───────────────────────────────────────────────────────────╯")
95
+ lines.push("")
96
+
97
+ if (toolCount === 0) {
98
+ if (mode === "since-user") {
99
+ lines.push("No tools found since the previous user message.")
100
+ } else {
101
+ lines.push(`No tools found to sweep.`)
102
+ }
103
+ if (skippedProtected && skippedProtected > 0) {
104
+ lines.push(`(${skippedProtected} protected tool(s) skipped)`)
105
+ }
106
+ } else {
107
+ if (mode === "since-user") {
108
+ lines.push(`Swept ${toolCount} tool(s) since the previous user message.`)
109
+ } else {
110
+ lines.push(`Swept the last ${toolCount} tool(s).`)
111
+ }
112
+ lines.push(`Tokens saved: ~${tokensSaved.toLocaleString()}`)
113
+ if (skippedProtected && skippedProtected > 0) {
114
+ lines.push(`(${skippedProtected} protected tool(s) skipped)`)
115
+ }
116
+ lines.push("")
117
+ const itemLines = formatPrunedItemsList(toolIds, toolMetadata, workingDirectory)
118
+ lines.push(...itemLines)
119
+ }
120
+
121
+ return lines.join("\n")
122
+ }
123
+
124
+ export async function handleSweepCommand(ctx: SweepCommandContext): Promise<void> {
125
+ const { client, state, config, logger, sessionId, messages, args, workingDirectory } = ctx
126
+
127
+ const params = getCurrentParams(state, messages, logger)
128
+ const protectedTools = config.commands.protectedTools
129
+
130
+ syncToolCache(state, config, logger, messages)
131
+ buildToolIdList(state, messages, logger)
132
+
133
+ // Parse optional numeric argument
134
+ const numArg = args[0] ? parseInt(args[0], 10) : null
135
+ const isLastNMode = numArg !== null && !isNaN(numArg) && numArg > 0
136
+
137
+ let toolIdsToSweep: string[]
138
+ let mode: "since-user" | "last-n"
139
+
140
+ if (isLastNMode) {
141
+ // Mode: Sweep last N tools
142
+ mode = "last-n"
143
+ const startIndex = Math.max(0, state.toolIdList.length - numArg!)
144
+ toolIdsToSweep = state.toolIdList.slice(startIndex)
145
+ logger.info(`Sweep command: last ${numArg} mode, found ${toolIdsToSweep.length} tools`)
146
+ } else {
147
+ // Mode: Sweep since last user message
148
+ mode = "since-user"
149
+ const lastUserMsgIndex = findLastUserMessageIndex(messages)
150
+
151
+ if (lastUserMsgIndex === -1) {
152
+ // No user message found - show message and return
153
+ const message = formatNoUserMessage()
154
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
155
+ logger.info("Sweep command: no user message found")
156
+ return
157
+ } else {
158
+ toolIdsToSweep = collectToolIdsAfterIndex(state, messages, lastUserMsgIndex)
159
+ logger.info(
160
+ `Sweep command: found last user at index ${lastUserMsgIndex}, sweeping ${toolIdsToSweep.length} tools`,
161
+ )
162
+ }
163
+ }
164
+
165
+ // Filter out already-pruned tools, protected tools, and protected file paths
166
+ const newToolIds = toolIdsToSweep.filter((id) => {
167
+ if (state.prune.tools.has(id)) {
168
+ return false
169
+ }
170
+ const entry = state.toolParameters.get(id)
171
+ if (!entry) {
172
+ return true
173
+ }
174
+ if (protectedTools.includes(entry.tool)) {
175
+ logger.debug(`Sweep: skipping protected tool ${entry.tool} (${id})`)
176
+ return false
177
+ }
178
+ const filePaths = getFilePathsFromParameters(entry.tool, entry.parameters)
179
+ if (isProtected(filePaths, config.protectedFilePatterns)) {
180
+ logger.debug(`Sweep: skipping protected file path(s) ${filePaths.join(", ")} (${id})`)
181
+ return false
182
+ }
183
+ return true
184
+ })
185
+
186
+ // Count how many were skipped due to protection
187
+ const skippedProtected = toolIdsToSweep.filter((id) => {
188
+ const entry = state.toolParameters.get(id)
189
+ if (!entry) {
190
+ return false
191
+ }
192
+ if (protectedTools.includes(entry.tool)) {
193
+ return true
194
+ }
195
+ const filePaths = getFilePathsFromParameters(entry.tool, entry.parameters)
196
+ if (isProtected(filePaths, config.protectedFilePatterns)) {
197
+ return true
198
+ }
199
+ return false
200
+ }).length
201
+
202
+ if (newToolIds.length === 0) {
203
+ const message = formatSweepMessage(
204
+ 0,
205
+ 0,
206
+ mode,
207
+ [],
208
+ new Map(),
209
+ workingDirectory,
210
+ skippedProtected,
211
+ )
212
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
213
+ logger.info("Sweep command: no new tools to sweep", { skippedProtected })
214
+ return
215
+ }
216
+
217
+ const tokensSaved = getTotalToolTokens(state, newToolIds)
218
+
219
+ // Add to prune list
220
+ for (const id of newToolIds) {
221
+ const entry = state.toolParameters.get(id)
222
+ state.prune.tools.set(id, entry?.tokenCount ?? 0)
223
+ }
224
+ state.stats.pruneTokenCounter += tokensSaved
225
+ state.stats.totalPruneTokens += state.stats.pruneTokenCounter
226
+ state.stats.pruneTokenCounter = 0
227
+
228
+ // Collect metadata for logging
229
+ const toolMetadata: Map<string, ToolParameterEntry> = new Map()
230
+ for (const id of newToolIds) {
231
+ const entry = state.toolParameters.get(id)
232
+ if (entry) {
233
+ toolMetadata.set(id, entry)
234
+ }
235
+ }
236
+
237
+ // Persist state
238
+ saveSessionState(state, logger).catch((err) =>
239
+ logger.error("Failed to persist state after sweep", { error: err.message }),
240
+ )
241
+
242
+ const message = formatSweepMessage(
243
+ newToolIds.length,
244
+ tokensSaved,
245
+ mode,
246
+ newToolIds,
247
+ toolMetadata,
248
+ workingDirectory,
249
+ skippedProtected,
250
+ )
251
+ await sendIgnoredMessage(client, sessionId, message, params, logger)
252
+
253
+ logger.info("Sweep command completed", {
254
+ toolsSwept: newToolIds.length,
255
+ tokensSaved,
256
+ skippedProtected,
257
+ mode,
258
+ tools: Array.from(toolMetadata.entries()).map(([id, entry]) => ({
259
+ id,
260
+ tool: entry.tool,
261
+ })),
262
+ })
263
+ }