@tarquinen/opencode-dcp 3.1.12 → 3.1.13

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 (106) hide show
  1. package/README.md +14 -9
  2. package/dist/index.js +105 -39
  3. package/dist/index.js.map +1 -1
  4. package/dist/lib/commands/context.d.ts +15 -0
  5. package/dist/lib/commands/context.d.ts.map +1 -1
  6. package/dist/lib/commands/help.d.ts +1 -0
  7. package/dist/lib/commands/help.d.ts.map +1 -1
  8. package/dist/lib/commands/manual.d.ts +1 -1
  9. package/dist/lib/commands/manual.d.ts.map +1 -1
  10. package/dist/lib/commands/stats.d.ts +10 -0
  11. package/dist/lib/commands/stats.d.ts.map +1 -1
  12. package/dist/lib/compress/pipeline.d.ts.map +1 -1
  13. package/dist/lib/hooks.d.ts.map +1 -1
  14. package/dist/lib/state/persistence.d.ts +3 -0
  15. package/dist/lib/state/persistence.d.ts.map +1 -1
  16. package/dist/lib/state/state.d.ts +1 -0
  17. package/dist/lib/state/state.d.ts.map +1 -1
  18. package/dist/lib/tui/commands.d.ts +3 -0
  19. package/dist/lib/tui/commands.d.ts.map +1 -0
  20. package/dist/lib/tui/data.d.ts +14 -0
  21. package/dist/lib/tui/data.d.ts.map +1 -0
  22. package/dist/lib/tui/dialogs.d.ts +30 -0
  23. package/dist/lib/tui/dialogs.d.ts.map +1 -0
  24. package/dist/lib/tui/format.d.ts +4 -0
  25. package/dist/lib/tui/format.d.ts.map +1 -0
  26. package/dist/lib/tui/modals.d.ts +10 -0
  27. package/dist/lib/tui/modals.d.ts.map +1 -0
  28. package/dist/lib/tui/types.d.ts +15 -0
  29. package/dist/lib/tui/types.d.ts.map +1 -0
  30. package/dist/lib/tui/ui.d.ts +48 -0
  31. package/dist/lib/tui/ui.d.ts.map +1 -0
  32. package/dist/tui.d.ts +7 -0
  33. package/dist/tui.d.ts.map +1 -0
  34. package/lib/auth.ts +37 -0
  35. package/lib/commands/compression-targets.ts +137 -0
  36. package/lib/commands/context.ts +305 -0
  37. package/lib/commands/decompress.ts +275 -0
  38. package/lib/commands/help.ts +76 -0
  39. package/lib/commands/index.ts +11 -0
  40. package/lib/commands/manual.ts +127 -0
  41. package/lib/commands/recompress.ts +224 -0
  42. package/lib/commands/stats.ts +159 -0
  43. package/lib/commands/sweep.ts +268 -0
  44. package/lib/compress/index.ts +3 -0
  45. package/lib/compress/message-utils.ts +250 -0
  46. package/lib/compress/message.ts +145 -0
  47. package/lib/compress/pipeline.ts +108 -0
  48. package/lib/compress/protected-content.ts +208 -0
  49. package/lib/compress/range-utils.ts +308 -0
  50. package/lib/compress/range.ts +192 -0
  51. package/lib/compress/search.ts +267 -0
  52. package/lib/compress/state.ts +268 -0
  53. package/lib/compress/timing.ts +77 -0
  54. package/lib/compress/types.ts +108 -0
  55. package/lib/compress-permission.ts +25 -0
  56. package/lib/config.ts +1007 -0
  57. package/lib/hooks.ts +375 -0
  58. package/lib/host-permissions.ts +101 -0
  59. package/lib/logger.ts +226 -0
  60. package/lib/message-ids.ts +172 -0
  61. package/lib/messages/index.ts +8 -0
  62. package/lib/messages/inject/inject.ts +215 -0
  63. package/lib/messages/inject/subagent-results.ts +82 -0
  64. package/lib/messages/inject/utils.ts +374 -0
  65. package/lib/messages/priority.ts +102 -0
  66. package/lib/messages/prune.ts +233 -0
  67. package/lib/messages/query.ts +72 -0
  68. package/lib/messages/reasoning-strip.ts +40 -0
  69. package/lib/messages/shape.ts +50 -0
  70. package/lib/messages/sync.ts +124 -0
  71. package/lib/messages/utils.ts +185 -0
  72. package/lib/prompts/compress-message.ts +43 -0
  73. package/lib/prompts/compress-range.ts +60 -0
  74. package/lib/prompts/context-limit-nudge.ts +18 -0
  75. package/lib/prompts/extensions/nudge.ts +43 -0
  76. package/lib/prompts/extensions/system.ts +32 -0
  77. package/lib/prompts/extensions/tool.ts +35 -0
  78. package/lib/prompts/index.ts +29 -0
  79. package/lib/prompts/iteration-nudge.ts +6 -0
  80. package/lib/prompts/store.ts +467 -0
  81. package/lib/prompts/system.ts +33 -0
  82. package/lib/prompts/turn-nudge.ts +10 -0
  83. package/lib/protected-patterns.ts +128 -0
  84. package/lib/state/index.ts +4 -0
  85. package/lib/state/persistence.ts +305 -0
  86. package/lib/state/state.ts +208 -0
  87. package/lib/state/tool-cache.ts +98 -0
  88. package/lib/state/types.ts +111 -0
  89. package/lib/state/utils.ts +345 -0
  90. package/lib/strategies/deduplication.ts +127 -0
  91. package/lib/strategies/index.ts +2 -0
  92. package/lib/strategies/purge-errors.ts +88 -0
  93. package/lib/subagents/subagent-results.ts +74 -0
  94. package/lib/token-utils.ts +164 -0
  95. package/lib/tui/commands.ts +31 -0
  96. package/lib/tui/data.ts +73 -0
  97. package/lib/tui/dialogs.tsx +235 -0
  98. package/lib/tui/format.ts +25 -0
  99. package/lib/tui/modals.tsx +92 -0
  100. package/lib/tui/types.ts +16 -0
  101. package/lib/tui/ui.tsx +219 -0
  102. package/lib/ui/notification.ts +347 -0
  103. package/lib/ui/utils.ts +304 -0
  104. package/lib/update.ts +185 -0
  105. package/package.json +18 -5
  106. package/tui.tsx +26 -0
package/lib/hooks.ts ADDED
@@ -0,0 +1,375 @@
1
+ import type { SessionState, WithParts } from "./state"
2
+ import type { Logger } from "./logger"
3
+ import type { PluginConfig } from "./config"
4
+ import { assignMessageRefs } from "./message-ids"
5
+ import {
6
+ buildPriorityMap,
7
+ buildToolIdList,
8
+ injectCompressNudges,
9
+ injectExtendedSubAgentResults,
10
+ injectMessageIds,
11
+ prune,
12
+ stripHallucinations,
13
+ stripHallucinationsFromString,
14
+ stripStaleMetadata,
15
+ syncCompressionBlocks,
16
+ } from "./messages"
17
+ import { renderSystemPrompt, type PromptStore } from "./prompts"
18
+ import { buildProtectedToolsExtension } from "./prompts/extensions/system"
19
+ import {
20
+ applyPendingCompressionDurations,
21
+ buildCompressionTimingKey,
22
+ consumeCompressionStart,
23
+ resolveCompressionDuration,
24
+ } from "./compress/timing"
25
+ import { filterMessages, filterMessagesInPlace } from "./messages/shape"
26
+ import {
27
+ applyPendingManualTrigger,
28
+ handleContextCommand,
29
+ handleDecompressCommand,
30
+ handleHelpCommand,
31
+ handleManualToggleCommand,
32
+ handleManualTriggerCommand,
33
+ handleRecompressCommand,
34
+ handleStatsCommand,
35
+ handleSweepCommand,
36
+ } from "./commands"
37
+ import { type HostPermissionSnapshot } from "./host-permissions"
38
+ import { compressPermission, syncCompressPermissionState } from "./compress-permission"
39
+ import { checkSession, ensureSessionInitialized, saveSessionState, syncToolCache } from "./state"
40
+ import { cacheSystemPromptTokens } from "./ui/utils"
41
+
42
+ const INTERNAL_AGENT_SIGNATURES = [
43
+ "You are a title generator",
44
+ "You are a helpful AI assistant tasked with summarizing conversations",
45
+ "You are an anchored context summarization assistant for coding sessions",
46
+ "Summarize what was done in this conversation",
47
+ ]
48
+
49
+ export function createSystemPromptHandler(
50
+ state: SessionState,
51
+ logger: Logger,
52
+ config: PluginConfig,
53
+ prompts: PromptStore,
54
+ ) {
55
+ return async (
56
+ input: { sessionID?: string; model: { limit: { context: number } } },
57
+ output: { system: string[] },
58
+ ) => {
59
+ if (input.model?.limit?.context) {
60
+ state.modelContextLimit = input.model.limit.context
61
+ logger.debug("Cached model context limit", { limit: state.modelContextLimit })
62
+ }
63
+
64
+ if (state.isSubAgent && !config.experimental.allowSubAgents) {
65
+ return
66
+ }
67
+
68
+ const systemText = output.system.join("\n")
69
+ if (INTERNAL_AGENT_SIGNATURES.some((sig) => systemText.includes(sig))) {
70
+ logger.info("Skipping DCP system prompt injection for internal agent")
71
+ return
72
+ }
73
+
74
+ const effectivePermission =
75
+ input.sessionID && state.sessionId === input.sessionID
76
+ ? compressPermission(state, config)
77
+ : config.compress.permission
78
+
79
+ if (effectivePermission === "deny") {
80
+ return
81
+ }
82
+
83
+ prompts.reload()
84
+ const runtimePrompts = prompts.getRuntimePrompts()
85
+ const newPrompt = renderSystemPrompt(
86
+ runtimePrompts,
87
+ buildProtectedToolsExtension(config.compress.protectedTools),
88
+ !!state.manualMode,
89
+ state.isSubAgent && config.experimental.allowSubAgents,
90
+ )
91
+ if (output.system.length > 0) {
92
+ output.system[output.system.length - 1] += "\n\n" + newPrompt
93
+ } else {
94
+ output.system.push(newPrompt)
95
+ }
96
+ }
97
+ }
98
+
99
+ export function createChatMessageTransformHandler(
100
+ client: any,
101
+ state: SessionState,
102
+ logger: Logger,
103
+ config: PluginConfig,
104
+ prompts: PromptStore,
105
+ hostPermissions: HostPermissionSnapshot,
106
+ ) {
107
+ return async (input: {}, output: { messages: WithParts[] }) => {
108
+ const receivedMessages = Array.isArray(output.messages) ? output.messages.length : 0
109
+ const messages = filterMessagesInPlace(output.messages)
110
+ if (messages.length !== receivedMessages) {
111
+ logger.warn("Skipping messages with unexpected shape during chat transform", {
112
+ received: receivedMessages,
113
+ usable: messages.length,
114
+ })
115
+ }
116
+
117
+ await checkSession(client, state, logger, output.messages, config.manualMode.enabled)
118
+
119
+ syncCompressPermissionState(state, config, hostPermissions, output.messages)
120
+
121
+ if (state.isSubAgent && !config.experimental.allowSubAgents) {
122
+ return
123
+ }
124
+
125
+ stripHallucinations(output.messages)
126
+ cacheSystemPromptTokens(state, output.messages)
127
+ assignMessageRefs(state, output.messages)
128
+ syncCompressionBlocks(state, logger, output.messages)
129
+ syncToolCache(state, config, logger, output.messages)
130
+ buildToolIdList(state, output.messages)
131
+ prune(state, logger, config, output.messages)
132
+ await injectExtendedSubAgentResults(
133
+ client,
134
+ state,
135
+ logger,
136
+ output.messages,
137
+ config.experimental.allowSubAgents,
138
+ )
139
+ const compressionPriorities = buildPriorityMap(config, state, output.messages)
140
+ prompts.reload()
141
+ injectCompressNudges(
142
+ state,
143
+ config,
144
+ logger,
145
+ output.messages,
146
+ prompts.getRuntimePrompts(),
147
+ compressionPriorities,
148
+ )
149
+ injectMessageIds(state, config, output.messages, compressionPriorities)
150
+ applyPendingManualTrigger(state, output.messages, logger)
151
+ stripStaleMetadata(output.messages)
152
+
153
+ if (state.sessionId) {
154
+ await logger.saveContext(state.sessionId, output.messages)
155
+ }
156
+ }
157
+ }
158
+
159
+ export function createCommandExecuteHandler(
160
+ client: any,
161
+ state: SessionState,
162
+ logger: Logger,
163
+ config: PluginConfig,
164
+ workingDirectory: string,
165
+ hostPermissions: HostPermissionSnapshot,
166
+ ) {
167
+ return async (
168
+ input: { command: string; sessionID: string; arguments: string },
169
+ output: { parts: any[] },
170
+ ) => {
171
+ if (!config.commands.enabled) {
172
+ return
173
+ }
174
+
175
+ if (input.command === "dcp" || input.command === "dcp-compress") {
176
+ const messagesResponse = await client.session.messages({
177
+ path: { id: input.sessionID },
178
+ })
179
+ const messages = filterMessages(messagesResponse.data || messagesResponse)
180
+
181
+ await ensureSessionInitialized(
182
+ client,
183
+ state,
184
+ input.sessionID,
185
+ logger,
186
+ messages,
187
+ config.manualMode.enabled,
188
+ )
189
+
190
+ syncCompressPermissionState(state, config, hostPermissions, messages)
191
+
192
+ const effectivePermission = compressPermission(state, config)
193
+ if (effectivePermission === "deny") {
194
+ return
195
+ }
196
+
197
+ const args = (input.arguments || "").trim().split(/\s+/).filter(Boolean)
198
+ const isCompressCommand = input.command === "dcp-compress"
199
+ const subcommand = isCompressCommand ? "compress" : args[0]?.toLowerCase() || ""
200
+ const subArgs = isCompressCommand ? args : args.slice(1)
201
+
202
+ const commandCtx = {
203
+ client,
204
+ state,
205
+ config,
206
+ logger,
207
+ sessionId: input.sessionID,
208
+ messages,
209
+ }
210
+
211
+ if (subcommand === "context") {
212
+ await handleContextCommand(commandCtx)
213
+ return
214
+ }
215
+
216
+ if (subcommand === "stats") {
217
+ await handleStatsCommand(commandCtx)
218
+ return
219
+ }
220
+
221
+ if (subcommand === "sweep") {
222
+ await handleSweepCommand({
223
+ ...commandCtx,
224
+ args: subArgs,
225
+ workingDirectory,
226
+ })
227
+ return
228
+ }
229
+
230
+ if (subcommand === "manual") {
231
+ await handleManualToggleCommand(commandCtx, subArgs[0]?.toLowerCase())
232
+ return
233
+ }
234
+
235
+ if (subcommand === "compress") {
236
+ const userFocus = subArgs.join(" ").trim()
237
+ const prompt = await handleManualTriggerCommand(commandCtx, "compress", userFocus)
238
+ if (!prompt) {
239
+ throw new Error("__DCP_MANUAL_TRIGGER_BLOCKED__")
240
+ }
241
+
242
+ state.manualMode = "compress-pending"
243
+ state.pendingManualTrigger = {
244
+ sessionId: input.sessionID,
245
+ prompt,
246
+ }
247
+ const rawArgs = (input.arguments || "").trim()
248
+ output.parts.length = 0
249
+ output.parts.push({
250
+ type: "text",
251
+ text: isCompressCommand
252
+ ? rawArgs
253
+ ? `/dcp-compress ${rawArgs}`
254
+ : "/dcp-compress"
255
+ : rawArgs
256
+ ? `/dcp ${rawArgs}`
257
+ : `/dcp ${subcommand}`,
258
+ })
259
+ return
260
+ }
261
+
262
+ if (subcommand === "decompress") {
263
+ await handleDecompressCommand({
264
+ ...commandCtx,
265
+ args: subArgs,
266
+ })
267
+ return
268
+ }
269
+
270
+ if (subcommand === "recompress") {
271
+ await handleRecompressCommand({
272
+ ...commandCtx,
273
+ args: subArgs,
274
+ })
275
+ return
276
+ }
277
+
278
+ await handleHelpCommand(commandCtx)
279
+ return
280
+ }
281
+ }
282
+ }
283
+
284
+ export function createTextCompleteHandler() {
285
+ return async (
286
+ _input: { sessionID: string; messageID: string; partID: string },
287
+ output: { text: string },
288
+ ) => {
289
+ output.text = stripHallucinationsFromString(output.text)
290
+ }
291
+ }
292
+
293
+ export function createEventHandler(state: SessionState, logger: Logger) {
294
+ return async (input: { event: any }) => {
295
+ const eventTime =
296
+ typeof input.event?.time === "number" && Number.isFinite(input.event.time)
297
+ ? input.event.time
298
+ : typeof input.event?.properties?.time === "number" &&
299
+ Number.isFinite(input.event.properties.time)
300
+ ? input.event.properties.time
301
+ : undefined
302
+
303
+ if (input.event.type !== "message.part.updated") {
304
+ return
305
+ }
306
+
307
+ const part = input.event.properties?.part
308
+ if (part?.type !== "tool" || part.tool !== "compress") {
309
+ return
310
+ }
311
+
312
+ if (part.state.status === "pending") {
313
+ if (typeof part.callID !== "string" || typeof part.messageID !== "string") {
314
+ return
315
+ }
316
+
317
+ const startedAt = eventTime ?? Date.now()
318
+ const key = buildCompressionTimingKey(part.messageID, part.callID)
319
+ if (state.compressionTiming.startsByCallId.has(key)) {
320
+ return
321
+ }
322
+ state.compressionTiming.startsByCallId.set(key, startedAt)
323
+ logger.debug("Recorded compression start", {
324
+ messageID: part.messageID,
325
+ callID: part.callID,
326
+ startedAt,
327
+ })
328
+ return
329
+ }
330
+
331
+ if (part.state.status === "completed") {
332
+ if (typeof part.callID !== "string" || typeof part.messageID !== "string") {
333
+ return
334
+ }
335
+
336
+ const key = buildCompressionTimingKey(part.messageID, part.callID)
337
+ const start = consumeCompressionStart(state, part.messageID, part.callID)
338
+ const durationMs = resolveCompressionDuration(start, eventTime, part.state.time)
339
+ if (typeof durationMs !== "number") {
340
+ return
341
+ }
342
+
343
+ state.compressionTiming.pendingByCallId.set(key, {
344
+ messageId: part.messageID,
345
+ callId: part.callID,
346
+ durationMs,
347
+ })
348
+
349
+ const updates = applyPendingCompressionDurations(state)
350
+ if (updates === 0) {
351
+ return
352
+ }
353
+
354
+ await saveSessionState(state, logger)
355
+
356
+ logger.info("Attached compression time to blocks", {
357
+ messageID: part.messageID,
358
+ callID: part.callID,
359
+ blocks: updates,
360
+ durationMs,
361
+ })
362
+ return
363
+ }
364
+
365
+ if (part.state.status === "running") {
366
+ return
367
+ }
368
+
369
+ if (typeof part.callID === "string" && typeof part.messageID === "string") {
370
+ state.compressionTiming.startsByCallId.delete(
371
+ buildCompressionTimingKey(part.messageID, part.callID),
372
+ )
373
+ }
374
+ }
375
+ }
@@ -0,0 +1,101 @@
1
+ export type PermissionAction = "ask" | "allow" | "deny"
2
+
3
+ export type PermissionValue = PermissionAction | Record<string, PermissionAction>
4
+
5
+ export type PermissionConfig = Record<string, PermissionValue> | undefined
6
+
7
+ export interface HostPermissionSnapshot {
8
+ global: PermissionConfig
9
+ agents: Record<string, PermissionConfig>
10
+ }
11
+
12
+ type PermissionRule = {
13
+ permission: string
14
+ pattern: string
15
+ action: PermissionAction
16
+ }
17
+
18
+ const findLastMatchingRule = (
19
+ rules: PermissionRule[],
20
+ predicate: (rule: PermissionRule) => boolean,
21
+ ): PermissionRule | undefined => {
22
+ for (let index = rules.length - 1; index >= 0; index -= 1) {
23
+ const rule = rules[index]
24
+ if (rule && predicate(rule)) {
25
+ return rule
26
+ }
27
+ }
28
+
29
+ return undefined
30
+ }
31
+
32
+ const wildcardMatch = (value: string, pattern: string): boolean => {
33
+ const normalizedValue = value.replaceAll("\\", "/")
34
+ let escaped = pattern
35
+ .replaceAll("\\", "/")
36
+ .replace(/[.+^${}()|[\]\\]/g, "\\$&")
37
+ .replace(/\*/g, ".*")
38
+ .replace(/\?/g, ".")
39
+
40
+ if (escaped.endsWith(" .*")) {
41
+ escaped = escaped.slice(0, -3) + "( .*)?"
42
+ }
43
+
44
+ const flags = process.platform === "win32" ? "si" : "s"
45
+ return new RegExp(`^${escaped}$`, flags).test(normalizedValue)
46
+ }
47
+
48
+ const getPermissionRules = (permissionConfigs: PermissionConfig[]): PermissionRule[] => {
49
+ const rules: PermissionRule[] = []
50
+ for (const permissionConfig of permissionConfigs) {
51
+ if (!permissionConfig) {
52
+ continue
53
+ }
54
+
55
+ for (const [permission, value] of Object.entries(permissionConfig)) {
56
+ if (value === "ask" || value === "allow" || value === "deny") {
57
+ rules.push({ permission, pattern: "*", action: value })
58
+ continue
59
+ }
60
+
61
+ for (const [pattern, action] of Object.entries(value)) {
62
+ if (action === "ask" || action === "allow" || action === "deny") {
63
+ rules.push({ permission, pattern, action })
64
+ }
65
+ }
66
+ }
67
+ }
68
+ return rules
69
+ }
70
+
71
+ export const compressDisabledByOpencode = (...permissionConfigs: PermissionConfig[]): boolean => {
72
+ const match = findLastMatchingRule(getPermissionRules(permissionConfigs), (rule) =>
73
+ wildcardMatch("compress", rule.permission),
74
+ )
75
+
76
+ return match?.pattern === "*" && match.action === "deny"
77
+ }
78
+
79
+ export const resolveEffectiveCompressPermission = (
80
+ basePermission: PermissionAction,
81
+ hostPermissions: HostPermissionSnapshot,
82
+ agentName?: string,
83
+ ): PermissionAction => {
84
+ if (basePermission === "deny") {
85
+ return "deny"
86
+ }
87
+
88
+ return compressDisabledByOpencode(
89
+ hostPermissions.global,
90
+ agentName ? hostPermissions.agents[agentName] : undefined,
91
+ )
92
+ ? "deny"
93
+ : basePermission
94
+ }
95
+
96
+ export const hasExplicitToolPermission = (
97
+ permissionConfig: PermissionConfig,
98
+ tool: string,
99
+ ): boolean => {
100
+ return permissionConfig ? Object.prototype.hasOwnProperty.call(permissionConfig, tool) : false
101
+ }
package/lib/logger.ts ADDED
@@ -0,0 +1,226 @@
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)
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, metadata
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
+ const textPart: any = { type: "text", text: part.text }
155
+ if (part.metadata) textPart.metadata = part.metadata
156
+ return textPart
157
+ }
158
+
159
+ if (part.type === "reasoning") {
160
+ const reasoningPart: any = { type: "reasoning", text: part.text }
161
+ if (part.metadata) reasoningPart.metadata = part.metadata
162
+ return reasoningPart
163
+ }
164
+
165
+ if (part.type === "tool") {
166
+ const toolPart: any = {
167
+ type: "tool",
168
+ tool: part.tool,
169
+ callID: part.callID,
170
+ }
171
+
172
+ if (part.state?.status) {
173
+ toolPart.status = part.state.status
174
+ }
175
+ if (part.state?.input) {
176
+ toolPart.input = part.state.input
177
+ }
178
+ if (part.state?.output) {
179
+ toolPart.output = part.state.output
180
+ }
181
+ if (part.state?.error) {
182
+ toolPart.error = part.state.error
183
+ }
184
+ if (part.metadata) {
185
+ toolPart.metadata = part.metadata
186
+ }
187
+ if (part.state?.metadata) {
188
+ toolPart.metadata = {
189
+ ...(toolPart.metadata || {}),
190
+ ...part.state.metadata,
191
+ }
192
+ }
193
+ if (part.state?.title) {
194
+ toolPart.title = part.state.title
195
+ }
196
+
197
+ return toolPart
198
+ }
199
+
200
+ return null
201
+ })
202
+ .filter(Boolean)
203
+ }
204
+
205
+ return minimized
206
+ })
207
+ }
208
+
209
+ async saveContext(sessionId: string, messages: any[]) {
210
+ if (!this.enabled) return
211
+
212
+ try {
213
+ const contextDir = join(this.logDir, "context", sessionId)
214
+ if (!existsSync(contextDir)) {
215
+ await mkdir(contextDir, { recursive: true })
216
+ }
217
+
218
+ const minimized = this.minimizeForDebug(messages).filter(
219
+ (msg) => msg.parts && msg.parts.length > 0,
220
+ )
221
+ const timestamp = new Date().toISOString().replace(/[:.]/g, "-")
222
+ const contextFile = join(contextDir, `${timestamp}.json`)
223
+ await writeFile(contextFile, JSON.stringify(minimized, null, 2))
224
+ } catch (error) {}
225
+ }
226
+ }